@bongos/core 1.24.29 → 1.24.31

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 (113) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +174 -1
  8. package/docs/api-reference.md +12 -2
  9. package/docs/copy-inventory.md +226 -224
  10. package/docs/copy-registry.json +583 -549
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +18 -37
  13. package/docs/page-readings.json +306 -361
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/government/db.js +29 -0
  18. package/modules/government/docket.js +61 -4
  19. package/modules/government/routes/government.js +2 -0
  20. package/modules/hall-ui/public/ai-studio.html +1 -1
  21. package/modules/hall-ui/public/atlas.html +1 -1
  22. package/modules/hall-ui/public/blockers.html +1 -1
  23. package/modules/hall-ui/public/board-room.html +1 -1
  24. package/modules/hall-ui/public/builders.js +3 -79
  25. package/modules/hall-ui/public/city.html +1 -1
  26. package/modules/hall-ui/public/collab.html +1 -1
  27. package/modules/hall-ui/public/commands.html +1 -1
  28. package/modules/hall-ui/public/copy-desk.html +1 -1
  29. package/modules/hall-ui/public/craft-mode.js +1 -1
  30. package/modules/hall-ui/public/deploy.html +1 -1
  31. package/modules/hall-ui/public/diagrams.html +1 -1
  32. package/modules/hall-ui/public/drachmae.html +1 -1
  33. package/modules/hall-ui/public/fleet.html +1 -1
  34. package/modules/hall-ui/public/gate.html +1 -1
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/hall-render.js +68 -167
  39. package/modules/hall-ui/public/hall-widgets.js +4 -45
  40. package/modules/hall-ui/public/home-city-draw.js +583 -0
  41. package/modules/hall-ui/public/home-city.css +102 -0
  42. package/modules/hall-ui/public/home-city.js +339 -0
  43. package/modules/hall-ui/public/home-gallery.css +129 -0
  44. package/modules/hall-ui/public/home-gallery.js +349 -0
  45. package/modules/hall-ui/public/home-studio.css +72 -0
  46. package/modules/hall-ui/public/home-studio.js +203 -0
  47. package/modules/hall-ui/public/idea.html +1 -1
  48. package/modules/hall-ui/public/ideas.html +1 -1
  49. package/modules/hall-ui/public/index.html +9 -7
  50. package/modules/hall-ui/public/index.states.json +73 -8
  51. package/modules/hall-ui/public/market-watch-item.html +1 -1
  52. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  53. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  54. package/modules/hall-ui/public/market-watch.html +141 -54
  55. package/modules/hall-ui/public/market-watch.js +54 -0
  56. package/modules/hall-ui/public/market-watch.states.json +2 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/my-agents.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/project-settings.html +1 -1
  62. package/modules/hall-ui/public/ranks.html +1 -1
  63. package/modules/hall-ui/public/roadmap.html +1 -1
  64. package/modules/hall-ui/public/roster.html +1 -1
  65. package/modules/hall-ui/public/sessions.html +1 -1
  66. package/modules/hall-ui/public/settings.html +1 -1
  67. package/modules/hall-ui/public/shell.js +2 -3
  68. package/modules/hall-ui/public/street.html +1 -1
  69. package/modules/hall-ui/public/studio.html +9 -1
  70. package/modules/hall-ui/public/style.css +74 -27
  71. package/modules/hall-ui/public/task.html +1 -1
  72. package/modules/hall-ui/public/thinking.html +1 -1
  73. package/modules/hall-ui/public/tweak-editor.html +1 -1
  74. package/modules/hall-ui/public/watch.html +1 -1
  75. package/modules/hall-ui/public/work.html +1 -1
  76. package/modules/hall-ui/records/home-city.md +189 -0
  77. package/modules/hall-ui/records/loader.md +1 -1
  78. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  79. package/modules/hall-ui/records/market-watch.md +3 -1
  80. package/modules/lifecycle/home-city.js +388 -0
  81. package/modules/lifecycle/home-gallery.js +320 -0
  82. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  83. package/modules/lifecycle/module.json +2 -0
  84. package/modules/lifecycle/routes/home-city.js +29 -0
  85. package/modules/lifecycle/routes/home-gallery.js +37 -0
  86. package/modules/market-watch/routes/settings.js +1 -1
  87. package/package-lock.json +2 -2
  88. package/package.json +1 -1
  89. package/release-notes.json +38 -0
  90. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  91. package/scripts/hall-preview/README.md +1 -0
  92. package/scripts/hall-preview/server.js +10 -0
  93. package/src/bongos/serve-internal.js +17 -3
  94. package/src/module-api.js +1 -1
  95. package/tests/claim_from_task_and_home.mjs +8 -43
  96. package/tests/hall_board_world.mjs +10 -12
  97. package/tests/hall_builders_page_pass.mjs +18 -27
  98. package/tests/hall_home_ships_bounded.mjs +4 -3
  99. package/tests/hall_loading.mjs +1 -1
  100. package/tests/hall_nav.mjs +11 -4
  101. package/tests/hall_page_gate_map.mjs +7 -4
  102. package/tests/home_bounded_reads.mjs +16 -31
  103. package/tests/home_city.mjs +580 -0
  104. package/tests/home_city_widget.mjs +260 -0
  105. package/tests/home_gallery.mjs +255 -0
  106. package/tests/home_gallery_widget.mjs +220 -0
  107. package/tests/home_studio.mjs +169 -0
  108. package/tests/home_studio_widget.mjs +157 -0
  109. package/tests/leaderboard_roles.mjs +3 -2
  110. package/tests/market_watch_pages.mjs +162 -0
  111. package/tests/nav_permission_atoms.mjs +2 -2
  112. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  113. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -86,16 +86,10 @@ await test('task.html\'s cache-bust moved for task.js', () => {
86
86
  assert.match(taskHtml, /task\.js\?v=2026-09-28-taskclaim/);
87
87
  });
88
88
 
89
- // == Home's "Next up" rows ====================================================
90
-
91
- await test('hall-render.js constructs the shared claim action for Home\'s Next-up row', () => {
92
- // Lives in hall-render.js, not builders.js: builders.js sits at the
93
- // 1,500-line fitness ratchet (hall-ui/CLAUDE.md — growth goes in the
94
- // lib/renderer, not the page).
95
- assert.match(hallRenderJs, /function wireNextUpClaim\(\)/);
96
- assert.match(hallRenderJs, /makeClaimAction\(\{/);
97
- assert.match(hallRenderJs, /window\.OTBClaimAction/);
98
- });
89
+ // == Home ====================================================================
90
+ // task 1005054: Home's "Next up" rows left with the "Your work" card (the project
91
+ // city took its place), so Home carries no claim button and no claim action.
92
+ // Claiming lives on the task page above and on the Work board.
99
93
 
100
94
  await test('the shared postJSON carries { status, data } — the contract claim-action.js reads', () => {
101
95
  const m = buildersJs.match(/async function postJSON\(path, body\) \{[\s\S]*?\n {2}\}/);
@@ -103,46 +97,17 @@ await test('the shared postJSON carries { status, data } — the contract claim-
103
97
  assert.match(m[0], /err\.data\s*=/, 'postJSON must set err.data or every refusal here falls through to the generic line');
104
98
  });
105
99
 
106
- await test('each Next-up row renders a Claim button carrying its task id', () => {
107
- const m = buildersJs.match(/list\.innerHTML = rows\.map\(\(t\) => \{[\s\S]*?\n {6}\}\)\.join\(''\);/);
108
- assert.ok(m, 'loadNextUp row template not found');
109
- assert.match(m[0], /class="[^"]*next-up__claim[^"]*"/);
110
- assert.match(m[0], /data-task-id="\$\{escapeHtml\(String\(t\.id\)\)\}"/);
111
- });
112
-
113
- await test('the Next-up Claim button routes through claimAction.claim, not a raw POST', () => {
114
- const m = hallRenderJs.match(/function wireNextUpClaim\(\) \{[\s\S]*?\n {2}\}/);
115
- assert.ok(m, 'wireNextUpClaim not found');
116
- assert.match(m[0], /\$\('#home-next-list'\)\?\.addEventListener\('click'/);
117
- assert.match(m[0], /claimAction\.claim\(taskId\)/);
118
- });
119
-
120
- await test('builders.js wires the Next-up claim action rather than hand-rolling it', () => {
100
+ await test('Home hand-rolls no claim: no POST /claims, no leftover Next-up wiring', () => {
121
101
  assert.doesNotMatch(buildersJs, /postJSON\(\s*`\$\{API\}\/claims/);
122
- assert.match(buildersJs, /wireNextUpClaim\(\)/, 'builders.js must call the renderer\'s wiring');
102
+ assert.doesNotMatch(buildersJs + hallRenderJs, /wireNextUpClaim|home-next-list|next-up__claim/);
103
+ assert.doesNotMatch(indexHtml, /claim-action\.js/, 'Home has no claim button, so it loads no claim action');
123
104
  });
124
105
 
125
- await test('the claim wiring landed in the renderer, not back over the 1,500-line ratchet', () => {
126
- // Not a second copy of fitness.js's own gate (scripts/bongos/fitness-ratchets.js)
127
- // — this is the reason wireNextUpClaim moved to hall-render.js in the first
128
- // place (see its own comment): a regression here explains WHY that file grew,
129
- // not just THAT some file grew.
106
+ await test('the page halves stay under the 1,500-line ratchet', () => {
130
107
  for (const [name, src] of [['builders.js', buildersJs], ['hall-render.js', hallRenderJs]]) {
131
108
  const lines = src.split('\n').length;
132
109
  assert.ok(lines <= 1500, `${name} is ${lines} lines — over the fitness ratchet`);
133
110
  }
134
111
  });
135
112
 
136
- await test('index.html loads claim-action.js before builders.js', () => {
137
- const shared = indexHtml.indexOf('claim-action.js');
138
- const after = indexHtml.indexOf('builders.js?v=');
139
- assert.ok(shared > -1, 'index.html must load claim-action.js');
140
- assert.ok(after > shared, 'builders.js must load AFTER claim-action.js');
141
- });
142
-
143
- await test('index.html\'s cache-bust moved for builders.js', () => {
144
- assert.doesNotMatch(indexHtml, /builders\.js\?v=2026-06-27-r75/, 'the old bust is still there');
145
- assert.match(indexHtml, /builders\.js\?v=2026-09-28-nextupclaim/);
146
- });
147
-
148
113
  summary();
@@ -303,13 +303,14 @@ test('the system notices are inbox LINES, first in the card, and counted by its
303
303
  }
304
304
  });
305
305
 
306
- test('the greeting reads /me and the public progress (for the version tab), and builders.js feeds both', () => {
307
- assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
306
+ test('the greeting reads /me only; the version tab and its progress read left Home', () => {
307
+ // task 1005054: the version tab left with the stats strip, so Home no longer
308
+ // fetches /public/progress; the project city (home-city.js) draws how far it is.
309
+ assert.doesNotMatch(buildersJs, /\/public\/progress/, 'Home no longer fetches the public progress');
308
310
  assert.doesNotMatch(buildersJs, /getJSON\(`\$\{API\}\/tasks\/claimable`/,
309
311
  'no unbounded claimable fetch on Home');
310
- assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
311
- assert.doesNotMatch(buildersJs, /lastClaimableCount/, 'the greeting no longer needs the claimable count (task 1004780)');
312
- assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 2, 'rendered after /me and after progress — each as it lands');
312
+ assert.doesNotMatch(buildersJs, /lastProgressState|lastClaimableCount/, 'the greeting needs neither the progress nor the claimable count');
313
+ assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 1, 'rendered once, as soon as /me answers');
313
314
  });
314
315
 
315
316
  // Boot hall-render.js against a stub DOM and a ctx of getters (the way
@@ -368,16 +369,13 @@ const ME = {
368
369
  const LEADERBOARD = [{ github_login: 'a', total_credits: 20000 }, { github_login: 'percy', total_credits: 14441 }, { github_login: 'b', total_credits: 3 }, { github_login: 'c', total_credits: 1 }];
369
370
  const PROGRESS = { progress: [{ version_id: 'BONGOS-V1', status: 'building', total_count: 605, percent_complete: 75 }, { version_id: 'V2', status: 'planning', total_count: 9000, percent_complete: 0 }] };
370
371
 
371
- test('RUN: the greeting names the builder, and the version tab carries how far the version is', () => {
372
+ test('RUN: the greeting names the builder, and writes nothing else', () => {
373
+ // task 1005054: the version tab left Home with the stats strip; how far the
374
+ // project has come is the city under the greeting (home-city.js).
372
375
  const { api, el } = bootHallRender({ me: ME, progress: PROGRESS });
373
376
  api.renderGreeting();
374
377
  assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
375
- assert.equal(el('home-version-name').textContent, 'BONGOS-V1', 'the building version, not the bigger planning one');
376
- assert.equal(el('home-version-pct').textContent, '75% complete');
377
- assert.equal(el('home-version').hidden, false);
378
- const none = bootHallRender({ me: ME });
379
- none.api.renderGreeting();
380
- assert.equal(none.el('home-version').hidden, true, 'no progress yet: no tab');
378
+ assert.equal(el('home-version').textContent || '', '', 'no version tab is drawn');
381
379
  });
382
380
 
383
381
  test('RUN: the Standing card carries the three facts, the place as "2nd of 4", and no banner', () => {
@@ -117,42 +117,33 @@ function renderLandingHtml(joinability) {
117
117
  return html();
118
118
  }
119
119
 
120
- test('A: the apply door gives signing in a real button, not a footnote link', () => {
120
+ // The sign-in redesign made the sign-in button the card's one primary control
121
+ // in EVERY mode; only the door beneath it depends on joinability.
122
+ const SIGNIN = /class="landing__btn landing__btn--block" id="landing-signin" href="\/api\/bongos\/auth\/web\/start"/;
123
+ const signInCount = (html) => (html.match(/href="\/api\/bongos\/auth\/web\/start"/g) || []).length;
124
+
125
+ test('A: the apply door leads with sign-in and folds the request form under it', () => {
121
126
  const html = renderLandingHtml('apply');
122
- assert.match(html, /landing__returning/, 'the apply door renders a returning-builder block');
123
- assert.match(
124
- html,
125
- /class="landing__btn landing__btn--quiet" href="\/api\/bongos\/auth\/web\/start"/,
126
- 'that block carries a real sign-in control in the quiet fill',
127
- );
128
- // The whole point of the change: the footnote link is GONE from this door, so
129
- // there is exactly one sign-in path and it is the button.
130
- assert.ok(
131
- !/Already a member\? <a /.test(html),
132
- 'the apply door no longer repeats sign-in as a prose link under the form',
133
- );
134
- assert.match(html, /We store only your GitHub login/, 'the privacy sentence survives the note');
135
- // Two controls total: request access, and sign in. Not three.
136
- assert.equal(countButtons(html), 2, 'the apply door carries the request button and the sign-in button');
127
+ assert.match(html, SIGNIN, 'sign-in is a real primary button');
128
+ assert.equal(signInCount(html), 1, 'exactly one sign-in path — never a second prose link');
129
+ assert.match(html, /<details class="landing__disclosure">[\s\S]*id="access-request-form"/, 'the request form sits behind a disclosure');
130
+ assert.ok(html.indexOf('landing-signin') < html.indexOf('access-request-form'), 'sign-in comes first');
131
+ assert.equal(countButtons(html), 2, 'two controls: sign in, and request access');
137
132
  });
138
133
 
139
- test('A: the two doors that already had a sign-in button do not grow a second', () => {
134
+ test('A: the open and invite-only doors carry one sign-in control and no form', () => {
140
135
  for (const mode of ['open', 'invite_only']) {
141
136
  const html = renderLandingHtml(mode);
142
- assert.ok(
143
- !/landing__returning/.test(html),
144
- `the ${mode} door does not add a returning block — its primary door already signs you in`,
145
- );
146
- assert.equal(countButtons(html), 1, `the ${mode} door carries exactly one sign-in control`);
147
- assert.match(html, /Already a member\? <a /, `the ${mode} door keeps its prose note for returning builders`);
137
+ assert.match(html, SIGNIN, `the ${mode} door signs in with the same button`);
138
+ assert.equal(signInCount(html), 1, `the ${mode} door has exactly one sign-in path`);
139
+ assert.ok(!/access-request-form/.test(html), `the ${mode} door has nothing to apply with`);
140
+ assert.equal(countButtons(html), 1, `the ${mode} door carries exactly one control`);
148
141
  }
149
142
  });
150
143
 
151
144
  test('A: an older branding pack with no joinability falls back to the apply door', () => {
152
- // joinabilityMode() treats anything but open/invite_only as 'apply', so the
153
- // button must follow that fallback — not sit behind a field an older pack
154
- // never sent.
155
- assert.match(renderLandingHtml(null), /landing__returning/);
145
+ // joinabilityMode() treats anything but open/invite_only as 'apply'.
146
+ assert.match(renderLandingHtml(null), /id="access-request-form"/);
156
147
  });
157
148
 
158
149
  test('A: the quiet fill is a defined variant, not an inline colour', () => {
@@ -122,14 +122,15 @@ function loadRender({ view = 'home' } = {}) {
122
122
 
123
123
  const homePage = (total, firstId = 3000) => ({ ships: [ship(firstId), ship(firstId - 1)], total });
124
124
 
125
- test('Home: the count and the five-row table come from the small page, with no ledger fetch', async () => {
125
+ test('Home: the Standing chip\'s count comes from the small page, with no ledger fetch', async () => {
126
+ // task 1005054: the Shipped tile and the five-row table left Home with the stats
127
+ // strip and "Your work"; the small page still feeds the Standing chip's count.
126
128
  const { r, el, fetched } = loadRender();
127
129
  assert.equal(r.HOME_SHIPS_LIMIT, 5);
128
130
  r.setRecentShips(homePage(3000));
129
131
  await new Promise((done) => setTimeout(done, 0));
130
132
  assert.deepEqual(fetched, [], 'Home never asks for the full history');
131
- assert.equal(el('stat-shipped').textContent, '3000', 'the Shipped tile shows the server total, not rows held');
132
- assert.match(el('home-recent-table tbody').innerHTML, /#3000[\s\S]*#2999/);
133
+ assert.equal(el('standing-shipped').textContent, '3000', 'the chip shows the server total, not rows held');
133
134
  assert.equal(el('recent-ships tbody').innerHTML, '', 'the ledger is untouched until its view opens');
134
135
  });
135
136
 
@@ -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', 'fleet', 'gate', 'government', 'market-watch-settings', 'project-settings', 'sessions']) {
86
+ for (const page of ['board-room', 'deploy', 'fleet', 'gate', 'government', 'project-settings', 'sessions']) {
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*)*/, '');
@@ -321,9 +321,16 @@ test('sidebar groups read by audience, in order', () => {
321
321
  // the steering surfaces because that is what it is — the owner's third release gate.
322
322
  // 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
323
323
  // what each hosted project costs is an operator surface, like the rest of this group.
324
- // 'market-watch-settings' (task 1004938) is the owner's Market Watch switch and AI cap,
325
- // archon like its four routes, and dropped where the market-watch module is off.
326
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'market-watch-settings', 'government']);
324
+ // 'market-watch-settings' (task 1004938) LEFT the group in task 1005045: the owner saw
325
+ // "Market Watch" twice and had to guess which was which (owner decision 2026-11-03,
326
+ // idea 1001310). The settings are an owner-only Settings tab on /market-watch now.
327
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government']);
328
+ // task 1005045: and no label is left twice anywhere in the sidebar, so the next pair
329
+ // fails here instead of in front of the owner.
330
+ const labels = [...boot().sidebarHtml.matchAll(/<a class="nav-item" [^>]*>[\s\S]*?<span class="nav-item__label">([^<]*)<\/span>/g)].map((m) => m[1]);
331
+ assert.ok(labels.includes('Market Watch'), 'the labels were actually read');
332
+ assert.deepEqual(labels.filter((l, i) => labels.indexOf(l) !== i), [], 'every sidebar label is said once');
333
+ assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === '/market-watch-settings'), 'no item points at the retired settings page');
327
334
  });
328
335
 
329
336
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -755,7 +762,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
755
762
  // block (task 1004713). These drive the real shell and the real gov-positions.js.
756
763
 
757
764
  const tick = () => new Promise((resolve) => setImmediate(resolve));
758
- const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'market-watch-settings', 'government'];
765
+ const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government'];
759
766
  const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
760
767
  const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
761
768
 
@@ -56,10 +56,11 @@ const ORACLE = {
56
56
  // settings. Every read behind it is the viewer's own (GET /me, /me/* prefs,
57
57
  // /specialities) and requireBuilder, so sign-in is the whole gate.
58
58
  '/builders/my-agents': 'requireBuilderPage',
59
- // task 1004938: the owner's Market Watch settings. Its read and writes are
60
- // owner-only (atom + owner seat) (tests/market_watch_settings.mjs proves the 403s), and the
61
- // shell carries no data, so sign-in is the page tier and the routes are the wall.
62
- '/builders/market-watch-settings': 'requireBuilderPage',
59
+ // Gone (task 1005045, owner decision 2026-11-03): the owner's Market Watch settings
60
+ // (task 1004938) are an owner-only Settings tab on /market-watch now, and the old URL
61
+ // 302s to /market-watch#settings before any gate runs. A redirect is not a page; the
62
+ // four settings routes keep their own owner-only wall (tests/market_watch_settings.mjs).
63
+ '/builders/market-watch-settings': null,
63
64
  // task 1004939: the Market Watch home and tracked-item pages. Every member of the
64
65
  // project reads them; both reads are requireBuilder, so sign-in is the whole gate.
65
66
  '/builders/market-watch': 'requireBuilderPage',
@@ -347,6 +348,8 @@ const GONE_PAGES = {
347
348
  // Task 1003444 — the owner deleted the hall's cross-project directory; the hub
348
349
  // community page (task 1003363) is its home, and /people redirects to /roster.
349
350
  '/builders/people': 'task 1003444 — the hall has no cross-project directory; /people redirects to /roster',
351
+ // Task 1005045 — one sidebar item for Market Watch: its settings are a tab on the page.
352
+ '/builders/market-watch-settings': 'task 1005045 — the settings are the Settings tab on /market-watch; the old URL redirects there',
350
353
  };
351
354
 
352
355
  test('a row that is null because the page left the hall serves no page', () => {
@@ -28,7 +28,6 @@ const require = createRequire(import.meta.url);
28
28
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
29
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
30
30
  const BUILDERS_JS = read('modules/hall-ui/public/builders.js');
31
- const WIDGETS_JS = read('modules/hall-ui/public/hall-widgets.js');
32
31
  const ROUTE = read('modules/lifecycle/routes/tasks.js');
33
32
  const DB_TASKS = read('modules/lifecycle/db-tasks.js');
34
33
 
@@ -48,26 +47,24 @@ await test('no Home read downloads a feed and slices it in the browser', () => {
48
47
  // DISPLAYED is fine; narrowing what was FETCHED is the bug, and the two look
49
48
  // identical at the call site — which is why this is a test and not a code review.
50
49
  const src = noComments(BUILDERS_JS);
51
- for (const fn of ['loadNextUp', 'loadTriageInbox']) {
52
- const from = src.indexOf(`function ${fn}(`);
53
- assert.ok(from > 0, `${fn} must exist`);
54
- const body = src.slice(from, src.indexOf('\n }\n', from));
55
- const name = fn;
56
- const url = body.match(/\$\{API\}\/[^`'"]+/);
57
- assert.ok(url, `${name} must issue a request`);
58
- assert.match(url[0], /[?&]limit=\d+/, `${name} must ask the server for its page size`);
59
- }
50
+ const from = src.indexOf('function loadTriageInbox(');
51
+ assert.ok(from > 0, 'loadTriageInbox must exist');
52
+ const body = src.slice(from, src.indexOf('\n }\n', from));
53
+ const url = body.match(/\$\{API\}\/[^`'"]+/);
54
+ assert.ok(url, 'loadTriageInbox must issue a request');
55
+ assert.match(url[0], /[?&]limit=\d+/, 'loadTriageInbox must ask the server for its page size');
60
56
  });
61
57
 
62
- await test('the two cards ask for exactly the rows they draw', () => {
63
- assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=5/, 'Next up draws five rows, level with Recently shipped');
64
- assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
65
- // …and no longer re-slices what came back.
66
- const src = noComments(BUILDERS_JS);
67
- const from = src.indexOf('function loadNextUp(');
68
- const body = src.slice(from, src.indexOf('\n }\n', from));
69
- assert.ok(body.length > 0, 'loadNextUp body found');
70
- assert.doesNotMatch(body, /\.slice\(0,\s*2\)/, 'the client-side slice is gone');
58
+ await test('the project city asks for ONE weekly summary, and the Next-up read left with its card', () => {
59
+ // task 1005054: Home's body is the project city. It draws from one bounded read
60
+ // the server counts per week (modules/lifecycle/home-city.js), never a task feed;
61
+ // the Next-up card and its GET /tasks/claimable read left Home with "Your work".
62
+ const CITY_JS = noComments(read('modules/hall-ui/public/home-city.js'));
63
+ const reads = [...CITY_JS.matchAll(/api\.request\('GET',\s*`([^`]+)`/g)].map((m) => m[1]);
64
+ assert.deepEqual(reads, ['${API}/home/city?weeks=${WEEKS}'], 'one read, asked for its window');
65
+ assert.doesNotMatch(CITY_JS, /\/tasks|claimable|recent-ships/, 'the city never downloads a feed');
66
+ assert.doesNotMatch(noComments(BUILDERS_JS), /function loadNextUp\(|tasks\/claimable/, 'Home no longer reads the claimable feed');
67
+ assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card still draws five');
71
68
  });
72
69
 
73
70
  // ---------------------------------------------------------------------------
@@ -153,16 +150,4 @@ await test('the limit is applied AFTER the JS post-filters, not as a SQL LIMIT',
153
150
  assert.match(routeBody, /feed\.slice\(0, claimLimit\)/, 'the slice happens on the finished feed');
154
151
  });
155
152
 
156
- // ---------------------------------------------------------------------------
157
- // 4. what the reader sees
158
- // ---------------------------------------------------------------------------
159
-
160
- await test('the foot names the queue the two rows came off', () => {
161
- assert.match(WIDGETS_JS, /id="home-next-foot"/, 'the link has an id to write into');
162
- assert.match(BUILDERS_JS, /See all \$\{fmtNum\(total\)\} ready work/);
163
- // Silent rather than wrong when the count is missing or not larger than what is
164
- // shown: "See all 2" under two rows is noise, and a guessed number is worse.
165
- assert.match(BUILDERS_JS, /Number\.isFinite\(total\) && total > rows\.length/);
166
- });
167
-
168
153
  summary();