diff --git a/.github/workflows/design-diff.yml b/.github/workflows/design-diff.yml new file mode 100644 index 0000000..4972a24 --- /dev/null +++ b/.github/workflows/design-diff.yml @@ -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
Raw JSON\n\n\`\`\`json\n${JSON.stringify(report, null, 2)}\n\`\`\`\n\n
` + : '### 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 }) + } diff --git a/DESIGN.md b/DESIGN.md index 64d92c4..dc4ae92 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: @@ -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, @@ -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 diff --git a/playground/style.css b/playground/style.css index e698fca..3be6e90 100644 --- a/playground/style.css +++ b/playground/style.css @@ -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 { @@ -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 { diff --git a/scripts/lint-design.ts b/scripts/lint-design.ts index 5a2fbb6..bd4e38c 100644 --- a/scripts/lint-design.ts +++ b/scripts/lint-design.ts @@ -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' @@ -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) }