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

docs/DESIGN.md holds the rules of attention, and every page has a measured first-view budget #10331

Merged opened this in answer to

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

Merged

scout/forge:scout/10297-docs-design-md-holds-the-rules-of-attent → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10297-docs-design-md-holds-the-rules-of-attent was merged into main as e5cf0df.

Review

Sign in to review this change.
8 files changed +442 −4
README.md +1
@@ -39,6 +39,7 @@ The crew needs the `claude` CLI and a model credential. On Linux, install
3939 - [How apps, previews and runs work](docs/APPS.md)
4040 - [Connecting forges](docs/ORIGINS.md)
4141 - [Developing the forge](docs/DEVELOP.md)
42 - [Attention budgets: words, blocks and colours per page, and the baseline](docs/features/attention.md)
4243 - [If something goes wrong](docs/SECURITY-RUNBOOK.md)
4344 - [All documentation](docs/README.md)
4445
docs/DESIGN.md +17
@@ -11,6 +11,23 @@
1111 7. Rows over boxes: a list is rows, a fact is a `dl` row, not a card.
1212 8. Plain words, as in `docs/WRITING.md`.
1313
14 **The five rules of attention.** Every pass over a page follows them; the owner's time is the cost.
15 Each has a real page as its pair. Enforced by rule 5: `test/attention.test.ts`.
16
17 1. **One answer.** A page answers its one question at first sight. State is a word and a glyph, not a sentence.
18 - Do: `securityPage` gives each fact a toned `badge` (Recent, Overdue), not a paragraph.
19 - Don't: `previewSection` (`src/web/pages/apps.ts`) says "The last deploy of this preview failed. Push again to redeploy it." where `Failed` with the tone says it.
20 2. **No notes.** Explanatory prose that does not change a decision goes. Help lives in one place, on demand.
21 - Do: the home composer is the sentence of purpose and the control itself (`studioHome`).
22 - Don't: a `<p class="muted small">` under a list that explains what the list is; the heading already did.
23 3. **Real depth only.** Details, history, logs and evidence open in place, in their own compartment. The essential is never one tap away, and nothing is a tap with nothing behind it.
24 - Do: the `disclosure` in `src/web/ui.ts` (used in `src/web/pages/new-forge.ts`) holds the optional extras; the choice stays on the page.
25 - Don't: a disclosure that opens onto one line, or one that hides the verdict.
26 4. **Compartments.** Each concern sits in one region with one heading, in the same order on every page. Regions never repeat each other.
27 - Do: `/settings`, where each concern is one `settings-block` with one heading.
28 - Don't: the same fact in the header, the tab count and a status line, as the Pulls tab and its list once did (#10312).
29 5. **Measured.** Every page has a first-view budget at a narrow (390 px) and a wide (1280 px) width: visible words, text blocks and distinct colours, in `docs/design/attention-budget.json`. A page over its budget fails `test/attention.test.ts`. A pass may lower a budget; raising one needs a note in the pull. See `docs/features/attention.md`.
30
1431 **Worked examples.**
1532 - `src/web/studio/home.ts`, `studioHome`: the `<h1 class="sr-only">Home</h1>` then `tellBox` is
1633 one sentence of purpose (1) and the real composer, not a picture of one (2); `<section class="stack">`
docs/design/attention-budget.json added +122
@@ -0,0 +1,122 @@
1 {
2 "home": {
3 "390": {
4 "words": 182,
5 "blocks": 20,
6 "colours": 3
7 },
8 "1280": {
9 "words": 182,
10 "blocks": 20,
11 "colours": 3
12 }
13 },
14 "repository": {
15 "390": {
16 "words": 189,
17 "blocks": 28,
18 "colours": 2
19 },
20 "1280": {
21 "words": 189,
22 "blocks": 28,
23 "colours": 2
24 }
25 },
26 "app": {
27 "390": {
28 "words": 189,
29 "blocks": 28,
30 "colours": 2
31 },
32 "1280": {
33 "words": 189,
34 "blocks": 28,
35 "colours": 2
36 }
37 },
38 "pull": {
39 "390": {
40 "words": 133,
41 "blocks": 22,
42 "colours": 4
43 },
44 "1280": {
45 "words": 133,
46 "blocks": 22,
47 "colours": 4
48 }
49 },
50 "issue": {
51 "390": {
52 "words": 76,
53 "blocks": 19,
54 "colours": 2
55 },
56 "1280": {
57 "words": 76,
58 "blocks": 19,
59 "colours": 2
60 }
61 },
62 "issues list": {
63 "390": {
64 "words": 32,
65 "blocks": 8,
66 "colours": 2
67 },
68 "1280": {
69 "words": 32,
70 "blocks": 8,
71 "colours": 2
72 }
73 },
74 "pulls list": {
75 "390": {
76 "words": 31,
77 "blocks": 7,
78 "colours": 1
79 },
80 "1280": {
81 "words": 31,
82 "blocks": 7,
83 "colours": 1
84 }
85 },
86 "runs": {
87 "390": {
88 "words": 46,
89 "blocks": 8,
90 "colours": 1
91 },
92 "1280": {
93 "words": 46,
94 "blocks": 8,
95 "colours": 1
96 }
97 },
98 "run": {
99 "390": {
100 "words": 34,
101 "blocks": 11,
102 "colours": 3
103 },
104 "1280": {
105 "words": 34,
106 "blocks": 11,
107 "colours": 3
108 }
109 },
110 "settings": {
111 "390": {
112 "words": 274,
113 "blocks": 37,
114 "colours": 1
115 },
116 "1280": {
117 "words": 274,
118 "blocks": 37,
119 "colours": 1
120 }
121 }
122 }
docs/features/attention.md added +33
@@ -0,0 +1,33 @@
1 # Attention budgets
2
3 `docs/DESIGN.md` holds the five rules of attention. This is how they are measured.
4
5 `bun test test/attention.test.ts` renders each main page (signed in, on a small seeded forge) and counts what a
6 reader sees on first view at 390 px and 1280 px: **words**, **text blocks** and **distinct colours**
7 (`src/measure/attention.ts`). It prints each page's numbers against its budget in
8 `docs/design/attention-budget.json`, and fails when any number is over.
9
10 - A pass that makes a page quieter lowers its budget: `FORGE_ATTENTION_WRITE=1 bun test test/attention.test.ts`
11 rewrites the file from today's numbers. Lowering is free.
12 - Raising a number needs a note in the pull saying why. Nothing else may.
13 - It counts the served HTML, with no browser: `hidden`, `.sr-only`, `.js-only`, the body of a closed `<details>`,
14 and the classes the stylesheet hides at that width (`.hide-sm`, `.hide-lg`, `.keyhint`, `.crumbs`) are not seen.
15 Layout is not simulated, so the whole visible `<main>` counts as first view, and today the two widths agree.
16 A colour is a tone (`data-variant`, `data-tone`, `data-state`, `data-status`) other than the neutral ones, or a colour written in a `style`.
17 - On a live app: `bun scripts/measure-page.ts --attention /path …` prints one JSON line per page, `{path, status, firstView: {390: {words, blocks, colours}, 1280: …}}`, from a seeded forge it boots itself.
outdated

This lists data-variant, data-tone and data-state, but the code also counts data-status. Align them. Also document bun scripts/measure-page.ts --attention /path.

18 - `/:owner/:name/app` redirects to the repository page, so "a repository" and "an app" are one page today.
19
20 ## Baseline (words / blocks / colours)
21
22 | Page | 390 px | 1280 px |
23 |---|---|---|
24 | home | 182 / 20 / 3 | 182 / 20 / 3 |
25 | repository | 189 / 28 / 2 | 189 / 28 / 2 |
26 | app | 189 / 28 / 2 | 189 / 28 / 2 |
27 | pull | 133 / 22 / 4 | 133 / 22 / 4 |
28 | issue | 76 / 19 / 2 | 76 / 19 / 2 |
29 | issues list | 32 / 8 / 2 | 32 / 8 / 2 |
30 | pulls list | 31 / 7 / 1 | 31 / 7 / 1 |
31 | runs | 46 / 8 / 1 | 46 / 8 / 1 |
32 | run | 34 / 11 / 3 | 34 / 11 / 3 |
33 | settings | 274 / 37 / 1 | 274 / 37 / 1 |
scripts/measure-page.ts +8 −4
@@ -4,13 +4,14 @@
44 * the word count (`src/measure/words.ts`, the one method both modes use).
55 * Usage and the table it prints: docs/features/measure-page.md.
66 *
7 * bun scripts/measure-page.ts [--root DIR] [--width 1440] [--shots DIR] [--words-only] /path[#id] …
7 * bun scripts/measure-page.ts [--root DIR] [--width 1440] [--shots DIR] [--words-only | --attention] /path[#id] …
88 */
99 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join, resolve } from "node:path";
1212 import { childEnv } from "../src/apps/process.ts";
1313 import { findChromium } from "../src/looks/capture.ts";
14 import { attentionLine } from "../src/measure/attention.ts";
1415 import { wordsOf } from "../src/measure/words.ts";
1516
1617 const args = process.argv.slice(2);
@@ -18,13 +19,14 @@ const take = (flag: string): string | undefined => {
1819 const at = args.indexOf(flag);

The --attention branch has no test. Extract the output and test it, or drop the flag.

1920 return at < 0 ? undefined : args.splice(at, 2)[1];
2021 };
21 const wordsOnly = args.includes("--words-only");
22 const attention = args.includes("--attention");
23 const wordsOnly = args.includes("--words-only") || attention;
2224 const root = resolve(take("--root") ?? ".");
2325 const width = Number(take("--width") ?? 1440);
2426 const shots = take("--shots");
2527 const paths = args.filter((a) => a.startsWith("/"));
2628 if (!Number.isInteger(width) || width < 200 || width > 4000 || paths.length === 0) {
27 console.error("usage: bun scripts/measure-page.ts [--root DIR] [--width N] [--shots DIR] [--words-only] /path[#id] …");
29 console.error("usage: bun scripts/measure-page.ts [--root DIR] [--width N] [--shots DIR] [--words-only | --attention] /path[#id] …");
2830 process.exit(2);
2931 }
3032 const binary = findChromium();
@@ -154,7 +156,9 @@ try {
154156 if (wordsOnly) {
155157 const [path, id] = split(target);
156158 const res = await fetch(`${servers.url}${path}`, { headers: { cookie: `forge_session=${token}` } });
157 console.log(JSON.stringify({ path: target, status: res.status, words: wordsOf(await res.text(), id) }));
159 const html = await res.text();
160 if (attention) console.log(attentionLine(target, res.status, html));
161 else console.log(JSON.stringify({ path: target, status: res.status, words: wordsOf(html, id) }));
158162 continue;
159163 }
160164 console.log(JSON.stringify({ path: target, width, ...(await look(target, width, false)) }));
src/measure/attention.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * What a reader sees on first view of a page (#10297), counted from its HTML
3 * with no browser: visible words, text blocks and distinct colours, at a phone
4 * width and a desktop width. "Visible" is what the stylesheet and the markup
5 * decide without a click: `hidden`, `.sr-only`, `.js-only`, the closed body of
6 * a `<details>`, and the classes the stylesheet hides at that width
7 * (`.hide-sm` under 720 px, `.hide-lg` from 720 px, `.keyhint` to 640 px,
8 * `.crumbs` under 720 px). Layout is not simulated: "first view" is the
9 * whole visible `<main>`, the stricter reading. Words are counted as
10 * `src/measure/words.ts` counts them.
11 */
12 import { mainHtml, textOf } from "./words.ts";
13
14 export type Attention = { words: number; blocks: number; colours: number };
15 export const WIDTHS = [390, 1280] as const;
16
17 const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
18 const GONE = new Set(["script", "style", "template", "noscript", "svg"]);
19 /** Tags whose text belongs to the nearest block around them: a link in a sentence is not a second block. */
20 const INLINE = new Set(["a", "span", "b", "i", "em", "strong", "code", "small", "time", "abbr", "mark", "s", "u", "sub", "sup", "kbd", "button", "label"]);
21 const COLOUR_ATTR = /\b(?:data-variant|data-tone|data-state|data-status)="([^"]+)"/g;
22 const COLOUR_STYLE = /(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|var\(--[a-z0-9-]*(?:color|accent|ok|warn|bad|danger|green|red|amber|blue)[a-z0-9-]*\))/gi;
23 const NEUTRAL = new Set(["default", "secondary", "outline", "muted", "none", "neutral"]);
24
25 function hiddenClasses(width: number): Set<string> {
26 const out = new Set(["sr-only", "js-only"]);
27 if (width < 720) {
28 out.add("hide-sm");
29 out.add("crumbs");
30 } else out.add("hide-lg");
31 if (width <= 640) out.add("keyhint");
32 return out;
33 }
34
35 const attrOf = (tag: string, name: string): string | undefined =>
36 new RegExp(`\\s${name}(?:="([^"]*)")?(?=[\\s/>]|$)`, "i").exec(tag)?.[1] ?? (new RegExp(`\\s${name}(?=[\\s/>]|$)`, "i").test(tag) ? "" : undefined);
37
38 /** The first view of `html` (a whole page or its `<main>`) at `width` px. */
39 export function firstView(html: string, width: number): Attention {
40 const main = mainHtml(html) ?? html;
41 const hide = hiddenClasses(width);
42 const colours = new Set<string>();
43 let words = 0;
44 let blocks = 0;
45 // Each open element: its tag, whether it hides its subtree, whether it is a
46 // closed details (whose summary alone stays), and whether it has text yet.
47 const stack: { tag: string; off: boolean; closed: boolean; text: boolean }[] = [];
48 const off = (): boolean => stack.some((e) => e.off);
49 const tokens = /<!--[\s\S]*?-->|<(\/?)([a-z][a-z0-9-]*)\b([^>]*)>|([^<]+)/gi;
50 for (let m = tokens.exec(main); m !== null; m = tokens.exec(main)) {
51 if (m[4] !== undefined) {
52 const n = textOf(m[4]).split(/\s+/).filter(Boolean).length;
53 // Bare text straight inside a closed <details> is as hidden as its elements.
54 if (n > 0 && !off() && stack[stack.length - 1]?.closed !== true) {
55 words += n;
56 const owner = [...stack].reverse().find((e) => !INLINE.has(e.tag));
57 if (owner !== undefined && !owner.text) {
58 owner.text = true;
59 blocks++;
60 }
61 }
62 continue;
63 }
64 if (m[2] === undefined) continue;
65 const tag = m[2].toLowerCase();
66 if (m[1] === "/") {
67 for (let i = stack.length - 1; i >= 0; i--)
68 if (stack[i]!.tag === tag) {
69 stack.length = i;
70 break;
71 }
72 continue;
73 }
74 const attrs = m[3]!;
75 if (GONE.has(tag) && !VOID.has(tag)) {
76 // Skip to the matching end tag: nothing inside is read.
77 const end = new RegExp(`</${tag}\\s*>`, "gi");
78 end.lastIndex = tokens.lastIndex;
79 const e = end.exec(main);
80 tokens.lastIndex = e === null ? main.length : e.index + e[0].length;
81 continue;
82 }
83 const classes = (attrOf(attrs, "class") ?? "").split(/\s+/);
84 const parent = stack[stack.length - 1];
85 const closedBody = parent?.closed === true && tag !== "summary";
86 const here =
87 attrOf(attrs, "hidden") !== undefined || classes.some((c) => hide.has(c)) || closedBody ||
88 /\baria-hidden="true"/.test(attrs) || attrOf(attrs, "type")?.toLowerCase() === "hidden";
89 if (!here && !off()) {
90 for (const v of attrs.matchAll(COLOUR_ATTR)) if (!NEUTRAL.has(v[1]!)) colours.add(v[1]!.toLowerCase());
91 const style = attrOf(attrs, "style");
92 if (style !== undefined) for (const v of style.matchAll(COLOUR_STYLE)) colours.add(v[1]!.toLowerCase().replace(/\s+/g, ""));
93 }
94 if (!VOID.has(tag) && !/\/\s*$/.test(attrs)) {
95 stack.push({ tag, off: here, closed: tag === "details" && attrOf(attrs, "open") === undefined, text: false });
96 }
97 }
98 return { words, blocks, colours: colours.size };
99 }
100
101 /** The line `measure-page --attention` prints for one page: its path, status and first view at each width. */
102 export const attentionLine = (path: string, status: number, html: string): string =>
103 JSON.stringify({ path, status, firstView: Object.fromEntries(WIDTHS.map((w) => [w, firstView(html, w)])) });
test/attention-count.test.ts added +44
@@ -0,0 +1,44 @@
1 /** `firstView` counts only what a reader sees without a click (#10297). */
2 import { expect, test } from "bun:test";
3 import { attentionLine, firstView } from "../src/measure/attention.ts";
4
5 const page = (inner: string): string => `<html><body><main>${inner}</main></body></html>`;
6
7 test("words, blocks and colours of what is shown", () => {
8 const v = firstView(page(`<h1>One answer</h1><p>Two words <span data-variant="success">ok</span></p>`), 1280);
9 expect(v).toEqual({ words: 5, blocks: 2, colours: 1 });
10 });
11
12 test("hidden, screen-reader, script and closed details are not seen", () => {
13 const html = page(
14 `<p hidden>a b c</p><p class="sr-only">d e</p><script>var x = 1 2 3</script>` +
15 `<details><summary>More</summary><p>deep detail here</p></details>` +
16 `<details open><summary>Open</summary><p>shown now</p></details>`,
17 );
18 expect(firstView(html, 1280)).toEqual({ words: 4, blocks: 3, colours: 0 });
19 });
20
21 test("the width decides what the stylesheet hides", () => {
22 const html = page(`<p class="hide-sm">wide only</p><p class="hide-lg">narrow only now</p>`);
23 expect(firstView(html, 390).words).toBe(3);
24 expect(firstView(html, 1280).words).toBe(2);
25 });
26
27 test("neutral variants are not colours; styled colours are", () => {
28 const v = firstView(page(`<span data-variant="secondary">a</span><i style="color:#f00">b</i><i style="color: #F00">c</i>`), 390);
29 expect(v.colours).toBe(1);
30 });
31
32 test("bare text directly inside a closed details is not seen", () => {
33 const html = page(`<details><summary>More</summary>loose hidden words</details><details open><summary>Open</summary>loose shown</details>`);
34 expect(firstView(html, 1280).words).toBe(4);
35 });
36
37 test("the --attention line carries path, status and both widths", () => {
38 const line = JSON.parse(attentionLine("/x", 200, page(`<p>one two</p>`)));
39 expect(line).toEqual({ path: "/x", status: 200, firstView: { 390: { words: 2, blocks: 1, colours: 0 }, 1280: { words: 2, blocks: 1, colours: 0 } } });
40 });
41
42 test("data-status counts as a colour", () => {
43 expect(firstView(page(`<i data-status="failed">x</i>`), 390).colours).toBe(1);
44 });
test/attention.test.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * Every main page has a first-view budget (#10297): visible words, text
3 * blocks and distinct colours, at 390 px and 1280 px, in
4 * docs/design/attention-budget.json. A page over its budget fails. A pass may
5 * lower a budget (run with FORGE_ATTENTION_WRITE=1 to rewrite the file);
6 * nothing may raise one without a note in the pull.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { enableApp } from "../src/actions/apps.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { createRun } from "../src/core/runs.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { firstView, WIDTHS, type Attention } from "../src/measure/attention.ts";
21 import { proveAll } from "./helpers/verified.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 const BUDGET = "docs/design/attention-budget.json";
27 type Budget = Record<string, Record<string, Attention>>;
28
29 let root = "";
30 let app: App;
31 let servers: Servers;
32 let cookie = "";
33 let pages: Record<string, string> = {};
34 const seen: Budget = {};
35
36 beforeAll(async () => {
37 root = mkdtempSync(join(tmpdir(), "forge-attention-"));
38 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
39 const u = await createUser(app.db, {
40 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
41 password: "a-good-passphrase", adminIfFirst: true,
42 });
43 if (!u.ok) throw new Error(u.error);
44 proveAll(app);
45 const actor = { id: u.value.id, username: "alice" } as never;
46 const made = await createRepo(app, actor, { name: "vault" });
47 if (!made.ok) throw new Error(String(made.error));
48 const repo = made.value;
49 for (const [branch, path] of [["main", "README.md"], ["feature/x", "x.ts"]] as const) {
50 const c = await commitToBranch(app, u.value, repo, {
51 message: "Start", branch, files: [{ path, content: "v\n" }],
52 } as never);
53 if (!c.ok) throw new Error(JSON.stringify(c.error));
54 }
55 const enabled = await enableApp(app, u.value, repo, { slug: "vault" });
56 if (!enabled.ok) throw new Error(String(enabled.error));
57 const issue = openIssue(app, u.value, repo, { title: "Make the vault faster", body: "It is slow." });
58 if (!issue.ok) throw new Error(String(issue.error));
59 const pull = await openPull(app, u.value, repo, { title: "Add x", baseBranch: "main", headBranch: "feature/x" });
60 if (!pull.ok) throw new Error(String(pull.error));
61 const run = createRun(app.db, { repoId: repo.id, kind: "check", env: "main", commitOid: "0".repeat(40), reason: "push" });
62 servers = await serve(app);
63 const signin = await fetch(`${servers.url}/signin`, {
64 method: "POST", headers: { origin: servers.url },
65 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual",
66 });
67 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
68 const r = "/alice/vault";
69 const paths: Record<string, string> = {
70 home: "/", repository: r, app: `${r}/app`, pull: `${r}/pulls/${pull.value.number}`,
71 issue: `${r}/issues/${issue.value.number}`, "issues list": `${r}/issues`, "pulls list": `${r}/pulls`,
72 runs: `${r}/runs`, run: `${r}/runs/${run.id}`, settings: "/settings",
73 };
74 for (const [name, path] of Object.entries(paths)) {
75 const res = await fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "follow" });
76 if (res.status !== 200) throw new Error(`${name} ${path} answered ${res.status}`);
77 pages[name] = await res.text();
78 }
79 });
80 afterAll(() => {
81 servers.stop();
82 app.close();
83 rmSync(root, { recursive: true, force: true });
84 });
85
86 test("each page stays inside its first-view budget", () => {
87 for (const [name, html] of Object.entries(pages)) {
88 seen[name] = {};
89 for (const w of WIDTHS) seen[name]![String(w)] = firstView(html, w);
90 }
91 if (process.env.FORGE_ATTENTION_WRITE === "1") writeFileSync(BUDGET, `${JSON.stringify(seen, null, 2)}\n`);
92 const budget = JSON.parse(readFileSync(BUDGET, "utf8")) as Budget;
93 const rows: string[] = ["page".padEnd(13) + "width words blocks colours"];
94 const over: string[] = [];
95 for (const name of Object.keys(pages))
96 for (const w of WIDTHS) {
97 const got = seen[name]![String(w)]!;
98 const max = budget[name]?.[String(w)];
99 const cell = (k: keyof Attention): string => `${got[k]}/${max?.[k] ?? "-"}`.padEnd(8);
100 rows.push(`${name.padEnd(13)}${String(w).padEnd(7)}${cell("words")}${cell("blocks")}${cell("colours")}`);
101 if (max === undefined) over.push(`${name} @${w}: no budget`);
102 else for (const k of ["words", "blocks", "colours"] as const)
103 if (got[k] > max[k]) over.push(`${name} @${w}: ${k} ${got[k]} > budget ${max[k]}`);
104 }
105 console.log(`${rows.join("\n")}\n(seen/budget)`);
106 expect(over).toEqual([]);
107 });
108
109 test("the budget file names every page at both widths and no other", () => {
110 // A budget for every page at both widths, and no stale page in the file.
111 const budget = JSON.parse(readFileSync(BUDGET, "utf8")) as Budget;
112 expect(Object.keys(budget).sort()).toEqual(Object.keys(pages).sort());
113 for (const name of Object.keys(budget)) expect(Object.keys(budget[name]!)).toEqual(WIDTHS.map(String));
114 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs