Files
DeepSeek-TUI/web/lib/docs-theme-contract.test.ts
Hunter Bown de001f3c4b 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>
2026-09-01 13:42:57 -07:00

71 lines
3.0 KiB
TypeScript

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");
function selectorBlock(selector: string): string {
const match = CSS.match(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "s"));
if (!match) throw new Error(`Missing CSS selector: ${selector}`);
return match[1];
}
function selectorVars(selector: string): Record<string, string> {
const block = selectorBlock(selector);
const vars: Record<string, string> = {};
// 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;
}
function relativeLuminance(hex: string): number {
const full = hex.length === 4 ? hex.slice(1).split("").map((c) => c + c).join("") : hex.slice(1);
const channels = full
.match(/.{2}/g)!
.map((value) => Number.parseInt(value, 16) / 255)
.map((value) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4));
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
function contrastRatio(foreground: string, background: string): number {
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background));
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background));
return (lighter + 0.05) / (darker + 0.05);
}
describe("docs theme contrast contract", () => {
// Tideline: dark is the site default (the bare `.docs-theme` block inherits
// the dark surface tokens from `:root`), and the light sheet is the opt-in
// override. Both are checked.
const themes = () => [
{ ...selectorVars(":root"), ...selectorVars(".docs-theme") },
{ ...selectorVars(":root"), ...selectorVars('html[data-theme="light"] .docs-theme') },
];
it("keeps current and hover sidebar text at WCAG AA contrast", () => {
for (const theme of themes()) {
const accent = theme["docs-accent"];
const background = theme["paper"];
expect(contrastRatio(accent, background)).toBeGreaterThanOrEqual(4.5);
}
expect(CSS).toMatch(/\.docs-sidebar-link:hover,\s*\.docs-sidebar-link-current\s*{[^}]*color:\s*var\(--docs-accent\)/s);
});
it("keeps secondary button text at WCAG AA contrast", () => {
for (const theme of themes()) {
const text = theme["docs-button-text"];
const background = theme["docs-button-bg"];
expect(contrastRatio(text, background)).toBeGreaterThanOrEqual(4.5);
}
expect(selectorBlock(".docs-theme .portal-button-secondary")).toContain(
"color: var(--docs-button-text)",
);
});
});