@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 +444 -78
- package/engine/lib.mjs +26 -1
- package/engine/server.mjs +59 -12
- package/package.json +1 -1
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(--
|
|
1452
|
-
#fsRoot .fstvb.on{background:var(--hover);color:var(--
|
|
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
|
|
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
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
.
|
|
1481
|
-
.
|
|
1482
|
-
.
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
|
1726
|
-
#fsRoot .trow{position:relative;padding:6px 6px 6px
|
|
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
|
|
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:
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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(
|
|
2166
|
-
padding:
|
|
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
|
-
|
|
2198
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
//
|
|
5396
|
-
//
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
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', '
|
|
5427
|
-
+ col('Review', '
|
|
5428
|
-
+ col('Done', 'var(--
|
|
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
|
-
//
|
|
6380
|
-
//
|
|
6381
|
-
//
|
|
6382
|
-
//
|
|
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 <
|
|
6743
|
+
.then((r) => r.json()).then((d) => { if (!d?.isPaying && ++tries < 5) setTimeout(tick, 1500); else refresh(); })
|
|
6397
6744
|
.catch(() => {}); };
|
|
6398
|
-
setTimeout(tick,
|
|
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,
|
|
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
|
-
|
|
213
|
-
|
|
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
|
|
217
|
-
if (
|
|
217
|
+
const entry = pendingSignins.get(nonce);
|
|
218
|
+
if (entry === undefined) return false;
|
|
218
219
|
pendingSignins.delete(nonce);
|
|
219
|
-
return
|
|
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
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
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
|
-
:
|
|
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