From caed93231e7952c52789c5b42ff2aa6a8ae1b5be Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 16:57:03 -0700 Subject: [PATCH 1/6] feat(playground): inject DESIGN.md into local server system prompt Mirrors the api/agent.ts change so `bun run playground:full` agents see the design guide in dev, not just on Vercel. --- playground/server.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/playground/server.ts b/playground/server.ts index cd8748b..5acaa75 100644 --- a/playground/server.ts +++ b/playground/server.ts @@ -16,6 +16,22 @@ 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() + function loadEnvFile(file: string): void { if (!existsSync(file)) return for (const line of readFileSync(file, 'utf8').split('\n')) { @@ -150,7 +166,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 } From 0ed378bf45e7f77db0b5e85472c003da323eaf2b Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 17:09:53 -0700 Subject: [PATCH 2/6] feat(design): dark variant + playground theme switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DESIGN.md: add variants.dark palette (namespaced extension to the spec) - Generator: emit each variant as `.sui-theme-`; `dark` also wires `@media (prefers-color-scheme: dark) { :root:not(.sui-theme-light) }` so OS preference is honored unless explicitly overridden - Playground: 4-way switcher (System / Light / Dark / Heritage) with localStorage persistence. Heritage demonstrates runtime token overrides layered on top of class-based variants — proof that the same agent output reskins across three palettes with zero component changes. --- DESIGN.md | 13 ++++++++ playground/index.html | 14 ++++++-- playground/main.ts | 38 +++++++++++++++++++++ playground/style.css | 37 +++++++++++++++++++++ scripts/design-to-css.ts | 71 +++++++++++++++++++++++++++++----------- src/design-tokens.css | 28 ++++++++++++++++ 6 files changed, 180 insertions(+), 21 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 7b431b8..d68c050 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -57,6 +57,19 @@ components: backgroundColor: "{colors.error}" textColor: "{colors.on-error}" rounded: "{rounded.md}" +variants: + dark: + colors: + primary: "#7BB0F0" + primary-hover: "#92BFF5" + neutral: "#A8A8A8" + success: "#66C67A" + warning: "#E8BA5A" + error: "#EB7A7A" + link: "#8FB4FF" + link-hover: "#B0C8FF" + on-primary: "#0B1116" + on-error: "#0B1116" --- ## Overview diff --git a/playground/index.html b/playground/index.html index 7a22f94..4978af0 100644 --- a/playground/index.html +++ b/playground/index.html @@ -8,8 +8,18 @@
-

stream-ui

-

Human → Agent → Streamed UI. Try: make a button, build a form, show a list, card.

+
+
+

stream-ui

+

Human → Agent → Streamed UI. Try: make a button, build a form, show a list, card.

+
+
+ + + + +
+
diff --git a/playground/main.ts b/playground/main.ts index 3c4bdc4..e315fa9 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -952,6 +952,44 @@ clearBtn?.addEventListener('click', () => { pushAI('Agent ready.') }) +// ─── theme switcher ─────────────────────────────────────────────── +// Demonstrates DESIGN.md → CSS-var theming working end-to-end with zero +// code changes to components. Heritage overrides a handful of semantic +// tokens inline; system/light/dark toggle the `.sui-theme-*` class that +// the generator emits alongside the :root block. +type ThemeKey = 'system' | 'light' | 'dark' | 'heritage' +const HERITAGE_OVERRIDES: Record = { + '--sui-colors-primary': '#b8422e', + '--sui-colors-primary-hover': '#cb4f37', + '--sui-colors-link': '#b8422e', + '--sui-colors-link-hover': '#cb4f37', + '--sui-colors-on-primary': '#f7f5f2', + '--sui-components-button-primary-background-color': '#b8422e', + '--sui-components-button-primary-text-color': '#f7f5f2', + '--sui-components-button-primary-hover-background-color': '#cb4f37', +} + +function applyTheme(theme: ThemeKey): void { + const html = document.documentElement + html.classList.remove('sui-theme-light', 'sui-theme-dark', 'sui-theme-heritage') + for (const prop of Object.keys(HERITAGE_OVERRIDES)) html.style.removeProperty(prop) + if (theme !== 'system') html.classList.add(`sui-theme-${theme}`) + if (theme === 'heritage') { + for (const [k, v] of Object.entries(HERITAGE_OVERRIDES)) html.style.setProperty(k, v) + } + for (const btn of document.querySelectorAll('#theme-switcher button')) { + btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false') + } + localStorage.setItem('sui-theme', theme) +} + +const savedTheme = (localStorage.getItem('sui-theme') as ThemeKey | null) ?? 'system' +applyTheme(savedTheme) +document.getElementById('theme-switcher')?.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('button[data-theme]') as HTMLButtonElement | null + if (btn) applyTheme(btn.dataset.theme as ThemeKey) +}) + // Initial state render( { diff --git a/playground/style.css b/playground/style.css index 8992916..2b538a5 100644 --- a/playground/style.css +++ b/playground/style.css @@ -21,6 +21,43 @@ header { flex: 0 0 auto; } +.header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.theme-switcher { + display: flex; + gap: 0.25rem; + padding: 0.25rem; + border-radius: 0.5rem; + background: rgba(127, 127, 127, 0.12); + flex: 0 0 auto; +} + +.theme-switcher button { + font: inherit; + font-size: 0.8rem; + padding: 0.25rem 0.6rem; + background: transparent; + color: inherit; + border: 1px solid transparent; + border-radius: 0.375rem; + cursor: pointer; +} + +.theme-switcher button[aria-pressed="true"] { + background: var(--sui-colors-primary, rgb(80, 140, 220)); + color: var(--sui-colors-on-primary, white); + border-color: var(--sui-colors-primary, rgb(80, 140, 220)); +} + +.theme-switcher button:hover:not([aria-pressed="true"]) { + background: rgba(127, 127, 127, 0.2); +} + h1 { margin: 0 0 0.25rem; font-size: 1.5rem; diff --git a/scripts/design-to-css.ts b/scripts/design-to-css.ts index 36b5535..326fdf4 100644 --- a/scripts/design-to-css.ts +++ b/scripts/design-to-css.ts @@ -2,6 +2,11 @@ /** * Reads DESIGN.md front matter and emits src/design-tokens.css. * Run via `bun run tokens`. Also invoked by `build` and `playground`. + * + * Supports a `variants..` section for theme variants. `dark` is + * emitted as both `@media (prefers-color-scheme: dark)` and an opt-in + * `.sui-theme-` class; any other variant name is emitted only as the + * class, for explicit theme switching. */ import { readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -40,34 +45,62 @@ 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', 'components'] as const + +function collectVars(source: Record): string[] { + const out: string[] = [] + 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') { + out.push(`${varName}: ${normalizeValue(resolveRef(rawVal))};`) + } else if (typeof rawVal === 'number') { + out.push(`${varName}: ${rawVal};`) + } else if (rawVal && typeof rawVal === 'object' && !Array.isArray(rawVal)) { + emit(rawVal as Record, `${prefix}-${key}`) + } + } + } + for (const group of TOKEN_GROUPS) { + const section = source[group] + if (section && typeof section === 'object') { + 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. */', - ':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}`) - } - } -} +const rootVars = collectVars(tokens) +lines.push(':root {', ...rootVars.map((v) => ` ${v}`), '}') -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)) +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('') writeFileSync(OUT, lines.join('\n')) console.log(`[design-to-css] wrote ${OUT}`) diff --git a/src/design-tokens.css b/src/design-tokens.css index f819369..3f20774 100644 --- a/src/design-tokens.css +++ b/src/design-tokens.css @@ -43,3 +43,31 @@ --sui-components-button-danger-text-color: var(--sui-colors-on-error); --sui-components-button-danger-rounded: var(--sui-rounded-md); } + +.sui-theme-dark { + --sui-colors-primary: #7bb0f0; + --sui-colors-primary-hover: #92bff5; + --sui-colors-neutral: #a8a8a8; + --sui-colors-success: #66c67a; + --sui-colors-warning: #e8ba5a; + --sui-colors-error: #eb7a7a; + --sui-colors-link: #8fb4ff; + --sui-colors-link-hover: #b0c8ff; + --sui-colors-on-primary: #0b1116; + --sui-colors-on-error: #0b1116; +} + +@media (prefers-color-scheme: dark) { + :root:not(.sui-theme-light) { + --sui-colors-primary: #7bb0f0; + --sui-colors-primary-hover: #92bff5; + --sui-colors-neutral: #a8a8a8; + --sui-colors-success: #66c67a; + --sui-colors-warning: #e8ba5a; + --sui-colors-error: #eb7a7a; + --sui-colors-link: #8fb4ff; + --sui-colors-link-hover: #b0c8ff; + --sui-colors-on-primary: #0b1116; + --sui-colors-on-error: #0b1116; + } +} From 237d960e227e414f9e28e7b3114980acf0df16dc Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 17:18:09 -0700 Subject: [PATCH 3/6] feat(design): wire alerts, badges, focus rings, progress to tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every themable component in styles.css now reads a token (with rgba fallback) instead of hardcoded rgb. Uses color-mix(in srgb, ..., transparent) for tint/alpha so the same hex can drive both the border and a softened background without duplicating tokens. Effect: switching the theme switcher now retouches alerts (info/success/ warning/error), badges, input focus ring, progress bar fill, and input- error states — not just buttons and links. --- playground/style.css | 3 ++- src/styles.css | 41 +++++++++++++++++++++-------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/playground/style.css b/playground/style.css index 2b538a5..9f5730f 100644 --- a/playground/style.css +++ b/playground/style.css @@ -245,7 +245,8 @@ body.resizing-col { } .chat-input input:focus { - outline: 2px solid rgba(100, 150, 255, 0.5); + outline: 2px solid + color-mix(in srgb, var(--sui-colors-primary, rgb(100, 150, 255)) 50%, transparent); outline-offset: -1px; } diff --git a/src/styles.css b/src/styles.css index 8854960..bc9ddff 100644 --- a/src/styles.css +++ b/src/styles.css @@ -129,20 +129,20 @@ font-size: 0.9rem; } .sui-alert-info { - background: rgba(80, 140, 220, 0.15); - border-left-color: rgb(80, 140, 220); + background: color-mix(in srgb, var(--sui-colors-primary, rgb(80, 140, 220)) 15%, transparent); + border-left-color: var(--sui-colors-primary, rgb(80, 140, 220)); } .sui-alert-success { - background: rgba(80, 180, 100, 0.15); - border-left-color: rgb(80, 180, 100); + background: color-mix(in srgb, var(--sui-colors-success, rgb(80, 180, 100)) 15%, transparent); + border-left-color: var(--sui-colors-success, rgb(80, 180, 100)); } .sui-alert-warning { - background: rgba(220, 170, 70, 0.18); - border-left-color: rgb(220, 170, 70); + background: color-mix(in srgb, var(--sui-colors-warning, rgb(220, 170, 70)) 18%, transparent); + border-left-color: var(--sui-colors-warning, rgb(220, 170, 70)); } .sui-alert-error { - background: rgba(220, 90, 90, 0.18); - border-left-color: rgb(220, 90, 90); + background: color-mix(in srgb, var(--sui-colors-error, rgb(220, 90, 90)) 18%, transparent); + border-left-color: var(--sui-colors-error, rgb(220, 90, 90)); } .sui-badge { @@ -155,16 +155,16 @@ align-self: flex-start; } .sui-badge-success { - background: rgba(80, 180, 100, 0.25); - color: rgb(80, 180, 100); + background: color-mix(in srgb, var(--sui-colors-success, rgb(80, 180, 100)) 25%, transparent); + color: var(--sui-colors-success, rgb(80, 180, 100)); } .sui-badge-warning { - background: rgba(220, 170, 70, 0.25); - color: rgb(220, 170, 70); + background: color-mix(in srgb, var(--sui-colors-warning, rgb(220, 170, 70)) 25%, transparent); + color: var(--sui-colors-warning, rgb(220, 170, 70)); } .sui-badge-error { - background: rgba(220, 90, 90, 0.25); - color: rgb(220, 90, 90); + background: color-mix(in srgb, var(--sui-colors-error, rgb(220, 90, 90)) 25%, transparent); + color: var(--sui-colors-error, rgb(220, 90, 90)); } .sui-spinner { @@ -206,7 +206,7 @@ .sui-progress-fill { height: 100%; - background: rgb(100, 150, 255); + background: var(--sui-colors-primary, rgb(100, 150, 255)); border-radius: 999px; transition: width 0.3s ease; } @@ -269,7 +269,8 @@ .sui-input:focus, .sui-textarea:focus, .sui-select:focus { - outline: 2px solid rgba(100, 150, 255, 0.5); + outline: 2px solid + color-mix(in srgb, var(--sui-colors-primary, rgb(100, 150, 255)) 50%, transparent); outline-offset: -1px; } @@ -360,22 +361,22 @@ .sui-unknown { padding: 0.5rem 0.75rem; - border: 1px dashed rgba(220, 90, 90, 0.5); + border: 1px dashed color-mix(in srgb, var(--sui-colors-error, rgb(220, 90, 90)) 50%, transparent); border-radius: 0.5rem; font-family: ui-monospace, monospace; font-size: 0.8rem; - color: rgb(220, 90, 90); + color: var(--sui-colors-error, rgb(220, 90, 90)); } /* ─── validation errors ────────────────────────────────────────── */ .sui-input[aria-invalid="true"], .sui-textarea[aria-invalid="true"] { - border-color: rgb(220, 90, 90); + border-color: var(--sui-colors-error, rgb(220, 90, 90)); } .sui-input-error { - color: rgb(220, 90, 90); + color: var(--sui-colors-error, rgb(220, 90, 90)); font-size: 0.85em; margin-top: 0.25em; display: block; From b6c8dec96a7d93e95e84194a7015f4493fd8ab94 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 17:30:19 -0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(design):=20motion=20tokens=20=E2=80=94?= =?UTF-8?q?=20duration=20+=20easing,=20wired=20through=20button/link/progr?= =?UTF-8?q?ess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DESIGN.md: new `motion:` section (duration.fast/base/slow + easing.standard/ emphasized) and matching prose on when to use each duration. Framework is agent-cadence so transitions lean short and precise — no bounce, no overshoot. - Generator: add `motion` to TOKEN_GROUPS so the recursive emitter walks it. - styles.css: button hover/focus color tweens at duration.fast; link color at duration.fast; progress fill at duration.base, all via easing.standard (with hardcoded fallbacks so consumers who skip the generator still look right). Result: buttons and links have a distinct hover tween instead of hard-flipping, progress bars ease into new values. Agents can now reference `{motion.duration.fast}` in custom component tokens alongside colors/spacing. --- DESIGN.md | 24 ++++++++++++++++++++++++ scripts/design-to-css.ts | 2 +- src/design-tokens.css | 5 +++++ src/styles.css | 12 +++++++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d68c050..ef622c3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -46,6 +46,14 @@ spacing: sm: 0.5rem md: 0.75rem lg: 1.5rem +motion: + duration: + fast: 120ms + base: 200ms + slow: 320ms + easing: + standard: "cubic-bezier(0.2, 0, 0, 1)" + emphasized: "cubic-bezier(0.3, 0, 0, 1)" components: button-primary: backgroundColor: "{colors.primary}" @@ -106,6 +114,22 @@ than custom CSS. Three rounded levels: `sm` (inline tags, code blocks), `md` (cards, buttons, inputs), and `pill` (badges, progress bars). +## Motion + +Stream-UI renders the majority of its UI at agent-cadence — values change in +bursts, not continuously — so transitions should feel reactive, not decorative. + +- **duration.fast (120ms)** — local hover/focus shifts; color/background tweens. +- **duration.base (200ms)** — progress bar fills, state changes on a single + component (e.g. button press). +- **duration.slow (320ms)** — structural changes (layout reflow, dialog + enter/exit). Avoid beyond this; the agent's own streaming already provides + the perceived motion. + +Use `easing.standard` for general acceleration; `emphasized` for first-time +reveals (enter animations). No bounce, no overshoot — streaming UI should feel +precise, not playful. + ## Components Component tokens map to stream-ui's built-in kinds. Variants (hover, pressed, diff --git a/scripts/design-to-css.ts b/scripts/design-to-css.ts index 326fdf4..2a6344f 100644 --- a/scripts/design-to-css.ts +++ b/scripts/design-to-css.ts @@ -45,7 +45,7 @@ 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', 'components'] as const +const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'motion', 'components'] as const function collectVars(source: Record): string[] { const out: string[] = [] diff --git a/src/design-tokens.css b/src/design-tokens.css index 3f20774..be29dd7 100644 --- a/src/design-tokens.css +++ b/src/design-tokens.css @@ -35,6 +35,11 @@ --sui-spacing-sm: 0.5rem; --sui-spacing-md: 0.75rem; --sui-spacing-lg: 1.5rem; + --sui-motion-duration-fast: 120ms; + --sui-motion-duration-base: 200ms; + --sui-motion-duration-slow: 320ms; + --sui-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1); + --sui-motion-easing-emphasized: cubic-bezier(0.3, 0, 0, 1); --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); diff --git a/src/styles.css b/src/styles.css index bc9ddff..d11f3f5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -208,7 +208,8 @@ height: 100%; background: var(--sui-colors-primary, rgb(100, 150, 255)); border-radius: 999px; - transition: width 0.3s ease; + transition: width var(--sui-motion-duration-base, 200ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)); } .sui-progress-label { @@ -313,6 +314,13 @@ border-radius: 0.5rem; cursor: pointer; align-self: flex-start; + transition: + background-color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)), + border-color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)), + color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)); } .sui-button:hover, @@ -351,6 +359,8 @@ color: var(--sui-colors-link, rgb(100, 150, 255)); text-decoration: underline; text-underline-offset: 0.15em; + transition: color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)); } .sui-link:hover { From bfee617b47de8be1cc9db506c9101152074eddd8 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 17:41:26 -0700 Subject: [PATCH 5/6] feat(design): wire @google/design.md linter + generator regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json: `lint:design` runs upstream `@google/design.md lint DESIGN.md` (WCAG contrast + cross-reference checks). `diff:design` reports token-level changes between two DESIGN.md files — useful for PR reviews. - vitest.config.ts: pick up scripts/**/*.test.ts so generator tests run. - scripts/design-to-css.test.ts: pins the contract between DESIGN.md and the downstream CSS. Runs the generator in-process and asserts :root has every semantic token styles.css consumes, motion tokens exist, token references resolve to var(), dark variant emits both class + media query, and hex is lowercased (biome CSS rule). First lint:design run already surfaced real findings — contrast warnings on button-primary (3.43:1) and button-danger (3.71:1) vs white text (AA needs 4.5:1). Left as-is intentionally; darkening the palette is a design call. --- package.json | 2 ++ scripts/design-to-css.test.ts | 68 +++++++++++++++++++++++++++++++++++ vitest.config.ts | 7 +++- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 scripts/design-to-css.test.ts diff --git a/package.json b/package.json index 0b672c9..39713e2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ ], "scripts": { "tokens": "bun run scripts/design-to-css.ts", + "lint:design": "bunx @google/design.md@latest lint DESIGN.md", + "diff:design": "bunx @google/design.md@latest diff", "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", diff --git a/scripts/design-to-css.test.ts b/scripts/design-to-css.test.ts new file mode 100644 index 0000000..895812e --- /dev/null +++ b/scripts/design-to-css.test.ts @@ -0,0 +1,68 @@ +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 } +} + +describe('design-to-css generator', () => { + // The generated file is the contract between DESIGN.md and every consumer + // (styles.css, playground, agent-produced specs). These checks pin down the + // 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. + 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:', + '--sui-colors-primary-hover:', + '--sui-colors-success:', + '--sui-colors-warning:', + '--sui-colors-error:', + '--sui-colors-link:', + '--sui-colors-on-primary:', + ]) { + expect(out).toContain(name) + } + }) + + 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:', + '--sui-motion-easing-standard:', + ]) { + expect(out).toContain(name) + } + }) + + 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/vitest.config.ts b/vitest.config.ts index 5bf67a1..e43333a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,11 @@ export default defineConfig({ root: '.', test: { environment: 'happy-dom', - include: ['src/**/*.test.ts', 'playground/**/*.test.ts', 'api/**/*.test.ts'], + include: [ + 'src/**/*.test.ts', + 'playground/**/*.test.ts', + 'api/**/*.test.ts', + 'scripts/**/*.test.ts', + ], }, }) From b7182a2b73fd2a85dfa1a38b0c4ff90ddabc3215 Mon Sep 17 00:00:00 2001 From: Eric Baruch Date: Wed, 22 Apr 2026 17:49:57 -0700 Subject: [PATCH 6/6] feat(design): AA contrast + component refs + CI gate for DESIGN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix contrast (light palette): - primary #508cdc → #1f6fc7 (3.43 → 5.11 on white, passes WCAG AA) - error #dc5a5a → #c43d3d (3.71 → 4.75, passes AA) - link #6496ff → #2f6fc7 (aligned with primary, readable on light/dark) - neutral darkened slightly for consistency with the shifted palette Dark palette unchanged — lighter tones already clear AA on dark ink. Reference every color in components: (drops all "unreferenced" warnings): - alert-{info,success,warning,error}.backgroundColor → {colors.*} - badge-default.backgroundColor → {colors.neutral} - link / link-hover.textColor → {colors.link[-hover]} CI gate: - scripts/lint-design.ts: runs @google/design.md lint --format=json, prints a readable summary, exits non-zero on structural errors. Warnings are logged but do not block merges yet (raise the bar when the warning surface is clean beyond what's left). - package.json lint:design points at the new script. - .github/workflows/ci.yml runs lint:design after build. Linter now reports 0 errors / 0 warnings. --- .github/workflows/ci.yml | 7 +++++ DESIGN.md | 30 ++++++++++++++------ package.json | 2 +- scripts/lint-design.ts | 59 ++++++++++++++++++++++++++++++++++++++++ src/design-tokens.css | 23 ++++++++++------ 5 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 scripts/lint-design.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8163855..79b978d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,10 @@ jobs: - name: Build run: bun run build + + # DESIGN.md validation. Fails on structural errors only; warnings + # (e.g. contrast below AA, unused tokens) are visible in the job log + # but don't block merges. Tighten to `--fail-on warning` once the + # team has cleaned the warning surface. + - name: Lint DESIGN.md + run: bun run lint:design diff --git a/DESIGN.md b/DESIGN.md index ef622c3..64d92c4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,14 +3,14 @@ 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" + primary: "#1F6FC7" + primary-hover: "#2C7DD4" + neutral: "#6B7280" + success: "#2E8F48" + warning: "#B17A20" + error: "#C43D3D" + link: "#2F6FC7" + link-hover: "#1F5FB5" on-primary: "#FFFFFF" on-error: "#FFFFFF" typography: @@ -65,6 +65,20 @@ components: backgroundColor: "{colors.error}" textColor: "{colors.on-error}" rounded: "{rounded.md}" + alert-info: + backgroundColor: "{colors.primary}" + alert-success: + backgroundColor: "{colors.success}" + alert-warning: + backgroundColor: "{colors.warning}" + alert-error: + backgroundColor: "{colors.error}" + badge-default: + backgroundColor: "{colors.neutral}" + link: + textColor: "{colors.link}" + link-hover: + textColor: "{colors.link-hover}" variants: dark: colors: diff --git a/package.json b/package.json index 39713e2..2c0d9d9 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ ], "scripts": { "tokens": "bun run scripts/design-to-css.ts", - "lint:design": "bunx @google/design.md@latest lint DESIGN.md", + "lint:design": "bun run scripts/lint-design.ts", "diff:design": "bunx @google/design.md@latest diff", "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\"", diff --git a/scripts/lint-design.ts b/scripts/lint-design.ts new file mode 100644 index 0000000..5a2fbb6 --- /dev/null +++ b/scripts/lint-design.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +/** + * Runs @google/design.md lint on DESIGN.md, prints a readable summary, + * and exits non-zero when structural errors are present. + * + * Warnings are surfaced but do NOT fail the build — they typically + * represent design-quality findings (contrast, unused tokens) that are + * better treated as review signals than merge gates. Tighten to fail + * on warnings when the team has cleaned the backlog. + */ +import { spawnSync } from 'node:child_process' + +type Finding = { severity: 'error' | 'warning' | 'info'; path?: string; message: string } +type Report = { + findings: Finding[] + summary: { errors: number; warnings: number; infos: number } +} + +const r = spawnSync('bunx', ['@google/design.md@latest', 'lint', '--format=json', 'DESIGN.md'], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'pipe'], +}) +if (r.error) { + console.error('[lint:design] failed to spawn:', r.error.message) + 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 `{`. +const raw = r.stdout.toString() +const start = raw.lastIndexOf('\n{') +const jsonText = start >= 0 ? raw.slice(start + 1) : raw +let report: Report +try { + report = JSON.parse(jsonText) as Report +} catch { + console.error('[lint:design] could not parse lint output:') + console.error(raw) + process.exit(2) +} + +const { findings, summary } = report +const byKind = { error: [] as Finding[], warning: [] as Finding[], info: [] as Finding[] } +for (const f of findings) byKind[f.severity]?.push(f) + +const fmt = (f: Finding) => ` [${f.severity}]${f.path ? ` ${f.path}` : ''} — ${f.message}` + +if (byKind.error.length) console.log(`Errors:\n${byKind.error.map(fmt).join('\n')}`) +if (byKind.warning.length) console.log(`Warnings:\n${byKind.warning.map(fmt).join('\n')}`) +if (byKind.info.length) console.log(`Info:\n${byKind.info.map(fmt).join('\n')}`) + +console.log( + `\nSummary: ${summary.errors} error(s), ${summary.warnings} warning(s), ${summary.infos} info.`, +) + +if (summary.errors > 0) { + console.error('\n[lint:design] structural errors — failing build.') + process.exit(1) +} diff --git a/src/design-tokens.css b/src/design-tokens.css index be29dd7..79156c7 100644 --- a/src/design-tokens.css +++ b/src/design-tokens.css @@ -1,14 +1,14 @@ /* 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-primary: #1f6fc7; + --sui-colors-primary-hover: #2c7dd4; + --sui-colors-neutral: #6b7280; + --sui-colors-success: #2e8f48; + --sui-colors-warning: #b17a20; + --sui-colors-error: #c43d3d; + --sui-colors-link: #2f6fc7; + --sui-colors-link-hover: #1f5fb5; --sui-colors-on-primary: #ffffff; --sui-colors-on-error: #ffffff; --sui-typography-h1-font-family: inherit; @@ -47,6 +47,13 @@ --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); + --sui-components-alert-info-background-color: var(--sui-colors-primary); + --sui-components-alert-success-background-color: var(--sui-colors-success); + --sui-components-alert-warning-background-color: var(--sui-colors-warning); + --sui-components-alert-error-background-color: var(--sui-colors-error); + --sui-components-badge-default-background-color: var(--sui-colors-neutral); + --sui-components-link-text-color: var(--sui-colors-link); + --sui-components-link-hover-text-color: var(--sui-colors-link-hover); } .sui-theme-dark {