@bongos/core 1.21.27 → 1.21.28

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 (57) hide show
  1. package/.bongos-core.json +76 -61
  2. package/docs/copy-inventory.md +21 -13
  3. package/docs/copy-registry.json +88 -16
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/page-inventory.json +68 -0
  6. package/docs/page-readings.json +3504 -3354
  7. package/modules/hall-ui/public/atlas.html +3 -1
  8. package/modules/hall-ui/public/blockers.html +3 -1
  9. package/modules/hall-ui/public/board-room.html +3 -1
  10. package/modules/hall-ui/public/city.html +3 -1
  11. package/modules/hall-ui/public/collab.html +3 -1
  12. package/modules/hall-ui/public/commands.html +3 -1
  13. package/modules/hall-ui/public/copy-desk.html +3 -1
  14. package/modules/hall-ui/public/craft-mode.js +297 -0
  15. package/modules/hall-ui/public/deploy.html +3 -1
  16. package/modules/hall-ui/public/diagrams.html +3 -1
  17. package/modules/hall-ui/public/drachmae.html +3 -1
  18. package/modules/hall-ui/public/fleet.html +3 -1
  19. package/modules/hall-ui/public/gate.html +3 -1
  20. package/modules/hall-ui/public/goal-map.html +3 -1
  21. package/modules/hall-ui/public/goals.html +3 -1
  22. package/modules/hall-ui/public/government.html +3 -1
  23. package/modules/hall-ui/public/idea.html +3 -1
  24. package/modules/hall-ui/public/ideas.html +3 -1
  25. package/modules/hall-ui/public/index.html +3 -1
  26. package/modules/hall-ui/public/modules.html +3 -1
  27. package/modules/hall-ui/public/primer.html +3 -1
  28. package/modules/hall-ui/public/profile.html +3 -1
  29. package/modules/hall-ui/public/project-settings.html +3 -1
  30. package/modules/hall-ui/public/ranks.html +3 -1
  31. package/modules/hall-ui/public/roadmap.html +3 -1
  32. package/modules/hall-ui/public/roster.html +3 -1
  33. package/modules/hall-ui/public/sessions.html +3 -1
  34. package/modules/hall-ui/public/settings.html +3 -1
  35. package/modules/hall-ui/public/shell-trail.js +186 -0
  36. package/modules/hall-ui/public/shell.js +17 -172
  37. package/modules/hall-ui/public/street.html +3 -1
  38. package/modules/hall-ui/public/studio.html +3 -1
  39. package/modules/hall-ui/public/style.css +228 -0
  40. package/modules/hall-ui/public/task.html +3 -1
  41. package/modules/hall-ui/public/thinking.html +3 -1
  42. package/modules/hall-ui/public/tweak-editor.html +3 -1
  43. package/modules/hall-ui/public/watch.html +3 -1
  44. package/modules/hall-ui/public/work.html +3 -1
  45. package/modules/hall-ui/records/craft-mode.md +22 -0
  46. package/modules/hall-ui/records/hall-navigation.md +1 -1
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +6 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/craft_mode.mjs +267 -112
  52. package/tests/goal_map_page.mjs +2 -2
  53. package/tests/hall_city.mjs +1 -1
  54. package/tests/hall_fullscreen.mjs +3 -0
  55. package/tests/hall_street.mjs +1 -1
  56. package/tests/hall_tweak_editor.mjs +1 -1
  57. package/tests/nav_permission_atoms.mjs +1 -1
@@ -1,141 +1,296 @@
1
- // tests/craft_mode.mjs — the claimed craft's pack pointer is INJECTED, not
2
- // printed (task 1002992).
1
+ // tests/craft_mode.mjs — the hall's craft mode picker (task 1004492, goal 1000095 WA6).
3
2
  //
4
- // Four altitudes: the server helper over the real registry, the /me wiring, the
5
- // Conductor's worktree-scoped relay, and claim.js — which now prints one role line
6
- // but must still land a ui claim's module-owned skills (the regression this task
7
- // was most likely to ship). The hook itself is run end to end, against the
8
- // helper's real output, in tests/conductor_main_e2e.mjs.
9
- //
10
- // Run: node tests/craft_mode.mjs
11
-
12
- import { strict as assert } from 'node:assert';
13
- import { createRequire } from 'node:module';
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 craft = require('../src/bongos/craft-mode.js');
23
- const conductor = require('../.claude/hooks/conductor.js');
24
- const { test, summary } = makeRunner();
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
- const REGISTRY = path.join(ROOT, 'scripts', 'gds', 'discipline-modes.json');
27
- const MODES = craft.readModes();
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
- // ---- the helper, over the ONE registry ---------------------------------------
30
- await test('the server reads the same registry file claim.js and fitness read', () => {
31
- assert.deepEqual(MODES, JSON.parse(fs.readFileSync(REGISTRY, 'utf8')).modes);
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
- await test('every registry craft yields a contract naming its pack or skill, with its directive', () => {
35
- const names = Object.keys(MODES);
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
- await test('fail-open: no craft, an unknown craft, or an unreadable registry yields nothing', () => {
50
- assert.equal(craft.describeCraftMode(null, MODES), null);
51
- assert.equal(craft.describeCraftMode('governor', MODES), null);
52
- assert.equal(craft.describeCraftMode('engineer', null), null);
53
- assert.deepEqual(craft.readModes(path.join(os.tmpdir(), 'no-such-registry.json')), {});
54
- const bad = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'craft-')), 'modes.json');
55
- fs.writeFileSync(bad, '{ not json');
56
- assert.deepEqual(craft.readModes(bad), {});
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
- await test('the default registry is read once per process; an explicit file is read fresh', () => {
60
- assert.equal(craft.readModes(), craft.readModes(), 'GET /me does not re-read and re-parse per request');
61
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-'));
62
- const f = path.join(dir, 'modes.json');
63
- fs.writeFileSync(f, JSON.stringify({ modes: { a: { directive: 'x' } } }));
64
- assert.deepEqual(Object.keys(craft.readModes(f)), ['a']);
65
- fs.writeFileSync(f, JSON.stringify({ modes: { b: { directive: 'y' } } }));
66
- assert.deepEqual(Object.keys(craft.readModes(f)), ['b'], 'claim.js always sees the file as it is now');
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
- // ---- /me carries it on each claim ---------------------------------------------
70
- await test('GET /me puts each claim\'s craft_mode on the claim, from the helper', () => {
71
- const me = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
72
- assert.match(me, /require\('\.\.\/craft-mode'\)/);
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
- // ---- the Conductor relays THIS worktree's craft only ---------------------------
78
- const claim = (id, discipline) => ({ task_id: String(id), title: 't', touches: [], worktree_name: `task-${id}`, discipline, craft_mode: craft.describeCraftMode(discipline, MODES) });
79
- const craftIds = (out) => out.filter((a) => String(a.id).startsWith('craft:')).map((a) => a.id);
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
- await test('the claim bound to this worktree gets its craft; the other tree\'s does not', () => {
82
- const claims = [claim(1, 'engineer'), claim(2, 'ideator')];
83
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: 'task-1' })), ['craft:engineer']);
84
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: 'task-2' })), ['craft:ideator']);
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
- await test('no bound claim, no session, or no registry entry: nothing is injected', () => {
88
- const claims = [claim(1, 'engineer')];
89
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: 'task-9' })), [], 'an unrelated tree');
90
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: claims, worktree: null })), [], 'not in a worktree');
91
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: null, worktree: 'task-1' })), [], 'no session / no network');
92
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: [{ ...claim(1, 'governor') }], worktree: 'task-1' })), [], 'a craft with no entry');
93
- assert.deepEqual(craftIds(conductor.evaluateTriggers({ activeClaims: [{ ...claim(1, 'engineer'), craft_mode: { inject: true, contract: '' } }], worktree: 'task-1' })), [], 'an empty contract');
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
- // ---- claim.js: one role line, and the ui skills still land ---------------------
97
- const claimCli = require('../scripts/gds/claim.js');
98
- const materialize = require('../scripts/gds/claude-materialize.js');
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
- function capture(fn) {
101
- const lines = [];
102
- const orig = console.log;
103
- console.log = (...a) => { lines.push(a.join(' ')); };
104
- try { fn(); } finally { console.log = orig; }
105
- return lines.filter((l) => l !== '');
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
- await test('claim.js prints exactly one role line and no directive block', () => {
109
- const lines = capture(() => claimCli.printRoleLine({ discipline: 'engineer' }));
110
- assert.deepEqual(lines, ['▶ Engineering session — read docs/packs/engineer.md']);
111
- const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/claim.js'), 'utf8');
112
- assert.doesNotMatch(src, /mode\.directive\}/, 'the directive is no longer printed');
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
- await test('claiming a ui task STILL materialises its module-owned skills', () => {
117
- const saved = { into: materialize.materializeModuleSkillsIntoCore, root: materialize.isCoreCheckoutRoot };
118
- const calls = [];
119
- materialize.isCoreCheckoutRoot = () => true;
120
- materialize.materializeModuleSkillsIntoCore = (opts) => { calls.push(opts); return { materialized: ['impeccable', 'minimalist-ui'] }; };
121
- try {
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
- await test('claim.js fails open: no craft or an unknown craft prints nothing and never throws', () => {
136
- assert.deepEqual(capture(() => claimCli.printRoleLine({})), []);
137
- assert.deepEqual(capture(() => claimCli.printRoleLine({ discipline: 'governor' })), []);
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
- summary();
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
+ });
@@ -431,9 +431,9 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
431
431
  assert.match(html, /<main id="hall-main" class="sky-stage">/, 'the sky is drawn in the stage, the skip link\'s target');
432
432
  const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
433
433
  // criterion-uat-ui.js (task 1004402): the rings' UAT state words, shared with the goal page.
434
- assert.deepEqual(scripts(html).filter((s) => s[0] !== 'shell.js').map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
434
+ assert.deepEqual(scripts(html).filter((s) => !['shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0])).map((s) => s[0]), ['idea-objects.js', 'criterion-uat-ui.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
435
435
  // Both doors load the shell (tasks 1004327, 1004430); the SKY's files are the same.
436
- const skyScripts = (h) => scripts(h).filter((s) => s[0] !== 'shell.js');
436
+ const skyScripts = (h) => scripts(h).filter((s) => !['shell-trail.js', 'craft-mode.js', 'shell.js'].includes(s[0]));
437
437
  assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
438
438
  assert.equal(html.match(/thinking\.css\?v=([^"]+)/)[1], thinking.match(/thinking\.css\?v=([^"]+)/)[1]);
439
439
  assert.match(read('thinking.css'), /body\[data-page\] \.sky-stage \{/, 'the sheet dresses the one stage both doors draw in inside the hall');
@@ -253,7 +253,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
253
253
  for (const id of ['app-sidebar', 'app-scrim', 'app-topbar']) assert.match(html, new RegExp(`id="${id}"`), id);
254
254
  assert.match(html, /<main id="hall-main" class="city-main">\s*<div class="city-top">/, 'the stage is the one <main>, the strip inside it');
255
255
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
256
- assert.deepEqual(scripts, ['shell', 'palette', 'city-draw', 'city'], 'the shell first, as on every hall page');
256
+ assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'city'], 'the shell first, as on every hall page');
257
257
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
258
258
  assert.match(css, /\.city-main \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
259
259
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
@@ -22,6 +22,8 @@ import { fileURLToPath } from 'node:url';
22
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
23
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
24
24
  const SHELL = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
25
+ // task 1004492: the way back lives in shell-trail.js, which every page loads first.
26
+ const TRAIL = fs.readFileSync(path.join(HALL, 'shell-trail.js'), 'utf8');
25
27
  const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
26
28
 
27
29
  // Boot the shell once — one page load. `store` is the browser's localStorage and
@@ -121,6 +123,7 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
121
123
  };
122
124
  sandbox.globalThis = sandbox;
123
125
  vm.createContext(sandbox);
126
+ vm.runInContext(TRAIL, sandbox, { filename: 'shell-trail.js' });
124
127
  vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
125
128
  const root = documentObj.documentElement;
126
129
  return {
@@ -376,7 +376,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
376
376
  assert.match(html, /<a class="street-mark" id="streetHome" href="\/builders\/city"[^>]*>\{\{productName\}\}</);
377
377
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
378
378
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
379
- assert.deepEqual(scripts, ['shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
379
+ assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-amend', 'street'], 'the hall shell (task 1004544), the shared kit, then the card (task 1004528), the interiors (task 1004526) and the drafted amendment (task 1004530)');
380
380
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
381
381
  });
382
382
 
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-02-trail/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-02-craft/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '901fc3252b096ce6';
90
+ const SHELL_SHA = '8932abeda77f250a';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);