@galda/cli 0.10.27 → 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
@@ -654,6 +654,18 @@
654
654
  .delmodal{width:380px}
655
655
  .delpreview{border:1px solid var(--hair2);border-radius:10px;padding:10px 12px;background:var(--paper);
656
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)}
657
669
  .howto{margin-top:14px;border:1px solid var(--hair);border-radius:10px;padding:10px 14px;background:var(--paper)}
658
670
  .howto b{font-size:12px;color:var(--ink)}
659
671
  .howto ol{margin:6px 0 0;padding-left:18px;font-size:11.5px;color:var(--ink2);line-height:1.8}
@@ -1095,8 +1107,11 @@
1095
1107
  the layout starts at the three cards. Frame = 14px all around (flagship .app{inset:14px}).
1096
1108
  Dev escape hatch: ?dev=1 (or localStorage devbar=1) adds body.devbar to show the bar. */
1097
1109
  body[data-layout="flagship"]:not(.devbar) .top{display:none}
1098
- body[data-layout="flagship"] #fsRoot{flex:1;min-height:0;display:flex;gap:12px;padding:14px;position:relative;
1099
- --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
1100
1115
  overflow:hidden — see below) can be placed via left:calc(var(--railw)...) and never get clipped.
1101
1116
  single source of truth for the reading column — .fsstream (feed) and
1102
1117
  .compwrap (composer) both read this var so they can never drift apart (fix: composer was
@@ -1219,7 +1234,7 @@
1219
1234
  color:color-mix(in srgb,var(--ink) 24%,transparent);cursor:pointer;
1220
1235
  display:grid;place-items:center;position:absolute;z-index:2;font:inherit}
1221
1236
  #fsRoot .railtg:hover,#fsRoot .lanetg:hover{background:transparent;color:color-mix(in srgb,var(--ink) 85%,transparent)}
1222
- #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) */
1223
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) */
1224
1239
  #fsRoot.railfold .fsrail{flex-basis:44px;padding:44px 7px 14px} /* top pad 44px — project dots clear the toggle (v45 §2) */
1225
1240
  #fsRoot.railfold .fsrail .lb,#fsRoot.railfold .fsproj span,#fsRoot.railfold .fsproj .num,#fsRoot.railfold .fsprojadd .pa-label{display:none}
@@ -1365,6 +1380,11 @@
1365
1380
  outright, its original home) — float it just above the input, matching the
1366
1381
  legacy layout's "just above the composer" placement (CDO判定 2026-07-16). */
1367
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}
1368
1388
  #fsRoot.lanefold{--fscenterw:1020px} /* folded space flows to the center (§1.5-7) — one var flip keeps feed+composer in lockstep */
1369
1389
  /* adopted composer (#composerWrap moves in — same node, same handlers, focus fix intact) */
1370
1390
  body[data-layout="flagship"] #composerWrap{padding:0;border-top:0;max-width:none;width:auto;margin:0}
@@ -1390,10 +1410,11 @@
1390
1410
  flagship-only 34px override that used to live here is gone, it was redundant
1391
1411
  with (and masked that) the base .send circle was still 32px elsewhere. */
1392
1412
  /* ---- right lane: Review (top, §1.5-4) + To Do timeline ---- */
1393
- #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;
1394
1414
  transition:flex-basis .28s cubic-bezier(.4,0,.2,1),padding .28s}
1395
1415
  #fsRoot .fslane.noanim{transition:none}
1396
- #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). */
1397
1418
  #fsRoot.lanefold .lanetg{left:50%;margin-left:-14px} /* no rotation (v45 §4) */
1398
1419
  #fsRoot.lanefold .fslane{flex-basis:44px;padding:18px 7px}
1399
1420
  #fsRoot.lanefold .fslane>*:not(.lanetg){display:none}
@@ -1403,6 +1424,79 @@
1403
1424
  #fsRoot .lanebody{display:flex;flex-direction:column;min-height:0;flex:1;overflow-y:auto;overflow-x:hidden;scrollbar-width:none}
1404
1425
  #fsRoot .lanebody::-webkit-scrollbar{display:none}
1405
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}
1406
1500
  #fsRoot .lhd{display:flex;align-items:baseline;gap:8px;margin-bottom:12px}
1407
1501
  #fsRoot .lhd .hd{font-size:13.5px;letter-spacing:-.02em}
1408
1502
  #fsRoot .lhd .num{font-size:10px;color:var(--ink3)}
@@ -2103,19 +2197,33 @@
2103
2197
  body[data-layout="flagship"] .gearpop{left:10px!important;right:10px!important;top:64px!important;
2104
2198
  width:auto!important;max-width:none;max-height:calc(100dvh - 88px)}
2105
2199
  }
2106
- /* Billing UI (H16) — self-serve plan. Tokens only; red is a line/label, never
2107
- a fill (DESIGN-RULES §5.6). CTA reverses ink/panel per theme; on the two
2108
- translucent-panel themes (banff, glass) the label is pinned opaque so it
2109
- doesn't wash out. */
2110
- .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}
2111
2205
  .bx-cta:hover{opacity:.9}
2112
- .bx-pm{font-family:var(--mono);font-size:10.5px;opacity:.82;margin-left:2px}
2113
- 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}
2114
2222
  /* ① free-limit prompt — rendered in #errbar with .billing neutralizing its error fill */
2115
2223
  .errbar.billing{background:transparent!important;backdrop-filter:none!important;box-shadow:none!important;border:0!important;padding:0!important}
2116
- .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)}
2117
2225
  .bx-qt{flex:1;text-align:left}
2118
- .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)}
2119
2227
  .bx-q{font-size:13px;color:var(--ink);margin-top:4px}
2120
2228
  .bx-act{flex:none}
2121
2229
  /* neutral ask (not a limit/error — e.g. the sign-in nudge): same card, --blue
@@ -2123,6 +2231,13 @@
2123
2231
  (§7決定ログ「AI確認:色は青系」) rather than inventing a new color. */
2124
2232
  .bx-ask.info{border-left-color:var(--blue)}
2125
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}
2126
2241
  /* ② post-checkout return bar */
2127
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)}
2128
2243
  .bx-cobar[hidden]{display:none}
@@ -2295,6 +2410,7 @@
2295
2410
  overflow:hidden (Masa 2026-07-16). -->
2296
2411
  <div class="railgrip" id="fsRgrip" title="Drag to resize"></div>
2297
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>
2298
2414
  <div id="fsBand"></div>
2299
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>
2300
2416
  <div id="fsBandPop"><div class="bandgrid">
@@ -2329,11 +2445,17 @@
2329
2445
  </div>
2330
2446
  <div class="fsdim" id="fsLaneDim"></div>
2331
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>
2332
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>
2333
2454
  <div class="lanegrip" id="fsGrip" title="Drag to resize"></div>
2334
2455
  <div class="lanebody">
2335
2456
  <div id="fsRevSec"></div>
2336
2457
  <div id="fsTodoSec"></div>
2458
+ <div id="fsBoard"></div>
2337
2459
  </div>
2338
2460
  </div>
2339
2461
  </div>
@@ -2510,6 +2632,21 @@
2510
2632
  </div>
2511
2633
  </div>
2512
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>
2513
2650
  <script>
2514
2651
  // Auth key lives in localStorage (the ?key= from the first visit) so auth
2515
2652
  // never depends on cookies — Chrome drops session cookies when the server
@@ -4912,6 +5049,7 @@ const fsUI = {
4912
5049
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
4913
5050
  revMore: false, // "+N more" fold on the review list (§1.5-8)
4914
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)
4915
5053
  logOpen: new Set(), logFocus: null, // "View log" targets
4916
5054
  repsOpen: new Set(), // reply-group folds
4917
5055
  };
@@ -5198,6 +5336,10 @@ function toggleGoalDetail(goalId){
5198
5336
  if (state.goalDetailOpen === goalId && overlay.classList.contains('show')) closeGoalDetail();
5199
5337
  else openGoalDetail(goalId);
5200
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
+ }
5201
5343
  function renderFsLane(){
5202
5344
  if (state.layout !== 'flagship') return;
5203
5345
  const lane = $('fsLane'); if (!lane) return;
@@ -5205,7 +5347,86 @@ function renderFsLane(){
5205
5347
  // bug as the qreplyrow "vanishes mid-type" fix.
5206
5348
  const ae = document.activeElement;
5207
5349
  if (ae && lane.contains(ae) && ['INPUT', 'TEXTAREA'].includes(ae.tagName)) return;
5350
+ lane.classList.toggle('boardmode', fsUI.todoView === 'board');
5351
+ syncFsTvSeg();
5208
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(''));
5209
5430
  }
5210
5431
  // Review (top of the lane, §1.5-4). Counts are DETERMINISTIC (§1.5-9): computed
5211
5432
  // from state via buildReviewDigest — no LLM, no donut, no badges.
@@ -5325,26 +5546,36 @@ function renderFsReview(){
5325
5546
  headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
5326
5547
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
5327
5548
  }
5328
- // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
5329
- // error as a small in-row colour element (§1.5-8), quiet Done fold.
5330
- function renderFsTodo(){
5331
- 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(){
5332
5552
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
5333
5553
  const list = state.tasks.filter((t) => t.projectId === state.active);
5334
5554
  const order = state.queueOrder[state.active] ?? [];
5335
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; };
5336
- const running = list.filter((t) => t.status === 'running');
5337
- const failed = list.filter((t) => ['failed', 'interrupted'].includes(t.status));
5338
- const blocked = pgoals.filter((g) => g.status === 'blocked');
5339
- const queued = list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue);
5340
- 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();
5341
5573
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
5342
5574
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
5343
5575
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
5344
5576
  // renders only when they exist. Same endpoints as the old layout's data-pstart/pdel:
5345
5577
  // Start = POST /api/goals/:id/activate, Delete = DELETE /api/goals/:id (confirm).
5346
5578
  // No mono # — pending goals are never planned, so they carry no task number yet.
5347
- const later = pgoals.filter((g) => g.status === 'pending');
5348
5579
  const laterRows = later.map((g) => `<div class="trow up dim"><span class="st"></span><span class="tt">${tt(g.text, 70)}</span>
5349
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('');
5350
5581
  // reference numbers (HANDOFF-v45 §11): a #NN prefix on every To Do / Review item's name.
@@ -5401,7 +5632,18 @@ function renderFsTodo(){
5401
5632
  // specifically, since that suffix describes the Next-up sublist's own sort, not the
5402
5633
  // lane total.
5403
5634
  const todoTotal = running.length + failed.length + blocked.length + queued.length + later.length;
5404
- 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>
5405
5647
  <div class="tl">
5406
5648
  ${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
5407
5649
  ${upRows ? `<span class="tlcap"${doingRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
@@ -5524,6 +5766,9 @@ async function createProject(){
5524
5766
  try {
5525
5767
  const r = await fetch(withKey('/api/projects'), { method: 'POST', headers: { 'content-type': 'application/json' },
5526
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; }
5527
5772
  if (!r.ok) throw new Error();
5528
5773
  const body = await r.json();
5529
5774
  state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
@@ -5531,8 +5776,22 @@ async function createProject(){
5531
5776
  document.body.classList.remove('projectsopen');
5532
5777
  render();
5533
5778
  } catch { showErr('New project failed.'); }
5534
- if (add) add.disabled = false;
5535
- }
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'); }
5536
5795
  async function deleteProject(p){
5537
5796
  if (!p) return;
5538
5797
  if (!(await confirmDelete({
@@ -5720,6 +5979,16 @@ function renderClawdHero(){
5720
5979
  };
5721
5980
  $('fsRailTg').onclick = () => { root.classList.toggle('railfold'); tgUpdate(); fsGearAlignX(); }; // §1.5-7: fold
5722
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();
5723
5992
  tgUpdate();
5724
5993
  $('fsMoreBtn').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.toggle('open'); };
5725
5994
  document.addEventListener('click', (e) => { if (!e.target.closest('#fsRoot .lbrow')) $('fsMoreMenu').classList.remove('open'); });
@@ -5770,17 +6039,43 @@ function renderClawdHero(){
5770
6039
  if (restoreLane && canRestoreFlagshipWidths(restoreRail ? savedRail : defaultRailW, saved)) {
5771
6040
  root.style.setProperty('--lanew', `${saved}px`);
5772
6041
  }
5773
- let sx = 0, sw = 0, drag = false;
5774
- 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
+ });
5775
6068
  document.addEventListener('mousemove', (e) => {
5776
6069
  if (!drag) return;
5777
- const w = Math.max(280, Math.min(540, sw + (sx - e.clientX)));
5778
- 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`);
5779
6073
  });
5780
6074
  document.addEventListener('mouseup', () => {
5781
6075
  if (!drag) return;
5782
6076
  drag = false; lane.classList.remove('noanim');
5783
- 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));
5784
6079
  });
5785
6080
  // rail width drag: 72px – railMax() (freely widens, only bounded by keeping the center column
5786
6081
  // readable), persisted (flagship .railgrip port, v36 JS; grip hides when folded)
@@ -5801,6 +6096,52 @@ function renderClawdHero(){
5801
6096
  localStorage.setItem('fsRailW', String(parseInt(root.style.getPropertyValue('--railw')) || 168)); // H19: default floor 168
5802
6097
  if ($('gearPop').classList.contains('open')) custPlace(); // panel sits beside the rail — track the new edge
5803
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
+ })();
5804
6145
  // theme-6 band picker (visual thumbnails, §6.5 T6)
5805
6146
  $('fsBandBtn').onclick = (e) => { e.stopPropagation(); $('fsBandPop').classList.toggle('open'); };
5806
6147
  document.addEventListener('click', (e) => { if (!e.target.closest('#fsBandPop') && !e.target.closest('#fsBandBtn')) $('fsBandPop').classList.remove('open'); });
@@ -5853,39 +6194,86 @@ function upsert(arr, item){ const i = arr.findIndex((x) => x.id === item.id); if
5853
6194
 
5854
6195
  let errTimer = null;
5855
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
+ }
5856
6216
  function showErr(msg){
5857
6217
  const bar = $('errbar');
5858
6218
  bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
6219
+ billingBlockedActive = false;
5859
6220
  bar.textContent = msg; bar.style.display = 'block';
5860
6221
  clearTimeout(errTimer);
5861
6222
  errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
5862
6223
  }
5863
6224
 
5864
- // Minimal placeholder for the free-tier paywall pending CDO design
5865
- // (handoff-billing-ui-request.md §①) reuses the existing #errbar rather
5866
- // than inventing new chrome; the polished version can replace showBillingBlocked's
5867
- // body without touching the postOutboxItem call site below.
5868
- const BLOCKED_REASON_TEXT = {
5869
- 'pending-goal-limit': 'Free plan limit reached — 5 pending To Dos.',
5870
- 'cumulative-goal-limit': 'Free plan limit reached — 19 To Dos total.',
5871
- '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
+ },
5872
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.
5873
6261
  function showBillingBlocked(blocked){
5874
6262
  const bar = $('errbar');
5875
- const text = BLOCKED_REASON_TEXT[blocked?.reason] || 'Free plan limit reached.';
5876
- const pl = fmtPrice(state.billing?.price);
5877
- // ① the free-limit prompt (design H16): a bordered card, red as a 2px left
5878
- // rule only (no fill), with an Upgrade CTA that opens Stripe Checkout. The
5879
- // .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'];
5880
6265
  bar.classList.add('billing');
5881
6266
  bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Free limit</div>`
5882
- + `<div class="bx-q">${esc(text)} Upgrade to keep going?</div></div>`
5883
- + `<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>`;
5884
6269
  bar.style.display = 'block';
5885
6270
  const btn = bar.querySelector('#bxUpgrade');
5886
- 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.
5887
6275
  clearTimeout(errTimer);
5888
- errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
6276
+ billingBlockedActive = true;
5889
6277
  }
5890
6278
 
5891
6279
  // Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
@@ -5937,27 +6325,45 @@ function showSignInNudge(){
5937
6325
  errTimer = setTimeout(() => { bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5938
6326
  }
5939
6327
 
5940
- // "$12/mo" from the Stripe-owned price (state.billing.price), never hardcoded.
5941
- // 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".
5942
6333
  function fmtPrice(p){
5943
- if (!p || p.amount == null) return '';
6334
+ if (!p || p.amount == null) return null;
5944
6335
  const n = p.amount / 100;
5945
6336
  const amt = Number.isInteger(n) ? String(n) : n.toFixed(2);
5946
6337
  const cur = String(p.currency || 'usd').toLowerCase() === 'usd' ? '$' : (String(p.currency).toUpperCase() + ' ');
5947
- const iv = p.interval ? '/' + ({ month: 'mo', year: 'yr', week: 'wk', day: 'day' }[p.interval] || p.interval) : '';
5948
- 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>` : '';
5949
6345
  }
5950
6346
 
5951
6347
  // Upgrade → Stripe Checkout (via the engine proxy, same-origin). On return the
5952
6348
  // browser lands back with ?checkout=success|cancel (handled at load).
5953
- 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'); }
5954
6356
  try {
5955
6357
  const r = await fetch(withKey('/api/checkout'), { method: 'POST', headers: { 'content-type': 'application/json' },
5956
6358
  body: JSON.stringify({ successUrl: location.origin + location.pathname + '?checkout=success', cancelUrl: location.origin + location.pathname + '?checkout=cancel' }) });
5957
6359
  const d = await r.json().catch(() => ({}));
5958
- if (r.ok && d.url) location.href = d.url;
5959
- else showErr(d.error || 'Could not start checkout.');
5960
- } 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
+ }
5961
6367
  }
5962
6368
 
5963
6369
  // Manage billing → Stripe Customer Portal (paying users; engine proxy).
@@ -6329,6 +6735,10 @@ async function postOutboxItem(item){
6329
6735
  return;
6330
6736
  }
6331
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();
6332
6742
  const goal = await r.json();
6333
6743
  if (goal.kind === 'chat') {
6334
6744
  // Conversation-first (SLICE 2 + Pattern A H23): a chat/help/settings turn,
@@ -7299,6 +7709,10 @@ $('delCancelBtn').onclick = () => closeDeleteConfirm(false);
7299
7709
  $('delConfirmBtn').onclick = () => closeDeleteConfirm(true);
7300
7710
  $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(_delDismissValue); });
7301
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(); });
7302
7716
  $('input').addEventListener('keydown', (e) => {
7303
7717
  // Palette navigation takes priority while it is open (Claude-Code keys).
7304
7718
  if (state.palette && !e.isComposing) {
@@ -7997,24 +8411,25 @@ function gearSetHTML(){
7997
8411
  // so the polished version can drop in without touching the wiring below.
7998
8412
  // ③ PLAN card: identity (Sign in with Google / Signed in as X) + plan status.
7999
8413
  // Free → usage vs limits + Upgrade; Paid → green dot + Manage (Stripe Portal).
8000
- const pl = fmtPrice(bill.price);
8001
8414
  const u = bill.usage || {}, lim = bill.limits || {};
8002
- let billingRow;
8003
- if (!bill.licensed) {
8004
- 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>
8005
8419
  <button class="optchip" data-gsignin="1">Sign in with Google</button></div>
8006
8420
  <div class="srow dis" id="licKeyMsg" style="display:none"></div>`;
8007
- } else if (bill.plan === 'paid') {
8008
- 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>
8009
- <div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
8010
- <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>`;
8011
- } else {
8012
- 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>
8013
- <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>
8014
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>
8015
- <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 19}</span></div>
8016
- <button class="bx-cta bx-up-set" data-gupgrade="1">Upgrade${pl ? `<span class="bx-pm">${esc(pl)}</span>` : ''}</button>`;
8017
- }
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;
8018
8433
  return `
8019
8434
  <div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
8020
8435
  <button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
@@ -8063,7 +8478,7 @@ function buildGearPop(){
8063
8478
  signBtn.disabled = false;
8064
8479
  };
8065
8480
  const upBtn = pop.querySelector('[data-gupgrade]');
8066
- if (upBtn) upBtn.onclick = () => startCheckout();
8481
+ if (upBtn) upBtn.onclick = () => startCheckout(upBtn);
8067
8482
  const portalBtn = pop.querySelector('[data-gportal]');
8068
8483
  if (portalBtn) portalBtn.onclick = () => openBillingPortal();
8069
8484
  const signOutBtn = pop.querySelector('[data-gsignout]');
package/engine/lib.mjs CHANGED
@@ -2775,8 +2775,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2775
2775
 
2776
2776
  // ---- billing entitlement gate (public self-serve launch, docs/BILLING-LAUNCH-PLAN.md) --
2777
2777
  // Mirrors workers/billing-api/src/entitlement.mjs's FREE_TIER_LIMITS/
2778
- // checkFreeTierLimit/resolveEntitlement exactly (Masa decision 2026-07-12:
2779
- // 5 pending goals, 19 lifetime goals, 1 project, $16/mo unlocks all three).
2778
+ // checkFreeTierLimit/resolveEntitlement exactly (Masa: 5 queued to-dos,
2779
+ // 20 total tasks, 1 project matching galda.app's own words. Updated
2780
+ // 2026-07-17: lifetime cap 19→20 so the wall says the same number the sales
2781
+ // page promises ("20 total tasks"); it used to block the 20th create, one short
2782
+ // of the promise. Pro unlocks all three; price is Stripe-derived, not in code.)
2780
2783
  // Deliberately duplicated rather than imported: engine/ and workers/billing-api/
2781
2784
  // are independently deployed (a user's Mac vs Cloudflare) and independently
2782
2785
  // tested — sharing a module across that boundary would couple two things
@@ -2784,10 +2787,21 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2784
2787
  // hand; both have their own test coverage against the same free-tier numbers.
2785
2788
  export const FREE_TIER_LIMITS = Object.freeze({
2786
2789
  maxPendingGoals: 5,
2787
- maxCumulativeGoals: 19,
2790
+ maxCumulativeGoals: 20,
2788
2791
  maxProjects: 1,
2789
2792
  });
2790
2793
 
2794
+ // The statuses that occupy a "queued to-do" slot (the 5-slot wall). Narrower
2795
+ // than "every open goal" on purpose: a goal in `review` has finished its work
2796
+ // and is waiting on a human — that's not a to-do in flight, and galda.app sells
2797
+ // the slot as "queued to-dos", so review must not eat one (Masa 2026-07-17,
2798
+ // "5枠は実行待ちだけ"). Kept as its own named list rather than filtering an
2799
+ // "all open" constant so the intent is legible where it's read.
2800
+ export const QUEUED_GOAL_STATUSES = Object.freeze([
2801
+ 'stacked', 'sending', 'pending', 'planning', 'needsInput',
2802
+ 'running', 'partial', 'failed', 'interrupted', 'blocked', 'retesting',
2803
+ ]);
2804
+
2791
2805
  export function checkFreeTierLimit(usage) {
2792
2806
  const pendingCount = Number(usage?.pendingCount) || 0;
2793
2807
  const cumulativeCount = Number(usage?.cumulativeCount) || 0;
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -177,6 +177,19 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
177
177
  // it survives restarts.
178
178
  const licenseFile = join(DATA_DIR, 'license.token');
179
179
  let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
180
+ // Test-only seam: override the license-verify public key via env so tests can
181
+ // sign tokens the running server actually accepts (the baked LICENSE_PUBLIC_JWK
182
+ // has no committed private half, so the licensed-but-lapsed path is otherwise
183
+ // untestable — see docs/HANDOFF-A2C-WORKERS-DEV-CLEANUP.md item 6). Unset in
184
+ // prod → verifyLicenseToken falls back to the baked key. This is NOT a payment
185
+ // bypass: forging an identity here only picks WHO the email is; whether they're
186
+ // paying is still the live remote billing-api (fetchIsPaying). An env escape
187
+ // hatch already exists on the gate (MANAGER_ENTITLEMENT_BYPASS=1) — same spirit.
188
+ const LICENSE_PUBKEY_OVERRIDE = (() => {
189
+ const raw = process.env.MANAGER_LICENSE_PUBKEY;
190
+ if (!raw) return undefined;
191
+ try { return JSON.parse(raw); } catch { return undefined; }
192
+ })();
180
193
 
181
194
  // Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
182
195
  // (the billing Worker hashes this email → the same account_id the accounts table
@@ -210,7 +223,7 @@ async function loadLicenseFromDisk() {
210
223
  if (!existsSync(licenseFile)) return;
211
224
  const token = readFileSync(licenseFile, 'utf8').trim();
212
225
  if (!token) return;
213
- const result = await verifyLicenseToken({ token });
226
+ const result = await verifyLicenseToken({ token, publicJwk: LICENSE_PUBKEY_OVERRIDE });
214
227
  if (result.valid) licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
215
228
  }
216
229
  loadLicenseFromDisk();
@@ -219,7 +232,7 @@ loadLicenseFromDisk();
219
232
  // token to disk that doesn't actually verify, so a bad paste can't silently
220
233
  // wedge the app into "licensed but broken".
221
234
  async function activateLicense(token) {
222
- const result = await verifyLicenseToken({ token: String(token ?? '') });
235
+ const result = await verifyLicenseToken({ token: String(token ?? ''), publicJwk: LICENSE_PUBKEY_OVERRIDE });
223
236
  if (!result.valid) return { ok: false, error: `invalid license: ${result.reason}` };
224
237
  writeFileSync(licenseFile, String(token));
225
238
  licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
@@ -250,26 +263,32 @@ function signinResultPage(outcome) {
250
263
  `</script></body>`;
251
264
  }
252
265
 
253
- // "Currently occupying capacity" for the free-tier pending-goal cap every
254
- // status short of a genuinely finished/dismissed one. Deliberately broad for
255
- // the same reason RATE_LIMIT_RE above is broad: undercounting here would let
256
- // someone quietly exceed the cap via an odd status, which costs more than
257
- // occasionally counting a goal that's nearly done.
258
- const OPEN_GOAL_STATUSES = ['stacked', 'sending', 'pending', 'planning', 'needsInput', 'running', 'partial', 'failed', 'interrupted', 'blocked', 'review', 'retesting'];
259
-
260
- // NOTE(billing, needs a product decision): `projectCount` is left at 0 —
261
- // projects are shared/global in this codebase today (see the "Projects ...
262
- // stay shared for everyone" comment above goalVisibleTo in lib.mjs), not
263
- // per-identity, so a per-user "max 1 project" cap has nothing correct to
264
- // gate yet (an unpaid user's 2nd project would affect every other user, not
265
- // just them). checkFreeTierLimit already supports projectCount > 1 the
266
- // moment that's resolved see docs/BILLING-LAUNCH-PLAN.md.
266
+ // The 5-slot "queued to-dos" cap counts QUEUED_GOAL_STATUSES (from lib.mjs)
267
+ // every open status EXCEPT `review`. A goal in review has finished its work and
268
+ // is waiting on a human, so it isn't a to-do in flight, and galda.app sells the
269
+ // slot as "queued to-dos" (Masa 2026-07-17, "5枠は実行待ちだけ"). Still broad
270
+ // otherwise, for the same reason RATE_LIMIT_RE is: undercounting would let
271
+ // someone quietly exceed the cap via an odd status. The list lives in lib.mjs
272
+ // so the wall's meaning is defined next to checkFreeTierLimit and unit-tested.
273
+
274
+ // `projectCount` is install-wide, not per-identity: projects are shared/global
275
+ // in this codebase (see the "Projects ... stay shared for everyone" comment
276
+ // above goalVisibleTo in lib.mjs) while only goals are partitioned by owner.
277
+ // That asymmetry used to leave this at 0 pending a product decision (a per-user
278
+ // "max 1 project" cap would have let one user's 2nd project block everyone
279
+ // else's). Resolved 2026-07-17 (Masa): the shipping product is local-first —
280
+ // app.galda.app reaches each user's OWN engine over the relay, so one install
281
+ // is one user, and the license this gate consults (licenseState.email) is
282
+ // already install-wide too. Counting every project on the install is therefore
283
+ // the same scope billing already runs at. The stale caveat only applies to the
284
+ // legacy Cloudflare Access multi-user mode, where several identities share one
285
+ // install AND one license — there, a shared cap is already the status quo.
267
286
  function computeFreeTierUsage(identity) {
268
287
  const owned = goals.filter((g) => (g.owner ?? 'owner') === identity);
269
288
  return {
270
- pendingCount: owned.filter((g) => OPEN_GOAL_STATUSES.includes(g.status)).length,
289
+ pendingCount: owned.filter((g) => QUEUED_GOAL_STATUSES.includes(g.status)).length,
271
290
  cumulativeCount: owned.length,
272
- projectCount: 0,
291
+ projectCount: projects.length,
273
292
  };
274
293
  }
275
294
 
@@ -362,15 +381,25 @@ async function getBillingPrice() {
362
381
  // every installation (~/.claude/plans/toasty-rolling-marshmallow.md).
363
382
  // A personal/dev instance that wants to skip billing entirely should opt in
364
383
  // explicitly via MANAGER_ENTITLEMENT_BYPASS=1, not ride the 'owner' string.
365
- async function requireEntitlementGate(identity) {
384
+ async function requireEntitlementGate(identity, intent = 'goal') {
366
385
  if (process.env.MANAGER_ENTITLEMENT_BYPASS === '1') return { allowed: true, blocked: null };
367
- // Count the goal being created (existing + 1): computeFreeTierUsage counts
368
- // only goals already on file and this gate runs *before* the new one is
386
+ // Count the thing being created (existing + 1): computeFreeTierUsage counts
387
+ // only what's already on file and this gate runs *before* the new one is
369
388
  // added, so "5 pending / 19 lifetime max" must block the 6th / 20th create,
370
389
  // not the 7th / 21st. (checkFreeTierLimit keeps its `> limit` semantics; we
371
390
  // feed it the post-create count. Display in /api/state stays the raw count.)
391
+ //
392
+ // Only the dimension the caller is actually spending gets its real count;
393
+ // the others are zeroed. checkFreeTierLimit weighs all three at once and
394
+ // mirrors billing-api byte-for-byte, so it must not learn about intents —
395
+ // the caller narrows the question instead. Without this, an install already
396
+ // holding 2 projects (created before the project cap was enforced) would
397
+ // trip 'project-limit' on every *goal* create and lose a feature it had:
398
+ // caps are enforced at the moment you spend, never retroactively.
372
399
  const existing = computeFreeTierUsage(identity);
373
- const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
400
+ const usage = intent === 'project'
401
+ ? { pendingCount: 0, cumulativeCount: 0, projectCount: existing.projectCount + 1 }
402
+ : { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1, projectCount: 0 };
374
403
  if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
375
404
  const billingEmail = licenseState.email;
376
405
  if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
@@ -3027,9 +3056,15 @@ const server = createServer(async (req, res) => {
3027
3056
  if (url.pathname === '/api/projects' && req.method === 'POST') {
3028
3057
  let body = '';
3029
3058
  req.on('data', (d) => { body += d; });
3030
- req.on('end', () => {
3059
+ req.on('end', async () => {
3031
3060
  try {
3032
3061
  const input = JSON.parse(body || '{}');
3062
+ // Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): a 2nd project is paid.
3063
+ // Same 402 shape as POST /api/tasks so the client has one branch to
3064
+ // handle. Gated here, on create, and nowhere else: projects that
3065
+ // already exist keep working on the free tier.
3066
+ const gate = await requireEntitlementGate(identityFor(req), 'project');
3067
+ if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
3033
3068
  const base = projects.find((p) => p.id === input.baseProjectId) ?? projects[0];
3034
3069
  const nameBase = String(input.name ?? 'New project').trim().slice(0, 80) || 'New project';
3035
3070
  let name = nameBase, n = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.27",
3
+ "version": "0.10.30",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {