@galda/cli 0.10.24 → 0.10.30

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
@@ -592,6 +592,10 @@
592
592
  .task .name{font-size:13px;color:var(--ink);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
593
593
  .task.done .name{color:var(--ink)}
594
594
  .task .cond{font-family:var(--mono);font-size:10.5px;color:var(--ink3);margin-top:3px}
595
+ /* PRD §6.5: a folded goal is settled, not failed — its title sinks to --ink2 like
596
+ any other settled row. No red, no painted surface, no badge (§1.5 art.8/art.9);
597
+ the pointer is the text itself, which reads as "the work is over there". */
598
+ .task.revrow.foldrow .name{color:var(--ink2)}
595
599
  .replyctx{color:var(--ink2)}
596
600
  .replyctx svg{width:11px;height:11px;vertical-align:-1px;stroke:currentColor;stroke-width:2;fill:none}
597
601
  .side-foot{flex:0 0 auto;padding:11px 14px;border-top:1px solid var(--hair);font-size:11.5px;color:var(--ink3);display:flex;align-items:center;gap:7px}
@@ -650,6 +654,18 @@
650
654
  .delmodal{width:380px}
651
655
  .delpreview{border:1px solid var(--hair2);border-radius:10px;padding:10px 12px;background:var(--paper);
652
656
  color:var(--ink);font-size:12.5px;line-height:1.5;max-height:120px;overflow:auto;word-break:break-word}
657
+ /* moment ③ New-project paywall dialog (H22). Same narrow .modal as .delmodal,
658
+ but a one-action layout (Masa 2026-07-17 確定 __dlg(2)): Upgrade full-width,
659
+ "Not now" a quiet underlined link centered below it. */
660
+ .flimdlg{width:380px}
661
+ .flimdlg .fd-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--danger);margin-bottom:8px}
662
+ .flimdlg h3{margin:0 0 8px;font-size:15px;letter-spacing:-.01em;color:var(--ink);text-wrap:balance}
663
+ .flimdlg p{margin:0;font-size:12.5px;color:var(--ink2);line-height:1.65}
664
+ .flimdlg .fd-row{display:flex;flex-direction:column-reverse;gap:10px;align-items:stretch;margin-top:16px}
665
+ .flimdlg .fd-cta{width:100%;height:34px}
666
+ .flimdlg .fd-no{border:0;background:0;height:auto;padding:0;color:var(--ink3);font:500 12px/1 var(--ui);
667
+ text-decoration:underline;text-underline-offset:2px;align-self:center;cursor:pointer}
668
+ .flimdlg .fd-no:hover{color:var(--ink2)}
653
669
  .howto{margin-top:14px;border:1px solid var(--hair);border-radius:10px;padding:10px 14px;background:var(--paper)}
654
670
  .howto b{font-size:12px;color:var(--ink)}
655
671
  .howto ol{margin:6px 0 0;padding-left:18px;font-size:11.5px;color:var(--ink2);line-height:1.8}
@@ -1091,8 +1107,11 @@
1091
1107
  the layout starts at the three cards. Frame = 14px all around (flagship .app{inset:14px}).
1092
1108
  Dev escape hatch: ?dev=1 (or localStorage devbar=1) adds body.devbar to show the bar. */
1093
1109
  body[data-layout="flagship"]:not(.devbar) .top{display:none}
1094
- body[data-layout="flagship"] #fsRoot{flex:1;min-height:0;display:flex;gap:12px;padding:14px;position:relative;
1095
- --fscenterw:760px} /* position:relative so .railgrip (a direct child, kept outside .fsrail's
1110
+ body[data-layout="flagship"] #fsRoot{flex:1;min-width:0;min-height:0;display:flex;gap:12px;padding:14px;position:relative;
1111
+ --fscenterw:760px} /* min-width:0 (Masa 2026-07-16): rail/lane are flex-shrink:0 (fixed basis), so without
1112
+ this #fsRoot's own automatic min-width == their full combined width, and #fsRoot refuses to shrink
1113
+ below that even though .fscenter (flex:1;min-width:0) is perfectly able to — the whole page overflowed
1114
+ horizontally once the lane got wide instead of .fscenter just getting narrow. position:relative so .railgrip (a direct child, kept outside .fsrail's
1096
1115
  overflow:hidden — see below) can be placed via left:calc(var(--railw)...) and never get clipped.
1097
1116
  single source of truth for the reading column — .fsstream (feed) and
1098
1117
  .compwrap (composer) both read this var so they can never drift apart (fix: composer was
@@ -1215,7 +1234,7 @@
1215
1234
  color:color-mix(in srgb,var(--ink) 24%,transparent);cursor:pointer;
1216
1235
  display:grid;place-items:center;position:absolute;z-index:2;font:inherit}
1217
1236
  #fsRoot .railtg:hover,#fsRoot .lanetg:hover{background:transparent;color:color-mix(in srgb,var(--ink) 85%,transparent)}
1218
- #fsRoot .railtg{top:8px;right:6px}
1237
+ #fsRoot .railtg{top:12px;right:6px} /* 28px button centers its glyph — lowered ~4px onto the Galda logo's visual baseline (Masa via CDO artifact 2026-07-16, not yet in flagship.html) */
1219
1238
  #fsRoot.railfold .railtg{right:50%;margin-right:-14px} /* centred in the folded 44px rail — the arrow, not rotation, signals the opening direction (v45 §4) */
1220
1239
  #fsRoot.railfold .fsrail{flex-basis:44px;padding:44px 7px 14px} /* top pad 44px — project dots clear the toggle (v45 §2) */
1221
1240
  #fsRoot.railfold .fsrail .lb,#fsRoot.railfold .fsproj span,#fsRoot.railfold .fsproj .num,#fsRoot.railfold .fsprojadd .pa-label{display:none}
@@ -1361,6 +1380,11 @@
1361
1380
  outright, its original home) — float it just above the input, matching the
1362
1381
  legacy layout's "just above the composer" placement (CDO判定 2026-07-16). */
1363
1382
  #fsRoot .compwrap>.errbar{position:absolute;left:50%;bottom:calc(100% + 10px);transform:translateX(-50%)}
1383
+ /* Free-limit banner (H22) spans the composer, not a centered content-width pill
1384
+ like a plain showErr toast: the width rule is scoped to .billing so ordinary
1385
+ errors stay centered pills. left/right:0 stretches it to .compwrap's own box
1386
+ (= composer width), and .bx-ask is display:flex so it fills that. */
1387
+ #fsRoot .compwrap>.errbar.billing{left:0;right:0;transform:none}
1364
1388
  #fsRoot.lanefold{--fscenterw:1020px} /* folded space flows to the center (§1.5-7) — one var flip keeps feed+composer in lockstep */
1365
1389
  /* adopted composer (#composerWrap moves in — same node, same handlers, focus fix intact) */
1366
1390
  body[data-layout="flagship"] #composerWrap{padding:0;border-top:0;max-width:none;width:auto;margin:0}
@@ -1386,10 +1410,11 @@
1386
1410
  flagship-only 34px override that used to live here is gone, it was redundant
1387
1411
  with (and masked that) the base .send circle was still 32px elsewhere. */
1388
1412
  /* ---- right lane: Review (top, §1.5-4) + To Do timeline ---- */
1389
- #fsRoot .fslane{flex:0 0 var(--lanew,372px);display:flex;flex-direction:column;padding:36px 18px 14px;position:relative;
1413
+ #fsRoot .fslane{flex:0 0 var(--lanew,372px);min-width:0;display:flex;flex-direction:column;padding:36px 18px 14px;position:relative;
1390
1414
  transition:flex-basis .28s cubic-bezier(.4,0,.2,1),padding .28s}
1391
1415
  #fsRoot .fslane.noanim{transition:none}
1392
- #fsRoot .lanetg{top:9px;left:10px} /* 畳みトグル: 左上・帯 (§6.5 確定 tgpos1 — lane padding-top 36px = the band strip) */
1416
+ #fsRoot .lanetg{top:12px;left:10px} /* 畳みトグル: 左上・帯 (§6.5 確定 tgpos1 — lane padding-top 36px = the band strip).
1417
+ top:12 (was 9) matches .railtg's Galda-baseline lower (Masa via CDO artifact 2026-07-16, not yet in flagship.html). */
1393
1418
  #fsRoot.lanefold .lanetg{left:50%;margin-left:-14px} /* no rotation (v45 §4) */
1394
1419
  #fsRoot.lanefold .fslane{flex-basis:44px;padding:18px 7px}
1395
1420
  #fsRoot.lanefold .fslane>*:not(.lanetg){display:none}
@@ -1399,6 +1424,79 @@
1399
1424
  #fsRoot .lanebody{display:flex;flex-direction:column;min-height:0;flex:1;overflow-y:auto;overflow-x:hidden;scrollbar-width:none}
1400
1425
  #fsRoot .lanebody::-webkit-scrollbar{display:none}
1401
1426
  #fsRevSec:not(:empty){margin-bottom:12px}
1427
+
1428
+ /* ---- To Do: List/Board view toggle, in the To Do header — same spot both modes (Masa 2026-07-16) ---- */
1429
+ /* List/Board toggle: a FIXED overlay beside the fold icon, in BOTH modes — matching the
1430
+ Artifact's .labseg.inlane exactly (2026-07-17: it's never inline in the To Do header row
1431
+ in the Artifact, always an absolute sibling of .lanetg; a prior commit put it inside
1432
+ #fsTodoHd's flow instead, which pushed it onto its own row below the fold icon whenever
1433
+ the header text above it wrapped/existed — "まだなおってない" screenshot). .lanetg is
1434
+ top:12/left:10/width:28 (center y=26) → this sits at the same 8px gap the Artifact uses
1435
+ after its own fold icon, vertically centered on that same 26px line. */
1436
+ #fsRoot .fstvseg{position:absolute;top:16px;left:42px;z-index:6;display:inline-flex;gap:2px} /* top/left measured directly off the Artifact's own
1437
+ .labseg.inlane (2026-07-17: opened it locally, getBoundingClientRect()) — centers on .lanetg exactly like the fold icon,
1438
+ left:42 = .lanetg's right edge (10+28=38) + a measured 4px gap (an earlier pass guessed 46/8px without measuring the gap itself). */
1439
+ /* Board mode: the "To Do N" title is redundant once a To Do COLUMN exists among the board's
1440
+ own columns — hide it; the toggle+fold are unaffected since they no longer live inside it
1441
+ (Masa 2026-07-16: 看板の時は上のTo Doを消してよい — the column keeps the name). */
1442
+ #fsRoot .fslane.boardmode #fsTodoHd{margin-bottom:0}
1443
+ #fsRoot .fslane.boardmode #fsTodoHd .fstdlabel{display:none}
1444
+ /* toggle tone matches the CDO Artifact's .labseg button exactly (2026-07-17 checked against
1445
+ the Artifact directly, not a guess): ink3 @ opacity .75 at rest, hover/on = filled
1446
+ background + full ink + opacity 1. This is a DIFFERENT convention from the fold buttons
1447
+ (which are glyph-only, no fill) — the segmented toggle is its own component and the
1448
+ Artifact deliberately fills a background for the selected state; a prior commit "fixed"
1449
+ this to match the fold buttons' no-fill rule, which was the wrong reference. */
1450
+ #fsRoot .fstvb{width:22px;height:20px;padding:0;display:grid;place-items:center;border:0;background:transparent;color:var(--ink3);cursor:pointer;border-radius:6px;opacity:.75;font:inherit} /* 22x20/radius:6 — was 20x18/radius:5, an unmeasured guess ("小さい") */
1451
+ #fsRoot .fstvb:hover{background:var(--hover);color:var(--ink);opacity:1}
1452
+ #fsRoot .fstvb.on{background:var(--hover);color:var(--ink);opacity:1}
1453
+ #fsRoot .fstvb:focus:not(:focus-visible){outline:none}
1454
+ #fsRoot .fstvb svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} /* was 12px, guessed */
1455
+ #fsRoot .fstvb svg rect{fill:none}
1456
+
1457
+ /* ---- kanban board: fills the SAME lane slot the To Do list uses (var(--lanew-board), independent
1458
+ from the list's own var(--lanew)) — swapping List⇄Board never resizes the lane or the chat.
1459
+ 5+ columns don't fit the lane width, so it scrolls horizontally in place instead of growing. ---- */
1460
+ #fsRoot .fslane.boardmode{flex-basis:var(--lanew-board,372px)}
1461
+ #fsRoot .fslane.boardmode #fsTodoSec .tl,#fsRoot .fslane.boardmode #fsTodoSec .doneline,#fsRoot .fslane.boardmode #fsTodoSec #fsDonelist{display:none}
1462
+ #fsBoard{display:none;gap:14px;flex:1 1 auto;min-width:0;min-height:0;overflow-x:auto;overflow-y:hidden;padding:0 0 4px 0;margin-top:26px;align-items:flex-start;line-height:1.5} /* 26px measured directly off the Artifact's #labBoard (was 8px — guessed, never checked).
1463
+ line-height:1.5 — the Artifact's <body> sets this globally and every board text element inherits it (measured: .labcolnm computes to 18.75px = 12.5×1.5);
1464
+ this app's <body> doesn't set a global line-height, so the same elements were rendering at the browser's "normal" default instead — same
1465
+ container-box position (measured identical, 63px both), but the glyph sits differently inside that box. Scoped here, not on <body>, to avoid
1466
+ a global typography change. */
1467
+ #fsRoot .fslane.boardmode #fsBoard{display:flex}
1468
+ #fsBoard::-webkit-scrollbar{height:8px}#fsBoard::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1469
+ .fsbcol{flex:0 0 200px;max-height:100%;display:flex;flex-direction:column;min-height:0}
1470
+ .fsbcolhd{display:flex;align-items:center;gap:8px;padding:1px 4px 9px;flex:0 0 auto}
1471
+ .fsbdot{width:7px;height:7px;border-radius:50%;background:var(--stg,var(--ink3));flex:0 0 auto;box-shadow:0 0 0 3px color-mix(in srgb,var(--stg,#fff) 14%,transparent)}
1472
+ .fsbcolnm{font-size:12.5px;font-weight:600;color:var(--ink);letter-spacing:-.01em}
1473
+ .fsbcnt{font-family:var(--mono);font-size:10px;color:var(--ink3);font-variant-numeric:tabular-nums;background:var(--surf);border-radius:5px;padding:1px 6px}
1474
+ .fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
1475
+ .fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1476
+ .fsbrow{padding:7px 9px;border-radius:8px;background:color-mix(in srgb,var(--ink) 5%,transparent);box-shadow:0 0 0 1px var(--hair);font-size:12.4px;display:flex;align-items:center;gap:6px}
1477
+ .fsbrow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px}
1478
+ .fsbrow .st.run{border-color:var(--green);background:radial-gradient(circle,rgba(var(--green-rgb),.4),transparent 70%)}
1479
+ .fsbrow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%)}
1480
+ .fsbrow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%)}
1481
+ .fsbrow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
1482
+ .fsbrow .st.ok::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
1483
+ .fsbrow .st.rev{border-color:var(--blue);background:radial-gradient(circle,color-mix(in srgb,var(--blue) 42%,transparent),transparent 70%)}
1484
+ .fsbrow .tt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1485
+ .fsbempty{color:var(--ink3);font-size:11px;padding:4px 2px}
1486
+
1487
+ /* ---- chat ⇄ lane panel swap: grab anywhere in the top ~40px of either panel, drop lands
1488
+ EXACTLY on the side the dashed guide showed. Rail is not part of this (Masa 2026-07-16). ---- */
1489
+ #fsRoot .fscenter{order:1}
1490
+ #fsRoot .fslane{order:2}
1491
+ #fsRoot.swapmid .fscenter{order:2}
1492
+ #fsRoot.swapmid .fslane{order:1}
1493
+ #fsRoot .panelgrip{position:absolute;top:10px;right:10px;width:24px;height:24px;display:grid;place-items:center;border:0;background:transparent;color:var(--ink3);opacity:.4;cursor:grab;border-radius:6px;z-index:5;transition:opacity .12s,background .12s} /* right:10 on BOTH .fscenter and .fslane, measured directly off the Artifact (2026-07-17) — it uses the same uniform 10/10 for both grips, no special-cased offset for #fsBandBtn (which is Cinema-theme-only and the Artifact doesn't dodge it either); a prior right:44px guess for .fscenter was the actual bug ("6点が左にずれてる") */
1494
+ #fsRoot .panelgrip:hover{opacity:1;background:var(--hover)}
1495
+ #fsRoot .panelgrip svg{width:14px;height:14px;fill:currentColor}
1496
+ #fsRoot .panel-over{outline:2px dashed color-mix(in srgb,var(--ink) 22%,transparent);outline-offset:-2px;border-radius:14px}
1497
+ #fsRoot .fscenter.top-armed,#fsRoot .fslane.top-armed{cursor:grab}
1498
+ #fsRoot .fscenter.top-armed:active,#fsRoot .fslane.top-armed:active{cursor:grabbing}
1499
+ #panelDropLine{position:absolute;width:0;display:none;border-left:2px dashed var(--ink2);z-index:20;pointer-events:none;border-radius:2px}
1402
1500
  #fsRoot .lhd{display:flex;align-items:baseline;gap:8px;margin-bottom:12px}
1403
1501
  #fsRoot .lhd .hd{font-size:13.5px;letter-spacing:-.02em}
1404
1502
  #fsRoot .lhd .num{font-size:10px;color:var(--ink3)}
@@ -1438,6 +1536,33 @@
1438
1536
  /* review row number — #NN PREFIX on the title (HANDOFF-v45 §11), same form as the To Do
1439
1537
  rows (PRD §5: numbering matches the sidebar) */
1440
1538
  #fsRoot .rcard .rid{font-size:10.5px;font-weight:600;color:var(--ink3);margin:0 5px 0 0;display:inline-block;font-variant-numeric:tabular-nums}
1539
+ /* PRD §6.5 fold note — on the carrier ("also carries #13") and on the folded card
1540
+ ("merged into #12"). Deliberately NOT the .pr treatment: no tinted ground, no
1541
+ badge (§1.5-9), no red — folding is not a failure. Mono + --ink3 = the same quiet
1542
+ meta tone the rest of the lane uses (§5.5「品は罫線とトーン差・monoで出す」). */
1543
+ /* display:block, not inline-block: it shares .rc with the PR pill, which is itself
1544
+ inline-block — side by side the pill sat on top of the last "#NN" and ate it. */
1545
+ #fsRoot .rcard .fnote{font-family:var(--mono);font-size:9.5px;color:var(--ink3);margin-top:3px;display:block}
1546
+ /* In rv3 the meta sits on the near-black photo scrim whatever the theme, where --ink3
1547
+ is effectively invisible (§5.5「見えるが騒がない」— near-invisible is a fail). Match
1548
+ .rid's white-on-scrim treatment. The folded card opts out of the scrim (rule above),
1549
+ so it keeps --ink3 on the panel where that tone is correct. */
1550
+ #fsRoot[data-rv="3"] .rcard:not(.fold) .fnote{color:rgba(255,255,255,.66);text-shadow:0 1px 3px rgba(0,0,0,.55)}
1551
+ /* A folded card is settled, not failed: title sinks to the settled tone, and it
1552
+ keeps no ✓/✕ because the thing to approve lives on the goal it was folded into. */
1553
+ #fsRoot .rcard.fold .t{color:var(--ink2)}
1554
+ /* A folded card is a pointer, not a review item: no proof to show, nothing to approve.
1555
+ So it opts OUT of the rv3 thumbnail treatment (whose .rc is absolutely positioned
1556
+ over an 84px photo ground) and stays the same quiet line in every view — the form
1557
+ Masa drew. Giving it a fake dark thumbnail would claim it has something to look at. */
1558
+ /* .fold.noimg, not .fold: the placeholder-ground rule below is `.rcard.noimg`, which
1559
+ ties this on specificity and would win on order — a folded card would keep the fake
1560
+ dark thumbnail ground. Matching both classes settles it regardless of order. */
1561
+ #fsRoot[data-rv="3"] .rcard.fold.noimg{display:flex;position:static;align-items:center;gap:10px;
1562
+ padding:9px 10px;min-height:0;border:1px solid var(--hair);border-radius:11px;background:none;box-shadow:none}
1563
+ #fsRoot[data-rv="3"] .rcard.fold.noimg .rc{position:static;padding:0;background:none}
1564
+ #fsRoot[data-rv="3"] .rcard.fold.noimg .t{color:var(--ink2);font-size:12.5px;text-shadow:none}
1565
+ #fsRoot[data-rv="3"] .rcard.fold.noimg .rid{color:var(--ink3)}
1441
1566
  /* reference numbers everywhere else (HANDOFF-v45 §11): a leading #NN prefix on every
1442
1567
  To Do item's name — same goal = same number (goalReviewNumber), distinct from the
1443
1568
  PR's own #id shown separately. */
@@ -2072,19 +2197,33 @@
2072
2197
  body[data-layout="flagship"] .gearpop{left:10px!important;right:10px!important;top:64px!important;
2073
2198
  width:auto!important;max-width:none;max-height:calc(100dvh - 88px)}
2074
2199
  }
2075
- /* Billing UI (H16) — self-serve plan. Tokens only; red is a line/label, never
2076
- a fill (DESIGN-RULES §5.6). CTA reverses ink/panel per theme; on the two
2077
- translucent-panel themes (banff, glass) the label is pinned opaque so it
2078
- doesn't wash out. */
2079
- .bx-cta{height:29px;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 13px;border-radius:8px;background:var(--ink);color:var(--panel);font:550 11.5px/1 var(--ui);border:0;cursor:pointer}
2200
+ /* Billing UI (H16/H22) — self-serve plan. Tokens only; red is a line/label,
2201
+ never a fill (DESIGN-RULES §5.6), and it is --danger (the app's real token —
2202
+ there is no --red here). The CTA text is --invink (defined per theme, always
2203
+ readable on --ink) so it needs no per-theme opaque pin. */
2204
+ .bx-cta{height:29px;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 13px;border-radius:8px;background:var(--ink);color:var(--invink);font:550 11.5px/1 var(--ui);border:0;cursor:pointer}
2080
2205
  .bx-cta:hover{opacity:.9}
2081
- .bx-pm{font-family:var(--mono);font-size:10.5px;opacity:.82;margin-left:2px}
2082
- body[data-theme="banff"] .bx-cta,body[data-theme="glass"] .bx-cta{color:#101014}
2206
+ /* Masa 2026-07-17: tapping Upgrade jumps straight to Stripe's own page — no
2207
+ transition reads as "did that even do anything?". A spinner in the button
2208
+ itself (not a separate overlay) confirms the tap landed during the request
2209
+ round-trip, right up until the redirect actually happens. Reuses the
2210
+ existing @keyframes spin (line ~130) rather than inventing a new one. */
2211
+ .bx-cta.loading{cursor:default;opacity:.85}
2212
+ .bx-spin{display:none}
2213
+ .bx-cta.loading .bx-spin{display:inline-block;width:11px;height:11px;border-radius:50%;border:1.5px solid rgba(255,255,255,.4);border-top-color:#fff;animation:spin .7s linear infinite;margin-right:1px}
2214
+ body[data-theme="banff"] .bx-cta.loading .bx-spin,body[data-theme="glass"] .bx-cta.loading .bx-spin{border-color:rgba(16,16,20,.35);border-top-color:#101014}
2215
+ /* Price = figure only, currency small (DESIGN-RULES §5.5 / flagship .fl-pm):
2216
+ the $ is set to 74% of the figure and optically raised so its top meets the
2217
+ digits' cap line (§5.5 グリフで揃える), not sunk to the baseline. No /mo on
2218
+ the button — the number is the message. This is the ONE place a price is
2219
+ styled; every screen inherits it via fmtPrice(). */
2220
+ .bx-pm{font-family:var(--mono);font-size:10.5px;opacity:.82;margin-left:2px;font-variant-numeric:tabular-nums}
2221
+ .bx-pm .cur{margin-right:.5px;font-size:.74em;vertical-align:.12em}
2083
2222
  /* ① free-limit prompt — rendered in #errbar with .billing neutralizing its error fill */
2084
2223
  .errbar.billing{background:transparent!important;backdrop-filter:none!important;box-shadow:none!important;border:0!important;padding:0!important}
2085
- .bx-ask{border:1px solid var(--hair);border-left:2px solid var(--red);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:14px;background:var(--panel)}
2224
+ .bx-ask{border:1px solid var(--hair);border-left:2px solid var(--danger);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:14px;background:var(--panel)}
2086
2225
  .bx-qt{flex:1;text-align:left}
2087
- .bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--red)}
2226
+ .bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--danger)}
2088
2227
  .bx-q{font-size:13px;color:var(--ink);margin-top:4px}
2089
2228
  .bx-act{flex:none}
2090
2229
  /* neutral ask (not a limit/error — e.g. the sign-in nudge): same card, --blue
@@ -2092,6 +2231,13 @@
2092
2231
  (§7決定ログ「AI確認:色は青系」) rather than inventing a new color. */
2093
2232
  .bx-ask.info{border-left-color:var(--blue)}
2094
2233
  .bx-ask.info .bx-lb{color:var(--blue)}
2234
+ /* free-tier pending counter in the To Do header (H22): mono figure, dim slash,
2235
+ --danger at the cap. Built only on the free plan (see renderFsTodo), so it
2236
+ needs no display toggle here — it simply replaces the lane-total .num. */
2237
+ .fcount{font-family:var(--mono);font-size:11px;font-variant-numeric:tabular-nums;color:var(--ink3)}
2238
+ .fcount .fc-l{opacity:.62}
2239
+ .fcount.cap{color:var(--danger)}
2240
+ .fcount.cap .fc-l{opacity:.75}
2095
2241
  /* ② post-checkout return bar */
2096
2242
  .bx-cobar{box-shadow:inset 0 0 0 1px var(--hair2);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:10px;font-size:12.5px;margin:0 auto 12px;width:100%;max-width:var(--fscenterw,760px)}
2097
2243
  .bx-cobar[hidden]{display:none}
@@ -2264,6 +2410,7 @@
2264
2410
  overflow:hidden (Masa 2026-07-16). -->
2265
2411
  <div class="railgrip" id="fsRgrip" title="Drag to resize"></div>
2266
2412
  <div class="fscard fscenter">
2413
+ <button class="panelgrip" draggable="true" title="Drag to swap chat ⇄ lane"><svg viewBox="0 0 24 24"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg></button>
2267
2414
  <div id="fsBand"></div>
2268
2415
  <button id="fsBandBtn" title="Change the band image"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 15l5-4 4 3 4-5 5 6"/></svg></button>
2269
2416
  <div id="fsBandPop"><div class="bandgrid">
@@ -2298,11 +2445,17 @@
2298
2445
  </div>
2299
2446
  <div class="fsdim" id="fsLaneDim"></div>
2300
2447
  <div class="fscard fslane" id="fsLane">
2448
+ <button class="panelgrip" draggable="true" title="Drag to swap chat ⇄ lane"><svg viewBox="0 0 24 24"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg></button>
2301
2449
  <button class="lanetg" id="fsLaneTg" title="Fold the lane"></button><!-- panel icon filled by tgUpdate (v45 §4) -->
2450
+ <span class="fstvseg" id="fsTvSeg"><!-- List/Board toggle — fixed beside the fold icon in BOTH modes, matching the Artifact (not inline in the To Do header) -->
2451
+ <button class="fstvb" data-fstv="list" title="List"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg></button>
2452
+ <button class="fstvb" data-fstv="board" title="Board"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="16" rx="1"/><rect x="17" y="4" width="4" height="16" rx="1"/></svg></button>
2453
+ </span>
2302
2454
  <div class="lanegrip" id="fsGrip" title="Drag to resize"></div>
2303
2455
  <div class="lanebody">
2304
2456
  <div id="fsRevSec"></div>
2305
2457
  <div id="fsTodoSec"></div>
2458
+ <div id="fsBoard"></div>
2306
2459
  </div>
2307
2460
  </div>
2308
2461
  </div>
@@ -2479,6 +2632,21 @@
2479
2632
  </div>
2480
2633
  </div>
2481
2634
  </div>
2635
+ <!-- moment ③: the New-project paywall dialog (H22). Reuses the .overlay dim+blur +
2636
+ narrow .modal of the delete confirm (the product's one dimmed dialog family),
2637
+ with a one-action layout: Upgrade full-width, "Not now" a quiet link under it.
2638
+ Title/body/price are filled per language at open (openProjectLimitDialog). -->
2639
+ <div class="overlay" id="flimProjOverlay">
2640
+ <div class="modal flimdlg">
2641
+ <div class="fd-lb">Free limit</div>
2642
+ <h3 id="flimProjTitle"></h3>
2643
+ <p id="flimProjBody"></p>
2644
+ <div class="fd-row">
2645
+ <button class="bx-cta fd-cta" id="flimProjUpgrade"><span class="bx-spin"></span>Upgrade</button>
2646
+ <button class="fd-no" id="flimProjNo">Not now</button>
2647
+ </div>
2648
+ </div>
2649
+ </div>
2482
2650
  <script>
2483
2651
  // Auth key lives in localStorage (the ?key= from the first visit) so auth
2484
2652
  // never depends on cookies — Chrome drops session cookies when the server
@@ -2545,11 +2713,18 @@ const STATUS_LABEL = {
2545
2713
  // chip too; omitting it reproduces the original hardcoded labels. `startedAt`
2546
2714
  // (goal.startedAt) disambiguates 'running': planning finishes with every
2547
2715
  // task still 'queued', so it reads QUEUED until startedAt is set, then DOING.
2716
+ // Mirrors engine/lib.mjs goalChip() — this inline script can't import that
2717
+ // module. Had drifted out of sync (missing 'retesting'/'reverted'/'folded',
2718
+ // all silently falling to FAIL) until Masa caught a reverted goal reading as
2719
+ // FAIL (2026-07-17); keep this in lockstep with goalChip() by hand.
2548
2720
  const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending' ? [pickColumnLabel(columns, 'todo', 'QUEUED'), ''] :
2549
2721
  s === 'running' && !startedAt ? [pickColumnLabel(columns, 'todo', 'QUEUED'), ''] :
2550
2722
  ['planning', 'running'].includes(s) ? [pickColumnLabel(columns, 'doing', 'DOING'), 'doing'] :
2551
2723
  s === 'review' ? [pickColumnLabel(columns, 'review', 'REVIEW'), 'review'] :
2552
2724
  s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
2725
+ s === 'retesting' ? ['RE-TESTING', 'doing'] :
2726
+ s === 'reverted' ? ['REVERTED', ''] :
2727
+ s === 'folded' ? ['FOLDED', ''] :
2553
2728
  s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
2554
2729
  const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
2555
2730
  const ICONS = {
@@ -2703,6 +2878,16 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
2703
2878
  // goal's own request language, same as the planner/worker; ja/en pin the
2704
2879
  // project to one language regardless of what a given goal was typed in.
2705
2880
  function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
2881
+ // PRD §6.5 fold note language (Masa 2026-07-17: "ユーザーの言語に合わせて" — not
2882
+ // hardcoded English). Mirrors the server's own 'auto' resolution exactly
2883
+ // (reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : ...,
2884
+ // engine/server.mjs) so this note reads in the same language as the review
2885
+ // headline/PR body for the same goal: the project's fixed override wins if set,
2886
+ // otherwise match the language the goal's own request was written in.
2887
+ function foldLangFor(goal){
2888
+ const l = reviewLangFor(state.active);
2889
+ return (l === 'ja' || l === 'en') ? l : (/[぀-ヿ㐀-鿿]/.test(goal?.text || '') ? 'ja' : 'en');
2890
+ }
2706
2891
 
2707
2892
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
2708
2893
 
@@ -3609,7 +3794,16 @@ function renderTasks(){
3609
3794
  // Feedback 3: the row carries only the check-line + PR link. Tests/proof are
3610
3795
  // internal gate checks (they still block), NOT list clutter; the Review number
3611
3796
  // is redundant with the title; tapping the row opens the detail (no button).
3797
+ // PRD §6.5: goals that landed on the same files were folded onto this one, so
3798
+ // they ride this PR instead of opening a second one editing the same lines.
3799
+ // Whoever opens this review must know it answers more than one request.
3800
+ // Only rendered when it actually happened (§1.5 art.3 — no empty container).
3801
+ const carried = pgoals.filter((x) => x.foldedInto === g.id);
3802
+ const carryNote = carried.length ? (foldLangFor(g) === 'ja'
3803
+ ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
3804
+ : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`) : '';
3612
3805
  return `<div class="task revrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
3806
+ ${carryNote ? `<div class="cond carries">${carryNote}</div>` : ''}
3613
3807
  <div class="cond">${g.pr ? `<a class="prbig" href="${esc(g.pr)}" target="_blank">PR #${esc((g.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : ''}</div>
3614
3808
  <div class="qreplyrow" data-qreplyrow="${g.id}"${state.qreplyOpen.has(g.id) ? '' : ' hidden'}><input type="text" data-qreplyinput="${g.id}" placeholder="直したい点を書いて送信(Todoに戻して再作業)"><button type="button" class="qreplysend" data-qreplysend="${g.id}" aria-label="Send">${ICONS.up}</button></div></div>
3615
3809
  <div class="revquick">
@@ -3619,6 +3813,16 @@ function renderTasks(){
3619
3813
  </div></div>`;
3620
3814
  }).join('')
3621
3815
  + (reviewGoalsAll.length > REVIEW_LIMIT ? `<div class="more" data-reviewmore="1">${state.showAllReview ? 'show less' : `more · +${reviewGoalsAll.length - REVIEW_LIMIT}`}</div>` : '');
3816
+ // PRD §3.5: a folded goal is never deleted — deleting it just moves the question
3817
+ // from "why is my finished work stuck" to "where did #13 go". It keeps its row and
3818
+ // says where its work went. Not a failure: no red, no painted surface (§1.5 art.8);
3819
+ // no ✓/✕ either, because the thing to approve lives on the goal it was folded into.
3820
+ const foldedRows = pgoals.filter((g) => g.status === 'folded').map((g) => {
3821
+ const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 66);
3822
+ const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
3823
+ return `<div class="task revrow foldrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
3824
+ <div class="cond">${mergedNote}</div></div></div>`;
3825
+ }).join('');
3622
3826
  const doneRows = grouped(list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num), (t) => row(t));
3623
3827
  // Pending = deliberately shelved goals (e.g. "npm公開はPhase 2"): never
3624
3828
  // planned or queued until Start is pressed. Parked below To do on purpose.
@@ -3630,7 +3834,7 @@ function renderTasks(){
3630
3834
  const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
3631
3835
  $('tasklist').innerHTML =
3632
3836
  sec('Attention', attention, 'attn')
3633
- + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
3837
+ + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review + foldedRows, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
3634
3838
  + sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
3635
3839
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
3636
3840
  + sec('Pending', pendingRows, 'dim')
@@ -3673,9 +3877,16 @@ function renderTasks(){
3673
3877
  || e.target.closest('.revquick') || e.target.closest('.qreplyrow')) return;
3674
3878
  const goalId = Number(el.dataset.goal);
3675
3879
  const goal = state.goals.find((g) => g.id === goalId);
3676
- goToGoalInChat(goalId);
3677
- if (goal?.status === 'review') openReviewChecklist(goalId);
3678
- else openGoalDetail(goalId);
3880
+ // A folded goal has nothing of its own to open — its work is in the goal it
3881
+ // was folded into. Send the click there. The target briefly goes back to
3882
+ // 'running' while it re-runs this goal's request, so resolve what to open
3883
+ // from the target's own status rather than assuming it is still in Review.
3884
+ const opened = goal?.status === 'folded' && goal.foldedInto != null
3885
+ ? state.goals.find((x) => x.id === goal.foldedInto) ?? goal
3886
+ : goal;
3887
+ goToGoalInChat(opened.id);
3888
+ if (opened.status === 'review') openReviewChecklist(opened.id);
3889
+ else openGoalDetail(opened.id);
3679
3890
  });
3680
3891
  }
3681
3892
  for (const b of $('tasklist').querySelectorAll('.reqopen')) {
@@ -4838,6 +5049,7 @@ const fsUI = {
4838
5049
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
4839
5050
  revMore: false, // "+N more" fold on the review list (§1.5-8)
4840
5051
  todoFold: false, doneOpen: false, todoMore: false,
5052
+ todoView: localStorage.getItem('fsTodoView') === 'board' ? 'board' : 'list', // To Do: list (default) / kanban board (Masa 2026-07-16)
4841
5053
  logOpen: new Set(), logFocus: null, // "View log" targets
4842
5054
  repsOpen: new Set(), // reply-group folds
4843
5055
  };
@@ -5124,6 +5336,10 @@ function toggleGoalDetail(goalId){
5124
5336
  if (state.goalDetailOpen === goalId && overlay.classList.contains('show')) closeGoalDetail();
5125
5337
  else openGoalDetail(goalId);
5126
5338
  }
5339
+ function syncFsTvSeg(){
5340
+ const seg = $('fsTvSeg'); if (!seg) return;
5341
+ for (const b of seg.querySelectorAll('[data-fstv]')) b.classList.toggle('on', b.dataset.fstv === fsUI.todoView);
5342
+ }
5127
5343
  function renderFsLane(){
5128
5344
  if (state.layout !== 'flagship') return;
5129
5345
  const lane = $('fsLane'); if (!lane) return;
@@ -5131,7 +5347,86 @@ function renderFsLane(){
5131
5347
  // bug as the qreplyrow "vanishes mid-type" fix.
5132
5348
  const ae = document.activeElement;
5133
5349
  if (ae && lane.contains(ae) && ['INPUT', 'TEXTAREA'].includes(ae.tagName)) return;
5350
+ lane.classList.toggle('boardmode', fsUI.todoView === 'board');
5351
+ syncFsTvSeg();
5134
5352
  renderFsReview(); renderFsTodo();
5353
+ if (fsUI.todoView === 'board') renderFsBoard();
5354
+ }
5355
+ // Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and
5356
+ // the status chips can never drift; the two columns the chips have no word for
5357
+ // (Later / Needs you) are spelled out here. Pure: goal in, column key out.
5358
+ function fsBoardBucket(goal){
5359
+ const s = goal?.status;
5360
+ if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5361
+ if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
5362
+ if (s === 'review') return 'review';
5363
+ if (s === 'done' || s === 'reverted') return 'done'; // both terminal; REVERTED is not "in flight"
5364
+ if (s === 'stacked' || s === 'sending') return 'todo';
5365
+ if (s === 'running' && !goal.startedAt) return 'todo'; // goalChip: planned, nothing started yet
5366
+ return 'doing'; // planning/running/retesting/blocked/failed — still in flight
5367
+ }
5368
+ // Kanban board — GOAL-level: one card per goal, never per task.
5369
+ //
5370
+ // A card is one thing the user asked for, and its #NN names that one thing exactly
5371
+ // once (Masa 2026-07-17). Tasks are the AI's decomposition of a single request —
5372
+ // the user didn't write them, doesn't review them one by one (proof and the PR are
5373
+ // per goal), and never says "#14" about one. They are the inside of a card, not
5374
+ // cards. The board used to lay out one card per TASK while #NN kept naming the
5375
+ // GOAL, so a goal split into three tasks put three cards reading "#13" in three
5376
+ // different columns.
5377
+ //
5378
+ // No drag-to-change-status: each status transition is its own gated server endpoint
5379
+ // (approve/dismiss/reverify/activate/…), there's no generic "set status" call to hook a
5380
+ // drag onto, so v1 is a visual overview only. Review already has its own rich section
5381
+ // above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
5382
+ function renderFsBoard(){
5383
+ const el = $('fsBoard'); if (!el) return;
5384
+ const { pgoals, list, running } = fsBucketTodo();
5385
+ const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
5386
+ const goalNo = new Map();
5387
+ for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
5388
+ const noSpan = (goalId) => { const n = goalId != null ? goalNo.get(goalId) : null; return n != null ? `<span class="gno" data-n="${n}">#${n}</span>` : ''; };
5389
+ const card = (stCls, goalId, title, extra) =>
5390
+ `<div class="fsbrow"><span class="st ${stCls}"></span>${noSpan(goalId)}<span class="tt">${tt(title)}</span>${extra ?? ''}</div>`;
5391
+ const col = (name, stg, count, html) =>
5392
+ `<section class="fsbcol" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div></section>`;
5393
+ // The status dot keeps the meaning it had on task cards, read off the goal instead.
5394
+ // Everything in Doing that isn't healthily in flight reads as an error, the way
5395
+ // goalChip() calls them all FAIL — 'partial' (ran, but some tasks failed) included,
5396
+ // or a half-failed goal would sit in Doing wearing a healthy green dot.
5397
+ const dotFor = (g) => g.status === 'needsInput' ? 'run int'
5398
+ : g.status === 'review' ? 'rev'
5399
+ : (g.status === 'done' || g.status === 'reverted') ? 'ok'
5400
+ : fsBoardBucket(g) !== 'doing' ? ''
5401
+ : ['stacked', 'sending', 'planning', 'running', 'retesting'].includes(g.status) ? 'run'
5402
+ : 'run bad'; // blocked / failed / partial / interrupted
5403
+ // % is the RUNNING TASK's progress — the same number the old task cards showed, now
5404
+ // carried by the goal it belongs to. What a card should really say about a goal split
5405
+ // into several tasks (n/m done? the step's name? proof?) is a design question, and it
5406
+ // is deliberately not invented here.
5407
+ const pctFor = (g) => {
5408
+ const t = running.find((r) => r.goalId === g.id);
5409
+ return t ? `<span class="pct num">${progressFor(t)}%</span>` : '';
5410
+ };
5411
+ // The label is the goal's OWN request text, not plan[0] — showing the plan here just
5412
+ // repeats the first task's title, which renderTasks() already learned the hard way
5413
+ // (Masa: "このグレー文字ってなに?").
5414
+ const goalCard = (g) => card(dotFor(g), g.id, g.text || g.plan?.[0], pctFor(g));
5415
+ const no = (g) => goalNo.get(g.id) ?? Infinity;
5416
+ const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
5417
+ for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
5418
+ for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
5419
+ // To Do / Doing / Needs you / Review / Done / Later — the original design's columns
5420
+ // (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
5421
+ // distinct from "blocked" (an error needing retry/archive, which stays in Doing, the
5422
+ // same grouping the List view already uses). Later (backlog) sits LAST, not before Done.
5423
+ el.innerHTML =
5424
+ col('To Do', 'var(--ink3)', bucket.todo.length, bucket.todo.map(goalCard).join(''))
5425
+ + col('Doing', 'var(--green)', bucket.doing.length, bucket.doing.map(goalCard).join(''))
5426
+ + (bucket.needs.length ? col('Needs you', 'var(--blue)', bucket.needs.length, bucket.needs.map(goalCard).join('')) : '')
5427
+ + col('Review', 'var(--blue)', bucket.review.length, bucket.review.map(goalCard).join(''))
5428
+ + col('Done', 'var(--green)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''))
5429
+ + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''));
5135
5430
  }
5136
5431
  // Review (top of the lane, §1.5-4). Counts are DETERMINISTIC (§1.5-9): computed
5137
5432
  // from state via buildReviewDigest — no LLM, no donut, no badges.
@@ -5140,11 +5435,18 @@ function renderFsReview(){
5140
5435
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
5141
5436
  const list = state.tasks.filter((t) => t.projectId === state.active);
5142
5437
  const rows = buildReviewDigest({ goals: pgoals, tasks: list });
5143
- if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews the UI itself does not exist
5438
+ // PRD §3.5/§6.5: folded goals still have a row they were never deleted, their work
5439
+ // just went somewhere else. They must be able to hold this section up ALONE: the goal
5440
+ // they were folded into flips back to 'running' while it re-runs their request, so
5441
+ // there is a window where `rows` is empty and only the folded ones remain. Returning
5442
+ // early there would make #13 vanish for the length of the re-run — exactly the "where
5443
+ // did my finished work go" this feature exists to prevent.
5444
+ const foldedGoals = pgoals.filter((g) => g.status === 'folded');
5445
+ if (!rows.length && !foldedGoals.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
5144
5446
  const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
5145
5447
  const proofN = rows.filter((r) => r.proof).length;
5146
5448
  const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
5147
- const pct = Math.round((green / rows.length) * 100);
5449
+ const pct = rows.length ? Math.round((green / rows.length) * 100) : 0;
5148
5450
  // CDO review 2026-07-08 §6: same fix as the s5sum digest above — green/rows.length
5149
5451
  // already exclude blocked goals (different status, never enters `rows`), so the
5150
5452
  // ratio was already correct; "all green" is the line that used to fire regardless
@@ -5170,26 +5472,49 @@ function renderFsReview(){
5170
5472
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
5171
5473
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
5172
5474
  const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
5173
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.checkLine)}</div>${rnote}${pr}</div>
5475
+ // PRD §6.5: goals that landed on the same files ride this card's PR instead of
5476
+ // opening a second one that edits the same lines. Whoever opens this review has
5477
+ // to know it answers more than one request. Only when it happened (§1.5-3).
5478
+ const carried = pgoals.filter((x) => r.goalIds.includes(x.foldedInto));
5479
+ const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
5480
+ ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
5481
+ : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
5482
+ 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>
5174
5483
  <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>`;
5175
5484
  };
5485
+ // A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
5486
+ // it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
5487
+ // it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
5488
+ const foldCard = (g) => {
5489
+ const line = String(g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 60);
5490
+ const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
5491
+ return `<div class="rcard noimg fold" data-fsfold="${g.id}">
5492
+ <div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
5493
+ <span class="fnote">${mergedNote}</span></div></div>`;
5494
+ };
5176
5495
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
5177
5496
  // above. Reviews render directly: meter + Review header + cards (+N more fold, §1.5-8).
5178
- el.innerHTML = `<div class="sm2">
5497
+ // The meter counts what is reviewable, so it only exists when something is (§1.5-3):
5498
+ // during the re-run window the section can be folded-rows-only, and "0 / 0 ready" is
5499
+ // a claim about nothing.
5500
+ el.innerHTML = `${rows.length ? `<div class="sm2">
5179
5501
  <div class="m-top"><span class="num mt">${green} / ${rows.length}</span><span> ready</span><button class="detailbtn" id="fsSeeAll">See all</button></div>
5180
5502
  <div class="m-bar"><i style="width:${pct}%"></i></div>
5181
- <div class="s">${esc(sline)}</div></div>
5503
+ <div class="s">${esc(sline)}</div></div>` : ''}
5182
5504
  <div class="revhd" id="fsRevHd"><span class="hd">Review</span><span class="rnum num">${green} ready</span>
5183
5505
  <span class="rvbtns">
5184
5506
  <button class="rvb${fsUI.rv === 3 ? ' on' : ''}" data-fsrv="3" title="Thumbnails"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 15l5-4 4 3 4-5 5 6"/></svg></button>
5185
5507
  <button class="rvb${fsUI.rv === 1 ? ' on' : ''}" data-fsrv="1" title="List"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M13 6h8M13 9h5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M13 16h8M13 19h5"/></svg></button>
5186
5508
  <button class="rvb${fsUI.rv === 2 ? ' on' : ''}" data-fsrv="2" title="Compact"><svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
5187
5509
  </span></div>
5188
- <div class="revlist">${shown.map(card).join('')}
5510
+ <div class="revlist">${shown.map(card).join('')}${foldedGoals.map(foldCard).join('')}
5189
5511
  ${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
5190
5512
  // See all = the Ledger dialog (HANDOFF-v45 §5, replacing the ペライチ);
5191
5513
  // Cmd/Ctrl+click = standalone full-page view in a new tab (#seeall route).
5192
- $('fsSeeAll').onclick = (e) => { e.stopPropagation();
5514
+ // guarded: the meter (and with it See all) is absent while the section holds only
5515
+ // folded rows — see the §1.5-3 note above.
5516
+ const seeAll = $('fsSeeAll');
5517
+ if (seeAll) seeAll.onclick = (e) => { e.stopPropagation();
5193
5518
  if (e.metaKey || e.ctrlKey) { window.open(saStandaloneUrl(), '_blank'); return; }
5194
5519
  saOpen(); };
5195
5520
  const moreEl = el.querySelector('#fsRevMore');
@@ -5200,6 +5525,19 @@ function renderFsReview(){
5200
5525
  if (e.target.closest('a') || e.target.closest('.act')) return;
5201
5526
  openReviewChecklist(Number(c.dataset.fsrev));
5202
5527
  };
5528
+ // A folded goal has nothing of its own to review — its work is in the goal it was
5529
+ // folded into, so the tap goes there. That goal is only in Review once its re-run
5530
+ // finishes, so resolve from its live status instead of assuming (openReviewChecklist
5531
+ // on a running goal would present an empty checklist).
5532
+ for (const c of el.querySelectorAll('[data-fsfold]')) c.onclick = (e) => {
5533
+ if (e.target.closest('a')) return;
5534
+ const g = state.goals.find((x) => x.id === Number(c.dataset.fsfold));
5535
+ const target = state.goals.find((x) => x.id === g?.foldedInto);
5536
+ if (!target) return;
5537
+ goToGoalInChat(target.id);
5538
+ if (target.status === 'review') openReviewChecklist(target.id);
5539
+ else openGoalDetail(target.id);
5540
+ };
5203
5541
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
5204
5542
  let r; for (const id of b.dataset.fsapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
5205
5543
  if (r && r.ok) { celebrateApprove(); await refresh(); } else showErr('Approve failed.'); };
@@ -5208,26 +5546,36 @@ function renderFsReview(){
5208
5546
  headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
5209
5547
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
5210
5548
  }
5211
- // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
5212
- // error as a small in-row colour element (§1.5-8), quiet Done fold.
5213
- function renderFsTodo(){
5214
- const el = $('fsTodoSec'); if (!el) return;
5549
+ // Shared To Do bucketing (list rows AND the kanban board read the same groups
5550
+ // single source of truth, see renderFsBoard below). Pure of the DOM: state in, groups out.
5551
+ function fsBucketTodo(){
5215
5552
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
5216
5553
  const list = state.tasks.filter((t) => t.projectId === state.active);
5217
5554
  const order = state.queueOrder[state.active] ?? [];
5218
5555
  const byQueue = (a, b) => { const ia = order.indexOf(a.id), ib = order.indexOf(b.id); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib) || a.num - b.num; };
5219
- const running = list.filter((t) => t.status === 'running');
5220
- const failed = list.filter((t) => ['failed', 'interrupted'].includes(t.status));
5221
- const blocked = pgoals.filter((g) => g.status === 'blocked');
5222
- const queued = list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue);
5223
- const doneAll = list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num);
5556
+ return {
5557
+ pgoals, list,
5558
+ running: list.filter((t) => t.status === 'running'),
5559
+ failed: list.filter((t) => ['failed', 'interrupted'].includes(t.status)),
5560
+ blocked: pgoals.filter((g) => g.status === 'blocked'),
5561
+ needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
5562
+ reviewGoals: pgoals.filter((g) => g.status === 'review'),
5563
+ queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
5564
+ doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
5565
+ later: pgoals.filter((g) => g.status === 'pending'),
5566
+ };
5567
+ }
5568
+ // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
5569
+ // error as a small in-row colour element (§1.5-8), quiet Done fold.
5570
+ function renderFsTodo(){
5571
+ const el = $('fsTodoSec'); if (!el) return;
5572
+ const { pgoals, list, running, failed, blocked, queued, doneAll, later } = fsBucketTodo();
5224
5573
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
5225
5574
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
5226
5575
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
5227
5576
  // renders only when they exist. Same endpoints as the old layout's data-pstart/pdel:
5228
5577
  // Start = POST /api/goals/:id/activate, Delete = DELETE /api/goals/:id (confirm).
5229
5578
  // No mono # — pending goals are never planned, so they carry no task number yet.
5230
- const later = pgoals.filter((g) => g.status === 'pending');
5231
5579
  const laterRows = later.map((g) => `<div class="trow up dim"><span class="st"></span><span class="tt">${tt(g.text, 70)}</span>
5232
5580
  <span class="acts"><button class="ab txt" data-fspstart="${g.id}" title="Queue it now">Start</button><button class="ab txt" data-fspdel="${g.id}" title="Delete">Delete</button></span></div>`).join('');
5233
5581
  // reference numbers (HANDOFF-v45 §11): a #NN prefix on every To Do / Review item's name.
@@ -5284,7 +5632,18 @@ function renderFsTodo(){
5284
5632
  // specifically, since that suffix describes the Next-up sublist's own sort, not the
5285
5633
  // lane total.
5286
5634
  const todoTotal = running.length + failed.length + blocked.length + queued.length + later.length;
5287
- el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd">To Do</span><span class="num">${todoTotal}${queued.length ? ' · next-up order' : ''}</span><span class="fold">▾</span></div>
5635
+ // Free-tier pending counter (H22): on the free plan the header shows the wall
5636
+ // — pending goals out of the 5-slot cap — reddening at the cap and gone once
5637
+ // paid. Reads the SAME numbers the gate enforces (state.billing, never a local
5638
+ // recompute) so the header and the wall can't drift. usage.pending already
5639
+ // excludes review server-side (#128).
5640
+ const _bill = state.billing || {};
5641
+ const _fp = _bill.usage?.pending, _fl = _bill.limits?.pending;
5642
+ const _showFc = _bill.plan !== 'paid' && Number.isFinite(_fp) && Number.isFinite(_fl);
5643
+ const _todoNum = _showFc
5644
+ ? `<span class="fcount fstdlabel${_fp >= _fl ? ' cap' : ''}">${_fp}<span class="fc-l">/${_fl}</span></span>`
5645
+ : `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
5646
+ el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
5288
5647
  <div class="tl">
5289
5648
  ${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
5290
5649
  ${upRows ? `<span class="tlcap"${doingRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
@@ -5407,6 +5766,9 @@ async function createProject(){
5407
5766
  try {
5408
5767
  const r = await fetch(withKey('/api/projects'), { method: 'POST', headers: { 'content-type': 'application/json' },
5409
5768
  body: JSON.stringify({ baseProjectId: state.active, name: 'New project' }) });
5769
+ // Free tier allows one project (moment ③): the server 402s the second. Raise
5770
+ // the centered upgrade dialog instead of the generic "failed." toast.
5771
+ if (r.status === 402) { openProjectLimitDialog(); return; }
5410
5772
  if (!r.ok) throw new Error();
5411
5773
  const body = await r.json();
5412
5774
  state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
@@ -5414,8 +5776,22 @@ async function createProject(){
5414
5776
  document.body.classList.remove('projectsopen');
5415
5777
  render();
5416
5778
  } catch { showErr('New project failed.'); }
5417
- if (add) add.disabled = false;
5418
- }
5779
+ finally { if (add) add.disabled = false; } // the 402 early-return must not leave +New dead
5780
+ }
5781
+ // moment ③ paywall dialog — filled per language from BILLING_COPY, Upgrade shows
5782
+ // the one price format (small $, no /mo), "Not now" just closes.
5783
+ function openProjectLimitDialog(){
5784
+ const c = BILLING_COPY[billingLang()];
5785
+ $('flimProjTitle').textContent = c.projTitle;
5786
+ $('flimProjBody').textContent = c.projBody;
5787
+ $('flimProjNo').textContent = c.notNow;
5788
+ const up = $('flimProjUpgrade');
5789
+ up.disabled = false; up.classList.remove('loading');
5790
+ up.innerHTML = `<span class="bx-spin"></span>Upgrade${priceTag(state.billing?.price)}`;
5791
+ up.onclick = () => startCheckout(up);
5792
+ $('flimProjOverlay').classList.add('show');
5793
+ }
5794
+ function closeProjectLimitDialog(){ $('flimProjOverlay').classList.remove('show'); }
5419
5795
  async function deleteProject(p){
5420
5796
  if (!p) return;
5421
5797
  if (!(await confirmDelete({
@@ -5603,6 +5979,16 @@ function renderClawdHero(){
5603
5979
  };
5604
5980
  $('fsRailTg').onclick = () => { root.classList.toggle('railfold'); tgUpdate(); fsGearAlignX(); }; // §1.5-7: fold
5605
5981
  $('fsLaneTg').onclick = () => { root.classList.toggle('lanefold'); tgUpdate(); };
5982
+ // List/Board toggle — wired once (static, unlike the row it used to live inside which
5983
+ // rebuilds on every renderFsTodo()); syncFsTvSeg() paints .on and is called from renderFsLane.
5984
+ for (const b of $('fsTvSeg').querySelectorAll('[data-fstv]')) b.onclick = (e) => {
5985
+ e.stopPropagation();
5986
+ fsUI.todoView = b.dataset.fstv; localStorage.setItem('fsTodoView', fsUI.todoView);
5987
+ $('fsLane').classList.toggle('boardmode', fsUI.todoView === 'board');
5988
+ syncFsTvSeg();
5989
+ if (fsUI.todoView === 'board') renderFsBoard();
5990
+ };
5991
+ syncFsTvSeg();
5606
5992
  tgUpdate();
5607
5993
  $('fsMoreBtn').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.toggle('open'); };
5608
5994
  document.addEventListener('click', (e) => { if (!e.target.closest('#fsRoot .lbrow')) $('fsMoreMenu').classList.remove('open'); });
@@ -5653,17 +6039,43 @@ function renderClawdHero(){
5653
6039
  if (restoreLane && canRestoreFlagshipWidths(restoreRail ? savedRail : defaultRailW, saved)) {
5654
6040
  root.style.setProperty('--lanew', `${saved}px`);
5655
6041
  }
5656
- let sx = 0, sw = 0, drag = false;
5657
- grip.addEventListener('mousedown', (e) => { drag = true; sx = e.clientX; sw = lane.getBoundingClientRect().width; lane.classList.add('noanim'); e.preventDefault(); });
6042
+ // Board view keeps its OWN width (--lanew-board), independent from the List width
6043
+ // (--lanew) both start at the same 372px default, each persists once resized
6044
+ // (Masa 2026-07-16: 看板とToDoの横幅は最初同じ・それぞれ保持). List stays capped at
6045
+ // 540 (a To Do list doesn't need more); Board can grow with the window, same
6046
+ // "no flat ceiling" treatment as the rail above (Masa 2026-07-16: もっと広げたい) —
6047
+ // but its live max reserves the rail's CURRENT width (not a flat guess), same
6048
+ // protection railMax() gives center: a wide-open board must not crush the rail's
6049
+ // own content (Masa 2026-07-16: 狭くした時にバグる — rail at its 72px floor overlaps
6050
+ // its own logo/project row; reserving rail's real width keeps it out of that floor).
6051
+ // Board is the star of this view — chat only needs to stay usable, not "comfortably
6052
+ // readable" (that 640px figure is for List/chat-first use). 320px LOOKED right but was
6053
+ // measured wrong: the composer's own button row (.crow — app/model/effort selects + send)
6054
+ // needs ~342px just for its content, and .fscenter's padding adds ~44px more — below ~430
6055
+ // those chips get clipped off the edge of the composer card (Masa 2026-07-16 repro:
6056
+ // dragged the board grip wide on a 1280px-wide window, "Medium"+send were cut off).
6057
+ // 440 is the smallest value that doesn't clip on a real laptop width.
6058
+ const minCenterWBoard = 440;
6059
+ const boardMax = () => Math.max(280, window.innerWidth - 28 - 24
6060
+ - (document.querySelector('#fsRoot .fsrail')?.getBoundingClientRect().width || defaultRailW) - minCenterWBoard);
6061
+ const savedBoard = Number(localStorage.getItem('fsLaneWBoard'));
6062
+ if (savedBoard >= 280 && savedBoard <= boardMax()) root.style.setProperty('--lanew-board', `${savedBoard}px`);
6063
+ let sx = 0, sw = 0, drag = false, boarding = false;
6064
+ grip.addEventListener('mousedown', (e) => {
6065
+ drag = true; sx = e.clientX; boarding = lane.classList.contains('boardmode');
6066
+ sw = lane.getBoundingClientRect().width; lane.classList.add('noanim'); e.preventDefault();
6067
+ });
5658
6068
  document.addEventListener('mousemove', (e) => {
5659
6069
  if (!drag) return;
5660
- const w = Math.max(280, Math.min(540, sw + (sx - e.clientX)));
5661
- root.style.setProperty('--lanew', `${w}px`);
6070
+ const max = boarding ? boardMax() : 540;
6071
+ const w = Math.max(280, Math.min(max, sw + (sx - e.clientX)));
6072
+ root.style.setProperty(boarding ? '--lanew-board' : '--lanew', `${w}px`);
5662
6073
  });
5663
6074
  document.addEventListener('mouseup', () => {
5664
6075
  if (!drag) return;
5665
6076
  drag = false; lane.classList.remove('noanim');
5666
- localStorage.setItem('fsLaneW', String(parseInt(root.style.getPropertyValue('--lanew')) || 372));
6077
+ if (boarding) localStorage.setItem('fsLaneWBoard', String(parseInt(root.style.getPropertyValue('--lanew-board')) || 372));
6078
+ else localStorage.setItem('fsLaneW', String(parseInt(root.style.getPropertyValue('--lanew')) || 372));
5667
6079
  });
5668
6080
  // rail width drag: 72px – railMax() (freely widens, only bounded by keeping the center column
5669
6081
  // readable), persisted (flagship .railgrip port, v36 JS; grip hides when folded)
@@ -5684,6 +6096,52 @@ function renderClawdHero(){
5684
6096
  localStorage.setItem('fsRailW', String(parseInt(root.style.getPropertyValue('--railw')) || 168)); // H19: default floor 168
5685
6097
  if ($('gearPop').classList.contains('open')) custPlace(); // panel sits beside the rail — track the new edge
5686
6098
  });
6099
+ // chat ⇄ lane panel swap — grab anywhere in the top ~40px of either panel (not just the
6100
+ // grip icon), drop lands EXACTLY on the side the dashed guide showed (never a blind
6101
+ // toggle, which could disagree with what was shown). Rail is fixed, not part of this
6102
+ // (Masa 2026-07-16).
6103
+ (() => {
6104
+ const center = root.querySelector('.fscenter');
6105
+ if (!center || !lane) return;
6106
+ const TOPZONE = 40;
6107
+ const sideFor = (target, x) => { const r = target.getBoundingClientRect(); return x < r.left + r.width / 2 ? 'left' : 'right'; };
6108
+ const dropLine = document.createElement('div'); dropLine.id = 'panelDropLine'; root.appendChild(dropLine);
6109
+ const showDropLine = (target, side) => {
6110
+ const r = target.getBoundingClientRect(), ar = root.getBoundingClientRect();
6111
+ const edgeX = side === 'left' ? r.left : r.right;
6112
+ dropLine.style.left = `${edgeX - ar.left}px`; dropLine.style.top = `${r.top - ar.top}px`;
6113
+ dropLine.style.height = `${r.height}px`; dropLine.style.display = 'block';
6114
+ };
6115
+ const hideDropLine = () => { dropLine.style.display = 'none'; };
6116
+ document.addEventListener('dragend', hideDropLine);
6117
+ const armTop = (panel, name) => {
6118
+ panel.addEventListener('mousedown', (e) => { const r = panel.getBoundingClientRect(); if (e.clientY - r.top <= TOPZONE) panel.setAttribute('draggable', 'true'); });
6119
+ panel.addEventListener('mousemove', (e) => { const r = panel.getBoundingClientRect(); panel.classList.toggle('top-armed', e.clientY - r.top <= TOPZONE); });
6120
+ panel.addEventListener('mouseleave', () => panel.classList.remove('top-armed'));
6121
+ panel.addEventListener('dragstart', (e) => {
6122
+ if (panel.getAttribute('draggable') !== 'true') return;
6123
+ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/x-panel-swap', name);
6124
+ });
6125
+ const disarm = () => { panel.removeAttribute('draggable'); panel.classList.remove('top-armed'); };
6126
+ panel.addEventListener('dragend', disarm); panel.addEventListener('mouseup', disarm);
6127
+ };
6128
+ armTop(center, 'center'); armTop(lane, 'lane');
6129
+ for (const p of [center, lane]) {
6130
+ p.addEventListener('dragover', (e) => {
6131
+ if (!e.dataTransfer.types.includes('application/x-panel-swap')) return;
6132
+ e.preventDefault(); p.classList.add('panel-over'); showDropLine(p, sideFor(p, e.clientX));
6133
+ });
6134
+ p.addEventListener('dragleave', () => { p.classList.remove('panel-over'); hideDropLine(); });
6135
+ p.addEventListener('drop', (e) => {
6136
+ if (!e.dataTransfer.types.includes('application/x-panel-swap')) return;
6137
+ e.preventDefault(); p.classList.remove('panel-over'); hideDropLine();
6138
+ const dragged = e.dataTransfer.getData('application/x-panel-swap');
6139
+ const side = sideFor(p, e.clientX);
6140
+ const wantLeft = side === 'left' ? dragged : (dragged === 'center' ? 'lane' : 'center');
6141
+ root.classList.toggle('swapmid', wantLeft === 'lane');
6142
+ });
6143
+ }
6144
+ })();
5687
6145
  // theme-6 band picker (visual thumbnails, §6.5 T6)
5688
6146
  $('fsBandBtn').onclick = (e) => { e.stopPropagation(); $('fsBandPop').classList.toggle('open'); };
5689
6147
  document.addEventListener('click', (e) => { if (!e.target.closest('#fsBandPop') && !e.target.closest('#fsBandBtn')) $('fsBandPop').classList.remove('open'); });
@@ -5736,39 +6194,86 @@ function upsert(arr, item){ const i = arr.findIndex((x) => x.id === item.id); if
5736
6194
 
5737
6195
  let errTimer = null;
5738
6196
  let authDown = false; // true while the worker-auth banner is up → keep auto-hide timers from clearing it
6197
+ // true while the free-tier paywall is up. Unlike a transient error, "you are
6198
+ // over the free limit" doesn't resolve itself — it persists until the user
6199
+ // upgrades or usage drops, so it must not auto-hide on a fixed timer (Masa
6200
+ // 2026-07-17: a 20s auto-hide + a 15s retry that background-tab throttling can
6201
+ // stretch to ~once/minute meant the banner could vanish for long stretches
6202
+ // with the outbox still silently stuck "Unsent" and no visible explanation —
6203
+ // exactly the "queue fills up, nothing happens" report). Mirrors authDown's
6204
+ // existing guard pattern; cleared only in postOutboxItem once a send actually
6205
+ // succeeds, which is the one signal that's actually true evidence the block
6206
+ // lifted (an upgrade, or the count dropping).
6207
+ let billingBlockedActive = false;
6208
+ function clearBillingBlocked(){
6209
+ if (!billingBlockedActive) return;
6210
+ billingBlockedActive = false;
6211
+ const bar = $('errbar');
6212
+ bar.classList.remove('billing');
6213
+ bar.style.display = 'none';
6214
+ bar.innerHTML = '';
6215
+ }
5739
6216
  function showErr(msg){
5740
6217
  const bar = $('errbar');
5741
6218
  bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
6219
+ billingBlockedActive = false;
5742
6220
  bar.textContent = msg; bar.style.display = 'block';
5743
6221
  clearTimeout(errTimer);
5744
6222
  errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
5745
6223
  }
5746
6224
 
5747
- // Minimal placeholder for the free-tier paywall pending CDO design
5748
- // (handoff-billing-ui-request.md §①) reuses the existing #errbar rather
5749
- // than inventing new chrome; the polished version can replace showBillingBlocked's
5750
- // body without touching the postOutboxItem call site below.
5751
- const BLOCKED_REASON_TEXT = {
5752
- 'pending-goal-limit': 'Free plan limit reached — 5 pending To Dos.',
5753
- 'cumulative-goal-limit': 'Free plan limit reached — 19 To Dos total.',
5754
- 'project-limit': 'Free plan limit reached — 1 project.',
6225
+ // Free-limit copy (H22, Masa 2026-07-17 確定 · ja+en). The sentence is content,
6226
+ // so it follows the user's language; the mono label and buttons stay English
6227
+ // 汎用名 (DOING / FAILED / Retry). <b> is intentional (rendered as innerHTML from
6228
+ // this fixed internal constant, never user input) — the figure is the message.
6229
+ const BILLING_COPY = {
6230
+ ja: {
6231
+ 'pending-goal-limit': '無料プランでは一度に登録できるタスクは <b>5個</b> までです。Proプランでは無制限に登録できます。',
6232
+ 'cumulative-goal-limit': '無料プランの <b>20/20</b> タスクをすべて使いました。',
6233
+ projTitle: 'プロジェクトは1つまでです',
6234
+ projBody: '無料プランに含まれるプロジェクトは1つです。アップグレードすると、プロジェクトを無制限に作れます。',
6235
+ notNow: 'Not now',
6236
+ },
6237
+ en: {
6238
+ 'pending-goal-limit': 'The free plan holds <b>5 tasks</b> at a time. Pro holds as many as you want.',
6239
+ 'cumulative-goal-limit': "You've used all <b>20/20</b> free tasks.",
6240
+ projTitle: 'Free includes one project',
6241
+ projBody: 'Your free plan includes one project. Upgrade to create as many as you need.',
6242
+ notNow: 'Not now',
6243
+ },
5755
6244
  };
6245
+ // Which language the paywall speaks. Mirrors foldLangFor's path (the project's
6246
+ // review-language override wins; otherwise match the language the user is
6247
+ // actually writing in — the latest goal in the active project — else default en)
6248
+ // so the wall reads in the same language as everything else in the project.
6249
+ function billingLang(){
6250
+ const l = reviewLangFor(state.active);
6251
+ if (l === 'ja' || l === 'en') return l;
6252
+ const recent = (state.goals || []).filter((g) => g.projectId === state.active).slice(-1)[0];
6253
+ if (recent && /[぀-ヿ㐀-鿿]/.test(recent.text || '')) return 'ja';
6254
+ return document.documentElement.lang === 'ja' ? 'ja' : 'en';
6255
+ }
6256
+ // The free-limit banner (moments ① pending-goal-limit / ② cumulative-goal-limit):
6257
+ // a bordered card in #errbar, --danger as a 2px left rule only (no fill, §5.6),
6258
+ // with an Upgrade CTA that opens Stripe Checkout. project-limit is NOT a banner —
6259
+ // it is the centered dialog raised from createProject (moment ③). The .billing
6260
+ // class strips #errbar's own error fill so the card stands alone.
5756
6261
  function showBillingBlocked(blocked){
5757
6262
  const bar = $('errbar');
5758
- const text = BLOCKED_REASON_TEXT[blocked?.reason] || 'Free plan limit reached.';
5759
- const pl = fmtPrice(state.billing?.price);
5760
- // ① the free-limit prompt (design H16): a bordered card, red as a 2px left
5761
- // rule only (no fill), with an Upgrade CTA that opens Stripe Checkout. The
5762
- // .billing class strips #errbar's own error fill so the card stands alone.
6263
+ const c = BILLING_COPY[billingLang()];
6264
+ const q = c[blocked?.reason] || c['pending-goal-limit'];
5763
6265
  bar.classList.add('billing');
5764
6266
  bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Free limit</div>`
5765
- + `<div class="bx-q">${esc(text)} Upgrade to keep going?</div></div>`
5766
- + `<div class="bx-act"><button class="bx-cta" id="bxUpgrade">Upgrade${pl ? `<span class="bx-pm">${esc(pl)}</span>` : ''}</button></div></div>`;
6267
+ + `<div class="bx-q">${q}</div></div>`
6268
+ + `<div class="bx-act"><button class="bx-cta" id="bxUpgrade"><span class="bx-spin"></span>Upgrade${priceTag(state.billing?.price)}</button></div></div>`;
5767
6269
  bar.style.display = 'block';
5768
6270
  const btn = bar.querySelector('#bxUpgrade');
5769
- if (btn) btn.onclick = () => startCheckout();
6271
+ if (btn) btn.onclick = () => startCheckout(btn);
6272
+ // No auto-hide: the condition persists until the user acts, so a fixed timer
6273
+ // would just recreate the bug this fixes. clearTimeout only cancels any showErr
6274
+ // 6s timer that happened to be mid-flight on this same shared bar.
5770
6275
  clearTimeout(errTimer);
5771
- errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
6276
+ billingBlockedActive = true;
5772
6277
  }
5773
6278
 
5774
6279
  // Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
@@ -5820,27 +6325,45 @@ function showSignInNudge(){
5820
6325
  errTimer = setTimeout(() => { bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5821
6326
  }
5822
6327
 
5823
- // "$12/mo" from the Stripe-owned price (state.billing.price), never hardcoded.
5824
- // Returns '' when the price isn't known yet so the CTA just reads "Upgrade".
6328
+ // The Stripe-owned price (state.billing.price), never hardcoded. Split into the
6329
+ // currency mark + the figure so the ONE price format (§5.5 / .bx-pm) can render
6330
+ // the mark smaller and optically raised. No interval — /mo is dropped on the
6331
+ // button (the number is the message). Returns null when the price isn't known
6332
+ // yet so the CTA just reads "Upgrade".
5825
6333
  function fmtPrice(p){
5826
- if (!p || p.amount == null) return '';
6334
+ if (!p || p.amount == null) return null;
5827
6335
  const n = p.amount / 100;
5828
6336
  const amt = Number.isInteger(n) ? String(n) : n.toFixed(2);
5829
6337
  const cur = String(p.currency || 'usd').toLowerCase() === 'usd' ? '$' : (String(p.currency).toUpperCase() + ' ');
5830
- const iv = p.interval ? '/' + ({ month: 'mo', year: 'yr', week: 'wk', day: 'day' }[p.interval] || p.interval) : '';
5831
- return `${cur}${amt}${iv}`;
6338
+ return { cur, amt };
6339
+ }
6340
+ // Render a price into the single styled price span (small currency mark, no /mo).
6341
+ // '' when the price is unknown so callers can concatenate it unconditionally.
6342
+ function priceTag(p){
6343
+ const f = fmtPrice(p);
6344
+ return f ? `<span class="bx-pm"><span class="cur">${esc(f.cur)}</span>${esc(f.amt)}</span>` : '';
5832
6345
  }
5833
6346
 
5834
6347
  // Upgrade → Stripe Checkout (via the engine proxy, same-origin). On return the
5835
6348
  // browser lands back with ?checkout=success|cancel (handled at load).
5836
- async function startCheckout(){
6349
+ // btn (optional): the button that triggered this, so it can show a spinner
6350
+ // for the round trip to /api/checkout — jumping straight to Stripe's own page
6351
+ // with zero feedback reads as "did that even do anything?" (Masa 2026-07-17).
6352
+ // Left spinning through the r.ok/d.url success path on purpose: the page is
6353
+ // about to navigate away to Stripe, so there is nothing to reset back to.
6354
+ async function startCheckout(btn){
6355
+ if (btn) { btn.disabled = true; btn.classList.add('loading'); }
5837
6356
  try {
5838
6357
  const r = await fetch(withKey('/api/checkout'), { method: 'POST', headers: { 'content-type': 'application/json' },
5839
6358
  body: JSON.stringify({ successUrl: location.origin + location.pathname + '?checkout=success', cancelUrl: location.origin + location.pathname + '?checkout=cancel' }) });
5840
6359
  const d = await r.json().catch(() => ({}));
5841
- if (r.ok && d.url) location.href = d.url;
5842
- else showErr(d.error || 'Could not start checkout.');
5843
- } catch { showErr('Could not start checkout — check your connection.'); }
6360
+ if (r.ok && d.url) { location.href = d.url; return; }
6361
+ if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
6362
+ showErr(d.error || 'Could not start checkout.');
6363
+ } catch {
6364
+ if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
6365
+ showErr('Could not start checkout — check your connection.');
6366
+ }
5844
6367
  }
5845
6368
 
5846
6369
  // Manage billing → Stripe Customer Portal (paying users; engine proxy).
@@ -6061,12 +6584,13 @@ function bootDisconnected(info){
6061
6584
  #connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
6062
6585
  /* A long wait (15s+) almost always means an account mismatch, not a slow
6063
6586
  first connect (Masa 2026-07-17: kept re-running npx, kept landing back
6064
- here — the always-on caption below was too easy to miss). Escalate to
6065
- the existing warning tone (--amber, same as failed-step/review chips)
6066
- color only, no filled box, per the hover/attention convention used
6067
- everywhere else in this app. */
6068
- #connectGate .cg-switch.escalated{color:var(--amber);font-weight:600}
6069
- #connectGate .cg-switch.escalated a{color:var(--amber);text-decoration:underline}
6587
+ here — the always-on caption below was too easy to miss). At this point
6588
+ the board is unusable until the user acts, so this reads as an error
6589
+ state, not a caution --danger, same tone as failed steps/review chips
6590
+ (Masa 2026-07-17: escalated hint should be red, not amber) — color only,
6591
+ no filled box, per the hover/attention convention used everywhere else. */
6592
+ #connectGate .cg-switch.escalated{color:var(--danger);font-weight:600}
6593
+ #connectGate .cg-switch.escalated a{color:var(--danger);text-decoration:underline}
6070
6594
  #connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
6071
6595
  #connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
6072
6596
  #connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
@@ -6088,7 +6612,7 @@ function bootDisconnected(info){
6088
6612
  + '<a href="https://openai.com/codex/" target="_blank" rel="noreferrer">Codex</a>, then run the same command '
6089
6613
  + '— Galda uses whichever agent you have.</p>'
6090
6614
  + '<p class="cg-who">Signed in as <b></b></p>'
6091
- + '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch this browser →</a></p>'
6615
+ + '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch Google account →</a></p>'
6092
6616
  + '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
6093
6617
  document.body.appendChild(ov);
6094
6618
  ov.querySelector('.cg-who b').textContent = email || 'your account';
@@ -6131,7 +6655,7 @@ function bootDisconnected(info){
6131
6655
  const CG_ESCALATE_MS = 15000;
6132
6656
  const escalateTimer = (email && cgSwitch) ? setTimeout(() => {
6133
6657
  cgSwitch.classList.add('escalated');
6134
- cgSwitch.innerHTML = `Still waiting? Galda may already be running on this computer under a <b>different account</b> than <b>${email.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</b>. Run <code>npx @galda/cli --signin</code> in that terminal and pick this account. Or <a href="/logout">switch this browser →</a>`;
6658
+ cgSwitch.innerHTML = `Still waiting? Galda may already be running on this computer under a <b>different account</b> than <b>${email.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</b>. Run <code>npx @galda/cli --signin</code> in that terminal and pick this account. Or <a href="/logout">switch Google account →</a>`;
6135
6659
  }, CG_ESCALATE_MS) : null;
6136
6660
  try {
6137
6661
  const es = new EventSource('/presence');
@@ -6211,6 +6735,10 @@ async function postOutboxItem(item){
6211
6735
  return;
6212
6736
  }
6213
6737
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
6738
+ // A send just succeeded, which is the one signal that's actually true
6739
+ // evidence the free-tier block lifted (upgrade, or usage dropping below
6740
+ // the limit) — clear the persistent paywall banner if it was still up.
6741
+ clearBillingBlocked();
6214
6742
  const goal = await r.json();
6215
6743
  if (goal.kind === 'chat') {
6216
6744
  // Conversation-first (SLICE 2 + Pattern A H23): a chat/help/settings turn,
@@ -7181,6 +7709,10 @@ $('delCancelBtn').onclick = () => closeDeleteConfirm(false);
7181
7709
  $('delConfirmBtn').onclick = () => closeDeleteConfirm(true);
7182
7710
  $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(_delDismissValue); });
7183
7711
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(_delDismissValue); });
7712
+ // moment ③ dialog dismissal: "Not now" link, backdrop click, and Escape all close it.
7713
+ $('flimProjNo').onclick = () => closeProjectLimitDialog();
7714
+ $('flimProjOverlay').addEventListener('click', (e) => { if (e.target === $('flimProjOverlay')) closeProjectLimitDialog(); });
7715
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('flimProjOverlay').classList.contains('show')) closeProjectLimitDialog(); });
7184
7716
  $('input').addEventListener('keydown', (e) => {
7185
7717
  // Palette navigation takes priority while it is open (Claude-Code keys).
7186
7718
  if (state.palette && !e.isComposing) {
@@ -7879,24 +8411,25 @@ function gearSetHTML(){
7879
8411
  // so the polished version can drop in without touching the wiring below.
7880
8412
  // ③ PLAN card: identity (Sign in with Google / Signed in as X) + plan status.
7881
8413
  // Free → usage vs limits + Upgrade; Paid → green dot + Manage (Stripe Portal).
7882
- const pl = fmtPrice(bill.price);
7883
8414
  const u = bill.usage || {}, lim = bill.limits || {};
7884
- let billingRow;
7885
- if (!bill.licensed) {
7886
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Sign in with Google to unlock the full plan — the free tier works without it</span></div>
8415
+ // Account row: signed in (email + Log out) vs anonymous (Sign in with Google).
8416
+ const acct = bill.licensed
8417
+ ? `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>`
8418
+ : `<div class="srow"><div class="sl">Account<span class="sd">Sign in with Google to sync your usage across devices — the free tier works without it</span></div>
7887
8419
  <button class="optchip" data-gsignin="1">Sign in with Google</button></div>
7888
8420
  <div class="srow dis" id="licKeyMsg" style="display:none"></div>`;
7889
- } else if (bill.plan === 'paid') {
7890
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
7891
- <div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
7892
- <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`;
7893
- } else {
7894
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
7895
- <div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
8421
+ // The free-tier usage + Upgrade shows on the free plan whether or not the user
8422
+ // has signed in it mirrors the To Do header counter (H22), which is visible
8423
+ // to anonymous free users too. Sign-in is an extra (sync across devices), not a
8424
+ // gate to see your own usage or upgrade.
8425
+ const freeUsage = `<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
7896
8426
  <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>
7897
- <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 19}</span></div>
7898
- <button class="bx-cta bx-up-set" data-gupgrade="1">Upgrade${pl ? `<span class="bx-pm">${esc(pl)}</span>` : ''}</button>`;
7899
- }
8427
+ <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 20}</span></div>
8428
+ <button class="bx-cta bx-up-set" data-gupgrade="1"><span class="bx-spin"></span>Upgrade${priceTag(bill.price)}</button>`;
8429
+ const billingRow = bill.plan === 'paid'
8430
+ ? acct + `<div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
8431
+ <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`
8432
+ : acct + freeUsage;
7900
8433
  return `
7901
8434
  <div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
7902
8435
  <button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
@@ -7945,7 +8478,7 @@ function buildGearPop(){
7945
8478
  signBtn.disabled = false;
7946
8479
  };
7947
8480
  const upBtn = pop.querySelector('[data-gupgrade]');
7948
- if (upBtn) upBtn.onclick = () => startCheckout();
8481
+ if (upBtn) upBtn.onclick = () => startCheckout(upBtn);
7949
8482
  const portalBtn = pop.querySelector('[data-gportal]');
7950
8483
  if (portalBtn) portalBtn.onclick = () => openBillingPortal();
7951
8484
  const signOutBtn = pop.querySelector('[data-gsignout]');