yichus 0.2.0 → 0.3.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.
@@ -0,0 +1,752 @@
1
+ // What happens when: one entry's scenario tree, drawn from a what-happens-1
2
+ // record (web/what-happens-record.js) that scripts/what-happens-examples.mjs
3
+ // copies from the engine. It never measures code.
4
+ //
5
+ // renderWhatHappens(record, { width, entry, open, cross, ghosts }) any width; 390 and 1100 are tuned
6
+ // renderNotes(record) the author's text that is not a claim (HTML, outside the frame)
7
+ //
8
+ // The tree is the journey view's metro line turned on its side: a request
9
+ // comes in at the top and runs down. The path that reaches the main outcome
10
+ // (an answer a claim names, else the one with the most effects) is one
11
+ // straight spine; an arm that ends at once is a stub off to its side, the
12
+ // arm and what the request gets on one line; only an arm that branches
13
+ // again nests, with its own spine. The rack above draws the described
14
+ // entries and one line counting the rest (data-open="ghosts" opens it). Effects are the map's stations, a
15
+ // branch the code takes is the map's fork, the declared sign-in or role
16
+ // check is the map's red diamond, and a subtree too large for the view is
17
+ // the map's bundle, opened with a breadcrumb. The one new mark is the
18
+ // outcome cap at the end of each path. Colour comes only from checks: red
19
+ // where a claim fails or a check's witness ends, green where every
20
+ // obligation that failed at the base is checked here and holds (its base
21
+ // drawn faded beside it), hollow and dashed where the engine cannot say
22
+ // (an inconclusive claim, or a base failure no longer checked), grey
23
+ // otherwise. Inside the "Yichus output" frame every word is computed; the
24
+ // author's words sit only on ids the engine computed, in italic, with the
25
+ // code name beside them or in the tooltip.
26
+ //
27
+ // Concerns: the engine classifies every branch and effect by where it
28
+ // comes from in value flow (concern.layer: core, cross, beside, platform).
29
+ // The handler's own work is drawn in full. Each step that is not
30
+ // (a wrapper's guard or short-circuit, the platform's declared check or its
31
+ // transaction, work beside the wrapper, a wrapper's work after the handler)
32
+ // is folded by default into one shaded box on the line, the map's hull,
33
+ // that keeps each arm that skips the handler, read as a side stub ("not
34
+ // signed in → refused 401"), then "continue", and its effects. cross:
35
+ // 'shown' draws them inline, each under its role and where it comes from on
36
+ // a light band of the same hull; opening a box
37
+ // (data-open="step:<node id>") draws that step's own tree with a
38
+ // breadcrumb, its way on to the handler a bundle.
39
+ //
40
+ // The scenario tree and its bug/fix colouring come from Lauren Tan's
41
+ // (poteto) TLA+ diagrams.
42
+
43
+ import { mark, tg, MIN_LABEL, COLORS as C } from './system-map.js';
44
+ import { leavesOf, pathEffects, allEffects, stationLabel, outcomeLabel, isWriteOp } from './what-happens-record.js';
45
+ import { SANS, MONO, textWidth, clipText, boxAttr, fitAttr, labelAttr, labelled } from './label-fit.js';
46
+
47
+ const RED = C.guard, GREEN = '#1f7a45', GREEN_SOFT = '#e4f4ea', RED_SOFT = '#fbe9e7';
48
+ const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
49
+ const f1 = n => (+n).toFixed(1);
50
+ const width = (s, size, mono, weight = 400, italic = false) => textWidth(s, size, mono, weight, italic);
51
+ const clip = (s, size, room, mono = true, weight = 400, italic = false) => clipText(s, size, room, mono, weight, italic);
52
+ // break text into at most n lines that fit `room`, cutting at a space or separator
53
+ function lines(s, size, room, mono, n = 2, weight = 400) {
54
+ const out = []; let rest = String(s);
55
+ while (rest && out.length < n) {
56
+ if (width(rest, size, mono, weight) <= room || out.length === n - 1) { out.push(clip(rest, size, room, mono, weight)); break; }
57
+ let cut = -1;
58
+ for (let i = 1; i < rest.length; i++) if (' ,/(·'.includes(rest[i]) && width(rest.slice(0, i), size, mono, weight) <= room) cut = i;
59
+ if (cut <= 0) cut = Math.max(1, Math.floor(room / (size * (mono ? 0.602 : 0.56))));
60
+ out.push(rest.slice(0, cut).trimEnd()); rest = rest.slice(cut).trimStart();
61
+ }
62
+ return out;
63
+ }
64
+ const T = (x, y, s, o = {}) => `<text x="${f1(x)}" y="${f1(y)}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''} font-family="${o.mono === false ? SANS : MONO}" font-size="${f1(Math.max(MIN_LABEL, o.size || 10))}"${o.weight ? ` font-weight="${o.weight}"` : ''}${o.italic ? ' font-style="italic"' : ''} fill="${o.fill || C.ink2}"${o.opacity ? ` opacity="${o.opacity}"` : ''}${o.fit ? fitAttr(o.fit) : ''}${o.label ? labelAttr(o.label) : ''} stroke="${C.paper}" stroke-width="3" stroke-linejoin="round" paint-order="stroke">${esc(s)}</text>`;
65
+ const title = s => `<title>${esc(s)}</title>`;
66
+ const shape = (kind, svg) => `<g data-mark="${kind}">${svg}</g>`;
67
+ const opener = (key, label) => ` data-open="${esc(key)}" tabindex="0" role="button" aria-label="${esc(label)}"`;
68
+
69
+ // The new mark: the cap at the end of a path. status grey | red | green |
70
+ // unchecked; a refusal's cap is a bar across the line (the request stops
71
+ // there), a returned value's cap is a filled disc on a ring (it is
72
+ // answered). Unchecked is the cap's outline only, dashed like the struck
73
+ // ghosts: the engine cannot say, so nothing is filled in.
74
+ export function outcomeMark(x, y, status, refused, s = 1, faded = false) {
75
+ const op = faded ? ' opacity="0.45"' : '';
76
+ if (status === 'unchecked') {
77
+ const dash = `stroke="${C.muted}" stroke-width="${f1(1.4 * s)}" stroke-dasharray="2 1.6"`;
78
+ const body = refused
79
+ ? `<rect x="${f1(x - 8 * s)}" y="${f1(y - 3 * s)}" width="${f1(16 * s)}" height="${f1(6 * s)}" rx="${f1(2 * s)}" fill="${C.paper}" ${dash}/>`
80
+ : `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(7 * s)}" fill="${C.paper}" ${dash}/>`;
81
+ return `<g data-mark="outcome" data-status="unchecked"${op}>${body}</g>`;
82
+ }
83
+ const col = status === 'red' ? RED : status === 'green' ? GREEN : C.ink2;
84
+ const soft = status === 'red' ? RED_SOFT : status === 'green' ? GREEN_SOFT : C.paper;
85
+ const body = refused
86
+ ? `<rect x="${f1(x - 8 * s)}" y="${f1(y - 3 * s)}" width="${f1(16 * s)}" height="${f1(6 * s)}" rx="${f1(2 * s)}" fill="${col}"/>`
87
+ : `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(7 * s)}" fill="${soft}" stroke="${col}" stroke-width="${f1(2 * s)}"/><circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(3 * s)}" fill="${col}"/>`;
88
+ return `<g data-mark="outcome" data-status="${status}"${op}>${body}</g>`;
89
+ }
90
+ const statusInk = status => (status === 'red' ? RED : status === 'green' ? GREEN : status === 'unchecked' ? C.muted : C.ink);
91
+ // a claim's tick: ✓ holds, ✗ fails, ? the engine cannot say
92
+ export const tick = c => (c.verdict === 'holds' ? '✓' : c.verdict === 'fails' ? '✗' : '?');
93
+ const ticksOf = l => l.claims.map(tick).join('');
94
+ const ticksInk = l => (l.claims.some(c => c.verdict === 'fails') ? RED : C.ink2);
95
+ const claimsTextOf = l => l.claims.map(c => `${c.verdict}: ${c.parsed}`).join('; ');
96
+ const holdsAbsent = c => c.verdict === 'holds' && c.property.negated && (c.property.is === 'writes' || c.property.is === 'reads');
97
+
98
+ // ---------------------------------------------------------------- views
99
+
100
+ const countLeaves = t => leavesOf(t).length;
101
+ const bare = s => String(s).split('::').pop();
102
+ const isCore = e => !e.layer || e.layer === 'core';
103
+ const ROLE = { guard: 'guard', 'short-circuit': 'short-circuit', beside: 'beside the wrapper', after: 'after the handler', branch: 'wrapper branch' };
104
+ // A node the folded view draws as one box: a step that is not the
105
+ // handler's own work, with exactly one arm on to the handler.
106
+ export function foldOf(t) {
107
+ if (!t || t.kind !== 'node' || !t.concern || t.concern.layer === 'core') return null;
108
+ if (!['guard', 'short-circuit', 'beside'].includes(t.concern.role)) return null;
109
+ const go = t.arms.filter(a => a.handler), skip = t.arms.filter(a => !a.handler);
110
+ return go.length === 1 && skip.length ? { go: go[0], skip } : null;
111
+ }
112
+ // the status and codes, less what the label already says
113
+ const shortDetail = o => {
114
+ const head = outcomeLabel(o);
115
+ return [o.status && !head.includes(String(o.status)) ? String(o.status) : '', ...o.codes.filter(c => !head.split(' ').includes(c))].filter(Boolean).join(' ');
116
+ };
117
+ const outcomeText = o => {
118
+ const head = outcomeLabel(o), detail = shortDetail(o);
119
+ return `${o.kind === 'refused' ? 'refused ' : ''}${head}${detail ? ` ${detail}` : ''}`;
120
+ };
121
+ // What a folded step can answer instead of running the handler: one row per
122
+ // arm that skips it, read as a side stub reads (the arm, the writes a claim
123
+ // says it does not make, struck, then what the request gets, its witness
124
+ // number and the claims' ticks), then "continue" (a transaction: "runs").
125
+ // A skip that is not a refusal still ends the request there: "ends Denied".
126
+ const humane = p => p.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
127
+ const absentOf = l => l.claims.filter(holdsAbsent).map(c => {
128
+ const { is: verb, subject: table } = c.property;
129
+ return `${verb === 'writes' ? 'write' : 'read'} · ${table}`;
130
+ });
131
+ const endText = o => (o.kind === 'refused' ? outcomeText(o) : `ends ${outcomeLabel(o)}`);
132
+ export function foldArms(t, witnessNo = new Map()) {
133
+ const f = foldOf(t);
134
+ if (!f) return null;
135
+ const rows = f.skip.map(a => {
136
+ const ls = leavesOf(a.next);
137
+ const segs = [{ text: a.words || (a.pattern === '_' ? 'otherwise' : t.from && t.from.kind === 'platform' ? humane(a.pattern) : a.pattern), italic: !!a.words, mono: !a.words, fill: a.words ? C.ink : C.muted }];
138
+ for (const w of [...new Set(ls.flatMap(absentOf))]) segs.push({ text: w, struck: true, fill: C.faint });
139
+ segs.push({ text: '→', mono: false, fill: C.faint });
140
+ ls.forEach((l, i) => {
141
+ const no = witnessNo.get(l.id);
142
+ if (i) segs.push({ text: 'or', mono: false, fill: C.faint });
143
+ segs.push({ text: `${endText(l.outcome)}${no ? ` [${no}]` : ''}`, weight: 600, fill: statusInk(l.status) });
144
+ if (l.claims.length) segs.push({ text: ticksOf(l), mono: false, fill: ticksInk(l) });
145
+ });
146
+ return segs;
147
+ });
148
+ return [...rows, [{ text: t.from && t.from.kind === 'platform' ? f.go.arm : 'continue', fill: C.ink2 }]];
149
+ }
150
+ export function foldOutcomes(t) {
151
+ const rows = foldArms(t);
152
+ return rows && rows.map(r => r.map(x => (x.struck ? `~${x.text}~` : x.text)).join(' '));
153
+ }
154
+ function armInto(tree, node) {
155
+ let hit = null;
156
+ const go = t => { if (!t || hit || t.kind !== 'node') return; for (const a of t.arms) { if (a.next === node) { hit = a; return; } go(a.next); } };
157
+ go(tree);
158
+ return hit;
159
+ }
160
+ const effectKey = e => `${e.primitive}|${e.site}|${e.line}|${e.tables.join(',')}|${e.phase || ''}`;
161
+ function findNode(tree, id) {
162
+ let hit = null;
163
+ const go = t => { if (!t || hit || t.kind !== 'node') return; if (t.id === id) { hit = t; return; } for (const a of t.arms) go(a.next); };
164
+ go(tree);
165
+ return hit;
166
+ }
167
+ function findArm(tree, id) {
168
+ let hit = null;
169
+ const go = t => { if (!t || hit || t.kind !== 'node') return; for (const a of t.arms) { if (a.id === id) { hit = a; return; } go(a.next); } };
170
+ go(tree);
171
+ return hit;
172
+ }
173
+ function pathTo(tree, id) {
174
+ const go = (t, acc) => {
175
+ if (!t || t.kind !== 'node') return null;
176
+ for (const a of t.arms) { const here = [...acc, { node: t, arm: a }]; if (a.id === id) return here; const deeper = go(a.next, here); if (deeper) return deeper; }
177
+ return null;
178
+ };
179
+ return go(tree, []) || [];
180
+ }
181
+ export const armLabel = a => a.words || a.pattern;
182
+ // open: 'arm:<id>' (a bundle), 'step:<node id>' (a folded step), or a bare arm id
183
+ const parseOpen = open => !open || open === 'ghosts' ? null : open.startsWith('step:') ? { step: open.slice(5) } : { arm: open.startsWith('arm:') ? open.slice(4) : open };
184
+ export function openLabel(record, entryKey, open) {
185
+ const e = record.entries.find(x => x.entry === entryKey);
186
+ const o = parseOpen(open);
187
+ if (!e || !o) return e ? e.label : entryKey;
188
+ if (o.arm) return pathTo(e.tree, o.arm).map(p => armLabel(p.arm)).join(' › ');
189
+ const n = findNode(e.tree, o.step);
190
+ if (!n) return e.label;
191
+ const into = armInto(e.tree, n);
192
+ const before = into ? pathTo(e.tree, into.id).map(p => armLabel(p.arm)) : [];
193
+ return [...before, `${ROLE[n.concern.role] || n.concern.role}: ${n.words || n.test}`].join(' › ');
194
+ }
195
+
196
+ export function renderWhatHappens(record, opts = {}) {
197
+ const W = Math.max(320, opts.width || 1100);
198
+ const phone = W < 600;
199
+ const P = {
200
+ phone, W, pad: phone ? 10 : 24, step: phone ? 16 : 26, fs: phone ? 10 : 11.5, fsS: phone ? 9 : 10, row: phone ? 15 : 17,
201
+ limit: opts.limit || (phone ? 16 : 24), witnessNo: new Map(), cross: opts.cross === 'shown' ? 'shown' : 'folded', stepRoot: null,
202
+ };
203
+ const entry = record.entries.find(e => e.entry === opts.entry) || record.entries.find(e => e.described) || record.entries[0];
204
+ const out = [];
205
+ let y = 14;
206
+ // the frame label
207
+ const frameTop = 8;
208
+ y = frameTop + (phone ? 18 : 22);
209
+ // the entry rack: the described entries, then one line for the others
210
+ y = rack(record, entry, P, y, out, !!opts.ghosts || opts.open === 'ghosts');
211
+ // whether a base was compared, always in this place: a fix is then read
212
+ // off the frame, also when nothing is green
213
+ y = compared(record, P, y + (phone ? 13 : 15), out);
214
+ y += phone ? 10 : 14;
215
+ // the entry's own line
216
+ const o = parseOpen(opts.open);
217
+ const root = o && o.arm ? findArm(entry.tree, o.arm) : null;
218
+ const stepNode = o && o.step ? findNode(entry.tree, o.step) : null;
219
+ if (stepNode) { P.stepRoot = stepNode.id; P.stepArms = new Set(stepNode.arms.filter(a => a.handler).map(a => a.id)); P.cross = 'shown'; }
220
+ // the folded steps: each node step's effects go in its box, and so do a
221
+ // wrapper's effects after the handler and the work beside it
222
+ P.paths = pathEffects(entry);
223
+ P.steps = new Map((entry.steps || []).filter(st => st.node).map(st => [st.node, st]));
224
+ P.hidden = new Set((entry.steps || []).filter(st => !st.node || foldOf(findNode(entry.tree, st.node))).flatMap(st => st.effects.map(effectKey)));
225
+ const x0 = P.pad + (phone ? 6 : 12);
226
+ y = entryHead(entry, root || stepNode, record, P, x0, y, out, opts.open);
227
+ const subject = root ? root.next : stepNode || entry.tree;
228
+ // work beside the wrapper, folded; a wrapper's work after the handler is
229
+ // folded on each path that runs it, just before its outcome
230
+ P.wrapperName = entry.wrapper && entry.wrapper.generator ? bare(entry.wrapper.generator) : null;
231
+ P.afterSteps = (entry.steps || []).filter(st => st.role === 'after' && !st.node);
232
+ P.after = P.cross === 'folded' ? P.afterSteps : [];
233
+ if (P.cross === 'folded' && !root && !stepNode) for (const st of entry.steps || []) if (!st.node && st.role !== 'after') y = foldBox(x0, y, P, out, {
234
+ key: st.id, role: st.role, head: `${ROLE[st.role] || st.role}${st.wrapper ? ` · in ${bare(st.wrapper)}` : ''}`, outs: [], effects: st.effects });
235
+ // stations before the first branch; an opened step's own effects before it
236
+ if (!root && !stepNode) y = stations(entry.effects, x0, y, P, out, 'entry');
237
+ if (stepNode) { const into = armInto(entry.tree, stepNode); y = stations(into ? into.effects : entry.effects, x0, y, P, out, 'step'); }
238
+ if (entry.unsupported && !entry.tree) {
239
+ out.push(T(x0 + 12, y + P.row * 0.7, entry.truncated ? `not drawn: ${entry.unsupported}` : entry.unsupported, { size: P.fsS, fill: C.muted, mono: false }));
240
+ y += P.row * 1.4;
241
+ } else {
242
+ const red = leavesOf(subject).filter(l => l.status === 'red' || l.status === 'green' || l.status === 'unchecked');
243
+ red.forEach((l, i) => P.witnessNo.set(l.id, i + 1));
244
+ y = tree(subject, 0, x0, y, P, out, entry, true);
245
+ }
246
+ y = witnessList(record, entry, subject, P, x0, y + (phone ? 10 : 14), out);
247
+ const H = Math.ceil(y + (phone ? 10 : 16));
248
+ const frame = labelled('frame', `<rect x="2.5" y="${frameTop}" width="${W - 5}" height="${H - frameTop - 3}" rx="10" fill="none" stroke="${C.frame}" stroke-width="1"/>` +
249
+ `<text x="14" y="${frameTop + 4}" font-family="${SANS}" font-size="${phone ? 9 : 10}" font-weight="600" fill="${C.muted}" stroke="${C.page}" stroke-width="6" paint-order="stroke">Yichus output</text>`);
250
+ const label = `${record.program}: what happens when ${entry.label} runs`;
251
+ return `<svg xmlns="http://www.w3.org/2000/svg" class="what-happens-svg" data-cross="${P.cross}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="group" aria-label="${esc(label)}" data-fit="canvas">` +
252
+ `<rect width="${W}" height="${H}" fill="${C.page}"${boxAttr('canvas')}/>${frame}${out.join('')}</svg>`;
253
+ }
254
+
255
+ function rack(record, selected, P, y, out, expanded) {
256
+ const { pad, W, phone } = P;
257
+ const fs = P.fsS, gap = phone ? 12 : 18, lh = phone ? 17 : 19;
258
+ const x0 = pad + (phone ? 6 : 12);
259
+ let x = x0, yy = y;
260
+ const ghosts = record.entries.filter(e => !e.described);
261
+ out.push(T(x, yy, ghosts.length ? `entries · ${record.entries.length - ghosts.length} of ${record.entries.length} described by a scenario` : `entries · every one described`,
262
+ { size: fs, fill: C.muted, mono: false }));
263
+ yy += lh;
264
+ // the described entries, and the one on view; the others are one line
265
+ // that opens to show each of them, struck
266
+ const shown = record.entries.filter(e => e.described || e.entry === selected.entry || expanded);
267
+ const place = w => { if (x + w > W - pad - 6 && x > x0 + 8) { x = x0; yy += lh; } };
268
+ for (const e of shown) {
269
+ const red = leavesOf(e.tree).some(l => l.status === 'red'), green = leavesOf(e.tree).some(l => l.status === 'green');
270
+ const unchecked = !red && !green && leavesOf(e.tree).some(l => l.status === 'unchecked');
271
+ const text = clip(e.label, fs, W - 2 * pad - 30);
272
+ const w = 12 + width(text, fs, true) + (red || green || unchecked ? 9 : 0);
273
+ place(w);
274
+ const on = e.entry === selected.entry;
275
+ const ghost = !e.described;
276
+ let g = '';
277
+ g += ghost ? ghostHead(x, yy) : mark.head(x + 7, yy - 4, allEffects(e).some(f => isWriteOp(f.op)), 0.7);
278
+ g += T(x + 11, yy, text, { size: fs, fill: ghost ? C.faint : on ? C.ink : C.ink2, weight: on ? 700 : null });
279
+ if (ghost) g += shape('struck', `<path d="M${f1(x + 10)} ${f1(yy - 3.5)} H${f1(x + 11 + width(text, fs, true))}" stroke="${C.faint}" stroke-width="1.1"/>`);
280
+ if (on) g += `<path d="M${f1(x + 11)} ${f1(yy + 3)} H${f1(x + 11 + width(text, fs, true))}" stroke="${C.ink}" stroke-width="1.6"/>`;
281
+ if (red || green) g += `<circle cx="${f1(x + 11 + width(text, fs, true) + 5)}" cy="${f1(yy - 3.5)}" r="3" fill="${red ? RED : GREEN}"/>`;
282
+ else if (unchecked) g += `<circle data-status="unchecked" cx="${f1(x + 11 + width(text, fs, true) + 5)}" cy="${f1(yy - 3.5)}" r="3" fill="${C.paper}" stroke="${C.muted}" stroke-width="1.1" stroke-dasharray="1.6 1.2"/>`;
283
+ const what = `${e.label}${e.title ? `: ${e.title}` : ''}${ghost ? ' (no scenario describes it)' : ''}`;
284
+ out.push(tg([`${ghost ? 'ghost' : 'entry'}:${e.entry}`], `<g${labelAttr(`entry:${e.entry}`)}${opener(`entry:${e.entry}`, `Show ${what}`)}>${title(what)}${g}</g>`));
285
+ x += w + gap;
286
+ }
287
+ const hidden = ghosts.filter(e => !shown.includes(e)).length;
288
+ if (ghosts.length && (hidden || expanded)) {
289
+ const n = expanded ? ghosts.length : hidden;
290
+ const text = `${n} ${n === 1 ? 'entry' : 'entries'} not described${expanded ? ' · fold' : ''}`;
291
+ x = x0; yy += lh;
292
+ const g = ghostHead(x, yy) + T(x + 11, yy, text, { size: fs, fill: C.muted, mono: false });
293
+ out.push(tg([`ghosts:${n}`], `<g data-ghosts="${expanded ? 'open' : 'folded'}"${opener('ghosts', expanded ? 'Fold the entries no scenario describes' : `Show the ${n} entries no scenario describes`)}>${title(`${n} entries no scenario describes`)}${g}</g>`));
294
+ }
295
+ return yy + 4;
296
+ }
297
+ function compared(record, P, y, out) {
298
+ const x = P.pad + (P.phone ? 6 : 12), d = record.diff;
299
+ if (!d) {
300
+ const text = 'no base compared', w = width(text, P.fsS, false);
301
+ out.push(tg(['compared:none'], `<g${labelAttr('compared')}>${title('no earlier version of the program was compared, so nothing here says what was fixed')}${T(x, y, text, { size: P.fsS, fill: C.faint, mono: false })}${shape('struck', `<path d="M${f1(x - 1)} ${f1(y - 3.5)} H${f1(x + w + 1)}" stroke="${C.faint}" stroke-width="1.1"/>`)}</g>`));
302
+ return y + 4;
303
+ }
304
+ const parts = [[`compared with ${d.base}`, C.muted, null], [`${d.fixed.length} fixed`, d.fixed.length ? GREEN : C.muted, 600], [`${d.stillRed.length} still failing`, d.stillRed.length ? RED : C.muted, 600]];
305
+ if (d.unresolved.length) parts.push([`${d.unresolved.length} no longer checked`, C.muted, 600]);
306
+ if (d.goneAtHead.length) parts.push([`${d.goneAtHead.length} gone with ${d.goneAtHead.length === 1 ? 'its entry' : 'their entries'}`, C.muted, null]);
307
+ let cx = x, g = '', yy = y;
308
+ parts.forEach(([text, fill, weight], i) => {
309
+ const t = `${i ? '· ' : ''}${text}`, w = width(t, P.fsS, false);
310
+ if (cx > x && cx + w > P.W - P.pad - 6) { cx = x + 8; yy += P.row * 0.8; }
311
+ g += T(cx, yy, t, { size: P.fsS, fill, weight, mono: false });
312
+ cx += w + 5;
313
+ });
314
+ out.push(tg([`compared:${d.base}`], `<g>${title(`compared with ${d.base}: ${d.fixed.length} red leaves of the base have every failure checked and holding here; ${d.stillRed.length} are red in both; ${d.unresolved.length} have a failure not checked here`)}${g}</g>`));
315
+ return yy + 4;
316
+ }
317
+ const ghostHead = (x, y) => `<g data-mark="entry"><path d="M${f1(x)} ${f1(y - 8)} L${f1(x + 7)} ${f1(y - 4)} L${f1(x)} ${f1(y)}" fill="none" stroke="${C.faint}" stroke-width="1.3" stroke-dasharray="2 1.6"/></g>`;
318
+
319
+ function entryHead(entry, root, record, P, x0, y, out, open) {
320
+ const { phone, W, pad } = P;
321
+ y += P.row;
322
+ out.push(mark.head(x0 + 1, y - 4, allEffects(entry).some(f => isWriteOp(f.op)), 0.95));
323
+ // rotate nothing: the head points into the program; the line runs down from it
324
+ const room = W - pad - (x0 + 12) - 6;
325
+ out.push(tg([`entry:${entry.entry}`], T(x0 + 12, y, clip(entry.label, P.fs + 1, room), { size: P.fs + 1, weight: 700, fill: C.ink })));
326
+ if (entry.title) {
327
+ y += P.row;
328
+ out.push(tg([`words:${entry.entry}`], `<g>${title(`author's title for ${entry.entry}`)}${T(x0 + 12, y, clip(entry.title, P.fs, room, false), { size: P.fs, italic: true, mono: false, fill: C.ink })}</g>`));
329
+ }
330
+ if (root) {
331
+ y += P.row;
332
+ out.push(T(x0 + 12, y, clip(`opened at ${openLabel(record, entry.entry, open)}`, P.fsS, room, false), { size: P.fsS, mono: false, fill: C.muted }));
333
+ }
334
+ y += phone ? 6 : 8;
335
+ out.push(line(x0, y - 2, x0, y + 6));
336
+ return y + 6;
337
+ }
338
+
339
+ const line = (x1, y1, x2, y2, o = {}) => `<path d="M${f1(x1)} ${f1(y1)} ${x1 === x2 ? `V${f1(y2)}` : `C${f1(x1)} ${f1(y1 + (y2 - y1) * 0.55)} ${f1(x2)} ${f1(y1 + (y2 - y1) * 0.35)} ${f1(x2)} ${f1(y2)}`}" fill="none" stroke="${o.color || C.ink2}" stroke-width="${o.w || 2.2}" stroke-linecap="round"${o.dash ? ` stroke-dasharray="${o.dash}"` : ''}/>`;
340
+
341
+ function stations(effects, x, y, P, out, key) {
342
+ for (const e of (effects || []).filter(e => P.cross === 'shown' || isCore(e) || !P.hidden.has(effectKey(e)))) {
343
+ const r = P.phone ? 4.6 : 5.4;
344
+ const cy = y + P.row * 0.6;
345
+ out.push(line(x, y, x, cy + P.row * 0.6));
346
+ const color = e.resource === 'model' ? C.model : e.resource === 'net' ? C.net : e.resource === 'db' ? C.db : C.muted;
347
+ const kind = e.resource === 'model' ? 'modelRing' : e.resource === 'net' ? 'netRing' : 'station';
348
+ const ring = kind === 'station' ? mark.station(x, cy, r, isWriteOp(e.op), color) : shape(kind, `<circle cx="${f1(x)}" cy="${f1(cy)}" r="${r}" fill="${C.paper}" stroke="${color}" stroke-width="2.2"/>`);
349
+ // a wrapper's effect says so, and one after the handler whether every path that runs the handler runs it
350
+ const after = e.phase === 'after' ? (P.afterSteps || []).find(st => st.effects.some(x => effectKey(x) === effectKey(e))) : null;
351
+ const whose = e.layer && e.layer !== 'core' && e.layer !== 'beside' && e.layer !== 'platform' && P.wrapperName ? `, in ${P.wrapperName}` : '';
352
+ const text = stationLabel(e) + (isCore(e) ? '' : ` · ${e.phase === 'after' ? `after the handler${whose}${after ? (after.everyPath ? ' · on every path that runs it' : ' · not on every path that runs it') : ''}` : e.layer === 'beside' ? 'beside the wrapper' : `before the handler${whose}`}`);
353
+ const where = `${text} in ${String(e.site).split('::').pop()}${e.line ? `, line ${e.line}` : ''}`;
354
+ const rows = lines(text, P.fsS, P.W - P.pad - x - r - 10, true, 2);
355
+ out.push(tg([`effect:${key}:${e.op}:${e.tables.join(',')}`], `<g data-layer="${e.layer || 'core'}">${title(where)}${ring}${rows.map((l, i) => T(x + r + 6 + (i ? 8 : 0), cy + 3.5 + i * P.row * 0.8, l, { size: P.fsS, fill: isCore(e) ? color : C.hullInk })).join('')}</g>`));
356
+ if (rows.length > 1) out.push(line(x, cy, x, cy + P.row * 1.2));
357
+ y = cy + P.row * 0.6 + (rows.length - 1) * P.row * 0.8;
358
+ }
359
+ return y;
360
+ }
361
+
362
+ // One subtree from (x, y) down. Returns the y where it ends.
363
+ // A folded step: one shaded box on the line (the map's hull), its role,
364
+ // what it answers instead of running the handler, and its effects.
365
+ function foldBox(x, y, P, out, { key, role, head, outs, effects, open }) {
366
+ const lx = x + (P.phone ? 13 : 16), room = P.W - P.pad - lx - 10;
367
+ const lh = P.row * 0.82, fs = P.fsS;
368
+ // each row is a list of pieces laid out left to right; a piece that does
369
+ // not fit starts the next line, indented
370
+ const rows = [];
371
+ for (const l of lines(head, fs, room - 8, false, 3, 700)) rows.push([{ text: l, fill: C.hullInk, weight: 700, mono: false }]);
372
+ for (const o of outs) rows.push(typeof o === 'string' ? [{ text: o, fill: C.ink2 }] : o);
373
+ const eff = [...new Set((effects || []).map(stationLabel))];
374
+ if (eff.length) for (const l of lines(eff.join(', '), fs, room, true, 2)) rows.push([{ text: l, fill: C.hullInk }]);
375
+ const placed = [];
376
+ let line_ = 0, wMax = 0;
377
+ for (const r of rows) {
378
+ let cx = 0;
379
+ for (const piece of r) {
380
+ const mono = piece.mono !== false;
381
+ const text = clip(piece.text, fs, room - 8, mono, piece.weight, piece.italic), w = width(text, fs, mono, piece.weight, piece.italic);
382
+ if (cx > 0 && cx + w > room) { line_ += 1; cx = 8; }
383
+ placed.push({ ...piece, text, mono, x: cx, line: line_, w });
384
+ cx += w + 6;
385
+ wMax = Math.max(wMax, cx - 6);
386
+ }
387
+ line_ += 1;
388
+ }
389
+ const top = y + P.row * 0.35, h = line_ * lh + 8;
390
+ out.push(line(x, y, x, top + h + P.row * 0.35));
391
+ const box = `fold:${key}`;
392
+ let g = shape('hull', `<rect x="${f1(x - 8)}" y="${f1(top)}" width="${f1(lx - x + Math.min(room, wMax) + 16)}" height="${f1(h)}" rx="${P.phone ? 7 : 9}" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1.2"${boxAttr(box)}/>`);
393
+ g += `<path d="M${f1(x)} ${f1(top - 1)} V${f1(top + h + 1)}" stroke="${C.ink2}" stroke-width="2.2"/>`;
394
+ for (const p of placed) {
395
+ const px = lx + p.x, py = top + 4 + lh * (p.line + 0.72);
396
+ const strike = p.struck ? `strike:${key}:${p.line}:${p.x}` : null;
397
+ g += T(px, py, p.text, { size: fs, fill: p.fill, weight: p.weight, mono: p.mono, italic: p.italic, fit: box, label: strike });
398
+ if (strike) g += shape('struck', `<path d="M${f1(px)} ${f1(py - 3.5)} H${f1(px + p.w)}" stroke="${C.faint}" stroke-width="1"${labelAttr(strike)}/>`);
399
+ }
400
+ const said = outs.map(o => (typeof o === 'string' ? o : o.map(x => (x.struck ? `no ${x.text}` : x.text)).join(' ')));
401
+ const what = `${head}: ${said.join('; ')}${eff.length ? `; ${eff.join(', ')}` : ''}`;
402
+ out.push(tg([`fold:${key}`], `<g data-fold="${esc(key)}" data-role="${esc(role)}"${open ? opener(open, `Open ${what}`) : ''}>${title(what)}${g}</g>`));
403
+ return top + h + P.row * 0.35;
404
+ }
405
+
406
+ // a step's role and where it comes from: its folded box's head, and the
407
+ // line above its test when it is drawn inline
408
+ function stepParts(t) {
409
+ const platform = t.from && t.from.kind === 'platform';
410
+ const where = t.concern.wrapper ? `in ${bare(t.concern.wrapper)}` : platform ? `in ${bare(t.from.site)}` : t.concern.layer === 'platform' ? 'declared on the route' : t.concern.layer === 'beside' ? 'beside the wrapper' : bare(t.site);
411
+ return [platform ? 'platform step' : ROLE[t.concern.role] || t.concern.layer, where];
412
+ }
413
+ const stepHead = t => stepParts(t).join(' · ');
414
+
415
+ function tree(t, depth, x, y, P, out, entry, top) {
416
+ if (!t) return y;
417
+ if (t.kind === 'leaf') return leaf(t, x, y, P, out);
418
+ const f = P.cross === 'folded' && P.stepRoot !== t.id ? foldOf(t) : null;
419
+ if (f) {
420
+ const [role, where] = stepParts(t);
421
+ const effects = P.steps.get(t.id)?.effects || [];
422
+ y = foldBox(x, y, P, out, { key: t.id, role: t.concern.role, head: `${role} · ${t.words || t.test} · ${where}`,
423
+ outs: foldArms(t, P.witnessNo), effects, open: `step:${t.id}` });
424
+ y = stations(f.go.effects, x, y, P, out, f.go.id);
425
+ return sub(f.go, depth + 1, x, y, P, out, entry);
426
+ }
427
+ const guard = t.from && t.from.kind === 'guard';
428
+ // a step that is not the handler's own work keeps its concern when it is
429
+ // drawn inline: its role and where it comes from, in the wrapper's ink,
430
+ // above the test, and a light band behind the step and its exits
431
+ const concern = t.concern && t.concern.layer !== 'core' ? stepHead(t) : null;
432
+ const bandAt = out.length, bandTop = y;
433
+ if (concern) {
434
+ const hy = y + P.row * 0.75, hx = x + (P.phone ? 13 : 16);
435
+ out.push(line(x, y, x, hy + 2));
436
+ for (const [i, l] of lines(concern, P.fsS, P.W - P.pad - hx - 6, false, 2, 700).entries())
437
+ out.push(tg([`concern:${t.id}`], T(hx, hy + i * P.row * 0.8, l, { size: P.fsS, mono: false, weight: 700, fill: C.hullInk, fit: `band:${t.id}` })));
438
+ y = hy + (width(concern, P.fsS, false, 700) > P.W - P.pad - hx - 6 ? P.row * 0.8 : 0);
439
+ }
440
+ const ny = y + P.row * 0.7;
441
+ out.push(line(x, y, x, ny));
442
+ const crossNode = !!concern && !guard;
443
+ out.push(tg([`node:${t.id}`], `<g${t.concern ? ` data-layer="${t.concern.layer}"${t.concern.role ? ` data-role="${t.concern.role}"` : ''}` : ''}>${title(`${t.test} · ${t.key}${t.from && t.from.site ? ` in ${String(t.from.site).split('::').pop()}` : ''}${t.from && t.from.line ? `, line ${t.from.line}` : ''}`)}${guard ? mark.guard(x, ny, P.phone ? 0.75 : 0.85) : mark.fork(x - 1, ny, P.phone ? 0.85 : 1)}</g>`));
444
+ // the test: the author's words in italic, else the code
445
+ const lx = x + (P.phone ? 13 : 16), room = P.W - P.pad - lx - 6;
446
+ let yy = ny + 3.5;
447
+ if (t.words) {
448
+ out.push(tg([`words:${t.id}`], `<g>${title(`${t.test} (${t.key})`)}${T(lx, yy, clip(t.words, P.fs, room, false, 400, true), { size: P.fs, italic: true, mono: false, fill: crossNode ? C.hullInk : C.ink })}</g>`));
449
+ if (!P.phone) out.push(T(lx + Math.min(room, width(clip(t.words, P.fs, room, false, 400, true), P.fs, false, 400, true)) + 8, yy, clip(t.test, P.fsS, Math.max(0, room - width(t.words, P.fs, false, 400, true) - 10)), { size: P.fsS, fill: C.faint }));
450
+ } else {
451
+ for (const [i, l] of lines(t.test, P.fs, room, !guard, 2, guard ? 600 : 400).entries())
452
+ out.push(T(lx, yy + i * P.row * 0.85, l, { size: P.fs, mono: !guard, fill: guard ? RED : crossNode ? C.hullInk : C.ink2, weight: guard ? 600 : null }));
453
+ if (width(t.test, P.fs, !guard, guard ? 600 : 400) > room) yy += P.row * 0.85;
454
+ }
455
+ y = yy + P.row * 0.35;
456
+ // the spine continues straight down: the arm that reaches the main
457
+ // outcome. An arm that ends at once is a stub off to the side, its arm
458
+ // and outcome on one line; only an arm with branches of its own nests.
459
+ const main = mainLeaf(t, P);
460
+ const trunk = t.arms.find(a => leavesOf(a.next).includes(main)) || t.arms[t.arms.length - 1];
461
+ const others = t.arms.filter(a => a !== trunk);
462
+ const xs = x + P.step;
463
+ for (const a of others) {
464
+ if (stubbable(a, P)) { y = stub(a, x, y, P, out, guard); continue; }
465
+ const start = y;
466
+ const ay = start + P.row * 0.9;
467
+ out.push(line(x, start - 2, xs, ay));
468
+ out.push(line(x, start - 2, x, ay + 2));
469
+ y = armText(a, xs, ay, P, out, guard);
470
+ y = stations(a.effects, xs, y, P, out, a.id);
471
+ y = sub(a, depth + 1, xs, y, P, out, entry);
472
+ y += P.phone ? 4 : 6;
473
+ }
474
+ if (concern) {
475
+ const bx = x - (P.phone ? 7 : 9), bw = P.W - P.pad - 4 - bx;
476
+ out.splice(bandAt, 0, shape('hull', `<rect data-band="${esc(t.id)}" x="${f1(bx)}" y="${f1(bandTop + 2)}" width="${f1(bw)}" height="${f1(y + P.row * 0.2 - bandTop - 2)}" rx="6" fill="${C.hull}" opacity="0.55"${boxAttr(`band:${t.id}`)}/>`));
477
+ }
478
+ // the spine's arm, below the band when there is one
479
+ const ay = Math.max(y + P.row * 0.5, concern ? y + P.row * 0.2 + P.fs * 0.9 + 2 : 0);
480
+ out.push(line(x, y - 2, x, ay));
481
+ y = armText(trunk, x, ay, P, out, guard, true);
482
+ y = stations(trunk.effects, x, y, P, out, trunk.id);
483
+ // a leaf right under its arm does not repeat the arm's words
484
+ P.armWords = trunk.next.kind === 'leaf' ? trunk.words : null;
485
+ return sub(trunk, depth + 1, x, y, P, out, entry);
486
+ }
487
+
488
+ // The main outcome under a node: a leaf that is not a refusal (a refusal
489
+ // only when every path is one); among those, one a holding claim names
490
+ // (not a negation), then the one with the most effects on its path. Ties
491
+ // go to the earlier leaf.
492
+ function mainLeaf(t, P) {
493
+ const ls = leavesOf(t);
494
+ const rank = l => [l.outcome.kind === 'refused' ? 0 : 1, l.claims.some(c => c.verdict === 'holds' && !c.property.negated) ? 1 : 0, (P.paths.get(l.id) || []).length];
495
+ const better = (a, b) => { const ra = rank(a), rb = rank(b); for (let i = 0; i < ra.length; i++) if (rb[i] !== ra[i]) return rb[i] > ra[i] ? b : a; return a; };
496
+ return ls.reduce(better);
497
+ }
498
+
499
+ // An early exit drawn as one line: the arm ends at a leaf, and nothing on
500
+ // the way needs a row of its own (no folded step after the handler).
501
+ const stubbable = (a, P) => a.next.kind === 'leaf' && !(a.next.handler && P.after.some(st => st.effects.some(e => (P.paths.get(a.next.id) || []).some(h => effectKey(h) === effectKey(e)))));
502
+
503
+ function stub(a, x, y, P, out, guard) {
504
+ const l = a.next, refused = l.outcome.kind === 'refused';
505
+ const ry = y + P.row * 0.85, bx = x + (P.phone ? 12 : 16), s = P.phone ? 0.7 : 0.8;
506
+ out.push(`<path d="M${f1(x)} ${f1(ry - P.row * 0.45)} C${f1(x)} ${f1(ry - 1)} ${f1(x + 3)} ${f1(ry)} ${f1(bx)} ${f1(ry)}" fill="none" stroke="${C.ink2}" stroke-width="1.6" stroke-linecap="round"/>`);
507
+ const right = P.W - P.pad - 4, lx = bx + 4;
508
+ // one line, left to right; a piece that does not fit starts the next row
509
+ let cx = lx, cy = ry;
510
+ const place = w => { if (cx > lx && cx + w > right) { cx = lx + 8; cy += P.row * 0.8; } const at = cx; cx += w + 6; return at; };
511
+ // the arm, in the author's words or the code
512
+ const words = a.words, text = words || (a.pattern === '_' ? 'otherwise' : a.pattern);
513
+ const ao = { size: P.fsS, italic: !!words, mono: !words && !guard, fill: words ? C.ink : C.muted };
514
+ const at = clip(text, P.fsS, (right - lx) * 0.6, !!ao.mono);
515
+ out.push(tg([`arm:${a.id}`], `<g>${title(`${a.pattern}${a.from && a.from.line ? `, line ${a.from.line}` : ''}`)}${T(place(width(at, P.fsS, !!ao.mono)), cy + 3.5, at, ao)}</g>`));
516
+ // what the exit does on the way: its effects, and the writes a claim says it does not make
517
+ for (const e of a.effects.filter(e => P.cross === 'shown' || isCore(e) || !P.hidden.has(effectKey(e)))) {
518
+ const r = 3.6, lbl = clip(stationLabel(e), P.fsS, right - lx - 20);
519
+ const ex = place(2 * r + 3 + width(lbl, P.fsS, true));
520
+ const color = e.resource === 'model' ? C.model : e.resource === 'net' ? C.net : e.resource === 'db' ? C.db : C.muted;
521
+ out.push(tg([`effect:${a.id}:${e.op}:${e.tables.join(',')}`], `<g data-layer="${e.layer || 'core'}">${title(`${stationLabel(e)} in ${bare(e.site)}${e.line ? `, line ${e.line}` : ''}`)}${mark.station(ex + r, cy, r, isWriteOp(e.op), color)}${T(ex + 2 * r + 3, cy + 3.5, lbl, { size: P.fsS, fill: isCore(e) ? color : C.hullInk })}</g>`));
522
+ }
523
+ for (const c of l.claims.filter(holdsAbsent)) {
524
+ const { is: verb, subject: table } = c.property;
525
+ const lbl = `${verb === 'writes' ? 'write' : 'read'} · ${table}`, lw = width(lbl, P.fsS, true), sx = place(lw);
526
+ out.push(tg([`absent:${l.id}:${verb}:${table}`], labelled(`absent:${l.id}:${verb}:${table}`, shape('struck', `${title(`checked: this path ${verb} no ${table}`)}${T(sx, cy + 3.5, lbl, { size: P.fsS, fill: C.faint })}<path d="M${f1(sx)} ${f1(cy)} H${f1(sx + lw)}" stroke="${C.faint}" stroke-width="1"/>`))));
527
+ }
528
+ out.push(T(place(8), cy + 3.5, '→', { size: P.fsS, fill: C.faint, mono: false }));
529
+ // the outcome, its status and codes, and the claims' ticks
530
+ const head = outcomeLabel(l.outcome), detail = shortDetail(l.outcome);
531
+ const no = P.witnessNo.get(l.id), badge = no ? ` [${no}]` : '';
532
+ const color = statusInk(l.status);
533
+ const tt = clip(`${refused ? 'refused ' : ''}${head}${badge}`, P.fsS, right - lx - 30);
534
+ const ox = place(18 + width(tt, P.fsS, true));
535
+ let g = outcomeMark(ox + 7, cy, l.status, refused, s) + T(ox + 18, cy + 3.5, tt, { size: P.fsS, fill: color, weight: 600 });
536
+ if (detail) g += T(place(width(detail, P.fsS, true)), cy + 3.5, detail, { size: P.fsS, fill: C.muted });
537
+ const ticks = ticksOf(l);
538
+ if (ticks) g += T(place(width(ticks, P.fsS, false)), cy + 3.5, ticks, { size: P.fsS, fill: ticksInk(l), mono: false });
539
+ // a second row only for the author's words or the base it fixes
540
+ if (l.words && l.words !== a.words) { cy += P.row * 0.8; g += `<g>${title(`author's words for ${l.id}`)}${T(lx, cy + 3.5, clip(l.words, P.fsS, right - lx, false), { size: P.fsS, italic: true, mono: false, fill: C.ink })}</g>`; }
541
+ if (l.status === 'green' && l.base) {
542
+ cy += P.row * 0.85;
543
+ const extra = l.base.effects.filter(e => !(P.paths.get(l.id) || []).some(h => h.op === e.op && h.tables.join() === e.tables.join())).map(stationLabel);
544
+ const btxt = clip(`base: ${l.base.outcome.kind === 'refused' ? 'refused ' : ''}${outcomeLabel(l.base.outcome)}${extra.length ? ` after ${extra.join(', ')}` : ''}`, P.fsS, right - lx - 14);
545
+ g += `<g data-f="${encodeURIComponent(`base:${l.id}`)}">${title('the same leaf at the base, where it was red')}${outcomeMark(lx + 4, cy, 'red', l.base.outcome.kind === 'refused', s * 0.8, true)}${T(lx + 13, cy + 3.5, btxt, { size: P.fsS, fill: RED, opacity: 0.6 })}</g>`;
546
+ }
547
+ out.push(line(x, y - 2, x, cy + P.row * 0.45));
548
+ const claimsText = claimsTextOf(l);
549
+ out.push(tg([`leaf:${l.id}`], `<g data-stub="1"${no ? ` data-witness="${no}"` : ''}>${title(`${l.id}: ${l.outcome.shown}${claimsText ? ` · ${claimsText}` : ''}`)}${g}</g>`));
550
+ return cy + P.row * 0.45;
551
+ }
552
+
553
+ function sub(a, depth, x, y, P, out, entry) {
554
+ const n = countLeaves(a.next);
555
+ // an opened step: its way on to the handler is a bundle that opens it
556
+ const onward = !!P.stepArms && P.stepArms.has(a.id);
557
+ if (a.next.kind === 'node' && (n > P.limit || onward)) {
558
+ // too large for this view: a bundle that opens the arm's own tree
559
+ const by = y + P.row * 0.9;
560
+ out.push(line(x, y, x, by, { w: 2.2 + Math.min(n, 30) * 0.12 }));
561
+ const count = st => leavesOf(a.next).filter(l => l.status === st).length;
562
+ const red = count('red'), green = count('green'), unchecked = count('unchecked');
563
+ const txt = `${onward ? 'on to the handler · ' : ''}×${n} paths · open`;
564
+ const fill = red ? RED : green ? GREEN : C.ink2;
565
+ out.push(tg([`bundle:${a.id}`], `<g${opener(`arm:${a.id}`, `Open ${armLabel(a)}: ${n} paths`)}>${title(`${n} paths under ${armLabel(a)}${red ? `, ${red} red` : ''}${green ? `, ${green} fixed` : ''}${unchecked ? `, ${unchecked} unchecked` : ''}`)}` +
566
+ shape('cable', `<rect x="${f1(x - 6)}" y="${f1(by - 2)}" width="12" height="9" rx="3" fill="${C.ink2}"/>`) +
567
+ T(x + 12, by + 6, txt, { size: P.fsS, fill, weight: 600 }) +
568
+ (red ? T(x + 16 + width(txt, P.fsS, true), by + 6, `${red} red`, { size: P.fsS, fill: RED }) : '') + `</g>`));
569
+ return by + P.row * 0.9;
570
+ }
571
+ return tree(a.next, depth, x, y, P, out, entry, false);
572
+ }
573
+
574
+ function armText(a, x, y, P, out, guard, trunk = false) {
575
+ const lx = x + (P.phone ? 7 : 9), room = P.W - P.pad - lx - 4;
576
+ const words = a.words;
577
+ const text = words || (a.pattern === '_' ? 'otherwise' : a.pattern);
578
+ const o = { size: P.fsS, italic: !!words, mono: !words && !guard, fill: words ? C.ink : C.muted };
579
+ out.push(tg([`arm:${a.id}`], `<g>${title(`${a.pattern}${a.from && a.from.line ? `, line ${a.from.line}` : ''}`)}${T(lx, y + 3, clip(`${trunk ? '' : ''}${text}`, P.fsS, room, !!o.mono), o)}</g>`));
580
+ return y + P.row * 0.45;
581
+ }
582
+
583
+ function leaf(l, x, y, P, out) {
584
+ const refused = l.outcome.kind === 'refused';
585
+ const here = new Set((P.paths.get(l.id) || []).map(effectKey));
586
+ for (const st of P.after) if (l.handler && st.effects.some(e => here.has(effectKey(e))))
587
+ y = foldBox(x, y, P, out, { key: `${st.id}:${l.id}`, role: 'after', head: `${ROLE.after}${st.wrapper ? ` · in ${bare(st.wrapper)}` : ''}`,
588
+ outs: [st.everyPath ? 'on every path that runs the handler' : 'on this path, not on every one'], effects: st.effects.filter(e => here.has(effectKey(e))) });
589
+ // a claim that a path makes no write: the write it does not make, struck
590
+ const absent = l.claims.filter(holdsAbsent);
591
+ for (const c of absent) {
592
+ const { is: verb, subject: table } = c.property;
593
+ const cy = y + P.row * 0.6;
594
+ out.push(line(x, y, x, cy + P.row * 0.5));
595
+ out.push(tg([`absent:${l.id}:${verb}:${table}`], labelled(`absent:${l.id}:${verb}:${table}`, shape('struck', `${title(`checked: this path ${verb === 'writes' ? 'writes' : 'reads'} no ${table}`)}<circle cx="${f1(x)}" cy="${f1(cy)}" r="4.6" fill="${C.paper}" stroke="${C.faint}" stroke-width="1.3" stroke-dasharray="2 1.6"/>` +
596
+ `<path d="M${f1(x - 6)} ${f1(cy + 5)} L${f1(x + 6)} ${f1(cy - 5)}" stroke="${C.faint}" stroke-width="1.2"/>` +
597
+ T(x + 10, cy + 3.5, `${verb === 'writes' ? 'write' : 'read'} · ${table}`, { size: P.fsS, fill: C.faint }) +
598
+ `<path d="M${f1(x + 10)} ${f1(cy)} H${f1(x + 10 + width(`${verb === 'writes' ? 'write' : 'read'} · ${table}`, P.fsS, true))}" stroke="${C.faint}" stroke-width="1"/>`))));
599
+ y = cy + P.row * 0.5;
600
+ }
601
+ const cy = y + P.row * 0.75;
602
+ out.push(line(x, y, x, cy - 4));
603
+ const s = P.phone ? 0.85 : 1;
604
+ const lx = x + (P.phone ? 13 : 15), room = P.W - P.pad - lx - 4;
605
+ const head = outcomeLabel(l.outcome), detail = shortDetail(l.outcome);
606
+ const color = statusInk(l.status);
607
+ const no = P.witnessNo.get(l.id);
608
+ const badge = no ? ` [${no}]` : '';
609
+ const claimsText = claimsTextOf(l);
610
+ let g = outcomeMark(x, cy, l.status, refused, s);
611
+ const t1 = clip(`${refused ? 'refused ' : ''}${head}${badge}`, P.fs, room);
612
+ g += T(lx, cy + 4, t1, { size: P.fs, fill: color, weight: 600 });
613
+ let after = width(t1, P.fs, true) + 8;
614
+ const ticks = ticksOf(l);
615
+ if (detail && after + width(detail, P.fsS, true) < room) { g += T(lx + after, cy + 4, detail, { size: P.fsS, fill: C.muted }); after += width(detail, P.fsS, true) + 8; }
616
+ if (ticks && after + width(ticks, P.fs, false) < room) g += T(lx + after, cy + 4, ticks, { size: P.fs, fill: ticksInk(l), mono: false });
617
+ let yy = cy + 4;
618
+ if (l.words && l.words !== P.armWords) { yy += P.row * 0.85; g += `<g>${title(`author's words for ${l.id}`)}${T(lx, yy, clip(l.words, P.fsS, room, false), { size: P.fsS, italic: true, mono: false, fill: C.ink })}</g>`; }
619
+ if (l.status === 'green' && l.base) {
620
+ // the base's red leaf, faded beside the fix
621
+ yy += P.row * 0.95;
622
+ const bx = lx + 6;
623
+ const extra = l.base.effects.filter(e => !(P.paths.get(l.id) || []).some(h => h.op === e.op && h.tables.join() === e.tables.join())).map(stationLabel);
624
+ const btxt = clip(`base: ${l.base.outcome.kind === 'refused' ? 'refused ' : ''}${outcomeLabel(l.base.outcome)}${extra.length ? ` after ${extra.join(', ')}` : ''}`, P.fsS, room - 14);
625
+ g += `<g data-f="${encodeURIComponent(`base:${l.id}`)}">${title('the same leaf at the base, where it was red')}${outcomeMark(bx, yy - 3, 'red', l.base.outcome.kind === 'refused', s * 0.8, true)}${T(bx + 11, yy, btxt, { size: P.fsS, fill: RED, opacity: 0.6 })}</g>`;
626
+ }
627
+ const what = `${l.id}: ${l.outcome.shown}${claimsText ? ` · ${claimsText}` : ''}`;
628
+ out.push(tg([`leaf:${l.id}`], `<g${no ? ` data-witness="${no}"` : ''}>${title(what)}${g}</g>`));
629
+ return yy + P.row * 0.75;
630
+ }
631
+
632
+ function witnessList(record, entry, subject, P, x0, y, out) {
633
+ const ls = leavesOf(subject).filter(l => P.witnessNo.has(l.id));
634
+ if (!ls.length) return y;
635
+ const room = P.W - P.pad - x0 - 22;
636
+ out.push(T(x0, y, 'why each coloured leaf is coloured', { size: P.fsS, fill: C.muted, mono: false, weight: 600 }));
637
+ y += P.row;
638
+ for (const l of ls) {
639
+ const no = P.witnessNo.get(l.id);
640
+ const rows = [];
641
+ for (const c of l.claims.filter(c => c.verdict === 'fails')) rows.push({ text: `the claim ${c.parsed} fails here`, claim: true });
642
+ for (const c of l.claims.filter(c => c.verdict === 'inconclusive')) rows.push({ text: `the engine cannot say whether ${c.parsed} holds here: ${c.reasons.join('; ')}` });
643
+ for (const wid of l.witnesses) { const w = record.witnesses.find(x => x.id === wid); if (w) rows.push({ text: `${w.check} check: ${String(w.site).split('::').pop()} ${w.operation} on ${w.resource}: ${w.detail}` }); }
644
+ const unresolved = record.diff ? record.diff.unresolved.find(u => u.id === l.id) : null;
645
+ if (unresolved) for (const r of unresolved.reasons) rows.push({ text: `not fixed, not checked: ${r}` });
646
+ if (l.status === 'green' && l.base) {
647
+ for (const c of l.base.claims.filter(c => c.verdict === 'fails')) rows.push({ text: `fixed: at the base the claim ${c.parsed} failed here; it holds now`, fixed: true });
648
+ for (const w of l.base.witnesses) rows.push({ text: `fixed: at the base the ${w.check} check reported ${String(w.site).split('::').pop()} ${w.operation} on ${w.resource}`, fixed: true });
649
+ }
650
+ const color = statusInk(l.status);
651
+ out.push(T(x0, y, `[${no}]`, { size: P.fsS, fill: color, weight: 700 }));
652
+ for (const r of rows) {
653
+ for (const t of lines(r.text, P.fsS, room, false, 3)) { out.push(tg([`why:${l.id}`], T(x0 + 22, y, t, { size: P.fsS, fill: r.fixed ? GREEN : C.ink2, mono: false }))); y += P.row * 0.85; }
654
+ }
655
+ y += P.row * 0.3;
656
+ }
657
+ return y;
658
+ }
659
+
660
+ // The author's text the engine could not check, outside the frame.
661
+ export function renderNotes(record) {
662
+ const items = [];
663
+ for (const n of record.notes) items.push(`<li><span class="wh-note-kind">not a claim</span> <q>${esc(n.text)}</q> <span class="wh-note-at">${esc(n.at)}</span></li>`);
664
+ for (const r of record.refused) items.push(`<li><span class="wh-note-kind">refused</span> <q>${esc(r.words)}</q> names <code>${esc(r.id)}</code>, which the engine did not compute</li>`);
665
+ for (const m of record.mismatches) items.push(`<li><span class="wh-note-kind">mismatch</span> a scenario names <code>${esc(m.entry)}</code>, which is not an entry of this program</li>`);
666
+ if (!items.length) return '';
667
+ return `<aside class="wh-notes" aria-label="Unverified notes"><h3>Unverified notes from the author</h3><p>Outside the frame: text that is not a checkable claim, and names for ids the engine did not compute. Nothing here is checked or drawn.</p><ul>${items.join('')}</ul></aside>`;
668
+ }
669
+
670
+ // ---------------------------------------------------------------- on a page
671
+ // <div data-what-happens="notes-api" data-entry="route:/notes/add"> draws
672
+ // evidence/what-happens/notes-api-what-happens.json.
673
+ const evidenceRoot = typeof document === 'undefined' ? null : new URL('evidence/what-happens/', import.meta.url);
674
+
675
+ export function mount(root, record) {
676
+ let entry = root.dataset.entry || null, open = null, ghosts = false;
677
+ let cross = root.dataset.cross === 'shown' ? 'shown' : 'folded';
678
+ // the control that expands or folds the steps that are not the handler's own work
679
+ const toggle = document.createElement('button');
680
+ toggle.type = 'button';
681
+ toggle.className = 'what-happens-cross';
682
+ toggle.addEventListener('click', () => { cross = cross === 'folded' ? 'shown' : 'folded'; draw(); });
683
+ const crumb = document.createElement('nav');
684
+ crumb.className = 'system-map-crumb';
685
+ crumb.setAttribute('aria-label', `${record.program}: what happens when`);
686
+ const drawing = document.createElement('div');
687
+ drawing.className = 'system-map-drawing';
688
+ const notes = document.createElement('div');
689
+ notes.innerHTML = renderNotes(record);
690
+ root.replaceChildren(crumb, toggle, drawing, notes);
691
+ let w = 0;
692
+ const draw = () => {
693
+ w = Math.max(320, Math.floor(drawing.clientWidth || root.clientWidth || 1100));
694
+ const e = record.entries.find(x => x.entry === entry) || record.entries.find(x => x.described) || record.entries[0];
695
+ entry = e.entry;
696
+ const here = document.createElement('span');
697
+ here.setAttribute('aria-current', 'page');
698
+ if (open) {
699
+ const back = document.createElement('button');
700
+ back.type = 'button';
701
+ back.textContent = e.label;
702
+ back.addEventListener('click', () => { open = null; draw(); });
703
+ here.textContent = openLabel(record, e.entry, open);
704
+ crumb.replaceChildren(back, document.createTextNode(' › '), here);
705
+ } else {
706
+ here.textContent = `${record.program}: what happens when ${e.label} runs`;
707
+ crumb.replaceChildren(here);
708
+ }
709
+ root.dataset.cross = cross;
710
+ const steps = (e.steps || []).length;
711
+ toggle.hidden = !steps;
712
+ toggle.setAttribute('aria-pressed', String(cross === 'shown'));
713
+ toggle.textContent = cross === 'folded' ? `Show the ${steps} wrapper and platform step${steps === 1 ? '' : 's'} inline` : 'Fold the wrapper and platform steps';
714
+ drawing.innerHTML = renderWhatHappens(record, { width: w, entry, open, cross, ghosts });
715
+ };
716
+ const choose = target => {
717
+ const g = target.closest('[data-open]');
718
+ if (!g) return;
719
+ const key = g.getAttribute('data-open');
720
+ if (key === 'ghosts') ghosts = !ghosts;
721
+ else if (key.startsWith('entry:')) { entry = key.slice(6); open = null; }
722
+ else if (key.startsWith('arm:') || key.startsWith('step:')) open = key;
723
+ draw();
724
+ drawing.querySelector(`[data-open="${CSS.escape(key)}"]`)?.focus();
725
+ };
726
+ drawing.addEventListener('click', event => choose(event.target));
727
+ drawing.addEventListener('keydown', event => {
728
+ if ((event.key === 'Enter' || event.key === ' ') && event.target.closest('[data-open]')) { event.preventDefault(); choose(event.target); }
729
+ });
730
+ new ResizeObserver(() => { if (Math.floor(drawing.clientWidth) !== w) draw(); }).observe(drawing);
731
+ draw();
732
+ }
733
+
734
+ if (typeof document !== 'undefined') for (const root of document.querySelectorAll('[data-what-happens]')) {
735
+ const load = async () => {
736
+ try {
737
+ const response = await fetch(new URL(`${root.dataset.whatHappens}-what-happens.json`, evidenceRoot));
738
+ if (!response.ok) throw new Error(`request failed (${response.status})`);
739
+ mount(root, (await response.json()).record);
740
+ } catch (error) {
741
+ const message = document.createElement('p');
742
+ message.setAttribute('role', 'alert');
743
+ message.textContent = `The scenario tree could not be loaded: ${error.message}.`;
744
+ const retry = document.createElement('button');
745
+ retry.type = 'button';
746
+ retry.textContent = 'Retry';
747
+ retry.addEventListener('click', load);
748
+ root.replaceChildren(message, retry);
749
+ }
750
+ };
751
+ load();
752
+ }