@bongos/core 1.24.42 → 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 (79) hide show
  1. package/.bongos-core.json +93 -83
  2. package/docs/copy-inventory.md +86 -104
  3. package/docs/copy-registry.json +159 -351
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/onboarding/primer.md +1 -1
  6. package/docs/page-inventory.json +3 -3
  7. package/docs/page-readings.json +386 -428
  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 +22 -505
  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 +23 -524
  24. package/modules/hall-ui/public/hall-widgets.js +11 -109
  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 +6 -19
  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/shell-trail.js +2 -1
  47. package/modules/hall-ui/public/shell.js +28 -25
  48. package/modules/hall-ui/public/street.html +4 -4
  49. package/modules/hall-ui/public/studio.html +4 -4
  50. package/modules/hall-ui/public/task.html +4 -4
  51. package/modules/hall-ui/public/thinking.html +4 -4
  52. package/modules/hall-ui/public/tweak-editor.html +4 -4
  53. package/modules/hall-ui/public/watch.html +4 -4
  54. package/modules/hall-ui/public/work.html +4 -4
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +6 -0
  58. package/src/module-api.js +1 -1
  59. package/tests/curve_controls_delegation.mjs +9 -8
  60. package/tests/curve_kind_overlay.mjs +5 -5
  61. package/tests/hall_acclaim_vote_control.mjs +5 -3
  62. package/tests/hall_audit.mjs +10 -6
  63. package/tests/hall_board_world.mjs +40 -26
  64. package/tests/hall_builders_page_pass.mjs +14 -5
  65. package/tests/hall_home_ships_bounded.mjs +10 -21
  66. package/tests/hall_kit_charts.mjs +3 -2
  67. package/tests/hall_loading.mjs +2 -1
  68. package/tests/hall_nav.mjs +36 -37
  69. package/tests/hall_numbers_page.mjs +100 -0
  70. package/tests/hall_palette.mjs +19 -24
  71. package/tests/hall_profile_world.mjs +7 -2
  72. package/tests/hall_reading_room.mjs +3 -2
  73. package/tests/helpers.mjs +8 -2
  74. package/tests/leaderboard_roles.mjs +1 -1
  75. package/tests/module_contributions.mjs +6 -4
  76. package/tests/nav_permission_atoms.mjs +3 -2
  77. package/tests/page_tweak_artist.mjs +2 -1
  78. package/tests/rank_tier_single_source.mjs +3 -0
  79. package/tests/task_visuals.mjs +3 -1
@@ -320,16 +320,19 @@ test('the three bar copies are gone, and their class families with them', () =>
320
320
  for (const dead of ['curve__kind-', 'curve__chart', 'watch-trend', 'pmix']) {
321
321
  assert.ok(!css.includes(dead), `${dead} is the kit's .kit-bars/.kit-chart now`);
322
322
  }
323
- assert.match(read('hall-render.js'), /OTBKit\.barSeriesHtml\(/, 'the Curve\'s kinds draw through the kit');
323
+ // task 1005050: the Curve moved with Accomplishments to profile-own.js.
324
+ assert.match(read('profile-own.js'), /OTBKit\.barSeriesHtml\(/, 'the Curve\'s kinds draw through the kit');
324
325
  assert.match(read('watch.js'), /OTBKit\.barSeriesHtml\(/, 'Watch\'s trend draws through the kit');
325
326
  assert.match(read('profile-charts.js'), /OTBKit\.barSeriesHtml\(/, 'the craft mix draws through the kit');
326
327
  assert.match(read('profile-charts.js'), /OTBKit\.calendarGridHtml\(/, 'the contribution calendar draws through the kit (task 1003572)');
327
- assert.match(read('hall-render.js'), /OTBKit\.sparklineHtml\(/, 'and the Curve\'s line does too');
328
+ assert.match(read('profile-own.js'), /OTBKit\.sparklineHtml\(/, 'and the Curve\'s line does too');
328
329
  });
329
330
 
330
331
  test('the peer-recognition feed is paged through OTBKit, not by hand', () => {
331
- assert.match(read('hall-widgets.js'), /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
332
- const js = read('builders.js');
332
+ // task 1005050: the feed moved with Accomplishments to the profile's "Just you"
333
+ // screen; profile-own.js stamps the kit's bar and pages it.
334
+ const js = read('profile-own.js');
335
+ assert.match(js, /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
333
336
  assert.match(js, /makePager\('acclaim'/, 'acclaim has a kit pager');
334
337
  assert.match(js, /paginate\(acclaimState\.tasks, acclaimPager\)/, 'rows are sliced by the kit');
335
338
  assert.match(js, /wirePager\(acclaimPager/, 'prev/next/per-page are wired');
@@ -486,8 +489,9 @@ test('the colour-literal guard CATCHES a reintroduction, and tolerates a task re
486
489
  'a token is exactly what this rule wants');
487
490
  });
488
491
 
489
- test("Standing's grade column renders its three verdicts through the vocabulary", () => {
490
- const js = read('builders.js');
492
+ test("the Reviews grade column renders its three verdicts through the vocabulary", () => {
493
+ // task 1005050: Accomplishments' Reviews table is the profile's "Just you" screen's.
494
+ const js = read('profile-own.js');
491
495
  const start = js.indexOf('function renderRecentGrades');
492
496
  assert.ok(start > 0, 'renderRecentGrades present');
493
497
  const body = js.slice(start, js.indexOf('\n }', start));
@@ -153,11 +153,14 @@ test('work.html has exactly one h1', () => {
153
153
  });
154
154
 
155
155
  test('both re-skinned pages have a states file, so the floors are reproducible', () => {
156
- for (const f of ['work.states.json', 'index.states.json']) {
156
+ // task 1005050: Home's two Accomplishments states left with that view (it
157
+ // folded into the Profile page, whose states file measures the "Just you" screen).
158
+ for (const [f, min] of [['work.states.json', 4], ['index.states.json', 2]]) {
157
159
  const json = JSON.parse(read(f));
158
160
  assert.equal(json.surface, 'hall-ui', `${f} names its surface`);
159
- assert.ok(Object.keys(json.states).length >= 4, `${f} renders more than the default state`);
161
+ assert.ok(Object.keys(json.states).length >= min, `${f} renders more than the default state`);
160
162
  }
163
+ assert.ok(JSON.parse(read('profile.states.json')).states.yours, 'the "Just you" screen is measured');
161
164
  });
162
165
 
163
166
  // ---- 7. the way INTO a record is the title ----------------------------------
@@ -397,46 +400,57 @@ test('RUN: the Standing card carries the three facts, the place as "2nd of 4", a
397
400
  assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
398
401
  });
399
402
 
400
- // task 1005046: "Where you stand" left the retired Ranks page for Accomplishments.
401
- // builders.js hands the factory its real tier MAP and its coercion; the card names
402
- // the viewer's rank, the rank above it read off that map, and how to reach it.
403
- const TIER_CTX = {
404
- RANK_ORDER: { archon: 4, metic: 3, thetes: 2, xenos: 1 },
405
- normalizeRank: (r) => ({ archon: 4, metic: 3, thetes: 2, xenos: 1 }[r] ? r : 'xenos'),
406
- };
403
+ // task 1005046: "Where you stand" left the retired Ranks page for Accomplishments,
404
+ // and task 1005050 moved it on, with Accomplishments, to your own profile's
405
+ // "Just you" screen (profile-own.js). The card names the viewer's rank, the rank
406
+ // above it read off the ladder, and how to reach it.
407
+ function bootOwnRank(builder) {
408
+ const nodes = {};
409
+ const el = (id) => (nodes[id] = nodes[id] || { id, hidden: true, innerHTML: '', textContent: '', style: {}, addEventListener() {}, querySelector: () => null });
410
+ const document = { getElementById: el, querySelector: (sel) => el(String(sel).replace(/^#/, '')), querySelectorAll: () => [] };
411
+ const window = {
412
+ OTB: { escapeHtml: (x) => String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])), $: document.querySelector, fmtNum: String, fmtDate: String,
413
+ rankLabel: (r) => (r ? r.charAt(0).toUpperCase() + r.slice(1) : '') },
414
+ OTBKit: { makePager: () => ({ page: 1 }), paginate: (x) => x, wirePager() {}, pagerBarHtml: () => '' },
415
+ };
416
+ // A quiet console: the feeds this stub refuses log their own failure.
417
+ const sandbox = { window, document, console: { log() {}, warn() {}, error() {} }, setTimeout, Promise };
418
+ vm.createContext(sandbox);
419
+ vm.runInContext(read('profile-own.js'), sandbox, { filename: 'profile-own.js' });
420
+ const api = { request: () => Promise.resolve({ status: 404, ok: false, data: null }) };
421
+ window.OTBProfileOwn.render({ api, API: '/api/bongos', me: { builder } });
422
+ return { el };
423
+ }
407
424
 
408
425
  test('RUN: Where you stand names the viewer\'s rank, the one above it, and how to reach it', () => {
409
- const { api, el } = bootHallRender({ me: ME, ctxExtra: TIER_CTX });
410
- api.renderProfile({ ...ME.builder, rank: 'thetes' });
426
+ const { el } = bootOwnRank({ ...ME.builder, rank: 'thetes' });
411
427
  const html = el('rank-standing-body').innerHTML;
428
+ assert.equal(el('rank-scroll').hidden, false, 'the card shows on your own profile');
412
429
  assert.match(html, /<div class="ranks-standing__badge"><span class="rank-badge rank-badge--thetes"/);
413
430
  assert.match(html, /<p class="ranks-standing__title">The proven builder<\/p>/);
414
- assert.match(html, /The rank above yours is <span class="rank-badge rank-badge--metic"/, 'the next rung is read off the tier map');
431
+ assert.match(html, /The rank above yours is <span class="rank-badge rank-badge--metic"/, 'the next rung is read off the ladder');
415
432
  assert.match(html, /Promotion to <strong>Metic<\/strong>/, 'and the climb is the one from THIS rank');
416
433
  assert.match(html, /<a href="\/primer#the-rank-ladder">/, 'the full ladder is one chapter of the Primer');
417
434
  assert.doesNotMatch(html, /Loading…/, 'the skeleton is replaced');
418
435
  });
419
436
 
420
437
  test('RUN: Where you stand says so at the top, and coerces a stray rank to the entry rung', () => {
421
- const top = bootHallRender({ me: ME, ctxExtra: TIER_CTX });
422
- top.api.renderProfile({ ...ME.builder, rank: 'archon' });
423
- const atTop = top.el('rank-standing-body').innerHTML;
438
+ const atTop = bootOwnRank({ ...ME.builder, rank: 'archon' }).el('rank-standing-body').innerHTML;
424
439
  assert.equal((atTop.match(/You hold the highest rank/g) || []).length, 1, 'said once, not twice');
425
440
  assert.doesNotMatch(atTop, /ranks-standing__next/, 'no next rung to name');
426
- const stray = bootHallRender({ me: ME, ctxExtra: TIER_CTX });
427
- stray.api.renderProfile({ ...ME.builder, rank: 'strategos' });
428
- const html = stray.el('rank-standing-body').innerHTML;
429
- assert.match(html, /<p class="ranks-standing__title">The newcomer<\/p>/, 'a reserved rank reads as xenos, as the Standing band does');
441
+ const html = bootOwnRank({ ...ME.builder, rank: 'strategos' }).el('rank-standing-body').innerHTML;
442
+ assert.match(html, /<p class="ranks-standing__title">The newcomer<\/p>/, 'a reserved rank reads as xenos');
430
443
  assert.match(html, /The rank above yours is <span class="rank-badge rank-badge--thetes"/);
431
444
  });
432
445
 
433
- test('the Where you stand card is a Progress section of Accomplishments, with a Loading… line', () => {
434
- const widgets = read('hall-widgets.js');
435
- const at = widgets.indexOf("w('rank-scroll', 'Where you stand'");
436
- assert.ok(at > -1, 'registered as a hall widget');
437
- const reg = widgets.slice(at, widgets.indexOf('});', at));
438
- assert.match(reg, /<div id="rank-standing-body"><div class="hall-loading" role="status">Loading…<\/div><\/div>/);
439
- assert.match(reg, /view: 'standing', group: 'progress'/);
446
+ test('the Where you stand card is on the profile\'s "Just you" screen, hidden until drawn, with a Loading… line', () => {
447
+ const html = read('profile.html');
448
+ const at = html.indexOf('id="rank-scroll"');
449
+ assert.ok(at > -1, 'profile.html carries the card');
450
+ const sec = html.slice(html.lastIndexOf('<section', at), html.indexOf('</section>', at));
451
+ assert.match(sec, /data-group="yours" hidden/);
452
+ assert.match(sec, /<div id="rank-standing-body"><div class="hall-loading" role="status">Loading…<\/div><\/div>/);
453
+ assert.ok(!/w\('rank-scroll'/.test(read('hall-widgets.js')), 'Home no longer registers it');
440
454
  });
441
455
 
442
456
  test('RUN: the three notices are inbox lines with a kind, a line and — where one exists — a door', () => {
@@ -229,10 +229,19 @@ test('B: the id wears the same mono/faint voice as the login', () => {
229
229
  // PART C — grade and cost on the peer-acclaim feed (task 1002835)
230
230
  // =========================================================================
231
231
 
232
- // acclaimEvidenceHtml is a view painter, so it lives in the hall-render carve
233
- // (the same reason renderLanding does) and builders.js takes it from the
234
- // factory. Read it off the same factory rather than re-deriving it.
235
- const acclaimEvidence = () => hallRender().hall.acclaimEvidenceHtml;
232
+ // acclaimEvidenceHtml moved with the peer-recognition feed to profile-own.js
233
+ // (task 1005050: Accomplishments folded into the Profile page). It is private to
234
+ // that file's closure, so the shipped function's own source is run here.
235
+ const acclaimEvidence = () => {
236
+ const src = read('profile-own.js');
237
+ const start = src.indexOf(' function acclaimEvidenceHtml(t) {');
238
+ assert.ok(start > 0, 'profile-own.js defines acclaimEvidenceHtml');
239
+ const end = src.indexOf('\n }\n', start) + 4;
240
+ const ctx = {};
241
+ vm.createContext(ctx);
242
+ vm.runInContext(`${src.slice(start, end)}\nthis.fn = acclaimEvidenceHtml;`, ctx);
243
+ return ctx.fn;
244
+ };
236
245
  test('C: a graded, costed ship carries both numbers', () => {
237
246
  const html = acclaimEvidence()({ grade_score: '8.5', grade_passed: true, cost_usd: '12.3456' });
238
247
  assert.match(html, /ledger__verdict--ok">8\.5 \/ 10</, 'a passing grade reads as passing');
@@ -264,7 +273,7 @@ test('C: absence renders as absence, never as zero', () => {
264
273
  });
265
274
 
266
275
  test('C: the evidence line hangs under the work, in the feed row', () => {
267
- const src = read('builders.js');
276
+ const src = read('profile-own.js');
268
277
  assert.match(
269
278
  src,
270
279
  /<td>\$\{title\}\$\{acclaimEvidenceHtml\(t\)\}<\/td>/,
@@ -134,28 +134,17 @@ test('Home: the Standing chip\'s count comes from the small page, with no ledger
134
134
  assert.equal(el('recent-ships tbody').innerHTML, '', 'the ledger is untouched until its view opens');
135
135
  });
136
136
 
137
- test('Accomplishments: the full ledger loads when the view opens, once', async () => {
138
- const { r, el, fetched, showView } = loadRender();
137
+ // task 1005050: the full ledger was the Accomplishments view's, and that view
138
+ // folded into the Profile page (whose Works ledger reads its own record). Home
139
+ // now NEVER asks for the whole history — not on a view change, not on a refresh.
140
+ test('Home never downloads the whole history, whatever view event or refresh arrives', async () => {
141
+ const { r, el, fetched, showView } = loadRender({ view: 'standing' });
139
142
  r.setRecentShips(homePage(3000));
140
143
  showView('standing');
141
- await r.loadShipLedger();
142
- assert.deepEqual(fetched, ['/api/bongos/me/recent-ships?limit=all']);
143
- assert.match(el('ships-count').textContent, /3000 works shipped/);
144
- showView('home');
145
- showView('standing');
146
- await r.loadShipLedger();
147
- assert.equal(fetched.length, 1, 'reopening the view with nothing new does not refetch');
148
- });
149
-
150
- test('a live refresh refetches the open ledger only when the newest ships or the count moved', async () => {
151
- const { r, fetched } = loadRender({ view: 'standing' });
152
- r.setRecentShips(homePage(3000));
153
- await r.loadShipLedger();
154
- assert.equal(fetched.length, 1, 'opening straight onto Accomplishments loads the ledger');
155
- r.setRecentShips(homePage(3000)); // a nudge from someone else's write
156
- await r.loadShipLedger();
157
- assert.equal(fetched.length, 1, 'an unrelated write does not re-download the history');
158
144
  r.setRecentShips(homePage(3001, 3001)); // this builder shipped
159
- await r.loadShipLedger();
160
- assert.equal(fetched.length, 2, 'a new ship does');
145
+ await new Promise((done) => setTimeout(done, 0));
146
+ assert.deepEqual(fetched, [], 'no ?limit=all read from Home');
147
+ assert.equal(el('standing-shipped').textContent, '3001', 'the chip follows the newest total');
148
+ assert.equal(r.loadShipLedger, undefined, 'the ledger loader went with the view');
161
149
  });
150
+
@@ -338,7 +338,8 @@ test('Watch\'s trend takes the neutral tone — on an oversight page the accent
338
338
  });
339
339
 
340
340
  test('the Curve keeps its own line colour, which is not the kit default', () => {
341
- const src = read('hall-render.js');
341
+ // task 1005050: the Curve moved with Accomplishments to the profile's "Just you" screen.
342
+ const src = read('profile-own.js');
342
343
  assert.match(src, /sparklineHtml\(values, \{ color: 'var\(--accent-deep\)'/,
343
344
  'the Curve\'s first chart is accent-deep; the kit\'s fallback is --accent');
344
345
  assert.match(src, /OTBKit\.barSeriesHtml\(/, 'and its kinds row draws through the kit');
@@ -348,7 +349,7 @@ test('the kit is loaded before every page that draws through it', () => {
348
349
  // A page script reading window.OTBKit at render time with the kit loaded
349
350
  // after it is a blank chart in production and a green unit suite.
350
351
  for (const [page, script] of [
351
- ['index.html', 'hall-render.js'],
352
+ ['profile.html', 'profile-own.js'],
352
353
  ['watch.html', 'watch.js'],
353
354
  ['profile.html', 'profile-charts.js'],
354
355
  // task 1003447: the roadmap's own series module reads the kit, and the page
@@ -135,7 +135,8 @@ test('no hall page waits on a bare line of text any more', () => {
135
135
  assert.deepEqual(misses, [], 'every waiting placeholder wears the loader');
136
136
  // The table placeholder rows keep their cell, with the loader inside it.
137
137
  assert.match(read('work.html'), /<td colspan="6" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
138
- assert.match(read('hall-widgets.js'), /<td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
138
+ // task 1005050: the karma feed's table moved with Accomplishments into profile.html.
139
+ assert.match(read('profile.html'), /<td colspan="4" class="ledger__empty"><span class="hall-loading" role="status">Loading…<\/span><\/td>/);
139
140
  });
140
141
 
141
142
  test('the placeholders the page scripts write wear it too, and a failure stops it', () => {
@@ -10,8 +10,9 @@
10
10
  // - every nav href resolves to a page that exists ("all links land"),
11
11
  // - a group's head is never more permissive than its most permissive item
12
12
  // (otherwise the head renders over an empty group),
13
- // - Achievements has no nav entry, but #achievements still routes to Standing
14
- // (task 2174 folded it in; the alias is what keeps old bookmarks working).
13
+ // - Achievements has no nav entry, and since task 1005050 neither has
14
+ // Accomplishments: both folded into the Profile page, and every old
15
+ // #achievements / #accomplishments / #standing link redirects there.
15
16
  //
16
17
  // Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
17
18
  import assert from 'node:assert/strict';
@@ -296,7 +297,8 @@ test('sidebar groups read by audience, in order', () => {
296
297
  // task 1004685 (the living-city prototype, round-3 board 6): 'my-agents' joins You —
297
298
  // how your own Claude works for you, the personal half of "the city steers
298
299
  // everyone, My agents steers yours". Its subject is the viewer's own agents.
299
- assert.deepEqual(nav[3].items.map((i) => i.id), ['standing', 'profile', 'my-agents'], 'You = your own numbers and your own agents');
300
+ // task 1005050: 'standing' (Accomplishments) left — your numbers are the one Profile page.
301
+ assert.deepEqual(nav[3].items.map((i) => i.id), ['profile', 'my-agents'], 'You = your own numbers and your own agents');
300
302
  // task 1003444: 'people' LEFT the group — the owner deleted the hall's
301
303
  // cross-project directory (ADR 0337 D5); its home is the hub, and /people
302
304
  // redirects to /roster. Builders is the hall's directory now.
@@ -556,16 +558,13 @@ test('#leaderboard REDIRECTS to /roster from the index, and only from the index'
556
558
  assert.deepEqual(unknown.replaced, [], 'an unknown hash is not a redirect');
557
559
  });
558
560
 
559
- test('Achievements has no nav entry, but #achievements still routes to Standing', () => {
561
+ test('Achievements has no nav entry, and #achievements lands on your own profile', () => {
560
562
  const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
561
563
  assert.ok(!ids.includes('achievements'), 'the nav entry retired with task 2175');
562
-
563
- // The alias is the compatibility contract task 2174 left behind — old
564
- // bookmarks and the Standing stat tiles still link #achievements.
564
+ // task 1005050: the Standing view it routed to folded into Profile, whose
565
+ // overview carries your achievements.
565
566
  const idx = boot({ page: 'home', pathname: '/', hash: '#achievements' });
566
- assert.equal(idx.viewHidden('standing'), false, '#achievements shows the Standing view');
567
- assert.equal(idx.viewHidden('home'), true, 'and hides Home');
568
- assert.equal(idx.shell.currentView(), 'standing');
567
+ assert.deepEqual(idx.replaced, ['/profile'], '#achievements redirects to your own profile');
569
568
  });
570
569
 
571
570
  test('collapsible groups carry a stable storage key (collapse persistence)', () => {
@@ -871,35 +870,35 @@ test('My agents draws its own glyph and lands on a real page', () => {
871
870
  assert.ok(hrefResolves(item), 'my-agents.html exists');
872
871
  });
873
872
 
874
- // task 1004826 (owner, 2026-10-23): Standing is called Accomplishments
875
- // everywhere a reader sees it. The view keeps its internal key, and every old
876
- // #standing link still lands on it.
877
- test('Standing is Accomplishments to the reader, and old #standing links still land', () => {
873
+ // task 1005050 (owner decision 2026-11-03, idea 1001315): Accomplishments
874
+ // folded into the Profile page — one page for your numbers. Its nav item is gone
875
+ // and every old link, by either of its names, lands on your OWN profile: the
876
+ // section it named, or the "Just you" screen that holds its private half.
877
+ test('Accomplishments is gone from the sidebar, and every old link lands on your own profile', () => {
878
878
  const sidebar = boot().sidebarHtml;
879
- const item = parseNav(sidebar).flatMap((g) => g.items).find((i) => i.id === 'standing');
880
- assert.ok(item, 'the nav item is still there');
881
- assert.equal(item.href, '/#accomplishments');
882
- const anchor = sidebar.slice(sidebar.indexOf('id="nav-standing"'), sidebar.indexOf('</a>', sidebar.indexOf('id="nav-standing"')));
883
- assert.match(anchor, />Accomplishments</, 'labelled Accomplishments');
884
- assert.ok(!/>Standing</.test(sidebar), 'nothing in the nav is called Standing');
885
-
886
- for (const hash of ['#accomplishments', '#accomplishments-karma', '#standing', '#standing-work']) {
879
+ const ids = parseNav(sidebar).flatMap((g) => g.items).map((i) => i.id);
880
+ assert.ok(!ids.includes('standing'), 'no Accomplishments item');
881
+ assert.ok(!/>Accomplishments</.test(sidebar), 'nothing in the nav is called Accomplishments');
882
+ const LANDS = {
883
+ '#accomplishments': '/profile#yours',
884
+ '#accomplishments-karma': '/profile#yours',
885
+ '#accomplishments-progress': '/profile#yours',
886
+ '#accomplishments-work': '/profile#works',
887
+ '#accomplishments-achievements': '/profile',
888
+ '#standing': '/profile#yours',
889
+ '#standing-karma': '/profile#yours',
890
+ '#standing-work': '/profile#works',
891
+ };
892
+ for (const [hash, to] of Object.entries(LANDS)) {
887
893
  const idx = boot({ page: 'home', pathname: '/', hash });
888
- assert.equal(idx.shell.currentView(), 'standing', `${hash} opens the Accomplishments view`);
889
- assert.equal(idx.viewHidden('home'), true, `${hash} hides Home`);
890
- }
891
- const html = fs.readFileSync(path.join(HALL, 'index.html'), 'utf8');
892
- assert.match(html, /id="view-standing" data-view-title="Accomplishments"/, 'the top bar names it Accomplishments');
893
- assert.ok(!/href="#standing/.test(fs.readFileSync(path.join(HALL, 'hall-widgets.js'), 'utf8')), 'Home links use the new name');
894
- });
895
-
896
- test('the Accomplishments band carries no notices — Home\'s attention cards own them (task 1004826)', () => {
897
- const src = fs.readFileSync(path.join(HALL, 'hall-render.js'), 'utf8');
898
- const fn = src.slice(src.indexOf(' function renderProfile(builder) {'), src.indexOf(' function renderSystemNotices('));
899
- const band = fn.slice(fn.indexOf("$('#profile-body').innerHTML"), fn.indexOf("const welcomeLine"));
900
- assert.ok(band.length > 100, 'sanity: found the band template');
901
- for (const cls of ['profile__rebase', 'profile__needs', 'profile__budget']) {
902
- assert.ok(!band.includes(cls), `${cls} no longer renders on the band`);
894
+ assert.deepEqual(idx.replaced, [to], `${hash} lands on ${to}`);
903
895
  }
896
+ // No view is left for them to open on the index.
897
+ assert.ok(!/id="view-standing"/.test(fs.readFileSync(path.join(HALL, 'index.html'), 'utf8')), 'the view is gone');
898
+ // Home's Standing card still names the shipped count and links to the profile.
899
+ const render = fs.readFileSync(path.join(HALL, 'hall-render.js'), 'utf8');
900
+ assert.match(render, /View full profile →/);
901
+ assert.ok(!/\$\('#profile-body'\)/.test(render), 'the Accomplishments band is not drawn any more');
902
+ const fn = render.slice(render.indexOf(' function renderProfile(builder) {'), render.indexOf(' function renderSystemNotices('));
904
903
  assert.match(fn, /renderSystemNotices\(\{ rebaseTasks, need, actionNeeds, cost \}\)/, 'Home still gets every notice');
905
904
  });
@@ -0,0 +1,100 @@
1
+ // tests/hall_numbers_page.mjs — your numbers on one page (task 1005050, owner
2
+ // decision 2026-11-03, idea 1001315 item 2).
3
+ //
4
+ // Accomplishments (Home's 'standing' view) and Profile both showed your shipped
5
+ // work, karma and achievements. Profile is the one page now:
6
+ //
7
+ // * ONE PAGE. The Accomplishments view, its nav item and its widgets are gone;
8
+ // its private half — where you stand, reviews, the karma feed, peer
9
+ // recognition and the progress curve — is Profile's "Just you" screen.
10
+ // * NOTHING PRIVATE ON SOMEONE ELSE'S PROFILE. profile-own.js runs only when the
11
+ // profile ASKED FOR is the viewer's own; on anyone else's it reads nothing and
12
+ // draws nothing, whatever the hash says.
13
+ // * OLD LINKS LAND ON YOUR OWN PROFILE (shell.js VIEW_REDIRECTS; held, with the
14
+ // sidebar, in tests/hall_nav.mjs).
15
+ //
16
+ // These run the real profile.js and profile-own.js against the shared profile
17
+ // harness (tests/helpers.mjs bootProfilePage).
18
+ //
19
+ // Run: node tests/hall_numbers_page.mjs
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { bootProfilePage } from './helpers.mjs';
27
+
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
30
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
31
+ const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); };
32
+
33
+ // The reads only the "Just you" screen makes: the peer-recognition feed and the
34
+ // viewer's own progress curve.
35
+ const OWN_READS = /\/tasks\/peer-votes\/feed|\/analytics\/builder\//;
36
+
37
+ test('your own profile (no ?id) draws the "Just you" screen', async () => {
38
+ const p = bootProfilePage({ withOwn: true });
39
+ await settle();
40
+ assert.match(p.el('rank-standing-body').innerHTML, /ranks-standing__title/, 'where you stand is drawn');
41
+ assert.equal(p.el('rank-scroll').hidden, false);
42
+ assert.equal(p.el('grades-scroll').hidden, false, 'the Reviews section shows');
43
+ // The two feeds with reads of their own wait until the screen is opened: a
44
+ // visit for your Works does not pay for them.
45
+ assert.equal(p.requests.filter((u) => OWN_READS.test(u)).length, 0, 'no feed is read before the screen opens');
46
+ p.window.OTBProfileOwn.open();
47
+ await settle();
48
+ assert.ok(p.requests.some((u) => /\/tasks\/peer-votes\/feed/.test(u)), 'the peer-recognition feed is read');
49
+ assert.ok(p.requests.some((u) => /\/analytics\/builder\/95\?/.test(u)), 'your own curve is read');
50
+ const n = p.requests.length;
51
+ p.window.OTBProfileOwn.open();
52
+ await settle();
53
+ assert.equal(p.requests.length, n, 'opening it again reads nothing more');
54
+ });
55
+
56
+ test('?id= naming yourself is still your own profile', async () => {
57
+ const p = bootProfilePage({ withOwn: true, search: '?id=95' });
58
+ await settle();
59
+ assert.match(p.el('rank-standing-body').innerHTML, /ranks-standing__title/);
60
+ });
61
+
62
+ test('someone else\'s profile reads and draws nothing private', async () => {
63
+ // The harness answers every profile with builder 95's payload, so this is the
64
+ // case that matters: the data alone would say "yours" — the REQUEST says not.
65
+ const p = bootProfilePage({ withOwn: true, search: '?id=2001' });
66
+ await settle();
67
+ p.window.OTBProfileOwn.open(); // even a forced open has no viewer to read for
68
+ await settle();
69
+ assert.equal(p.requests.filter((u) => OWN_READS.test(u)).length, 0, 'no private read is made');
70
+ assert.equal(p.el('rank-standing-body').innerHTML, '', 'where you stand is not drawn');
71
+ assert.equal(p.el('recent-grades tbody').innerHTML, '', 'no reviews are drawn');
72
+ });
73
+
74
+ test('a signed-out visitor gets no "Just you" screen', async () => {
75
+ const p = bootProfilePage({ withOwn: true, signedIn: false, search: '?id=95' });
76
+ await settle();
77
+ assert.equal(p.requests.filter((u) => OWN_READS.test(u)).length, 0);
78
+ });
79
+
80
+ test('the screen starts hidden, rail key and all, and only profile.js opens it', () => {
81
+ const html = read('profile.html');
82
+ for (const id of ['rank-scroll', 'grades-scroll', 'karma-scroll', 'acclaim-scroll', 'analytics-scroll']) {
83
+ assert.match(html, new RegExp(`id="${id}"[^>]*data-group="yours" hidden>`), `#${id} starts hidden`);
84
+ }
85
+ const js = read('profile.js');
86
+ assert.match(js, /if \(group === 'yours' && window\.OTBProfileOwn && window\.OTBProfileOwn\.open\) window\.OTBProfileOwn\.open\(\);/, 'the feeds load when the screen first opens');
87
+ assert.match(js, /\(i\.id !== 'yours' \|\| ownShown\)/, 'the rail offers the key only once the screen is drawn');
88
+ assert.match(js, /if \(ownRequest && selfId != null && selfMe && window\.OTBProfileOwn\)/, 'drawn only for your own request');
89
+ const i = (s) => html.indexOf(s);
90
+ assert.ok(i('/builders/profile-own.js') > 0 && i('/builders/profile-own.js') < i('/builders/profile.js?'), 'loaded before profile.js');
91
+ });
92
+
93
+ test('Home carries no Accomplishments view, and no renderer of what moved', () => {
94
+ assert.ok(!/id="view-standing"/.test(read('index.html')));
95
+ assert.ok(!/view: 'standing'/.test(read('hall-widgets.js')), 'no widget mounts into it');
96
+ for (const fn of ['renderRecentGrades', 'renderKarmaFeed', 'renderAcclaimFeed', 'loadAnalytics', 'renderCurveBody', 'renderRankStanding', 'acclaimEvidenceHtml']) {
97
+ assert.ok(!new RegExp(`function ${fn}\\b`).test(read('builders.js') + read('hall-render.js')), `${fn} moved, not copied`);
98
+ assert.match(read('profile-own.js'), new RegExp(`function ${fn}\\b`), `${fn} lives in profile-own.js`);
99
+ }
100
+ });
@@ -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');