@galda/cli 0.10.30 → 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
@@ -765,6 +765,47 @@
765
765
  .revundo:hover{color:var(--ink);border-color:var(--ink3)}
766
766
  @keyframes rvpop{0%{transform:scale(0)}70%{transform:scale(1.18)}100%{transform:scale(1)}}
767
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} }
768
809
  .reqlist{display:flex;flex-direction:column;gap:12px}
769
810
  .reqcard{border:1px solid var(--hair2);border-radius:14px;padding:14px 16px;background:var(--paper);transition:border-color .15s,background-color .15s}
770
811
  .reqcard.approved{border-color:rgba(var(--green-rgb),.5);background:rgba(var(--green-rgb),.05)}
@@ -1424,6 +1465,9 @@
1424
1465
  #fsRoot .lanebody{display:flex;flex-direction:column;min-height:0;flex:1;overflow-y:auto;overflow-x:hidden;scrollbar-width:none}
1425
1466
  #fsRoot .lanebody::-webkit-scrollbar{display:none}
1426
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}
1427
1471
 
1428
1472
  /* ---- To Do: List/Board view toggle, in the To Do header — same spot both modes (Masa 2026-07-16) ---- */
1429
1473
  /* List/Board toggle: a FIXED overlay beside the fold icon, in BOTH modes — matching the
@@ -1448,8 +1492,8 @@
1448
1492
  Artifact deliberately fills a background for the selected state; a prior commit "fixed"
1449
1493
  this to match the fold buttons' no-fill rule, which was the wrong reference. */
1450
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 ("小さい") */
1451
- #fsRoot .fstvb:hover{background:var(--hover);color:var(--ink);opacity:1}
1452
- #fsRoot .fstvb.on{background:var(--hover);color:var(--ink);opacity:1}
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) */
1453
1497
  #fsRoot .fstvb:focus:not(:focus-visible){outline:none}
1454
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 */
1455
1499
  #fsRoot .fstvb svg rect{fill:none}
@@ -1459,6 +1503,13 @@
1459
1503
  5+ columns don't fit the lane width, so it scrolls horizontally in place instead of growing. ---- */
1460
1504
  #fsRoot .fslane.boardmode{flex-basis:var(--lanew-board,372px)}
1461
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}
1462
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).
1463
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);
1464
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
@@ -1466,24 +1517,97 @@
1466
1517
  a global typography change. */
1467
1518
  #fsRoot .fslane.boardmode #fsBoard{display:flex}
1468
1519
  #fsBoard::-webkit-scrollbar{height:8px}#fsBoard::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1469
- .fsbcol{flex:0 0 200px;max-height:100%;display:flex;flex-direction:column;min-height:0}
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) */
1470
1521
  .fsbcolhd{display:flex;align-items:center;gap:8px;padding:1px 4px 9px;flex:0 0 auto}
1471
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)}
1472
1523
  .fsbcolnm{font-size:12.5px;font-weight:600;color:var(--ink);letter-spacing:-.01em}
1473
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}
1474
1529
  .fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
1475
1530
  .fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1476
- .fsbrow{padding:7px 9px;border-radius:8px;background:color-mix(in srgb,var(--ink) 5%,transparent);box-shadow:0 0 0 1px var(--hair);font-size:12.4px;display:flex;align-items:center;gap:6px}
1477
- .fsbrow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px}
1478
- .fsbrow .st.run{border-color:var(--green);background:radial-gradient(circle,rgba(var(--green-rgb),.4),transparent 70%)}
1479
- .fsbrow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%)}
1480
- .fsbrow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%)}
1481
- .fsbrow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
1482
- .fsbrow .st.ok::after{content:"";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
1483
- .fsbrow .st.rev{border-color:var(--blue);background:radial-gradient(circle,color-mix(in srgb,var(--blue) 42%,transparent),transparent 70%)}
1484
- .fsbrow .tt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
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}
1485
1575
  .fsbempty{color:var(--ink3);font-size:11px;padding:4px 2px}
1486
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
+
1487
1611
  /* ---- chat ⇄ lane panel swap: grab anywhere in the top ~40px of either panel, drop lands
1488
1612
  EXACTLY on the side the dashed guide showed. Rail is not part of this (Masa 2026-07-16). ---- */
1489
1613
  #fsRoot .fscenter{order:1}
@@ -1722,8 +1846,8 @@
1722
1846
  #fsRoot .rcard .rnote{display:block;font:500 9.5px/1.4 var(--mono);color:var(--green);margin-top:3px}
1723
1847
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1724
1848
  #fsRoot .tl{position:relative}
1725
- #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}
1726
- #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) */
1727
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 */
1728
1852
  #fsRoot .trow:hover{background:var(--hover-s)}
1729
1853
  #fsRoot .trow.now{font-weight:500}
@@ -2112,6 +2236,15 @@
2112
2236
  #fsRoot .cc-who .cs-n{font-size:12px;font-weight:600;color:var(--ink)}
2113
2237
  #fsRoot .cc-who .cs-d{font-family:var(--mono);font-size:9.5px;color:var(--ink3);letter-spacing:.05em}
2114
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)}
2115
2248
  /* ============================================================================
2116
2249
  FLAGSHIP · MOBILE (Masa 2026-07-09): app shell. The phone view keeps the
2117
2250
  implementing column as the primary screen, fixes the composer to the bottom,
@@ -2120,8 +2253,8 @@
2120
2253
  @media (max-width: 640px){
2121
2254
  html,body{height:100dvh}
2122
2255
  body[data-layout="flagship"]{overflow:hidden}
2123
- body[data-layout="flagship"] #fsRoot{height:100dvh;flex-direction:column;padding:8px;gap:8px;
2124
- 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) */
2125
2258
  body[data-layout="flagship"].devbar #fsRoot{padding-top:8px}
2126
2259
  body[data-layout="flagship"] #fsRoot .fsrail,
2127
2260
  body[data-layout="flagship"] #fsRoot.railfold .fsrail{
@@ -2153,19 +2286,44 @@
2153
2286
  body[data-layout="flagship"].projectsopen #fsRoot #fsProjects{display:flex}
2154
2287
  body[data-layout="flagship"] #fsRoot .fsproj,
2155
2288
  body[data-layout="flagship"] #fsRoot .fsprojadd{width:100%;flex:0 0 auto;white-space:nowrap;min-height:40px}
2156
- 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 */
2157
2290
  body[data-layout="flagship"] #fsRoot .chd{margin-bottom:10px}
2158
- 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) */
2159
2292
  body[data-layout="flagship"] #fsCompwrap{padding-bottom:0}
2160
- body[data-layout="flagship"] #composerWrap{position:fixed;left:8px;right:8px;bottom:0;z-index:70;
2161
- padding:0 0 max(8px, env(safe-area-inset-bottom));max-width:none;width:auto;margin:0}
2162
- 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}
2163
2315
  body[data-layout="flagship"] #fsRoot .fslane,
2164
2316
  body[data-layout="flagship"] #fsRoot.lanefold .fslane{
2165
- position:fixed;top:0;right:0;height:100dvh;width:min(88vw,360px);min-width:0;display:flex;
2166
- padding:18px 14px max(14px, env(safe-area-inset-bottom));z-index:85;transform:translateX(102%);
2167
- 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 */
2168
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}
2169
2327
  body[data-layout="flagship"] #fsRoot.lanefold .fslane>*:not(.lanetg){display:flex}
2170
2328
  body[data-layout="flagship"] #fsRoot .fsdim{display:block;position:fixed;inset:0;background:rgba(9,12,18,.32);
2171
2329
  opacity:0;pointer-events:none;transition:opacity .22s;z-index:80}
@@ -2194,8 +2352,49 @@
2194
2352
  /* ---- settings/customize popover — anchored via JS inline left (rail.right+8px),
2195
2353
  which breaks once the rail is a full-width top bar; !important is the only way
2196
2354
  to beat an inline style without touching the JS ---- */
2197
- body[data-layout="flagship"] .gearpop{left:10px!important;right:10px!important;top:64px!important;
2198
- 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}
2199
2398
  }
2200
2399
  /* Billing UI (H16/H22) — self-serve plan. Tokens only; red is a line/label,
2201
2400
  never a fill (DESIGN-RULES §5.6), and it is --danger (the app's real token —
@@ -2402,7 +2601,7 @@
2402
2601
  <button class="ghostbtn" id="fsSet">Settings</button>
2403
2602
  </div>
2404
2603
  </div>
2405
- <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>
2406
2605
  <div id="fsProjects"></div>
2407
2606
  </div>
2408
2607
  <!-- .railgrip lives here (sibling of .fsrail, not a child — see the CSS comment above)
@@ -2435,6 +2634,8 @@
2435
2634
  <div class="nowdoing" id="fsNowDoing" hidden></div>
2436
2635
  <!-- Clawd HERO (§4.5 初期状態): filled by JS (CLAWD generator), shown via #fsRoot.clawdhero -->
2437
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>
2438
2639
  <div class="fsstream"><div id="fsFeed"></div></div>
2439
2640
  <div class="compwrap" id="fsCompwrap">
2440
2641
  <!-- q3 chat-bottom askbar: DOM path for the "flowed away" dynamic rule (§6.5) — hidden;
@@ -2451,7 +2652,10 @@
2451
2652
  <button class="fstvb" data-fstv="list" title="List"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg></button>
2452
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>
2453
2654
  </span>
2655
+ <div class="fswait" id="fsWait"><!-- waiting badge: "N Needs you / N Review" beside the toggle, board mode only (renderFsWait) --></div>
2454
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>
2455
2659
  <div class="lanebody">
2456
2660
  <div id="fsRevSec"></div>
2457
2661
  <div id="fsTodoSec"></div>
@@ -2673,7 +2877,7 @@ const savedTheme = (() => {
2673
2877
  if (v !== s) localStorage.setItem('theme', v);
2674
2878
  return v;
2675
2879
  })();
2676
- 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,
2677
2881
  // per-message send overrides set via slash-commands (reset after each send,
2678
2882
  // except model which is sticky in localStorage). palette = open command list.
2679
2883
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -4021,13 +4225,11 @@ function positionLayout(){
4021
4225
  // into the center card. Moving the node keeps every handler and the
4022
4226
  // input-focus fix (c32fda1): appendChild is guarded so the focused #input
4023
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 下地).
4024
4231
  const host = $('fsCompwrap'), cw = $('composerWrap');
4025
4232
  if (host && cw && cw.parentNode !== host) host.appendChild(cw);
4026
- // #errbar (showErr/showBillingBlocked/showSignInNudge) lives inside .main,
4027
- // which flagship hides outright (display:none) — every transient message
4028
- // silently never rendered. Adopt it beside the composer, same technique as
4029
- // #composerWrap above; a flagship CSS rule floats it just above the input
4030
- // (CDO判定 2026-07-16 — same fix as #gearPop's body[data-layout] escape).
4031
4233
  const eb = $('errbar');
4032
4234
  if (host && eb && eb.parentNode !== host) host.appendChild(eb);
4033
4235
  // tasklist / actpanel go back home (they live hidden inside .main here)
@@ -5359,11 +5561,12 @@ function fsBoardBucket(goal){
5359
5561
  const s = goal?.status;
5360
5562
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5361
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)
5362
5565
  if (s === 'review') return 'review';
5363
5566
  if (s === 'done' || s === 'reverted') return 'done'; // both terminal; REVERTED is not "in flight"
5364
5567
  if (s === 'stacked' || s === 'sending') return 'todo';
5365
5568
  if (s === 'running' && !goal.startedAt) return 'todo'; // goalChip: planned, nothing started yet
5366
- return 'doing'; // planning/running/retesting/blocked/failedstill in flight
5569
+ return 'doing'; // planning / running / retesting — actively in flight only
5367
5570
  }
5368
5571
  // Kanban board — GOAL-level: one card per goal, never per task.
5369
5572
  //
@@ -5381,37 +5584,56 @@ function fsBoardBucket(goal){
5381
5584
  // above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
5382
5585
  function renderFsBoard(){
5383
5586
  const el = $('fsBoard'); if (!el) return;
5384
- const { pgoals, list, running } = fsBucketTodo();
5587
+ const { pgoals, list } = fsBucketTodo();
5385
5588
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
5386
5589
  const goalNo = new Map();
5387
5590
  for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
5388
- const noSpan = (goalId) => { const n = goalId != null ? goalNo.get(goalId) : null; return n != null ? `<span class="gno" data-n="${n}">#${n}</span>` : ''; };
5389
- const card = (stCls, goalId, title, extra) =>
5390
- `<div class="fsbrow"><span class="st ${stCls}"></span>${noSpan(goalId)}<span class="tt">${tt(title)}</span>${extra ?? ''}</div>`;
5391
- const col = (name, stg, count, html) =>
5392
- `<section class="fsbcol" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div></section>`;
5393
- // The status dot keeps the meaning it had on task cards, read off the goal instead.
5394
- // Everything in Doing that isn't healthily in flight reads as an error, the way
5395
- // goalChip() calls them all FAIL 'partial' (ran, but some tasks failed) included,
5396
- // or a half-failed goal would sit in Doing wearing a healthy green dot.
5397
- const dotFor = (g) => g.status === 'needsInput' ? 'run int'
5398
- : g.status === 'review' ? 'rev'
5399
- : (g.status === 'done' || g.status === 'reverted') ? 'ok'
5400
- : fsBoardBucket(g) !== 'doing' ? ''
5401
- : ['stacked', 'sending', 'planning', 'running', 'retesting'].includes(g.status) ? 'run'
5402
- : 'run bad'; // blocked / failed / partial / interrupted
5403
- // % is the RUNNING TASK's progress the same number the old task cards showed, now
5404
- // carried by the goal it belongs to. What a card should really say about a goal split
5405
- // into several tasks (n/m done? the step's name? proof?) is a design question, and it
5406
- // is deliberately not invented here.
5407
- const pctFor = (g) => {
5408
- const t = running.find((r) => r.goalId === g.id);
5409
- return t ? `<span class="pct num">${progressFor(t)}%</span>` : '';
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>`;
5410
5636
  };
5411
- // The label is the goal's OWN request text, not plan[0] — showing the plan here just
5412
- // repeats the first task's title, which renderTasks() already learned the hard way
5413
- // (Masa: "このグレー文字ってなに?").
5414
- const goalCard = (g) => card(dotFor(g), g.id, g.text || g.plan?.[0], pctFor(g));
5415
5637
  const no = (g) => goalNo.get(g.id) ?? Infinity;
5416
5638
  const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
5417
5639
  for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
@@ -5421,12 +5643,59 @@ function renderFsBoard(){
5421
5643
  // distinct from "blocked" (an error needing retry/archive, which stays in Doing, the
5422
5644
  // same grouping the List view already uses). Later (backlog) sits LAST, not before Done.
5423
5645
  el.innerHTML =
5424
- col('To Do', 'var(--ink3)', bucket.todo.length, bucket.todo.map(goalCard).join(''))
5425
- + col('Doing', 'var(--green)', bucket.doing.length, bucket.doing.map(goalCard).join(''))
5426
- + (bucket.needs.length ? col('Needs you', 'var(--blue)', bucket.needs.length, bucket.needs.map(goalCard).join('')) : '')
5427
- + col('Review', 'var(--blue)', bucket.review.length, bucket.review.map(goalCard).join(''))
5428
- + col('Done', 'var(--green)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''))
5429
- + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''));
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();
5430
5699
  }
5431
5700
  // Review (top of the lane, §1.5-4). Counts are DETERMINISTIC (§1.5-9): computed
5432
5701
  // from state via buildReviewDigest — no LLM, no donut, no badges.
@@ -5509,6 +5778,8 @@ function renderFsReview(){
5509
5778
  </span></div>
5510
5779
  <div class="revlist">${shown.map(card).join('')}${foldedGoals.map(foldCard).join('')}
5511
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) : '';
5512
5783
  // See all = the Ledger dialog (HANDOFF-v45 §5, replacing the ペライチ);
5513
5784
  // Cmd/Ctrl+click = standalone full-page view in a new tab (#seeall route).
5514
5785
  // guarded: the meter (and with it See all) is absent while the section holds only
@@ -5649,11 +5920,14 @@ function renderFsTodo(){
5649
5920
  ${upRows ? `<span class="tlcap"${doingRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
5650
5921
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
5651
5922
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
5923
+ <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
5652
5924
  </div>
5653
5925
  ${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
5654
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>` : ''}`;
5655
5927
  el.classList.toggle('folded', !!fsUI.todoFold);
5656
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();
5657
5931
  const dl = el.querySelector('#fsDoneLine');
5658
5932
  if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
5659
5933
  const more = el.querySelector('#fsTodoMore');
@@ -6001,6 +6275,12 @@ function renderClawdHero(){
6001
6275
  document.body.classList.toggle('laneopen');
6002
6276
  };
6003
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
+ }; });
6004
6284
  $('fsProjBtn').onclick = (e) => {
6005
6285
  e.stopPropagation();
6006
6286
  document.body.classList.remove('laneopen');
@@ -6376,27 +6656,100 @@ async function openBillingPortal(){
6376
6656
  } catch { showErr('Could not open billing — check your connection.'); }
6377
6657
  }
6378
6658
 
6379
- // after returning from Stripe Checkout (?checkout=success|cancel): a thin
6380
- // inline confirmation bar at the top of the reading column, then URL cleanup.
6381
- // On success, nudge the engine to re-check entitlement (the webhook can lag a
6382
- // 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.
6383
6733
  function handleCheckoutReturn(){
6384
6734
  const c = new URLSearchParams(location.search).get('checkout');
6385
6735
  if (!c) return;
6386
- const center = document.querySelector('#fsRoot .fscenter') || document.body;
6387
- const bar = document.createElement('div');
6388
- bar.className = 'bx-cobar ' + (c === 'success' ? 'bx-ok' : 'bx-cxl');
6389
- 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>`;
6390
- center.insertBefore(bar, center.firstChild);
6391
6736
  const url = new URL(location.href); url.searchParams.delete('checkout');
6392
6737
  history.replaceState({}, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams : ''));
6393
6738
  if (c === 'success') {
6739
+ clearBillingBlocked(); // drop the paywall banner immediately (it is not tied to state)
6740
+ celebrateUpgrade();
6394
6741
  let tries = 0;
6395
6742
  const tick = () => { fetch(withKey('/api/refresh-billing'), { method: 'POST' })
6396
- .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(); })
6397
6744
  .catch(() => {}); };
6398
- setTimeout(tick, 1500);
6745
+ setTimeout(tick, 700);
6746
+ return;
6399
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);
6400
6753
  setTimeout(() => bar.remove(), 12000);
6401
6754
  }
6402
6755
  window.addEventListener('load', handleCheckoutReturn);
@@ -7775,10 +8128,18 @@ applyLayout();
7775
8128
  // default Gradient (Lavender). Pure token overrides + structural layers.
7776
8129
  function applyTheme(){
7777
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
7778
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'));
7779
8134
  window.__glassSync?.(); // Glass is ARCHIVED (§6.5) — never selectable, so this guard keeps the WebGL loop dormant
7780
8135
  fsGearAlignX(); // fonts/metrics can shift with the theme — re-zero the gear/toggle delta (v45 §2)
7781
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
+ };
7782
8143
  for (const b of document.querySelectorAll('#themesel .laybtn')) {
7783
8144
  b.onclick = () => { state.theme = b.dataset.theme; localStorage.setItem('theme', state.theme); applyTheme(); };
7784
8145
  }
@@ -8512,6 +8873,9 @@ function buildGearPop(){
8512
8873
  };
8513
8874
  const themeBtns = [['studio', 'Studio'], ['gradient', 'Gradient'], ['banff', 'Banff'], ['midnight', 'Midnight'], ['cinema', 'Cinema']].map(([v, l]) =>
8514
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('');
8515
8879
  const grad2 = (p) => `linear-gradient(135deg,${p.join(',')})`;
8516
8880
  const arcSw = ARC17.map((p, i) =>
8517
8881
  `<button class="sw${cust.arc === i ? ' on' : ''}" data-garc="${i}" title="${ARC_NAMES[i]}" style="background:${grad2(p)}"></button>`).join('');
@@ -8561,6 +8925,7 @@ function buildGearPop(){
8561
8925
  // FONT exploration section retired (v45 §1): the brand fonts are final — no chips.
8562
8926
  pop.innerHTML = gearTabsHTML() +
8563
8927
  `<h4>THEME</h4><div class="swrow">${themeBtns}</div>` +
8928
+ `<h4>BOARD</h4><div class="swrow">${boardChips}</div>` +
8564
8929
  sec(th === 'gradient', `<h4>COLOR</h4><div class="swrow">${arcSw}</div>` +
8565
8930
  `<h4>SURFACE</h4><div class="swrow">${surfChips}</div>` +
8566
8931
  `<h4>TEXTBOX</h4><div class="swrow">${tbChips}</div>`) +
@@ -8581,6 +8946,7 @@ function buildGearPop(){
8581
8946
  // EDIT SESSION (v45 §3): every handler below is LIVE PREVIEW ONLY — mutate cust/state,
8582
8947
  // apply to the DOM, re-render the pop. localStorage is written ONLY in custSave().
8583
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(); };
8584
8950
  for (const b of pop.querySelectorAll('[data-garc]')) b.onclick = () => { cust.arc = Number(b.dataset.garc); applyCust(); buildGearPop(); };
8585
8951
  for (const b of pop.querySelectorAll('[data-gapanel]')) b.onclick = () => { cust.arcPanel = Number(b.dataset.gapanel); applyCust(); buildGearPop(); };
8586
8952
  for (const b of pop.querySelectorAll('[data-garctb]')) b.onclick = () => { cust.arctb = Number(b.dataset.garctb); applyCust(); buildGearPop(); };
package/engine/lib.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { resolve, join, basename } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
- import { createPublicKey, verify as verifyRsaSignature } from 'node:crypto';
6
+ import { createPublicKey, verify as verifyRsaSignature, createHash } from 'node:crypto';
7
7
 
8
8
  // Worker agents Galda can drive. Galda is NOT tied to Claude Code — it runs on
9
9
  // whichever agent CLI you have. Neither is auto-installed and codex is never even
@@ -3334,3 +3334,28 @@ export function classifyRelayClientConflict({ existingLock, alive, newIdentity }
3334
3334
  export function shouldEscalateConnectGateHint(waitedMs, email, thresholdMs = 15000) {
3335
3335
  return Number(waitedMs) >= thresholdMs && Boolean(String(email ?? '').trim());
3336
3336
  }
3337
+
3338
+ // --- device-flow (PKCE) sign-in helpers --------------------------------------
3339
+ // The browser no longer delivers the minted token to a live loopback port —
3340
+ // the engine POLLS the Worker to claim it (device-flow). PKCE ties the claim to
3341
+ // the engine that started the flow: the engine keeps a secret `verifier`, sends
3342
+ // only its SHA-256 `challenge` through the browser, and the Worker hands the
3343
+ // token back only to a claimant that can present the matching verifier. These
3344
+ // are pure so the crypto + response-shape parsing are unit-tested with no server.
3345
+ export function makeSigninChallenge(verifier) {
3346
+ return createHash('sha256').update(String(verifier ?? '')).digest('base64url');
3347
+ }
3348
+ export function verifierMatchesChallenge(verifier, challenge) {
3349
+ if (!verifier || !challenge) return false;
3350
+ return makeSigninChallenge(verifier) === String(challenge);
3351
+ }
3352
+ // Normalize the Worker's /connect/claim JSON into a single shape the poller can
3353
+ // switch on. Worker returns {pending:true} before the browser lands, {error} on
3354
+ // failure, {token,email} once minted. Anything malformed reads as an error so a
3355
+ // bad response never masquerades as "still pending" forever.
3356
+ export function parseClaimResponse(obj) {
3357
+ if (!obj || typeof obj !== 'object') return { state: 'error', error: 'malformed claim response' };
3358
+ if (obj.error) return { state: 'error', error: String(obj.error) };
3359
+ if (obj.token) return { state: 'ready', token: String(obj.token), email: obj.email != null ? String(obj.email) : undefined };
3360
+ return { state: 'pending' };
3361
+ }
package/engine/server.mjs CHANGED
@@ -20,7 +20,7 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
- import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } from './lib.mjs';
23
+ import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
24
  import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
@@ -204,19 +204,65 @@ let _freeExhaustedEmitted = false;
204
204
  // the nonce, round-trips it through the flow, and requires it back — so a random
205
205
  // page can't drive the local /oauth/callback with a token the user never asked for.
206
206
  const SIGNIN_NONCE_TTL_MS = 10 * 60 * 1000;
207
- const pendingSignins = new Map(); // nonce -> expiresAt
207
+ const pendingSignins = new Map(); // nonce -> { expiresAt, verifier }
208
208
  function newSigninNonce() {
209
209
  const now = Date.now();
210
- for (const [k, exp] of pendingSignins) if (exp < now) pendingSignins.delete(k); // opportunistic sweep
210
+ for (const [k, v] of pendingSignins) if (v.expiresAt < now) pendingSignins.delete(k); // opportunistic sweep
211
211
  const nonce = randomBytes(18).toString('base64url');
212
- pendingSignins.set(nonce, now + SIGNIN_NONCE_TTL_MS);
213
- return nonce;
212
+ const verifier = randomBytes(32).toString('base64url'); // PKCE secret — never leaves the engine
213
+ pendingSignins.set(nonce, { expiresAt: now + SIGNIN_NONCE_TTL_MS, verifier });
214
+ return { nonce, verifier };
214
215
  }
215
216
  function consumeSigninNonce(nonce) {
216
- const exp = pendingSignins.get(nonce);
217
- if (exp === undefined) return false;
217
+ const entry = pendingSignins.get(nonce);
218
+ if (entry === undefined) return false;
218
219
  pendingSignins.delete(nonce);
219
- return exp >= Date.now();
220
+ return entry.expiresAt >= Date.now();
221
+ }
222
+
223
+ // Device-flow (PKCE) sign-in. The old loopback flow delivered the minted token
224
+ // to http://127.0.0.1:<port>/oauth/callback — which dead-ends with
225
+ // ERR_CONNECTION_REFUSED the moment that ephemeral port is gone (engine
226
+ // restarted, MANAGER_PORT=0 instance exited). Instead the engine POLLS the
227
+ // Worker to claim the token and the browser lands on a HOSTED success page, so
228
+ // delivery never depends on the browser reaching a live local port.
229
+ const activeSigninPolls = new Set(); // nonces with a live poller (one per nonce)
230
+ function buildSigninUrl() {
231
+ const { nonce, verifier } = newSigninNonce();
232
+ const challenge = makeSigninChallenge(verifier);
233
+ startSigninPoll(nonce, verifier);
234
+ return `${BILLING_API_URL}/connect?state=${encodeURIComponent(nonce)}&challenge=${encodeURIComponent(challenge)}`;
235
+ }
236
+ // Best-effort background loop: GET /connect/claim every 2s until the token is
237
+ // minted (ready), an error is stored, or the nonce's 10-min TTL lapses. On
238
+ // success it activates the license and nudges the app over SSE (the same
239
+ // 'signed-in'/'signin-error' events the popup postMessage used to carry). Never
240
+ // throws — an unreachable Worker just means we try again next tick.
241
+ async function startSigninPoll(nonce, verifier) {
242
+ if (!BILLING_API_URL || activeSigninPolls.has(nonce)) return;
243
+ activeSigninPolls.add(nonce);
244
+ const deadline = Date.now() + SIGNIN_NONCE_TTL_MS;
245
+ const stop = () => { activeSigninPolls.delete(nonce); pendingSignins.delete(nonce); };
246
+ try {
247
+ while (Date.now() < deadline) {
248
+ await new Promise((r) => { const t = setTimeout(r, 2000); t.unref?.(); });
249
+ if (!activeSigninPolls.has(nonce)) return; // stopped elsewhere
250
+ try {
251
+ const url = `${BILLING_API_URL}/connect/claim?state=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(verifier)}`;
252
+ const res = await fetch(url);
253
+ if (!res.ok) continue; // 403 mismatch / transient — keep trying until TTL
254
+ const parsed = parseClaimResponse(await res.json().catch(() => null));
255
+ if (parsed.state === 'ready') {
256
+ const result = await activateLicense(parsed.token);
257
+ if (result.ok) send({ ev: 'signed-in', email: result.email });
258
+ else send({ ev: 'signin-error', error: result.error });
259
+ return;
260
+ }
261
+ if (parsed.state === 'error') { send({ ev: 'signin-error', error: parsed.error }); return; }
262
+ // pending → loop again
263
+ } catch { /* best-effort — retry next tick */ }
264
+ }
265
+ } finally { stop(); }
220
266
  }
221
267
 
222
268
  async function loadLicenseFromDisk() {
@@ -2981,9 +3027,10 @@ const server = createServer(async (req, res) => {
2981
3027
  // browser; Google bounces back to /oauth/callback (below) with a token.
2982
3028
  if (url.pathname === '/api/signin-url' && req.method === 'GET') {
2983
3029
  if (!BILLING_API_URL) return json(res, 501, { error: 'sign-in not configured (MANAGER_BILLING_API_URL unset)' });
2984
- const nonce = newSigninNonce();
2985
- const signinUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(nonce)}`;
2986
- return json(res, 200, { url: signinUrl });
3030
+ // Device-flow: no loopback port. buildSigninUrl mints {nonce,verifier},
3031
+ // returns the /connect URL carrying only the PKCE challenge, and kicks off
3032
+ // the background poll that claims the token from the Worker.
3033
+ return json(res, 200, { url: buildSigninUrl() });
2987
3034
  }
2988
3035
 
2989
3036
  // GET /oauth/callback?token&email&state | ?error&state — loopback landing for
@@ -4076,7 +4123,7 @@ server.listen(PORT, '127.0.0.1', () => {
4076
4123
  const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
4077
4124
  openUrl = signedIn
4078
4125
  ? APP_URL
4079
- : `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
4126
+ : buildSigninUrl(); // device-flow: also starts the background claim poll
4080
4127
  console.log(signedIn
4081
4128
  ? `[manager] opening your app → ${APP_URL}`
4082
4129
  : `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.30",
3
+ "version": "0.10.32",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {