@galda/cli 0.10.27 → 0.10.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/index.html CHANGED
@@ -654,6 +654,18 @@
654
654
  .delmodal{width:380px}
655
655
  .delpreview{border:1px solid var(--hair2);border-radius:10px;padding:10px 12px;background:var(--paper);
656
656
  color:var(--ink);font-size:12.5px;line-height:1.5;max-height:120px;overflow:auto;word-break:break-word}
657
+ /* moment ③ New-project paywall dialog (H22). Same narrow .modal as .delmodal,
658
+ but a one-action layout (Masa 2026-07-17 確定 __dlg(2)): Upgrade full-width,
659
+ "Not now" a quiet underlined link centered below it. */
660
+ .flimdlg{width:380px}
661
+ .flimdlg .fd-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--danger);margin-bottom:8px}
662
+ .flimdlg h3{margin:0 0 8px;font-size:15px;letter-spacing:-.01em;color:var(--ink);text-wrap:balance}
663
+ .flimdlg p{margin:0;font-size:12.5px;color:var(--ink2);line-height:1.65}
664
+ .flimdlg .fd-row{display:flex;flex-direction:column-reverse;gap:10px;align-items:stretch;margin-top:16px}
665
+ .flimdlg .fd-cta{width:100%;height:34px}
666
+ .flimdlg .fd-no{border:0;background:0;height:auto;padding:0;color:var(--ink3);font:500 12px/1 var(--ui);
667
+ text-decoration:underline;text-underline-offset:2px;align-self:center;cursor:pointer}
668
+ .flimdlg .fd-no:hover{color:var(--ink2)}
657
669
  .howto{margin-top:14px;border:1px solid var(--hair);border-radius:10px;padding:10px 14px;background:var(--paper)}
658
670
  .howto b{font-size:12px;color:var(--ink)}
659
671
  .howto ol{margin:6px 0 0;padding-left:18px;font-size:11.5px;color:var(--ink2);line-height:1.8}
@@ -753,6 +765,47 @@
753
765
  .revundo:hover{color:var(--ink);border-color:var(--ink3)}
754
766
  @keyframes rvpop{0%{transform:scale(0)}70%{transform:scale(1.18)}100%{transform:scale(1)}}
755
767
  @keyframes rvdraw{to{stroke-dashoffset:0}}
768
+ /* Upgrade celebration — the full-screen reward shown the instant the user
769
+ returns from a completed Stripe checkout (celebrateUpgrade()). Reuses the
770
+ review check-pop vocabulary (green circle + drawn tick, rvpop) scaled up.
771
+ The scrim is a fixed dark wash (same rgba as .overlay), so title/sub use a
772
+ FIXED light color on purpose: no theme token is guaranteed light here
773
+ (--on-accent and --invink are near-black in several themes, --ink would
774
+ vanish on the scrim in light modes). The check is a WHITE tick on the brand
775
+ green disc (the joyful high-signal success mark; white always reads on the
776
+ always-bright green, unlike a theme token). */
777
+ .upcel{position:fixed;inset:0;z-index:200;display:none;place-items:center;cursor:pointer;
778
+ /* .72 (darker than the .55 dialog scrim) so the fixed-white text keeps strong
779
+ contrast even in the light themes, where a lighter wash would grey out. */
780
+ background:rgba(0,0,0,.72);backdrop-filter:blur(7px)}
781
+ .upcel.show{display:grid;animation:upcelin .3s ease}
782
+ .upcel.out{animation:upcelout .34s ease forwards}
783
+ .upcelcard{display:flex;flex-direction:column;align-items:center;gap:16px;text-align:center;padding:0 24px}
784
+ .upcelchk{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;
785
+ background:var(--green);box-shadow:0 0 34px rgba(var(--green-rgb),.55)}
786
+ .upcelchk svg{width:38px;height:38px;stroke:#fff;stroke-width:3;fill:none;stroke-linecap:round;stroke-linejoin:round}
787
+ .upcelchk svg path{stroke-dasharray:30;stroke-dashoffset:30}
788
+ .upcel.show .upcelchk{animation:rvpop .5s cubic-bezier(.2,1.3,.5,1)}
789
+ .upcel.show .upcelchk svg path{animation:updraw .38s .22s ease forwards}
790
+ .upceltitle{font-size:26px;font-weight:700;letter-spacing:-.01em;color:#fff}
791
+ .upcelsub{font-size:14px;line-height:1.5;max-width:300px;color:rgba(255,255,255,.82)}
792
+ @keyframes upcelin{from{opacity:0}to{opacity:1}}
793
+ @keyframes upcelout{from{opacity:1}to{opacity:0}}
794
+ @keyframes updraw{to{stroke-dashoffset:0}}
795
+ @media (prefers-reduced-motion:reduce){
796
+ .upcel.show,.upcel.out{animation:none}
797
+ .upcel.show .upcelchk,.upcel.show .upcelchk svg path{animation:none}
798
+ .upcelchk svg path{stroke-dashoffset:0}
799
+ }
800
+ /* .upcelburst centers a soft green flash pulse (brand color) on the check so
801
+ the pop reads as a burst of light, not just a scale. */
802
+ .upcelburst{position:relative;display:grid;place-items:center;width:76px;height:76px}
803
+ .upcelburst .upcelchk{position:relative;z-index:1}
804
+ .upcelflash{position:absolute;inset:-10px;border-radius:50%;opacity:0;
805
+ background:radial-gradient(circle, rgba(var(--green-rgb),.6) 0%, rgba(var(--green-rgb),0) 70%)}
806
+ .upcel.show .upcelflash{animation:upcelflash .72s ease-out forwards}
807
+ @keyframes upcelflash{0%{transform:scale(.4);opacity:.95}100%{transform:scale(3);opacity:0}}
808
+ @media (prefers-reduced-motion:reduce){ .upcel.show .upcelflash{animation:none;opacity:0} }
756
809
  .reqlist{display:flex;flex-direction:column;gap:12px}
757
810
  .reqcard{border:1px solid var(--hair2);border-radius:14px;padding:14px 16px;background:var(--paper);transition:border-color .15s,background-color .15s}
758
811
  .reqcard.approved{border-color:rgba(var(--green-rgb),.5);background:rgba(var(--green-rgb),.05)}
@@ -1095,8 +1148,11 @@
1095
1148
  the layout starts at the three cards. Frame = 14px all around (flagship .app{inset:14px}).
1096
1149
  Dev escape hatch: ?dev=1 (or localStorage devbar=1) adds body.devbar to show the bar. */
1097
1150
  body[data-layout="flagship"]:not(.devbar) .top{display:none}
1098
- body[data-layout="flagship"] #fsRoot{flex:1;min-height:0;display:flex;gap:12px;padding:14px;position:relative;
1099
- --fscenterw:760px} /* position:relative so .railgrip (a direct child, kept outside .fsrail's
1151
+ body[data-layout="flagship"] #fsRoot{flex:1;min-width:0;min-height:0;display:flex;gap:12px;padding:14px;position:relative;
1152
+ --fscenterw:760px} /* min-width:0 (Masa 2026-07-16): rail/lane are flex-shrink:0 (fixed basis), so without
1153
+ this #fsRoot's own automatic min-width == their full combined width, and #fsRoot refuses to shrink
1154
+ below that even though .fscenter (flex:1;min-width:0) is perfectly able to — the whole page overflowed
1155
+ horizontally once the lane got wide instead of .fscenter just getting narrow. position:relative so .railgrip (a direct child, kept outside .fsrail's
1100
1156
  overflow:hidden — see below) can be placed via left:calc(var(--railw)...) and never get clipped.
1101
1157
  single source of truth for the reading column — .fsstream (feed) and
1102
1158
  .compwrap (composer) both read this var so they can never drift apart (fix: composer was
@@ -1219,7 +1275,7 @@
1219
1275
  color:color-mix(in srgb,var(--ink) 24%,transparent);cursor:pointer;
1220
1276
  display:grid;place-items:center;position:absolute;z-index:2;font:inherit}
1221
1277
  #fsRoot .railtg:hover,#fsRoot .lanetg:hover{background:transparent;color:color-mix(in srgb,var(--ink) 85%,transparent)}
1222
- #fsRoot .railtg{top:8px;right:6px}
1278
+ #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
1279
  #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
1280
  #fsRoot.railfold .fsrail{flex-basis:44px;padding:44px 7px 14px} /* top pad 44px — project dots clear the toggle (v45 §2) */
1225
1281
  #fsRoot.railfold .fsrail .lb,#fsRoot.railfold .fsproj span,#fsRoot.railfold .fsproj .num,#fsRoot.railfold .fsprojadd .pa-label{display:none}
@@ -1365,6 +1421,11 @@
1365
1421
  outright, its original home) — float it just above the input, matching the
1366
1422
  legacy layout's "just above the composer" placement (CDO判定 2026-07-16). */
1367
1423
  #fsRoot .compwrap>.errbar{position:absolute;left:50%;bottom:calc(100% + 10px);transform:translateX(-50%)}
1424
+ /* Free-limit banner (H22) spans the composer, not a centered content-width pill
1425
+ like a plain showErr toast: the width rule is scoped to .billing so ordinary
1426
+ errors stay centered pills. left/right:0 stretches it to .compwrap's own box
1427
+ (= composer width), and .bx-ask is display:flex so it fills that. */
1428
+ #fsRoot .compwrap>.errbar.billing{left:0;right:0;transform:none}
1368
1429
  #fsRoot.lanefold{--fscenterw:1020px} /* folded space flows to the center (§1.5-7) — one var flip keeps feed+composer in lockstep */
1369
1430
  /* adopted composer (#composerWrap moves in — same node, same handlers, focus fix intact) */
1370
1431
  body[data-layout="flagship"] #composerWrap{padding:0;border-top:0;max-width:none;width:auto;margin:0}
@@ -1390,10 +1451,11 @@
1390
1451
  flagship-only 34px override that used to live here is gone, it was redundant
1391
1452
  with (and masked that) the base .send circle was still 32px elsewhere. */
1392
1453
  /* ---- 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;
1454
+ #fsRoot .fslane{flex:0 0 var(--lanew,372px);min-width:0;display:flex;flex-direction:column;padding:36px 18px 14px;position:relative;
1394
1455
  transition:flex-basis .28s cubic-bezier(.4,0,.2,1),padding .28s}
1395
1456
  #fsRoot .fslane.noanim{transition:none}
1396
- #fsRoot .lanetg{top:9px;left:10px} /* 畳みトグル: 左上・帯 (§6.5 確定 tgpos1 — lane padding-top 36px = the band strip) */
1457
+ #fsRoot .lanetg{top:12px;left:10px} /* 畳みトグル: 左上・帯 (§6.5 確定 tgpos1 — lane padding-top 36px = the band strip).
1458
+ top:12 (was 9) matches .railtg's Galda-baseline lower (Masa via CDO artifact 2026-07-16, not yet in flagship.html). */
1397
1459
  #fsRoot.lanefold .lanetg{left:50%;margin-left:-14px} /* no rotation (v45 §4) */
1398
1460
  #fsRoot.lanefold .fslane{flex-basis:44px;padding:18px 7px}
1399
1461
  #fsRoot.lanefold .fslane>*:not(.lanetg){display:none}
@@ -1403,6 +1465,162 @@
1403
1465
  #fsRoot .lanebody{display:flex;flex-direction:column;min-height:0;flex:1;overflow-y:auto;overflow-x:hidden;scrollbar-width:none}
1404
1466
  #fsRoot .lanebody::-webkit-scrollbar{display:none}
1405
1467
  #fsRevSec:not(:empty){margin-bottom:12px}
1468
+ /* List view: drop the whole lane content below the fold/list/board toggle row, the same 26px
1469
+ clearance the board gets — the summary/"See all" was crammed right under the toggle (Masa 2026-07-18). */
1470
+ #fsRoot .fslane:not(.boardmode) .lanebody{padding-top:26px}
1471
+
1472
+ /* ---- To Do: List/Board view toggle, in the To Do header — same spot both modes (Masa 2026-07-16) ---- */
1473
+ /* List/Board toggle: a FIXED overlay beside the fold icon, in BOTH modes — matching the
1474
+ Artifact's .labseg.inlane exactly (2026-07-17: it's never inline in the To Do header row
1475
+ in the Artifact, always an absolute sibling of .lanetg; a prior commit put it inside
1476
+ #fsTodoHd's flow instead, which pushed it onto its own row below the fold icon whenever
1477
+ the header text above it wrapped/existed — "まだなおってない" screenshot). .lanetg is
1478
+ top:12/left:10/width:28 (center y=26) → this sits at the same 8px gap the Artifact uses
1479
+ after its own fold icon, vertically centered on that same 26px line. */
1480
+ #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
1481
+ .labseg.inlane (2026-07-17: opened it locally, getBoundingClientRect()) — centers on .lanetg exactly like the fold icon,
1482
+ left:42 = .lanetg's right edge (10+28=38) + a measured 4px gap (an earlier pass guessed 46/8px without measuring the gap itself). */
1483
+ /* Board mode: the "To Do N" title is redundant once a To Do COLUMN exists among the board's
1484
+ own columns — hide it; the toggle+fold are unaffected since they no longer live inside it
1485
+ (Masa 2026-07-16: 看板の時は上のTo Doを消してよい — the column keeps the name). */
1486
+ #fsRoot .fslane.boardmode #fsTodoHd{margin-bottom:0}
1487
+ #fsRoot .fslane.boardmode #fsTodoHd .fstdlabel{display:none}
1488
+ /* toggle tone matches the CDO Artifact's .labseg button exactly (2026-07-17 checked against
1489
+ the Artifact directly, not a guess): ink3 @ opacity .75 at rest, hover/on = filled
1490
+ background + full ink + opacity 1. This is a DIFFERENT convention from the fold buttons
1491
+ (which are glyph-only, no fill) — the segmented toggle is its own component and the
1492
+ Artifact deliberately fills a background for the selected state; a prior commit "fixed"
1493
+ this to match the fold buttons' no-fill rule, which was the wrong reference. */
1494
+ #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 ("小さい") */
1495
+ #fsRoot .fstvb:hover{background:var(--hover);color:var(--ink2);opacity:1}
1496
+ #fsRoot .fstvb.on{background:var(--hover-s);color:var(--ink2);opacity:1} /* selected: soft fill + softened glyph (--ink2, not near-black --ink) so it doesn't read heavier than its neighbours (Masa 2026-07-18) */
1497
+ #fsRoot .fstvb:focus:not(:focus-visible){outline:none}
1498
+ #fsRoot .fstvb svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} /* was 12px, guessed */
1499
+ #fsRoot .fstvb svg rect{fill:none}
1500
+
1501
+ /* ---- kanban board: fills the SAME lane slot the To Do list uses (var(--lanew-board), independent
1502
+ from the list's own var(--lanew)) — swapping List⇄Board never resizes the lane or the chat.
1503
+ 5+ columns don't fit the lane width, so it scrolls horizontally in place instead of growing. ---- */
1504
+ #fsRoot .fslane.boardmode{flex-basis:var(--lanew-board,372px)}
1505
+ #fsRoot .fslane.boardmode #fsTodoSec .tl,#fsRoot .fslane.boardmode #fsTodoSec .doneline,#fsRoot .fslane.boardmode #fsTodoSec #fsDonelist{display:none}
1506
+ /* Board mode = board ONLY (Masa 2026-07-17): the Review section — proof thumbnails, PR
1507
+ links, approve/dismiss, and its "N ready" summary line — is spoken by the board's own
1508
+ Review column, so it must not also stack on top of the board. This was the same bug the
1509
+ flagship had: hiding only the To Do list bits (above) left #fsRevSec sitting over the
1510
+ board. #fsRevSec holds both the summary count (.revhd) and the rows, so one rule hides
1511
+ both (production has no separate #sumwrap element). */
1512
+ #fsRoot .fslane.boardmode #fsRevSec{display:none}
1513
+ #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).
1514
+ 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);
1515
+ 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
1516
+ container-box position (measured identical, 63px both), but the glyph sits differently inside that box. Scoped here, not on <body>, to avoid
1517
+ a global typography change. */
1518
+ #fsRoot .fslane.boardmode #fsBoard{display:flex}
1519
+ #fsBoard::-webkit-scrollbar{height:8px}#fsBoard::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1520
+ .fsbcol{flex:0 0 236px;max-height:100%;display:flex;flex-direction:column;min-height:0} /* 236 = flagship .labcol (was 200, a prod-side narrowing that made the board read cramped vs the Artifact) */
1521
+ .fsbcolhd{display:flex;align-items:center;gap:8px;padding:1px 4px 9px;flex:0 0 auto}
1522
+ .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)}
1523
+ .fsbcolnm{font-size:12.5px;font-weight:600;color:var(--ink);letter-spacing:-.01em}
1524
+ .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}
1525
+ .fsbcadd{margin-left:auto;width:20px;height:20px;display:grid;place-items:center;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:5px;opacity:0;transition:opacity .12s} /* header "+" — matches flagship .labcadd (hover-reveal add) */
1526
+ .fsbcol:hover .fsbcadd{opacity:.7}
1527
+ .fsbcadd:hover{opacity:1;color:var(--ink);background:var(--hover)}
1528
+ .fsbcadd svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
1529
+ .fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
1530
+ .fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1531
+ /* Board card = Depth (CDO, Masa 2026-07-17): a card is SCANNED, not read — #NN + a
1532
+ 2-line title, colour rides the dot (never a fill, §5.5), and a continuous stroke + n/m
1533
+ ONLY while the goal is in flight. The column already names the status, so the card never
1534
+ repeats it. (Replaces the old .fsbrow one-liner that just cloned the list row's shape.) */
1535
+ .lbc{padding:9px 10px;border-radius:8px;display:flex;flex-direction:column;gap:6px;transition:box-shadow .12s,background .12s;line-height:1.35}
1536
+ .lbc:hover{background:var(--hover-s)} /* fill-less (light / quiet) hover */
1537
+ /* Board surface skin (Masa 2026-07-18). DEFAULT "auto" lets the THEME decide, because a white
1538
+ board and a black board want different cards (Masa: unifying them dragged white back to an
1539
+ ugly grey box): dark themes (banff/midnight/cinema) get the Artifact card — a near-black-grey
1540
+ fill + hairline; light themes (studio/gradient) get the fill-less card, held by whitespace +
1541
+ hover, like the To Do list. "card"/"quiet"/"frost" force one look in every theme. Switch via
1542
+ __boardskin() / the Customize gear's BOARD row. */
1543
+ body[data-boardskin="auto"][data-theme="banff"] .lbc,
1544
+ body[data-boardskin="auto"][data-theme="midnight"] .lbc,
1545
+ body[data-boardskin="auto"][data-theme="cinema"] .lbc,
1546
+ body[data-boardskin="card"] .lbc{background:color-mix(in srgb,var(--ink) 5%,transparent);box-shadow:0 0 0 1px var(--hair)}
1547
+ body[data-boardskin="auto"][data-theme="banff"] .lbc:hover,
1548
+ body[data-boardskin="auto"][data-theme="midnight"] .lbc:hover,
1549
+ body[data-boardskin="auto"][data-theme="cinema"] .lbc:hover,
1550
+ body[data-boardskin="card"] .lbc:hover{box-shadow:0 0 0 1px var(--hair2)}
1551
+ body[data-boardskin="quiet"] .lbc{background:transparent;box-shadow:none}
1552
+ body[data-boardskin="quiet"] .lbc:hover{background:var(--hover-s);box-shadow:none}
1553
+ body[data-boardskin="frost"] .lbc{background:var(--glass,var(--panel));-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:0 2px 8px rgba(20,23,31,.10),0 0 0 1px var(--hair2)}
1554
+ .lbc-hd{display:flex;align-items:center;gap:6px;min-height:12px}
1555
+ .lbc-n{font:600 10px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums;letter-spacing:.02em}
1556
+ .lbc-dot{width:6px;height:6px;border-radius:50%;flex:0 0 6px;background:var(--ink3)}
1557
+ .lbc-dot.run{background:var(--green);animation:lbcpulse 2.4s ease-in-out infinite}
1558
+ /* Status dot palette (Masa 2026-07-18): Needs you = amber #ffca16, Review = steel #8390f2,
1559
+ Doing = green (kept), Done/To Do/Later = grey, Failed = red. Fixed hues (like flagship's
1560
+ board), chosen to sit together as one family anchored on the green. */
1561
+ .lbc-dot.rev{background:#8390f2}
1562
+ .lbc-dot.bad{background:var(--danger)}
1563
+ .lbc-dot.intr{background:#ffca16}
1564
+ .lbc-dot.ok{background:var(--ink3);opacity:.6}
1565
+ @keyframes lbcpulse{0%,100%{opacity:.4}50%{opacity:1}}
1566
+ @media (prefers-reduced-motion:reduce){.lbc-dot.run{animation:none}}
1567
+ .lbc-t{font-size:12.6px;line-height:1.35;color:var(--ink);letter-spacing:-.005em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
1568
+ .lbc.is-quiet .lbc-t{color:var(--ink2)} /* Done / Later / To Do: visible, not shouting */
1569
+ .lbc-w{font:600 8.5px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;margin-left:auto}
1570
+ .lbc-w.bad{color:var(--danger)} .lbc-w.intr{color:#ffca16}
1571
+ .lbc-meter{display:flex;align-items:center;gap:7px}
1572
+ .lbc-meter .bar{flex:1;height:2px;border-radius:2px;background:color-mix(in srgb,var(--ink) 11%,transparent);overflow:hidden}
1573
+ .lbc-meter .bar i{display:block;height:100%;border-radius:2px;background:var(--green);transition:width .3s}
1574
+ .lbc-meter .frac{font:600 9.5px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
1575
+ .fsbempty{color:var(--ink3);font-size:11px;padding:4px 2px}
1576
+
1577
+ /* ---- Waiting badge (CDO, Masa 2026-07-17): columns stay in work-flow order (left→right =
1578
+ the kanban convention), and the two columns that fall off the right edge at 372px — Needs
1579
+ you and Review, i.e. "your turn" — announce themselves on the toggle's OWN row, so no new
1580
+ vertical space is taken. Zero waiting → nothing drawn. Colour is the dot only, no fill
1581
+ (§5.5); a column already scrolled into view sinks (pointing at what's visible is a lie).
1582
+ Board mode only. ---- */
1583
+ #fsRoot .fswait{position:absolute;top:16px;z-index:6;display:none;align-items:center;gap:10px;height:20px}
1584
+ #fsRoot .fslane.boardmode .fswait{display:flex}
1585
+ #fsRoot .fswait button{display:flex;align-items:center;gap:5px;border:0;background:none;padding:0;cursor:pointer;font:400 11px/1 var(--ui);color:var(--ink2);transition:color .12s,opacity .18s}
1586
+ #fsRoot .fswait button:hover{color:var(--ink)} /* no fill — glyph colour only */
1587
+ #fsRoot .fswait button:focus:not(:focus-visible){outline:none}
1588
+ #fsRoot .fswait button.seen{opacity:.34} /* that column is already in view — nothing to point at */
1589
+ #fsRoot .fswait .wd{width:6px;height:6px;border-radius:50%;flex:0 0 6px;background:var(--stg,var(--ink3))}
1590
+ #fsRoot .fswait .wn{font:600 10px/1 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink)}
1591
+ #fsRoot .fswait button.seen .wn{color:var(--ink2)}
1592
+
1593
+ /* ---- + New (CDO decision 5, Masa 2026-07-17): a quiet add-row where + sits in the ● column
1594
+ and New in the text column — aligned to the row's own grid, not a box shoved right. The
1595
+ shared .paplus glyph is 13px (the same span as the ● dot), with the stroke re-centred inside
1596
+ that box so the GLYPH lines up, not the box (Masa aesthetic: align glyphs, not boxes). Click
1597
+ = focus the composer (#input) — the one real create path, no invented row / server call. On
1598
+ the board it lives ONLY in the To Do column: a new goal is a new to-do; you don't "create"
1599
+ into Done/Review/Doing. ---- */
1600
+ #fsRoot .paplus{width:13px;height:13px;flex:0 0 13px;position:relative;display:inline-block}
1601
+ #fsRoot .paplus::before,#fsRoot .paplus::after{content:"";position:absolute;background:currentColor;border-radius:1px}
1602
+ #fsRoot .paplus::before{left:5.85px;top:2px;width:1.3px;height:9px}
1603
+ #fsRoot .paplus::after{top:5.85px;left:2px;height:1.3px;width:9px}
1604
+ /* list rail add-row — padding-left:8px puts + on the .trow ● (8px pad + 13px ● + 9px gap grid) */
1605
+ #fsRoot .qadd{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:9px;width:100%;padding:6px 4px 6px 8px;border-radius:8px;color:var(--ink3);font-size:11.5px;cursor:pointer;opacity:.45;transition:opacity .15s,color .15s}
1606
+ #fsRoot .qadd:hover{opacity:1;color:var(--ink)}
1607
+ /* board To Do column add-row — 2px body pad + 10px card pad = 12px lines + up with the card's #NN */
1608
+ #fsRoot .fsbadd{margin:5px 2px 0;padding:6px 10px;border-radius:7px;border:0;background:none;color:var(--ink3);font:400 11.5px/1 var(--ui);text-align:left;cursor:pointer;display:flex;align-items:center;gap:9px;opacity:.45;transition:opacity .15s,color .15s}
1609
+ #fsRoot .fsbadd:hover{opacity:1;color:var(--ink2)}
1610
+
1611
+ /* ---- chat ⇄ lane panel swap: grab anywhere in the top ~40px of either panel, drop lands
1612
+ EXACTLY on the side the dashed guide showed. Rail is not part of this (Masa 2026-07-16). ---- */
1613
+ #fsRoot .fscenter{order:1}
1614
+ #fsRoot .fslane{order:2}
1615
+ #fsRoot.swapmid .fscenter{order:2}
1616
+ #fsRoot.swapmid .fslane{order:1}
1617
+ #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点が左にずれてる") */
1618
+ #fsRoot .panelgrip:hover{opacity:1;background:var(--hover)}
1619
+ #fsRoot .panelgrip svg{width:14px;height:14px;fill:currentColor}
1620
+ #fsRoot .panel-over{outline:2px dashed color-mix(in srgb,var(--ink) 22%,transparent);outline-offset:-2px;border-radius:14px}
1621
+ #fsRoot .fscenter.top-armed,#fsRoot .fslane.top-armed{cursor:grab}
1622
+ #fsRoot .fscenter.top-armed:active,#fsRoot .fslane.top-armed:active{cursor:grabbing}
1623
+ #panelDropLine{position:absolute;width:0;display:none;border-left:2px dashed var(--ink2);z-index:20;pointer-events:none;border-radius:2px}
1406
1624
  #fsRoot .lhd{display:flex;align-items:baseline;gap:8px;margin-bottom:12px}
1407
1625
  #fsRoot .lhd .hd{font-size:13.5px;letter-spacing:-.02em}
1408
1626
  #fsRoot .lhd .num{font-size:10px;color:var(--ink3)}
@@ -1628,8 +1846,8 @@
1628
1846
  #fsRoot .rcard .rnote{display:block;font:500 9.5px/1.4 var(--mono);color:var(--green);margin-top:3px}
1629
1847
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1630
1848
  #fsRoot .tl{position:relative}
1631
- #fsRoot .tlcap{display:block;font-family:var(--mono);font-size:8.5px;letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;padding:13px 0 5px 8px}
1632
- #fsRoot .trow{position:relative;padding:6px 6px 6px 8px;border-radius:8px;font-size:12.5px;color:var(--ink);display:flex;align-items:center;gap:9px} /* §14.3 12.5px to match Review heading */
1849
+ #fsRoot .tlcap{display:block;font-family:var(--mono);font-size:8.5px;letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;padding:13px 0 5px 0} /* left 8->0: sit the DOING/Next up/Later labels on the same spine as the section headings (Masa 2026-07-18) */
1850
+ #fsRoot .trow{position:relative;padding:6px 6px 6px 0;border-radius:8px;font-size:12.5px;color:var(--ink);display:flex;align-items:center;gap:9px} /* left 8->0: row dot lines up with the Review/To Do headings' left edge, not 8px inside it (Masa 2026-07-18) */
1633
1851
  #fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
1634
1852
  #fsRoot .trow:hover{background:var(--hover-s)}
1635
1853
  #fsRoot .trow.now{font-weight:500}
@@ -2018,6 +2236,15 @@
2018
2236
  #fsRoot .cc-who .cs-n{font-size:12px;font-weight:600;color:var(--ink)}
2019
2237
  #fsRoot .cc-who .cs-d{font-family:var(--mono);font-size:9.5px;color:var(--ink3);letter-spacing:.05em}
2020
2238
 
2239
+ .lanemtop{display:none} /* mobile-only lane sheet header (H25②); shown in the max-width:640 block */
2240
+ /* H23 mobile empty-state hero (flagship .m-hero) — shown only on phones when the
2241
+ column is empty (#fsRoot.clawdhero), replacing the Clawd waiting-hero there. */
2242
+ .m-hero{display:none;flex-direction:column;align-items:center;justify-content:center;text-align:center;flex:1 1 auto;min-height:0;gap:15px;padding:24px 20px}
2243
+ .m-hero h2{font-size:20px;font-weight:600;letter-spacing:-.01em;color:var(--ink);margin:0;text-wrap:balance}
2244
+ .m-hero p{font-size:13px;line-height:1.55;color:var(--ink3);margin:0;max-width:290px}
2245
+ .m-chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:3px}
2246
+ .m-chip{border:1px solid var(--hair);border-radius:999px;padding:8px 14px;font-size:12.5px;color:var(--ink2);background:transparent;cursor:pointer;font-family:inherit}
2247
+ .m-chip:active{background:var(--hover-s)}
2021
2248
  /* ============================================================================
2022
2249
  FLAGSHIP · MOBILE (Masa 2026-07-09): app shell. The phone view keeps the
2023
2250
  implementing column as the primary screen, fixes the composer to the bottom,
@@ -2026,8 +2253,8 @@
2026
2253
  @media (max-width: 640px){
2027
2254
  html,body{height:100dvh}
2028
2255
  body[data-layout="flagship"]{overflow:hidden}
2029
- body[data-layout="flagship"] #fsRoot{height:100dvh;flex-direction:column;padding:8px;gap:8px;
2030
- overflow:hidden;position:relative;--fscenterw:100%}
2256
+ body[data-layout="flagship"] #fsRoot{height:100dvh;flex-direction:column;padding:8px 24px max(14px, calc(env(safe-area-inset-bottom) + 10px));gap:8px;
2257
+ overflow:hidden;position:relative;--fscenterw:100%} /* Masa: side margins — narrower cards (8→18px) */
2031
2258
  body[data-layout="flagship"].devbar #fsRoot{padding-top:8px}
2032
2259
  body[data-layout="flagship"] #fsRoot .fsrail,
2033
2260
  body[data-layout="flagship"] #fsRoot.railfold .fsrail{
@@ -2059,19 +2286,44 @@
2059
2286
  body[data-layout="flagship"].projectsopen #fsRoot #fsProjects{display:flex}
2060
2287
  body[data-layout="flagship"] #fsRoot .fsproj,
2061
2288
  body[data-layout="flagship"] #fsRoot .fsprojadd{width:100%;flex:0 0 auto;white-space:nowrap;min-height:40px}
2062
- body[data-layout="flagship"] #fsRoot .fscenter{flex:1 1 auto;width:100%;min-height:0;padding:12px 12px 0}
2289
+ body[data-layout="flagship"] #fsRoot .fscenter{flex:1 1 0;width:100%;min-height:0;padding:12px 12px 12px} /* Masa: bottom padding so the composer isn't flush with the card edge */
2063
2290
  body[data-layout="flagship"] #fsRoot .chd{margin-bottom:10px}
2064
- body[data-layout="flagship"] #fsRoot .fsstream{padding-bottom:154px}
2291
+ body[data-layout="flagship"] #fsRoot .fsstream{padding-bottom:12px} /* composer no longer overlaps (it's a sibling now) */
2065
2292
  body[data-layout="flagship"] #fsCompwrap{padding-bottom:0}
2066
- body[data-layout="flagship"] #composerWrap{position:fixed;left:8px;right:8px;bottom:0;z-index:70;
2067
- padding:0 0 max(8px, env(safe-area-inset-bottom));max-width:none;width:auto;margin:0}
2068
- body[data-layout="flagship"] #composerWrap .composer{box-shadow:0 12px 36px rgba(20,23,31,.18),0 0 0 1px var(--hair)}
2293
+ /* Composer = a SEPARATE card, sibling of the feed card (positionLayout mobile branch puts
2294
+ #composerWrap directly under .fscenter in #fsRoot). #fsRoot's `gap` then shows the base
2295
+ (下地) between the feed and the composer they no longer merge into one white block.
2296
+ As a normal flex child it inherits #fsRoot's side padding = aligned with the feed, in
2297
+ every theme (no more backdrop-filter containing-block double-inset). */
2298
+ /* Composer sits in-flow at the bottom of the ONE center card (the connected 下地): the feed
2299
+ (.fsstream) scrolls above it, the queue tray + input rest on the card's surface. Not fixed,
2300
+ not a separate card — matches the desktop center column. */
2301
+ body[data-layout="flagship"] #composerWrap{position:static;flex:0 0 auto;max-width:none;width:auto;margin:8px 0 0;padding:0}
2302
+ body[data-layout="flagship"] #composerWrap .composer{box-shadow:none} /* input box sits ON the center card — no separate float shadow */
2303
+ /* Masa: composer was too tall — compact it (ChatGPT-like). Controls + send stay on ONE row
2304
+ (nowrap; the model/agent chips shrink first, the send never shrinks). */
2305
+ body[data-layout="flagship"] #fsRoot .composer{padding:8px 10px;gap:6px;border-radius:14px}
2306
+ body[data-layout="flagship"] #fsRoot .composer textarea{font-size:15px;line-height:1.3;padding:1px 2px 0;max-height:90px}
2307
+ body[data-layout="flagship"] #fsRoot .crow{flex-wrap:nowrap;gap:6px}
2308
+ /* collapse the agent button to its icon (drop the "Claude Code" label) so the row fits
2309
+ one line with the send fully visible; model + effort keep their text. */
2310
+ body[data-layout="flagship"] #fsRoot #appbtn>span{display:none}
2311
+ body[data-layout="flagship"] #fsRoot .crow .appbtn,
2312
+ body[data-layout="flagship"] #fsRoot .crow .deliv{height:26px;flex:0 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2313
+ body[data-layout="flagship"] #fsRoot .crow .imgbtn{flex:0 0 auto}
2314
+ body[data-layout="flagship"] #fsRoot .crow .send{flex:0 0 32px;width:32px;height:32px}
2069
2315
  body[data-layout="flagship"] #fsRoot .fslane,
2070
2316
  body[data-layout="flagship"] #fsRoot.lanefold .fslane{
2071
- position:fixed;top:0;right:0;height:100dvh;width:min(88vw,360px);min-width:0;display:flex;
2072
- padding:18px 14px max(14px, env(safe-area-inset-bottom));z-index:85;transform:translateX(102%);
2073
- transition:transform .28s cubic-bezier(.4,0,.2,1);border-radius:16px 0 0 16px}
2317
+ position:fixed;top:0;right:0;height:100dvh;width:min(78vw,340px);min-width:0;display:flex;flex-direction:column;
2318
+ padding:12px 14px max(14px, calc(env(safe-area-inset-bottom) + 12px));z-index:85;transform:translateX(102%);
2319
+ transition:transform .28s cubic-bezier(.4,0,.2,1);border-radius:16px 0 0 16px} /* H25②: 78vw leaves chat peeking on the left; grab-handle header pins above a scrolling body */
2074
2320
  body[data-layout="flagship"].laneopen #fsRoot .fslane{transform:translateX(0)}
2321
+ /* H25②: mobile sheet header — center grab handle + close X, pinned above the scrolling lane body */
2322
+ body[data-layout="flagship"] #fsRoot .fslane .lanemtop{display:flex;align-items:center;justify-content:center;position:relative;flex:0 0 auto;height:22px;margin:0 0 8px}
2323
+ body[data-layout="flagship"] #fsRoot .fslane .lanemgrab{width:36px;height:4px;border-radius:3px;background:var(--hair2)}
2324
+ body[data-layout="flagship"] #fsRoot .fslane .lanemx{position:absolute;right:-2px;top:50%;transform:translateY(-50%);width:32px;height:32px;border:0;border-radius:9px;background:transparent;color:var(--ink3);display:grid;place-items:center;font-size:15px;cursor:pointer}
2325
+ body[data-layout="flagship"] #fsRoot .fslane .lanemx:active{color:var(--ink);background:var(--hover-s)}
2326
+ body[data-layout="flagship"] #fsRoot .fslane .lanebody{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch}
2075
2327
  body[data-layout="flagship"] #fsRoot.lanefold .fslane>*:not(.lanetg){display:flex}
2076
2328
  body[data-layout="flagship"] #fsRoot .fsdim{display:block;position:fixed;inset:0;background:rgba(9,12,18,.32);
2077
2329
  opacity:0;pointer-events:none;transition:opacity .22s;z-index:80}
@@ -2100,22 +2352,77 @@
2100
2352
  /* ---- settings/customize popover — anchored via JS inline left (rail.right+8px),
2101
2353
  which breaks once the rail is a full-width top bar; !important is the only way
2102
2354
  to beat an inline style without touching the JS ---- */
2103
- body[data-layout="flagship"] .gearpop{left:10px!important;right:10px!important;top:64px!important;
2104
- width:auto!important;max-width:none;max-height:calc(100dvh - 88px)}
2355
+ /* H25④: settings/customize popover → bottom sheet (grab handle + bigger tap targets;
2356
+ app classes: .csw swatches / .optchip type chips / .cfoot .cbtn / .moremenu .ghostbtn). */
2357
+ body[data-layout="flagship"] .gearpop{left:8px!important;right:8px!important;top:auto!important;
2358
+ bottom:calc(env(safe-area-inset-bottom) + 8px)!important;width:auto!important;max-width:none!important;
2359
+ max-height:82dvh!important;border-radius:16px;padding-top:8px}
2360
+ body[data-layout="flagship"] .gearpop::before{content:"";position:sticky;top:0;z-index:2;display:block;
2361
+ width:38px;height:5px;margin:0 auto 12px;border-radius:3px;background:var(--hair2)}
2362
+ body[data-layout="flagship"] .gearpop .csw{width:30px;height:30px}
2363
+ body[data-layout="flagship"] .gearpop .optchip{padding:9px 15px;font-size:12.5px}
2364
+ body[data-layout="flagship"] .gearpop .cfoot .cbtn{height:38px;padding:0 20px;font-size:12.5px}
2365
+ body[data-layout="flagship"] #fsRoot .moremenu .ghostbtn{min-height:46px;display:flex;align-items:center;padding:0 14px;font-size:14px;border-radius:10px}
2366
+ /* H23: centered top bar — gear (settings ⚙) left · project name centered + caret · Review (☰) right. */
2367
+ body[data-layout="flagship"] #fsRoot .fsrail{position:relative}
2368
+ body[data-layout="flagship"] #fsRoot .lbrow{order:0;margin-left:0;margin-right:auto}
2369
+ body[data-layout="flagship"] #fsRoot .fshamb{order:9;margin-left:0}
2370
+ body[data-layout="flagship"] #fsRoot .fsprojbtn{position:absolute;left:50%;transform:translateX(-50%);
2371
+ max-width:62%;justify-content:center;margin:0}
2372
+ /* Review/To Do opener (right) = plain ☰ icon (Masa 2026-07-18: ボタン化はやめ、三本線だけ・
2373
+ タップで To Do/Review ドロワー). The base .fshamb rule above already renders the ☰ svg. */
2374
+ body[data-layout="flagship"] #fsRoot .fshamb .fshamb-lb,
2375
+ body[data-layout="flagship"] #fsRoot .fshamb .fshamb-n{display:none}
2376
+ /* Top bar = chromeless on mobile for ALL themes (Masa 2026-07-18: Midnight で上に変なフレーム
2377
+ が出ていた → Gradient と同じく枠/面なしに). Gradient already did this; extend to every theme. */
2378
+ body[data-layout="flagship"] #fsRoot .fsrail{background:transparent;box-shadow:none}
2379
+ /* Masa: on mobile drop the drag-to-swap grip (⠿) — it collides with the lane close ✕ and
2380
+ there's nothing to drag on a phone. Keep the ✕. */
2381
+ body[data-layout="flagship"] #fsRoot .panelgrip{display:none}
2382
+ /* Masa: the QUESTION card text ran under the top-right trash icon at some lengths — reserve
2383
+ the icon's gutter so the label/question never collide with it. */
2384
+ body[data-layout="flagship"] #fsRoot .cc-ask-lb,
2385
+ body[data-layout="flagship"] #fsRoot .cc-ask-q{padding-right:30px}
2386
+ /* H23: mobile empty state → hero (chips), replacing the Clawd waiting-hero on phones. */
2387
+ body[data-layout="flagship"] #fsRoot.clawdhero #fsClawdHero{display:none}
2388
+ body[data-layout="flagship"] #fsRoot.clawdhero .m-hero{display:flex}
2389
+ /* H23: chat-first — hide the "Implementing" header (the feed is the screen); slim the now-doing line. */
2390
+ body[data-layout="flagship"] #fsRoot .chd{display:none}
2391
+ body[data-layout="flagship"] #fsRoot .nowdoing{background:transparent;border:0;box-shadow:none;border-radius:0;
2392
+ margin:0 0 4px;padding:0 2px 10px;gap:9px;border-bottom:1px solid var(--hair)}
2393
+ /* H25③: See-all ledger → scrim + inset panel (a full-bleed sheet let translucent themes show through). */
2394
+ body[data-layout="flagship"] #seeall{background:rgba(9,12,20,.55)}
2395
+ body[data-layout="flagship"] #seeall .sa-panel{width:96%!important;max-width:96%!important;
2396
+ max-height:calc(100dvh - env(safe-area-inset-top) - 22px)!important;
2397
+ margin-top:calc(env(safe-area-inset-top) + 10px)!important;border-radius:16px!important}
2105
2398
  }
2106
- /* Billing UI (H16) — self-serve plan. Tokens only; red is a line/label, never
2107
- a fill (DESIGN-RULES §5.6). CTA reverses ink/panel per theme; on the two
2108
- translucent-panel themes (banff, glass) the label is pinned opaque so it
2109
- doesn't wash out. */
2110
- .bx-cta{height:29px;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 13px;border-radius:8px;background:var(--ink);color:var(--panel);font:550 11.5px/1 var(--ui);border:0;cursor:pointer}
2399
+ /* Billing UI (H16/H22) — self-serve plan. Tokens only; red is a line/label,
2400
+ never a fill (DESIGN-RULES §5.6), and it is --danger (the app's real token —
2401
+ there is no --red here). The CTA text is --invink (defined per theme, always
2402
+ readable on --ink) so it needs no per-theme opaque pin. */
2403
+ .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
2404
  .bx-cta:hover{opacity:.9}
2112
- .bx-pm{font-family:var(--mono);font-size:10.5px;opacity:.82;margin-left:2px}
2113
- body[data-theme="banff"] .bx-cta,body[data-theme="glass"] .bx-cta{color:#101014}
2405
+ /* Masa 2026-07-17: tapping Upgrade jumps straight to Stripe's own page — no
2406
+ transition reads as "did that even do anything?". A spinner in the button
2407
+ itself (not a separate overlay) confirms the tap landed during the request
2408
+ round-trip, right up until the redirect actually happens. Reuses the
2409
+ existing @keyframes spin (line ~130) rather than inventing a new one. */
2410
+ .bx-cta.loading{cursor:default;opacity:.85}
2411
+ .bx-spin{display:none}
2412
+ .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}
2413
+ 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}
2414
+ /* Price = figure only, currency small (DESIGN-RULES §5.5 / flagship .fl-pm):
2415
+ the $ is set to 74% of the figure and optically raised so its top meets the
2416
+ digits' cap line (§5.5 グリフで揃える), not sunk to the baseline. No /mo on
2417
+ the button — the number is the message. This is the ONE place a price is
2418
+ styled; every screen inherits it via fmtPrice(). */
2419
+ .bx-pm{font-family:var(--mono);font-size:10.5px;opacity:.82;margin-left:2px;font-variant-numeric:tabular-nums}
2420
+ .bx-pm .cur{margin-right:.5px;font-size:.74em;vertical-align:.12em}
2114
2421
  /* ① free-limit prompt — rendered in #errbar with .billing neutralizing its error fill */
2115
2422
  .errbar.billing{background:transparent!important;backdrop-filter:none!important;box-shadow:none!important;border:0!important;padding:0!important}
2116
- .bx-ask{border:1px solid var(--hair);border-left:2px solid var(--red);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:14px;background:var(--panel)}
2423
+ .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
2424
  .bx-qt{flex:1;text-align:left}
2118
- .bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--red)}
2425
+ .bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--danger)}
2119
2426
  .bx-q{font-size:13px;color:var(--ink);margin-top:4px}
2120
2427
  .bx-act{flex:none}
2121
2428
  /* neutral ask (not a limit/error — e.g. the sign-in nudge): same card, --blue
@@ -2123,6 +2430,13 @@
2123
2430
  (§7決定ログ「AI確認:色は青系」) rather than inventing a new color. */
2124
2431
  .bx-ask.info{border-left-color:var(--blue)}
2125
2432
  .bx-ask.info .bx-lb{color:var(--blue)}
2433
+ /* free-tier pending counter in the To Do header (H22): mono figure, dim slash,
2434
+ --danger at the cap. Built only on the free plan (see renderFsTodo), so it
2435
+ needs no display toggle here — it simply replaces the lane-total .num. */
2436
+ .fcount{font-family:var(--mono);font-size:11px;font-variant-numeric:tabular-nums;color:var(--ink3)}
2437
+ .fcount .fc-l{opacity:.62}
2438
+ .fcount.cap{color:var(--danger)}
2439
+ .fcount.cap .fc-l{opacity:.75}
2126
2440
  /* ② post-checkout return bar */
2127
2441
  .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
2442
  .bx-cobar[hidden]{display:none}
@@ -2287,7 +2601,7 @@
2287
2601
  <button class="ghostbtn" id="fsSet">Settings</button>
2288
2602
  </div>
2289
2603
  </div>
2290
- <button class="fshamb" id="fsHamburger" title="Open To Do and Review"><svg viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h14"/></svg></button>
2604
+ <button class="fshamb" id="fsHamburger" title="Open To Do and Review"><svg viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h14"/></svg><span class="fshamb-lb">Review</span><span class="fshamb-n" id="fsHambN"></span></button>
2291
2605
  <div id="fsProjects"></div>
2292
2606
  </div>
2293
2607
  <!-- .railgrip lives here (sibling of .fsrail, not a child — see the CSS comment above)
@@ -2295,6 +2609,7 @@
2295
2609
  overflow:hidden (Masa 2026-07-16). -->
2296
2610
  <div class="railgrip" id="fsRgrip" title="Drag to resize"></div>
2297
2611
  <div class="fscard fscenter">
2612
+ <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
2613
  <div id="fsBand"></div>
2299
2614
  <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
2615
  <div id="fsBandPop"><div class="bandgrid">
@@ -2319,6 +2634,8 @@
2319
2634
  <div class="nowdoing" id="fsNowDoing" hidden></div>
2320
2635
  <!-- Clawd HERO (§4.5 初期状態): filled by JS (CLAWD generator), shown via #fsRoot.clawdhero -->
2321
2636
  <div id="fsClawdHero" aria-hidden="true"></div>
2637
+ <!-- H23 mobile empty-state hero (flagship .m-hero) — chips fill the composer (#input) -->
2638
+ <div class="m-hero"><h2>What should we build?</h2><p>Describe a goal in plain language. Claude Code runs it, checks its own work, and only calls it done with proof.</p><div class="m-chips"><button class="m-chip">Fix the failing test in lib.mjs</button><button class="m-chip">Add a token-usage counter</button><button class="m-chip">Summarize the open PRs</button></div></div>
2322
2639
  <div class="fsstream"><div id="fsFeed"></div></div>
2323
2640
  <div class="compwrap" id="fsCompwrap">
2324
2641
  <!-- q3 chat-bottom askbar: DOM path for the "flowed away" dynamic rule (§6.5) — hidden;
@@ -2329,11 +2646,20 @@
2329
2646
  </div>
2330
2647
  <div class="fsdim" id="fsLaneDim"></div>
2331
2648
  <div class="fscard fslane" id="fsLane">
2649
+ <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
2650
  <button class="lanetg" id="fsLaneTg" title="Fold the lane"></button><!-- panel icon filled by tgUpdate (v45 §4) -->
2651
+ <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) -->
2652
+ <button class="fstvb" data-fstv="list" title="List"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg></button>
2653
+ <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>
2654
+ </span>
2655
+ <div class="fswait" id="fsWait"><!-- waiting badge: "N Needs you / N Review" beside the toggle, board mode only (renderFsWait) --></div>
2333
2656
  <div class="lanegrip" id="fsGrip" title="Drag to resize"></div>
2657
+ <!-- mobile-only sheet header (H25②): grab handle + close X; display:none on desktop -->
2658
+ <div class="lanemtop"><span class="lanemgrab"></span><button class="lanemx" id="fsLaneClose" title="Close" aria-label="Close">✕</button></div>
2334
2659
  <div class="lanebody">
2335
2660
  <div id="fsRevSec"></div>
2336
2661
  <div id="fsTodoSec"></div>
2662
+ <div id="fsBoard"></div>
2337
2663
  </div>
2338
2664
  </div>
2339
2665
  </div>
@@ -2510,6 +2836,21 @@
2510
2836
  </div>
2511
2837
  </div>
2512
2838
  </div>
2839
+ <!-- moment ③: the New-project paywall dialog (H22). Reuses the .overlay dim+blur +
2840
+ narrow .modal of the delete confirm (the product's one dimmed dialog family),
2841
+ with a one-action layout: Upgrade full-width, "Not now" a quiet link under it.
2842
+ Title/body/price are filled per language at open (openProjectLimitDialog). -->
2843
+ <div class="overlay" id="flimProjOverlay">
2844
+ <div class="modal flimdlg">
2845
+ <div class="fd-lb">Free limit</div>
2846
+ <h3 id="flimProjTitle"></h3>
2847
+ <p id="flimProjBody"></p>
2848
+ <div class="fd-row">
2849
+ <button class="bx-cta fd-cta" id="flimProjUpgrade"><span class="bx-spin"></span>Upgrade</button>
2850
+ <button class="fd-no" id="flimProjNo">Not now</button>
2851
+ </div>
2852
+ </div>
2853
+ </div>
2513
2854
  <script>
2514
2855
  // Auth key lives in localStorage (the ?key= from the first visit) so auth
2515
2856
  // never depends on cookies — Chrome drops session cookies when the server
@@ -2536,7 +2877,7 @@ const savedTheme = (() => {
2536
2877
  if (v !== s) localStorage.setItem('theme', v);
2537
2878
  return v;
2538
2879
  })();
2539
- const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'galda2', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2880
+ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'galda2', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2540
2881
  // per-message send overrides set via slash-commands (reset after each send,
2541
2882
  // except model which is sticky in localStorage). palette = open command list.
2542
2883
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -3884,13 +4225,11 @@ function positionLayout(){
3884
4225
  // into the center card. Moving the node keeps every handler and the
3885
4226
  // input-focus fix (c32fda1): appendChild is guarded so the focused #input
3886
4227
  // is never detached mid-typing on SSE-driven renders.
4228
+ // Composer (queue + input + FREE LIMIT banner) + transient #errbar live at the bottom of
4229
+ // the ONE center card — desktop and mobile alike (全面つなげた下地: feed scrolls above,
4230
+ // composer sits on the same connected surface, the queue tray shares that 下地).
3887
4231
  const host = $('fsCompwrap'), cw = $('composerWrap');
3888
4232
  if (host && cw && cw.parentNode !== host) host.appendChild(cw);
3889
- // #errbar (showErr/showBillingBlocked/showSignInNudge) lives inside .main,
3890
- // which flagship hides outright (display:none) — every transient message
3891
- // silently never rendered. Adopt it beside the composer, same technique as
3892
- // #composerWrap above; a flagship CSS rule floats it just above the input
3893
- // (CDO判定 2026-07-16 — same fix as #gearPop's body[data-layout] escape).
3894
4233
  const eb = $('errbar');
3895
4234
  if (host && eb && eb.parentNode !== host) host.appendChild(eb);
3896
4235
  // tasklist / actpanel go back home (they live hidden inside .main here)
@@ -4912,6 +5251,7 @@ const fsUI = {
4912
5251
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
4913
5252
  revMore: false, // "+N more" fold on the review list (§1.5-8)
4914
5253
  todoFold: false, doneOpen: false, todoMore: false,
5254
+ todoView: localStorage.getItem('fsTodoView') === 'board' ? 'board' : 'list', // To Do: list (default) / kanban board (Masa 2026-07-16)
4915
5255
  logOpen: new Set(), logFocus: null, // "View log" targets
4916
5256
  repsOpen: new Set(), // reply-group folds
4917
5257
  };
@@ -5198,6 +5538,10 @@ function toggleGoalDetail(goalId){
5198
5538
  if (state.goalDetailOpen === goalId && overlay.classList.contains('show')) closeGoalDetail();
5199
5539
  else openGoalDetail(goalId);
5200
5540
  }
5541
+ function syncFsTvSeg(){
5542
+ const seg = $('fsTvSeg'); if (!seg) return;
5543
+ for (const b of seg.querySelectorAll('[data-fstv]')) b.classList.toggle('on', b.dataset.fstv === fsUI.todoView);
5544
+ }
5201
5545
  function renderFsLane(){
5202
5546
  if (state.layout !== 'flagship') return;
5203
5547
  const lane = $('fsLane'); if (!lane) return;
@@ -5205,7 +5549,153 @@ function renderFsLane(){
5205
5549
  // bug as the qreplyrow "vanishes mid-type" fix.
5206
5550
  const ae = document.activeElement;
5207
5551
  if (ae && lane.contains(ae) && ['INPUT', 'TEXTAREA'].includes(ae.tagName)) return;
5552
+ lane.classList.toggle('boardmode', fsUI.todoView === 'board');
5553
+ syncFsTvSeg();
5208
5554
  renderFsReview(); renderFsTodo();
5555
+ if (fsUI.todoView === 'board') renderFsBoard();
5556
+ }
5557
+ // Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and
5558
+ // the status chips can never drift; the two columns the chips have no word for
5559
+ // (Later / Needs you) are spelled out here. Pure: goal in, column key out.
5560
+ function fsBoardBucket(goal){
5561
+ const s = goal?.status;
5562
+ if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5563
+ if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
5564
+ if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
5565
+ if (s === 'review') return 'review';
5566
+ if (s === 'done' || s === 'reverted') return 'done'; // both terminal; REVERTED is not "in flight"
5567
+ if (s === 'stacked' || s === 'sending') return 'todo';
5568
+ if (s === 'running' && !goal.startedAt) return 'todo'; // goalChip: planned, nothing started yet
5569
+ return 'doing'; // planning / running / retesting — actively in flight only
5570
+ }
5571
+ // Kanban board — GOAL-level: one card per goal, never per task.
5572
+ //
5573
+ // A card is one thing the user asked for, and its #NN names that one thing exactly
5574
+ // once (Masa 2026-07-17). Tasks are the AI's decomposition of a single request —
5575
+ // the user didn't write them, doesn't review them one by one (proof and the PR are
5576
+ // per goal), and never says "#14" about one. They are the inside of a card, not
5577
+ // cards. The board used to lay out one card per TASK while #NN kept naming the
5578
+ // GOAL, so a goal split into three tasks put three cards reading "#13" in three
5579
+ // different columns.
5580
+ //
5581
+ // No drag-to-change-status: each status transition is its own gated server endpoint
5582
+ // (approve/dismiss/reverify/activate/…), there's no generic "set status" call to hook a
5583
+ // drag onto, so v1 is a visual overview only. Review already has its own rich section
5584
+ // above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
5585
+ function renderFsBoard(){
5586
+ const el = $('fsBoard'); if (!el) return;
5587
+ const { pgoals, list } = fsBucketTodo();
5588
+ const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
5589
+ const goalNo = new Map();
5590
+ for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
5591
+ // + New in EVERY column, matching the flagship Artifact: a hover-reveal "+" in the header
5592
+ // (.fsbcadd) and a persistent "+ New" at the bottom (.fsbadd). Both just focus the composer
5593
+ // (#input) — the real create path (no editable rows / no per-column status invention like the
5594
+ // flagship mock; a new goal enters via the composer). Earlier this was To-Do-only, which read
5595
+ // as "position is off" against the Artifact (Masa 2026-07-18).
5596
+ const col = (name, stg, count, html, stage) =>
5597
+ `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5598
+ // Depth card (CDO, Masa 2026-07-17): #NN + title, colour on the dot only, and a continuous
5599
+ // stroke + n/m ONLY while the goal is actually in flight. The column already names the
5600
+ // status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
5601
+ // the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
5602
+ const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
5603
+ const goalCard = (g) => {
5604
+ const bkt = fsBoardBucket(g);
5605
+ // errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
5606
+ // not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
5607
+ const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
5608
+ const intr = g.status === 'interrupted';
5609
+ const healthyRun = bkt === 'doing';
5610
+ let dot = '', word = '';
5611
+ if (errored) { dot = intr ? 'intr' : 'bad'; word = intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5612
+ else if (bkt === 'review') dot = 'rev';
5613
+ else if (bkt === 'done') dot = 'ok';
5614
+ else if (bkt === 'needs') dot = 'intr'; // your turn — a clarifying question is waiting
5615
+ else if (healthyRun) dot = 'run';
5616
+ // To Do (idle) and Later carry no dot — the column already says it. Quiet = dimmer title.
5617
+ const quiet = bkt === 'todo' || bkt === 'done' || bkt === 'later';
5618
+ const n = goalNo.get(g.id);
5619
+ let h = '<div class="lbc-hd">'
5620
+ + (dot ? `<span class="lbc-dot ${dot}"></span>` : '')
5621
+ + (n != null ? `<span class="lbc-n">#${n}</span>` : '')
5622
+ + (word ? `<span class="lbc-w ${intr ? 'intr' : 'bad'}">${word}</span>` : '')
5623
+ + '</div>'
5624
+ + `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
5625
+ // n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
5626
+ // residue, not progress — a card showing n/m reads unambiguously as "moving right now".
5627
+ // done/total are the goal's real tasks, so it never invents a number.
5628
+ if (healthyRun) {
5629
+ const gt = goalTasks(g), total = gt.length;
5630
+ if (total) {
5631
+ const done = gt.filter((t) => ['done', 'skipped'].includes(t.status)).length;
5632
+ h += `<div class="lbc-meter"><span class="bar"><i style="width:${Math.round(done / total * 100)}%"></i></span><span class="frac">${done}/${total}</span></div>`;
5633
+ }
5634
+ }
5635
+ return `<div class="lbc${quiet ? ' is-quiet' : ''}">${h}</div>`;
5636
+ };
5637
+ const no = (g) => goalNo.get(g.id) ?? Infinity;
5638
+ const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
5639
+ for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
5640
+ for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
5641
+ // To Do / Doing / Needs you / Review / Done / Later — the original design's columns
5642
+ // (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
5643
+ // distinct from "blocked" (an error needing retry/archive, which stays in Doing, the
5644
+ // same grouping the List view already uses). Later (backlog) sits LAST, not before Done.
5645
+ el.innerHTML =
5646
+ col('To Do', 'var(--ink3)', bucket.todo.length, bucket.todo.map(goalCard).join(''), 'todo')
5647
+ + col('Doing', 'var(--green)', bucket.doing.length, bucket.doing.map(goalCard).join(''), 'doing')
5648
+ + (bucket.needs.length ? col('Needs you', '#ffca16', bucket.needs.length, bucket.needs.map(goalCard).join(''), 'needs') : '')
5649
+ + col('Review', '#8390f2', bucket.review.length, bucket.review.map(goalCard).join(''), 'review')
5650
+ + col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
5651
+ + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
5652
+ for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
5653
+ renderFsWait(bucket);
5654
+ }
5655
+ // Waiting badge (CDO, Masa 2026-07-17 decision 2): the board keeps work-flow column order, so
5656
+ // the "your turn" columns (Needs you, Review) can sit off the right edge at a narrow lane
5657
+ // width. They announce themselves beside the List/Board toggle instead — pure read of the same
5658
+ // buckets the board just rendered (no server call). Colour on the dot only; a column already
5659
+ // in view sinks. Only the columns with items are drawn — zero waiting shows nothing.
5660
+ const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', '#8390f2']];
5661
+ function fsWaitPlace(){
5662
+ const bar = $('fsWait'), seg = $('fsTvSeg'), lane = $('fsLane');
5663
+ if (!bar || !seg || !lane) return;
5664
+ const sr = seg.getBoundingClientRect(), lr = lane.getBoundingClientRect();
5665
+ bar.style.left = Math.round(sr.right - lr.left + 14) + 'px'; // measured off the toggle, not guessed
5666
+ }
5667
+ function fsWaitSeen(){
5668
+ const bar = $('fsWait'), board = $('fsBoard');
5669
+ if (!bar || !board) return;
5670
+ const br = board.getBoundingClientRect();
5671
+ for (const b of bar.querySelectorAll('button')) {
5672
+ const col = board.querySelector(`.fsbcol[data-stage="${b.dataset.stage}"]`);
5673
+ if (!col) continue;
5674
+ const cr = col.getBoundingClientRect();
5675
+ b.classList.toggle('seen', cr.left < br.right - 40 && cr.right > br.left + 40);
5676
+ }
5677
+ }
5678
+ function renderFsWait(bucket){
5679
+ const bar = $('fsWait'); if (!bar) return;
5680
+ bar.innerHTML = FS_WAIT.map(([k, label, c]) => bucket[k].length
5681
+ ? `<button data-stage="${k}" style="--stg:${c}"><span class="wd"></span><span class="wn">${bucket[k].length}</span>${label}</button>` : '').join('');
5682
+ fsWaitPlace();
5683
+ if (!renderFsWait.__wired) {
5684
+ renderFsWait.__wired = true;
5685
+ bar.addEventListener('click', (e) => {
5686
+ const b = e.target.closest('button'); if (!b) return;
5687
+ const col = $('fsBoard')?.querySelector(`.fsbcol[data-stage="${b.dataset.stage}"]`);
5688
+ if (col) col.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
5689
+ });
5690
+ const board = $('fsBoard');
5691
+ if (board) {
5692
+ board.addEventListener('scroll', fsWaitSeen, { passive: true });
5693
+ // the lane can also widen a hidden column into view (drag the grip) — repos + re-check
5694
+ if (window.ResizeObserver) new ResizeObserver(() => { fsWaitPlace(); fsWaitSeen(); }).observe(board);
5695
+ }
5696
+ window.addEventListener('resize', () => { fsWaitPlace(); fsWaitSeen(); });
5697
+ }
5698
+ fsWaitSeen();
5209
5699
  }
5210
5700
  // Review (top of the lane, §1.5-4). Counts are DETERMINISTIC (§1.5-9): computed
5211
5701
  // from state via buildReviewDigest — no LLM, no donut, no badges.
@@ -5288,6 +5778,8 @@ function renderFsReview(){
5288
5778
  </span></div>
5289
5779
  <div class="revlist">${shown.map(card).join('')}${foldedGoals.map(foldCard).join('')}
5290
5780
  ${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
5781
+ // Mirror the ready-review count onto the mobile top-bar "Review N" opener (H23).
5782
+ const _hn = document.getElementById('fsHambN'); if (_hn) _hn.textContent = green > 0 ? String(green) : '';
5291
5783
  // See all = the Ledger dialog (HANDOFF-v45 §5, replacing the ペライチ);
5292
5784
  // Cmd/Ctrl+click = standalone full-page view in a new tab (#seeall route).
5293
5785
  // guarded: the meter (and with it See all) is absent while the section holds only
@@ -5325,26 +5817,36 @@ function renderFsReview(){
5325
5817
  headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
5326
5818
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
5327
5819
  }
5328
- // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
5329
- // error as a small in-row colour element (§1.5-8), quiet Done fold.
5330
- function renderFsTodo(){
5331
- const el = $('fsTodoSec'); if (!el) return;
5820
+ // Shared To Do bucketing (list rows AND the kanban board read the same groups
5821
+ // single source of truth, see renderFsBoard below). Pure of the DOM: state in, groups out.
5822
+ function fsBucketTodo(){
5332
5823
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
5333
5824
  const list = state.tasks.filter((t) => t.projectId === state.active);
5334
5825
  const order = state.queueOrder[state.active] ?? [];
5335
5826
  const byQueue = (a, b) => { const ia = order.indexOf(a.id), ib = order.indexOf(b.id); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib) || a.num - b.num; };
5336
- const running = list.filter((t) => t.status === 'running');
5337
- const failed = list.filter((t) => ['failed', 'interrupted'].includes(t.status));
5338
- const blocked = pgoals.filter((g) => g.status === 'blocked');
5339
- const queued = list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue);
5340
- const doneAll = list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num);
5827
+ return {
5828
+ pgoals, list,
5829
+ running: list.filter((t) => t.status === 'running'),
5830
+ failed: list.filter((t) => ['failed', 'interrupted'].includes(t.status)),
5831
+ blocked: pgoals.filter((g) => g.status === 'blocked'),
5832
+ needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
5833
+ reviewGoals: pgoals.filter((g) => g.status === 'review'),
5834
+ queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
5835
+ doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
5836
+ later: pgoals.filter((g) => g.status === 'pending'),
5837
+ };
5838
+ }
5839
+ // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
5840
+ // error as a small in-row colour element (§1.5-8), quiet Done fold.
5841
+ function renderFsTodo(){
5842
+ const el = $('fsTodoSec'); if (!el) return;
5843
+ const { pgoals, list, running, failed, blocked, queued, doneAll, later } = fsBucketTodo();
5341
5844
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
5342
5845
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
5343
5846
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
5344
5847
  // renders only when they exist. Same endpoints as the old layout's data-pstart/pdel:
5345
5848
  // Start = POST /api/goals/:id/activate, Delete = DELETE /api/goals/:id (confirm).
5346
5849
  // No mono # — pending goals are never planned, so they carry no task number yet.
5347
- const later = pgoals.filter((g) => g.status === 'pending');
5348
5850
  const laterRows = later.map((g) => `<div class="trow up dim"><span class="st"></span><span class="tt">${tt(g.text, 70)}</span>
5349
5851
  <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
5852
  // reference numbers (HANDOFF-v45 §11): a #NN prefix on every To Do / Review item's name.
@@ -5401,17 +5903,31 @@ function renderFsTodo(){
5401
5903
  // specifically, since that suffix describes the Next-up sublist's own sort, not the
5402
5904
  // lane total.
5403
5905
  const todoTotal = running.length + failed.length + blocked.length + queued.length + later.length;
5404
- el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd">To Do</span><span class="num">${todoTotal}${queued.length ? ' · next-up order' : ''}</span><span class="fold">▾</span></div>
5906
+ // Free-tier pending counter (H22): on the free plan the header shows the wall
5907
+ // — pending goals out of the 5-slot cap — reddening at the cap and gone once
5908
+ // paid. Reads the SAME numbers the gate enforces (state.billing, never a local
5909
+ // recompute) so the header and the wall can't drift. usage.pending already
5910
+ // excludes review server-side (#128).
5911
+ const _bill = state.billing || {};
5912
+ const _fp = _bill.usage?.pending, _fl = _bill.limits?.pending;
5913
+ const _showFc = _bill.plan !== 'paid' && Number.isFinite(_fp) && Number.isFinite(_fl);
5914
+ const _todoNum = _showFc
5915
+ ? `<span class="fcount fstdlabel${_fp >= _fl ? ' cap' : ''}">${_fp}<span class="fc-l">/${_fl}</span></span>`
5916
+ : `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
5917
+ el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
5405
5918
  <div class="tl">
5406
5919
  ${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
5407
5920
  ${upRows ? `<span class="tlcap"${doingRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
5408
5921
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
5409
5922
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
5923
+ <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
5410
5924
  </div>
5411
5925
  ${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
5412
5926
  <div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.slice(0, 3).map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
5413
5927
  el.classList.toggle('folded', !!fsUI.todoFold);
5414
5928
  $('fsTodoHd').onclick = () => { fsUI.todoFold = !fsUI.todoFold; el.classList.toggle('folded', fsUI.todoFold); };
5929
+ const qadd = el.querySelector('.qadd[data-fsnew]'); // + New → focus the composer (the real create path)
5930
+ if (qadd) qadd.onclick = () => $('input')?.focus();
5415
5931
  const dl = el.querySelector('#fsDoneLine');
5416
5932
  if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
5417
5933
  const more = el.querySelector('#fsTodoMore');
@@ -5524,6 +6040,9 @@ async function createProject(){
5524
6040
  try {
5525
6041
  const r = await fetch(withKey('/api/projects'), { method: 'POST', headers: { 'content-type': 'application/json' },
5526
6042
  body: JSON.stringify({ baseProjectId: state.active, name: 'New project' }) });
6043
+ // Free tier allows one project (moment ③): the server 402s the second. Raise
6044
+ // the centered upgrade dialog instead of the generic "failed." toast.
6045
+ if (r.status === 402) { openProjectLimitDialog(); return; }
5527
6046
  if (!r.ok) throw new Error();
5528
6047
  const body = await r.json();
5529
6048
  state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
@@ -5531,8 +6050,22 @@ async function createProject(){
5531
6050
  document.body.classList.remove('projectsopen');
5532
6051
  render();
5533
6052
  } catch { showErr('New project failed.'); }
5534
- if (add) add.disabled = false;
5535
- }
6053
+ finally { if (add) add.disabled = false; } // the 402 early-return must not leave +New dead
6054
+ }
6055
+ // moment ③ paywall dialog — filled per language from BILLING_COPY, Upgrade shows
6056
+ // the one price format (small $, no /mo), "Not now" just closes.
6057
+ function openProjectLimitDialog(){
6058
+ const c = BILLING_COPY[billingLang()];
6059
+ $('flimProjTitle').textContent = c.projTitle;
6060
+ $('flimProjBody').textContent = c.projBody;
6061
+ $('flimProjNo').textContent = c.notNow;
6062
+ const up = $('flimProjUpgrade');
6063
+ up.disabled = false; up.classList.remove('loading');
6064
+ up.innerHTML = `<span class="bx-spin"></span>Upgrade${priceTag(state.billing?.price)}`;
6065
+ up.onclick = () => startCheckout(up);
6066
+ $('flimProjOverlay').classList.add('show');
6067
+ }
6068
+ function closeProjectLimitDialog(){ $('flimProjOverlay').classList.remove('show'); }
5536
6069
  async function deleteProject(p){
5537
6070
  if (!p) return;
5538
6071
  if (!(await confirmDelete({
@@ -5720,6 +6253,16 @@ function renderClawdHero(){
5720
6253
  };
5721
6254
  $('fsRailTg').onclick = () => { root.classList.toggle('railfold'); tgUpdate(); fsGearAlignX(); }; // §1.5-7: fold
5722
6255
  $('fsLaneTg').onclick = () => { root.classList.toggle('lanefold'); tgUpdate(); };
6256
+ // List/Board toggle — wired once (static, unlike the row it used to live inside which
6257
+ // rebuilds on every renderFsTodo()); syncFsTvSeg() paints .on and is called from renderFsLane.
6258
+ for (const b of $('fsTvSeg').querySelectorAll('[data-fstv]')) b.onclick = (e) => {
6259
+ e.stopPropagation();
6260
+ fsUI.todoView = b.dataset.fstv; localStorage.setItem('fsTodoView', fsUI.todoView);
6261
+ $('fsLane').classList.toggle('boardmode', fsUI.todoView === 'board');
6262
+ syncFsTvSeg();
6263
+ if (fsUI.todoView === 'board') renderFsBoard();
6264
+ };
6265
+ syncFsTvSeg();
5723
6266
  tgUpdate();
5724
6267
  $('fsMoreBtn').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.toggle('open'); };
5725
6268
  document.addEventListener('click', (e) => { if (!e.target.closest('#fsRoot .lbrow')) $('fsMoreMenu').classList.remove('open'); });
@@ -5732,6 +6275,12 @@ function renderClawdHero(){
5732
6275
  document.body.classList.toggle('laneopen');
5733
6276
  };
5734
6277
  $('fsLaneDim').onclick = closeMobilePanels;
6278
+ { const lx = document.getElementById('fsLaneClose'); if (lx) lx.onclick = closeMobilePanels; }
6279
+ // Mobile empty-state hero chips (H23): tapping one drafts it into the composer.
6280
+ document.querySelectorAll('.m-chip').forEach((ch) => { ch.onclick = () => {
6281
+ const inp = document.getElementById('input');
6282
+ if (inp) { inp.value = ch.textContent; inp.focus(); inp.dispatchEvent(new Event('input', { bubbles: true })); }
6283
+ }; });
5735
6284
  $('fsProjBtn').onclick = (e) => {
5736
6285
  e.stopPropagation();
5737
6286
  document.body.classList.remove('laneopen');
@@ -5770,17 +6319,43 @@ function renderClawdHero(){
5770
6319
  if (restoreLane && canRestoreFlagshipWidths(restoreRail ? savedRail : defaultRailW, saved)) {
5771
6320
  root.style.setProperty('--lanew', `${saved}px`);
5772
6321
  }
5773
- let sx = 0, sw = 0, drag = false;
5774
- grip.addEventListener('mousedown', (e) => { drag = true; sx = e.clientX; sw = lane.getBoundingClientRect().width; lane.classList.add('noanim'); e.preventDefault(); });
6322
+ // Board view keeps its OWN width (--lanew-board), independent from the List width
6323
+ // (--lanew) both start at the same 372px default, each persists once resized
6324
+ // (Masa 2026-07-16: 看板とToDoの横幅は最初同じ・それぞれ保持). List stays capped at
6325
+ // 540 (a To Do list doesn't need more); Board can grow with the window, same
6326
+ // "no flat ceiling" treatment as the rail above (Masa 2026-07-16: もっと広げたい) —
6327
+ // but its live max reserves the rail's CURRENT width (not a flat guess), same
6328
+ // protection railMax() gives center: a wide-open board must not crush the rail's
6329
+ // own content (Masa 2026-07-16: 狭くした時にバグる — rail at its 72px floor overlaps
6330
+ // its own logo/project row; reserving rail's real width keeps it out of that floor).
6331
+ // Board is the star of this view — chat only needs to stay usable, not "comfortably
6332
+ // readable" (that 640px figure is for List/chat-first use). 320px LOOKED right but was
6333
+ // measured wrong: the composer's own button row (.crow — app/model/effort selects + send)
6334
+ // needs ~342px just for its content, and .fscenter's padding adds ~44px more — below ~430
6335
+ // those chips get clipped off the edge of the composer card (Masa 2026-07-16 repro:
6336
+ // dragged the board grip wide on a 1280px-wide window, "Medium"+send were cut off).
6337
+ // 440 is the smallest value that doesn't clip on a real laptop width.
6338
+ const minCenterWBoard = 440;
6339
+ const boardMax = () => Math.max(280, window.innerWidth - 28 - 24
6340
+ - (document.querySelector('#fsRoot .fsrail')?.getBoundingClientRect().width || defaultRailW) - minCenterWBoard);
6341
+ const savedBoard = Number(localStorage.getItem('fsLaneWBoard'));
6342
+ if (savedBoard >= 280 && savedBoard <= boardMax()) root.style.setProperty('--lanew-board', `${savedBoard}px`);
6343
+ let sx = 0, sw = 0, drag = false, boarding = false;
6344
+ grip.addEventListener('mousedown', (e) => {
6345
+ drag = true; sx = e.clientX; boarding = lane.classList.contains('boardmode');
6346
+ sw = lane.getBoundingClientRect().width; lane.classList.add('noanim'); e.preventDefault();
6347
+ });
5775
6348
  document.addEventListener('mousemove', (e) => {
5776
6349
  if (!drag) return;
5777
- const w = Math.max(280, Math.min(540, sw + (sx - e.clientX)));
5778
- root.style.setProperty('--lanew', `${w}px`);
6350
+ const max = boarding ? boardMax() : 540;
6351
+ const w = Math.max(280, Math.min(max, sw + (sx - e.clientX)));
6352
+ root.style.setProperty(boarding ? '--lanew-board' : '--lanew', `${w}px`);
5779
6353
  });
5780
6354
  document.addEventListener('mouseup', () => {
5781
6355
  if (!drag) return;
5782
6356
  drag = false; lane.classList.remove('noanim');
5783
- localStorage.setItem('fsLaneW', String(parseInt(root.style.getPropertyValue('--lanew')) || 372));
6357
+ if (boarding) localStorage.setItem('fsLaneWBoard', String(parseInt(root.style.getPropertyValue('--lanew-board')) || 372));
6358
+ else localStorage.setItem('fsLaneW', String(parseInt(root.style.getPropertyValue('--lanew')) || 372));
5784
6359
  });
5785
6360
  // rail width drag: 72px – railMax() (freely widens, only bounded by keeping the center column
5786
6361
  // readable), persisted (flagship .railgrip port, v36 JS; grip hides when folded)
@@ -5801,6 +6376,52 @@ function renderClawdHero(){
5801
6376
  localStorage.setItem('fsRailW', String(parseInt(root.style.getPropertyValue('--railw')) || 168)); // H19: default floor 168
5802
6377
  if ($('gearPop').classList.contains('open')) custPlace(); // panel sits beside the rail — track the new edge
5803
6378
  });
6379
+ // chat ⇄ lane panel swap — grab anywhere in the top ~40px of either panel (not just the
6380
+ // grip icon), drop lands EXACTLY on the side the dashed guide showed (never a blind
6381
+ // toggle, which could disagree with what was shown). Rail is fixed, not part of this
6382
+ // (Masa 2026-07-16).
6383
+ (() => {
6384
+ const center = root.querySelector('.fscenter');
6385
+ if (!center || !lane) return;
6386
+ const TOPZONE = 40;
6387
+ const sideFor = (target, x) => { const r = target.getBoundingClientRect(); return x < r.left + r.width / 2 ? 'left' : 'right'; };
6388
+ const dropLine = document.createElement('div'); dropLine.id = 'panelDropLine'; root.appendChild(dropLine);
6389
+ const showDropLine = (target, side) => {
6390
+ const r = target.getBoundingClientRect(), ar = root.getBoundingClientRect();
6391
+ const edgeX = side === 'left' ? r.left : r.right;
6392
+ dropLine.style.left = `${edgeX - ar.left}px`; dropLine.style.top = `${r.top - ar.top}px`;
6393
+ dropLine.style.height = `${r.height}px`; dropLine.style.display = 'block';
6394
+ };
6395
+ const hideDropLine = () => { dropLine.style.display = 'none'; };
6396
+ document.addEventListener('dragend', hideDropLine);
6397
+ const armTop = (panel, name) => {
6398
+ panel.addEventListener('mousedown', (e) => { const r = panel.getBoundingClientRect(); if (e.clientY - r.top <= TOPZONE) panel.setAttribute('draggable', 'true'); });
6399
+ panel.addEventListener('mousemove', (e) => { const r = panel.getBoundingClientRect(); panel.classList.toggle('top-armed', e.clientY - r.top <= TOPZONE); });
6400
+ panel.addEventListener('mouseleave', () => panel.classList.remove('top-armed'));
6401
+ panel.addEventListener('dragstart', (e) => {
6402
+ if (panel.getAttribute('draggable') !== 'true') return;
6403
+ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/x-panel-swap', name);
6404
+ });
6405
+ const disarm = () => { panel.removeAttribute('draggable'); panel.classList.remove('top-armed'); };
6406
+ panel.addEventListener('dragend', disarm); panel.addEventListener('mouseup', disarm);
6407
+ };
6408
+ armTop(center, 'center'); armTop(lane, 'lane');
6409
+ for (const p of [center, lane]) {
6410
+ p.addEventListener('dragover', (e) => {
6411
+ if (!e.dataTransfer.types.includes('application/x-panel-swap')) return;
6412
+ e.preventDefault(); p.classList.add('panel-over'); showDropLine(p, sideFor(p, e.clientX));
6413
+ });
6414
+ p.addEventListener('dragleave', () => { p.classList.remove('panel-over'); hideDropLine(); });
6415
+ p.addEventListener('drop', (e) => {
6416
+ if (!e.dataTransfer.types.includes('application/x-panel-swap')) return;
6417
+ e.preventDefault(); p.classList.remove('panel-over'); hideDropLine();
6418
+ const dragged = e.dataTransfer.getData('application/x-panel-swap');
6419
+ const side = sideFor(p, e.clientX);
6420
+ const wantLeft = side === 'left' ? dragged : (dragged === 'center' ? 'lane' : 'center');
6421
+ root.classList.toggle('swapmid', wantLeft === 'lane');
6422
+ });
6423
+ }
6424
+ })();
5804
6425
  // theme-6 band picker (visual thumbnails, §6.5 T6)
5805
6426
  $('fsBandBtn').onclick = (e) => { e.stopPropagation(); $('fsBandPop').classList.toggle('open'); };
5806
6427
  document.addEventListener('click', (e) => { if (!e.target.closest('#fsBandPop') && !e.target.closest('#fsBandBtn')) $('fsBandPop').classList.remove('open'); });
@@ -5853,39 +6474,86 @@ function upsert(arr, item){ const i = arr.findIndex((x) => x.id === item.id); if
5853
6474
 
5854
6475
  let errTimer = null;
5855
6476
  let authDown = false; // true while the worker-auth banner is up → keep auto-hide timers from clearing it
6477
+ // true while the free-tier paywall is up. Unlike a transient error, "you are
6478
+ // over the free limit" doesn't resolve itself — it persists until the user
6479
+ // upgrades or usage drops, so it must not auto-hide on a fixed timer (Masa
6480
+ // 2026-07-17: a 20s auto-hide + a 15s retry that background-tab throttling can
6481
+ // stretch to ~once/minute meant the banner could vanish for long stretches
6482
+ // with the outbox still silently stuck "Unsent" and no visible explanation —
6483
+ // exactly the "queue fills up, nothing happens" report). Mirrors authDown's
6484
+ // existing guard pattern; cleared only in postOutboxItem once a send actually
6485
+ // succeeds, which is the one signal that's actually true evidence the block
6486
+ // lifted (an upgrade, or the count dropping).
6487
+ let billingBlockedActive = false;
6488
+ function clearBillingBlocked(){
6489
+ if (!billingBlockedActive) return;
6490
+ billingBlockedActive = false;
6491
+ const bar = $('errbar');
6492
+ bar.classList.remove('billing');
6493
+ bar.style.display = 'none';
6494
+ bar.innerHTML = '';
6495
+ }
5856
6496
  function showErr(msg){
5857
6497
  const bar = $('errbar');
5858
6498
  bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
6499
+ billingBlockedActive = false;
5859
6500
  bar.textContent = msg; bar.style.display = 'block';
5860
6501
  clearTimeout(errTimer);
5861
6502
  errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
5862
6503
  }
5863
6504
 
5864
- // Minimal placeholder for the free-tier paywall pending CDO design
5865
- // (handoff-billing-ui-request.md §①) reuses the existing #errbar rather
5866
- // than inventing new chrome; the polished version can replace showBillingBlocked's
5867
- // body without touching the postOutboxItem call site below.
5868
- const BLOCKED_REASON_TEXT = {
5869
- 'pending-goal-limit': 'Free plan limit reached — 5 pending To Dos.',
5870
- 'cumulative-goal-limit': 'Free plan limit reached — 19 To Dos total.',
5871
- 'project-limit': 'Free plan limit reached — 1 project.',
6505
+ // Free-limit copy (H22, Masa 2026-07-17 確定 · ja+en). The sentence is content,
6506
+ // so it follows the user's language; the mono label and buttons stay English
6507
+ // 汎用名 (DOING / FAILED / Retry). <b> is intentional (rendered as innerHTML from
6508
+ // this fixed internal constant, never user input) — the figure is the message.
6509
+ const BILLING_COPY = {
6510
+ ja: {
6511
+ 'pending-goal-limit': '無料プランでは一度に登録できるタスクは <b>5個</b> までです。Proプランでは無制限に登録できます。',
6512
+ 'cumulative-goal-limit': '無料プランの <b>20/20</b> タスクをすべて使いました。',
6513
+ projTitle: 'プロジェクトは1つまでです',
6514
+ projBody: '無料プランに含まれるプロジェクトは1つです。アップグレードすると、プロジェクトを無制限に作れます。',
6515
+ notNow: 'Not now',
6516
+ },
6517
+ en: {
6518
+ 'pending-goal-limit': 'The free plan holds <b>5 tasks</b> at a time. Pro holds as many as you want.',
6519
+ 'cumulative-goal-limit': "You've used all <b>20/20</b> free tasks.",
6520
+ projTitle: 'Free includes one project',
6521
+ projBody: 'Your free plan includes one project. Upgrade to create as many as you need.',
6522
+ notNow: 'Not now',
6523
+ },
5872
6524
  };
6525
+ // Which language the paywall speaks. Mirrors foldLangFor's path (the project's
6526
+ // review-language override wins; otherwise match the language the user is
6527
+ // actually writing in — the latest goal in the active project — else default en)
6528
+ // so the wall reads in the same language as everything else in the project.
6529
+ function billingLang(){
6530
+ const l = reviewLangFor(state.active);
6531
+ if (l === 'ja' || l === 'en') return l;
6532
+ const recent = (state.goals || []).filter((g) => g.projectId === state.active).slice(-1)[0];
6533
+ if (recent && /[぀-ヿ㐀-鿿]/.test(recent.text || '')) return 'ja';
6534
+ return document.documentElement.lang === 'ja' ? 'ja' : 'en';
6535
+ }
6536
+ // The free-limit banner (moments ① pending-goal-limit / ② cumulative-goal-limit):
6537
+ // a bordered card in #errbar, --danger as a 2px left rule only (no fill, §5.6),
6538
+ // with an Upgrade CTA that opens Stripe Checkout. project-limit is NOT a banner —
6539
+ // it is the centered dialog raised from createProject (moment ③). The .billing
6540
+ // class strips #errbar's own error fill so the card stands alone.
5873
6541
  function showBillingBlocked(blocked){
5874
6542
  const bar = $('errbar');
5875
- const text = BLOCKED_REASON_TEXT[blocked?.reason] || 'Free plan limit reached.';
5876
- const pl = fmtPrice(state.billing?.price);
5877
- // ① the free-limit prompt (design H16): a bordered card, red as a 2px left
5878
- // rule only (no fill), with an Upgrade CTA that opens Stripe Checkout. The
5879
- // .billing class strips #errbar's own error fill so the card stands alone.
6543
+ const c = BILLING_COPY[billingLang()];
6544
+ const q = c[blocked?.reason] || c['pending-goal-limit'];
5880
6545
  bar.classList.add('billing');
5881
6546
  bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Free limit</div>`
5882
- + `<div class="bx-q">${esc(text)} Upgrade to keep going?</div></div>`
5883
- + `<div class="bx-act"><button class="bx-cta" id="bxUpgrade">Upgrade${pl ? `<span class="bx-pm">${esc(pl)}</span>` : ''}</button></div></div>`;
6547
+ + `<div class="bx-q">${q}</div></div>`
6548
+ + `<div class="bx-act"><button class="bx-cta" id="bxUpgrade"><span class="bx-spin"></span>Upgrade${priceTag(state.billing?.price)}</button></div></div>`;
5884
6549
  bar.style.display = 'block';
5885
6550
  const btn = bar.querySelector('#bxUpgrade');
5886
- if (btn) btn.onclick = () => startCheckout();
6551
+ if (btn) btn.onclick = () => startCheckout(btn);
6552
+ // No auto-hide: the condition persists until the user acts, so a fixed timer
6553
+ // would just recreate the bug this fixes. clearTimeout only cancels any showErr
6554
+ // 6s timer that happened to be mid-flight on this same shared bar.
5887
6555
  clearTimeout(errTimer);
5888
- errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
6556
+ billingBlockedActive = true;
5889
6557
  }
5890
6558
 
5891
6559
  // Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
@@ -5937,27 +6605,45 @@ function showSignInNudge(){
5937
6605
  errTimer = setTimeout(() => { bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5938
6606
  }
5939
6607
 
5940
- // "$12/mo" from the Stripe-owned price (state.billing.price), never hardcoded.
5941
- // Returns '' when the price isn't known yet so the CTA just reads "Upgrade".
6608
+ // The Stripe-owned price (state.billing.price), never hardcoded. Split into the
6609
+ // currency mark + the figure so the ONE price format (§5.5 / .bx-pm) can render
6610
+ // the mark smaller and optically raised. No interval — /mo is dropped on the
6611
+ // button (the number is the message). Returns null when the price isn't known
6612
+ // yet so the CTA just reads "Upgrade".
5942
6613
  function fmtPrice(p){
5943
- if (!p || p.amount == null) return '';
6614
+ if (!p || p.amount == null) return null;
5944
6615
  const n = p.amount / 100;
5945
6616
  const amt = Number.isInteger(n) ? String(n) : n.toFixed(2);
5946
6617
  const cur = String(p.currency || 'usd').toLowerCase() === 'usd' ? '$' : (String(p.currency).toUpperCase() + ' ');
5947
- const iv = p.interval ? '/' + ({ month: 'mo', year: 'yr', week: 'wk', day: 'day' }[p.interval] || p.interval) : '';
5948
- return `${cur}${amt}${iv}`;
6618
+ return { cur, amt };
6619
+ }
6620
+ // Render a price into the single styled price span (small currency mark, no /mo).
6621
+ // '' when the price is unknown so callers can concatenate it unconditionally.
6622
+ function priceTag(p){
6623
+ const f = fmtPrice(p);
6624
+ return f ? `<span class="bx-pm"><span class="cur">${esc(f.cur)}</span>${esc(f.amt)}</span>` : '';
5949
6625
  }
5950
6626
 
5951
6627
  // Upgrade → Stripe Checkout (via the engine proxy, same-origin). On return the
5952
6628
  // browser lands back with ?checkout=success|cancel (handled at load).
5953
- async function startCheckout(){
6629
+ // btn (optional): the button that triggered this, so it can show a spinner
6630
+ // for the round trip to /api/checkout — jumping straight to Stripe's own page
6631
+ // with zero feedback reads as "did that even do anything?" (Masa 2026-07-17).
6632
+ // Left spinning through the r.ok/d.url success path on purpose: the page is
6633
+ // about to navigate away to Stripe, so there is nothing to reset back to.
6634
+ async function startCheckout(btn){
6635
+ if (btn) { btn.disabled = true; btn.classList.add('loading'); }
5954
6636
  try {
5955
6637
  const r = await fetch(withKey('/api/checkout'), { method: 'POST', headers: { 'content-type': 'application/json' },
5956
6638
  body: JSON.stringify({ successUrl: location.origin + location.pathname + '?checkout=success', cancelUrl: location.origin + location.pathname + '?checkout=cancel' }) });
5957
6639
  const d = await r.json().catch(() => ({}));
5958
- if (r.ok && d.url) location.href = d.url;
5959
- else showErr(d.error || 'Could not start checkout.');
5960
- } catch { showErr('Could not start checkout — check your connection.'); }
6640
+ if (r.ok && d.url) { location.href = d.url; return; }
6641
+ if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
6642
+ showErr(d.error || 'Could not start checkout.');
6643
+ } catch {
6644
+ if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
6645
+ showErr('Could not start checkout — check your connection.');
6646
+ }
5961
6647
  }
5962
6648
 
5963
6649
  // Manage billing → Stripe Customer Portal (paying users; engine proxy).
@@ -5970,27 +6656,100 @@ async function openBillingPortal(){
5970
6656
  } catch { showErr('Could not open billing — check your connection.'); }
5971
6657
  }
5972
6658
 
5973
- // after returning from Stripe Checkout (?checkout=success|cancel): a thin
5974
- // inline confirmation bar at the top of the reading column, then URL cleanup.
5975
- // On success, nudge the engine to re-check entitlement (the webhook can lag a
5976
- // beat) so the PLAN card flips to Paid without a re-sign-in.
6659
+ // The full-screen "you're Pro now" reward. Fires the instant the user returns
6660
+ // from a completed checkout landing on the success_url IS Stripe's confirmation
6661
+ // that the payment went through, so there's nothing to wait for before rewarding
6662
+ // (the entitlement flip only has to catch up before this fades, ~2.6s). Built in
6663
+ // JS (no static markup), auto-dismisses, click-to-dismiss, reduced-motion aware.
6664
+ // The reward = green disc + WHITE check (the joyful, high-signal success mark,
6665
+ // not a dark glyph) that pops, a soft green flash pulse behind it, and a bright
6666
+ // confetti burst. The palette is a harmonized set of brights (DESIGN-RULES §5.7
6667
+ // motion) — NOT the drab CONFETTI_COLORS (which leans on dark --ink pieces). Kept
6668
+ // stylish: the brand green anchors, confetti is curated brights, not an even
6669
+ // rainbow (DESIGN-RULES §5.5 動き — 報酬モーション). Tune the two constants below.
6670
+ const UPCEL_BRIGHT = ['#3DDC97', '#22C7A9', '#FFC93C', '#FF8A5B', '#FF5FA2', '#7C9CFF', '#B07CFF'];
6671
+ const UPCEL_CONFETTI = { count: 46, ribbon: 0.4, spread: 220 }; // ribbon = fraction rendered as fluttering strips
6672
+ let upcelShown = false;
6673
+ function celebrateUpgrade(){
6674
+ if (upcelShown) return; // once per return, even if handleCheckoutReturn re-runs
6675
+ upcelShown = true;
6676
+ const lang = billingLang();
6677
+ const title = lang === 'ja' ? 'プロになりました' : "You're Pro now";
6678
+ const sub = lang === 'ja' ? '無制限のタスクとプロジェクトが使えます。' : 'Unlimited tasks and projects are unlocked.';
6679
+ const ov = document.createElement('div');
6680
+ ov.className = 'upcel';
6681
+ ov.innerHTML = '<div class="upcelcard"><div class="upcelburst"><span class="upcelflash"></span>'
6682
+ + '<div class="upcelchk"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg></div></div>'
6683
+ + '<div class="upceltitle"></div><div class="upcelsub"></div></div>';
6684
+ ov.querySelector('.upceltitle').textContent = title; // textContent: copy is data, never markup
6685
+ ov.querySelector('.upcelsub').textContent = sub;
6686
+ document.body.appendChild(ov);
6687
+ requestAnimationFrame(() => ov.classList.add('show'));
6688
+ const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion:reduce)').matches;
6689
+ if (!reduce) setTimeout(() => upcelConfetti(UPCEL_CONFETTI), 170); // reduced motion: static check, no confetti
6690
+ let done = false;
6691
+ const close = () => { if (done) return; done = true; ov.classList.add('out'); setTimeout(() => ov.remove(), 360); };
6692
+ ov.addEventListener('click', close);
6693
+ setTimeout(close, 2600);
6694
+ }
6695
+ // Radial confetti burst from the check, bright palette. cfg.ribbon = fraction of
6696
+ // pieces rendered as long fluttering strips; cfg.spread = travel radius.
6697
+ // .confetti-piece carries z-index:9999, so it rides above the .upcel scrim.
6698
+ function upcelConfetti(cfg){
6699
+ const cx = window.innerWidth / 2, cy = window.innerHeight * 0.42;
6700
+ for (let i = 0; i < cfg.count; i++){
6701
+ const el = document.createElement('div');
6702
+ el.className = 'confetti-piece';
6703
+ const ribbon = Math.random() < cfg.ribbon;
6704
+ let w, h;
6705
+ if (ribbon) { w = 4 + Math.random() * 3; h = 13 + Math.random() * 10; }
6706
+ else { const s = 7 + Math.random() * 7; w = s; h = s; }
6707
+ el.style.width = `${w}px`; el.style.height = `${h}px`;
6708
+ el.style.background = UPCEL_BRIGHT[i % UPCEL_BRIGHT.length];
6709
+ el.style.borderRadius = ribbon ? '2px' : (Math.random() < 0.5 ? '50%' : '2px');
6710
+ el.style.left = `${cx}px`; el.style.top = `${cy}px`;
6711
+ el.style.opacity = '1';
6712
+ el.style.transform = 'translate(-50%,-50%) rotate(0deg)';
6713
+ el.style.transition = 'transform 1.1s cubic-bezier(.12,.72,.28,1), opacity 1.1s ease .4s';
6714
+ document.body.appendChild(el);
6715
+ const ang = Math.random() * Math.PI * 2;
6716
+ const dist = 70 + Math.random() * cfg.spread;
6717
+ const dx = Math.cos(ang) * dist, dy = Math.sin(ang) * dist * 0.62 + 115; // burst out, settle down
6718
+ const rot = (Math.random() < 0.5 ? -1 : 1) * (420 + Math.random() * 560);
6719
+ requestAnimationFrame(() => {
6720
+ el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${rot}deg)`;
6721
+ el.style.opacity = '0';
6722
+ });
6723
+ setTimeout(() => el.remove(), 1300);
6724
+ }
6725
+ }
6726
+ // After returning from Stripe Checkout (?checkout=success|cancel), then URL cleanup.
6727
+ // On SUCCESS: lift the paywall UI at once (the #errbar billing banner is imperative,
6728
+ // not state-driven — refresh() alone never clears it, which is why it used to linger
6729
+ // ~5s until the entitlement poll finished) and celebrate. Keep nudging the engine to
6730
+ // re-check entitlement so the PLAN card flips to Paid; the first tick is tightened to
6731
+ // 700ms so the confirmation usually lands while the celebration is still on screen.
6732
+ // On CANCEL: the thin inline bar, unchanged.
5977
6733
  function handleCheckoutReturn(){
5978
6734
  const c = new URLSearchParams(location.search).get('checkout');
5979
6735
  if (!c) return;
5980
- const center = document.querySelector('#fsRoot .fscenter') || document.body;
5981
- const bar = document.createElement('div');
5982
- bar.className = 'bx-cobar ' + (c === 'success' ? 'bx-ok' : 'bx-cxl');
5983
- bar.innerHTML = `<span class="bx-dot"></span><span class="bx-cotxt">${c === 'success' ? "<b>You're on Paid.</b> Free limits lifted." : "<b>Checkout canceled.</b> Nothing changed."}</span>`;
5984
- center.insertBefore(bar, center.firstChild);
5985
6736
  const url = new URL(location.href); url.searchParams.delete('checkout');
5986
6737
  history.replaceState({}, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams : ''));
5987
6738
  if (c === 'success') {
6739
+ clearBillingBlocked(); // drop the paywall banner immediately (it is not tied to state)
6740
+ celebrateUpgrade();
5988
6741
  let tries = 0;
5989
6742
  const tick = () => { fetch(withKey('/api/refresh-billing'), { method: 'POST' })
5990
- .then((r) => r.json()).then((d) => { if (!d?.isPaying && ++tries < 4) setTimeout(tick, 2500); else refresh(); })
6743
+ .then((r) => r.json()).then((d) => { if (!d?.isPaying && ++tries < 5) setTimeout(tick, 1500); else refresh(); })
5991
6744
  .catch(() => {}); };
5992
- setTimeout(tick, 1500);
6745
+ setTimeout(tick, 700);
6746
+ return;
5993
6747
  }
6748
+ const center = document.querySelector('#fsRoot .fscenter') || document.body;
6749
+ const bar = document.createElement('div');
6750
+ bar.className = 'bx-cobar bx-cxl';
6751
+ bar.innerHTML = '<span class="bx-dot"></span><span class="bx-cotxt"><b>Checkout canceled.</b> Nothing changed.</span>';
6752
+ center.insertBefore(bar, center.firstChild);
5994
6753
  setTimeout(() => bar.remove(), 12000);
5995
6754
  }
5996
6755
  window.addEventListener('load', handleCheckoutReturn);
@@ -6329,6 +7088,10 @@ async function postOutboxItem(item){
6329
7088
  return;
6330
7089
  }
6331
7090
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
7091
+ // A send just succeeded, which is the one signal that's actually true
7092
+ // evidence the free-tier block lifted (upgrade, or usage dropping below
7093
+ // the limit) — clear the persistent paywall banner if it was still up.
7094
+ clearBillingBlocked();
6332
7095
  const goal = await r.json();
6333
7096
  if (goal.kind === 'chat') {
6334
7097
  // Conversation-first (SLICE 2 + Pattern A H23): a chat/help/settings turn,
@@ -7299,6 +8062,10 @@ $('delCancelBtn').onclick = () => closeDeleteConfirm(false);
7299
8062
  $('delConfirmBtn').onclick = () => closeDeleteConfirm(true);
7300
8063
  $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(_delDismissValue); });
7301
8064
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(_delDismissValue); });
8065
+ // moment ③ dialog dismissal: "Not now" link, backdrop click, and Escape all close it.
8066
+ $('flimProjNo').onclick = () => closeProjectLimitDialog();
8067
+ $('flimProjOverlay').addEventListener('click', (e) => { if (e.target === $('flimProjOverlay')) closeProjectLimitDialog(); });
8068
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('flimProjOverlay').classList.contains('show')) closeProjectLimitDialog(); });
7302
8069
  $('input').addEventListener('keydown', (e) => {
7303
8070
  // Palette navigation takes priority while it is open (Claude-Code keys).
7304
8071
  if (state.palette && !e.isComposing) {
@@ -7361,10 +8128,18 @@ applyLayout();
7361
8128
  // default Gradient (Lavender). Pure token overrides + structural layers.
7362
8129
  function applyTheme(){
7363
8130
  document.body.dataset.theme = state.theme || 'gradient';
8131
+ document.body.dataset.boardskin = state.boardskin || 'auto'; // board surface skin (card default = the Artifact card) rides with the theme apply
7364
8132
  for (const b of document.querySelectorAll('#themesel .laybtn')) b.classList.toggle('on', b.dataset.theme === (state.theme || 'gradient'));
8133
+ for (const b of document.querySelectorAll('#boardskinsel .laybtn')) b.classList.toggle('on', b.dataset.boardskin === (state.boardskin || 'auto'));
7365
8134
  window.__glassSync?.(); // Glass is ARCHIVED (§6.5) — never selectable, so this guard keeps the WebGL loop dormant
7366
8135
  fsGearAlignX(); // fonts/metrics can shift with the theme — re-zero the gear/toggle delta (v45 §2)
7367
8136
  }
8137
+ // Board surface skin — card (the Artifact card, default) / quiet (like the To Do list) / frost. Switchable + persisted.
8138
+ const BOARDSKINS = ['auto', 'card', 'quiet', 'frost'];
8139
+ window.__boardskin = (v) => {
8140
+ const skin = typeof v === 'number' ? (BOARDSKINS[((v | 0) % 4 + 4) % 4]) : (BOARDSKINS.includes(v) ? v : 'auto');
8141
+ state.boardskin = skin; localStorage.setItem('boardskin', skin); applyTheme();
8142
+ };
7368
8143
  for (const b of document.querySelectorAll('#themesel .laybtn')) {
7369
8144
  b.onclick = () => { state.theme = b.dataset.theme; localStorage.setItem('theme', state.theme); applyTheme(); };
7370
8145
  }
@@ -7997,24 +8772,25 @@ function gearSetHTML(){
7997
8772
  // so the polished version can drop in without touching the wiring below.
7998
8773
  // ③ PLAN card: identity (Sign in with Google / Signed in as X) + plan status.
7999
8774
  // Free → usage vs limits + Upgrade; Paid → green dot + Manage (Stripe Portal).
8000
- const pl = fmtPrice(bill.price);
8001
8775
  const u = bill.usage || {}, lim = bill.limits || {};
8002
- let billingRow;
8003
- if (!bill.licensed) {
8004
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Sign in with Google to unlock the full plan — the free tier works without it</span></div>
8776
+ // Account row: signed in (email + Log out) vs anonymous (Sign in with Google).
8777
+ const acct = bill.licensed
8778
+ ? `<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>`
8779
+ : `<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
8780
  <button class="optchip" data-gsignin="1">Sign in with Google</button></div>
8006
8781
  <div class="srow dis" id="licKeyMsg" style="display:none"></div>`;
8007
- } else if (bill.plan === 'paid') {
8008
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
8009
- <div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
8010
- <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`;
8011
- } else {
8012
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
8013
- <div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
8782
+ // The free-tier usage + Upgrade shows on the free plan whether or not the user
8783
+ // has signed in it mirrors the To Do header counter (H22), which is visible
8784
+ // to anonymous free users too. Sign-in is an extra (sync across devices), not a
8785
+ // gate to see your own usage or upgrade.
8786
+ const freeUsage = `<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
8014
8787
  <div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
8015
- <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 19}</span></div>
8016
- <button class="bx-cta bx-up-set" data-gupgrade="1">Upgrade${pl ? `<span class="bx-pm">${esc(pl)}</span>` : ''}</button>`;
8017
- }
8788
+ <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 20}</span></div>
8789
+ <button class="bx-cta bx-up-set" data-gupgrade="1"><span class="bx-spin"></span>Upgrade${priceTag(bill.price)}</button>`;
8790
+ const billingRow = bill.plan === 'paid'
8791
+ ? acct + `<div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
8792
+ <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>`
8793
+ : acct + freeUsage;
8018
8794
  return `
8019
8795
  <div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
8020
8796
  <button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
@@ -8063,7 +8839,7 @@ function buildGearPop(){
8063
8839
  signBtn.disabled = false;
8064
8840
  };
8065
8841
  const upBtn = pop.querySelector('[data-gupgrade]');
8066
- if (upBtn) upBtn.onclick = () => startCheckout();
8842
+ if (upBtn) upBtn.onclick = () => startCheckout(upBtn);
8067
8843
  const portalBtn = pop.querySelector('[data-gportal]');
8068
8844
  if (portalBtn) portalBtn.onclick = () => openBillingPortal();
8069
8845
  const signOutBtn = pop.querySelector('[data-gsignout]');
@@ -8097,6 +8873,9 @@ function buildGearPop(){
8097
8873
  };
8098
8874
  const themeBtns = [['studio', 'Studio'], ['gradient', 'Gradient'], ['banff', 'Banff'], ['midnight', 'Midnight'], ['cinema', 'Cinema']].map(([v, l]) =>
8099
8875
  `<button class="thtile${(state.theme || 'gradient') === v ? ' on' : ''}" data-gtheme="${v}"><span class="tp" style="background:${THEME_TP[v]}"></span><span>${l}</span></button>`).join('');
8876
+ // Board surface — Quiet (like the To Do list, default) / Paper (card lifts) / Frost (glass over the wallpaper). Applies in every theme.
8877
+ const boardChips = [['auto', 'Auto'], ['card', 'Card'], ['quiet', 'Quiet'], ['frost', 'Frost']].map(([v, l]) =>
8878
+ `<button class="optchip${(state.boardskin || 'auto') === v ? ' on' : ''}" data-gboardskin="${v}">${l}</button>`).join('');
8100
8879
  const grad2 = (p) => `linear-gradient(135deg,${p.join(',')})`;
8101
8880
  const arcSw = ARC17.map((p, i) =>
8102
8881
  `<button class="sw${cust.arc === i ? ' on' : ''}" data-garc="${i}" title="${ARC_NAMES[i]}" style="background:${grad2(p)}"></button>`).join('');
@@ -8146,6 +8925,7 @@ function buildGearPop(){
8146
8925
  // FONT exploration section retired (v45 §1): the brand fonts are final — no chips.
8147
8926
  pop.innerHTML = gearTabsHTML() +
8148
8927
  `<h4>THEME</h4><div class="swrow">${themeBtns}</div>` +
8928
+ `<h4>BOARD</h4><div class="swrow">${boardChips}</div>` +
8149
8929
  sec(th === 'gradient', `<h4>COLOR</h4><div class="swrow">${arcSw}</div>` +
8150
8930
  `<h4>SURFACE</h4><div class="swrow">${surfChips}</div>` +
8151
8931
  `<h4>TEXTBOX</h4><div class="swrow">${tbChips}</div>`) +
@@ -8166,6 +8946,7 @@ function buildGearPop(){
8166
8946
  // EDIT SESSION (v45 §3): every handler below is LIVE PREVIEW ONLY — mutate cust/state,
8167
8947
  // apply to the DOM, re-render the pop. localStorage is written ONLY in custSave().
8168
8948
  for (const b of pop.querySelectorAll('[data-gtheme]')) b.onclick = () => { state.theme = b.dataset.gtheme; applyTheme(); applyCust(); buildGearPop(); };
8949
+ for (const b of pop.querySelectorAll('[data-gboardskin]')) b.onclick = () => { window.__boardskin(b.dataset.gboardskin); buildGearPop(); };
8169
8950
  for (const b of pop.querySelectorAll('[data-garc]')) b.onclick = () => { cust.arc = Number(b.dataset.garc); applyCust(); buildGearPop(); };
8170
8951
  for (const b of pop.querySelectorAll('[data-gapanel]')) b.onclick = () => { cust.arcPanel = Number(b.dataset.gapanel); applyCust(); buildGearPop(); };
8171
8952
  for (const b of pop.querySelectorAll('[data-garctb]')) b.onclick = () => { cust.arctb = Number(b.dataset.garctb); applyCust(); buildGearPop(); };