@bongos/core 1.21.71 → 1.21.73

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 (56) hide show
  1. package/.bongos-core.json +66 -61
  2. package/docs/copy-inventory.md +387 -380
  3. package/docs/copy-registry.json +520 -447
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +1228 -1248
  6. package/modules/hall-ui/public/atlas.html +3 -3
  7. package/modules/hall-ui/public/blockers.html +3 -3
  8. package/modules/hall-ui/public/board-room.html +3 -3
  9. package/modules/hall-ui/public/city.html +3 -3
  10. package/modules/hall-ui/public/collab.html +3 -3
  11. package/modules/hall-ui/public/commands.html +3 -3
  12. package/modules/hall-ui/public/copy-desk.html +3 -3
  13. package/modules/hall-ui/public/craft-mode.js +69 -1
  14. package/modules/hall-ui/public/deploy.html +3 -3
  15. package/modules/hall-ui/public/diagrams.html +3 -3
  16. package/modules/hall-ui/public/drachmae.html +3 -3
  17. package/modules/hall-ui/public/fleet.html +3 -3
  18. package/modules/hall-ui/public/gate.html +3 -3
  19. package/modules/hall-ui/public/goal-map.html +3 -3
  20. package/modules/hall-ui/public/goals.html +3 -3
  21. package/modules/hall-ui/public/government.html +3 -3
  22. package/modules/hall-ui/public/idea.html +3 -3
  23. package/modules/hall-ui/public/ideas.html +3 -3
  24. package/modules/hall-ui/public/index.html +3 -3
  25. package/modules/hall-ui/public/modules.html +3 -3
  26. package/modules/hall-ui/public/primer.html +3 -3
  27. package/modules/hall-ui/public/profile.html +3 -3
  28. package/modules/hall-ui/public/project-settings.html +3 -3
  29. package/modules/hall-ui/public/ranks.html +3 -3
  30. package/modules/hall-ui/public/roadmap.html +3 -3
  31. package/modules/hall-ui/public/roster.html +3 -3
  32. package/modules/hall-ui/public/sessions.html +3 -3
  33. package/modules/hall-ui/public/settings.html +3 -3
  34. package/modules/hall-ui/public/shell.js +9 -0
  35. package/modules/hall-ui/public/street.html +3 -3
  36. package/modules/hall-ui/public/studio.html +3 -3
  37. package/modules/hall-ui/public/task.html +3 -3
  38. package/modules/hall-ui/public/thinking.html +3 -3
  39. package/modules/hall-ui/public/tweak-editor.html +3 -3
  40. package/modules/hall-ui/public/watch.html +3 -3
  41. package/modules/hall-ui/public/work.html +3 -3
  42. package/modules/public-landing/public/projects-hall-live.states.json +7 -1
  43. package/modules/public-landing/public/projects.html +581 -0
  44. package/modules/public-landing/public/projects.probes.json +2 -2
  45. package/modules/public-landing/public/projects.states.json +1 -1
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +12 -0
  49. package/src/module-api.js +1 -1
  50. package/tests/craft_mode_nav.mjs +117 -1
  51. package/tests/hall_city.mjs +5 -3
  52. package/tests/hall_nav.mjs +3 -1
  53. package/tests/hall_street.mjs +2 -1
  54. package/tests/hall_tweak_editor.mjs +1 -1
  55. package/tests/nav_permission_atoms.mjs +1 -1
  56. package/tests/projects_hub_cloud_hop.mjs +303 -0
@@ -167,9 +167,9 @@
167
167
  { "name": "the review (tasks 1004504, 1004550): Edit opens a row in place, Done confirms it, and Continue is the one confirmation for the rest", "auth": true, "width": 1440, "url": "/projects?view=new",
168
168
  "steps": [["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",200],["click","[data-phys-solo=\"many\"]"],["wait",600],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",200]],
169
169
  "expect": {"visible":["#panel-review","#reviewRows .setRow[data-row=\"rewards\"]","#reviewRows .setRow.done[data-row=\"team\"]"]} },
170
- { "name": "the wizard driver (tasks 1003346 and 1004504): the fork, what, the checks, every review row confirmed (the stub grants repo access, so the new repository is named from the project; the dotless stub host takes a domain the founder owns), the look, then the filing — the done panel with the standup strip and the human steps", "auth": true, "width": 1440, "url": "/projects?view=new",
170
+ { "name": "the wizard driver (tasks 1003346 and 1004504): the fork, what, the checks, every review row confirmed (the stub grants repo access, so the new repository is named from the project; the dotless stub host takes a domain the founder owns), the look, then the filing — the done panel waits with the cloud hop and its status line (task 1004578), the strip and the locked checklist standing down", "auth": true, "width": 1440, "url": "/projects?view=new",
171
171
  "steps": [["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],
172
- "expect": {"visible":["#panel-done","#liveProgress .prog","#liveProgress .prog .stages li.now","#humanSteps li"],"hidden":["#panel-look","#panel-review"],"text":[["#done-h","Setting up Mercury"]]} },
172
+ "expect": {"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#panel-review","#liveProgress","#humanSteps"],"text":[["#done-h","Setting up Mercury"],["#hopStatus","Raising the server, database, and address — nothing needed from you."]]} },
173
173
  { "name": "the front door at 390: the fork's two doors stack inside one pill", "auth": true, "width": 390, "url": "/projects?view=new",
174
174
  "steps": [["wait",600],["click","#termsAck"],["wait",100]],
175
175
  "expect": {"visible":["#panel-fork",".doorPill","#wizDemo","#wizStart"]} },
@@ -57,7 +57,7 @@
57
57
  "in-new-review-run": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"run\"] [data-row-chg]"],["wait",300],["click","[data-phys=\"framing\"][data-val=\"hybrid\"]"],["wait",600]],"expect":{"visible":["#physSuite"]}},
58
58
  "in-new-review-confirmed": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200]],"expect":{"visible":["#reviewGo","#reviewRows .setRow.done"],"text":[["#reviewGo","Looks right, continue"]]}},
59
59
  "in-new-look": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],"expect":{"visible":["#panel-look","#wizLook","#wizRecap","#doCreate"],"hidden":["#panel-review"],"attr":[["#wizRoom","data-screen","look"]]}},
60
- "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#liveProgress .prog","#humanSteps li","#wizInviteLater"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite"]}},
60
+ "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite","#liveProgress","#humanSteps","#wizInviteLater","#stepPager"]}},
61
61
  "in-new-resume-offer": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500],["click","#projTrig"],["wait",350],["click","#menuProjects [data-nav=\"new\"]"],["wait",500]],"expect":{"visible":["#panel-fork","#wizResume","#wizResumeGo"],"hidden":["#panel-done"],"text":[["#wizResumeName","Mercury"]]}},
62
62
  "in-new-look-custom": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#wizLook-custom"],["wait",200]],"expect":{"visible":["#panel-look","#wizAccent","#wizAccentNote"],"hidden":["#panel-review"]}},
63
63
  "out-new": {"auth":false,"url":"/projects?view=new","expect":{"visible":["#panel-gate","#wizSignin"],"hidden":["#qWrap","#panel-fork"]}},
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.71",
3
+ "version": "1.21.73",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.71",
9
+ "version": "1.21.73",
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.71",
3
+ "version": "1.21.73",
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",
@@ -9236,5 +9236,17 @@
9236
9236
  {
9237
9237
  "text": "Hosted projects like Will's now get a working Update button in Settings, instead of being told to run a command on a server they don't have access to."
9238
9238
  }
9239
+ ],
9240
+ "1.21.72": [
9241
+ {
9242
+ "id": "1004578",
9243
+ "text": "While a new project is being set up, founders now get a little cloud-hopping game: each cloud is a public project on the platform, in its own planet colour. When the project goes live, clouds and stars fill the screen and fade"
9244
+ }
9245
+ ],
9246
+ "1.21.73": [
9247
+ {
9248
+ "id": "1004625",
9249
+ "text": "The governor city now has its own tab, and in Governing mode it leads the sidebar with every other governing page tucked into one dropdown under it."
9250
+ }
9239
9251
  ]
9240
9252
  }
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.71'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.73'; // 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');
@@ -187,7 +187,7 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
187
187
  p.signIn('thetes');
188
188
  assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
189
189
  assert.ok(!p.headShowing('government'), 'nor its group head');
190
- assert.deepEqual(p.showing(), ['blockers', 'board-room', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
190
+ assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
191
191
  p.signIn('archon');
192
192
  assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
193
193
  assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');
@@ -202,3 +202,119 @@ test('shell.js applies the mode AFTER its gate loop, and style.css hides what th
202
202
  assert.match(read('style.css'), /#app-sidebar \[data-craft-off\] \{ display: none !important; \}/);
203
203
  assert.doesNotMatch(CRAFT, /\.hidden\s*=/, 'the mode never writes `hidden`, which the gate owns');
204
204
  });
205
+
206
+ // ---------- the Governing fold (owner, 2026-10-08) ----------
207
+ // Governing leads with the City and folds its other own tabs into one dropdown
208
+ // under it. The fold MOVES nodes, which the parsed sidebar above cannot model, so
209
+ // these cases run the real craft-mode.js against a small working tree: nodes with
210
+ // parents, insertBefore and comments, laid out as shell.js's NAV groups are.
211
+ function treeNode(tag, nodeType = 1) {
212
+ const attrs = {};
213
+ const n = {
214
+ tagName: tag, nodeType, id: '', className: '', dataset: {}, hidden: false, childNodes: [], parentNode: null, _html: '', type: '',
215
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
216
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
217
+ get classList() {
218
+ const self = this;
219
+ const list = () => self.className.split(/\s+/).filter(Boolean);
220
+ return {
221
+ contains: (c) => list().includes(c),
222
+ add: (c) => { if (!list().includes(c)) self.className = list().concat(c).join(' '); },
223
+ remove: (c) => { self.className = list().filter((x) => x !== c).join(' '); },
224
+ toggle(c, on) { if (on === undefined) on = !this.contains(c); if (on) this.add(c); else this.remove(c); return on; },
225
+ };
226
+ },
227
+ get nextSibling() { const p = this.parentNode; return p ? p.childNodes[p.childNodes.indexOf(this) + 1] || null : null; },
228
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
229
+ appendChild(c) { return this.insertBefore(c, null); },
230
+ insertBefore(c, ref) {
231
+ if (c.parentNode) c.parentNode.removeChild(c);
232
+ const i = ref ? this.childNodes.indexOf(ref) : -1;
233
+ if (i === -1) this.childNodes.push(c); else this.childNodes.splice(i, 0, c);
234
+ c.parentNode = this;
235
+ return c;
236
+ },
237
+ removeChild(c) { this.childNodes.splice(this.childNodes.indexOf(c), 1); c.parentNode = null; return c; },
238
+ walk(fn) { for (const c of this.childNodes) { if (c.nodeType === 1) { fn(c); c.walk(fn); } } },
239
+ querySelectorAll(sel) { const out = []; this.walk((c) => { if (sel.startsWith('.') && c.classList.contains(sel.slice(1))) out.push(c); }); return out; },
240
+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; },
241
+ addEventListener() {}, focus() {},
242
+ };
243
+ return n;
244
+ }
245
+ function bootTree({ mode = null, activeId = null } = {}) {
246
+ const el = (tag, cls, id) => { const n = treeNode(tag); n.className = cls || ''; n.id = id || ''; return n; };
247
+ const sidebar = el('NAV', '', 'app-sidebar');
248
+ // shell.js's NAV, group by group: the unlabeled one holds its items directly,
249
+ // a labeled one a head and a .nav-items wrapper (renderSidebar's shape).
250
+ for (const block of SHELL.slice(SHELL.indexOf('var NAV = ['), SHELL.indexOf('// ---------- nav group collapse')).split('{ group: ').slice(1)) {
251
+ const label = /^'([^']*)'/.exec(block)[1];
252
+ const group = el('DIV', 'nav-group' + (label ? ' nav-group--collapsible' : ''));
253
+ let into = group;
254
+ if (label) {
255
+ const head = el('BUTTON', 'nav-group__head');
256
+ head.dataset.group = label.toLowerCase();
257
+ group.appendChild(head);
258
+ into = group.appendChild(el('DIV', 'nav-items', 'nav-items-' + label.toLowerCase()));
259
+ }
260
+ for (const m of block.matchAll(/\{ id: '([a-z-]+)', label:/g)) {
261
+ into.appendChild(el('A', 'nav-item' + (m[1] === activeId ? ' nav-item--active' : ''), 'nav-' + m[1]));
262
+ }
263
+ sidebar.appendChild(group);
264
+ }
265
+ const root = treeNode('HTML');
266
+ const byId = (id) => { if (sidebar.id === id) return sidebar; let hit = null; sidebar.walk((c) => { if (!hit && c.id === id) hit = c; }); return hit; };
267
+ const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
268
+ const documentObj = {
269
+ body: treeNode('BODY'), documentElement: root, activeElement: null,
270
+ getElementById: byId, querySelector: () => null, querySelectorAll: () => [],
271
+ createElement: (t) => treeNode(t.toUpperCase()), createComment: (t) => Object.assign(treeNode('#comment', 8), { data: t }),
272
+ addEventListener() {}, removeEventListener() {},
273
+ };
274
+ const windowObj = {
275
+ location: { search: '' }, matchMedia: () => ({ matches: false }),
276
+ localStorage: mk({}), sessionStorage: mk(mode ? { 'hall-craft': mode } : {}),
277
+ };
278
+ const sandbox = { window: windowObj, document: documentObj, console, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
279
+ sandbox.globalThis = sandbox;
280
+ vm.createContext(sandbox);
281
+ vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
282
+ const craft = windowObj.OTBCraftMode;
283
+ craft.mount();
284
+ const fold = () => byId('craft-fold');
285
+ const ids = (n) => n.querySelectorAll('.nav-item').map((a) => a.id.slice(4));
286
+ const order = () => ids(sidebar);
287
+ return { craft, sidebar, fold, ids, order, byId };
288
+ }
289
+
290
+ test('GOVERNING LEADS WITH THE CITY: its other tabs fold into one dropdown right under it', () => {
291
+ const before = bootTree().order();
292
+ const p = bootTree({ mode: 'governing' });
293
+ const fold = p.fold();
294
+ assert.ok(fold, 'the dropdown exists in Governing');
295
+ assert.equal(p.byId('nav-city').nextSibling, fold, 'directly under the City tab');
296
+ const own = p.craft.tabsFor('governing').filter((id) => !p.craft.ALWAYS_TABS.includes(id) && id !== 'city');
297
+ assert.deepEqual(JSON.parse(JSON.stringify(p.ids(fold))), JSON.parse(JSON.stringify(own)), 'every other Governing tab is in it, in the mode\'s order');
298
+ assert.ok(fold.classList.contains('nav-group--collapsed'), 'a dropdown starts closed');
299
+ const head = fold.querySelector('.nav-group__head');
300
+ assert.equal(head.getAttribute('aria-expanded'), 'false');
301
+ assert.equal(head.getAttribute('aria-controls'), 'nav-items-craft-fold', 'the head names the region it opens');
302
+ assert.match(head.innerHTML, /More governing/);
303
+ assert.ok(head.hasAttribute('data-craft-off') === false, 'its head shows while a tab inside does');
304
+ assert.ok(p.sidebar.querySelectorAll('.nav-group__head').find((h) => h.dataset.group === 'government').hasAttribute('data-craft-off'),
305
+ 'the emptied Government head hides, so no label sits over nothing');
306
+ p.craft.choose('building');
307
+ assert.equal(p.fold(), null, 'leaving Governing removes the dropdown');
308
+ assert.deepEqual(p.order(), before, 'and puts every tab back exactly where it was');
309
+ p.craft.choose('governing');
310
+ p.craft.choose('governing');
311
+ assert.equal(p.sidebar.querySelectorAll('.craft-fold').length, 1, 'choosing it again builds no second dropdown');
312
+ });
313
+
314
+ test('the dropdown opens on its own when the page you are on is inside it', () => {
315
+ const p = bootTree({ mode: 'governing', activeId: 'watch' });
316
+ assert.ok(!p.fold().classList.contains('nav-group--collapsed'), 'on Watch, the dropdown is open');
317
+ assert.equal(p.fold().querySelector('.nav-group__head').getAttribute('aria-expanded'), 'true');
318
+ assert.equal(bootTree({ mode: 'artistry' }).fold(), null, 'no other mode folds anything');
319
+ assert.equal(bootTree().fold(), null, 'nor does no mode');
320
+ });
@@ -271,8 +271,9 @@ test('the page holds only the wordmark, the switch, the city, one button and one
271
271
  });
272
272
 
273
273
  // task 1004544 (owner decision 2026-10-02): the city sits in the hall chrome
274
- // behind the shell's full-screen switch, the sky's mechanism (task 1004327),
275
- // and still has no sidebar link.
274
+ // behind the shell's full-screen switch, the sky's mechanism (task 1004327).
275
+ // The owner put the CITY on the sidebar on 2026-10-08 (the Governing mode's
276
+ // lead tab); the street still has no link of its own.
276
277
  test('the city sits in the hall chrome, behind the shell\'s full-screen switch', () => {
277
278
  const html = read('city.html').replace(/<!--[\s\S]*?-->/g, '');
278
279
  assert.match(html, /<body data-page="city"[^>]*\sdata-fullscreen-switch>/, 'the bare attribute: the page lays itself out for full screen');
@@ -285,7 +286,8 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
285
286
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
286
287
  assert.match(css, /html\[data-fullscreen="on"\] \.city-top \{ right: 184px; \}/, 'the strip steps clear of the corner exit');
287
288
  const shell = read('shell.js');
288
- assert.doesNotMatch(shell, /href:\s*'(\/builders)?\/(city|street)'/, 'no sidebar link (owner: leave it off the sidebar)');
289
+ assert.match(shell, /\{ id: 'city', label: 'City', icon: 'city', href: '\/city', gate: 'perm:board\.vote\.cast' \}/, 'the city is a tab, gated on its page\'s atom');
290
+ assert.doesNotMatch(shell, /href:\s*'(\/builders)?\/street'/, 'the street has no sidebar link: the way in is the city');
289
291
  });
290
292
 
291
293
  test('motion is CSS only and dies under reduced motion; no page :root; no innerHTML', () => {
@@ -267,7 +267,9 @@ test('sidebar groups read by audience, in order', () => {
267
267
  // purpose (task 1003479, and tests/goal_map_page.mjs pinned it); the owner
268
268
  // granted the sky that slot on 2026-09-24 (ADR 0227 §11), under Goals because
269
269
  // it is the goals seen as one picture.
270
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'thinking', 'studio'],
270
+ // 2026-10-08: 'city' — the governor city — joins after the Board Room, on the
271
+ // same atom and for the same reason (owner call; it leads the Governing mode).
272
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'city', 'thinking', 'studio'],
271
273
  'the unlabeled group is the project itself (task 1002425 order)');
272
274
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
273
275
  // task 1003444: 'people' LEFT the group — the owner deleted the hall's
@@ -504,7 +504,8 @@ test('the street sits in the hall chrome, behind the shell\'s full-screen switch
504
504
  assert.match(css, /\.street-stage \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
505
505
  assert.match(css, /html\[data-fullscreen="on"\] \.street-stage \{ top: 0; left: 0; \}/);
506
506
  assert.match(css, /html\[data-fullscreen="on"\] \.street-top \{ padding-right: 184px; \}/, 'the strip steps clear of the corner exit');
507
- assert.doesNotMatch(read('shell.js'), /href:\s*'(\/builders)?\/(city|street)'/, 'no sidebar link (owner: leave it off the sidebar)');
507
+ // The city became a tab on 2026-10-08 (owner); the street is still reached through it.
508
+ assert.doesNotMatch(read('shell.js'), /href:\s*'(\/builders)?\/street'/, 'no sidebar link (owner: leave it off the sidebar)');
508
509
  });
509
510
 
510
511
  test('motion is CSS only and dies under reduced motion; no page :root; no innerHTML', () => {
@@ -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-04-lightbulb/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-08-city-tab/);
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 = 'af1c61ca87767b44';
90
+ const SHELL_SHA = '02e600a176c32f6a';
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);
@@ -0,0 +1,303 @@
1
+ // tests/projects_hub_cloud_hop.mjs
2
+ //
3
+ // The create wizard's done panel in two moments (task 1004578, from idea 1001205):
4
+ // WAIT — one status line, a thin bar and the cloud hop, a little game with one
5
+ // cloud per PUBLIC project on the platform — then ARRIVE — the steps only the
6
+ // founder can play, one at a time. The owner set the game's rules on a played
7
+ // prototype, and the nausea ones are the ones a refactor would quietly lose: the
8
+ // clouds stand still, the sky stands still, the view does not follow a jump.
9
+ //
10
+ // EXECUTED by the vm-slice idiom: momentFor/setMoment ride the same region the DNS
11
+ // test runs (renderProgress); the physics (hopStep), the score line (hopScore),
12
+ // the status sync (hopSync) and the pager (stepsPaint) are sliced function by
13
+ // function and run under narrow stubs. Every slice is asserted to exist, so a
14
+ // rename fails loudly instead of testing nothing. vm-born values are compared as
15
+ // primitives or through JSON.
16
+ //
17
+ // Run: node --test tests/projects_hub_cloud_hop.mjs
18
+
19
+ import { test } from 'node:test';
20
+ import { strict as assert } from 'node:assert';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
28
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
29
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
30
+ const STYLE = HUB.slice(HUB.indexOf('<style>'), HUB.indexOf('</style>'));
31
+ const J = (v) => JSON.parse(JSON.stringify(v));
32
+
33
+ function between(from, to) {
34
+ const a = SCRIPT.indexOf(from);
35
+ const b = SCRIPT.indexOf(to, a + 1);
36
+ assert.ok(a > 0, `slice start must exist: ${from}`);
37
+ assert.ok(b > a, `slice end must follow it: ${to}`);
38
+ return SCRIPT.slice(a, b);
39
+ }
40
+ // one wizard-scope function, closed on the first `}` at the wizard's four-space indent
41
+ function fn(name) {
42
+ const m = SCRIPT.match(new RegExp(`\\n {4}function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n {4}\\}`));
43
+ assert.ok(m, `function ${name}() must be findable in projects.html`);
44
+ return m[0];
45
+ }
46
+ const HOP_CONSTS = (SCRIPT.match(/\n {4}var HOP_GAP = [^\n]*\n/) || [''])[0];
47
+ assert.ok(HOP_CONSTS.includes('HOP_JUMP'), 'the hop constants line must be findable');
48
+ const HOP_REGION = between('/* ══ THE CLOUD HOP (task 1004578)', '/* ── the hub\'s three doors ── */');
49
+
50
+ // ── the moment ──────────────────────────────────────────────────────────────────
51
+ const PROGRESS = between('var PROG_FLOWS = {', 'function startProgress(');
52
+ const OVERLAY = between('function verifiedAt(inst)', 'function chipMarkup(');
53
+
54
+ function bootMoment() {
55
+ const attrs = {};
56
+ const panel = { setAttribute: (k, v) => { attrs[k] = v; } };
57
+ const sandbox = {
58
+ $: (id) => (id === 'panel-done' ? panel : null),
59
+ esc: (s) => String(s), projectPageLink: (l) => l, relTime: () => 'now', canRestart: () => false,
60
+ FRESH_MS: 1, LIVE_MOMENT_MS: 1, state: {}, paintInviteGate() {}, lastProgHtml: '',
61
+ };
62
+ vm.createContext(sandbox);
63
+ vm.runInContext(PROGRESS + '\n' + OVERLAY, sandbox);
64
+ return { sandbox, attrs };
65
+ }
66
+ const NOW = () => new Date().toISOString();
67
+ const live = { status: 'active', domain: 'mercury.cloudbongos.com', dns_resolves: true, last_verified_up: true, last_verified_at: NOW(), open_intent: null };
68
+
69
+ test('the moment follows the same branches renderProgress speaks', () => {
70
+ const { sandbox } = bootMoment();
71
+ const m = (inst) => sandbox.momentFor(inst);
72
+ assert.equal(m({ status: 'requested' }), 'wait', 'filed: the hop');
73
+ assert.equal(m({ status: 'provisioning' }), 'wait', 'standing up: the hop');
74
+ assert.equal(m({ ...live, dns_resolves: false, last_verified_up: null }), 'wait', 'address still being set up: keep playing');
75
+ assert.equal(m({ ...live, last_verified_up: null }), 'wait', 'standing, not checked yet: keep playing');
76
+ assert.equal(m({ ...live, last_verified_up: false }), 'wait', 'not answering right now: keep playing');
77
+ assert.equal(m(live), 'arrive', 'answering on a resolved address: arrive');
78
+ assert.equal(m({ ...live, open_intent: { action: 'standup', state: 'running' } }), 'arrive',
79
+ 'an intent renderProgress does not speak for (a running standup) falls through to the status');
80
+ assert.equal(m({ ...live, open_intent: { action: 'provision' } }), 'wait', 'a queued standup is still a wait');
81
+ assert.equal(m({ status: 'torn_down', open_intent: { action: 'provision' } }), 'wait', 'a queued revive is a wait');
82
+ });
83
+
84
+ test('a snag, an ending, a settings change or an addressless project keeps the plain panel', () => {
85
+ const { sandbox } = bootMoment();
86
+ const m = (inst) => sandbox.momentFor(inst);
87
+ assert.equal(m({ status: 'error' }), 'plain');
88
+ assert.equal(m({ status: 'tearing_down' }), 'plain');
89
+ assert.equal(m({ status: 'torn_down' }), 'plain');
90
+ assert.equal(m({ ...live, open_intent: { action: 'teardown' } }), 'plain');
91
+ assert.equal(m({ ...live, open_intent: { action: 'settings-apply', state: 'running' } }), 'plain');
92
+ assert.equal(m({ ...live, domain: null }), 'plain', 'no address will never answer — nothing to arrive at');
93
+ });
94
+
95
+ test('renderProgress sets the moment before it writes the strip, and only for a real read', () => {
96
+ const { sandbox, attrs } = bootMoment();
97
+ const nodes = { liveProgress: { innerHTML: '', querySelector: () => null }, 'done-h': { textContent: '' }, doneLede: { innerHTML: '' } };
98
+ sandbox.$ = ((orig) => (id) => nodes[id] || orig(id))(sandbox.$);
99
+ sandbox.renderProgress({ status: 'provisioning' });
100
+ assert.equal(attrs['data-moment'], 'wait');
101
+ sandbox.renderProgress(live);
102
+ assert.equal(attrs['data-moment'], 'arrive');
103
+ const body = fn('renderProgress');
104
+ assert.ok(body.indexOf('setMoment(momentFor(inst))') < body.indexOf('setProgress('), 'the moment is decided first');
105
+ assert.match(body, /if \(inst\) setMoment\(momentFor\(inst\)\);/, 'a null read never moves the panel');
106
+ });
107
+
108
+ test('the panel leaves the hop on a stopped watch and starts on it at renderDone', () => {
109
+ const sp = between('function startProgress(', 'function resetForNewProject(');
110
+ assert.equal((sp.match(/setMoment\('plain'\);/g) || []).length, 2, 'the ~33-minute hand-off and a gone instance both drop to plain');
111
+ assert.match(fn('renderDone'), /setMoment\('wait'\);/, 'a fresh done panel opens on the hop');
112
+ assert.match(fn('resetForNewProject'), /hopReset\(\);/, 'a second project starts with a fresh hop');
113
+ });
114
+
115
+ // ── the physics — the owner's rules ─────────────────────────────────────────────
116
+ function bootHop({ clouds = 3, reduced = false } = {}) {
117
+ const score = { textContent: '' };
118
+ const sandbox = { $: (id) => (id === 'hopScore' ? score : null), REDUCED: reduced, Math };
119
+ vm.createContext(sandbox);
120
+ vm.runInContext(HOP_CONSTS + 'var hop = { w: 600, feed: [], clouds: [], best: 0, maxed: false, p: null, cam: 0, camT: 0, L: false, R: false };\n' +
121
+ fn('hopCloudX') + fn('hopCloudW') + fn('hopScore') + fn('hopStep'), sandbox);
122
+ const hop = sandbox.hop;
123
+ hop.clouds.push({ i: 0, base: true, y: 0 });
124
+ // one fixed column of clouds straight up the middle, so a plain jump lands
125
+ for (let i = 1; i <= clouds; i++) hop.clouds.push({ i, f: 0.5, cw: 100, y: -i * sandbox.HOP_GAP, name: 'P' + i, acc: 'sea' });
126
+ hop.p = { x: 300, y: -14, vy: 0, on: hop.clouds[0], face: 1 };
127
+ hop.cam = hop.camT = -340 * 0.8;
128
+ return { sandbox, hop, score };
129
+ }
130
+ function run(sb, frames) { for (let i = 0; i < frames; i++) sb.hopStep(1); }
131
+
132
+ test('a jump lands on the cloud above and the best height counts it', () => {
133
+ const { sandbox, hop, score } = bootHop();
134
+ const before = J(hop.clouds.map((c) => [c.f, c.y]));
135
+ hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
136
+ run(sandbox, 80);
137
+ assert.equal(hop.p.on && hop.p.on.i, 1, 'landed on the first project cloud');
138
+ assert.equal(hop.best, 1);
139
+ assert.equal(score.textContent, 'Best: 1 of 3 projects');
140
+ assert.deepEqual(J(hop.clouds.map((c) => [c.f, c.y])), before, 'the clouds never move — moving clouds read as nausea');
141
+ });
142
+
143
+ test('the view holds still during a jump and glides only after the landing', () => {
144
+ const { sandbox, hop } = bootHop();
145
+ const cam0 = hop.cam;
146
+ hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
147
+ let airborneMoved = false;
148
+ for (let i = 0; i < 80 && !hop.p.on; i++) { sandbox.hopStep(1); if (!hop.p.on && Math.abs(hop.cam - cam0) > 0.01) airborneMoved = true; }
149
+ assert.equal(airborneMoved, false, 'no camera motion while the player is in the air');
150
+ assert.ok(hop.p.on, 'and it did land');
151
+ run(sandbox, 60);
152
+ assert.ok(hop.cam < cam0 - 10, 'once landed higher, the view glides up');
153
+ });
154
+
155
+ test('a miss falls, and the full-width base cloud always catches', () => {
156
+ const { sandbox, hop } = bootHop();
157
+ hop.p.on = hop.clouds[2]; hop.p.y = hop.clouds[2].y - 14; hop.best = 2;
158
+ hop.p.x = 600 - 10; // walk off the right edge of a centred cloud
159
+ run(sandbox, 200);
160
+ assert.equal(hop.p.on && hop.p.on.base, true, 'it fell past the lower clouds and landed on Your planet');
161
+ assert.equal(hop.best, 2, 'falling never takes the best height away');
162
+ });
163
+
164
+ test('steering moves the player, held to the stage', () => {
165
+ const { sandbox, hop } = bootHop();
166
+ hop.R = true; run(sandbox, 500);
167
+ assert.equal(hop.p.x, 590, 'right stops 10px from the edge');
168
+ hop.R = false; hop.L = true; run(sandbox, 500);
169
+ assert.equal(hop.p.x, 10, 'and left at 10px');
170
+ });
171
+
172
+ test('past the last cloud: max projects reached, build more to jump more', () => {
173
+ const { sandbox, hop, score } = bootHop({ clouds: 1 });
174
+ hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
175
+ run(sandbox, 80);
176
+ assert.equal(hop.maxed, true);
177
+ assert.equal(score.textContent, 'Max projects reached — build more to jump more');
178
+ const jump = fn('hopJump');
179
+ assert.match(jump, /!hop\.maxed/, 'and the jumping stops');
180
+ assert.match(HOP_REGION, /'Max projects reached'/);
181
+ assert.match(HOP_REGION, /'Build more to jump more'/);
182
+ });
183
+
184
+ test('the clouds are the public planets only, in the map\'s colours', () => {
185
+ const feed = fn('hopLoadFeed');
186
+ assert.match(feed, /p\.orb === 'planet'/, 'never a private star or a stealth black hole');
187
+ assert.match(feed, /localeCompare\(String\(b\.origin\)\)/, 'the accent walk runs over the origin-sorted catalog, like assignAccents');
188
+ assert.match(feed, /ORB_ACCENT_NAMES\.indexOf\(p\.planet_accent\) !== -1 \? p\.planet_accent/, 'the owner\'s pick wins');
189
+ assert.match(fn('hopBuild'), /hop\.clouds\.length === 1/, 'no public project at all reads as max reached');
190
+ });
191
+
192
+ // ── motion and the world ────────────────────────────────────────────────────────
193
+ test('the loop never runs under reduced motion; the rest frame is a full still sky', () => {
194
+ assert.match(fn('hopRunning'), /return !REDUCED && !document\.hidden && hop\.target === 'wait'/, 'the gate, like the map\'s skyActive (C9)');
195
+ const start = fn('hopStart');
196
+ assert.ok(start.indexOf('hopRunning()') < start.indexOf('requestAnimationFrame'), 'nothing is scheduled before the gate');
197
+ assert.match(start, /hopDraw\(performance\.now\(\)\)/, 'a stopped hop draws its still frame');
198
+ assert.equal((HOP_REGION.match(/requestAnimationFrame\(/g) || []).length, 4,
199
+ 'the start, the frame (playing and curtain) and the curtain\'s kick-off — no other loop');
200
+ assert.match(fn('hopFrame'), /if \(!hopRunning\(\)\) \{ hopDraw\(now\); return; \}/,
201
+ 'every frame re-asks the gate, so turning reduced motion on stops it');
202
+ assert.match(fn('hopMoment'), /!REDUCED && hop\.raf/, 'the curtain plays only from a running game');
203
+ assert.match(STYLE, /\.hop\.isStill \.hopRow\{display:none;\}/, 'reduced motion has nothing to steer');
204
+ });
205
+
206
+ test('the game reads its colours off the page tokens — no literal but white or black with alpha', () => {
207
+ const hexes = HOP_REGION.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
208
+ assert.deepEqual(hexes, [], 'no hex in the hop');
209
+ // hopCss() is the builder ('rgba(' + a token's channels), not a colour
210
+ const literals = (HOP_REGION.match(/'rgba?\([^']*'/g) || []).filter((m) => m !== "'rgba('");
211
+ for (const m of literals) assert.match(m, /^'rgba\(0,0,0,/, `only a black scrim may be literal: ${m}`);
212
+ assert.match(HOP_REGION, /var HOP_WHITE = \[255, 255, 255\];/, 'white is the one other literal');
213
+ assert.match(fn('hopColors'), /hopRead\('var\(--' \+ nm \+ '\)'\)/, 'every colour resolves from a token');
214
+ });
215
+
216
+ // ── the status line and the arrival pager ───────────────────────────────────────
217
+ function el(cls = []) {
218
+ const set = new Set(cls);
219
+ const attrs = {};
220
+ return {
221
+ classList: { contains: (c) => set.has(c), toggle: (c, on) => (on ? set.add(c) : set.delete(c)), add: (c) => set.add(c), remove: (c) => set.delete(c) },
222
+ setAttribute: (k, v) => { attrs[k] = v; }, getAttribute: (k) => attrs[k], attrs, hidden: false, textContent: '', style: {},
223
+ };
224
+ }
225
+
226
+ test('the status line and the bar read what the strip says', () => {
227
+ const st = { textContent: '' }, fill = { style: {} };
228
+ const sandbox = { $: (id) => ({ hopStatus: st, hopBarFill: fill })[id] || null, Math };
229
+ vm.createContext(sandbox);
230
+ vm.runInContext(fn('hopSync'), sandbox);
231
+ const strip = {
232
+ querySelector: (q) => (q === '.pnote' ? { textContent: 'Raising the server, database, and address — nothing needed from you.' } : q === '.stages li.now' ? {} : null),
233
+ querySelectorAll: (q) => ({ length: q === '.stages li' ? 3 : 1 }),
234
+ };
235
+ sandbox.hopSync(strip);
236
+ assert.equal(st.textContent, 'Raising the server, database, and address — nothing needed from you.');
237
+ assert.equal(fill.style.width, '50%', 'one done and one under way of three');
238
+ assert.match(fn('setProgress'), /hopSync\(el\)/, 'every strip write syncs the line');
239
+ });
240
+
241
+ function bootSteps(items, signin) {
242
+ const list = { children: items, querySelector: (q) => items.find((li) => q.includes(li.key) || (q === 'li.cur' && li.classList.contains('cur'))) || null };
243
+ const panel = el(signin ? ['hopSignin'] : []);
244
+ const nodes = { 'panel-done': panel, humanSteps: list, hopWhile: el(), stepCount: el(), stepBack: el(), stepNext: el() };
245
+ const sandbox = { $: (id) => nodes[id] || null, Math, Array };
246
+ vm.createContext(sandbox);
247
+ vm.runInContext('var stepIdx = -1, stepAuto = true;' + fn('stepsPaint') + fn('stepGo'), sandbox);
248
+ return { sandbox, nodes };
249
+ }
250
+ function li(key, cls = []) { const e = el(cls); e.key = key; e.focus = () => {}; return e; }
251
+
252
+ test('the arrival shows one step at a time, opening on the first not yet done', () => {
253
+ const items = [li('github-repo', ['sdone']), li('oauth-app'), li('device-flow', ['slocked']), li('enter-hall', ['slocked'])];
254
+ const { sandbox, nodes } = bootSteps(items);
255
+ sandbox.stepsPaint(true);
256
+ assert.deepEqual(items.map((i) => i.classList.contains('cur')), [false, true, false, false]);
257
+ assert.equal(nodes.stepCount.textContent, 'Step 2 of 4');
258
+ assert.equal(items[1].attrs['data-n'], '02', 'the step on show carries its own number — a hidden step never counts');
259
+ assert.equal(nodes.stepBack.hidden, false);
260
+ assert.equal(nodes.stepNext.hidden, false);
261
+ // the checklist ticks itself: the step on show finishing moves the founder on
262
+ items[1].classList.add('sdone');
263
+ sandbox.stepsPaint();
264
+ assert.equal(nodes.stepCount.textContent, 'Step 3 of 4');
265
+ // once they page for themselves, their choice holds
266
+ sandbox.stepGo(-1);
267
+ items[2].classList.add('sdone');
268
+ sandbox.stepsPaint();
269
+ assert.equal(nodes.stepCount.textContent, 'Step 2 of 4');
270
+ sandbox.stepGo(1); sandbox.stepGo(1);
271
+ assert.equal(nodes.stepNext.hidden, true, 'no Continue past the last step');
272
+ });
273
+
274
+ test('while waiting, the sign-in step is offered only when it can be done', () => {
275
+ const open = bootSteps([li('github-repo'), li('oauth-app')]);
276
+ open.sandbox.stepsPaint();
277
+ assert.equal(open.nodes.hopWhile.hidden, false, 'a domain and a public platform: offer it');
278
+ const locked = bootSteps([li('github-repo'), li('oauth-app', ['slocked'])]);
279
+ locked.sandbox.stepsPaint();
280
+ assert.equal(locked.nodes.hopWhile.hidden, true, 'locked: nothing to offer');
281
+ const done = bootSteps([li('github-repo'), li('oauth-app', ['sdone'])]);
282
+ done.sandbox.stepsPaint();
283
+ assert.equal(done.nodes.hopWhile.hidden, true, 'already wired: nothing to offer');
284
+ const opened = bootSteps([li('github-repo'), li('oauth-app', ['sdone'])], true);
285
+ opened.sandbox.stepsPaint();
286
+ assert.equal(opened.nodes.hopWhile.hidden, false, 'but an open one keeps its door so it can be closed');
287
+ });
288
+
289
+ // ── the markup and the sheet ────────────────────────────────────────────────────
290
+ test('the wait stands the strip, the locked checklist and the later lines down; the arrival pages', () => {
291
+ for (const id of ['hop', 'hopStatus', 'hopBarFill', 'hopCanvas', 'hopLeft', 'hopJump', 'hopRight', 'hopScore', 'hopWhile', 'hopSignin', 'stepPager', 'stepCount', 'stepBack', 'stepNext']) {
292
+ assert.ok(HUB.includes(`id="${id}"`), `#${id} is baked into the panel`);
293
+ }
294
+ const panel = HUB.slice(HUB.indexOf('id="panel-done"'), HUB.indexOf('</section>', HUB.indexOf('id="panel-done"')));
295
+ assert.ok(panel.indexOf('id="liveProgress"') < panel.indexOf('id="hop"') && panel.indexOf('id="hop"') < panel.indexOf('id="wizInvite"'),
296
+ 'the hop sits where the strip was, above the invite');
297
+ assert.match(panel, /id="hopStatus" role="status" aria-live="polite"/, 'the line is the live region while the strip is hidden');
298
+ assert.match(STYLE, /#panel-done\[data-moment="wait"\] #liveProgress,/);
299
+ assert.match(STYLE, /#panel-done\[data-moment="wait"\]:not\(\.hopSignin\) #humanSteps\{display:none;\}/);
300
+ assert.match(STYLE, /#panel-done\[data-moment="arrive"\] \.humanSteps>li:not\(\.cur\)\{display:none;\}/);
301
+ assert.match(STYLE, /#panel-done\[data-moment="arrive"\] #liveProgress \.stages\{display:none;\}/, 'the arrival keeps the note with the address, not the dots');
302
+ assert.match(STYLE, /@media \(prefers-reduced-motion:no-preference\)\{\n {2}#panel-done\[data-moment="arrive"\] \.humanSteps>li\.cur,/, 'the arrival\'s fade-in is motion-gated');
303
+ });