@gamaze/hicortex 0.22.3 → 0.23.1

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.
@@ -345,9 +345,8 @@
345
345
  }
346
346
  .acct-btn:hover{ border-color:var(--green-mid); color:var(--green-mid); }
347
347
  .acct-btn:disabled{ opacity:0.5; cursor:default; }
348
- .acct-menu details{ border-top:1px solid var(--line); margin-top:10px; padding-top:8px; }
349
- .acct-menu summary{ cursor:pointer; color:var(--dim); font-size:13px; }
350
- .acct-menu summary:hover{ color:var(--green-mid); }
348
+ /* the acct-* snippet classes are shared by the account menu's token row
349
+ and the connect overlay's snippet blocks (#506 — one idiom, two hosts) */
351
350
  .acct-snippet{ margin-bottom:8px; }
352
351
  .acct-snippet pre{
353
352
  background:var(--panel); border:1px solid var(--line2); border-radius:7px;
@@ -363,12 +362,40 @@
363
362
  }
364
363
  .acct-link:hover{ background:var(--panel); }
365
364
  .acct-link.acct-danger:hover{ color:var(--red); }
366
- /* the upgrade CTA as a MENU ITEM (#409 fix round 2, owner ruling
367
- 2026-09-14: the corner is the account chip; upgrade lives INSIDE the
368
- menu — no standalone header button). Accent row in the #365 link style. */
369
- .acct-link.acct-upgrade{ color:var(--green-mid); font-weight:640; }
370
- .acct-link.acct-upgrade:hover{ background:color-mix(in srgb, var(--green) 8%, transparent); }
371
- .acct-link.acct-upgrade:disabled{ opacity:.5; cursor:default; }
365
+ /* the Subscription entry's upgrade pill (#371 round 3, owner: the menu's
366
+ two billing entries merge into ONE "Subscription" item — "it can have
367
+ an upgrade pill/label after it before any subscription is active").
368
+ The ENTRY always renders; the PILL is what the plan-unset eligibility
369
+ gates. Accent badge in the trigger's chip idiom. */
370
+ .acct-link .acct-pill{
371
+ margin-left:8px; font-size:9px; font-weight:640; letter-spacing:.08em; text-transform:uppercase;
372
+ color:var(--green-mid); border:1px solid color-mix(in srgb, var(--green) 35%, transparent);
373
+ background:color-mix(in srgb, var(--green) 8%, transparent);
374
+ border-radius:999px; padding:1px 7px; vertical-align:1px;
375
+ }
376
+
377
+ /* ============ billing-return banner (#371) — the one-line acknowledgment
378
+ for ?billing=success|cancelled (the URLs the router stamps on every
379
+ checkout). A page-level element under the header, deliberately NOT a
380
+ toast: the returning buyer must still find it when they look back at
381
+ the page. Shown once — the param is stripped on show, so neither
382
+ dismissal nor a plain reload can re-show it. Theme tokens only. */
383
+ #billing-banner{
384
+ display:flex; align-items:center; justify-content:center; gap:10px;
385
+ margin:8px 16px 0; padding:7px 12px; border-radius:10px;
386
+ border:1px solid var(--line2); background:var(--panel);
387
+ font-size:12px; color:var(--dim); flex:none;
388
+ }
389
+ /* author display beats the UA's [hidden]{display:none} — without the
390
+ override the "hidden" banner rendered an EMPTY strip under the header
391
+ on every plain load (found live in preview). The #model-modal idiom. */
392
+ #billing-banner[hidden]{ display:none; }
393
+ #billing-banner[data-kind="success"]{
394
+ color:var(--green-mid);
395
+ border-color:color-mix(in srgb, var(--green) 30%, var(--line2));
396
+ }
397
+ #billing-banner button{ color:var(--faint); font-size:11px; padding:2px 4px; }
398
+ #billing-banner button:hover{ color:var(--text); }
372
399
 
373
400
  /* ============ the stats band — a WRAPPING CARD GRID ============
374
401
  Cards come in TWO widths only — the row is a positional grid
@@ -503,8 +530,9 @@
503
530
 
504
531
  .cap-of{ font-size:10.5px; color:var(--faint); font-variant-numeric:tabular-nums; }
505
532
  .bseg.warn .csub{ color:var(--amber); }
506
- /* Agents card: a faded amber Connect pill, bottom-right — opens the rail's
507
- connect drawer (the phase-1 stand-in for the seats CTA: no cap exists) */
533
+ /* Agents card: a faded amber Connect pill, bottom-right — opens the
534
+ full-cover connect overlay (#506; the phase-1 rail drawer stand-in for
535
+ the seats CTA is retired: no cap exists) */
508
536
  #lim-ag .limit-cta{
509
537
  display:inline-block; align-self:flex-end;
510
538
  color:var(--amber); border-color:color-mix(in srgb, var(--amber) 45%, transparent);
@@ -766,24 +794,6 @@
766
794
  .ctx-detail .ctx-head{ display:flex; align-items:center; gap:8px; margin-bottom:4px; min-width:0; }
767
795
  .ctx-detail .ctx-head b{ font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
768
796
  .ctx-detail .ctx-head small{ color:var(--faint); font-family:var(--mono); font-size:9px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
769
- #connect{ flex:none; border-top:1px solid var(--line); padding:8px; }
770
- #connect-btn{
771
- display:flex; align-items:center; gap:7px; width:100%; justify-content:center;
772
- color:var(--green-mid); border:1px dashed color-mix(in srgb, var(--green) 35%, transparent); border-radius:10px;
773
- padding:7px 10px; font-size:11.5px; transition:box-shadow .15s, background .15s;
774
- }
775
- html:not([data-theme="light"]) #connect-btn:hover{ box-shadow:0 0 12px rgba(61,255,122,.18); }
776
- html[data-theme="light"] #connect-btn:hover{ background:color-mix(in srgb, var(--green) 5%, transparent); }
777
- #connect-btn .plus{ font-size:14px; line-height:1; }
778
- #connect-x{ display:none; margin-top:8px; background:var(--elev); border:1px solid var(--line2); border-radius:10px; padding:10px 11px; }
779
- #connect-x.open{ display:block; }
780
- #connect-x p{ font-size:10.5px; color:var(--dim); line-height:1.5; margin-bottom:7px; }
781
- #connect-x .snip{
782
- font-family:var(--mono); font-size:10px; color:var(--green-mid); background:var(--panel);
783
- border:1px solid var(--line2); border-radius:7px; padding:7px 9px;
784
- white-space:nowrap; overflow-x:auto; margin-bottom:7px;
785
- }
786
- #connect-x .row{ display:flex; justify-content:flex-end; gap:6px; }
787
797
 
788
798
  /* ---- shared kv rows (band detail groups + memory detail internals) ---- */
789
799
  .kv{ display:flex; justify-content:space-between; gap:12px; font-size:11.5px; padding:2.5px 0; min-width:0; }
@@ -975,9 +985,9 @@
975
985
  /* toasts + tooltip */
976
986
  #toasts{ position:fixed; left:50%; bottom:20px; transform:translateX(-50%); z-index:95; display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; }
977
987
 
978
- /* model-settings modal (#422) — the #panel/#connect-x visual language as a
979
- centered overlay; every color rides the theme vars (zero theme-specific
980
- rules, both themes work) */
988
+ /* model-settings modal (#422) — the #panel visual language as a centered
989
+ overlay; every color rides the theme vars (zero theme-specific rules,
990
+ both themes work) */
981
991
  #model-modal{ position:fixed; inset:0; z-index:90; display:grid; place-items:center; }
982
992
  #model-modal[hidden]{ display:none; }
983
993
  #model-modal .mm-backdrop{ position:absolute; inset:0; background:rgba(0,0,0,.55); }
@@ -994,6 +1004,61 @@
994
1004
  .mm-check small{ color:var(--faint); }
995
1005
  #model-modal .mm-note{ padding:4px 14px 8px; font-size:10px; color:var(--faint); font-style:italic; }
996
1006
  #model-modal .mm-foot{ display:flex; justify-content:flex-end; gap:7px; padding:10px 14px; border-top:1px solid var(--line); }
1007
+
1008
+ /* plan & billing overlay (#371, owner amendment 2 2026-09-23: the under-
1009
+ graph section was rejected — the upgrade surface must COVER the dash).
1010
+ The #model-modal idiom, widened for two side-by-side option cards; the
1011
+ cover is OPAQUE (var(--bg), not a scrim) — the dash is fully hidden
1012
+ behind it, not dimmed-partial. The [hidden] override is mandatory
1013
+ (author display beats the UA rule — the banner lesson, pinned by tests). */
1014
+ #plan-overlay{ position:fixed; inset:0; z-index:92; display:grid; place-items:center; }
1015
+ #plan-overlay[hidden]{ display:none; }
1016
+ #plan-overlay .po-cover{ position:absolute; inset:0; background:var(--bg); }
1017
+ #plan-overlay .po-card{
1018
+ position:relative; width:min(860px, calc(100vw - 32px)); max-height:calc(100vh - 48px);
1019
+ overflow-y:auto; background:var(--elev); border:1px solid var(--line2); border-radius:14px;
1020
+ box-shadow:var(--shadow-pop);
1021
+ }
1022
+ #plan-overlay .po-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 16px 10px; border-bottom:1px solid var(--line); }
1023
+ #plan-overlay .po-head b{ font-family:var(--serif); font-weight:500; font-size:17px; letter-spacing:.06em; text-transform:uppercase; }
1024
+ #plan-overlay .po-body{ padding:14px 16px 16px; display:flex; flex-direction:column; gap:10px; }
1025
+ #plan-overlay .po-current{ font-size:12.5px; color:var(--dim); }
1026
+ .po-opts{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
1027
+ #acct-options[hidden]{ display:none; } /* author grid beats [hidden] (renderPlanOverlay hides it when a plan is set) */
1028
+ .po-opt{ background:var(--panel); border:1px solid var(--line2); border-radius:12px; padding:14px 16px 13px; display:flex; flex-direction:column; gap:5px; min-width:0; }
1029
+ .po-opt .po-name{ font-size:10px; color:var(--faint); text-transform:uppercase; letter-spacing:.13em; }
1030
+ .po-opt .po-price{ font-size:26px; font-weight:640; font-variant-numeric:tabular-nums; letter-spacing:.01em; color:var(--green); }
1031
+ html:not([data-theme="light"]) .po-opt .po-price{ text-shadow:0 0 12px rgba(61,255,122,.35); }
1032
+ .po-opt .po-note{ font-size:11px; color:var(--dim); }
1033
+ /* what's included (round 3, owner: "NO info on what is included in the
1034
+ plan") — the marketing site's Cloud feature lines, verbatim, per card. */
1035
+ .po-opt .po-feat{ list-style:none; margin-top:7px; display:flex; flex-direction:column; gap:3px; }
1036
+ .po-opt .po-feat li{ font-size:11px; color:var(--dim); display:flex; gap:7px; align-items:baseline; }
1037
+ .po-opt .po-feat li::before{ content:"✓"; color:var(--green-mid); font-size:10px; }
1038
+ .po-choose{
1039
+ align-self:flex-start; margin-top:8px;
1040
+ color:var(--green-mid); border:1px solid color-mix(in srgb, var(--green) 35%, transparent);
1041
+ border-radius:999px; padding:4px 15px; font-size:11.5px; font-weight:600; white-space:nowrap;
1042
+ }
1043
+ .po-choose:hover{ border-color:var(--green-mid); }
1044
+ .po-choose:disabled{ opacity:.5; cursor:default; }
1045
+ @media (max-width:720px){ .po-opts{ grid-template-columns:1fr; } }
1046
+
1047
+ /* connect overlay (#506) — ONE full-cover surface for every "Connect an
1048
+ agent" entry (the #plan-overlay idiom, owner 2026-09-24: the fleet
1049
+ rail's inline drawer is REMOVED, not kept; the account-menu entry and
1050
+ the Agents-detected pill both open THIS). The cover is OPAQUE
1051
+ (var(--bg), not a scrim) and the [hidden] override is MANDATORY
1052
+ (author display beats the UA rule — the banner lesson, pinned by
1053
+ tests). The snippet blocks reuse the acct-* classes. */
1054
+ #connect-overlay{ position:fixed; inset:0; z-index:92; display:grid; place-items:center; }
1055
+ #connect-overlay[hidden]{ display:none; }
1056
+ #connect-overlay .co-cover{ position:absolute; inset:0; background:var(--bg); }
1057
+ #connect-overlay .co-card{ position:relative; width:min(640px, calc(100vw - 32px)); max-height:calc(100vh - 48px); overflow-y:auto; background:var(--elev); border:1px solid var(--line2); border-radius:14px; box-shadow:var(--shadow-pop); }
1058
+ #connect-overlay .co-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 16px 10px; border-bottom:1px solid var(--line); }
1059
+ #connect-overlay .co-head b{ font-family:var(--serif); font-weight:500; font-size:17px; letter-spacing:.06em; text-transform:uppercase; }
1060
+ #connect-overlay .co-body{ padding:14px 16px 16px; display:flex; flex-direction:column; gap:10px; }
1061
+ #connect-overlay .co-intro{ font-size:12px; color:var(--dim); line-height:1.5; }
997
1062
  .toast{
998
1063
  background:var(--elev); border:1px solid var(--line2); border-radius:999px; padding:8px 17px;
999
1064
  font-size:12px; color:var(--text); box-shadow:var(--shadow-pop);
@@ -1146,6 +1211,11 @@
1146
1211
  </div>
1147
1212
  </header>
1148
1213
 
1214
+ <!-- billing-return banner (#371) — filled by wireBillingBanner() when the
1215
+ URL carries ?billing=success|cancelled (the checkout return URLs the
1216
+ router stamps). Dismiss removes it; the param is stripped on show. -->
1217
+ <div id="billing-banner" role="status" aria-live="polite" hidden></div>
1218
+
1149
1219
  <!-- ============ the stats band — card grid (two rows of four) ============ -->
1150
1220
  <section id="measures" aria-label="Vitals, operator data, and account limits">
1151
1221
  <div class="band" id="strip">
@@ -1164,8 +1234,10 @@
1164
1234
  <div class="csub" id="ag-sub-band">found on their own</div>
1165
1235
  <button class="limit-cta" id="ag-cta">Connect Agent</button>
1166
1236
  </div>
1167
- <!-- #plan-billing: the account menu's "Plan & billing" target — the
1168
- token/cap usage card is this page's billing-adjacent surface -->
1237
+ <!-- #plan-billing: the token/cap usage card — this page's
1238
+ billing-adjacent surface. The account-menu item that scrolled
1239
+ here was consolidated into the Subscription entry (#371 round 3);
1240
+ the anchor id stays (diagnostics + tests pin it). -->
1169
1241
  <div class="bseg c-red" id="plan-billing">
1170
1242
  <div class="gh"><span class="t">Token usage</span></div>
1171
1243
  <div class="big"><span class="n" id="tok-n">—</span><span class="l">used</span><span class="cap-of" id="tok-of" hidden></span></div>
@@ -1284,17 +1356,9 @@
1284
1356
  <aside id="agentscol" aria-label="Connected agents">
1285
1357
  <div class="col-head"><span class="t">Fleet</span><small id="ag-sub">0 &middot; auto-detected</small></div>
1286
1358
  <div id="ag-list"></div>
1287
- <div id="connect">
1288
- <button id="connect-btn"><span class="plus">+</span> connect an agent</button>
1289
- <div id="connect-x">
1290
- <p>Run this on the machine that hosts the agent. It appears here the moment it first talks to the server.</p>
1291
- <div class="snip" id="connect-snip"></div>
1292
- <div class="row">
1293
- <button class="btn sm" id="connect-copy">Copy command</button>
1294
- <button class="btn sm save" id="connect-close">Done</button>
1295
- </div>
1296
- </div>
1297
- </div>
1359
+ <!-- #506: the fleet rail's + connect-an-agent drawer lived here — REMOVED
1360
+ (owner 2026-09-24). Both surviving connect entries (the account-menu
1361
+ entry + the Agents-detected pill) open the #connect-overlay. -->
1298
1362
  </aside>
1299
1363
  </main>
1300
1364
 
@@ -1338,6 +1402,64 @@
1338
1402
  </div>
1339
1403
  </div>
1340
1404
  </div>
1405
+
1406
+ <!-- ============ the PLAN & BILLING overlay (#371, owner amendment 2
1407
+ 2026-09-23: the under-graph section was rejected after the live look —
1408
+ "the plan and billing needs to open up as overlay, not under the
1409
+ graph, it could not even find it first. needs to cover the normal dash
1410
+ or be a separate tab or view"; the owner picked the full overlay).
1411
+ Opens from the account menu's Subscription entry (round 3: the two
1412
+ billing entries merged into one); the opaque .po-cover fully hides
1413
+ the dash (not dimmed-partial). × / cover-click / ESC close and return
1414
+ where the user was. The option cards are GENERATED from the
1415
+ PLAN_OPTIONS data array (round 3: N-driven — a future plan is a data
1416
+ entry + a router price id, not a page rework); prices and the
1417
+ included-features bullets are display constants aligned with the
1418
+ marketing site (lockstep-pinned by tests). The router deliberately
1419
+ knows only Stripe price ids, so there is no server-side amount to
1420
+ read; the Stripe Checkout page shows the authoritative amount before
1421
+ payment. ============ -->
1422
+ <div id="plan-overlay" hidden>
1423
+ <div class="po-cover" id="po-cover"></div>
1424
+ <div class="po-card" role="dialog" aria-modal="true" aria-labelledby="po-title">
1425
+ <div class="po-head"><b id="po-title">Plan &amp; billing</b><button class="pm-close" id="po-close" aria-label="Close">&#10005;</button></div>
1426
+ <div class="po-body">
1427
+ <div class="po-current" id="po-current"></div>
1428
+ <div class="po-opts" id="acct-options"></div>
1429
+ </div>
1430
+ </div>
1431
+ </div>
1432
+
1433
+ <!-- ============ the CONNECT overlay (#506) — ONE full-cover surface for
1434
+ every "Connect an agent" entry. Before this there were two shapes: the
1435
+ account menu's inline expandable snippets and the fleet rail's +
1436
+ drawer. Owner 2026-09-24: the rail entry is REMOVED, not kept; the
1437
+ account-menu entry and the Agents-detected pill both open THIS
1438
+ (#plan-overlay idiom: opaque cover, × / cover-click / ESC). The
1439
+ snippet <pre>s and copy buttons keep the acct-* ids so
1440
+ renderAcctToken keeps driving them (token-gated: disabled until
1441
+ /account/token answers); the wiring is STATIC so the overlay works
1442
+ even when /account failed and the menu never rendered. The overlay
1443
+ itself lazily fetches the token on open (idempotent). ============ -->
1444
+ <div id="connect-overlay" hidden>
1445
+ <div class="co-cover" id="co-cover"></div>
1446
+ <div class="co-card" role="dialog" aria-modal="true" aria-labelledby="co-title">
1447
+ <div class="co-head"><b id="co-title">Connect an agent</b><button class="pm-close" id="co-close" aria-label="Close">&#10005;</button></div>
1448
+ <div class="co-body">
1449
+ <p class="co-intro">Run this on the machine that hosts the agent. It appears here the moment it first talks to the server.</p>
1450
+ <div class="acct-label">CLI</div>
1451
+ <div class="acct-snippet">
1452
+ <pre id="acct-cli"></pre>
1453
+ <button id="acct-copy-cli" type="button" class="acct-btn" disabled>Copy</button>
1454
+ </div>
1455
+ <div class="acct-label">MCP — ~/.claude.json</div>
1456
+ <div class="acct-snippet">
1457
+ <pre id="acct-mcp"></pre>
1458
+ <button id="acct-copy-mcp" type="button" class="acct-btn" disabled>Copy</button>
1459
+ </div>
1460
+ </div>
1461
+ </div>
1462
+ </div>
1341
1463
  <script>
1342
1464
  'use strict';
1343
1465
  /* =====================================================================
@@ -2966,9 +3088,12 @@ document.addEventListener('keydown',e=>{
2966
3088
  else if(e.key==='ArrowRight'){ manualTake(); seek(T+1); }
2967
3089
  else if(e.key==='t'||e.key==='T'){ setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark'); }
2968
3090
  else if(e.key==='Escape'){
2969
- if(document.getElementById('acct-menu') && document.getElementById('acct-menu').classList.contains('open')) closeAcctMenu();
3091
+ const po=el('plan-overlay');
3092
+ const co=el('connect-overlay');
3093
+ if(po && !po.hidden) closePlanOverlay(); /* the full-cover overlay is topmost — it closes first (#371) */
3094
+ else if(co && !co.hidden) closeConnectOverlay(); /* #506 — same tier: the connect overlay */
3095
+ else if(document.getElementById('acct-menu') && document.getElementById('acct-menu').classList.contains('open')) closeAcctMenu();
2970
3096
  else if(panel.classList.contains('open')) closePanel();
2971
- else if(el('connect-x').classList.contains('open')) el('connect-x').classList.remove('open');
2972
3097
  else if(focusDom!=null) setFocus(null);
2973
3098
  }
2974
3099
  wake();
@@ -3784,7 +3909,9 @@ function lastNightByAgent(){
3784
3909
  function buildAgents(){
3785
3910
  const listEl=el('ag-list'); listEl.innerHTML='';
3786
3911
  const gs=contextGroups();
3787
- if(!gs.length){ listEl.innerHTML='<div class="pm-empty" style="padding:10px 6px;">no contexts yet — connect an agent below</div>'; return; }
3912
+ /* #506: the "below" target (the rail drawer) is gone — the empty state
3913
+ points at the one remaining connect surface, the account menu */
3914
+ if(!gs.length){ listEl.innerHTML='<div class="pm-empty" style="padding:10px 6px;">no contexts yet — connect an agent from the account menu (top right)</div>'; return; }
3788
3915
  const lastNight=lastNightByAgent();
3789
3916
  /* #423 phase 3: presence + pauses ride D.fleet — ABSENT on a pre-phase-3
3790
3917
  server → no dots, no toggles, no paused state (phase-2 rendering) */
@@ -4022,8 +4149,6 @@ async function refreshFleet(){
4022
4149
  renderBand();
4023
4150
  }catch(e){ toast('Refresh failed: '+e.message); }
4024
4151
  }
4025
- el('connect-btn').onclick=()=>{ el('connect-x').classList.toggle('open'); wake(); };
4026
-
4027
4152
  /* model-settings modal wiring (#422) */
4028
4153
  el('model-cfg').onclick=()=>{ openModelModal(); wake(); };
4029
4154
  el('mm-close').onclick=closeModelModal;
@@ -4031,14 +4156,10 @@ el('mm-cancel').onclick=closeModelModal;
4031
4156
  el('mm-backdrop').onclick=closeModelModal;
4032
4157
  el('mm-save').onclick=()=>{ saveModelSettings(); wake(); };
4033
4158
  el('mm-think').addEventListener('change',()=>{ mmThinkTouched=true; });
4034
- el('ag-cta').onclick=()=>{ el('connect-x').classList.add('open');
4035
- el('connect-btn').scrollIntoView({block:'nearest',behavior:'smooth'}); wake(); };
4036
- el('connect-close').onclick=()=>el('connect-x').classList.remove('open');
4037
- el('connect-snip').textContent='npx @gamaze/hicortex init --server '+location.origin;
4038
- el('connect-copy').onclick=()=>{
4039
- copyToClipboard('npx @gamaze/hicortex init --server '+location.origin, el('connect-copy'));
4040
- wake();
4041
- };
4159
+ /* #506: the Agents-detected pill opens the full-cover connect overlay —
4160
+ the rail drawer (+ button, open/scroll mechanics) it used to target is
4161
+ REMOVED (owner 2026-09-24) */
4162
+ el('ag-cta').onclick=()=>{ openConnectOverlay(); wake(); };
4042
4163
 
4043
4164
  /* ---------- the memory browser — dropdown under the search pill ---------- */
4044
4165
  const panel=el('panel');
@@ -4416,8 +4537,9 @@ async function refreshAfterWrite(){
4416
4537
 
4417
4538
  /* =====================================================================
4418
4539
  ACCOUNT MENU — harvested from the 0.20.4 console (#365): authoritative
4419
- token via lazy GET /account/token, connect snippets, plan & billing,
4420
- sign-out (client-side only — dropping the stored tokens IS the sign-out).
4540
+ token via lazy GET /account/token, connect overlay entry (#506 — the
4541
+ snippets moved to the full-cover overlay), plan & billing, sign-out
4542
+ (client-side only — dropping the stored tokens IS the sign-out).
4421
4543
  ===================================================================== */
4422
4544
  const $ = el; // the harvested block below uses the $ shorthand
4423
4545
  /* Pure decision — what the corner renders for an /account payload (#409 fix
@@ -4441,6 +4563,58 @@ function acctMenuPlan(a) {
4441
4563
  avatar: ((typeof a.name === "string" && a.name) ? a.name : "?").slice(0, 2).toUpperCase(),
4442
4564
  };
4443
4565
  }
4566
+ /* Pure decision (#371) — the purchasable periods. Decided pricing (owner
4567
+ 2026-08-19): ONE price, TWO periods — €12/mo and €108/yr. ANNUAL FIRST:
4568
+ annual is the stated product default; its note is the numeric savings
4569
+ sub-line (round-4 addendum 2026-09-24: it replaces the retired
4570
+ words-only annual tagline — same fact, numbers not words; effective
4571
+ monthly 108/12 = €9, discount (144−108)/144 = 25%, derived-pinned by
4572
+ tests so a price change forces the sub-line update). Prices are
4573
+ display constants — the
4574
+ router deliberately knows only Stripe price ids (no server-side amount
4575
+ exists to read), and the Stripe Checkout page shows the authoritative
4576
+ amount before payment. Round 3: a PURE DATA ARRAY and the single source
4577
+ of truth — the overlay cards are GENERATED from it (renderPlanCards)
4578
+ and the wiring loops it (wireSubscription); no two-plan hardcoding
4579
+ anywhere. The bullets are the marketing site's Cloud feature lines,
4580
+ verbatim (lockstep-pinned against web/index.html) — the seven
4581
+ owner-decided lines (amendment 4, 2026-09-24: copy-first ruling; the
4582
+ agent-cap enforcement rides its own issue); both periods are the same
4583
+ Cloud product, so both carry the same seven. */
4584
+ const PLAN_OPTIONS = [
4585
+ { id: "annual", posts: "annual", name: "Annual", price: "€108/year", note: "€9/mo · 25% off", cta: "Choose annual", bullets: ["Fully managed — no API keys needed", "Claude Code, Hermes, OpenClaw, Pi, OpenCode", "10,000 memories included", "Up to 10 agents", "Learnings & nightly reflection", "Semantic search + knowledge graph", "Multi-machine support"] },
4586
+ { id: "monthly", posts: "monthly", name: "Monthly", price: "€12/mo", note: "", cta: "Choose monthly", bullets: ["Fully managed — no API keys needed", "Claude Code, Hermes, OpenClaw, Pi, OpenCode", "10,000 memories included", "Up to 10 agents", "Learnings & nightly reflection", "Semantic search + knowledge graph", "Multi-machine support"] },
4587
+ ];
4588
+ /* Pure decision (#371) — the Stripe return parameter (?billing=…, the
4589
+ success_url/cancel_url the router stamps on every checkout) → the
4590
+ acknowledgment banner spec, or null when there is nothing to acknowledge
4591
+ (unknown or absent → the page shows nothing, never a guess). */
4592
+ function billingBanner(value) {
4593
+ if (value === "success")
4594
+ return { kind: "success", text: "payment received — activation completes within a minute" };
4595
+ if (value === "cancelled")
4596
+ return { kind: "cancelled", text: "checkout cancelled — nothing was charged" };
4597
+ return null;
4598
+ }
4599
+ /* Pure decision (#371, amendment 2) — the overlay's current-plan line.
4600
+ HONEST by construction: /account provides name/org/plan only, so the
4601
+ line shows the plan label verbatim when set, and when unset the
4602
+ documented meaning of a null label (the same eligibility the Upgrade
4603
+ entry uses) — never invented tiers or numbers. */
4604
+ function acctPlanLine(p) {
4605
+ return p.plan
4606
+ ? "Current plan — " + p.plan
4607
+ : "Current plan — none yet (hosted trial, or self-hosted install)";
4608
+ }
4609
+ /* Pure decision (#371 round 3) — the Subscription entry's Upgrade pill.
4610
+ The menu's two billing entries (Upgrade + Plan & billing) merged into
4611
+ ONE always-rendered "Subscription" item; the PILL is what the old
4612
+ plan-unset eligibility gates (owner: "it can have an upgrade pill/label
4613
+ after it before any subscription is active"). Returns the pill markup
4614
+ or "" — interpolated into the entry's label. */
4615
+ function acctPill(p) {
4616
+ return p.upgrade ? '<span class="acct-pill">Upgrade</span>' : "";
4617
+ }
4444
4618
  function renderAccount(a) {
4445
4619
  const host = $("nav-account");
4446
4620
  const p = acctMenuPlan(a);
@@ -4449,9 +4623,7 @@ function renderAccount(a) {
4449
4623
  const plan = p.plan
4450
4624
  ? `<span class="mini" style="font-size:10.5px;border:1px solid var(--line2);border-radius:999px;padding:2px 9px;color:var(--dim)">${esc(p.plan)}</span>`
4451
4625
  : "";
4452
- const upgrade = p.upgrade
4453
- ? '<button id="acct-upgrade" type="button" class="acct-link acct-upgrade">Upgrade</button>'
4454
- : "";
4626
+ const pill = acctPill(p); // #371 round 3: the entry always renders; the PILL is eligibility-gated
4455
4627
  host.innerHTML = `
4456
4628
  <button id="acct-trigger" type="button" class="acct-trigger" aria-haspopup="menu" aria-expanded="false">
4457
4629
  <span class="avatar">${esc(p.avatar)}</span>${who?'<span class="who">'+who+'</span> ':''}${plan}<span class="acct-caret" aria-hidden="true">&#9660;</span>
@@ -4463,55 +4635,182 @@ function renderAccount(a) {
4463
4635
  <button id="acct-reveal" type="button" class="acct-btn" disabled>Reveal</button>
4464
4636
  <button id="acct-copy-token" type="button" class="acct-btn" disabled>Copy</button>
4465
4637
  </div>
4466
- <details id="acct-connect">
4467
- <summary>Connect an agent</summary>
4468
- <div class="acct-label">CLI</div>
4469
- <div class="acct-snippet">
4470
- <pre id="acct-cli"></pre>
4471
- <button id="acct-copy-cli" type="button" class="acct-btn" disabled>Copy</button>
4472
- </div>
4473
- <div class="acct-label">MCP — ~/.claude.json</div>
4474
- <div class="acct-snippet">
4475
- <pre id="acct-mcp"></pre>
4476
- <button id="acct-copy-mcp" type="button" class="acct-btn" disabled>Copy</button>
4477
- </div>
4478
- </details>
4479
- ${upgrade}
4480
- <button id="acct-plan" type="button" class="acct-link">Plan &amp; billing</button>
4638
+ <button id="acct-connect-entry" type="button" class="acct-link">Connect an agent</button>
4639
+ <button id="acct-subscription" type="button" class="acct-link">Subscription${pill}</button>
4481
4640
  <button id="acct-signout" type="button" class="acct-link acct-danger">Sign out</button>
4482
4641
  </div>`;
4483
4642
  wireAcctMenu();
4484
- if (upgrade) wireUpgrade();
4643
+ renderPlanOverlay(p);
4644
+ wireSubscription();
4645
+ wireConnectEntry();
4646
+ }
4647
+ /* The overlay's content (#371, amendment 2): the honest current-plan line
4648
+ from the pure decision, and the purchase options under the SAME
4649
+ plan-label-unset eligibility as the menu entry (a second checkout for an
4650
+ active subscriber would mint a second Stripe subscription — checkout
4651
+ passes customer_email, not a subscription update; switching is Phase 3).
4652
+ /account failed (p null) → nothing fills (fail-soft, like the empty
4653
+ corner); the overlay itself opens only from the menu entry anyway. */
4654
+ function renderPlanOverlay(p) {
4655
+ if (!p) return;
4656
+ const cur = $("po-current");
4657
+ if (cur) cur.textContent = acctPlanLine(p);
4658
+ const opts = $("acct-options");
4659
+ if (opts) opts.hidden = !p.upgrade;
4660
+ }
4661
+ function openPlanOverlay() {
4662
+ const ov = $("plan-overlay");
4663
+ if (!ov || !ov.hidden) return;
4664
+ ov.hidden = false;
4665
+ const close = $("po-close");
4666
+ if (close) close.focus(); // keyboard users land on the primary exit (the modal focus idiom)
4667
+ }
4668
+ function closePlanOverlay() {
4669
+ const ov = $("plan-overlay");
4670
+ if (ov) ov.hidden = true;
4671
+ }
4672
+ /* #506 — the connect overlay: ONE full-cover surface for every "Connect an
4673
+ agent" entry (the #plan-overlay idiom). Opening lazily fetches the token
4674
+ (idempotent) because the overlay is reachable WITHOUT the account menu
4675
+ (the Agents-detected pill) — the menu path would otherwise be the only
4676
+ thing seeding the snippets. */
4677
+ function openConnectOverlay() {
4678
+ const ov = $("connect-overlay");
4679
+ if (!ov || !ov.hidden) return;
4680
+ ov.hidden = false;
4681
+ fetchAcctToken(); // lazy + idempotent — one attempt per page load
4682
+ const close = $("co-close");
4683
+ if (close) close.focus(); // keyboard users land on the primary exit (the modal focus idiom)
4485
4684
  }
4486
- function wireUpgrade() {
4487
- const btn = $("acct-upgrade");
4685
+ function closeConnectOverlay() {
4686
+ const ov = $("connect-overlay");
4687
+ if (ov) ov.hidden = true;
4688
+ }
4689
+ /* N-driven card render (#371 round 3): the option cards are GENERATED from
4690
+ PLAN_OPTIONS — N entries, N cards, no per-plan literals anywhere in the
4691
+ markup or this template (a future plan is a data entry + a router price
4692
+ id, not a page rework). Runs once at script init; account data only
4693
+ toggles visibility (renderPlanOverlay). */
4694
+ function renderPlanCards() {
4695
+ const host = $("acct-options");
4696
+ if (!host) return;
4697
+ host.innerHTML = PLAN_OPTIONS.map((o) => `
4698
+ <div class="po-opt" id="acct-opt-${o.id}">
4699
+ <div class="po-name">${esc(o.name)}</div>
4700
+ <div class="po-price">${esc(o.price)}</div>
4701
+ ${o.note ? `<div class="po-note">${esc(o.note)}</div>` : ""}
4702
+ <ul class="po-feat">${o.bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>
4703
+ <button id="acct-buy-${o.id}" type="button" class="po-choose">${esc(o.cta)}</button>
4704
+ </div>`).join("");
4705
+ }
4706
+ renderPlanCards();
4707
+ function wireSubscription() {
4708
+ const btn = $("acct-subscription");
4488
4709
  if (!btn) return;
4489
- btn.addEventListener("click", async () => {
4490
- btn.disabled = true;
4491
- try {
4492
- const resp = await fetch("/stripe/checkout", {
4493
- method: "POST",
4494
- headers: { "content-type": "application/json" },
4495
- body: JSON.stringify({ plan: "annual" }),
4496
- });
4497
- if (resp.status === 401) {
4498
- // Never hard-redirect to Google on a daemon (dead end / loop) — only
4499
- // where the probe says OAuth exists; else surface the expired session.
4500
- if (await probeGoogleAuth()) { location.href = "/auth/google/start"; return; }
4501
- alert("Session expired — reload the page to sign in again.");
4502
- btn.disabled = false;
4503
- return;
4504
- }
4505
- const body = await resp.json().catch(() => ({}));
4506
- if (resp.ok && body.url) { location.href = body.url; return; }
4507
- alert("Checkout unavailable (" + resp.status + ") — try again later.");
4508
- btn.disabled = false;
4509
- } catch {
4510
- alert("Checkout unreachable — try again later.");
4710
+ // #371 round 3: the ONE Subscription entry (Upgrade + Plan & billing
4711
+ // merged, owner) opens the FULL-COVER plan & billing overlay; the entry
4712
+ // always renders — the Upgrade pill alone marks the plan-unset state.
4713
+ // Menu closes first; × / cover-click / ESC return.
4714
+ btn.addEventListener("click", () => {
4715
+ closeAcctMenu();
4716
+ openPlanOverlay();
4717
+ });
4718
+ for (const o of PLAN_OPTIONS) {
4719
+ const buy = $("acct-buy-" + o.id);
4720
+ if (!buy) continue;
4721
+ buy.addEventListener("click", () => { startCheckout(o.posts, buy); });
4722
+ }
4723
+ }
4724
+ /* #506: the menu's Connect-an-agent entry opens the full-cover connect
4725
+ overlay — the same launch idiom as wireSubscription (#371): the menu
4726
+ closes first; × / cover-click / ESC close and return where the user
4727
+ was. The inline expandable snippets this entry used to reveal are gone;
4728
+ the overlay owns connecting. */
4729
+ function wireConnectEntry() {
4730
+ const btn = $("acct-connect-entry");
4731
+ if (!btn) return;
4732
+ btn.addEventListener("click", () => {
4733
+ closeAcctMenu();
4734
+ openConnectOverlay();
4735
+ });
4736
+ }
4737
+ /** POST /stripe/checkout for ONE plan, then navigate to the returned Stripe
4738
+ * URL (#371 — each period's action posts its own plan; the hardcoded
4739
+ * annual-only body was the defect). The failure seams are the shipped
4740
+ * checkout-handler contract, kept verbatim for EVERY action: 401 → Google
4741
+ * re-auth where the probe says OAuth exists, else the expired-session
4742
+ * alert; non-ok → the unavailable alert; network error → the unreachable
4743
+ * alert. Each button is disabled independently while its own request is in
4744
+ * flight. */
4745
+ async function startCheckout(plan, btn) {
4746
+ btn.disabled = true;
4747
+ try {
4748
+ const resp = await fetch("/stripe/checkout", {
4749
+ method: "POST",
4750
+ headers: { "content-type": "application/json" },
4751
+ body: JSON.stringify({ plan }),
4752
+ });
4753
+ if (resp.status === 401) {
4754
+ // Never hard-redirect to Google on a daemon (dead end / loop) — only
4755
+ // where the probe says OAuth exists; else surface the expired session.
4756
+ if (await probeGoogleAuth()) { location.href = "/auth/google/start"; return; }
4757
+ alert("Session expired — reload the page to sign in again.");
4511
4758
  btn.disabled = false;
4759
+ return;
4512
4760
  }
4513
- });
4761
+ const body = await resp.json().catch(() => ({}));
4762
+ if (resp.ok && body.url) { location.href = body.url; return; }
4763
+ alert("Checkout unavailable (" + resp.status + ") — try again later.");
4764
+ btn.disabled = false;
4765
+ } catch {
4766
+ alert("Checkout unreachable — try again later.");
4767
+ btn.disabled = false;
4768
+ }
4769
+ }
4770
+ /* The return acknowledgment (#371): read ?billing= once on load and show
4771
+ the one-line banner. The param is STRIPPED via history.replaceState the
4772
+ moment the banner shows — neither dismissal nor a plain reload can
4773
+ re-show it (a reload re-reads a URL that no longer carries the param).
4774
+ Runs at script init, before boot: the acknowledgment must not depend on
4775
+ the data fetches succeeding. */
4776
+ function wireBillingBanner() {
4777
+ const host = $("billing-banner");
4778
+ if (!host) return;
4779
+ const params = new URLSearchParams(location.search);
4780
+ const spec = billingBanner(params.get("billing"));
4781
+ if (!spec) return;
4782
+ params.delete("billing");
4783
+ const qs = params.toString();
4784
+ history.replaceState(null, "", location.pathname + (qs ? "?" + qs : "") + location.hash);
4785
+ host.dataset.kind = spec.kind;
4786
+ host.innerHTML =
4787
+ "<span>" + esc(spec.text) + "</span>" +
4788
+ '<button id="billing-banner-dismiss" type="button" aria-label="Dismiss">&#10005;</button>';
4789
+ host.hidden = false;
4790
+ $("billing-banner-dismiss").addEventListener("click", () => { host.remove(); });
4514
4791
  }
4792
+ wireBillingBanner();
4793
+ /* the plan & billing overlay's close affordances (the #model-modal wiring
4794
+ idiom): the × button and a click on the opaque cover both return to the
4795
+ dash; ESC lives in the shared keyboard handler below. */
4796
+ $("po-close").addEventListener("click", closePlanOverlay);
4797
+ $("po-cover").addEventListener("click", closePlanOverlay);
4798
+ /* the connect overlay's close + copy affordances (#506) — the same static
4799
+ wiring idiom. The copy buttons are STATIC overlay elements, wired at
4800
+ script init so they work even when /account failed and the menu never
4801
+ rendered; the payloads are the same byte-for-byte commands the old
4802
+ handlers copied. Token-gated exactly like the menu's buttons: a copy
4803
+ without the token would paste a command that cannot authenticate. */
4804
+ $("co-close").addEventListener("click", closeConnectOverlay);
4805
+ $("co-cover").addEventListener("click", closeConnectOverlay);
4806
+ const copyCliBtn = $("acct-copy-cli");
4807
+ copyCliBtn.addEventListener("click", () => {
4808
+ if (acctTokenState === "ok") copyToClipboard("npx @gamaze/hicortex init --server " + location.origin, copyCliBtn);
4809
+ });
4810
+ const copyMcpBtn = $("acct-copy-mcp");
4811
+ copyMcpBtn.addEventListener("click", () => {
4812
+ if (acctTokenState === "ok") copyToClipboard(acctMcpText(), copyMcpBtn);
4813
+ });
4515
4814
  let acctToken = null; // token from /account/token (server truth)
4516
4815
  let acctTokenState = "unfetched"; // "unfetched" | "ok" | "unavailable"
4517
4816
  let acctTokenFetched = false; // one lazy fetch attempt per page load
@@ -4539,30 +4838,33 @@ function acctMcpText() {
4539
4838
  }, null, 2);
4540
4839
  }
4541
4840
  function renderAcctToken() {
4841
+ /* #506: per-element guards instead of the old bail-early `if (!val)
4842
+ return` — the snippet <pre>s + copy buttons now live in the STATIC
4843
+ connect overlay, so token state must render even when /account failed
4844
+ and the menu (val/reveal/copy-token) never rendered. Gating semantics
4845
+ are unchanged: everything disabled unless ok, "token unavailable"
4846
+ text otherwise, "…" while unfetched, revealed text only in the ok
4847
+ path. */
4848
+ const ok = acctTokenState === "ok";
4542
4849
  const val = $("acct-token-val");
4543
- if (!val) return; // menu not rendered (/account failed — the corner stays empty)
4544
4850
  const revealBtn = $("acct-reveal");
4545
4851
  const copyToken = $("acct-copy-token");
4546
4852
  const copyCli = $("acct-copy-cli");
4547
4853
  const copyMcp = $("acct-copy-mcp");
4548
- if (acctTokenState !== "ok") {
4549
- val.textContent = acctTokenState === "unfetched" ? "…" : "token unavailable";
4550
- revealBtn.disabled = true;
4551
- copyToken.disabled = true;
4552
- copyCli.disabled = true;
4553
- copyMcp.disabled = true;
4554
- $("acct-cli").textContent = acctTokenState === "unfetched" ? "" : "token unavailable";
4555
- $("acct-mcp").textContent = acctTokenState === "unfetched" ? "" : "token unavailable";
4556
- return;
4557
- }
4558
- revealBtn.disabled = false;
4559
- copyToken.disabled = false;
4560
- copyCli.disabled = false;
4561
- copyMcp.disabled = false;
4562
- val.textContent = acctRevealed ? acctToken : maskToken(acctToken);
4563
- revealBtn.textContent = acctRevealed ? "Hide" : "Reveal";
4564
- $("acct-cli").textContent = acctCliText();
4565
- $("acct-mcp").textContent = acctMcpText();
4854
+ if (revealBtn) revealBtn.disabled = !ok;
4855
+ if (copyToken) copyToken.disabled = !ok;
4856
+ if (copyCli) copyCli.disabled = !ok;
4857
+ if (copyMcp) copyMcp.disabled = !ok;
4858
+ if (val) {
4859
+ if (!ok) val.textContent = acctTokenState === "unfetched" ? "…" : "token unavailable";
4860
+ else val.textContent = acctRevealed ? acctToken : maskToken(acctToken);
4861
+ }
4862
+ if (revealBtn && ok) revealBtn.textContent = acctRevealed ? "Hide" : "Reveal";
4863
+ const cli = $("acct-cli"); // the connect overlay's snippets (#506) — render regardless of menu presence
4864
+ const mcp = $("acct-mcp");
4865
+ const degraded = acctTokenState === "unfetched" ? "" : "token unavailable";
4866
+ if (cli) cli.textContent = ok ? acctCliText() : degraded;
4867
+ if (mcp) mcp.textContent = ok ? acctMcpText() : degraded;
4566
4868
  }
4567
4869
  async function fetchAcctToken() {
4568
4870
  if (acctTokenFetched) return;
@@ -4588,8 +4890,6 @@ function closeAcctMenu() {
4588
4890
  const trigger = document.getElementById("acct-trigger");
4589
4891
  if (trigger) trigger.setAttribute("aria-expanded", "false");
4590
4892
  acctRevealed = false;
4591
- const details = document.getElementById("acct-connect");
4592
- if (details) details.removeAttribute("open");
4593
4893
  renderAcctToken();
4594
4894
  }
4595
4895
  function openAcctMenu() {
@@ -4652,21 +4952,11 @@ function wireAcctMenu() {
4652
4952
  copyToken.addEventListener("click", () => {
4653
4953
  if (acctTokenState === "ok") copyToClipboard(acctConnectLine(), copyToken);
4654
4954
  });
4655
- const copyCli = $("acct-copy-cli");
4656
- copyCli.addEventListener("click", () => {
4657
- if (acctTokenState === "ok") {
4658
- copyToClipboard("npx @gamaze/hicortex init --server " + location.origin, copyCli);
4659
- }
4660
- });
4661
- const copyMcp = $("acct-copy-mcp");
4662
- copyMcp.addEventListener("click", () => {
4663
- if (acctTokenState === "ok") copyToClipboard(acctMcpText(), copyMcp);
4664
- });
4665
- $("acct-plan").addEventListener("click", () => {
4666
- closeAcctMenu();
4667
- const t = document.getElementById("plan-billing");
4668
- if (t) t.scrollIntoView({ behavior: "smooth", block: "nearest" });
4669
- });
4955
+ // #506: the copy-cli/copy-mcp wiring moved to the STATIC init block by
4956
+ // the connect overlay — those buttons live in the overlay now, not here.
4957
+ // #371 round 3: the old "Plan & billing" scroll item is gone — its
4958
+ // target tile (#plan-billing) stays in the measures band as the
4959
+ // billing-adjacent surface; the Subscription entry owns the route now.
4670
4960
  $("acct-signout").addEventListener("click", signOut);
4671
4961
  renderAcctToken();
4672
4962
  }