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