design: export the TUI whale palette instead of re-typing it (#5797)

* design: export the TUI whale palette instead of re-typing it

crates/tui/src/palette/tokens.rs is the whale palette. The web app repeated
its hexes by hand, the desktop shell ran a separate neutral-gray palette, and
the Android theme a fourth one — four palettes sharing exactly one value
(#08111C). Make the Rust file the source and generate the rest.

scripts/export-design-tokens.py parses the 47 WHALE_*_RGB consts (aliases
included: INFO = ACTION = ACCENT_PRIMARY, SUCCESS = WORKING_GREEN,
ERROR_BORDER = ERROR, ...) and emits web/app/tokens.css, plus — when a
codewhale-apps checkout sits beside this repo — the desktop CSS token file
and a Compose WhaleTokens object. Aliases are emitted as var()/val
references, so the alias structure survives the export instead of flattening
into duplicate literals. --check fails when a generated file is stale; it is
wired in as `npm run check:tokens` and runs in the web workflow.

globals.css keeps its own variable names (--paper, --ink, --indigo — the
component rules and the docs light sheet consume them) and now binds them to
--whale-* rather than re-typing the hex. Only byte-identical values were
rebound; --paper-card, --indigo-deep, --stage-ambient and --stage-hint are
not whale tokens and stayed literal. No rendered color changes.

The two contract tests read hexes straight out of globals.css, so they now
resolve one hop through the generated file (lib/whale-tokens.ts). They still
catch a wrong mapping: pointing --paper at --whale-panel fails with
"expected '#0e1729' to be '#03070d'".

Evidence, in web/:
  npm test          -> Test Files 42 passed (42), Tests 364 passed (364)
  npm run lint      -> clean; npx tsc --noEmit -> clean
  npm run build     -> succeeded; built CSS carries --whale-bg:#03070d
                       and --paper:var(--whale-bg)
  npm run check:tokens -> design tokens up to date (47 tokens)
  check:facts, check:docs -> PASS
Perturbing tokens.css makes --check exit 1 with "stale: web/app/tokens.css".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbuqwHAXSDcikPiS6L6Qw

* design tokens: the generator targets the web app only

The desktop and Android targets were rendered and rejected on sight; the
script now writes nothing outside this repository and has no --apps-root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbuqwHAXSDcikPiS6L6Qw

---------

Co-authored-by: CodeWhale Bot <bot@codewhale.net>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-09-01 13:42:57 -07:00
committed by GitHub
parent 51c806b7ce
commit de001f3c4b
8 changed files with 333 additions and 60 deletions

View File

@@ -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

115
scripts/export-design-tokens.py Executable file
View File

@@ -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: <repo>/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())

View File

@@ -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;

98
web/app/tokens.css Normal file
View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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<string, string> {
const block = selectorBlock(selector);
const vars: Record<string, string> = {};
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;
}

40
web/lib/whale-tokens.ts Normal file
View File

@@ -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<string, string> = (() => {
const css = readFileSync(new URL("../app/tokens.css", import.meta.url), "utf8");
const raw: Record<string, string> = {};
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>()): 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;
}

View File

@@ -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",