From bbf0b1951530da71a10b1f58d3feeda201ca4011 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 21:20:14 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20simplify=20codebase=20=E2=80=94=20d?= =?UTF-8?q?edup=20agent=20core,=20harden=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared agent core (api/agent-core.ts) from playground/server.ts and api/agent.ts; trims ~400 lines of duplication across system prompt, SSE encoding, streamText loop, and request body parsing. - Consolidate DEFAULT_MODEL to a single source (api/model.ts); re-export from playground/settings.ts; drop drifted copies in api/health.ts and playground/server.ts. - Middleware fails closed in production when BASIC_AUTH_PASS is unset; logs on cold start when disabled. - Bound SSE parse buffer at 1 MB to prevent memory growth from a misbehaving server. - Fix wireResizers early-return leak (moved !first/!second guard before setPointerCapture + body class mutations). - Persist custom model input on blur/Enter instead of per keystroke. - Replace inline URL regex with safe-url#isExternal; adds protocol checking via URL parse instead of string prefix. - Extract appendChildren helper (card/stack/row/grid); CSS.escape form field name in querySelector; tighten numeric validation to HTML input[type=number] semantics. - design-to-css: export generate() so tests call it in-process instead of spawning bun subprocesses; switch TOKEN_GROUPS allow-list to a deny-list so new DESIGN.md sections aren't silently dropped. - build-styles: mkdir dist/ before write; parallelize reads. - lint-design: balanced-brace JSON extractor replaces fragile lastIndexOf('\n{') heuristic. - settings-ui: generic toggleGroup helper unifies theme/layout button groups; .settings-hide-ai-row CSS class replaces inline styles. - playground: single WELCOME_SPEC, unified appendLine helper, tidied isFieldChange narrowing. --- api/agent-core.ts | 222 +++++++++++++++++++++++++++++++++ api/agent.ts | 205 +------------------------------ api/health.ts | 13 +- middleware.ts | 30 ++++- playground/main.ts | 66 +++++----- playground/server.ts | 225 +++------------------------------- playground/settings-ui.ts | 87 ++++++++----- playground/settings.ts | 24 ++-- playground/style.css | 6 + scripts/build-styles.ts | 12 +- scripts/design-to-css.test.ts | 26 ++-- scripts/design-to-css.ts | 92 ++++++++------ scripts/lint-design.ts | 41 ++++++- src/components.ts | 35 +++--- src/index.ts | 7 +- src/safe-url.ts | 12 ++ src/validation.ts | 4 +- 17 files changed, 519 insertions(+), 588 deletions(-) create mode 100644 api/agent-core.ts diff --git a/api/agent-core.ts b/api/agent-core.ts new file mode 100644 index 0000000..c7b6358 --- /dev/null +++ b/api/agent-core.ts @@ -0,0 +1,222 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { stepCountIs, streamText, tool } from 'ai' +import { z } from 'zod' +import { BUILTIN_KINDS } from '../src/types' + +// Vercel AI SDK's gateway provider reads AI_GATEWAY_API_KEY; accept the longer +// VERCEL_AI_GATEWAY_API_KEY as an alias. Run once at module load. +if (!process.env.AI_GATEWAY_API_KEY && process.env.VERCEL_AI_GATEWAY_API_KEY) { + process.env.AI_GATEWAY_API_KEY = process.env.VERCEL_AI_GATEWAY_API_KEY +} + +export const componentSpecSchema = z + .object({ + kind: z.string().describe('Component kind, e.g. "button", "card", "alert"'), + }) + .passthrough() + .describe('A stream-ui ComponentSpec. Include all fields the kind expects.') + +export type PlaygroundMessage = + | { role: 'user'; kind: 'prompt'; text: string } + | { role: 'user'; kind: 'form-submit'; name: string; fields: Record } + | { role: 'user'; kind: 'button-click'; action: string } + | { role: 'assistant'; kind: 'thinking'; text: string } + | { role: 'assistant'; kind: 'render' | 'append'; spec: unknown } + +type CoreMessage = { role: 'user' | 'assistant'; content: string } + +export function toCoreMessages(messages: PlaygroundMessage[]): CoreMessage[] { + return messages.map((m) => { + if (m.role === 'user') { + if (m.kind === 'prompt') return { role: 'user', content: m.text } + if (m.kind === 'form-submit') { + const body = Object.entries(m.fields) + .map(([k, v]) => `${k}="${String(v)}"`) + .join(' ') + return { role: 'user', content: `[form submit: ${m.name}] ${body}` } + } + return { role: 'user', content: `[button clicked: ${m.action}]` } + } + if (m.kind === 'thinking') return { role: 'assistant', content: m.text } + const tag = m.kind === 'render' ? '[render]' : '[append]' + return { role: 'assistant', content: `${tag} ${JSON.stringify(m.spec)}` } + }) +} + +function loadDesignGuide(): string | null { + const candidates: string[] = [resolve(process.cwd(), 'DESIGN.md')] + try { + const here = dirname(fileURLToPath(import.meta.url)) + candidates.push(resolve(here, '..', 'DESIGN.md')) + candidates.push(resolve(here, '..', '..', 'DESIGN.md')) + } catch { + // no import.meta.url — rely on cwd candidate + } + for (const c of candidates) { + try { + return readFileSync(c, 'utf8') + } catch { + // try next + } + } + return null +} + +const DESIGN_GUIDE = loadDesignGuide() + +export const systemPrompt = `You are a UI-generation agent for stream-ui. + +stream-ui is a framework where JSON specs are rendered to DOM. Your job is to +take a user's natural-language prompt and produce UI by calling one of two +tools: + +- render_ui(spec): replace the UI region with this component +- append_ui(spec): append this component to the existing UI region + +Each spec is a JSON object: { kind, ...fields }. + +Built-in kinds available: +${BUILTIN_KINDS.map((k) => ` - ${k}`).join('\n')} + +Common spec shapes (partial reference): + { kind: 'text', content: string } + { kind: 'heading', level: 1|2|3|4|5|6, content: string } + { kind: 'paragraph', content: string } + { kind: 'card', title: string, body?: string, children?: Spec[] } + { kind: 'stack'|'row'|'grid', children: Spec[], gap?: 'sm'|'md'|'lg' } + { kind: 'alert', variant: 'info'|'success'|'warning'|'error', content: string } + { kind: 'badge', content: string, variant?: 'default'|'success'|'warning'|'error' } + { kind: 'button', label: string, action: string, variant?: 'default'|'primary'|'danger' } + { kind: 'link', label: string, href: string } + { kind: 'input', name: string, label: string, type?: string, action?: string, + format?: 'email'|'phone'|'url'|'zip'|'credit-card', + validation?: { required?: boolean, pattern?: string, minLength?: number, + maxLength?: number, min?: number, max?: number, errorMessage?: string } } + { kind: 'textarea', name: string, label: string, rows?: number, + validation?: { ...same as input... } } + { kind: 'form', submitLabel: string, fields: [ + { name, label, type, placeholder?, format?, validation? }, ... + ] } + { kind: 'list', items: string[], ordered?: boolean } + { kind: 'table', headers: string[], rows: string[][] } + +Input formats bundle validation + display masking: + - 'email' → validates email address + - 'phone' → validates 10-digit US phone + auto-formats as (555) 123-4567 + - 'url' → validates http(s) URL + - 'zip' → validates 5 or 9 digit US zip + formats 12345-6789 + - 'credit-card' → validates 13-19 digits + formats as groups of 4 + +Guidelines: +1. Default to render_ui. Use append_ui only when the user says "add" or "also". +2. Compose: wrap groups of components in card/stack/row/grid rather than + emitting many top-level appends. +3. Keep specs small, real, and purposeful. No placeholder lorem ipsum. +4. For interactive elements (button, input, form, link) always set a meaningful + action or href that describes what the user should be able to do. +5. For fields that are emails / phone numbers / URLs / ZIP codes / credit cards, + set the appropriate 'format' — don't just set 'type'. Use 'validation.required: + true' when the field is mandatory. The framework handles both validation and + display masking for you. +6. Think briefly about the goal before the first tool call. +7. If the latest user message is "[form submit: ] key="value" ...", the user + submitted form . Acknowledge or advance — e.g. render_ui a success card. +8. If the latest user message is "[button clicked: ]", the user clicked a + button with that action. Continue the flow accordingly.${ + DESIGN_GUIDE + ? ` + +--- + +## Design system (from DESIGN.md) + +The following is the project's visual identity. Read the tokens to pick +component variants by *intent*, not hex values. Honor the Do's and Don'ts. + +${DESIGN_GUIDE}` + : '' + }` + +export type AgentEvent = + | { type: 'thinking'; text: string } + | { type: 'render'; spec: unknown } + | { type: 'append'; spec: unknown } + | { type: 'done' } + | { type: 'error'; error: string } + +export function sseEncode(event: AgentEvent): string { + return `data: ${JSON.stringify(event)}\n\n` +} + +export function hasAnyApiKey(): boolean { + return Boolean( + process.env.AI_GATEWAY_API_KEY || + process.env.VERCEL_AI_GATEWAY_API_KEY || + process.env.ANTHROPIC_API_KEY || + process.env.OPENAI_API_KEY, + ) +} + +export function parseAgentBody( + body: unknown, +): { messages: PlaygroundMessage[] } | { error: string } { + if (!body || typeof body !== 'object') return { error: 'Missing prompt or messages' } + const b = body as { prompt?: unknown; messages?: unknown } + if (Array.isArray(b.messages) && b.messages.length > 0) { + return { messages: b.messages as PlaygroundMessage[] } + } + if (typeof b.prompt === 'string' && b.prompt.trim() !== '') { + return { messages: [{ role: 'user', kind: 'prompt', text: b.prompt }] } + } + return { error: 'Missing prompt or messages' } +} + +export async function runAgent( + messages: PlaygroundMessage[], + model: string, + send: (event: AgentEvent) => void, +): Promise { + try { + const result = streamText({ + model, + system: systemPrompt, + messages: toCoreMessages(messages), + tools: { + render_ui: tool({ + description: 'Render a component, replacing the existing UI.', + inputSchema: z.object({ spec: componentSpecSchema }), + execute: async () => ({ ok: true }), + }), + append_ui: tool({ + description: 'Append a component to the existing UI.', + inputSchema: z.object({ spec: componentSpecSchema }), + execute: async () => ({ ok: true }), + }), + }, + stopWhen: stepCountIs(8), + }) + + for await (const part of result.fullStream) { + if (part.type === 'text-delta') { + const text = (part as { text?: string }).text ?? '' + if (text) send({ type: 'thinking', text }) + } else if (part.type === 'tool-call') { + const spec = (part as { input?: { spec?: unknown } }).input?.spec + if (!spec) continue + if (part.toolName === 'render_ui') send({ type: 'render', spec }) + else if (part.toolName === 'append_ui') send({ type: 'append', spec }) + } else if (part.type === 'error') { + const err = (part as { error?: unknown }).error + send({ type: 'error', error: err instanceof Error ? err.message : String(err) }) + } + } + send({ type: 'done' }) + } catch (err) { + send({ + type: 'error', + error: err instanceof Error ? err.message : String(err), + }) + } +} diff --git a/api/agent.ts b/api/agent.ts index 9d97ae9..f39a3d6 100644 --- a/api/agent.ts +++ b/api/agent.ts @@ -1,154 +1,7 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' import type { VercelRequest, VercelResponse } from '@vercel/node' -import { stepCountIs, streamText, tool } from 'ai' -import { z } from 'zod' -import { BUILTIN_KINDS } from '../src/types.js' +import { type AgentEvent, parseAgentBody, runAgent, sseEncode } from './agent-core.js' import { resolveModel } from './model.js' -// Load DESIGN.md once at cold start. Agents see it as a trailing section of -// the system prompt so streamed specs honor the project's visual identity. -function loadDesignGuide(): string | null { - try { - const here = dirname(fileURLToPath(import.meta.url)) - for (const candidate of [join(here, '..', 'DESIGN.md'), join(process.cwd(), 'DESIGN.md')]) { - try { - return readFileSync(candidate, 'utf8') - } catch { - // try next candidate - } - } - } catch { - // fall through - } - return null -} - -const DESIGN_GUIDE = loadDesignGuide() - -if (!process.env.AI_GATEWAY_API_KEY && process.env.VERCEL_AI_GATEWAY_API_KEY) { - process.env.AI_GATEWAY_API_KEY = process.env.VERCEL_AI_GATEWAY_API_KEY -} - -const componentSpecSchema = z - .object({ - kind: z.string().describe('Component kind, e.g. "button", "card", "alert"'), - }) - .passthrough() - .describe('A stream-ui ComponentSpec. Include all fields the kind expects.') - -type PlaygroundMessage = - | { role: 'user'; kind: 'prompt'; text: string } - | { role: 'user'; kind: 'form-submit'; name: string; fields: Record } - | { role: 'user'; kind: 'button-click'; action: string } - | { role: 'assistant'; kind: 'thinking'; text: string } - | { role: 'assistant'; kind: 'render' | 'append'; spec: unknown } - -type CoreMessage = { role: 'user' | 'assistant'; content: string } - -function toCoreMessages(messages: PlaygroundMessage[]): CoreMessage[] { - return messages.map((m) => { - if (m.role === 'user') { - if (m.kind === 'prompt') return { role: 'user', content: m.text } - if (m.kind === 'form-submit') { - const body = Object.entries(m.fields) - .map(([k, v]) => `${k}="${String(v)}"`) - .join(' ') - return { role: 'user', content: `[form submit: ${m.name}] ${body}` } - } - return { role: 'user', content: `[button clicked: ${m.action}]` } - } - if (m.kind === 'thinking') return { role: 'assistant', content: m.text } - const tag = m.kind === 'render' ? '[render]' : '[append]' - return { role: 'assistant', content: `${tag} ${JSON.stringify(m.spec)}` } - }) -} - -const systemPrompt = `You are a UI-generation agent for stream-ui. - -stream-ui is a framework where JSON specs are rendered to DOM. Your job is to -take a user's natural-language prompt and produce UI by calling one of two -tools: - -- render_ui(spec): replace the UI region with this component -- append_ui(spec): append this component to the existing UI region - -Each spec is a JSON object: { kind, ...fields }. - -Built-in kinds available: -${BUILTIN_KINDS.map((k) => ` - ${k}`).join('\n')} - -Common spec shapes (partial reference): - { kind: 'text', content: string } - { kind: 'heading', level: 1|2|3|4|5|6, content: string } - { kind: 'paragraph', content: string } - { kind: 'card', title: string, body?: string, children?: Spec[] } - { kind: 'stack'|'row'|'grid', children: Spec[], gap?: 'sm'|'md'|'lg' } - { kind: 'alert', variant: 'info'|'success'|'warning'|'error', content: string } - { kind: 'badge', content: string, variant?: 'default'|'success'|'warning'|'error' } - { kind: 'button', label: string, action: string, variant?: 'default'|'primary'|'danger' } - { kind: 'link', label: string, href: string } - { kind: 'input', name: string, label: string, type?: string, action?: string, - format?: 'email'|'phone'|'url'|'zip'|'credit-card', - validation?: { required?: boolean, pattern?: string, minLength?: number, - maxLength?: number, min?: number, max?: number, errorMessage?: string } } - { kind: 'textarea', name: string, label: string, rows?: number, - validation?: { ...same as input... } } - { kind: 'form', submitLabel: string, fields: [ - { name, label, type, placeholder?, format?, validation? }, ... - ] } - { kind: 'list', items: string[], ordered?: boolean } - { kind: 'table', headers: string[], rows: string[][] } - -Input formats bundle validation + display masking: - - 'email' → validates email address - - 'phone' → validates 10-digit US phone + auto-formats as (555) 123-4567 - - 'url' → validates http(s) URL - - 'zip' → validates 5 or 9 digit US zip + formats 12345-6789 - - 'credit-card' → validates 13-19 digits + formats as groups of 4 - -Guidelines: -1. Default to render_ui. Use append_ui only when the user says "add" or "also". -2. Compose: wrap groups of components in card/stack/row/grid rather than - emitting many top-level appends. -3. Keep specs small, real, and purposeful. No placeholder lorem ipsum. -4. For interactive elements (button, input, form, link) always set a meaningful - action or href that describes what the user should be able to do. -5. For fields that are emails / phone numbers / URLs / ZIP codes / credit cards, - set the appropriate 'format' — don't just set 'type'. Use 'validation.required: - true' when the field is mandatory. The framework handles both validation and - display masking for you. -6. Think briefly about the goal before the first tool call. -7. If the latest user message is "[form submit: ] key="value" ...", the user - submitted form . Acknowledge or advance — e.g. render_ui a success card. -8. If the latest user message is "[button clicked: ]", the user clicked a - button with that action. Continue the flow accordingly.${ - DESIGN_GUIDE - ? ` - ---- - -## Design system (from DESIGN.md) - -The following is the project's visual identity. Read the tokens to pick -component variants by *intent*, not hex values. Honor the Do's and Don'ts. - -${DESIGN_GUIDE}` - : '' - }` - -type AgentEvent = - | { type: 'thinking'; text: string } - | { type: 'render'; spec: unknown } - | { type: 'append'; spec: unknown } - | { type: 'done' } - | { type: 'error'; error: string } - -function sseEncode(event: AgentEvent): string { - return `data: ${JSON.stringify(event)}\n\n` -} - export const config = { maxDuration: 300, } @@ -159,24 +12,18 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return } - const body = req.body as { prompt?: unknown; messages?: unknown } | undefined - let messages: PlaygroundMessage[] - if (body && Array.isArray(body.messages) && body.messages.length > 0) { - messages = body.messages as PlaygroundMessage[] - } else if (body && typeof body.prompt === 'string' && body.prompt.trim() !== '') { - messages = [{ role: 'user', kind: 'prompt', text: body.prompt }] - } else { - res.status(400).send('Missing prompt or messages') + const parsed = parseAgentBody(req.body) + if ('error' in parsed) { + res.status(400).send(parsed.error) return } - const model = resolveModel(body as { model?: unknown } | undefined, process.env) + const model = resolveModel(req.body as { model?: unknown } | undefined, process.env) res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') res.setHeader('Cache-Control', 'no-cache, no-transform') res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') - // Hint to Node to flush headers before any chunk arrives res.flushHeaders?.() const send = (event: AgentEvent) => { @@ -184,47 +31,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } try { - const result = streamText({ - model, - system: systemPrompt, - messages: toCoreMessages(messages), - tools: { - render_ui: tool({ - description: 'Render a component, replacing the existing UI.', - inputSchema: z.object({ spec: componentSpecSchema }), - execute: async () => ({ ok: true }), - }), - append_ui: tool({ - description: 'Append a component to the existing UI.', - inputSchema: z.object({ spec: componentSpecSchema }), - execute: async () => ({ ok: true }), - }), - }, - stopWhen: stepCountIs(8), - }) - - for await (const part of result.fullStream) { - if (part.type === 'text-delta') { - const text = (part as { text?: string; textDelta?: string }).text ?? '' - if (text) send({ type: 'thinking', text }) - } else if (part.type === 'tool-call') { - const input = (part as { input?: { spec?: unknown } }).input - const spec = input?.spec - if (!spec) continue - if (part.toolName === 'render_ui') send({ type: 'render', spec }) - else if (part.toolName === 'append_ui') send({ type: 'append', spec }) - } else if (part.type === 'error') { - const err = (part as { error?: unknown }).error - send({ type: 'error', error: err instanceof Error ? err.message : String(err) }) - } - } - - send({ type: 'done' }) - } catch (err) { - send({ - type: 'error', - error: err instanceof Error ? err.message : String(err), - }) + await runAgent(parsed.messages, model, send) } finally { res.end() } diff --git a/api/health.ts b/api/health.ts index e3eeb3f..6f891e2 100644 --- a/api/health.ts +++ b/api/health.ts @@ -1,15 +1,8 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' +import { hasAnyApiKey } from './agent-core.js' +import { DEFAULT_MODEL } from './model.js' -const MODEL = process.env.AI_MODEL ?? 'anthropic/claude-sonnet-4-6' - -function hasAnyApiKey(): boolean { - return Boolean( - process.env.AI_GATEWAY_API_KEY || - process.env.VERCEL_AI_GATEWAY_API_KEY || - process.env.ANTHROPIC_API_KEY || - process.env.OPENAI_API_KEY, - ) -} +const MODEL = process.env.AI_MODEL ?? DEFAULT_MODEL export default function handler(_req: VercelRequest, res: VercelResponse) { res.status(200).json({ diff --git a/middleware.ts b/middleware.ts index ca0bc2a..72a9eb2 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,10 +2,30 @@ export const config = { matcher: ['/((?!_next/static|favicon.ico).*)'], } +const BASIC_REALM = 'stream-ui playground' +const isProd = process.env.VERCEL_ENV === 'production' + +if (!process.env.BASIC_AUTH_PASS) { + if (isProd) { + console.warn( + '[middleware] BASIC_AUTH_PASS is unset on production — all requests will be rejected.', + ) + } else { + console.warn('[middleware] BASIC_AUTH_PASS is unset — auth is disabled for this deployment.') + } +} + export default function middleware(req: Request): Response | undefined { const user = process.env.BASIC_AUTH_USER ?? 'admin' const pass = process.env.BASIC_AUTH_PASS - if (!pass) return + + // No password configured: outside production we stay open (useful for + // previews / local dev). In production we fail closed so a missing env var + // can't silently expose the app. + if (!pass) { + if (!isProd) return + return challenge('Authentication required (auth not configured)') + } const header = req.headers.get('authorization') ?? '' if (header.startsWith('Basic ')) { @@ -18,8 +38,12 @@ export default function middleware(req: Request): Response | undefined { } } - return new Response('Authentication required', { + return challenge('Authentication required') +} + +function challenge(body: string): Response { + return new Response(body, { status: 401, - headers: { 'WWW-Authenticate': 'Basic realm="stream-ui playground"' }, + headers: { 'WWW-Authenticate': `Basic realm="${BASIC_REALM}"` }, }) } diff --git a/playground/main.ts b/playground/main.ts index b96dc15..11d16ed 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -216,16 +216,16 @@ function wireResizers(g: HTMLDivElement): void { const tracks = PAIR_TRACKS[layout][pair] if (!axis || !tracks) return - e.preventDefault() - resizer.setPointerCapture(e.pointerId) - resizer.classList.add('dragging') - document.body.classList.add(axis === 'col' ? 'resizing-col' : 'resizing-row') - const [firstClass, secondClass] = PAIR_REGIONS[pair] const first = g.querySelector(`.${firstClass}`) const second = g.querySelector(`.${secondClass}`) if (!first || !second) return + e.preventDefault() + resizer.setPointerCapture(e.pointerId) + resizer.classList.add('dragging') + document.body.classList.add(axis === 'col' ? 'resizing-col' : 'resizing-row') + const firstRect = first.getBoundingClientRect() const secondRect = second.getBoundingClientRect() const totalPx = @@ -337,20 +337,20 @@ if (settingsBtn && settingsPopover && grid) { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) -function pushChat(who: 'human' | 'agent' | 'system', text: string): void { +function appendLine(container: HTMLElement, className: string, text: string): void { const el = document.createElement('div') - el.className = `chat-msg chat-${who}` + el.className = className el.textContent = text - chatLog.appendChild(el) - chatLog.scrollTop = chatLog.scrollHeight + container.appendChild(el) + container.scrollTop = container.scrollHeight +} + +function pushChat(who: 'human' | 'agent' | 'system', text: string): void { + appendLine(chatLog, `chat-msg chat-${who}`, text) } function pushAI(text: string, variant: 'thinking' | 'normal' | 'action' = 'normal'): void { - const el = document.createElement('div') - el.className = `ai-line ${variant === 'normal' ? '' : variant}`.trim() - el.textContent = text - aiStream.appendChild(el) - aiStream.scrollTop = aiStream.scrollHeight + appendLine(aiStream, `ai-line ${variant === 'normal' ? '' : variant}`.trim(), text) } const onAction: ActionHandler = (event: ActionEvent): void => { @@ -372,9 +372,7 @@ const onAction: ActionHandler = (event: ActionEvent): void => { // Skip non-submit field-change actions — they're local state only. const p = event.payload as { name?: string; value?: unknown; checked?: unknown } | undefined - const isFieldChange = - typeof p?.name === 'string' && ('value' in (p ?? {}) || 'checked' in (p ?? {})) - if (isFieldChange) return + if (p && typeof p.name === 'string' && ('value' in p || 'checked' in p)) return addMessage({ role: 'user', kind: 'button-click', action: event.action }) void runAgent() @@ -833,12 +831,19 @@ async function* realAgent(msgs: PlaygroundMessage[]): AsyncGenerator MAX_BUFFER) { + reader.cancel().catch(() => {}) + throw new Error(`SSE buffer exceeded ${MAX_BUFFER} bytes without event separator`) + } let split = buffer.indexOf('\n\n') while (split !== -1) { const chunk = buffer.slice(0, split) @@ -1004,34 +1009,23 @@ if (agentMode === 'auto') { pushAI(agentMode === 'llm' ? 'Forced LLM mode.' : 'Forced mock mode.', 'thinking') } +const WELCOME_SPEC: ComponentSpec = { + kind: 'card', + title: 'Welcome to stream-ui', + body: 'Type a prompt in CHAT. Type "palette" to see every component at once. Try: "make a button", "alert error", "show a table", "add a checkbox".', +} + const clearBtn = document.getElementById('chat-clear') as HTMLButtonElement | null clearBtn?.addEventListener('click', () => { messages.length = 0 saveMessages(messages) chatLog.innerHTML = '' aiStream.innerHTML = '' - render( - { - kind: 'card', - title: 'Welcome to stream-ui', - body: 'Type a prompt in CHAT. Type "palette" to see every component at once.', - }, - uiStage, - onAction, - ) + render(WELCOME_SPEC, uiStage, onAction) pushChat('system', 'session cleared') pushAI('Agent ready.') }) -// Initial state -render( - { - kind: 'card', - title: 'Welcome to stream-ui', - body: 'Type a prompt in CHAT. Type "palette" to see every component at once. Try: "make a button", "alert error", "show a table", "add a checkbox".', - }, - uiStage, - onAction, -) +render(WELCOME_SPEC, uiStage, onAction) pushAI('Agent ready. Type "palette" to see all components.') pushChat('system', 'session started') diff --git a/playground/server.ts b/playground/server.ts index 5acaa75..8356ceb 100644 --- a/playground/server.ts +++ b/playground/server.ts @@ -8,29 +8,18 @@ // Env: AI_GATEWAY_API_KEY=... (or VERCEL_AI_GATEWAY_API_KEY / ANTHROPIC_API_KEY // / OPENAI_API_KEY fallback). Parent-directory .env files are auto-loaded // so shared keys in e.g. ~/dev/.env propagate without copying. -// Model: override with AI_MODEL=provider/model-id (default: anthropic/claude-sonnet-4-6) +// Model: override with AI_MODEL=provider/model-id (default: from api/model.ts) import { existsSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' -import { stepCountIs, streamText, tool } from 'ai' -import { z } from 'zod' -import { BUILTIN_KINDS } from '../src/types' - -function loadDesignGuide(): string | null { - for (const candidate of [ - resolve(process.cwd(), 'DESIGN.md'), - resolve(import.meta.dir, '..', 'DESIGN.md'), - ]) { - try { - return readFileSync(candidate, 'utf8') - } catch { - // try next candidate - } - } - return null -} - -const DESIGN_GUIDE = loadDesignGuide() +import { + type AgentEvent, + hasAnyApiKey, + parseAgentBody, + runAgent, + sseEncode, +} from '../api/agent-core' +import { DEFAULT_MODEL } from '../api/model' function loadEnvFile(file: string): void { if (!existsSync(file)) return @@ -57,158 +46,20 @@ function loadParentEnvFiles(): void { loadEnvFile(resolve(home, '.env')) } -function hasAnyApiKey(): boolean { - return Boolean( - process.env.AI_GATEWAY_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY, - ) -} - loadParentEnvFiles() -// The Vercel AI SDK gateway provider reads AI_GATEWAY_API_KEY; accept the -// longer VERCEL_AI_GATEWAY_API_KEY as an alias. -if (!process.env.AI_GATEWAY_API_KEY && process.env.VERCEL_AI_GATEWAY_API_KEY) { - process.env.AI_GATEWAY_API_KEY = process.env.VERCEL_AI_GATEWAY_API_KEY -} - const PORT = Number(process.env.PLAYGROUND_SERVER_PORT ?? 3030) -const MODEL = process.env.AI_MODEL ?? 'anthropic/claude-sonnet-4-6' - -const componentSpecSchema = z - .object({ - kind: z.string().describe('Component kind, e.g. "button", "card", "alert"'), - }) - .passthrough() - .describe('A stream-ui ComponentSpec. Include all fields the kind expects.') - -type PlaygroundMessage = - | { role: 'user'; kind: 'prompt'; text: string } - | { role: 'user'; kind: 'form-submit'; name: string; fields: Record } - | { role: 'user'; kind: 'button-click'; action: string } - | { role: 'assistant'; kind: 'thinking'; text: string } - | { role: 'assistant'; kind: 'render' | 'append'; spec: unknown } - -type CoreMessage = { role: 'user' | 'assistant'; content: string } - -function toCoreMessages(messages: PlaygroundMessage[]): CoreMessage[] { - return messages.map((m) => { - if (m.role === 'user') { - if (m.kind === 'prompt') return { role: 'user', content: m.text } - if (m.kind === 'form-submit') { - const body = Object.entries(m.fields) - .map(([k, v]) => `${k}="${String(v)}"`) - .join(' ') - return { role: 'user', content: `[form submit: ${m.name}] ${body}` } - } - return { role: 'user', content: `[button clicked: ${m.action}]` } - } - if (m.kind === 'thinking') return { role: 'assistant', content: m.text } - const tag = m.kind === 'render' ? '[render]' : '[append]' - return { role: 'assistant', content: `${tag} ${JSON.stringify(m.spec)}` } - }) -} - -const systemPrompt = `You are a UI-generation agent for stream-ui. - -stream-ui is a framework where JSON specs are rendered to DOM. Your job is to -take a user's natural-language prompt and produce UI by calling one of two -tools: - -- render_ui(spec): replace the UI region with this component -- append_ui(spec): append this component to the existing UI region - -Each spec is a JSON object: { kind, ...fields }. - -Built-in kinds available: -${BUILTIN_KINDS.map((k) => ` - ${k}`).join('\n')} - -Common spec shapes (partial reference): - { kind: 'text', content: string } - { kind: 'heading', level: 1|2|3|4|5|6, content: string } - { kind: 'paragraph', content: string } - { kind: 'card', title: string, body?: string, children?: Spec[] } - { kind: 'stack'|'row'|'grid', children: Spec[], gap?: 'sm'|'md'|'lg' } - { kind: 'alert', variant: 'info'|'success'|'warning'|'error', content: string } - { kind: 'badge', content: string, variant?: 'default'|'success'|'warning'|'error' } - { kind: 'button', label: string, action: string, variant?: 'default'|'primary'|'danger' } - { kind: 'link', label: string, href: string } - { kind: 'input', name: string, label: string, type?: string, action?: string, - format?: 'email'|'phone'|'url'|'zip'|'credit-card', - validation?: { required?: boolean, pattern?: string, minLength?: number, - maxLength?: number, min?: number, max?: number, errorMessage?: string } } - { kind: 'textarea', name: string, label: string, rows?: number, - validation?: { ...same as input... } } - { kind: 'form', submitLabel: string, fields: [ - { name, label, type, placeholder?, format?, validation? }, ... - ] } - { kind: 'list', items: string[], ordered?: boolean } - { kind: 'table', headers: string[], rows: string[][] } - -Input formats bundle validation + display masking: - - 'email' → validates email address - - 'phone' → validates 10-digit US phone + auto-formats as (555) 123-4567 - - 'url' → validates http(s) URL - - 'zip' → validates 5 or 9 digit US zip + formats 12345-6789 - - 'credit-card' → validates 13-19 digits + formats as groups of 4 - -Guidelines: -1. Default to render_ui. Use append_ui only when the user says "add" or "also". -2. Compose: wrap groups of components in card/stack/row/grid rather than - emitting many top-level appends. -3. Keep specs small, real, and purposeful. No placeholder lorem ipsum. -4. For interactive elements (button, input, form, link) always set a meaningful - action or href that describes what the user should be able to do. -5. For fields that are emails / phone numbers / URLs / ZIP codes / credit cards, - set the appropriate 'format' — don't just set 'type'. Use 'validation.required: - true' when the field is mandatory. The framework handles both validation and - display masking for you. -6. Think briefly about the goal before the first tool call. -7. If the latest user message is "[form submit: ] key="value" ...", the user - submitted form . Acknowledge or advance — e.g. render_ui a success card. -8. If the latest user message is "[button clicked: ]", the user clicked a - button with that action. Continue the flow accordingly.${ - DESIGN_GUIDE - ? ` - ---- - -## Design system (from DESIGN.md) - -The following is the project's visual identity. Read the tokens to pick -component variants by *intent*, not hex values. Honor the Do's and Don'ts. - -${DESIGN_GUIDE}` - : '' - }` - -type AgentEvent = - | { type: 'thinking'; text: string } - | { type: 'render'; spec: unknown } - | { type: 'append'; spec: unknown } - | { type: 'done' } - | { type: 'error'; error: string } - -function sseEncode(event: AgentEvent): string { - return `data: ${JSON.stringify(event)}\n\n` -} +const MODEL = process.env.AI_MODEL ?? DEFAULT_MODEL async function handleAgent(req: Request): Promise { - let messages: PlaygroundMessage[] + let body: unknown try { - const body = (await req.json()) as { - prompt?: unknown - messages?: unknown - } - if (Array.isArray(body.messages) && body.messages.length > 0) { - messages = body.messages as PlaygroundMessage[] - } else if (typeof body.prompt === 'string' && body.prompt.trim() !== '') { - messages = [{ role: 'user', kind: 'prompt', text: body.prompt }] - } else { - return new Response('Missing prompt or messages', { status: 400 }) - } + body = await req.json() } catch { return new Response('Invalid JSON', { status: 400 }) } + const parsed = parseAgentBody(body) + if ('error' in parsed) return new Response(parsed.error, { status: 400 }) const stream = new ReadableStream({ async start(controller) { @@ -216,52 +67,8 @@ async function handleAgent(req: Request): Promise { const send = (event: AgentEvent) => { controller.enqueue(encoder.encode(sseEncode(event))) } - - try { - const result = streamText({ - model: MODEL, - system: systemPrompt, - messages: toCoreMessages(messages), - tools: { - render_ui: tool({ - description: 'Render a component, replacing the existing UI.', - inputSchema: z.object({ spec: componentSpecSchema }), - execute: async () => ({ ok: true }), - }), - append_ui: tool({ - description: 'Append a component to the existing UI.', - inputSchema: z.object({ spec: componentSpecSchema }), - execute: async () => ({ ok: true }), - }), - }, - stopWhen: stepCountIs(8), - }) - - for await (const part of result.fullStream) { - if (part.type === 'text-delta') { - const text = (part as { text?: string; textDelta?: string }).text ?? '' - if (text) send({ type: 'thinking', text }) - } else if (part.type === 'tool-call') { - const input = (part as { input?: { spec?: unknown } }).input - const spec = input?.spec - if (!spec) continue - if (part.toolName === 'render_ui') send({ type: 'render', spec }) - else if (part.toolName === 'append_ui') send({ type: 'append', spec }) - } else if (part.type === 'error') { - const err = (part as { error?: unknown }).error - send({ type: 'error', error: err instanceof Error ? err.message : String(err) }) - } - } - - send({ type: 'done' }) - } catch (err) { - send({ - type: 'error', - error: err instanceof Error ? err.message : String(err), - }) - } finally { - controller.close() - } + await runAgent(parsed.messages, MODEL, send) + controller.close() }, }) diff --git a/playground/settings-ui.ts b/playground/settings-ui.ts index dc62a56..0b558e5 100644 --- a/playground/settings-ui.ts +++ b/playground/settings-ui.ts @@ -77,28 +77,51 @@ export function mountSettingsPopover( ) } - function themeRow(current: ThemePreset): HTMLElement { + function toggleGroup(opts: { + wrapClass: string + wrapRole: string + wrapLabel?: string + buttonClass?: string + buttonRole?: string + values: readonly T[] + labels: Record + current: T + onPick: (value: T) => void + }): HTMLElement { const wrap = document.createElement('div') - wrap.className = 'settings-theme-row' - wrap.setAttribute('role', 'group') - wrap.setAttribute('aria-label', 'Theme') - for (const t of THEME_PRESETS) { + wrap.className = opts.wrapClass + wrap.setAttribute('role', opts.wrapRole) + if (opts.wrapLabel) wrap.setAttribute('aria-label', opts.wrapLabel) + for (const v of opts.values) { const btn = document.createElement('button') btn.type = 'button' - btn.className = 'settings-theme-btn' - btn.textContent = THEME_LABELS[t] - btn.dataset.theme = t - btn.setAttribute('aria-pressed', t === current ? 'true' : 'false') - btn.addEventListener('click', () => { - writeSettings({ theme: t }) - cb.onThemeChange(t) - render() - }) + if (opts.buttonClass) btn.className = opts.buttonClass + if (opts.buttonRole) btn.setAttribute('role', opts.buttonRole) + btn.textContent = opts.labels[v] + btn.setAttribute('aria-pressed', v === opts.current ? 'true' : 'false') + btn.addEventListener('click', () => opts.onPick(v)) wrap.appendChild(btn) } return wrap } + function themeRow(current: ThemePreset): HTMLElement { + return toggleGroup({ + wrapClass: 'settings-theme-row', + wrapRole: 'group', + wrapLabel: 'Theme', + buttonClass: 'settings-theme-btn', + values: THEME_PRESETS, + labels: THEME_LABELS, + current, + onPick: (t) => { + writeSettings({ theme: t }) + cb.onThemeChange(t) + render() + }, + }) + } + function section(title: string, children: HTMLElement[]): HTMLElement { const wrap = document.createElement('div') wrap.className = 'settings-section' @@ -140,37 +163,35 @@ export function mountSettingsPopover( input.placeholder = 'provider/model-slug' input.value = isCustom ? current : '' input.hidden = !isCustom - input.addEventListener('input', () => { - writeSettings({ model: input.value.trim() }) + // Persist on blur or Enter so we don't write to localStorage per keystroke + // (which also mid-typing leaves invalid partial slugs like 'ant' in storage). + const commit = () => writeSettings({ model: input.value.trim() }) + input.addEventListener('blur', commit) + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') commit() }) return input } function presetRow(current: LayoutPreset): HTMLElement { - const wrap = document.createElement('div') - wrap.className = 'settings-layout-presets' - wrap.setAttribute('role', 'radiogroup') - for (const p of LAYOUT_PRESETS) { - const b = document.createElement('button') - b.type = 'button' - b.textContent = PRESET_LABELS[p] - b.setAttribute('role', 'radio') - b.setAttribute('aria-pressed', String(p === current)) - b.addEventListener('click', () => { + return toggleGroup({ + wrapClass: 'settings-layout-presets', + wrapRole: 'radiogroup', + buttonRole: 'radio', + values: LAYOUT_PRESETS, + labels: PRESET_LABELS, + current, + onPick: (p) => { writeSettings({ layout: p }) render() cb.onLayoutChange() - }) - wrap.appendChild(b) - } - return wrap + }, + }) } function hideAIRow(current: boolean): HTMLElement { const label = document.createElement('label') - label.style.display = 'flex' - label.style.alignItems = 'center' - label.style.gap = '0.4rem' + label.className = 'settings-hide-ai-row' const box = document.createElement('input') box.type = 'checkbox' box.checked = current diff --git a/playground/settings.ts b/playground/settings.ts index ea34f22..cf70b1b 100644 --- a/playground/settings.ts +++ b/playground/settings.ts @@ -1,4 +1,6 @@ -export const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-6' +import { DEFAULT_MODEL } from '../api/model' + +export { DEFAULT_MODEL } export const MODEL_PRESETS: ReadonlyArray = [ 'anthropic/claude-sonnet-4-6', @@ -15,9 +17,18 @@ export const LAYOUT_PRESETS: ReadonlyArray = ['default', 'sideBySi export type ThemePreset = 'system' | 'light' | 'dark' | 'heritage' export const THEME_PRESETS: ReadonlyArray = ['system', 'light', 'dark', 'heritage'] -export type ResizerPair = 'chat-ai' | 'ai-ui' | 'top-bottom' +export const RESIZER_PAIRS = ['chat-ai', 'ai-ui', 'top-bottom'] as const +export type ResizerPair = (typeof RESIZER_PAIRS)[number] export type SizeMap = Partial> +function isResizerPair(v: unknown): v is ResizerPair { + return typeof v === 'string' && (RESIZER_PAIRS as readonly string[]).includes(v) +} + +function isLayoutPreset(v: unknown): v is LayoutPreset { + return typeof v === 'string' && (LAYOUT_PRESETS as readonly string[]).includes(v) +} + export type SuiSettings = { model: string theme: ThemePreset @@ -60,19 +71,14 @@ function readSizeMap(preset: LayoutPreset): SizeMap { if (!raw || typeof raw !== 'object') return {} const out: SizeMap = {} for (const [k, v] of Object.entries(raw as Record)) { - if ((k === 'chat-ai' || k === 'ai-ui' || k === 'top-bottom') && typeof v === 'number') { - out[k] = v - } + if (isResizerPair(k) && typeof v === 'number') out[k] = v } return out } export function readSettings(): SuiSettings { const rawLayout = getItem(KEY_LAYOUT) - const layout: LayoutPreset = - rawLayout === 'default' || rawLayout === 'sideBySide' || rawLayout === 'stacked' - ? rawLayout - : 'default' + const layout: LayoutPreset = isLayoutPreset(rawLayout) ? rawLayout : 'default' const rawTheme = getItem(KEY_THEME) const theme: ThemePreset = isThemePreset(rawTheme) ? rawTheme : 'system' return { diff --git a/playground/style.css b/playground/style.css index 3be6e90..c60839c 100644 --- a/playground/style.css +++ b/playground/style.css @@ -133,6 +133,12 @@ header { background: rgba(127, 127, 127, 0.15); } +.settings-hide-ai-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + h1 { margin: 0 0 0.25rem; font-size: 1.5rem; diff --git a/scripts/build-styles.ts b/scripts/build-styles.ts index 0c8e568..1b76c85 100644 --- a/scripts/build-styles.ts +++ b/scripts/build-styles.ts @@ -6,15 +6,19 @@ * * Run after tsup via `bun run build`. */ -import { readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const ROOT = join(here, '..') +const OUT = join(ROOT, 'dist', 'styles.css') -const tokens = readFileSync(join(ROOT, 'src', 'design-tokens.css'), 'utf8') -const styles = readFileSync(join(ROOT, 'src', 'styles.css'), 'utf8') +const [tokens, styles] = await Promise.all([ + Bun.file(join(ROOT, 'src', 'design-tokens.css')).text(), + Bun.file(join(ROOT, 'src', 'styles.css')).text(), +]) -writeFileSync(join(ROOT, 'dist', 'styles.css'), `${tokens}\n${styles}`) +mkdirSync(dirname(OUT), { recursive: true }) +writeFileSync(OUT, `${tokens}\n${styles}`) console.log('[build-styles] wrote dist/styles.css') diff --git a/scripts/design-to-css.test.ts b/scripts/design-to-css.test.ts index 895812e..7aab4aa 100644 --- a/scripts/design-to-css.test.ts +++ b/scripts/design-to-css.test.ts @@ -1,16 +1,5 @@ -import { spawnSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const TOKENS = join(ROOT, 'src', 'design-tokens.css') - -function run(): { out: string; status: number | null } { - const r = spawnSync('bun', ['run', 'scripts/design-to-css.ts'], { cwd: ROOT, encoding: 'utf8' }) - return { out: readFileSync(TOKENS, 'utf8'), status: r.status } -} +import { beforeAll, describe, expect, it } from 'vitest' +import { generate } from './design-to-css' describe('design-to-css generator', () => { // The generated file is the contract between DESIGN.md and every consumer @@ -18,9 +7,12 @@ describe('design-to-css generator', () => { // specific tokens each downstream selector depends on so a drift in the // generator — or an unintended rename in DESIGN.md — fails loudly here // instead of showing up as a cosmetic regression in the browser. + let out = '' + beforeAll(() => { + out = generate() + }) + it('emits :root with every semantic color token styles.css references', () => { - const { status, out } = run() - expect(status).toBe(0) expect(out).toMatch(/^:root \{/m) for (const name of [ '--sui-colors-primary:', @@ -36,7 +28,6 @@ describe('design-to-css generator', () => { }) it('emits motion tokens used by button/link/progress transitions', () => { - const { out } = run() for (const name of [ '--sui-motion-duration-fast:', '--sui-motion-duration-base:', @@ -47,21 +38,18 @@ describe('design-to-css generator', () => { }) it('resolves token references via var()', () => { - const { out } = run() expect(out).toContain( '--sui-components-button-primary-background-color: var(--sui-colors-primary);', ) }) it('emits dark variant as both class and prefers-color-scheme media query', () => { - const { out } = run() expect(out).toContain('.sui-theme-dark {') expect(out).toContain('@media (prefers-color-scheme: dark) {') expect(out).toContain(':root:not(.sui-theme-light) {') }) it('lowercases hex values for biome compatibility', () => { - const { out } = run() // Uppercase hex would fail biome lint; DESIGN.md is authored uppercase. expect(out).not.toMatch(/#[0-9A-F]{3,8}\b/) }) diff --git a/scripts/design-to-css.ts b/scripts/design-to-css.ts index 2a6344f..4f4b2a3 100644 --- a/scripts/design-to-css.ts +++ b/scripts/design-to-css.ts @@ -18,15 +18,6 @@ const ROOT = join(here, '..') const SRC = join(ROOT, 'DESIGN.md') const OUT = join(ROOT, 'src', 'design-tokens.css') -const file = readFileSync(SRC, 'utf8') -const match = file.match(/^---\r?\n([\s\S]*?)\r?\n---/) -if (!match) { - console.error('[design-to-css] DESIGN.md: missing YAML front matter') - process.exit(1) -} - -const tokens = parseYaml(match[1]) as Record - function kebab(s: string): string { return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() } @@ -45,7 +36,11 @@ function normalizeValue(value: string): string { return value.replace(/#[0-9A-Fa-f]{3,8}\b/g, (h) => h.toLowerCase()) } -const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'motion', 'components'] as const +// Top-level DESIGN.md keys that are NOT CSS-emitted token groups: +// - variants: handled separately below (per-theme blocks) +// - voice: text-authoring guidance for agents, not design tokens +// - metadata: string scalars at the top level +const NON_TOKEN_KEYS = new Set(['variants', 'voice', 'version', 'name', 'description']) function collectVars(source: Record): string[] { const out: string[] = [] @@ -62,45 +57,62 @@ function collectVars(source: Record): string[] { } } } - for (const group of TOKEN_GROUPS) { - const section = source[group] - if (section && typeof section === 'object') { + for (const [group, section] of Object.entries(source)) { + if (NON_TOKEN_KEYS.has(group)) continue + if (section && typeof section === 'object' && !Array.isArray(section)) { emit(section as Record, kebab(group)) } } return out } -const lines: string[] = [ - '/* AUTO-GENERATED from DESIGN.md — do not edit by hand.', - ' * Run `bun run tokens` to regenerate after changing DESIGN.md. */', -] +export function generate(): string { + const file = readFileSync(SRC, 'utf8') + const match = file.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) { + throw new Error('[design-to-css] DESIGN.md: missing YAML front matter') + } + const tokens = parseYaml(match[1]) as Record -const rootVars = collectVars(tokens) -lines.push(':root {', ...rootVars.map((v) => ` ${v}`), '}') + const lines: string[] = [ + '/* AUTO-GENERATED from DESIGN.md — do not edit by hand.', + ' * Run `bun run tokens` to regenerate after changing DESIGN.md. */', + ] + lines.push(':root {', ...collectVars(tokens).map((v) => ` ${v}`), '}') -const variants = tokens.variants -if (variants && typeof variants === 'object') { - for (const [name, payload] of Object.entries(variants as Record)) { - if (!payload || typeof payload !== 'object') continue - const variantVars = collectVars(payload as Record) - if (variantVars.length === 0) continue - const className = `.sui-theme-${kebab(name)}` - lines.push('', `${className} {`, ...variantVars.map((v) => ` ${v}`), '}') - if (name === 'dark') { - lines.push( - '', - '@media (prefers-color-scheme: dark) {', - ' :root:not(.sui-theme-light) {', - ...variantVars.map((v) => ` ${v}`), - ' }', - '}', - ) + const variants = tokens.variants + if (variants && typeof variants === 'object') { + for (const [name, payload] of Object.entries(variants as Record)) { + if (!payload || typeof payload !== 'object') continue + const variantVars = collectVars(payload as Record) + if (variantVars.length === 0) continue + const className = `.sui-theme-${kebab(name)}` + lines.push('', `${className} {`, ...variantVars.map((v) => ` ${v}`), '}') + if (name === 'dark') { + lines.push( + '', + '@media (prefers-color-scheme: dark) {', + ' :root:not(.sui-theme-light) {', + ...variantVars.map((v) => ` ${v}`), + ' }', + '}', + ) + } } } -} -lines.push('') + lines.push('') + return lines.join('\n') +} -writeFileSync(OUT, lines.join('\n')) -console.log(`[design-to-css] wrote ${OUT}`) +// Only run the CLI side when invoked directly (bun scripts/design-to-css.ts), +// not when imported from tests. +if (import.meta.main) { + try { + writeFileSync(OUT, generate()) + console.log(`[design-to-css] wrote ${OUT}`) + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) + } +} diff --git a/scripts/lint-design.ts b/scripts/lint-design.ts index bd4e38c..244cc61 100644 --- a/scripts/lint-design.ts +++ b/scripts/lint-design.ts @@ -25,13 +25,46 @@ if (r.error) { process.exit(2) } -// The CLI writes progress text to stdout before the JSON report. Pull -// out the final JSON object by scanning from the last standalone `{`. +// The CLI writes progress text to stdout before the JSON report. Scan for the +// last balanced top-level `{…}` — more robust than substring heuristics when +// progress lines contain braces or the JSON starts at column 0. +function extractLastJson(raw: string): string | null { + let last: string | null = null + let depth = 0 + let start = -1 + let inString = false + let escaped = false + for (let i = 0; i < raw.length; i++) { + const ch = raw[i] + if (escaped) { + escaped = false + continue + } + if (inString) { + if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') { + if (depth === 0) start = i + depth++ + } else if (ch === '}') { + depth-- + if (depth === 0 && start >= 0) { + last = raw.slice(start, i + 1) + start = -1 + } + } + } + return last +} + const raw = r.stdout.toString() -const start = raw.lastIndexOf('\n{') -const jsonText = start >= 0 ? raw.slice(start + 1) : raw +const jsonText = extractLastJson(raw) let report: Report try { + if (!jsonText) throw new Error('no JSON object found') report = JSON.parse(jsonText) as Report } catch { console.error('[lint:design] could not parse lint output:') diff --git a/src/components.ts b/src/components.ts index d08cc00..2766beb 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,8 +1,9 @@ import { createElement } from './registry' -import { safeHref, safeImageSrc } from './safe-url' +import { isExternal, safeHref, safeImageSrc } from './safe-url' import type { ActionHandler, ComponentKind, + ComponentSpec, InputFormat, Renderer, SpecOf, @@ -94,6 +95,14 @@ function bindValidation( // Fires onAction with { name, value } on every input event when `action` is // set — shared by the input and textarea renderers. +function appendChildren( + el: HTMLElement, + children: readonly ComponentSpec[], + onAction: ActionHandler | undefined, +): void { + for (const child of children) el.appendChild(createElement(child, onAction)) +} + function bindInputAction( control: HTMLInputElement | HTMLTextAreaElement, name: string, @@ -165,29 +174,21 @@ export const builtins: BuiltinRenderers = { p.textContent = spec.body el.appendChild(p) } - if (spec.children) { - for (const child of spec.children) { - el.appendChild(createElement(child, onAction)) - } - } + if (spec.children) appendChildren(el, spec.children, onAction) return el }, stack: (spec, onAction) => { const el = document.createElement('div') el.className = `sui-stack sui-gap-${spec.gap ?? 'md'}` - for (const child of spec.children) { - el.appendChild(createElement(child, onAction)) - } + appendChildren(el, spec.children, onAction) return el }, row: (spec, onAction) => { const el = document.createElement('div') el.className = `sui-row sui-gap-${spec.gap ?? 'md'} sui-align-${spec.align ?? 'start'}` - for (const child of spec.children) { - el.appendChild(createElement(child, onAction)) - } + appendChildren(el, spec.children, onAction) return el }, @@ -195,9 +196,7 @@ export const builtins: BuiltinRenderers = { const el = document.createElement('div') el.className = `sui-grid sui-gap-${spec.gap ?? 'md'}` el.style.setProperty('--sui-grid-cols', String(spec.columns ?? 2)) - for (const child of spec.children) { - el.appendChild(createElement(child, onAction)) - } + appendChildren(el, spec.children, onAction) return el }, @@ -383,7 +382,9 @@ export const builtins: BuiltinRenderers = { e.preventDefault() let firstInvalid: HTMLInputElement | null = null for (const field of spec.fields) { - const input = el.querySelector(`input[name="${field.name}"]`) as HTMLInputElement | null + const input = el.querySelector( + `input[name="${CSS.escape(field.name)}"]`, + ) as HTMLInputElement | null if (!input) continue const msg = validate(input.value, field.validation, field.format) const wrap = input.closest('.sui-input-wrap') as HTMLElement | null @@ -419,7 +420,7 @@ export const builtins: BuiltinRenderers = { const safe = safeHref(spec.href) el.href = safe el.textContent = spec.label - if (/^(https?:)?\/\//.test(safe)) { + if (isExternal(safe)) { el.target = '_blank' el.rel = 'noopener noreferrer' } diff --git a/src/index.ts b/src/index.ts index bcd05b2..abbc3d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,13 +17,12 @@ import { builtins } from './components' import { createElement, register } from './registry' import type { ActionHandler, AnySpec, ComponentSpec, Renderer } from './types' +import { BUILTIN_KINDS } from './types' // Auto-register every built-in on module load. Consumers can call // `unregister('button')` afterward if they want to disable a built-in. -// The cast widens each per-kind Renderer> to the registry's -// permissive Renderer type — runtime dispatch only cares about `kind`. -for (const [kind, renderer] of Object.entries(builtins) as Array<[string, Renderer]>) { - register(kind, renderer) +for (const kind of BUILTIN_KINDS) { + register(kind, builtins[kind] as Renderer) } export const VERSION = '0.7.0' diff --git a/src/safe-url.ts b/src/safe-url.ts index c5c5571..202d6e8 100644 --- a/src/safe-url.ts +++ b/src/safe-url.ts @@ -24,6 +24,18 @@ export function safeHref(input: unknown): string { return 'about:blank' } +// True when the (already-validated) href points to an absolute http(s) URL. +// Relative, hash, query, mailto, tel, and about: URLs are internal. +export function isExternal(href: string): boolean { + try { + const url = new URL(href, 'http://_relative_') + if (url.origin === 'http://_relative_') return false + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + export function safeImageSrc(input: unknown): string { if (typeof input !== 'string') return '' const trimmed = input.trim() diff --git a/src/validation.ts b/src/validation.ts index 18f564f..10722c7 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -51,7 +51,9 @@ export function validate( } if (rules?.min !== undefined || rules?.max !== undefined) { - const n = Number(value) + // Match HTML `` semantics: reject whitespace, hex, + // and scientific notation that `Number('1e3')` / `Number('0x10')` accept. + const n = /^-?\d+(\.\d+)?$/.test(value) ? Number.parseFloat(value) : Number.NaN if (Number.isNaN(n)) return errorMessage ?? 'Must be a number' if (rules.min !== undefined && n < rules.min) { return errorMessage ?? `Must be at least ${rules.min}`