@christianmorup/review-intent 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.
package/dist/render.js ADDED
@@ -0,0 +1,982 @@
1
+ import { isTestPath, isCodePath, isNoisePath } from "./scorecard.js";
2
+ /** Pure: produce a self-contained HTML document from the review model. */
3
+ export function renderHtml(model) {
4
+ return `<!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9
+ <title>${esc(model.title)} — intent review</title>
10
+ <style>${CSS}</style>
11
+ </head>
12
+ <body>
13
+ <header class="page-head">
14
+ <div class="badge">diff: ${esc(model.base)}…HEAD</div>
15
+ <h1>${esc(model.title)}</h1>
16
+ <div class="tldr">${md(model.tldr)}</div>
17
+ <details class="overall-wrap" open>
18
+ <summary>Full summary</summary>
19
+ <div class="overall">${md(model.overall)}</div>
20
+ </details>
21
+ </header>
22
+
23
+ ${renderBlastRadius(model)}
24
+
25
+ ${renderVisuals(model)}
26
+
27
+ ${renderTests(model.tests)}
28
+
29
+ ${renderDiagrams(model)}
30
+
31
+ <main>
32
+ ${model.files.length === 0 ? `<p class="empty">No file changes in this diff.</p>` : model.files.map(renderFile).join("\n")}
33
+ </main>
34
+
35
+ ${renderFilesWithoutChanges(model)}
36
+
37
+ ${LIGHTBOX}
38
+
39
+ ${MERMAID_SCRIPT}
40
+ ${LIGHTBOX_SCRIPT}
41
+ </body>
42
+ </html>`;
43
+ }
44
+ function renderBlastRadius(model) {
45
+ return `<section class="blast">
46
+ <h2>Blast radius</h2>
47
+ <div class="blast-grid">
48
+ ${renderScorecard(model)}
49
+ ${renderRisks(model.risks)}
50
+ </div>
51
+ ${renderReach(model.reach)}
52
+ </section>`;
53
+ }
54
+ function renderScorecard(model) {
55
+ const s = model.scorecard;
56
+ const statusBits = Object.entries(s.byStatus)
57
+ .map(([k, v]) => `${v} ${esc(k)}`)
58
+ .join(", ");
59
+ const badges = s.badges.length
60
+ ? s.badges
61
+ .map((b) => `<span class="badge tone-${b.tone}">${esc(b.label)}</span>`)
62
+ .join("")
63
+ : `<span class="badge tone-ok">no flags</span>`;
64
+ // Derived, measured signals — pure arithmetic over the counts above.
65
+ const net = s.added - s.removed;
66
+ const netStr = `net ${net >= 0 ? "+" : "−"}${Math.abs(net)}`;
67
+ const concentration = s.filesChanged
68
+ ? (s.hunks / s.filesChanged).toFixed(1)
69
+ : "0.0";
70
+ const newFiles = s.byStatus.added ?? 0;
71
+ const fanIn = model.reach.edges.length;
72
+ const ic = model.intentCoverage;
73
+ const diagramNames = [
74
+ model.diagrams.class ? "class" : null,
75
+ model.diagrams.sequence ? "sequence" : null,
76
+ ].filter(Boolean);
77
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
78
+ // Extra signals; the ones that are themselves smells get a danger tint.
79
+ const extra = [
80
+ `<span>${netStr} lines</span>`,
81
+ `<span>${s.testLines} test / ${s.codeLines} code lines</span>`,
82
+ `<span>${concentration} hunks/file</span>`,
83
+ `<span>${plural(newFiles, "new file")}</span>`,
84
+ `<span>${plural(fanIn, "dependent")} <span class="muted">(reach)</span></span>`,
85
+ `<span>intent: ${ic.filesCovered}/${ic.filesTotal} files · ${ic.hunksCovered}/${ic.hunksTotal} hunks</span>`,
86
+ `<span>diagrams: ${diagramNames.length ? diagramNames.join(", ") : "none"}</span>`,
87
+ ];
88
+ if (s.debtMarkers > 0) {
89
+ extra.push(`<span class="flag">${plural(s.debtMarkers, "debt/debug marker")} added</span>`);
90
+ }
91
+ if (s.noiseFiles > 0) {
92
+ extra.push(`<span class="flag">${plural(s.noiseFiles, "noise file")}</span>`);
93
+ }
94
+ if (s.largestFile) {
95
+ extra.push(`<span>largest: <code>${esc(s.largestFile.path)}</code> ±${s.largestFile.churn}</span>`);
96
+ }
97
+ const cx = model.complexity;
98
+ if (cx.available) {
99
+ const hs = cx.hotspots.length;
100
+ extra.push(`<span${hs ? ' class="flag"' : ""}>max CCN ${cx.maxCcn}${hs ? ` · ${plural(hs, "hotspot")} ≥ ${cx.threshold}` : ""}</span>`);
101
+ }
102
+ else {
103
+ extra.push(`<span class="muted">complexity: ${esc(cx.note ?? "n/a")}</span>`);
104
+ }
105
+ return `<div class="card scorecard">
106
+ <h3>Surface area <span class="src">measured</span></h3>
107
+ <div class="metrics">
108
+ <span><b>${s.filesChanged}</b> files${statusBits ? ` <span class="muted">(${statusBits})</span>` : ""}</span>
109
+ <span><b>${s.hunks}</b> hunks</span>
110
+ <span class="add">+${s.added}</span>
111
+ <span class="del">−${s.removed}</span>
112
+ <span><b>${s.testFiles}</b> test / <b>${s.codeFiles}</b> code files</span>
113
+ </div>
114
+ <div class="metrics metrics-extra">${extra.join("")}</div>
115
+ <div class="badges">${badges}</div>
116
+ </div>`;
117
+ }
118
+ function renderRisks(risks) {
119
+ if (risks.length === 0) {
120
+ return `<div class="card risks">
121
+ <h3>Risk ledger <span class="src">claimed</span></h3>
122
+ <div class="nudge">No risks declared. Per the honesty contract an empty ledger is itself a signal — is this change really assumption-free?</div>
123
+ </div>`;
124
+ }
125
+ const rows = risks
126
+ .map((r) => `<tr>
127
+ <td>${md(r.assumption)}</td>
128
+ <td>${md(r.ifFalse)}</td>
129
+ <td>${r.howYoudKnow ? md(r.howYoudKnow) : '<span class="muted">—</span>'}</td>
130
+ </tr>`)
131
+ .join("");
132
+ return `<div class="card risks">
133
+ <h3>Risk ledger <span class="src">claimed</span></h3>
134
+ <table class="risk-table">
135
+ <thead><tr><th>Assumption</th><th>If false</th><th>How you'd know</th></tr></thead>
136
+ <tbody>${rows}</tbody>
137
+ </table>
138
+ </div>`;
139
+ }
140
+ function renderReach(reach) {
141
+ const note = reach.truncatedNote
142
+ ? `<div class="reach-note">⚠ ${esc(reach.truncatedNote)}</div>`
143
+ : "";
144
+ if (reach.changed.length === 0) {
145
+ return `<div class="card reach">
146
+ <h3>Reach <span class="src">measured · heuristic</span></h3>
147
+ <div class="muted">No code files in this change set to trace.</div>
148
+ </div>`;
149
+ }
150
+ if (reach.edges.length === 0) {
151
+ return `<div class="card reach">
152
+ <h3>Reach <span class="src">measured · heuristic</span></h3>
153
+ <div class="muted">No file-level dependents found for the changed files (heuristic import scan).</div>
154
+ ${note}
155
+ </div>`;
156
+ }
157
+ return `<div class="card reach zoomable">
158
+ <h3>Reach <span class="src">measured · heuristic</span></h3>
159
+ <p class="muted">Changed files sit at the centre; files that import them ripple outward (line = "depends on"). Heuristic — may miss or over-match.</p>
160
+ ${reachRipple(reach)}
161
+ ${note}
162
+ </div>`;
163
+ }
164
+ /** Inline-SVG radial "ripple": changed files at the centre, importers on an
165
+ * outer ring, with a connecting line per dependency edge. Pure & deterministic. */
166
+ function reachRipple(reach) {
167
+ const W = 720;
168
+ const H = 420;
169
+ const cx = W / 2;
170
+ const cy = H / 2;
171
+ const cap = 36;
172
+ const importers = [...new Set(reach.edges.map((e) => e.from))];
173
+ const shown = importers.slice(0, cap);
174
+ const hidden = importers.length - shown.length;
175
+ const cpos = new Map();
176
+ reach.changed.forEach((c, i) => {
177
+ if (reach.changed.length === 1) {
178
+ cpos.set(c, { x: cx, y: cy });
179
+ }
180
+ else {
181
+ const a = (i / reach.changed.length) * 2 * Math.PI - Math.PI / 2;
182
+ cpos.set(c, { x: cx + 64 * Math.cos(a), y: cy + 64 * Math.sin(a) });
183
+ }
184
+ });
185
+ const ipos = new Map();
186
+ shown.forEach((f, i) => {
187
+ const a = (i / shown.length) * 2 * Math.PI - Math.PI / 2;
188
+ ipos.set(f, { x: cx + 190 * Math.cos(a), y: cy + 150 * Math.sin(a) });
189
+ });
190
+ const rings = `<circle cx="${cx}" cy="${cy}" r="155" class="ripple-ring" /><circle cx="${cx}" cy="${cy}" r="80" class="ripple-ring" />`;
191
+ const lines = reach.edges
192
+ .filter((e) => ipos.has(e.from) && cpos.has(e.to))
193
+ .map((e) => {
194
+ const a = ipos.get(e.from);
195
+ const b = cpos.get(e.to);
196
+ return `<line x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}" class="ripple-edge" />`;
197
+ })
198
+ .join("");
199
+ const iNodes = shown.map((f) => rippleNode(ipos.get(f), f, false)).join("");
200
+ const cNodes = reach.changed.map((c) => rippleNode(cpos.get(c), c, true)).join("");
201
+ const more = hidden > 0
202
+ ? `<text x="${cx}" y="${H - 8}" text-anchor="middle" class="ripple-label">+${hidden} more importer(s) not drawn</text>`
203
+ : "";
204
+ return `<svg class="viz-ripple" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img">
205
+ ${rings}${lines}${iNodes}${cNodes}${more}
206
+ </svg>`;
207
+ }
208
+ function rippleNode(p, path, isChanged) {
209
+ const r = isChanged ? 8 : 5;
210
+ const fill = isChanged ? "#1f6feb" : "#21262d";
211
+ const stroke = isChanged ? "#58a6ff" : "#8b949e";
212
+ const ly = isChanged ? p.y - 13 : p.y + 16;
213
+ return `<g class="ripple-node">
214
+ <circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5" />
215
+ <text x="${p.x.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" class="ripple-label">${esc(shortPath(path, 22))}</text>
216
+ </g>`;
217
+ }
218
+ const CAT_COLOR = {
219
+ test: "#3fb950",
220
+ code: "#58a6ff",
221
+ noise: "#6e7681",
222
+ other: "#8b949e",
223
+ };
224
+ const DIR_PALETTE = [
225
+ "#1f6feb", "#3fb950", "#a371f7", "#d29922",
226
+ "#db61a2", "#2ea043", "#f0883e", "#58a6ff",
227
+ ];
228
+ function fileStats(model) {
229
+ return model.files.map((f) => {
230
+ let added = 0;
231
+ let removed = 0;
232
+ for (const h of f.hunks) {
233
+ for (const l of h.lines) {
234
+ if (l.type === "add")
235
+ added++;
236
+ else if (l.type === "del")
237
+ removed++;
238
+ }
239
+ }
240
+ const category = isTestPath(f.path)
241
+ ? "test"
242
+ : isNoisePath(f.path)
243
+ ? "noise"
244
+ : isCodePath(f.path)
245
+ ? "code"
246
+ : "other";
247
+ return { path: f.path, added, removed, churn: added + removed, category, hasIntent: !!f.why };
248
+ });
249
+ }
250
+ function renderVisuals(model) {
251
+ const stats = fileStats(model).filter((s) => s.churn > 0);
252
+ const blocks = [
253
+ renderDiffMass(stats),
254
+ renderTreemap(stats),
255
+ renderComplexityHotspots(model.complexity),
256
+ renderCoverageRings(model.intentCoverage),
257
+ renderHonestyQuadrant(model),
258
+ ].filter(Boolean);
259
+ if (blocks.length === 0)
260
+ return "";
261
+ return `<section class="visuals">
262
+ <h2>Visual summary <span class="src">measured</span></h2>
263
+ <div class="viz-grid">
264
+ ${blocks.join("\n ")}
265
+ </div>
266
+ </section>`;
267
+ }
268
+ /** #1 Diff mass — diverging add/remove bars per file, sorted by churn. */
269
+ function renderDiffMass(stats) {
270
+ if (stats.length === 0)
271
+ return "";
272
+ const rows = [...stats].sort((a, b) => b.churn - a.churn);
273
+ const cap = 25;
274
+ const shown = rows.slice(0, cap);
275
+ const hidden = rows.length - shown.length;
276
+ const maxSide = Math.max(1, ...shown.map((s) => Math.max(s.added, s.removed)));
277
+ const W = 720;
278
+ const rowH = 22;
279
+ const pad = 10;
280
+ const plotL = 200;
281
+ const plotR = W - 86;
282
+ const xc = (plotL + plotR) / 2;
283
+ const half = (plotR - plotL) / 2 - 4;
284
+ const scale = half / maxSide;
285
+ const H = pad * 2 + shown.length * rowH;
286
+ const body = shown
287
+ .map((f, i) => {
288
+ const y = pad + i * rowH;
289
+ const mid = y + rowH / 2;
290
+ const remW = f.removed * scale;
291
+ const addW = f.added * scale;
292
+ const mark = f.hasIntent
293
+ ? `<circle cx="9" cy="${mid}" r="3" fill="#3fb950" />`
294
+ : `<circle cx="9" cy="${mid}" r="3" fill="none" stroke="#f85149" stroke-width="1.5" />`;
295
+ return `${mark}
296
+ <text x="18" y="${mid + 3}" class="viz-label" fill="${CAT_COLOR[f.category]}">${esc(shortPath(f.path, 26))}</text>
297
+ <rect x="${(xc - remW).toFixed(1)}" y="${y + 4}" width="${remW.toFixed(1)}" height="${rowH - 8}" fill="#f85149" fill-opacity="0.85" />
298
+ <rect x="${xc.toFixed(1)}" y="${y + 4}" width="${addW.toFixed(1)}" height="${rowH - 8}" fill="#3fb950" fill-opacity="0.85" />
299
+ <text x="${plotR + 6}" y="${mid + 3}" class="viz-num">+${f.added} −${f.removed}</text>`;
300
+ })
301
+ .join("\n ");
302
+ const axis = `<line x1="${xc}" y1="${pad}" x2="${xc}" y2="${H - pad}" class="viz-axis" />`;
303
+ const more = hidden > 0
304
+ ? `<p class="viz-cap">+${hidden} more file(s) not charted (showing the ${cap} largest).</p>`
305
+ : "";
306
+ return `<div class="card viz viz-span zoomable">
307
+ <h3>Diff mass <span class="src">± lines per file</span></h3>
308
+ <svg class="viz-diffmass" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img">
309
+ ${axis}
310
+ ${body}
311
+ </svg>
312
+ ${more}
313
+ </div>`;
314
+ }
315
+ /** #2 Change treemap — squarified, area ∝ churn, colour = directory. */
316
+ function renderTreemap(stats) {
317
+ if (stats.length === 0)
318
+ return "";
319
+ const W = 720;
320
+ const H = 300;
321
+ const sorted = [...stats].sort((a, b) => b.churn - a.churn);
322
+ const total = sorted.reduce((n, s) => n + s.churn, 0);
323
+ const scale = (W * H) / total;
324
+ const items = sorted.map((s) => ({ ...s, area: s.churn * scale }));
325
+ const rects = squarify(items, { x: 0, y: 0, w: W, h: H });
326
+ const cells = rects
327
+ .map((r) => {
328
+ const stroke = r.hasIntent ? "#30363d" : "#f85149";
329
+ const sw = r.hasIntent ? 1 : 2;
330
+ const label = r.w > 54 && r.h > 18
331
+ ? `<text x="${(r.x + 5).toFixed(1)}" y="${(r.y + 15).toFixed(1)}" class="viz-cell-label">${esc(shortPath(basename(r.path), Math.max(3, Math.floor(r.w / 7))))}</text>`
332
+ : "";
333
+ return `<g><rect x="${r.x.toFixed(1)}" y="${r.y.toFixed(1)}" width="${r.w.toFixed(1)}" height="${r.h.toFixed(1)}" fill="${dirColor(r.path)}" fill-opacity="0.82" stroke="${stroke}" stroke-width="${sw}" />${label}</g>`;
334
+ })
335
+ .join("\n ");
336
+ return `<div class="card viz viz-span zoomable">
337
+ <h3>Change treemap <span class="src">area = churn</span></h3>
338
+ <svg class="viz-treemap" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img">
339
+ ${cells}
340
+ </svg>
341
+ <p class="viz-cap">Rectangle area ∝ ± lines · colour = top-level directory · red outline = no intent written.</p>
342
+ </div>`;
343
+ }
344
+ /** Squarified treemap layout (Bruls et al.) — deterministic, no I/O. */
345
+ function squarify(items, rect) {
346
+ const out = [];
347
+ const queue = items.slice();
348
+ let { x, y, w, h } = rect;
349
+ let row = [];
350
+ const sum = (r) => r.reduce((n, it) => n + it.area, 0);
351
+ const worst = (r, side) => {
352
+ const s = sum(r);
353
+ if (s === 0)
354
+ return Infinity;
355
+ const max = Math.max(...r.map((it) => it.area));
356
+ const min = Math.min(...r.map((it) => it.area));
357
+ const s2 = s * s;
358
+ const side2 = side * side;
359
+ return Math.max((side2 * max) / s2, s2 / (side2 * min));
360
+ };
361
+ const layoutRow = (r) => {
362
+ const s = sum(r);
363
+ if (w <= h) {
364
+ const stripH = s / w;
365
+ let cxp = x;
366
+ for (const it of r) {
367
+ const cw = it.area / stripH;
368
+ out.push({ ...it, x: cxp, y, w: cw, h: stripH });
369
+ cxp += cw;
370
+ }
371
+ y += stripH;
372
+ h -= stripH;
373
+ }
374
+ else {
375
+ const stripW = s / h;
376
+ let cyp = y;
377
+ for (const it of r) {
378
+ const ch = it.area / stripW;
379
+ out.push({ ...it, x, y: cyp, w: stripW, h: ch });
380
+ cyp += ch;
381
+ }
382
+ x += stripW;
383
+ w -= stripW;
384
+ }
385
+ };
386
+ while (queue.length) {
387
+ const side = Math.min(w, h);
388
+ const next = queue[0];
389
+ if (row.length === 0 || worst(row, side) >= worst([...row, next], side)) {
390
+ row.push(next);
391
+ queue.shift();
392
+ }
393
+ else {
394
+ layoutRow(row);
395
+ row = [];
396
+ }
397
+ }
398
+ if (row.length)
399
+ layoutRow(row);
400
+ return out;
401
+ }
402
+ /** #3 Intent coverage — donut rings for files & hunks annotated. */
403
+ function renderCoverageRings(ic) {
404
+ if (ic.filesTotal === 0 && ic.hunksTotal === 0)
405
+ return "";
406
+ return `<div class="card viz zoomable">
407
+ <h3>Intent coverage <span class="src">measured</span></h3>
408
+ <div class="viz-rings">
409
+ ${coverageRing("files", ic.filesCovered, ic.filesTotal)}
410
+ ${coverageRing("hunks", ic.hunksCovered, ic.hunksTotal)}
411
+ </div>
412
+ </div>`;
413
+ }
414
+ function coverageRing(label, num, den) {
415
+ const f = den ? num / den : 0;
416
+ const pct = Math.round(f * 100);
417
+ const r = 42;
418
+ const c = 2 * Math.PI * r;
419
+ const dash = (f * c).toFixed(1);
420
+ const color = f >= 0.8 ? "#3fb950" : f >= 0.5 ? "#d29922" : "#f85149";
421
+ return `<svg viewBox="0 0 120 150" class="viz-ring-svg" role="img">
422
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="#30363d" stroke-width="12" />
423
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="${color}" stroke-width="12" stroke-linecap="round" stroke-dasharray="${dash} ${c.toFixed(1)}" transform="rotate(-90 60 60)" />
424
+ <text x="60" y="67" text-anchor="middle" class="viz-ring-pct">${pct}%</text>
425
+ <text x="60" y="135" text-anchor="middle" class="viz-ring-label">${esc(label)} ${num}/${den}</text>
426
+ </svg>`;
427
+ }
428
+ /** #4 reach ripple lives with the blast radius (reachRipple above). */
429
+ /** Complexity hotspots — horizontal CCN bars for the most complex changed
430
+ * functions (measured by lizard). Rendered only when analysis ran and found any. */
431
+ function renderComplexityHotspots(cx) {
432
+ if (!cx.available || cx.hotspots.length === 0)
433
+ return "";
434
+ const rows = cx.hotspots;
435
+ const maxC = Math.max(...rows.map((r) => r.ccn));
436
+ const W = 720;
437
+ const rowH = 24;
438
+ const pad = 10;
439
+ const barL = 300;
440
+ const barR = W - 60;
441
+ const barMax = barR - barL;
442
+ const H = pad * 2 + rows.length * rowH;
443
+ const body = rows
444
+ .map((r, i) => {
445
+ const y = pad + i * rowH;
446
+ const mid = y + rowH / 2;
447
+ const w = (r.ccn / maxC) * barMax;
448
+ const color = r.ccn >= cx.threshold * 2 ? "#f85149" : "#d29922";
449
+ const label = `${r.name} · ${basename(r.file)}:${r.line}`;
450
+ return `<text x="6" y="${mid + 3}" class="viz-label">${esc(shortPath(label, 44))}</text>
451
+ <rect x="${barL}" y="${y + 4}" width="${w.toFixed(1)}" height="${rowH - 8}" fill="${color}" fill-opacity="0.85" />
452
+ <text x="${(barL + w + 6).toFixed(1)}" y="${mid + 3}" class="viz-num">${r.ccn}</text>`;
453
+ })
454
+ .join("\n ");
455
+ return `<div class="card viz viz-span zoomable">
456
+ <h3>Complexity hotspots <span class="src">lizard · CCN</span></h3>
457
+ <svg class="viz-complexity" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img">
458
+ ${body}
459
+ </svg>
460
+ <p class="viz-cap">Cyclomatic complexity of changed functions at or above the threshold (${cx.threshold}); bars at ≥ 2× threshold are red.</p>
461
+ </div>`;
462
+ }
463
+ /** #5 Honesty quadrant — claimed candor vs measured blast radius. */
464
+ function renderHonestyQuadrant(model) {
465
+ if (model.files.length === 0)
466
+ return "";
467
+ const sc = model.scorecard;
468
+ const ic = model.intentCoverage;
469
+ const sat = (v, k) => (v <= 0 ? 0 : 1 - 1 / (1 + v / k));
470
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
471
+ const churn = sc.added + sc.removed;
472
+ const fanIn = model.reach.edges.length;
473
+ const blast = clamp01(0.6 * sat(churn, 400) + 0.4 * sat(fanIn, 8));
474
+ const hunkCov = ic.hunksTotal ? ic.hunksCovered / ic.hunksTotal : 0;
475
+ const candor = clamp01(0.6 * hunkCov + 0.4 * sat(model.risks.length, 3));
476
+ const S = 360;
477
+ const m = 44;
478
+ const plot = S - 2 * m;
479
+ const px = m + blast * plot;
480
+ const py = m + (1 - candor) * plot;
481
+ const mid = m + plot / 2;
482
+ return `<div class="card viz zoomable">
483
+ <h3>Honesty quadrant <span class="src">claimed vs measured</span></h3>
484
+ <svg class="viz-quadrant" viewBox="0 0 ${S} ${S}" preserveAspectRatio="xMidYMid meet" role="img">
485
+ <rect x="${mid}" y="${mid}" width="${plot / 2}" height="${plot / 2}" class="viz-danger" />
486
+ <line x1="${m}" y1="${mid}" x2="${S - m}" y2="${mid}" class="viz-axis" />
487
+ <line x1="${mid}" y1="${m}" x2="${mid}" y2="${S - m}" class="viz-axis" />
488
+ <rect x="${m}" y="${m}" width="${plot}" height="${plot}" fill="none" stroke="#30363d" />
489
+ <text x="${S - m}" y="${S - m + 20}" text-anchor="end" class="viz-axis-label">blast radius →</text>
490
+ <text x="${m - 8}" y="${m - 14}" class="viz-axis-label">↑ candor</text>
491
+ <text x="${mid + 8}" y="${S - m - 10}" class="viz-axis-label viz-danger-label">high blast · low candor</text>
492
+ <circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="7" class="viz-dot" />
493
+ </svg>
494
+ <p class="viz-cap">blast = churn (${churn}±) + reach (${fanIn}); candor = hunk intent (${Math.round(hunkCov * 100)}%) + ${model.risks.length} declared risk(s). A dot in the red corner is a confident change that hid its risk.</p>
495
+ </div>`;
496
+ }
497
+ function basename(p) {
498
+ return p.split("/").pop() ?? p;
499
+ }
500
+ function dirColor(p) {
501
+ const dir = p.includes("/") ? p.slice(0, p.indexOf("/")) : "·";
502
+ let h = 0;
503
+ for (const ch of dir)
504
+ h = (h * 31 + ch.charCodeAt(0)) >>> 0;
505
+ return DIR_PALETTE[h % DIR_PALETTE.length];
506
+ }
507
+ /** Truncate a path from the left, keeping the tail (most specific part). */
508
+ function shortPath(p, max) {
509
+ if (p.length <= max)
510
+ return p;
511
+ return "…" + p.slice(p.length - (max - 1));
512
+ }
513
+ // ── Tests: agent-described cases (claimed; pure display, never measured) ──
514
+ const KIND_ORDER = ["unit", "integration", "e2e", "manual"];
515
+ const KIND_COLOR = {
516
+ unit: "#3fb950",
517
+ integration: "#58a6ff",
518
+ e2e: "#a371f7",
519
+ manual: "#d29922",
520
+ };
521
+ const kindColor = (key) => KIND_COLOR[key] ?? "#8b949e";
522
+ /** Pure: render the agent's human-readable test descriptions, grouped by kind.
523
+ * Returns "" when none were authored (the section is optional). */
524
+ function renderTests(tests) {
525
+ if (tests.length === 0)
526
+ return "";
527
+ // Group by normalized kind, then order: known kinds first (fixed order), then
528
+ // any other kinds in first-appearance order, then the untagged group last.
529
+ const groups = new Map();
530
+ for (const t of tests) {
531
+ const key = (t.kind ?? "").trim().toLowerCase();
532
+ const list = groups.get(key);
533
+ if (list)
534
+ list.push(t);
535
+ else
536
+ groups.set(key, [t]);
537
+ }
538
+ const keys = [...groups.keys()];
539
+ const ordered = [
540
+ ...KIND_ORDER.filter((k) => groups.has(k)),
541
+ ...keys.filter((k) => k !== "" && !KIND_ORDER.includes(k)),
542
+ ...(groups.has("") ? [""] : []),
543
+ ];
544
+ // A lone untagged group needs no header (it would just say "other").
545
+ const flat = ordered.length === 1 && ordered[0] === "";
546
+ const blocks = ordered
547
+ .map((key) => {
548
+ const items = groups
549
+ .get(key)
550
+ .map((t) => {
551
+ const name = t.name
552
+ ? ` <code class="test-name">${esc(t.name)}</code>`
553
+ : "";
554
+ return `<li class="test-case">${md(t.describes)}${name}</li>`;
555
+ })
556
+ .join("\n ");
557
+ const header = flat
558
+ ? ""
559
+ : `<h4 class="test-kind">${esc(key || "other")}</h4>`;
560
+ return `<div class="test-group" style="--k:${kindColor(key)}">
561
+ ${header}
562
+ <ul class="test-list">
563
+ ${items}
564
+ </ul>
565
+ </div>`;
566
+ })
567
+ .join("\n ");
568
+ const n = tests.length;
569
+ return `<section class="tests">
570
+ <h2>Tests <span class="src">claimed</span> <span class="muted test-count">${n} case${n === 1 ? "" : "s"} described</span></h2>
571
+ ${blocks}
572
+ </section>`;
573
+ }
574
+ function renderDiagrams(model) {
575
+ const { class: cls, sequence } = model.diagrams;
576
+ if (!cls && !sequence)
577
+ return "";
578
+ const block = (heading, src) => src
579
+ ? `<section class="diagram zoomable">
580
+ <h2>${esc(heading)}</h2>
581
+ <pre class="mermaid">${esc(src)}</pre>
582
+ </section>`
583
+ : "";
584
+ return `<section class="diagrams">
585
+ <div class="diagram-grid">
586
+ ${block("Class diagram", cls)}
587
+ ${block("Sequence diagram (changed steps highlighted)", sequence)}
588
+ </div>
589
+ </section>`;
590
+ }
591
+ function renderFile(file) {
592
+ return `<section class="file">
593
+ <div class="file-head">
594
+ <span class="status status-${file.status}">${file.status}</span>
595
+ <code class="path">${esc(file.path)}</code>
596
+ </div>
597
+ ${file.why
598
+ ? `<div class="file-intent">${whatWhy(file.what, file.why)}</div>`
599
+ : `<div class="file-intent missing">⚠ No rationale (what/why) written for this changed file.</div>`}
600
+ ${file.hunks.map(renderHunk).join("\n")}
601
+ ${file.unmatchedIntents.length
602
+ ? `<div class="unmatched">
603
+ <h4>Notes not matched to a hunk</h4>
604
+ ${file.unmatchedIntents.map((n) => `<div class="note"><span class="anchor">line ${n.anchor}</span>${whatWhy(n.what, n.why)}</div>`).join("")}
605
+ </div>`
606
+ : ""}
607
+ </section>`;
608
+ }
609
+ /** Render a what/why pair (the structured per-change intent). */
610
+ function whatWhy(what, why) {
611
+ const whatBlock = what
612
+ ? `<div class="what"><span class="lbl">What</span> ${md(what)}</div>`
613
+ : "";
614
+ return `<div class="ww">${whatBlock}<div class="why"><span class="lbl">Why</span> ${md(why)}</div></div>`;
615
+ }
616
+ function renderHunk(hunk) {
617
+ return `<div class="hunk-row">
618
+ <div class="hunk-diff">
619
+ <div class="hunk-header">${esc(hunk.header)}</div>
620
+ <table class="diff">${hunk.lines.map(renderLine).join("")}</table>
621
+ </div>
622
+ <aside class="hunk-notes">
623
+ ${hunk.intents.length
624
+ ? hunk.intents.map((i) => `<div class="note">${whatWhy(i.what, i.why)}</div>`).join("")
625
+ : `<div class="note missing">⚠ No intent for this hunk.</div>`}
626
+ </aside>
627
+ </div>`;
628
+ }
629
+ function renderLine(line) {
630
+ const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
631
+ return `<tr class="ln ln-${line.type}">
632
+ <td class="num">${line.oldNumber ?? ""}</td>
633
+ <td class="num">${line.newNumber ?? ""}</td>
634
+ <td class="sign">${sign}</td>
635
+ <td class="code">${esc(line.content) || "&nbsp;"}</td>
636
+ </tr>`;
637
+ }
638
+ function renderFilesWithoutChanges(model) {
639
+ if (model.filesWithoutChanges.length === 0)
640
+ return "";
641
+ return `<section class="orphans">
642
+ <h2>Intent for files not in this diff</h2>
643
+ <ul>${model.filesWithoutChanges
644
+ .map((f) => `<li><code>${esc(f.path)}</code>${f.why ? `: ${md(f.why)}` : ""}</li>`)
645
+ .join("")}</ul>
646
+ </section>`;
647
+ }
648
+ /** Escape text for safe HTML embedding. Also used for mermaid sources so the
649
+ * browser hands the correct characters to mermaid via textContent. */
650
+ function esc(s) {
651
+ return s
652
+ .replace(/&/g, "&amp;")
653
+ .replace(/</g, "&lt;")
654
+ .replace(/>/g, "&gt;")
655
+ .replace(/"/g, "&quot;");
656
+ }
657
+ /** Tiny markdown subset: paragraphs, inline code, bold, italic, links.
658
+ * Deliberately minimal — intent prose, not a full document renderer. */
659
+ function md(src) {
660
+ const paragraphs = src.trim().split(/\n{2,}/);
661
+ return paragraphs
662
+ .map((p) => {
663
+ const inline = esc(p)
664
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
665
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
666
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
667
+ .replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" rel="noreferrer">$1</a>')
668
+ .replace(/\n/g, "<br>");
669
+ return `<p>${inline}</p>`;
670
+ })
671
+ .join("\n");
672
+ }
673
+ const CSS = `
674
+ :root {
675
+ --bg: #0d1117; --panel: #161b22; --border: #30363d; --text: #e6edf3;
676
+ --muted: #8b949e; --add-bg: #12261e; --add-bd: #2ea043; --del-bg: #2a1416;
677
+ --del-bd: #f85149; --accent: #58a6ff; --note: #1c2433;
678
+ }
679
+ * { box-sizing: border-box; }
680
+ body {
681
+ margin: 0; background: var(--bg); color: var(--text);
682
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
683
+ }
684
+ .page-head { padding: 28px 32px; border-bottom: 1px solid var(--border); }
685
+ .page-head h1 { margin: 8px 0 12px; font-size: 24px; }
686
+ .badge {
687
+ display: inline-block; font-family: ui-monospace, monospace; font-size: 12px;
688
+ color: var(--muted); border: 1px solid var(--border); border-radius: 999px;
689
+ padding: 2px 10px;
690
+ }
691
+ .tldr {
692
+ max-width: 80ch; font-size: 17px; line-height: 1.5; color: var(--text);
693
+ border-left: 3px solid var(--accent); padding: 4px 0 4px 14px; margin: 0 0 12px;
694
+ }
695
+ .tldr p { margin: 0; }
696
+ .overall-wrap { max-width: 80ch; }
697
+ .overall-wrap > summary {
698
+ cursor: pointer; color: var(--muted); font-size: 12px; text-transform: uppercase;
699
+ letter-spacing: .04em; margin-bottom: 8px;
700
+ }
701
+ .overall { color: var(--text); }
702
+ .overall p { margin: 0 0 10px; }
703
+ main, .diagrams, .orphans, .blast { padding: 24px 32px; }
704
+ .blast { border-bottom: 1px solid var(--border); }
705
+ .blast > h2 { font-size: 16px; color: var(--muted); margin: 0 0 14px; }
706
+ .blast-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
707
+ .card {
708
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
709
+ padding: 14px 16px;
710
+ }
711
+ .card h3 { margin: 0 0 12px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
712
+ .card h3 .src {
713
+ font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
714
+ color: var(--muted); border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
715
+ }
716
+ .metrics { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 12px; font-size: 13px; }
717
+ .metrics .add { color: #aff5b4; }
718
+ .metrics .del { color: #ffb4b4; }
719
+ .metrics-extra {
720
+ font-size: 12px; color: var(--muted); gap: 10px 14px;
721
+ padding-top: 10px; border-top: 1px solid var(--border);
722
+ }
723
+ .metrics-extra code { font-size: 11px; padding: 0 4px; }
724
+ .metrics-extra .flag { color: var(--del-bd); font-weight: 600; }
725
+ .muted { color: var(--muted); }
726
+ .badges { display: flex; flex-wrap: wrap; gap: 8px; }
727
+ .badge {
728
+ font-size: 12px; font-weight: 600; border-radius: 999px; padding: 3px 10px;
729
+ border: 1px solid var(--border);
730
+ }
731
+ .tone-danger { background: var(--del-bg); color: var(--del-bd); border-color: var(--del-bd); }
732
+ .tone-warn { background: #2a2417; color: #d29922; border-color: #d29922; }
733
+ .tone-info { background: #1f2730; color: var(--accent); }
734
+ .tone-ok { background: var(--add-bg); color: var(--add-bd); border-color: var(--add-bd); }
735
+ .risk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
736
+ .risk-table th { text-align: left; color: var(--muted); font-weight: 600; padding: 4px 8px; border-bottom: 1px solid var(--border); }
737
+ .risk-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
738
+ .risk-table td p, .risks .nudge p { margin: 0; }
739
+ .nudge { color: #d29922; font-size: 13px; }
740
+ .reach { margin-top: 16px; }
741
+ .reach .mermaid { margin-top: 8px; }
742
+ .reach-note { color: #d29922; font-size: 12px; margin-top: 8px; }
743
+ @media (max-width: 900px) { .blast-grid { grid-template-columns: 1fr; } }
744
+
745
+ /* ── Visual summary ── */
746
+ .visuals { padding: 24px 32px; border-bottom: 1px solid var(--border); }
747
+ .visuals > h2 { font-size: 16px; color: var(--muted); margin: 0 0 14px; display: flex; align-items: center; gap: 8px; }
748
+ .visuals > h2 .src {
749
+ font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
750
+ color: var(--muted); border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
751
+ }
752
+ /* Compact overview: tile every figure as a small thumbnail; click any to
753
+ open it full-size in the lightbox (see .zoomable / #lightbox below). */
754
+ .viz-grid {
755
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
756
+ gap: 14px; align-items: start;
757
+ }
758
+ .viz.viz-span { grid-column: auto; }
759
+ /* Cap charts at their native viewBox width so they never upscale (which would
760
+ blow up the in-SVG font sizes past the page baseline); they still scale down. */
761
+ .viz svg { width: 100%; height: auto; display: block; }
762
+ .viz-diffmass, .viz-treemap, .viz-complexity { max-width: 720px; }
763
+ .viz-ripple { max-width: 720px; margin: 0 auto; }
764
+ /* Thumbnail clamp: scale the figure down to a uniform height for the overview.
765
+ preserveAspectRatio keeps it whole (no clipping) — full detail lives in the
766
+ lightbox. The lightbox stage lifts this clamp (rules further down). */
767
+ .zoomable svg { max-height: 168px; }
768
+ .zoomable .mermaid { max-height: 200px; overflow: hidden; }
769
+ .zoomable {
770
+ cursor: zoom-in; position: relative; transition: border-color .15s;
771
+ }
772
+ .zoomable:hover { border-color: var(--accent); }
773
+ .zoomable::after {
774
+ content: "⤢ expand"; position: absolute; top: 8px; right: 10px;
775
+ font-size: 10px; font-weight: 600; letter-spacing: .04em; color: var(--accent);
776
+ background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
777
+ padding: 1px 6px; opacity: 0; transition: opacity .15s; pointer-events: none;
778
+ }
779
+ .zoomable:hover::after { opacity: 1; }
780
+ .viz-cap { color: var(--muted); font-size: 11px; margin: 8px 0 0; line-height: 1.5; }
781
+ .viz-label { font-family: ui-monospace, monospace; font-size: 11px; }
782
+ .viz-num { fill: var(--muted); font-family: ui-monospace, monospace; font-size: 10px; }
783
+ .viz-axis { stroke: var(--border); stroke-width: 1; }
784
+ .viz-cell-label { fill: #0b1020; font-family: ui-monospace, monospace; font-size: 10px; font-weight: 600; }
785
+ .viz-rings { display: flex; gap: 8px; justify-content: space-around; }
786
+ .viz-ring-svg { max-width: 150px; }
787
+ .viz-ring-pct { fill: var(--text); font-size: 22px; font-weight: 700; font-family: -apple-system, sans-serif; }
788
+ .viz-ring-label { fill: var(--muted); font-size: 11px; font-family: -apple-system, sans-serif; }
789
+ .viz-quadrant { max-width: 360px; margin: 0 auto; }
790
+ .viz-danger { fill: rgba(248, 81, 73, 0.13); }
791
+ .viz-danger-label { fill: var(--del-bd); }
792
+ .viz-axis-label { fill: var(--muted); font-size: 11px; font-family: -apple-system, sans-serif; }
793
+ .viz-dot { fill: var(--accent); stroke: #fff; stroke-width: 2; }
794
+ .ripple-ring { fill: none; stroke: var(--border); stroke-dasharray: 3 5; }
795
+ .ripple-edge { stroke: var(--muted); stroke-width: 1; opacity: 0.4; }
796
+ .ripple-label { fill: var(--muted); font-family: ui-monospace, monospace; font-size: 10px; }
797
+ @media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
798
+ /* ── Tests (claimed) ── */
799
+ .tests { padding: 24px 32px; border-bottom: 1px solid var(--border); }
800
+ .tests > h2 { font-size: 16px; color: var(--muted); margin: 0 0 14px; display: flex; align-items: center; gap: 8px; }
801
+ .tests > h2 .src {
802
+ font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
803
+ color: var(--muted); border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
804
+ }
805
+ .tests > h2 .test-count { font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; }
806
+ .test-group { margin-bottom: 16px; }
807
+ .test-group:last-child { margin-bottom: 0; }
808
+ .test-kind {
809
+ display: flex; align-items: center; gap: 10px; margin: 0 0 6px;
810
+ font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
811
+ color: var(--k, var(--muted));
812
+ }
813
+ .test-kind::after { content: ""; flex: 1; height: 1px; background: var(--border); }
814
+ .test-list { list-style: none; margin: 0; padding: 0; }
815
+ .test-case {
816
+ position: relative; padding: 5px 0 5px 20px; border-bottom: 1px solid var(--border);
817
+ max-width: 90ch;
818
+ }
819
+ .test-group:last-child .test-case:last-child { border-bottom: 0; }
820
+ .test-case::before {
821
+ content: "▸"; position: absolute; left: 2px; color: var(--k, var(--muted));
822
+ }
823
+ .test-case p { display: inline; margin: 0; }
824
+ .test-name {
825
+ font-size: 11px; color: var(--muted);
826
+ background: rgba(110,118,129,0.12); margin-left: 6px;
827
+ }
828
+ .diagrams { border-bottom: 1px solid var(--border); }
829
+ .diagram-grid {
830
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
831
+ gap: 16px; align-items: start;
832
+ }
833
+ .diagram { margin: 0; }
834
+ .diagram h2, .diagrams > h2 { font-size: 16px; color: var(--muted); margin: 0 0 10px; }
835
+ .diagram.zoomable { border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
836
+ .diagram.zoomable h2 { font-size: 13px; }
837
+ .mermaid {
838
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
839
+ padding: 16px; overflow: auto;
840
+ }
841
+ .file {
842
+ border: 1px solid var(--border); border-radius: 8px; margin-bottom: 24px;
843
+ overflow: hidden; background: var(--panel);
844
+ }
845
+ .file-head {
846
+ display: flex; align-items: center; gap: 10px; padding: 10px 14px;
847
+ border-bottom: 1px solid var(--border); background: #11161d;
848
+ }
849
+ .path { font-family: ui-monospace, monospace; font-size: 13px; }
850
+ .status {
851
+ font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
852
+ padding: 2px 8px; border-radius: 4px; font-weight: 600;
853
+ }
854
+ .status-added { background: var(--add-bg); color: var(--add-bd); }
855
+ .status-deleted { background: var(--del-bg); color: var(--del-bd); }
856
+ .status-modified { background: #1f2730; color: var(--accent); }
857
+ .status-renamed { background: #2a2417; color: #d29922; }
858
+ .file-intent {
859
+ padding: 12px 14px; color: var(--text); border-bottom: 1px solid var(--border);
860
+ }
861
+ .file-intent p { margin: 0 0 8px; }
862
+ .hunk-row {
863
+ display: grid; grid-template-columns: 1fr 320px; gap: 0;
864
+ border-top: 1px solid var(--border);
865
+ }
866
+ .hunk-diff { overflow: auto; }
867
+ .hunk-header {
868
+ font-family: ui-monospace, monospace; font-size: 12px; color: var(--accent);
869
+ padding: 6px 12px; background: #11161d;
870
+ }
871
+ table.diff { width: 100%; border-collapse: collapse; font-family: ui-monospace, monospace; font-size: 12.5px; }
872
+ .ln td { padding: 0 8px; white-space: pre; vertical-align: top; }
873
+ .num { color: var(--muted); text-align: right; width: 1%; user-select: none; }
874
+ .sign { width: 1%; user-select: none; color: var(--muted); }
875
+ .code { width: 100%; }
876
+ .ln-add { background: var(--add-bg); }
877
+ .ln-add .code { color: #aff5b4; }
878
+ .ln-del { background: var(--del-bg); }
879
+ .ln-del .code { color: #ffb4b4; }
880
+ .hunk-notes {
881
+ border-left: 1px solid var(--border); padding: 10px 14px; background: var(--note);
882
+ }
883
+ .note { margin-bottom: 10px; }
884
+ .note p { margin: 0 0 6px; }
885
+ .ww .what, .ww .why { margin-bottom: 6px; }
886
+ .ww .why { margin-bottom: 0; }
887
+ .ww p { margin: 0; display: inline; }
888
+ .lbl {
889
+ display: inline-block; font-size: 10px; font-weight: 700; text-transform: uppercase;
890
+ letter-spacing: .05em; color: var(--muted); margin-right: 6px;
891
+ border: 1px solid var(--border); border-radius: 3px; padding: 0 4px;
892
+ }
893
+ .missing {
894
+ color: var(--del-bd); font-weight: 600; font-size: 13px;
895
+ background: var(--del-bg); border: 1px solid var(--del-bd); border-radius: 6px;
896
+ padding: 8px 10px;
897
+ }
898
+ .file-intent.missing { margin: 0; border-radius: 0; border-left: 0; border-right: 0; }
899
+ .anchor {
900
+ display: inline-block; font-family: ui-monospace, monospace; font-size: 11px;
901
+ color: var(--muted); margin-bottom: 4px;
902
+ }
903
+ .unmatched, .orphans {
904
+ padding: 12px 14px; border-top: 1px solid var(--border); color: var(--muted);
905
+ }
906
+ .unmatched h4 { margin: 0 0 6px; color: #d29922; }
907
+ code {
908
+ font-family: ui-monospace, monospace;
909
+ background: rgba(110,118,129,0.2); padding: 1px 5px; border-radius: 4px;
910
+ }
911
+ .empty { color: var(--muted); }
912
+ a { color: var(--accent); }
913
+ @media (max-width: 900px) {
914
+ .hunk-row { grid-template-columns: 1fr; }
915
+ .hunk-notes { border-left: 0; border-top: 1px dashed var(--border); }
916
+ }
917
+
918
+ /* ── Lightbox: click a thumbnail to view a figure full-size ── */
919
+ #lightbox {
920
+ position: fixed; inset: 0; z-index: 1000; display: none;
921
+ align-items: center; justify-content: center; padding: 40px;
922
+ background: rgba(1, 4, 9, 0.82);
923
+ }
924
+ #lightbox.open { display: flex; }
925
+ .lightbox-stage {
926
+ background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
927
+ padding: 18px 22px; max-width: 1100px; width: 100%; max-height: 90vh; overflow: auto;
928
+ }
929
+ /* Lift the thumbnail clamp inside the stage so the figure renders at full size. */
930
+ .lightbox-stage .card { background: transparent; border: 0; padding: 0; cursor: auto; }
931
+ .lightbox-stage .zoomable::after { content: none; }
932
+ .lightbox-stage svg { max-height: none; }
933
+ .lightbox-stage .mermaid { max-height: none; overflow: auto; }
934
+ .lightbox-stage .diagram.zoomable { border: 0; padding: 0; }
935
+ .lightbox-stage .diagram.zoomable h2 { font-size: 16px; }
936
+ .lightbox-close {
937
+ position: fixed; top: 14px; right: 20px; z-index: 1001;
938
+ background: var(--panel); color: var(--text); border: 1px solid var(--border);
939
+ border-radius: 8px; font-size: 20px; line-height: 1; cursor: pointer;
940
+ padding: 6px 12px;
941
+ }
942
+ .lightbox-close:hover { border-color: var(--accent); color: var(--accent); }
943
+ `;
944
+ const MERMAID_SCRIPT = `<script type="module">
945
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
946
+ mermaid.initialize({ startOnLoad: true, theme: "dark", securityLevel: "strict" });
947
+ </script>`;
948
+ /** Empty overlay the lightbox script clones the clicked figure into. */
949
+ const LIGHTBOX = `<div id="lightbox" role="dialog" aria-modal="true" aria-label="Figure detail">
950
+ <button class="lightbox-close" type="button" aria-label="Close">✕</button>
951
+ <div class="lightbox-stage"></div>
952
+ </div>`;
953
+ /** Static, dependency-free click-to-zoom. Clones the figure live on click, so a
954
+ * mermaid <pre> that has since rendered to <svg> is captured as drawn. */
955
+ const LIGHTBOX_SCRIPT = `<script>
956
+ (function () {
957
+ var box = document.getElementById("lightbox");
958
+ var stage = box.querySelector(".lightbox-stage");
959
+ function close() {
960
+ box.classList.remove("open");
961
+ stage.replaceChildren();
962
+ document.body.style.overflow = "";
963
+ }
964
+ function open(fig) {
965
+ stage.replaceChildren(fig.cloneNode(true));
966
+ box.classList.add("open");
967
+ document.body.style.overflow = "hidden";
968
+ }
969
+ document.querySelectorAll(".zoomable").forEach(function (el) {
970
+ el.addEventListener("click", function (e) {
971
+ if (e.target.closest("a")) return;
972
+ open(el);
973
+ });
974
+ });
975
+ box.addEventListener("click", function (e) {
976
+ if (e.target === box || e.target.classList.contains("lightbox-close")) close();
977
+ });
978
+ document.addEventListener("keydown", function (e) {
979
+ if (e.key === "Escape" && box.classList.contains("open")) close();
980
+ });
981
+ })();
982
+ </script>`;