@bongos/core 1.19.399 → 1.19.401

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.
Files changed (37) hide show
  1. package/.bongos-core.json +60 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0199-restart-an-unreachable-active-project.md +49 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +74 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +233 -232
  12. package/docs/copy-registry.json +293 -284
  13. package/docs/design/projects-hub-direction-v2.md +38 -0
  14. package/docs/recipes/ui-look-before-you-ship.md +1 -1
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/provisioning/migrations/provisioning_011_restart.sql +28 -0
  17. package/modules/provisioning/provisioning.js +42 -4
  18. package/modules/provisioning/routes/provisioning.js +59 -0
  19. package/modules/ui-design/kit/lib.js +6 -2
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/public-landing/assets/cosmos.css +88 -35
  23. package/public-landing/index.html +13 -15
  24. package/public-landing/index.probes.json +8 -5
  25. package/public-landing/projects.html +111 -16
  26. package/public-landing/projects.probes.json +17 -10
  27. package/public-landing/projects.states.json +1 -1
  28. package/scripts/gds/provision.js +104 -0
  29. package/src/module-api.js +5 -1
  30. package/tests/landing_page.mjs +8 -4
  31. package/tests/projects_hub.mjs +28 -1
  32. package/tests/projects_hub_pre_uat.mjs +40 -0
  33. package/tests/provision_restart.mjs +242 -0
  34. package/tests/provisioning_restart.mjs +285 -0
  35. package/tests/provisioning_settings_apply.mjs +8 -3
  36. package/tests/provisioning_teardown_intent.mjs +9 -5
  37. package/tests/ui_design_kit.mjs +5 -4
@@ -3,9 +3,12 @@
3
3
  "page": "/",
4
4
  "surface": "public-landing",
5
5
  "probes": [
6
- { "name": "landing: Enter opens Projects on the first item", "auth": true, "width": 1440,
6
+ { "name": "landing closed: the main content starts under the bar", "auth": true, "width": 1440,
7
+ "steps": [],
8
+ "expect": { "expanded": false, "top": [["#main", 72]] } },
9
+ { "name": "landing: Enter opens Projects on the first item; the band overlays and the hero does not move (task 1003332)", "continue": true,
7
10
  "steps": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 150]],
8
- "expect": { "open": "#menuProjects", "activeText": "Sky" } },
11
+ "expect": { "open": "#menuProjects", "activeText": "Sky", "top": [["#main", 72]] } },
9
12
  { "name": "landing: arrows walk the visible items (Your projects shows signed in)", "continue": true,
10
13
  "steps": [["press", "ArrowDown"], ["press", "ArrowDown"]],
11
14
  "expect": { "activeText": "Your projects" } },
@@ -14,16 +17,16 @@
14
17
  "expect": { "closed": "#menuProjects", "activeId": "projTrig" } },
15
18
  { "name": "landing: the profile band closes the Projects band", "continue": true,
16
19
  "steps": [["click", "#projTrig"], ["wait", 120], ["click", "#who .trig"], ["wait", 120]],
17
- "expect": { "open": "#menuProfile", "closed": "#menuProjects" } },
20
+ "expect": { "open": "#menuProfile", "closed": "#menuProjects", "top": [["#main", 72]] } },
18
21
  { "name": "landing: the profile band names the person; sign out keeps the honest href", "continue": true,
19
22
  "steps": [],
20
23
  "expect": { "href": [["#menuProfile a.signout", "/api/bongos/auth/logout"]], "text": [["#bandWho", "builder"]], "count": [["#bandWho img", 1]] } },
21
24
  { "name": "landing: a click outside closes", "continue": true,
22
25
  "steps": [["clickAt", 700, 700], ["wait", 120]],
23
26
  "expect": { "closed": "#menuProfile", "expanded": false } },
24
- { "name": "landing: the band deep-links the hub views + About + Docs", "continue": true,
27
+ { "name": "landing: the band deep-links the hub views; About and Docs are footer links (task 1003332)", "continue": true,
25
28
  "steps": [["click", "#projTrig"], ["wait", 120]],
26
- "expect": { "count": [["#menuProjects a", 6]], "href": [["#menuProjects .bandCell:nth-child(1) a", "/projects?view=map"], ["#menuProjects .bandCell:nth-child(2) a", "/projects?view=map&sub=table"], ["#menuProjects a[href=\"#about\"]", "#about"], ["#menuProjects a[href=\"/docs\"]", "/docs"]] } },
29
+ "expect": { "count": [["#menuProjects a", 4], ["#menuProjects a[href=\"/docs\"]", 0]], "href": [["#menuProjects .bandCell:nth-child(1) a", "/projects?view=map"], ["#menuProjects .bandCell:nth-child(2) a", "/projects?view=map&sub=table"], ["footer a[href=\"#about\"]", "#about"], ["footer a[href=\"/docs\"]", "/docs"]] } },
27
30
  { "name": "landing: #about exists", "continue": true,
28
31
  "steps": [["press", "Escape"]],
29
32
  "expect": { "count": [["#about", 1]] } },
@@ -418,9 +418,10 @@ summary{min-height:24px;padding:3px 0;}
418
418
  </header>
419
419
 
420
420
  <!-- ══ THE BANDS ══ The menus are not floating cards. Each is a full-width band
421
- that unfolds under the bar's edge and PUSHES the page down, so a
422
- destination is a place the page opens into rather than a list that hovers
423
- over it. One component, two instances (see the bands block in the script):
421
+ that unfolds under the bar's edge as a layer OVER the page — the page does
422
+ not reflow (owner, 2026-08-28; task 1003332) — so a destination is a place
423
+ that opens under the bar rather than a list that hovers beside it. One
424
+ component, two instances (see the bands block in the script):
424
425
  Escape closes and returns focus to the trigger, the arrows walk the items,
425
426
  a click outside closes, opening one closes the other. Plain links in a
426
427
  disclosure (aria-expanded + aria-controls, no menu role), so what is
@@ -435,12 +436,8 @@ summary{min-height:24px;padding:3px 0;}
435
436
  <li class="bandCell"><a class="bandT" href="?view=mine" data-nav="mine">Your projects</a><span class="bandC">the ones you own or build on</span></li>
436
437
  <li class="bandCell"><a class="bandT" href="?view=new" data-nav="new">New project</a><span class="bandC">five questions and a home</span></li>
437
438
  </ul>
438
- <!-- under a hairline (§5's About cell): what this is, and the docs. About is the landing's
439
- own explanation until goal 1000063's About page takes the anchor over. -->
440
- <ul class="bandRow bandMore">
441
- <li class="bandCell"><a class="bandT" href="/#about">About</a></li>
442
- <li class="bandCell"><a class="bandT" href="/docs">Docs</a></li>
443
- </ul>
439
+ <!-- About and Docs are site pages, not project views: they live in the
440
+ footer row with Privacy / Terms / Contact (owner, 2026-08-28; task 1003332). -->
444
441
  </div>
445
442
  </div>
446
443
  <div class="menuBand" id="menuProfile" data-menu="profile" hidden>
@@ -508,6 +505,15 @@ summary{min-height:24px;padding:3px 0;}
508
505
  </div>
509
506
 
510
507
  <div class="wrap viewWrap" id="sub-table-body" hidden>
508
+ <!-- ══ THE TABLE IN THE WORLD (task 1003332) ══ The table is a view of the same
509
+ world as the sky: a masthead in the world's material — the view title in
510
+ the display voice and one chrome object — then ledger rows. The owner's
511
+ pick from three rendered treatments (docs/design/projects-hub-direction-v2.md
512
+ §9): "the ledger", with the title cut to two words. -->
513
+ <div class="tblMast">
514
+ <h2 class="tblTitle">every project</h2>
515
+ <span class="mastObj" aria-hidden="true"></span>
516
+ </div>
511
517
  <table class="tbl">
512
518
  <thead>
513
519
  <tr>
@@ -533,7 +539,7 @@ summary{min-height:24px;padding:3px 0;}
533
539
  <td class="right num" id="selfBuilders">—</td>
534
540
  <td class="colTags" id="selfTags">—</td>
535
541
  <td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
536
- <td class="colGo"><a class="btn small ghost" href="/">Open →</a></td>
542
+ <td class="colGo"><a class="rowGo" href="/">Open →</a></td>
537
543
  </tr>
538
544
  <!--apex-only:end-->
539
545
  </tbody>
@@ -1070,7 +1076,7 @@ summary{min-height:24px;padding:3px 0;}
1070
1076
 
1071
1077
  </main>
1072
1078
 
1073
- <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud&nbsp;Bongos</span>. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><!--apex-only:start--><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a><!--apex-only:end--></nav></footer>
1079
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud&nbsp;Bongos</span>. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.<nav class="footNav" aria-label="Site"><a href="/#about">About</a><a href="/docs">Docs</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><!--apex-only:start--><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a><!--apex-only:end--></nav></footer>
1074
1080
 
1075
1081
  <script>
1076
1082
  /* Brand-pack fill (R57): the server injects window.__BRANDING__ ahead of this
@@ -1485,7 +1491,7 @@ summary{min-height:24px;padding:3px 0;}
1485
1491
  '<td class="right colStatus">' + (p._inst ? chipFor(p._inst)
1486
1492
  : '<span class="chip calm"><span class="cdot"></span>—</span>') + '</td>' +
1487
1493
  '<td class="colGo">' + (safeHttpUrl(p.origin)
1488
- ? '<a class="btn small ghost" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">Open →</a>'
1494
+ ? '<a class="rowGo" href="' + esc(safeHttpUrl(p.origin)) + '" target="_blank" rel="noopener">Open →</a>'
1489
1495
  : '') + '</td>' +
1490
1496
  '</tr>';
1491
1497
  }).join(''));
@@ -4023,6 +4029,17 @@ summary{min-height:24px;padding:3px 0;}
4023
4029
  both re-POST the slug (the server's PROVISION_ENQUEUE_FROM gate) */
4024
4030
  function canRetry(inst) { return inst.status === 'error' && !inst.open_intent; }
4025
4031
  function canRevive(inst) { return inst.status === 'torn_down' && !inst.open_intent; }
4032
+ /* the restart door (task 1003238): client mirror of RESTART_FROM {active} +
4033
+ RESTART_SHAPES (a dedicated droplet has no runner exec path), shown ONLY
4034
+ where it has a reason to exist — a project the heartbeat cannot reach.
4035
+ last_verified_up === false is the verdict; null means never probed and is
4036
+ deliberately NOT the same claim (publicInstance keeps the two apart), so a
4037
+ brand-new project never grows a Restart button it has no use for. A door on
4038
+ every healthy card would only invite needless downtime. */
4039
+ function canRestart(inst) {
4040
+ return inst.status === 'active' && inst.last_verified_up === false &&
4041
+ inst.hosting_shape !== 'dedicated' && !inst.open_intent;
4042
+ }
4026
4043
  /* keep watching while the platform is doing something to this instance */
4027
4044
  function shouldWatch(inst) {
4028
4045
  if (inst.status === 'requested' || inst.status === 'provisioning' || inst.status === 'tearing_down') return true;
@@ -4053,7 +4070,8 @@ summary{min-height:24px;padding:3px 0;}
4053
4070
  revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
4054
4071
  teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
4055
4072
  address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' },
4056
- settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
4073
+ settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' },
4074
+ restart: { names: ['Restart queued', 'Restarting', 'Answering again'], cls: '' }
4057
4075
  };
4058
4076
  function stripMarkup(flow, stageNow, stagesDone, note, extraCls) {
4059
4077
  var f = FLOWS[flow];
@@ -4090,6 +4108,11 @@ summary{min-height:24px;padding:3px 0;}
4090
4108
  if (a === 'settings-apply') return inst.open_intent.state === 'running'
4091
4109
  ? stripMarkup('settings', 1, 1, 'Applying a settings change — the project restarts to pick it up.')
4092
4110
  : stripMarkup('settings', 0, 0, 'A settings change is queued — this card updates when it starts.');
4111
+ /* task 1003238: the owner asked for a restart — say so, and never claim
4112
+ it came back before a probe has said it did */
4113
+ if (a === 'restart') return inst.open_intent.state === 'running'
4114
+ ? stripMarkup('restart', 1, 1, 'Restarting the project — it goes down and comes back up.')
4115
+ : stripMarkup('restart', 0, 0, 'A restart is queued — this card updates when it starts.');
4093
4116
  if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
4094
4117
  return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
4095
4118
  }
@@ -4176,7 +4199,17 @@ summary{min-height:24px;padding:3px 0;}
4176
4199
  /* the runner had no DNS hook (F16): the CAUSE beats the bare symptom —
4177
4200
  a successful A record flips last_verified_up and retires the note */
4178
4201
  if (inst.dns_note) return 'Its address is ' + link + '. ' + esc(inst.dns_note);
4179
- if (inst.last_verified_up === false) return 'Not answering at ' + link + ' right now. We keep checking every few minutes and update this on its own if it comes back.';
4202
+ /* task 1003238: there IS an action now, so the sentence names it — the old
4203
+ copy could only counsel patience because nothing else was on offer. It
4204
+ names it through canRestart(), the SAME predicate the button renders on:
4205
+ a dedicated-shape project (no runner exec path) or one with work already
4206
+ queued gets no button, and pointing it at one would be the exact
4207
+ misdirection ADR 0199 §8 forbids — a refusal must name the action that IS
4208
+ available. Where there is no button the sentence stops at the full stop. */
4209
+ if (inst.last_verified_up === false) {
4210
+ return 'Not answering at ' + link + ' right now. We keep checking every few minutes and update this on its own if it comes back' +
4211
+ (canRestart(inst) ? ' — or restart it yourself below.' : '.');
4212
+ }
4180
4213
  if (inst.last_verified_up) return 'Its address is ' + link + ' — last answered ' + esc(relTime(verifiedAt(inst))) + '.';
4181
4214
  return 'Its address is ' + link + ' — confirming it answers.';
4182
4215
  }
@@ -4263,6 +4296,7 @@ summary{min-height:24px;padding:3px 0;}
4263
4296
  if (inst.status === 'active' && inst.domain) acts.unshift('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
4264
4297
  if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-retry="' + iid + '">Try again</button>');
4265
4298
  if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-revive="' + iid + '">Bring it back</button>');
4299
+ if (canRestart(inst)) acts.push('<button type="button" class="btn small" data-restart="' + iid + '">Restart it</button>');
4266
4300
  if (canTeardown(inst)) acts.push('<button type="button" class="btn small danger" data-teardown="' + iid + '">Tear it down</button>');
4267
4301
  if (acts.length) body += '<div class="actions" id="act-' + iid + '">' + acts.join('') + '</div>';
4268
4302
  body += attachFold(inst, iid);
@@ -4299,7 +4333,7 @@ summary{min-height:24px;padding:3px 0;}
4299
4333
  if (!el || !el.getAttribute) return null;
4300
4334
  if (el.tagName === 'SUMMARY') return 'details.attachFold summary';
4301
4335
  if (el.hasAttribute('data-teardown-yes') || el.hasAttribute('data-teardown-no')) return '[data-teardown]';
4302
- var keys = ['data-manage', 'data-attach', 'data-retry', 'data-revive', 'data-teardown'];
4336
+ var keys = ['data-manage', 'data-attach', 'data-retry', 'data-revive', 'data-restart', 'data-teardown'];
4303
4337
  for (var i = 0; i < keys.length; i++) if (el.hasAttribute(keys[i])) return '[' + keys[i] + ']';
4304
4338
  return null;
4305
4339
  }
@@ -4539,6 +4573,7 @@ summary{min-height:24px;padding:3px 0;}
4539
4573
  if (inst.status === 'active' && inst.domain) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
4540
4574
  if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-m-retry>Try again</button>');
4541
4575
  if (canRevive(inst)) acts.push('<button type="button" class="btn small" data-m-revive>Bring it back</button>');
4576
+ if (canRestart(inst)) acts.push('<button type="button" class="btn small" data-m-restart>Restart it</button>');
4542
4577
  $('mActs').innerHTML = acts.join('');
4543
4578
  $('mActs').hidden = !acts.length;
4544
4579
  }
@@ -4575,11 +4610,33 @@ summary{min-height:24px;padding:3px 0;}
4575
4610
  box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
4576
4611
  }).then(function () { loadManage(true, true); });
4577
4612
  }
4613
+ /* Restart from the project's own page — the same POST the list card sends */
4614
+ function mRestart() {
4615
+ var inst = manageInst;
4616
+ if (!inst) return;
4617
+ var box = $('mMsg');
4618
+ box.className = 'projMsg'; box.textContent = 'Asking the platform to restart it…';
4619
+ $('mActs').querySelectorAll('button').forEach(function (b) { b.disabled = true; });
4620
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/restart', {
4621
+ method: 'POST', credentials: 'same-origin'
4622
+ }).then(function (res) {
4623
+ if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
4624
+ return jsonOrEmpty(res).then(function (data) {
4625
+ if (res.ok) { box.className = 'projMsg ok'; box.textContent = data.message || 'Restart queued.'; return; }
4626
+ box.className = 'projMsg err';
4627
+ box.textContent = restartFailure(data, res.status);
4628
+ });
4629
+ }).catch(function () {
4630
+ box.className = 'projMsg err';
4631
+ box.textContent = 'Couldn’t reach the platform — check your connection and try again.';
4632
+ }).then(function () { loadManage(true, true); });
4633
+ }
4578
4634
  $('mActs').addEventListener('click', function (ev) {
4579
4635
  var t = ev.target.closest && ev.target.closest('button');
4580
4636
  if (!t) return;
4581
4637
  if (t.hasAttribute('data-m-retry')) return mReProvision('retry');
4582
4638
  if (t.hasAttribute('data-m-revive')) return mReProvision('revive');
4639
+ if (t.hasAttribute('data-m-restart')) return mRestart();
4583
4640
  });
4584
4641
 
4585
4642
  /* an IN-FLIGHT project is watched here at the card's cadence — its own copy
@@ -5315,6 +5372,39 @@ summary{min-height:24px;padding:3px 0;}
5315
5372
  });
5316
5373
  }
5317
5374
 
5375
+ /* ── restart an unreachable project (task 1003238). Its own route, because
5376
+ PROVISION_ENQUEUE_FROM excludes 'active' — re-POSTing the slug the way
5377
+ Try again / Bring it back do would be refused on every press. Every
5378
+ refusal is shown as the server worded it: the server owns the gate, and
5379
+ a card that invents its own reason drifts from it. ── */
5380
+
5381
+ function restartFailure(data, status) {
5382
+ var e = apiError(data, status);
5383
+ if (e.code === 'rate_limited') return e.message || 'Too many requests right now — give it a moment and try again.';
5384
+ return e.message || 'The restart could not be queued — try again in a moment.';
5385
+ }
5386
+
5387
+ function doRestart(id) {
5388
+ var inst = byId[id];
5389
+ if (!inst) return;
5390
+ quietActions(id);
5391
+ msg(id, 'Asking…');
5392
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/restart', {
5393
+ method: 'POST', credentials: 'same-origin'
5394
+ }).then(function (res) {
5395
+ if (res.status === 401) { msg(id, signInAgain(), 'err'); return null; }
5396
+ return jsonOrEmpty(res).then(function (data) {
5397
+ if (res.ok) { msg(id, esc(data.message || 'Restart queued.'), 'ok'); return; }
5398
+ msg(id, esc(restartFailure(data, res.status)), 'err');
5399
+ });
5400
+ }).catch(function () {
5401
+ msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
5402
+ }).then(function () {
5403
+ if (byId[id]) renderCard(byId[id]);
5404
+ watchOnce(id);
5405
+ });
5406
+ }
5407
+
5318
5408
  /* ── domain attach: same truth-telling for every refusal (task 1002692) ── */
5319
5409
 
5320
5410
  function doAttach(id) {
@@ -5373,6 +5463,7 @@ summary{min-height:24px;padding:3px 0;}
5373
5463
  if (t.hasAttribute('data-attach')) return doAttach(t.getAttribute('data-attach'));
5374
5464
  if (t.hasAttribute('data-retry')) return reProvision(t.getAttribute('data-retry'), 'retry');
5375
5465
  if (t.hasAttribute('data-revive')) return reProvision(t.getAttribute('data-revive'), 'revive');
5466
+ if (t.hasAttribute('data-restart')) return doRestart(t.getAttribute('data-restart'));
5376
5467
  });
5377
5468
 
5378
5469
  /* ── boot: who am I → my instances ────────────────────────────────────── */
@@ -5458,10 +5549,14 @@ summary{min-height:24px;padding:3px 0;}
5458
5549
  /* Boot. The view is set FIRST, from the URL, so the page is navigable before
5459
5550
  any request resolves — /projects?view=new must land on the create view even
5460
5551
  if the identity call is slow or fails. */
5552
+ /* the boot query is read ONCE, before the view is set: setView rewrites the
5553
+ URL to ?view=<v> and would drop everything else (the Table deep link from
5554
+ the landing landed on the sky until task 1003332) */
5555
+ var bootQ = new URLSearchParams(location.search);
5461
5556
  setView(viewFromUrl());
5462
5557
  /* the Table item's deep link (and the landing's) names the sub-view; a view change
5463
5558
  never writes it back, so a reload from the band returns to the sky as before */
5464
- if (new URLSearchParams(location.search).get('sub') === 'table') setSub('table');
5559
+ if (bootQ.get('sub') === 'table') setSub('table');
5465
5560
 
5466
5561
  fetch(API + '/me', { credentials: 'same-origin' })
5467
5562
  .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
@@ -3,26 +3,29 @@
3
3
  "page": "/projects",
4
4
  "surface": "public-landing",
5
5
  "probes": [
6
- { "name": "Enter opens Projects and lands on the first item", "auth": true, "width": 1440,
6
+ { "name": "closed: the main content starts under the bar", "auth": true, "width": 1440,
7
+ "steps": [],
8
+ "expect": { "expanded": false, "top": [["#hubMain", 64]] } },
9
+ { "name": "Enter opens Projects and lands on the first item; the band overlays and the content does not move (task 1003332)", "continue": true,
7
10
  "steps": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 150]],
8
- "expect": { "open": "#menuProjects", "activeText": "Sky" } },
11
+ "expect": { "open": "#menuProjects", "activeText": "Sky", "top": [["#hubMain", 64]] } },
9
12
  { "name": "ArrowDown walks to Table", "continue": true,
10
13
  "steps": [["press", "ArrowDown"]],
11
14
  "expect": { "activeText": "Table" } },
12
- { "name": "ArrowUp wraps to the last item (Docs, the About/Docs row)", "continue": true,
15
+ { "name": "ArrowUp wraps to the last item (New project; About and Docs are footer links, task 1003332)", "continue": true,
13
16
  "steps": [["press", "ArrowUp"], ["press", "ArrowUp"]],
14
- "expect": { "activeText": "Docs" } },
17
+ "expect": { "activeText": "New project" } },
15
18
  { "name": "Escape closes and returns focus to the trigger", "continue": true,
16
19
  "steps": [["press", "Escape"], ["wait", 100]],
17
20
  "expect": { "closed": "#menuProjects", "activeId": "projTrig", "expanded": false } },
18
21
  { "name": "opening the profile band closes the Projects band", "continue": true,
19
22
  "steps": [["click", "#projTrig"], ["wait", 120], ["click", "#who"], ["wait", 120]],
20
- "expect": { "open": "#menuProfile", "closed": "#menuProjects", "expandedIds": ["who"] } },
23
+ "expect": { "open": "#menuProfile", "closed": "#menuProjects", "expandedIds": ["who"], "top": [["#hubMain", 64]] } },
21
24
  { "name": "a click outside closes", "continue": true,
22
25
  "steps": [["clickAt", 700, 600], ["wait", 120]],
23
26
  "expect": { "closed": "#menuProfile", "expanded": false } },
24
27
  { "name": "Tab out of the band closes it", "continue": true,
25
- "steps": [["click", "#projTrig"], ["wait", 120], ["focus", "#menuProjects a[href=\"/docs\"]"], ["press", "Tab"], ["wait", 120]],
28
+ "steps": [["click", "#projTrig"], ["wait", 120], ["focus", "#menuProjects a[data-nav=\"new\"]"], ["press", "Tab"], ["wait", 120]],
26
29
  "expect": { "closed": "#menuProjects" } },
27
30
  { "name": "Table from the band: the table on stage, Table current, band closed", "continue": true,
28
31
  "steps": [["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"table\"]"], ["wait", 300]],
@@ -54,12 +57,16 @@
54
57
  { "name": "the mode toggle: the label says what the press does, and the press does it", "continue": true,
55
58
  "steps": [["clickAt", 700, 600], ["click", "#modeToggle"], ["wait", 100]],
56
59
  "expect": { "attr": [["#modeToggle", "aria-label", "Switch to light mode"], ["html", "data-mode", "dark"]] } },
60
+ { "name": "the Table deep link (the landing's band item) lands on the table, Table current (task 1003332)", "auth": true, "width": 1440, "url": "/projects?view=map&sub=table",
61
+ "steps": [["wait", 300]],
62
+ "expect": { "visible": ["#sub-table-body", ".tblMast"], "hidden": ["#sub-sky-body"], "text": [["[aria-current=\"page\"]", "Table"], [".tblTitle", "every project"]] } },
57
63
  { "name": "signed out: no chip, the door shows, no switch, the search stays", "auth": false, "width": 1440,
58
64
  "steps": [],
59
65
  "expect": { "hidden": ["#who", ".switch"], "visible": ["#signinTop", "#qWrap"] } },
60
- { "name": "signed out the Projects band is Sky / Table / New project + About / Docs (no Your projects)", "continue": true,
66
+ { "name": "signed out the Projects band is Sky / Table / New project (no Your projects); About and Docs are in the footer row", "continue": true,
61
67
  "steps": [["click", "#projTrig"], ["wait", 120]],
62
- "expect": { "count": [["#menuProjects .bandCell:not([hidden])", 5]], "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
68
+ "expect": { "count": [["#menuProjects .bandCell:not([hidden])", 3], ["#menuProjects a[href=\"/docs\"]", 0]], "hidden": ["#menuProjects [data-nav=\"mine\"]"],
69
+ "href": [[".footNav a[href=\"/#about\"]", "/#about"], [".footNav a[href=\"/docs\"]", "/docs"]] } },
63
70
  { "name": "signed out: the card still closes on Escape (nothing throws with no session)", "continue": true,
64
71
  "steps": [["press", "Escape"], ["wait", 100], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 400], ["press", "Escape"], ["wait", 150]],
65
72
  "expect": { "dialog": false, "closed": "#menuProjects" } },
@@ -69,9 +76,9 @@
69
76
  { "name": "390: Escape folds the row and returns focus to the magnifier", "continue": true,
70
77
  "steps": [["press", "Escape"], ["wait", 100]],
71
78
  "expect": { "activeId": "qToggle", "hasClass": [["#qWrap", "is-open", false]] } },
72
- { "name": "390: an opening band folds the search", "continue": true,
79
+ { "name": "390: an opening band folds the search and overlays the sky (the content does not move)", "continue": true,
73
80
  "steps": [["click", "#qToggle"], ["wait", 100], ["click", "#projTrig"], ["wait", 150]],
74
- "expect": { "hasClass": [["#qWrap", "is-open", false]], "open": "#menuProjects" } },
81
+ "expect": { "hasClass": [["#qWrap", "is-open", false]], "open": "#menuProjects", "top": [["#hubMain", 60]] } },
75
82
  { "name": "390: a solo Shift tap opens the fold and focuses the field, without overflow", "continue": true,
76
83
  "steps": [["press", "Escape"], ["wait", 100], ["press", "Shift"], ["wait", 150]],
77
84
  "expect": { "hasClass": [["#qWrap", "is-open", true]], "activeId": "mapQ", "noOverflow": true } }
@@ -14,7 +14,7 @@
14
14
  "in-map-kbd": { "auth": true, "actions": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 350], ["press", "ArrowDown"], ["wait", 120]], "expect": { "expanded": true, "activeText": "Table" } },
15
15
  "in-map-search": { "auth": true, "actions": [["clickIfVisible", "#qToggle"], ["wait", 200], ["fill", "#mapQ", "emer"], ["wait", 300]], "expect": { "value": [["#mapQ", "emer"]] } },
16
16
  "in-map-mine-on": { "auth": true, "actions": [["check", "#onlyMine"], ["wait", 300]], "expect": { "checked": "#onlyMine" } },
17
- "in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body"], "closed": "#menuProjects" } },
17
+ "in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body", ".tblMast", ".mastObj"], "closed": "#menuProjects" } },
18
18
  "in-mine": { "auth": true, "url": "/projects?view=mine", "expect": { "hidden": ["#qWrap"] } },
19
19
  "in-new": { "auth": true, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
20
20
  "out-new": { "auth": false, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
@@ -768,6 +768,107 @@ async function settingsApplyInstance(inst, deps) {
768
768
  return { ok: true, healthy, consumed };
769
769
  }
770
770
 
771
+ // ---------------------------------------------------------------------------
772
+ // Restart an ALREADY-ACTIVE instance the heartbeat cannot reach (task 1003238 —
773
+ // the honest owner action behind the card's "unreachable" line). This is
774
+ // settingsApplyInstance minus the env patch: nothing about the instance's
775
+ // configuration changes, so there is no file to read and no file to write — just
776
+ // the restart and the same PATIENT healthz verify, whose result is recorded on
777
+ // the liveness overlay so the card that sent the owner here updates itself.
778
+ //
779
+ // Why its own leg rather than a re-provision: PROVISION_ENQUEUE_FROM deliberately
780
+ // excludes 'active' (task 1002691), and provisionInstance is a no-op on an active
781
+ // instance by design — a full standup is the wrong tool for "turn it off and on
782
+ // again". Why not settings-apply: that leg only runs behind a value that actually
783
+ // CHANGED, which is exactly the case a stuck project does not have.
784
+ //
785
+ // Like the settings push, EVERY expected failure RETURNS { ok:false } instead of
786
+ // throwing: the drain loop's catch flips the instance to 'error', and an instance
787
+ // that is merely unreachable must not be relabelled broken by the very action
788
+ // meant to recover it — that would drop it off the liveness sweep and leave a
789
+ // re-provision as its only exit (ADR 0190 §2).
790
+ // ---------------------------------------------------------------------------
791
+ async function restartInstance(inst, deps) {
792
+ const { db, provisioning, exec, log, apply, privileged } = deps;
793
+ // Same box routing as settingsApplyInstance: a standalone runs control-plane-local.
794
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
795
+ const sudoP = privileged ? 'sudo ' : '';
796
+ log(`restart ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
797
+
798
+ // A clean no-op (not an error) so a teardown that raced the restart never burns
799
+ // the intent's retry budget: every non-active state either has nothing running
800
+ // yet or is being reclaimed.
801
+ if (inst.status !== 'active') {
802
+ log(` instance is '${inst.status}' — nothing running to restart`);
803
+ return { ok: true, noop: true };
804
+ }
805
+ // A dedicated droplet has no runner exec path (the placeManifestCreds refusal).
806
+ // The route refuses to enqueue for this shape too; this is the second gate.
807
+ if (inst.hosting_shape === 'dedicated') {
808
+ return { ok: false, error: `restart is not automated for a dedicated droplet — run 'systemctl restart ${inst.slug}' on that machine` };
809
+ }
810
+ if (!apply) {
811
+ boxExec(`${sudoP}systemctl restart ${inst.slug}`);
812
+ boxExec(healthzCmd(inst), { allowFail: true });
813
+ log(` ✓ would restart ${inst.slug} and wait for it to answer (dry-run — nothing ran)`);
814
+ return { ok: true, healthy: null };
815
+ }
816
+ try {
817
+ log(' [service] restart');
818
+ boxExec(`${sudoP}systemctl restart ${inst.slug}`);
819
+ } catch (e) {
820
+ // ok:false, never a throw — the retry simply restarts again (idempotent).
821
+ return { ok: false, error: `could not restart ${inst.slug}: ${(e && e.message) || e}` };
822
+ }
823
+
824
+ // Verify it came back, with the manifest probe's patience (the F23 lesson). A
825
+ // false "down" is never cosmetic here: this instance is ALREADY flagged
826
+ // unreachable, and a hasty verdict would tell the owner their restart failed.
827
+ log(' [healthz] verify the instance answers after the restart');
828
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
829
+ let healthy = false;
830
+ for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
831
+ const res = boxExec(healthzCmd(inst), { allowFail: true });
832
+ if (res && !res.softFailed) { healthy = true; break; }
833
+ if (i < MANIFEST_VERIFY_TRIES - 1) await sleep(MANIFEST_VERIFY_INTERVAL_MS);
834
+ }
835
+ // Same narrowness as the settings push: a loopback PASS proves the process, not
836
+ // the address, so only the domain-scoped sweep may claim "up" for an instance
837
+ // that HAS a domain. Silence after the whole budget is a verdict on any shape.
838
+ const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
839
+ if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
840
+ // A restart that worked retires any "we stopped trying" note left by earlier
841
+ // work (the F19 latch) — the note outliving its own recovery is the bug.
842
+ // DELIBERATELY gated on `healthy`, NOT on probeIsVerdict: the two answer
843
+ // different questions. probeIsVerdict asks "may this run claim the instance is
844
+ // UP?" — a loopback pass may not, for an instance whose address the sweep owns.
845
+ // The note asks "did work stop partway?", and a process that restarted and
846
+ // answered is that work finishing, whatever the public address does next.
847
+ // Holding the note until the sweep agrees would leave a stale "nothing more
848
+ // will happen" on the card of a project that just visibly recovered.
849
+ if (healthy && provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
850
+ // Name WHAT was observed, not a verdict this probe cannot reach. healthzCmd is
851
+ // loopback (127.0.0.1:<port>) while the liveness sweep probes the PUBLIC
852
+ // https://<domain>/healthz — so an instance can answer here while DNS, Caddy or
853
+ // TLS is the thing actually broken. "Answering again" on a domained instance
854
+ // would contradict the card still reading "unreachable"; the same asymmetry is
855
+ // why probeIsVerdict above declines to write an up-verdict for that case.
856
+ const outcome = !healthy ? 'restarted, but it is not answering on the box — something there needs a look'
857
+ : inst.domain ? 'restarted and answering on the box; whether its public address answers is for the next liveness sweep to say'
858
+ : 'restarted and answering again';
859
+ await provisioning.recordEvent(db, {
860
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'restarted',
861
+ detail: `restart requested — ${outcome}`,
862
+ actor: 'runner',
863
+ }).catch(() => {});
864
+ log(healthy ? ` ✓ ${inst.slug} ${outcome}` : ` ⚠ ${inst.slug} ${outcome}`);
865
+ // ok:true either way: the restart RAN. A project that stays down is a truthful
866
+ // observation for the card, not a failed intent to retry MAX_INTENT_ATTEMPTS
867
+ // times — restarting a broken instance four more times fixes nothing and would
868
+ // end by stamping an error_note over the owner's own deliberate action.
869
+ return { ok: true, healthy };
870
+ }
871
+
771
872
  // ---------------------------------------------------------------------------
772
873
  // Teardown one instance (reverse the runbook; idempotent).
773
874
  // ---------------------------------------------------------------------------
@@ -1049,6 +1150,7 @@ async function cmdRunIntents(deps) {
1049
1150
  else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
1050
1151
  else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1051
1152
  else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
1153
+ else if (intent.action === 'restart') result = await restartInstance(inst, deps);
1052
1154
  else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
1053
1155
  if (result && result.ok === false) {
1054
1156
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
@@ -1238,6 +1340,8 @@ module.exports = {
1238
1340
  domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
1239
1341
  // live settings push (task 1003140) + the in-place env patch + read-back it rides on
1240
1342
  settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
1343
+ // restart an unreachable ACTIVE instance (task 1003238) — the settings leg minus the env patch
1344
+ restartInstance,
1241
1345
  // GitHub App Manifest flow (task 2080, ADR 0133)
1242
1346
  convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1243
1347
  // cross-site identity federation (ADR 0141 §4, task #1002109)
package/src/module-api.js CHANGED
@@ -1238,8 +1238,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1238
1238
  // landed since 1.19.397 with no explicit bump. run 33199474439. (task 1002620)
1239
1239
  // 1.19.399 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1240
1240
  // landed since 1.19.398 with no explicit bump. run 33201243958. (task 1002620)
1241
+ // 1.19.400 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1242
+ // landed since 1.19.399 with no explicit bump. run 33201979306. (task 1002620)
1243
+ // 1.19.401 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1244
+ // landed since 1.19.400 with no explicit bump. run 33205015717. (task 1002620)
1241
1245
  // ---------------------------------------------------------------------------
1242
- const CORE_VERSION = '1.19.399'; // CI auto-patch carrier (ADR 0161); see changelog above
1246
+ const CORE_VERSION = '1.19.401'; // CI auto-patch carrier (ADR 0161); see changelog above
1243
1247
 
1244
1248
  // A namespaced logger so a module's log lines are attributable + consistent.
1245
1249
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -475,14 +475,17 @@ test('the bar is markup: the mark, the Projects trigger, the mode glyph, the sig
475
475
  assert.match(PAGE_CSS, /\.topbar\{position:relative;z-index:50;height:var\(--top-h\);display:flex;align-items:center;gap:22px;\s*padding:0 26px;background:var\(--bg\);border-bottom:1px solid var\(--rule\)\}/, 'the page ground closed by one hairline, z-index unified with the hub');
476
476
  });
477
477
 
478
- test('the Projects band deep-links the hub\'s views; About and Docs sit under a hairline; no item is current here', () => {
478
+ test('the Projects band deep-links the hub\'s views; About and Docs are footer links, not band items; no item is current here', () => {
479
479
  const body = HTML.split('<body>')[1].split('<script>')[0];
480
480
  assert.match(body, /<div class="menuBand" id="menuProjects" data-menu="projects" hidden>/);
481
481
  assert.match(body, /<a class="bandT" href="\/projects\?view=map">Sky<\/a>/);
482
482
  assert.match(body, /<a class="bandT" href="\/projects\?view=map&amp;sub=table">Table<\/a>/, 'the Table item lands on the table (the hub reads sub=table on boot)');
483
483
  assert.match(body, /<li class="bandCell" id="mineCell" hidden><a class="bandT" href="\/projects\?view=mine">Your projects<\/a>/, 'member-gated: hidden until the session resolves');
484
484
  assert.match(body, /<a class="bandT" href="\/projects\?view=new">New project<\/a>/);
485
- assert.match(body, /<ul class="bandRow bandMore">\s*<li class="bandCell"><a class="bandT" href="#about">About<\/a><\/li>\s*<li class="bandCell"><a class="bandT" href="\/docs">Docs<\/a><\/li>/, 'About and Docs under the hairline (§5)');
485
+ // About and Docs are site pages, not project views (owner, 2026-08-28; task 1003332):
486
+ // they left the band for the footer row, beside Privacy / Terms / Contact
487
+ assert.doesNotMatch(body, /bandMore|<a class="bandT" href="#about">|<a class="bandT" href="\/docs">/, 'About and Docs are not in the Projects band');
488
+ assert.match(body, /<a href="#about">About<\/a>\s*<a href="\/docs">Docs<\/a>\s*<a href="\/privacy">Privacy<\/a>/, 'About and Docs open the footer row');
486
489
  assert.match(body, /<section class="band" id="about">/, 'About has a real anchor: the lifecycle band');
487
490
  assert.doesNotMatch(body.split('<main')[0], /aria-current/, 'the landing is none of those views');
488
491
  // the profile band: the person, My projects, the deferred slot, Sign out as the honest anchor
@@ -496,9 +499,10 @@ test('the Projects band deep-links the hub\'s views; About and Docs sit under a
496
499
  assert.ok(body.includes('>' + copy + '<'), `"${copy}" is only reachable with scripting on`);
497
500
  }
498
501
  // the menu container is .menuBand: .band is this page's own content sections
499
- assert.match(PAGE_CSS, /\.menuBand\{position:relative;z-index:49;background:var\(--bg-card\);border-bottom:1px solid var\(--rule\);/);
502
+ // the bands OVERLAY the page (task 1003332): absolute at the bar's height, so the hero never reflows
503
+ assert.match(PAGE_CSS, /\.menuBand\{position:absolute;left:0;right:0;top:var\(--top-h\);z-index:49;background:var\(--bg-card\);border-bottom:1px solid var\(--rule\);/);
500
504
  assert.doesNotMatch(PAGE_CSS, /\.band\{[^}]*animation:bandIn/, 'the menu rule never lands on the content bands');
501
- assert.match(PAGE_CSS, /\.bandMore \.bandT\{font-size:17px;font-weight:400;min-height:24px;display:inline-flex;align-items:center\}/, 'the About/Docs links clear the 24px floor');
505
+ assert.doesNotMatch(PAGE_CSS, /\.bandMore/, 'the About/Docs rung left the sheet with the markup');
502
506
  });
503
507
 
504
508
  test('the bands are one disclosure: Escape returns focus, arrows cycle, Tab out closes, one closes the other', () => {
@@ -49,7 +49,14 @@ test('the Projects band carries the views; Home is gone and the mark is the way
49
49
  assert.match(HUB, /href="\?view=map" data-nav="map" data-sub="sky" aria-current="page">Sky<\/a>/);
50
50
  assert.match(HUB, /href="\?view=map&amp;sub=table" data-nav="map" data-sub="table">Table<\/a>/);
51
51
  assert.match(SCRIPT, /get\('sub'\) === 'table'\) setSub\('table'\);/, 'the Table deep link lands on the table');
52
- assert.match(HUB, /<a class="bandT" href="\/#about">About<\/a>/, 'About sits under the hairline (§5)');
52
+ // setView rewrites the URL to ?view=<v>; the boot query must be captured BEFORE it
53
+ // runs or sub=table is gone by the time it is read (task 1003332)
54
+ assert.match(SCRIPT, /var bootQ = new URLSearchParams\(location\.search\);\s*setView\(viewFromUrl\(\)\);[\s\S]{0,400}?if \(bootQ\.get\('sub'\) === 'table'\) setSub\('table'\);/, 'the boot query is read once, before setView');
55
+ // About and Docs are site pages, not project views (owner, 2026-08-28; task 1003332):
56
+ // they left the band for the footer row, beside Privacy / Terms / Contact
57
+ assert.doesNotMatch(HUB, /bandMore|<a class="bandT" href="\/#about">|<a class="bandT" href="\/docs">/, 'About and Docs are not in the Projects band');
58
+ assert.match(HUB, /<nav class="footNav" aria-label="Site"><a href="\/#about">About<\/a><a href="\/docs">Docs<\/a><a href="\/privacy">Privacy<\/a>/, 'About and Docs open the footer row');
59
+ assert.doesNotMatch(COSMOS, /\.bandMore/, 'the About/Docs rung left cosmos with the markup');
53
60
  assert.doesNotMatch(HUB, /id="sub-sky"|id="sub-table"|class="toolbar"|class="seg"/, 'the toolbar and its seg are retired');
54
61
  assert.doesNotMatch(SCRIPT, /dataset\.nav === 'home'/, 'no dead Home branch in the router');
55
62
  assert.match(SCRIPT, /if \(a\.dataset\.sub\) setSub\(a\.dataset\.sub\);/, 'a band item may pick the map sub-view');
@@ -138,6 +145,10 @@ test('the bands: one disclosure, two instances — Escape returns focus, the car
138
145
  assert.match(HUB, /<div class="menuBand" id="menuProjects" data-menu="projects" hidden>/);
139
146
  assert.match(HUB, /<div class="menuBand" id="menuProfile" data-menu="profile" hidden>/);
140
147
  assert.doesNotMatch(HUB, /role="menu"|aria-haspopup/, 'plain links in a disclosure — announced as what ships');
148
+ // the bands OVERLAY the page (owner, 2026-08-28; task 1003332): absolute against the
149
+ // document at the bar's height, so opening one never reflows the sky, the table or the footer
150
+ assert.match(COSMOS, /\.menuBand\{position:absolute;left:0;right:0;top:var\(--top-h\);z-index:49;background:var\(--bg-card\);border-bottom:1px solid var\(--rule\);/, 'a band is a layer under the bar, never a push');
151
+ assert.doesNotMatch(COSMOS, /PUSHES the page/, 'the sheet no longer describes the push-down');
141
152
  assert.match(SCRIPT, /function closeMenus\(\) \{[\s\S]*?if \(inside && trig\) trig\.focus\(\);/, 'focus restoration is a property of the close, not of one caller');
142
153
  assert.match(SCRIPT, /if \(ev\.key === 'Escape' && !openBand\(\)\) closeCard\(\);/, 'the card yields Escape to an open band');
143
154
  assert.match(SCRIPT, /function setView\(v, focus\) \{[\s\S]*?\$\('qWrap'\)\.hidden = \$\('qToggle'\)\.hidden = \(v !== 'map'\);/, 'the search hides off the map in the router');
@@ -525,6 +536,22 @@ test('no sort control is offered for a column that cannot sort', () => {
525
536
  }
526
537
  });
527
538
 
539
+ test('the table is in the world: a masthead, then ledger rows — no panel, no shadow, the accent only on the row\'s door (task 1003332)', () => {
540
+ // the owner's pick from three rendered treatments: the view title in the display
541
+ // voice and one chrome object; the title is two words ("every project", the owner cut ", as rows")
542
+ assert.match(HUB, /<div class="tblMast">\s*<h2 class="tblTitle">every project<\/h2>\s*<span class="mastObj" aria-hidden="true"><\/span>\s*<\/div>\s*<table class="tbl">/, 'one masthead ahead of the table');
543
+ assert.doesNotMatch(HUB, /tblMast--t[123]|data-tbl|mastCallouts|mastField/, 'the render round\'s switch and the unpicked mastheads left with the pick');
544
+ assert.match(COSMOS, /\.tbl\{width:100%;border-collapse:collapse;background:none;\}/, 'the rows sit on the page ground — no card');
545
+ assert.doesNotMatch((COSMOS.match(/\.tbl\{[^}]*\}/) || [''])[0], /border-radius|box-shadow/, 'no radius, no shadow on the table');
546
+ assert.match(COSMOS, /\.tbl td\{padding:17px 14px;border-bottom:1px solid var\(--rule-soft\);/, 'ledger rows: hairlines');
547
+ assert.match(COSMOS, /\.tbl tbody tr:hover td\{border-bottom-color:var\(--ink-faint\);\}/, 'hover is the hairline taking ink, not an accent tint');
548
+ assert.match(COSMOS, /\.rowGo\{[^}]*color:var\(--accent-deep\);text-decoration:underline;/, 'the row\'s door is accent text');
549
+ assert.match(HUB, /<a class="rowGo" href="\/">Open →<\/a>/, 'the self row\'s door');
550
+ assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(safeHttpUrl\(p\.origin\)\)/, 'the generated rows\' door');
551
+ assert.match(COSMOS, /\.mastObj\{display:block;--dia:clamp\(96px,10vw,132px\);[^}]*background-image:var\(--plate-set-cut\);/, 'the object is the set\'s small bead by token, in both modes');
552
+ assert.doesNotMatch(COSMOS, /mix-blend-mode:var\(--mast/, 'the cutout needs no blend — the view wrap is a stacking context');
553
+ });
554
+
528
555
  test('every live column keeps its styling — .colType included since task 1002772', () => {
529
556
  for (const live of ['.tbl .num', '.tbl .tags', '.colTags', '.colType', '.typeTag']) {
530
557
  assert.ok(COSMOS.includes(live), `${live} styles a column that renders real data`);