From 69287ebec05c5cc0906a2d658f7ce2aacaaec3ae Mon Sep 17 00:00:00 2001 From: SinachPat Date: Mon, 17 Aug 2026 17:10:25 +0100 Subject: [PATCH] feat: build the chat + preview + approve interface - web: two-pane app chrome (chat panel + browser-framed preview) - ChatPanel, Preview, ApproveBar components + useChat (mock agent for now) - Fluent/Figma design system (Inter, dark chrome, elevation, motion) - 107 unit tests + Playwright e2e green; web typechecks + builds --- web/__tests__/ApproveBar.test.tsx | 29 ++ web/__tests__/ChatPanel.test.tsx | 36 +++ web/index.html | 6 + web/src/App.tsx | 46 +-- web/src/components/ApproveBar.tsx | 21 ++ web/src/components/ChatPanel.tsx | 54 ++++ web/src/components/Preview.tsx | 40 +++ web/src/hooks/useChat.ts | 39 +++ web/src/styles/global.css | 489 +++++++++++++++++++++++++++++- web/tsconfig.json | 3 +- 10 files changed, 739 insertions(+), 24 deletions(-) create mode 100644 web/__tests__/ApproveBar.test.tsx create mode 100644 web/__tests__/ChatPanel.test.tsx create mode 100644 web/src/components/ApproveBar.tsx create mode 100644 web/src/components/ChatPanel.tsx create mode 100644 web/src/components/Preview.tsx create mode 100644 web/src/hooks/useChat.ts diff --git a/web/__tests__/ApproveBar.test.tsx b/web/__tests__/ApproveBar.test.tsx new file mode 100644 index 0000000..cdb3b01 --- /dev/null +++ b/web/__tests__/ApproveBar.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ApproveBar } from '../src/components/ApproveBar.tsx'; + +describe('ApproveBar', () => { + it('renders apply and reject buttons when visible', () => { + render(); + expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reject/i })).toBeInTheDocument(); + }); + + it('calls onApprove and onReject', async () => { + const onApprove = vi.fn(); + const onReject = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /apply/i })); + expect(onApprove).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button', { name: /reject/i })); + expect(onReject).toHaveBeenCalled(); + }); + + it('renders nothing when not visible', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/web/__tests__/ChatPanel.test.tsx b/web/__tests__/ChatPanel.test.tsx new file mode 100644 index 0000000..4733647 --- /dev/null +++ b/web/__tests__/ChatPanel.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChatPanel } from '../src/components/ChatPanel.tsx'; + +describe('ChatPanel', () => { + it('renders user and agent messages', () => { + render( + , + ); + expect(screen.getByText('Change the heading')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + + it('calls onSend with the typed message', async () => { + const onSend = vi.fn(); + render(); + + await userEvent.type(screen.getByPlaceholderText('Describe what you want…'), 'Change the heading'); + await userEvent.click(screen.getByRole('button', { name: /send/i })); + + expect(onSend).toHaveBeenCalledWith('Change the heading'); + }); + + it('shows a working indicator while the agent works', () => { + render(); + expect(screen.getByText(/working/i)).toBeInTheDocument(); + }); +}); diff --git a/web/index.html b/web/index.html index e81b466..23c13fb 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,12 @@ + + + Wursor diff --git a/web/src/App.tsx b/web/src/App.tsx index a727320..2ca6a4c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,36 +1,38 @@ import { useState } from 'react'; +import { ApproveBar } from './components/ApproveBar.tsx'; +import { ChatPanel } from './components/ChatPanel.tsx'; +import { Preview } from './components/Preview.tsx'; import { SignUp } from './components/SignUp.tsx'; - -async function signUp(email: string, password: string): Promise { - const res = await fetch('/auth/signup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - if (!res.ok) { - const body = (await res.json().catch(() => null)) as { error?: string } | null; - throw new Error(body?.error ?? 'Sign up failed'); - } -} +import { useChat } from './hooks/useChat.ts'; export function App() { const [signedUp, setSignedUp] = useState(false); + const [applied, setApplied] = useState(false); + const chat = useChat(); - if (signedUp) { + if (!signedUp) { return ( -
-

Describe what you want.

- +
+ setSignedUp(true)} />
); } return ( - { - await signUp(email, password); - setSignedUp(true); - }} - /> +
+
+ Wursor + Preview sandbox +
+
+ + +
+ setApplied(true)} + onReject={() => setApplied(false)} + /> +
); } diff --git a/web/src/components/ApproveBar.tsx b/web/src/components/ApproveBar.tsx new file mode 100644 index 0000000..82920e2 --- /dev/null +++ b/web/src/components/ApproveBar.tsx @@ -0,0 +1,21 @@ +type ApproveBarProps = { + visible: boolean; + onApprove: () => void; + onReject: () => void; +}; + +export function ApproveBar({ visible, onApprove, onReject }: ApproveBarProps) { + if (!visible) return null; + + return ( +
+ Looks good? + + +
+ ); +} diff --git a/web/src/components/ChatPanel.tsx b/web/src/components/ChatPanel.tsx new file mode 100644 index 0000000..92c6426 --- /dev/null +++ b/web/src/components/ChatPanel.tsx @@ -0,0 +1,54 @@ +import { useState, type FormEvent } from 'react'; +import type { ChatMessage, ChatStatus } from '../hooks/useChat.ts'; + +type ChatPanelProps = { + messages: ChatMessage[]; + status: ChatStatus; + onSend: (text: string) => void; +}; + +export function ChatPanel({ messages, status, onSend }: ChatPanelProps) { + const [draft, setDraft] = useState(''); + + function submit(event: FormEvent) { + event.preventDefault(); + const text = draft.trim(); + if (text === '') return; + setDraft(''); + onSend(text); + } + + return ( + + ); +} diff --git a/web/src/components/Preview.tsx b/web/src/components/Preview.tsx new file mode 100644 index 0000000..ffb90e1 --- /dev/null +++ b/web/src/components/Preview.tsx @@ -0,0 +1,40 @@ +import type { ChatStatus } from '../hooks/useChat.ts'; + +type PreviewProps = { + heading: string; + status: ChatStatus; + applied: boolean; +}; + +export function Preview({ heading, status, applied }: PreviewProps) { + return ( +
+
+
+ + + + preview.wursor.dev + {applied && Applied to your site ✓} +
+
+ {status === 'working' ? ( +
Applying your change…
+ ) : ( +
+ +
+

{heading}

+

This is a live preview of your sandbox — the real site stays untouched until you approve.

+
+
+ )} +
+
+
+ ); +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts new file mode 100644 index 0000000..a0ab57b --- /dev/null +++ b/web/src/hooks/useChat.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; + +export type ChatMessage = { id: string; role: 'user' | 'agent'; text: string }; +export type ChatStatus = 'idle' | 'working' | 'done'; + +function extractHeading(text: string): string | undefined { + const match = text.match(/["“']([^"”']+)["”']/); + return match?.[1]; +} + +export function useChat() { + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState('idle'); + const [heading, setHeading] = useState('Welcome to our site'); + + const send = useCallback(async (text: string) => { + const nextHeading = extractHeading(text); + setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]); + setStatus('working'); + await new Promise((resolve) => setTimeout(resolve, 1200)); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: 'agent', + text: + nextHeading !== undefined + ? `Done — I updated the homepage heading to “${nextHeading}”. Preview it below.` + : 'Done — your change is ready to preview.', + }, + ]); + if (nextHeading !== undefined) { + setHeading(nextHeading); + } + setStatus('done'); + }, []); + + return { messages, status, heading, send }; +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 7968360..7b037e5 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -1,8 +1,495 @@ +:root { + --bg-app: #0c0c10; + --bg-panel: #111115; + --bg-toolbar: #141418; + --bg-surface: #1c1c22; + --bg-input: rgba(255, 255, 255, 0.04); + + --fg: rgba(255, 255, 255, 0.88); + --fg-2: rgba(255, 255, 255, 0.45); + --fg-3: rgba(255, 255, 255, 0.22); + + --border: rgba(255, 255, 255, 0.055); + --border-2: rgba(255, 255, 255, 0.1); + + --accent: #3385ff; + --accent-bg: rgba(51, 133, 255, 0.14); + --accent-strong: #0066ff; + --success: #10b981; + + --sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3); + --sh-lg: 0 10px 24px rgba(0, 0, 0, 0.35), 0 4px 8px rgba(0, 0, 0, 0.25); + --sh-product: 0 48px 96px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.06); + + --r-2: 8px; + --r-3: 12px; + --r-4: 16px; + --r-5: 20px; + --ease: cubic-bezier(0.16, 1, 0.3, 1); + + font-family: 'Inter', system-ui, -apple-system, sans-serif; +} + * { box-sizing: border-box; } body { margin: 0; - font-family: system-ui, -apple-system, sans-serif; + background: var(--bg-app); + color: var(--fg); + -webkit-font-smoothing: antialiased; +} + +/* ---------- Auth screen ---------- */ +.auth-screen { + min-height: 100vh; + display: grid; + place-items: center; + background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(51, 133, 255, 0.08), transparent 60%), var(--bg-app); + padding: 40px; +} + +.wursor-signup { + width: 360px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--r-5); + padding: 40px 32px; + box-shadow: var(--sh-lg); + display: flex; + flex-direction: column; + gap: 14px; +} + +.wursor-signup input { + background: var(--bg-input); + border: 1px solid var(--border-2); + border-radius: var(--r-2); + color: var(--fg); + padding: 12px 14px; + font-size: 0.9375rem; + outline: none; + transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease); +} + +.wursor-signup input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} + +.wursor-signup button { + margin-top: 4px; + background: var(--accent-strong); + color: #fff; + border: none; + border-radius: var(--r-2); + padding: 12px; + font-weight: 600; + font-size: 0.9375rem; + cursor: pointer; + transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease); +} + +.wursor-signup button:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 102, 255, 0.35); +} + +.wursor-signup button:disabled { + opacity: 0.5; + cursor: default; +} + +.wursor-signup [role='alert'] { + color: #ef4444; + font-size: 0.8125rem; + margin: 0; +} + +/* ---------- App chrome ---------- */ +.app { + height: 100vh; + display: flex; + flex-direction: column; + position: relative; +} + +.app-topbar { + height: 60px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 24px; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border); +} + +.app-logo { + font-weight: 800; + font-size: 1rem; + letter-spacing: -0.02em; +} + +.app-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 0.625rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-3); + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 9999px; +} + +.app-body { + flex: 1; + display: flex; + min-height: 0; +} + +/* ---------- Chat panel ---------- */ +.chat { + width: 380px; + flex-shrink: 0; + background: var(--bg-panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.chat-scroll { + flex: 1; + overflow-y: auto; + padding: 24px 20px; +} + +.wursor-welcome h2 { + font-size: 1.625rem; + font-weight: 800; + letter-spacing: -0.028em; + margin: 8px 0; + line-height: 1.25; +} + +.welcome-kicker { + font-family: 'JetBrains Mono', monospace; + font-size: 0.625rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent); + margin: 0; +} + +.welcome-sub { + color: var(--fg-2); + font-size: 0.9375rem; + line-height: 1.6; + margin: 0; +} + +.chat-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message { + max-width: 85%; + padding: 12px 14px; + border-radius: var(--r-3); + font-size: 0.9375rem; + line-height: 1.5; + animation: pop 0.2s var(--ease); +} + +.message-user { + align-self: flex-end; + background: var(--accent-strong); + color: #fff; + border-bottom-right-radius: 4px; +} + +.message-agent { + align-self: flex-start; + background: var(--bg-surface); + border: 1px solid var(--border); + border-bottom-left-radius: 4px; +} + +.wursor-working { + color: var(--fg-2); + font-size: 0.8125rem; + padding: 12px 2px; + animation: pulse 1.2s ease-in-out infinite; +} + +.chat-composer { + display: flex; + gap: 8px; + padding: 16px; + border-top: 1px solid var(--border); +} + +.wursor-chat-input { + flex: 1; + background: var(--bg-input); + border: 1px solid var(--border-2); + border-radius: var(--r-2); + color: var(--fg); + padding: 12px 14px; + font-size: 0.9375rem; + outline: none; +} + +.wursor-chat-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} + +.wursor-chat-send { + width: 44px; + background: var(--accent-strong); + color: #fff; + border: none; + border-radius: var(--r-2); + font-size: 1.2rem; + cursor: pointer; + transition: transform 0.12s var(--ease); +} + +.wursor-chat-send:hover { + transform: translateY(-1px); +} + +/* ---------- Preview ---------- */ +.preview { + flex: 1; + display: grid; + place-items: center; + padding: 32px; + background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px); + background-size: 24px 24px; + overflow: hidden; +} + +.browser { + width: min(1000px, 100%); + height: min(640px, 100%); + background: #ffffff; + border-radius: var(--r-5); + box-shadow: var(--sh-product); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.browser-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #f4f4f5; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); +} + +.dot { + width: 11px; + height: 11px; + border-radius: 9999px; +} + +.dot-red { + background: #ff5f57; +} + +.dot-yellow { + background: #febc2e; +} + +.dot-green { + background: #28c840; +} + +.browser-url { + flex: 1; + text-align: center; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.07); + border-radius: 6px; + padding: 5px 12px; + font-size: 0.8125rem; + color: #71717a; +} + +.preview-badge { + color: #047857; + font-size: 0.8125rem; + font-weight: 600; +} + +.browser-body { + flex: 1; + overflow: auto; + background: #ffffff; + color: #0a0a0a; +} + +.preview-working { + height: 100%; + display: grid; + place-items: center; + color: #71717a; + font-size: 0.9375rem; + animation: pulse 1.2s ease-in-out infinite; +} + +.mock-site { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.mock-nav { + display: flex; + align-items: center; + gap: 24px; + padding: 20px 40px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + font-size: 0.875rem; + color: #52525b; +} + +.mock-brand { + font-weight: 700; + color: #0a0a0a; +} + +.mock-hero { + padding: 80px 40px; + text-align: center; +} + +.mock-hero h1 { + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.03em; + color: #0a0a0a; + margin: 0 0 16px; + transition: opacity 0.3s var(--ease); +} + +.mock-hero p { + color: #52525b; + max-width: 480px; + margin: 0 auto; + line-height: 1.7; +} + +/* ---------- Approve bar ---------- */ +.approve-bar { + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + background: rgba(28, 28, 34, 0.92); + backdrop-filter: blur(20px) saturate(1.6); + border: 1px solid var(--border-2); + border-radius: 9999px; + padding: 8px 10px 8px 20px; + box-shadow: var(--sh-lg); + animation: rise 0.25s var(--ease); +} + +.approve-copy { + color: var(--fg-2); + font-size: 0.875rem; +} + +.wursor-approve-button { + background: var(--accent-strong); + color: #fff; + border: none; + border-radius: 9999px; + padding: 10px 18px; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease); +} + +.wursor-approve-button:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 102, 255, 0.4); +} + +.wursor-reject-button { + background: transparent; + color: var(--fg-2); + border: 1px solid var(--border-2); + border-radius: 9999px; + padding: 10px 18px; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background 0.12s var(--ease); +} + +.wursor-reject-button:hover { + background: var(--bg-surface); +} + +/* ---------- Motion ---------- */ +@keyframes pop { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes rise { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +@media (max-width: 800px) { + .app-body { + flex-direction: column; + } + .chat { + width: 100%; + height: 50%; + border-right: none; + border-bottom: 1px solid var(--border); + } + .preview { + height: 50%; + padding: 16px; + } } diff --git a/web/tsconfig.json b/web/tsconfig.json index 32c8a58..6d51467 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -5,7 +5,8 @@ "outDir": "dist", "rootDir": "src", "lib": ["ES2022", "DOM", "DOM.Iterable"], - "noEmit": true + "noEmit": true, + "allowImportingTsExtensions": true }, "include": ["src"] }