@galda/cli 0.10.34 → 0.10.35

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/app/index.html CHANGED
@@ -452,6 +452,18 @@
452
452
  .vchip.none{color:var(--ink3);border-color:var(--hair2)}
453
453
  .vchip.human{color:var(--amber);border-color:rgba(var(--amber-rgb),.5);font-weight:700}
454
454
  .vchip.human.high{color:var(--danger);border-color:rgba(var(--danger-rgb),.5)}
455
+ /* H23 (2026-07-18) evidence-first review card: a fact stops looking like a button —
456
+ glyph carries the state colour, text stays plain ink, no border/pill (border is
457
+ reserved for the PR link, the card's only clickable pill). Scoped to .lg-ev so it
458
+ only affects the Ledger review card; other .vchip call sites are unchanged/out of
459
+ scope for this pass. Ported from flagship.html .lg-ev .vf (~L401-407). */
460
+ .lg-ev .vf{display:inline-flex;align-items:center;gap:5px;color:var(--ink2)}
461
+ .lg-ev .vf .g{font-style:normal}
462
+ .lg-ev .vf.ok .g{color:var(--green)}
463
+ .lg-ev .vf.no .g{color:var(--danger)}
464
+ .lg-ev .vf.none{color:var(--ink3)}
465
+ .lg-ev .vf.human .g{color:var(--amber)}
466
+ .lg-ev .vf.human.high .g{color:var(--danger)}
455
467
  .pbadge{font-family:var(--mono);font-size:9.5px;padding:1px 6px;border-radius:5px;margin-right:6px;color:var(--iri);border:1px solid rgba(var(--blue-rgb),.35)}
456
468
  .blockreason{color:var(--amber)}
457
469
  .rvcheck2{font-size:15px;font-weight:600;color:var(--ink);line-height:1.5;margin-bottom:4px}
@@ -829,6 +841,14 @@
829
841
  .beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:8px}
830
842
  .ba-col{min-width:0}
831
843
  .balabel{display:block;font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--ink3);margin-bottom:2px}
844
+ /* diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18) — only emitted
845
+ by beforeAfterBoxedHtml(), so these never touch the plain pair above or the
846
+ older per-task renderReviewChecklist before/after (out of scope, untouched). */
847
+ .ba-imgwrap{position:relative;margin-top:8px}
848
+ .ba-imgwrap .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;margin-top:0;display:block}
849
+ .ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
850
+ .ba-zoomhd{margin-top:14px}
851
+ .lg-ev .ba-zoomimg{display:block;width:100%;height:auto;max-height:none;object-fit:initial;margin-top:8px;border-radius:9px;border:1px solid var(--hair2)}
832
852
  .reqbtns{display:flex;gap:8px;margin-top:4px}
833
853
  .reqbtn{height:30px;padding:0 14px;border-radius:9px;border:1px solid var(--hair2);background:transparent;
834
854
  color:var(--ink2);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.15s}
@@ -1781,40 +1801,95 @@
1781
1801
  .sa-ic{width:30px;height:30px;border:0;border-radius:8px;background:transparent;color:var(--ink3);display:grid;place-items:center;cursor:pointer;flex:0 0 30px}
1782
1802
  .sa-ic:hover{color:var(--ink);background:transparent} /* icon hover = glyph colour only (§5.5) */
1783
1803
  .sa-ic svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
1784
- /* ── Context Ledger card (H06 / flagship .lg-*): 1 goal = 1 台帳 — context + evidence
1785
- on one card. Ported verbatim from docs/design/flagship/flagship.html (~L339-420),
1786
- flagship --red/--green-bg substituted for the app's real tokens (--danger,
1787
- rgba(var(--green-rgb),.x)/color-mix see theme.css :root, no --green-bg here). */
1788
- .lg-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:13px}
1804
+ /* ── Review card, evidence-first (H23 2026-07-18 full spec, superseding H22/H06):
1805
+ Evidence sits right under the title/meta row, Context is a closed-by-default
1806
+ <details> with a one-line quote summary, and Evidence itself branches by task
1807
+ kind (image/logic/docs/missing). Context / Done when / Evidence all sit on ONE
1808
+ flush surface now — hairlines only, no more Evidence-only canvas box (§5.5 "面の
1809
+ 処理はパネル全体に均一"). Ported from docs/design/flagship/flagship.html
1810
+ (~L340-434), flagship --red/--green-bg substituted for the app's real tokens
1811
+ (--danger, rgba(var(--green-rgb),.x)). Old Plan/Review-thread/Changed-files&diff
1812
+ folds (.lg-det/.lg-plan/.lg-thread/.lg-diffwrap) are retired in favour of the
1813
+ What-we-did/What-to-look-at bullet lists inside Context. */
1814
+ .lg-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:2px}
1789
1815
  .lg-hd .no{flex:0 0 auto;font:600 11px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
1790
1816
  .lg-hd .t{flex:1;font:600 15px/1.3 var(--ui);letter-spacing:-.014em}
1791
1817
  .lg-state{flex:0 0 auto;font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase} /* state = colour text only (§5.5 no badge) */
1792
1818
  .lg-state.review{color:var(--blue)}
1793
- .lg-ctx{display:grid;grid-template-columns:66px 1fr;gap:7px 12px;margin-bottom:14px;font-size:12.5px;line-height:1.5}
1794
- .lg-ctx .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase}
1795
- .lg-ctx .v{color:var(--ink)}
1796
- .lg-ev{background:var(--canvas);border-radius:12px;padding:11px 13px;margin-bottom:13px}
1819
+ /* meta row: PR link / token savings (real tap-to-open toggle) / Run locally — a
1820
+ compact row directly under the title (H23 §1, pattern 1 won over a plain text
1821
+ line and title-row-right-aligned chips). */
1822
+ .lg-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin:9px 0 13px}
1823
+ .lg-meta .none{font:500 10.5px/1.5 var(--mono);color:var(--ink3);font-style:italic}
1824
+ .sa-prlink.sm{font-size:9.5px;padding:4px 7px}
1825
+ .sa-runlocal{display:inline-flex;align-items:center;gap:5px;font:600 10px/1 var(--mono);letter-spacing:.03em;
1826
+ color:var(--blue);background:transparent;border:1px solid color-mix(in srgb,var(--blue) 35%,transparent);
1827
+ border-radius:7px;padding:5px 9px 5px 8px;cursor:pointer}
1828
+ .sa-runlocal:hover{border-color:var(--blue)}
1829
+ .sa-runlocal svg{width:11px;height:11px;fill:currentColor;stroke:none}
1830
+ .tokchip{display:inline-flex;align-items:center;gap:4px;font:600 9.5px/1 var(--mono);letter-spacing:.02em;color:var(--green);
1831
+ background:rgba(var(--green-rgb),.12);border:0;border-radius:7px;padding:5px 9px;box-shadow:inset 0 0 0 1px rgba(var(--green-rgb),.22);
1832
+ cursor:pointer}
1833
+ .tokchip:hover{box-shadow:inset 0 0 0 1px var(--green)}
1834
+ .tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.75}
1835
+ .tokchip.open .tokchevron{transform:rotate(90deg)}
1836
+ .tokdetail{display:none;margin:0 0 13px;padding:9px 11px;background:var(--canvas);border-radius:8px}
1837
+ .tokdetail.open{display:block}
1838
+ .tokdetail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
1839
+ .tokdetail .v{font:500 11px/1.6 var(--mono);color:var(--ink2)}
1840
+ /* Context: closed by default, one-line quote summary, chevron large + far right (H23 §2) */
1841
+ .lg-ctx{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;font-size:12.5px;line-height:1.5}
1842
+ .lg-fact{display:grid;grid-template-columns:66px 1fr;gap:7px 12px}
1843
+ .lg-fact .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase}
1844
+ .lg-fact .v{color:var(--ink)}
1845
+ .lg-goal{border:0}
1846
+ .lg-goal>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px;color:var(--ink3);min-width:0}
1847
+ .lg-goal>summary::-webkit-details-marker{display:none}
1848
+ .lg-goal>summary .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;flex:0 0 auto}
1849
+ .lg-goal>summary .v{color:var(--ink);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
1850
+ .lg-goal>summary .gtoggle{font-style:normal;font-size:19px;line-height:1;transition:transform .2s;flex:0 0 auto;
1851
+ margin-left:auto;color:var(--ink3);padding-left:10px}
1852
+ .lg-goal:hover>summary{color:var(--ink2)}
1853
+ .lg-goal:hover>summary .gtoggle{color:var(--ink)}
1854
+ .lg-goal[open]>summary .gtoggle{transform:rotate(90deg)}
1855
+ .lg-context{margin-top:10px;padding-top:10px;border-top:1px solid var(--hair)}
1856
+ /* fanned-out goal: list every sibling task/goal so a reviewer never wonders whether
1857
+ the AI silently dropped part of a long ask (H23 §2/§4). */
1858
+ .lg-splitnote{background:var(--canvas);border-radius:8px;padding:10px 12px;margin-bottom:13px}
1859
+ .lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
1860
+ .lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:11.5px;line-height:1.8;color:var(--ink3)}
1861
+ .lg-splitnote li.cur{color:var(--ink)}
1862
+ .lg-splitnote li.cur b{color:var(--green);font-weight:600}
1863
+ .lg-notfound{font-size:11.5px;line-height:1.65;color:var(--ink3);font-style:italic;margin-bottom:13px}
1864
+ .lg-bullethd{font:600 8px/1 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;margin:13px 0 6px}
1865
+ .lg-bullets{margin:0;padding:0 0 0 16px;font-size:12px;line-height:1.65;color:var(--ink2)}
1866
+ .lg-bullets li{margin-bottom:5px}
1867
+ /* Evidence: same flush surface as Context/Done when — hairline only, no boxed canvas
1868
+ (H23 §5.5 correction: "コンテキストとダウンウェインとエビデンスが全部違うレイヤー") */
1869
+ .lg-ev{border-top:1px solid var(--hair);padding-top:13px;margin-top:2px}
1797
1870
  .lg-ev .lg-evhd{font:600 8.5px/1 var(--mono);letter-spacing:.13em;color:var(--ink3);text-transform:uppercase;margin-bottom:10px}
1798
1871
  .lg-ev .row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 15px;font:500 11.5px/1.2 var(--mono);font-variant-numeric:tabular-nums}
1799
1872
  .lg-ev .a{color:var(--green);font-style:normal} .lg-ev .d{color:var(--danger);font-style:normal}
1800
1873
  .lg-ev .fls{color:var(--ink3)}
1801
1874
  .lg-ev img{display:block;width:100%;max-height:190px;object-fit:cover;object-position:top;border-radius:9px;margin-top:11px;cursor:zoom-in}
1802
- /* staged disclosure = native <details> (§1.5-8 answer the folded case first) */
1803
- .lg-det{border-top:1px solid var(--hair)}
1804
- .lg-det>summary{list-style:none;padding:10px 2px;display:flex;align-items:center;gap:9px;cursor:pointer;font-size:12.5px;color:var(--ink2)}
1805
- .lg-det>summary::-webkit-details-marker{display:none}
1806
- .lg-det>summary::before{content:"›";color:var(--ink3);font-size:14px;transition:transform .2s;line-height:1}
1807
- .lg-det[open]>summary::before{transform:rotate(90deg)}
1808
- .lg-det>summary:hover{color:var(--ink)}
1809
- .lg-det>summary .c{margin-left:auto;font:500 10px/1 var(--mono);color:var(--ink3)}
1810
- .lg-det>summary .c .a{color:var(--green);font-style:normal} .lg-det>summary .c .d{color:var(--danger);font-style:normal}
1811
- .lg-plan{padding:2px 2px 11px 16px;font-size:12px;color:var(--ink2)}
1812
- .lg-plan .step{display:flex;gap:9px;padding:3px 0;line-height:1.5}
1813
- .lg-plan .step .n{flex:0 0 auto;font:600 10px/1.6 var(--mono);color:var(--ink3)}
1814
- .lg-thread{padding:0 2px 11px}
1815
- .lg-tmsg{display:flex;gap:9px;padding:4px 0;font-size:12px;line-height:1.5}
1816
- .lg-tmsg .who{flex:0 0 46px;font:600 9.5px/1.7 var(--mono);color:var(--ink3)}
1817
- .lg-diffwrap{padding:0 0 11px}
1875
+ /* logic-only hero: no UI to show, so the number IS the evidence */
1876
+ .lg-herostat{display:flex;align-items:baseline;gap:10px;margin:4px 0 12px}
1877
+ .lg-herostat .n{font:700 30px/1 var(--mono);color:var(--green);font-variant-numeric:tabular-nums}
1878
+ .lg-herostat.fail .n{color:var(--danger)}
1879
+ .lg-herostat .l{font:500 11px/1.4 var(--ui);color:var(--ink3)}
1880
+ /* docs-only hero: GitHub-style diff — added lines get a green band + "+" gutter */
1881
+ .lg-diffblock{background:var(--canvas);border-radius:7px;padding:7px 0;font:400 11.5px/1.8 var(--mono);
1882
+ color:var(--ink2);margin-top:6px;overflow-x:auto}
1883
+ .lg-diffblock .ln{display:flex;padding:0 11px}
1884
+ .lg-diffblock .ln.a{background:rgba(var(--green-rgb),.14)}
1885
+ .lg-diffblock .gutter{flex:0 0 14px;color:var(--green);font-weight:700;user-select:none}
1886
+ .lg-diffblock .ln:not(.a) .gutter{color:transparent}
1887
+ .lg-diffblock .ln.a .txt{color:var(--green)}
1888
+ /* missing-capture hero: an honest small amber note, never a fabricated image */
1889
+ .lg-missinghd{display:flex;align-items:center;gap:8px;font:600 9px/1 var(--mono);letter-spacing:.1em;
1890
+ text-transform:uppercase;color:var(--amber);margin-bottom:9px}
1891
+ .lg-missinghd svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2}
1892
+ .lg-manualnote{font-size:12px;line-height:1.7;color:var(--ink2);background:var(--canvas);border-radius:8px;padding:11px 13px}
1818
1893
  /* risk/conflict = small inline amber, never a filled panel (§1.5-8 don't paint the surface) */
1819
1894
  .lg-risk{display:flex;align-items:center;gap:8px;color:var(--amber);font-size:12px;line-height:1.4;padding:9px 2px;border-top:1px solid var(--hair)}
1820
1895
  .lg-risk svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
@@ -1847,6 +1922,7 @@
1847
1922
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1848
1923
  #fsRoot .tl{position:relative}
1849
1924
  #fsRoot .tlcap{display:block;font-family:var(--mono);font-size:8.5px;letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;padding:13px 0 5px 0} /* left 8->0: sit the DOING/Next up/Later labels on the same spine as the section headings (Masa 2026-07-18) */
1925
+ #fsRoot .tlcap.needs{color:var(--amber)} /* "Needs you" caption tinted to the attention accent, matching the board's Needs-you column (Masa 2026-07-19). Colour on the label only — the rows below carry their own in-row colour per §1.5-8. */
1850
1926
  #fsRoot .trow{position:relative;padding:6px 6px 6px 0;border-radius:8px;font-size:12.5px;color:var(--ink);display:flex;align-items:center;gap:9px} /* left 8->0: row dot lines up with the Review/To Do headings' left edge, not 8px inside it (Masa 2026-07-18) */
1851
1927
  #fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
1852
1928
  #fsRoot .trow:hover{background:var(--hover-s)}
@@ -3231,6 +3307,40 @@ function buildReviewDigest({ goals, tasks } = {}){
3231
3307
  }
3232
3308
  return rows;
3233
3309
  }
3310
+ // Review title: mirrors the label convention used throughout (reviewSummary.changed
3311
+ // → plan[0] → text) — pulled into one place so the review card's own title and its
3312
+ // sibling-goal titles (H23 split note) stay consistent.
3313
+ function saReviewTitle(g){
3314
+ return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3315
+ }
3316
+ // H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
3317
+ // hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
3318
+ // module. flagship's demo data carries an explicit `kind` field
3319
+ // ('image'|'logic'|'docs'|'missing'); real goals/tasks have no such field, so the
3320
+ // review card derives one from real signals it already has (a captured proof image,
3321
+ // whether changed files look like UI source, whether they're all docs-only). This
3322
+ // is a HEURISTIC, not a stored fact on the goal — flagged as a judgment call in the
3323
+ // H23 implementation PR for a human to correct if it misclassifies real data.
3324
+ const UI_TOUCH_RE_LOCAL = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
3325
+ function isUiChangeLocal(files){ return (files ?? []).some((f) => UI_TOUCH_RE_LOCAL.test(String(f ?? ''))); }
3326
+ const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
3327
+ function hasTestRelevantChangesLocal(files){
3328
+ const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
3329
+ if (!fs.length) return true;
3330
+ return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
3331
+ }
3332
+ // Priority: image (a real capture exists) > missing (UI touched, should have a
3333
+ // capture per PRD §4(C) but doesn't) > docs (every changed file is docs-only) >
3334
+ // logic (verified only by tests, no UI). Falls back to 'image' (today's generic
3335
+ // fact-row + no image) when no signal is conclusive — never claims a stronger kind
3336
+ // than the data actually supports.
3337
+ function saEvidenceKind(filePaths, hasProofImg, testResult){
3338
+ if (hasProofImg) return 'image';
3339
+ if (isUiChangeLocal(filePaths)) return 'missing';
3340
+ if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
3341
+ if (testResult?.ran) return 'logic';
3342
+ return 'image';
3343
+ }
3234
3344
  // Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
3235
3345
  // — kept in sync manually since the browser script isn't a module. Used by both
3236
3346
  // the review checklist top summary and the chat-side review导线.
@@ -3435,6 +3545,92 @@ function beforeAfterMediaHtml(taskId, proof, altLabel){
3435
3545
  <div class="ba-col"><span class="balabel">After</span>${proofMediaHtml(taskId, proof, altLabel)}</div>
3436
3546
  </div>`;
3437
3547
  }
3548
+ // Diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18): on top of the
3549
+ // plain before/after pair above, isolate the region that actually changed and draw
3550
+ // a red box over it on both images, plus a cropped/enlarged row below. Computed
3551
+ // client-side from the two PNGs via <canvas> pixel comparison at render time — not
3552
+ // baked in at capture time — so it works retroactively on every proof pair already
3553
+ // on disk with no new server-side image dependency (see the handoff for the full
3554
+ // rationale). Reference algorithm, verified against a real before/after pair
3555
+ // (composer icon swap, 640x200): correctly isolated a 57x52px box centered on the
3556
+ // changed icon — 0.13% image coverage, no false positives elsewhere in the frame.
3557
+ function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0005, maxCoverage = 0.7, pad = 0.03 } = {}) {
3558
+ const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3559
+ if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
3560
+ const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
3561
+ const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
3562
+ c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
3563
+ c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
3564
+ const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
3565
+ const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
3566
+ let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
3567
+ for (let y = 0; y < h; y++) {
3568
+ for (let x = 0; x < w; x++) {
3569
+ const i = (y * w + x) * 4;
3570
+ const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
3571
+ if (d > threshold) {
3572
+ diffPx++;
3573
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
3574
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
3575
+ }
3576
+ }
3577
+ }
3578
+ const total = w * h;
3579
+ if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
3580
+ if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
3581
+ const padPx = Math.round(Math.max(w, h) * pad);
3582
+ minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
3583
+ maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
3584
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
3585
+ }
3586
+ function baLoadImage(src){
3587
+ return new Promise((resolve, reject) => {
3588
+ const im = new Image();
3589
+ im.onload = () => resolve(im);
3590
+ im.onerror = () => reject(new Error(`image failed to load: ${src}`));
3591
+ im.src = src;
3592
+ });
3593
+ }
3594
+ // Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
3595
+ // aspect ratio as the box, never stretched. Magnification is capped at 6x so a
3596
+ // near-single-pixel box doesn't blow up into mush.
3597
+ function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
3598
+ const scale = Math.min(maxW / box.w, maxH / box.h, 6);
3599
+ const outW = Math.max(1, Math.round(box.w * scale));
3600
+ const outH = Math.max(1, Math.round(box.h * scale));
3601
+ const c = document.createElement('canvas'); c.width = outW; c.height = outH;
3602
+ c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
3603
+ return c.toDataURL('image/png');
3604
+ }
3605
+ // Async: loads the before/after PNGs, runs computeDiffBox(), and — only when a real
3606
+ // box is found — renders the before/after pair with a red box overlay on each image
3607
+ // (percent-positioned so it tracks the image's displayed size at any viewport width)
3608
+ // plus a "Zoomed on the boxed area" row below. Returns null on every fallback
3609
+ // condition (no PNG pair, load failure, no clean box) so the caller keeps showing
3610
+ // the plain beforeAfterMediaHtml() pair unchanged — never a guessed or empty box
3611
+ // (HANDOFF-review-card-diffbox-2026-07-18). Only kind==='image' Ledger evidence
3612
+ // (saEvidenceHtml) calls this; the older per-task renderReviewChecklist before/after
3613
+ // is explicitly out of scope for this handoff and stays on beforeAfterMediaHtml.
3614
+ async function beforeAfterBoxedHtml(taskId, proof, altLabel){
3615
+ const beforeSrc = proof?.beforePng, afterSrc = proof?.png;
3616
+ if (!beforeSrc || !afterSrc) return null; // the box needs two real stills, not a GIF frame
3617
+ let beforeImg, afterImg;
3618
+ try {
3619
+ [beforeImg, afterImg] = await Promise.all([
3620
+ baLoadImage(withKey(`/proof/${taskId}/${beforeSrc}`)),
3621
+ baLoadImage(withKey(`/proof/${taskId}/${afterSrc}`)),
3622
+ ]);
3623
+ } catch { return null; } // a missing/broken file is a fallback, not a crash
3624
+ const box = computeDiffBox(beforeImg, afterImg);
3625
+ if (!box) return null;
3626
+ const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3627
+ const boxStyle = `left:${(box.x / w * 100).toFixed(3)}%;top:${(box.y / h * 100).toFixed(3)}%;width:${(box.w / w * 100).toFixed(3)}%;height:${(box.h / h * 100).toFixed(3)}%`;
3628
+ const frame = (img, label) => `<div class="ba-col"><span class="balabel">${label}</span><div class="ba-imgwrap"><img class="proofimg" src="${img.src}" alt="${label.toLowerCase()}: ${altLabel}" loading="lazy"><div class="ba-box" style="${boxStyle}"></div></div></div>`;
3629
+ const zoomCol = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label.toLowerCase()}: ${altLabel}"></div>`;
3630
+ return `<div class="beforeafter">${frame(beforeImg, 'Before')}${frame(afterImg, 'After')}</div>
3631
+ <div class="lg-evhd ba-zoomhd">Zoomed on the boxed area</div>
3632
+ <div class="beforeafter ba-zoomrow">${zoomCol(baZoomDataUrl(beforeImg, box), 'before')}${zoomCol(baZoomDataUrl(afterImg, box), 'after')}</div>`;
3633
+ }
3438
3634
  // swap the still for the real video only on demand, one at a time
3439
3635
  document.addEventListener('click', (e) => {
3440
3636
  const w = e.target.closest('.proofwrap');
@@ -5554,19 +5750,27 @@ function renderFsLane(){
5554
5750
  renderFsReview(); renderFsTodo();
5555
5751
  if (fsUI.todoView === 'board') renderFsBoard();
5556
5752
  }
5557
- // Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and
5558
- // the status chips can never drift; the two columns the chips have no word for
5559
- // (Later / Needs you) are spelled out here. Pure: goal in, column key out.
5560
- function fsBoardBucket(goal){
5753
+ // Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and the
5754
+ // status chips can never drift; the two columns the chips have no word for (Later / Needs
5755
+ // you) are spelled out here. Takes the goal's TASKS so it can tell a genuinely in-flight
5756
+ // goal from one whose status got stuck 'running' after every task already finished — that
5757
+ // stale case is what piled old goals into Doing (Masa 2026-07-19). Pure: goal + tasks in.
5758
+ function fsBoardBucket(goal, tasks){
5561
5759
  const s = goal?.status;
5562
5760
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5563
5761
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
5564
5762
  if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
5565
5763
  if (s === 'review') return 'review';
5566
- if (s === 'done' || s === 'reverted') return 'done'; // both terminal; REVERTED is not "in flight"
5764
+ if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
5567
5765
  if (s === 'stacked' || s === 'sending') return 'todo';
5568
- if (s === 'running' && !goal.startedAt) return 'todo'; // goalChip: planned, nothing started yet
5569
- return 'doing'; // planning / running / retesting actively in flight only
5766
+ if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
5767
+ // Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
5768
+ // goal whose status stuck 'running' after every task already finished is NOT moving —
5769
+ // that is exactly what piled stale goals into Doing (Masa 2026-07-19, "Doing に11件").
5770
+ // If it has tasks and they're all terminal, it belongs in Done, not Doing.
5771
+ const mine = (tasks ?? []).filter((t) => t.goalId === goal?.id && !t.reply);
5772
+ if (mine.length && mine.every((t) => ['done', 'skipped'].includes(t.status))) return 'done';
5773
+ return 'doing'; // planning / retesting / actively in flight
5570
5774
  }
5571
5775
  // Kanban board — GOAL-level: one card per goal, never per task.
5572
5776
  //
@@ -5601,7 +5805,7 @@ function renderFsBoard(){
5601
5805
  // the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
5602
5806
  const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
5603
5807
  const goalCard = (g) => {
5604
- const bkt = fsBoardBucket(g);
5808
+ const bkt = fsBoardBucket(g, list);
5605
5809
  // errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
5606
5810
  // not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
5607
5811
  const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
@@ -5636,7 +5840,7 @@ function renderFsBoard(){
5636
5840
  };
5637
5841
  const no = (g) => goalNo.get(g.id) ?? Infinity;
5638
5842
  const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
5639
- for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
5843
+ for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
5640
5844
  for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
5641
5845
  // To Do / Doing / Needs you / Review / Done / Later — the original design's columns
5642
5846
  // (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
@@ -5840,7 +6044,7 @@ function fsBucketTodo(){
5840
6044
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
5841
6045
  function renderFsTodo(){
5842
6046
  const el = $('fsTodoSec'); if (!el) return;
5843
- const { pgoals, list, running, failed, blocked, queued, doneAll, later } = fsBucketTodo();
6047
+ const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
5844
6048
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
5845
6049
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
5846
6050
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
@@ -5855,16 +6059,29 @@ function renderFsTodo(){
5855
6059
  const goalNo = new Map();
5856
6060
  for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
5857
6061
  const noSpan = (goalId) => { const n = goalId != null ? goalNo.get(goalId) : null; return n != null ? `<span class="gno" data-n="${n}">#${n}</span>` : ''; };
6062
+ // DOING = only what is actively moving (running task rows). Stopped/errored work moved
6063
+ // out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
6064
+ // as "still in flight".
5858
6065
  const doingRows =
5859
6066
  running.map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
5860
- <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
6067
+ <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('');
6068
+ // NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
6069
+ // it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
6070
+ // keeping the affordances it already had — nothing about the failed-task and blocked-goal
6071
+ // rows changed except the caption they sit under:
6072
+ // • a failed/interrupted TASK — the rich Retry/Resume row;
6073
+ // • a BLOCKED goal — the Retry/Archive goal row (a blocked goal can ALSO have a stopped
6074
+ // task; both rows show, as before — they carry different, goal- vs task-level actions);
6075
+ // • a needsInput goal — new here: a clarifying question waiting on you (Answer opens it,
6076
+ // where the chat feed's question card takes the actual answer).
6077
+ const needsRows =
5861
6078
  // Doing-failure UI (HANDOFF-v45 §11, revises v45 §6): a stopped task shows NO progress
5862
6079
  // bar (a dead bar pushed Retry down) — state is a COLOURED TEXT WORD only, no badge.
5863
6080
  // Interrupted (amber, ⏸, "Resume") is distinguished from Failed (red, ✗, "Retry") —
5864
6081
  // both still call the same /retry endpoint, which already resumes the session when one
5865
6082
  // exists (server-side retry = "resume if possible, else re-run"; there is no separate
5866
6083
  // resume endpoint to call).
5867
- + failed.map((t) => { const isInt = t.status === 'interrupted';
6084
+ failed.map((t) => { const isInt = t.status === 'interrupted';
5868
6085
  const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
5869
6086
  // don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
5870
6087
  // when the task has no distinct result text — the coloured word already said it, so
@@ -5877,7 +6094,10 @@ function renderFsTodo(){
5877
6094
  <div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
5878
6095
  + blocked.map((g) => `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
5879
6096
  <div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
5880
- <div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('');
6097
+ <div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
6098
+ + needsInput.map((g) => `<div class="drow err int"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
6099
+ <div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
6100
+ <div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
5881
6101
  const NCAP = 6;
5882
6102
  const upShown = fsUI.todoMore ? queued : queued.slice(0, NCAP);
5883
6103
  const seenGoals = new Set();
@@ -5898,11 +6118,12 @@ function renderFsTodo(){
5898
6118
  // "Next up" pool only) — so a project sitting in Doing/Later with an empty Next-up
5899
6119
  // queue showed "To Do 0" directly above visible Doing/Later rows. The header labels
5900
6120
  // the whole lane ("To Do"), so it now counts everything the lane actually renders
5901
- // below it: Doing (running + failed/interrupted + blocked-goal rows) + Next up
5902
- // (queued) + Later (pending goals). "· next-up order" stays keyed off queued.length
5903
- // specifically, since that suffix describes the Next-up sublist's own sort, not the
5904
- // lane total.
5905
- const todoTotal = running.length + failed.length + blocked.length + queued.length + later.length;
6121
+ // below it: Doing (running) + Needs you (failed/interrupted tasks + stopped/needsInput
6122
+ // goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
6123
+ // queued.length specifically, since that suffix describes the Next-up sublist's own sort,
6124
+ // not the lane total.
6125
+ const needsCount = failed.length + blocked.length + needsInput.length;
6126
+ const todoTotal = running.length + needsCount + queued.length + later.length;
5906
6127
  // Free-tier pending counter (H22): on the free plan the header shows the wall
5907
6128
  // — pending goals out of the 5-slot cap — reddening at the cap and gone once
5908
6129
  // paid. Reads the SAME numbers the gate enforces (state.billing, never a local
@@ -5917,7 +6138,8 @@ function renderFsTodo(){
5917
6138
  el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
5918
6139
  <div class="tl">
5919
6140
  ${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
5920
- ${upRows ? `<span class="tlcap"${doingRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6141
+ ${needsRows ? `<span class="tlcap needs"${doingRows ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6142
+ ${upRows ? `<span class="tlcap"${doingRows || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
5921
6143
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
5922
6144
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
5923
6145
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
@@ -7411,7 +7633,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
7411
7633
  // 4 archived / 5 reverted — session-local; the server round-trip is what
7412
7634
  // settles it (see saSync's "returned to review" reset).
7413
7635
  // ============================================================================
7414
- const SA = { open: false, items: [], diffs: {} , detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
7636
+ const SA = { open: false, items: [], diffs: {}, diffBoxHtml: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
7415
7637
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
7416
7638
  const SA_SVG = {
7417
7639
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -7424,27 +7646,40 @@ function saRows(){
7424
7646
  return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => {
7425
7647
  const g = gs.find((x) => x.id === r.goalIds[0]);
7426
7648
  const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
7427
- // Review thread: every rework/reply task for this goal is one round —
7428
- // the user's own text (queueReplyTask stores it as title/detail) followed
7429
- // by the worker's report (task.result), if it has finished.
7430
- const thread = ts.filter((t) => r.goalIds.includes(t.goalId) && t.reply)
7431
- .sort((a, b) => (a.num ?? 0) - (b.num ?? 0))
7432
- .flatMap((t) => {
7433
- const msgs = [['You', String(t.detail || t.title || '').replace(/\s+/g, ' ').trim()]];
7434
- if (t.result) msgs.push(['Claude', String(t.result).replace(/\s+/g, ' ').trim().slice(0, 400)]);
7435
- return msgs;
7436
- });
7649
+ // "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
7650
+ // per finished requirement task's own report; falls back to the goal's plan
7651
+ // steps when nothing has finished yet (e.g. a plan awaiting approval — the plan
7652
+ // IS "what we're going to do" in that case, which is the honest content to show).
7653
+ const doneTasks = ts.filter((t) => r.goalIds.includes(t.goalId) && !t.reply && t.result)
7654
+ .sort((a, b) => (a.num ?? 0) - (b.num ?? 0));
7655
+ const did = doneTasks.length
7656
+ ? doneTasks.map((t) => String(t.result).replace(/\s+/g, ' ').trim().slice(0, 160))
7657
+ : (Array.isArray(g?.plan) ? g.plan.filter(Boolean).map((s) => String(s).replace(/\s+/g, ' ').trim()) : []);
7658
+ // Sibling goals (H23 §2/§4 "did the AI drop part of what I asked"): buildReviewDigest
7659
+ // already collapses goals that share one PR into a single row (goalIds.length > 1)
7660
+ // — the real-data analog of a long ask fanning out into several tracked pieces.
7661
+ // The row's primary goal (goalIds[0], whose text/title drive this card) is marked
7662
+ // "cur"; the rest are listed so nothing reads as silently dropped.
7663
+ const siblings = r.goalIds.length > 1
7664
+ ? r.goalIds.map((gid) => {
7665
+ const sg = gs.find((x) => x.id === gid);
7666
+ return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
7667
+ })
7668
+ : null;
7437
7669
  return {
7438
7670
  ids: r.goalIds, goalId: r.goalIds[0], goal: g,
7439
7671
  no: r.reviewNumber,
7440
- title: String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90),
7672
+ title: saReviewTitle(g),
7441
7673
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
7442
7674
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
7443
- plan: Array.isArray(g?.plan) ? g.plan.filter(Boolean) : [],
7444
- thread, risk: g?.risk ?? null,
7675
+ did, siblings, risk: g?.risk ?? null,
7445
7676
  note: g?.retest?.passed ? g.retest.note : null,
7446
7677
  pr: r.pr, testResult: r.testResult,
7447
7678
  proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
7679
+ // Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
7680
+ // §3 kind==='image'): most tasks never have a "before" capture, so this is almost
7681
+ // always null and beforeAfterMediaHtml() falls back to the single-shot view.
7682
+ proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
7448
7683
  // Token-frugality is the product's whole pitch — surface it here too, not
7449
7684
  // just in the per-goal review modal. buildReviewSummary computes the same
7450
7685
  // estimate; reuse its logic directly off the goal's runUsage/runTokens.
@@ -7461,6 +7696,79 @@ function saRows(){
7461
7696
  };
7462
7697
  });
7463
7698
  }
7699
+ // Meta row: PR link / token savings (real tap-to-open toggle) / Run locally — under
7700
+ // the title (H23 §1). Run-locally has no real per-task command yet — this is the
7701
+ // CDO's answer to "let me test this locally", left for CTO/engine to decide what it
7702
+ // actually triggers (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
7703
+ // §5). Shown only for tasks with a real automated check to plausibly re-run
7704
+ // (it.testResult.ran), and tapping it is honest about not being wired yet rather
7705
+ // than pretending to execute anything — never a fake "running…" state.
7706
+ function saMetaRowHtml(it, i){
7707
+ const canRunLocal = Boolean(it.testResult?.ran);
7708
+ if (!it.pr && !it.tokenOpt && !canRunLocal) {
7709
+ return '<div class="lg-meta"><span class="none">No PR link / token-savings data on this task.</span></div>';
7710
+ }
7711
+ const chips = [];
7712
+ if (it.pr) chips.push(`<a class="sa-prlink sm" href="${esc(it.pr)}" target="_blank" title="Open pull request">PR #${esc((it.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>`);
7713
+ if (it.tokenOpt) {
7714
+ const pct = Number(it.tokenOpt.percent);
7715
+ const label = pct > 0 ? `saved ${pct}% tokens` : 'token usage';
7716
+ chips.push(`<button class="tokchip" type="button" data-saact="tok:${i}">${esc(label)} <i class="tokchevron">›</i></button>`);
7717
+ }
7718
+ if (canRunLocal) chips.push(`<button class="sa-runlocal" type="button" data-saact="runlocal:${i}" title="Run this locally"><svg viewBox="0 0 24 24"><path d="M6 4l14 8-14 8V4z"/></svg>Run locally</button>`);
7719
+ const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
7720
+ const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
7721
+ const tokDetail = it.tokenOpt ? `<div class="tokdetail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)} · ${esc(tokTech)}</span></div>` : '';
7722
+ const runNote = canRunLocal ? `<div class="tokdetail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
7723
+ return `<div class="lg-meta">${chips.join('')}</div>${tokDetail}${runNote}`;
7724
+ }
7725
+ // Context: closed by default, one-line verbatim-quote summary, chevron large and at
7726
+ // the far right (H23 §2). Quotes goal.text — the user's own words — never
7727
+ // task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
7728
+ // the AI's own read on the request as "your ask" and it wasn't recognizable).
7729
+ function saContextHtml(it, gk){
7730
+ const goalTxt = it.goalText;
7731
+ const summary = goalTxt
7732
+ ? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
7733
+ : 'No verbatim ask on record for this goal.';
7734
+ const split = it.siblings
7735
+ ? `<div class="lg-splitnote"><div class="hd">Split into ${it.siblings.length} goals — nothing else was dropped</div><ul>${it.siblings.map((s) => `<li class="${s.cur ? 'cur' : ''}">${s.no != null ? `#${esc(s.no)} ` : ''}${esc(s.title)}${s.cur ? ' <b>(this one)</b>' : ''}</li>`).join('')}</ul></div>`
7736
+ : '';
7737
+ const did = it.did?.length ? `<div class="lg-bullethd">What we did</div><ul class="lg-bullets">${it.did.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
7738
+ const look = it.look?.length ? `<div class="lg-bullethd">What to look at</div><ul class="lg-bullets">${it.look.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
7739
+ const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
7740
+ const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
7741
+ return `<details class="lg-goal" data-detkey="${gk}:ctx"${open}><summary><span class="k">Context</span><span class="v">${summary}</span><i class="gtoggle">›</i></summary><div class="lg-context">${inner}</div></details>`;
7742
+ }
7743
+ // Evidence hero: shape follows task kind, not one image-shaped template (H23 §3).
7744
+ // Real data check: most finished tasks have no proof image at all (logic-only /
7745
+ // docs-only / research changes verify without a screenshot) — kind is derived, see
7746
+ // saEvidenceKind() above.
7747
+ function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
7748
+ if (kind === 'logic') {
7749
+ const passed = gtr?.passed ?? 0, failed = gtr?.failed ?? 0, ok = gtr?.ok !== false;
7750
+ const n = ok ? String(passed) : `${passed}/${passed + failed}`;
7751
+ return `<div class="lg-ev"><div class="lg-evhd">Evidence — tests, no UI to show</div>
7752
+ <div class="lg-herostat${ok ? '' : ' fail'}"><span class="n">${esc(n)}</span><span class="l">tests passed<br>${esc(filePaths.join(', '))}</span></div></div>`;
7753
+ }
7754
+ if (kind === 'docs') {
7755
+ const lines = diffText ? diffText.split('\n').filter((l) => l.length) : [];
7756
+ const diffHtml = lines.map((l) => {
7757
+ const isAdd = l.startsWith('+');
7758
+ return `<div class="ln${isAdd ? ' a' : ''}"><span class="gutter">+</span><span class="txt">${esc(l.slice(1) || l)}</span></div>`;
7759
+ }).join('');
7760
+ return `<div class="lg-ev"><div class="lg-evhd">Evidence — the diff, no test to run</div>
7761
+ <div class="row"><span class="fls">${esc(filePaths.join(', '))} · docs-only, no test target</span></div>
7762
+ ${diffHtml ? `<div class="lg-diffblock">${diffHtml}</div>` : ''}</div>`;
7763
+ }
7764
+ if (kind === 'missing') {
7765
+ const note = it.check || 'Please verify this change manually — no automated capture exists for it.';
7766
+ return `<div class="lg-ev"><div class="lg-missinghd"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>No capture — this touched the UI and should have one</div>
7767
+ <div class="lg-manualnote">${esc(note)}</div></div>`;
7768
+ }
7769
+ // kind === 'image' (default)
7770
+ return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
7771
+ }
7464
7772
  function saItemHtml(it, i){
7465
7773
  const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed — re-testing ×3'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
7466
7774
  if (done) {
@@ -7473,46 +7781,50 @@ function saItemHtml(it, i){
7473
7781
  // Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
7474
7782
  // renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
7475
7783
  // chip, never silently dropped — an untested/proofless goal must never LOOK verified.
7784
+ // H23: facts stop looking like buttons — glyph carries the state colour, text
7785
+ // stays plain ink, no border/pill (border is reserved for the PR link).
7476
7786
  const gtr = it.testResult;
7477
7787
  const testsChip = gtr?.ran
7478
- ? (gtr.ok ? `<span class="vchip ok">✓ ${gtr.passed} passed</span>` : `<span class="vchip no">✗ ${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed</span>`)
7479
- : '<span class="vchip none">— not tested</span>';
7788
+ ? (gtr.ok ? `<span class="vf ok"><i class="g">✓</i>${gtr.passed} passed</span>` : `<span class="vf no"><i class="g">✗</i>${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed</span>`)
7789
+ : '<span class="vf none">— not tested</span>';
7480
7790
  const hasProofImg = it.proofImg && it.proofTaskId != null;
7481
- const pm = d ? `<span><i class="a">+${d.add}</i> <i class="d">−${d.del}</i></span>` : ''; // omit when unknown (v46)
7482
- const pr = it.pr ? `<a class="sa-prlink" href="${esc(it.pr)}" target="_blank" title="Open pull request">PR #${esc((it.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>` : '';
7483
- const img = hasProofImg ? `<img class="proofimg" src="${withKey(`/proof/${it.proofTaskId}/${it.proofImg}`)}" alt="proof" loading="lazy">` : '';
7484
- // Details: PR description (goal text + plain-language changed line) changed
7485
- // files code diff. Server has no diff for this goal → render what exists
7486
- // and omit the rest honestly (no fabrication).
7487
- const desc = esc(String(it.goal?.reviewSummary?.changed ? `${it.goal.reviewSummary.changed} ${it.goal?.text ?? ''}` : it.goal?.text ?? '').slice(0, 500));
7488
- let files = '', diffBlock = '', miss = '', nf = 0;
7489
- if (d) {
7490
- nf = d.files.length;
7491
- files = nf ? `<div class="files">${d.files.map((f) => `${esc(f.path)} +${f.add} −${f.del}`).join('<br>')}</div>` : '';
7492
- diffBlock = d.diff ? `<div class="sa-diff">${d.diff.split('\n').map((l) => `<div class="${l.startsWith('+') ? 'ln-a' : l.startsWith('-') ? 'ln-d' : ''}">${esc(l) || ' '}</div>`).join('')}${d.truncated ? '<div>… diff truncated</div>' : ''}</div>` : '';
7493
- } else if (d === null) {
7494
- const cf = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
7495
- nf = cf.length;
7496
- files = nf ? `<div class="files">${cf.map(esc).join('<br>')}</div>` : '';
7497
- miss = '<div class="miss">no code diff recorded for this goal</div>';
7791
+ // Before/after (H23 spec §3 kind==='image'): reuse the same beforeafter/ba-col/
7792
+ // balabel renderer as the older per-task checklist (beforeAfterMediaHtml, ~L3281).
7793
+ // it.proofBefore is null for the ~all tasks with no "before" capture, so this
7794
+ // falls back to the identical single <img class="proofimg"> markup it replaced.
7795
+ // Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18) is computed async
7796
+ // after the card first paints (saEnhanceDiffBoxes, below saRender). SA.diffBoxHtml
7797
+ // holds the result a string once a box is found, null once ruled out, undefined
7798
+ // until then so it survives every later saRender() the same way SA.diffs already
7799
+ // does for the changed-files diff.
7800
+ const boxedImg = SA.diffBoxHtml[it.goalId];
7801
+ const img = hasProofImg
7802
+ ? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, 'proof'))
7803
+ : '';
7804
+ // Changed-file paths feed both the evidence-kind heuristic and the docs-kind diff
7805
+ // same source the old "Changed files & diff" fold used (d from the diff endpoint,
7806
+ // falling back to task.changedFiles when the server has no diff for this goal).
7807
+ let filePaths = [], nf = 0;
7808
+ if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
7809
+ else if (d === null) {
7810
+ filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
7811
+ nf = filePaths.length;
7498
7812
  }
7499
- const evRow = [
7813
+ const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
7814
+ const factRow = [
7500
7815
  testsChip,
7501
- it.note ? `<span class="vchip ok">✓ ${esc(it.note)}</span>` : '',
7502
- pm,
7816
+ it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
7503
7817
  `<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
7504
- pr,
7505
- hasProofImg ? '' : '<span class="vchip none">— no proof</span>',
7818
+ hasProofImg ? '' : '<span class="vf none">— no proof</span>',
7506
7819
  ].filter(Boolean).join('');
7507
7820
  const gk = it.goalId;
7508
- const planHtml = it.plan?.length
7509
- ? `<details class="lg-det" data-detkey="${gk}:plan"${SA.detOpen.has(`${gk}:plan`) ? ' open' : ''}><summary>Plan<span class="c">${it.plan.length} steps</span></summary><div class="lg-plan">${it.plan.map((s, n) => `<div class="step"><span class="n">${n + 1}</span><span>${esc(s)}</span></div>`).join('')}</div></details>`
7510
- : '';
7511
- const threadHtml = it.thread?.length
7512
- ? `<details class="lg-det" data-detkey="${gk}:thread"${SA.detOpen.has(`${gk}:thread`) ? ' open' : ''}><summary>Review thread<span class="c">${it.thread.length}</span></summary><div class="lg-thread">${it.thread.map((m) => `<div class="lg-tmsg"><span class="who">${esc(m[0])}</span><span>${esc(m[1])}</span></div>`).join('')}</div></details>`
7513
- : '';
7514
- const diffCount = d ? `${nf} · <i class="a">+${d.add}</i> <i class="d">−${d.del}</i>` : `${nf} file${nf === 1 ? '' : 's'}`;
7515
- const diffHtml = `<details class="lg-det" data-detkey="${gk}:diff"${SA.detOpen.has(`${gk}:diff`) ? ' open' : ''}><summary>Changed files &amp; diff<span class="c">${diffCount}</span></summary><div class="lg-diffwrap"><div class="desc">${desc}</div>${files}${diffBlock}${miss}</div></details>`;
7821
+ // "What to look at" (H23 §2): the changed files themselves — a real, non-fabricated
7822
+ // pointer for the reviewer's eyes, same file list that used to live in the retired
7823
+ // "Changed files & diff" fold.
7824
+ const look = filePaths.slice(0, 8);
7825
+ if (filePaths.length > 8) look.push(`+${filePaths.length - 8} more`);
7826
+ const contextHtml = saContextHtml({ goalText: it.goalText, siblings: it.siblings, did: it.did, look }, gk);
7827
+ const evidenceHtml = saEvidenceHtml(kind, factRow, img, filePaths, d?.diff ?? null, gtr, it);
7516
7828
  // shouldPause = classifyChangeRisk (engine/lib.mjs) already forced this goal to
7517
7829
  // stop in review for exactly this reason — surface it; a non-blocking low-risk
7518
7830
  // read isn't worth a permanent row on every card (goal 427 §4 already covers it
@@ -7523,16 +7835,9 @@ function saItemHtml(it, i){
7523
7835
  : '';
7524
7836
  return `<div class="sa-item lg${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
7525
7837
  <div class="lg-hd"><span class="no">${it.no != null ? `#${it.no}` : ''}</span><span class="t">${esc(it.title)}</span><span class="lg-state review">Review</span></div>
7526
- <div class="lg-ctx"><span class="k">Goal</span><span class="v">${esc(it.goalText || it.title)}</span><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div>
7527
- <div class="lg-ev">
7528
- <div class="lg-evhd">Evidence</div>
7529
- <div class="row">${evRow}</div>
7530
- ${tokenOptimizationHtml(it.tokenOpt)}
7531
- ${img}
7532
- </div>
7533
- ${planHtml}
7534
- ${threadHtml}
7535
- ${diffHtml}
7838
+ ${saMetaRowHtml(it, i)}
7839
+ <div class="lg-ctx">${contextHtml}<div class="lg-fact"><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div></div>
7840
+ ${evidenceHtml}
7536
7841
  ${riskHtml}
7537
7842
  <div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
7538
7843
  </div>`;
@@ -7550,7 +7855,7 @@ function saRender(){
7550
7855
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
7551
7856
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
7552
7857
  // and wire each one's toggle to keep that set current.
7553
- for (const det of S.querySelectorAll('.lg-det[data-detkey]')) {
7858
+ for (const det of S.querySelectorAll('.lg-goal[data-detkey]')) {
7554
7859
  det.addEventListener('toggle', () => {
7555
7860
  const k = det.dataset.detkey;
7556
7861
  if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
@@ -7597,6 +7902,25 @@ async function saFetchDiffs(){
7597
7902
  }));
7598
7903
  if (SA.open) saSync();
7599
7904
  }
7905
+ // Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18): unlike
7906
+ // saFetchDiffs above (whose fetch settling triggers a full saSync()/saRender()), this
7907
+ // patches each card's already-rendered .beforeafter node directly — a full re-render
7908
+ // here would reset scroll, close every open <details>, and drop an in-progress reply
7909
+ // keystroke, all just to draw a box around one screenshot. SA.diffBoxHtml is still
7910
+ // cached per goal, so if a later SSE-driven saSync() does rebuild the card anyway,
7911
+ // saItemHtml() picks the cached result back up with no flicker to the plain pair.
7912
+ async function saEnhanceDiffBoxes(){
7913
+ const targets = SA.items.filter((it) => it.proofImg && it.proofTaskId != null && it.proofBefore && SA.diffBoxHtml[it.goalId] === undefined);
7914
+ await Promise.all(targets.map(async (it) => {
7915
+ let html = null;
7916
+ try { html = await beforeAfterBoxedHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')); }
7917
+ catch { html = null; } // a decode/pixel-diff failure is a fallback, never a crash
7918
+ SA.diffBoxHtml[it.goalId] = html; // cache even on null — never re-attempt an already-answered "no box"
7919
+ if (!html || !SA.open) return;
7920
+ const host = document.querySelector(`.sa-item[data-gid="${it.goalId}"] .beforeafter`);
7921
+ if (host) host.outerHTML = html; // html itself opens with the replacement .beforeafter
7922
+ }));
7923
+ }
7600
7924
  function saOpen(focusGoalId){
7601
7925
  SA.open = true;
7602
7926
  SA.items = saRows().map((r) => ({ ...r, st: 0 }));
@@ -7618,6 +7942,7 @@ function saOpen(focusGoalId){
7618
7942
  saRender();
7619
7943
  $('seeall').classList.add('open');
7620
7944
  saFetchDiffs();
7945
+ saEnhanceDiffBoxes();
7621
7946
  }
7622
7947
  function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
7623
7948
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
@@ -7676,6 +8001,10 @@ $('seeall').addEventListener('click', async (e) => {
7676
8001
  const it = SA.items[i];
7677
8002
  if (k === 'close') return saClose();
7678
8003
  if (!it) return;
8004
+ // meta-row toggles (H23): tap the token chip to reveal the technique breakdown, tap
8005
+ // Run-locally to reveal the honest "not wired yet" note — neither performs an action.
8006
+ if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
8007
+ if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
7679
8008
  // every verdict auto-advances the focus to the next pending card (tempo).
7680
8009
  if (k === 'app') { const bx = b.getBoundingClientRect(); confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); return saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); }
7681
8010
  if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });