@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.
- package/.bongos-core.json +60 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0199-restart-an-unreachable-active-project.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +233 -232
- package/docs/copy-registry.json +293 -284
- package/docs/design/projects-hub-direction-v2.md +38 -0
- package/docs/recipes/ui-look-before-you-ship.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/migrations/provisioning_011_restart.sql +28 -0
- package/modules/provisioning/provisioning.js +42 -4
- package/modules/provisioning/routes/provisioning.js +59 -0
- package/modules/ui-design/kit/lib.js +6 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +88 -35
- package/public-landing/index.html +13 -15
- package/public-landing/index.probes.json +8 -5
- package/public-landing/projects.html +111 -16
- package/public-landing/projects.probes.json +17 -10
- package/public-landing/projects.states.json +1 -1
- package/scripts/gds/provision.js +104 -0
- package/src/module-api.js +5 -1
- package/tests/landing_page.mjs +8 -4
- package/tests/projects_hub.mjs +28 -1
- package/tests/projects_hub_pre_uat.mjs +40 -0
- package/tests/provision_restart.mjs +242 -0
- package/tests/provisioning_restart.mjs +285 -0
- package/tests/provisioning_settings_apply.mjs +8 -3
- package/tests/provisioning_teardown_intent.mjs +9 -5
- 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:
|
|
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
|
|
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",
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
<!--
|
|
439
|
-
|
|
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="
|
|
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 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 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="
|
|
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
|
-
|
|
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 (
|
|
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": "
|
|
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 (
|
|
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": "
|
|
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[
|
|
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
|
|
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])",
|
|
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"] } },
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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.
|
|
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');
|
package/tests/landing_page.mjs
CHANGED
|
@@ -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
|
|
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&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
|
-
|
|
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
|
-
|
|
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.
|
|
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', () => {
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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&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
|
-
|
|
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`);
|