Public: anyone can read this; the crew can change it.
design-lint refuses the words a stranger is never shown on the claim, start and waiting pages #11521
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11458-design-lint-refuses-the-words-a-stranger → main 3 commits fork
4 files
changed +108
−5
docs/DESIGN.md +2 −2
| @@ -14,9 +14,9 @@ | ||
| 14 | 14 | **The first five minutes.** Later work is judged against this one journey, a stranger's from a link to a change live: |
| 15 | 15 | 1. They follow a link. 2. They name the forge. 3. They choose a username and password. 4. They say what to build. 5. They see the crew working. |
| 16 | 16 | |
| 17 | Budgets: at most three screens before the forge; the forge ready in under 20 s; the first crew activity in under 60 s; the change live within five minutes. | |
| 17 | Budgets (`FIRST_FIVE_MINUTES`): at most three screens before the forge; the forge ready in under 20 s; the first crew activity in under 60 s; the change live within five minutes. | |
| 18 | 18 | |
| 19 | Words a stranger is never shown on this path: subscription, autonomy, cell, child, claim, founder, autonomous, mode. The list lives once, as `STRANGER_NEVER_SEES` in `src/design/first_five_minutes.ts` (with the budgets), for a test to import. | |
| 19 | Words a stranger is never shown on this path: subscription, autonomy, cell, child, claim, founder, autonomous, mode. The list lives once, as `STRANGER_NEVER_SEES` in `src/design/first_five_minutes.ts` (with the budgets), for a test to import. `bun scripts/design-lint.ts` reads the visible text (not attributes, routes, code or styles) of `claim.ts`, `start-forge.ts`, `new-forge.ts` and `forge-waiting.ts` and names each of these words with its file and line. | |
| 20 | 20 | |
| 21 | 21 | **The five rules of attention.** Every pass over a page follows them; the owner's time is the cost. |
| 22 | 22 | Each has a real page as its pair. Enforced by rule 5: `test/attention.test.ts`. |
scripts/design-lint.ts +81 −1
| @@ -37,6 +37,8 @@ | ||
| 37 | 37 | * accent token such as `--accent-2`; only `--accent` and `--accent-fg` exist). |
| 38 | 38 | * They match markup and style, never a paragraph's words. Named in docs/design/areas/quiet.md. |
| 39 | 39 | * |
| 40 | * `stranger` (#11458) names each word of `STRANGER_NEVER_SEES` that a stranger-path page says; see `strangerWords()`. | |
| 41 | * | |
| 40 | 42 | * `banner` (#10699) refuses a standing alert in the home and board builders; see `banner()`. |
| 41 | 43 | * |
| 42 | 44 | * A red names the file:line, the literal and what to use instead, so one pass |
| @@ -59,6 +61,7 @@ | ||
| 59 | 61 | |
| 60 | 62 | import { readdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 61 | 63 | import { join, relative } from "node:path"; |
| 64 | import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts"; | |
| 62 | 65 | |
| 63 | 66 | /** The type scale in docs/DESIGN.md §3, in px. */ |
| 64 | 67 | export const TYPE_SCALE: readonly number[] = [11.5, 12.5, 13, 14, 15, 16, 20]; |
| @@ -494,6 +497,83 @@ export function scanCards(root: string): string[] { | ||
| 494 | 497 | .flatMap((f) => loneSentenceCards(readFileSync(f, "utf8")).map((c) => `${relative(root, f)}:${c.line} card(${c.text}…) → principle 7 (docs/DESIGN.md): a card (or a listCard of one bare row) around one sentence is a box around a thing; put the sentence directly under the page head, or make it a dl row`)); |
| 495 | 498 | } |
| 496 | 499 | |
| 500 | /** | |
| 501 | * The stranger's words (#11458): the screens a signed-out person passes from a | |
| 502 | * link to their forge show none of `STRANGER_NEVER_SEES`. Only visible text is | |
| 503 | * read: the words of a template or a sentence-like string, with tags (so | |
| 504 | * attributes), `${}` code, comments, CSS and scripts left out. "claim" stays a | |
| 505 | * fine route name and class; it is a refusal only where it is read. | |
| 506 | */ | |
| 507 | export const STRANGER_FILES = ["src/web/pages/claim.ts", "src/web/pages/start-forge.ts", "src/web/pages/new-forge.ts", "src/web/pages/forge-waiting.ts"]; | |
| 508 | const STRANGER_RE = new RegExp(`(?<![A-Za-z0-9_-])(?:${STRANGER_NEVER_SEES.join("|")})(?:s|ed|ing)?(?![A-Za-z0-9_-])`, "gi"); | |
| 509 | export type StrangerFinding = { line: number; word: string; text: string }; | |
| 510 | ||
| 511 | /** The strings of `code`: [start offset, raw text with `${}` holes blanked]; nested templates are their own strings. */ | |
| 512 | function stringsOf(code: string): { at: number; text: string; quote: string }[] { | |
| 513 | const out: { at: number; text: string; quote: string }[] = []; | |
| 514 | const template = (i: number): number => { | |
| 515 | let text = ""; | |
| 516 | const at = i + 1; | |
| 517 | for (i++; i < code.length; i++) { | |
| 518 | const c = code[i]!; | |
| 519 | if (c === "\\") { text += " "; i++; continue; } | |
| 520 | if (c === "`") break; | |
| 521 | if (c === "$" && code[i + 1] === "{") { | |
| 522 | let depth = 1; | |
| 523 | i += 2; | |
| 524 | text += " "; | |
| 525 | for (; i < code.length && depth > 0; i++) { | |
| 526 | const d = code[i]!; | |
| 527 | if (d === "`") { i = template(i); text += " "; i--; continue; } | |
| 528 | if (d === '"' || d === "'") { const e = code.indexOf(d, i + 1); i = e < 0 ? code.length : e; text += " "; continue; } | |
| 529 | if (d === "{") depth++; | |
| 530 | else if (d === "}") depth--; | |
| 531 | if (d === "\n") text += "\n"; | |
| 532 | } | |
| 533 | i--; | |
| 534 | continue; | |
| 535 | } | |
| 536 | text += c; | |
| 537 | } | |
| 538 | out.push({ at, text, quote: "`" }); | |
| 539 | return i; | |
| 540 | }; | |
| 541 | for (let i = 0; i < code.length; i++) { | |
| 542 | const c = code[i]!; | |
| 543 | if (c === "`") i = template(i); | |
| 544 | else if (c === '"' || c === "'") { | |
| 545 | let j = i + 1; | |
| 546 | while (j < code.length && code[j] !== c && code[j] !== "\n") j += code[j] === "\\" ? 2 : 1; | |
| 547 | if (code[j] === c) out.push({ at: i + 1, text: code.slice(i + 1, j), quote: c }); | |
| 548 | i = j; | |
| 549 | } else if (c === "/" && /[(,=:]\s*$/.test(code.slice(Math.max(0, i - 4), i)) && code[i + 1] !== "/") { | |
| 550 | const e = code.indexOf("\n", i); // a regex literal: skip to the line's end rather than read its quotes | |
| 551 | i = e < 0 ? code.length : e; | |
| 552 | } | |
| 553 | } | |
| 554 | return out; | |
| 555 | } | |
| 556 | ||
| 557 | export function strangerWords(text: string): StrangerFinding[] { | |
| 558 | const code = blanked(text); | |
| 559 | const out: StrangerFinding[] = []; | |
| 560 | for (const s of stringsOf(code)) { | |
| 561 | let t = s.text; | |
| 562 | if (s.quote === "`" && !/<[a-z]/i.test(t) && (!/\s/.test(t.trim()) || /^[\w./#:?&=@-]+\s*$/.test(t.trim()))) continue; // a key or a path, not a sentence | |
| 563 | if (s.quote === "`") { | |
| 564 | t = t.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, (m) => m.replace(/[^\n]/g, " ")).replace(/<[^<>]*>/g, (m) => m.replace(/[^\n]/g, " ")); | |
| 565 | if (/[{};]\s*[\w.#:-]*\s*[{};]|[{;]\s*$/.test(t) && /[{}]/.test(t)) continue; // CSS | |
| 566 | } else if (!/\s/.test(t) || /^[\w./#:?&=@-]+$/.test(t)) continue; // an id, a path, a key: not a sentence | |
| 567 | for (const m of t.matchAll(STRANGER_RE)) { | |
| 568 | out.push({ line: code.slice(0, s.at).split("\n").length + t.slice(0, m.index!).split("\n").length - 1, word: m[0], text: t.slice(Math.max(0, m.index! - 20), m.index! + m[0].length + 20).replace(/\s+/g, " ").trim() }); | |
| 569 | } | |
| 570 | } | |
| 571 | return out.sort((a, b) => a.line - b.line); | |
| 572 | } | |
| 573 | export function scanStranger(root: string): string[] { | |
| 574 | return STRANGER_FILES.flatMap((f) => strangerWords(readFileSync(join(root, f), "utf8")).map((w) => `${f}:${w.line} "${w.word}" in "${w.text}" → a stranger is never shown this word (docs/DESIGN.md § The first five minutes): say it in plain words`)); | |
| 575 | } | |
| 576 | ||
| 497 | 577 | const BASELINE = "docs/design/baseline.json"; |
| 498 | 578 | const DESIGN = "docs/DESIGN.md"; |
| 499 | 579 | const SECTIONS = "docs/design/sections"; |
| @@ -510,7 +590,7 @@ if (import.meta.main) { | ||
| 510 | 590 | } else { |
| 511 | 591 | const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8")); |
| 512 | 592 | const debt: QuietDebt = JSON.parse(readFileSync(join(root, QUIET_DEBT), "utf8")); |
| 513 | const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...scanCards(root)]; | |
| 593 | const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...scanCards(root), ...scanStranger(root)]; | |
| 514 | 594 | for (const p of problems) console.error(p); |
| 515 | 595 | if (problems.length) process.exit(1); |
| 516 | 596 | console.log("design: no colour, size or component added outside the system"); |
scripts/unreached-allow.json −1
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/crew/governor_log.ts", |
| 3 | "src/design/first_five_minutes.ts", | |
| 4 | 3 | "src/flow/clog_words.ts", |
| 5 | 4 | "src/flow/conflict_pairs.ts", |
| 6 | 5 | "src/flow/shared_red.ts", |
test/design-lint.test.ts +25 −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, compareQuiet, quiet, scanQuiet, scanCards, countFile, designText, documentedClasses, locate, loneSentenceCards, registeredClasses, scan, sectionLinks, webCss } from "../scripts/design-lint.ts"; | |
| 3 | import { areaFiles, classesIn, compare, compareQuiet, quiet, scanQuiet, scanCards, scanStranger, strangerWords, STRANGER_FILES, countFile, designText, documentedClasses, locate, loneSentenceCards, 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 = [ |
| @@ -286,3 +286,27 @@ describe("design check: a card around one sentence (#11190, principle 7)", () => | ||
| 286 | 286 | expect(bad).toEqual([]); |
| 287 | 287 | }); |
| 288 | 288 | }); |
| 289 | ||
| 290 | describe("the stranger's words (#11458)", () => { | |
| 291 | test("a builder that says a banned word in visible text is named with its line", () => { | |
| 292 | const src = [ | |
| 293 | "export const page = () => html`<main>", | |
| 294 | ' <a href="/claim" title="your claim" class="claim-link">Open</a>', | |
| 295 | " <p>Your child forge is ready.</p>", | |
| 296 | " <p>${claimUrl} Check your subscription.</p>", | |
| 297 | "</main>`;", | |
| 298 | '// a claim in a comment is not read', | |
| 299 | 'export const note = "Autonomy is off here";', | |
| 300 | 'const key = `claim:${ip}`;', | |
| 301 | ].join("\n"); | |
| 302 | expect(strangerWords(src).map((w) => [w.line, w.word.toLowerCase()])).toEqual([[3, "child"], [4, "subscription"], [7, "autonomy"]]); | |
| 303 | }); | |
| 304 | test("only visible text counts: attributes, routes, scripts and styles do not", () => { | |
| 305 | const src = "const a = html`<a href=\"/claim\" aria-label=\"claim\">Open</a><style>.claim { color: red; }</style><script>fetch('/claim')</script>`;\nconst b = \"/claim\";"; | |
| 306 | expect(strangerWords(src)).toEqual([]); | |
| 307 | }); | |
| 308 | test("the stranger-path builders on main are clean", () => { | |
| 309 | expect(STRANGER_FILES.length).toBe(4); | |
| 310 | expect(scanStranger(process.cwd())).toEqual([]); | |
| 311 | }); | |
| 312 | }); |
new above
offline