@prajwalghate/sourcetruth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1074 @@
1
+ /* sourcetruth — the map, in the browser.
2
+ *
3
+ * This script RENDERS. Every fact it draws — who is outside, what an action touches, whose authority
4
+ * it carries, where each card sits — was computed in Node and embedded as #st-data. It decides
5
+ * nothing about the code, and it fetches nothing.
6
+ *
7
+ * Text that came from source is only ever assigned with textContent. Contract code is full of angle
8
+ * brackets, and a report that parsed it as markup would be the least trustworthy thing on the page.
9
+ */
10
+ (() => {
11
+ "use strict";
12
+
13
+ const dataEl = document.getElementById("st-data");
14
+ const mount = document.getElementById("app");
15
+ if (!dataEl || !mount) return;
16
+
17
+ const D = JSON.parse(dataEl.textContent);
18
+ const U = D.units;
19
+ const G = D.ghosts;
20
+ const E = D.entries;
21
+ const A = D.actors;
22
+ const W = D.card.w;
23
+ const H = D.card.h;
24
+ const NS = "http://www.w3.org/2000/svg";
25
+ const TOUR_KEY = "sourcetruth.tour.v1";
26
+ const reduceMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
27
+
28
+ const nodeById = new Map([...U, ...G].map((n) => [n.id, n]));
29
+ const actorIndex = new Map(A.map((a, i) => [a.name, i]));
30
+ const bare = (p) => String(p).replace(/^\?/, "");
31
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
32
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
33
+
34
+ // ── tiny DOM helpers ────────────────────────────────────────────────────────────────────────────
35
+ function h(tag, props, ...kids) {
36
+ const el = document.createElement(tag);
37
+ if (props) {
38
+ for (const [k, v] of Object.entries(props)) {
39
+ if (v == null || v === false) continue;
40
+ if (k === "class") el.className = v;
41
+ else if (k === "text") el.textContent = v;
42
+ else if (k === "style") Object.assign(el.style, v);
43
+ else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
44
+ else el.setAttribute(k, v === true ? "" : String(v));
45
+ }
46
+ }
47
+ for (const kid of kids.flat(Infinity)) {
48
+ if (kid == null || kid === false) continue;
49
+ el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
50
+ }
51
+ return el;
52
+ }
53
+ function s(tag, attrs, ...kids) {
54
+ const el = document.createElementNS(NS, tag);
55
+ if (attrs) for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, String(v));
56
+ for (const kid of kids.flat()) if (kid) el.append(kid);
57
+ return el;
58
+ }
59
+
60
+ const PATHS = {
61
+ create: ["M8 3.2v9.6", "M3.2 8h9.6"],
62
+ destroy: ["M4.3 4.3l7.4 7.4", "M11.7 4.3l-7.4 7.4"],
63
+ call: ["M4.6 11.4l6.8-6.8", "M6.2 4.6h5.2v5.2"],
64
+ read: ["M1.7 8s2.3-4.3 6.3-4.3S14.3 8 14.3 8s-2.3 4.3-6.3 4.3S1.7 8 1.7 8z", "M8 9.7a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4z"],
65
+ blind: ["M6.3 6.2a1.8 1.8 0 1 1 2.6 1.6c-.6.3-.9.8-.9 1.4v.2", "M8 11.5v.1"],
66
+ none: ["M12.7 6.2A5 5 0 1 0 13 9.2", "M13.2 2.8v3.4H9.8"],
67
+ transition: ["M2.8 5.7h9l-2.3-2.3", "M13.2 10.3h-9l2.3 2.3"],
68
+ terminal: ["M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z", "M6 6l4 4", "M10 6l-4 4"],
69
+ back: ["M9.8 3.8L5.6 8l4.2 4.2"],
70
+ chev: ["M6.2 3.8L10.4 8l-4.2 4.2"],
71
+ code: ["M5.6 4.2L2 8l3.6 3.8", "M10.4 4.2L14 8l-3.6 3.8"],
72
+ fit: ["M2.6 6V2.6H6", "M10 2.6h3.4V6", "M13.4 10v3.4H10", "M6 13.4H2.6V10"],
73
+ plus: ["M8 3.5v9", "M3.5 8h9"],
74
+ minus: ["M3.5 8h9"],
75
+ stamp: ["M5.7 2.3h4.6v3.1L8.9 7.2H7.1L5.7 5.4z", "M2.8 9.4h10.4v3.9H2.8z"],
76
+ arrow: ["M2.5 8h11", "M9.5 4l4 4-4 4"],
77
+ down: ["M8 2.8v10.4", "M4.2 9.4L8 13.2l3.8-3.8"],
78
+ map: ["M2.5 4l3.8-1.5 3.4 1.5 3.8-1.5v9.5l-3.8 1.5-3.4-1.5-3.8 1.5z", "M6.3 2.5V12", "M9.7 4v9.5"],
79
+ learn: ["M1.8 5.2L8 2.6l6.2 2.6L8 7.8z", "M4.4 6.4v3.4c0 1 1.6 2.1 3.6 2.1s3.6-1.1 3.6-2.1V6.4"],
80
+ tour: ["M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z", "M6.8 5.6l3.4 2.4-3.4 2.4z"],
81
+ mark: ["M5.2 2.8H3v10.4h2.2", "M10.8 2.8H13v10.4h-2.2", "M8 9.3a1.3 1.3 0 1 0 0-2.6 1.3 1.3 0 0 0 0 2.6z"],
82
+ };
83
+ function icon(name, extra = "") {
84
+ if (name === "play" || name === "replay") {
85
+ const el = s("svg", { class: `i solid ${extra}`, viewBox: "0 0 16 16", "aria-hidden": "true" });
86
+ el.append(s("path", { d: name === "play" ? "M5 3.1v9.8l7.9-4.9z" : "M8 2.2a5.8 5.8 0 1 1-5.5 4h1.8A4.1 4.1 0 1 0 8 3.9V6L4.9 3.1 8 .2z" }));
87
+ return el;
88
+ }
89
+ const el = s("svg", { class: `i ${extra}`, viewBox: "0 0 16 16", "aria-hidden": "true" });
90
+ if (name === "blind") el.append(s("circle", { cx: 8, cy: 8, r: 6.1, "stroke-dasharray": "2 2.1" }));
91
+ for (const d of PATHS[name] ?? []) el.append(s("path", { d }));
92
+ return el;
93
+ }
94
+
95
+ // ── people and their chips ──────────────────────────────────────────────────────────────────────
96
+ const actorOf = (name) => A[actorIndex.get(bare(name))];
97
+ function initial(name) {
98
+ const a = actorOf(name);
99
+ if (a?.anyone) return "*";
100
+ if (a?.unseen) return "?";
101
+ if (a?.expr) return "ƒ";
102
+ return bare(name).replace(/^[^A-Za-z0-9]+/, "").charAt(0) || "?";
103
+ }
104
+ function avatar(name) {
105
+ return h("span", { class: "avatar", "aria-hidden": "true", text: initial(name) });
106
+ }
107
+ function chip(name) {
108
+ const a = actorOf(name);
109
+ let cls = "chip";
110
+ if (a?.anyone) cls += " anyone";
111
+ else if (a && a.side === "outside") cls += " outside";
112
+ if (String(name).startsWith("?")) cls += " optional";
113
+ if (a?.expr) cls += " expr";
114
+ const title = a?.anyone ? "No access check: anyone can call this"
115
+ : a?.expr ? "An expression, not a named party — who this is is decided when the code runs"
116
+ : String(name).startsWith("?") ? `${bare(name)} — only when configured`
117
+ : a ? (a.side === "outside" ? `${a.name} — outside the protocol` : `${a.name} — inside the protocol`) : null;
118
+ return h("span", { class: cls, title }, avatar(name), bare(name));
119
+ }
120
+ function whoChips(e) {
121
+ if (e.anyone) return [chip("anyone")];
122
+ if (e.unseen) return [h("span", { class: "chip", title: "The controller could not be read from source" }, avatar("(unreadable)"), "unreadable")];
123
+ return e.auth.map((p) => chip(p));
124
+ }
125
+ const stamp = (p, cls = "") => h("span", { class: `stamp ${cls}`, text: p });
126
+
127
+ // ── state ───────────────────────────────────────────────────────────────────────────────────────
128
+ const S = { view: "map", sel: null, hist: [], trail: [], playing: null };
129
+ const same = (a, b) => a && b && a.t === b.t && a.i === b.i && a.id === b.id;
130
+
131
+ // ── shell ───────────────────────────────────────────────────────────────────────────────────────
132
+ const tabsEl = h("nav", { class: "tabs", "aria-label": "Views" });
133
+ const topEl = h("header", { class: "top" });
134
+ const peopleEl = h("aside", { class: "people", "aria-label": "Who can act" });
135
+ const mapEl = h("div", { class: "map", role: "application", "aria-label": "Contract map" });
136
+ const inspEl = h("aside", { class: "inspect", "aria-live": "polite", "aria-label": "Details" });
137
+ const learnEl = h("section", { class: "learn", hidden: true, "aria-label": "How to read this" });
138
+ mount.className = "app";
139
+ mount.append(topEl, peopleEl, mapEl, inspEl, learnEl);
140
+
141
+ function renderTop() {
142
+ const tab = (view, label, ic) => h("button", {
143
+ class: "tab", "data-view": view, "aria-selected": String(S.view === view), onclick: () => setView(view),
144
+ }, icon(ic), " ", label);
145
+ tabsEl.replaceChildren(tab("map", "Map", "map"), tab("learn", "Learn", "learn"), tab("code", "Code", "code"));
146
+ const st = D.stats;
147
+ // Declarations and constructors are not actions anyone takes; the count says what the map shows.
148
+ const callable = E.filter((e) => !e.declared && !e.deployOnly).length;
149
+ const traced = st.resolution == null ? 100 : st.resolution;
150
+ topEl.replaceChildren(
151
+ h("div", { class: "brand" },
152
+ h("span", { class: "brand-mark", "aria-hidden": "true" }, icon("mark")),
153
+ h("span", { class: "brand-name", text: "sourcetruth" }),
154
+ h("span", { class: "brand-title", text: D.title, title: D.title })),
155
+ tabsEl,
156
+ h("div", { class: "top-right" },
157
+ h("span", { class: "counts" }, h("b", { text: String(st.units) }), ` contract${st.units === 1 ? "" : "s"} · `,
158
+ h("b", { text: String(callable) }), ` action${callable === 1 ? "" : "s"}`),
159
+ h("span", { class: "trace", title: `${traced}% of links between contracts could be traced from source` },
160
+ h("span", { class: "trace-bar", "aria-hidden": "true" }, h("span", { style: { width: `${traced}%` } })),
161
+ `${traced}% traced`),
162
+ st.holes > 0 ? h("button", {
163
+ class: "btn blind", onclick: () => { setView("map"); select({ t: "blind" }); },
164
+ title: "Links the tool could not trace. It does not guess them.",
165
+ }, icon("blind"), plural(st.holes, "blind spot")) : null,
166
+ h("button", { class: "btn", onclick: () => startTour() }, icon("tour"), "Tour")),
167
+ );
168
+ }
169
+
170
+ function setView(view) {
171
+ S.view = view;
172
+ document.documentElement.classList.toggle("view-code", view === "code");
173
+ learnEl.hidden = view !== "learn";
174
+ for (const b of tabsEl.querySelectorAll(".tab")) b.setAttribute("aria-selected", String(b.dataset.view === view));
175
+ if (view === "code") window.scrollTo(0, 0);
176
+ if (view === "map" && !fitted) requestAnimationFrame(() => home(false));
177
+ }
178
+
179
+ // ── the map ─────────────────────────────────────────────────────────────────────────────────────
180
+ const world = h("div", { class: "world" });
181
+ const linksSvg = s("svg", { class: "links", width: D.size.w, height: D.size.h + 80 });
182
+ const fxSvg = s("svg", { class: "fx", width: D.size.w, height: D.size.h + 80 });
183
+ const tokenLayer = h("div", { style: { position: "absolute", left: "0", top: "0" } });
184
+ const cardById = new Map();
185
+ const linkEls = [];
186
+ const view = { x: 0, y: 0, k: 1 };
187
+ let fitted = false;
188
+ let dragMoved = false;
189
+
190
+ function marker(id, cls) {
191
+ return s("marker", { id, viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" },
192
+ s("path", { d: "M0,0 L10,5 L0,10 z", class: cls }));
193
+ }
194
+ function curve(a, b) {
195
+ const sx = a.x + W, sy = a.y + H / 2, tx = b.x, ty = b.y + H / 2;
196
+ if (tx >= sx + 12) {
197
+ const c = Math.max(36, (tx - sx) * 0.5);
198
+ return `M${sx},${sy} C${sx + c},${sy} ${tx - c},${ty} ${tx},${ty}`;
199
+ }
200
+ // Backwards, or in the same column: loop underneath rather than drawing through the cards.
201
+ const bx = a.x + W * 0.62, by = a.y + H, ex = b.x + W * 0.38, ey = b.y + H;
202
+ const dip = Math.max(by, ey) + 42;
203
+ return `M${bx},${by} C${bx},${dip} ${ex},${dip} ${ex},${ey}`;
204
+ }
205
+
206
+ function buildMap() {
207
+ if (U.length === 0) {
208
+ mapEl.append(h("div", { class: "map-empty", text: D.notes[0] || "No contracts found under this path." }));
209
+ return;
210
+ }
211
+ const defs = s("defs", null,
212
+ marker("m-base", "arrow-head"),
213
+ s("marker", { id: "m-lit", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" },
214
+ s("path", { d: "M0,0 L10,5 L0,10 z", style: "fill: var(--flow)" })));
215
+ linksSvg.append(defs);
216
+ for (const l of D.links) {
217
+ const a = nodeById.get(l.from);
218
+ const b = nodeById.get(l.to);
219
+ if (!a || !b || a.x == null || b.x == null) continue;
220
+ const via = l.via.map((i) => `${U[E[i].unit].name}.${E[i].name}`);
221
+ const p = s("path", { class: `link ${l.k}`, d: curve(a, b), "marker-end": "url(#m-base)" });
222
+ const t = s("title");
223
+ t.textContent = `${via.join(", ")} ${l.k === "create" ? "creates" : "calls"} ${b.name}`;
224
+ p.append(t);
225
+ linksSvg.append(p);
226
+ linkEls.push({ el: p, l });
227
+ }
228
+ world.append(linksSvg);
229
+ if (D.size.standaloneTop > 0) {
230
+ world.append(h("div", {
231
+ class: "standalone-label", style: { left: "2px", top: `${D.size.standaloneTop - 24}px` },
232
+ text: "Standalone", title: "Nothing in this code creates or calls these, and they create or call nothing",
233
+ }));
234
+ }
235
+ for (const u of U) if (u.x != null) world.append(cardEl(u));
236
+ for (const g of G) if (g.x != null) world.append(cardEl(g));
237
+ world.append(fxSvg, tokenLayer);
238
+ mapEl.append(world,
239
+ h("div", { class: "legend", "aria-hidden": "true" },
240
+ h("span", null, legendLine(false), "creates"),
241
+ h("span", null, legendLine(true), "calls"),
242
+ G.some((g) => g.x != null) ? h("span", null, h("span", { class: "lg-box" }), "outside this code") : null),
243
+ h("div", { class: "map-tools" },
244
+ h("button", { class: "btn", title: "Zoom in", "aria-label": "Zoom in", onclick: () => zoomBy(1.2) }, icon("plus")),
245
+ h("button", { class: "btn", title: "Zoom out", "aria-label": "Zoom out", onclick: () => zoomBy(1 / 1.2) }, icon("minus")),
246
+ h("button", { class: "btn", title: "Fit to screen", "aria-label": "Fit to screen", onclick: () => fit(true) }, icon("fit"))));
247
+ wireMap();
248
+ }
249
+ function legendLine(dashed) {
250
+ return s("svg", { viewBox: "0 0 26 10" },
251
+ s("path", { d: "M1 5h19", style: `stroke: var(--line-strong); stroke-width: 1.6; fill: none;${dashed ? " stroke-dasharray: 4 3;" : ""}` }),
252
+ s("path", { d: "M18 1.5 L25 5 L18 8.5 z", style: "fill: var(--line-strong)" }));
253
+ }
254
+
255
+ function cardEl(n) {
256
+ const ghost = Boolean(n.ghost);
257
+ const el = h("button", {
258
+ class: `card${ghost ? " ghost" : ""}`, style: { left: `${n.x}px`, top: `${n.y}px` }, "data-id": n.id,
259
+ "aria-label": ghost ? `${n.name}, defined outside this code` : `${n.name}, ${plural(n.actions, "action")}`,
260
+ onclick: () => { if (dragMoved) return; select(ghost ? { t: "ghost", id: n.id } : { t: "unit", i: n.i }); },
261
+ },
262
+ h("span", { class: "card-name", text: n.name, title: n.name }),
263
+ h("span", { class: "card-signers" },
264
+ ghost ? h("span", { class: "card-kind muted", text: "outside this code" })
265
+ : n.signers.length ? n.signers.map((p) => stamp(p)) : h("span", { class: "card-kind muted", text: n.kind })),
266
+ h("span", { class: "card-foot" },
267
+ ghost ? null : h("span", { text: plural(n.actions, "action") }),
268
+ !ghost && n.holes ? h("span", { class: "qbadge", title: plural(n.holes, "blind spot") }, icon("blind"), String(n.holes)) : null));
269
+ cardById.set(n.id, el);
270
+ return el;
271
+ }
272
+
273
+ function applyView(glide) {
274
+ world.classList.toggle("glide", Boolean(glide) && !reduceMotion());
275
+ world.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.k})`;
276
+ }
277
+ function fit(glide) {
278
+ const r = mapEl.getBoundingClientRect();
279
+ if (!r.width || !r.height || !D.size.w) return;
280
+ const pad = 56;
281
+ const k = clamp(Math.min((r.width - pad * 2) / D.size.w, (r.height - pad * 2 - 30) / (D.size.h + 40)), 0.28, 1.05);
282
+ view.k = k;
283
+ view.x = (r.width - D.size.w * k) / 2;
284
+ view.y = Math.max(pad * 0.6, (r.height - D.size.h * k) / 2 - 16);
285
+ applyView(glide);
286
+ fitted = true;
287
+ }
288
+ /**
289
+ * The opening view. If the whole protocol can be drawn at a size you can read, draw all of it.
290
+ * Otherwise start where the risk is — the riskiest outside action's contract and what it makes and
291
+ * calls — at a readable zoom, and leave the whole picture one click away on Fit. A complete map
292
+ * at 28% is a picture of boxes, and shows nothing.
293
+ */
294
+ function home(glide) {
295
+ const r = mapEl.getBoundingClientRect();
296
+ if (!r.width || !r.height || !D.size.w) return;
297
+ const pad = 56;
298
+ const whole = Math.min((r.width - pad * 2) / D.size.w, (r.height - pad * 2 - 30) / (D.size.h + 40));
299
+ if (whole >= 0.62) { fit(glide); return; }
300
+ const door = D.doors.length ? E[D.doors[0]] : null;
301
+ const ids = door
302
+ ? [`u${door.unit}`, ...door.steps.filter((st) => st.to && (st.k === "create" || st.k === "call")).map((st) => st.to)]
303
+ : [U[0].id];
304
+ const ns = ids.map((id) => nodeById.get(id)).filter((n) => n && n.x != null);
305
+ const minX = Math.min(...ns.map((n) => n.x)), minY = Math.min(...ns.map((n) => n.y));
306
+ const maxX = Math.max(...ns.map((n) => n.x + W)), maxY = Math.max(...ns.map((n) => n.y + H));
307
+ const k = clamp(Math.min((r.width - 110) / (maxX - minX), (r.height - 150) / (maxY - minY)), 0.62, 0.95);
308
+ view.k = k;
309
+ view.x = r.width / 2 - ((minX + maxX) / 2) * k;
310
+ view.y = (r.height - 40) / 2 - ((minY + maxY) / 2) * k;
311
+ applyView(glide);
312
+ fitted = true;
313
+ }
314
+ function zoomBy(f, cx, cy) {
315
+ const r = mapEl.getBoundingClientRect();
316
+ const px = cx ?? r.width / 2, py = cy ?? r.height / 2;
317
+ const k = clamp(view.k * f, 0.22, 2.2);
318
+ view.x = px - (px - view.x) * (k / view.k);
319
+ view.y = py - (py - view.y) * (k / view.k);
320
+ view.k = k;
321
+ applyView(false);
322
+ }
323
+ /** Bring these cards into view, zooming out only if they cannot all fit at the current zoom. */
324
+ function frame(ids) {
325
+ const ns = ids.map((id) => nodeById.get(id)).filter((n) => n && n.x != null);
326
+ const r = mapEl.getBoundingClientRect();
327
+ if (!ns.length || !r.width) return;
328
+ const minX = Math.min(...ns.map((n) => n.x)), minY = Math.min(...ns.map((n) => n.y));
329
+ const maxX = Math.max(...ns.map((n) => n.x + W)), maxY = Math.max(...ns.map((n) => n.y + H));
330
+ const m = 60;
331
+ const inside = view.x + minX * view.k >= m && view.y + minY * view.k >= m
332
+ && view.x + maxX * view.k <= r.width - m && view.y + maxY * view.k <= r.height - m;
333
+ if (inside) return;
334
+ // Zoom out to fit only as far as a card stays legible. Past that, centre on the first id — the
335
+ // thing selected — and let the highlighted lines lead off-screen to the rest.
336
+ const need = Math.min((r.width - m * 2) / (maxX - minX), (r.height - m * 2) / (maxY - minY));
337
+ const k = Math.max(0.58, Math.min(view.k < 0.58 ? 0.75 : view.k, need));
338
+ const focus = need >= 0.58 ? { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } : { x: ns[0].x + W / 2, y: ns[0].y + H / 2 };
339
+ view.k = k;
340
+ view.x = r.width / 2 - focus.x * k;
341
+ view.y = r.height / 2 - focus.y * k;
342
+ applyView(true);
343
+ }
344
+
345
+ function wireMap() {
346
+ let drag = null;
347
+ mapEl.addEventListener("pointerdown", (ev) => {
348
+ if (ev.button !== 0) return;
349
+ drag = { x: ev.clientX, y: ev.clientY, vx: view.x, vy: view.y };
350
+ dragMoved = false;
351
+ });
352
+ window.addEventListener("pointermove", (ev) => {
353
+ if (!drag) return;
354
+ const dx = ev.clientX - drag.x, dy = ev.clientY - drag.y;
355
+ if (!dragMoved && Math.hypot(dx, dy) < 5) return;
356
+ dragMoved = true;
357
+ mapEl.classList.add("dragging");
358
+ view.x = drag.vx + dx;
359
+ view.y = drag.vy + dy;
360
+ applyView(false);
361
+ });
362
+ window.addEventListener("pointerup", () => {
363
+ if (!drag) return;
364
+ drag = null;
365
+ mapEl.classList.remove("dragging");
366
+ setTimeout(() => { dragMoved = false; }, 0);
367
+ });
368
+ mapEl.addEventListener("click", (ev) => {
369
+ if (!dragMoved && (ev.target === mapEl || ev.target === world)) select(null);
370
+ });
371
+ mapEl.addEventListener("wheel", (ev) => {
372
+ ev.preventDefault();
373
+ const r = mapEl.getBoundingClientRect();
374
+ if (ev.ctrlKey || ev.metaKey) zoomBy(Math.exp(-ev.deltaY * 0.0085), ev.clientX - r.left, ev.clientY - r.top);
375
+ else { view.x -= ev.deltaX; view.y -= ev.deltaY; applyView(false); }
376
+ }, { passive: false });
377
+ window.addEventListener("resize", () => { if (!S.sel) home(false); });
378
+ }
379
+
380
+ // ── what an action does, as steps ───────────────────────────────────────────────────────────────
381
+ /** The consuming effect is implicit in Daml, so it is shown as the first step rather than left out. */
382
+ function txSteps(e) {
383
+ const own = `u${e.unit}`;
384
+ const out = [];
385
+ if (D.daml && e.effect !== "none") out.push({ k: "destroy", to: own, name: U[e.unit].name, consumed: true, n: 1 });
386
+ if (!D.daml && e.effect === "transition") out.push({ k: "write", to: own, name: U[e.unit].name, n: 1 });
387
+ for (const st of e.steps) out.push(st);
388
+ return out;
389
+ }
390
+ /** Archives, creates, calls and blind spots — the steps that can change something — in source order. */
391
+ const changes = (e) => txSteps(e).filter((st) => st.k !== "read");
392
+ const readsOf = (e) => txSteps(e).filter((st) => st.k === "read");
393
+ function verb(st, e) {
394
+ if (st.k === "destroy") return st.consumed ? "archived — consumed by this action" : "archived";
395
+ if (st.k === "create") return st.to === `u${e.unit}` ? "new version made" : "created";
396
+ if (st.k === "call") return D.daml ? "action exercised" : "called";
397
+ if (st.k === "read") return D.daml ? "fetched" : "read";
398
+ if (st.k === "write") return "its storage can change";
399
+ if (st.eth) return `sends ETH to ${st.recv ?? "an address"} — can't follow the receiver`;
400
+ if (st.low) return `low-level ${st.low}${st.lib ? ` via ${st.lib}` : ""} — the target can't be named`;
401
+ if (st.recv) return `couldn't tell what ${st.recv} is — read this line`;
402
+ return "couldn't trace — read this line";
403
+ }
404
+ const KIND_ICON = { create: "create", destroy: "destroy", call: "call", read: "read", blind: "blind", write: "transition" };
405
+ const TAG = { create: "made", destroy: "archived", call: "called", read: "read", blind: "?", write: "writes" };
406
+
407
+ function tag(card, kind, text) {
408
+ if (!card) return;
409
+ let box = card.querySelector(".hit-tags");
410
+ if (!box) {
411
+ box = h("span", { class: "hit-tags", style: { position: "absolute", top: "-11px", right: "8px", display: "flex", gap: "4px" } });
412
+ card.append(box);
413
+ }
414
+ if (box.querySelector(`[data-k="${kind}"]`)) return;
415
+ box.append(h("span", { class: `hit-tag ${kind === "write" ? "call" : kind}`, "data-k": kind, style: { position: "static" } },
416
+ icon(KIND_ICON[kind]), text));
417
+ }
418
+ function linkFor(from, to, kind) {
419
+ return linkEls.find((x) => x.l.from === from && x.l.to === to && (!kind || x.l.k === kind));
420
+ }
421
+ function fxPath(from, to, cls) {
422
+ const a = nodeById.get(from), b = nodeById.get(to);
423
+ if (!a || !b || a.x == null || b.x == null || a === b) return;
424
+ fxSvg.append(s("path", { class: cls, d: curve(a, b) }));
425
+ }
426
+ function applyStep(e, st, live) {
427
+ const from = `u${e.unit}`;
428
+ const card = st.to ? cardById.get(st.to) : null;
429
+ if (st.k === "blind") { tag(cardById.get(from), "blind", TAG.blind); return; }
430
+ if (card) {
431
+ card.classList.add(st.k === "write" ? "hit-call" : `hit-${st.k === "destroy" ? "destroy" : st.k}`);
432
+ tag(card, st.k, TAG[st.k]);
433
+ }
434
+ if (st.k === "create" || st.k === "call") {
435
+ const link = linkFor(from, st.to, st.k);
436
+ if (link) {
437
+ link.el.classList.add(live ? `run-${st.k}` : "lit");
438
+ link.el.setAttribute("marker-end", "url(#m-lit)");
439
+ } else if (st.to && st.to !== from) fxPath(from, st.to, st.k === "call" ? "fx-call" : "fx-read");
440
+ }
441
+ if (st.k === "read" && st.to && st.to !== from) fxPath(from, st.to, "fx-read");
442
+ }
443
+
444
+ function clearMap() {
445
+ for (const el of cardById.values()) {
446
+ el.classList.remove("on", "lit", "hit-create", "hit-destroy", "hit-call", "hit-read");
447
+ el.querySelector(".hit-tags")?.remove();
448
+ }
449
+ for (const { el } of linkEls) {
450
+ el.classList.remove("lit", "run-create", "run-call");
451
+ el.setAttribute("marker-end", "url(#m-base)");
452
+ }
453
+ fxSvg.replaceChildren();
454
+ tokenLayer.replaceChildren();
455
+ }
456
+
457
+ function paintMap() {
458
+ clearMap();
459
+ const sel = S.sel;
460
+ mapEl.classList.toggle("focus", Boolean(sel) && sel.t !== "blind");
461
+ if (!sel) return;
462
+ const on = new Set();
463
+ const lit = new Set();
464
+ const litLink = (x) => { x.el.classList.add("lit"); x.el.setAttribute("marker-end", "url(#m-lit)"); };
465
+ if (sel.t === "actor") {
466
+ const a = A[sel.i];
467
+ for (const i of a.entries) {
468
+ on.add(`u${E[i].unit}`);
469
+ for (const st of E[i].steps) if (st.to && (st.k === "create" || st.k === "call")) lit.add(st.to);
470
+ }
471
+ for (const x of linkEls) if (x.l.via.some((i) => a.entries.includes(i))) litLink(x);
472
+ } else if (sel.t === "unit" || sel.t === "ghost") {
473
+ const id = sel.t === "unit" ? `u${sel.i}` : sel.id;
474
+ on.add(id);
475
+ for (const x of linkEls) if (x.l.from === id || x.l.to === id) { litLink(x); lit.add(x.l.from); lit.add(x.l.to); }
476
+ } else if (sel.t === "entry") {
477
+ const e = E[sel.i];
478
+ on.add(`u${e.unit}`);
479
+ for (const st of e.steps) if (st.to) lit.add(st.to);
480
+ if (S.playing !== e.i) {
481
+ for (const st of txSteps(e)) applyStep(e, st, false);
482
+ addToken(e);
483
+ }
484
+ }
485
+ for (const id of on) cardById.get(id)?.classList.add("on");
486
+ for (const id of lit) if (!on.has(id)) cardById.get(id)?.classList.add("lit");
487
+ }
488
+
489
+ function addToken(e) {
490
+ const own = U[e.unit];
491
+ if (own.x == null) return;
492
+ const names = e.anyone ? ["anyone"] : e.unseen ? ["(unreadable)"] : e.auth.map(bare);
493
+ tokenLayer.replaceChildren(h("div", {
494
+ class: `token${e.anyone ? " anyone" : ""}`, style: { left: `${own.x}px`, top: `${own.y - 34}px`, maxWidth: `${W * 1.6}px`, overflow: "hidden", textOverflow: "ellipsis" },
495
+ }, avatar(names[0]), `${names.join(" + ")} → ${e.name}`));
496
+ }
497
+
498
+ // ── play ────────────────────────────────────────────────────────────────────────────────────────
499
+ let timers = [];
500
+ function stopPlay() {
501
+ for (const t of timers) clearTimeout(t);
502
+ timers = [];
503
+ S.playing = null;
504
+ }
505
+ function play(i) {
506
+ stopPlay();
507
+ const e = E[i];
508
+ if (!same(S.sel, { t: "entry", i })) select({ t: "entry", i }, { trail: "keep", center: false });
509
+ S.playing = i;
510
+ paintMap();
511
+ const steps = changes(e);
512
+ const rows = [...inspEl.querySelectorAll(".tx.changes .row")];
513
+ const btn = inspEl.querySelector(".play");
514
+ frame([`u${e.unit}`, ...steps.map((st) => st.to).filter(Boolean)]);
515
+ addToken(e);
516
+ const reads = readsOf(e);
517
+ const quick = reduceMotion();
518
+ rows.forEach((r) => r.classList.add("pending"));
519
+ if (btn) btn.replaceChildren(icon("play"), "Playing…");
520
+ const gap = 820;
521
+ steps.forEach((st, k) => {
522
+ const run = () => {
523
+ rows.forEach((r, j) => {
524
+ r.classList.toggle("now", j === k && !quick);
525
+ if (j <= k) r.classList.remove("pending");
526
+ });
527
+ applyStep(e, st, !quick);
528
+ rows[k]?.scrollIntoView({ block: "nearest", behavior: quick ? "auto" : "smooth" });
529
+ };
530
+ if (quick) run(); else timers.push(setTimeout(run, 650 + k * gap));
531
+ });
532
+ const done = () => {
533
+ for (const st of reads) applyStep(e, st, false);
534
+ rows.forEach((r) => r.classList.remove("now", "pending"));
535
+ S.playing = null;
536
+ if (btn) btn.replaceChildren(icon("replay"), "Play again");
537
+ };
538
+ if (quick) done(); else timers.push(setTimeout(done, 650 + steps.length * gap + 500));
539
+ }
540
+
541
+ // ── selection ───────────────────────────────────────────────────────────────────────────────────
542
+ function select(sel, opts = {}) {
543
+ stopPlay();
544
+ if (!sel && S.sel) requestAnimationFrame(() => home(true));
545
+ if (opts.push !== false && S.sel && !same(S.sel, sel)) S.hist.push(S.sel);
546
+ if (!sel) S.hist = [];
547
+ S.sel = sel;
548
+ if (sel && sel.t === "entry") {
549
+ if (opts.trail === "continue") S.trail = [...S.trail.filter((x) => x !== sel.i), sel.i];
550
+ else if (typeof opts.trail === "number") S.trail = S.trail.slice(0, opts.trail + 1);
551
+ else if (opts.trail !== "keep") S.trail = [sel.i];
552
+ }
553
+ renderPeople();
554
+ renderInspector();
555
+ paintMap();
556
+ if (sel && opts.center !== false) frameSelection();
557
+ }
558
+ function frameSelection() {
559
+ const sel = S.sel;
560
+ if (!sel) return;
561
+ if (sel.t === "entry") frame([`u${E[sel.i].unit}`, ...changes(E[sel.i]).map((x) => x.to).filter(Boolean)]);
562
+ else if (sel.t === "unit") frame([`u${sel.i}`]);
563
+ else if (sel.t === "ghost") frame([sel.id]);
564
+ else if (sel.t === "actor") frame([...new Set(A[sel.i].entries.map((i) => `u${E[i].unit}`))]);
565
+ }
566
+ function goBack() {
567
+ const prev = S.hist.pop() ?? null;
568
+ select(prev, { push: false, trail: "keep" });
569
+ }
570
+
571
+ // ── people rail ─────────────────────────────────────────────────────────────────────────────────
572
+ function renderPeople() {
573
+ const person = (a, i) => h("button", {
574
+ class: `person ${a.side}${a.anyone ? " anyone" : ""}${a.unseen ? " unseen" : ""}${a.expr ? " expr" : ""}`,
575
+ "data-actor": i, "aria-pressed": String(S.sel?.t === "actor" && S.sel.i === i),
576
+ title: a.side === "outside" ? `${a.name} can act without the protocol's own parties` : `${a.name} runs the protocol, or only acts with it`,
577
+ onclick: () => select(S.sel?.t === "actor" && S.sel.i === i ? null : { t: "actor", i }),
578
+ },
579
+ avatar(a.name),
580
+ h("span", { class: "person-name mono", text: a.name === "(unreadable)" ? "unreadable" : a.name }),
581
+ a.side === "outside" && a.borrows ? h("span", { class: "pdot", title: "Carries authority it does not hold itself" }) : null,
582
+ h("span", { class: "person-n", text: String(a.entries.length) }));
583
+ const outside = A.map((a, i) => [a, i]).filter(([a]) => a.side === "outside");
584
+ const insiders = A.map((a, i) => [a, i]).filter(([a]) => a.side === "inside");
585
+ peopleEl.replaceChildren(
586
+ h("div", { class: "label", title: "Can act without any of the protocol's own parties" }, "Outside"),
587
+ outside.length ? h("div", { class: "group" }, outside.map(([a, i]) => person(a, i)))
588
+ : h("div", { class: "row-sub", style: { padding: "0 8px" }, text: "No one" }),
589
+ h("div", { class: "label", title: "Runs the protocol, or only ever acts alongside it" }, "Inside"),
590
+ h("div", { class: "group" }, insiders.map(([a, i]) => person(a, i))),
591
+ );
592
+ }
593
+
594
+ // ── inspector building blocks ───────────────────────────────────────────────────────────────────
595
+ function powerMark(e) {
596
+ return h("span", { class: "power-mark", title: `Runs with the authority of ${e.borrowed.join(", ")}` }, icon("stamp"));
597
+ }
598
+ function entryRow(i, opts = {}) {
599
+ const e = E[i];
600
+ return h("button", {
601
+ class: `row${opts.cls ? ` ${opts.cls}` : ""}`, "data-entry": i,
602
+ onclick: opts.onclick ?? (() => select({ t: "entry", i }, { trail: opts.trail ?? "reset" })),
603
+ },
604
+ h("span", { class: `fx-icon ${e.effect}`, title: D.labels[e.effect] }, icon(e.effect)),
605
+ h("span", { class: "row-main" },
606
+ h("span", { class: "row-title mono", text: e.name, title: `${U[e.unit].name}.${e.name}` }),
607
+ h("span", { class: "row-sub" },
608
+ h("span", { class: "chips", style: { display: "inline-flex", verticalAlign: "middle" } }, whoChips(e)),
609
+ opts.unit === false ? null : [" on ", h("span", { class: "mono", text: U[e.unit].name })],
610
+ opts.thr ? h("span", { class: "thr" }, " via ", h("span", { class: "mono", text: opts.thr })) : null)),
611
+ h("span", { class: "row-marks" },
612
+ e.alone && e.borrowed.length ? powerMark(e) : null,
613
+ e.holes ? h("span", { class: "qmark", title: plural(e.holes, "blind spot") }, icon("blind")) : null));
614
+ }
615
+ function fold(title, body, open = false) {
616
+ return h("details", { class: "fold", open }, h("summary", null, icon("chev"), title), body);
617
+ }
618
+ function backBtn() {
619
+ return S.hist.length ? h("button", { class: "back", onclick: goBack }, icon("back"), "Back")
620
+ : h("button", { class: "back", onclick: () => select(null) }, icon("back"), "Start");
621
+ }
622
+ function codeButton(anchor) {
623
+ return h("button", { class: "btn", onclick: () => openCode(anchor) }, icon("code"), "Code");
624
+ }
625
+ function openCode(anchor) {
626
+ setView("code");
627
+ const target = document.getElementById(anchor);
628
+ if (target) {
629
+ if (location.hash !== `#${anchor}`) location.hash = anchor;
630
+ requestAnimationFrame(() => target.scrollIntoView({ block: "start" }));
631
+ }
632
+ }
633
+ function sourceOf(e) {
634
+ const sec = document.getElementById(e.anchor);
635
+ const pre = sec?.querySelector("pre.src");
636
+ return pre ? pre.textContent : null;
637
+ }
638
+
639
+ // ── inspector panels ────────────────────────────────────────────────────────────────────────────
640
+ function renderInspector() {
641
+ const sel = S.sel;
642
+ let body;
643
+ if (!sel) body = homePanel();
644
+ else if (sel.t === "actor") body = actorPanel(A[sel.i]);
645
+ else if (sel.t === "unit") body = unitPanel(U[sel.i]);
646
+ else if (sel.t === "ghost") body = ghostPanel(nodeById.get(sel.id));
647
+ else if (sel.t === "entry") body = entryPanel(E[sel.i]);
648
+ else if (sel.t === "blind") body = blindPanel();
649
+ inspEl.replaceChildren(...[body].flat().filter(Boolean));
650
+ inspEl.scrollTop = 0;
651
+ }
652
+
653
+ let showAllDoors = false;
654
+ function homePanel() {
655
+ const limit = showAllDoors ? D.doors.length : 7;
656
+ return [
657
+ h("p", { class: "hero-q", text: "Where can someone outside get in?" }),
658
+ D.doors.length
659
+ ? h("p", { class: "sub", text: `${plural(D.doors.length, "action")} they can take alone — riskiest first.` })
660
+ : h("p", { class: "sub", text: "No action here can be taken by someone outside on their own." }),
661
+ h("div", { class: "rows" }, D.doors.slice(0, limit).map((i) => entryRow(i, { cls: "door" }))),
662
+ D.doors.length > limit ? h("button", {
663
+ class: "more", onclick: () => { showAllDoors = true; renderInspector(); },
664
+ }, `Show all ${D.doors.length}`) : null,
665
+ ...D.notes.map((n) => h("p", { class: "strip blind", text: n })),
666
+ h("div", { class: "tour-card" },
667
+ h("p", { text: "Not sure where to start?" }),
668
+ h("div", { class: "btns" },
669
+ h("button", { class: "btn primary", onclick: () => startTour() }, icon("tour"), "Show me how"),
670
+ h("button", { class: "btn", onclick: () => setView("learn") }, icon("learn"), D.daml ? "Daml in pictures" : "Solidity in pictures"))),
671
+ ];
672
+ }
673
+
674
+ function actorPanel(a) {
675
+ const byUnit = new Map();
676
+ for (const i of a.entries) {
677
+ if (!byUnit.has(E[i].unit)) byUnit.set(E[i].unit, []);
678
+ byUnit.get(E[i].unit).push(i);
679
+ }
680
+ return [
681
+ backBtn(),
682
+ h("div", { class: "who-line" }, chip(a.name === "(unreadable)" ? "(unreadable)" : a.name),
683
+ h("span", { class: "muted", text: a.side === "outside" ? "outside the protocol" : "inside the protocol" })),
684
+ h("p", { class: "sub", text: `${plural(a.entries.length, "action")} on ${plural(byUnit.size, "contract")}` }),
685
+ ...[...byUnit].map(([ui, list]) => h("div", { class: "section" },
686
+ h("div", { class: "label" },
687
+ h("button", { class: "unit-link", onclick: () => select({ t: "unit", i: ui }), text: U[ui].name })),
688
+ h("div", { class: "rows" }, list
689
+ .sort((x, y) => E[y].risk - E[x].risk)
690
+ .map((i) => entryRow(i, { unit: false, trail: "reset" }))))),
691
+ ];
692
+ }
693
+
694
+ function unitPanel(u) {
695
+ const life = D.life[u.i];
696
+ const own = E.filter((e) => e.unit === u.i);
697
+ const repeatable = own.filter((e) => e.effect === "none");
698
+ const rowsOf = (list, empty) => list.length
699
+ ? h("div", { class: "rows" }, list.map((x) => entryRow(x.e ?? x.i, { thr: x.thr })))
700
+ : h("div", { class: "empty", text: empty });
701
+ const closedElsewhere = life.archived.length > 0;
702
+ return [
703
+ backBtn(),
704
+ h("div", { class: "label", style: { margin: "0 0 4px" }, text: D.daml ? "Contract" : u.kind }),
705
+ h("p", { class: "h big", text: u.name }),
706
+ h("div", { class: "on-line" },
707
+ u.signers.length ? [h("span", { text: "signed by" }), ...u.signers.map((p) => stamp(p))] : null,
708
+ h("span", { class: "muted", text: `${u.path}:${u.line}` })),
709
+ h("div", { class: "actions-bar" }, codeButton(u.anchor)),
710
+ h("div", { class: "life" },
711
+ h("div", { class: "stage" },
712
+ h("div", { class: "stage-head" }, icon("create"), "Made by"),
713
+ rowsOf(life.made, "Nothing in this code makes it")),
714
+ h("div", { class: "joint", "aria-hidden": "true" }, icon("down")),
715
+ h("div", { class: "stage core" },
716
+ h("div", { class: "stage-head" }, icon("transition"), D.daml ? "Replaced by" : "Changed by"),
717
+ rowsOf([...life.changed, ...life.replaced], D.daml ? "Nothing replaces it — once made it stays as it is" : "Nothing changes it"),
718
+ repeatable.length ? [
719
+ h("div", { class: "stage-head", style: { marginTop: "10px" } }, icon("none"), "Repeatable on it"),
720
+ rowsOf(repeatable.map((e) => ({ e: e.i })), ""),
721
+ ] : null),
722
+ h("div", { class: "joint", "aria-hidden": "true" }, icon("down")),
723
+ h("div", { class: "stage" },
724
+ h("div", { class: "stage-head" }, icon("terminal"), "Ended by"),
725
+ life.ended.length ? rowsOf(life.ended, "") : null,
726
+ closedElsewhere ? [
727
+ h("div", { class: "row-sub", style: { margin: "6px 2px 2px" }, text: "archived from another contract:" }),
728
+ rowsOf(life.archived, ""),
729
+ ] : null,
730
+ // The tool cannot see branches. An action that archives this contract and creates one may
731
+ // do both every time — or create only on some paths and END it on the rest. A real
732
+ // pool's withdraw does exactly that (`create … if createResidual`), and a card that
733
+ // listed it only under "Replaced by" said a full withdrawal never ends a position.
734
+ life.replaced.length ? [
735
+ h("div", { class: "row-sub", style: { margin: "6px 2px 2px" }, text: "archive it, and may not make a new one:" }),
736
+ rowsOf(life.replaced, ""),
737
+ ] : null,
738
+ life.ended.length || closedElsewhere || life.replaced.length ? null
739
+ : h("div", { class: `strip ${life.changed.length || repeatable.length ? "warn" : ""}`, style: { margin: "2px 0" } },
740
+ icon("terminal"), "Nothing in this code ends it"))),
741
+ life.read.length ? fold(`Read by · ${life.read.length}`, rowsOf(life.read, "")) : null,
742
+ ];
743
+ }
744
+
745
+ function ghostPanel(g) {
746
+ const touching = E.filter((e) => e.steps.some((st) => st.to === g.id));
747
+ return [
748
+ backBtn(),
749
+ h("div", { class: "label", style: { margin: "0 0 4px" }, text: "Outside this code" }),
750
+ h("p", { class: "h big", text: g.name }),
751
+ h("p", { class: "sub", text: "Defined in a dependency — the tool can see who uses it, not what it does." }),
752
+ h("div", { class: "rows" }, touching.map((e) => entryRow(e.i))),
753
+ ];
754
+ }
755
+
756
+ function nextTargets(e) {
757
+ const ids = [];
758
+ for (const st of e.steps) {
759
+ if (st.k !== "create" || !st.to || !st.to.startsWith("u")) continue;
760
+ if (!ids.includes(st.to)) ids.push(st.to);
761
+ }
762
+ return ids.map((id) => U[Number(id.slice(1))]);
763
+ }
764
+
765
+ /** One step of an action. Clicking a step that lands on a contract opens that contract. */
766
+ function stepRow(st, e) {
767
+ const target = st.to ? nodeById.get(st.to) : null;
768
+ return h("button", {
769
+ class: `row${target ? "" : " static"}`, title: st.raw || null,
770
+ onclick: () => {
771
+ if (!target) return;
772
+ select(target.ghost ? { t: "ghost", id: target.id } : { t: "unit", i: target.i });
773
+ },
774
+ },
775
+ h("span", { class: `fx-icon ${KIND_ICON[st.k] === "transition" ? "transition" : st.k}` }, icon(KIND_ICON[st.k])),
776
+ h("span", { class: "row-main" },
777
+ h("span", { class: "row-title mono", text: st.k === "blind" ? st.raw : (st.name ?? "") }),
778
+ h("span", { class: "row-sub" }, verb(st, e),
779
+ st.thr ? h("span", { class: "thr" }, " · in ", h("span", { class: "mono", text: st.thr })) : null,
780
+ target?.ghost ? " · outside this code" : null)),
781
+ st.n > 1 ? h("span", { class: "times", text: `×${st.n}` }) : null);
782
+ }
783
+
784
+ function entryPanel(e) {
785
+ const u = U[e.unit];
786
+ const steps = txSteps(e);
787
+ const reads = steps.filter((st) => st.k === "read");
788
+ const src = sourceOf(e);
789
+ const trail = S.trail.length > 1 || (S.trail.length === 1 && S.trail[0] !== e.i) ? h("nav", { class: "trail", "aria-label": "Path so far" },
790
+ S.trail.map((i, k) => [
791
+ k ? h("span", { class: "sep", "aria-hidden": "true", text: "›" }) : null,
792
+ h("button", {
793
+ "aria-current": i === e.i ? "step" : null, title: `${U[E[i].unit].name}.${E[i].name}`,
794
+ onclick: () => select({ t: "entry", i }, { trail: k }), text: E[i].name,
795
+ }),
796
+ ])) : null;
797
+
798
+ const next = nextTargets(e);
799
+ return [
800
+ backBtn(),
801
+ trail,
802
+ h("div", { class: "who-line" }, whoChips(e)),
803
+ h("p", { class: "h big", text: e.name }),
804
+ h("div", { class: "on-line" },
805
+ h("span", { text: "on" }),
806
+ h("button", { class: "unit-link", onclick: () => select({ t: "unit", i: e.unit }), text: u.name }),
807
+ h("span", { class: `effect ${e.effect}` }, icon(e.effect), D.labels[e.effect])),
808
+ e.borrowed.length ? h("div", { class: "strip power", title: "Every action runs with the authority of the contract's signers as well as its caller" },
809
+ icon("stamp"), "also carries the authority of", e.borrowed.map((p) => stamp(p))) : null,
810
+ e.anyone && e.effect !== "none" ? h("div", { class: "strip power" }, icon("stamp"), "No access check — anyone can call this") : null,
811
+ e.argParties.length ? h("div", { class: "strip power", title: "The controller is one of this action's own arguments" },
812
+ icon("stamp"), "who acts is an argument — the caller names", e.argParties.map((p) => stamp(p))) : null,
813
+ e.unseen ? h("div", { class: "strip blind" }, icon("blind"), "Who can take this action could not be read from source") : null,
814
+ e.inherited ? h("div", { class: "strip", style: { background: "var(--raised)", color: "var(--muted)" } },
815
+ icon("code"), "inherited from", stamp(e.inherited), "— written outside this code, runs as part of", stamp(u.name)) : null,
816
+ e.unread.length ? h("div", { class: "strip blind", title: "An access check may live in code the tool could not read" },
817
+ icon("blind"), "relies on code that isn't on disk:", e.unread.map((x) => stamp(x))) : null,
818
+ h("div", { class: "actions-bar" },
819
+ h("button", { class: "btn flow play", onclick: () => play(e.i) }, icon("play"), "Play"),
820
+ codeButton(e.anchor)),
821
+ h("div", { class: "section" },
822
+ h("div", { class: "label", text: "What it changes, in order" }),
823
+ h("div", { class: "tx changes" }, steps.filter((st) => st.k !== "read").map((st) => stepRow(st, e)))),
824
+ reads.length ? fold(`What it reads · ${reads.length}`, h("div", { class: "tx" }, reads.map((st) => stepRow(st, e)))) : null,
825
+ e.cidArgs.length ? h("div", { class: "strip", style: { background: "var(--raised)", color: "var(--muted)" },
826
+ title: "Each of these is chosen by whoever calls. The action must check it received the contract it expected." },
827
+ icon("blind"), `the caller picks ${e.cidArgs.length === 1 ? "this contract id" : `these ${e.cidArgs.length} contract ids`}`,
828
+ e.cidArgs.map((p) => stamp(p))) : null,
829
+ e.guards.length ? fold(`Checks · ${e.guards.length}`, e.guards.map((g) => h("div", { class: "check", text: g }))) : null,
830
+ src ? fold("Code", h("pre", { class: "code-snippet", text: src })) : null,
831
+ next.length ? h("div", { class: "section" },
832
+ h("div", { class: "label", text: "Then, what can happen next" }),
833
+ next.slice(0, 3).map((t) => {
834
+ const list = E.filter((x) => x.unit === t.i).sort((a, b) => Number(b.alone) - Number(a.alone) || b.risk - a.risk);
835
+ return h("div", { style: { marginBottom: "10px" } },
836
+ h("div", { class: "row-sub", style: { margin: "4px 2px" } }, "on ",
837
+ h("button", { class: "unit-link", onclick: () => select({ t: "unit", i: t.i }), text: t.name })),
838
+ list.length ? h("div", { class: "rows" }, list.slice(0, 4).map((x) => entryRow(x.i, { unit: false, trail: "continue" })))
839
+ : h("div", { class: "empty", style: { color: "var(--faint)", padding: "2px" }, text: "No actions on it — nothing further happens to it here" }),
840
+ list.length > 4 ? h("button", { class: "more", onclick: () => select({ t: "unit", i: t.i }) }, `All ${list.length}`) : null);
841
+ })) : null,
842
+ ];
843
+ }
844
+
845
+ function blindPanel() {
846
+ const rows = [];
847
+ for (const e of E) {
848
+ for (const st of e.steps) {
849
+ if (st.k !== "blind") continue;
850
+ rows.push(h("button", { class: "row", onclick: () => select({ t: "entry", i: e.i }) },
851
+ h("span", { class: "fx-icon blind" }, icon("blind")),
852
+ h("span", { class: "row-main" },
853
+ h("span", { class: "row-title mono", text: st.raw }),
854
+ h("span", { class: "row-sub mono", text: `${U[e.unit].name}.${e.name} · ${e.path}:${e.line}` }))));
855
+ }
856
+ }
857
+ return [
858
+ backBtn(),
859
+ h("p", { class: "h", text: plural(D.stats.holes, "blind spot") }),
860
+ h("p", { class: "sub", text: "The tool couldn't tell what these lines point at, so it drew nothing. Read them yourself." }),
861
+ h("div", { class: "rows" }, rows),
862
+ ];
863
+ }
864
+
865
+ // ── learn: each idea, drawn with this codebase's own contracts ──────────────────────────────────
866
+ function mini(name, stamps = [], cls = "", glow = []) {
867
+ return h("div", { class: `mc ${cls}` },
868
+ h("b", { text: name }),
869
+ stamps.length ? h("div", { class: "stamps" }, stamps.map((p) => stamp(p, glow.includes(p) ? "borrowed a-glow" : ""))) : null);
870
+ }
871
+ const arrow = (cls = "") => h("span", { class: `art-arrow ${cls}` }, icon("arrow"));
872
+ function art(key, e, u) {
873
+ const unitName = u?.name ?? "Contract";
874
+ const signers = u?.signers ?? [];
875
+ const firstCreate = e?.steps.find((st) => st.k === "create" && st.to !== `u${e.unit}`);
876
+ const createdName = firstCreate?.name ?? "New";
877
+ const createdSigners = firstCreate?.to?.startsWith("u") ? U[Number(firstCreate.to.slice(1))].signers : [];
878
+ const who = () => (e ? whoChips(e)[0] : chip("anyone"));
879
+ switch (key) {
880
+ case "contract": return mini(unitName, signers);
881
+ case "action": return [who(), arrow(), mini(unitName, signers)];
882
+ case "changes": return D.daml
883
+ ? [mini(unitName, signers, "a-old"), arrow(), mini(unitName, signers, "a-new")]
884
+ : [who(), arrow(), mini(unitName, [], "a-glow")];
885
+ case "ends": return [mini(unitName, signers, "a-old"), arrow(), h("span", { class: "fx-icon terminal a-p2" }, icon("terminal"))];
886
+ case "repeatable": return [mini(unitName, signers), arrow(),
887
+ h("div", { class: "art-col", style: { gap: "4px" } },
888
+ mini(createdName, [], "a-p1"), mini(createdName, [], "a-p2"), mini(createdName, [], "a-p3"))];
889
+ // Two rows: who fires it on which contract, then what that makes — carrying the borrowed stamps.
890
+ case "borrowed": return h("div", { class: "art-col" },
891
+ h("div", { class: "art-row" }, who(), arrow(), mini(unitName, signers, "", e?.borrowed ?? [])),
892
+ h("div", { class: "art-row indent" }, h("span", { class: "art-arrow" }, icon("down")),
893
+ mini(createdName, createdSigners.length ? createdSigners : (e?.borrowed ?? []), "a-new", e?.borrowed ?? [])));
894
+ // The caller fires the action — and separately hands over an id nobody checked they may choose.
895
+ case "cid": return h("div", { class: "art-col" },
896
+ h("div", { class: "art-row" }, who(), arrow(), mini(unitName, signers)),
897
+ h("div", { class: "art-row indent" }, h("span", { class: "art-arrow" }, icon("call")),
898
+ h("span", { class: "mc ghost a-pulse" }, h("b", null, icon("blind"), " ", e?.cidArgs[0] ?? "someCid"))));
899
+ case "blind": return [mini(unitName, signers), arrow("a-pulse"), h("span", { class: "mc ghost a-pulse" }, h("b", null, icon("blind"), " ?"))];
900
+ case "anyone": return [chip("anyone"), arrow(), mini(unitName)];
901
+ case "guarded": return [who(), arrow(), mini(unitName)];
902
+ case "calls": return [mini(unitName), arrow(), mini(e?.steps.find((st) => st.k === "call")?.name ?? "Other", [], "a-new")];
903
+ case "creates": return [mini(unitName), arrow(), mini(createdName, [], "a-new")];
904
+ case "type": {
905
+ const door = D.doors[0] != null ? E[D.doors[0]] : E[0];
906
+ return h("div", { class: "type-demo" },
907
+ h("span", { class: "src", text: door ? `${U[door.unit].name}.${door.name}` : "Vault.Close" }),
908
+ h("span", { class: "say", text: door ? `the tool: ${D.labels[door.effect]}` : "the tool: ends it" }));
909
+ }
910
+ default: return null;
911
+ }
912
+ }
913
+ function buildLearn() {
914
+ learnEl.replaceChildren(
915
+ h("div", { class: "learn-head" },
916
+ h("h2", { text: D.daml ? "Daml, in pictures" : "Solidity, in pictures" }),
917
+ h("p", { text: "Every picture uses a contract from the code you pointed at." })),
918
+ h("div", { class: "tiles" }, D.concepts.map((c) => {
919
+ const e = c.entry != null ? E[c.entry] : null;
920
+ const u = c.unit != null ? U[c.unit] : e ? U[e.unit] : null;
921
+ const target = e ? { t: "entry", i: e.i } : u ? { t: "unit", i: u.i } : null;
922
+ return h("article", { class: "tile" },
923
+ h("div", { class: "stage-art", "aria-hidden": "true" }, art(c.key, e, u)),
924
+ h("div", { class: "tile-body" },
925
+ h("div", { class: "tile-title", text: c.title }),
926
+ h("div", { class: "tile-line", text: c.line }),
927
+ c.note ? h("details", null, h("summary", { text: "What to check" }), h("p", { text: c.note })) : null,
928
+ target ? h("div", { class: "tile-eg" }, "See it:",
929
+ h("button", {
930
+ onclick: () => { setView("map"); select(target, { trail: "reset" }); },
931
+ text: e ? `${U[e.unit].name}.${e.name}` : u.name,
932
+ }))
933
+ : c.key !== "type" ? h("div", { class: "tile-eg", text: "Not in this code." }) : null));
934
+ })));
935
+ }
936
+
937
+ // ── tour ────────────────────────────────────────────────────────────────────────────────────────
938
+ let tourEl = null;
939
+ let tourCleanup = null;
940
+ // A step can schedule work (play starts after the panel renders). Moving on must cancel it, or a
941
+ // quick Next lets the late play drag the view back to a step the reader has already left.
942
+ let tourTimer = null;
943
+ function tourSteps() {
944
+ const door = D.doors[0];
945
+ const e = door != null ? E[door] : null;
946
+ const ai = e ? A.findIndex((a) => a.side === "outside" && a.entries.includes(door)) : A.findIndex((a) => a.side === "outside");
947
+ const made = e ? txSteps(e).find((st) => st.k === "create" && st.to?.startsWith("u") && st.to !== `u${e.unit}`) : null;
948
+ return [
949
+ { el: () => peopleEl, text: "These are the parties the code lets act. Outside means they don't need the protocol to do it.",
950
+ run: () => { setView("map"); select(null); } },
951
+ ai >= 0 && { el: () => peopleEl.querySelector(`[data-actor="${ai}"]`), text: "Pick one. The map lights up every contract they can touch.",
952
+ run: () => select({ t: "actor", i: ai }) },
953
+ e && { el: () => inspEl.querySelector(`[data-entry="${door}"]`) ?? inspEl, text: "Pick one of their actions to see exactly what it does.",
954
+ run: () => { if (!(S.sel?.t === "actor" && S.sel.i === ai)) select({ t: "actor", i: ai }); } },
955
+ e && { el: () => inspEl.querySelector(".play"), text: "Press play and watch: what it archives, what it creates, what it calls.",
956
+ run: () => { select({ t: "entry", i: door }, { trail: "reset" }); tourTimer = setTimeout(() => play(door), 450); } },
957
+ made && { el: () => inspEl.querySelector(".life") ?? inspEl, text: "Every contract has a life: what makes it, what replaces it, what ends it.",
958
+ run: () => select({ t: "unit", i: Number(made.to.slice(1)) }) },
959
+ D.stats.holes > 0 && { el: () => topEl.querySelector(".btn.blind"), text: "Amber means the tool couldn't trace a line. It never guesses — read those yourself.",
960
+ run: () => {} },
961
+ { el: () => tabsEl.querySelector('[data-view="learn"]'), text: "New to this language? Learn shows each idea using this code.",
962
+ run: () => {} },
963
+ ].filter(Boolean);
964
+ }
965
+ function endTour() {
966
+ clearTimeout(tourTimer);
967
+ tourCleanup?.();
968
+ tourCleanup = null;
969
+ tourEl?.remove();
970
+ tourEl = null;
971
+ }
972
+ function startTour() {
973
+ endTour();
974
+ const steps = tourSteps();
975
+ let k = 0;
976
+ const hole = h("div", { class: "tour-hole" });
977
+ const tip = h("div", { class: "tour-tip", role: "dialog", "aria-label": "Tour" });
978
+ tourEl = h("div", { class: "tour" }, hole, tip);
979
+ document.body.append(tourEl);
980
+ const place = () => {
981
+ const el = steps[k].el();
982
+ if (!el) return;
983
+ const r = el.getBoundingClientRect();
984
+ const pad = 6;
985
+ Object.assign(hole.style, { left: `${r.left - pad}px`, top: `${r.top - pad}px`, width: `${r.width + pad * 2}px`, height: `${r.height + pad * 2}px` });
986
+ const tw = 290, th = tip.offsetHeight || 120, vw = window.innerWidth, vh = window.innerHeight;
987
+ let x = r.right + 16, y = r.top;
988
+ if (x + tw > vw - 10) x = r.left - tw - 16;
989
+ if (x < 10) { x = clamp(r.left, 10, vw - tw - 10); y = r.bottom + 14; }
990
+ if (y + th > vh - 10) y = Math.max(10, r.top - th - 14);
991
+ Object.assign(tip.style, { left: `${clamp(x, 10, vw - tw - 10)}px`, top: `${clamp(y, 10, vh - th - 10)}px` });
992
+ };
993
+ const show = () => {
994
+ clearTimeout(tourTimer);
995
+ stopPlay();
996
+ steps[k].run?.();
997
+ tip.replaceChildren(
998
+ h("p", { text: steps[k].text }),
999
+ h("div", { class: "tour-actions" },
1000
+ h("span", { class: "n", text: `${k + 1} / ${steps.length}` }),
1001
+ h("button", { class: "btn ghost", onclick: endTour, text: "Skip" }),
1002
+ h("button", { class: "btn primary", onclick: next, text: k === steps.length - 1 ? "Done" : "Next" })));
1003
+ requestAnimationFrame(() => requestAnimationFrame(place));
1004
+ setTimeout(place, 600);
1005
+ tip.querySelector(".btn.primary")?.focus({ preventScroll: true });
1006
+ };
1007
+ const next = () => { if (++k >= steps.length) endTour(); else show(); };
1008
+ const onKey = (ev) => {
1009
+ if (ev.key === "Escape") endTour();
1010
+ else if (ev.key === "ArrowRight") next();
1011
+ };
1012
+ window.addEventListener("resize", place);
1013
+ document.addEventListener("keydown", onKey, true);
1014
+ tourCleanup = () => { window.removeEventListener("resize", place); document.removeEventListener("keydown", onKey, true); };
1015
+ show();
1016
+ try { localStorage.setItem(TOUR_KEY, "1"); } catch { /* storage can be unavailable; the tour still works */ }
1017
+ }
1018
+
1019
+ // ── start ───────────────────────────────────────────────────────────────────────────────────────
1020
+ renderTop();
1021
+ buildMap();
1022
+ renderPeople();
1023
+ renderInspector();
1024
+ buildLearn();
1025
+ // Frame the map once it has a size. Opened in a background tab — or a hidden pane — it measures 0×0
1026
+ // at load, and a view computed then leaves every card off-screen with nothing to retry it.
1027
+ let framedOnce = false;
1028
+ const firstFrame = () => {
1029
+ if (framedOnce) return;
1030
+ const r = mapEl.getBoundingClientRect();
1031
+ if (!r.width || !r.height) return;
1032
+ framedOnce = true;
1033
+ if (S.sel) frameSelection(); else home(false);
1034
+ };
1035
+ if (typeof ResizeObserver === "function") new ResizeObserver(firstFrame).observe(mapEl);
1036
+ requestAnimationFrame(firstFrame);
1037
+
1038
+ document.addEventListener("keydown", (ev) => {
1039
+ if (tourEl || ev.defaultPrevented) return;
1040
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName ?? "");
1041
+ if (ev.key === "Escape" && S.view === "map" && S.sel) { ev.preventDefault(); goBack(); }
1042
+ else if (!typing && (ev.key === "p" || ev.key === "P") && S.sel?.t === "entry") play(S.sel.i);
1043
+ });
1044
+
1045
+ // Links open things on the map: #e-Unit-action, #u-Unit, #a-party. Followed on load AND while the
1046
+ // page is open, so a guide beside the map can drive it one step at a time.
1047
+ const followLink = () => {
1048
+ const hash = decodeURIComponent(location.hash.slice(1));
1049
+ if (!hash) return false;
1050
+ if (hash === "map" || hash === "learn") {
1051
+ endTour();
1052
+ setView(hash);
1053
+ if (hash === "map") select(null);
1054
+ return true;
1055
+ }
1056
+ const linkedEntry = E.find((e) => e.anchor === hash);
1057
+ const linkedUnit = U.find((u) => u.anchor === hash);
1058
+ const linkedActor = hash.startsWith("a-") ? A.findIndex((a) => a.name === hash.slice(2)) : -1;
1059
+ if (!linkedEntry && !linkedUnit && linkedActor < 0) return false;
1060
+ endTour();
1061
+ setView("map");
1062
+ if (linkedEntry) select({ t: "entry", i: linkedEntry.i }, { trail: "reset" });
1063
+ else if (linkedUnit) select({ t: "unit", i: linkedUnit.i });
1064
+ else select({ t: "actor", i: linkedActor });
1065
+ return true;
1066
+ };
1067
+ const hash = location.hash.slice(1);
1068
+ followLink();
1069
+ window.addEventListener("hashchange", () => { if (S.view !== "code") followLink(); });
1070
+
1071
+ let seen = true;
1072
+ try { seen = localStorage.getItem(TOUR_KEY) === "1"; } catch { seen = true; }
1073
+ if (!seen && !hash && D.doors.length) setTimeout(startTour, 700);
1074
+ })();