@galda/cli 0.10.34 → 0.10.36

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}
@@ -1526,6 +1546,20 @@
1526
1546
  .fsbcol:hover .fsbcadd{opacity:.7}
1527
1547
  .fsbcadd:hover{opacity:1;color:var(--ink);background:var(--hover)}
1528
1548
  .fsbcadd svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
1549
+ /* Doing列の一時停止/再生トグル (Masa 2026-07-18・Spotify式). Running=hoverで出る一時停止
1550
+ ❚❚ / paused=常時表示の再生 ▶ (停止状態が読める+再開できる). hoverは面を塗らずグリフ
1551
+ 色のみ (DESIGN-RULES §5.5). 中立グレー: 緑/赤はstatusに読めるので使わない. board版=col
1552
+ ヘッダの Doing ラベル隣, list版=下の .tlcap-pause が #fsTodoSec の DOING 見出し隣. */
1553
+ .fsbcpause{width:20px;height:20px;display:grid;place-items:center;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
1554
+ .fsbcol:hover .fsbcpause{opacity:.7}
1555
+ .fsbcpause:hover{opacity:1;color:var(--ink)}
1556
+ .fsbcpause[aria-pressed="true"]{opacity:1;color:var(--ink)}
1557
+ .fsbcpause svg{width:15px;height:15px;fill:currentColor;stroke:none}
1558
+ .tlcap-pause{display:inline-grid;place-items:center;width:15px;height:15px;vertical-align:-3px;margin-left:6px;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:4px;opacity:0;transition:opacity .12s,color .12s}
1559
+ #fsTodoSec:hover .tlcap-pause{opacity:.7}
1560
+ .tlcap-pause:hover{opacity:1;color:var(--ink)}
1561
+ .tlcap-pause[aria-pressed="true"]{opacity:1;color:var(--ink)}
1562
+ .tlcap-pause svg{width:11px;height:11px;fill:currentColor;stroke:none}
1529
1563
  .fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
1530
1564
  .fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1531
1565
  /* Board card = Depth (CDO, Masa 2026-07-17): a card is SCANNED, not read — #NN + a
@@ -1781,40 +1815,95 @@
1781
1815
  .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
1816
  .sa-ic:hover{color:var(--ink);background:transparent} /* icon hover = glyph colour only (§5.5) */
1783
1817
  .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}
1818
+ /* ── Review card, evidence-first (H23 2026-07-18 full spec, superseding H22/H06):
1819
+ Evidence sits right under the title/meta row, Context is a closed-by-default
1820
+ <details> with a one-line quote summary, and Evidence itself branches by task
1821
+ kind (image/logic/docs/missing). Context / Done when / Evidence all sit on ONE
1822
+ flush surface now — hairlines only, no more Evidence-only canvas box (§5.5 "面の
1823
+ 処理はパネル全体に均一"). Ported from docs/design/flagship/flagship.html
1824
+ (~L340-434), flagship --red/--green-bg substituted for the app's real tokens
1825
+ (--danger, rgba(var(--green-rgb),.x)). Old Plan/Review-thread/Changed-files&diff
1826
+ folds (.lg-det/.lg-plan/.lg-thread/.lg-diffwrap) are retired in favour of the
1827
+ What-we-did/What-to-look-at bullet lists inside Context. */
1828
+ .lg-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:2px}
1789
1829
  .lg-hd .no{flex:0 0 auto;font:600 11px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
1790
1830
  .lg-hd .t{flex:1;font:600 15px/1.3 var(--ui);letter-spacing:-.014em}
1791
1831
  .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
1832
  .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}
1833
+ /* meta row: PR link / token savings (real tap-to-open toggle) / Run locally — a
1834
+ compact row directly under the title (H23 §1, pattern 1 won over a plain text
1835
+ line and title-row-right-aligned chips). */
1836
+ .lg-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin:9px 0 13px}
1837
+ .lg-meta .none{font:500 10.5px/1.5 var(--mono);color:var(--ink3);font-style:italic}
1838
+ .sa-prlink.sm{font-size:9.5px;padding:4px 7px}
1839
+ .sa-runlocal{display:inline-flex;align-items:center;gap:5px;font:600 10px/1 var(--mono);letter-spacing:.03em;
1840
+ color:var(--blue);background:transparent;border:1px solid color-mix(in srgb,var(--blue) 35%,transparent);
1841
+ border-radius:7px;padding:5px 9px 5px 8px;cursor:pointer}
1842
+ .sa-runlocal:hover{border-color:var(--blue)}
1843
+ .sa-runlocal svg{width:11px;height:11px;fill:currentColor;stroke:none}
1844
+ .tokchip{display:inline-flex;align-items:center;gap:4px;font:600 9.5px/1 var(--mono);letter-spacing:.02em;color:var(--green);
1845
+ 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);
1846
+ cursor:pointer}
1847
+ .tokchip:hover{box-shadow:inset 0 0 0 1px var(--green)}
1848
+ .tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.75}
1849
+ .tokchip.open .tokchevron{transform:rotate(90deg)}
1850
+ .tokdetail{display:none;margin:0 0 13px;padding:9px 11px;background:var(--canvas);border-radius:8px}
1851
+ .tokdetail.open{display:block}
1852
+ .tokdetail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
1853
+ .tokdetail .v{font:500 11px/1.6 var(--mono);color:var(--ink2)}
1854
+ /* Context: closed by default, one-line quote summary, chevron large + far right (H23 §2) */
1855
+ .lg-ctx{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;font-size:12.5px;line-height:1.5}
1856
+ .lg-fact{display:grid;grid-template-columns:66px 1fr;gap:7px 12px}
1857
+ .lg-fact .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase}
1858
+ .lg-fact .v{color:var(--ink)}
1859
+ .lg-goal{border:0}
1860
+ .lg-goal>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px;color:var(--ink3);min-width:0}
1861
+ .lg-goal>summary::-webkit-details-marker{display:none}
1862
+ .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}
1863
+ .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}
1864
+ .lg-goal>summary .gtoggle{font-style:normal;font-size:19px;line-height:1;transition:transform .2s;flex:0 0 auto;
1865
+ margin-left:auto;color:var(--ink3);padding-left:10px}
1866
+ .lg-goal:hover>summary{color:var(--ink2)}
1867
+ .lg-goal:hover>summary .gtoggle{color:var(--ink)}
1868
+ .lg-goal[open]>summary .gtoggle{transform:rotate(90deg)}
1869
+ .lg-context{margin-top:10px;padding-top:10px;border-top:1px solid var(--hair)}
1870
+ /* fanned-out goal: list every sibling task/goal so a reviewer never wonders whether
1871
+ the AI silently dropped part of a long ask (H23 §2/§4). */
1872
+ .lg-splitnote{background:var(--canvas);border-radius:8px;padding:10px 12px;margin-bottom:13px}
1873
+ .lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
1874
+ .lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:11.5px;line-height:1.8;color:var(--ink3)}
1875
+ .lg-splitnote li.cur{color:var(--ink)}
1876
+ .lg-splitnote li.cur b{color:var(--green);font-weight:600}
1877
+ .lg-notfound{font-size:11.5px;line-height:1.65;color:var(--ink3);font-style:italic;margin-bottom:13px}
1878
+ .lg-bullethd{font:600 8px/1 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;margin:13px 0 6px}
1879
+ .lg-bullets{margin:0;padding:0 0 0 16px;font-size:12px;line-height:1.65;color:var(--ink2)}
1880
+ .lg-bullets li{margin-bottom:5px}
1881
+ /* Evidence: same flush surface as Context/Done when — hairline only, no boxed canvas
1882
+ (H23 §5.5 correction: "コンテキストとダウンウェインとエビデンスが全部違うレイヤー") */
1883
+ .lg-ev{border-top:1px solid var(--hair);padding-top:13px;margin-top:2px}
1797
1884
  .lg-ev .lg-evhd{font:600 8.5px/1 var(--mono);letter-spacing:.13em;color:var(--ink3);text-transform:uppercase;margin-bottom:10px}
1798
1885
  .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
1886
  .lg-ev .a{color:var(--green);font-style:normal} .lg-ev .d{color:var(--danger);font-style:normal}
1800
1887
  .lg-ev .fls{color:var(--ink3)}
1801
1888
  .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}
1889
+ /* logic-only hero: no UI to show, so the number IS the evidence */
1890
+ .lg-herostat{display:flex;align-items:baseline;gap:10px;margin:4px 0 12px}
1891
+ .lg-herostat .n{font:700 30px/1 var(--mono);color:var(--green);font-variant-numeric:tabular-nums}
1892
+ .lg-herostat.fail .n{color:var(--danger)}
1893
+ .lg-herostat .l{font:500 11px/1.4 var(--ui);color:var(--ink3)}
1894
+ /* docs-only hero: GitHub-style diff — added lines get a green band + "+" gutter */
1895
+ .lg-diffblock{background:var(--canvas);border-radius:7px;padding:7px 0;font:400 11.5px/1.8 var(--mono);
1896
+ color:var(--ink2);margin-top:6px;overflow-x:auto}
1897
+ .lg-diffblock .ln{display:flex;padding:0 11px}
1898
+ .lg-diffblock .ln.a{background:rgba(var(--green-rgb),.14)}
1899
+ .lg-diffblock .gutter{flex:0 0 14px;color:var(--green);font-weight:700;user-select:none}
1900
+ .lg-diffblock .ln:not(.a) .gutter{color:transparent}
1901
+ .lg-diffblock .ln.a .txt{color:var(--green)}
1902
+ /* missing-capture hero: an honest small amber note, never a fabricated image */
1903
+ .lg-missinghd{display:flex;align-items:center;gap:8px;font:600 9px/1 var(--mono);letter-spacing:.1em;
1904
+ text-transform:uppercase;color:var(--amber);margin-bottom:9px}
1905
+ .lg-missinghd svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2}
1906
+ .lg-manualnote{font-size:12px;line-height:1.7;color:var(--ink2);background:var(--canvas);border-radius:8px;padding:11px 13px}
1818
1907
  /* risk/conflict = small inline amber, never a filled panel (§1.5-8 don't paint the surface) */
1819
1908
  .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
1909
  .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 +1936,7 @@
1847
1936
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1848
1937
  #fsRoot .tl{position:relative}
1849
1938
  #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) */
1939
+ #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
1940
  #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
1941
  #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
1942
  #fsRoot .trow:hover{background:var(--hover-s)}
@@ -3231,6 +3321,40 @@ function buildReviewDigest({ goals, tasks } = {}){
3231
3321
  }
3232
3322
  return rows;
3233
3323
  }
3324
+ // Review title: mirrors the label convention used throughout (reviewSummary.changed
3325
+ // → plan[0] → text) — pulled into one place so the review card's own title and its
3326
+ // sibling-goal titles (H23 split note) stay consistent.
3327
+ function saReviewTitle(g){
3328
+ return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3329
+ }
3330
+ // H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
3331
+ // hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
3332
+ // module. flagship's demo data carries an explicit `kind` field
3333
+ // ('image'|'logic'|'docs'|'missing'); real goals/tasks have no such field, so the
3334
+ // review card derives one from real signals it already has (a captured proof image,
3335
+ // whether changed files look like UI source, whether they're all docs-only). This
3336
+ // is a HEURISTIC, not a stored fact on the goal — flagged as a judgment call in the
3337
+ // H23 implementation PR for a human to correct if it misclassifies real data.
3338
+ const UI_TOUCH_RE_LOCAL = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
3339
+ function isUiChangeLocal(files){ return (files ?? []).some((f) => UI_TOUCH_RE_LOCAL.test(String(f ?? ''))); }
3340
+ const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
3341
+ function hasTestRelevantChangesLocal(files){
3342
+ const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
3343
+ if (!fs.length) return true;
3344
+ return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
3345
+ }
3346
+ // Priority: image (a real capture exists) > missing (UI touched, should have a
3347
+ // capture per PRD §4(C) but doesn't) > docs (every changed file is docs-only) >
3348
+ // logic (verified only by tests, no UI). Falls back to 'image' (today's generic
3349
+ // fact-row + no image) when no signal is conclusive — never claims a stronger kind
3350
+ // than the data actually supports.
3351
+ function saEvidenceKind(filePaths, hasProofImg, testResult){
3352
+ if (hasProofImg) return 'image';
3353
+ if (isUiChangeLocal(filePaths)) return 'missing';
3354
+ if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
3355
+ if (testResult?.ran) return 'logic';
3356
+ return 'image';
3357
+ }
3234
3358
  // Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
3235
3359
  // — kept in sync manually since the browser script isn't a module. Used by both
3236
3360
  // the review checklist top summary and the chat-side review导线.
@@ -3435,6 +3559,92 @@ function beforeAfterMediaHtml(taskId, proof, altLabel){
3435
3559
  <div class="ba-col"><span class="balabel">After</span>${proofMediaHtml(taskId, proof, altLabel)}</div>
3436
3560
  </div>`;
3437
3561
  }
3562
+ // Diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18): on top of the
3563
+ // plain before/after pair above, isolate the region that actually changed and draw
3564
+ // a red box over it on both images, plus a cropped/enlarged row below. Computed
3565
+ // client-side from the two PNGs via <canvas> pixel comparison at render time — not
3566
+ // baked in at capture time — so it works retroactively on every proof pair already
3567
+ // on disk with no new server-side image dependency (see the handoff for the full
3568
+ // rationale). Reference algorithm, verified against a real before/after pair
3569
+ // (composer icon swap, 640x200): correctly isolated a 57x52px box centered on the
3570
+ // changed icon — 0.13% image coverage, no false positives elsewhere in the frame.
3571
+ function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0005, maxCoverage = 0.7, pad = 0.03 } = {}) {
3572
+ const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3573
+ if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
3574
+ const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
3575
+ const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
3576
+ c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
3577
+ c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
3578
+ const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
3579
+ const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
3580
+ let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
3581
+ for (let y = 0; y < h; y++) {
3582
+ for (let x = 0; x < w; x++) {
3583
+ const i = (y * w + x) * 4;
3584
+ const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
3585
+ if (d > threshold) {
3586
+ diffPx++;
3587
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
3588
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
3589
+ }
3590
+ }
3591
+ }
3592
+ const total = w * h;
3593
+ if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
3594
+ if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
3595
+ const padPx = Math.round(Math.max(w, h) * pad);
3596
+ minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
3597
+ maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
3598
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
3599
+ }
3600
+ function baLoadImage(src){
3601
+ return new Promise((resolve, reject) => {
3602
+ const im = new Image();
3603
+ im.onload = () => resolve(im);
3604
+ im.onerror = () => reject(new Error(`image failed to load: ${src}`));
3605
+ im.src = src;
3606
+ });
3607
+ }
3608
+ // Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
3609
+ // aspect ratio as the box, never stretched. Magnification is capped at 6x so a
3610
+ // near-single-pixel box doesn't blow up into mush.
3611
+ function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
3612
+ const scale = Math.min(maxW / box.w, maxH / box.h, 6);
3613
+ const outW = Math.max(1, Math.round(box.w * scale));
3614
+ const outH = Math.max(1, Math.round(box.h * scale));
3615
+ const c = document.createElement('canvas'); c.width = outW; c.height = outH;
3616
+ c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
3617
+ return c.toDataURL('image/png');
3618
+ }
3619
+ // Async: loads the before/after PNGs, runs computeDiffBox(), and — only when a real
3620
+ // box is found — renders the before/after pair with a red box overlay on each image
3621
+ // (percent-positioned so it tracks the image's displayed size at any viewport width)
3622
+ // plus a "Zoomed on the boxed area" row below. Returns null on every fallback
3623
+ // condition (no PNG pair, load failure, no clean box) so the caller keeps showing
3624
+ // the plain beforeAfterMediaHtml() pair unchanged — never a guessed or empty box
3625
+ // (HANDOFF-review-card-diffbox-2026-07-18). Only kind==='image' Ledger evidence
3626
+ // (saEvidenceHtml) calls this; the older per-task renderReviewChecklist before/after
3627
+ // is explicitly out of scope for this handoff and stays on beforeAfterMediaHtml.
3628
+ async function beforeAfterBoxedHtml(taskId, proof, altLabel){
3629
+ const beforeSrc = proof?.beforePng, afterSrc = proof?.png;
3630
+ if (!beforeSrc || !afterSrc) return null; // the box needs two real stills, not a GIF frame
3631
+ let beforeImg, afterImg;
3632
+ try {
3633
+ [beforeImg, afterImg] = await Promise.all([
3634
+ baLoadImage(withKey(`/proof/${taskId}/${beforeSrc}`)),
3635
+ baLoadImage(withKey(`/proof/${taskId}/${afterSrc}`)),
3636
+ ]);
3637
+ } catch { return null; } // a missing/broken file is a fallback, not a crash
3638
+ const box = computeDiffBox(beforeImg, afterImg);
3639
+ if (!box) return null;
3640
+ const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3641
+ 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)}%`;
3642
+ 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>`;
3643
+ const zoomCol = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label.toLowerCase()}: ${altLabel}"></div>`;
3644
+ return `<div class="beforeafter">${frame(beforeImg, 'Before')}${frame(afterImg, 'After')}</div>
3645
+ <div class="lg-evhd ba-zoomhd">Zoomed on the boxed area</div>
3646
+ <div class="beforeafter ba-zoomrow">${zoomCol(baZoomDataUrl(beforeImg, box), 'before')}${zoomCol(baZoomDataUrl(afterImg, box), 'after')}</div>`;
3647
+ }
3438
3648
  // swap the still for the real video only on demand, one at a time
3439
3649
  document.addEventListener('click', (e) => {
3440
3650
  const w = e.target.closest('.proofwrap');
@@ -5554,19 +5764,28 @@ function renderFsLane(){
5554
5764
  renderFsReview(); renderFsTodo();
5555
5765
  if (fsUI.todoView === 'board') renderFsBoard();
5556
5766
  }
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){
5767
+ // Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and the
5768
+ // status chips can never drift; the two columns the chips have no word for (Later / Needs
5769
+ // you) are spelled out here. Takes the goal's TASKS so it can tell a genuinely in-flight
5770
+ // goal from one whose status got stuck 'running' after every task already finished — that
5771
+ // stale case is what piled old goals into Doing (Masa 2026-07-19). Pure: goal + tasks in.
5772
+ function fsBoardBucket(goal, tasks){
5561
5773
  const s = goal?.status;
5562
5774
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5563
5775
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
5776
+ if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
5564
5777
  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
5778
  if (s === 'review') return 'review';
5566
- if (s === 'done' || s === 'reverted') return 'done'; // both terminal; REVERTED is not "in flight"
5779
+ 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
5780
  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
5781
+ if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
5782
+ // Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
5783
+ // goal whose status stuck 'running' after every task already finished is NOT moving —
5784
+ // that is exactly what piled stale goals into Doing (Masa 2026-07-19, "Doing に11件").
5785
+ // If it has tasks and they're all terminal, it belongs in Done, not Doing.
5786
+ const mine = (tasks ?? []).filter((t) => t.goalId === goal?.id && !t.reply);
5787
+ if (mine.length && mine.every((t) => ['done', 'skipped'].includes(t.status))) return 'done';
5788
+ return 'doing'; // planning / retesting / actively in flight
5570
5789
  }
5571
5790
  // Kanban board — GOAL-level: one card per goal, never per task.
5572
5791
  //
@@ -5584,6 +5803,7 @@ function fsBoardBucket(goal){
5584
5803
  // above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
5585
5804
  function renderFsBoard(){
5586
5805
  const el = $('fsBoard'); if (!el) return;
5806
+ const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
5587
5807
  const { pgoals, list } = fsBucketTodo();
5588
5808
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
5589
5809
  const goalNo = new Map();
@@ -5594,21 +5814,23 @@ function renderFsBoard(){
5594
5814
  // flagship mock; a new goal enters via the composer). Earlier this was To-Do-only, which read
5595
5815
  // as "position is off" against the Artifact (Masa 2026-07-18).
5596
5816
  const col = (name, stg, count, html, stage) =>
5597
- `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5817
+ `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span>${stage === 'doing' ? `<button class="fsbcpause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button>` : ''}<span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5598
5818
  // Depth card (CDO, Masa 2026-07-17): #NN + title, colour on the dot only, and a continuous
5599
5819
  // stroke + n/m ONLY while the goal is actually in flight. The column already names the
5600
5820
  // status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
5601
5821
  // the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
5602
5822
  const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
5603
5823
  const goalCard = (g) => {
5604
- const bkt = fsBoardBucket(g);
5824
+ const bkt = fsBoardBucket(g, list);
5605
5825
  // errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
5606
5826
  // not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
5607
5827
  const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
5608
5828
  const intr = g.status === 'interrupted';
5609
- const healthyRun = bkt === 'doing';
5829
+ const isPaused = g.status === 'blocked' && g.blocked?.kind === 'paused'; // 一時停止=エラーでなくアンバー扱い
5830
+ const amber = intr || isPaused;
5831
+ const healthyRun = bkt === 'doing' && !errored; // paused(=errored) goals now sit in 'doing' too but carry no progress bar
5610
5832
  let dot = '', word = '';
5611
- if (errored) { dot = intr ? 'intr' : 'bad'; word = intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5833
+ if (errored) { dot = amber ? 'intr' : 'bad'; word = isPaused ? 'Paused' : intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5612
5834
  else if (bkt === 'review') dot = 'rev';
5613
5835
  else if (bkt === 'done') dot = 'ok';
5614
5836
  else if (bkt === 'needs') dot = 'intr'; // your turn — a clarifying question is waiting
@@ -5619,7 +5841,7 @@ function renderFsBoard(){
5619
5841
  let h = '<div class="lbc-hd">'
5620
5842
  + (dot ? `<span class="lbc-dot ${dot}"></span>` : '')
5621
5843
  + (n != null ? `<span class="lbc-n">#${n}</span>` : '')
5622
- + (word ? `<span class="lbc-w ${intr ? 'intr' : 'bad'}">${word}</span>` : '')
5844
+ + (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
5623
5845
  + '</div>'
5624
5846
  + `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
5625
5847
  // n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
@@ -5636,7 +5858,7 @@ function renderFsBoard(){
5636
5858
  };
5637
5859
  const no = (g) => goalNo.get(g.id) ?? Infinity;
5638
5860
  const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
5639
- for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
5861
+ for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
5640
5862
  for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
5641
5863
  // To Do / Doing / Needs you / Review / Done / Later — the original design's columns
5642
5864
  // (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
@@ -5650,8 +5872,22 @@ function renderFsBoard(){
5650
5872
  + col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
5651
5873
  + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
5652
5874
  for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
5875
+ for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // Doing列の一時停止/再生
5653
5876
  renderFsWait(bucket);
5654
5877
  }
5878
+ // Doing列の一時停止/再生 (Masa 2026-07-18・Spotify式トグル). 楽観的にローカルの
5879
+ // project.paused を反転して即描画→POST. サーバは pause/resume 後に {ev:'projects'}
5880
+ // を broadcast するので、確定状態はSSEハンドラが state.projects を上書きして一致
5881
+ // させる. POST失敗時だけ元に戻す. board/list 両方の [data-fspause] が共有する。
5882
+ function togglePauseActive(){
5883
+ const p = state.projects.find((x) => x.id === state.active);
5884
+ if (!p) return;
5885
+ const next = !p.paused;
5886
+ p.paused = next; render();
5887
+ fetch(withKey(`/api/projects/${state.active}/${next ? 'pause' : 'resume'}`), { method: 'POST', headers: { 'content-type': 'application/json' } })
5888
+ .then((r) => { if (!r.ok) throw new Error(`pause toggle ${r.status}`); })
5889
+ .catch(() => { p.paused = !next; render(); });
5890
+ }
5655
5891
  // Waiting badge (CDO, Masa 2026-07-17 decision 2): the board keeps work-flow column order, so
5656
5892
  // the "your turn" columns (Needs you, Review) can sit off the right edge at a narrow lane
5657
5893
  // width. They announce themselves beside the List/Board toggle instead — pure read of the same
@@ -5840,7 +6076,11 @@ function fsBucketTodo(){
5840
6076
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
5841
6077
  function renderFsTodo(){
5842
6078
  const el = $('fsTodoSec'); if (!el) return;
5843
- const { pgoals, list, running, failed, blocked, queued, doneAll, later } = fsBucketTodo();
6079
+ const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
6080
+ const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
6081
+ // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
6082
+ // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
6083
+ const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
5844
6084
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
5845
6085
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
5846
6086
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
@@ -5855,16 +6095,31 @@ function renderFsTodo(){
5855
6095
  const goalNo = new Map();
5856
6096
  for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
5857
6097
  const noSpan = (goalId) => { const n = goalId != null ? goalNo.get(goalId) : null; return n != null ? `<span class="gno" data-n="${n}">#${n}</span>` : ''; };
6098
+ // DOING = only what is actively moving (running task rows). Stopped/errored work moved
6099
+ // out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
6100
+ // as "still in flight".
5858
6101
  const doingRows =
5859
- 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>
6102
+ running.filter((t) => !pausedGoalIds.has(t.goalId)).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
6103
  <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
6104
+ // 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
6105
+ + pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
6106
+ // NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
6107
+ // it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
6108
+ // keeping the affordances it already had — nothing about the failed-task and blocked-goal
6109
+ // rows changed except the caption they sit under:
6110
+ // • a failed/interrupted TASK — the rich Retry/Resume row;
6111
+ // • a BLOCKED goal — the Retry/Archive goal row (a blocked goal can ALSO have a stopped
6112
+ // task; both rows show, as before — they carry different, goal- vs task-level actions);
6113
+ // • a needsInput goal — new here: a clarifying question waiting on you (Answer opens it,
6114
+ // where the chat feed's question card takes the actual answer).
6115
+ const needsRows =
5861
6116
  // Doing-failure UI (HANDOFF-v45 §11, revises v45 §6): a stopped task shows NO progress
5862
6117
  // bar (a dead bar pushed Retry down) — state is a COLOURED TEXT WORD only, no badge.
5863
6118
  // Interrupted (amber, ⏸, "Resume") is distinguished from Failed (red, ✗, "Retry") —
5864
6119
  // both still call the same /retry endpoint, which already resumes the session when one
5865
6120
  // exists (server-side retry = "resume if possible, else re-run"; there is no separate
5866
6121
  // resume endpoint to call).
5867
- + failed.map((t) => { const isInt = t.status === 'interrupted';
6122
+ failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
5868
6123
  const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
5869
6124
  // don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
5870
6125
  // when the task has no distinct result text — the coloured word already said it, so
@@ -5875,9 +6130,12 @@ function renderFsTodo(){
5875
6130
  return `<div class="drow err${isInt ? ' int' : ''}"><div class="trow now"><span class="st run ${isInt ? 'int' : 'bad'}"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="stword ${isInt ? 'int' : ''}">${word}</span>${fsActs(t.goalId)}</div>
5876
6131
  <div class="errline">${reasonLine}
5877
6132
  <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
- + 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>
6133
+ + blocked.map((g) => g.blocked?.kind === 'paused' ? '' : `<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
6134
  <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('');
6135
+ <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('')
6136
+ + 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>
6137
+ <div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
6138
+ <div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
5881
6139
  const NCAP = 6;
5882
6140
  const upShown = fsUI.todoMore ? queued : queued.slice(0, NCAP);
5883
6141
  const seenGoals = new Set();
@@ -5898,11 +6156,12 @@ function renderFsTodo(){
5898
6156
  // "Next up" pool only) — so a project sitting in Doing/Later with an empty Next-up
5899
6157
  // queue showed "To Do 0" directly above visible Doing/Later rows. The header labels
5900
6158
  // 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;
6159
+ // below it: Doing (running) + Needs you (failed/interrupted tasks + stopped/needsInput
6160
+ // goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
6161
+ // queued.length specifically, since that suffix describes the Next-up sublist's own sort,
6162
+ // not the lane total.
6163
+ const needsCount = failed.length + blocked.length + needsInput.length;
6164
+ const todoTotal = running.length + needsCount + queued.length + later.length;
5906
6165
  // Free-tier pending counter (H22): on the free plan the header shows the wall
5907
6166
  // — pending goals out of the 5-slot cap — reddening at the cap and gone once
5908
6167
  // paid. Reads the SAME numbers the gate enforces (state.billing, never a local
@@ -5916,8 +6175,9 @@ function renderFsTodo(){
5916
6175
  : `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
5917
6176
  el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
5918
6177
  <div class="tl">
5919
- ${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}` : ''}
6178
+ ${(doingRows || paused) ? `<span class="tlcap tlcap-doing" style="padding-top:0">Doing<button class="tlcap-pause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button></span>${doingRows}` : ''}
6179
+ ${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6180
+ ${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
5921
6181
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
5922
6182
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
5923
6183
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
@@ -5937,6 +6197,7 @@ function renderFsTodo(){
5937
6197
  fsUI.repsOpen.has(id) ? fsUI.repsOpen.delete(id) : fsUI.repsOpen.add(id);
5938
6198
  b.closest('.grp').classList.toggle('open');
5939
6199
  };
6200
+ for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // list版 Doing 一時停止/再生(+Paused行のResume)
5940
6201
  for (const b of el.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
5941
6202
  for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
5942
6203
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
@@ -7411,7 +7672,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
7411
7672
  // 4 archived / 5 reverted — session-local; the server round-trip is what
7412
7673
  // settles it (see saSync's "returned to review" reset).
7413
7674
  // ============================================================================
7414
- const SA = { open: false, items: [], diffs: {} , detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
7675
+ const SA = { open: false, items: [], diffs: {}, diffBoxHtml: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
7415
7676
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
7416
7677
  const SA_SVG = {
7417
7678
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -7424,27 +7685,40 @@ function saRows(){
7424
7685
  return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => {
7425
7686
  const g = gs.find((x) => x.id === r.goalIds[0]);
7426
7687
  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
- });
7688
+ // "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
7689
+ // per finished requirement task's own report; falls back to the goal's plan
7690
+ // steps when nothing has finished yet (e.g. a plan awaiting approval — the plan
7691
+ // IS "what we're going to do" in that case, which is the honest content to show).
7692
+ const doneTasks = ts.filter((t) => r.goalIds.includes(t.goalId) && !t.reply && t.result)
7693
+ .sort((a, b) => (a.num ?? 0) - (b.num ?? 0));
7694
+ const did = doneTasks.length
7695
+ ? doneTasks.map((t) => String(t.result).replace(/\s+/g, ' ').trim().slice(0, 160))
7696
+ : (Array.isArray(g?.plan) ? g.plan.filter(Boolean).map((s) => String(s).replace(/\s+/g, ' ').trim()) : []);
7697
+ // Sibling goals (H23 §2/§4 "did the AI drop part of what I asked"): buildReviewDigest
7698
+ // already collapses goals that share one PR into a single row (goalIds.length > 1)
7699
+ // — the real-data analog of a long ask fanning out into several tracked pieces.
7700
+ // The row's primary goal (goalIds[0], whose text/title drive this card) is marked
7701
+ // "cur"; the rest are listed so nothing reads as silently dropped.
7702
+ const siblings = r.goalIds.length > 1
7703
+ ? r.goalIds.map((gid) => {
7704
+ const sg = gs.find((x) => x.id === gid);
7705
+ return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
7706
+ })
7707
+ : null;
7437
7708
  return {
7438
7709
  ids: r.goalIds, goalId: r.goalIds[0], goal: g,
7439
7710
  no: r.reviewNumber,
7440
- title: String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90),
7711
+ title: saReviewTitle(g),
7441
7712
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
7442
7713
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
7443
- plan: Array.isArray(g?.plan) ? g.plan.filter(Boolean) : [],
7444
- thread, risk: g?.risk ?? null,
7714
+ did, siblings, risk: g?.risk ?? null,
7445
7715
  note: g?.retest?.passed ? g.retest.note : null,
7446
7716
  pr: r.pr, testResult: r.testResult,
7447
7717
  proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
7718
+ // Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
7719
+ // §3 kind==='image'): most tasks never have a "before" capture, so this is almost
7720
+ // always null and beforeAfterMediaHtml() falls back to the single-shot view.
7721
+ proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
7448
7722
  // Token-frugality is the product's whole pitch — surface it here too, not
7449
7723
  // just in the per-goal review modal. buildReviewSummary computes the same
7450
7724
  // estimate; reuse its logic directly off the goal's runUsage/runTokens.
@@ -7461,6 +7735,79 @@ function saRows(){
7461
7735
  };
7462
7736
  });
7463
7737
  }
7738
+ // Meta row: PR link / token savings (real tap-to-open toggle) / Run locally — under
7739
+ // the title (H23 §1). Run-locally has no real per-task command yet — this is the
7740
+ // CDO's answer to "let me test this locally", left for CTO/engine to decide what it
7741
+ // actually triggers (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
7742
+ // §5). Shown only for tasks with a real automated check to plausibly re-run
7743
+ // (it.testResult.ran), and tapping it is honest about not being wired yet rather
7744
+ // than pretending to execute anything — never a fake "running…" state.
7745
+ function saMetaRowHtml(it, i){
7746
+ const canRunLocal = Boolean(it.testResult?.ran);
7747
+ if (!it.pr && !it.tokenOpt && !canRunLocal) {
7748
+ return '<div class="lg-meta"><span class="none">No PR link / token-savings data on this task.</span></div>';
7749
+ }
7750
+ const chips = [];
7751
+ 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>`);
7752
+ if (it.tokenOpt) {
7753
+ const pct = Number(it.tokenOpt.percent);
7754
+ const label = pct > 0 ? `saved ${pct}% tokens` : 'token usage';
7755
+ chips.push(`<button class="tokchip" type="button" data-saact="tok:${i}">${esc(label)} <i class="tokchevron">›</i></button>`);
7756
+ }
7757
+ 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>`);
7758
+ const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
7759
+ const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
7760
+ 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>` : '';
7761
+ const runNote = canRunLocal ? `<div class="tokdetail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
7762
+ return `<div class="lg-meta">${chips.join('')}</div>${tokDetail}${runNote}`;
7763
+ }
7764
+ // Context: closed by default, one-line verbatim-quote summary, chevron large and at
7765
+ // the far right (H23 §2). Quotes goal.text — the user's own words — never
7766
+ // task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
7767
+ // the AI's own read on the request as "your ask" and it wasn't recognizable).
7768
+ function saContextHtml(it, gk){
7769
+ const goalTxt = it.goalText;
7770
+ const summary = goalTxt
7771
+ ? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
7772
+ : 'No verbatim ask on record for this goal.';
7773
+ const split = it.siblings
7774
+ ? `<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>`
7775
+ : '';
7776
+ 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>` : '';
7777
+ 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>` : '';
7778
+ const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
7779
+ const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
7780
+ 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>`;
7781
+ }
7782
+ // Evidence hero: shape follows task kind, not one image-shaped template (H23 §3).
7783
+ // Real data check: most finished tasks have no proof image at all (logic-only /
7784
+ // docs-only / research changes verify without a screenshot) — kind is derived, see
7785
+ // saEvidenceKind() above.
7786
+ function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
7787
+ if (kind === 'logic') {
7788
+ const passed = gtr?.passed ?? 0, failed = gtr?.failed ?? 0, ok = gtr?.ok !== false;
7789
+ const n = ok ? String(passed) : `${passed}/${passed + failed}`;
7790
+ return `<div class="lg-ev"><div class="lg-evhd">Evidence — tests, no UI to show</div>
7791
+ <div class="lg-herostat${ok ? '' : ' fail'}"><span class="n">${esc(n)}</span><span class="l">tests passed<br>${esc(filePaths.join(', '))}</span></div></div>`;
7792
+ }
7793
+ if (kind === 'docs') {
7794
+ const lines = diffText ? diffText.split('\n').filter((l) => l.length) : [];
7795
+ const diffHtml = lines.map((l) => {
7796
+ const isAdd = l.startsWith('+');
7797
+ return `<div class="ln${isAdd ? ' a' : ''}"><span class="gutter">+</span><span class="txt">${esc(l.slice(1) || l)}</span></div>`;
7798
+ }).join('');
7799
+ return `<div class="lg-ev"><div class="lg-evhd">Evidence — the diff, no test to run</div>
7800
+ <div class="row"><span class="fls">${esc(filePaths.join(', '))} · docs-only, no test target</span></div>
7801
+ ${diffHtml ? `<div class="lg-diffblock">${diffHtml}</div>` : ''}</div>`;
7802
+ }
7803
+ if (kind === 'missing') {
7804
+ const note = it.check || 'Please verify this change manually — no automated capture exists for it.';
7805
+ 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>
7806
+ <div class="lg-manualnote">${esc(note)}</div></div>`;
7807
+ }
7808
+ // kind === 'image' (default)
7809
+ return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
7810
+ }
7464
7811
  function saItemHtml(it, i){
7465
7812
  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
7813
  if (done) {
@@ -7473,46 +7820,50 @@ function saItemHtml(it, i){
7473
7820
  // Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
7474
7821
  // renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
7475
7822
  // chip, never silently dropped — an untested/proofless goal must never LOOK verified.
7823
+ // H23: facts stop looking like buttons — glyph carries the state colour, text
7824
+ // stays plain ink, no border/pill (border is reserved for the PR link).
7476
7825
  const gtr = it.testResult;
7477
7826
  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>';
7827
+ ? (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>`)
7828
+ : '<span class="vf none">— not tested</span>';
7480
7829
  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>';
7830
+ // Before/after (H23 spec §3 kind==='image'): reuse the same beforeafter/ba-col/
7831
+ // balabel renderer as the older per-task checklist (beforeAfterMediaHtml, ~L3281).
7832
+ // it.proofBefore is null for the ~all tasks with no "before" capture, so this
7833
+ // falls back to the identical single <img class="proofimg"> markup it replaced.
7834
+ // Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18) is computed async
7835
+ // after the card first paints (saEnhanceDiffBoxes, below saRender). SA.diffBoxHtml
7836
+ // holds the result a string once a box is found, null once ruled out, undefined
7837
+ // until then so it survives every later saRender() the same way SA.diffs already
7838
+ // does for the changed-files diff.
7839
+ const boxedImg = SA.diffBoxHtml[it.goalId];
7840
+ const img = hasProofImg
7841
+ ? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, 'proof'))
7842
+ : '';
7843
+ // Changed-file paths feed both the evidence-kind heuristic and the docs-kind diff
7844
+ // same source the old "Changed files & diff" fold used (d from the diff endpoint,
7845
+ // falling back to task.changedFiles when the server has no diff for this goal).
7846
+ let filePaths = [], nf = 0;
7847
+ if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
7848
+ else if (d === null) {
7849
+ filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
7850
+ nf = filePaths.length;
7498
7851
  }
7499
- const evRow = [
7852
+ const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
7853
+ const factRow = [
7500
7854
  testsChip,
7501
- it.note ? `<span class="vchip ok">✓ ${esc(it.note)}</span>` : '',
7502
- pm,
7855
+ it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
7503
7856
  `<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
7504
- pr,
7505
- hasProofImg ? '' : '<span class="vchip none">— no proof</span>',
7857
+ hasProofImg ? '' : '<span class="vf none">— no proof</span>',
7506
7858
  ].filter(Boolean).join('');
7507
7859
  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>`;
7860
+ // "What to look at" (H23 §2): the changed files themselves — a real, non-fabricated
7861
+ // pointer for the reviewer's eyes, same file list that used to live in the retired
7862
+ // "Changed files & diff" fold.
7863
+ const look = filePaths.slice(0, 8);
7864
+ if (filePaths.length > 8) look.push(`+${filePaths.length - 8} more`);
7865
+ const contextHtml = saContextHtml({ goalText: it.goalText, siblings: it.siblings, did: it.did, look }, gk);
7866
+ const evidenceHtml = saEvidenceHtml(kind, factRow, img, filePaths, d?.diff ?? null, gtr, it);
7516
7867
  // shouldPause = classifyChangeRisk (engine/lib.mjs) already forced this goal to
7517
7868
  // stop in review for exactly this reason — surface it; a non-blocking low-risk
7518
7869
  // read isn't worth a permanent row on every card (goal 427 §4 already covers it
@@ -7523,16 +7874,9 @@ function saItemHtml(it, i){
7523
7874
  : '';
7524
7875
  return `<div class="sa-item lg${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
7525
7876
  <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}
7877
+ ${saMetaRowHtml(it, i)}
7878
+ <div class="lg-ctx">${contextHtml}<div class="lg-fact"><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div></div>
7879
+ ${evidenceHtml}
7536
7880
  ${riskHtml}
7537
7881
  <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
7882
  </div>`;
@@ -7550,7 +7894,7 @@ function saRender(){
7550
7894
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
7551
7895
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
7552
7896
  // and wire each one's toggle to keep that set current.
7553
- for (const det of S.querySelectorAll('.lg-det[data-detkey]')) {
7897
+ for (const det of S.querySelectorAll('.lg-goal[data-detkey]')) {
7554
7898
  det.addEventListener('toggle', () => {
7555
7899
  const k = det.dataset.detkey;
7556
7900
  if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
@@ -7597,6 +7941,25 @@ async function saFetchDiffs(){
7597
7941
  }));
7598
7942
  if (SA.open) saSync();
7599
7943
  }
7944
+ // Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18): unlike
7945
+ // saFetchDiffs above (whose fetch settling triggers a full saSync()/saRender()), this
7946
+ // patches each card's already-rendered .beforeafter node directly — a full re-render
7947
+ // here would reset scroll, close every open <details>, and drop an in-progress reply
7948
+ // keystroke, all just to draw a box around one screenshot. SA.diffBoxHtml is still
7949
+ // cached per goal, so if a later SSE-driven saSync() does rebuild the card anyway,
7950
+ // saItemHtml() picks the cached result back up with no flicker to the plain pair.
7951
+ async function saEnhanceDiffBoxes(){
7952
+ const targets = SA.items.filter((it) => it.proofImg && it.proofTaskId != null && it.proofBefore && SA.diffBoxHtml[it.goalId] === undefined);
7953
+ await Promise.all(targets.map(async (it) => {
7954
+ let html = null;
7955
+ try { html = await beforeAfterBoxedHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')); }
7956
+ catch { html = null; } // a decode/pixel-diff failure is a fallback, never a crash
7957
+ SA.diffBoxHtml[it.goalId] = html; // cache even on null — never re-attempt an already-answered "no box"
7958
+ if (!html || !SA.open) return;
7959
+ const host = document.querySelector(`.sa-item[data-gid="${it.goalId}"] .beforeafter`);
7960
+ if (host) host.outerHTML = html; // html itself opens with the replacement .beforeafter
7961
+ }));
7962
+ }
7600
7963
  function saOpen(focusGoalId){
7601
7964
  SA.open = true;
7602
7965
  SA.items = saRows().map((r) => ({ ...r, st: 0 }));
@@ -7618,6 +7981,7 @@ function saOpen(focusGoalId){
7618
7981
  saRender();
7619
7982
  $('seeall').classList.add('open');
7620
7983
  saFetchDiffs();
7984
+ saEnhanceDiffBoxes();
7621
7985
  }
7622
7986
  function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
7623
7987
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
@@ -7676,6 +8040,10 @@ $('seeall').addEventListener('click', async (e) => {
7676
8040
  const it = SA.items[i];
7677
8041
  if (k === 'close') return saClose();
7678
8042
  if (!it) return;
8043
+ // meta-row toggles (H23): tap the token chip to reveal the technique breakdown, tap
8044
+ // Run-locally to reveal the honest "not wired yet" note — neither performs an action.
8045
+ if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
8046
+ if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
7679
8047
  // every verdict auto-advances the focus to the next pending card (tempo).
7680
8048
  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
8049
  if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });