@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 +488 -73
- package/engine/lib.mjs +17 -3
- package/engine/server.mjs +59 -24
- package/package.json +1 -1
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} /*
|
|
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:
|
|
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:
|
|
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,
|
|
2107
|
-
a fill (DESIGN-RULES §5.6)
|
|
2108
|
-
|
|
2109
|
-
|
|
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(--
|
|
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
|
-
|
|
2113
|
-
|
|
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(--
|
|
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(--
|
|
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 (
|
|
5329
|
-
//
|
|
5330
|
-
function
|
|
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
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5774
|
-
|
|
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
|
|
5778
|
-
|
|
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('
|
|
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
|
-
//
|
|
5865
|
-
//
|
|
5866
|
-
//
|
|
5867
|
-
//
|
|
5868
|
-
const
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
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
|
|
5876
|
-
const
|
|
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">${
|
|
5883
|
-
+ `<div class="bx-act"><button class="bx-cta" id="bxUpgrade"
|
|
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
|
-
|
|
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
|
-
//
|
|
5941
|
-
//
|
|
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
|
-
|
|
5948
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5960
|
-
|
|
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
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
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
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
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 ??
|
|
8016
|
-
<button class="bx-cta bx-up-set" data-gupgrade="1"
|
|
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
|
|
2779
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
254
|
-
// status
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// projects are shared/global
|
|
262
|
-
// stay shared for everyone" comment
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
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) =>
|
|
289
|
+
pendingCount: owned.filter((g) => QUEUED_GOAL_STATUSES.includes(g.status)).length,
|
|
271
290
|
cumulativeCount: owned.length,
|
|
272
|
-
projectCount:
|
|
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
|
|
368
|
-
// only
|
|
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 =
|
|
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