@bongos/core 1.21.27 → 1.21.29
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 +89 -69
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +399 -391
- package/docs/copy-registry.json +532 -460
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +68 -0
- package/docs/page-readings.json +3737 -3587
- package/modules/hall-ui/public/atlas.html +3 -1
- package/modules/hall-ui/public/blockers.html +3 -1
- package/modules/hall-ui/public/board-room.html +3 -1
- package/modules/hall-ui/public/city.html +3 -1
- package/modules/hall-ui/public/collab.html +3 -1
- package/modules/hall-ui/public/commands.html +3 -1
- package/modules/hall-ui/public/copy-desk.html +3 -1
- package/modules/hall-ui/public/craft-mode.js +297 -0
- package/modules/hall-ui/public/deploy.html +3 -1
- package/modules/hall-ui/public/diagrams.html +3 -1
- package/modules/hall-ui/public/drachmae.html +3 -1
- package/modules/hall-ui/public/fleet.html +3 -1
- package/modules/hall-ui/public/gate.html +3 -1
- package/modules/hall-ui/public/goal-map.html +3 -1
- package/modules/hall-ui/public/goals.html +3 -1
- package/modules/hall-ui/public/government.html +3 -1
- package/modules/hall-ui/public/idea.html +3 -1
- package/modules/hall-ui/public/ideas.html +3 -1
- package/modules/hall-ui/public/index.html +3 -1
- package/modules/hall-ui/public/modules.html +3 -1
- package/modules/hall-ui/public/primer.html +3 -1
- package/modules/hall-ui/public/profile.html +3 -1
- package/modules/hall-ui/public/project-settings.html +3 -1
- package/modules/hall-ui/public/ranks.html +3 -1
- package/modules/hall-ui/public/roadmap.html +3 -1
- package/modules/hall-ui/public/roster.html +3 -1
- package/modules/hall-ui/public/sessions.html +3 -1
- package/modules/hall-ui/public/settings.html +3 -1
- package/modules/hall-ui/public/shell-trail.js +186 -0
- package/modules/hall-ui/public/shell.js +17 -172
- package/modules/hall-ui/public/street.html +3 -1
- package/modules/hall-ui/public/studio.html +3 -1
- package/modules/hall-ui/public/style.css +228 -0
- package/modules/hall-ui/public/task.html +3 -1
- package/modules/hall-ui/public/thinking.html +3 -1
- package/modules/hall-ui/public/tweak-editor.html +3 -1
- package/modules/hall-ui/public/watch.html +3 -1
- package/modules/hall-ui/public/work.html +3 -1
- package/modules/hall-ui/records/craft-mode.md +22 -0
- package/modules/hall-ui/records/hall-navigation.md +1 -1
- package/modules/provisioning/routes/callback-page.js +28 -65
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +68 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/craft_mode.mjs +267 -112
- package/tests/goal_map_page.mjs +2 -2
- package/tests/hall_city.mjs +1 -1
- package/tests/hall_fullscreen.mjs +3 -0
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/provisioning_callback_page.mjs +5 -5
- package/tests/provisioning_github_app_routes.mjs +24 -25
- package/tests/wizard_auto_open.mjs +94 -0
- package/tests/wizard_intent_resume.mjs +17 -2
|
@@ -423,6 +423,10 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
|
|
|
423
423
|
.samePath{font-size:13px;font-weight:400;color:var(--ink-faint);margin-top:18px;}
|
|
424
424
|
.samePath a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;}
|
|
425
425
|
#liveProgress{margin-top:4px;}
|
|
426
|
+
/* the sign-in wait inside its step + the "open it when ready" tick (task 1004548) */
|
|
427
|
+
.humanSteps .prog{margin-top:14px;}
|
|
428
|
+
.autoOpen{display:flex;align-items:flex-start;gap:10px;margin-top:14px;font-size:14px;line-height:1.45;color:var(--ink-soft);cursor:pointer;min-height:24px;}
|
|
429
|
+
.autoOpen input{width:18px;height:18px;margin:1px 0 0;flex:0 0 auto;accent-color:var(--accent);cursor:pointer;}
|
|
426
430
|
|
|
427
431
|
/* ── the standup strip (task 2083), drawn as the lifecycle rail (DESIGN.md):
|
|
428
432
|
three hairlines, a 7px dot, one lit step — no plate, no connector, no
|
|
@@ -6557,6 +6561,35 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6557
6561
|
'<p>The platform team places the generated credentials; this step ticks itself once they land.</p></details>';
|
|
6558
6562
|
}
|
|
6559
6563
|
|
|
6564
|
+
/* "Open it when it's ready" (task 1004548). The founder now waits for sign-in on
|
|
6565
|
+
this checklist instead of on the callback page, so the auto-open lives here: a
|
|
6566
|
+
tick, on unless they untick it (remembered per browser), that opens the project
|
|
6567
|
+
the moment it answers AND sign-in is wired. Armed only by a wait this page has
|
|
6568
|
+
actually watched, so revisiting a long-finished checklist never jumps away. */
|
|
6569
|
+
var AUTO_OPEN_KEY = 'cb-open-when-ready';
|
|
6570
|
+
var autoOpenArmed = false;
|
|
6571
|
+
var autoOpened = false;
|
|
6572
|
+
function autoOpenPref() {
|
|
6573
|
+
try { return localStorage.getItem(AUTO_OPEN_KEY) !== '0'; } catch (e) { return true; }
|
|
6574
|
+
}
|
|
6575
|
+
function autoOpenTick() {
|
|
6576
|
+
return '<label class="autoOpen"><input type="checkbox" data-action="auto-open"' + (autoOpenPref() ? ' checked' : '') + '>' +
|
|
6577
|
+
'<span>Open my project automatically when it’s ready</span></label>';
|
|
6578
|
+
}
|
|
6579
|
+
function maybeAutoOpen() {
|
|
6580
|
+
var domain = resolvedDomain();
|
|
6581
|
+
if (autoOpened || !autoOpenArmed || !domain || !autoOpenPref()) return;
|
|
6582
|
+
if (live.manifest !== 'done' || live.instStatus !== 'active' || !live.addrReady) return;
|
|
6583
|
+
autoOpened = true;
|
|
6584
|
+
var url = 'https://' + domain;
|
|
6585
|
+
/* a new tab keeps this checklist (two steps follow sign-in); a browser that
|
|
6586
|
+
blocks an unprompted tab gets this one sent instead — the founder asked */
|
|
6587
|
+
var w = null;
|
|
6588
|
+
try { w = window.open(url, '_blank'); } catch (e) {}
|
|
6589
|
+
if (w) { try { w.opener = null; } catch (e) {} }
|
|
6590
|
+
else window.location.assign(url);
|
|
6591
|
+
}
|
|
6592
|
+
|
|
6560
6593
|
/* oauth-app — the ADR 0133 one-click GitHub-App manifest flow is the primary path
|
|
6561
6594
|
(gap 3 / idea 1000676); locked until a domain exists (the App needs the real
|
|
6562
6595
|
callback host — the server refuses domainless with domain_required). */
|
|
@@ -6572,7 +6605,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6572
6605
|
return '<li class="slocked" data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>This platform is running on a local address, and GitHub refuses app setups it can’t reach over the public internet — so the one-click flow can’t complete here. On a publicly-addressed platform this is one click.</p>' + manualOauthDetails() + '</li>';
|
|
6573
6606
|
}
|
|
6574
6607
|
if (live.manifest === 'done') {
|
|
6575
|
-
|
|
6608
|
+
/* the wait ends HERE now, not on the callback page (task 1004548): once the
|
|
6609
|
+
project answers too, the step carries the door itself */
|
|
6610
|
+
var open = live.instStatus === 'active' && live.addrReady;
|
|
6611
|
+
return '<li class="sdone" data-onboard-step="oauth-app"><b>GitHub sign-in is wired</b><p>Credentials placed and verified — sign-in on <code style="font-family:var(--font-mono)">' + esc(domain) + '</code> is live.' +
|
|
6612
|
+
(open ? '' : ' The door below opens once your project is answering.') + '</p>' +
|
|
6613
|
+
(open ? '<a class="ghbtn" href="https://' + esc(domain) + '" target="_blank" rel="noopener">Open ' + esc(domain) + ' →</a>' : '') + '</li>';
|
|
6576
6614
|
}
|
|
6577
6615
|
if (live.manifest === 'error' || live.manifest === 'expired') {
|
|
6578
6616
|
/* a plain sentence first; the raw route text folds under Technical
|
|
@@ -6589,10 +6627,14 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6589
6627
|
if (live.manifest === 'code_received') {
|
|
6590
6628
|
/* GitHub's half is done — say so, rather than keep telling the founder
|
|
6591
6629
|
to approve an app they just approved */
|
|
6592
|
-
|
|
6630
|
+
autoOpenArmed = true;
|
|
6631
|
+
return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>GitHub’s part is done. The platform is placing the credentials on your project now — this usually takes a few minutes, and this step ticks itself.</p>' +
|
|
6632
|
+
progressMarkup(1, 1, 'Placing credentials and checking sign-in works…', '', 'signin') + autoOpenTick() + '</li>';
|
|
6593
6633
|
}
|
|
6594
6634
|
if (live.manifest) { /* pending — GitHub holds the manifest, nobody has approved it yet */
|
|
6635
|
+
autoOpenArmed = true;
|
|
6595
6636
|
return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Waiting on GitHub — approve the app in the tab that opened. Once approved, the platform places the credentials and verifies sign-in on its own; this step ticks itself.</p>' +
|
|
6637
|
+
progressMarkup(0, 0, 'Waiting for your approval on GitHub…', '', 'signin') + autoOpenTick() +
|
|
6596
6638
|
(manifestStale ? '<button type="button" class="linklike" data-action="manifest-start">Didn’t get to GitHub? Start the setup again</button>' : '') + '</li>';
|
|
6597
6639
|
}
|
|
6598
6640
|
return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in — one click</b><p>Approve a pre-filled GitHub App and the platform wires sign-in by itself: no keys to copy, and the secret never leaves the server side.</p>' +
|
|
@@ -6678,6 +6720,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6678
6720
|
if (html === lastStepsHtml) return; /* change-only re-render — polls never clobber open UI */
|
|
6679
6721
|
lastStepsHtml = html;
|
|
6680
6722
|
$('humanSteps').innerHTML = html;
|
|
6723
|
+
maybeAutoOpen();
|
|
6681
6724
|
});
|
|
6682
6725
|
}
|
|
6683
6726
|
|
|
@@ -6730,6 +6773,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6730
6773
|
});
|
|
6731
6774
|
});
|
|
6732
6775
|
|
|
6776
|
+
$('humanSteps').addEventListener('change', function (ev) {
|
|
6777
|
+
var box = ev.target.closest && ev.target.closest('[data-action="auto-open"]');
|
|
6778
|
+
if (!box) return;
|
|
6779
|
+
try { localStorage.setItem(AUTO_OPEN_KEY, box.checked ? '1' : '0'); } catch (e) {}
|
|
6780
|
+
});
|
|
6781
|
+
|
|
6733
6782
|
/* ── C8: the wizard's polls run only on stage, exactly like the sky.
|
|
6734
6783
|
Off-view or hidden-tab, a due tick parks itself instead of fetching;
|
|
6735
6784
|
enter() and the visibility flip resume whatever was parked. ── */
|
|
@@ -6754,6 +6803,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6754
6803
|
var manifestPending = false;
|
|
6755
6804
|
var manifestStartedAt = 0; /* when the founder was sent to GitHub */
|
|
6756
6805
|
var manifestStale = false; /* ~90s with no approval → offer to start again */
|
|
6806
|
+
/* the GitHub hand-back (task 1004548): the callback page returns the founder here
|
|
6807
|
+
with the attempt's one-time state in the fragment (#ghstate=…), so a checklist
|
|
6808
|
+
that page opened — not only the tab that started the setup — keeps watching the
|
|
6809
|
+
same attempt. Read once at load, before the router's replaceState drops it. */
|
|
6810
|
+
var handbackState = (function () {
|
|
6811
|
+
try { var m = /[#&]ghstate=([0-9a-f]{16,128})/i.exec(location.hash || ''); return m ? m[1] : null; } catch (e) { return null; }
|
|
6812
|
+
})();
|
|
6757
6813
|
/* the manifest leg survives a reload: token + last status ride the CREATED
|
|
6758
6814
|
record, so enter() re-seeds the step instead of re-offering "Set up
|
|
6759
6815
|
GitHub sign-in" for an app the founder already approved (task 1003232) */
|
|
@@ -6846,7 +6902,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6846
6902
|
standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
|
|
6847
6903
|
revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
|
|
6848
6904
|
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
|
|
6849
|
-
settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
|
|
6905
|
+
settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' },
|
|
6906
|
+
/* the GitHub sign-in wait, drawn inside its checklist step (task 1004548) */
|
|
6907
|
+
signin: { names: ['Approve on GitHub', 'Placing credentials', 'Ready to sign in'], cls: '' }
|
|
6850
6908
|
};
|
|
6851
6909
|
/* No live-region attributes here: this strip is destroyed and rebuilt on
|
|
6852
6910
|
every 5s tick, and a live region that is itself replaced never announces.
|
|
@@ -7171,6 +7229,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7171
7229
|
/* restore the opt-out too — without it resolvedDomain() re-derives the
|
|
7172
7230
|
assigned address and the done panel promises one that doesn't exist */
|
|
7173
7231
|
state.noAddress = !!created.noaddr;
|
|
7232
|
+
/* a hand-back from the callback page names the attempt GitHub just approved */
|
|
7233
|
+
if (handbackState) {
|
|
7234
|
+
created.manifest_state = handbackState;
|
|
7235
|
+
if (created.manifest !== 'done') created.manifest = 'code_received';
|
|
7236
|
+
rememberManifest(handbackState, created.manifest);
|
|
7237
|
+
handbackState = null;
|
|
7238
|
+
}
|
|
7174
7239
|
/* the manifest leg's last known truth, kept across a reload */
|
|
7175
7240
|
if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
|
|
7176
7241
|
if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.29",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.29",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.29",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8918,5 +8918,17 @@
|
|
|
8918
8918
|
"id": "1004476",
|
|
8919
8919
|
"text": "In the builders' hall, a page you reach through several clicks now shows the whole way back at the top, like Goals > goal 12 > task 340, and each step is a link. Clicking an earlier step takes you straight there; the sidebar s"
|
|
8920
8920
|
}
|
|
8921
|
+
],
|
|
8922
|
+
"1.21.28": [
|
|
8923
|
+
{
|
|
8924
|
+
"id": "1004492",
|
|
8925
|
+
"text": "The Builders Hall now asks what you are doing when you arrive: Building, Artistry, Ideating or Governing, or Legacy mode for the hall exactly as it was. It asks once per session (or never again, if you untick the box), artists"
|
|
8926
|
+
}
|
|
8927
|
+
],
|
|
8928
|
+
"1.21.29": [
|
|
8929
|
+
{
|
|
8930
|
+
"id": "1004548",
|
|
8931
|
+
"text": "After approving GitHub sign-in, founders go straight back to their setup checklist to wait, and can tick a box to have their project open automatically when it's ready."
|
|
8932
|
+
}
|
|
8921
8933
|
]
|
|
8922
8934
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.29'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/craft_mode.mjs
CHANGED
|
@@ -1,141 +1,296 @@
|
|
|
1
|
-
// tests/craft_mode.mjs — the
|
|
2
|
-
// printed (task 1002992).
|
|
1
|
+
// tests/craft_mode.mjs — the hall's craft mode picker (task 1004492, goal 1000095 WA6).
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
3
|
+
// On entering the hall a picker asks once per session "What are you doing
|
|
4
|
+
// now?" (Building, Artistry, Ideating, Governing, or Legacy mode); the answer is
|
|
5
|
+
// html[data-craft], and a switch in the top bar changes it without a reload.
|
|
6
|
+
// These cases boot the REAL shell-trail.js + craft-mode.js + shell.js in a DOM
|
|
7
|
+
// stub, in the order every hall page loads them, and drive them the way a
|
|
8
|
+
// reader does: sign in, pick a door, reload, start a new session, press the
|
|
9
|
+
// switch. The criterion's own disqualifier comes first: a hall with no mode
|
|
10
|
+
// chosen must render exactly as the hall did before craft modes existed.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
14
13
|
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
14
|
import path from 'node:path';
|
|
15
|
+
import vm from 'node:vm';
|
|
17
16
|
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { makeRunner } from './helpers.mjs';
|
|
19
17
|
|
|
20
|
-
const require = createRequire(import.meta.url);
|
|
21
18
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
19
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
20
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
21
|
+
const SHELL = read('shell.js');
|
|
22
|
+
const TRAIL = read('shell-trail.js');
|
|
23
|
+
const CRAFT = read('craft-mode.js');
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// A tiny element: attributes, a dataset, children by markup. An element built
|
|
26
|
+
// from markup answers the selectors the craft code asks with a regex over that
|
|
27
|
+
// markup, so a door or a checkbox the code forgot to render cannot be found.
|
|
28
|
+
function makeEl(tag, html = '') {
|
|
29
|
+
const attrs = {}, listeners = {};
|
|
30
|
+
const el = {
|
|
31
|
+
tagName: tag, id: '', className: '', dataset: {}, style: {}, textContent: '', hidden: false, title: '',
|
|
32
|
+
_html: html, children: [], parentNode: null,
|
|
33
|
+
classList: { _s: new Set(), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle(c, on) { if (on === undefined) on = !this._s.has(c); if (on) this._s.add(c); else this._s.delete(c); return on; }, contains(c) { return this._s.has(c); } },
|
|
34
|
+
setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
35
|
+
hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
|
|
36
|
+
addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
|
|
37
|
+
fire(t, ev) { for (const fn of listeners[t] || []) fn(ev); },
|
|
38
|
+
appendChild(c) { c.parentNode = el; el.children.push(c); return c; },
|
|
39
|
+
removeChild(c) { el.children = el.children.filter((x) => x !== c); c.parentNode = null; },
|
|
40
|
+
insertBefore(c) { return el.appendChild(c); },
|
|
41
|
+
focus() { focused.el = el; },
|
|
42
|
+
closest() { return null; },
|
|
43
|
+
querySelector(sel) { return el.querySelectorAll(sel)[0] || null; },
|
|
44
|
+
querySelectorAll(sel) { return fromMarkup(el, sel); },
|
|
45
|
+
set innerHTML(v) { el._html = String(v); el._kids = null; }, get innerHTML() { return el._html; },
|
|
46
|
+
};
|
|
47
|
+
return el;
|
|
48
|
+
}
|
|
49
|
+
const focused = { el: null };
|
|
50
|
+
// Buttons and inputs parsed out of an element's markup, built once so a state
|
|
51
|
+
// written to one (aria-pressed, checked) is the state read back later.
|
|
52
|
+
function kids(el) {
|
|
53
|
+
if (el._kids) return el._kids;
|
|
54
|
+
el._kids = [...el._html.matchAll(/<(button|input)\b([^>]*)>/g)].map((m) => {
|
|
55
|
+
const k = makeEl(m[1].toUpperCase());
|
|
56
|
+
for (const a of m[2].matchAll(/([a-z-]+)(?:="([^"]*)")?/g)) {
|
|
57
|
+
if (a[1] === 'class') k.className = a[2]; else k.setAttribute(a[1], a[2] == null ? '' : a[2]);
|
|
58
|
+
}
|
|
59
|
+
k.checked = k.hasAttribute('checked');
|
|
60
|
+
k.closest = () => k;
|
|
61
|
+
return k;
|
|
62
|
+
});
|
|
63
|
+
return el._kids;
|
|
64
|
+
}
|
|
65
|
+
function fromMarkup(el, sel) {
|
|
66
|
+
const all = kids(el);
|
|
67
|
+
let m;
|
|
68
|
+
if ((m = /^\[([a-z-]+)\]$/.exec(sel))) return all.filter((k) => k.hasAttribute(m[1]));
|
|
69
|
+
if ((m = /^#craft-switch \[([a-z-]+)\]$/.exec(sel))) return all.filter((k) => k.hasAttribute(m[1]));
|
|
70
|
+
if ((m = /^\.([a-z-]+)$/.exec(sel))) return all.filter((k) => k.className.split(/\s+/).includes(m[1]));
|
|
71
|
+
if (sel === 'button, input') return all;
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
28
74
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
})
|
|
75
|
+
// One page load. `session` is the tab's sessionStorage and `store` the
|
|
76
|
+
// browser's localStorage: pass the same objects to a second boot and it is a
|
|
77
|
+
// reload; a fresh `session` with the same `store` is a new browser session.
|
|
78
|
+
function boot({ craft = true, session = {}, store = {}, throws = false, optOut = false, narrow = false } = {}) {
|
|
79
|
+
const events = [];
|
|
80
|
+
const root = makeEl('HTML');
|
|
81
|
+
const topbar = makeEl('DIV');
|
|
82
|
+
const sidebar = makeEl('NAV');
|
|
83
|
+
const body = makeEl('BODY');
|
|
84
|
+
body.dataset.page = 'work';
|
|
85
|
+
if (optOut) body.setAttribute('data-craft-mode', 'off');
|
|
86
|
+
const els = { 'app-topbar': topbar, 'app-sidebar': sidebar };
|
|
87
|
+
const docListeners = {};
|
|
88
|
+
const documentObj = {
|
|
89
|
+
body, referrer: '', documentElement: root, activeElement: null,
|
|
90
|
+
getElementById(id) {
|
|
91
|
+
if (els[id]) return els[id];
|
|
92
|
+
if (id === 'craft-switch') {
|
|
93
|
+
if (!topbar._html.includes('id="craft-switch"')) return null;
|
|
94
|
+
const sw = els[id] = makeEl('DIV', topbar._html.match(/<div class="craft-switch[\s\S]*?<\/div>/)[0]);
|
|
95
|
+
return sw;
|
|
96
|
+
}
|
|
97
|
+
if (/^(fullscreen-toggle|theme-toggle|zoom-toggle|topbar-menu|topbar-title|topbar-jump|topbar-back|founding-band|fullscreen-hint|skip-link|app-foot|app-scrim|shell-signin|shell-user|logout-link|craft-picker)$/.test(id)) {
|
|
98
|
+
if (id === 'craft-picker') return body.children.find((c) => c.id === 'craft-picker') || null;
|
|
99
|
+
return topbar._html.includes(`id="${id}"`) ? (els[id] = els[id] || makeEl('X')) : null;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
querySelector: () => null,
|
|
104
|
+
querySelectorAll: (sel) => (sel.startsWith('#craft-switch') && els['craft-switch'] ? els['craft-switch'].querySelectorAll(sel) : []),
|
|
105
|
+
createElement: (t) => makeEl(t.toUpperCase()),
|
|
106
|
+
addEventListener(t, fn) { (docListeners[t] ||= []).push(fn); },
|
|
107
|
+
removeEventListener(t, fn) { docListeners[t] = (docListeners[t] || []).filter((f) => f !== fn); },
|
|
108
|
+
dispatchEvent(ev) { events.push(ev); return true; },
|
|
109
|
+
get cookie() { return ''; }, set cookie(_v) {},
|
|
110
|
+
};
|
|
111
|
+
const location = { hostname: 'h', origin: 'https://h', href: 'https://h/work', pathname: '/work', hash: '', search: '', reloads: 0, assign() { this.reloads++; }, replace() { this.reloads++; }, reload() { this.reloads++; } };
|
|
112
|
+
const mk = (obj) => ({
|
|
113
|
+
getItem: (k) => { if (throws) throw new Error('SecurityError'); return k in obj ? obj[k] : null; },
|
|
114
|
+
setItem: (k, v) => { if (throws) throw new Error('SecurityError'); obj[k] = String(v); },
|
|
115
|
+
removeItem: (k) => { if (throws) throw new Error('SecurityError'); delete obj[k]; },
|
|
116
|
+
});
|
|
117
|
+
const windowObj = {
|
|
118
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
119
|
+
BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
|
|
120
|
+
matchMedia: (q) => ({ matches: narrow && /max-width: 640px/.test(q), addEventListener() {} }),
|
|
121
|
+
localStorage: mk(store), sessionStorage: mk(session), location,
|
|
122
|
+
clearTimeout() {}, setTimeout() { return 0; }, requestAnimationFrame() {}, scrollTo() {},
|
|
123
|
+
addEventListener() {}, dispatchEvent() { return true; },
|
|
124
|
+
};
|
|
125
|
+
const sandbox = {
|
|
126
|
+
window: windowObj, document: documentObj, location, console, URL, URLSearchParams, queueMicrotask,
|
|
127
|
+
setTimeout() { return 0; }, clearTimeout() {},
|
|
128
|
+
Event: class { constructor(t) { this.type = t; } },
|
|
129
|
+
CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } },
|
|
130
|
+
};
|
|
131
|
+
sandbox.globalThis = sandbox;
|
|
132
|
+
vm.createContext(sandbox);
|
|
133
|
+
vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
|
|
134
|
+
if (craft) vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
|
|
135
|
+
vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
|
|
136
|
+
const shell = windowObj.OTBShell;
|
|
137
|
+
const picker = () => body.children.find((c) => c.id === 'craft-picker') || null;
|
|
138
|
+
const sw = () => documentObj.getElementById('craft-switch');
|
|
139
|
+
return {
|
|
140
|
+
root, topbar, sidebar, body, session, store, location, events,
|
|
141
|
+
mode: () => root.dataset.craft,
|
|
142
|
+
picker,
|
|
143
|
+
signIn: (builder = { id: 7, rank: 'thetes' }) => shell.applyAccess({ authed: true, rank: builder.rank, builder }),
|
|
144
|
+
signOut: () => shell.applyAccess({ authed: false }),
|
|
145
|
+
// Press a picker button: a door, Legacy, or the close button.
|
|
146
|
+
pick(attr, value, { askAgain = true } = {}) {
|
|
147
|
+
const p = picker();
|
|
148
|
+
const box = p.querySelector('[data-craft-again]');
|
|
149
|
+
if (box) box.checked = askAgain;
|
|
150
|
+
const btn = p.querySelectorAll(`[${attr}]`).find((b) => value == null || b.getAttribute(attr) === value);
|
|
151
|
+
assert.ok(btn, `the picker renders a [${attr}${value ? '=' + value : ''}] button`);
|
|
152
|
+
p.fire('click', { target: btn });
|
|
153
|
+
},
|
|
154
|
+
press(mode) {
|
|
155
|
+
const btn = sw().querySelectorAll('[data-craft-opt]').find((b) => b.getAttribute('data-craft-opt') === mode);
|
|
156
|
+
assert.ok(btn, `the switch renders a ${mode} option`);
|
|
157
|
+
sw().fire('click', { target: btn });
|
|
158
|
+
},
|
|
159
|
+
pressed: () => sw().querySelectorAll('[data-craft-opt]').filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.getAttribute('data-craft-opt')),
|
|
160
|
+
escape() { for (const fn of docListeners.keydown || []) fn({ key: 'Escape', preventDefault() {} }); },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
33
163
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
assert.ok(names.length >= 3, 'the registry was read at all');
|
|
37
|
-
for (const d of names) {
|
|
38
|
-
const m = craft.describeCraftMode(d, MODES);
|
|
39
|
-
assert.ok(m && m.inject, `${d} injects`);
|
|
40
|
-
assert.equal(m.discipline, d);
|
|
41
|
-
const pointer = MODES[d].pack || `/${MODES[d].skill}`;
|
|
42
|
-
assert.ok(m.contract.includes(pointer), `${d} names ${pointer}`);
|
|
43
|
-
assert.ok(m.contract.includes(MODES[d].directive.slice(0, 40)), `${d} carries its directive`);
|
|
44
|
-
}
|
|
45
|
-
assert.match(craft.describeCraftMode('engineer', MODES).contract, /read docs\/packs\/engineer\.md/);
|
|
46
|
-
assert.match(craft.describeCraftMode('ui', MODES).contract, /— \/design/, 'a skill is a command to invoke, not a file to read');
|
|
47
|
-
});
|
|
164
|
+
const MODES = ['building', 'artistry', 'ideating', 'governing', 'legacy'];
|
|
165
|
+
const noSwitch = (html) => html.replace(/<div class="craft-switch[\s\S]*?<\/div>/, '');
|
|
48
166
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
assert.equal(
|
|
53
|
-
assert.deepEqual(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert.
|
|
167
|
+
test('NO MODE IS THE HALL AS TODAY: with no mode chosen, nothing but the switch differs from a hall without craft modes', () => {
|
|
168
|
+
const before = boot({ craft: false });
|
|
169
|
+
const after = boot({ craft: true });
|
|
170
|
+
assert.equal(after.mode(), undefined, 'no html[data-craft]');
|
|
171
|
+
assert.deepEqual({ ...after.root.dataset }, { ...before.root.dataset }, 'the root carries exactly the attributes it did');
|
|
172
|
+
assert.equal(after.sidebar.innerHTML, before.sidebar.innerHTML, 'the sidebar is byte-identical');
|
|
173
|
+
assert.equal(noSwitch(after.topbar.innerHTML), before.topbar.innerHTML, 'the top bar differs by the switch alone');
|
|
174
|
+
assert.ok(after.topbar.innerHTML.includes('id="craft-switch"'), 'and the switch is there to choose one');
|
|
175
|
+
assert.equal(after.picker(), null, 'nobody is asked before a signed-in builder is known');
|
|
57
176
|
});
|
|
58
177
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
assert.
|
|
178
|
+
test('a signed-in builder is asked ONCE per session, and a reload does not ask again', () => {
|
|
179
|
+
const session = {};
|
|
180
|
+
const p = boot({ session });
|
|
181
|
+
p.signIn();
|
|
182
|
+
assert.ok(p.picker(), 'the picker opens');
|
|
183
|
+
const html = p.picker().innerHTML;
|
|
184
|
+
assert.match(html, /role="dialog" aria-modal="true"/);
|
|
185
|
+
assert.match(html, /What are you doing now\?/);
|
|
186
|
+
for (const m of ['building', 'artistry', 'ideating', 'governing']) assert.match(html, new RegExp(`data-craft-pick="${m}"`), `a ${m} door`);
|
|
187
|
+
assert.match(html, /data-craft-pick="legacy"[^>]*>Legacy mode/, 'Legacy mode is the fifth choice (owner, 2026-10-01)');
|
|
188
|
+
p.escape();
|
|
189
|
+
assert.equal(p.picker(), null, 'Escape closes it without choosing');
|
|
190
|
+
assert.equal(p.mode(), undefined, 'and leaves the hall with no mode');
|
|
191
|
+
const reload = boot({ session });
|
|
192
|
+
reload.signIn();
|
|
193
|
+
assert.equal(reload.picker(), null, 'the same session is not asked twice');
|
|
67
194
|
});
|
|
68
195
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert.
|
|
73
|
-
assert.match(me, /craft_mode: describeCraftMode\(c\.discipline, craftModes\)/);
|
|
74
|
-
assert.doesNotMatch(me, /"modes"\s*:/, 'no second copy of the registry in the route');
|
|
196
|
+
test('a visitor who is not signed in is never asked', () => {
|
|
197
|
+
const p = boot();
|
|
198
|
+
p.signOut();
|
|
199
|
+
assert.equal(p.picker(), null);
|
|
75
200
|
});
|
|
76
201
|
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
202
|
+
test('choosing a door sets html[data-craft] for every page of the session, and says so on document', () => {
|
|
203
|
+
const session = {}, store = {};
|
|
204
|
+
const p = boot({ session, store });
|
|
205
|
+
p.signIn();
|
|
206
|
+
p.pick('data-craft-pick', 'ideating');
|
|
207
|
+
assert.equal(p.picker(), null, 'the picker closes');
|
|
208
|
+
assert.equal(p.mode(), 'ideating');
|
|
209
|
+
assert.ok(p.events.some((e) => e.type === 'otb:craft' && e.detail.mode === 'ideating'), 'otb:craft fires with the mode');
|
|
210
|
+
assert.deepEqual(p.pressed(), ['ideating'], 'the switch lights the chosen mode');
|
|
211
|
+
const next = boot({ session, store });
|
|
212
|
+
assert.equal(next.mode(), 'ideating', 'the next page of the session reads it before /me answers');
|
|
213
|
+
assert.equal(store['hall-craft-keep'], undefined, '"Ask me again next session" was ticked, so nothing outlives the session');
|
|
214
|
+
const tomorrow = boot({ store });
|
|
215
|
+
tomorrow.signIn();
|
|
216
|
+
assert.equal(tomorrow.mode(), undefined, 'a new session starts with no mode');
|
|
217
|
+
assert.ok(tomorrow.picker(), 'and asks again');
|
|
218
|
+
});
|
|
80
219
|
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
220
|
+
test('unticking "Ask me again next session" keeps the mode for later sessions', () => {
|
|
221
|
+
const store = {};
|
|
222
|
+
const p = boot({ store });
|
|
223
|
+
p.signIn();
|
|
224
|
+
p.pick('data-craft-pick', 'governing', { askAgain: false });
|
|
225
|
+
assert.equal(store['hall-craft-keep'], 'governing');
|
|
226
|
+
const tomorrow = boot({ store });
|
|
227
|
+
tomorrow.signIn();
|
|
228
|
+
assert.equal(tomorrow.mode(), 'governing', 'the kept mode applies');
|
|
229
|
+
assert.equal(tomorrow.picker(), null, 'and nobody is asked');
|
|
85
230
|
});
|
|
86
231
|
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
assert.deepEqual(
|
|
91
|
-
|
|
92
|
-
assert.
|
|
93
|
-
|
|
232
|
+
test('THE HEADER SWITCH changes the mode without a reload, and offers all five', () => {
|
|
233
|
+
const p = boot();
|
|
234
|
+
const opts = [...p.topbar.innerHTML.matchAll(/data-craft-opt="([a-z]+)"/g)].map((m) => m[1]);
|
|
235
|
+
assert.deepEqual(opts, MODES, 'the four crafts, then Legacy');
|
|
236
|
+
p.press('artistry');
|
|
237
|
+
assert.equal(p.mode(), 'artistry');
|
|
238
|
+
p.press('legacy');
|
|
239
|
+
assert.equal(p.mode(), 'legacy', 'anyone can go back to the old view at any time');
|
|
240
|
+
assert.deepEqual(p.pressed(), ['legacy']);
|
|
241
|
+
assert.equal(p.location.reloads, 0, 'no navigation happened');
|
|
242
|
+
assert.equal(p.session['hall-craft'], 'legacy', 'and the session remembers it');
|
|
94
243
|
});
|
|
95
244
|
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
245
|
+
test('on a phone the switch shows only the current mode, and tapping it opens the picker', () => {
|
|
246
|
+
const session = { 'hall-craft': 'building' };
|
|
247
|
+
const p = boot({ session, narrow: true });
|
|
248
|
+
p.press('building');
|
|
249
|
+
assert.ok(p.picker(), 'the current mode opens the picker');
|
|
250
|
+
assert.equal(p.mode(), 'building', 'without changing the mode');
|
|
251
|
+
});
|
|
99
252
|
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
253
|
+
test('artists start on Artistry: the door is pre-selected and the picker says why', () => {
|
|
254
|
+
const p = boot();
|
|
255
|
+
p.signIn({ id: 3, rank: 'thetes', main_discipline: 'artist', preferred_disciplines: ['engineer', 'artist'] });
|
|
256
|
+
const html = p.picker().innerHTML;
|
|
257
|
+
assert.match(html, /craft-door--artistry craft-door--pre"/);
|
|
258
|
+
assert.match(html, /Artists start on Artistry\./);
|
|
259
|
+
const eng = boot();
|
|
260
|
+
eng.signIn({ id: 4, rank: 'thetes', main_discipline: 'engineer', preferred_disciplines: ['artist'] });
|
|
261
|
+
assert.doesNotMatch(eng.picker().innerHTML, /craft-door--pre/, 'a main role that is not artist pre-selects nothing');
|
|
262
|
+
const unset = boot();
|
|
263
|
+
unset.signIn({ id: 5, rank: 'thetes', main_discipline: null, preferred_disciplines: ['artist'] });
|
|
264
|
+
assert.match(unset.picker().innerHTML, /craft-door--artistry craft-door--pre"/, 'with no main role, the first craft decides');
|
|
265
|
+
});
|
|
107
266
|
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
assert.
|
|
113
|
-
assert.doesNotMatch(src, /Your pack:/);
|
|
267
|
+
test('storage that throws is never asked and never wears a mode', () => {
|
|
268
|
+
const p = boot({ throws: true });
|
|
269
|
+
p.signIn();
|
|
270
|
+
assert.equal(p.picker(), null, 'it could not remember being asked, so it would ask on every page');
|
|
271
|
+
assert.equal(p.mode(), undefined);
|
|
114
272
|
});
|
|
115
273
|
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const lines = capture(() => claimCli.printRoleLine({ discipline: 'ui' }));
|
|
123
|
-
assert.equal(calls.length, 1, 'the ui claim landed its skills');
|
|
124
|
-
assert.equal(lines[0], '▶ UI design session — /design');
|
|
125
|
-
assert.match(lines[1], /2 module-owned skill\(s\)/);
|
|
126
|
-
calls.length = 0;
|
|
127
|
-
capture(() => claimCli.printRoleLine({ discipline: 'engineer' }));
|
|
128
|
-
assert.equal(calls.length, 0, 'a craft that did not opt in lands nothing');
|
|
129
|
-
} finally {
|
|
130
|
-
materialize.materializeModuleSkillsIntoCore = saved.into;
|
|
131
|
-
materialize.isCoreCheckoutRoot = saved.root;
|
|
132
|
-
}
|
|
274
|
+
test('a page can opt out with <body data-craft-mode="off">', () => {
|
|
275
|
+
const p = boot({ optOut: true, session: { 'hall-craft': 'artistry' } });
|
|
276
|
+
p.signIn();
|
|
277
|
+
assert.doesNotMatch(p.topbar.innerHTML, /craft-switch/, 'no switch');
|
|
278
|
+
assert.equal(p.picker(), null, 'no picker');
|
|
279
|
+
assert.equal(p.mode(), undefined, 'no mode');
|
|
133
280
|
});
|
|
134
281
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
assert.
|
|
138
|
-
assert.deepEqual(capture(() => claimCli.printRoleLine(null)), []);
|
|
282
|
+
test('a stored value that is not a mode is ignored, not trusted', () => {
|
|
283
|
+
const p = boot({ session: { 'hall-craft': '"><img src=x>' }, store: { 'hall-craft-keep': 'admin' } });
|
|
284
|
+
assert.equal(p.mode(), undefined);
|
|
139
285
|
});
|
|
140
286
|
|
|
141
|
-
|
|
287
|
+
test('every hall page loads shell-trail.js and craft-mode.js right before shell.js, deferred alike', () => {
|
|
288
|
+
const pages = fs.readdirSync(HALL).filter((f) => f.endsWith('.html') && read(f).includes('/builders/shell.js'));
|
|
289
|
+
assert.ok(pages.length >= 30, `found ${pages.length} hall pages`);
|
|
290
|
+
for (const f of pages) {
|
|
291
|
+
const tags = [...read(f).matchAll(/<script src="\/builders\/([a-z-]+)\.js[^"]*"( defer)?><\/script>/g)].map((m) => [m[1], !!m[2]]);
|
|
292
|
+
const at = tags.findIndex((t) => t[0] === 'shell');
|
|
293
|
+
assert.deepEqual(tags.slice(at - 2, at + 1).map((t) => t[0]), ['shell-trail', 'craft-mode', 'shell'], f);
|
|
294
|
+
assert.equal(new Set(tags.slice(at - 2, at + 1).map((t) => t[1])).size, 1, `${f}: all three load the same way`);
|
|
295
|
+
}
|
|
296
|
+
});
|