@christianmorup/review-intent 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { isTestPath, isCodePath, isNoisePath } from "./scorecard.js";
2
+ import { reviewOrder } from "./review-order.js";
3
+ import { THEMES, themeCss } from "./themes.js";
2
4
  /** Pure: produce a self-contained HTML document from the review model. */
3
5
  export function renderHtml(model) {
6
+ const ranked = reviewOrder(model);
4
7
  return `<!DOCTYPE html>
5
8
  <html lang="en">
6
9
  <head>
@@ -10,45 +13,277 @@ export function renderHtml(model) {
10
13
  <style>${CSS}</style>
11
14
  </head>
12
15
  <body>
13
- <header class="page-head">
14
- <div class="badge">diff: ${esc(model.base)}…HEAD</div>
16
+ ${themeScript()}
17
+ ${renderTopbar(model)}
18
+ <header class="page-head" id="top">
19
+ <div class="eyebrow">Intent review <span class="eyebrow-diff">${esc(model.base)}…HEAD</span></div>
15
20
  <h1>${esc(model.title)}</h1>
16
21
  <div class="tldr">${md(model.tldr)}</div>
17
- <details class="overall-wrap" open>
22
+ <details class="overall-wrap">
18
23
  <summary>Full summary</summary>
19
24
  <div class="overall">${md(model.overall)}</div>
20
25
  </details>
21
26
  </header>
22
27
 
23
- ${renderBlastRadius(model)}
28
+ <div class="layout">
29
+ <aside class="rail" id="rail" aria-label="Pinned blocks"></aside>
30
+ <div class="content">
31
+ ${movable("vitals", renderVitals(model))}
24
32
 
25
- ${renderVisuals(model)}
33
+ ${movable("review-first", renderReviewFirst(ranked))}
26
34
 
27
- ${renderTests(model.tests)}
35
+ ${movable("file-index", renderFileIndex(ranked))}
28
36
 
29
- ${renderDiagrams(model)}
37
+ ${movable("blast", renderBlastRadius(model))}
38
+
39
+ ${movable("visuals", renderVisuals(model))}
40
+
41
+ ${movable("tests", renderTests(model.tests))}
42
+
43
+ ${movable("diagrams", renderDiagrams(model))}
30
44
 
31
45
  <main>
32
- ${model.files.length === 0 ? `<p class="empty">No file changes in this diff.</p>` : model.files.map(renderFile).join("\n")}
46
+ ${ranked.length === 0
47
+ ? `<p class="empty">No file changes in this diff.</p>`
48
+ : ranked.map((r) => renderFile(model.files[r.index], r)).join("\n")}
33
49
  </main>
34
50
 
35
51
  ${renderFilesWithoutChanges(model)}
52
+ ${renderFeedbackPanel(model)}
53
+ </div>
54
+ </div>
36
55
 
37
56
  ${LIGHTBOX}
57
+ ${TOUR}
38
58
 
39
59
  ${MERMAID_SCRIPT}
40
60
  ${LIGHTBOX_SCRIPT}
61
+ ${viewedScript(model)}
62
+ ${pinScript(model)}
63
+ ${commentScript(model)}
64
+ ${tourScript(model, ranked)}
41
65
  </body>
42
66
  </html>`;
43
67
  }
68
+ /** Wrap a movable top-level block with a pin control so the reader can move it
69
+ * into the sticky rail on wide screens. Empty sections (e.g. an unwritten
70
+ * Tests block) stay empty — no stray wrapper, no orphan pin button. */
71
+ function movable(key, html) {
72
+ if (!html)
73
+ return "";
74
+ return `<div class="movable" data-movable="${key}">${pinButton()}${html}</div>`;
75
+ }
76
+ function pinButton() {
77
+ return `<button class="pin-btn" type="button" aria-pressed="false" aria-label="Pin to sidebar" title="Pin to sidebar">📌</button>`;
78
+ }
79
+ /** Static, dependency-free enhancement: move pinned blocks into the sticky rail
80
+ * on wide screens and remember the choice (per-change, like the viewed state).
81
+ * Default is the file index alone — the "bare minimum" spine. Below the wide
82
+ * breakpoint every block returns to its original place, so narrow layouts are
83
+ * untouched. Each block keeps a comment anchor marking its home slot so it can
84
+ * always be restored in the original order. */
85
+ function pinScript(model) {
86
+ const KEY = `review-intent:pinned:${model.title}@${model.base}`;
87
+ return `<script>
88
+ (function () {
89
+ var rail = document.getElementById("rail");
90
+ if (!rail) return;
91
+ var KEY = ${JSON.stringify(KEY).replace(/<\//g, "<\\/")};
92
+ // Declaration order — the rail stacks pinned blocks in this order regardless
93
+ // of the order the reader pinned them, so the rail stays predictable.
94
+ var ORDER = ["vitals", "review-first", "file-index", "blast", "visuals", "tests", "diagrams"];
95
+ var wide = window.matchMedia("(min-width: 1920px)");
96
+ var nodes = {}, anchors = {};
97
+ document.querySelectorAll(".movable").forEach(function (el) {
98
+ var k = el.getAttribute("data-movable");
99
+ nodes[k] = el;
100
+ var a = document.createComment("m:" + k);
101
+ el.parentNode.insertBefore(a, el);
102
+ anchors[k] = a;
103
+ });
104
+ var pinned;
105
+ try { pinned = JSON.parse(localStorage.getItem(KEY)); } catch (e) {}
106
+ if (!Array.isArray(pinned)) pinned = ["file-index"];
107
+ pinned = pinned.filter(function (k) { return nodes[k]; });
108
+ function apply() {
109
+ var isWide = wide.matches;
110
+ ORDER.forEach(function (k) {
111
+ var el = nodes[k];
112
+ if (!el) return;
113
+ var on = pinned.indexOf(k) !== -1;
114
+ var btn = el.querySelector(".pin-btn");
115
+ if (btn) {
116
+ btn.setAttribute("aria-pressed", on ? "true" : "false");
117
+ btn.title = on ? "Unpin from sidebar" : "Pin to sidebar";
118
+ btn.setAttribute("aria-label", btn.title);
119
+ }
120
+ if (isWide && on) rail.appendChild(el);
121
+ else anchors[k].parentNode.insertBefore(el, anchors[k]);
122
+ });
123
+ document.body.classList.toggle("has-pins", isWide && pinned.length > 0);
124
+ }
125
+ document.querySelectorAll(".pin-btn").forEach(function (btn) {
126
+ btn.addEventListener("click", function (e) {
127
+ e.preventDefault();
128
+ e.stopPropagation();
129
+ var el = btn.closest(".movable");
130
+ if (!el) return;
131
+ var k = el.getAttribute("data-movable");
132
+ var i = pinned.indexOf(k);
133
+ if (i === -1) pinned.push(k); else pinned.splice(i, 1);
134
+ try { localStorage.setItem(KEY, JSON.stringify(pinned)); } catch (e) {}
135
+ apply();
136
+ });
137
+ });
138
+ if (wide.addEventListener) wide.addEventListener("change", apply);
139
+ else if (wide.addListener) wide.addListener(apply);
140
+ apply();
141
+ })();
142
+ </script>`;
143
+ }
144
+ /** Restore the saved theme before paint (no flash) and wire the cogwheel menu.
145
+ * Static string — pure. Same localStorage + <script> pattern as pinScript. */
146
+ function themeScript() {
147
+ return `<script>
148
+ (function () {
149
+ var KEY = "review-intent:theme";
150
+ var root = document.documentElement;
151
+ function applyId(id) {
152
+ if (!id || id === "paper") delete root.dataset.theme;
153
+ else root.dataset.theme = id;
154
+ }
155
+ var saved;
156
+ try { saved = localStorage.getItem(KEY); } catch (e) {}
157
+ applyId(saved);
158
+ document.addEventListener("DOMContentLoaded", function () {
159
+ var gear = document.querySelector(".tb-gear");
160
+ var menu = document.querySelector(".theme-menu");
161
+ if (!gear || !menu) return;
162
+ var current = saved || "paper";
163
+ function mark() {
164
+ menu.querySelectorAll(".theme-opt").forEach(function (o) {
165
+ o.setAttribute("aria-checked", o.getAttribute("data-theme-id") === current ? "true" : "false");
166
+ });
167
+ }
168
+ function open(v) {
169
+ menu.hidden = !v;
170
+ gear.setAttribute("aria-expanded", v ? "true" : "false");
171
+ }
172
+ mark();
173
+ gear.addEventListener("click", function (e) {
174
+ e.stopPropagation();
175
+ open(menu.hidden);
176
+ });
177
+ menu.querySelectorAll(".theme-opt").forEach(function (o) {
178
+ o.addEventListener("click", function () {
179
+ current = o.getAttribute("data-theme-id");
180
+ applyId(current);
181
+ try { localStorage.setItem(KEY, current); } catch (e) {}
182
+ mark();
183
+ open(false);
184
+ });
185
+ });
186
+ document.addEventListener("click", function (e) {
187
+ if (!menu.hidden && !menu.contains(e.target) && e.target !== gear) open(false);
188
+ });
189
+ document.addEventListener("keydown", function (e) {
190
+ if (e.key === "Escape") open(false);
191
+ });
192
+ });
193
+ })();
194
+ </script>`;
195
+ }
196
+ /** Slim sticky bar: persistent wayfinding across the long scroll. The progress
197
+ * counter is updated client-side as files are marked "seen". */
198
+ function renderTopbar(model) {
199
+ const n = model.files.length;
200
+ const groups = [];
201
+ const seen = new Set();
202
+ for (const t of THEMES)
203
+ if (!seen.has(t.group)) {
204
+ seen.add(t.group);
205
+ groups.push(t.group);
206
+ }
207
+ const menu = groups
208
+ .map((g) => {
209
+ const opts = THEMES.filter((t) => t.group === g)
210
+ .map((t) => `<button type="button" class="theme-opt" role="menuitemradio" aria-checked="false" data-theme-id="${esc(t.id)}">${esc(t.label)}</button>`)
211
+ .join("");
212
+ return `<div class="theme-grp"><div class="theme-grp-h">${esc(g)}</div>${opts}</div>`;
213
+ })
214
+ .join("");
215
+ return `<div class="topbar">
216
+ <span class="tb-title">${esc(model.title)}</span>
217
+ <span class="tb-progress" data-total="${n}">0 / ${n} reviewed</span>
218
+ ${n > 0 ? `<button class="tb-tour" type="button">▶ Guided review</button>` : ""}
219
+ <a class="tb-top" href="#top">↑ Top</a>
220
+ <div class="tb-theme">
221
+ <button class="tb-gear" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Change theme" title="Change theme">⚙</button>
222
+ <div class="theme-menu" role="menu" aria-label="Theme" hidden>
223
+ <button type="button" class="theme-opt" role="menuitemradio" aria-checked="true" data-theme-id="paper">Paper (default)</button>
224
+ ${menu}
225
+ </div>
226
+ </div>
227
+ </div>`;
228
+ }
229
+ /** The overview strip: the handful of measured numbers that define the change's
230
+ * shape, scannable at a glance. Pure — derived from data already on the model. */
231
+ function renderVitals(model) {
232
+ const s = model.scorecard;
233
+ const ic = model.intentCoverage;
234
+ const net = s.added - s.removed;
235
+ const hunkCov = ic.hunksTotal
236
+ ? Math.round((ic.hunksCovered / ic.hunksTotal) * 100)
237
+ : null;
238
+ const cx = model.complexity;
239
+ const vitals = [
240
+ { value: `${s.filesChanged}`, label: s.filesChanged === 1 ? "file" : "files" },
241
+ {
242
+ value: `+${s.added} −${s.removed}`,
243
+ label: `net ${net >= 0 ? "+" : "−"}${Math.abs(net)} lines`,
244
+ },
245
+ { value: `${s.hunks}`, label: s.hunks === 1 ? "hunk" : "hunks" },
246
+ {
247
+ value: hunkCov === null ? "—" : `${hunkCov}%`,
248
+ label: "intent covered",
249
+ tone: hunkCov === null ? undefined : hunkCov >= 80 ? "add" : hunkCov >= 50 ? "warn" : "del",
250
+ },
251
+ {
252
+ value: `${model.risks.length}`,
253
+ label: model.risks.length === 1 ? "risk declared" : "risks declared",
254
+ tone: model.risks.length === 0 ? "warn" : undefined,
255
+ },
256
+ {
257
+ value: cx.available ? `${cx.maxCcn}` : "—",
258
+ label: cx.available ? "max complexity" : "complexity n/a",
259
+ tone: cx.available && cx.hotspots.length ? "del" : undefined,
260
+ },
261
+ {
262
+ value: `${model.reach.edges.length}`,
263
+ label: model.reach.edges.length === 1 ? "dependent" : "dependents",
264
+ },
265
+ ];
266
+ return `<section class="vitals" aria-label="Change vitals">
267
+ ${vitals
268
+ .map((v) => `<div class="vital${v.tone ? ` vital-${v.tone}` : ""}">
269
+ <span class="vital-num">${esc(v.value)}</span>
270
+ <span class="vital-lbl">${esc(v.label)}</span>
271
+ </div>`)
272
+ .join("\n ")}
273
+ </section>`;
274
+ }
44
275
  function renderBlastRadius(model) {
45
276
  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)}
277
+ <details class="band" open>
278
+ <summary class="band-head"><h2>Blast radius</h2></summary>
279
+ <div class="band-body">
280
+ <div class="blast-grid">
281
+ ${renderScorecard(model)}
282
+ ${renderRisks(model.risks)}
283
+ </div>
284
+ ${renderReach(model.reach)}
285
+ </div>
286
+ </details>
52
287
  </section>`;
53
288
  }
54
289
  function renderScorecard(model) {
@@ -207,23 +442,34 @@ function reachRipple(reach) {
207
442
  }
208
443
  function rippleNode(p, path, isChanged) {
209
444
  const r = isChanged ? 8 : 5;
210
- const fill = isChanged ? "#1f6feb" : "#21262d";
211
- const stroke = isChanged ? "#58a6ff" : "#8b949e";
445
+ const fill = isChanged ? C_ACCENT : "var(--viz-node)";
446
+ const stroke = isChanged ? "var(--viz-accent-stroke)" : "var(--viz-node-stroke)";
212
447
  const ly = isChanged ? p.y - 13 : p.y + 16;
448
+ const tip = isChanged ? `${path} — changed file` : `${path} — imports a changed file`;
213
449
  return `<g class="ripple-node">
450
+ <title>${esc(tip)}</title>
214
451
  <circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5" />
215
452
  <text x="${p.x.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" class="ripple-label">${esc(shortPath(path, 22))}</text>
216
453
  </g>`;
217
454
  }
455
+ // Light-canvas palette. Semantic fills (add/del/warn) and a categorical set for
456
+ // the treemap, all tuned to read on the warm-paper background.
457
+ const C_ADD = "var(--viz-add)";
458
+ const C_ADD_INK = "var(--viz-add-ink)";
459
+ const C_DEL = "var(--viz-del)";
460
+ const C_DEL_INK = "var(--viz-del-ink)";
461
+ const C_WARN = "var(--viz-warn)";
462
+ const C_ACCENT = "var(--viz-accent)";
463
+ const C_LINE = "var(--viz-line)";
218
464
  const CAT_COLOR = {
219
- test: "#3fb950",
220
- code: "#58a6ff",
221
- noise: "#6e7681",
222
- other: "#8b949e",
465
+ test: C_ADD_INK,
466
+ code: C_ACCENT,
467
+ noise: "var(--viz-noise)",
468
+ other: "var(--viz-other)",
223
469
  };
224
470
  const DIR_PALETTE = [
225
- "#1f6feb", "#3fb950", "#a371f7", "#d29922",
226
- "#db61a2", "#2ea043", "#f0883e", "#58a6ff",
471
+ "var(--viz-s1)", "var(--viz-s2)", "var(--viz-s3)", "var(--viz-s4)",
472
+ "var(--viz-s5)", "var(--viz-s6)", "var(--viz-s7)", "var(--viz-s8)",
227
473
  ];
228
474
  function fileStats(model) {
229
475
  return model.files.map((f) => {
@@ -254,15 +500,19 @@ function renderVisuals(model) {
254
500
  renderTreemap(stats),
255
501
  renderComplexityHotspots(model.complexity),
256
502
  renderCoverageRings(model.intentCoverage),
257
- renderHonestyQuadrant(model),
503
+ renderChangeScatter(model),
258
504
  ].filter(Boolean);
259
505
  if (blocks.length === 0)
260
506
  return "";
261
507
  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>
508
+ <details class="band">
509
+ <summary class="band-head"><h2>Visual summary <span class="src">measured</span></h2></summary>
510
+ <div class="band-body">
511
+ <div class="viz-grid">
512
+ ${blocks.join("\n ")}
513
+ </div>
514
+ </div>
515
+ </details>
266
516
  </section>`;
267
517
  }
268
518
  /** #1 Diff mass — diverging add/remove bars per file, sorted by churn. */
@@ -290,26 +540,25 @@ function renderDiffMass(stats) {
290
540
  const remW = f.removed * scale;
291
541
  const addW = f.added * scale;
292
542
  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}
543
+ ? `<circle cx="9" cy="${mid}" r="3" fill="${C_ADD}" />`
544
+ : `<circle cx="9" cy="${mid}" r="3" fill="none" stroke="${C_DEL}" stroke-width="1.5" />`;
545
+ const tip = `${f.path} — +${f.added} −${f.removed} (${f.category})${f.hasIntent ? "" : " · no intent written"}`;
546
+ return `<g><title>${esc(tip)}</title>${mark}
296
547
  <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>`;
548
+ <rect x="${(xc - remW).toFixed(1)}" y="${y + 4}" width="${remW.toFixed(1)}" height="${rowH - 8}" fill="${C_DEL}" fill-opacity="0.9" />
549
+ <rect x="${xc.toFixed(1)}" y="${y + 4}" width="${addW.toFixed(1)}" height="${rowH - 8}" fill="${C_ADD}" fill-opacity="0.9" />
550
+ <text x="${plotR + 6}" y="${mid + 3}" class="viz-num">+${f.added} −${f.removed}</text></g>`;
300
551
  })
301
552
  .join("\n ");
302
553
  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
- : "";
554
+ const more = hidden > 0 ? ` ${hidden} more file${plural(hidden)} not charted (showing the ${cap} largest).` : "";
306
555
  return `<div class="card viz viz-span zoomable">
307
556
  <h3>Diff mass <span class="src">± lines per file</span></h3>
308
557
  <svg class="viz-diffmass" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img">
309
558
  ${axis}
310
559
  ${body}
311
560
  </svg>
312
- ${more}
561
+ <p class="viz-cap">One row per changed file, longest diff first: bar length = lines added (green, right) vs removed (red, left). The dot is filled ● when intent was written for the file, hollow ○ when it wasn't. Hover a row for its path and counts.${more}</p>
313
562
  </div>`;
314
563
  }
315
564
  /** #2 Change treemap — squarified, area ∝ churn, colour = directory. */
@@ -325,12 +574,13 @@ function renderTreemap(stats) {
325
574
  const rects = squarify(items, { x: 0, y: 0, w: W, h: H });
326
575
  const cells = rects
327
576
  .map((r) => {
328
- const stroke = r.hasIntent ? "#30363d" : "#f85149";
577
+ const stroke = r.hasIntent ? "var(--viz-cell-stroke)" : C_DEL_INK;
329
578
  const sw = r.hasIntent ? 1 : 2;
330
579
  const label = r.w > 54 && r.h > 18
331
580
  ? `<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
581
  : "";
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>`;
582
+ const tip = `${r.path} ${r.churn} line${plural(r.churn)} changed${r.hasIntent ? "" : " · no intent written"}`;
583
+ return `<g><title>${esc(tip)}</title><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
584
  })
335
585
  .join("\n ");
336
586
  return `<div class="card viz viz-span zoomable">
@@ -338,7 +588,7 @@ function renderTreemap(stats) {
338
588
  <svg class="viz-treemap" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img">
339
589
  ${cells}
340
590
  </svg>
341
- <p class="viz-cap">Rectangle area ∝ ± lines · colour = top-level directory · red outline = no intent written.</p>
591
+ <p class="viz-cap">Every changed file as a rectangle: area ∝ lines changed, so the biggest tiles are where most of the diff lives. Colour groups files by top-level directory; a red outline marks a file with no intent written. Hover a tile for its path and line count.</p>
342
592
  </div>`;
343
593
  }
344
594
  /** Squarified treemap layout (Bruls et al.) — deterministic, no I/O. */
@@ -406,20 +656,23 @@ function renderCoverageRings(ic) {
406
656
  return `<div class="card viz zoomable">
407
657
  <h3>Intent coverage <span class="src">measured</span></h3>
408
658
  <div class="viz-rings">
409
- ${coverageRing("files", ic.filesCovered, ic.filesTotal)}
410
- ${coverageRing("hunks", ic.hunksCovered, ic.hunksTotal)}
659
+ ${coverageRing("files", "file", ic.filesCovered, ic.filesTotal)}
660
+ ${coverageRing("hunks", "hunk", ic.hunksCovered, ic.hunksTotal)}
411
661
  </div>
662
+ <p class="viz-cap">Share of changed files (each needs a what + why) and diff hunks (each needs an anchored note) that carry agent-written intent. The completeness gate normally forces both to 100% — anything lower means the page was rendered with <code>--allow-gaps</code> and some of the change is unexplained.</p>
412
663
  </div>`;
413
664
  }
414
- function coverageRing(label, num, den) {
665
+ function coverageRing(label, unit, num, den) {
415
666
  const f = den ? num / den : 0;
416
667
  const pct = Math.round(f * 100);
417
668
  const r = 42;
418
669
  const c = 2 * Math.PI * r;
419
670
  const dash = (f * c).toFixed(1);
420
- const color = f >= 0.8 ? "#3fb950" : f >= 0.5 ? "#d29922" : "#f85149";
671
+ const color = f >= 0.8 ? C_ADD : f >= 0.5 ? C_WARN : C_DEL;
672
+ const tip = `${num} of ${den} ${unit}${plural(den)} carry intent (${pct}%)`;
421
673
  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" />
674
+ <title>${esc(tip)}</title>
675
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="${C_LINE}" stroke-width="12" />
423
676
  <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
677
  <text x="60" y="67" text-anchor="middle" class="viz-ring-pct">${pct}%</text>
425
678
  <text x="60" y="135" text-anchor="middle" class="viz-ring-label">${esc(label)} ${num}/${den}</text>
@@ -445,11 +698,12 @@ function renderComplexityHotspots(cx) {
445
698
  const y = pad + i * rowH;
446
699
  const mid = y + rowH / 2;
447
700
  const w = (r.ccn / maxC) * barMax;
448
- const color = r.ccn >= cx.threshold * 2 ? "#f85149" : "#d29922";
701
+ const color = r.ccn >= cx.threshold * 2 ? C_DEL : C_WARN;
449
702
  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>
703
+ const tip = `${r.name} CCN ${r.ccn} (threshold ${cx.threshold}) at ${r.file}:${r.line}`;
704
+ return `<g><title>${esc(tip)}</title><text x="6" y="${mid + 3}" class="viz-label">${esc(shortPath(label, 44))}</text>
451
705
  <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>`;
706
+ <text x="${(barL + w + 6).toFixed(1)}" y="${mid + 3}" class="viz-num">${r.ccn}</text></g>`;
453
707
  })
454
708
  .join("\n ");
455
709
  return `<div class="card viz viz-span zoomable">
@@ -457,46 +711,161 @@ function renderComplexityHotspots(cx) {
457
711
  <svg class="viz-complexity" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img">
458
712
  ${body}
459
713
  </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>
714
+ <p class="viz-cap">Changed functions whose measured cyclomatic complexity (CCN — the number of independent paths through the code) is at or above the repo threshold of ${cx.threshold}; bars at ≥ 2× threshold turn red. These are the functions most likely to hide a bug or be hard to test. Hover a bar for its file and line.</p>
461
715
  </div>`;
462
716
  }
463
- /** #5 Honesty quadrantclaimed candor vs measured blast radius. */
464
- function renderHonestyQuadrant(model) {
465
- if (model.files.length === 0)
717
+ /** #5 Change mapone dot per changed file, placed by measured blast radius
718
+ * (downstream reach) against measured size (churn), so the file that most
719
+ * deserves a reviewer's attention is the one in the top-right. Everything here
720
+ * is measured: there is no honest per-file "candor" signal (risks aren't
721
+ * file-scoped and per-hunk intent is forced complete by the gate). */
722
+ function renderChangeScatter(model) {
723
+ const cx = model.complexity;
724
+ const norm = (p) => p.replace(/\\/g, "/");
725
+ // Files carrying a complexity hotspot — measured; empty when lizard didn't run.
726
+ const hotFiles = cx.available ? cx.hotspots.map((h) => norm(h.file)) : [];
727
+ const isHot = (path) => {
728
+ const p = norm(path);
729
+ const base = p.split("/").pop() ?? p;
730
+ return hotFiles.some((h) => h === p || h.endsWith("/" + p) || p.endsWith("/" + h) || (h.split("/").pop() ?? h) === base);
731
+ };
732
+ const fanInOf = (path) => model.reach.edges.reduce((n, e) => (norm(e.to) === norm(path) ? n + 1 : n), 0);
733
+ const pts = model.files
734
+ .map((f) => {
735
+ let churn = 0;
736
+ for (const h of f.hunks)
737
+ for (const l of h.lines)
738
+ if (l.type === "add" || l.type === "del")
739
+ churn++;
740
+ return { path: f.path, churn, fanIn: fanInOf(f.path), hunks: f.hunks.length, hot: isHot(f.path) };
741
+ })
742
+ .filter((p) => p.churn > 0 || p.fanIn > 0);
743
+ if (pts.length === 0)
466
744
  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" />
745
+ const maxChurn = Math.max(...pts.map((p) => p.churn), 1);
746
+ const maxFan = Math.max(...pts.map((p) => p.fanIn), 1);
747
+ const maxHunks = Math.max(...pts.map((p) => p.hunks), 1);
748
+ const W = 720;
749
+ const H = 380;
750
+ const mL = 54;
751
+ const mR = 18;
752
+ const mT = 22;
753
+ const mB = 46;
754
+ const plotW = W - mL - mR;
755
+ const plotH = H - mT - mB;
756
+ // sqrt on the magnitude axes so one huge file doesn't crush the rest into the
757
+ // corner; monotonic, so the visual order still reflects the real order.
758
+ const sq = (v, max) => (max > 0 ? Math.sqrt(v) / Math.sqrt(max) : 0);
759
+ const X = (fan) => mL + sq(fan, maxFan) * plotW;
760
+ const Y = (churn) => mT + (1 - sq(churn, maxChurn)) * plotH;
761
+ const R = (hunks) => 5 + sq(hunks, maxHunks) * 9;
762
+ const midX = mL + plotW / 2;
763
+ const midY = mT + plotH / 2;
764
+ // Files with the same downstream reach land on the same x-column and would
765
+ // overlap (commonly the many files nothing imports, all at the left axis).
766
+ // Dodge each column's dots horizontally deterministic, ordered by churn —
767
+ // so every file stays individually visible and hoverable. The column's centre
768
+ // still reads as its reach value; the spread is clamped inside the plot.
769
+ const minX = mL + 16;
770
+ const maxX = W - mR - 16;
771
+ const columns = new Map();
772
+ for (const p of pts) {
773
+ const key = Math.round(X(p.fanIn));
774
+ const arr = columns.get(key);
775
+ if (arr)
776
+ arr.push(p);
777
+ else
778
+ columns.set(key, [p]);
779
+ }
780
+ const dodgeX = new Map();
781
+ for (const [key, group] of columns) {
782
+ const ordered = [...group].sort((a, b) => a.churn - b.churn || (a.path < b.path ? -1 : 1));
783
+ const n = ordered.length;
784
+ // Step the dodge by the widest dot in the column (its diameter + a 4px gap)
785
+ // so dots never overlap. If a column holds too many same-reach files to fit
786
+ // the plot width, compress the step to fit rather than spilling off-plot.
787
+ const maxR = Math.max(...ordered.map((p) => R(p.hunks)));
788
+ let step = 2 * maxR + 4;
789
+ const availW = maxX - minX;
790
+ if (n > 1 && (n - 1) * step > availW)
791
+ step = availW / (n - 1);
792
+ const run = (n - 1) * step;
793
+ let start = key - run / 2;
794
+ if (start + run > maxX)
795
+ start = maxX - run;
796
+ if (start < minX)
797
+ start = minX;
798
+ ordered.forEach((p, i) => dodgeX.set(p.path, start + i * step));
799
+ }
800
+ const PX = (p) => dodgeX.get(p.path) ?? X(p.fanIn);
801
+ // Label the files nearest the top-right (highest combined reach + churn) so a
802
+ // dense change doesn't become a wall of text; the rest stay as bare dots.
803
+ const weight = (p) => sq(p.fanIn, maxFan) + sq(p.churn, maxChurn);
804
+ const labelled = new Set([...pts].sort((a, b) => weight(b) - weight(a)).slice(0, 6).map((p) => p.path));
805
+ const circles = pts
806
+ .map((p) => {
807
+ const cls = p.hot ? "viz-dot viz-dot-hot" : "viz-dot";
808
+ // Native SVG tooltip — full path + the measured numbers behind the dot.
809
+ const tip = `${p.path} — ${p.churn} line${plural(p.churn)} changed · ${p.hunks} hunk${plural(p.hunks)} · imported by ${p.fanIn} file${plural(p.fanIn)}${p.hot ? " · complexity hotspot" : ""}`;
810
+ return `<circle cx="${PX(p).toFixed(1)}" cy="${Y(p.churn).toFixed(1)}" r="${R(p.hunks).toFixed(1)}" class="${cls}"><title>${esc(tip)}</title></circle>`;
811
+ })
812
+ .join("\n ");
813
+ // Place labels for the notable files: flip to the left of dots near the right
814
+ // edge so text never spills off the viewBox, and nudge each one down past the
815
+ // previous label on its side so a stacked column stays legible. Deterministic
816
+ // — no glyph measurement, just fixed line spacing.
817
+ const GAP = 13;
818
+ const lastY = { l: -Infinity, r: -Infinity };
819
+ const labels = pts
820
+ .filter((p) => labelled.has(p.path))
821
+ .map((p) => ({ p, x: PX(p), y: Y(p.churn), r: R(p.hunks) }))
822
+ .sort((a, b) => a.y - b.y)
823
+ .map(({ p, x, y, r }) => {
824
+ const side = x > mL + plotW * 0.62 ? "l" : "r";
825
+ const ly = Math.max(y + 3, lastY[side] + GAP);
826
+ lastY[side] = ly;
827
+ const lx = side === "r" ? x + r + 4 : x - r - 4;
828
+ const anchor = side === "r" ? "" : ` text-anchor="end"`;
829
+ return `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}"${anchor} class="viz-label">${esc(shortPath(basename(p.path), 22))}</text>`;
830
+ })
831
+ .join("\n ");
832
+ const dots = `${circles}\n ${labels}`;
833
+ // Glyph key. When lizard didn't run, colour carries no meaning, so say so
834
+ // rather than implying every dot is hotspot-free.
835
+ const dotSwatch = (cls) => `<svg class="viz-lg-dot" viewBox="0 0 14 14" width="13" height="13" aria-hidden="true"><circle cx="7" cy="7" r="5" class="${cls}" /></svg>`;
836
+ const colorKey = cx.available
837
+ ? `<span class="viz-lg">${dotSwatch("viz-dot")}no hotspot</span>
838
+ <span class="viz-lg">${dotSwatch("viz-dot viz-dot-hot")}complexity hotspot (CCN ≥ ${cx.threshold})</span>`
839
+ : `<span class="viz-lg">${dotSwatch("viz-dot")}changed file</span>
840
+ <span class="viz-lg viz-lg-muted">complexity not measured (lizard unavailable)</span>`;
841
+ return `<div class="card viz viz-span zoomable">
842
+ <h3>Change map <span class="src">per file · measured</span></h3>
843
+ <svg class="viz-scatter" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img">
844
+ <rect x="${midX}" y="${mT}" width="${plotW / 2}" height="${plotH / 2}" class="viz-danger" />
845
+ <line x1="${mL}" y1="${midY}" x2="${W - mR}" y2="${midY}" class="viz-axis" />
846
+ <line x1="${midX}" y1="${mT}" x2="${midX}" y2="${H - mB}" class="viz-axis" />
847
+ <line x1="${mL}" y1="${mT}" x2="${mL}" y2="${H - mB}" stroke="${C_LINE}" />
848
+ <line x1="${mL}" y1="${H - mB}" x2="${W - mR}" y2="${H - mB}" stroke="${C_LINE}" />
849
+ <text x="${W - mR}" y="${H - mB + 24}" text-anchor="end" class="viz-axis-label">downstream reach →</text>
850
+ <text x="${mL - 8}" y="${mT - 8}" class="viz-axis-label">↑ churn (± lines)</text>
851
+ <text x="${midX + 8}" y="${mT + 16}" class="viz-axis-label viz-danger-label">high churn · high reach — review first</text>
852
+ ${dots}
493
853
  </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>
854
+ <div class="viz-legend">
855
+ ${colorKey}
856
+ <span class="viz-lg"><svg class="viz-lg-dot" viewBox="0 0 36 14" width="34" height="13" aria-hidden="true"><circle cx="5" cy="7" r="3" class="viz-dot" /><circle cx="26" cy="7" r="6" class="viz-dot" /></svg>more hunks → bigger dot</span>
857
+ <span class="viz-lg"><span class="viz-lg-zone"></span>review-first zone</span>
858
+ </div>
859
+ <p class="viz-cap">Each dot is one changed file, placed by how far it reaches — repo files that import it (x) — against how much it changed in lines (y). The further toward the top-right, the more it warrants a close read. Hover a dot for its exact numbers.</p>
495
860
  </div>`;
496
861
  }
497
862
  function basename(p) {
498
863
  return p.split("/").pop() ?? p;
499
864
  }
865
+ /** "" for 1, "s" otherwise — for tooltip/caption pluralization. */
866
+ function plural(n) {
867
+ return n === 1 ? "" : "s";
868
+ }
500
869
  function dirColor(p) {
501
870
  const dir = p.includes("/") ? p.slice(0, p.indexOf("/")) : "·";
502
871
  let h = 0;
@@ -513,12 +882,12 @@ function shortPath(p, max) {
513
882
  // ── Tests: agent-described cases (claimed; pure display, never measured) ──
514
883
  const KIND_ORDER = ["unit", "integration", "e2e", "manual"];
515
884
  const KIND_COLOR = {
516
- unit: "#3fb950",
517
- integration: "#58a6ff",
518
- e2e: "#a371f7",
519
- manual: "#d29922",
885
+ unit: C_ADD_INK,
886
+ integration: C_ACCENT,
887
+ e2e: "var(--kind-e2e)",
888
+ manual: C_WARN,
520
889
  };
521
- const kindColor = (key) => KIND_COLOR[key] ?? "#8b949e";
890
+ const kindColor = (key) => KIND_COLOR[key] ?? "var(--viz-other)";
522
891
  /** Pure: render the agent's human-readable test descriptions, grouped by kind.
523
892
  * Returns "" when none were authored (the section is optional). */
524
893
  function renderTests(tests) {
@@ -567,8 +936,12 @@ function renderTests(tests) {
567
936
  .join("\n ");
568
937
  const n = tests.length;
569
938
  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}
939
+ <details class="band">
940
+ <summary class="band-head"><h2>Tests <span class="src">claimed</span> <span class="muted test-count">${n} case${n === 1 ? "" : "s"} described</span></h2></summary>
941
+ <div class="band-body">
942
+ ${blocks}
943
+ </div>
944
+ </details>
572
945
  </section>`;
573
946
  }
574
947
  function renderDiagrams(model) {
@@ -582,29 +955,125 @@ function renderDiagrams(model) {
582
955
  </section>`
583
956
  : "";
584
957
  return `<section class="diagrams">
585
- <div class="diagram-grid">
958
+ <details class="band">
959
+ <summary class="band-head"><h2>Diagrams</h2></summary>
960
+ <div class="band-body">
961
+ <div class="diagram-grid">
586
962
  ${block("Class diagram", cls)}
587
963
  ${block("Sequence diagram (changed steps highlighted)", sequence)}
588
- </div>
964
+ </div>
965
+ </div>
966
+ </details>
589
967
  </section>`;
590
968
  }
591
- function renderFile(file) {
592
- return `<section class="file">
593
- <div class="file-head">
969
+ /** Compact measured signals shown in a file's head — same data the overview
970
+ * ranks on, carried down to the diff so context isn't lost on the way. */
971
+ function fileBadges(r) {
972
+ const b = [
973
+ `<span class="fbadge fbadge-churn" title="lines added / removed">+${r.added} −${r.removed}</span>`,
974
+ ];
975
+ if (r.fanIn > 0)
976
+ b.push(`<span class="fbadge fbadge-reach" title="repo files importing this one (reach)">→ ${r.fanIn}</span>`);
977
+ if (r.hotspot)
978
+ b.push(`<span class="fbadge fbadge-hot" title="measured cyclomatic complexity hotspot">CCN ${r.maxCcn}</span>`);
979
+ if (r.missingIntent)
980
+ b.push(`<span class="fbadge fbadge-gap" title="some of this file has no written intent">⚠ intent</span>`);
981
+ return `<span class="fbadges">${b.join("")}</span>`;
982
+ }
983
+ /** Actionable triage: the up-to-three files most worth a reviewer's first pass,
984
+ * each with the measured reasons it surfaced. Empty when nothing stands out. */
985
+ function renderReviewFirst(ranked) {
986
+ const top = ranked.filter((r) => r.score > 0).slice(0, 3);
987
+ if (top.length === 0)
988
+ return "";
989
+ const card = (r) => {
990
+ const reasons = [];
991
+ if (r.churn > 0)
992
+ reasons.push(`${r.churn} lines`);
993
+ if (r.fanIn > 0)
994
+ reasons.push(`imported by ${r.fanIn}`);
995
+ if (r.hotspot)
996
+ reasons.push(`CCN ${r.maxCcn}`);
997
+ if (r.missingIntent)
998
+ reasons.push(`no intent`);
999
+ return `<a class="rf-card" href="#${r.slug}">
1000
+ <span class="rf-rank">#${r.rank}</span>
1001
+ <code class="rf-path">${esc(shortPath(r.path, 40))}</code>
1002
+ <span class="rf-reasons">${reasons.map((x) => `<span>${esc(x)}</span>`).join("")}</span>
1003
+ </a>`;
1004
+ };
1005
+ return `<section class="review-first">
1006
+ <h2>Review first</h2>
1007
+ <div class="rf-cards">${top.map(card).join("")}</div>
1008
+ </section>`;
1009
+ }
1010
+ /** The spine: every changed file as a clickable row, in review-priority order,
1011
+ * carrying its measured signals. Links jump to the file's detail <details>. */
1012
+ function renderFileIndex(ranked) {
1013
+ if (ranked.length === 0)
1014
+ return "";
1015
+ const rows = ranked
1016
+ .map((r) => `<li class="fi-row">
1017
+ <a class="fi-link" href="#${r.slug}">
1018
+ <span class="fi-rank">#${r.rank}</span>
1019
+ <span class="status status-${r.status}">${r.status}</span>
1020
+ <code class="fi-path">${esc(r.path)}</code>
1021
+ <span class="fi-sig">
1022
+ <span class="fi-churn" title="± lines">+${r.added} −${r.removed}</span>
1023
+ ${r.fanIn ? `<span class="fi-reach" title="dependents (reach)">→ ${r.fanIn}</span>` : ""}
1024
+ ${r.hotspot ? `<span class="fi-hot" title="complexity hotspot">CCN ${r.maxCcn}</span>` : ""}
1025
+ ${r.missingIntent ? `<span class="fi-gap" title="unexplained change">⚠</span>` : ""}
1026
+ </span>
1027
+ </a>
1028
+ </li>`)
1029
+ .join("\n ");
1030
+ const n = ranked.length;
1031
+ return `<nav class="file-index" aria-label="Changed files">
1032
+ <h2>Files <span class="muted fi-count">${n} changed · review-ordered</span></h2>
1033
+ <ol class="fi-list">
1034
+ ${rows}
1035
+ </ol>
1036
+ </nav>`;
1037
+ }
1038
+ function renderFile(file, r) {
1039
+ // Noise files (lockfiles, generated) start collapsed; real code starts open.
1040
+ const open = r.isNoise ? "" : " open";
1041
+ return `<details class="file${r.isNoise ? " is-noise" : ""}" id="${r.slug}"${open}>
1042
+ <summary class="file-head">
594
1043
  <span class="status status-${file.status}">${file.status}</span>
595
1044
  <code class="path">${esc(file.path)}</code>
596
- </div>
1045
+ <span class="file-rank" title="review priority">#${r.rank}</span>
1046
+ ${fileBadges(r)}
1047
+ <label class="viewed-toggle" title="Mark as reviewed"><input type="checkbox" class="viewed-cb" /> seen</label>
1048
+ </summary>
1049
+ <div class="file-body">
597
1050
  ${file.why
598
1051
  ? `<div class="file-intent">${whatWhy(file.what, file.why)}</div>`
599
1052
  : `<div class="file-intent missing">⚠ No rationale (what/why) written for this changed file.</div>`}
600
- ${file.hunks.map(renderHunk).join("\n")}
1053
+ ${commentBox(r.slug, file.path, "file")}
1054
+ ${file.hunks.map((h, j) => renderHunk(h, r.index, j, file.path)).join("\n")}
601
1055
  ${file.unmatchedIntents.length
602
1056
  ? `<div class="unmatched">
603
1057
  <h4>Notes not matched to a hunk</h4>
604
1058
  ${file.unmatchedIntents.map((n) => `<div class="note"><span class="anchor">line ${n.anchor}</span>${whatWhy(n.what, n.why)}</div>`).join("")}
605
1059
  </div>`
606
1060
  : ""}
607
- </section>`;
1061
+ </div>
1062
+ </details>`;
1063
+ }
1064
+ /** A reviewer comment affordance: a 💬 toggle + a hidden textarea the comment
1065
+ * script persists. Pure markup; the textarea carries the data the assembled
1066
+ * prompt is built from. `cid` is the localStorage key, `ref` the human-readable
1067
+ * location shown in the prompt. */
1068
+ function commentBox(cid, ref, kind, hdr) {
1069
+ const hdrAttr = hdr ? ` data-hdr="${esc(hdr)}"` : "";
1070
+ const ph = kind === "hunk"
1071
+ ? "Note to the agent about this hunk…"
1072
+ : "Note to the agent about this file…";
1073
+ return `<div class="cbox" data-ckind="${kind}">
1074
+ <button class="cbtn" type="button" aria-label="Add a comment" title="Add a comment">💬</button>
1075
+ <textarea class="cinput" data-cid="${esc(cid)}" data-ref="${esc(ref)}"${hdrAttr} placeholder="${ph}"></textarea>
1076
+ </div>`;
608
1077
  }
609
1078
  /** Render a what/why pair (the structured per-change intent). */
610
1079
  function whatWhy(what, why) {
@@ -613,7 +1082,9 @@ function whatWhy(what, why) {
613
1082
  : "";
614
1083
  return `<div class="ww">${whatBlock}<div class="why"><span class="lbl">Why</span> ${md(why)}</div></div>`;
615
1084
  }
616
- function renderHunk(hunk) {
1085
+ function renderHunk(hunk, fileIndex, hunkIndex, path) {
1086
+ const cid = `file-${fileIndex}-hunk-${hunkIndex}`;
1087
+ const ref = `${path}:${hunk.newStart}${hunk.newEnd !== hunk.newStart ? `-${hunk.newEnd}` : ""}`;
617
1088
  return `<div class="hunk-row">
618
1089
  <div class="hunk-diff">
619
1090
  <div class="hunk-header">${esc(hunk.header)}</div>
@@ -623,6 +1094,7 @@ function renderHunk(hunk) {
623
1094
  ${hunk.intents.length
624
1095
  ? hunk.intents.map((i) => `<div class="note">${whatWhy(i.what, i.why)}</div>`).join("")
625
1096
  : `<div class="note missing">⚠ No intent for this hunk.</div>`}
1097
+ ${commentBox(cid, ref, "hunk", hunk.header)}
626
1098
  </aside>
627
1099
  </div>`;
628
1100
  }
@@ -645,6 +1117,28 @@ function renderFilesWithoutChanges(model) {
645
1117
  .join("")}</ul>
646
1118
  </section>`;
647
1119
  }
1120
+ /** Gathered review feedback: page-level comment + a live, readonly prompt the
1121
+ * reviewer copies back to the agent. Assembly happens client-side in
1122
+ * commentScript; this is the pure markup shell. */
1123
+ function renderFeedbackPanel(model) {
1124
+ if (model.files.length === 0)
1125
+ return "";
1126
+ return `<section class="review-feedback" id="feedback">
1127
+ <h2>Review feedback</h2>
1128
+ <p class="rf-hint">Comment on any hunk or file with the 💬 buttons, add overall notes here, then copy the assembled prompt back to the agent.</p>
1129
+ <label class="fb-general">
1130
+ <span class="fb-general-lbl">Overall comment</span>
1131
+ <textarea class="cinput fb-general-input" data-cid="__page__" data-ref="__general__" placeholder="Overall feedback on the change set…"></textarea>
1132
+ </label>
1133
+ <div class="fb-summary"></div>
1134
+ <h3 class="fb-out-head">Prompt for the agent</h3>
1135
+ <textarea class="fb-output" readonly placeholder="Comments you add are gathered here as a prompt for the agent."></textarea>
1136
+ <div class="fb-actions">
1137
+ <button class="fb-copy" type="button">Copy as prompt</button>
1138
+ <span class="fb-copied" hidden>Copied ✓</span>
1139
+ </div>
1140
+ </section>`;
1141
+ }
648
1142
  /** Escape text for safe HTML embedding. Also used for mermaid sources so the
649
1143
  * browser hands the correct characters to mermaid via textContent. */
650
1144
  function esc(s) {
@@ -671,285 +1165,838 @@ function md(src) {
671
1165
  .join("\n");
672
1166
  }
673
1167
  const CSS = `
1168
+ /* ── review-intent · clean editorial dossier ───────────────────────────────
1169
+ Light, warm-paper canvas. Prose is set in a humanist sans; every *measured*
1170
+ value — vitals, metrics, code, labels — is set in mono, so the page reads
1171
+ like an instrument. Colour is rationed: green/red/amber carry meaning, one
1172
+ quiet blue carries structure. */
674
1173
  :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;
1174
+ --paper: #f5f3ee; /* page canvas (warm paper) */
1175
+ --surface: #fffdf9; /* cards / raised panels */
1176
+ --surface-2: #efece4; /* recessed: diff gutter, file headers */
1177
+ --ink: #211f1b; /* primary text */
1178
+ --ink-soft: #565249; /* secondary text */
1179
+ --muted: #8e887c; /* tertiary / labels */
1180
+ --line: #e4ded2; /* hairline */
1181
+ --line-2: #d6cfbf; /* stronger hairline */
1182
+ --accent: #2f5d9c; /* the one structural accent */
1183
+ --accent-soft: #e9eff7;
1184
+ --add: #1f7a3d; --add-soft: #ebf4ed;
1185
+ --del: #bd3a2e; --del-soft: #faece9;
1186
+ --warn: #8a6400; --warn-soft: #f5eed8;
1187
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
1188
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, Helvetica, Arial, sans-serif;
1189
+ --maxw: 1080px;
1190
+ /* derived (were hard-coded literals; defaults reproduce paper exactly) */
1191
+ --add-border: #c7e2cd; --del-border: #eccac4;
1192
+ --warn-border: #e6d8a8; --accent-border: #cfdcef;
1193
+ --accent-shadow: rgba(47,93,156,.1);
1194
+ --on-accent: #fff;
1195
+ --glass: rgba(255,253,249,.9);
1196
+ --code-add: #115c2c; --code-del: #952c22;
1197
+ /* visual-summary chart palette */
1198
+ --viz-add: #1f9d4d; --viz-add-ink: #137a36;
1199
+ --viz-del: #dd574d; --viz-del-ink: #c0362c;
1200
+ --viz-warn: #c79100; --viz-accent: #2f5d9c; --viz-line: #e3ded3;
1201
+ --viz-node: #ffffff; --viz-node-stroke: #b8b1a4; --viz-accent-stroke: #21456f;
1202
+ --viz-cell-stroke: #ffffff;
1203
+ --viz-noise: #9b958a; --viz-other: #7e776c;
1204
+ --viz-cell-label: #23211d; --viz-zone: rgba(189, 58, 46, 0.09);
1205
+ --kind-e2e: #7a4fa0;
1206
+ --viz-s1: #5b7db1; --viz-s2: #5fa389; --viz-s3: #b08a5a; --viz-s4: #a07ba6;
1207
+ --viz-s5: #c47d72; --viz-s6: #7fa86a; --viz-s7: #d0a85a; --viz-s8: #7a93b8;
678
1208
  }
679
1209
  * { box-sizing: border-box; }
1210
+ html { -webkit-text-size-adjust: 100%; }
680
1211
  body {
681
- margin: 0; background: var(--bg); color: var(--text);
682
- font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
1212
+ margin: 0; background: var(--paper); color: var(--ink);
1213
+ font: 15px/1.6 var(--sans);
1214
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
683
1215
  }
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;
1216
+
1217
+ /* Every top-level band shares one centred measure with hairline separators. */
1218
+ .page-head, .vitals, .blast, .visuals, .tests, .diagrams, main, .orphans {
1219
+ max-width: var(--maxw); margin: 0 auto; padding: 36px 40px;
1220
+ }
1221
+ .blast, .visuals, .tests, .diagrams, .vitals { border-top: 1px solid var(--line); }
1222
+
1223
+ /* Section eyebrows — small, lettered, quiet. The recurring section-head idiom. */
1224
+ .band-head h2 {
1225
+ margin: 0 0 22px; font-size: 12px; font-weight: 700; color: var(--muted);
1226
+ text-transform: uppercase; letter-spacing: .14em;
1227
+ display: flex; align-items: baseline; gap: 12px;
1228
+ }
1229
+
1230
+ /* ── Masthead ── */
1231
+ .page-head { padding-top: 48px; padding-bottom: 40px; }
1232
+ .eyebrow {
1233
+ font: 600 12px/1 var(--mono); letter-spacing: .08em; color: var(--muted);
1234
+ text-transform: uppercase; margin-bottom: 18px;
1235
+ }
1236
+ .eyebrow-diff {
1237
+ color: var(--accent); background: var(--accent-soft);
1238
+ border-radius: 5px; padding: 3px 8px; margin-left: 4px; letter-spacing: .02em;
1239
+ }
1240
+ .page-head h1 {
1241
+ margin: 0 0 18px; font-size: clamp(26px, 4vw, 38px); line-height: 1.12;
1242
+ font-weight: 720; letter-spacing: -.02em; max-width: 22ch;
690
1243
  }
691
1244
  .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;
1245
+ max-width: 70ch; font-size: 19px; line-height: 1.5; color: var(--ink-soft);
1246
+ margin: 0 0 22px;
694
1247
  }
695
1248
  .tldr p { margin: 0; }
696
- .overall-wrap { max-width: 80ch; }
1249
+ .overall-wrap { max-width: 72ch; border-top: 1px solid var(--line); padding-top: 16px; }
697
1250
  .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; }
1251
+ cursor: pointer; color: var(--muted); font: 600 11px/1 var(--mono);
1252
+ text-transform: uppercase; letter-spacing: .1em; list-style: none;
1253
+ display: inline-flex; align-items: center; gap: 7px; user-select: none;
1254
+ }
1255
+ .overall-wrap > summary::-webkit-details-marker { display: none; }
1256
+ .overall-wrap > summary::before { content: "›"; font-size: 15px; transition: transform .15s; display: inline-block; }
1257
+ .overall-wrap[open] > summary::before { transform: rotate(90deg); }
1258
+ .overall { color: var(--ink-soft); margin-top: 14px; font-size: 15px; }
1259
+ .overall p { margin: 0 0 12px; }
1260
+
1261
+ /* ── Vitals: the at-a-glance overview spine ── */
1262
+ .vitals {
1263
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(124px, 1fr));
1264
+ gap: 1px; background: var(--line); background-clip: content-box;
1265
+ padding-top: 26px; padding-bottom: 26px;
1266
+ }
1267
+ /* Cells sit on the paper; the 1px grid gap reveals the container's line colour
1268
+ as a hairline between every cell — clean dividers at any column count, no
1269
+ stray edge borders when the grid wraps (the old flex-wrap left those). */
1270
+ .vital {
1271
+ min-width: 0; padding: 6px 22px; background: var(--paper);
1272
+ display: flex; flex-direction: column; gap: 6px;
1273
+ }
1274
+ .vital-num {
1275
+ font: 600 26px/1 var(--mono); letter-spacing: -.01em; color: var(--ink);
1276
+ font-variant-numeric: tabular-nums;
1277
+ }
1278
+ .vital-lbl {
1279
+ font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted);
1280
+ }
1281
+ .vital-add .vital-num { color: var(--add); }
1282
+ .vital-del .vital-num { color: var(--del); }
1283
+ .vital-warn .vital-num { color: var(--warn); }
1284
+
1285
+ /* ── Cards ── */
707
1286
  .card {
708
- background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
709
- padding: 14px 16px;
1287
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
1288
+ padding: 18px 20px;
1289
+ }
1290
+ .card h3 {
1291
+ margin: 0 0 14px; font-size: 14px; font-weight: 680;
1292
+ display: flex; align-items: center; gap: 10px;
710
1293
  }
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;
1294
+ /* claimed vs measured provenance tag quiet, lettered */
1295
+ .src {
1296
+ font: 600 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em;
1297
+ color: var(--muted); background: var(--surface-2);
1298
+ border-radius: 4px; padding: 3px 7px;
715
1299
  }
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; }
1300
+
1301
+ .blast-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
1302
+ .metrics { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 14px; font: 13px/1.4 var(--mono); }
1303
+ .metrics > span { font-variant-numeric: tabular-nums; }
1304
+ .metrics b { font-weight: 680; }
1305
+ .metrics .add { color: var(--add); }
1306
+ .metrics .del { color: var(--del); }
719
1307
  .metrics-extra {
720
- font-size: 12px; color: var(--muted); gap: 10px 14px;
721
- padding-top: 10px; border-top: 1px solid var(--border);
1308
+ font-size: 12px; color: var(--ink-soft); gap: 8px 16px;
1309
+ padding-top: 14px; border-top: 1px solid var(--line);
722
1310
  }
723
- .metrics-extra code { font-size: 11px; padding: 0 4px; }
724
- .metrics-extra .flag { color: var(--del-bd); font-weight: 600; }
1311
+ .metrics-extra code { font-size: 11px; padding: 1px 5px; }
1312
+ .metrics-extra .flag { color: var(--del); font-weight: 700; }
725
1313
  .muted { color: var(--muted); }
726
1314
  .badges { display: flex; flex-wrap: wrap; gap: 8px; }
727
1315
  .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; }
1316
+ font: 600 11px/1.5 var(--mono); letter-spacing: .02em;
1317
+ border-radius: 6px; padding: 3px 9px; border: 1px solid var(--line-2);
1318
+ color: var(--ink-soft); background: var(--surface-2);
1319
+ }
1320
+ .tone-danger { background: var(--del-soft); color: var(--del); border-color: var(--del-border); }
1321
+ .tone-warn { background: var(--warn-soft); color: var(--warn); border-color: var(--warn-border); }
1322
+ .tone-info { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-border); }
1323
+ .tone-ok { background: var(--add-soft); color: var(--add); border-color: var(--add-border); }
1324
+
1325
+ .risk-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
1326
+ .risk-table th {
1327
+ text-align: left; color: var(--muted); font: 600 11px/1 var(--mono);
1328
+ text-transform: uppercase; letter-spacing: .06em;
1329
+ padding: 0 10px 8px; border-bottom: 1px solid var(--line-2);
1330
+ }
1331
+ .risk-table td { padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; color: var(--ink-soft); }
1332
+ .risk-table tr:last-child td { border-bottom: 0; }
738
1333
  .risk-table td p, .risks .nudge p { margin: 0; }
739
- .nudge { color: #d29922; font-size: 13px; }
740
- .reach { margin-top: 16px; }
1334
+ .nudge { color: var(--warn); font-size: 13.5px; background: var(--warn-soft); border-radius: 8px; padding: 12px 14px; }
1335
+ .reach { margin-top: 18px; }
741
1336
  .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; } }
1337
+ .reach-note { color: var(--warn); font-size: 12px; margin-top: 10px; }
1338
+ @media (max-width: 820px) { .blast-grid { grid-template-columns: 1fr; } }
744
1339
 
745
1340
  /* ── 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
1341
  .viz-grid {
755
- display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
756
- gap: 14px; align-items: start;
1342
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
1343
+ gap: 16px; align-items: start;
757
1344
  }
758
1345
  .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
1346
  .viz svg { width: 100%; height: auto; display: block; }
762
- .viz-diffmass, .viz-treemap, .viz-complexity { max-width: 720px; }
1347
+ .viz-diffmass, .viz-treemap, .viz-complexity, .viz-scatter { max-width: 720px; }
763
1348
  .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). */
1349
+ /* Thumbnail clamp for the overview grid; lifted inside the lightbox. */
767
1350
  .zoomable svg { max-height: 168px; }
768
1351
  .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); }
1352
+ .zoomable { cursor: zoom-in; position: relative; transition: border-color .15s, box-shadow .15s; }
1353
+ .zoomable:hover { border-color: var(--accent); box-shadow: 0 2px 14px var(--accent-shadow); }
773
1354
  .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;
1355
+ content: "⤢ expand"; position: absolute; top: 10px; right: 12px;
1356
+ font: 600 10px/1 var(--mono); letter-spacing: .04em; color: var(--accent);
1357
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 5px;
1358
+ padding: 3px 7px; opacity: 0; transition: opacity .15s; pointer-events: none;
778
1359
  }
779
1360
  .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; }
1361
+ .viz-cap { color: var(--muted); font-size: 11.5px; margin: 10px 0 0; line-height: 1.5; }
1362
+ .viz-label { font-family: var(--mono); font-size: 11px; fill: var(--ink-soft); }
1363
+ .viz-num { fill: var(--muted); font-family: var(--mono); font-size: 10px; }
1364
+ .viz-axis { stroke: var(--line-2); stroke-width: 1; }
1365
+ .viz-cell-label { fill: var(--viz-cell-label); font-family: var(--mono); font-size: 10px; font-weight: 600; }
785
1366
  .viz-rings { display: flex; gap: 8px; justify-content: space-around; }
786
1367
  .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; } }
1368
+ .viz-ring-pct { fill: var(--ink); font-size: 22px; font-weight: 700; font-family: var(--sans); }
1369
+ .viz-ring-label { fill: var(--muted); font-size: 11px; font-family: var(--sans); }
1370
+ .viz-danger { fill: var(--viz-zone); }
1371
+ .viz-danger-label { fill: var(--del); }
1372
+ .viz-axis-label { fill: var(--muted); font-size: 11px; font-family: var(--sans); }
1373
+ .viz-dot { fill: var(--accent); stroke: var(--surface); stroke-width: 2.5; }
1374
+ .viz-dot-hot { fill: var(--del); }
1375
+ .viz-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 12px; }
1376
+ .viz-lg { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-soft); }
1377
+ .viz-lg-muted { color: var(--muted); }
1378
+ .viz-lg-dot { flex: none; }
1379
+ .zoomable .viz-lg-dot { max-height: none; }
1380
+ .viz-lg-zone { width: 13px; height: 13px; border-radius: 3px; background: var(--viz-zone); border: 1px solid var(--del-border); }
1381
+ .ripple-ring { fill: none; stroke: var(--line-2); stroke-dasharray: 3 5; }
1382
+ .ripple-edge { stroke: var(--accent); stroke-width: 1; opacity: 0.32; }
1383
+ .ripple-label { fill: var(--muted); font-family: var(--mono); font-size: 10px; }
1384
+ @media (max-width: 820px) { .viz-grid { grid-template-columns: 1fr; } }
1385
+
798
1386
  /* ── 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; }
1387
+ .tests .test-count { font-size: 12px; font-weight: 400; font-family: var(--sans); text-transform: none; letter-spacing: 0; color: var(--muted); }
1388
+ .test-group { margin-bottom: 22px; max-width: 80ch; }
807
1389
  .test-group:last-child { margin-bottom: 0; }
808
1390
  .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;
1391
+ display: flex; align-items: center; gap: 12px; margin: 0 0 8px;
1392
+ font: 700 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em;
811
1393
  color: var(--k, var(--muted));
812
1394
  }
813
- .test-kind::after { content: ""; flex: 1; height: 1px; background: var(--border); }
1395
+ .test-kind::after { content: ""; flex: 1; height: 1px; background: var(--line); }
814
1396
  .test-list { list-style: none; margin: 0; padding: 0; }
815
1397
  .test-case {
816
- position: relative; padding: 5px 0 5px 20px; border-bottom: 1px solid var(--border);
817
- max-width: 90ch;
1398
+ position: relative; padding: 8px 0 8px 22px; border-bottom: 1px solid var(--line);
1399
+ color: var(--ink-soft);
818
1400
  }
819
1401
  .test-group:last-child .test-case:last-child { border-bottom: 0; }
820
1402
  .test-case::before {
821
- content: ""; position: absolute; left: 2px; color: var(--k, var(--muted));
1403
+ content: ""; position: absolute; left: 4px; top: 15px;
1404
+ width: 6px; height: 6px; border-radius: 50%; background: var(--k, var(--muted));
822
1405
  }
823
1406
  .test-case p { display: inline; margin: 0; }
824
1407
  .test-name {
825
1408
  font-size: 11px; color: var(--muted);
826
- background: rgba(110,118,129,0.12); margin-left: 6px;
1409
+ background: var(--surface-2); margin-left: 7px;
827
1410
  }
828
- .diagrams { border-bottom: 1px solid var(--border); }
1411
+
1412
+ /* ── Diagrams ── */
829
1413
  .diagram-grid {
830
1414
  display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
831
- gap: 16px; align-items: start;
1415
+ gap: 18px; align-items: start;
832
1416
  }
833
1417
  .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; }
1418
+ .diagram h2 { font-size: 13px; color: var(--ink-soft); margin: 0 0 10px; font-weight: 680; }
1419
+ .diagram.zoomable { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; }
837
1420
  .mermaid {
838
- background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
1421
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
839
1422
  padding: 16px; overflow: auto;
840
1423
  }
1424
+
1425
+ /* ── File diffs ── */
1426
+ main { display: flex; flex-direction: column; gap: 26px; }
841
1427
  .file {
842
- border: 1px solid var(--border); border-radius: 8px; margin-bottom: 24px;
843
- overflow: hidden; background: var(--panel);
1428
+ border: 1px solid var(--line); border-radius: 10px;
1429
+ overflow: hidden; background: var(--surface);
844
1430
  }
845
1431
  .file-head {
846
- display: flex; align-items: center; gap: 10px; padding: 10px 14px;
847
- border-bottom: 1px solid var(--border); background: #11161d;
1432
+ display: flex; align-items: center; gap: 12px; padding: 12px 16px;
1433
+ border-bottom: 1px solid var(--line); background: var(--surface-2);
848
1434
  }
849
- .path { font-family: ui-monospace, monospace; font-size: 13px; }
1435
+ .path { font-family: var(--mono); font-size: 13px; color: var(--ink); min-width: 0; overflow-wrap: anywhere; }
850
1436
  .status {
851
- font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
852
- padding: 2px 8px; border-radius: 4px; font-weight: 600;
1437
+ font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
1438
+ padding: 4px 8px; border-radius: 5px;
853
1439
  }
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; }
1440
+ .status-added { background: var(--add-soft); color: var(--add); }
1441
+ .status-deleted { background: var(--del-soft); color: var(--del); }
1442
+ .status-modified { background: var(--accent-soft); color: var(--accent); }
1443
+ .status-renamed { background: var(--warn-soft); color: var(--warn); }
858
1444
  .file-intent {
859
- padding: 12px 14px; color: var(--text); border-bottom: 1px solid var(--border);
1445
+ padding: 16px; color: var(--ink-soft); border-bottom: 1px solid var(--line);
860
1446
  }
861
1447
  .file-intent p { margin: 0 0 8px; }
862
1448
  .hunk-row {
863
- display: grid; grid-template-columns: 1fr 320px; gap: 0;
864
- border-top: 1px solid var(--border);
1449
+ display: grid; grid-template-columns: 1fr 340px; gap: 0;
1450
+ border-top: 1px solid var(--line);
865
1451
  }
866
- .hunk-diff { overflow: auto; }
1452
+ .hunk-diff { overflow: auto; min-width: 0; }
867
1453
  .hunk-header {
868
- font-family: ui-monospace, monospace; font-size: 12px; color: var(--accent);
869
- padding: 6px 12px; background: #11161d;
1454
+ font-family: var(--mono); font-size: 12px; color: var(--accent);
1455
+ padding: 7px 14px; background: var(--surface-2); border-bottom: 1px solid var(--line);
870
1456
  }
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; }
1457
+ table.diff { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
1458
+ .ln td { padding: 1px 8px; white-space: pre; vertical-align: top; }
873
1459
  .num { color: var(--muted); text-align: right; width: 1%; user-select: none; }
874
1460
  .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; }
1461
+ .code { width: 100%; color: var(--ink); }
1462
+ .ln-add { background: var(--add-soft); }
1463
+ .ln-add .code { color: var(--code-add); }
1464
+ .ln-add .sign { color: var(--add); }
1465
+ .ln-del { background: var(--del-soft); }
1466
+ .ln-del .code { color: var(--code-del); }
1467
+ .ln-del .sign { color: var(--del); }
880
1468
  .hunk-notes {
881
- border-left: 1px solid var(--border); padding: 10px 14px; background: var(--note);
1469
+ border-left: 1px solid var(--line); padding: 14px 16px; background: var(--paper);
882
1470
  }
883
- .note { margin-bottom: 10px; }
1471
+ .note { margin-bottom: 12px; }
1472
+ .note:last-child { margin-bottom: 0; }
884
1473
  .note p { margin: 0 0 6px; }
885
- .ww .what, .ww .why { margin-bottom: 6px; }
1474
+ .ww .what, .ww .why { margin-bottom: 7px; }
886
1475
  .ww .why { margin-bottom: 0; }
887
1476
  .ww p { margin: 0; display: inline; }
888
1477
  .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;
1478
+ display: inline-block; font: 700 9.5px/1.6 var(--mono); text-transform: uppercase;
1479
+ letter-spacing: .06em; color: var(--muted); margin-right: 7px;
1480
+ border: 1px solid var(--line-2); border-radius: 4px; padding: 1px 5px; vertical-align: 1px;
892
1481
  }
893
1482
  .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;
1483
+ color: var(--del); font-weight: 600; font-size: 13px;
1484
+ background: var(--del-soft); border: 1px solid var(--del-border); border-radius: 8px;
1485
+ padding: 10px 12px;
897
1486
  }
898
1487
  .file-intent.missing { margin: 0; border-radius: 0; border-left: 0; border-right: 0; }
899
1488
  .anchor {
900
- display: inline-block; font-family: ui-monospace, monospace; font-size: 11px;
1489
+ display: inline-block; font-family: var(--mono); font-size: 11px;
901
1490
  color: var(--muted); margin-bottom: 4px;
902
1491
  }
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; }
1492
+ .unmatched, .orphans { padding: 14px 16px; border-top: 1px solid var(--line); color: var(--ink-soft); }
1493
+ .orphans { background: transparent; }
1494
+ .orphans h2 { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .14em; margin: 0 0 12px; }
1495
+ .orphans ul { margin: 0; padding-left: 20px; }
1496
+ .unmatched h4 { margin: 0 0 8px; color: var(--warn); font: 700 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
907
1497
  code {
908
- font-family: ui-monospace, monospace;
909
- background: rgba(110,118,129,0.2); padding: 1px 5px; border-radius: 4px;
1498
+ font-family: var(--mono); font-size: .92em;
1499
+ background: var(--surface-2); padding: 1px 5px; border-radius: 4px;
910
1500
  }
911
1501
  .empty { color: var(--muted); }
912
- a { color: var(--accent); }
913
- @media (max-width: 900px) {
1502
+ a { color: var(--accent); text-underline-offset: 2px; }
1503
+ @media (max-width: 820px) {
1504
+ .page-head, .vitals, .blast, .visuals, .tests, .diagrams, main, .orphans { padding: 28px 22px; }
914
1505
  .hunk-row { grid-template-columns: 1fr; }
915
- .hunk-notes { border-left: 0; border-top: 1px dashed var(--border); }
1506
+ .hunk-notes { border-left: 0; border-top: 1px dashed var(--line-2); }
916
1507
  }
917
1508
 
918
1509
  /* ── Lightbox: click a thumbnail to view a figure full-size ── */
919
1510
  #lightbox {
920
1511
  position: fixed; inset: 0; z-index: 1000; display: none;
921
1512
  align-items: center; justify-content: center; padding: 40px;
922
- background: rgba(1, 4, 9, 0.82);
1513
+ background: rgba(33, 31, 27, 0.5); backdrop-filter: blur(3px);
923
1514
  }
924
1515
  #lightbox.open { display: flex; }
925
1516
  .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;
1517
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 12px;
1518
+ padding: 24px 28px; max-width: 1100px; width: 100%; max-height: 90vh; overflow: auto;
1519
+ box-shadow: 0 24px 60px rgba(33,31,27,.22);
928
1520
  }
929
- /* Lift the thumbnail clamp inside the stage so the figure renders at full size. */
930
1521
  .lightbox-stage .card { background: transparent; border: 0; padding: 0; cursor: auto; }
931
1522
  .lightbox-stage .zoomable::after { content: none; }
932
1523
  .lightbox-stage svg { max-height: none; }
933
- .lightbox-stage .mermaid { max-height: none; overflow: auto; }
1524
+ .lightbox-stage .mermaid { max-height: none; overflow: auto; border: 0; padding: 0; }
934
1525
  .lightbox-stage .diagram.zoomable { border: 0; padding: 0; }
935
1526
  .lightbox-stage .diagram.zoomable h2 { font-size: 16px; }
936
1527
  .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;
1528
+ position: fixed; top: 16px; right: 22px; z-index: 1001;
1529
+ background: var(--surface); color: var(--ink); border: 1px solid var(--line-2);
1530
+ border-radius: 8px; font-size: 18px; line-height: 1; cursor: pointer;
1531
+ padding: 8px 13px;
941
1532
  }
942
1533
  .lightbox-close:hover { border-color: var(--accent); color: var(--accent); }
1534
+
1535
+ /* ── Sticky utility bar ── */
1536
+ html { scroll-behavior: smooth; scroll-padding-top: 48px; }
1537
+ .topbar {
1538
+ position: sticky; top: 0; z-index: 50;
1539
+ display: flex; align-items: center; gap: 16px;
1540
+ /* Align the bar's content with the page measure on wide screens; floor to a
1541
+ small inset on narrow ones. */
1542
+ padding: 9px max(18px, calc((100% - var(--maxw)) / 2 + 40px));
1543
+ background: var(--glass);
1544
+ backdrop-filter: blur(6px); border-bottom: 1px solid var(--line);
1545
+ font: 12px/1 var(--mono);
1546
+ }
1547
+ /* Title absorbs all shrinkage and truncates; the counter + link never shrink. */
1548
+ .tb-title { flex: 0 1 auto; min-width: 0; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1549
+ .tb-progress { flex: none; margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
1550
+ .tb-top { flex: none; color: var(--accent); text-decoration: none; white-space: nowrap; }
1551
+ .tb-theme { position: relative; }
1552
+ .tb-gear {
1553
+ background: none; border: 0; cursor: pointer; font-size: 15px;
1554
+ color: var(--muted); padding: 4px 6px; line-height: 1; border-radius: 6px;
1555
+ }
1556
+ .tb-gear:hover { color: var(--ink); background: var(--surface-2); }
1557
+ .theme-menu {
1558
+ position: absolute; right: 0; top: 130%; z-index: 50;
1559
+ background: var(--surface); border: 1px solid var(--line-2);
1560
+ border-radius: 10px; padding: 8px; min-width: 180px;
1561
+ box-shadow: 0 10px 30px rgba(33,31,27,.18);
1562
+ display: grid; gap: 8px;
1563
+ }
1564
+ .theme-menu[hidden] { display: none; }
1565
+ .theme-grp-h {
1566
+ font: 600 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em;
1567
+ color: var(--muted); margin: 2px 4px 4px;
1568
+ }
1569
+ .theme-opt {
1570
+ display: block; width: 100%; text-align: left; background: none; border: 0;
1571
+ cursor: pointer; padding: 5px 8px; border-radius: 6px; color: var(--ink-soft);
1572
+ font: 13px/1.2 var(--sans);
1573
+ }
1574
+ .theme-opt:hover { background: var(--surface-2); color: var(--ink); }
1575
+ .theme-opt[aria-checked="true"] { color: var(--accent); font-weight: 600; }
1576
+
1577
+ /* ── Review-first callout ── */
1578
+ .review-first { max-width: var(--maxw); margin: 0 auto; padding: 22px 40px; border-top: 1px solid var(--line); }
1579
+ .review-first > h2 {
1580
+ margin: 0 0 14px; font-size: 12px; font-weight: 700; color: var(--muted);
1581
+ text-transform: uppercase; letter-spacing: .14em;
1582
+ }
1583
+ .rf-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
1584
+ .rf-card {
1585
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
1586
+ padding: 12px 14px; border: 1px solid var(--line-2); border-radius: 9px;
1587
+ background: var(--surface); text-decoration: none; color: var(--ink);
1588
+ transition: border-color .15s, box-shadow .15s;
1589
+ }
1590
+ .rf-card:hover { border-color: var(--accent); box-shadow: 0 2px 14px var(--accent-shadow); }
1591
+ .rf-rank { font: 700 13px/1 var(--mono); color: var(--accent); }
1592
+ .rf-path { font-size: 12.5px; min-width: 0; overflow-wrap: anywhere; }
1593
+ .rf-reasons { display: flex; flex-wrap: wrap; gap: 4px 8px; width: 100%; }
1594
+ .rf-reasons span {
1595
+ font: 600 10px/1.5 var(--mono); color: var(--ink-soft);
1596
+ background: var(--surface-2); border-radius: 4px; padding: 2px 6px;
1597
+ }
1598
+
1599
+ /* ── File index (spine) ── */
1600
+ .file-index { max-width: var(--maxw); margin: 0 auto; padding: 28px 40px; border-top: 1px solid var(--line); }
1601
+ .file-index > h2 {
1602
+ margin: 0 0 14px; font-size: 12px; font-weight: 700; color: var(--muted);
1603
+ text-transform: uppercase; letter-spacing: .14em;
1604
+ }
1605
+ .fi-count { font-weight: 400; text-transform: none; letter-spacing: 0; font-family: var(--sans); font-size: 12px; }
1606
+ .fi-list { list-style: none; margin: 0; padding: 0; }
1607
+ .fi-row { border-bottom: 1px solid var(--line); }
1608
+ .fi-row:last-child { border-bottom: 0; }
1609
+ .fi-link {
1610
+ display: flex; align-items: center; gap: 12px; padding: 8px 6px;
1611
+ text-decoration: none; color: var(--ink); border-radius: 6px;
1612
+ }
1613
+ .fi-link:hover { background: var(--surface-2); }
1614
+ .fi-link.active { background: var(--accent-soft); }
1615
+ .fi-rank { font: 700 12px/1 var(--mono); color: var(--accent); width: 2.4em; flex: none; }
1616
+ .fi-path { font-size: 12.5px; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; background: none; padding: 0; }
1617
+ .fi-sig { display: flex; gap: 4px 10px; flex: none; font: 11px/1.4 var(--mono); color: var(--muted); }
1618
+ .fi-sig .fi-hot { color: var(--del); font-weight: 700; }
1619
+ .fi-sig .fi-gap { color: var(--warn); font-weight: 700; }
1620
+
1621
+ /* ── Collapsible analytics bands ── */
1622
+ .band { border: 0; }
1623
+ .band-head { cursor: pointer; list-style: none; }
1624
+ .band-head::-webkit-details-marker { display: none; }
1625
+ .band-head h2::before {
1626
+ content: "›"; font-size: 15px; color: var(--muted); transition: transform .15s; display: inline-block;
1627
+ }
1628
+ .band[open] > .band-head h2::before { transform: rotate(90deg); }
1629
+ .band-body { padding-top: 22px; }
1630
+
1631
+ /* ── File head badges + collapsible files + viewed state ── */
1632
+ .file > summary.file-head { cursor: pointer; list-style: none; }
1633
+ .file > summary.file-head::-webkit-details-marker { display: none; }
1634
+ .file-head { flex-wrap: wrap; }
1635
+ .file-rank { font: 700 11px/1 var(--mono); color: var(--accent); }
1636
+ .fbadges { display: inline-flex; flex-wrap: wrap; gap: 4px 6px; }
1637
+ .fbadge {
1638
+ font: 600 10px/1.5 var(--mono); border-radius: 4px; padding: 2px 6px;
1639
+ background: var(--surface); border: 1px solid var(--line-2); color: var(--ink-soft);
1640
+ }
1641
+ .fbadge-hot { color: var(--del); border-color: var(--del-border); background: var(--del-soft); }
1642
+ .fbadge-gap { color: var(--warn); border-color: var(--warn-border); background: var(--warn-soft); }
1643
+ .viewed-toggle {
1644
+ margin-left: auto; display: inline-flex; align-items: center; gap: 5px;
1645
+ font: 600 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
1646
+ cursor: pointer; user-select: none;
1647
+ }
1648
+ .file.viewed { opacity: .55; }
1649
+ .file.viewed:hover { opacity: 1; }
1650
+
1651
+ @media (max-width: 820px) {
1652
+ .review-first, .file-index { padding: 22px; }
1653
+ .fi-sig { width: 100%; }
1654
+ }
1655
+
1656
+ /* ── Phones ── */
1657
+ @media (max-width: 560px) {
1658
+ .vital-num { font-size: 22px; }
1659
+ /* The 3-column risk ledger can't stay legible much below ~460px — let it
1660
+ scroll inside its card rather than crushing every column to a sliver. */
1661
+ .risks { overflow-x: auto; }
1662
+ .risk-table { min-width: 440px; }
1663
+ /* Give the lightbox the whole small screen. */
1664
+ #lightbox { padding: 12px; }
1665
+ .lightbox-close { top: 8px; right: 10px; }
1666
+ }
1667
+
1668
+ /* ── Pin-to-rail: movable blocks + a sticky sidebar on wide screens ── */
1669
+ .movable { position: relative; }
1670
+ .rail:empty { display: none; }
1671
+ /* The pin affordance only exists where there's a rail to pin to (wide screens). */
1672
+ .pin-btn { display: none; }
1673
+
1674
+ @media (min-width: 1920px) {
1675
+ .pin-btn {
1676
+ display: inline-flex; align-items: center; justify-content: center;
1677
+ position: absolute; top: 12px; right: 14px; z-index: 3;
1678
+ font: 12px/1 var(--mono); cursor: pointer;
1679
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 6px;
1680
+ padding: 4px 6px; opacity: 0; transition: opacity .15s, border-color .15s;
1681
+ }
1682
+ .movable:hover > .pin-btn, .pin-btn:focus-visible { opacity: 1; }
1683
+ .pin-btn[aria-pressed="true"] { opacity: 1; border-color: var(--accent); background: var(--accent-soft); }
1684
+
1685
+ /* The two-pane shell engages only once something is pinned; with an empty
1686
+ rail the page stays the calm centred column it is below this width. */
1687
+ body.has-pins .page-head { max-width: 1840px; }
1688
+ body.has-pins .topbar { padding-inline: max(18px, calc((100% - 1840px) / 2 + 40px)); }
1689
+ body.has-pins .layout {
1690
+ max-width: 1840px; margin: 0 auto; padding: 0 40px;
1691
+ display: grid; grid-template-columns: 320px minmax(0, 1fr);
1692
+ gap: 36px; align-items: start;
1693
+ }
1694
+ body.has-pins .rail {
1695
+ position: sticky; top: 56px; align-self: start;
1696
+ max-height: calc(100vh - 72px); overflow: auto;
1697
+ display: flex; flex-direction: column; gap: 18px;
1698
+ }
1699
+ body.has-pins .content { min-width: 0; }
1700
+ /* Inside the shell, blocks fill their column instead of self-centring. */
1701
+ body.has-pins .content > .movable > section,
1702
+ body.has-pins .content > .movable > nav,
1703
+ body.has-pins .content > main,
1704
+ body.has-pins .content > .orphans,
1705
+ body.has-pins .rail > .movable > section,
1706
+ body.has-pins .rail > .movable > nav { max-width: none; margin: 0; }
1707
+ /* Rail blocks: trim the band padding, drop the band rule, fit narrow charts
1708
+ and let a wide table scroll rather than crush. */
1709
+ body.has-pins .rail > .movable > section,
1710
+ body.has-pins .rail > .movable > nav { padding: 18px 16px; border-top: 0; }
1711
+ body.has-pins .rail .band-body { padding-top: 16px; }
1712
+ body.has-pins .rail .blast-grid { grid-template-columns: 1fr; }
1713
+ body.has-pins .rail .risks { overflow-x: auto; }
1714
+ body.has-pins .rail .viz-grid { grid-template-columns: 1fr; }
1715
+ }
1716
+
1717
+ /* ── Review comments ── */
1718
+ .cbox { margin-top: 10px; }
1719
+ .hunk-notes .cbox { margin-top: 12px; border-top: 1px dashed var(--line-2); padding-top: 10px; }
1720
+ .cbtn {
1721
+ font-size: 12px; line-height: 1; cursor: pointer; color: var(--ink-soft);
1722
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 6px; padding: 3px 7px;
1723
+ }
1724
+ .cbtn:hover { border-color: var(--accent); }
1725
+ .cbox.has-comment .cbtn { border-color: var(--accent); background: var(--accent-soft); }
1726
+ .cbox.has-comment .cbtn::after { content: " •"; color: var(--accent); }
1727
+ .cinput {
1728
+ display: none; width: 100%; margin-top: 8px; resize: vertical; min-height: 54px;
1729
+ font: 13px/1.5 var(--sans); color: var(--ink);
1730
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; padding: 8px 10px;
1731
+ }
1732
+ .cbox.open .cinput { display: block; }
1733
+
1734
+ /* ── Review feedback panel ── */
1735
+ .review-feedback { max-width: var(--maxw); margin: 0 auto; padding: 36px 40px; border-top: 1px solid var(--line); }
1736
+ .review-feedback > h2 {
1737
+ margin: 0 0 8px; font-size: 12px; font-weight: 700; color: var(--muted);
1738
+ text-transform: uppercase; letter-spacing: .14em;
1739
+ }
1740
+ .rf-hint { color: var(--muted); font-size: 13px; margin: 0 0 18px; max-width: 72ch; }
1741
+ .fb-general { display: block; margin-bottom: 14px; }
1742
+ .fb-general-lbl { display: block; font: 600 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 6px; }
1743
+ .fb-general-input {
1744
+ display: block; width: 100%; resize: vertical; min-height: 60px;
1745
+ font: 13px/1.5 var(--sans); color: var(--ink);
1746
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; padding: 8px 10px;
1747
+ }
1748
+ .fb-summary { font: 12px/1 var(--mono); color: var(--muted); margin-bottom: 14px; }
1749
+ .fb-out-head { margin: 0 0 8px; font-size: 13px; font-weight: 680; color: var(--ink-soft); }
1750
+ .fb-output {
1751
+ display: block; width: 100%; min-height: 160px; resize: vertical;
1752
+ font: 12.5px/1.55 var(--mono); color: var(--ink);
1753
+ background: var(--surface-2); border: 1px solid var(--line-2); border-radius: 8px; padding: 12px 14px;
1754
+ }
1755
+ .fb-actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
1756
+ .fb-copy {
1757
+ font: 600 12px/1 var(--mono); cursor: pointer; color: var(--on-accent);
1758
+ background: var(--accent); border: 1px solid var(--accent); border-radius: 8px; padding: 9px 16px;
1759
+ }
1760
+ .fb-copy:hover { filter: brightness(1.06); }
1761
+ .fb-copied { color: var(--add); font: 600 12px/1 var(--mono); }
1762
+
1763
+ /* ── Guided tour ── */
1764
+ .tb-tour {
1765
+ flex: none; font: 600 11px/1 var(--mono); cursor: pointer; color: var(--accent);
1766
+ background: var(--accent-soft); border: 1px solid var(--accent-border); border-radius: 6px; padding: 5px 9px;
1767
+ }
1768
+ .tb-tour:hover { border-color: var(--accent); }
1769
+ .tour {
1770
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 80;
1771
+ display: flex; align-items: center; gap: 12px;
1772
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 12px;
1773
+ padding: 10px 14px; box-shadow: 0 10px 30px rgba(33,31,27,.18);
1774
+ font: 12px/1.2 var(--mono); max-width: calc(100vw - 32px);
1775
+ }
1776
+ .tour[hidden] { display: none; }
1777
+ .tour-status { color: var(--ink-soft); }
1778
+ .tour-status b { color: var(--ink); }
1779
+ .tour-path { font-size: 11.5px; background: none; padding: 0; color: var(--accent); overflow-wrap: anywhere; }
1780
+ .tour-btn {
1781
+ flex: none; font: 600 12px/1 var(--mono); cursor: pointer; color: var(--ink-soft);
1782
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 7px; padding: 6px 10px;
1783
+ }
1784
+ .tour-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
1785
+ .tour-btn:disabled { opacity: .4; cursor: default; }
1786
+ .tour-flash { animation: tour-flash 1.2s ease-out; }
1787
+ @keyframes tour-flash {
1788
+ 0% { box-shadow: 0 0 0 3px var(--accent); }
1789
+ 100% { box-shadow: 0 0 0 3px transparent; }
1790
+ }
1791
+ @media (max-width: 560px) {
1792
+ .tour { flex-wrap: wrap; justify-content: center; bottom: 10px; }
1793
+ }
1794
+ ${themeCss()}
943
1795
  `;
944
1796
  const MERMAID_SCRIPT = `<script type="module">
945
1797
  import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
946
- mermaid.initialize({ startOnLoad: true, theme: "dark", securityLevel: "strict" });
1798
+ mermaid.initialize({ startOnLoad: true, theme: "neutral", securityLevel: "strict" });
947
1799
  </script>`;
948
1800
  /** Empty overlay the lightbox script clones the clicked figure into. */
949
1801
  const LIGHTBOX = `<div id="lightbox" role="dialog" aria-modal="true" aria-label="Figure detail">
950
1802
  <button class="lightbox-close" type="button" aria-label="Close">✕</button>
951
1803
  <div class="lightbox-stage"></div>
952
1804
  </div>`;
1805
+ /** Fixed guided-review control, hidden until the tour starts. */
1806
+ const TOUR = `<div class="tour" id="tour" hidden role="region" aria-label="Guided review">
1807
+ <button class="tour-btn tour-prev" type="button">‹ Prev</button>
1808
+ <span class="tour-status">Reviewing <b class="tour-cur">1</b> of <b class="tour-total">0</b> — <code class="tour-path"></code></span>
1809
+ <button class="tour-btn tour-next" type="button">Next ›</button>
1810
+ <button class="tour-btn tour-exit" type="button" aria-label="Exit guided review">✕</button>
1811
+ </div>`;
1812
+ /** Static, dependency-free progressive enhancement: persist "seen" files,
1813
+ * keep the topbar counter in sync, and highlight the active file in the index.
1814
+ * Storage key is deterministic (title@base) so it stays per-change. */
1815
+ function viewedScript(model) {
1816
+ const KEY = `review-intent:viewed:${model.title}@${model.base}`;
1817
+ return `<script>
1818
+ (function () {
1819
+ var KEY = ${JSON.stringify(KEY).replace(/<\//g, "<\\/")};
1820
+ var store;
1821
+ try { store = JSON.parse(localStorage.getItem(KEY) || "{}"); } catch (e) { store = {}; }
1822
+ var files = Array.prototype.slice.call(document.querySelectorAll("details.file"));
1823
+ var prog = document.querySelector(".tb-progress");
1824
+ function update() {
1825
+ if (!prog) return;
1826
+ var done = files.filter(function (f) { return f.classList.contains("viewed"); }).length;
1827
+ prog.textContent = done + " / " + files.length + " reviewed";
1828
+ }
1829
+ files.forEach(function (f) {
1830
+ var cb = f.querySelector(".viewed-cb");
1831
+ var toggle = f.querySelector(".viewed-toggle");
1832
+ if (!cb) return;
1833
+ if (store[f.id]) { cb.checked = true; f.classList.add("viewed"); f.open = false; }
1834
+ // Don't let the control toggle the <details> it lives in.
1835
+ if (toggle) toggle.addEventListener("click", function (e) { e.stopPropagation(); });
1836
+ cb.addEventListener("change", function () {
1837
+ if (cb.checked) { f.classList.add("viewed"); f.open = false; store[f.id] = 1; }
1838
+ else { f.classList.remove("viewed"); delete store[f.id]; f.open = true; }
1839
+ try { localStorage.setItem(KEY, JSON.stringify(store)); } catch (e) {}
1840
+ update();
1841
+ });
1842
+ });
1843
+ update();
1844
+
1845
+ var links = {};
1846
+ document.querySelectorAll(".file-index a[href^='#']").forEach(function (a) {
1847
+ links[a.getAttribute("href").slice(1)] = a;
1848
+ });
1849
+ if (window.IntersectionObserver) {
1850
+ var io = new IntersectionObserver(function (entries) {
1851
+ entries.forEach(function (en) {
1852
+ var a = links[en.target.id];
1853
+ if (a) a.classList.toggle("active", en.isIntersecting);
1854
+ });
1855
+ }, { rootMargin: "-45% 0px -45% 0px" });
1856
+ files.forEach(function (f) { io.observe(f); });
1857
+ }
1858
+ })();
1859
+ </script>`;
1860
+ }
1861
+ /** Static enhancement: persist reviewer comments (per-change, like viewed
1862
+ * state), keep the gathered-prompt textarea + summary in sync, and copy. The
1863
+ * prompt is assembled from the live textareas in DOM order (= review order). */
1864
+ function commentScript(model) {
1865
+ const KEY = `review-intent:comments:${model.title}@${model.base}`;
1866
+ const META = JSON.stringify({ title: model.title, base: model.base }).replace(/<\//g, "<\\/");
1867
+ return `<script>
1868
+ (function () {
1869
+ var KEY = ${JSON.stringify(KEY).replace(/<\//g, "<\\/")};
1870
+ var META = ${META};
1871
+ var store;
1872
+ try { store = JSON.parse(localStorage.getItem(KEY) || "{}"); } catch (e) { store = {}; }
1873
+ function save() { try { localStorage.setItem(KEY, JSON.stringify(store)); } catch (e) {} }
1874
+
1875
+ var inputs = Array.prototype.slice.call(document.querySelectorAll(".cinput"));
1876
+ var out = document.querySelector(".fb-output");
1877
+ var summary = document.querySelector(".fb-summary");
1878
+ function clean(s) { return s.replace(/\\r/g, "").trim(); }
1879
+ function mark(t) { var b = t.closest(".cbox"); if (b) b.classList.toggle("has-comment", !!clean(t.value)); }
1880
+ function reveal(t) { var b = t.closest(".cbox"); if (b) b.classList.add("open"); }
1881
+
1882
+ function assemble() {
1883
+ var lines = [], count = 0;
1884
+ var files = Array.prototype.slice.call(document.querySelectorAll("details.file"));
1885
+ var done = files.filter(function (f) { return f.classList.contains("viewed"); }).length;
1886
+ files.forEach(function (f) {
1887
+ var code = f.querySelector(".path");
1888
+ var path = code ? code.textContent : f.id;
1889
+ var section = [];
1890
+ var fc = f.querySelector('.cbox[data-ckind="file"] .cinput');
1891
+ if (fc && clean(fc.value)) { section.push("- " + clean(fc.value).replace(/\\n/g, "\\n ")); count++; }
1892
+ f.querySelectorAll('.cbox[data-ckind="hunk"] .cinput').forEach(function (hc) {
1893
+ if (clean(hc.value)) {
1894
+ var ref = hc.getAttribute("data-ref"), hdr = hc.getAttribute("data-hdr");
1895
+ section.push("### " + ref + (hdr ? " (" + hdr + ")" : ""));
1896
+ section.push("- " + clean(hc.value).replace(/\\n/g, "\\n "));
1897
+ count++;
1898
+ }
1899
+ });
1900
+ if (section.length) { lines.push("## " + path); lines.push.apply(lines, section); lines.push(""); }
1901
+ });
1902
+ var pg = document.querySelector('.cinput[data-cid="__page__"]');
1903
+ if (pg && clean(pg.value)) { lines.push("## General"); lines.push("- " + clean(pg.value).replace(/\\n/g, "\\n ")); lines.push(""); count++; }
1904
+ if (out) {
1905
+ if (count === 0) { out.value = ""; }
1906
+ else {
1907
+ var head = 'Review feedback on "' + META.title + '" (' + META.base + "...HEAD).\\n" +
1908
+ "Sign-off: " + done + " / " + files.length + " files reviewed. Address each item below.\\n";
1909
+ out.value = head + "\\n" + lines.join("\\n").replace(/\\n+$/, "") + "\\n";
1910
+ }
1911
+ }
1912
+ if (summary) {
1913
+ summary.textContent = done + " / " + files.length + " files reviewed · " + count + " comment" + (count === 1 ? "" : "s");
1914
+ }
1915
+ }
1916
+
1917
+ inputs.forEach(function (t) {
1918
+ var cid = t.getAttribute("data-cid");
1919
+ if (store[cid]) { t.value = store[cid]; reveal(t); }
1920
+ mark(t);
1921
+ t.addEventListener("input", function () {
1922
+ if (clean(t.value)) store[cid] = t.value; else delete store[cid];
1923
+ save(); mark(t); assemble();
1924
+ });
1925
+ });
1926
+
1927
+ document.querySelectorAll(".cbtn").forEach(function (b) {
1928
+ b.addEventListener("click", function (e) {
1929
+ e.preventDefault(); e.stopPropagation();
1930
+ var box = b.closest(".cbox"); if (!box) return;
1931
+ box.classList.toggle("open");
1932
+ if (box.classList.contains("open")) { var ta = box.querySelector(".cinput"); if (ta) ta.focus(); }
1933
+ });
1934
+ });
1935
+
1936
+ var copyBtn = document.querySelector(".fb-copy"), copied = document.querySelector(".fb-copied");
1937
+ if (copyBtn && out) {
1938
+ copyBtn.addEventListener("click", function () {
1939
+ assemble();
1940
+ var text = out.value; if (!text) return;
1941
+ function flash() { if (copied) { copied.hidden = false; setTimeout(function () { copied.hidden = true; }, 1600); } }
1942
+ out.select();
1943
+ var ok = false; try { ok = document.execCommand("copy"); } catch (e) {}
1944
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1945
+ navigator.clipboard.writeText(text).then(flash, function () { if (ok) flash(); });
1946
+ } else if (ok) { flash(); }
1947
+ });
1948
+ }
1949
+
1950
+ assemble();
1951
+ })();
1952
+ </script>`;
1953
+ }
1954
+ /** Static enhancement: a numbered prev/next walkthrough of the changed files in
1955
+ * review-order. Order is injected from reviewOrder so it matches the page. Does
1956
+ * not touch viewed state — navigation and sign-off stay separate. */
1957
+ function tourScript(model, ranked) {
1958
+ const ORDER = JSON.stringify(ranked.map((r) => ({ slug: r.slug, path: r.path }))).replace(/<\//g, "<\\/");
1959
+ return `<script>
1960
+ (function () {
1961
+ var ORDER = ${ORDER};
1962
+ var tour = document.getElementById("tour");
1963
+ var startBtn = document.querySelector(".tb-tour");
1964
+ if (!tour || !startBtn || !ORDER.length) return;
1965
+ var cur = tour.querySelector(".tour-cur"), total = tour.querySelector(".tour-total");
1966
+ var pathEl = tour.querySelector(".tour-path");
1967
+ var prev = tour.querySelector(".tour-prev"), next = tour.querySelector(".tour-next"), exit = tour.querySelector(".tour-exit");
1968
+ var i = 0, flashTimer;
1969
+ if (total) total.textContent = ORDER.length;
1970
+ function go(n) {
1971
+ i = Math.max(0, Math.min(ORDER.length - 1, n));
1972
+ var item = ORDER[i], el = document.getElementById(item.slug);
1973
+ if (el) {
1974
+ if (el.tagName === "DETAILS") el.open = true;
1975
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
1976
+ el.classList.add("tour-flash");
1977
+ clearTimeout(flashTimer);
1978
+ flashTimer = setTimeout(function () { el.classList.remove("tour-flash"); }, 1200);
1979
+ }
1980
+ if (cur) cur.textContent = i + 1;
1981
+ if (pathEl) pathEl.textContent = item.path;
1982
+ if (prev) prev.disabled = i === 0;
1983
+ if (next) next.disabled = i === ORDER.length - 1;
1984
+ }
1985
+ function start() { tour.hidden = false; document.body.classList.add("touring"); go(0); }
1986
+ function close() { tour.hidden = true; document.body.classList.remove("touring"); }
1987
+ startBtn.addEventListener("click", start);
1988
+ if (prev) prev.addEventListener("click", function () { go(i - 1); });
1989
+ if (next) next.addEventListener("click", function () { go(i + 1); });
1990
+ if (exit) exit.addEventListener("click", close);
1991
+ document.addEventListener("keydown", function (e) {
1992
+ if (tour.hidden) return;
1993
+ if (e.key === "ArrowRight") { e.preventDefault(); go(i + 1); }
1994
+ else if (e.key === "ArrowLeft") { e.preventDefault(); go(i - 1); }
1995
+ else if (e.key === "Escape") { close(); }
1996
+ });
1997
+ })();
1998
+ </script>`;
1999
+ }
953
2000
  /** Static, dependency-free click-to-zoom. Clones the figure live on click, so a
954
2001
  * mermaid <pre> that has since rendered to <svg> is captured as drawn. */
955
2002
  const LIGHTBOX_SCRIPT = `<script>