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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 59 additions & 8 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
Expand All @@ -57,6 +65,33 @@ 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:
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
Expand Down Expand Up @@ -93,6 +128,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,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
],
"scripts": {
"tokens": "bun run scripts/design-to-css.ts",
"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\"",
"playground": "bun run tokens && vite",
Expand Down
37 changes: 36 additions & 1 deletion playground/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
render,
VERSION,
} from '../src/index'
import { type LayoutPreset, type ResizerPair, readSettings, writeSettings } from './settings'
import {
type LayoutPreset,
type ResizerPair,
readSettings,
type ThemePreset,
writeSettings,
} from './settings'
import { mountSettingsPopover } from './settings-ui'

// ─── Message state and localStorage ─────────────────────────────────────
Expand Down Expand Up @@ -291,11 +297,40 @@ async function doLogout(): Promise<void> {
window.location.reload()
}

// ─── theme ───────────────────────────────────────────────────────────────
// Demonstrates DESIGN.md → CSS-var theming 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.
const HERITAGE_OVERRIDES: Record<string, string> = {
'--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: ThemePreset): 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)
}
}

applyTheme(readSettings().theme)

const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement | null
const settingsPopover = document.getElementById('settings-popover') as HTMLDivElement | null
if (settingsBtn && settingsPopover && grid) {
mountSettingsPopover(settingsBtn, settingsPopover, {
onLayoutChange: () => applyLayout(grid),
onThemeChange: (t) => applyTheme(t),
onLogout: () => doLogout(),
})
}
Expand Down
31 changes: 30 additions & 1 deletion playground/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -150,7 +166,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
33 changes: 33 additions & 0 deletions playground/settings-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import {
type LayoutPreset,
MODEL_PRESETS,
readSettings,
THEME_PRESETS,
type ThemePreset,
writeSettings,
} from './settings'

export type PopoverCallbacks = {
onLayoutChange: () => void
onThemeChange: (theme: ThemePreset) => void
onLogout: () => void
}

Expand All @@ -18,6 +21,13 @@ const PRESET_LABELS: Record<LayoutPreset, string> = {
stacked: 'Stacked',
}

const THEME_LABELS: Record<ThemePreset, string> = {
system: 'System',
light: 'Light',
dark: 'Dark',
heritage: 'Heritage',
}

export function mountSettingsPopover(
btn: HTMLButtonElement,
popover: HTMLDivElement,
Expand Down Expand Up @@ -57,6 +67,7 @@ export function mountSettingsPopover(
const s = readSettings()
const isCustomModel = !MODEL_PRESETS.includes(s.model)
popover.replaceChildren(
section('Theme', [themeRow(s.theme)]),
section('Model', [
modelSelect(s.model, isCustomModel),
customModelInput(s.model, isCustomModel),
Expand All @@ -66,6 +77,28 @@ export function mountSettingsPopover(
)
}

function themeRow(current: ThemePreset): 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) {
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()
})
wrap.appendChild(btn)
}
return wrap
}

function section(title: string, children: HTMLElement[]): HTMLElement {
const wrap = document.createElement('div')
wrap.className = 'settings-section'
Expand Down
14 changes: 14 additions & 0 deletions playground/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,30 @@ export const MODEL_PRESETS: ReadonlyArray<string> = [
export type LayoutPreset = 'default' | 'sideBySide' | 'stacked'
export const LAYOUT_PRESETS: ReadonlyArray<LayoutPreset> = ['default', 'sideBySide', 'stacked']

export type ThemePreset = 'system' | 'light' | 'dark' | 'heritage'
export const THEME_PRESETS: ReadonlyArray<ThemePreset> = ['system', 'light', 'dark', 'heritage']

export type ResizerPair = 'chat-ai' | 'ai-ui' | 'top-bottom'
export type SizeMap = Partial<Record<ResizerPair, number>>

export type SuiSettings = {
model: string
theme: ThemePreset
layout: LayoutPreset
hideAI: boolean
sizes: Record<LayoutPreset, SizeMap>
}

const KEY_MODEL = 'sui.model'
const KEY_THEME = 'sui.theme'
const KEY_LAYOUT = 'sui.layout.preset'
const KEY_HIDE_AI = 'sui.layout.hideAI'
const sizesKey = (p: LayoutPreset) => `sui.layout.sizes.${p}`

function isThemePreset(v: unknown): v is ThemePreset {
return v === 'system' || v === 'light' || v === 'dark' || v === 'heritage'
}

function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key)
Expand Down Expand Up @@ -64,8 +73,11 @@ export function readSettings(): SuiSettings {
rawLayout === 'default' || rawLayout === 'sideBySide' || rawLayout === 'stacked'
? rawLayout
: 'default'
const rawTheme = getItem(KEY_THEME)
const theme: ThemePreset = isThemePreset(rawTheme) ? rawTheme : 'system'
return {
model: getItem(KEY_MODEL) ?? DEFAULT_MODEL,
theme,
layout,
hideAI: getItem(KEY_HIDE_AI) === 'true',
sizes: {
Expand All @@ -78,6 +90,7 @@ export function readSettings(): SuiSettings {

export type SettingsPatch = Partial<{
model: string
theme: ThemePreset
layout: LayoutPreset
hideAI: boolean
sizes: Partial<Record<LayoutPreset, SizeMap>>
Expand All @@ -86,6 +99,7 @@ export type SettingsPatch = Partial<{
export function writeSettings(patch: SettingsPatch): void {
try {
if (patch.model !== undefined) localStorage.setItem(KEY_MODEL, patch.model)
if (patch.theme !== undefined) localStorage.setItem(KEY_THEME, patch.theme)
if (patch.layout !== undefined) localStorage.setItem(KEY_LAYOUT, patch.layout)
if (patch.hideAI !== undefined) localStorage.setItem(KEY_HIDE_AI, String(patch.hideAI))
if (patch.sizes !== undefined) {
Expand Down
28 changes: 27 additions & 1 deletion playground/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ header {
background: rgba(220, 60, 60, 0.25);
}

.settings-theme-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.settings-theme-btn {
flex: 1 0 auto;
padding: 0.35rem 0.5rem;
border: 1px solid rgba(127, 127, 127, 0.3);
background: transparent;
color: inherit;
border-radius: 0.35rem;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.settings-theme-btn[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));
}
.settings-theme-btn:hover:not([aria-pressed="true"]) {
background: rgba(127, 127, 127, 0.15);
}

h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
Expand Down Expand Up @@ -412,7 +437,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;
}

Expand Down
Loading
Loading