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

Merged opened this

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

Merged

scout/forge:scout/11458-design-lint-refuses-the-words-a-stranger → main 3 commits fork

  • Merged. scout/11458-design-lint-refuses-the-words-a-stranger was merged into main as ef71500.

Review

Sign in to review this change.
4 files changed +108 −5
docs/DESIGN.md +2 −2
@@ -14,9 +14,9 @@
1414 **The first five minutes.** Later work is judged against this one journey, a stranger's from a link to a change live:
1515 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.
1616
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.
1818
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.
2020
2121 **The five rules of attention.** Every pass over a page follows them; the owner's time is the cost.
2222 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 @@
3737 * accent token such as `--accent-2`; only `--accent` and `--accent-fg` exist).
3838 * They match markup and style, never a paragraph's words. Named in docs/design/areas/quiet.md.
3939 *
40 * `stranger` (#11458) names each word of `STRANGER_NEVER_SEES` that a stranger-path page says; see `strangerWords()`.
41 *
4042 * `banner` (#10699) refuses a standing alert in the home and board builders; see `banner()`.
4143 *
4244 * A red names the file:line, the literal and what to use instead, so one pass
@@ -59,6 +61,7 @@
5961
6062 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
6163 import { join, relative } from "node:path";
64 import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts";
6265
6366 /** The type scale in docs/DESIGN.md §3, in px. */
6467 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[] {
494497 .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`));
495498 }
496499
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
497577 const BASELINE = "docs/design/baseline.json";
498578 const DESIGN = "docs/DESIGN.md";
499579 const SECTIONS = "docs/design/sections";
@@ -510,7 +590,7 @@ if (import.meta.main) {
510590 } else {
511591 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
512592 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)];
514594 for (const p of problems) console.error(p);
515595 if (problems.length) process.exit(1);
516596 console.log("design: no colour, size or component added outside the system");
scripts/unreached-allow.json −1
@@ -1,6 +1,5 @@
11 [
22 "src/crew/governor_log.ts",
3 "src/design/first_five_minutes.ts",
43 "src/flow/clog_words.ts",
54 "src/flow/conflict_pairs.ts",
65 "src/flow/shared_red.ts",
test/design-lint.test.ts +25 −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, 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";
44
55 /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */
66 const DESIGN = [
@@ -286,3 +286,27 @@ describe("design check: a card around one sentence (#11190, principle 7)", () =>
286286 expect(bad).toEqual([]);
287287 });
288288 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs