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