Public: anyone can read this; the crew can change it.

design-lint holds the quiet-page rules a machine can check, so new pages get them #8779

Merged opened this

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

Merged

scout/forge:scout/8775-design-lint-holds-the-quiet-page-rules-a → main 1 commit fork

  • Merged. scout/8775-design-lint-holds-the-quiet-page-rules-a was merged into main as edb0a27.

Review

Sign in to review this change.
4 files changed +150 −2
docs/design/areas/quiet.md added +14
@@ -0,0 +1,14 @@
1 # Quiet pages
2
3 The rules of the quiet-page redesign (#8719) that a machine can check. `bun scripts/design-lint.ts` holds them in
4 markup and style, never in a paragraph's words. What already breaks one on main is the debt in
5 `docs/design/quiet-debt.json`, per file and rule; a pull may not raise it, and a paid debt must be struck
6 (`bun scripts/design-lint.ts --update`), so the list only shrinks.
7
8 - `label` — a "Status:" or "State:" label straight before a status word, a tag or an interpolation. Status is one
9 small glyph and colour, its word on hover and on the detail page.
10 - `caps` — `text-transform: uppercase` in a stylesheet. Size and weight carry hierarchy, not small uppercase captions.
11 - `echo` — an `<a>` or `<button>` holding an icon whose `aria-label` is the same as its visible text. An icon never
12 repeats its own label: keep the label and hide the icon, or keep the icon and its aria-label.
13 - `inline-colour` — a colour name in a `style=""` attribute. Colour is a token (`var(--…)`); hex and `rgb()` literals
14 anywhere in `src/web` are already refused by the colour count.
docs/design/quiet-debt.json added +8
@@ -0,0 +1,8 @@
1 {
2 "src/web/pages/map.ts": {
3 "caps": 1
4 },
5 "src/web/style/components.ts": {
6 "caps": 4
7 }
8 }
scripts/design-lint.ts +81 −1
@@ -30,6 +30,12 @@
3030 * target a button, link, input or select under 24px wide or high, WCAG 2.5.8
3131 * (specification.website, target size)
3232 *
33 * The quiet-page rules (#8775, #8719) are held per file in `docs/design/quiet-debt.json`,
34 * the same ratchet, and may only go down: `label` ("Status: Open"), `caps`
35 * (`text-transform: uppercase`), `echo` (an icon's aria-label equal to its visible
36 * text) and `inline-colour` (a colour name in a `style=""`). They match markup and
37 * style, never a paragraph's words. Named in docs/design/areas/quiet.md.
38 *
3339 * A red names the file:line, the literal and what to use instead, so one pass
3440 * fixes it; run `bun scripts/design-lint.ts` before pushing. A new class is named
3541 * in its own `docs/design/areas/<area>.md` rather than in DESIGN.md, which
@@ -308,6 +314,78 @@ export function compare(base: Baseline, now: Scan, design: string, areas: readon
308314 return out;
309315 }
310316
317 /** The quiet-page rules a machine can check (#8775, from #8719): markup and style only, never prose. */
318 export type QuietRule = "label" | "caps" | "echo" | "inline-colour";
319 export type QuietFinding = { line: number; rule: QuietRule; text: string; use: string };
320 export const QUIET_RULES: readonly QuietRule[] = ["label", "caps", "echo", "inline-colour"];
321 const STATUS_WORD = "open|closed|merged|draft|done|failed|passing|failing|pending|running|queued|shipped|blocked|active|ready";
322
323 /** Where `text` breaks a quiet-page rule: file line, the offence, and what to use instead. */
324 export function quiet(text: string): QuietFinding[] {
325 const code = blanked(text);
326 const at = (i: number) => code.slice(0, i).split("\n").length;
327 const out: QuietFinding[] = [];
328 // "Status: Open": a label tag ending in a colon, straight before a status word, a tag or an interpolation.
329 for (const m of code.matchAll(new RegExp(`>\\s*(?:Status|State)\\s*:\\s*(?=<|\\$\\{|(?:${STATUS_WORD})\\b)`, "gi"))) {
330 out.push({ line: at(m.index!), rule: "label", text: m[0].slice(1).trim(), use: "the status glyph and colour alone, its word in title/aria-label and on the detail page (§6a)" });
331 }
332 for (const { css, at: start } of cssBlocksAt(text)) {
333 for (const m of css.matchAll(/text-transform\s*:\s*uppercase/gi)) {
334 out.push({ line: at(start + m.index!), rule: "caps", text: m[0], use: "size and weight for hierarchy; drop the uppercase caption" });
335 }
336 }
337 // An icon beside its own label: the aria-label and the visible text are the same words.
338 for (const m of code.matchAll(/<(button|a)\b[^>]*\baria-label="([^"$]+)"[^>]*>([\s\S]*?)<\/\1>/g)) {
339 const visible = m[3]!.replace(/<svg[\s\S]*?<\/svg>/g, "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
340 if (/<svg\b/.test(m[3]!) && visible.toLowerCase() === m[2]!.trim().toLowerCase()) {
341 out.push({ line: at(m.index!), rule: "echo", text: `aria-label="${m[2]}" beside the visible "${visible}"`, use: "either the icon with the aria-label, or the visible word with the icon aria-hidden — never both" });
342 }
343 }
344 // A colour name in an inline style="": the stylesheet scan reads css templates only.
345 for (const m of code.matchAll(/\bstyle="([^"]*)"/g)) {
346 const bad = m[1]!.match(NAMED_RE);
347 if (bad) out.push({ line: at(m.index!), rule: "inline-colour", text: bad[0].trim(), use: tokenFor(bad[0]) });
348 }
349 return out.sort((a, b) => a.line - b.line);
350 }
351
352 export const QUIET_DEBT = "docs/design/quiet-debt.json";
353 export type QuietDebt = Record<string, Partial<Record<QuietRule, number>>>;
354
355 /** Per file and rule, how many findings there are: the shape the debt list is kept in. */
356 export function quietCounts(files: Record<string, QuietFinding[]>): QuietDebt {
357 const out: QuietDebt = {};
358 for (const [f, list] of Object.entries(files)) {
359 for (const x of list) (out[f] ??= {})[x.rule] = ((out[f] ??= {})[x.rule] ?? 0) + 1;
360 }
361 return out;
362 }
363
364 /** What broke a quiet rule beyond the debt, and a debt that has been paid and not struck. Empty passes. */
365 export function compareQuiet(debt: QuietDebt, files: Record<string, QuietFinding[]>): string[] {
366 const out: string[] = [];
367 const now = quietCounts(files);
368 for (const f of new Set([...Object.keys(now), ...Object.keys(debt)])) {
369 for (const r of QUIET_RULES) {
370 const n = now[f]?.[r] ?? 0, d = debt[f]?.[r] ?? 0;
371 if (n > d) {
372 const where = (files[f] ?? []).filter((x) => x.rule === r).map((x) => `\n ${f}:${x.line} ${x.text} → use ${x.use}`).join("");
373 out.push(`${f}: ${n - d} new quiet-page "${r}" finding(s) (${d} already there are debt in ${QUIET_DEBT}):${where}`);
374 } else if (n < d) out.push(`${f}: the "${r}" debt is ${d} and only ${n} remain — lower it in ${QUIET_DEBT} (bun scripts/design-lint.ts --update)`);
375 }
376 }
377 return out;
378 }
379
380 export function scanQuiet(root: string): Record<string, QuietFinding[]> {
381 const out: Record<string, QuietFinding[]> = {};
382 for (const f of sourceFiles(root)) {
383 const found = quiet(readFileSync(f, "utf8"));
384 if (found.length) out[relative(root, f)] = found;
385 }
386 return out;
387 }
388
311389 const BASELINE = "docs/design/baseline.json";
312390 const DESIGN = "docs/DESIGN.md";
313391 const SECTIONS = "docs/design/sections";
@@ -319,10 +397,12 @@ if (import.meta.main) {
319397 if (process.argv.includes("--update")) {
320398 const baseline = { ...JSON.parse(readFileSync(join(root, BASELINE), "utf8")), files: now.files }; // keeps `coldRead` (#5882)
321399 writeFileSync(join(root, BASELINE), JSON.stringify(baseline, null, 2) + "\n");
400 writeFileSync(join(root, QUIET_DEBT), JSON.stringify(quietCounts(scanQuiet(root)), null, 2) + "\n");
322401 console.log(`wrote ${BASELINE}`);
323402 } else {
324403 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
325 const problems = compare(base, now, designText(root), areaFiles(root));
404 const debt: QuietDebt = JSON.parse(readFileSync(join(root, QUIET_DEBT), "utf8"));
405 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root))];
326406 for (const p of problems) console.error(p);
327407 if (problems.length) process.exit(1);
328408 console.log("design: no colour, size or component added outside the system");
test/design-lint.test.ts +47 −1
@@ -1,6 +1,6 @@
11 import { describe, expect, test } from "bun:test";
22 import { readdirSync, readFileSync } from "node:fs";
3 import { areaFiles, classesIn, compare, countFile, designText, documentedClasses, locate, registeredClasses, scan, sectionLinks, webCss } from "../scripts/design-lint.ts";
3 import { areaFiles, classesIn, compare, compareQuiet, quiet, scanQuiet, countFile, designText, documentedClasses, locate, registeredClasses, scan, sectionLinks, 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 = [
@@ -206,3 +206,49 @@ describe("search inputs (#4750)", () => {
206206 expect(readFileSync("src/web/style/components.ts", "utf8")).toContain(".bar .input[type=search]");
207207 });
208208 });
209
210 describe("design check: the quiet-page rules (#8775)", () => {
211 const rules = (t: string) => quiet(t).map((f) => f.rule);
212 const css = (c: string) => "x = /* css */ `" + c + "`";
213
214 test("label: Status: before a status word, not a sentence", () => {
215 expect(rules("h = `<p>Status: Open</p>`")).toEqual(["label"]);
216 expect(rules("h = `<dt>State:</dt><dd>${s}</dd>`")).toEqual(["label"]);
217 expect(rules("h = `<p>Status: ${s}</p>`")).toEqual(["label"]);
218 expect(rules("h = `<p>The status: what a pull is doing now.</p>`")).toEqual([]);
219 expect(rules('h = `<span class="dot" title="Open"></span>`')).toEqual([]);
220 });
221
222 test("caps: uppercase in a stylesheet, with its line", () => {
223 const f = quiet(css(".k{\n text-transform: uppercase}"));
224 expect(f).toHaveLength(1);
225 expect(f[0]).toMatchObject({ rule: "caps", line: 2 });
226 expect(rules(css(".k{font-weight:600}"))).toEqual([]);
227 expect(rules("p = `Write it in uppercase`")).toEqual([]);
228 });
229
230 test("echo: an icon's aria-label equal to its visible text", () => {
231 expect(rules('h = `<button aria-label="Merge"><svg><use href="#m"/></svg> Merge</button>`')).toEqual(["echo"]);
232 expect(rules('h = `<button aria-label="Merge"><svg><use href="#m"/></svg></button>`')).toEqual([]);
233 expect(rules('h = `<button aria-label="Merge"><svg></svg> Merge pull</button>`')).toEqual([]);
234 expect(rules('h = `<button aria-label="Merge">Merge</button>`')).toEqual([]);
235 });
236
237 test("inline-colour: a colour name in style, not a token", () => {
238 expect(rules('h = `<i style="color: red"></i>`')).toEqual(["inline-colour"]);
239 expect(rules('h = `<i style="color: var(--danger)"></i>`')).toEqual([]);
240 expect(countFile('h = `<i style="color:#f00"></i>`').colour).toBe(1);
241 });
242
243 test("a pull may not add to the debt, and a paid debt must be struck", () => {
244 const f = { "src/web/a.ts": quiet(css("a{text-transform:uppercase}")) };
245 expect(compareQuiet({ "src/web/a.ts": { caps: 1 } }, f)).toEqual([]);
246 expect(compareQuiet({}, f)[0]).toContain("src/web/a.ts:1");
247 expect(compareQuiet({ "src/web/a.ts": { caps: 2 } }, f)[0]).toContain("lower it");
248 });
249
250 test("the tree breaks no rule beyond its debt", () => {
251 const debt = JSON.parse(readFileSync("docs/design/quiet-debt.json", "utf8"));
252 expect(compareQuiet(debt, scanQuiet("."))).toEqual([]);
253 }, 60_000);
254 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs