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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2939-website-spec-css-ratchet-in-design-lint → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2939-website-spec-css-ratchet-in-design-lint was merged into main as 6f769d9.

Review

Sign in to review this change.
3 files changed +142 −8
docs/design/baseline.json +69 −4
@@ -2,19 +2,84 @@
22 "files": {
33 "src/web/pages/board.ts": {
44 "colour": 0,
5 "size": 6
5 "size": 6,
6 "motion": 3,
7 "forced": 0,
8 "focus": 0,
9 "input": 0,
10 "target": 0
611 },
712 "src/web/pages/conflicts.ts": {
813 "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
1029 },
1130 "src/web/standards.ts": {
1231 "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
1465 },
1566 "src/web/style/components.ts": {
1667 "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
1883 }
1984 }
2085 }
scripts/design-lint.ts +54 −3
@@ -15,6 +15,21 @@
1515 * registers the page-local classes that predate the rule; a name
1616 * it keeps after its last rule is gone fails too, so it only shrinks
1717 *
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 *
1833 * Known gaps, so nobody mistakes the gate for more than it is: colours built
1934 * in JS strings other than the forms above, `oklch()`,
2035 * `lab()` and system colours are not seen; sizes in `rem`/`em` are not
@@ -41,7 +56,16 @@ const NAMED_RE = new RegExp(
4156 "gi",
4257 );
4358
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 };
4569 export type Baseline = { files: Record<string, Counts> };
4670 export type Scan = Baseline & { classes: string[] };
4771
@@ -92,6 +116,30 @@ export function countFile(text: string): Counts {
92116 return { colour, size };
93117 }
94118
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)) {

The forced rule is judged per template. A forced-colors media block in a sibling template will not satisfy a :focus rule in this one. That is probably intended, but state it in the header comment.

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
95143 export function classesIn(text: string): string[] {
96144 const set = new Set<string>();
97145 for (const css of cssBlocks(text)) {
@@ -147,8 +195,8 @@ export function scan(root: string): Scan {
147195 const classes = new Set<string>();
148196 for (const f of sourceFiles(root)) {
149197 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;
152200 for (const k of classesIn(text)) classes.add(k);
153201 }
154202 return { files, classes: [...classes].sort() };
@@ -159,6 +207,9 @@ export function compare(base: Baseline, now: Scan, design: string): string[] {
159207 const out: string[] = [];
160208 for (const [file, c] of Object.entries(now.files)) {
161209 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 }
162213 if (c.colour > b.colour) out.push(`${file}: ${c.colour - b.colour} new colour literal(s) — use a token from themes.ts (var(--…))`);
163214 if (c.size > b.size) out.push(`${file}: ${c.size - b.size} new off-scale font-size — the scale is ${TYPE_SCALE.join(", ")}px`);
164215 }
test/design-lint.test.ts +19 −1
@@ -1,6 +1,6 @@
11 import { describe, expect, test } from "bun:test";
22 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";
44
55 /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */
66 const DESIGN = [
@@ -37,6 +37,24 @@ describe("design check: nothing added outside the system (#2678)", () => {
3737 expect(countFile('const u = "https://x.test/#abc";').colour).toBe(1);
3838 });
3939
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
4058 test("finds the classes a css template defines, and only those", () => {
4159 const text = "const x = a.ts; const css = /* css */ `.btn:hover, .card > .row-title { color: red }\n.new-thing { top: 0.5px }`; foo.bar";
4260 expect(classesIn(text).sort()).toEqual(["btn", "card", "new-thing", "row-title"]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs