Public: anyone can read this; the crew can change it.
Website spec: CSS ratchet in design-lint for reduced-motion, forced-colors, focus-visible, input size, target size (#2915 check 6) #2959
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2939-website-spec-css-ratchet-in-design-lint → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +142
−8
docs/design/baseline.json +69 −4
| @@ -2,19 +2,84 @@ | ||
| 2 | 2 | "files": { |
| 3 | 3 | "src/web/pages/board.ts": { |
| 4 | 4 | "colour": 0, |
| 5 | "size": 6 | |
| 5 | "size": 6, | |
| 6 | "motion": 3, | |
| 7 | "forced": 0, | |
| 8 | "focus": 0, | |
| 9 | "input": 0, | |
| 10 | "target": 0 | |
| 6 | 11 | }, |
| 7 | 12 | "src/web/pages/conflicts.ts": { |
| 8 | 13 | "colour": 0, |
| 9 | "size": 1 | |
| 14 | "size": 1, | |
| 15 | "motion": 0, | |
| 16 | "forced": 0, | |
| 17 | "focus": 0, | |
| 18 | "input": 0, | |
| 19 | "target": 0 | |
| 20 | }, | |
| 21 | "src/web/pages/map.ts": { | |
| 22 | "colour": 0, | |
| 23 | "size": 0, | |
| 24 | "motion": 3, | |
| 25 | "forced": 1, | |
| 26 | "focus": 0, | |
| 27 | "input": 0, | |
| 28 | "target": 0 | |
| 10 | 29 | }, |
| 11 | 30 | "src/web/standards.ts": { |
| 12 | 31 | "colour": 2, |
| 13 | "size": 0 | |
| 32 | "size": 0, | |
| 33 | "motion": 0, | |
| 34 | "forced": 0, | |
| 35 | "focus": 0, | |
| 36 | "input": 0, | |
| 37 | "target": 0 | |
| 38 | }, | |
| 39 | "src/web/studio/feed_style.ts": { | |
| 40 | "colour": 0, | |
| 41 | "size": 0, | |
| 42 | "motion": 0, | |
| 43 | "forced": 1, | |
| 44 | "focus": 0, | |
| 45 | "input": 0, | |
| 46 | "target": 0 | |
| 47 | }, | |
| 48 | "src/web/studio/style.ts": { | |
| 49 | "colour": 0, | |
| 50 | "size": 0, | |
| 51 | "motion": 3, | |
| 52 | "forced": 0, | |
| 53 | "focus": 0, | |
| 54 | "input": 0, | |
| 55 | "target": 0 | |
| 56 | }, | |
| 57 | "src/web/style/base.ts": { | |
| 58 | "colour": 0, | |
| 59 | "size": 0, | |
| 60 | "motion": 4, | |
| 61 | "forced": 1, | |
| 62 | "focus": 0, | |
| 63 | "input": 0, | |
| 64 | "target": 0 | |
| 14 | 65 | }, |
| 15 | 66 | "src/web/style/components.ts": { |
| 16 | 67 | "colour": 3, |
| 17 | "size": 2 | |
| 68 | "size": 2, | |
| 69 | "motion": 20, | |
| 70 | "forced": 1, | |
| 71 | "focus": 2, | |
| 72 | "input": 1, | |
| 73 | "target": 1 | |
| 74 | }, | |
| 75 | "src/web/style/content.ts": { | |
| 76 | "colour": 0, | |
| 77 | "size": 0, | |
| 78 | "motion": 13, | |
| 79 | "forced": 1, | |
| 80 | "focus": 1, | |
| 81 | "input": 2, | |
| 82 | "target": 2 | |
| 18 | 83 | } |
| 19 | 84 | } |
| 20 | 85 | } |
scripts/design-lint.ts +54 −3
| @@ -15,6 +15,21 @@ | ||
| 15 | 15 | * registers the page-local classes that predate the rule; a name |
| 16 | 16 | * it keeps after its last rule is gone fails too, so it only shrinks |
| 17 | 17 | * |
| 18 | * Five more counts hold the website specification's stylesheet rules | |
| 19 | * (#2939, specification.website, the same ratchet: today's count is the | |
| 20 | * ceiling). Each is counted per `/* css *\/` template (`webCss`): | |
| 21 | * | |
| 22 | * motion an animation/transition/@keyframes in a block with no | |
| 23 | * `prefers-reduced-motion` (specification.website, motion) | |
| 24 | * forced a block that styles `:focus` and has no `forced-colors: active` | |
| 25 | * (specification.website, forced colours) | |
| 26 | * focus a `:focus` selector that is not `:focus-visible`/`:focus-within` | |
| 27 | * (specification.website, focus) | |
| 28 | * input an input, select or textarea under 16px, which zooms iOS | |
| 29 | * (specification.website, input size) | |
| 30 | * target a button, link, input or select under 24px wide or high, WCAG 2.5.8 | |
| 31 | * (specification.website, target size) | |
| 32 | * | |
| 18 | 33 | * Known gaps, so nobody mistakes the gate for more than it is: colours built |
| 19 | 34 | * in JS strings other than the forms above, `oklch()`, |
| 20 | 35 | * `lab()` and system colours are not seen; sizes in `rem`/`em` are not |
| @@ -41,7 +56,16 @@ const NAMED_RE = new RegExp( | ||
| 41 | 56 | "gi", |
| 42 | 57 | ); |
| 43 | 58 | |
| 44 | export type Counts = { colour: number; size: number }; | |
| 59 | export type Counts = { colour: number; size: number } & Partial<WebCounts>; | |
| 60 | export type WebCounts = { motion: number; forced: number; focus: number; input: number; target: number }; | |
| 61 | const WEB_KEYS = ["motion", "forced", "focus", "input", "target"] as const; | |
| 62 | const WEB_MESSAGE: Record<(typeof WEB_KEYS)[number], string> = { | |
| 63 | motion: "animation without a prefers-reduced-motion guard (specification.website, motion)", | |
| 64 | forced: "custom focus styling without @media (forced-colors: active) (specification.website, forced colours)", | |
| 65 | focus: ":focus styled without :focus-visible (specification.website, focus)", | |
| 66 | input: "form input under 16px, which zooms iOS (specification.website, input size)", | |
| 67 | target: "interactive target under 24x24 CSS px, WCAG 2.5.8 (specification.website, target size)", | |
| 68 | }; | |
| 45 | 69 | export type Baseline = { files: Record<string, Counts> }; |
| 46 | 70 | export type Scan = Baseline & { classes: string[] }; |
| 47 | 71 | |
| @@ -92,6 +116,30 @@ export function countFile(text: string): Counts { | ||
| 92 | 116 | return { colour, size }; |
| 93 | 117 | } |
| 94 | 118 | |
| 119 | const INTERACTIVE = /(^|[\s,>+~])(a|button|input|select|summary)\b|\.btn\b|\[role=["']?button/i; | |
| 120 | const FIELD = /(^|[\s,>+~])(input|select|textarea)\b/i; | |
| 121 | ||
| 122 | /** The five specification.website stylesheet counts for one file's css templates. */ | |
| 123 | export function webCss(text: string): WebCounts { | |
| 124 | const n: WebCounts = { motion: 0, forced: 0, focus: 0, input: 0, target: 0 }; | |
| 125 | for (const css of cssBlocks(text)) { | |
| 126 | const bare = css.replace(/\/\*[\s\S]*?\*\//g, ""); | |
| 127 | if (!/prefers-reduced-motion/.test(bare)) n.motion += (bare.match(/(?<![\w-])(?:animation|transition)(?:-name)?\s*:|@keyframes\b/g) ?? []).length; | |
| 128 | if (/:focus(?!-visible|-within)|:focus-visible/.test(bare) && !/forced-colors/.test(bare)) n.forced++; | |
| 129 | n.focus += (bare.match(/:focus(?![-\w])/g) ?? []).length; | |
| 130 | for (const r of bare.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { | |
| 131 | const sel = r[1]!, body = r[2]!; | |
| 132 | if (FIELD.test(sel)) { | |
| 133 | for (const f of body.matchAll(/font-size:\s*([0-9.]+)px/g)) if (Number(f[1]) < 16) n.input++; | |
| 134 | } | |
| 135 | if (INTERACTIVE.test(sel)) { | |
| 136 | for (const d of body.matchAll(/(?<![\w-])(?:min-)?(?:width|height)\s*:\s*([0-9.]+)px/g)) if (Number(d[1]) < 24) n.target++; | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | return n; | |
| 141 | } | |
| 142 | ||
| 95 | 143 | export function classesIn(text: string): string[] { |
| 96 | 144 | const set = new Set<string>(); |
| 97 | 145 | for (const css of cssBlocks(text)) { |
| @@ -147,8 +195,8 @@ export function scan(root: string): Scan { | ||
| 147 | 195 | const classes = new Set<string>(); |
| 148 | 196 | for (const f of sourceFiles(root)) { |
| 149 | 197 | const text = readFileSync(f, "utf8"); |
| 150 | const c = countFile(text); | |
| 151 | if (c.colour || c.size) files[relative(root, f)] = c; | |
| 198 | const c: Counts = { ...countFile(text), ...webCss(text) }; | |
| 199 | if (c.colour || c.size || WEB_KEYS.some((k) => c[k])) files[relative(root, f)] = c; | |
| 152 | 200 | for (const k of classesIn(text)) classes.add(k); |
| 153 | 201 | } |
| 154 | 202 | return { files, classes: [...classes].sort() }; |
| @@ -159,6 +207,9 @@ export function compare(base: Baseline, now: Scan, design: string): string[] { | ||
| 159 | 207 | const out: string[] = []; |
| 160 | 208 | for (const [file, c] of Object.entries(now.files)) { |
| 161 | 209 | const b = base.files[file] ?? { colour: 0, size: 0 }; |
| 210 | for (const k of WEB_KEYS) { | |
| 211 | if ((c[k] ?? 0) > (b[k] ?? 0)) out.push(`${file}: ${(c[k] ?? 0) - (b[k] ?? 0)} new ${WEB_MESSAGE[k]}`); | |
| 212 | } | |
| 162 | 213 | if (c.colour > b.colour) out.push(`${file}: ${c.colour - b.colour} new colour literal(s) — use a token from themes.ts (var(--…))`); |
| 163 | 214 | if (c.size > b.size) out.push(`${file}: ${c.size - b.size} new off-scale font-size — the scale is ${TYPE_SCALE.join(", ")}px`); |
| 164 | 215 | } |
test/design-lint.test.ts +19 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | 2 | import { readFileSync } from "node:fs"; |
| 3 | import { classesIn, compare, countFile, documentedClasses, registeredClasses, scan } from "../scripts/design-lint.ts"; | |
| 3 | import { classesIn, compare, countFile, documentedClasses, registeredClasses, scan, webCss } from "../scripts/design-lint.ts"; | |
| 4 | 4 | |
| 5 | 5 | /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */ |
| 6 | 6 | const DESIGN = [ |
| @@ -37,6 +37,24 @@ describe("design check: nothing added outside the system (#2678)", () => { | ||
| 37 | 37 | expect(countFile('const u = "https://x.test/#abc";').colour).toBe(1); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | test("counts the website-spec stylesheet rules (#2939)", () => { | |
| 41 | const css = (c: string) => webCss("x = /* css */ `" + c + "`"); | |
| 42 | expect(css("a{transition:color .2s} @keyframes s{}").motion).toBe(2); | |
| 43 | expect(css("a{transition:color .2s} @media (prefers-reduced-motion: reduce){a{transition:none}}").motion).toBe(0); | |
| 44 | expect(css("a:focus{outline:2px solid var(--fg)}")).toMatchObject({ forced: 1, focus: 1 }); | |
| 45 | expect(css("a:focus-visible{outline:2px solid var(--fg)} @media (forced-colors: active){a:focus-visible{outline-color:Highlight}}")).toMatchObject({ forced: 0, focus: 0 }); | |
| 46 | expect(css("input{font-size:14px} textarea{font-size:16px} p{font-size:12px}").input).toBe(1); | |
| 47 | expect(css("button{height:20px} .btn{min-width:24px} div{height:8px} a{width:10px}").target).toBe(2); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a pull may not raise a website-spec count, and may lower it (#2939)", () => { | |
| 51 | const base = { files: { "src/web/x.ts": { colour: 0, size: 0, motion: 2 } } }; | |
| 52 | const now = { classes: ["btn", "run-log", "x-card", "x-row", "x-wrapped"], files: { "src/web/x.ts": { colour: 0, size: 0, motion: 3 } } }; | |
| 53 | expect(compare(base, now, DESIGN)[0]).toContain("prefers-reduced-motion"); | |
| 54 | expect(compare(base, { ...now, files: { "src/web/x.ts": { colour: 0, size: 0, motion: 1 } } }, DESIGN)).toEqual([]); | |
| 55 | expect(compare(base, { ...now, files: { "src/web/y.ts": { colour: 0, size: 0, target: 1 } } }, DESIGN)[0]).toContain("WCAG 2.5.8"); | |
| 56 | }); | |
| 57 | ||
| 40 | 58 | test("finds the classes a css template defines, and only those", () => { |
| 41 | 59 | const text = "const x = a.ts; const css = /* css */ `.btn:hover, .card > .row-title { color: red }\n.new-thing { top: 0.5px }`; foo.bar"; |
| 42 | 60 | expect(classesIn(text).sort()).toEqual(["btn", "card", "new-thing", "row-title"]); |
new above
offline
The
forcedrule is judged per template. A forced-colors media block in a sibling template will not satisfy a:focusrule in this one. That is probably intended, but state it in the header comment.