yichus 0.2.0 → 0.3.1

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