@galda/cli 0.10.36 → 0.10.38

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
@@ -192,6 +192,18 @@
192
192
  .qico:disabled{opacity:.25;cursor:default}
193
193
  .qico:disabled:hover{color:var(--ink3);background:transparent}
194
194
  .qico svg{width:14px;height:14px;stroke:currentColor;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
195
+ /* Connection status line (offline) — inline color element, NEVER a painted
196
+ surface (DESIGN-RULES §1.5-8). Lives as the top row of the queue tray. The
197
+ .creconn button reuses the .errbtn idiom but re-declares its base here: #queue
198
+ sits in #composerWrap, OUTSIDE #fsRoot, so `#fsRoot .errbtn` never reaches it. */
199
+ .connline{display:flex;align-items:center;gap:8px;padding:6px 10px 6px 8px;font-family:var(--mono);font-size:10.5px;line-height:1.4}
200
+ .connline .cdot{width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:var(--danger);animation:connpulse 1.6s ease-in-out infinite}
201
+ .connline .cw{font-weight:600;color:var(--danger)}
202
+ .connline .cmsg{color:var(--ink2)}
203
+ .connline .creconn{margin-left:auto;border:0;background:transparent;color:var(--ink2);font-family:var(--mono);font-size:10px;letter-spacing:.02em;font-weight:600;line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2);cursor:pointer}
204
+ .connline .creconn:hover{background:var(--hover);color:var(--ink)}
205
+ @keyframes connpulse{0%,100%{opacity:1}50%{opacity:.35}}
206
+ @media (prefers-reduced-motion:reduce){.connline .cdot{animation:none}}
195
207
  .qedit{width:100%;box-sizing:border-box;background:var(--paper);border:1px solid var(--hair2);border-radius:9px;color:var(--ink);
196
208
  font:inherit;font-size:13px;line-height:1.5;padding:7px 10px;outline:none;min-height:56px;resize:vertical;margin:4px 0}
197
209
  .qbtns{display:flex;align-items:center;gap:6px;justify-content:flex-end;padding-bottom:4px}
@@ -201,6 +213,7 @@
201
213
 
202
214
  .errbar{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:40;background:var(--errbg);backdrop-filter:blur(14px);
203
215
  border:1px solid rgba(var(--danger-rgb),.4);color:var(--danger);font-size:12.5px;border-radius:10px;padding:8px 14px;display:none}
216
+ .errbar.ok{border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
204
217
 
205
218
  /* composer */
206
219
  .composer-wrap{flex:0 0 auto;padding:8px 22px 18px}
@@ -400,6 +413,7 @@
400
413
  .chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
401
414
  .chip2.doing{color:var(--ink);border-color:rgba(var(--blue-rgb),.4)}
402
415
  .chip2.review{color:var(--amber);border-color:rgba(var(--amber-rgb),.4)}
416
+ .chip2.rejected{color:var(--danger);border-color:rgba(var(--danger-rgb),.35)}
403
417
  .goalx{scroll-margin-top:24px;border-radius:12px;transition:background-color .3s}
404
418
  .goalx.selected{background-color:rgba(var(--amber-rgb),.12)}
405
419
  .prmini{font-family:var(--mono);font-size:9.5px;color:var(--green);text-decoration:none;border:1px solid rgba(var(--green-rgb),.35);border-radius:6px;padding:2px 7px;flex:0 0 auto;background:transparent;cursor:pointer}
@@ -666,6 +680,10 @@
666
680
  .delmodal{width:380px}
667
681
  .delpreview{border:1px solid var(--hair2);border-radius:10px;padding:10px 12px;background:var(--paper);
668
682
  color:var(--ink);font-size:12.5px;line-height:1.5;max-height:120px;overflow:auto;word-break:break-word}
683
+ /* 07-20c: borderless preview for the merge-confirm dialog — must NOT read as an editable field */
684
+ .delpreview.plain{border:none;background:none;padding:2px 0;max-height:none}
685
+ .delpreview.plain .pvtitle{font-weight:600;color:var(--ink);font-size:14px}
686
+ .delpreview.plain .pvpr{color:var(--ink2);font-size:11px;font-family:var(--mono);margin-top:3px}
669
687
  /* moment ③ New-project paywall dialog (H22). Same narrow .modal as .delmodal,
670
688
  but a one-action layout (Masa 2026-07-17 確定 __dlg(2)): Upgrade full-width,
671
689
  "Not now" a quiet underlined link centered below it. */
@@ -1930,12 +1948,180 @@
1930
1948
  .sa-done .undo{margin-left:auto;border:0;background:none;color:var(--ink3);font:500 10px/1 var(--mono);cursor:pointer}
1931
1949
  .sa-done .undo:hover{color:var(--ink);background:transparent}
1932
1950
  .sa-empty{font:500 11px/1 var(--mono);color:var(--ink3);padding:20px 0}
1951
+ /* ═══ Review card redesign (REVIEW-CARD-SPEC.md · Masa 2026-07-20 confirmed):
1952
+ Linear-docs typography · META = green outline pill (Chip) · GitHub diff = Bright.
1953
+ Ported from the flagship redesign (fs-redesign.css), the dev-only PLAN/META/DIFF
1954
+ switchers resolved to the confirmed defaults plan=d / meta=a / gh=d, and the
1955
+ flagship fallback hexes mapped to the app's real theme tokens (theme.css).
1956
+ Scoped to .sa-item.redesign so the plain .sa-done one-liner is untouched. ═══ */
1957
+ #seeall .sa-panel{width:min(940px,94%);max-width:940px;backdrop-filter:blur(30px) saturate(1.4);-webkit-backdrop-filter:blur(30px) saturate(1.4)}
1958
+ /* body/header/card geometry ported verbatim from the artifact (fs-redesign.css plan=d):
1959
+ the CARD carries the 40px side gutters + 28/34 top/bottom, the header matches (40/34),
1960
+ and the See-all body drops its own gutter/gap/centering so the card padding is the
1961
+ single source of the Linear-docs air. !important because the app's own .sa-item /
1962
+ .sa-body / .lg rules (added first, same-or-higher specificity) otherwise win. */
1963
+ #seeall .sa-body:has(.sa-item.redesign){gap:0 !important;align-items:stretch !important}
1964
+ #seeall .sa-panel .sa-hd:has(~ .sa-body .sa-item.redesign){padding:16px 34px 12px 40px !important}
1965
+ .sa-item.redesign{width:100% !important;background:none !important;border:0 !important;border-radius:0 !important;box-shadow:none !important;
1966
+ padding:28px 40px 34px !important;font-family:var(--ui);color:var(--ink);line-height:1.6}
1967
+ .sa-item.redesign *{box-sizing:border-box}
1968
+ /* first card: no extra top margin — the card's own 28px padding-top (plus the
1969
+ body's 16px) is the top air, matching the ~40px inter-card rhythm below.
1970
+ The old 52px pushed the header→#N gap to ~96px of dead whitespace (Masa 2026-07-20). */
1971
+ #seeall .sa-body>.sa-item.redesign:first-child{margin-top:0 !important}
1972
+ #seeall .sa-body>.sa-item.redesign+.sa-item.redesign{border-top:1px solid var(--hair) !important;margin-top:0 !important;padding-top:40px !important}
1973
+ /* reply composer: match the flagship saItem field (34px tall · radius 10 · 12px left pad)
1974
+ so it doesn't read cramped next to the 32px Approve/Dismiss (Masa 2026-07-20).
1975
+ padding-right (42px for the send ring) stays from .sa-chatwrap .sa-chat. */
1976
+ #seeall .sa-chat{height:34px;border-radius:10px;padding-left:12px}
1977
+ /* header — mono eyebrow · review dot · In review */
1978
+ .redesign .rhead{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:11px;color:var(--ink3)}
1979
+ .redesign .rtag{letter-spacing:.02em;font-variant-numeric:tabular-nums}
1980
+ .redesign .rst{display:inline-flex;align-items:center;gap:6px;color:var(--ink2)}
1981
+ .redesign .rdot{width:6px;height:6px;border-radius:50%;background:var(--amber,#e0a021);display:inline-block}
1982
+ .redesign .rtitle{font-family:var(--ui);font-size:18px;font-weight:680;letter-spacing:-.018em;line-height:1.3;margin:8px 0 0 !important;color:var(--ink)}
1983
+ /* meta — Linear sans, green/neutral chips, hairline under (spec §4) */
1984
+ .redesign .rmeta{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:12px 0 0 !important;padding-bottom:12px !important;
1985
+ border-bottom:1px solid var(--hair);font-family:var(--ui);font-size:12px;letter-spacing:-.004em;color:var(--ink2)}
1986
+ .redesign .rmeta .mx{display:inline-flex;align-items:center;gap:5px}
1987
+ .redesign .rmeta .mk{color:var(--ink3)}
1988
+ .redesign .rmeta b{color:var(--ink);font-weight:600}
1989
+ .redesign .rmeta .mmut{color:var(--ink3);font-weight:400}
1990
+ .redesign .rmeta .ar{font-size:.82em;margin-left:1px;color:var(--ink3)}
1991
+ .redesign .rmeta .a{color:#3fd07a;font-style:normal} .redesign .rmeta .d{color:#ff7b72;font-style:normal}
1992
+ /* Chip = outline pill (meta=a): green for PR/Token, neutral for Local */
1993
+ .redesign .rmeta .mlink{cursor:pointer;padding:3px 10px;border-radius:999px;
1994
+ border:1px solid color-mix(in srgb,currentColor 40%,var(--hair));text-decoration:none;background:none;font:inherit}
1995
+ .redesign .rmeta .mlink:hover{background:color-mix(in srgb,currentColor 9%,transparent)}
1996
+ .redesign .rmeta .mlink.pr,.redesign .rmeta .mlink.pr *{color:var(--green)}
1997
+ /* whole PR chip bold, not just the number (REVIEW-CARD-SPEC §5) — scoped to .mlink.pr so Token/Local chips are unaffected */
1998
+ .redesign .rmeta .mlink.pr,.redesign .rmeta .mlink.pr .mk,.redesign .rmeta .mlink.pr b,.redesign .rmeta .mlink.pr .mmut{font-weight:700}
1999
+ .redesign .rmeta .mlink:not(.pr){color:var(--ink2)}
2000
+ .redesign .rmeta .mlink:not(.pr) .mk{color:var(--ink3)}
2001
+ .redesign .rmeta .mlink:not(.pr) b{color:var(--ink)}
2002
+ .redesign .rmeta .mtok{cursor:pointer;padding:3px 10px;border-radius:999px;font:inherit;
2003
+ border:1px solid color-mix(in srgb,var(--green) 40%,var(--hair));background:none}
2004
+ .redesign .rmeta .mtok,.redesign .rmeta .mtok *{color:var(--green)}
2005
+ .redesign .rmeta .mtok b{font-weight:700}
2006
+ .redesign .rmeta .ok{color:var(--green)}
2007
+ .redesign .rmeta .tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.8}
2008
+ .redesign .rmeta .mtok.open .tokchevron{transform:rotate(90deg)}
2009
+ .redesign .rmeta-detail{display:none;margin:12px 0 0;padding:9px 12px;background:color-mix(in srgb,var(--ink) 4%,transparent);border-radius:8px}
2010
+ .redesign .rmeta-detail.open{display:block}
2011
+ .redesign .rmeta-detail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
2012
+ .redesign .rmeta-detail .v{font:500 11.5px/1.6 var(--ui);color:var(--ink2)}
2013
+ /* sections — Linear-docs bold sentence-case headings + high-contrast body.
2014
+ margins are !important: the app's pre-existing .lg / .sa-item rules load first and
2015
+ otherwise tie/beat these on specificity, flattening the Linear-docs rhythm. */
2016
+ .redesign .rsec{margin-top:24px !important}
2017
+ .redesign .rsec-h{font-family:var(--ui);font-size:15px;font-weight:680;letter-spacing:-.012em;line-height:1.3;color:var(--ink);margin:0 0 9px !important}
2018
+ /* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
2019
+ 13px read thinner/lighter for Japanese than the surrounding app). §2. */
2020
+ .redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
2021
+ .redesign .rdesc,.redesign .rrisk{color:var(--ink2)}
2022
+ .redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
2023
+ .redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
2024
+ /* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
2025
+ .redesign .rreq .rsec-h{margin-bottom:6px !important}
2026
+ .redesign .reqacc{margin-top:0 !important}
2027
+ .redesign .racc>summary{list-style:none;cursor:pointer;display:flex;gap:10px;align-items:baseline}
2028
+ .redesign .racc>summary::-webkit-details-marker{display:none}
2029
+ .redesign .racc>summary::before{content:"›";font-size:1.05em;line-height:1;color:var(--ink2);display:inline-block;transition:transform .14s}
2030
+ .redesign .racc[open]>summary::before{transform:rotate(90deg)}
2031
+ .redesign .reqacc>summary{color:var(--ink);font-size:13.5px;line-height:1.55}
2032
+ .redesign .reqbody{margin:14px 0 0 !important;padding-left:22px !important}
2033
+ .redesign .rsub{font-family:var(--ui);font-size:11px;font-weight:600;color:var(--ink3);margin:16px 0 8px !important}
2034
+ .redesign .reqbody>.rsub:first-child{margin-top:2px !important}
2035
+ .redesign .reqbody .lg-bullets{margin:0;padding:0 0 0 16px;font-size:13px;line-height:1.65;color:var(--ink2)}
2036
+ .redesign .reqbody .lg-bullets li{margin-bottom:5px}
2037
+ .redesign .reqbody .lg-splitnote{background:color-mix(in srgb,var(--ink) 4%,transparent);border-radius:8px;padding:10px 12px;margin-top:12px}
2038
+ .redesign .reqbody .lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
2039
+ .redesign .reqbody .lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:12px;line-height:1.8;color:var(--ink3)}
2040
+ .redesign .reqbody .lg-splitnote li.cur{color:var(--ink)} .redesign .reqbody .lg-splitnote li.cur b{color:var(--green);font-weight:600}
2041
+ /* workflow (spec §8) — vertical ✓ green / → green / □ dim */
2042
+ .redesign .rwf{display:flex;flex-direction:column;gap:2px;margin:10px 0 0 !important;font-family:var(--ui);font-size:12px;letter-spacing:-.004em;color:var(--ink3)}
2043
+ .redesign .rwf .wf-s{display:flex;align-items:center;gap:9px}
2044
+ .redesign .rwf .wf-s .mk{width:12px;text-align:center;flex:0 0 12px}
2045
+ .redesign .rwf .wf-s.ok{color:var(--ink2)}.redesign .rwf .wf-s.ok .mk{color:var(--green)}
2046
+ .redesign .rwf .wf-s.now{color:var(--ink);font-weight:600}.redesign .rwf .wf-s.now .mk{color:var(--green);font-weight:700}
2047
+ .redesign .rwf .wf-s.todo{color:var(--ink3)}.redesign .rwf .wf-s.todo .mk{color:var(--ink3);font-size:.82em}
2048
+ /* proof (spec §6) — before/after forced same size · run-log · honest missing note */
2049
+ .redesign .beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px;align-items:start}
2050
+ .redesign .ba-col{display:flex;flex-direction:column;gap:6px;min-width:0}
2051
+ .redesign .balabel{font-family:var(--mono);font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink3)}
2052
+ .redesign .proofimg{display:block;width:100%;height:auto;max-height:none;object-fit:contain;border:1px solid var(--hair);border-radius:9px;cursor:zoom-in;margin:0}
2053
+ .redesign .rproof{max-width:640px}
2054
+ /* screen-recording player (spec §6) — sits under before/after, same 640px column */
2055
+ .redesign .rvid{margin:12px 0 0;max-width:640px}
2056
+ .redesign .rvid svg,.redesign .rvid .proofvid{display:block;width:100%;height:auto;border:1px solid var(--hair);border-radius:9px;background:#000}
2057
+ .redesign .rvid .proofvid{aspect-ratio:16/9;object-fit:contain}
2058
+ .redesign .rvid figcaption{margin-top:7px;font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--ink3)}
2059
+ .redesign .rrun{border:1px solid var(--hair);border-radius:10px;overflow:hidden;font-family:var(--mono);font-size:12.5px;max-width:640px}
2060
+ .redesign .rrun .rr{display:flex;justify-content:space-between;gap:16px;padding:10px 14px;border-top:1px solid var(--hair)}
2061
+ .redesign .rrun .rr:first-child{border-top:0}
2062
+ .redesign .rrun .rr .lab{color:var(--ink2)}.redesign .rrun .rr .v{color:var(--green);text-align:right}
2063
+ .redesign .rmiss{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;line-height:1.6;color:var(--ink2)}
2064
+ .redesign .rmiss svg{width:15px;height:15px;flex:0 0 15px;margin-top:2px;fill:none;stroke:var(--amber);stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
2065
+ /* GitHub diff = Bright (gh=d): old|new gutters · +/− column · line+gutter tint */
2066
+ .redesign .rdiff2{margin:12px 0 0;border:1px solid var(--hair);border-radius:10px;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.6}
2067
+ .redesign .ghl{display:grid;grid-template-columns:46px 46px 22px 1fr}
2068
+ .redesign .ghl .gho,.redesign .ghl .ghn{color:var(--ink3);opacity:.6;text-align:right;padding:1px 8px;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
2069
+ .redesign .ghl .ghm{text-align:center;color:var(--ink3);padding:1px 0}
2070
+ .redesign .ghl .ghc{padding:1px 10px;white-space:pre-wrap;color:var(--ink)}
2071
+ .redesign .gh-add{background:rgba(46,204,113,.22)}
2072
+ .redesign .gh-add .gho,.redesign .gh-add .ghn{background:rgba(46,204,113,.30)}
2073
+ .redesign .gh-add .ghm{color:#3fb950}
2074
+ .redesign .gh-del{background:rgba(248,81,73,.20)}
2075
+ .redesign .gh-del .gho,.redesign .gh-del .ghn{background:rgba(248,81,73,.30)}
2076
+ .redesign .gh-del .ghm{color:#f85149}
2077
+ .redesign .gh-hi{border-radius:2px;padding:0 1px}
2078
+ .redesign .gh-add .gh-hi{background:rgba(46,204,113,.55)} .redesign .gh-del .gh-hi{background:rgba(248,81,73,.5)}
2079
+ /* huge-diff footer (spec §7): "…+N 行 · GitHub で開く ↗" */
2080
+ .redesign .ghfoot{margin-top:8px;font-family:var(--mono);font-size:11.5px;color:var(--ink3);display:flex;gap:2px;align-items:center;flex-wrap:wrap}
2081
+ .redesign .ghfoot .ghopen{color:var(--ink2);text-decoration:none}
2082
+ .redesign .ghfoot .ghopen:hover{color:var(--blue)}
2083
+ /* meta links carry NO underline — PR is a real <a href> (spec §5, Masa 2026-07-20) */
2084
+ .redesign .rmeta a{text-decoration:none}
2085
+ /* Request / What-to-check = full contrast, same ink as the title (spec §2, no fade) */
2086
+ .redesign .rask,.redesign .rcheck,.redesign .rcheck li,.redesign .rcheck1{color:var(--ink) !important}
2087
+ .redesign ul.rcheck{margin:2px 0 0;padding:0 0 0 18px;font-size:14px;line-height:1.7;letter-spacing:-.012em}
2088
+ .redesign ul.rcheck li{margin-bottom:6px}
2089
+ /* Activity (spec §3) = ONE Linear-docs pulldown. Heading plain; chevron on the FIRST
2090
+ line; tapping opens the rest below, offset-indented (no left rule); Discussion folds
2091
+ in at the bottom of the opened body. */
2092
+ .redesign .ract{margin-top:24px !important}
2093
+ .redesign .ract>.rsec-h{margin-bottom:10px !important}
2094
+ .redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:9px;align-items:baseline}
2095
+ .redesign .lacc>summary::-webkit-details-marker{display:none}
2096
+ .redesign .lacc .ltog{flex:0 0 auto;color:var(--ink3);font-style:normal;line-height:1;transition:transform .14s}
2097
+ .redesign .lacc[open]>summary .ltog{transform:rotate(90deg)}
2098
+ .redesign .lacc .lfirst{flex:1;min-width:0}
2099
+ .redesign .lacc .lbody{margin-top:12px;padding-left:18px}
2100
+ /* transcript rows — Linear-clean, borderless (You / tool mono / res green / Claude) */
2101
+ .redesign .rtx{border:0;background:none}
2102
+ .redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55}
2103
+ .redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3);padding-top:1px}
2104
+ .redesign .txr.you .w,.redesign .txr.cl .w{color:var(--ink);font-weight:600}
2105
+ .redesign .txr .m{flex:1;min-width:0;color:var(--ink2)}
2106
+ .redesign .txr.cl .m{color:var(--ink)}
2107
+ .redesign .txr.tool .m{color:var(--ink2);font-family:var(--mono);font-size:12px}
2108
+ .redesign .txr.tool .m b{color:var(--ink);font-weight:600}
2109
+ .redesign .txr.res .m{font-family:var(--mono);font-size:12px;color:var(--green)}
2110
+ /* Discussion (review back-and-forth) folded at the bottom of Activity's body */
2111
+ .redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
2112
+ .redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
2113
+ .redesign .lbody .rmsg .msg{font-size:13px;line-height:1.55;color:var(--ink2)}
2114
+ /* sensitive-area note (real) sits below What changed */
2115
+ .redesign .lg-risk{margin-top:20px}
1933
2116
  #saconf{position:fixed;inset:0;pointer-events:none;z-index:99;display:block}
1934
2117
  /* retest ×3 note surfaces on the lane review card too (v45 §5.3) */
1935
2118
  #fsRoot .rcard .rnote{display:block;font:500 9.5px/1.4 var(--mono);color:var(--green);margin-top:3px}
1936
2119
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1937
2120
  #fsRoot .tl{position:relative}
1938
2121
  #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) */
2122
+ /* 07-20c: Rejected reads as a real section heading (like Review), not the tiny mono lane captions */
2123
+ #fsRoot .tlcap.rejcap{display:flex;align-items:baseline;gap:8px;font-family:inherit;font-size:13px;letter-spacing:0;text-transform:none;color:var(--ink);padding-top:16px}
2124
+ #fsRoot .tlcap.rejcap .num{font-size:10px;color:var(--ink3)}
1939
2125
  #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. */
1940
2126
  #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) */
1941
2127
  #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 */
@@ -1951,6 +2137,7 @@
1951
2137
  #fsRoot .trow .stword.int{color:var(--amber)}
1952
2138
  #fsRoot .trow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
1953
2139
  #fsRoot .trow .st.ok::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
2140
+ #fsRoot .trow .st.rej{border-color:rgba(var(--danger-rgb),.5)} /* 07-20c: rejected — a quiet danger dot (settled, not alarming) */
1954
2141
  #fsRoot .trow .tt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1955
2142
  #fsRoot .trow .num{font-size:10px;color:var(--ink3);flex:0 0 auto}
1956
2143
  #fsRoot .trow .pct{font-size:10.5px;color:var(--green);flex:0 0 auto;font-family:var(--mono);font-variant-numeric:tabular-nums}
@@ -2812,7 +2999,7 @@
2812
2999
  <div class="reqlist" id="reviewList"></div>
2813
3000
  <div class="revactions">
2814
3001
  <button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
2815
- <button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss goal</button>
3002
+ <button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss</button>
2816
3003
  <span style="flex:1"></span>
2817
3004
  <button class="mbtn ghost" id="reviewClose">Close</button>
2818
3005
  </div>
@@ -2967,7 +3154,7 @@ const savedTheme = (() => {
2967
3154
  if (v !== s) localStorage.setItem('theme', v);
2968
3155
  return v;
2969
3156
  })();
2970
- const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'galda2', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
3157
+ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2971
3158
  // per-message send overrides set via slash-commands (reset after each send,
2972
3159
  // except model which is sticky in localStorage). palette = open command list.
2973
3160
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -3000,6 +3187,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
3000
3187
  const STATUS_LABEL = {
3001
3188
  sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
3002
3189
  review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
3190
+ rejected: 'Dismissed',
3003
3191
  };
3004
3192
  // Mirrors engine/lib.mjs goalChip() — kept in sync manually since this
3005
3193
  // inline script can't import server-side code. `columns` (a project's
@@ -3018,6 +3206,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
3018
3206
  s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
3019
3207
  s === 'retesting' ? ['RE-TESTING', 'doing'] :
3020
3208
  s === 'reverted' ? ['REVERTED', ''] :
3209
+ s === 'rejected' ? ['DISMISSED', 'rejected'] :
3021
3210
  s === 'folded' ? ['FOLDED', ''] :
3022
3211
  s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
3023
3212
  const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
@@ -3321,11 +3510,17 @@ function buildReviewDigest({ goals, tasks } = {}){
3321
3510
  }
3322
3511
  return rows;
3323
3512
  }
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.
3513
+ // Review title: the same goal title the list view's goal header shows
3514
+ // (goalHeadText = g.text ?? g.plan[0]) so a card in "See all" and the same goal
3515
+ // in the list read identically (Masa 2026-07-19). Text-first; reviewSummary.changed
3516
+ // stays only as a last resort for a goal with no text. Pulled into one place so the
3517
+ // review card's own title and its sibling-goal titles (H23 split note) stay consistent.
3327
3518
  function saReviewTitle(g){
3328
- return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3519
+ // The goal's short ticket NAME wins everywhere it's shown (See-all title, Review-list
3520
+ // rail, board, sibling list) so the title reads the same in every place and never
3521
+ // repeats the full request (Masa 2026-07-21, spec §4). Real goals have no
3522
+ // reviewSummary.title, so they fall back to the request text unchanged (no regression).
3523
+ return String(g?.reviewSummary?.title || g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3329
3524
  }
3330
3525
  // H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
3331
3526
  // hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
@@ -3955,21 +4150,35 @@ function renderQueue(){
3955
4150
  const stacked = state.goals.filter((g) => g.projectId === state.active && g.status === 'stacked')
3956
4151
  .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id);
3957
4152
  const planning = state.goals.filter((g) => g.projectId === state.active && ['sending', 'planning'].includes(g.status));
3958
- if (!ob.length && !stacked.length && !planning.length) { q.classList.remove('show'); q.innerHTML = ''; return; }
4153
+ // Offline connection line: connOffline is the grace-debounced (~800ms) SSE-down
4154
+ // flag from the reconnect loop. When offline we always show the tray (even with
4155
+ // no queued items) so the red status line + Reconnect is visible above the composer.
4156
+ const offline = connOffline;
4157
+ if (!ob.length && !stacked.length && !planning.length && !offline) { q.classList.remove('show'); q.innerHTML = ''; return; }
3959
4158
  q.classList.add('show');
4159
+ // mirror connectionBannerState({online}) from engine/lib.mjs (a browser static file
4160
+ // can't import the module) — offline ⇒ red inline line + Reconnect atop the tray.
4161
+ const connHtml = offline
4162
+ ? '<div class="connline"><span class="cdot"></span><span class="cw">Galda is offline</span><span class="cmsg">— reconnecting…</span><button class="errbtn creconn" data-reconnect>Reconnect</button></div>'
4163
+ : '';
3960
4164
  // Optimistic outbox items (saved to localStorage BEFORE the network call) render
3961
4165
  // first — instant, offline-safe, never lost. 'unsent' rows are tap-to-resend.
3962
4166
  const obRows = ob.map((o) => {
3963
4167
  const unsent = o.status === 'unsent';
3964
4168
  const lead = unsent ? '<span class="qdot"></span>' : '<span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span>';
3965
- return `<div class="qrow"${unsent ? ` data-oresend="${o.oid}"` : ''}>${lead}<span class="qtxt">${esc((o.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">${unsent ? 'Unsent tap to resend' : 'Sending…'}</span></div>`;
4169
+ // mirror unsentMetaLabel({online}) from engine/lib.mjsoffline resend is a lie
4170
+ // (recovery is the connline's Reconnect), so drop tap-to-resend and swap the label.
4171
+ const resendable = unsent && !offline;
4172
+ const metaTxt = unsent ? (offline ? 'Waiting for connection' : 'Unsent — tap to resend') : 'Sending…';
4173
+ return `<div class="qrow"${resendable ? ` data-oresend="${o.oid}"` : ''}>${lead}<span class="qtxt">${esc((o.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">${metaTxt}</span></div>`;
3966
4174
  }).join('');
3967
4175
  const planningRows = planning.map((g) =>
3968
4176
  `<div class="qrow"><span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span><span class="qtxt">${esc((g.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">breaking down → To do…</span></div>`).join('');
3969
4177
  // Flagship: qtray label is the quiet mono "Queued · N" (flagship que1); other
3970
4178
  // layouts keep the explanatory sentence.
3971
4179
  const qn = ob.length + stacked.length + planning.length;
3972
- q.innerHTML = `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` + obRows + planningRows + stacked.map((g) => {
4180
+ const headHtml = qn > 0 ? `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` : '';
4181
+ q.innerHTML = connHtml + headHtml + obRows + planningRows + stacked.map((g) => {
3973
4182
  if (state.editing === g.id) {
3974
4183
  return `<textarea class="qedit" id="qedit-${g.id}">${esc(g.text)}</textarea>
3975
4184
  <div class="qbtns">
@@ -3987,6 +4196,7 @@ function renderQueue(){
3987
4196
  <button class="qico del qd" data-id="${g.id}" title="Delete">${ICONS.trash}</button></div>`;
3988
4197
  }).join('');
3989
4198
  for (const el of q.querySelectorAll('[data-oresend]')) el.onclick = () => { const item = outbox.load().find((x) => x.oid === el.dataset.oresend); if (item) postOutboxItem(item); };
4199
+ for (const el of q.querySelectorAll('[data-reconnect]')) el.onclick = () => forceReconnect();
3990
4200
  const qrows = [...q.querySelectorAll('.qrow[data-goal]')];
3991
4201
  for (const el of qrows) {
3992
4202
  el.addEventListener('dragstart', (e) => { state.dragGoal = Number(el.dataset.goal); el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
@@ -4313,14 +4523,22 @@ function renderTasks(){
4313
4523
  for (const b of $('tasklist').querySelectorAll('[data-qapprove]')) {
4314
4524
  b.onclick = async (e) => { e.stopPropagation();
4315
4525
  const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
4316
- let r; for (const id of b.dataset.qapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4317
- if (r && r.ok) confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2);
4318
- qrefresh(r, 'Approve failed.'); };
4526
+ let ok = true, mergeGoal = null;
4527
+ for (const id of b.dataset.qapprove.split(',')) {
4528
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4529
+ if (!r.ok){ ok = false; break; }
4530
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4531
+ }
4532
+ if (!ok){ showErr('Approve failed.'); return; }
4533
+ await refresh();
4534
+ if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
4535
+ confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); };
4319
4536
  }
4320
4537
  for (const b of $('tasklist').querySelectorAll('[data-qdismiss]')) {
4538
+ // 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
4539
+ // no auto-rework. Rework is asked for via chat.
4321
4540
  b.onclick = async (e) => { e.stopPropagation();
4322
- let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/dismiss`), { method: 'POST',
4323
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
4541
+ let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
4324
4542
  qrefresh(r, 'Dismiss failed.'); };
4325
4543
  }
4326
4544
  // Archive: retire an unfinished goal cleanly (no rework spawned). The escape
@@ -5069,7 +5287,39 @@ async function setRequirementStatus(goalId, taskId, status){
5069
5287
  // backend has never heard of, e.g. an injected verify fixture) and then
5070
5288
  // persisted server-side; a real goal's SSE 'goal' event reconciles it right
5071
5289
  // after, a no-op since the value already matches.
5290
+ // 07-20c: a review goal that HAS a PR is finished by MERGING the PR, not by approving in
5291
+ // the app (that was the "approved but PR still open" zombie). Reuses the confirmDelete modal
5292
+ // as a merge-confirm: primary action opens the PR; the goal stays in review and
5293
+ // syncGoalMerges flips it to done once GitHub reports the merge.
5294
+ async function confirmMerge(goal){
5295
+ const prNo = goal && goal.pr ? (String(goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1] : '';
5296
+ const open = await confirmDelete({
5297
+ title: 'Merge the Pull Request to finish',
5298
+ body: 'This goal has a PR. Approving here does not merge it — open the PR and merge it, and it moves to Done automatically.',
5299
+ previewHtml: `<div class="pvtitle">${esc(goal?.text || '')}</div>${prNo ? `<div class="pvpr">PR #${prNo} ↗</div>` : ''}`,
5300
+ previewPlain: true,
5301
+ confirmLabel: 'Open PR', cancelLabel: 'Close', confirmDanger: false,
5302
+ });
5303
+ if (open && goal && goal.pr) window.open(goal.pr, '_blank', 'noopener');
5304
+ }
5305
+ // Shared approve-response handler for the simple approve surfaces (inline ✓, review-card ✓,
5306
+ // See-all). Returns true if the goal actually advanced (done), false if it needs a merge.
5307
+ function handleApproveJson(json, goal){
5308
+ if (json && json.needsMerge){ if (json.goal) upsert(state.goals, json.goal); confirmMerge(json.goal || goal); return false; }
5309
+ if (json) upsert(state.goals, json);
5310
+ return true;
5311
+ }
5072
5312
  async function advanceGoalAfterAllApproved(goal){
5313
+ // 07-20c: a PR goal is not finished here — route it to the merge-confirm dialog and keep
5314
+ // it in review (no optimistic done, no celebration until the PR is actually merged).
5315
+ if (goal.pr){
5316
+ try {
5317
+ const r = await fetch(withKey(`/api/goals/${goal.id}/advance-review`), { method: 'POST' });
5318
+ if (r.ok){ const j = await r.json(); if (j.goal) upsert(state.goals, j.goal); if (j.needsMerge) confirmMerge(j.goal || goal); }
5319
+ } catch { /* offline — leave as review */ }
5320
+ renderReviewChecklist(); render();
5321
+ return;
5322
+ }
5073
5323
  const fly = captureReviewFly(goal.id);
5074
5324
  goal.status = 'done';
5075
5325
  renderReviewChecklist(); render();
@@ -5139,8 +5389,16 @@ $('reviewApprove').onclick = async () => {
5139
5389
  try {
5140
5390
  const r = await fetch(withKey(`/api/goals/${goal.id}/approve`), { method: 'POST' });
5141
5391
  if (!r.ok) throw new Error();
5392
+ const j = await r.json();
5393
+ if (j.needsMerge){ // 07-20c: PR goal — send to the merge dialog, no celebration/advance
5394
+ if (j.goal) upsert(state.goals, j.goal);
5395
+ renderReviewChecklist();
5396
+ $('reviewApprove').disabled = false;
5397
+ confirmMerge(j.goal || goal);
5398
+ return;
5399
+ }
5142
5400
  const fly = captureReviewFly(goal.id);
5143
- upsert(state.goals, await r.json());
5401
+ upsert(state.goals, j);
5144
5402
  renderReviewChecklist();
5145
5403
  const dm = $('reviewDoneMsg');
5146
5404
  if (dm){ dm.classList.remove('pop'); void dm.offsetWidth; dm.classList.add('pop'); }
@@ -5189,7 +5447,21 @@ async function sendReviewDismiss(text){
5189
5447
  render();
5190
5448
  } catch { showErr('Dismiss failed.'); }
5191
5449
  }
5192
- $('reviewDismiss').onclick = () => sendReviewDismiss('');
5450
+ // 07-20c: the review dialog's Reject button parks the goal in the Rejected column (/reject),
5451
+ // no auto-rework. Typing feedback into the box below and pressing Send still reworks (/dismiss).
5452
+ async function sendReviewReject(){
5453
+ const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
5454
+ if (!goal) return;
5455
+ try {
5456
+ const r = await fetch(withKey(`/api/goals/${goal.id}/reject`), { method: 'POST' });
5457
+ if (!r.ok) throw new Error();
5458
+ const body = await r.json();
5459
+ upsert(state.goals, body.goal);
5460
+ closeReviewChecklist();
5461
+ render();
5462
+ } catch { showErr('Dismiss failed.'); }
5463
+ }
5464
+ $('reviewDismiss').onclick = () => sendReviewReject();
5193
5465
  $('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
5194
5466
  $('reviewFixInput').addEventListener('keydown', (e) => {
5195
5467
  if (e.key === 'Enter') { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); }
@@ -5370,12 +5642,26 @@ $('gdApprove').onclick = async () => {
5370
5642
  try {
5371
5643
  const r = await fetch(withKey(`/api/goals/${goalId}/approve`), { method: 'POST' });
5372
5644
  if (!r.ok) throw new Error();
5373
- upsert(state.goals, await r.json());
5645
+ handleApproveJson(await r.json(), state.goals.find((g) => g.id === goalId)); // 07-20c: PR goal → merge dialog
5374
5646
  closeGoalDetail();
5375
5647
  render();
5376
5648
  } catch { showErr('Approve failed.'); }
5377
5649
  $('gdApprove').disabled = false;
5378
5650
  };
5651
+ // 07-20c: the goal-detail Reject button parks the goal in the Rejected column (/reject).
5652
+ // Revision text typed into gdSend still goes to /dismiss (rework) via sendDismiss below.
5653
+ async function sendReject(goalId){
5654
+ $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5655
+ try {
5656
+ const r = await fetch(withKey(`/api/goals/${goalId}/reject`), { method: 'POST' });
5657
+ if (!r.ok) throw new Error();
5658
+ const body = await r.json();
5659
+ upsert(state.goals, body.goal);
5660
+ closeGoalDetail();
5661
+ render();
5662
+ } catch { showErr('Dismiss failed.'); }
5663
+ $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
5664
+ }
5379
5665
  async function sendDismiss(goalId, text){
5380
5666
  $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5381
5667
  try {
@@ -5416,7 +5702,7 @@ async function discardGoal(goalId){
5416
5702
  } catch { showErr('Discard failed.'); }
5417
5703
  $('gdSend').disabled = false; $('gdDiscard').disabled = false;
5418
5704
  }
5419
- $('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendDismiss(goalId, ''); };
5705
+ $('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendReject(goalId); };
5420
5706
  $('gdDiscard').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) discardGoal(goalId); };
5421
5707
  $('gdSend').onclick = () => {
5422
5708
  const goalId = state.goalDetailOpen;
@@ -5460,7 +5746,7 @@ const fsUI = {
5460
5746
  rv: [1, 2, 3].includes(Number(localStorage.getItem('fsRv'))) ? Number(localStorage.getItem('fsRv')) : 3, // review view: 3 thumbnails (default) / 1 list / 2 compact
5461
5747
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
5462
5748
  revMore: false, // "+N more" fold on the review list (§1.5-8)
5463
- todoFold: false, doneOpen: false, todoMore: false,
5749
+ todoFold: false, doneOpen: false, rejOpen: false, todoMore: false,
5464
5750
  todoView: localStorage.getItem('fsTodoView') === 'board' ? 'board' : 'list', // To Do: list (default) / kanban board (Masa 2026-07-16)
5465
5751
  logOpen: new Set(), logFocus: null, // "View log" targets
5466
5752
  repsOpen: new Set(), // reply-group folds
@@ -5469,6 +5755,7 @@ const FS_ICONS = {
5469
5755
  reply: '<svg viewBox="0 0 24 24"><path d="M10 16l-5-4 5-4"/><path d="M5 12h9a4 4 0 0 1 4 4v2"/></svg>',
5470
5756
  edit: '<svg viewBox="0 0 24 24"><path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3z"/><path d="M14 7l3 3"/></svg>',
5471
5757
  del: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
5758
+ reopen: '<svg viewBox="0 0 24 24"><g transform="translate(24,0) scale(-1,1)"><polyline points="3 5 3 10 8 10"/><path d="M5.6 15.4A8 8 0 1 0 6 8.3L3 10.8"/></g></svg>', /* 07-20c: rotate arrow (mirrored → arrowhead top-right) = reopen/restore */
5472
5759
  };
5473
5760
  // per-project count icons (HANDOFF-v45 §10): To Do = rounded square, Review = Scan
5474
5761
  // (magnifier) — both stroked, NEUTRAL ink (never green); shape is the only distinction.
@@ -5984,14 +6271,19 @@ function renderFsReview(){
5984
6271
  const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
5985
6272
  ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
5986
6273
  : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
5987
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.checkLine)}</div>${rnote}${cnote}${pr}</div>
6274
+ // Title = the goal's own title (saReviewTitle → text-first), same as the See-all
6275
+ // Ledger and the To Do list, so a review card and its list row read identically
6276
+ // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
6277
+ // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
6278
+ // checkLine, which is the actionable label there, not a title mismatch.
6279
+ return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${cnote}${pr}</div>
5988
6280
  <div class="act"><button class="b" data-fsapprove="${ids}" title="Approve">✓</button><button class="b no" data-fsdismiss="${ids}" title="Send back">✕</button></div></div>`;
5989
6281
  };
5990
6282
  // A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
5991
6283
  // it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
5992
6284
  // it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
5993
6285
  const foldCard = (g) => {
5994
- const line = String(g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 60);
6286
+ const line = saReviewTitle(g).slice(0, 60); // title-first, same as the review card above
5995
6287
  const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
5996
6288
  return `<div class="rcard noimg fold" data-fsfold="${g.id}">
5997
6289
  <div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
@@ -6046,11 +6338,18 @@ function renderFsReview(){
6046
6338
  else openGoalDetail(target.id);
6047
6339
  };
6048
6340
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6049
- let r; for (const id of b.dataset.fsapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6050
- if (r && r.ok) { celebrateApprove(); await refresh(); } else showErr('Approve failed.'); };
6341
+ let ok = true, mergeGoal = null;
6342
+ for (const id of b.dataset.fsapprove.split(',')) {
6343
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6344
+ if (!r.ok){ ok = false; break; }
6345
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
6346
+ }
6347
+ if (!ok){ showErr('Approve failed.'); return; }
6348
+ await refresh();
6349
+ if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
6051
6350
  for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
6052
- let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/dismiss`), { method: 'POST',
6053
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
6351
+ // 07-20c: the review-card is a pure Reject Rejected column (/reject), no auto-rework.
6352
+ let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
6054
6353
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
6055
6354
  }
6056
6355
  // Shared To Do bucketing (list rows AND the kanban board read the same groups —
@@ -6070,13 +6369,14 @@ function fsBucketTodo(){
6070
6369
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
6071
6370
  doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
6072
6371
  later: pgoals.filter((g) => g.status === 'pending'),
6372
+ rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6073
6373
  };
6074
6374
  }
6075
6375
  // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
6076
6376
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
6077
6377
  function renderFsTodo(){
6078
6378
  const el = $('fsTodoSec'); if (!el) return;
6079
- const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
6379
+ const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later, rejected } = fsBucketTodo();
6080
6380
  const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
6081
6381
  // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
6082
6382
  // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
@@ -6177,7 +6477,8 @@ function renderFsTodo(){
6177
6477
  <div class="tl">
6178
6478
  ${(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
6479
  ${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}` : ''}
6480
+ ${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Dismissed<span class="num">${rejected.length}</span></span>${rejected.map((g) => `<div class="trow up rej"><span class="st rej"></span>${noSpan(g.id)}<span class="tt">${tt(g.text, 70)}</span><span class="acts"><button class="ab" data-fsreopen="${g.id}" title="Reopen — back to Review">${FS_ICONS.reopen}</button><button class="ab" data-fsrejdel="${g.id}" title="Delete — discard (closes the PR)">${FS_ICONS.del}</button></span></div>`).join('')}` : ''}
6481
+ ${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6181
6482
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
6182
6483
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
6183
6484
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
@@ -6190,6 +6491,16 @@ function renderFsTodo(){
6190
6491
  if (qadd) qadd.onclick = () => $('input')?.focus();
6191
6492
  const dl = el.querySelector('#fsDoneLine');
6192
6493
  if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
6494
+ for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
6495
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' });
6496
+ if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
6497
+ for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
6498
+ const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
6499
+ // Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
6500
+ // goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
6501
+ if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
6502
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' });
6503
+ if (!r.ok) showErr('Delete failed.'); else await refresh(); };
6193
6504
  const more = el.querySelector('#fsTodoMore');
6194
6505
  if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
6195
6506
  for (const b of el.querySelectorAll('[data-fsreps]')) b.onclick = () => {
@@ -6512,7 +6823,20 @@ function renderClawdHero(){
6512
6823
  $('fsRailTg').innerHTML = tgSvg('left', !root.classList.contains('railfold'));
6513
6824
  $('fsLaneTg').innerHTML = tgSvg('right', !root.classList.contains('lanefold'));
6514
6825
  };
6515
- $('fsRailTg').onclick = () => { root.classList.toggle('railfold'); tgUpdate(); fsGearAlignX(); }; // §1.5-7: fold
6826
+ $('fsRailTg').onclick = () => { // §1.5-7: fold
6827
+ root.classList.toggle('railfold'); tgUpdate();
6828
+ // Re-align the gear only AFTER the rail's fold animation settles. The old
6829
+ // synchronous fsGearAlignX() measured a half-collapsed rail mid-transition and
6830
+ // left the gear at a stale offset once the width finished moving — "たたむと歯車が
6831
+ // ズレる" (#474/#548, Masa 2026-07-19). Clear any stale offset now, re-measure on
6832
+ // transitionend (flex-basis), with a timeout fallback for reduced-motion / .noanim.
6833
+ const btn = $('fsMoreBtn'); if (btn) btn.style.transform = '';
6834
+ const rail = root.querySelector('.fsrail');
6835
+ let aligned = false;
6836
+ const align = () => { if (aligned) return; aligned = true; fsGearAlignX(); };
6837
+ if (rail) rail.addEventListener('transitionend', function te(e){ if (e.propertyName !== 'flex-basis') return; rail.removeEventListener('transitionend', te); align(); });
6838
+ setTimeout(align, 340);
6839
+ };
6516
6840
  $('fsLaneTg').onclick = () => { root.classList.toggle('lanefold'); tgUpdate(); };
6517
6841
  // List/Board toggle — wired once (static, unlike the row it used to live inside which
6518
6842
  // rebuilds on every renderFsTodo()); syncFsTvSeg() paints .on and is called from renderFsLane.
@@ -6756,12 +7080,22 @@ function clearBillingBlocked(){
6756
7080
  }
6757
7081
  function showErr(msg){
6758
7082
  const bar = $('errbar');
6759
- bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
7083
+ bar.classList.remove('billing', 'ok'); // shed the paywall/success styling if it was last used for that
6760
7084
  billingBlockedActive = false;
6761
7085
  bar.textContent = msg; bar.style.display = 'block';
6762
7086
  clearTimeout(errTimer);
6763
7087
  errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
6764
7088
  }
7089
+ // 07-20c: a neutral/success toast (green), reusing the #errbar surface. Used when a PR merge
7090
+ // lands back in the app (review→done via syncGoalMerges).
7091
+ function showToast(msg){
7092
+ const bar = $('errbar');
7093
+ bar.classList.remove('billing'); bar.classList.add('ok');
7094
+ billingBlockedActive = false;
7095
+ bar.textContent = msg; bar.style.display = 'block';
7096
+ clearTimeout(errTimer);
7097
+ errTimer = setTimeout(() => { bar.classList.remove('ok'); bar.style.display = 'none'; }, 4000);
7098
+ }
6765
7099
 
6766
7100
  // Free-limit copy (H22, Masa 2026-07-17 確定 · ja+en). The sentence is content,
6767
7101
  // so it follows the user's language; the mono label and buttons stay English
@@ -7109,15 +7443,55 @@ function scheduleActRender(){ if (_actT) { _actP = true; return; } renderActPane
7109
7443
  // (connection refused = fatal, no auto-retry), so we own the reconnect loop.
7110
7444
  let esRetryMs = 1000;
7111
7445
  let sseUp = false; // live engine link — the honest signal behind the MCP tab's status dot (v46)
7446
+ // Connection status line (queue tray): grace-debounce the offline state ~800ms so
7447
+ // sub-second SSE blips don't flash a red line. connOffline is the debounced flag
7448
+ // renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
7449
+ let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null;
7450
+ function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
7451
+ function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
7452
+ // Reconnect button: drop any pending backoff timer and reopen the EventSource now.
7453
+ function forceReconnect(){ if (_sseReconnectT) { clearTimeout(_sseReconnectT); _sseReconnectT = null; } try { _es && _es.close(); } catch { /* already closed */ } esRetryMs = 1000; connectSSE(); }
7454
+ // Robust liveness backstop that does NOT depend on EventSource onerror firing.
7455
+ // The browser throttles background tabs, so onerror can be delayed or never fire and
7456
+ // sseUp goes stale-true — that is why a dropped link showed no red line until the tab
7457
+ // was refreshed. A cheap HEAD probe (visible tabs only; also fired on tab-focus) flips
7458
+ // the connline within seconds of the user viewing a dead link, and forces a fresh SSE
7459
+ // so recovery (onopen -> clear + flush) still happens when onerror never fired.
7460
+ async function checkHealth(){
7461
+ if (document.hidden) return;
7462
+ let ok = false;
7463
+ try {
7464
+ const c = new AbortController(); const to = setTimeout(() => c.abort(), 3000);
7465
+ const r = await fetch(withKey('/'), { method: 'HEAD', cache: 'no-store', signal: c.signal });
7466
+ clearTimeout(to); ok = r.ok;
7467
+ } catch { ok = false; }
7468
+ if (!ok) {
7469
+ connSetOffline(); // grace-debounced ~800ms
7470
+ if (!_sseReconnectT) { try { _es && _es.close(); } catch {} sseUp = false; esRetryMs = Math.min(esRetryMs, 4000); _sseReconnectT = setTimeout(connectSSE, esRetryMs); }
7471
+ } else if (connOffline && !_sseReconnectT) {
7472
+ forceReconnect(); // server reachable again but the line is still up → reconnect to clear + flush
7473
+ }
7474
+ }
7475
+ function startHealthPoll(){ clearInterval(_healthT); _healthT = setInterval(checkHealth, 4000); }
7112
7476
  function connectSSE(){
7113
- const es = new EventSource(withKey('/api/events'));
7114
- es.onopen = () => { esRetryMs = 1000; sseUp = true; console.log('[manager] sse connected'); refresh(); flushOutbox();
7477
+ _sseReconnectT = null; // this attempt supersedes any scheduled retry
7478
+ const es = _es = new EventSource(withKey('/api/events'));
7479
+ es.onopen = () => { esRetryMs = 1000; sseUp = true; connSetOnline(); console.log('[manager] sse connected'); refresh(); flushOutbox();
7115
7480
  if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); };
7116
7481
  es.onmessage = (e) => {
7117
7482
  const m = JSON.parse(e.data);
7118
7483
  if (m.ev === 'goal') {
7119
- const line = goalLogLine(m.goal, state.goals.find((g) => g.id === m.goal.id));
7484
+ const prev = state.goals.find((g) => g.id === m.goal.id);
7485
+ const line = goalLogLine(m.goal, prev);
7120
7486
  if (line) state.logEntries = pushCapped(state.logEntries, { text: line, ts: Date.now() });
7487
+ // 07-20c: a PR goal reaches 'done' ONLY via a real merge (syncGoalMerges) — so a
7488
+ // review→done flip on a goal that has a PR means the user just merged it on GitHub and
7489
+ // came back. Celebrate (confetti) + a "merged" toast so the merge is unmistakable.
7490
+ if (prev && prev.status === 'review' && m.goal.status === 'done' && m.goal.pr) {
7491
+ try { celebrateApprove(); } catch {}
7492
+ const prNo = (String(m.goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1];
7493
+ showToast(`PR #${prNo} merged — “${(m.goal.text || '').replace(/\s+/g, ' ').slice(0, 48)}” is Done`);
7494
+ }
7121
7495
  upsert(state.goals, m.goal);
7122
7496
  }
7123
7497
  else if (m.ev === 'goal-deleted') state.goals = state.goals.filter((g) => g.id !== m.id);
@@ -7145,13 +7519,19 @@ function connectSSE(){
7145
7519
  scheduleRender();
7146
7520
  };
7147
7521
  es.onerror = () => {
7148
- if (es.readyState === EventSource.CLOSED) {
7149
- es.close();
7150
- if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
7151
- console.log(`[manager] sse closed reconnecting in ${esRetryMs}ms`);
7152
- setTimeout(connectSSE, esRetryMs);
7153
- esRetryMs = Math.min(esRetryMs * 2, 15000);
7154
- }
7522
+ // Native EventSource sits silently in CONNECTING and auto-retries on a dropped
7523
+ // or refused connection — readyState only reaches CLOSED on a non-2xx response.
7524
+ // Gating on CLOSED therefore missed every REAL relay/network drop (verified
7525
+ // 2026-07-20: CDP-offline AND killing the server both left the old gate un-fired,
7526
+ // so the red line never showed). Treat ANY error as the live link going down:
7527
+ // stop the native retry (close), flag offline (grace-debounced), own the backoff.
7528
+ if (_sseReconnectT) return; // a reconnect is already scheduled — don't stack
7529
+ try { es.close(); } catch { /* already closed */ }
7530
+ if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
7531
+ connSetOffline(); // arm the ~800ms grace timer before the red connline shows
7532
+ console.log(`[manager] sse down (readyState=${es.readyState}) — reconnecting in ${esRetryMs}ms`);
7533
+ _sseReconnectT = setTimeout(connectSSE, esRetryMs);
7534
+ esRetryMs = Math.min(esRetryMs * 2, 15000);
7155
7535
  };
7156
7536
  }
7157
7537
 
@@ -7302,7 +7682,8 @@ async function boot(){
7302
7682
  await refresh();
7303
7683
  flushOutbox();
7304
7684
  connectSSE();
7305
- document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); } });
7685
+ startHealthPoll();
7686
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); checkHealth(); } });
7306
7687
  // belt and suspenders for the never-lose guarantee: even if every event
7307
7688
  // path misfires, unsent memos are retried on a slow heartbeat
7308
7689
  setInterval(() => { if (outbox.load().some((o) => o.status === 'unsent')) flushOutbox(); }, 15000);
@@ -7713,16 +8094,46 @@ function saRows(){
7713
8094
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
7714
8095
  did, siblings, risk: g?.risk ?? null,
7715
8096
  note: g?.retest?.passed ? g.retest.note : null,
7716
- pr: r.pr, testResult: r.testResult,
8097
+ pr: r.pr,
8098
+ // Explicit review-summary display fields (spec §11: the card's shape follows the
8099
+ // task). A goal's reviewSummary may carry them directly (e.g. a conversational task
8100
+ // that reports an Answer + its Activity, or a ticket whose test count / diff totals
8101
+ // are recorded rather than re-derived); each falls back to the computed value.
8102
+ testResult: (typeof (g?.reviewSummary?.tests) === 'string')
8103
+ ? { ran: true, ok: true, passed: parseInt(g.reviewSummary.tests, 10) || 0, failed: 0, label: g.reviewSummary.tests }
8104
+ : r.testResult,
8105
+ answer: g?.reviewSummary?.answer ?? null,
8106
+ activity: Array.isArray(g?.reviewSummary?.activity) ? g.reviewSummary.activity : null,
8107
+ discussion: Array.isArray(g?.reviewSummary?.discussion) ? g.reviewSummary.discussion : null,
8108
+ changedDesc: g?.reviewSummary?.changed ?? null,
8109
+ metaFiles: Array.isArray(g?.reviewSummary?.files) ? g.reviewSummary.files : null,
8110
+ metaAdd: g?.reviewSummary?.a ?? null, metaDel: g?.reviewSummary?.d ?? null,
8111
+ ghRows: Array.isArray(g?.reviewSummary?.ghdiff) ? g.reviewSummary.ghdiff : null,
8112
+ diffTotal: g?.reviewSummary?.diffTotal ?? null,
7717
8113
  proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
7718
8114
  // Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
7719
8115
  // §3 kind==='image'): most tasks never have a "before" capture, so this is almost
7720
8116
  // always null and beforeAfterMediaHtml() falls back to the single-shot view.
7721
8117
  proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
8118
+ // Recording (spec §5/§6): a proof task may carry a screen recording (proof.mp4)
8119
+ // or just a recording:true flag; the goal's reviewSummary can also name one.
8120
+ // Drives the neutral "Verify recording ↗" meta chip + the Proof video player.
8121
+ // "ないものは載せない": almost all real tasks have none, so both stay hidden.
8122
+ recording: Boolean(pt?.proof?.mp4 || pt?.proof?.recording || g?.reviewSummary?.recording),
8123
+ recordingUrl: pt?.proof?.mp4 ? withKey(`/proof/${pt.id}/${pt.proof.mp4}`) : (typeof g?.reviewSummary?.recording === 'string' ? g.reviewSummary.recording : null),
8124
+ // Local URL (spec §5): a running localhost the reviewer can open ("Local localhost:4809 ↗").
8125
+ // Falls back to the existing "Local run ↗" (runlocal) button when absent.
8126
+ localUrl: g?.reviewSummary?.localUrl ?? null,
8127
+ // Client-side diff fallback: the git-backed /diff endpoint is null for seeded/
8128
+ // offline goals, so "What changed" never renders. A unified-diff string stored on
8129
+ // the goal (or any of its tasks) lets saGhDiffHtml render locally with no server.
8130
+ diffStr: g?.diff ?? (ts.filter((t) => r.goalIds.includes(t.goalId)).map((t) => t.diff).find(Boolean)) ?? null,
7722
8131
  // Token-frugality is the product's whole pitch — surface it here too, not
7723
8132
  // just in the per-goal review modal. buildReviewSummary computes the same
7724
8133
  // estimate; reuse its logic directly off the goal's runUsage/runTokens.
7725
- tokenOpt: estimateTokenOptimizationLocal({
8134
+ tokenOpt: (typeof g?.reviewSummary?.token === 'number')
8135
+ ? { percent: g.reviewSummary.token, basis: 'cache', techniques: ['prompt caching', '/compact'] }
8136
+ : estimateTokenOptimizationLocal({
7726
8137
  usage: g?.runUsage, weightedTokens: g?.runTokens, cacheReadWeight: g?.runTokenWeight,
7727
8138
  sessionMode: g?.executionPlan?.sessionMode,
7728
8139
  usedHandoff: g?.executionPlan?.sessionMode === 'cold-handoff' || Boolean(g?.contextHandoff),
@@ -7808,8 +8219,82 @@ function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
7808
8219
  // kind === 'image' (default)
7809
8220
  return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
7810
8221
  }
8222
+ // Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
8223
+ // walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
8224
+ // {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
8225
+ // NOT fabricated from a raw diff (the product never invents evidence) — the Bright
8226
+ // line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
8227
+ // extracted and unit-tested in engine/test/review-card-diff.test.mjs.
8228
+ function saUnifiedToGh(diff, max){
8229
+ const cap = max || 80; const out = []; let o = 0, n = 0;
8230
+ for (const raw of String(diff || '').split('\n')) {
8231
+ if (raw === '') continue; // split artifact / blank input — a real blank ctx line is a single space
8232
+ if (/^(diff --git|index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |Binary )/.test(raw)) continue;
8233
+ const m = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
8234
+ if (m) { o = +m[1]; n = +m[2]; continue; }
8235
+ if (raw.startsWith('\\')) continue; // ""
8236
+ if (raw.startsWith('+')) out.push({ o: null, n: n++, t: 'add', c: raw.slice(1) });
8237
+ else if (raw.startsWith('-')) out.push({ o: o++, n: null, t: 'del', c: raw.slice(1) });
8238
+ else out.push({ o: o++, n: n++, t: 'ctx', c: raw.replace(/^ /, '') });
8239
+ if (out.length >= cap) break;
8240
+ }
8241
+ return out;
8242
+ }
8243
+ function saGhDiffHtml(diff){
8244
+ const rows = saUnifiedToGh(diff);
8245
+ if (!rows.length) return '';
8246
+ return saGhDiffRowsHtml(rows);
8247
+ }
8248
+ // One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
8249
+ // Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
8250
+ function saTxRowHtml(e){
8251
+ if (e[0] === 'tool') return `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(e[1])}</b> ${esc(e[2] || '')}</span></div>`;
8252
+ if (e[0] === 'res') return `<div class="txr res"><span class="w"></span><span class="m">${esc(String(e[1]).replace(/^✗ /, '').replace(/^✓ /, ''))}</span></div>`;
8253
+ if (e[0] === 'user') return `<div class="txr you"><span class="w">You</span><span class="m">${esc(e[1])}</span></div>`;
8254
+ return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(e[1])}</span></div>`;
8255
+ }
8256
+ // Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
8257
+ // word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
8258
+ // footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
8259
+ // ghDiffHtml so the app pixel-matches. `more` = extra unshown line count; `pr` = url.
8260
+ function saGhDiffRowsHtml(rows, more, pr){
8261
+ if (!rows || !rows.length) return '';
8262
+ const body = '<div class="rdiff2">' + rows.map((l) => {
8263
+ const cls = l.t === 'add' ? 'gh-add' : l.t === 'del' ? 'gh-del' : 'gh-ctx';
8264
+ const mk = l.t === 'add' ? '+' : l.t === 'del' ? '−' : '';
8265
+ let code = esc(l.c);
8266
+ if (l.h) { const h = esc(l.h); code = code.replace(h, `<span class="gh-hi">${h}</span>`); }
8267
+ return `<div class="ghl ${cls}"><span class="gho">${l.o ?? ''}</span><span class="ghn">${l.n ?? ''}</span><span class="ghm">${mk}</span><span class="ghc">${code}</span></div>`;
8268
+ }).join('') + '</div>';
8269
+ const foot = more ? `<div class="ghfoot"><span>…+${more} 行</span>${pr ? ` · <a class="ghopen" href="${esc(pr)}" target="_blank" rel="noopener">GitHub で開く ↗</a>` : ''}</div>` : '';
8270
+ return body + foot;
8271
+ }
8272
+ // Screen-recording player for Proof (REVIEW-CARD-SPEC §6). A real file (recordingUrl)
8273
+ // renders a native <video controls>; otherwise a self-contained SVG player mockup
8274
+ // (faint app frame · centred play · scrub bar) matching the artifact's videoFig, with
8275
+ // a "▸ Watch recording · 0:12" caption. Purely presentational — no fabricated evidence
8276
+ // (only rendered when a recording actually exists on the task/goal).
8277
+ function saVideoFigHtml(url){
8278
+ if (url) return `<figure class="rvid"><video class="proofvid" src="${esc(url)}" controls preload="metadata"></video><figcaption>▸ Watch recording</figcaption></figure>`;
8279
+ const P = 'var(--panel)', H = 'var(--hair)';
8280
+ const svg = `<svg viewBox="0 0 560 315" preserveAspectRatio="xMidYMid meet"><rect width="560" height="315" fill="${P}"/>`
8281
+ + `<rect x="24" y="20" width="120" height="10" rx="3" fill="${H}"/><rect x="24" y="44" width="220" height="8" rx="3" fill="${H}"/><rect x="24" y="64" width="180" height="8" rx="3" fill="${H}"/>`
8282
+ + `<circle cx="280" cy="165" r="34" fill="rgba(0,0,0,.35)"/><path d="M270 150 l24 15 l-24 15 z" fill="#fff"/>`
8283
+ + `<rect x="24" y="286" width="512" height="4" rx="2" fill="${H}"/><rect x="24" y="286" width="150" height="4" rx="2" fill="#4fd79c"/><circle cx="174" cy="288" r="6" fill="#4fd79c"/></svg>`;
8284
+ return `<figure class="rvid">${svg}<figcaption>▸ Watch recording · 0:12</figcaption></figure>`;
8285
+ }
8286
+ // Workflow (REVIEW-CARD-SPEC §8): vertical ✓ / → / □ built ONLY from real signals on
8287
+ // this goal — each ✓ reflects something that actually happened (implemented, tested,
8288
+ // proof captured); the card's own "In review" is the → here; merge/deploy are ahead.
8289
+ // No fabricated pipeline: an untested goal shows □ Tested, not a green ✓.
8290
+ function saWorkflowHtml(it){
8291
+ const ran = Boolean(it.testResult?.ran);
8292
+ const hasProof = Boolean(it.proofImg && it.proofTaskId != null);
8293
+ const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['Proof captured', hasProof ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
8294
+ return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
8295
+ }
7811
8296
  function saItemHtml(it, i){
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];
8297
+ const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
7813
8298
  if (done) {
7814
8299
  // Revert deliberately has NO undo: the PR is closed and its branch deleted —
7815
8300
  // an "UNDO" here would be a lie (HANDOFF-v45 §5.3 honesty over symmetry).
@@ -7817,70 +8302,162 @@ function saItemHtml(it, i){
7817
8302
  return `<div class="sa-done ${done[0]}"><span class="mk">${done[1]}</span>${done[2]} · <span style="color:var(--ink3)">${esc(it.title.slice(0, 44))}</span>${undo}</div>`;
7818
8303
  }
7819
8304
  const d = SA.diffs[it.goalId];
7820
- // Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
7821
- // renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
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).
7825
8305
  const gtr = it.testResult;
7826
- const testsChip = gtr?.ran
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>';
7829
8306
  const hasProofImg = it.proofImg && it.proofTaskId != null;
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.
8307
+ // Before/after (spec §6): reuse the same beforeafter/ba-col/balabel renderer as the
8308
+ // per-task checklist (beforeAfterMediaHtml). it.proofBefore is null for the ~all
8309
+ // tasks with no "before" capture, so it falls back to the single <img class="proofimg">.
8310
+ // Diff-box + zoom inset is computed async after first paint (saEnhanceDiffBoxes);
8311
+ // SA.diffBoxHtml caches it so the box survives every later saRender().
7839
8312
  const boxedImg = SA.diffBoxHtml[it.goalId];
7840
8313
  const img = hasProofImg
7841
- ? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, 'proof'))
8314
+ ? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')))
7842
8315
  : '';
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).
8316
+ // Changed-file paths: d from the diff endpoint ({add,del,files:[{path,add,del}],diff}),
8317
+ // falling back to task.changedFiles when the server has no diff for this goal.
8318
+ // A usable git diff has actual files; the endpoint returns an empty {add:0,del:0,
8319
+ // files:[],diff:''} object for goals with no committed range (seeded/uncommitted),
8320
+ // which must NOT shadow the client-side fallback below.
8321
+ const hasGitDiff = Boolean(d && d.diff);
7846
8322
  let filePaths = [], nf = 0;
7847
- if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
7848
- else if (d === null) {
8323
+ if (d && d.files.length) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
8324
+ else {
8325
+ // no usable git diff: fall back to the tasks' own changedFiles so Files / What
8326
+ // changed still populate for seeded/offline goals.
7849
8327
  filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
7850
8328
  nf = filePaths.length;
7851
8329
  }
8330
+ // Client-side unified diff (spec §4 fallback): render "What changed" locally when the
8331
+ // server has no usable git diff. Line totals feed the Files chip's +add / −del.
8332
+ const clientDiff = (!hasGitDiff && it.diffStr) ? it.diffStr : null;
8333
+ let cAdd = 0, cDel = 0;
8334
+ if (clientDiff) for (const l of saUnifiedToGh(clientDiff, 2000)) { if (l.t === 'add') cAdd++; else if (l.t === 'del') cDel++; }
8335
+ // Recorded display fields (spec §11: card shape follows the task). A goal's reviewSummary
8336
+ // may carry an explicit file list; prefer it (real git-backed goals keep the computed
8337
+ // filePaths above). Split the "path +N −M" string down to the bare path for kind/log.
8338
+ if (it.metaFiles) { filePaths = it.metaFiles.map((f) => String(f).split(/\s{2,}/)[0]); nf = it.metaFiles.length; }
7852
8339
  const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
7853
- const factRow = [
7854
- testsChip,
7855
- it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
7856
- `<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
7857
- hasProofImg ? '' : '<span class="vf none">— no proof</span>',
7858
- ].filter(Boolean).join('');
7859
8340
  const gk = it.goalId;
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);
7867
- // shouldPause = classifyChangeRisk (engine/lib.mjs) already forced this goal to
7868
- // stop in review for exactly this reason surface it; a non-blocking low-risk
7869
- // read isn't worth a permanent row on every card (goal 427 §4 already covers it
7870
- // in the per-goal modal), so the Ledger only speaks up when it actually matters.
8341
+ const rs = it.goal?.reviewSummary || {};
8342
+ // Conversational task (no code change): drops Proof + What changed, shows an Answer
8343
+ // instead (spec §11); its Activity IS the conversation.
8344
+ const isConv = Boolean(it.answer);
8345
+ // ── meta (spec §5) fully data-driven / hide-empty: each chip renders only when its
8346
+ // data exists (no PR no PR chip · no tests → no Tests · no file totals → no Files ·
8347
+ // no recording → no Verify). PR = bold green, no underline (a real <a href>). ──
8348
+ const arw = '<span class="ar">↗</span>';
8349
+ const prNum = it.pr ? (it.pr.match(/pull\/(\d+)/) ?? [, ''])[1] : '';
8350
+ const tokPct = it.tokenOpt ? Number(it.tokenOpt.percent) : 0;
8351
+ const testsLabel = gtr?.ran ? (gtr.label || (gtr.ok ? `${gtr.passed} passed` : `${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed`)) : '';
8352
+ // Files totals: recorded strings (metaAdd/metaDel, already signed) win, else git/client.
8353
+ const fAdd = it.metaAdd ?? (hasGitDiff ? `+${d.add}` : clientDiff ? `+${cAdd}` : null);
8354
+ const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
8355
+ const chips = [];
8356
+ if (it.pr) chips.push(`<a class="mx mlink pr" href="${esc(it.pr)}" target="_blank" rel="noopener" title="Open pull request"><span class="mk">PR</span> <b>#${esc(prNum)}</b> <span class="mmut">Open</span>${arw}</a>`);
8357
+ if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">saved</span> <i class="tokchevron">›</i></button>`);
8358
+ if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
8359
+ // Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
8360
+ if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
8361
+ // Local: an open localhost URL (neutral, not green). A bare host:port isn't navigable in
8362
+ // the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
8363
+ if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
8364
+ chips.push(`<a class="mx mlink"${isUrl ? ` href="${esc(lu)}" target="_blank" rel="noopener"` : ' style="cursor:pointer"'} title="Open the running app"><span class="mk">Local</span> <b>${esc(lu.replace(/^https?:\/\//, ''))}</b>${arw}</a>`); }
8365
+ else if (gtr?.ran) chips.push(`<button type="button" class="mx mlink" data-saact="runlocal:${i}" title="Run this locally"><span class="mk">Local</span> <b>run</b>${arw}</button>`);
8366
+ // Verify = a screen recording of the check (spec §5): NEUTRAL. Opens the file when there
8367
+ // is a real URL, otherwise scrolls to the in-card player.
8368
+ if (it.recording) chips.push(it.recordingUrl
8369
+ ? `<a class="mx mlink" href="${esc(it.recordingUrl)}" target="_blank" rel="noopener" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`
8370
+ : `<a class="mx mlink" data-saact="rec:${i}" style="cursor:pointer" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`);
8371
+ const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
8372
+ const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
8373
+ const metaDetail = (it.tokenOpt && tokPct > 0) ? `<div class="rmeta-detail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)}${tokTech ? ` · ${esc(tokTech)}` : ''}</span></div>` : '';
8374
+ const runNote = gtr?.ran ? `<div class="rmeta-detail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
8375
+ const meta = `<div class="rmeta">${chips.join('')}</div>${metaDetail}${runNote}`;
8376
+ // ── Request (spec §4): a PLAIN section — the user's own short words (goal.text). Never
8377
+ // an accordion, never the implementation spec (that lives in Activity below). ──
8378
+ const reqSec = it.goalText
8379
+ ? `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`
8380
+ : '';
8381
+ // ── What to check (spec §2.5): a bulleted list when several, a line when one. ──
8382
+ // Only an explicit review-summary check renders here — never the goal text echoed back
8383
+ // (it.check falls back to goal.text via checkLine, which would fabricate a "What to
8384
+ // check" for a conversational task that has none). §0.5 no placeholder.
8385
+ const checkArr = Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
8386
+ : Array.isArray(rs.check) ? rs.check.filter(Boolean)
8387
+ : (typeof rs.check === 'string' && rs.check.trim() ? [rs.check.trim()] : []);
8388
+ const checkBody = checkArr.length > 1
8389
+ ? `<ul class="rcheck">${checkArr.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
8390
+ : (checkArr.length ? `<p class="rbody rcheck1">${esc(checkArr[0])}</p>` : '');
8391
+ // ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
8392
+ // run-log for logic-only tests; dropped entirely for a conversational task. ──
8393
+ let proofInner = '';
8394
+ if (!isConv) {
8395
+ if (hasProofImg) proofInner = `<div class="rproof">${img}</div>`;
8396
+ else if (kind === 'logic' && gtr?.ran) proofInner = `<div class="rrun"><div class="rr"><span class="lab">Tests</span><span class="v">${esc(testsLabel)}</span></div>${filePaths.length ? `<div class="rr"><span class="lab">Files</span><span class="v">${esc(filePaths.join(', '))}</span></div>` : ''}</div>`;
8397
+ if (it.recording) proofInner += saVideoFigHtml(it.recordingUrl);
8398
+ }
8399
+ // ── What changed (spec §4/§7): description + files summary (always) + the diff, which
8400
+ // peeks then "…+N 行 · GitHub で開く ↗" for a huge change (no GitHub link when there's no
8401
+ // PR). Structured recorded rows (ghRows) win over a git/client unified diff. Dropped
8402
+ // entirely for a conversational task. ──
8403
+ const changedDescHtml = (it.changedDesc || rs.changed) ? `<p class="rbody rdesc">${esc(String(it.changedDesc || rs.changed).slice(0, 600))}</p>` : '';
8404
+ const filesListHtml = it.metaFiles
8405
+ ? `<div class="rfiles">${it.metaFiles.map((f) => esc(f)).join('<br>')}</div>`
8406
+ : (d && d.files.length) ? `<div class="rfiles">${d.files.map((f) => `${esc(f.path)} <i class="a">+${f.add}</i> <i class="d">−${f.del}</i>`).join('<br>')}</div>`
8407
+ : (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
8408
+ const diffHtml = it.ghRows
8409
+ ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
8410
+ : hasGitDiff ? saGhDiffHtml(d.diff) : clientDiff ? saGhDiffHtml(clientDiff) : '';
8411
+ const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
8412
+ // ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
7871
8413
  const risk = it.risk;
7872
8414
  const riskHtml = risk?.shouldPause
7873
8415
  ? `<div class="lg-risk"><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>Needs your review — touches a sensitive area${risk.categories?.length ? `: ${esc(risk.categories.map((c) => c.label).join(', '))}` : ''}</div>`
7874
8416
  : '';
7875
- return `<div class="sa-item lg${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
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>
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}
8417
+ // ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
8418
+ // chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
8419
+ // below, offset-indented. The review Discussion folds in at the bottom of the body. The
8420
+ // transcript is the recorded build (activity); real goals fall back to "what we did".
8421
+ // Persisted open-state via data-detkey so SSE re-renders don't collapse it. ──
8422
+ const txArr = (it.activity && it.activity.length)
8423
+ ? it.activity.map((e) => saTxRowHtml(e))
8424
+ : (it.did?.length ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
8425
+ const discArr = it.discussion || it.thread || null;
8426
+ const threadHtml = (discArr && discArr.length)
8427
+ ? discArr.map((m) => `<div class="rmsg${m[0] === 'you' ? ' you' : ''}"><div class="who">${m[0] === 'you' ? 'You' : 'Manager'}</div><div class="msg">${esc(m[1])}</div></div>`).join('')
8428
+ : '';
8429
+ const actOpen = SA.detOpen.has(`${gk}:act`) ? ' open' : '';
8430
+ const txFirst = txArr[0] || '<div class="txr"><span class="w"></span><span class="m">Activity</span></div>';
8431
+ const txRest = txArr.slice(1).join('');
8432
+ const discInner = threadHtml ? `<div class="rsub">Discussion</div><div class="rthread">${threadHtml}</div>` : '';
8433
+ const activitySec = (txArr.length || threadHtml)
8434
+ ? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${txFirst}</div></summary><div class="lbody">${txRest ? `<div class="rtx">${txRest}</div>` : ''}${discInner}</div></details></section>`
8435
+ : '';
8436
+ const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
8437
+ return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
8438
+ <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
8439
+ <h2 class="rtitle">${esc(it.title)}</h2>
8440
+ ${meta}
8441
+ ${reqSec}
8442
+ ${S('What to check', checkBody)}
8443
+ ${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
8444
+ ${S('Proof', proofInner)}
8445
+ ${S('What changed', changedBody)}
8446
+ ${S('Not done', it.risk?.summary ? `<p class="rbody rrisk">${esc(it.risk.summary)}</p>` : '')}
7880
8447
  ${riskHtml}
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>
8448
+ ${activitySec}
8449
+ ${S('Workflow', saWorkflowHtml(it))}
8450
+ <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="Reply, ask, or send back to the AI…"><button class="sa-send" data-saact="send:${i}" title="Send"><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>
7882
8451
  </div>`;
7883
8452
  }
8453
+ // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
8454
+ // scroll area — the reviewer always starts reading a card from its header, never partway
8455
+ // down (Masa 2026-07-20). Uses a rect delta so it works regardless of offsetParent.
8456
+ function saScrollCardToTop(el){
8457
+ const body = document.querySelector('#seeall .sa-body');
8458
+ if (!body || !el) return;
8459
+ body.scrollTop += el.getBoundingClientRect().top - body.getBoundingClientRect().top;
8460
+ }
7884
8461
  function saRender(){
7885
8462
  const S = $('seeall'); if (!S) return;
7886
8463
  const pending = SA.items.filter((x) => x.st === 0);
@@ -7894,22 +8471,32 @@ function saRender(){
7894
8471
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
7895
8472
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
7896
8473
  // and wire each one's toggle to keep that set current.
7897
- for (const det of S.querySelectorAll('.lg-goal[data-detkey]')) {
8474
+ for (const det of S.querySelectorAll('[data-detkey]')) {
7898
8475
  det.addEventListener('toggle', () => {
7899
8476
  const k = det.dataset.detkey;
7900
8477
  if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
7901
8478
  });
7902
8479
  }
7903
- // H14: if a single-review focus is pending, center its card and flash a blue
7904
- // ring. Re-scroll on EVERY render for the focus window the post-diff saSync
7905
- // rebuild resets .sa-body scroll to the top, so a one-shot scroll would land
7906
- // back at the top. Self-clears after ~1.4s (outlasts the localhost diff fetch)
7907
- // so live SSE ticks stop yanking the view.
8480
+ // "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
8481
+ // show a broken-image placeholder. proofImg is only a stored filename, so a file that
8482
+ // isn't on disk (or fails to load) can only be caught at load time — drop the whole
8483
+ // Proof section if any of its images fails. Real captures load and stay.
8484
+ for (const im of S.querySelectorAll('.redesign .rproof img')) {
8485
+ const drop = () => { const sec = im.closest('section.rsec'); if (sec) sec.remove(); };
8486
+ if (im.complete && im.naturalWidth === 0) drop();
8487
+ else im.addEventListener('error', drop, { once: true });
8488
+ }
8489
+ // H14: if a single-review focus is pending, bring the card's HEADER (#tag + In review
8490
+ // + title) to the very top of the scroll area — never mid-card (Masa 2026-07-20) — and
8491
+ // flash a blue ring. Re-scroll on EVERY render for the focus window — the post-diff
8492
+ // saSync rebuild resets .sa-body scroll to the top, so a one-shot scroll would land back
8493
+ // there. Self-clears after ~1.4s (outlasts the localhost diff fetch) so live SSE ticks
8494
+ // stop yanking the view.
7908
8495
  if (SA.focusGid != null) {
7909
8496
  requestAnimationFrame(() => {
7910
8497
  const el = S.querySelector(`.sa-item[data-gid="${SA.focusGid}"]`);
7911
8498
  if (!el) return;
7912
- el.scrollIntoView({ block: 'center', behavior: 'auto' });
8499
+ saScrollCardToTop(el);
7913
8500
  el.classList.add('sa-flash');
7914
8501
  if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
7915
8502
  });
@@ -7980,6 +8567,14 @@ function saOpen(focusGoalId){
7980
8567
  }
7981
8568
  saRender();
7982
8569
  $('seeall').classList.add('open');
8570
+ // Land the opening card's header at the very top of the scroll area (Masa 2026-07-20).
8571
+ // With focusGid set, saRender's focus block already does this; without it (a plain
8572
+ // "See all"), resolved one-liners can sit above the first pending card, so scrollTop 0
8573
+ // isn't enough — scroll the current card's header to the top explicitly.
8574
+ if (SA.focusGid == null && SA.cur != null) requestAnimationFrame(() => {
8575
+ const el = $('seeall').querySelector(`.sa-item[data-gid="${SA.cur}"]`);
8576
+ if (el) saScrollCardToTop(el);
8577
+ });
7983
8578
  saFetchDiffs();
7984
8579
  saEnhanceDiffBoxes();
7985
8580
  }
@@ -8031,7 +8626,7 @@ async function saAct(it, st, path, body){
8031
8626
  saSync();
8032
8627
  return true;
8033
8628
  }
8034
- const SA_UNDO = { 1: 'revert-review', 2: 'retest-cancel', 3: 'undismiss', 4: 'unarchive' };
8629
+ const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
8035
8630
  $('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
8036
8631
  $('seeall').addEventListener('click', async (e) => {
8037
8632
  const b = e.target.closest('[data-saact]');
@@ -8044,9 +8639,17 @@ $('seeall').addEventListener('click', async (e) => {
8044
8639
  // Run-locally to reveal the honest "not wired yet" note — neither performs an action.
8045
8640
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
8046
8641
  if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
8642
+ // Verify chip (no external recording URL) → scroll the in-card video player into view.
8643
+ if (k === 'rec') { $('seeall').querySelector(`.sa-item[data-gid="${it.goalId}"] .rvid`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
8047
8644
  // every verdict auto-advances the focus to the next pending card (tempo).
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); }); }
8049
- if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8645
+ if (k === 'app') { const bx = b.getBoundingClientRect(); return saAct(it, 1, 'approve').then((ok) => {
8646
+ if (!ok) return;
8647
+ // 07-20c: if the goal has a PR it stays in review (needsMerge) — open the merge dialog
8648
+ // instead of celebrating/advancing; saCall already upserted the still-'review' goal.
8649
+ const g = state.goals.find((x) => x.id === it.goalId);
8650
+ if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; }
8651
+ confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); saAdvanceFocus(it.goalId); }); }
8652
+ if (k === 'dis') return saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); // 07-20c: Reject → Rejected column
8050
8653
  if (k === 'arch') return saAct(it, 4, 'archive').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8051
8654
  if (k === 'rev') return saAct(it, 5, 'revert').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8052
8655
  if (k === 'send') {
@@ -8090,11 +8693,16 @@ document.addEventListener('keydown', (e) => {
8090
8693
  e.preventDefault();
8091
8694
  const gid = it.goalId;
8092
8695
  if (e.key === 'a' || e.key === 'A') {
8093
- const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
8094
- const box = el?.getBoundingClientRect();
8095
- confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
8096
- saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(gid); });
8097
- } else saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(gid); });
8696
+ saAct(it, 1, 'approve').then((ok) => {
8697
+ if (!ok) return;
8698
+ const g = state.goals.find((x) => x.id === gid);
8699
+ if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; } // 07-20c: PR goal → merge dialog
8700
+ const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
8701
+ const box = el?.getBoundingClientRect();
8702
+ confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
8703
+ saAdvanceFocus(gid);
8704
+ });
8705
+ } else saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(gid); }); // 07-20c: D = Reject
8098
8706
  });
8099
8707
  // 紙吹雪 (Approveの気持ちよさ=機能要件・PRD §5.1) — flagship confetti port:
8100
8708
  // 6 full-spectrum colours, ~90 small rects, gravity, gone in 1.4s.
@@ -8403,7 +9011,7 @@ let _delConfirmResolve = null;
8403
9011
  // Discard positioned left); dismissing the dialog must still land on the non-destructive choice
8404
9012
  // regardless of which label happens to sit in which slot.
8405
9013
  let _delDismissValue = false;
8406
- function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
9014
+ function confirmDelete({ title, body, preview, previewHtml, previewPlain = false, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
8407
9015
  $('delModalTitle').textContent = title || 'Delete this goal?';
8408
9016
  $('delModalBody').textContent = body || "This can't be undone.";
8409
9017
  $('delConfirmBtn').textContent = confirmLabel || 'Delete';
@@ -8415,7 +9023,10 @@ function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismis
8415
9023
  $('delConfirmBtn').className = 'mbtn' + (confirmDanger ? ' danger' : ' primary');
8416
9024
  $('delCancelBtn').className = 'mbtn' + (cancelDanger ? ' danger' : ' ghost');
8417
9025
  const pv = $('delPreviewTitle');
8418
- if (preview) { pv.textContent = preview; pv.style.display = ''; } else { pv.textContent = ''; pv.style.display = 'none'; }
9026
+ pv.classList.toggle('plain', !!previewPlain);
9027
+ if (previewHtml) { pv.innerHTML = previewHtml; pv.style.display = ''; }
9028
+ else if (preview) { pv.textContent = preview; pv.style.display = ''; }
9029
+ else { pv.textContent = ''; pv.style.display = 'none'; }
8419
9030
  _delDismissValue = dismissValue;
8420
9031
  $('deleteConfirmOverlay').classList.add('show');
8421
9032
  return new Promise((resolve) => { _delConfirmResolve = resolve; });
@@ -9152,7 +9763,6 @@ function gearSetHTML(){
9152
9763
  // to anonymous free users too. Sign-in is an extra (sync across devices), not a
9153
9764
  // gate to see your own usage or upgrade.
9154
9765
  const freeUsage = `<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
9155
- <div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
9156
9766
  <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 20}</span></div>
9157
9767
  <button class="bx-cta bx-up-set" data-gupgrade="1"><span class="bx-spin"></span>Upgrade${priceTag(bill.price)}</button>`;
9158
9768
  const billingRow = bill.plan === 'paid'