@runbooks/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +14 -0
  2. package/dist/color.d.ts +17 -0
  3. package/dist/color.js +24 -0
  4. package/dist/geometry.test.d.ts +1 -0
  5. package/dist/geometry.test.js +155 -0
  6. package/dist/icons.d.ts +69 -0
  7. package/dist/icons.js +103 -0
  8. package/dist/icons.test.d.ts +1 -0
  9. package/dist/icons.test.js +140 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.js +9 -0
  12. package/dist/mark.d.ts +42 -0
  13. package/dist/mark.js +102 -0
  14. package/dist/primitives.d.ts +75 -0
  15. package/dist/primitives.js +126 -0
  16. package/dist/primitives.test.d.ts +1 -0
  17. package/dist/primitives.test.js +134 -0
  18. package/dist/render.d.ts +97 -0
  19. package/dist/render.js +1085 -0
  20. package/dist/render.test.d.ts +1 -0
  21. package/dist/render.test.js +179 -0
  22. package/dist/specimen.d.ts +2 -0
  23. package/dist/specimen.gen.d.ts +1 -0
  24. package/dist/specimen.gen.js +9 -0
  25. package/dist/specimen.js +81 -0
  26. package/dist/stylesheet.d.ts +95 -0
  27. package/dist/stylesheet.js +987 -0
  28. package/dist/stylesheet.test.d.ts +1 -0
  29. package/dist/stylesheet.test.js +265 -0
  30. package/dist/text.d.ts +28 -0
  31. package/dist/text.js +89 -0
  32. package/dist/tokens.d.ts +104 -0
  33. package/dist/tokens.js +142 -0
  34. package/dist/tokens.test.d.ts +1 -0
  35. package/dist/tokens.test.js +125 -0
  36. package/fonts/IBMPlexMono-Regular-Latin1.woff2 +0 -0
  37. package/fonts/IBMPlexMono-SemiBold-Latin1.woff2 +0 -0
  38. package/fonts/IBMPlexSans-Italic-Latin1.woff2 +0 -0
  39. package/fonts/IBMPlexSans-Medium-Latin1.woff2 +0 -0
  40. package/fonts/IBMPlexSans-Regular-Latin1.woff2 +0 -0
  41. package/fonts/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
  42. package/fonts/LICENSE.txt +93 -0
  43. package/package.json +40 -0
package/dist/mark.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The catalog's mark, and the picture a link to it shows.
3
+ *
4
+ * Both are drawn from the tokens for the same reason everything else here is: a mark
5
+ * hand-drawn beside the palette is a second copy of a decision, and it is the copy that
6
+ * goes stale — which is exactly what happened to every graph on the site the day the
7
+ * catalog turned dark.
8
+ *
9
+ * The mark is the product's own claim in one shape: a procedure is a graph, and the thing
10
+ * that matters about it is where it branches and which branch is dangerous. Three nodes
11
+ * and a fork, with the fork's far branch in the destructive hue — the same colour, from
12
+ * the same scale, that a reader will meet on a record page.
13
+ */
14
+ import { PALETTES, RISK_COLORS, FONT_STACKS } from "./tokens.js";
15
+ import { wrap } from "./text.js";
16
+ /**
17
+ * The icon, square, at any size.
18
+ *
19
+ * No text: at 16px a word is a smudge, and a favicon is read at 16px more often than
20
+ * anywhere else. The shape has to survive that, so it is three filled dots and two
21
+ * strokes and nothing finer.
22
+ */
23
+ export function markSvg(theme, size = 64) {
24
+ const palette = PALETTES[theme];
25
+ const danger = RISK_COLORS[theme].destructive;
26
+ const s = (n) => (n * size) / 64;
27
+ return [
28
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 64 64" role="img" aria-label="runbooks.directory">`,
29
+ `<rect width="64" height="64" rx="${s(14)}" fill="${palette.background}"/>`,
30
+ // The path: in, then a fork. Straight lines only, which is what the graphs are.
31
+ `<path d="M 16 32 H 30" stroke="${palette.textSecondary}" stroke-width="3" fill="none"/>`,
32
+ `<path d="M 30 32 H 36 L 44 20" stroke="${palette.textSecondary}" stroke-width="3" fill="none"/>`,
33
+ `<path d="M 36 32 L 44 44" stroke="${danger}" stroke-width="3" fill="none"/>`,
34
+ `<circle cx="16" cy="32" r="5" fill="${palette.trust}"/>`,
35
+ `<circle cx="45" cy="19" r="5" fill="${palette.textSecondary}"/>`,
36
+ `<circle cx="45" cy="45" r="5" fill="${danger}"/>`,
37
+ `</svg>`,
38
+ ].join("");
39
+ }
40
+ export const PREVIEW_WIDTH = 1200;
41
+ export const PREVIEW_HEIGHT = 630;
42
+ /**
43
+ * The card a link shows in a chat, a search result or a timeline.
44
+ *
45
+ * A record's preview carries that record's own graph, because the graph *is* the summary —
46
+ * a reader who sees it has already been told whether the procedure branches and whether
47
+ * there is red in it. Drawing something else for the preview would be a second opinion
48
+ * about the procedure, which is the thing this repository refuses everywhere else.
49
+ */
50
+ export function previewSvg(options) {
51
+ const palette = PALETTES[options.theme];
52
+ const margin = 72;
53
+ const room = PREVIEW_WIDTH - margin * 2;
54
+ // Measured rather than counted. A character budget is a guess about an average, and the
55
+ // guess was wrong by a word — the summary ran off the right edge of the card.
56
+ const title = wrap(options.title, 58, room, 1);
57
+ const summary = options.summary ? wrap(options.summary, 27, room, 2) : undefined;
58
+ const summaryLines = (summary?.lines ?? []).map((line, index) => `<text x="${margin}" y="${264 + index * 36}" fill="${palette.textSecondary}" font-size="27" font-family='${FONT_STACKS.sans}'>${escape(line)}</text>`);
59
+ /**
60
+ * The graph scaled to the space left under the summary.
61
+ *
62
+ * Drawn at its own size and scaled here rather than re-rendered small: it is the same
63
+ * picture the record page shows, which is the whole reason it is on the card.
64
+ */
65
+ const graphTop = 300 + (summary?.lines.length ?? 0) * 36;
66
+ const graphRoom = { w: room, h: PREVIEW_HEIGHT - graphTop - 90 };
67
+ const size = options.graph ? measureSvg(options.graph) : undefined;
68
+ const scale = size
69
+ ? Math.min(graphRoom.w / size.width, graphRoom.h / size.height, 3)
70
+ : 1;
71
+ return [
72
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${PREVIEW_WIDTH}" height="${PREVIEW_HEIGHT}" viewBox="0 0 ${PREVIEW_WIDTH} ${PREVIEW_HEIGHT}">`,
73
+ `<rect width="${PREVIEW_WIDTH}" height="${PREVIEW_HEIGHT}" fill="${palette.background}"/>`,
74
+ `<g transform="translate(${margin} 64)">${markSvg(options.theme, 56)}</g>`,
75
+ `<text x="${margin + 68}" y="104" fill="${palette.textSecondary}" font-size="26" font-family='${FONT_STACKS.mono}'>runbooks.directory</text>`,
76
+ `<text x="${margin}" y="212" fill="${palette.text}" font-size="58" font-weight="500" font-family='${FONT_STACKS.sans}'>${escape(title.lines[0] ?? options.title)}</text>`,
77
+ ...summaryLines,
78
+ options.graph
79
+ ? `<g transform="translate(${margin} ${graphTop}) scale(${round(scale)})">${options.graph}</g>`
80
+ : "",
81
+ options.footer
82
+ ? `<text x="${margin}" y="${PREVIEW_HEIGHT - 44}" fill="${palette.textSecondary}" font-size="24" font-family='${FONT_STACKS.mono}'>${escape(options.footer)}</text>`
83
+ : "",
84
+ `</svg>`,
85
+ ].join("");
86
+ }
87
+ /** The drawing's own size, read off the SVG it came with. */
88
+ function measureSvg(svg) {
89
+ const width = /width="(\d+(?:\.\d+)?)"/.exec(svg)?.[1];
90
+ const height = /height="(\d+(?:\.\d+)?)"/.exec(svg)?.[1];
91
+ return width && height ? { width: Number(width), height: Number(height) } : undefined;
92
+ }
93
+ function round(value) {
94
+ return Math.round(value * 1000) / 1000;
95
+ }
96
+ function escape(text) {
97
+ return text
98
+ .replace(/&/g, "&amp;")
99
+ .replace(/</g, "&lt;")
100
+ .replace(/>/g, "&gt;")
101
+ .replace(/"/g, "&quot;");
102
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Graph visual primitives.
3
+ *
4
+ * §18.2's channel table is what keeps a graph readable: each channel encodes exactly one
5
+ * property and is never reused. Shape carries `kind`, colour carries `risk`, edge style
6
+ * carries transition semantics, badges carry discrete flags.
7
+ *
8
+ * The table is enforced structurally here, not by discipline. `nodeShape` returns
9
+ * geometry and knows nothing about colour — it cannot encode risk even by accident.
10
+ * `stateStyle` returns stroke weight and fill opacity and no hue, so simulation state
11
+ * cannot take the channel risk owns. A rule kept by construction survives a refactor;
12
+ * one kept by review does not.
13
+ */
14
+ import type { Risk } from "@runbooks/schema";
15
+ import type { NodeKind, EdgeKind } from "@runbooks/graph";
16
+ export interface Box {
17
+ readonly width: number;
18
+ readonly height: number;
19
+ }
20
+ export interface Shape {
21
+ /** SVG path data. Geometry only — no colour, by construction. */
22
+ readonly path: string;
23
+ /** Extra geometry drawn inside the outline: the check indicator bar, the flag. */
24
+ readonly detail?: string;
25
+ /** `wait` is the only dashed outline; it is a property of the kind, not of state. */
26
+ readonly outlineDashed: boolean;
27
+ }
28
+ /**
29
+ * One shape per kind, distinguishable by silhouette alone — which is what makes a
30
+ * mini-graph on a card legible at 3-7 nodes, and what keeps the graph readable in
31
+ * greyscale and in print.
32
+ */
33
+ export declare function nodeShape(kind: NodeKind, box: Box): Shape;
34
+ export interface EdgeStyle {
35
+ readonly width: number;
36
+ /** SVG stroke-dasharray, or undefined for solid. */
37
+ readonly dash?: string;
38
+ /** Drawn as two parallel lines with a lock in the gap. */
39
+ readonly doubled: boolean;
40
+ /** Arrowhead points against the flow, as a rollback does. */
41
+ readonly reversed: boolean;
42
+ /** A returning arc rather than a straight run. */
43
+ readonly arc: boolean;
44
+ /** Whether the edge must carry a label (§6). */
45
+ readonly labelRequired: boolean;
46
+ }
47
+ export declare function edgeStyle(kind: EdgeKind): EdgeStyle;
48
+ export type SimulationState = "not-reached" | "current" | "passed";
49
+ export interface StateStyle {
50
+ readonly strokeWidth: number;
51
+ readonly fillOpacity: number;
52
+ }
53
+ /**
54
+ * Simulation and run state, expressed by stroke weight and fill only.
55
+ *
56
+ * There is deliberately no colour here and no way to add one through this type. Risk
57
+ * owns the hue channel; if state took it, a reader could no longer tell a destructive
58
+ * step from a step that happens to be current — which is the one confusion the whole
59
+ * colour system exists to prevent.
60
+ */
61
+ export declare function stateStyle(state: SimulationState): StateStyle;
62
+ /**
63
+ * Trust, as saturation, in the catalog only (§18.2). Never on the canvas: an author
64
+ * editing a draft would see their own work dimmed for not yet being verified, which
65
+ * says nothing useful and reads as a fault.
66
+ */
67
+ export declare function trustSaturation(trust: "T0" | "T1" | "T2" | "T3" | "T4"): number;
68
+ /**
69
+ * Hatching for print and for anyone who cannot rely on hue. Distinct per risk, so the
70
+ * scale survives a black-and-white printout even where lightness alone is close.
71
+ */
72
+ export declare const RISK_HATCH: Readonly<Record<Risk, string>>;
73
+ export type BadgeKind = "approval" | "untrusted" | "capability-mcp" | "capability-cli" | "capability-iam";
74
+ /** 16px grid, 1.5px stroke, no fills (§18.2). */
75
+ export declare function badge(kind: BadgeKind): string;
@@ -0,0 +1,126 @@
1
+ import { NODE_RADIUS } from "./tokens.js";
2
+ const r = NODE_RADIUS;
3
+ /**
4
+ * One shape per kind, distinguishable by silhouette alone — which is what makes a
5
+ * mini-graph on a card legible at 3-7 nodes, and what keeps the graph readable in
6
+ * greyscale and in print.
7
+ */
8
+ export function nodeShape(kind, box) {
9
+ const { width: w, height: h } = box;
10
+ switch (kind) {
11
+ case "start":
12
+ case "end": {
13
+ // Capsule: fully rounded ends, unmistakable at any zoom.
14
+ const rad = h / 2;
15
+ return {
16
+ path: `M ${rad} 0 H ${w - rad} A ${rad} ${rad} 0 0 1 ${w - rad} ${h} H ${rad} A ${rad} ${rad} 0 0 1 ${rad} 0 Z`,
17
+ outlineDashed: false,
18
+ };
19
+ }
20
+ case "check": {
21
+ // Rectangle with an indicator bar down the left edge.
22
+ const bar = 4;
23
+ return {
24
+ path: roundedRect(w, h, r),
25
+ detail: `M 0 ${r} H ${bar} V ${h - r} H 0 Z`,
26
+ outlineDashed: false,
27
+ };
28
+ }
29
+ case "action":
30
+ return { path: roundedRect(w, h, r), outlineDashed: false };
31
+ case "decision": {
32
+ // Clipped right corner — the branch is visible before the label is read.
33
+ const cut = Math.min(h * 0.5, w * 0.3);
34
+ return {
35
+ path: `M ${r} 0 H ${w - cut} L ${w} ${cut} V ${h - r} A ${r} ${r} 0 0 1 ${w - r} ${h} H ${r} A ${r} ${r} 0 0 1 0 ${h - r} V ${r} A ${r} ${r} 0 0 1 ${r} 0 Z`,
36
+ outlineDashed: false,
37
+ };
38
+ }
39
+ case "wait": {
40
+ const rad = h / 2;
41
+ return {
42
+ path: `M ${rad} 0 H ${w - rad} A ${rad} ${rad} 0 0 1 ${w - rad} ${h} H ${rad} A ${rad} ${rad} 0 0 1 ${rad} 0 Z`,
43
+ outlineDashed: true,
44
+ };
45
+ }
46
+ case "escalate": {
47
+ // Flag corner on the right: terminal, and visibly a way out.
48
+ const flag = Math.min(h * 0.45, 12);
49
+ return {
50
+ path: roundedRect(w, h, r),
51
+ detail: `M ${w - flag - 6} ${h / 2 - flag / 2} L ${w - 6} ${h / 2} L ${w - flag - 6} ${h / 2 + flag / 2} Z`,
52
+ outlineDashed: false,
53
+ };
54
+ }
55
+ }
56
+ }
57
+ function roundedRect(w, h, rad) {
58
+ return `M ${rad} 0 H ${w - rad} A ${rad} ${rad} 0 0 1 ${w} ${rad} V ${h - rad} A ${rad} ${rad} 0 0 1 ${w - rad} ${h} H ${rad} A ${rad} ${rad} 0 0 1 0 ${h - rad} V ${rad} A ${rad} ${rad} 0 0 1 ${rad} 0 Z`;
59
+ }
60
+ export function edgeStyle(kind) {
61
+ switch (kind) {
62
+ case "next":
63
+ return { width: 1.5, doubled: false, reversed: false, arc: false, labelRequired: false };
64
+ case "branch":
65
+ return { width: 1.5, doubled: false, reversed: false, arc: false, labelRequired: true };
66
+ case "on_fail":
67
+ return { width: 1.5, dash: "6 4", doubled: false, reversed: false, arc: false, labelRequired: false };
68
+ case "rollback":
69
+ return { width: 1.5, dash: "1 4", doubled: false, reversed: true, arc: false, labelRequired: false };
70
+ case "approval":
71
+ return { width: 1.5, doubled: true, reversed: false, arc: false, labelRequired: true };
72
+ case "retry":
73
+ return { width: 1.5, doubled: false, reversed: false, arc: true, labelRequired: true };
74
+ }
75
+ }
76
+ /**
77
+ * Simulation and run state, expressed by stroke weight and fill only.
78
+ *
79
+ * There is deliberately no colour here and no way to add one through this type. Risk
80
+ * owns the hue channel; if state took it, a reader could no longer tell a destructive
81
+ * step from a step that happens to be current — which is the one confusion the whole
82
+ * colour system exists to prevent.
83
+ */
84
+ export function stateStyle(state) {
85
+ switch (state) {
86
+ case "not-reached":
87
+ return { strokeWidth: 1, fillOpacity: 0 };
88
+ case "current":
89
+ return { strokeWidth: 3, fillOpacity: 0.18 };
90
+ case "passed":
91
+ return { strokeWidth: 1.5, fillOpacity: 0.08 };
92
+ }
93
+ }
94
+ /**
95
+ * Trust, as saturation, in the catalog only (§18.2). Never on the canvas: an author
96
+ * editing a draft would see their own work dimmed for not yet being verified, which
97
+ * says nothing useful and reads as a fault.
98
+ */
99
+ export function trustSaturation(trust) {
100
+ return { T0: 0.35, T1: 0.5, T2: 1, T3: 1, T4: 1 }[trust];
101
+ }
102
+ /**
103
+ * Hatching for print and for anyone who cannot rely on hue. Distinct per risk, so the
104
+ * scale survives a black-and-white printout even where lightness alone is close.
105
+ */
106
+ export const RISK_HATCH = {
107
+ "read-only": "none",
108
+ "reversible-write": "diagonal-thin",
109
+ destructive: "diagonal-dense",
110
+ irreversible: "cross",
111
+ };
112
+ /** 16px grid, 1.5px stroke, no fills (§18.2). */
113
+ export function badge(kind) {
114
+ switch (kind) {
115
+ case "approval":
116
+ return "M 4 7 V 5 a 4 4 0 0 1 8 0 v 2 M 3 7 h 10 v 7 H 3 Z";
117
+ case "untrusted":
118
+ return "M 8 2 L 15 14 H 1 Z M 8 6 v 4 M 8 12 v 0.5";
119
+ case "capability-mcp":
120
+ return "M 2 12 V 4 l 6 5 6 -5 v 8";
121
+ case "capability-cli":
122
+ return "M 3 4 l 4 4 -4 4 M 9 12 h 5";
123
+ case "capability-iam":
124
+ return "M 8 2 l 6 3 v 4 c 0 3 -3 5 -6 6 -3 -1 -6 -3 -6 -6 V 5 Z";
125
+ }
126
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { RISK_ORDER } from "@runbooks/schema";
6
+ import { nodeShape, edgeStyle, stateStyle, trustSaturation, badge, RISK_HATCH } from "./primitives.js";
7
+ import { specimenSheet } from "./specimen.js";
8
+ import { riskColor, PALETTES, meetsStrokeFloor } from "./tokens.js";
9
+ const KINDS = ["start", "check", "action", "decision", "wait", "escalate", "end"];
10
+ const EDGES = ["next", "branch", "on_fail", "rollback", "approval", "retry"];
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ /**
13
+ * §18.2's channel table, enforced structurally. Shape carries kind and must therefore
14
+ * be unable to carry anything else — a rule kept by construction survives a refactor,
15
+ * one kept by review does not.
16
+ */
17
+ describe("shape encodes kind, and nothing else", () => {
18
+ it.each(KINDS)("%s produces geometry with no colour in it", (kind) => {
19
+ const shape = nodeShape(kind, { width: 120, height: 32 });
20
+ const serialized = JSON.stringify(shape);
21
+ expect(serialized).not.toMatch(/#[0-9a-f]{3,6}/i);
22
+ expect(serialized).not.toMatch(/\b(fill|stroke|rgb|hsl|colou?r)\b/i);
23
+ });
24
+ // A silhouette only works if the silhouettes differ — but "silhouette" is the whole
25
+ // mark, not the outline alone. Outlines are shared on purpose (§18.2): check is a
26
+ // rectangle with an indicator bar, escalate a rectangle with a flag, wait a dashed
27
+ // capsule. Sharing an outline and differing in detail is the design, so distinctness
28
+ // is measured over the full shape.
29
+ it("gives every kind a distinct mark", () => {
30
+ const marks = KINDS.map((k) => JSON.stringify(nodeShape(k, { width: 120, height: 32 })));
31
+ // start and end are deliberately identical — both are terminal capsules, and the
32
+ // label is what tells them apart.
33
+ expect(new Set(marks).size).toBe(KINDS.length - 1);
34
+ });
35
+ it("shares an outline only where a detail distinguishes the kinds", () => {
36
+ const box = { width: 120, height: 32 };
37
+ const byOutline = new Map();
38
+ for (const kind of KINDS) {
39
+ const { path } = nodeShape(kind, box);
40
+ byOutline.set(path, [...(byOutline.get(path) ?? []), kind]);
41
+ }
42
+ for (const [, kinds] of byOutline) {
43
+ if (kinds.length < 2)
44
+ continue;
45
+ const marks = kinds.map((k) => JSON.stringify(nodeShape(k, box)));
46
+ // start/end are the one permitted collision.
47
+ const collisions = new Set(marks).size;
48
+ expect(collisions, `${kinds.join(", ")} share an outline`).toBe(kinds.includes("start") && kinds.includes("end") ? kinds.length - 1 : kinds.length);
49
+ }
50
+ });
51
+ it("keeps check and escalate apart by their inner detail", () => {
52
+ const check = nodeShape("check", { width: 120, height: 32 });
53
+ const escalate = nodeShape("escalate", { width: 120, height: 32 });
54
+ expect(check.path).toBe(escalate.path);
55
+ expect(check.detail).not.toBe(escalate.detail);
56
+ });
57
+ it("dashes only the wait outline, because that is a property of the kind", () => {
58
+ for (const kind of KINDS) {
59
+ expect(nodeShape(kind, { width: 120, height: 32 }).outlineDashed).toBe(kind === "wait");
60
+ }
61
+ });
62
+ // Legible at card scale: 3-7 nodes in a RunbookCard mini-graph.
63
+ it.each(KINDS)("%s still produces a valid path at mini-graph scale", (kind) => {
64
+ const shape = nodeShape(kind, { width: 24, height: 8 });
65
+ expect(shape.path).toMatch(/^M [\d.]+ [\d.]+/);
66
+ expect(shape.path).not.toMatch(/NaN|Infinity|-\d+\.?\d* [A-Z]/);
67
+ });
68
+ });
69
+ describe("state encodes progress, and never takes the hue channel", () => {
70
+ // If state took colour, a reader could not tell a destructive step from a step that
71
+ // happens to be current. That is the one confusion the colour system exists to stop.
72
+ it.each(["not-reached", "current", "passed"])("%s carries weight and fill only", (state) => {
73
+ expect(Object.keys(stateStyle(state)).sort()).toEqual(["fillOpacity", "strokeWidth"]);
74
+ });
75
+ it("separates the three states by stroke weight alone", () => {
76
+ const widths = ["not-reached", "current", "passed"].map((s) => stateStyle(s).strokeWidth);
77
+ expect(new Set(widths).size).toBe(3);
78
+ });
79
+ });
80
+ describe("edge style encodes transition semantics", () => {
81
+ it("gives every edge kind a distinct rendering", () => {
82
+ const signatures = EDGES.map((k) => JSON.stringify(edgeStyle(k)));
83
+ expect(new Set(signatures).size).toBe(EDGES.length);
84
+ });
85
+ it("points a rollback against the flow", () => {
86
+ expect(edgeStyle("rollback").reversed).toBe(true);
87
+ expect(EDGES.filter((k) => edgeStyle(k).reversed)).toEqual(["rollback"]);
88
+ });
89
+ it("requires a label exactly where §6 does", () => {
90
+ expect(EDGES.filter((k) => edgeStyle(k).labelRequired).sort())
91
+ .toEqual(["approval", "branch", "retry"]);
92
+ });
93
+ });
94
+ describe("the scale survives without colour", () => {
95
+ it("gives each risk a distinct hatch for print and black and white", () => {
96
+ expect(new Set(RISK_ORDER.map((r) => RISK_HATCH[r])).size).toBe(RISK_ORDER.length);
97
+ });
98
+ it("dims only unverified records, and never to invisibility", () => {
99
+ expect(trustSaturation("T0")).toBeLessThan(trustSaturation("T2"));
100
+ expect(trustSaturation("T0")).toBeGreaterThan(0.3);
101
+ expect(trustSaturation("T2")).toBe(1);
102
+ });
103
+ it("draws badges as strokes on a 16px grid with no fills", () => {
104
+ for (const kind of ["approval", "untrusted", "capability-mcp", "capability-cli", "capability-iam"]) {
105
+ const d = badge(kind);
106
+ expect(d).toMatch(/^M /);
107
+ const coords = [...d.matchAll(/-?\d+(\.\d+)?/g)].map((m) => Number(m[0]));
108
+ expect(Math.max(...coords)).toBeLessThanOrEqual(16);
109
+ }
110
+ });
111
+ });
112
+ describe("node strokes clear the contrast floor on the ground they are drawn on", () => {
113
+ it.each(["dark", "light"])("%s", (theme) => {
114
+ for (const risk of RISK_ORDER) {
115
+ expect(meetsStrokeFloor(riskColor(risk, theme), PALETTES[theme].surface)).toBe(true);
116
+ }
117
+ });
118
+ });
119
+ describe("the specimen sheet is a baseline, not decoration", () => {
120
+ it.each(["dark", "light"])("%s matches the checked-in file", (theme) => {
121
+ const baseline = readFileSync(join(HERE, "..", "specimen", `${theme}.svg`), "utf8");
122
+ expect(specimenSheet(theme)).toBe(baseline);
123
+ });
124
+ it("is deterministic", () => {
125
+ expect(specimenSheet("dark")).toBe(specimenSheet("dark"));
126
+ });
127
+ it("shows every kind, every risk, every state and every edge", () => {
128
+ const sheet = specimenSheet("light");
129
+ for (const kind of KINDS)
130
+ expect(sheet).toContain(`>${kind}<`);
131
+ for (const edge of EDGES)
132
+ expect(sheet).toContain(`>${edge}<`);
133
+ });
134
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Graph rendering: a pure function from a graph to markup.
3
+ *
4
+ * A string rather than a component, because the same renderer serves the runbook page,
5
+ * a card's mini-graph, the full-screen view and the CLI's SVG export. A React-only
6
+ * renderer would need a second implementation for the export, and two renderers drift.
7
+ *
8
+ * Server-rendered by construction: nothing here touches a DOM or waits for hydration.
9
+ * §18.4 puts the graph above the fold, and a graph that appears only after JavaScript
10
+ * loads is invisible to a crawler and to a reader on a slow connection during an
11
+ * incident — which is exactly when this page is opened.
12
+ *
13
+ * **What this draws, and why it is drawn that way.** The picture has one job: a reader
14
+ * finds the dangerous step and the way back from it in about two seconds, before reading
15
+ * a word. Three decisions carry that, and each replaced something that did not:
16
+ *
17
+ * - **Every box is as wide as its own label.** Labels were not measured — the box was a
18
+ * constant 168px and the title was cut at 28 characters, which is wider — so the
19
+ * longest titles ran out past both edges of their outline.
20
+ * - **Connectors are orthogonal.** A straight line between two boxes in different
21
+ * columns crosses whatever is between them; an elbow that drops, turns once and
22
+ * arrives from above never does, and it reads as a route rather than as a chord.
23
+ * - **Edge labels sit on a plate of the background.** `unreachable`, `responding` and
24
+ * `default` were drawn straight onto the strokes they name, at the same height, so
25
+ * the three overlapped each other and the lines under them.
26
+ */
27
+ import type { Graph } from "@runbooks/graph";
28
+ import { type Theme } from "./tokens.js";
29
+ import { type SimulationState } from "./primitives.js";
30
+ export type RenderMode = "page" | "mini" | "fullscreen";
31
+ /**
32
+ * Which way the procedure runs.
33
+ *
34
+ * Vertical by default, which is a reversal. Horizontal was chosen when the graph was a
35
+ * fixed picture that had to fit above the fold on a landscape screen; the graph now has a
36
+ * viewer with zoom, panning and a full-screen mode, so height costs a scroll rather than
37
+ * a third of the drawing, and the argument that decided it no longer holds.
38
+ *
39
+ * What decided it the second time is reading order. A procedure is a sequence of steps
40
+ * and every reader of one already reads top to bottom; laid out that way the happy path
41
+ * is a straight vertical line and a branch is a visible departure from it. Sideways, the
42
+ * happy path was the line the eye had to learn.
43
+ *
44
+ * It also buys room. Sideways, parallel branches stack into the short axis and the
45
+ * routing has to thread lanes between them; downwards they spread into the width, which
46
+ * is the axis with space to spare — which is most of why the crossings this renderer used
47
+ * to have were where they were.
48
+ *
49
+ * The axis assignments turn with it: the happy path is the centre axis either way,
50
+ * `on_fail` and `escalate` leave it on one side and `rollback` on the other.
51
+ */
52
+ export type Direction = "horizontal" | "vertical";
53
+ export interface RenderOptions {
54
+ readonly theme: Theme;
55
+ readonly mode?: RenderMode;
56
+ /** Per-node simulation or run state. Absent nodes render as not-reached. */
57
+ readonly states?: Readonly<Record<string, SimulationState>>;
58
+ /** Prefix for element ids, so two graphs on one page do not collide. */
59
+ readonly idPrefix?: string;
60
+ /**
61
+ * Positions to draw at, instead of this graph's own layout (W-17).
62
+ *
63
+ * A diff draws two versions over the coordinates of their union, which is what makes
64
+ * "unchanged regions do not shift" true rather than nearly true: laying each version out
65
+ * separately moves everything below an inserted step, and the reader compares two
66
+ * pictures that disagree about where the procedure is.
67
+ */
68
+ readonly positions?: Readonly<Record<string, {
69
+ readonly x: number;
70
+ readonly y: number;
71
+ }>>;
72
+ /** Defaults to vertical. See `Direction`. */
73
+ readonly direction?: Direction;
74
+ }
75
+ export interface GraphRender {
76
+ /** The picture. Hidden from assistive technology — the list carries the content. */
77
+ readonly svg: string;
78
+ /**
79
+ * Where each node was drawn, in the picture's own coordinates.
80
+ *
81
+ * Returned because the geometry is the part worth asserting: that a label fits inside
82
+ * its outline, and that no connector crosses a box it is not attached to, are claims
83
+ * about rectangles, and reading them back out of path data would be a second renderer.
84
+ */
85
+ readonly boxes: Readonly<Record<string, {
86
+ readonly x: number;
87
+ readonly y: number;
88
+ readonly w: number;
89
+ readonly h: number;
90
+ }>>;
91
+ /** The same procedure as an ordered list. A procedure that cannot be read linearly
92
+ * is inaccessible, and this is what a screen reader and a no-CSS reader get. */
93
+ readonly list: string;
94
+ readonly width: number;
95
+ readonly height: number;
96
+ }
97
+ export declare function renderGraph(graph: Graph, options: RenderOptions): GraphRender;