返回 CodeWhale
gpui-role-tokens.test.ts
根目录 / web / lib / gpui-role-tokens.test.ts
1 import { readFileSync } from "node:fs";
2 import { describe, expect, it } from "vitest";
3 import { resolveWhale } from "./whale-tokens";
4 import { siteCss } from "./site-css";
5
6 // The role layer (app/styles/tokens-roles.css): every role in every scheme,
7 // inks from the versioned GPUI artifact, the navy ocean grounds measured
8 // against every ink at WCAG AA.
9 const CSS = siteCss();
10 const DESIGN = JSON.parse(readFileSync(new URL("../../vendor/codewhale-design/tokens.json", import.meta.url), "utf8"));
11 const ROLES = [
12 "bg", "surface", "panel", "text", "muted", "line", "line-strong", "accent", "on-accent",
13 "hover", "selected", "selection", "ring", "live", "attention", "danger",
14 ];
15 const GROUNDS = ["bg", "surface", "panel", "hover", "selected"];
16 const TEXT_INKS = ["text", "muted", "accent", "live", "attention", "danger"];
17
18 function declarations(block: string): Record<string, string> {
19 const vars: Record<string, string> = {};
20 for (const match of block.matchAll(/--([\w-]+):\s*([^;]+);/g)) vars[match[1]] = match[2].trim();
21 return vars;
22 }
23
24 /** The first block for `selector` (at any indent) that declares `--bg`. */
25 function roleBlock(selector: string): Record<string, string> {
26 const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27 for (const match of CSS.matchAll(new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`, "g"))) {
28 const vars = declarations(match[1]);
29 if ("bg" in vars) return vars;
30 }
31 throw new Error(`No role block for ${selector}`);
32 }
33
34 function hex(value: string): string {
35 const resolved = resolveWhale(value);
36 if (!/^#[0-9a-f]{6}$/i.test(resolved)) throw new Error(`not a hex color: ${value} -> ${resolved}`);
37 return resolved.toLowerCase();
38 }
39
40 function luminance(color: string): number {
41 const [r, g, b] = color
42 .slice(1)
43 .match(/.{2}/g)!
44 .map((v) => Number.parseInt(v, 16) / 255)
45 .map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
46 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
47 }
48
49 function contrast(a: string, b: string): number {
50 const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
51 return (hi + 0.05) / (lo + 0.05);
52 }
53
54 const light = () => roleBlock(":root");
55 const pinnedDark = () => roleBlock(':root[data-theme="dark"]');
56 const stage = () => roleBlock(".stage,\n.site-footer");
57
58 describe("role tokens", () => {
59 it("defines every role in light, pinned-dark and the stage", () => {
60 for (const [name, scheme] of [["light", light()], ["pinned", pinnedDark()], ["stage", stage()]] as const) {
61 for (const role of ROLES) expect(scheme, `${name}.${role}`).toHaveProperty(role);
62 }
63 // Paper is the site's appearance: dark is a reader's pin, never the OS.
64 expect(CSS).not.toMatch(/@media \(prefers-color-scheme: dark\)/);
65 });
66
67 it("inks with the versioned GPUI artifact in both appearances", () => {
68 const inks: Record<string, string> = {
69 text: "foreground", muted: "muted_foreground", accent: "primary", "on-accent": "primary_foreground",
70 ring: "primary", live: "live", attention: "attention", danger: "danger",
71 };
72 for (const [mode, scheme] of [["light", light()], ["dark", pinnedDark()], ["dark", stage()]] as const) {
73 for (const [role, key] of Object.entries(inks)) {
74 expect(scheme[role], `${mode}.${role}`).toMatch(/^var\(--gpui-(light|dark)-[\w-]+\)$/);
75 expect(hex(scheme[role]), `${mode}.${role}`).toBe(`#${DESIGN.colors[mode][key]}`);
76 }
77 expect(scheme.selection).toBe(`rgb(var(--gpui-${mode}-primary-rgb) / var(--gpui-selection-opacity))`);
78 }
79 // Light keeps the artifact's paper grounds; dark reaches for the ocean.
80 expect(hex(light().bg)).toBe(`#${DESIGN.colors.light.background}`);
81 expect(hex(light().panel)).toBe(`#${DESIGN.colors.light.surface}`);
82 for (const role of GROUNDS) expect(pinnedDark()[role], role).toMatch(/^var\(--ocean-[\w-]+\)$/);
83 });
84
85 it("keeps every text ink at WCAG AA on every ground, in every scheme", () => {
86 for (const [name, scheme] of [["light", light()], ["dark", pinnedDark()], ["stage", stage()]] as const) {
87 for (const ground of GROUNDS) {
88 for (const ink of TEXT_INKS) {
89 expect(contrast(hex(scheme[ink]), hex(scheme[ground])), `${name}: ${ink} on ${ground}`).toBeGreaterThanOrEqual(4.5);
90 }
91 }
92 // Control edges and state marks clear the 3:1 non-text floor.
93 for (const ground of ["bg", "panel"]) {
94 expect(contrast(hex(scheme["line-strong"]), hex(scheme[ground])), `${name}: line-strong on ${ground}`).toBeGreaterThanOrEqual(3);
95 }
96 expect(contrast(hex(scheme["on-accent"]), hex(scheme.accent)), `${name}: on-accent`).toBeGreaterThanOrEqual(4.5);
97 }
98 });
99
100 it("puts white text on the logo gradient only where it clears 4.5:1", () => {
101 const fill = CSS.match(/--brand-fill:\s*linear-gradient\(([^;]+)\);/)?.[1] ?? "";
102 const stops = [...fill.matchAll(/#[0-9a-f]{6}|var\(--brand-[\w-]+\)/gi)].map((m) => hex(m[0]));
103 expect(stops.length).toBeGreaterThanOrEqual(2);
104 for (const stop of stops) expect(contrast("#ffffff", stop), stop).toBeGreaterThanOrEqual(4.5);
105 expect(hex("var(--brand-deep)")).toBe("#0b48bb");
106 expect(hex("var(--brand-light)")).toBe("#1e8fd8");
107 });
108
109 function stops(token: string): string[] {
110 const ramp = CSS.match(new RegExp(`--${token}:\\s*linear-gradient\\(([^;]+)\\);`))?.[1] ?? "";
111 return [...ramp.matchAll(/#[0-9a-f]{6}|var\(--[\w-]+\)/gi)].map((m) => hex(m[0]));
112 }
113
114 it("keeps sea text readable on every stop of the sea ramps", () => {
115 for (const token of ["sea", "sea-band"]) {
116 const ramp = stops(token);
117 expect(ramp.length, token).toBeGreaterThanOrEqual(3);
118 for (const stop of ramp) {
119 expect(contrast(hex(stage().muted), stop), `${token}: muted on ${stop}`).toBeGreaterThanOrEqual(4.5);
120 expect(contrast(hex(stage().text), stop), `${token}: text on ${stop}`).toBeGreaterThanOrEqual(4.5);
121 }
122 }
123 });
124
125 it("starts terminal-section text only where the tide is deep enough", () => {
126 // The tide runs paper -> aqua -> logo light -> logo deep -> mid -> navy.
127 // Headings (text ink) start past the logo-deep stop; muted ink (the
128 // terminal's description and caption) only past the mid stop. home.css
129 // pads the section so its first line sits below 9rem.
130 const ramp = stops("tide");
131 const deep = ramp.indexOf(hex("var(--brand-deep)"));
132 const mid = ramp.indexOf(hex("var(--sea-mid)"));
133 expect(deep).toBeGreaterThan(0);
134 expect(mid).toBeGreaterThan(deep);
135 for (const stop of ramp.slice(deep)) {
136 expect(contrast(hex(stage().text), stop), `text on ${stop}`).toBeGreaterThanOrEqual(4.5);
137 }
138 for (const stop of ramp.slice(mid)) {
139 expect(contrast(hex(stage().muted), stop), `muted on ${stop}`).toBeGreaterThanOrEqual(4.5);
140 }
141 expect(CSS).toMatch(/\.home-terminal-inner\s*\{[^}]*padding-block:\s*clamp\(9rem/);
142 });
143 });
144
144 lines TYPESCRIPT