diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..7b431b8 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,114 @@ +--- +version: alpha +name: Stream UI Default +description: Default theme for stream-ui — neutral, accessible, agent-friendly. +colors: + primary: "#508CDC" + primary-hover: "#5F9BEB" + neutral: "#7F7F7F" + success: "#50B464" + warning: "#DCAA46" + error: "#DC5A5A" + link: "#6496FF" + link-hover: "#82AFFF" + on-primary: "#FFFFFF" + on-error: "#FFFFFF" +typography: + h1: + fontFamily: inherit + fontSize: 1.75rem + lineHeight: 1.2 + h2: + fontFamily: inherit + fontSize: 1.4rem + lineHeight: 1.2 + h3: + fontFamily: inherit + fontSize: 1.15rem + lineHeight: 1.2 + body: + fontFamily: inherit + fontSize: 1rem + lineHeight: 1.5 + small: + fontFamily: inherit + fontSize: 0.875rem + lineHeight: 1.4 + code: + fontFamily: "ui-monospace, SFMono-Regular, monospace" + fontSize: 0.875em +rounded: + sm: 0.25rem + md: 0.5rem + pill: 999px +spacing: + xs: 0.25rem + sm: 0.5rem + md: 0.75rem + lg: 1.5rem +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + button-primary-hover: + backgroundColor: "{colors.primary-hover}" + button-danger: + backgroundColor: "{colors.error}" + textColor: "{colors.on-error}" + rounded: "{rounded.md}" +--- + +## Overview + +stream-ui's default look — minimal, theme-neutral, designed to inherit the host +app's color scheme where possible. Components expose `.sui-*` classes that read +CSS custom properties generated from this file. Override the variables in your +app's stylesheet to retheme without touching component code. + +Agents consuming this file should pick colors by *intent* (semantic name), not +by hex. The palette is deliberately small so component variants map cleanly. + +## Colors + +- **primary** — primary action buttons, focus rings, link base color. +- **success / warning / error** — status communication in alerts and badges. +- **neutral** — borders, dividers, table stripes. The raw CSS currently applies + neutral via low-alpha grey so it adapts to both light and dark backgrounds. + +## Typography + +Stream-UI inherits the host app's font stack (`fontFamily: inherit`) so brand +typography is preserved automatically. Heading sizes descend from 1.75rem (h1) +to 0.85rem (h6); the spec captures h1/h2/h3 as representative anchors. + +## Layout + +Spacing tokens (`xs → lg`) drive the `gap` utility on stack/row/grid +containers (`sui-gap-sm/md/lg`). Compose layout through these primitives rather +than custom CSS. + +## Shapes + +Three rounded levels: `sm` (inline tags, code blocks), `md` (cards, buttons, +inputs), and `pill` (badges, progress bars). + +## Components + +Component tokens map to stream-ui's built-in kinds. Variants (hover, pressed, +disabled) are expressed as sibling entries with a related key name — +`button-primary` and `button-primary-hover` rather than nested states. + +Valid component properties in this theme: `backgroundColor`, `textColor`, +`rounded`. + +## Do's and Don'ts + +- **Do** reference semantic tokens (`{colors.error}`) in derived themes instead + of duplicating hex values — override the token, every consumer updates. +- **Do** set `variant: 'primary' | 'danger'` on buttons and let component + tokens decide the color. Never inline custom colors in streamed specs. +- **Don't** introduce raw hex values in component props. That bypasses theming + and breaks dark-mode overrides downstream. +- **Don't** add UI chrome the user didn't ask for. Prefer the smallest spec + that fulfills the intent. diff --git a/api/agent.ts b/api/agent.ts index ff35b3a..15fd3d3 100644 --- a/api/agent.ts +++ b/api/agent.ts @@ -1,8 +1,31 @@ +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' +// 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 } @@ -101,7 +124,20 @@ Guidelines: 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.` + 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 } diff --git a/bun.lock b/bun.lock index 7782256..5157a3b 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "typescript": "^6.0.3", "vite": "^8.0.9", "vitest": "^4.1.5", + "yaml": "^2.8.3", "zod": "^4.3.6", }, }, @@ -603,6 +604,8 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], diff --git a/package.json b/package.json index 11cabe5..0b672c9 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ "LICENSE" ], "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean && cp src/styles.css dist/styles.css", - "dev": "tsup src/index.ts --format esm --dts --watch --onSuccess \"cp src/styles.css dist/styles.css\"", - "playground": "vite", + "tokens": "bun run scripts/design-to-css.ts", + "build": "bun run tokens && tsup src/index.ts --format esm --dts --clean && bun run scripts/build-styles.ts", + "dev": "bun run tokens && tsup src/index.ts --format esm --dts --watch --onSuccess \"bun run scripts/build-styles.ts\"", + "playground": "bun run tokens && vite", "playground:build": "vite build", "playground:server": "bun run --watch playground/server.ts", "playground:full": "bun run playground/scripts/run-full.ts", @@ -62,6 +63,7 @@ "typescript": "^6.0.3", "vite": "^8.0.9", "vitest": "^4.1.5", + "yaml": "^2.8.3", "zod": "^4.3.6" }, "trustedDependencies": [ diff --git a/playground/main.ts b/playground/main.ts index 5c769b8..3c4bdc4 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -1,3 +1,4 @@ +import '../src/design-tokens.css' import '../src/styles.css' import { type ActionEvent, diff --git a/scripts/build-styles.ts b/scripts/build-styles.ts new file mode 100644 index 0000000..0c8e568 --- /dev/null +++ b/scripts/build-styles.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun +/** + * Concatenates generated design tokens with src/styles.css into + * dist/styles.css, so published consumers get tokens + styles in one file + * with no runtime `@import`. + * + * Run after tsup via `bun run build`. + */ +import { readFileSync, 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 tokens = readFileSync(join(ROOT, 'src', 'design-tokens.css'), 'utf8') +const styles = readFileSync(join(ROOT, 'src', 'styles.css'), 'utf8') + +writeFileSync(join(ROOT, 'dist', 'styles.css'), `${tokens}\n${styles}`) +console.log('[build-styles] wrote dist/styles.css') diff --git a/scripts/design-to-css.ts b/scripts/design-to-css.ts new file mode 100644 index 0000000..36b5535 --- /dev/null +++ b/scripts/design-to-css.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +/** + * Reads DESIGN.md front matter and emits src/design-tokens.css. + * Run via `bun run tokens`. Also invoked by `build` and `playground`. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse as parseYaml } from 'yaml' + +const here = dirname(fileURLToPath(import.meta.url)) +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() +} + +// `{path.to.token}` → `var(--sui-path-to-token)`. +function resolveRef(value: string): string { + const ref = value.match(/^\{(.+)\}$/) + if (!ref) return value + const path = ref[1].split('.').map(kebab).join('-') + return `var(--sui-${path})` +} + +// Biome's CSS linter prefers lowercase hex. Lower only `#RGB`/`#RRGGBB`/ +// `#RRGGBBAA` tokens, never other strings. +function normalizeValue(value: string): string { + return value.replace(/#[0-9A-Fa-f]{3,8}\b/g, (h) => h.toLowerCase()) +} + +const lines: string[] = [ + '/* AUTO-GENERATED from DESIGN.md — do not edit by hand.', + ' * Run `bun run tokens` to regenerate after changing DESIGN.md. */', + ':root {', +] + +function emit(obj: Record, prefix: string): void { + for (const [rawKey, rawVal] of Object.entries(obj)) { + const key = kebab(rawKey) + const varName = `--sui-${prefix}-${key}` + if (typeof rawVal === 'string') { + lines.push(` ${varName}: ${normalizeValue(resolveRef(rawVal))};`) + } else if (typeof rawVal === 'number') { + lines.push(` ${varName}: ${rawVal};`) + } else if (rawVal && typeof rawVal === 'object' && !Array.isArray(rawVal)) { + emit(rawVal as Record, `${prefix}-${key}`) + } + } +} + +for (const group of ['colors', 'typography', 'rounded', 'spacing', 'components'] as const) { + const section = tokens[group] + if (section && typeof section === 'object') { + emit(section as Record, kebab(group)) + } +} + +lines.push('}', '') + +writeFileSync(OUT, lines.join('\n')) +console.log(`[design-to-css] wrote ${OUT}`) diff --git a/src/design-tokens.css b/src/design-tokens.css new file mode 100644 index 0000000..f819369 --- /dev/null +++ b/src/design-tokens.css @@ -0,0 +1,45 @@ +/* AUTO-GENERATED from DESIGN.md — do not edit by hand. + * Run `bun run tokens` to regenerate after changing DESIGN.md. */ +:root { + --sui-colors-primary: #508cdc; + --sui-colors-primary-hover: #5f9beb; + --sui-colors-neutral: #7f7f7f; + --sui-colors-success: #50b464; + --sui-colors-warning: #dcaa46; + --sui-colors-error: #dc5a5a; + --sui-colors-link: #6496ff; + --sui-colors-link-hover: #82afff; + --sui-colors-on-primary: #ffffff; + --sui-colors-on-error: #ffffff; + --sui-typography-h1-font-family: inherit; + --sui-typography-h1-font-size: 1.75rem; + --sui-typography-h1-line-height: 1.2; + --sui-typography-h2-font-family: inherit; + --sui-typography-h2-font-size: 1.4rem; + --sui-typography-h2-line-height: 1.2; + --sui-typography-h3-font-family: inherit; + --sui-typography-h3-font-size: 1.15rem; + --sui-typography-h3-line-height: 1.2; + --sui-typography-body-font-family: inherit; + --sui-typography-body-font-size: 1rem; + --sui-typography-body-line-height: 1.5; + --sui-typography-small-font-family: inherit; + --sui-typography-small-font-size: 0.875rem; + --sui-typography-small-line-height: 1.4; + --sui-typography-code-font-family: ui-monospace, SFMono-Regular, monospace; + --sui-typography-code-font-size: 0.875em; + --sui-rounded-sm: 0.25rem; + --sui-rounded-md: 0.5rem; + --sui-rounded-pill: 999px; + --sui-spacing-xs: 0.25rem; + --sui-spacing-sm: 0.5rem; + --sui-spacing-md: 0.75rem; + --sui-spacing-lg: 1.5rem; + --sui-components-button-primary-background-color: var(--sui-colors-primary); + --sui-components-button-primary-text-color: var(--sui-colors-on-primary); + --sui-components-button-primary-rounded: var(--sui-rounded-md); + --sui-components-button-primary-hover-background-color: var(--sui-colors-primary-hover); + --sui-components-button-danger-background-color: var(--sui-colors-error); + --sui-components-button-danger-text-color: var(--sui-colors-on-error); + --sui-components-button-danger-rounded: var(--sui-rounded-md); +} diff --git a/src/styles.css b/src/styles.css index a702781..8854960 100644 --- a/src/styles.css +++ b/src/styles.css @@ -325,19 +325,21 @@ } .sui-button-primary { - background: rgb(80, 140, 220); - color: white; - border-color: rgb(80, 140, 220); + background: var(--sui-components-button-primary-background-color, rgb(80, 140, 220)); + color: var(--sui-components-button-primary-text-color, white); + border-color: var(--sui-colors-primary, rgb(80, 140, 220)); + border-radius: var(--sui-components-button-primary-rounded, 0.5rem); } .sui-button-primary:hover, .sui-button-primary:focus { - background: rgb(95, 155, 235); + background: var(--sui-components-button-primary-hover-background-color, rgb(95, 155, 235)); } .sui-button-danger { - background: rgb(220, 90, 90); - color: white; - border-color: rgb(220, 90, 90); + background: var(--sui-components-button-danger-background-color, rgb(220, 90, 90)); + color: var(--sui-components-button-danger-text-color, white); + border-color: var(--sui-colors-error, rgb(220, 90, 90)); + border-radius: var(--sui-components-button-danger-rounded, 0.5rem); } .sui-button-danger:hover, .sui-button-danger:focus { @@ -345,13 +347,13 @@ } .sui-link { - color: rgb(100, 150, 255); + color: var(--sui-colors-link, rgb(100, 150, 255)); text-decoration: underline; text-underline-offset: 0.15em; } .sui-link:hover { - color: rgb(130, 175, 255); + color: var(--sui-colors-link-hover, rgb(130, 175, 255)); } /* ─── unknown-kind fallback ────────────────────────────────────── */ diff --git a/vercel.json b/vercel.json index 891e44e..7c81953 100644 --- a/vercel.json +++ b/vercel.json @@ -3,5 +3,10 @@ "buildCommand": "bun run playground:build", "installCommand": "bun install --frozen-lockfile", "outputDirectory": "playground/dist", - "framework": null + "framework": null, + "functions": { + "api/agent.ts": { + "includeFiles": "DESIGN.md" + } + } }