Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 37 additions & 1 deletion api/agent.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down Expand Up @@ -101,7 +124,20 @@ Guidelines:
7. If the latest user message is "[form submit: <name>] key="value" ...", the user
submitted form <name>. Acknowledge or advance — e.g. render_ui a success card.
8. If the latest user message is "[button clicked: <action>]", 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 }
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions playground/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../src/design-tokens.css'
import '../src/styles.css'
import {
type ActionEvent,
Expand Down
20 changes: 20 additions & 0 deletions scripts/build-styles.ts
Original file line number Diff line number Diff line change
@@ -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')
73 changes: 73 additions & 0 deletions scripts/design-to-css.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>

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<string, unknown>, 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<string, unknown>, `${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<string, unknown>, kebab(group))
}
}

lines.push('}', '')

writeFileSync(OUT, lines.join('\n'))
console.log(`[design-to-css] wrote ${OUT}`)
45 changes: 45 additions & 0 deletions src/design-tokens.css
Original file line number Diff line number Diff line change
@@ -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);
}
20 changes: 11 additions & 9 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -325,33 +325,35 @@
}

.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 {
background: rgb(235, 105, 105);
}

.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 ────────────────────────────────────── */
Expand Down
Loading
Loading