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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8775-design-lint-holds-the-quiet-page-rules-a → main 1 commit fork
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 @@ | ||
| 30 | 30 | * target a button, link, input or select under 24px wide or high, WCAG 2.5.8 |
| 31 | 31 | * (specification.website, target size) |
| 32 | 32 | * |
| 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 | * | |
| 33 | 39 | * A red names the file:line, the literal and what to use instead, so one pass |
| 34 | 40 | * fixes it; run `bun scripts/design-lint.ts` before pushing. A new class is named |
| 35 | 41 | * 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 | ||
| 308 | 314 | return out; |
| 309 | 315 | } |
| 310 | 316 | |
| 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 | ||
| 311 | 389 | const BASELINE = "docs/design/baseline.json"; |
| 312 | 390 | const DESIGN = "docs/DESIGN.md"; |
| 313 | 391 | const SECTIONS = "docs/design/sections"; |
| @@ -319,10 +397,12 @@ if (import.meta.main) { | ||
| 319 | 397 | if (process.argv.includes("--update")) { |
| 320 | 398 | const baseline = { ...JSON.parse(readFileSync(join(root, BASELINE), "utf8")), files: now.files }; // keeps `coldRead` (#5882) |
| 321 | 399 | writeFileSync(join(root, BASELINE), JSON.stringify(baseline, null, 2) + "\n"); |
| 400 | writeFileSync(join(root, QUIET_DEBT), JSON.stringify(quietCounts(scanQuiet(root)), null, 2) + "\n"); | |
| 322 | 401 | console.log(`wrote ${BASELINE}`); |
| 323 | 402 | } else { |
| 324 | 403 | 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))]; | |
| 326 | 406 | for (const p of problems) console.error(p); |
| 327 | 407 | if (problems.length) process.exit(1); |
| 328 | 408 | console.log("design: no colour, size or component added outside the system"); |
test/design-lint.test.ts +47 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | 2 | 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"; | |
| 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 = [ |
| @@ -206,3 +206,49 @@ describe("search inputs (#4750)", () => { | ||
| 206 | 206 | expect(readFileSync("src/web/style/components.ts", "utf8")).toContain(".bar .input[type=search]"); |
| 207 | 207 | }); |
| 208 | 208 | }); |
| 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 | }); |
new above
offline