@bongos/core 1.24.41 → 1.24.43

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 (90) hide show
  1. package/.bongos-core.json +117 -92
  2. package/docs/copy-inventory.md +98 -137
  3. package/docs/copy-registry.json +244 -644
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/onboarding/primer.md +1 -1
  6. package/docs/page-inventory.json +3 -3
  7. package/docs/page-readings.json +494 -535
  8. package/modules/hall-ui/public/ai-studio.html +4 -4
  9. package/modules/hall-ui/public/atlas.html +4 -4
  10. package/modules/hall-ui/public/blockers.html +4 -4
  11. package/modules/hall-ui/public/board-room.html +4 -4
  12. package/modules/hall-ui/public/builders.js +35 -763
  13. package/modules/hall-ui/public/city.html +4 -4
  14. package/modules/hall-ui/public/collab.html +4 -4
  15. package/modules/hall-ui/public/commands.html +4 -4
  16. package/modules/hall-ui/public/copy-desk.html +4 -4
  17. package/modules/hall-ui/public/craft-mode.js +1 -1
  18. package/modules/hall-ui/public/deploy.html +4 -4
  19. package/modules/hall-ui/public/dom-utils.js +2 -1
  20. package/modules/hall-ui/public/goal-map.html +4 -4
  21. package/modules/hall-ui/public/goals.html +4 -4
  22. package/modules/hall-ui/public/government.html +4 -4
  23. package/modules/hall-ui/public/hall-render.js +25 -771
  24. package/modules/hall-ui/public/hall-widgets.js +14 -123
  25. package/modules/hall-ui/public/idea.html +4 -4
  26. package/modules/hall-ui/public/ideas.html +4 -4
  27. package/modules/hall-ui/public/index.html +8 -22
  28. package/modules/hall-ui/public/index.states.json +0 -33
  29. package/modules/hall-ui/public/market-watch-item.html +4 -4
  30. package/modules/hall-ui/public/market-watch-rules.html +4 -4
  31. package/modules/hall-ui/public/market-watch.html +4 -4
  32. package/modules/hall-ui/public/modules.html +4 -4
  33. package/modules/hall-ui/public/money.html +4 -4
  34. package/modules/hall-ui/public/my-agents.html +4 -4
  35. package/modules/hall-ui/public/palette.js +7 -6
  36. package/modules/hall-ui/public/primer.html +4 -4
  37. package/modules/hall-ui/public/profile-own.js +639 -0
  38. package/modules/hall-ui/public/profile.css +2 -1
  39. package/modules/hall-ui/public/profile.html +48 -5
  40. package/modules/hall-ui/public/profile.js +27 -2
  41. package/modules/hall-ui/public/profile.states.json +18 -0
  42. package/modules/hall-ui/public/project-settings.html +4 -4
  43. package/modules/hall-ui/public/roadmap.html +4 -4
  44. package/modules/hall-ui/public/roster.html +4 -4
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/setup-gate.js +156 -0
  47. package/modules/hall-ui/public/shell-trail.js +2 -1
  48. package/modules/hall-ui/public/shell.js +44 -26
  49. package/modules/hall-ui/public/street.html +4 -4
  50. package/modules/hall-ui/public/studio.html +4 -4
  51. package/modules/hall-ui/public/style.css +85 -117
  52. package/modules/hall-ui/public/task.html +4 -4
  53. package/modules/hall-ui/public/thinking.html +4 -4
  54. package/modules/hall-ui/public/tweak-editor.html +4 -4
  55. package/modules/hall-ui/public/watch.html +4 -4
  56. package/modules/hall-ui/public/work.html +4 -4
  57. package/modules/hall-ui/records/setup-gate.md +43 -0
  58. package/modules/lifecycle/routes/tasks.js +2 -2
  59. package/modules/onboarding/onboarding-state.js +41 -20
  60. package/package-lock.json +2 -2
  61. package/package.json +1 -1
  62. package/release-notes.json +12 -0
  63. package/src/bongos/routes/me.js +2 -2
  64. package/src/module-api.js +1 -1
  65. package/tests/curve_controls_delegation.mjs +9 -8
  66. package/tests/curve_kind_overlay.mjs +5 -5
  67. package/tests/hall_acclaim_vote_control.mjs +5 -3
  68. package/tests/hall_audit.mjs +10 -6
  69. package/tests/hall_board_world.mjs +41 -27
  70. package/tests/hall_builders_page_pass.mjs +14 -5
  71. package/tests/hall_home_ships_bounded.mjs +10 -21
  72. package/tests/hall_kit_charts.mjs +3 -2
  73. package/tests/hall_landing_boot.mjs +2 -3
  74. package/tests/hall_loading.mjs +2 -1
  75. package/tests/hall_nav.mjs +36 -37
  76. package/tests/hall_numbers_page.mjs +100 -0
  77. package/tests/hall_onboarding_bongos_setup.mjs +14 -14
  78. package/tests/hall_palette.mjs +19 -24
  79. package/tests/hall_profile_world.mjs +7 -2
  80. package/tests/hall_reading_room.mjs +3 -2
  81. package/tests/helpers.mjs +8 -2
  82. package/tests/leaderboard_roles.mjs +1 -1
  83. package/tests/module_contributions.mjs +10 -6
  84. package/tests/nav_permission_atoms.mjs +4 -2
  85. package/tests/onboarding_state.mjs +33 -0
  86. package/tests/page_tweak_artist.mjs +2 -1
  87. package/tests/rank_tier_single_source.mjs +3 -0
  88. package/tests/setup_flow.mjs +242 -0
  89. package/tests/task_visuals.mjs +3 -1
  90. package/tests/welcome_boot.mjs +3 -2
@@ -35,7 +35,7 @@ const SHELL_SRC = read('shell.js');
35
35
  // SAME stub every time — real enough to fire DOM events at and read innerHTML
36
36
  // back from, which the pure-compute-only harness never needed before task 1003460
37
37
  // wired a real fetch() into the input handler.
38
- function bootPalette({ navItems = [], visibleIds = ['nav-standing', 'nav-work', 'shell-user'], modules = null, fetchImpl = undefined } = {}) {
38
+ function bootPalette({ navItems = [], visibleIds = ['nav-profile', 'nav-work', 'shell-user'], modules = null, fetchImpl = undefined } = {}) {
39
39
  const registry = {};
40
40
  const makeNav = (it) => ({
41
41
  hidden: !!it.hidden,
@@ -113,7 +113,7 @@ async function settleSearch() { await tick(400); }
113
113
  const NAV_FIXTURE = [
114
114
  { label: 'Home', href: '/' },
115
115
  { label: 'Tasks', href: '/work' },
116
- { label: 'Accomplishments', href: '/#accomplishments' },
116
+ { label: 'Profile', href: '/profile' },
117
117
  { label: 'Economy', href: '/drachmae' },
118
118
  { label: 'API docs', href: '/docs', external: true },
119
119
  { label: 'Watch', href: '/watch', hidden: true }, // Archon-only, gated off
@@ -126,22 +126,17 @@ function declaredHrefs(prefixTest) {
126
126
  return [...PALETTE_SRC.matchAll(/href:\s*'([^']+)'/g)].map((m) => m[1]).filter(prefixTest);
127
127
  }
128
128
 
129
- // task 1004826: Standing is Accomplishments to the reader; the sections are
130
- // #accomplishments-<group>, aliased onto the 'standing' view's anchors.
131
- test('Accomplishments section anchors exist in builders.js and are routable in shell.js', () => {
132
- // task 1003206: STANDING_GROUPS moved to hall-render.js — read the family.
133
- const groups = [...hallSource().matchAll(/\{ key: '([a-z]+)', label: '[^']+' \}/g)].map((m) => m[1]);
134
- assert.ok(groups.length >= 4, `sanity: found STANDING_GROUPS (${groups})`);
135
- assert.ok(!declaredHrefs((h) => h.startsWith('/#standing')).length, 'the palette names the page by its new name');
136
- const declared = declaredHrefs((h) => h.startsWith('/#accomplishments-')).map((h) => h.replace('/#', ''));
137
- assert.ok(declared.length, 'palette declares Accomplishments sections');
138
- for (const id of declared) {
139
- const key = id.replace('accomplishments-', '');
140
- assert.ok(groups.includes(key), `${id} must be a real STANDING_GROUPS key (builders.js)`);
141
- // shell.js must alias it, or route() ignores the hash and the jump goes nowhere.
142
- assert.match(SHELL_SRC, new RegExp(`'${id}':\\s*\\{ view: 'standing', anchor: 'standing-${key}' \\}`),
143
- `${id} must have a VIEW_ALIASES entry in shell.js`);
144
- }
129
+ // task 1005050: Accomplishments folded into Profile, so the palette's numbers
130
+ // jumps are the profile's own screens — profile.js RAIL_ITEMS — and its old words
131
+ // ("Accomplishments", karma, progress) still find the "Just you" screen.
132
+ test('Profile section anchors are real profile.js rail keys', () => {
133
+ const block = (read('profile.js').match(/const RAIL_ITEMS = \[([\s\S]*?)\];/) || [, ''])[1];
134
+ const keys = [...block.matchAll(/id: '([a-z]+)'/g)].map((m) => m[1]);
135
+ assert.ok(keys.includes('yours'), `sanity: profile.js rail keys (${keys})`);
136
+ const declared = declaredHrefs((h) => h.startsWith('/profile#'));
137
+ assert.ok(declared.length >= 3, 'palette declares the profile screens');
138
+ for (const h of declared) assert.ok(keys.includes(h.split('#')[1]), `${h} must be a real profile.js rail key`);
139
+ assert.ok(!declaredHrefs((h) => /^\/#(standing|accomplishments)/.test(h)).length, 'no jump points at the retired view');
145
140
  });
146
141
 
147
142
  test('Tasks tab anchors exist in work.js makeTabs', () => {
@@ -195,8 +190,8 @@ test('My agents anchors are ones my-agents.js lands, and the moved Settings pane
195
190
  });
196
191
 
197
192
  test('the anchor guard actually CATCHES a declared id that does not exist', () => {
198
- const groups = ['work', 'karma', 'progress', 'achievements'];
199
- assert.equal(groups.includes('standing-nope'.replace('standing-', '')), false,
193
+ const keys = ['overview', 'works', 'activity', 'connections', 'yours'];
194
+ assert.equal(keys.includes('profile#nope'.split('#')[1]), false,
200
195
  'an invented section id fails the membership check the tests above apply');
201
196
  });
202
197
 
@@ -205,17 +200,17 @@ test('the anchor guard actually CATCHES a declared id that does not exist', () =
205
200
  test('pages come from the sidebar, and a gated-off nav item is not a destination', () => {
206
201
  const P = bootPalette({ navItems: NAV_FIXTURE });
207
202
  const labels = P.compute('').filter((r) => r.group === 'Pages').map((r) => r.label);
208
- assert.deepEqual(labels, ['Home', 'Tasks', 'Accomplishments', 'Economy', 'API docs']);
203
+ assert.deepEqual(labels, ['Home', 'Tasks', 'Profile', 'Economy', 'API docs']);
209
204
  assert.ok(!labels.includes('Watch'), 'a hidden (rank-gated) nav item must not be jumpable');
210
205
  });
211
206
 
212
207
  test('section groups appear only when their owning nav item is visible', () => {
213
208
  const all = bootPalette({ navItems: NAV_FIXTURE }).compute('');
214
- assert.ok(all.some((r) => r.group === 'Accomplishments'), 'Accomplishments sections offered when nav-standing shows');
209
+ assert.ok(all.some((r) => r.group === 'Profile'), 'Profile sections offered when nav-profile shows');
215
210
  assert.ok(all.some((r) => r.group === 'Settings'), 'Settings panels offered when the profile chip shows');
216
211
 
217
212
  const gatedOut = bootPalette({ navItems: NAV_FIXTURE, visibleIds: [] }).compute('');
218
- assert.ok(!gatedOut.some((r) => ['Accomplishments', 'Tasks', 'Settings'].includes(r.group)),
213
+ assert.ok(!gatedOut.some((r) => ['Profile', 'Tasks', 'Settings'].includes(r.group)),
219
214
  'no sub-destinations when their owners are hidden');
220
215
  });
221
216
 
@@ -242,7 +237,7 @@ test('a non-matching query yields nothing (the empty state), and text still matc
242
237
  const P = bootPalette({ navItems: NAV_FIXTURE });
243
238
  assert.deepEqual(P.compute('zzzqqq'), []);
244
239
  assert.equal(P.compute('econ')[0].label, 'Economy', 'substring match');
245
- assert.equal(P.compute('stw')[0].label, 'Work', 'subsequence match reaches Standing > Work');
240
+ assert.equal(P.compute('accomplishments')[0].href, '/profile#yours', 'the old word still finds where its private half went');
246
241
  });
247
242
 
248
243
  // ---- progressive enhancement + wiring -------------------------------------
@@ -63,7 +63,8 @@ const profileCss = stripComments(read('profile.css'));
63
63
  // The plot's dataviz spec lives in the kit's sheet since task 1003445.
64
64
  const styleCss = stripComments(read('style.css'));
65
65
 
66
- const GROUPS = ['overview', 'works', 'activity', 'connections'];
66
+ // task 1005050: 'yours' joined — Accomplishments' private half, on your own profile only.
67
+ const GROUPS = ['overview', 'works', 'activity', 'connections', 'yours'];
67
68
 
68
69
  // ---- the rail and the groups ---------------------------------------------------
69
70
 
@@ -89,8 +90,12 @@ test('every section in the groups carries a known data-group, and the keys are t
89
90
  assert.equal(groupOf('works-scroll'), 'works', 'the works ledger is its own key');
90
91
  assert.equal(groupOf('activity-scroll'), 'activity');
91
92
  assert.equal(groupOf('conn-scroll'), 'connections');
93
+ for (const id of ['rank-scroll', 'grades-scroll', 'karma-scroll', 'acclaim-scroll', 'analytics-scroll']) {
94
+ assert.equal(groupOf(id), 'yours', `#${id} is on the "Just you" screen`);
95
+ assert.match(html, new RegExp(`id="${id}"[^>]*data-group="yours" hidden>`), `#${id} starts hidden`);
96
+ }
92
97
  const railKeys = profileJs.match(/const RAIL_ITEMS = \[([\s\S]*?)\];/)[1].match(/id: '([a-z]+)'/g).map((s) => s.slice(5, -1));
93
- assert.deepEqual(railKeys, GROUPS, 'the rail lists the four keys in reading order');
98
+ assert.deepEqual(railKeys, GROUPS, 'the rail lists the five keys in reading order');
94
99
  });
95
100
 
96
101
  test('the sheet shows exactly the active group, and a hidden section stays hidden', () => {
@@ -156,7 +156,8 @@ test('the lifecycle, the rank ladder and credits and karma are each explained ON
156
156
  const ladder = PRIMER_MD.slice(PRIMER_MD.indexOf('## The rank ladder'), PRIMER_MD.indexOf('## Credits and karma'));
157
157
  for (const rank of ['Xenos', 'Thetes', 'Metic', 'Archon']) assert.match(ladder, new RegExp(`\\*\\*${rank}\\*\\*`));
158
158
  assert.match(ladder, /### How you advance/);
159
- assert.match(ladder, /\/builders#accomplishments-progress/, 'and it points the reader at their own standing');
159
+ // task 1005050: Accomplishments folded into Profile; the card is its "Just you" screen.
160
+ assert.match(ladder, /\/builders\/profile#yours/, 'and it points the reader at their own standing');
160
161
  });
161
162
 
162
163
  test('the two stale primer lines are gone', () => {
@@ -172,7 +173,7 @@ test('the primer keeps a hall address in the hall, and sends repo paths to GitHu
172
173
  // Lift the resolver out of primer.js without booting the page.
173
174
  const src = primerJs.slice(primerJs.indexOf(' const resolveLinkHref = (() => {'), primerJs.indexOf(' function toast('));
174
175
  const fn = vm.runInContext(`(() => { const MD = window.OTBMdReader; const PRIMER_DIR = 'docs/onboarding/'; let repoBlobBase = 'https://gh/blob/main'; let repoTreeBase = 'https://gh/tree/main';\n${src}\nreturn resolveLinkHref; })()`, sandbox);
175
- assert.equal(fn('/builders#accomplishments-progress'), '/builders#accomplishments-progress');
176
+ assert.equal(fn('/builders/profile#yours'), '/builders/profile#yours');
176
177
  assert.equal(fn('drachmae-and-karma.md'), 'https://gh/blob/main/docs/onboarding/drachmae-and-karma.md',
177
178
  'the full reference is a repo doc now that its hall page is a chapter');
178
179
  assert.equal(fn('../adr/'), 'https://gh/tree/main/docs/adr');
package/tests/helpers.mjs CHANGED
@@ -542,7 +542,9 @@ const PROFILE_CRAFTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/publi
542
542
  // tab each. The default leads with the ideator, so the trail is the first tab
543
543
  // and a suite that reads it needs no switch; switchCraft('engineer') opens the
544
544
  // works and goals, switchCraft('artist') the approved pages.
545
- export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator', 'engineer'], artistPages = [] } = {}) {
545
+ // `search` is the page's ?query (task 1005050: ?id= decides whose profile it is), and
546
+ // `withOwn` also loads profile-own.js, the "Just you" screen drawn on your own profile.
547
+ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator', 'engineer'], artistPages = [], search = '', withOwn = false } = {}) {
546
548
  const els = new Map();
547
549
  const makeEl = () => ({
548
550
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
@@ -617,6 +619,8 @@ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator
617
619
  return { active: () => current, activate(id) { current = id; onChange(id); }, setCount() {} };
618
620
  },
619
621
  shipVisualThumbHtml: () => '',
622
+ // profile-own.js's peer-recognition pager (task 1005050).
623
+ paginate: (rows) => rows, wirePager() {}, pagerBarHtml: () => '',
620
624
  };
621
625
 
622
626
  const windowObj = {
@@ -638,7 +642,7 @@ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator
638
642
  rankLabel: (r) => r,
639
643
  toast() {},
640
644
  },
641
- location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
645
+ location: { hostname: 'builders.cloudbongos.com', search, pathname: '/profile' },
642
646
  localStorage: { getItem: () => null, setItem() {} },
643
647
  addEventListener() {},
644
648
  matchMedia: () => ({ matches: false }),
@@ -655,6 +659,7 @@ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator
655
659
  vm.runInContext(PROFILE_ROLES_JS, sandbox, { filename: 'profile-roles.js' });
656
660
  vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
657
661
  vm.runInContext(PROFILE_CRAFTS_JS, sandbox, { filename: 'profile-crafts.js' });
662
+ if (withOwn) vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile-own.js'), 'utf8'), sandbox, { filename: 'profile-own.js' });
658
663
  vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
659
664
 
660
665
  const htmlFor = (mountId) => {
@@ -666,6 +671,7 @@ export function bootProfilePage({ signedIn = true, trail = [], roles = ['ideator
666
671
  htmlFor,
667
672
  switchCraft: (id) => { if (tabsOnChange) tabsOnChange(id); },
668
673
  el: (id) => documentObj.getElementById(id),
674
+ window: windowObj,
669
675
  };
670
676
  }
671
677
 
@@ -236,7 +236,7 @@ function extract(src, start, end) {
236
236
 
237
237
  await test('the standing card adds the place inside the builder\'s main role, only when it says something new', () => {
238
238
  const src = read('modules/hall-ui/public/hall-render.js');
239
- const code = extract(src, 'const lbState = { rows: [] };', ' return { acclaimEvidenceHtml');
239
+ const code = extract(src, 'const lbState = { rows: [] };', ' return { lbState');
240
240
  const sandbox = { rows: FIX.leaderboard };
241
241
  vm.createContext(sandbox);
242
242
  vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
@@ -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`);
@@ -312,11 +314,13 @@ test('hall-widget registry: placement rules reject bad registrations, accept goo
312
314
  assert.ok(reg({ id: 'e', view: 'home', zone: 'home-main', summary: true }));
313
315
  assert.ok(reg({ id: 'f', view: 'home', zone: 'home-main', collapsedByDefault: true }));
314
316
  assert.ok(reg({ id: 'g', view: 'home', zone: 'home-main', core: true }));
315
- // a non-Home view needs no zone (Standing widgets carry `group` instead)
316
- assert.ok(reg({ id: 'h', view: 'standing', group: 'work' }));
317
+ // task 1005050: 'standing' (Accomplishments) left the views with the view
318
+ // itself — it folded into the Profile page — so a widget naming it is refused.
319
+ assert.equal(reg({ id: 'h', view: 'standing', group: 'work' }), null);
320
+ assert.match(errors.at(-1), /unknown view "standing"/);
317
321
 
318
- // only the accepted four reached the registry
319
- assert.deepEqual(OTB.hallWidgetIds(), ['e', 'f', 'g', 'h']);
322
+ // only the accepted three reached the registry
323
+ assert.deepEqual(OTB.hallWidgetIds(), ['e', 'f', 'g']);
320
324
  } finally {
321
325
  console.error = realError;
322
326
  delete global.window;
@@ -96,8 +96,10 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
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
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
- const SHELL_SHA = 'bb0abbadab2508ca';
99
+ // task 1005049: Sessions and Autobongos left the sidebar (tabs on Watch);
100
+ // task 1005076: the setup flow's holdForSetup;
101
+ // task 1005050: Accomplishments left it (folded into Profile; its hashes redirect).
102
+ const SHELL_SHA = '050dbf4ceac560dc';
101
103
 
102
104
  test('shell.js has not changed without its cache-bust being bumped', () => {
103
105
  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 () => {
@@ -195,7 +195,8 @@ await test('the status page and the acclaim feed render the helper', () => {
195
195
  const status = readFileSync(new URL('../modules/status-ui/public/status.js', import.meta.url), 'utf8');
196
196
  assert.match(status, /t\.helper_display_name \|\| t\.helper_login/);
197
197
  assert.match(status, /with help from \$\{helper\}/);
198
- const hall = readFileSync(new URL('../modules/hall-ui/public/builders.js', import.meta.url), 'utf8');
198
+ // task 1005050: the acclaim feed moved with Accomplishments to the profile's "Just you" screen.
199
+ const hall = readFileSync(new URL('../modules/hall-ui/public/profile-own.js', import.meta.url), 'utf8');
199
200
  assert.match(hall, /with help from \$\{escapeHtml\(helperName\)\}/, 'escaped before it reaches innerHTML');
200
201
  const feed = readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
201
202
  assert.match(feed, /helper\.helper_login, helper\.helper_display_name/, 'the acclaim feed carries the helper');
@@ -93,6 +93,9 @@ const STATIC_LADDER_FILES = [
93
93
  'modules/discord/discord-channels.js',
94
94
  'modules/memory/search.js',
95
95
  'modules/hall-ui/public/profile.js',
96
+ // task 1005050: Where you stand moved with Accomplishments to the profile's "Just
97
+ // you" screen and reads the rung above yours off this ladder.
98
+ 'modules/hall-ui/public/profile-own.js',
96
99
  // task 1005048: the rank <select> on each roster row moved from watch.js to the
97
100
  // Builders page with the roster itself; the copy moved with it.
98
101
  'modules/hall-ui/public/roster.js',
@@ -0,0 +1,242 @@
1
+ // tests/setup_flow.mjs — the setup flow a new builder finishes once (task 1005076).
2
+ //
3
+ // Owner, 2026-11-05: a new builder is held on one setup screen until every step is done,
4
+ // and once set up they are in for good — no setup banner on Home. Three halves, each run
5
+ // for real: the claimable route ticks first_start only for a Claude (CLI) session; the
6
+ // shell loads setup-gate.js and draws nothing else over it while setup is incomplete; and
7
+ // the screen draws the steps, saves a craft, and lets the builder in once /me says done.
8
+ // The rule itself (which steps, who goes straight in) is in tests/onboarding_state.mjs.
9
+ //
10
+ // Run: node tests/setup_flow.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { makeEl, realOTB, settle } from './helpers.mjs';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
21
+ const ROUTE = read('modules/lifecycle/routes/tasks.js');
22
+ const SHELL = read('modules/hall-ui/public/shell.js');
23
+ const GATE = read('modules/hall-ui/public/setup-gate.js');
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ const t = async (name, fn) => {
28
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
29
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
30
+ };
31
+
32
+ const STEPS = (cleared) => ['signed_in', 'cli_setup', 'pick_disciplines', 'first_start']
33
+ .map((id) => ({ id, label: `Label ${id}`, hint: `Hint ${id}`, cleared: cleared.includes(id) }));
34
+
35
+ console.log('the setup flow (task 1005076)');
36
+
37
+ // ---- the claimable route: first_start only from Claude --------------------
38
+
39
+ // The real guarded statement, run against each kind of session.
40
+ const from = ROUTE.indexOf("if (req.gdsSession && req.gdsSession.source === 'cli')");
41
+ const guard = ROUTE.slice(from, ROUTE.indexOf('\n', from));
42
+ const runGuard = (gdsSession) => {
43
+ const marks = [];
44
+ const seams = { resolveOptional: () => ({ safeMarkStage: (_pool, id, stage) => marks.push([id, stage]) }) };
45
+ vm.runInNewContext(guard, { req: { gdsSession, builder: { id: 7 } }, seams, pool: {} });
46
+ return marks;
47
+ };
48
+
49
+ await t('GET /tasks/claimable marks first_start for a Claude (CLI) session, never for the browser', () => {
50
+ assert.ok(from > 0, 'the guard is in the claimable handler');
51
+ assert.match(guard, /safeMarkStage\(pool, req\.builder\.id, 'first_start', req\.builder\)/);
52
+ assert.deepEqual(runGuard({ source: 'cli' }), [[7, 'first_start']]);
53
+ assert.deepEqual(runGuard({ source: 'web' }), [], 'the hall pages read this feed from the browser');
54
+ assert.deepEqual(runGuard(null), []);
55
+ assert.equal((ROUTE.match(/'first_start'/g) || []).length, 1, 'no unguarded mark elsewhere in the routes');
56
+ });
57
+
58
+ // ---- the screen ------------------------------------------------------------
59
+
60
+ function bootGate({ patchOk = true } = {}) {
61
+ const body = makeEl('body');
62
+ const htmlClasses = new Set();
63
+ const requests = [];
64
+ let reloaded = 0;
65
+ let me = null;
66
+ const api = { request: (method, p, opts) => {
67
+ requests.push({ method, p, body: opts && opts.body });
68
+ if (method === 'PATCH') return Promise.resolve({ ok: patchOk, status: patchOk ? 200 : 422, data: {} });
69
+ return Promise.resolve({ ok: true, status: 200, data: me });
70
+ } };
71
+ const intervals = [];
72
+ const document = {
73
+ body,
74
+ documentElement: { classList: { add: (c) => htmlClasses.add(c), remove: (c) => htmlClasses.delete(c) } },
75
+ createElement: (tag) => makeEl(tag),
76
+ getElementById: (id) => body.querySelector('#' + id),
77
+ activeElement: null,
78
+ };
79
+ // the crafts this project offers (dom-utils.js reads them off __MODULES__)
80
+ const window = { OTB: { ...realOTB(), disciplines: () => ['engineer', 'artist', 'ui'] }, location: { reload: () => { reloaded += 1; } } };
81
+ const sandbox = { window, document, setInterval: (fn) => { intervals.push(fn); return intervals.length; }, clearInterval() {} };
82
+ vm.createContext(sandbox);
83
+ vm.runInContext(GATE, sandbox, { filename: 'setup-gate.js' });
84
+ return {
85
+ show: (setup, builder) => window.OTBSetupGate.show(setup, { api, builder }),
86
+ gate: () => body.querySelector('#setup-gate'),
87
+ htmlClasses, requests, intervals,
88
+ setMe: (v) => { me = v; },
89
+ reloaded: () => reloaded,
90
+ click: (target) => body.querySelector('#setup-gate').fire('click', { target }),
91
+ };
92
+ }
93
+
94
+ await t('the screen lists every step in order, ticks the cleared ones, and opens the first gap', () => {
95
+ const g = bootGate();
96
+ g.show({ complete: false, steps: STEPS(['signed_in', 'cli_setup']) }, { display_name: 'Sam' });
97
+ const gate = g.gate();
98
+ assert.ok(gate, 'drawn');
99
+ assert.equal(gate.getAttribute('aria-modal'), 'true');
100
+ assert.ok(g.htmlClasses.has('setup-gate-open'), 'the hall behind it does not scroll');
101
+ assert.match(gate.querySelector('#setup-gate-h').textContent, /Welcome, Sam/);
102
+ assert.equal(gate.querySelector('.setup-gate__count').textContent, '2 of 4 done');
103
+ const steps = gate.querySelectorAll('.setup-gate__step');
104
+ assert.deepEqual(steps.map((s) => s.className), ['setup-gate__step is-done', 'setup-gate__step is-done', 'setup-gate__step is-current', 'setup-gate__step']);
105
+ assert.match(steps[2].textContent, /Hint pick_disciplines/, 'only the current step shows its hint');
106
+ assert.doesNotMatch(steps[3].textContent, /Hint first_start/);
107
+ assert.deepEqual(gate.querySelectorAll('[data-craft]').map((b) => b.textContent), ['Engineer', 'Artist', 'UI'],
108
+ 'the crafts this project offers');
109
+ });
110
+
111
+ await t('choosing a craft saves it through PATCH /me/disciplines, the route every picker uses', async () => {
112
+ const g = bootGate();
113
+ g.show({ complete: false, steps: STEPS(['signed_in', 'cli_setup']) });
114
+ const save = () => g.gate().querySelector('[data-save-crafts]');
115
+ assert.equal(save().disabled, true, 'nothing picked, nothing to save');
116
+ g.click(g.gate().querySelector('[data-craft="artist"]'));
117
+ assert.equal(g.gate().querySelector('[data-craft="artist"]').getAttribute('aria-pressed'), 'true');
118
+ assert.equal(save().disabled, false);
119
+ g.setMe({ onboarding: { setup: { complete: false, steps: STEPS(['signed_in', 'cli_setup', 'pick_disciplines']) } } });
120
+ g.click(save());
121
+ await settle(); await settle();
122
+ const patch = g.requests.find((r) => r.method === 'PATCH');
123
+ assert.equal(patch.p, '/api/bongos/me/disciplines');
124
+ assert.deepEqual(JSON.parse(JSON.stringify(patch.body)), { preferred_disciplines: ['artist'] });
125
+ assert.equal(g.gate().querySelector('.setup-gate__count').textContent, '3 of 4 done', 're-read /me and ticked');
126
+ });
127
+
128
+ await t('a craft that does not save says so and can be tried again', async () => {
129
+ const g = bootGate({ patchOk: false });
130
+ g.show({ complete: false, steps: STEPS(['signed_in', 'cli_setup']) });
131
+ g.click(g.gate().querySelector('[data-craft="engineer"]'));
132
+ g.click(g.gate().querySelector('[data-save-crafts]'));
133
+ await settle();
134
+ const err = g.gate().querySelector('[data-craft-err]');
135
+ assert.equal(err.hidden, false);
136
+ assert.match(err.textContent, /did not save/);
137
+ assert.equal(g.gate().querySelector('[data-save-crafts]').disabled, false);
138
+ });
139
+
140
+ await t('it checks /me while it is up, and lets the builder in the moment setup is complete', async () => {
141
+ const g = bootGate();
142
+ g.show({ complete: false, steps: STEPS(['signed_in', 'cli_setup', 'pick_disciplines']) });
143
+ assert.equal(g.intervals.length, 1, 'it polls');
144
+ g.setMe({ onboarding: { setup: { complete: true, steps: STEPS(['signed_in', 'cli_setup', 'pick_disciplines', 'first_start']) } } });
145
+ g.intervals[0]();
146
+ await settle();
147
+ assert.equal(g.gate(), null, 'the screen is gone');
148
+ assert.ok(!g.htmlClasses.has('setup-gate-open'));
149
+ assert.equal(g.reloaded(), 1, 'the page reloads into the hall');
150
+ });
151
+
152
+ await t('a complete setup draws nothing', () => {
153
+ const g = bootGate();
154
+ g.show({ complete: true, steps: STEPS(['signed_in', 'cli_setup', 'pick_disciplines', 'first_start']) });
155
+ assert.equal(g.gate(), null);
156
+ });
157
+
158
+ // ---- the shell: loads the screen, and nothing else is drawn over it ---------
159
+
160
+ function bootShell({ deferLoad = false } = {}) {
161
+ const loaded = [];
162
+ const pending = [];
163
+ const asked = [];
164
+ let sandbox = null;
165
+ const stubEl = () => ({
166
+ dataset: {}, style: {}, textContent: '', hidden: false, innerHTML: '',
167
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
168
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {}, addEventListener() {},
169
+ querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {}, focus() {},
170
+ });
171
+ const els = new Map();
172
+ const document = {
173
+ body: { dataset: { page: 'work' }, appendChild() {} },
174
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
175
+ getElementById(id) { if (!els.has(id)) els.set(id, stubEl()); return els.get(id); },
176
+ querySelectorAll() { return []; }, createElement: () => stubEl(),
177
+ addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
178
+ head: { appendChild(tag) {
179
+ loaded.push(tag.src);
180
+ const land = () => {
181
+ if (/setup-gate/.test(tag.src)) sandbox.window.OTBSetupGate = { show: (setup) => asked.push(['gate', setup.complete]) };
182
+ if (tag.onload) tag.onload();
183
+ };
184
+ if (deferLoad && /setup-gate/.test(tag.src)) pending.push(land); else land();
185
+ } },
186
+ };
187
+ const location = { hostname: 'h', origin: 'https://h', pathname: '/work', hash: '', assign() {}, replace() {} };
188
+ const window = {
189
+ OTB: realOTB(), __BRANDING__: {}, __MODULES__: { enabled: {} },
190
+ BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
191
+ // the boot pass runs before anyone is signed in, when neither draws anything: count signed-in asks only
192
+ OTBWelcome: { onAccess: (access) => { if (access.authed === true) asked.push(['welcome']); return false; } },
193
+ OTBCraftMode: { onAccess: (access) => { if (access.authed === true) asked.push(['craft']); }, applyNav() {}, mount() {}, setPositions() {}, switchHtml: () => '' },
194
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
195
+ localStorage: { getItem: () => null, setItem() {} },
196
+ requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
197
+ };
198
+ sandbox = { window, document, location, console, setTimeout, clearTimeout, queueMicrotask,
199
+ CustomEvent: class { constructor(tp, i) { this.type = tp; Object.assign(this, i); } } };
200
+ sandbox.globalThis = sandbox;
201
+ vm.createContext(sandbox);
202
+ vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
203
+ return { apply: (state) => window.OTBShell.applyAccess(state), land: () => pending.splice(0).forEach((f) => f()), loaded, asked };
204
+ }
205
+
206
+ await t('the shell holds an unfinished builder on the setup screen, with no welcome or craft picker over it', () => {
207
+ const s = bootShell();
208
+ s.apply({ authed: true, rank: 'xenos', builder: { id: 9 }, setup: { complete: false, steps: STEPS(['signed_in']) } });
209
+ assert.equal(s.loaded.filter((u) => /^\/builders\/setup-gate\.js/.test(u)).length, 1, 'its file is loaded');
210
+ assert.deepEqual(s.asked, [['gate', false]], 'the screen, and nothing over it');
211
+ });
212
+
213
+ await t('the setup screen loads once, however often the shell re-checks access before it lands', () => {
214
+ const s = bootShell({ deferLoad: true });
215
+ const held = { authed: true, rank: 'xenos', builder: { id: 9 }, setup: { complete: false, steps: STEPS(['signed_in']) } };
216
+ s.apply(held);
217
+ s.apply(held);
218
+ s.apply(held);
219
+ assert.equal(s.loaded.filter((u) => /setup-gate\.js/.test(u)).length, 1, 'one script tag, so one screen');
220
+ s.land();
221
+ assert.deepEqual(s.asked, [['gate', false]]);
222
+ });
223
+
224
+ await t('a set-up builder loads no setup file and gets the hall as before', () => {
225
+ const s = bootShell();
226
+ s.apply({ authed: true, rank: 'xenos', builder: { id: 9 }, setup: { complete: true, steps: [] } });
227
+ assert.equal(s.loaded.filter((u) => /setup-gate/.test(u)).length, 0);
228
+ assert.ok(!s.asked.some(([k]) => k === 'gate'), 'no setup screen');
229
+ assert.deepEqual(s.asked.slice(0, 2), [['welcome'], ['craft']], 'the daily welcome and craft picker as before');
230
+ const unknown = bootShell();
231
+ unknown.apply({ authed: true, rank: 'xenos', builder: { id: 9 }, setup: null });
232
+ assert.equal(unknown.loaded.filter((u) => /setup-gate/.test(u)).length, 0, 'an older server that sends no setup holds nobody');
233
+ });
234
+
235
+ await t('Home carries no setup banner: the Welcome and Getting started panels are gone', () => {
236
+ const widgets = read('modules/hall-ui/public/hall-widgets.js');
237
+ assert.doesNotMatch(widgets, /w\('welcome-scroll'|w\('onboarding-scroll'/);
238
+ assert.doesNotMatch(read('modules/hall-ui/public/index.html'), /welcome-reopen|Show the setup guide again/);
239
+ });
240
+
241
+ console.log(`\n${passed} passed, ${failed} failed`);
242
+ if (failed) process.exit(1);
@@ -280,7 +280,9 @@ await t('the callers reach for OTBVisual, and no longer for the kit', () => {
280
280
  assert.ok(!/OTBKit(\.|\[')?\s*(shipVisualThumbHtml|taskVisualFigureHtml|isTaskVisualUrl)/.test(src),
281
281
  `${f} still reads a visual renderer off OTBKit`);
282
282
  }
283
- assert.ok(HALL('hall-render.js').includes('window.OTBVisual.shipVisualThumbHtml'));
283
+ // task 1005050: Home's ship ledger (the one hall-render.js thumbnail) left with
284
+ // Accomplishments; the Profile's Works ledger is the ship-thumbnail caller now.
285
+ assert.ok(HALL('profile.js').includes('window.OTBVisual.shipVisualThumbHtml'));
284
286
  assert.ok(HALL('studio.js').includes('vis.taskVisualFigureHtml('));
285
287
  });
286
288
 
@@ -122,6 +122,7 @@ test('Home loads the welcome before the shell, and the shell asks it before the
122
122
  'welcome-boot.js, then the shell trio');
123
123
  assert.ok(at('welcome-boot.css') > 0, 'and its sheet');
124
124
  const shell = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
125
- assert.match(shell, /if \(!\(window\.OTBWelcome && window\.OTBWelcome\.onAccess\(access, state && state\.builder\)\) && CRAFT\) CRAFT\.onAccess\(/,
126
- 'the picker is asked only when the welcome did not show');
125
+ // task 1005076: and neither is asked while the setup screen holds the builder (tests/setup_flow.mjs).
126
+ assert.match(shell, /if \(!holdForSetup\(state && state\.builder\) && !\(window\.OTBWelcome && window\.OTBWelcome\.onAccess\(access, state && state\.builder\)\) && CRAFT\) CRAFT\.onAccess\(/,
127
+ 'the picker is asked only when setup is done and the welcome did not show');
127
128
  });