wren/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files draw
| 1 | /** |
| 2 | * The look holds: the tokens compile to both themes, every pair of colours |
| 3 | * a person must read meets WCAG AA in both, and no colour is spelled out |
| 4 | * anywhere but design/tokens.json. |
| 5 | */ |
| 6 | import { expect, test } from "bun:test"; |
| 7 | import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; |
| 8 | import { tmpdir } from "node:os"; |
| 9 | import { join } from "node:path"; |
| 10 | import { lint, rawColours, unknownTokens } from "../scripts/lint-design.ts"; |
| 11 | import { compile, flatten, token, tokens, tokensCss } from "./tokens.ts"; |
| 12 | |
| 13 | test("the tokens compile: light on :root, dark for the system and for data-theme", () => { |
| 14 | expect(tokensCss).toContain(`--color-bg: ${token("color-bg")};`); |
| 15 | expect(tokensCss).toContain("--space-4: 1rem;"); |
| 16 | expect(tokensCss).toContain( |
| 17 | '--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;', |
| 18 | ); |
| 19 | expect(tokensCss).toContain("@media (prefers-color-scheme: dark)"); |
| 20 | expect(tokensCss).toContain(':root:not([data-theme="light"])'); |
| 21 | expect(tokensCss).toContain(':root[data-theme="dark"]'); |
| 22 | // Every colour has a dark value, or the dark theme would inherit a light one. |
| 23 | for (const t of tokens.filter((t) => t.type === "color")) |
| 24 | expect([t.name, t.dark !== undefined]).toEqual([t.name, true]); |
| 25 | expect( |
| 26 | tokensCss.split(`--color-bg: ${token("color-bg", "dark")};`).length, |
| 27 | ).toBe(3); |
| 28 | }); |
| 29 | |
| 30 | test("tokens that do not read stop the app, with the path named", () => { |
| 31 | expect(() => flatten({ color: { bg: { $value: "#fff" } } })).toThrow( |
| 32 | "color.bg: no $type", |
| 33 | ); |
| 34 | expect(() => |
| 35 | flatten({ |
| 36 | color: { $type: "color", bg: { $value: "red; } body { display: none" } }, |
| 37 | }), |
| 38 | ).toThrow("color.bg: $value is not a CSS value"); |
| 39 | expect(() => |
| 40 | flatten({ color: { $type: "color", "b g": { $value: "#fff" } } }), |
| 41 | ).toThrow("not a name CSS can carry"); |
| 42 | expect( |
| 43 | compile(flatten({ size: { $type: "dimension", tap: { $value: "44px" } } })), |
| 44 | ).toContain("--size-tap: 44px;"); |
| 45 | }); |
| 46 | |
| 47 | // WCAG 2 relative luminance and contrast, from a #rrggbb. |
| 48 | const luminance = (hex: string): number => { |
| 49 | const [r, g, b] = [1, 3, 5].map((i) => { |
| 50 | const c = parseInt(hex.slice(i, i + 2), 16) / 255; |
| 51 | return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; |
| 52 | }) as [number, number, number]; |
| 53 | return 0.2126 * r + 0.7152 * g + 0.0722 * b; |
| 54 | }; |
| 55 | const contrast = (a: string, b: string): number => { |
| 56 | const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [ |
| 57 | number, |
| 58 | number, |
| 59 | ]; |
| 60 | return (x + 0.05) / (y + 0.05); |
| 61 | }; |
| 62 | |
| 63 | test("every pair a person reads is at least 4.5:1, in both themes", () => { |
| 64 | const pairs = [ |
| 65 | ["color-fg", "color-bg"], |
| 66 | ["color-fg", "color-surface"], |
| 67 | ["color-muted", "color-bg"], |
| 68 | ["color-muted", "color-surface"], |
| 69 | ["color-accent", "color-bg"], |
| 70 | ["color-accent", "color-surface"], |
| 71 | ["color-accent-fg", "color-accent"], |
| 72 | ["color-toast-fg", "color-toast-bg"], |
| 73 | ] as const; |
| 74 | for (const mode of ["light", "dark"] as const) |
| 75 | for (const [fg, bg] of pairs) { |
| 76 | const ratio = contrast(token(fg, mode), token(bg, mode)); |
| 77 | expect([mode, fg, bg, ratio >= 4.5]).toEqual([mode, fg, bg, true]); |
| 78 | } |
| 79 | }); |
| 80 | |
| 81 | test("the app spells out no colour but in its tokens", () => { |
| 82 | expect(lint(join(import.meta.dir, ".."))).toEqual([]); |
| 83 | }); |
| 84 | |
| 85 | test("the lint refuses a raw colour and a token that is not there", () => { |
| 86 | expect( |
| 87 | rawColours( |
| 88 | "a.css", |
| 89 | ".x { color: #ff0000; }\n.y { background: rgb(0 0 0); }", |
| 90 | ), |
| 91 | ).toEqual([ |
| 92 | "a.css:1: raw colour #ff0000 — name it in design/tokens.json and use var(--…)", |
| 93 | "a.css:2: raw colour rgb( — name it in design/tokens.json and use var(--…)", |
| 94 | ]); |
| 95 | // Not colours: an entity, an id selector's word, a pull number. |
| 96 | expect(rawColours("a.ts", "`'` #notes pull #12 #abcdefg")).toEqual([]); |
| 97 | expect( |
| 98 | unknownTokens( |
| 99 | "a.css", |
| 100 | ".x { color: var(--color-fg); gap: var(--spcae-2); }", |
| 101 | ), |
| 102 | ).toEqual(["a.css:1: --spcae-2 is not a token in design/tokens.json"]); |
| 103 | const root = mkdtempSync(join(tmpdir(), "app-lint-")); |
| 104 | mkdirSync(join(root, "static")); |
| 105 | mkdirSync(join(root, "src")); |
| 106 | writeFileSync(join(root, "static", "extra.css"), "a {\n color: #fff;\n}\n"); |
| 107 | writeFileSync(join(root, "src", "ok.test.ts"), "const red = '#f00';\n"); |
| 108 | expect(lint(root)).toEqual([ |
| 109 | "static/extra.css:2: raw colour #fff — name it in design/tokens.json and use var(--…)", |
| 110 | ]); |
| 111 | }); |