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

OG designs for the map and the audit #2030

Merged opened this in answer to

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

Merged

torque/forge:torque/454-og-designs-for-the-map-and-the-audit → main 11 commits fork 3 threads · 3 unresolved

  • Merged. torque/454-og-designs-for-the-map-and-the-audit was merged into main as 1aecf60.

Review

Sign in to review this change.
14 files changed +993 −56
README.md +20 −1
@@ -738,6 +738,25 @@ takes back (`supersedes`). `/asked` shows "said again, first on …", "takes bac
738738 social cards. They show only what an anonymous reader can open; anything
739739 private, sealed or unknown is the same 404.
740740
741 Two of them carry a figure beside the words:
742
743 - **Map** — `/og/map/owner~name.svg`: the repository's dependency graph as a
744 thumbnail, laid out exactly as `/owner/name/map` lays it out (ready green,
745 working solid accent, blocked grey, waiting/held purple, parked amber,
746 deferred and done faint), the critical path in the accent; beside it the
747 open count, the critical path's length and ends, and the next issue a free
748 hand would take; along the foot `ready · working · blocked · waiting · done`.
749 Drawn from what a signed-out reader sees: no sealed issue, no run on one.
750 - **Audit** — `/og/audit.svg`: the policy score as a ring, the share of apps
751 that hold (sound `contract.json` and no disagreeing probe; an app with no
752 contract does not hold — 3 sound, one disobeying, one unread is 2 of 4,
753 50%), with the top three findings worst first. Public repositories' apps only,
754 as `/audit` shows them signed out.
755
756 The map page (public repositories) and `/audit` name their `.png` as
757 `og:image` with `twitter:card=summary_large_image`; every other page keeps the
758 forge icon.
759
741760 ## Develop
742761
743762 ```sh
@@ -1051,7 +1070,7 @@ as kind `choose`.
10511070
10521071 ### OG cards as PNG
10531072
1054 `/og/{repo,app,issue,pull,run}/<ref>.png` is the same public card as the `.svg`,
1073 `/og/{repo,app,issue,pull,run,map}/<ref>.png` and `/og/audit.png` are the same public cards as the `.svg`,
10551074 rasterized (1200×630) for the link previews that refuse SVG. Same refs, same
10561075 404s, same ETag-per-inputs (suffixed `-png`). Each PNG is rendered once and kept
10571076 under `$FORGE_APP_DATA/og-png/<sha256 of the inputs>.png`; the cache holds at
scripts/gate.ts +1
@@ -75,6 +75,7 @@ export const GATE: readonly string[] = [
7575 "test/og-card.test.ts",
7676 "test/og-route.test.ts",
7777 "test/og-png.test.ts",
78 "test/og-designs.test.ts",
7879 "test/router.test.ts",
7980 "test/health.test.ts",
8081 "test/client-address.test.ts",
src/http/server.ts +4
@@ -1086,11 +1086,15 @@ export function buildRouter(): Router<Handler> {
10861086 router.add("GET", "/og/issue/:ref.svg", ogCard("issue"));
10871087 router.add("GET", "/og/pull/:ref.svg", ogCard("pull"));
10881088 router.add("GET", "/og/run/:ref.svg", ogCard("run"));
1089 router.add("GET", "/og/map/:ref.svg", ogCard("map"));
1090 router.add("GET", "/og/audit.svg", ogCard("audit"));
10891091 router.add("GET", "/og/repo/:ref.png", ogCard("repo", "png"));
10901092 router.add("GET", "/og/app/:ref.png", ogCard("app", "png"));
10911093 router.add("GET", "/og/issue/:ref.png", ogCard("issue", "png"));
10921094 router.add("GET", "/og/pull/:ref.png", ogCard("pull", "png"));
10931095 router.add("GET", "/og/run/:ref.png", ogCard("run", "png"));
1096 router.add("GET", "/og/map/:ref.png", ogCard("map", "png"));
1097 router.add("GET", "/og/audit.png", ogCard("audit", "png"));
10941098 // The pictures of an environment, read like the repository.
10951099 router.add("GET", "/:owner/:name/looks/:env/:commit/:kind.png", lookImage);
10961100
src/web/layout.ts +21 −5
@@ -62,6 +62,12 @@ export interface PageOptions {
6262 * are unchanged; only the main column lets go of `--page`.
6363 */
6464 wide?: boolean;
65 /**
66 * This page's link-preview card, a same-origin path such as
67 * `/og/audit.png` (`pages/og.ts`). Only for a page whose card answers a
68 * signed-out unfurler; absent, the forge's icon stands in.
69 */
70 ogImage?: string;
6571 /** This page's feed, advertised in the head so a reader can find it. */
6672 feed?: { href: string; title: string };
6773 /** Breadcrumb segments rendered in the topbar, e.g. owner / repo. */
@@ -595,11 +601,21 @@ export function page(options: PageOptions, status = 200): Response {
595601 />`
596602 : null
597603 }
598 <meta
599 property="og:image"
600 content="${ctx.url.origin}/icons/forge-512.png"
601 />
602 <meta name="twitter:card" content="summary" />
604 ${
605 options.ogImage !== undefined && options.ogImage.startsWith("/og/")
606 ? html`<meta
607 property="og:image"
608 content="${ctx.url.origin}${options.ogImage}"
609 />
610 <meta property="og:image:width" content="1200" />
611 <meta property="og:image:height" content="630" />
612 <meta name="twitter:card" content="summary_large_image" />`
613 : html`<meta
614 property="og:image"
615 content="${ctx.url.origin}/icons/forge-512.png"
616 />
617 <meta name="twitter:card" content="summary" />`
618 }
603619 <link rel="manifest" href="/manifest.webmanifest" />
604620 ${links.map(
605621 (l) =>
src/web/og/audit.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The audit's card: the policy score as a ring, and the top findings.
3 *
4 * The score is the share of apps that hold: an app holds when its
5 * `contract.json` reads sound and its doors did not disagree with its table
6 * the last time the forge knocked on them (an app never knocked on has not
7 * disagreed). An app with no contract does not hold — it is unread, and an
8 * audit that leaves out what it cannot read reads as a clean bill. So 3
9 * sound apps of which one disobeys, plus one unread, is 2 of 4: 50%.
10 *
11 * Findings, worst first, three at most: an app that disobeys its table; a
12 * contract that does not read; an app with doors open to anyone, the most
13 * open first; and the unread apps, together on one line.
14 *
15 * Plain data in (`AuditRow`, read by the route as nobody), a card out.
16 */
17
18 import type { CardInput } from "./card.ts";
19 import { scoreTone, type ScoreFigure } from "./score.ts";
20
21 const MAX_FINDINGS = 3;
22
23 const plural = (n: number, one: string, many = `${one}s`): string => (n === 1 ? one : many);
24
25 /** One app as the audit read it. */
26 export interface AuditRow {
27 /** `owner/name`. */
28 readonly name: string;
29 readonly standing: "sound" | "unsound" | "unread";
30 /** Doors the floor — anyone who can reach the app — may open. */
31 readonly open: number;
32 /** Why an unsound contract does not read. */
33 readonly problems: readonly string[];
34 /** The last probe found the doors disagreeing with the table. */
35 readonly disobeys: boolean;
36 }
37
38 export const holds = (r: AuditRow): boolean => r.standing === "sound" && !r.disobeys;
39
40 /** The findings, worst first. */
41 export function findings(rows: readonly AuditRow[]): string[] {
42 const out: string[] = [];
43 for (const r of rows) if (r.standing === "sound" && r.disobeys) out.push(`${r.name} disobeys its contract`);
44 for (const r of rows)
45 if (r.standing === "unsound")
46 out.push(`${r.name}: unsound${r.problems[0] === undefined ? "" : ` — ${r.problems[0]}`}`);
47 for (const r of rows
48 .filter((r) => r.standing === "sound" && r.open > 0)
49 .toSorted((a, b) => b.open - a.open || a.name.localeCompare(b.name)))
50 out.push(`${r.name}: ${r.open} ${plural(r.open, "door")} open to anyone`);
51 const unread = rows.filter((r) => r.standing === "unread");
52 if (unread.length === 1) out.push(`${unread[0]!.name} has no contract.json`);
53 else if (unread.length > 1) out.push(`${unread.length} apps have no contract.json`);
54 return out;
55 }
56
57 /** The audit's card from the apps as read. */
58 export function auditInput(rows: readonly AuditRow[]): CardInput {
59 const of = rows.length;
60 if (of === 0)
61 return {
62 kind: "audit",
63 title: "Audit",
64 lines: ["No public apps to audit yet.", "An app's policy shows here once it has a contract."],
65 };
66 const held = rows.filter(holds).length;
67 const sound = rows.filter((r) => r.standing === "sound").length;
68 const unsound = rows.filter((r) => r.standing === "unsound").length;
69 const unread = rows.filter((r) => r.standing === "unread").length;
70 const open = rows.reduce((n, r) => n + (r.standing === "sound" ? r.open : 0), 0);
71 const found = findings(rows);
72 const figure: ScoreFigure = {
73 kind: "score",
74 held,
75 of,
76 caption: of === 1 ? `${held} of 1 app holds` : `${held} of ${of} apps hold`,
77 tone: scoreTone(held, of),
78 };
79 return {
80 kind: "audit",
81 title: "Audit",
82 lines: found.length === 0 ? ["Every app holds its contract."] : found.slice(0, MAX_FINDINGS),
83 stat: [
84 `${sound} read`,
85 `${open} ${plural(open, "door")} open`,
86 `${unsound} unsound`,
87 `${unread} unread`,
88 ].join(" · "),
89 figure,
90 };
91 }
src/web/og/card.ts +43 −39
@@ -3,6 +3,10 @@
33 * 1200×630 SVG string in the studio's dark theme tokens. No I/O, no
44 * dependencies: the OG route and the OG designs fill this in.
55 *
6 * A card may carry a figure — the map's graph (`graph.ts`) or the audit's
7 * score (`score.ts`) — drawn in a box on the right; the words then keep to
8 * the left column, a little smaller, so the two never overlap.
9 *
610 * Every text is stripped of characters XML cannot carry, clamped to the
711 * card's width by an estimated advance (system fonts are unmeasurable here,
812 * so the estimate is deliberately wide), ended with an ellipsis, and only
@@ -10,18 +14,26 @@
1014 */
1115
1216 import { THEMES } from "../../design/themes.ts";
17 import { graphSvg, type GraphFigure } from "./graph.ts";
18 import { clean, escapeXml, fit } from "./xml.ts";
outdated

EM is imported but never used; it only appears in the comment on EM_BOLD. Import { clean, escapeXml, fit } only.

19 import { scoreSvg, type ScoreFigure } from "./score.ts";
1320
1421 export const CARD_WIDTH = 1200;
1522 export const CARD_HEIGHT = 630;
1623
17 export const CARD_KINDS = ["repo", "issue", "pull", "run", "app", "page"] as const;
24 export const CARD_KINDS = ["repo", "issue", "pull", "run", "app", "map", "audit", "page"] as const;
1825 export type CardKind = (typeof CARD_KINDS)[number];
1926
27 export type Figure = GraphFigure | ScoreFigure;
28
29 export { escapeXml, fit };
30
2031 export interface CardInput {
2132 kind: CardKind;
2233 title: string;
2334 lines?: readonly string[];
2435 stat?: string;
36 figure?: Figure;
2537 }
2638
2739 const T = THEMES.dark.tokens;
@@ -30,10 +42,12 @@ const MONO = `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monos
3042
3143 const PAD = 80;
3244 const INNER = CARD_WIDTH - PAD * 2;
45 /** With a figure: the words' column, and the figure's box to its right. */
46 const COLUMN = 620;
47 export const FIGURE_BOX = { x: 760, y: 150, w: 360, h: 330 } as const;
3348 const MAX_LINES = 3;
3449 const TITLE_LINES = 2;
35 /** Widest average glyph advance, in em, for the sans stack (bold is wider). */
36 const EM = 0.62;
50 /** Widest average glyph advance, in em, for the bold sans: wider than the regular face. */
3751 const EM_BOLD = 0.68;
3852 const EM_MONO = 0.62;
3953
@@ -43,37 +57,14 @@ const KIND_COLOR: Record<CardKind, string> = {
4357 pull: T.merged,
4458 run: T.primary,
4559 app: T.attn,
60 map: T.accent,
61 audit: T.success,
4662 page: T.primary,
4763 };
4864
49 /** Drop what XML 1.0 forbids (controls, lone surrogates); fold whitespace. */
50 function clean(s: unknown): string {
51 return String(s ?? "")
52 .replace(/\s+/g, " ")
53 .replace(/[^\u0009\u000A\u000D -\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "")
54 .trim();
55 }
56
57 export function escapeXml(s: string): string {
58 return s
59 .replace(/&/g, "&amp;")
60 .replace(/</g, "&lt;")
61 .replace(/>/g, "&gt;")
62 .replace(/"/g, "&quot;")
63 .replace(/'/g, "&#39;");
64 }
65
66 /** Clamp to `width` px at `size` px type; exported for the tests. */
67 export function fit(text: string, size: number, width: number, em = EM): string {
68 const max = Math.max(1, Math.floor(width / (size * em)));
69 const chars = Array.from(clean(text));
70 if (chars.length <= max) return chars.join("");
71 return chars.slice(0, max - 1).join("").trimEnd() + "…";
72 }
73
7465 /** Break a title into at most `n` lines of `max` chars; the last is clamped. */
75 function wrap(text: string, size: number, n: number, em: number): string[] {
76 const max = Math.max(1, Math.floor(INNER / (size * em)));
66 function wrap(text: string, size: number, n: number, em: number, width = INNER): string[] {
67 const max = Math.max(1, Math.floor(width / (size * em)));
7768 const words = clean(text).split(" ").filter(Boolean);
7869 const rows: string[] = [];
7970 let cur = "";
@@ -98,10 +89,10 @@ function wrap(text: string, size: number, n: number, em: number): string[] {
9889 const rest = i < words.length || rows.join(" ").length < clean(text).length;
9990 if (rest && rows.length) {
10091 const last = rows.length - 1;
101 rows[last] = fit(rows[last]! + "…", size, INNER, em);
92 rows[last] = fit(rows[last]! + "…", size, width, em);
10293 if (!rows[last]!.endsWith("…")) rows[last] += "…";
10394 }
104 return rows.map((r) => fit(r, size, INNER, em));
95 return rows.map((r) => fit(r, size, width, em));
10596 }
10697
10798 function text(
@@ -115,6 +106,14 @@ function text(
115106 return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}"${extra}>${escapeXml(body)}</text>`;
116107 }
117108
109 /** The figure's SVG, or nothing for an absent or unknown one. */
110 function drawFigure(figure: unknown): string {
111 const f = figure as Figure | undefined;
112 if (f?.kind === "graph") return graphSvg(f, FIGURE_BOX);
113 if (f?.kind === "score") return scoreSvg(f, FIGURE_BOX);
114 return "";
115 }
116
118117 export function renderCard(input: CardInput): string {
119118 const kind: CardKind = CARD_KINDS.includes(input?.kind) ? input.kind : "page";
120119 const color = KIND_COLOR[kind];
@@ -135,24 +134,29 @@ export function renderCard(input: CardInput): string {
135134 text(CARD_WIDTH - PAD, PAD + 24, 24, color, label, ` font-weight="700" text-anchor="end" letter-spacing="2"`),
136135 );
137136
138 // Title: up to two lines, 64px bold.
139 const tsize = 64;
140 const title = wrap(input?.title ?? "", tsize, TITLE_LINES, EM_BOLD);
137 const figure = drawFigure(input?.figure);
138 const column = figure ? COLUMN : INNER;
139 if (figure) out.push(figure);
140
141 // Title: up to two lines, 64px bold (56px beside a figure).
142 const tsize = figure ? 56 : 64;
143 const title = wrap(input?.title ?? "", tsize, TITLE_LINES, EM_BOLD, column);
141144 let y = 270;
142145 for (const row of title) {
143146 out.push(text(PAD, y, tsize, T.fg, row, ` font-weight="700"`));
144 y += 78;
147 y += tsize + 14;
145148 }
146149
147 // Supporting lines, 30px muted.
150 // Supporting lines, 30px muted (26px beside a figure).
148151 const lines = (Array.isArray(input?.lines) ? input.lines : [])
149152 .map(clean)
150153 .filter(Boolean)
151154 .slice(0, MAX_LINES);
155 const lsize = figure ? 26 : 30;
152156 y = Math.max(y + 10, 360);
153157 for (const l of lines) {
154 out.push(text(PAD, y, 30, T["muted-fg"], fit(l, 30, INNER)));
155 y += 44;
158 out.push(text(PAD, y, lsize, T["muted-fg"], fit(l, lsize, column)));
159 y += lsize + 14;
156160 }
157161
158162 // Footer rule and stat.
src/web/og/graph.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * The map as a thumbnail: the same layout the map page draws (`layout` in
3 * `web/pages/map.ts` — a card one level below its deepest blocker, long
4 * edges routed down the gutters), shrunk into a box, with the titles left
5 * out because nobody reads 9 px type in a chat bubble. What is left is the
6 * shape of the plan: each card filled by its word, and the critical path —
7 * its cards and its edges — in the accent.
8 *
9 * ready success, outlined working accent, solid
10 * blocked grey, filled waiting merged, outlined
11 * held merged, dashed parked attn, outlined
12 * deferred faint, dashed done faint, outlined
13 *
14 * The proportions are the page's own (a 196 × 68 card, 28 across and 44
15 * down between them), so the thumbnail is the page seen from far away.
16 * Plain data in, an SVG fragment out.
17 */
18
19 import { THEMES } from "../../design/themes.ts";
20 import type { Word } from "../pages/map.ts";
21
22 const T = THEMES.dark.tokens;
23
24 export interface Box {
25 readonly x: number;
26 readonly y: number;
27 readonly w: number;
28 readonly h: number;
29 }
30
31 export interface GraphNode {
32 readonly level: number;
33 readonly slot: number;
34 readonly word: Word;
35 readonly critical: boolean;
36 }
37
38 /** A spot a long edge passes through: a gutter at a level, shared by `lanes` lines. */
39 export interface GraphVia {
40 readonly level: number;
41 readonly gutter: number;
42 readonly lane: number;
43 readonly lanes: number;
44 }
45
46 export interface GraphEdge {
47 /** Indices into `nodes`: the blocker, then what it holds up. */
48 readonly from: number;
49 readonly to: number;
50 readonly via: readonly GraphVia[];
51 readonly critical: boolean;
52 /** Either end is closed: drawn faint. */
53 readonly done: boolean;
54 }
55
56 export interface GraphFigure {
57 readonly kind: "graph";
58 /** The widest level, in slots. */
59 readonly width: number;
60 readonly levels: number;
61 readonly nodes: readonly GraphNode[];
62 readonly edges: readonly GraphEdge[];
63 }
64
65 const W = 196;
66 const H = 68;
67 const GX = 28;
68 const GY = 44;
69 const LANE = 6;
70 /** No bigger than this, so a plan of two cards is not two slabs. */
71 const MAX_SCALE = 0.62;
72
73 /** Two decimals, and never `NaN` into an attribute. */
74 export function px(n: number): string {
75 return Number.isFinite(n) ? String(Math.round(n * 100) / 100) : "0";
76 }
77
78 /** The scale that fits a `width` × `levels` grid in the box: the smaller ratio, capped. */
79 export function graphScale(width: number, levels: number, box: Box): number {
80 const w = Math.max(1, width) * (W + GX) - GX;
81 const h = Math.max(1, levels) * (H + GY) - GY;
82 return Math.min(box.w / w, box.h / h, MAX_SCALE);
83 }
84
85 function look(word: Word): { fill: string; stroke: string; dash?: boolean } {
86 switch (word) {
87 case "ready":
88 return { fill: T.card, stroke: T.success };
89 case "working":
90 return { fill: T.accent, stroke: T.accent };
91 case "blocked":
92 return { fill: T.border, stroke: T["border-strong"] };
93 case "waiting":
94 return { fill: T.card, stroke: T.merged };
95 case "held":
96 return { fill: T.card, stroke: T.merged, dash: true };
97 case "parked":
98 return { fill: T.card, stroke: T.attn };
99 case "deferred":
100 return { fill: T.bg, stroke: T["border-strong"], dash: true };
101 default:
102 return { fill: T.bg, stroke: T.border };
103 }
104 }
105
106 export function graphSvg(fig: GraphFigure, box: Box): string {
107 const nodes = Array.isArray(fig.nodes) ? fig.nodes : [];
108 if (nodes.length === 0) return "";
109 const s = graphScale(fig.width, fig.levels, box);
110 const dw = (Math.max(1, fig.width) * (W + GX) - GX) * s;
111 const dh = (Math.max(1, fig.levels) * (H + GY) - GY) * s;
112 // Centred in the box: a short plan floats in the middle, a tall one fills it.
113 const ox = box.x + (box.w - dw) / 2;
114 const oy = box.y + Math.max(0, (box.h - dh) / 2);
115 const x = (slot: number): number => ox + slot * (W + GX) * s;
116 const y = (level: number): number => oy + level * (H + GY) * s;
117 const gx = (v: GraphVia): number =>
118 x(v.gutter) - (GX / 2) * s + (v.lane - (v.lanes - 1) / 2) * LANE * s;
119 const w = W * s;
120 const h = H * s;
121 const line = Math.max(1.5, Math.min(3, 4 * s));
122
123 const edges = (Array.isArray(fig.edges) ? fig.edges : [])
124 // The critical path last, so it is drawn over what it crosses.
125 .toSorted((a, b) => Number(a.critical) - Number(b.critical))
126 .map((e) => {
127 const from = nodes[e.from];
128 const to = nodes[e.to];
129 if (from === undefined || to === undefined) return "";
130 let cx = x(from.slot) + w / 2;
131 let cy = y(from.level) + h;
132 let d = `M${px(cx)} ${px(cy)}`;
133 const hop = (nx: number, ny: number): void => {
134 const bend = (ny - cy) / 2;
135 d += `C${px(cx)} ${px(cy + bend)} ${px(nx)} ${px(ny - bend)} ${px(nx)} ${px(ny)}`;
136 cx = nx;
137 cy = ny;
138 };
139 for (const v of e.via) {
140 const vx = gx(v);
141 hop(vx, y(v.level));
142 cy = y(v.level) + h;
143 d += `L${px(vx)} ${px(cy)}`;
144 }
145 hop(x(to.slot) + w / 2, y(to.level));
146 const stroke = e.critical ? T.accent : e.done ? T.border : T["border-strong"];
147 return `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${px(e.critical ? line * 1.6 : line)}"/>`;
148 })
149 .join("");
150
151 const cards = nodes
152 .map((n) => {
153 const c = look(n.word);
154 const stroke = n.critical && n.word !== "working" ? T.accent : c.stroke;
155 const sw = n.critical ? line * 1.6 : line;
156 const dash = c.dash && !n.critical ? ` stroke-dasharray="${px(sw * 3)} ${px(sw * 2)}"` : "";
157 return `<rect x="${px(x(n.slot))}" y="${px(y(n.level))}" width="${px(w)}" height="${px(h)}" rx="${px(Math.min(10, 8 * s + 1))}" fill="${c.fill}" stroke="${stroke}" stroke-width="${px(sw)}"${dash}/>`;
158 })
159 .join("");
160
161 return `<g>${edges}${cards}</g>`;
162 }
src/web/og/map.ts added +125
@@ -0,0 +1,125 @@
1 /**
2 * The map's card: the plan as a thumbnail, and the words counted.
3 *
4 * It reads what the map page reads — the graph, the ready list, the
5 * critical path, the crew's runs, the pulls that would close an issue —
6 * through the same functions (`web/pages/map.ts`), so the picture and the
7 * page cannot disagree on a word. The route hands it a graph read as
8 * nobody (`pages/og.ts`); from there on it is plain data in, a card out.
9 */
10
11 import { criticalPath, readyNow, type Graph } from "../../core/dependencies.ts";
12 import { countWords, layout, wordFor, type Marks, type Word } from "../pages/map.ts";
13 import type { CardInput } from "./card.ts";
14 import type { GraphEdge, GraphFigure, GraphNode } from "./graph.ts";
15
16 /** Unlinked issues are drawn only when nothing is linked, and then this many rows. */
17 const ISLAND_ROWS = 4;
18
19 const plural = (n: number, one: string, many = `${one}s`): string => (n === 1 ? one : many);
20
21 /**
22 * The thumbnail's data from a graph: the chains when there are any (the
23 * unlinked issues are counted, not drawn — a grid of loose cards says
24 * nothing at this size), otherwise the first few rows of loose ones.
25 */
26 export function graphFigure(
27 graph: Graph,
28 words: ReadonlyMap<string, Word>,
29 critical: readonly string[],
30 ): GraphFigure | undefined {
31 const placed = layout(graph);
32 const chained = placed.islandFrom > 0;
33 const drawn = placed.nodes.filter((n) => (chained ? !n.island : n.level < ISLAND_ROWS));
34 if (drawn.length === 0) return undefined;
35 const index = new Map(drawn.map((n, i) => [n.issue.id, i]));
36 const onPath = new Set(critical);
37 const chain = new Set<string>();
38 for (let i = 0; i + 1 < critical.length; i++) chain.add(`${critical[i]}>${critical[i + 1]}`);
39 const via = new Map(placed.routes.map((r) => [`${r.blockedById}>${r.issueId}`, r.via]));
40 const nodes: GraphNode[] = drawn.map((n) => ({
41 level: n.level,
42 slot: n.slot,
43 word: words.get(n.issue.id) ?? "blocked",
44 critical: onPath.has(n.issue.id),
45 }));
46 const edges: GraphEdge[] = [];
47 for (const e of graph.edges) {
48 const from = index.get(e.blockedById);
49 const to = index.get(e.issueId);
50 if (from === undefined || to === undefined) continue;
51 const key = `${e.blockedById}>${e.issueId}`;
52 edges.push({
53 from,
54 to,
55 via: (via.get(key) ?? []).map((v) => ({
56 level: v.level,
57 gutter: v.gutter,
58 lane: v.lane,
59 lanes: v.lanes,
60 })),
61 critical: chain.has(key),
62 done: nodes[from]!.word === "done" || nodes[to]!.word === "done",
63 });
64 }
65 return {
66 kind: "graph",
67 width: Math.max(...nodes.map((n) => n.slot)) + 1,
68 levels: Math.max(...nodes.map((n) => n.level)) + 1,
69 nodes,
70 edges,
71 };
72 }
73
74 /** The lines under the title: how much is open, how long the chain still to go is, what is next. */
75 export function mapLines(
76 open: number,
77 path: readonly { number: number }[],
78 next: { number: number; title: string } | undefined,
79 ): string[] {
80 if (open === 0) return ["Nothing open: every issue on the map is done."];
81 const lines = [`${open} open ${plural(open, "issue")}`];
82 lines.push(
83 path.length > 1
84 ? `Critical path: ${path.length} issues, #${path[0]!.number} → #${path.at(-1)!.number}`
85 : "None waits on another",
86 );
87 if (next !== undefined) lines.push(`Next: #${next.number} ${next.title}`);
88 return lines;
89 }
90
91 /** The counts along the foot: the four words that move, and what is done. */
92 export function mapStat(counts: Record<Word, number>): string {
93 const parts = [
94 `${counts.ready} ready`,
95 `${counts.working} working`,
96 `${counts.blocked} blocked`,
97 ];
98 const waiting = counts.waiting + counts.held;
99 if (waiting > 0) parts.push(`${waiting} waiting`);
100 parts.push(`${counts.done} done`);
101 return parts.join(" · ");
102 }
103
104 /** A repository's map card, from its graph as nobody reads it and its marks. */
105 export function mapInput(title: string, graph: Graph, marks: Marks): CardInput {
106 const ready = readyNow(graph);
107 const path = criticalPath(graph);
108 const readySet = new Set(ready.map((r) => r.issue.id));
109 const words = new Map(graph.issues.map((i) => [i.id, wordFor(i, readySet, marks)]));
110 const open = graph.issues.filter((i) => i.state === "open").length;
111 const figure = graphFigure(
112 graph,
113 words,
114 path.map((i) => i.id),
115 );
116 // Next is what a free hand would take: ready, and nobody on it yet.
117 const next = ready.find((r) => words.get(r.issue.id) === "ready")?.issue;
118 return {
119 kind: "map",
120 title,
121 lines: mapLines(open, path, next),
122 stat: mapStat(countWords([...words.values()])),
123 ...(figure === undefined ? {} : { figure }),
124 };
125 }
src/web/og/score.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * A score as a ring: the share of a whole, the arc filled clockwise from
3 * twelve o'clock, the number in the middle and what it counts underneath.
4 * The audit's card draws its policy score with it (`audit.ts`).
5 *
6 * The arc is one circle with a dash as long as the share of its
7 * circumference — no path arithmetic, so 0 and 100 are exact.
8 */
9
10 import { THEMES } from "../../design/themes.ts";
11 import { escapeXml, fit } from "./xml.ts";
12 import { px, type Box } from "./graph.ts";
13
14 const T = THEMES.dark.tokens;
15
16 export type ScoreTone = "success" | "attn" | "danger";
17
18 export interface ScoreFigure {
19 readonly kind: "score";
20 /** What is counted towards the score, and out of how many. */
21 readonly held: number;
22 readonly of: number;
23 /** Under the ring: what the number counts. */
24 readonly caption: string;
25 readonly tone: ScoreTone;
26 }
27
28 /** The score as a whole percent: held / of × 100, rounded; nothing to count is 0. */
29 export function percent(held: number, of: number): number {
30 if (!(of > 0) || !Number.isFinite(held)) return 0;
31 return Math.round((Math.min(Math.max(held, 0), of) / of) * 100);
32 }
33
34 /** 100% is success, half or more is attention, below half is danger. */
35 export function scoreTone(held: number, of: number): ScoreTone {
36 const p = percent(held, of);
37 return p === 100 ? "success" : p >= 50 ? "attn" : "danger";
38 }
39
40 const STROKE = 26;
41 const CAPTION = 24;
42 /** Digits and "%" in the bold sans run wide: 0.8 em each keeps the number off the stroke. */
43 const EM_NUMBER = 0.8;
44
45 export function scoreSvg(fig: ScoreFigure, box: Box): string {
46 const r = Math.min(box.w, box.h - 60) / 2 - STROKE / 2;
47 const cx = box.x + box.w / 2;
48 const cy = box.y + r + STROKE / 2;
49 const counted = fig.of > 0;
50 const share = counted ? Math.min(Math.max(fig.held, 0), fig.of) / fig.of : 0;
51 const circ = 2 * Math.PI * r;
52 const label = counted ? `${percent(fig.held, fig.of)}%` : "—";
53 const color = T[fig.tone] ?? T.attn;
54 // The number fills the ring's middle with a stroke's width to spare each side.
55 const hole = 2 * r - STROKE * 3;
56 const size = Math.min(84, Math.floor(hole / (label.length * EM_NUMBER)));
57 return [
58 `<circle cx="${px(cx)}" cy="${px(cy)}" r="${px(r)}" fill="none" stroke="${T.border}" stroke-width="${STROKE}"/>`,
59 share <= 0
60 ? ""
61 : `<circle cx="${px(cx)}" cy="${px(cy)}" r="${px(r)}" fill="none" stroke="${color}" stroke-width="${STROKE}" stroke-dasharray="${px(circ * share)} ${px(circ)}" transform="rotate(-90 ${px(cx)} ${px(cy)})"/>`,
62 `<text x="${px(cx)}" y="${px(cy + size * 0.36)}" font-size="${size}" font-weight="700" text-anchor="middle" fill="${counted ? T.fg : T["muted-fg"]}">${escapeXml(label)}</text>`,
63 `<text x="${px(cx)}" y="${px(cy + r + STROKE / 2 + 40)}" font-size="${CAPTION}" text-anchor="middle" fill="${T["muted-fg"]}">${escapeXml(fit(fig.caption, CAPTION, box.w))}</text>`,
64 ].join("");
65 }
src/web/og/xml.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * Text into an SVG card: strip what XML cannot carry, clamp to a width by
3 * an estimated advance, and escape. Shared by the card (`card.ts`) and the
4 * figures drawn on it, so every string on a card goes through one door.
5 */
6
7 /** Widest average glyph advance, in em, for the sans stack (bold is wider). */
8 export const EM = 0.62;
9
10 /** Drop what XML 1.0 forbids (controls, lone surrogates); fold whitespace. */
11 export function clean(s: unknown): string {
12 return String(s ?? "")
13 .replace(/\s+/g, " ")
14 .replace(/[^\u0009\u000A\u000D -\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "")
15 .trim();
16 }
17
18 export function escapeXml(s: string): string {
19 return s
20 .replace(/&/g, "&amp;")
21 .replace(/</g, "&lt;")
22 .replace(/>/g, "&gt;")
23 .replace(/"/g, "&quot;")
24 .replace(/'/g, "&#39;");
25 }
26
27 /** Clamp to `width` px at `size` px type; exported for the tests. */
28 export function fit(text: string, size: number, width: number, em = EM): string {
29 const max = Math.max(1, Math.floor(width / (size * em)));
30 const chars = Array.from(clean(text));
31 if (chars.length <= max) return chars.join("");
32 return chars.slice(0, max - 1).join("").trimEnd() + "…";
33 }
src/web/pages/audit.ts +1
@@ -89,6 +89,7 @@ export async function auditPage(ctx: Ctx): Promise<Response> {
8989 title: "Audit",
9090 description:
9191 "Who can do what, across every app, read from each one's contract.",
92 ogImage: "/og/audit.png",
9293 body: html`<div class="page-head">
9394 <h1>Audit</h1>
9495 <span class="spacer"></span>
src/web/pages/map.ts +4
@@ -1495,6 +1495,10 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14951495 ctx,
14961496 title: `Map · ${repo.name}`,
14971497 crumbs: crumbsFor(repo),
1498 // The card shows what a signed-out reader sees: only a public repo has one.
1499 ...(repo.visibility === "public"
1500 ? { ogImage: `/og/map/${repo.ownerName}~${repo.name}.png` }

Nit: this names the card whenever visibility === "public", but the route also requires canRead(db, repo, undefined). On a forge closed to signed-out readers, the page would advertise an og:image that answers 404. That is harmless (the unfurler shows no picture), but it would read better to gate on the same canRead check.

1501 : {}),
14981502 palette: repoPalette(ctx, repo),
14991503 body,
15001504 styleCss: mapCss,
src/web/pages/og.ts +70 −11
@@ -1,10 +1,13 @@
11 /**
2 * The social card on the web: GET /og/{repo,app,issue,pull,run}/:ref.svg.
2 * The social card on the web: GET /og/{repo,app,issue,pull,run,map}/:ref.svg
3 * and GET /og/audit.svg.
34 * Public by design — it is what a link preview fetches without a session —
45 * so it says only what an anonymous reader may already see: a public repo
56 * (`owner~name`), the public app of one (its slug), or an issue, pull or run
67 * of a public repo (`owner~name~number`, `owner~name~runid`) that an
7 * anonymous reader could open (not sealed, runs readable). Anything else, or a
8 * anonymous reader could open (not sealed, runs readable); a public repo's
9 * map (`owner~name`) drawn from only the issues and runs it may see; or the
10 * audit of the public apps. Anything else, or a
811 * ref that is not shaped like one, is the same 404. The ETag is the sha256
912 * of the card's inputs, so a changed repo is a new card and an unchanged
1013 * one is a 304. The `.png` twin is the same card rasterized once per input
@@ -16,22 +19,29 @@ import { createHash } from "node:crypto";
1619 import { join } from "node:path";
1720 import type { App } from "../../app.ts";
1821 import { getRepoRun } from "../../actions/runs.ts";
19 import { getAppBySlug, isSlug } from "../../core/apps.ts";
22 import { appsForRepos, getAppBySlug, isSlug } from "../../core/apps.ts";
23 import { audit } from "../../contract/audit.ts";
24 import { latestProbes } from "../../contract/probes.ts";
25 import { listActiveRuns } from "../../core/runs.ts";
2026 import {
2127 canRead,
2228 countsForRepos,
2329 getRepo,
2430 getRepoById,
31 listReposVisibleTo,
2532 type Repo,
2633 } from "../../core/repos.ts";
2734 import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts";
2835 import { getIssue, type Issue } from "../../core/issues.ts";
2936 import { getPull, type Pull } from "../../core/pulls.ts";
30 import { mayView } from "../../core/sealed.ts";
37 import { mayView, mayViewId, sealedScope } from "../../core/sealed.ts";
3138 import { isSafeName } from "../../git/guard.ts";
3239 import type { Ctx } from "../../http/context.ts";
3340 import { notFound } from "../../http/respond.ts";
41 import { auditInput, type AuditRow } from "../og/audit.ts";
3442 import { renderCard, type CardInput } from "../og/card.ts";
43 import { mapInput } from "../og/map.ts";
44 import { marksFor } from "./map.ts";
3545 import { Busy, pngCache, type PngCache } from "../og/png-cache.ts";
3646 import { rasterizer } from "../og/raster.ts";
3747
@@ -93,7 +103,10 @@ function waiting(ctx: Ctx, repo: Repo, issue: Issue): string {
93103 return `waits on ${shown.join(", ")}${more > 0 ? ` +${more}` : ""}`;
94104 }
95105
96 export type OgKind = "repo" | "app" | "issue" | "pull" | "run";
106 export type OgKind = "repo" | "app" | "issue" | "pull" | "run" | "map" | "audit";
107
108 /** The audit page's own bound. */
109 const AUDIT_LIMIT = 200;
97110
98111 const RUN_ID = /^[0-9a-z]{1,40}$/;
99112
@@ -146,15 +159,61 @@ function runInput(ctx: Ctx, ref: string): CardInput | undefined {
146159 };
147160 }
148161
149 function resolve(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined {
162 /** `owner~name` → its public repo, or nothing. */
163 function twoPart(ctx: Ctx, ref: string): Repo | undefined {
164 const [owner, name, extra] = ref.split("~");
165 if (extra !== undefined || !owner || !name) return undefined;
166 if (!isSafeName(owner) || !isSafeName(name)) return undefined;
167 return publicRepo(ctx, getRepo(ctx.app.db, owner, name));
168 }
169
170 /** The map as the map page draws it for someone signed out. */
171 function mapCardInput(ctx: Ctx, ref: string): CardInput | undefined {
172 const repo = twoPart(ctx, ref);
173 if (!repo) return undefined;
174 const db = ctx.app.db;
175 const graph = repoGraph(db, repo.id, sealedScope(db, repo, undefined));
176 // A run on a sealed report is not shown to who may not see the report.
177 const runs = listActiveRuns(db, [repo.id]).filter((r) =>
178 mayViewId(db, repo, r.issueId, undefined),
179 );
180 return mapInput(`${repo.ownerName}/${repo.name}`, graph, marksFor(db, repo.id, graph, runs));
181 }
182
183 /** The public apps, read the way the audit page reads them for someone signed out. */
184 async function auditCardInput(ctx: Ctx): Promise<CardInput> {
185 const db = ctx.app.db;
186 const repos = listReposVisibleTo(db, undefined, { limit: AUDIT_LIMIT });
187 const records = appsForRepos(
188 db,
189 repos.map((r) => r.id),
190 );
191 const apps = repos.filter((r) => records.has(r.id));
192 const audited = await Promise.all(apps.map((repo) => audit(ctx.app.reposRoot, repo)));

Low (cost): every request to /og/audit.{svg,png} re-audits up to 200 repos from git before the ETag/304 check. A 304 saves bytes, not the work. /audit already does the same work for a stranger, so this adds no new exposure. If unfurlers hit it hard, memoise the rows for a short time, or key on the repos' heads. The per-address render limit you deferred is the right home for that.

193 const appId = (repoId: string): string => records.get(repoId)!.id;
194 const probed = latestProbes(
195 db,
196 audited.filter((a) => a.standing.kind === "sound").map((a) => appId(a.repo.id)),
197 );
198 const rows: AuditRow[] = audited.map((a) => ({
199 name: `${a.repo.ownerName}/${a.repo.name}`,
200 standing: a.standing.kind,
201 open: a.standing.kind === "sound" ? a.standing.open.length : 0,
202 problems: a.standing.kind === "unsound" ? a.standing.problems : [],
203 disobeys:
204 a.standing.kind === "sound" && probed.get(appId(a.repo.id))?.verdict === "mismatch",
205 }));
206 return auditInput(rows);
207 }
208
209 async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | undefined> {
150210 const db = ctx.app.db;
151211 if (kind === "issue" || kind === "pull") return threadInput(ctx, kind, ref);
152212 if (kind === "run") return runInput(ctx, ref);
213 if (kind === "map") return mapCardInput(ctx, ref);
214 if (kind === "audit") return ref === "" ? auditCardInput(ctx) : undefined;
153215 if (kind === "repo") {
154 const [owner, name, extra] = ref.split("~");
155 if (extra !== undefined || !owner || !name) return undefined;
156 if (!isSafeName(owner) || !isSafeName(name)) return undefined;
157 const repo = publicRepo(ctx, getRepo(db, owner, name));
216 const repo = twoPart(ctx, ref);
158217 return repo && repoInput(ctx, repo, "repo");
159218 }
160219 if (!isSlug(ref)) return undefined;
@@ -181,7 +240,7 @@ function cacheFor(app: App): PngCache {
181240
182241 export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") {
183242 return async (ctx: Ctx): Promise<Response> => {
184 const input = resolve(ctx, kind, ctx.params.ref ?? "");
243 const input = await resolve(ctx, kind, ctx.params.ref ?? "");
185244 if (!input) return notFound();
186245 const etag = `"${createHash("sha256").update(JSON.stringify(input)).digest("hex")}${format === "png" ? "-png" : ""}"`;
187246 const headers = {
test/og-designs.test.ts added +353
@@ -0,0 +1,353 @@
1 /**
2 * The map's and the audit's link-preview cards (src/web/og/map.ts,
3 * audit.ts, graph.ts, score.ts): the thumbnail's scale and the policy
4 * score match a hand calculation, the words and the critical path are the
5 * map page's own, the findings come worst first — and over HTTP, a signed-out
6 * unfurler is served only what a signed-out reader sees: no sealed issue
7 * in a public plan, no private repository's plan or policy.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp as createForge, type App } from "../src/app.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { createApp } from "../src/core/apps.ts";
16 import { addDependency, type Graph } from "../src/core/dependencies.ts";
17 import { createIssue, type Issue } from "../src/core/issues.ts";
18 import { getRepo } from "../src/core/repos.ts";
19 import { createUser } from "../src/core/users.ts";
20 import { THEMES } from "../src/design/themes.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { NO_MARKS, type Marks } from "../src/web/pages/map.ts";
23 import { auditInput, findings, type AuditRow } from "../src/web/og/audit.ts";
24 import { FIGURE_BOX, renderCard } from "../src/web/og/card.ts";
25 import { graphScale, graphSvg } from "../src/web/og/graph.ts";
26 import { graphFigure, mapInput, mapLines, mapStat } from "../src/web/og/map.ts";
27 import { percent, scoreSvg, scoreTone } from "../src/web/og/score.ts";
28
29 const T = THEMES.dark.tokens;
30
31 const issue = (n: number, state: "open" | "closed" = "open"): Issue =>
32 ({
33 id: `i${n}`,
34 repoId: "r",
35 number: n,
36 title: `Issue ${n}`,
37 state,
38 kind: "issue",
39 createdAt: `2026-01-${String(n).padStart(2, "0")}T00:00:00.000Z`,
40 }) as unknown as Issue;
41 const edge = (blocker: number, blocked: number) => ({
42 issueId: `i${blocked}`,
43 blockedById: `i${blocker}`,
44 });
45 const graphOf = (issues: Issue[], edges: Graph["edges"]): Graph => ({
46 issues,
47 edges,
48 held: [],
49 unadopted: new Set(),
50 structural: new Set(),
51 deferred: new Map(),
52 });
53
54 function wellFormed(svg: string): void {
55 expect(svg.startsWith("<svg")).toBe(true);
56 expect(svg.endsWith("</svg>")).toBe(true);
57 expect(svg).not.toContain("NaN");
58 expect(svg).not.toContain("undefined");
59 }
60
61 describe("the map's thumbnail", () => {
62 test("its scale is the smaller of the two fits, capped", () => {
63 // 4 slots × 5 levels at the page's proportions (196 × 68, gaps 28 / 44):
64 // 4·224 − 28 = 868 wide, 5·112 − 44 = 516 tall. The box is 360 × 330:
65 // 360 / 868 = 0.41475…, 330 / 516 = 0.63953… — the width decides.
66 expect(FIGURE_BOX).toEqual({ x: 760, y: 150, w: 360, h: 330 });
67 expect(graphScale(4, 5, FIGURE_BOX)).toBeCloseTo(360 / 868, 9);
68 // 1 × 9: 196 wide, 9·112 − 44 = 964 tall; 330 / 964 = 0.34232 decides.
69 expect(graphScale(1, 9, FIGURE_BOX)).toBeCloseTo(330 / 964, 9);
70 // One card would be 1.8× the page; it is held to 0.62.
71 expect(graphScale(1, 1, FIGURE_BOX)).toBe(0.62);
72 });
73
74 // 1 → 3 → 5, 2 (closed) → 3, 4 → 5, and 6 on no edge.
75 const graph = graphOf(
76 [issue(1), issue(2, "closed"), issue(3), issue(4), issue(5), issue(6)],
77 [edge(1, 3), edge(2, 3), edge(3, 5), edge(4, 5)],
78 );
79 const marks: Marks = {
80 ...NO_MARKS,
81 crew: new Map([["i1", "running"]]),
82 waiting: new Set(["i4"]),
83 };
84
85 test("the card counts the page's words and draws the chain in the accent", () => {
86 const card = mapInput("alice/engine", graph, marks);
87 expect(card.kind).toBe("map");
88 expect(card.title).toBe("alice/engine");
89 // 1 working; 4 waiting on a pull; 6 ready (on no edge); 3 and 5 blocked; 2 done.
90 expect(card.stat).toBe("1 ready · 1 working · 2 blocked · 1 waiting · 1 done");
91 // Five open; the longest open chain is 1 → 3 → 5. Next skips 1, which
92 // the crew is on, and 4, which a pull is for: 6 is what a free hand takes.
93 expect(card.lines).toEqual(["5 open issues", "Critical path: 3 issues, #1 → #5", "Next: #6 Issue 6"]);
94 const fig = card.figure;
95 if (fig?.kind !== "graph") throw new Error("no graph");
96 // Only the chains are drawn: 6, on no edge, is counted, not drawn.
97 expect(fig.nodes.length).toBe(5);
98 expect(fig.levels).toBe(3);
99 expect(fig.nodes.filter((n) => n.critical).length).toBe(3);
100 expect(fig.edges.filter((e) => e.critical).length).toBe(2);
101 expect(fig.edges.filter((e) => e.done).length).toBe(1);
102 expect(fig.nodes.map((n) => n.word).toSorted()).toEqual(["blocked", "blocked", "done", "waiting", "working"]);
103 const svg = renderCard(card);
104 wellFormed(svg);
105 // Five cards in the figure, the two critical edges in the accent.
106 const figure = svg.slice(svg.indexOf("<g>"), svg.indexOf("</g>"));
107 expect(figure.match(/<rect /g)?.length).toBe(5);
108 expect(figure.match(new RegExp(`<path [^>]*stroke="${T.accent}"`, "g"))?.length).toBe(2);
109 expect(svg).toContain(">MAP<");
110 });
111
112 test("with nothing linked, the loose issues are drawn, four rows at most", () => {
113 const loose = graphOf(
114 Array.from({ length: 40 }, (_, i) => issue(i + 1)),
115 [],
116 );
117 const words = new Map(loose.issues.map((i) => [i.id, "ready" as const]));
118 const fig = graphFigure(loose, words, [])!;
119 expect(fig.levels).toBe(4);
120 expect(fig.nodes.length).toBe(4 * fig.width);
121 expect(fig.nodes.length).toBeLessThan(40);
122 expect(graphFigure(graphOf([], []), new Map(), [])).toBeUndefined();
123 expect(graphSvg({ kind: "graph", width: 0, levels: 0, nodes: [], edges: [] }, FIGURE_BOX)).toBe("");
124 });
125
126 test("the lines say what is open, the chain still to go, and what is next", () => {
127 expect(mapLines(0, [], undefined)).toEqual(["Nothing open: every issue on the map is done."]);
128 expect(mapLines(1, [], { number: 4, title: "Ship it" })).toEqual([
129 "1 open issue",
130 "None waits on another",
131 "Next: #4 Ship it",
132 ]);
133 const empty = mapInput("alice/empty", graphOf([], []), NO_MARKS);
134 expect(empty.figure).toBeUndefined();
135 expect(empty.stat).toBe("0 ready · 0 working · 0 blocked · 0 done");
136 wellFormed(renderCard(empty));
137 });
138
139 test("a held pull counts as waiting; deferred and held cards are dashed", () => {
140 expect(
141 mapStat({ ready: 2, working: 0, blocked: 1, waiting: 1, held: 2, parked: 0, deferred: 0, done: 3 }),
142 ).toBe("2 ready · 0 working · 1 blocked · 3 waiting · 3 done");
143 const svg = graphSvg(
144 {
145 kind: "graph",
146 width: 2,
147 levels: 1,
148 nodes: [
149 { level: 0, slot: 0, word: "held", critical: false },
150 { level: 0, slot: 1, word: "deferred", critical: false },
151 ],
152 edges: [],
153 },
154 FIGURE_BOX,
155 );
156 expect(svg.match(/stroke-dasharray/g)?.length).toBe(2);
157 });
158
159 test("a title can not break out of the card", () => {
160 const svg = renderCard(mapInput(`</text><script>x</script>`, graph, marks));
161 expect(svg).not.toContain("<script>");
162 expect(svg).toContain("&lt;/text&gt;");
163 });
164 });
165
166 describe("the audit's score", () => {
167 test("percent is held / of, rounded, and nothing to count is 0", () => {
168 expect(percent(2, 4)).toBe(50);
169 expect(percent(1, 3)).toBe(33); // 33.33…
170 expect(percent(2, 3)).toBe(67); // 66.67…
171 expect(percent(5, 4)).toBe(100);
172 expect(percent(-1, 4)).toBe(0);
173 expect(percent(0, 0)).toBe(0);
174 expect(scoreTone(4, 4)).toBe("success");
175 expect(scoreTone(1, 2)).toBe("attn");
176 expect(scoreTone(1, 3)).toBe("danger");
177 });
178
179 test("the ring's arc is the share of its circumference", () => {
180 const svg = scoreSvg({ kind: "score", held: 1, of: 4, caption: "c", tone: "danger" }, FIGURE_BOX);
181 // r = min(360, 330 − 60) / 2 − 26 / 2 = 135 − 13 = 122; 2π·122 = 766.55…
182 const circ = 2 * Math.PI * 122;
183 expect(svg).toContain(`r="122"`);
184 expect(svg).toContain(`stroke-dasharray="${Math.round((circ / 4) * 100) / 100} ${Math.round(circ * 100) / 100}"`);
185 expect(svg).toContain(">25%<");
186 expect(svg).toContain(`stroke="${T.danger}"`);
187 // Nothing held: no arc at all, only the track.
188 const none = scoreSvg({ kind: "score", held: 0, of: 4, caption: "c", tone: "danger" }, FIGURE_BOX);
189 expect(none.match(/<circle/g)?.length).toBe(1);
190 });
191 });
192
193 describe("the audit's findings", () => {
194 const row = (name: string, over: Partial<AuditRow> = {}): AuditRow => ({
195 name,
196 standing: "sound",
197 open: 0,
198 problems: [],
199 disobeys: false,
200 ...over,
201 });
202
203 test("worst first: disobeys, unsound, most open, then the unread together", () => {
204 const rows = [
205 row("a/few", { open: 1 }),
206 row("a/none", { standing: "unread" }),
207 row("a/many", { open: 3 }),
208 row("a/broken", { standing: "unsound", problems: ["roles is not a list"] }),
209 row("a/liar", { disobeys: true }),
210 row("a/none2", { standing: "unread" }),
211 ];
212 expect(findings(rows)).toEqual([
213 "a/liar disobeys its contract",
214 "a/broken: unsound — roles is not a list",
215 "a/many: 3 doors open to anyone",
216 "a/few: 1 door open to anyone",
217 "2 apps have no contract.json",
218 ]);
219 });
220
221 test("the score holds only sound apps that obey: 3 sound, one disobeying, one unread is 2 of 4", () => {
222 const card = auditInput([
223 row("a/one"),
224 row("a/two", { open: 2 }),
225 row("a/three", { disobeys: true }),
226 row("a/four", { standing: "unread" }),
227 ]);
228 expect(card.kind).toBe("audit");
229 expect(card.figure).toEqual({ kind: "score", held: 2, of: 4, caption: "2 of 4 apps hold", tone: "attn" });
230 // Three findings at most: the disobeying app, the open doors, the unread one.
231 expect(card.lines).toEqual([
232 "a/three disobeys its contract",
233 "a/two: 2 doors open to anyone",
234 "a/four has no contract.json",
235 ]);
236 expect(card.stat).toBe("3 read · 2 doors open · 0 unsound · 1 unread");
237 const svg = renderCard(card);
238 wellFormed(svg);
239 expect(svg).toContain(">50%<");
240 expect(svg).toContain(">AUDIT<");
241 });
242
243 test("every app holding says so; no apps draws no ring", () => {
244 expect(auditInput([row("a/one")]).lines).toEqual(["Every app holds its contract."]);
245 expect(auditInput([row("a/one")]).figure).toMatchObject({ caption: "1 of 1 app holds", tone: "success" });
246 const empty = auditInput([]);
247 expect(empty.figure).toBeUndefined();
248 expect(renderCard(empty)).not.toContain("<circle");
249 });
250 });
251
252 describe("over HTTP", () => {
253 let dataDir: string;
254 let app: App;
255 let servers: Servers;
256 let base: string;
257
258 beforeAll(async () => {
259 dataDir = await mkdtemp(join(tmpdir(), "forge-og-designs-"));
260 app = createForge({ dataDir, port: 0 });
261 servers = await serve(app);
262 base = servers.url;
263 const u = await createUser(app.db, {
264 trusted: true,
265 username: "alice",
266 email: "[email protected]",
267 password: "alice-passphrase",
268 adminIfFirst: true,
269 });
270 if (!u.ok) throw new Error(u.error);
271 for (const [name, visibility, access] of [
272 ["shown", "public", "public"],
273 ["hidden", "private", "public"],
274 ["quiet", "public", "private"],
275 ] as const) {
276 const made = await createRepo(app, u.value, { name, visibility });
277 if (!made.ok) throw new Error(String(made.error));
278 const rec = createApp(app.db, { repoId: made.value.id, slug: `${name}-app`, access });
279 if (!rec.ok) throw new Error(rec.error);
280 }
281 const mk = (repo: string, title: string, sealed = false): Issue => {
282 const r = getRepo(app.db, "alice", repo)!;
283 const i = createIssue(app.db, { repoId: r.id, authorId: u.value.id, title, kind: "issue", sealed });
284 if (!i.ok) throw new Error(String(i.error));
285 return i.value;
286 };
287 // shown: #1 → #2, and #3, sealed, waits on #2 too.
288 const one = mk("shown", "First");
289 const two = mk("shown", "Second");
290 const three = mk("shown", "A sealed report", true);
291 addDependency(app.db, two, one, u.value.id);
292 addDependency(app.db, three, two, u.value.id);
293 const h1 = mk("hidden", "Private one");
294 const h2 = mk("hidden", "Private two");
295 addDependency(app.db, h2, h1, u.value.id);
296 });
297
298 afterAll(async () => {
299 await servers.stop();
300 await rm(dataDir, { recursive: true, force: true });
301 });
302
303 const get = (path: string, headers: Record<string, string> = {}) => fetch(base + path, { headers });
304
305 test("a public repo's map is a card of only what a signed-out reader sees", async () => {
306 const res = await get("/og/map/alice~shown.svg");
307 expect(res.status).toBe(200);
308 expect(res.headers.get("content-type")).toContain("image/svg+xml");
309 expect(res.headers.get("cache-control")).toBe("public, max-age=300");
310 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
311 expect(res.headers.get("etag")).toMatch(/^"[0-9a-f]{64}"$/);
312 const svg = await res.text();
313 wellFormed(svg);
314 // The sealed #3 is neither counted nor drawn: two open, two cards.
315 expect(svg).toContain(">2 open issues<");
316 expect(svg).toContain("1 ready · 0 working · 1 blocked · 0 done");
317 expect(svg).not.toContain("sealed");
318 const figure = svg.slice(svg.indexOf("<g>"), svg.indexOf("</g>"));
319 expect(figure.match(/<rect /g)?.length).toBe(2);
320 const again = await get("/og/map/alice~shown.svg", { "if-none-match": res.headers.get("etag")! });
321 expect(again.status).toBe(304);
322 });
323
324 test("a private repo's map, an unknown one and a malformed ref are the same 404", async () => {
325 for (const ref of ["alice~hidden", "alice~nothing", "alice", "a~b~c", "..%2Fx"])
326 expect((await get(`/og/map/${ref}.svg`)).status).toBe(404);
327 });
328
329 test("the audit reads the apps of public repos only, as the page does for someone signed out", async () => {
330 const res = await get("/og/audit.svg");
331 expect(res.status).toBe(200);
332 const svg = await res.text();
333 wellFormed(svg);
334 // shown and quiet are public repos (a contract.json is as public as its
335 // repo, whoever may reach the app); hidden's repo is private. Neither has
336 // a contract, so 0 of 2 hold.
337 expect(svg).toContain("2 apps have no contract.json");
338 expect(svg).toContain(">0%<");
339 expect(svg).toContain("0 of 2 apps hold");
340 expect(svg).not.toContain("hidden");
341 });
342
343 test("the map page names its card only when the repo is public; the audit page always", async () => {
344 const page = await (await get("/alice/shown/map")).text();
345 expect(page).toContain(`content="${base}/og/map/alice~shown.png"`);
346 expect(page).toContain(`content="summary_large_image"`);
347 const audit = await (await get("/audit")).text();
348 expect(audit).toContain(`content="${base}/og/audit.png"`);
349 const home = await (await get("/")).text();
350 expect(home).toContain("/icons/forge-512.png");
351 expect(home).not.toContain("summary_large_image");
352 });
353 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs