@bongos/core 1.24.40 → 1.24.42

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 (85) hide show
  1. package/.bongos-core.json +103 -103
  2. package/docs/canonical-permissions.md +2 -1
  3. package/docs/copy-inventory.md +198 -223
  4. package/docs/copy-registry.json +316 -580
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +11 -69
  7. package/docs/page-readings.json +348 -728
  8. package/modules/hall-ui/public/ai-studio.html +2 -2
  9. package/modules/hall-ui/public/atlas.html +2 -2
  10. package/modules/hall-ui/public/blockers.html +2 -2
  11. package/modules/hall-ui/public/board-room.html +2 -2
  12. package/modules/hall-ui/public/builders.js +17 -262
  13. package/modules/hall-ui/public/city.html +2 -2
  14. package/modules/hall-ui/public/collab.html +2 -2
  15. package/modules/hall-ui/public/commands.html +2 -2
  16. package/modules/hall-ui/public/copy-desk.html +2 -2
  17. package/modules/hall-ui/public/craft-mode.js +1 -1
  18. package/modules/hall-ui/public/deploy.html +2 -2
  19. package/modules/hall-ui/public/gate.js +12 -5
  20. package/modules/hall-ui/public/goal-map.html +2 -2
  21. package/modules/hall-ui/public/goals.html +2 -2
  22. package/modules/hall-ui/public/government.html +2 -2
  23. package/modules/hall-ui/public/hall-render.js +3 -248
  24. package/modules/hall-ui/public/hall-widgets.js +3 -14
  25. package/modules/hall-ui/public/idea.html +2 -2
  26. package/modules/hall-ui/public/ideas.html +2 -2
  27. package/modules/hall-ui/public/index.html +4 -5
  28. package/modules/hall-ui/public/market-watch-item.html +2 -2
  29. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  30. package/modules/hall-ui/public/market-watch.html +2 -2
  31. package/modules/hall-ui/public/modules.html +2 -2
  32. package/modules/hall-ui/public/money.html +2 -2
  33. package/modules/hall-ui/public/my-agents.html +2 -2
  34. package/modules/hall-ui/public/palette.js +10 -0
  35. package/modules/hall-ui/public/primer.html +2 -2
  36. package/modules/hall-ui/public/profile.html +2 -2
  37. package/modules/hall-ui/public/project-settings.html +2 -2
  38. package/modules/hall-ui/public/roadmap.html +2 -2
  39. package/modules/hall-ui/public/roster.html +2 -2
  40. package/modules/hall-ui/public/sessions.js +21 -10
  41. package/modules/hall-ui/public/settings.html +2 -2
  42. package/modules/hall-ui/public/setup-gate.js +156 -0
  43. package/modules/hall-ui/public/shell.js +17 -3
  44. package/modules/hall-ui/public/street.html +2 -2
  45. package/modules/hall-ui/public/studio.html +2 -2
  46. package/modules/hall-ui/public/style.css +85 -117
  47. package/modules/hall-ui/public/task.html +2 -2
  48. package/modules/hall-ui/public/thinking.html +2 -2
  49. package/modules/hall-ui/public/tweak-editor.html +2 -2
  50. package/modules/hall-ui/public/watch.html +120 -7
  51. package/modules/hall-ui/public/watch.js +11 -0
  52. package/modules/hall-ui/public/watch.states.json +49 -1
  53. package/modules/hall-ui/public/work.html +2 -2
  54. package/modules/hall-ui/records/setup-gate.md +43 -0
  55. package/modules/lifecycle/routes/tasks.js +2 -2
  56. package/modules/onboarding/onboarding-state.js +41 -20
  57. package/package-lock.json +2 -2
  58. package/package.json +1 -1
  59. package/release-notes.json +12 -0
  60. package/scripts/bongos/gen-hall-sitemap.js +2 -0
  61. package/src/bongos/routes/me.js +2 -2
  62. package/src/bongos/serve-internal.js +15 -22
  63. package/src/module-api.js +1 -1
  64. package/tests/autonomy_builder_scope.mjs +2 -1
  65. package/tests/hall_audit.mjs +1 -1
  66. package/tests/hall_board_world.mjs +1 -1
  67. package/tests/hall_landing_boot.mjs +2 -3
  68. package/tests/hall_loading.mjs +13 -2
  69. package/tests/hall_nav.mjs +5 -2
  70. package/tests/hall_onboarding_bongos_setup.mjs +14 -14
  71. package/tests/hall_oversight_tabs.mjs +162 -0
  72. package/tests/hall_oversight_world.mjs +23 -9
  73. package/tests/hall_page_gate_map.mjs +7 -2
  74. package/tests/hall_roster_world.mjs +14 -9
  75. package/tests/hall_sessions_ledger.mjs +2 -1
  76. package/tests/module_contributions.mjs +4 -2
  77. package/tests/nav_permission_atoms.mjs +4 -2
  78. package/tests/onboarding_state.mjs +33 -0
  79. package/tests/sessions_kit_pager.mjs +8 -4
  80. package/tests/setup_flow.mjs +242 -0
  81. package/tests/welcome_boot.mjs +3 -2
  82. package/modules/hall-ui/public/gate.html +0 -76
  83. package/modules/hall-ui/public/gate.states.json +0 -60
  84. package/modules/hall-ui/public/sessions.html +0 -128
  85. package/modules/hall-ui/public/sessions.states.json +0 -59
@@ -92,15 +92,14 @@ async function boot({ meStatus, settle = false }) {
92
92
  },
93
93
  OTBHallRender: () => ({
94
94
  analyticsState: {}, lbState: {},
95
- shipsPager: { page: 1 }, welcomeDismissalKey: () => 'k',
95
+ shipsPager: { page: 1 },
96
96
  // task 1004854: Home asks for this many ships; the ledger's full read lives in hall-render.js.
97
97
  HOME_SHIPS_LIMIT: 5, setRecentShips: spy('setRecentShips'), populateShipVersionFilter: spy('populateShipVersionFilter'),
98
98
  mountHallWidgets: spy('mountHallWidgets'),
99
99
  renderLanding: spy('renderLanding'),
100
100
  renderCurveBody: spy('renderCurveBody'), renderGreeting: spy('renderGreeting'),
101
- renderNav: spy('renderNav'), renderOnboarding: spy('renderOnboarding'),
101
+ renderNav: spy('renderNav'),
102
102
  renderProfile: spy('renderProfile'), renderRecentShips: spy('renderRecentShips'),
103
- renderWelcomeCore: spy('renderWelcomeCore'),
104
103
  // task 1003459 — wireEvents() calls this unconditionally now (the Home
105
104
  // Next-up Claim button), so an incomplete stub throws mid-wire.
106
105
  wireNextUpClaim: spy('wireNextUpClaim'),
@@ -83,7 +83,7 @@ test('the page loaders step aside by themselves, and never show without :has()',
83
83
  assert.match(STYLE, /\.hall-loading--wait, \.hall-loading--zones \{ display: none; \}/);
84
84
  assert.match(STYLE, /@supports selector\(:has\(a\)\) \{\s*\.hall-loading--wait:not\(:has\(~ :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\.hall-loading--zones:not\(:has\(~ :not\(\[hidden\]\) > :not\(\[hidden\], script, template, style\)\)\) \{ display: flex; \}\s*\}/);
85
85
  // A page whose sections all start [hidden] carries the --wait loader FIRST in its main.
86
- for (const page of ['board-room', 'deploy', 'gate', 'government', 'money', 'project-settings', 'sessions']) {
86
+ for (const page of ['board-room', 'deploy', 'government', 'money', 'project-settings']) {
87
87
  const html = read(`${page}.html`);
88
88
  const main = html.slice(html.indexOf('<main id="hall-main">'));
89
89
  const first = main.replace(/<main id="hall-main">\s*(<!--[\s\S]*?-->\s*)*/, '');
@@ -95,6 +95,17 @@ test('the page loaders step aside by themselves, and never show without :has()',
95
95
  }
96
96
  assert.match(read('index.html'), /<section class="view" id="view-home"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Home waits on its zones');
97
97
  assert.match(read('watch.html'), /<main id="hall-main">\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…<\/p>/, 'Watch waits on its panes');
98
+ // task 1005049: the Sessions and Autobongos tabs (once their own pages) open on the
99
+ // --wait loader, so a tab whose sections are all still hidden says it is loading.
100
+ for (const pane of ['pane-sessions', 'pane-autobongos']) {
101
+ const html = read('watch.html');
102
+ const inner = html.slice(html.indexOf(`<div id="${pane}"`));
103
+ const body = inner.slice(inner.indexOf('>') + 1).replace(/^\s*(<!--[\s\S]*?-->\s*)*/, '');
104
+ assert.match(body, /^<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/, `${pane} opens on the page loader`);
105
+ const kids = [...body.slice(body.indexOf('</p>') + 4, body.indexOf('\n </div>')).matchAll(/^ {10}<(section|div|p|nav)\b([^>]*)>/gm)];
106
+ assert.ok(kids.length >= 1, `${pane} has sections after the loader`);
107
+ for (const [, tag, attrs] of kids) assert.match(attrs, /\bhidden\b/, `${pane}: a <${tag}> after the loader starts hidden`);
108
+ }
98
109
  // Settings' layout is never [hidden] while it loads, so its loader is put away by hideLayout().
99
110
  assert.match(read('settings.html'), /id="settings-groups"[^>]*>\s*<!--[^>]*-->\s*<p class="hall-loading hall-loading--page" id="settings-loading" role="status">/);
100
111
  assert.match(read('settings.js'), /function hideLayout\(hide\) \{[\s\S]*?getElementById\('settings-loading'\)[\s\S]*?loading\.hidden = true;/);
@@ -105,7 +116,7 @@ test('no hall page waits on a bare line of text any more', () => {
105
116
  // the class would stay behind on the finished sentence. Each is named with why.
106
117
  const IN_PLACE = new Set([
107
118
  'atlas.html#caption', // the map's caption, rewritten by atlas.js
108
- 'sessions.html#ledger-status', // the ledger's status line, rewritten by sessions.js
119
+ 'watch.html#ledger-status', // the Sessions tab's status line, rewritten by sessions.js
109
120
  'goals.html#goal-deps-card-lede', // the dependency card's lede, rewritten by goals-page.js
110
121
  'goals.html#goal-deps-lede',
111
122
  'profile.html#activity-placeholder', // reused for the signed-out notice by profile.js
@@ -335,7 +335,10 @@ test('sidebar groups read by audience, in order', () => {
335
335
  // 'market-watch-settings' (task 1004938) LEFT the group in task 1005045: the owner saw
336
336
  // "Market Watch" twice and had to guess which was which (owner decision 2026-11-03,
337
337
  // idea 1001310). The settings are an owner-only Settings tab on /market-watch now.
338
- assert.deepEqual(nav[5].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'money', 'deploy', 'government']);
338
+ // 'gate' (Autobongos) and 'sessions' LEFT the group in task 1005049 (owner decision
339
+ // 2026-11-03, idea 1001315): they are tabs on Watch, and /gate + /sessions redirect there.
340
+ assert.deepEqual(nav[5].items.map((i) => i.id), ['watch', 'project-settings', 'money', 'deploy', 'government']);
341
+ assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === '/gate' || i.href === '/sessions'), 'no item points at a page that became a Watch tab');
339
342
  // task 1005045: and no label is left twice anywhere in the sidebar, so the next pair
340
343
  // fails here instead of in front of the owner.
341
344
  const labels = [...boot().sidebarHtml.matchAll(/<a class="nav-item" [^>]*>[\s\S]*?<span class="nav-item__label">([^<]*)<\/span>/g)].map((m) => m[1]);
@@ -787,7 +790,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
787
790
  // block (task 1004713). These drive the real shell and the real gov-positions.js.
788
791
 
789
792
  const tick = () => new Promise((resolve) => setImmediate(resolve));
790
- const GOVERNING = ['city', 'board-room', 'watch', 'gate', 'sessions', 'project-settings', 'money', 'deploy', 'government'];
793
+ const GOVERNING = ['city', 'board-room', 'watch', 'project-settings', 'money', 'deploy', 'government'];
791
794
  const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
792
795
  const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
793
796
 
@@ -14,20 +14,20 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
14
  const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
15
15
  const OLD = /\/builder-setup|setup-builder\.(sh|js)/;
16
16
 
17
- // The welcome panel's markup, from the setupSteps literal to the panel's innerHTML.
18
- function welcomeMarkup() {
19
- const src = read('modules/hall-ui/public/hall-render.js');
20
- const start = src.indexOf('const setupSteps = `');
21
- const end = src.indexOf('body.innerHTML = `', start);
22
- assert.ok(start > 0 && end > start, 'welcome panel markup located');
17
+ // The setup screen's machine-linking step (task 1005076 replaced the Home welcome panel).
18
+ function setupStepMarkup() {
19
+ const src = read('modules/hall-ui/public/setup-gate.js');
20
+ const start = src.indexOf("if (step.id === 'cli_setup') {");
21
+ const end = src.indexOf("if (step.id === 'pick_disciplines')", start);
22
+ assert.ok(start > 0 && end > start, 'the setup screen\'s cli_setup step located');
23
23
  return src.slice(start, end).split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n');
24
24
  }
25
25
 
26
- test('welcome panel: the steps and the clone block say npx bongos setup', () => {
27
- const w = welcomeMarkup();
28
- assert.match(w, /Run <code>npx bongos setup<\/code>/);
29
- assert.match(w, /\\nnpm install\\nnpx bongos setup`/, 'clone block ends npm install + npx bongos setup');
30
- assert.doesNotMatch(w, OLD, 'no legacy setup command on the welcome panel');
26
+ test('setup screen: the steps and the clone block say npx bongos setup', () => {
27
+ const w = setupStepMarkup();
28
+ assert.match(w, /run <code>npx bongos setup<\/code>/);
29
+ assert.match(w, /\\nnpm install\\nnpx bongos setup<\/pre>/, 'clone block ends npm install + npx bongos setup');
30
+ assert.doesNotMatch(w, OLD, 'no legacy setup command on the setup screen');
31
31
  assert.doesNotMatch(w, /On Windows, run/, 'one command on every OS — no Windows-only line');
32
32
  assert.match(w, /nodejs\.org/, 'the tooling list names Node, which npm install needs');
33
33
  });
@@ -51,7 +51,7 @@ test('primer: both routes sign in with npx bongos setup', () => {
51
51
 
52
52
  test('the get-the-code block keeps one command per line (screen and copy)', () => {
53
53
  const css = read('modules/hall-ui/public/style.css');
54
- const m = /\.setup__getcode \.welcome__paste\s*\{([^}]*)\}/.exec(css);
54
+ const m = /\.setup-gate__code\s*\{([^}]*)\}/.exec(css);
55
55
  assert.ok(m, 'scoped rule present');
56
56
  assert.match(m[1], /white-space:\s*pre/, 'copy-on-click reads innerText, so pre is what keeps the line breaks');
57
57
  assert.match(m[1], /overflow-y:\s*hidden/, 'a one-axis strip names both axes');
@@ -66,8 +66,8 @@ test('bin/bongos.js is committed executable, so npx leaves a fresh clone clean',
66
66
  assert.match(entry, /^100755 /, `expected mode 100755, got: ${entry.trim()}`);
67
67
  });
68
68
 
69
- test('welcome intro and checklist lead ask only for what a builder needs', () => {
70
- const src = read('modules/hall-ui/public/hall-render.js');
69
+ test('the setup screen and checklist lead ask only for what a builder needs', () => {
70
+ const src = read('modules/hall-ui/public/setup-gate.js');
71
71
  assert.doesNotMatch(src, /run the platform locally/, 'a builder does not run the platform');
72
72
  assert.match(src, /Clone the project to your machine and run <code>npm install<\/code> in it\./);
73
73
  const st = read('modules/onboarding/onboarding-state.js');
@@ -0,0 +1,162 @@
1
+ // tests/hall_oversight_tabs.mjs — one oversight page (task 1005049, owner decision
2
+ // 2026-11-03, idea 1001315 item 1).
3
+ //
4
+ // Watch, Sessions and Autobongos (/gate) were three oversight pages in the same
5
+ // sidebar group. Sessions and Autobongos are tabs on Watch now:
6
+ //
7
+ // * OLD URLS LAND ON THE RIGHT TAB. /sessions and /gate 302 to /watch#sessions
8
+ // and /watch#autobongos (serve-internal.js RETIRED_TO_SECTION), with any query
9
+ // string kept; the real middleware runs here, on a bare app.
10
+ // * EVERYTHING THE TWO PAGES SHOWED IS ON WATCH. The tabs exist, their panes hold
11
+ // the ledger, the transcript reader and the runner panel, and the scripts that
12
+ // drew the pages still draw them.
13
+ // * A TAB LOADS WHEN IT OPENS. Each session-ledger load is an audited read on the
14
+ // server, so sessions.js and gate.js ask for nothing until Watch first opens
15
+ // their tab, and only once.
16
+ // * THE PALETTE STILL FINDS THEM: its Watch jumps are real tab ids.
17
+ //
18
+ // Run: node tests/hall_oversight_tabs.mjs
19
+
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import http from 'node:http';
24
+ import path from 'node:path';
25
+ import vm from 'node:vm';
26
+ import { createRequire } from 'node:module';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
32
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
33
+ const WATCH_HTML = read('watch.html');
34
+ const WATCH_JS = read('watch.js');
35
+
36
+ async function withHall(fn) {
37
+ const express = require('express');
38
+ const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
39
+ const app = express();
40
+ mountInternalSurfaces(app);
41
+ const server = app.listen(0, '127.0.0.1');
42
+ await new Promise((r) => server.once('listening', r));
43
+ const { port } = server.address();
44
+ const get = (p, host) => new Promise((resolve, reject) => {
45
+ http.get({ host: '127.0.0.1', port, path: p, headers: { host } }, (res) => {
46
+ res.resume();
47
+ res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location || '' }));
48
+ }).on('error', reject);
49
+ });
50
+ try { await fn(get); } finally { server.close(); }
51
+ }
52
+
53
+ const TAB_OF = { sessions: 'sessions', gate: 'autobongos' };
54
+
55
+ test('RUN: /sessions and /gate each 302 to their tab on Watch', async () => {
56
+ await withHall(async (get) => {
57
+ for (const [page, tab] of Object.entries(TAB_OF)) {
58
+ for (const form of [`/${page}`, `/${page}/`, `/${page}.html`]) {
59
+ const r = await get(form, 'builders.127.0.0.1');
60
+ assert.equal(r.status, 302, `${form} is a temporary redirect`);
61
+ assert.equal(r.location, `/watch#${tab}`, `${form} lands on #${tab}`);
62
+ }
63
+ }
64
+ const q = await get('/sessions?from=mail', 'builders.127.0.0.1');
65
+ assert.equal(q.location, '/watch?from=mail#sessions', 'a query string rides along; the tab is the fragment');
66
+ // The map the two joined still carries the page it was built for (task 1005047).
67
+ assert.equal((await get('/fleet', 'builders.127.0.0.1')).location, '/money#hosting');
68
+ });
69
+ });
70
+
71
+ test('Watch has a Sessions and an Autobongos tab, each with its pane', () => {
72
+ assert.match(WATCH_JS, /\{ id: 'sessions', label: 'Sessions', panel: 'pane-sessions' \}/);
73
+ assert.match(WATCH_JS, /\{ id: 'autobongos', label: 'Autobongos', panel: 'pane-autobongos' \}/);
74
+ for (const pane of ['pane-sessions', 'pane-autobongos']) {
75
+ assert.match(WATCH_HTML, new RegExp(`<div id="${pane}" role="tabpanel" aria-labelledby="tab-${pane.slice(5)}" hidden>`));
76
+ }
77
+ });
78
+
79
+ test('everything the two pages showed is in those panes', () => {
80
+ const sessions = WATCH_HTML.slice(WATCH_HTML.indexOf('<div id="pane-sessions"'), WATCH_HTML.indexOf('<div id="pane-autobongos"'));
81
+ for (const id of ['sess-sealed', 'stats-scroll', 'sess-ledger-scroll', 'session-type-filter', 'sessions-table', 'sessions-pager-mount', 'transcript-scroll']) {
82
+ assert.match(sessions, new RegExp(`id="${id}"`), `the Sessions tab holds #${id}`);
83
+ }
84
+ const runner = WATCH_HTML.slice(WATCH_HTML.indexOf('<div id="pane-autobongos"'));
85
+ for (const id of ['gate-sealed', 'runner-body', 'gate-archon-only', 'fence-panel', 'fence-body', 'fence-builders']) {
86
+ assert.match(runner, new RegExp(`id="${id}"`), `the Autobongos tab holds #${id}`);
87
+ }
88
+ // Their scripts load before watch.js, which boots them.
89
+ const i = (s) => WATCH_HTML.indexOf(s);
90
+ assert.ok(i('/builders/sessions.js') > 0 && i('/builders/sessions.js') < i('/builders/watch.js'));
91
+ assert.ok(i('/builders/gate.js') > 0 && i('/builders/gate.js') < i('/builders/watch.js'));
92
+ // No element id on the page is used twice: three pages' markup now share one document.
93
+ const ids = [...WATCH_HTML.replace(/<!--[\s\S]*?-->/g, '').matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]);
94
+ assert.deepEqual(ids.filter((x, k) => ids.indexOf(x) !== k), [], 'every id on Watch is said once');
95
+ });
96
+
97
+ test('the pages are gone, and nothing in the sidebar points at them', () => {
98
+ assert.ok(!fs.existsSync(path.join(HALL, 'sessions.html')));
99
+ assert.ok(!fs.existsSync(path.join(HALL, 'gate.html')));
100
+ assert.ok(!/href: '\/(sessions|gate)'/.test(read('shell.js')));
101
+ assert.ok(!/'gate', 'sessions'/.test(read('craft-mode.js')), 'Governing mode no longer lists them');
102
+ });
103
+
104
+ function bootTab(file, { sharedMe = false } = {}) {
105
+ const requests = [];
106
+ const el = () => ({ hidden: true, textContent: '', innerHTML: '', value: '', dataset: {}, style: {}, addEventListener() {}, querySelector: () => null, querySelectorAll: () => [], appendChild() {} });
107
+ const els = {};
108
+ const getEl = (id) => (els[id] = els[id] || el());
109
+ const document = { getElementById: getEl, querySelector: (s) => getEl(String(s).replace(/^#/, '')), querySelectorAll: () => [], createElement: el, addEventListener() {} };
110
+ const window = {
111
+ BongosClient: { createClient: () => ({ request: async (m, p) => { requests.push(p); return { status: 200, ok: true, data: { builder: { id: 1, rank: 'xenos' } } }; } }) },
112
+ OTB: { escapeHtml: (s) => String(s), $: document.querySelector, num: Number, fmtInt: String, fmtCompact: String, fmtDate: String, rankLabel: (r) => r, toast() {} },
113
+ OTBKit: { emptyStateHtml: (m) => m, makePager: () => ({ page: 1, perPage: 25 }) },
114
+ location: { assign() {} },
115
+ localStorage: { getItem: () => null, setItem() {} },
116
+ addEventListener() {},
117
+ };
118
+ // The shell's own /me read (shell.js probeMe shares it on window.__hallReads).
119
+ // A viewer below the floor, so each tab stops at its own sealed notice after /me.
120
+ if (sharedMe) window.__hallReads = { '/me': Promise.resolve({ builder: { id: 1, rank: 'xenos' } }) };
121
+ const sandbox = { window, document, console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout, Promise, URLSearchParams };
122
+ vm.createContext(sandbox);
123
+ vm.runInContext(read(file), sandbox, { filename: file });
124
+ return { window, requests, getEl };
125
+ }
126
+
127
+ for (const [file, tab] of [['sessions.js', 'sessions'], ['gate.js', 'autobongos']]) {
128
+ test(`${file} asks for nothing until Watch opens its tab, then boots once`, async () => {
129
+ const t = bootTab(file);
130
+ await new Promise((r) => setImmediate(r));
131
+ assert.equal(t.requests.length, 0, `${file} read the server before its tab opened`);
132
+ assert.equal(typeof t.window.OTBWatchTabs[tab], 'function', `${file} hands Watch a boot for #${tab}`);
133
+ t.window.OTBWatchTabs[tab]();
134
+ await new Promise((r) => setImmediate(r));
135
+ const first = t.requests.length;
136
+ assert.ok(first >= 1, 'opening the tab loads it');
137
+ t.window.OTBWatchTabs[tab]();
138
+ await new Promise((r) => setImmediate(r));
139
+ assert.equal(t.requests.length, first, 'opening it again does not load it twice');
140
+ });
141
+ }
142
+
143
+ for (const [file, tab] of [['sessions.js', 'sessions'], ['gate.js', 'autobongos']]) {
144
+ test(`${file} adopts the shell's /me read rather than asking again`, async () => {
145
+ const t = bootTab(file, { sharedMe: true });
146
+ t.window.OTBWatchTabs[tab]();
147
+ for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r));
148
+ assert.ok(!t.requests.some((p) => /\/me$/.test(p)), `${file} asked /me although the shell had`);
149
+ assert.equal(t.getEl(file === 'gate.js' ? 'gate-sealed' : 'sess-sealed').hidden, false, 'and still acts on the answer');
150
+ });
151
+ }
152
+
153
+ test('watch.js boots a tab from its own change hook', () => {
154
+ assert.match(WATCH_JS, /onChange: \(id\) => \{\s*const boot = window\.OTBWatchTabs && window\.OTBWatchTabs\[id\];\s*if \(boot\) boot\(\);/);
155
+ });
156
+
157
+ test('the palette\'s Watch jumps are real watch.js tab ids', () => {
158
+ const ids = [...WATCH_JS.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
159
+ const declared = [...read('palette.js').matchAll(/href:\s*'\/watch#([a-z-]+)'/g)].map((m) => m[1]);
160
+ assert.deepEqual(declared, ids, 'one jump per Watch tab, in order');
161
+ assert.ok(declared.includes('sessions') && declared.includes('autobongos'));
162
+ });
@@ -17,7 +17,8 @@ import { fileURLToPath } from 'node:url';
17
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
18
  const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
19
19
  const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8').replace(/\r\n/g, '\n');
20
- const PAGES = ['government', 'watch', 'gate'];
20
+ // task 1005049: the /gate page is Watch's Autobongos tab; its script and sheet stay.
21
+ const PAGES = ['government', 'watch'];
21
22
 
22
23
  test('every oversight page links the family sheet, before its own', () => {
23
24
  for (const p of PAGES) {
@@ -27,6 +28,11 @@ test('every oversight page links the family sheet, before its own', () => {
27
28
  assert.ok(fam > 0, `${p}.html links oversight.css`);
28
29
  assert.ok(own > fam, `${p}.html links its own sheet after the family's`);
29
30
  }
31
+ // Watch's two folded-in tabs bring their sheets, after the family's too.
32
+ const watch = read('watch.html');
33
+ for (const sheet of ['sessions', 'gate']) {
34
+ assert.ok(watch.indexOf(`/builders/${sheet}.css`) > watch.indexOf('/builders/oversight.css'), `watch.html links ${sheet}.css after the family's`);
35
+ }
30
36
  });
31
37
 
32
38
  test('the family sheet spends only the fifteen: no literal colour', () => {
@@ -110,6 +116,10 @@ test('the sealed state is a panel with the floor written from the pack, on all f
110
116
  assert.match(read(`${p}.html`), /class="scroll" id="[a-z-]*sealed(-scroll)?" /, `${p}.html seals with a .scroll panel`);
111
117
  assert.match(read(`${p}.js`), /rankLabel\('metic'\)/, `${p}.js writes the floor from the pack's rank label`);
112
118
  }
119
+ // The Autobongos and Sessions tabs keep their own sealed panels inside Watch.
120
+ assert.match(read('watch.html'), /class="scroll" id="gate-sealed" /);
121
+ assert.match(read('watch.html'), /class="scroll" id="sess-sealed" /);
122
+ assert.match(read('gate.js'), /rankLabel\('metic'\)/);
113
123
  });
114
124
 
115
125
  test('the vocabulary wall holds: the family is named oversight on the government surface', () => {
@@ -278,7 +288,9 @@ test('RUN: a metric its snapshots never carried is skipped, not drawn as a cliff
278
288
 
279
289
  // ---- task 1004829: the government pages read like the rebuilt Home ----------
280
290
 
281
- const GOV_PAGES = ['watch', 'gate', 'sessions', 'project-settings', 'money', 'deploy', 'government'];
291
+ const GOV_PAGES = ['watch', 'project-settings', 'money', 'deploy', 'government'];
292
+ // task 1005049: gate.js and sessions.js draw Watch's tabs now; their scripts still must not.
293
+ const GOV_SCRIPTS = [...GOV_PAGES, 'gate', 'sessions'];
282
294
 
283
295
  test('every government page head holds only its h1, and no script writes into it', () => {
284
296
  // No page shows its head (task 1004836). A sub-line there is a line nobody
@@ -291,7 +303,7 @@ test('every government page head holds only its h1, and no script writes into it
291
303
  assert.doesNotMatch(head[1], /page-head__eyebrow|page-head__sub/, `${page}.html's hidden head holds only the h1`);
292
304
  }
293
305
  const subIds = ['watch-sub', 'gate-sub', 'ledger-sub', 'ps-sub', 'dp-sub', 'fl-sub', 'gov-sub'];
294
- for (const page of GOV_PAGES) {
306
+ for (const page of GOV_SCRIPTS) {
295
307
  const js = read(`${page}.js`);
296
308
  for (const id of subIds) assert.ok(!js.includes(id), `${page}.js no longer writes into #${id}`);
297
309
  }
@@ -300,7 +312,7 @@ test('every government page head holds only its h1, and no script writes into it
300
312
  test('a failed load on Watch and Sessions is said in the page body', () => {
301
313
  assert.match(read('watch.js'), /showSealed\('Could not check your access\. Reload to try again\.', 'Could not load'\)/);
302
314
  assert.match(read('sessions.js'), /showSealed\('Could not check your access\. Reload to try again\.', 'Could not load'\)/);
303
- assert.match(read('sessions.html'), /id="ledger-status" role="status"/, 'the count and a failed page load sit on the ledger itself');
315
+ assert.match(read('watch.html'), /id="ledger-status" role="status"/, 'the count and a failed page load sit on the ledger itself');
304
316
  });
305
317
 
306
318
  test('the page at /gate is the Autobongos panel: no held-PR list, and a Metic is told the controls are the Archon\'s', () => {
@@ -312,10 +324,12 @@ test('the page at /gate is the Autobongos panel: no held-PR list, and a Metic is
312
324
  assert.ok(!/renderRow\(pr|OTBAsk/.test(js), 'no held-PR row or ask card is drawn');
313
325
  assert.match(js, /if \(rank !== 'archon'\) \{ showArchonOnly\(\); return; \}/, 'a Metic gets the note, not an empty page');
314
326
  assert.match(js, /The Autobongos controls are for the \$\{rankLabel\('archon'\)\}/, 'the rank is named from the pack');
315
- const html = read('gate.html');
316
- assert.match(html, /<title>Autobongos — /, 'the page says what it is');
317
- assert.match(html, /data-page-title="Autobongos"/);
318
- assert.match(html, /id="gate-archon-only"/);
327
+ // task 1005049: the panel is Watch's Autobongos tab, and /gate lands there.
328
+ const html = read('watch.html');
329
+ const pane = html.slice(html.indexOf('<div id="pane-autobongos"'));
330
+ assert.match(pane, /id="gate-archon-only"/);
319
331
  assert.ok(!/gate-list|hall-ask\.js/.test(html), 'the list mount and the ask-card script are gone');
320
- assert.match(read('shell.js'), /\{ id: 'gate', label: 'Autobongos', icon: 'gate', href: '\/gate'/, 'the nav names it, at the same URL');
332
+ assert.match(read('watch.js'), /\{ id: 'autobongos', label: 'Autobongos', panel: 'pane-autobongos' \}/, 'the tab says what it is');
333
+ assert.ok(!/href: '\/gate'/.test(read('shell.js')), 'the sidebar no longer lists it');
334
+ assert.match(fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8'), /gate: 'watch#autobongos'/, 'and the old URL lands on the tab');
321
335
  });
@@ -120,8 +120,10 @@ const ORACLE = {
120
120
 
121
121
  // Metic+ operator surfaces (the Governance nav group).
122
122
  '/builders/watch': 'requireGovernmentPage',
123
- '/builders/gate': 'requireGovernmentPage',
124
- '/builders/sessions': 'requireGovernmentPage',
123
+ // Gone (task 1005049, owner decision 2026-11-03): Sessions and Autobongos are tabs
124
+ // on Watch, behind Watch's gate, and both URLs 302 to their tab before any gate runs.
125
+ '/builders/gate': null,
126
+ '/builders/sessions': null,
125
127
  // The five fundamentals of the project (task 1003280, goal 1000072). Same tier as
126
128
  // its neighbours: a steering surface, metic+ per ADR 0157. Its two WRITES gate
127
129
  // independently on `project.settings.manage`, so this row is only about the shell.
@@ -360,6 +362,9 @@ const GONE_PAGES = {
360
362
  '/builders/ranks': 'task 1005046 — the rank ladder is a Primer chapter; /ranks redirects there',
361
363
  '/builders/drachmae': 'task 1005046 — credits and karma is a Primer chapter; /drachmae redirects there',
362
364
  '/builders/diagrams': 'task 1005046 — each diagram sits in its Primer chapter; /diagrams redirects there',
365
+ // Task 1005049 — one oversight page: each is a tab on Watch, and the old URL 302s to it.
366
+ '/builders/sessions': 'task 1005049 — the session log is Watch\'s Sessions tab; /sessions redirects there',
367
+ '/builders/gate': 'task 1005049 — the runner panel is Watch\'s Autobongos tab; /gate redirects there',
363
368
  };
364
369
 
365
370
  test('a row that is null because the page left the hall serves no page', () => {
@@ -230,12 +230,15 @@ test('sessions: the nowrap + wrapper pair is gone, and with it the sticky-header
230
230
  const css = decomment(read('sessions.css'));
231
231
  assert.doesNotMatch(css, /white-space:\s*nowrap/, 'no blanket nowrap on every cell');
232
232
  assert.doesNotMatch(css, /ledger-table-wrap/, 'no wrapper — the shell\'s card strip handles a phone');
233
- assert.doesNotMatch(read('sessions.html'), /ledger-table-wrap/);
233
+ assert.doesNotMatch(sessionsPane(), /ledger-table-wrap/);
234
234
  assert.doesNotMatch(css, /var\(--info\)|ledger-sid/, 'the session id is no longer painted in the info colour');
235
235
  noAlias(css, 'sessions.css');
236
236
  tokensDefined(css, 'sessions.css');
237
237
  });
238
238
 
239
+ // task 1005049: the session ledger is Watch's Sessions tab; its markup is that pane.
240
+ const sessionsPane = () => { const w = read('watch.html'); return w.slice(w.indexOf('<div id="pane-sessions"'), w.indexOf('<div id="pane-autobongos"')); };
241
+
239
242
  test('sessions: every id on a row is a door, the earned figure is shown, the totals ride the top strip', () => {
240
243
  const src = read('sessions.js');
241
244
  assert.match(src, /<a class="fact-chip" href="\/builders\/task\/\$\{encodeURIComponent\(String\(t\)\)\}">task \$\{escapeHtml\(String\(t\)\)\}<\/a>/,
@@ -244,8 +247,8 @@ test('sessions: every id on a row is a door, the earned figure is shown, the tot
244
247
  assert.match(src, /<span class="ov-mono" title="\$\{escapeHtml\(s\.session_id\)\}">/, 'the session id is a mono fact with the full id on hover');
245
248
  assert.match(src, /fmtInt\(s\.drachmae_earned\)/, 'what the session earned is on the row');
246
249
  assert.doesNotMatch(src, /`#\$\{escapeHtml\(t\)\}`/, 'no bare hash-prefixed task text');
247
- const html = read('sessions.html');
248
- assert.match(html, /<section class="ov-summary" id="stats-scroll"/, 'the totals are the top-of-page strip');
250
+ const html = sessionsPane();
251
+ assert.match(html, /<section class="ov-summary" id="stats-scroll"/, 'the totals are the top-of-tab strip');
249
252
  assert.match(html, /<div class="stat-row" id="stats-body"><\/div>/);
250
253
  const ths = (html.match(/<th[ >]/g) || []).length;
251
254
  // task 1002516: a tenth column, "Plain speech" — the transcript accessibility
@@ -259,8 +262,9 @@ test('sessions: every id on a row is a door, the earned figure is shown, the tot
259
262
  // empty state short by a column, which no screenshot pass would catch.
260
263
  assert.equal(ths, 11, 'eleven columns: session, builder, kind, tasks, earned, turns, tokens, tool errors, duration, plain speech, recorded');
261
264
  assert.match(src, new RegExp(`colspan="${ths}"`), 'the empty row spans them all');
262
- const fam = html.indexOf('/builders/oversight.css');
263
- assert.ok(fam > 0 && html.indexOf('/builders/sessions.css') > fam, 'sessions.html links the family sheet before its own');
265
+ const page = read('watch.html');
266
+ const fam = page.indexOf('/builders/oversight.css');
267
+ assert.ok(fam > 0 && page.indexOf('/builders/sessions.css') > fam, 'watch.html links the family sheet before the sessions sheet');
264
268
  });
265
269
 
266
270
  test('sessions: the floor is written from the pack\'s rank label, never a hardcoded rank', () => {
@@ -268,9 +272,9 @@ test('sessions: the floor is written from the pack\'s rank label, never a hardco
268
272
  const sealed = src.slice(src.indexOf('function showSealed'), src.indexOf('// ---- paging state'));
269
273
  assert.match(sealed, /const floor = rankLabel\('metic'\)/);
270
274
  // a failed load names its own heading; the rank floor is the default (task 1004829)
271
- assert.match(sealed, /\$\('#gate-h'\)\.textContent = heading \|\| `\$\{floor\} and up`/);
275
+ assert.match(sealed, /\$\('#sess-sealed-h'\)\.textContent = heading \|\| `\$\{floor\} and up`/);
272
276
  assert.doesNotMatch(decomment(src), /Archons only|Metic rank up|Archon-only/);
273
- assert.doesNotMatch(read('sessions.html'), /Archon/);
277
+ assert.doesNotMatch(sessionsPane(), /Archon/);
274
278
  });
275
279
 
276
280
  // =========================================================================
@@ -299,8 +303,9 @@ test('the states files exercise the redesign\'s new states', () => {
299
303
  const goals = JSON.parse(read('goals.states.json'));
300
304
  assert.ok(goals.states.filters && goals.states.cards, 'goals: the Filters disclosure and the cards view are measured');
301
305
  assert.deepEqual(goals.states.cards.expect.value, [['#f-goal-view', 'cards']]);
302
- const sessions = JSON.parse(read('sessions.states.json'));
303
- assert.deepEqual(sessions.states.ledger.expect.visible, ['#stats-scroll', '#sessions-scroll']);
306
+ // task 1005049: the ledger is Watch's Sessions tab, measured as that page's state.
307
+ const watch = JSON.parse(read('watch.states.json'));
308
+ assert.deepEqual(watch.states.sessions.expect.visible, ['#pane-sessions', '#stats-scroll', '#sess-ledger-scroll']);
304
309
  });
305
310
 
306
311
  test('the roster draws the circuit divider in CSS, on the first row\'s cell and not the row', () => {
@@ -19,7 +19,8 @@ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
19
19
  const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8').replace(/\r\n/g, '\n');
20
20
 
21
21
  const JS = read('sessions.js');
22
- const HTML = read('sessions.html');
22
+ // task 1005049: the ledger is Watch's Sessions tab; its markup lives in watch.html.
23
+ const HTML = (() => { const w = read('watch.html'); return w.slice(w.indexOf('<div id="pane-sessions"'), w.indexOf('<div id="pane-autobongos"')); })();
23
24
 
24
25
  // Slice `const ACCESSIBILITY_TITLE = {...}` through the end of accessibilityCell
25
26
  // and run it with the one helper it closes over.
@@ -228,7 +228,8 @@ test('the role axis has exactly one user-facing word, and no surface types it',
228
228
  'modules/hall-ui/public/work.js',
229
229
  'modules/hall-ui/public/goals-page.js',
230
230
  'modules/hall-ui/public/profile.js',
231
- 'modules/hall-ui/public/builders.js',
231
+ // task 1005076: the setup screen's role step replaced builders.js's checklist picker.
232
+ 'modules/hall-ui/public/setup-gate.js',
232
233
  ];
233
234
  for (const p of SURFACES) {
234
235
  const s = readSrc(p);
@@ -266,7 +267,8 @@ test('craft picker options: neither picker hardcodes the craft list any more', (
266
267
  // silently dropped a module-contributed craft. Whichever picker is edited next
267
268
  // must keep reading the server list.
268
269
  const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
269
- for (const p of ['modules/hall-ui/public/settings.js', 'modules/hall-ui/public/hall-render.js']) {
270
+ // task 1005076: the second picker is the setup screen's role step (it was the Home checklist's).
271
+ for (const p of ['modules/hall-ui/public/settings.js', 'modules/hall-ui/public/setup-gate.js']) {
270
272
  const src = read(p);
271
273
  assert.ok(/OTB\.disciplines\(\)/.test(src), `${p} builds its options from OTB.disciplines()`);
272
274
  assert.ok(!/\['engineer',\s*'artist',\s*'ideator'\]/.test(src), `${p} no longer hardcodes the craft triple`);
@@ -95,8 +95,10 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
95
95
  // task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
96
96
  // Market Watch settings nav item and its fuel glyph; task 1005041: the Update pill replaced the
97
97
  // core-update banner; task 1005045: the Market Watch settings item left (one sidebar item);
98
- // task 1005046: Ranks, Economy and Diagrams left Docs (one reading room, the Primer).
99
- const SHELL_SHA = 'baf8a15f025eba68';
98
+ // task 1005046: Ranks, Economy and Diagrams left Docs (one reading room, the Primer);
99
+ // task 1005049: Sessions and Autobongos left the sidebar (tabs on Watch);
100
+ // task 1005076: the setup flow's holdForSetup.
101
+ const SHELL_SHA = '248d104831bae0de';
100
102
 
101
103
  test('shell.js has not changed without its cache-bust being bumped', () => {
102
104
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -322,6 +322,39 @@ test('graduation is engine-only (display signals never gate it)', async () => {
322
322
  assert.ok(shaped.stages.every((s) => s.cleared === true));
323
323
  });
324
324
 
325
+ // ---- the setup flow (task 1005076) -----------------------------------------
326
+
327
+ const SETUP_IDS = ['signed_in', 'cli_setup', 'pick_disciplines', 'first_start'];
328
+ const setupOf = (state, opts) => onboarding.shapeForApi(state, opts).setup;
329
+ const clearedIds = (setup) => setup.steps.filter((s) => s.cleared).map((s) => s.id);
330
+
331
+ test('setup: a brand-new xenos is held, with only signed_in cleared', () => {
332
+ const setup = setupOf(null, { rank: 'xenos' });
333
+ assert.equal(setup.complete, false);
334
+ assert.deepEqual(setup.steps.map((s) => s.id), SETUP_IDS);
335
+ assert.deepEqual(clearedIds(setup), ['signed_in']);
336
+ assert.ok(setup.steps.every((s) => s.label && s.hint), 'every step says what to do');
337
+ });
338
+
339
+ test('setup: each step ticks from its own signal, and all four complete it', () => {
340
+ const state = { current_stage: 'pick_disciplines', completed_stages: ['auth_complete'] };
341
+ assert.deepEqual(clearedIds(setupOf(state, { rank: 'xenos', cliAuthed: true })), ['signed_in', 'cli_setup']);
342
+ const picked = { current_stage: 'first_start', completed_stages: ['auth_complete', 'pick_disciplines'] };
343
+ const almost = setupOf(picked, { rank: 'xenos', cliAuthed: true });
344
+ assert.equal(almost.complete, false, 'first_start not yet run from Claude');
345
+ const started = { current_stage: 'first_claim', completed_stages: ['auth_complete', 'pick_disciplines', 'first_start'] };
346
+ assert.equal(setupOf(started, { rank: 'xenos', cliAuthed: true }).complete, true);
347
+ assert.equal(setupOf(started, { rank: 'xenos', cliAuthed: false }).complete, false, 'no linked machine, not set up');
348
+ });
349
+
350
+ test('setup: anyone already past it goes straight in', () => {
351
+ const fresh = { current_stage: 'auth_complete', completed_stages: [] };
352
+ for (const rank of ['thetes', 'metic', 'archon']) assert.equal(setupOf(fresh, { rank }).complete, true, rank);
353
+ assert.equal(setupOf({ current_stage: 'graduated', completed_stages: [] }, { rank: 'xenos' }).complete, true, 'graduated');
354
+ assert.equal(setupOf({ current_stage: 'ship_three', completed_stages: ['first_claim'] }, { rank: 'xenos' }).complete, true, 'claimed a task');
355
+ assert.equal(setupOf(fresh, {}).complete, false, 'an unknown rank is not a pass');
356
+ });
357
+
325
358
  // ---- wrap-up --------------------------------------------------------------
326
359
 
327
360
  Promise.resolve().then(async () => {
@@ -33,7 +33,8 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
33
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
34
34
  const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
35
35
  const SESSIONS_JS = read('modules/hall-ui/public/sessions.js');
36
- const SESSIONS_HTML = read('modules/hall-ui/public/sessions.html');
36
+ // task 1005049: the ledger is Watch's Sessions tab, so its markup lives in watch.html.
37
+ const SESSIONS_HTML = read('modules/hall-ui/public/watch.html');
37
38
 
38
39
  const { test, summary } = makeRunner();
39
40
  const tick = () => new Promise((r) => setTimeout(r, 0));
@@ -147,6 +148,9 @@ function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPageP
147
148
  vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
148
149
  if (dropKit) delete sandbox.window.OTBKit;
149
150
  vm.runInContext(SESSIONS_JS, sandbox, { filename: 'sessions.js' });
151
+ // The tab boots when Watch first opens it (task 1005049); a second open is a no-op.
152
+ sandbox.window.OTBWatchTabs.sessions();
153
+ sandbox.window.OTBWatchTabs.sessions();
150
154
 
151
155
  const searches = () => requests.filter((u) => u.includes('/sessions/search'));
152
156
  const q = (n) => {
@@ -161,7 +165,7 @@ function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPageP
161
165
  // 1. the page markup no longer carries a pager of its own
162
166
  // ---------------------------------------------------------------------------
163
167
 
164
- await test('sessions.html carries a mount, not a hand-rolled pager', () => {
168
+ await test('the Sessions tab carries a mount, not a hand-rolled pager', () => {
165
169
  assert.ok(!/id="ledger-pager"/.test(SESSIONS_HTML), 'the bespoke <nav class="pager"> is gone');
166
170
  assert.ok(!/id="pager-(prev|next|pos)"/.test(SESSIONS_HTML), 'its bespoke button ids are gone');
167
171
  assert.match(SESSIONS_HTML, /id="sessions-pager-mount"/);
@@ -307,14 +311,14 @@ await test('a missing kit degrades to a named failure, not a blank page', async
307
311
  await tick(); await tick();
308
312
  assert.equal(h.searches().length, 0, 'no corpus request without a pager to place it');
309
313
  // in the page body: the head that used to carry it is hidden (task 1004829)
310
- assert.match(h.getEl('gate-body').textContent, /could not be loaded/);
314
+ assert.match(h.getEl('sess-sealed-body').textContent, /could not be loaded/);
311
315
  });
312
316
 
313
317
  await test('the rank gate still runs before any corpus request', async () => {
314
318
  const h = harness({ total: 320, viewerRank: 'xenos' });
315
319
  await tick(); await tick();
316
320
  assert.equal(h.searches().length, 0, 'a xenos must not even ask');
317
- assert.equal(h.getEl('gate-scroll').hidden, false, 'they get the sealed notice');
321
+ assert.equal(h.getEl('sess-sealed').hidden, false, 'they get the sealed notice');
318
322
  });
319
323
 
320
324
  summary();