| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 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 |
|
| 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 |
|
| 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 |
["color-fg", "color-hover"], |
| 74 |
["color-fg", "color-active"], |
| 75 |
["color-muted", "color-active"], |
| 76 |
["color-fg", "color-mark-bg"], |
| 77 |
["color-code-fg", "color-code-bg"], |
| 78 |
["color-tag-fg", "color-tag-bg"], |
| 79 |
["color-accent", "color-active"], |
| 80 |
["color-danger", "color-bg"], |
| 81 |
["color-bg", "color-danger"], |
| 82 |
["color-link-missing", "color-bg"], |
| 83 |
["color-callout-info", "color-bg"], |
| 84 |
["color-callout-tip", "color-bg"], |
| 85 |
["color-callout-warning", "color-bg"], |
| 86 |
["color-callout-danger", "color-bg"], |
| 87 |
] as const; |
| 88 |
for (const mode of ["light", "dark"] as const) |
| 89 |
for (const [fg, bg] of pairs) { |
| 90 |
const ratio = contrast(token(fg, mode), token(bg, mode)); |
| 91 |
expect([mode, fg, bg, ratio >= 4.5]).toEqual([mode, fg, bg, true]); |
| 92 |
} |
| 93 |
}); |
| 94 |
|
| 95 |
test("the app spells out no colour but in its tokens", () => { |
| 96 |
expect(lint(join(import.meta.dir, ".."))).toEqual([]); |
| 97 |
}); |
| 98 |
|
| 99 |
test("the lint refuses a raw colour and a token that is not there", () => { |
| 100 |
expect( |
| 101 |
rawColours( |
| 102 |
"a.css", |
| 103 |
".x { color: #ff0000; }\n.y { background: rgb(0 0 0); }", |
| 104 |
), |
| 105 |
).toEqual([ |
| 106 |
"a.css:1: raw colour #ff0000 — name it in design/tokens.json and use var(--…)", |
| 107 |
"a.css:2: raw colour rgb( — name it in design/tokens.json and use var(--…)", |
| 108 |
]); |
| 109 |
|
| 110 |
expect(rawColours("a.ts", "`'` #notes pull #12 #abcdefg")).toEqual([]); |
| 111 |
expect( |
| 112 |
unknownTokens( |
| 113 |
"a.css", |
| 114 |
".x { color: var(--color-fg); gap: var(--spcae-2); }", |
| 115 |
), |
| 116 |
).toEqual(["a.css:1: --spcae-2 is not a token in design/tokens.json"]); |
| 117 |
const root = mkdtempSync(join(tmpdir(), "app-lint-")); |
| 118 |
mkdirSync(join(root, "static")); |
| 119 |
mkdirSync(join(root, "src")); |
| 120 |
writeFileSync(join(root, "static", "extra.css"), "a {\n color: #fff;\n}\n"); |
| 121 |
writeFileSync(join(root, "src", "ok.test.ts"), "const red = '#f00';\n"); |
| 122 |
expect(lint(root)).toEqual([ |
| 123 |
"static/extra.css:2: raw colour #fff — name it in design/tokens.json and use var(--…)", |
| 124 |
]); |
| 125 |
}); |