diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 50ca1332c..af9fa4e7d 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -52,6 +52,10 @@ jobs: # Fails CI when docs-map.ts references non-existent repo files or # when website version / command snippets are stale. run: npm run check:docs + - name: Check design tokens + # app/tokens.css is generated from crates/tui/src/palette/tokens.rs. + # Fails CI when the palette moved and the export was not re-run. + run: npm run check:tokens - name: Run tests run: npm test - name: Run ESLint diff --git a/scripts/export-design-tokens.py b/scripts/export-design-tokens.py new file mode 100755 index 000000000..74507560f --- /dev/null +++ b/scripts/export-design-tokens.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Export the TUI whale palette to the other Codewhale clients. + +`crates/tui/src/palette/tokens.rs` is the single source for the whale colors. +This script parses its `WHALE_*_RGB` consts (aliases included) and writes the +same values as CSS custom properties so the web app stops hand-copying hexes. + +Target: /web/app/tokens.css. This script writes nothing outside this +repository. + +Usage: + scripts/export-design-tokens.py # write + scripts/export-design-tokens.py --check # exit 1 if any target is stale +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +TOKENS_RS = REPO / "crates/tui/src/palette/tokens.rs" +SOURCE_LABEL = "crates/tui/src/palette/tokens.rs" + +CONST_RE = re.compile( + r"^pub const (WHALE_[A-Z0-9_]+)_RGB: \(u8, u8, u8\) = " + r"(?:\((\d+), (\d+), (\d+)\)|(WHALE_[A-Z0-9_]+)_RGB);", + re.MULTILINE, +) + + + +def parse_tokens(text: str) -> list[tuple[str, tuple[int, int, int] | str]]: + """Return [(NAME, (r, g, b) | alias-NAME)] in source order.""" + tokens: list[tuple[str, tuple[int, int, int] | str]] = [] + known: set[str] = set() + for m in CONST_RE.finditer(text): + name = m.group(1) + if m.group(5) is not None: + target = m.group(5) + if target not in known: + raise SystemExit(f"{name} aliases unknown token {target}") + tokens.append((name, target)) + else: + tokens.append((name, (int(m.group(2)), int(m.group(3)), int(m.group(4))))) + known.add(name) + if not tokens: + raise SystemExit(f"no WHALE_*_RGB consts found in {TOKENS_RS}") + return tokens + + +def css_name(name: str) -> str: + return "--whale-" + name.removeprefix("WHALE_").lower().replace("_", "-") + + +def render_css(tokens) -> str: + lines = [ + f"/* generated from {SOURCE_LABEL} — do not edit */", + "/* regenerate: scripts/export-design-tokens.py (in the codewhale repo) */", + ":root {", + ] + for name, value in tokens: + prop = css_name(name) + if isinstance(value, str): + ref = css_name(value) + lines.append(f" {prop}: var({ref});") + lines.append(f" {prop}-rgb: var({ref}-rgb);") + else: + r, g, b = value + lines.append(f" {prop}: #{r:02x}{g:02x}{b:02x};") + lines.append(f" {prop}-rgb: {r} {g} {b};") + lines.append("}") + return "\n".join(lines) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--check", action="store_true", help="verify instead of write") + args = ap.parse_args() + + tokens = parse_tokens(TOKENS_RS.read_text(encoding="utf-8")) + css = render_css(tokens) + + targets: list[tuple[Path, str]] = [(REPO / "web/app/tokens.css", css)] + + stale = [] + for path, content in targets: + current = path.read_text(encoding="utf-8") if path.exists() else None + if current == content: + continue + if args.check: + stale.append(path) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"wrote {path}") + + if stale: + for path in stale: + print(f"stale: {path}", file=sys.stderr) + print( + "run scripts/export-design-tokens.py to regenerate from " + f"{SOURCE_LABEL}", + file=sys.stderr, + ) + return 1 + if args.check: + print(f"design tokens up to date ({len(targets)} file(s), {len(tokens)} tokens)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/app/globals.css b/web/app/globals.css index f7d2b0bcd..4418d35ea 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,3 +1,5 @@ +@import "./tokens.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -7,73 +9,74 @@ * The site is the deep-ocean stage the Tideline TUI redesign paints: one dark * field everywhere, with the whale palette's semantic inks on top. The token * NAMES below keep their historical shape (`--paper`, `--ink`, `--indigo`) so - * component rules and the docs light theme stay diffable, but the values are - * the WHALE_* dark tokens from crates/tui/src/palette/tokens.rs — the same - * field, chrome, and ink the product renders. + * component rules and the docs light theme stay diffable, but the values now + * resolve through ./tokens.css, generated from crates/tui/src/palette/tokens.rs + * by scripts/export-design-tokens.py — the same field, chrome, and ink the + * product renders, with no hand-copied hexes. */ :root { /* RGB channel triples backing the Tailwind surface/ink tokens (see tailwind.config.ts). The docs light theme overrides them for its opt-in light sheet. */ - --c-paper: 3 7 13; /* WHALE_BG — deep field */ - --c-paper-deep: 14 23 41; /* WHALE_PANEL */ - --c-paper-edge: 38 62 92; /* WHALE_BORDER — blue at 25% on stage */ - --c-ink: 246 242 232; /* WHALE_TEXT_BODY — whale ivory */ - --c-ink-soft: 182 192 212; /* WHALE_TEXT_SOFT */ - --c-ink-mute: 147 160 184; /* WHALE_TEXT_MUTED */ - --c-indigo: 106 174 242; /* WHALE_ACTION — interaction blue */ + --c-paper: var(--whale-bg-rgb); + --c-paper-deep: var(--whale-panel-rgb); + --c-paper-edge: var(--whale-border-rgb); + --c-ink: var(--whale-text-body-rgb); + --c-ink-soft: var(--whale-text-soft-rgb); + --c-ink-mute: var(--whale-text-muted-rgb); + --c-indigo: var(--whale-action-rgb); --c-indigo-deep: 143 196 248; /* lifted action blue — hover on dark */ - --c-stage-soft: 182 192 212; + --c-stage-soft: var(--whale-text-soft-rgb); - --paper: #03070d; - --paper-deep: #0e1729; - --paper-edge: #263e5c; + --paper: var(--whale-bg); + --paper-deep: var(--whale-panel); + --paper-edge: var(--whale-border); /* Cards are the TUI's panel plate resting on the deep field. */ --paper-card: #101d33; - --paper-line: #263e5c; - --paper-line-soft: #263e5c; - --ink: #f6f2e8; - --ink-soft: #b6c0d4; - --ink-mute: #93a0b8; - --indigo: #6aaef2; + --paper-line: var(--whale-border); + --paper-line-soft: var(--whale-border); + --ink: var(--whale-text-body); + --ink-soft: var(--whale-text-soft); + --ink-mute: var(--whale-text-muted); + --indigo: var(--whale-action); --indigo-deep: #8fc4f8; - --indigo-pale: rgba(106, 174, 242, 0.16); - --action-on-dark: #6aaef2; + --indigo-pale: rgb(var(--whale-action-rgb) / 0.16); + --action-on-dark: var(--whale-action); /* Semantic state inks, straight from the status-bar grammar: green is outcome, coral is cognition/attention, gold is the human mark. */ - --ochre: #f6c453; - --jade: #9bd66f; - --seafoam: #4fd1c5; - --cobalt: #6aaef2; - --cyan: #48d7ff; /* bounded accents only — eyebrow chrome, composer prompt */ + --ochre: var(--whale-human); + --jade: var(--whale-working-green); + --seafoam: var(--whale-accent-secondary); + --cobalt: var(--whale-action); + --cyan: var(--whale-cyan); /* bounded accents only — eyebrow chrome, composer prompt */ --settings-state-active: var(--indigo); --settings-state-ready: var(--jade); --settings-state-muted: var(--ink-mute); - --ocean-deep: #03070d; - --ocean-mid: #0e1729; - --ocean-current: #d1ebf4; - --ocean-mist: #b6c0d4; - --ocean-coral: #ff7a59; - --signal-gold: #f6c453; + --ocean-deep: var(--whale-bg); + --ocean-mid: var(--whale-panel); + --ocean-current: var(--whale-ice); + --ocean-mist: var(--whale-text-soft); + --ocean-coral: var(--whale-warning); + --signal-gold: var(--whale-human); /* The whale mark is white ink on the deep-blue tile; off the tile it is a white silhouette on the field. Gold stays reserved for human moments. */ --mark-ink: #ffffff; - --stage-text: #f6f2e8; - --stage-soft: #b6c0d4; - --stage-muted: #93a0b8; + --stage-text: var(--whale-text-body); + --stage-soft: var(--whale-text-soft); + --stage-muted: var(--whale-text-muted); /* Blue Stage field, verbatim from crates/tui/src/palette/tokens.rs — the same gradient the TUI samples down its water column. */ - --stage-field-top: #0e1729; /* WHALE_PANEL — surface */ - --stage-field-mid: #08111c; /* WHALE_CHROME — the authored 42% break */ - --stage-field-deep: #03070d; /* WHALE_BG — deep field, and the footer seabed */ + --stage-field-top: var(--whale-panel); /* surface */ + --stage-field-mid: var(--whale-chrome); /* the authored 42% break */ + --stage-field-deep: var(--whale-bg); /* deep field, and the footer seabed */ --stage-ambient: #264866; - --stage-composer: #162238; /* WHALE_COMPOSER — the raised input plate */ - --stage-elevated: #182742; /* WHALE_ELEVATED */ - --stage-line: #263e5c; /* WHALE_BORDER — blue at 25% on stage */ + --stage-composer: var(--whale-composer); /* the raised input plate */ + --stage-elevated: var(--whale-elevated); + --stage-line: var(--whale-border); /* blue at 25% on stage */ --stage-hint: #8491aa; - --stage-dim: #697791; - --violet: #ad88ff; /* WHALE_MODE_OPERATE — 1px rules only, never text */ + --stage-dim: var(--whale-text-dim); + --violet: var(--whale-mode-operate); /* 1px rules only, never text */ /* Type stacks. Display and body are one instrument voice — Tideline sets its headings in the same sans as its copy, separated by weight and @@ -85,9 +88,9 @@ /* Hairline + code surfaces routed through vars so the docs light theme can re-ink them without touching every rule. */ - --hairline: rgba(106, 174, 242, 0.2); - --code-bg: #08111c; - --code-fg: #f6f2e8; + --hairline: rgb(var(--whale-action-rgb) / 0.2); + --code-bg: var(--whale-chrome); + --code-fg: var(--whale-text-body); /* One site container and one vertical rhythm. Every page gutter aligns with the nav; every hero and section shares a single padding token. */ @@ -3403,10 +3406,10 @@ html[lang="ko"] .ocean-column h2 { /* ------------------------------------------------------------------ */ .docs-theme { - --docs-accent: #6aaef2; - --docs-button-bg: #0e1729; - --docs-button-border: #263e5c; - --docs-button-text: #f6f2e8; + --docs-accent: var(--whale-action); + --docs-button-bg: var(--whale-panel); + --docs-button-border: var(--whale-border); + --docs-button-text: var(--whale-text-body); background: var(--paper); color: var(--ink); } @@ -3450,19 +3453,19 @@ html[data-theme="light"] .docs-theme { --ink: #14213a; --ink-soft: #455168; --ink-mute: #5b6780; - --indigo: #315fd8; + --indigo: var(--whale-cobalt); --indigo-deep: #2448a6; --indigo-pale: #e8eef8; --ochre: #7a5500; --jade: #08766d; - --cobalt: #315fd8; + --cobalt: var(--whale-cobalt); --cyan: #0f7f9f; /* The whale silhouette re-inks to the light sheet's body navy. */ --mark-ink: #14213a; --hairline: rgba(20, 35, 70, 0.14); - --code-bg: #03070d; - --code-fg: #f6f2e8; - --docs-accent: #315fd8; + --code-bg: var(--whale-bg); + --code-fg: var(--whale-text-body); + --docs-accent: var(--whale-cobalt); --docs-button-bg: #fff; --docs-button-border: #aeb8c6; --docs-button-text: #1b2230; diff --git a/web/app/tokens.css b/web/app/tokens.css new file mode 100644 index 000000000..e1adda82c --- /dev/null +++ b/web/app/tokens.css @@ -0,0 +1,98 @@ +/* generated from crates/tui/src/palette/tokens.rs — do not edit */ +/* regenerate: scripts/export-design-tokens.py (in the codewhale repo) */ +:root { + --whale-bg: #03070d; + --whale-bg-rgb: 3 7 13; + --whale-chrome: #08111c; + --whale-chrome-rgb: 8 17 28; + --whale-panel: #0e1729; + --whale-panel-rgb: 14 23 41; + --whale-composer: #162238; + --whale-composer-rgb: 22 34 56; + --whale-elevated: #182742; + --whale-elevated-rgb: 24 39 66; + --whale-selection: #1f324d; + --whale-selection-rgb: 31 50 77; + --whale-text-body: #f6f2e8; + --whale-text-body-rgb: 246 242 232; + --whale-text-soft: #b6c0d4; + --whale-text-soft-rgb: 182 192 212; + --whale-text-muted: #93a0b8; + --whale-text-muted-rgb: 147 160 184; + --whale-text-hint: #8a99b3; + --whale-text-hint-rgb: 138 153 179; + --whale-text-dim: #697791; + --whale-text-dim-rgb: 105 119 145; + --whale-action: #6aaef2; + --whale-action-rgb: 106 174 242; + --whale-cobalt: #315fd8; + --whale-cobalt-rgb: 49 95 216; + --whale-ice: #d1ebf4; + --whale-ice-rgb: 209 235 244; + --whale-cyan: #48d7ff; + --whale-cyan-rgb: 72 215 255; + --whale-accent-secondary: #4fd1c5; + --whale-accent-secondary-rgb: 79 209 197; + --whale-brand-orange: #ff8a3d; + --whale-brand-orange-rgb: 255 138 61; + --whale-brand-magenta: #f04eb8; + --whale-brand-magenta-rgb: 240 78 184; + --whale-human: #f6c453; + --whale-human-rgb: 246 196 83; + --whale-accent-primary: var(--whale-action); + --whale-accent-primary-rgb: var(--whale-action-rgb); + --whale-working-green: #9bd66f; + --whale-working-green-rgb: 155 214 111; + --whale-accent-action: var(--whale-action); + --whale-accent-action-rgb: var(--whale-action-rgb); + --whale-error: #ff86b2; + --whale-error-rgb: 255 134 178; + --whale-error-hover: #ff9cc2; + --whale-error-hover-rgb: 255 156 194; + --whale-error-surface: #2b1522; + --whale-error-surface-rgb: 43 21 34; + --whale-error-border: var(--whale-error); + --whale-error-border-rgb: var(--whale-error-rgb); + --whale-error-text: #ffdbe8; + --whale-error-text-rgb: 255 219 232; + --whale-warning: #ff7a59; + --whale-warning-rgb: 255 122 89; + --whale-success: var(--whale-working-green); + --whale-success-rgb: var(--whale-working-green-rgb); + --whale-info: var(--whale-action); + --whale-info-rgb: var(--whale-action-rgb); + --whale-border: #263e5c; + --whale-border-rgb: 38 62 92; + --whale-reasoning-text: #e09948; + --whale-reasoning-text-rgb: 224 153 72; + --whale-reasoning-surface: #2a2218; + --whale-reasoning-surface-rgb: 42 34 24; + --whale-reasoning-tint: #182434; + --whale-reasoning-tint-rgb: 24 36 52; + --whale-diff-added: #57c785; + --whale-diff-added-rgb: 87 199 133; + --whale-diff-deleted: var(--whale-error); + --whale-diff-deleted-rgb: var(--whale-error-rgb); + --whale-diff-added-bg: #122a22; + --whale-diff-added-bg-rgb: 18 42 34; + --whale-diff-deleted-bg: #341827; + --whale-diff-deleted-bg-rgb: 52 24 39; + --whale-mode-agent: #76b5f5; + --whale-mode-agent-rgb: 118 181 245; + --whale-mode-yolo: #ff70a0; + --whale-mode-yolo-rgb: 255 112 160; + --whale-mode-plan: #b9dcec; + --whale-mode-plan-rgb: 185 220 236; + --whale-mode-operate: #ad88ff; + --whale-mode-operate-rgb: 173 136 255; + --whale-tool-live: var(--whale-accent-secondary); + --whale-tool-live-rgb: var(--whale-accent-secondary-rgb); + --whale-tool-issue: var(--whale-error); + --whale-tool-issue-rgb: var(--whale-error-rgb); + --whale-tool-output: var(--whale-text-soft); + --whale-tool-output-rgb: var(--whale-text-soft-rgb); + --whale-tool-surface: #121d32; + --whale-tool-surface-rgb: 18 29 50; + --whale-tool-active: #1b2c44; + --whale-tool-active-rgb: 27 44 68; +} diff --git a/web/lib/blue-stage-contract.test.ts b/web/lib/blue-stage-contract.test.ts index d18e5cd94..5523693a1 100644 --- a/web/lib/blue-stage-contract.test.ts +++ b/web/lib/blue-stage-contract.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { resolveWhale } from "./whale-tokens"; const CSS = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); const TUI_TOKENS = readFileSync( @@ -26,10 +27,16 @@ function selectorBlock(selector: string): string { return match[1]; } +// globals.css names the whale token (`--paper: var(--whale-bg)`) rather than +// repeating its hex; resolve one hop through the generated app/tokens.css. function cssHexIn(block: string, name: string): string { - const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, "i")); + const match = block.match(new RegExp(`--${name}:\\s*([^;]+);`, "i")); if (!match) throw new Error(`Missing CSS token in block: --${name}`); - return match[1].toLowerCase(); + const value = resolveWhale(match[1].trim()); + if (!/^#[0-9a-f]{6}$/i.test(value)) { + throw new Error(`--${name} is not a hex color: ${value}`); + } + return value.toLowerCase(); } const ROOT = selectorBlock(":root"); diff --git a/web/lib/docs-theme-contract.test.ts b/web/lib/docs-theme-contract.test.ts index cc8437c85..e7fbcf71e 100644 --- a/web/lib/docs-theme-contract.test.ts +++ b/web/lib/docs-theme-contract.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { resolveWhale } from "./whale-tokens"; const CSS = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); @@ -12,8 +13,12 @@ function selectorBlock(selector: string): string { function selectorVars(selector: string): Record { const block = selectorBlock(selector); const vars: Record = {}; - for (const match of block.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6}|#[0-9a-f]{3})/gi)) { - vars[match[1]] = match[2]; + // Values may be a literal hex or a `var(--whale-*)` reference into the + // generated app/tokens.css; non-color values (channel triples, lengths) are + // skipped, exactly as the hex-only regex used to skip them. + for (const match of block.matchAll(/--([\w-]+):\s*([^;]+);/g)) { + const value = resolveWhale(match[2].trim()); + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) vars[match[1]] = value; } return vars; } diff --git a/web/lib/whale-tokens.ts b/web/lib/whale-tokens.ts new file mode 100644 index 000000000..99e5aff45 --- /dev/null +++ b/web/lib/whale-tokens.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; + +/** + * The whale palette as the site actually resolves it. + * + * `app/tokens.css` is generated from `crates/tui/src/palette/tokens.rs` by + * `scripts/export-design-tokens.py`, so `globals.css` states which whale token + * each site variable uses (`--paper: var(--whale-bg)`) instead of repeating the + * hex. The contract tests still need the literal color to check parity and + * contrast, so this reads the generated file and flattens the alias chains + * (`--whale-info` -> `--whale-action` -> `#6aaef2`). + * + * Node-only (`node:fs`): imported by the contract tests, never by a component. + */ +const RAW: Record = (() => { + const css = readFileSync(new URL("../app/tokens.css", import.meta.url), "utf8"); + const raw: Record = {}; + for (const match of css.matchAll(/--(whale-[\w-]+):\s*([^;]+);/g)) { + raw[match[1]] = match[2].trim(); + } + if (Object.keys(raw).length === 0) { + throw new Error("app/tokens.css defines no --whale-* properties"); + } + return raw; +})(); + +function flatten(name: string, seen = new Set()): string { + const value = RAW[name]; + if (value === undefined) throw new Error(`Unknown whale token: --${name}`); + const alias = value.match(/^var\(--([\w-]+)\)$/); + if (!alias) return value; + if (seen.has(name)) throw new Error(`Cyclic whale token alias: --${name}`); + return flatten(alias[1], seen.add(name)); +} + +/** Resolve a `var(--whale-*)` reference to its literal value; pass anything else through. */ +export function resolveWhale(value: string): string { + const match = value.match(/^var\(--(whale-[\w-]+)\)$/); + return match ? flatten(match[1]) : value; +} diff --git a/web/package.json b/web/package.json index cdbfd7b3c..cde99089a 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "sync:latest-release": "node scripts/sync-latest-release.mjs", "check:latest-release": "node scripts/sync-latest-release.mjs --check", "check:docs": "node scripts/check-docs.mjs", + "check:tokens": "python3 ../scripts/export-design-tokens.py --check", "check:locales": "node scripts/check-locales.mjs && node scripts/gt-site.mjs check", "i18n:gt": "node scripts/gt-site.mjs", "check:deploy-env": "node scripts/check-cloudflare-deploy-env.mjs",