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
78 changes: 78 additions & 0 deletions .github/workflows/design-diff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: DESIGN.md diff

# Runs on PRs that touch DESIGN.md and posts a token-level delta as a
# comment so reviewers can see added/removed/modified tokens without
# reading a YAML diff by hand. Uses the upstream `@google/design.md diff`
# command, which understands token paths and reports a structured report.

on:
pull_request:
paths:
- DESIGN.md

permissions:
pull-requests: write
contents: read

jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Extract base DESIGN.md
run: git show "origin/${{ github.base_ref }}:DESIGN.md" > /tmp/DESIGN.base.md

- name: Run design.md diff
id: diff
run: |
bunx @google/design.md@latest diff /tmp/DESIGN.base.md DESIGN.md --format=json > /tmp/diff.json || true
echo 'report<<__END__' >> "$GITHUB_OUTPUT"
cat /tmp/diff.json >> "$GITHUB_OUTPUT"
echo '__END__' >> "$GITHUB_OUTPUT"

- name: Comment token delta
uses: actions/github-script@v7
with:
script: |
const fs = require('fs')
const raw = fs.readFileSync('/tmp/diff.json', 'utf8')
let report
try { report = JSON.parse(raw) } catch { report = null }
if (!report) {
console.log('No parseable diff output; skipping comment.')
return
}
const t = report.tokens ?? {}
const rows = []
for (const group of ['colors', 'typography', 'rounded', 'spacing', 'motion', 'components']) {
const g = t[group]
if (!g) continue
const added = g.added ?? []
const removed = g.removed ?? []
const modified = g.modified ?? []
if (!added.length && !removed.length && !modified.length) continue
rows.push(`**${group}** — +${added.length} added, -${removed.length} removed, ~${modified.length} modified`)
if (added.length) rows.push(` added: \`${added.join('`, `')}\``)
if (removed.length) rows.push(` removed: \`${removed.join('`, `')}\``)
if (modified.length) rows.push(` modified: \`${modified.join('`, `')}\``)
}
const regression = report.regression === true ? '⚠️ **regression detected**' : ''
const body = rows.length
? `### DESIGN.md token delta\n\n${regression}\n\n${rows.join('\n')}\n\n<details><summary>Raw JSON</summary>\n\n\`\`\`json\n${JSON.stringify(report, null, 2)}\n\`\`\`\n\n</details>`
: '### DESIGN.md token delta\n\nNo token-level changes detected (prose-only edits).'
const { owner, repo } = context.repo
const issue_number = context.payload.pull_request.number
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number })
const existing = comments.find(c => c.user?.type === 'Bot' && c.body?.startsWith('### DESIGN.md token delta'))
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body })
}
58 changes: 58 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ components:
textColor: "{colors.link}"
link-hover:
textColor: "{colors.link-hover}"
voice:
formality: conversational
density: concise
capitalization: sentence-case
emoji: sparingly
errorTone: matter-of-fact
ctaStyle: short-verb
variants:
dark:
colors:
Expand Down Expand Up @@ -144,6 +151,35 @@ Use `easing.standard` for general acceleration; `emphasized` for first-time
reveals (enter animations). No bounce, no overshoot — streaming UI should feel
precise, not playful.

## Voice

Stream-UI is rendered by an agent, so copy is part of the design system.
Component labels, alert text, empty-state prose and button CTAs all inherit
the tokens below. These apply to every string the agent streams into a spec.

- **formality: conversational** — write like a helpful coworker, not a lawyer
and not a TV announcer. Contractions are fine. Second person ("you") is
welcome. Never "please click here" — respect the user's time.
- **density: concise** — one sentence where one sentence works. No preamble
("Here's a button for you!"), no restating the obvious. Agents often want
to narrate the spec they just emitted — don't; the UI shows itself.
- **capitalization: sentence-case** — `Save changes`, not `Save Changes`.
Applies to buttons, headings, table headers, menu items. Preserves proper
nouns and brand names as written.
- **emoji: sparingly** — reserved for status (✓ ✗ ⚠) or when the user's own
prompt establishes a playful tone. Never in error messages, never in CTAs.
- **errorTone: matter-of-fact** — "Couldn't save — name is required" beats
"Oops! Something went wrong 😔". State the cause, suggest the fix, no
apology. Reserve apologies for our failures, not the user's input.
- **ctaStyle: short-verb** — primary buttons use a single verb when possible
(`Save`, `Continue`, `Delete`), verb + object when not (`Add item`, not
`Add a new item to the list`). Cancel/dismiss buttons stay `Cancel` — not
`Nevermind`, not `Go back`, not `×`.

When a prompt is ambiguous between two voices, pick the one that matches
these tokens. When the user's prompt explicitly overrides (e.g. "make the
copy playful"), honor the prompt.

## Components

Component tokens map to stream-ui's built-in kinds. Variants (hover, pressed,
Expand All @@ -153,6 +189,28 @@ disabled) are expressed as sibling entries with a related key name —
Valid component properties in this theme: `backgroundColor`, `textColor`,
`rounded`.

## Custom components

stream-ui's `register(kind, renderer)` API lets consumers add domain-specific
components. Those renderers should honor the design system by reading the
CSS custom properties this file generates — there's no separate "register
with DESIGN.md" step. The pattern is just plain CSS:

```css
.my-custom-card {
background: color-mix(in srgb, var(--sui-colors-neutral) 8%, transparent);
border: 1px solid var(--sui-colors-neutral);
border-radius: var(--sui-rounded-md);
padding: var(--sui-spacing-sm);
}
.my-custom-card--urgent {
border-left: 3px solid var(--sui-colors-error);
}
```

Consumers get theme switching, dark mode, and runtime token overrides for
free. The `kanban-card` example in the playground uses exactly this pattern.

## Do's and Don'ts

- **Do** reference semantic tokens (`{colors.error}`) in derived themes instead
Expand Down
37 changes: 22 additions & 15 deletions playground/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -501,34 +501,41 @@ body.resizing-col {
font-style: italic;
}

/* ─── consumer-registered example: kanban-card ─────────────────────── */
/* ─── consumer-registered example: kanban-card ─────────────────────────
* Custom components registered via stream-ui's `register()` API can honor
* the design system by reading DESIGN.md tokens directly — no extra spec
* surface required. Consumers write plain CSS against `--sui-colors-*`
* / `--sui-rounded-*` / `--sui-spacing-*` vars and get theme switching,
* dark mode, and per-app overrides for free.
*/

.kanban-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
border: 1px solid rgba(127, 127, 127, 0.4);
gap: var(--sui-spacing-xs, 0.4rem);
border: 1px solid
color-mix(in srgb, var(--sui-colors-neutral, rgb(127, 127, 127)) 40%, transparent);
border-left: 3px solid;
border-radius: 0.5rem;
padding: 0.6rem 0.85rem;
background: rgba(127, 127, 127, 0.05);
border-radius: var(--sui-rounded-md, 0.5rem);
padding: var(--sui-spacing-sm, 0.6rem) var(--sui-spacing-md, 0.85rem);
background: color-mix(in srgb, var(--sui-colors-neutral, rgb(127, 127, 127)) 8%, transparent);
}

.kanban-todo {
border-left-color: rgb(180, 180, 180);
border-left-color: var(--sui-colors-neutral, rgb(180, 180, 180));
}
.kanban-doing {
border-left-color: rgb(220, 170, 70);
border-left-color: var(--sui-colors-warning, rgb(220, 170, 70));
}
.kanban-done {
border-left-color: rgb(80, 180, 100);
border-left-color: var(--sui-colors-success, rgb(80, 180, 100));
}

.kanban-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
gap: var(--sui-spacing-sm, 0.5rem);
}

.kanban-title {
Expand All @@ -540,18 +547,18 @@ body.resizing-col {
letter-spacing: 0.1em;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgba(127, 127, 127, 0.2);
border-radius: var(--sui-rounded-pill, 999px);
background: color-mix(in srgb, var(--sui-colors-neutral, rgb(127, 127, 127)) 20%, transparent);
}

.kanban-todo .kanban-status {
color: rgb(180, 180, 180);
color: var(--sui-colors-neutral, rgb(180, 180, 180));
}
.kanban-doing .kanban-status {
color: rgb(220, 170, 70);
color: var(--sui-colors-warning, rgb(220, 170, 70));
}
.kanban-done .kanban-status {
color: rgb(80, 180, 100);
color: var(--sui-colors-success, rgb(80, 180, 100));
}

.kanban-assignee {
Expand Down
16 changes: 9 additions & 7 deletions scripts/lint-design.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/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.
* and exits non-zero when errors OR warnings 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.
* Since the DESIGN.md baseline is currently at 0/0, any new warning is a
* regression — unused tokens, failing WCAG contrast, etc. Block those at
* PR time instead of letting them accumulate. If a warning becomes
* intentional (rare), fix the root cause or relax this script.
*/
import { spawnSync } from 'node:child_process'

Expand Down Expand Up @@ -53,7 +53,9 @@ 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.')
if (summary.errors > 0 || summary.warnings > 0) {
console.error(
`\n[lint:design] ${summary.errors} error(s) + ${summary.warnings} warning(s) — failing build.`,
)
process.exit(1)
}
Loading