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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10297-docs-design-md-holds-the-rules-of-attent → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 39 | 39 | - [](docs/APPS.md) |
| 40 | 40 | - [](docs/ORIGINS.md) |
| 41 | 41 | - [](docs/DEVELOP.md) |
| 42 | - [](docs/features/attention.md) | |
| 42 | 43 | - [](docs/SECURITY-RUNBOOK.md) |
| 43 | 44 | - [](docs/README.md) |
| 44 | 45 |
docs/DESIGN.md +17
| @@ -11,6 +11,23 @@ | ||
| 11 | 11 | 7. Rows over boxes: a list is rows, a fact is a `dl` row, not a card. |
| 12 | 12 | 8. Plain words, as in `docs/WRITING.md`. |
| 13 | 13 | |
| 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 | ||
| 14 | 31 | **Worked examples.** |
| 15 | 32 | - `src/web/studio/home.ts`, `studioHome`: the `<h1 class="sr-only">Home</h1>` then `tellBox` is |
| 16 | 33 | 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. | |
| 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 @@ | ||
| 4 | 4 | * the word count (`src/measure/words.ts`, the one method both modes use). |
| 5 | 5 | * Usage and the table it prints: docs/features/measure-page.md. |
| 6 | 6 | * |
| 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] … | |
| 8 | 8 | */ |
| 9 | 9 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join, resolve } from "node:path"; |
| 12 | 12 | import { childEnv } from "../src/apps/process.ts"; |
| 13 | 13 | import { findChromium } from "../src/looks/capture.ts"; |
| 14 | import { attentionLine } from "../src/measure/attention.ts"; | |
| 14 | 15 | import { wordsOf } from "../src/measure/words.ts"; |
| 15 | 16 | |
| 16 | 17 | const args = process.argv.slice(2); |
| @@ -18,13 +19,14 @@ const take = (flag: string): string | undefined => { | ||
| 18 | 19 | const at = args.indexOf(flag); |
The |
||
| 19 | 20 | return at < 0 ? undefined : args.splice(at, 2)[1]; |
| 20 | 21 | }; |
| 21 | const wordsOnly = args.includes("--words-only"); | |
| 22 | const attention = args.includes("--attention"); | |
| 23 | const wordsOnly = args.includes("--words-only") || attention; | |
| 22 | 24 | const root = resolve(take("--root") ?? "."); |
| 23 | 25 | const width = Number(take("--width") ?? 1440); |
| 24 | 26 | const shots = take("--shots"); |
| 25 | 27 | const paths = args.filter((a) => a.startsWith("/")); |
| 26 | 28 | 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] …"); | |
| 28 | 30 | process.exit(2); |
| 29 | 31 | } |
| 30 | 32 | const binary = findChromium(); |
| @@ -154,7 +156,9 @@ try { | ||
| 154 | 156 | if (wordsOnly) { |
| 155 | 157 | const [path, id] = split(target); |
| 156 | 158 | 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) })); | |
| 158 | 162 | continue; |
| 159 | 163 | } |
| 160 | 164 | 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 | }); |
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.