/** * The look holds: the tokens compile to both themes, every pair of colours * a person must read meets WCAG AA in both, and no colour is spelled out * anywhere but design/tokens.json. */ import { expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { lint, rawColours, unknownTokens } from "../scripts/lint-design.ts"; import { compile, flatten, token, tokens, tokensCss } from "./tokens.ts"; test("the tokens compile: light on :root, dark for the system and for data-theme", () => { expect(tokensCss).toContain(`--color-bg: ${token("color-bg")};`); expect(tokensCss).toContain("--space-4: 1rem;"); expect(tokensCss).toContain( '--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;', ); expect(tokensCss).toContain("@media (prefers-color-scheme: dark)"); expect(tokensCss).toContain(':root:not([data-theme="light"])'); expect(tokensCss).toContain(':root[data-theme="dark"]'); // Every colour has a dark value, or the dark theme would inherit a light one. for (const t of tokens.filter((t) => t.type === "color")) expect([t.name, t.dark !== undefined]).toEqual([t.name, true]); expect( tokensCss.split(`--color-bg: ${token("color-bg", "dark")};`).length, ).toBe(3); }); test("tokens that do not read stop the app, with the path named", () => { expect(() => flatten({ color: { bg: { $value: "#fff" } } })).toThrow( "color.bg: no $type", ); expect(() => flatten({ color: { $type: "color", bg: { $value: "red; } body { display: none" } }, }), ).toThrow("color.bg: $value is not a CSS value"); expect(() => flatten({ color: { $type: "color", "b g": { $value: "#fff" } } }), ).toThrow("not a name CSS can carry"); expect( compile(flatten({ size: { $type: "dimension", tap: { $value: "44px" } } })), ).toContain("--size-tap: 44px;"); }); // WCAG 2 relative luminance and contrast, from a #rrggbb. const luminance = (hex: string): number => { const [r, g, b] = [1, 3, 5].map((i) => { const c = parseInt(hex.slice(i, i + 2), 16) / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }) as [number, number, number]; return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const contrast = (a: string, b: string): number => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [ number, number, ]; return (x + 0.05) / (y + 0.05); }; test("every pair a person reads is at least 4.5:1, in both themes", () => { const pairs = [ ["color-fg", "color-bg"], ["color-fg", "color-surface"], ["color-muted", "color-bg"], ["color-muted", "color-surface"], ["color-accent", "color-bg"], ["color-accent", "color-surface"], ["color-accent-fg", "color-accent"], ["color-toast-fg", "color-toast-bg"], ] as const; for (const mode of ["light", "dark"] as const) for (const [fg, bg] of pairs) { const ratio = contrast(token(fg, mode), token(bg, mode)); expect([mode, fg, bg, ratio >= 4.5]).toEqual([mode, fg, bg, true]); } }); test("the app spells out no colour but in its tokens", () => { expect(lint(join(import.meta.dir, ".."))).toEqual([]); }); test("the lint refuses a raw colour and a token that is not there", () => { expect( rawColours( "a.css", ".x { color: #ff0000; }\n.y { background: rgb(0 0 0); }", ), ).toEqual([ "a.css:1: raw colour #ff0000 — name it in design/tokens.json and use var(--…)", "a.css:2: raw colour rgb( — name it in design/tokens.json and use var(--…)", ]); // Not colours: an entity, an id selector's word, a pull number. expect(rawColours("a.ts", "`'` #notes pull #12 #abcdefg")).toEqual([]); expect( unknownTokens( "a.css", ".x { color: var(--color-fg); gap: var(--spcae-2); }", ), ).toEqual(["a.css:1: --spcae-2 is not a token in design/tokens.json"]); const root = mkdtempSync(join(tmpdir(), "app-lint-")); mkdirSync(join(root, "static")); mkdirSync(join(root, "src")); writeFileSync(join(root, "static", "extra.css"), "a {\n color: #fff;\n}\n"); writeFileSync(join(root, "src", "ok.test.ts"), "const red = '#f00';\n"); expect(lint(root)).toEqual([ "static/extra.css:2: raw colour #fff — name it in design/tokens.json and use var(--…)", ]); });