@bongos/core 1.24.30 → 1.24.32

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 (101) hide show
  1. package/.bongos-core.json +153 -103
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +139 -1
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +192 -187
  10. package/docs/copy-registry.json +530 -458
  11. package/docs/module-api-changelog.md +4 -2
  12. package/docs/page-inventory.json +15 -35
  13. package/docs/page-readings.json +228 -265
  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/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/craft-mode.js +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/hall-render.js +62 -86
  35. package/modules/hall-ui/public/home-city-draw.js +360 -52
  36. package/modules/hall-ui/public/home-city.css +47 -11
  37. package/modules/hall-ui/public/home-city.js +170 -40
  38. package/modules/hall-ui/public/home-gallery.css +129 -0
  39. package/modules/hall-ui/public/home-gallery.js +349 -0
  40. package/modules/hall-ui/public/home-studio.css +72 -0
  41. package/modules/hall-ui/public/home-studio.js +203 -0
  42. package/modules/hall-ui/public/idea.html +1 -1
  43. package/modules/hall-ui/public/ideas.html +1 -1
  44. package/modules/hall-ui/public/index.html +4 -1
  45. package/modules/hall-ui/public/market-watch-item.html +1 -1
  46. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  47. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  48. package/modules/hall-ui/public/market-watch.html +141 -54
  49. package/modules/hall-ui/public/market-watch.js +54 -0
  50. package/modules/hall-ui/public/market-watch.states.json +2 -1
  51. package/modules/hall-ui/public/modules.html +1 -1
  52. package/modules/hall-ui/public/my-agents.html +1 -1
  53. package/modules/hall-ui/public/primer.html +1 -1
  54. package/modules/hall-ui/public/profile.html +1 -1
  55. package/modules/hall-ui/public/project-settings.html +1 -1
  56. package/modules/hall-ui/public/ranks.html +1 -1
  57. package/modules/hall-ui/public/roadmap.html +1 -1
  58. package/modules/hall-ui/public/roster.html +1 -1
  59. package/modules/hall-ui/public/sessions.html +1 -1
  60. package/modules/hall-ui/public/settings.html +1 -1
  61. package/modules/hall-ui/public/shell.js +2 -3
  62. package/modules/hall-ui/public/street.html +1 -1
  63. package/modules/hall-ui/public/studio.html +9 -1
  64. package/modules/hall-ui/public/style.css +74 -27
  65. package/modules/hall-ui/public/task.html +1 -1
  66. package/modules/hall-ui/public/thinking.html +1 -1
  67. package/modules/hall-ui/public/tweak-editor.html +1 -1
  68. package/modules/hall-ui/public/watch.html +1 -1
  69. package/modules/hall-ui/public/work.html +1 -1
  70. package/modules/hall-ui/records/home-city.md +133 -0
  71. package/modules/hall-ui/records/loader.md +1 -1
  72. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  73. package/modules/hall-ui/records/market-watch.md +3 -1
  74. package/modules/lifecycle/home-city.js +58 -10
  75. package/modules/lifecycle/home-gallery.js +320 -0
  76. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  77. package/modules/lifecycle/module.json +1 -0
  78. package/modules/lifecycle/routes/home-gallery.js +37 -0
  79. package/modules/market-watch/routes/settings.js +1 -1
  80. package/package-lock.json +2 -2
  81. package/package.json +1 -1
  82. package/release-notes.json +35 -1
  83. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  84. package/scripts/hall-preview/README.md +1 -0
  85. package/scripts/hall-preview/server.js +10 -0
  86. package/src/bongos/serve-internal.js +17 -3
  87. package/src/module-api.js +1 -1
  88. package/tests/hall_builders_page_pass.mjs +18 -27
  89. package/tests/hall_loading.mjs +1 -1
  90. package/tests/hall_nav.mjs +11 -4
  91. package/tests/hall_page_gate_map.mjs +7 -4
  92. package/tests/home_city.mjs +200 -5
  93. package/tests/home_city_widget.mjs +187 -27
  94. package/tests/home_gallery.mjs +255 -0
  95. package/tests/home_gallery_widget.mjs +220 -0
  96. package/tests/home_studio.mjs +169 -0
  97. package/tests/home_studio_widget.mjs +157 -0
  98. package/tests/market_watch_pages.mjs +162 -0
  99. package/tests/nav_permission_atoms.mjs +2 -2
  100. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  101. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -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', () => {
@@ -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', () => {
@@ -20,6 +20,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
20
  process.env.NODE_ENV = 'test';
21
21
  const city = require(path.join(ROOT, 'modules/lifecycle/home-city.js'));
22
22
  const Draw = require(path.join(ROOT, 'modules/hall-ui/public/home-city-draw.js'));
23
+ const Gov = require(path.join(ROOT, 'modules/hall-ui/public/city-draw.js'));
23
24
  const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/home__city.json'), 'utf8'));
24
25
 
25
26
  const { test, summary } = makeRunner();
@@ -127,12 +128,12 @@ function fakePool({ tables = {} } = {}) {
127
128
  }
128
129
 
129
130
  await test('every read is bounded by the window, the areas or a LIMIT, and never selects a task body', async () => {
130
- const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true } });
131
+ const pool = fakePool({ tables: { government_board_votes: true, autonomy_runner_heartbeat: true, government_asks: true, ideas_votes: true } });
131
132
  await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'archon' } }, { pool, getFocusVersionId: async () => null });
132
133
  const data = pool.seen.filter((q) => !/to_regclass|date_trunc\('week'|FROM versions/.test(q.sql));
133
134
  assert.ok(data.length >= 8, 'the summary reads areas, weeks, leads, people, ideas, govern, sites and waiting');
134
135
  for (const q of data) {
135
- const bounded = /LIMIT \d+/.test(q.sql) || /goal_id = ANY\(\$1::bigint\[\]\)/.test(q.sql) || /(shipped_at|captured_at|cast_at|resolved_at) >= \$1/.test(q.sql) || /b\.id = ANY/.test(q.sql);
136
+ const bounded = /LIMIT \d+/.test(q.sql) || /goal_id = ANY\(\$1::bigint\[\]\)/.test(q.sql) || /(shipped_at|captured_at|cast_at|resolved_at|recorded_at) >= \$1/.test(q.sql) || /b\.id = ANY/.test(q.sql);
136
137
  assert.ok(bounded, `unbounded read:\n${q.sql}`);
137
138
  assert.doesNotMatch(q.sql, /description|body_md|value_summary/, 'the city never downloads prose it does not draw');
138
139
  }
@@ -320,8 +321,20 @@ await test('the fixture is a shape the drawing accepts, and the tallest areas st
320
321
  assert.equal(m.W, 10);
321
322
  assert.ok(m.areas.length > 5);
322
323
  assert.ok(m.areas[0].total >= m.areas[m.areas.length - 1].total);
323
- assert.equal(m.areas[0].y, 0.8, 'the tallest tower is in the back row');
324
- assert.ok(m.areas.slice(5).every((a) => a.y > 0.8), 'the shorter half stands in front');
324
+ const back = (a) => a.x + a.y;
325
+ assert.deepEqual([m.areas[0].x, m.areas[0].y], Draw.RING[0], 'the tallest tower takes the back corner');
326
+ assert.ok(m.areas.every((a, n) => n === 0 || back(a) >= back(m.areas[n - 1])), 'each shorter area stands no farther back than a taller one');
327
+ });
328
+
329
+ await test('the ring of plots stays off the civic core and the museum, and no two plots overlap (v16)', () => {
330
+ const F = 1.8, C = Draw.CORE, MUSEUM = { x0: 4.6, y0: 10.9, x1: 11.4, y1: 19.2 };
331
+ const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
332
+ const plots = Draw.RING.map(([x, y]) => ({ x0: x, y0: y, x1: x + F, y1: y + F }));
333
+ plots.forEach((p, n) => {
334
+ assert.ok(!hit(p, C), `plot ${n} stands clear of the civic plaza`);
335
+ assert.ok(!hit(p, MUSEUM), `plot ${n} stands clear of the museum and its garden`);
336
+ plots.slice(n + 1).forEach((q) => assert.ok(!hit(p, q), 'plots do not overlap'));
337
+ });
325
338
  });
326
339
 
327
340
  await test('more areas than the ground holds are reported, not drawn', () => {
@@ -370,16 +383,198 @@ await test('ledger text is never markup: titles and names travel as attribute va
370
383
  people: [{ id: '9', name: '"><script>x</script>', build: [1], art: [0], idea: [0], gov: [0] }],
371
384
  ideas: [{ id: '3', week: 0, by: '9', title: '<b>idea</b>' }], now: { sites: [{ goal_id: '1', builder_id: '9' }] } };
372
385
  const ns = all(Draw.citySvg(Draw.cityModel(hostile), 0, { mode: 'light' }).nodes);
373
- const SHAPES = new Set(['defs', 'radialGradient', 'stop', 'path', 'line', 'circle', 'g', 'rect', 'text', 'clipPath', 'image']);
386
+ const SHAPES = new Set(['defs', 'radialGradient', 'stop', 'path', 'line', 'circle', 'ellipse', 'g', 'a', 'rect', 'text', 'clipPath', 'image']);
374
387
  assert.deepEqual([...new Set(ns.map((n) => n.tag))].filter((t) => !SHAPES.has(t)), [], 'only SVG shapes, whatever a title says');
375
388
  assert.ok(ns.some((n) => n.attrs['data-tip'] === '<img src=x onerror=alert(1)>'), 'the title rides as an attribute value, set with setAttribute');
376
389
  assert.ok(ns.some((n) => n.tag === 'text' && n.text === '"'), 'a name\'s initial is a text node');
377
390
  });
378
391
 
392
+ // v16 (task 1005058): one city for every craft, its districts as doors
393
+ const linksOf = (ns) => ns.filter((n) => n.tag === 'a');
394
+ await test('the civic core is drawn by city-draw.js itself, and the whole district is ONE door to the street', () => {
395
+ const m = Draw.cityModel(FIXTURE);
396
+ const ns = all(Draw.citySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
397
+ const core = linksOf(ns).filter((n) => /\bhc-core\b/.test(n.attrs.class));
398
+ assert.equal(core.length, 1, 'one link for the nine buildings, not nine');
399
+ assert.equal(core[0].attrs.href, Gov.STREET_HREF);
400
+ // the same paths city-draw.js's recipe draws for the Charter, in its own palette
401
+ const k = Gov.isoKit(50, 25, 0, 0, 0.82, Gov.isoTheme('dark'), true), charter = [];
402
+ Gov.isoInstitution(charter, k, 'charter', 0, 0, {});
403
+ const fills = new Set(all([core[0]]).map((n) => n.attrs.fill));
404
+ charter.forEach((p) => assert.ok(fills.has(p.f), 'the core wears the Governor City\'s own faces'));
405
+ assert.ok(all([core[0]]).filter((n) => n.tag === 'path').length > 9 * 3, 'all nine buildings, not a placeholder');
406
+ const bare = all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes);
407
+ assert.ok(!linksOf(bare).some((n) => /hc-core/.test(n.attrs.class)), 'without city-draw.js loaded the city still draws, just without its core');
408
+ });
409
+
410
+ await test('on the builder\'s Home the core and the museum are faded; a craft\'s own district stays in colour', () => {
411
+ const m = Draw.cityModel(FIXTURE);
412
+ const cls = (focus, key) => linksOf(all(Draw.citySvg(m, m.W - 1, { mode: 'light', gov: Gov, focus }).nodes)).find((n) => new RegExp(`\\b${key}\\b`).test(n.attrs.class)).attrs.class;
413
+ assert.match(cls(undefined, 'hc-core'), /hc-quiet/);
414
+ assert.match(cls(undefined, 'hc-museum'), /hc-quiet/);
415
+ assert.doesNotMatch(cls('museum', 'hc-museum'), /hc-quiet/);
416
+ assert.doesNotMatch(cls('core', 'hc-core'), /hc-quiet/);
417
+ const css = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/home-city.css'), 'utf8');
418
+ assert.match(css, /\.hc-quiet\s*\{[^}]*opacity:\s*\.3/, 'faded');
419
+ assert.match(css, /\.hc-quiet:hover,\s*\.hc-svg \.hc-quiet:focus-visible\s*\{[^}]*filter:\s*none/, 'back to colour under the pointer or the keyboard');
420
+ });
421
+
422
+ await test('a building opens its goal; the museum and the band of stars are doors to the artist\'s and the ideator\'s Home', () => {
423
+ const m = Draw.cityModel(FIXTURE);
424
+ const ls = linksOf(all(Draw.citySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes));
425
+ const towers = ls.filter((n) => n.attrs.class === 'hc-tower');
426
+ assert.equal(towers.length, m.areas.length);
427
+ towers.forEach((n) => assert.match(n.attrs.href, /^\/goals#\/goal\/\d+$/));
428
+ assert.ok(m.areas.every((a) => towers.some((n) => n.attrs.href === `/goals#/goal/${a.id}`)), 'each area links to its own goal');
429
+ assert.equal(ls.find((n) => /hc-museum/.test(n.attrs.class)).attrs['data-craft-door'], 'artistry');
430
+ const sky = ls.find((n) => /hc-sky/.test(n.attrs.class));
431
+ assert.equal(sky.attrs['data-craft-door'], 'ideating');
432
+ assert.ok(ls.every((n) => n.attrs['aria-label']), 'every door is named for a screen reader');
433
+ const stars = all([sky]).filter((n) => n.tag === 'circle');
434
+ const box = Draw.viewBoxOf(m);
435
+ assert.ok(stars.length > 0 && stars.every((c) => c.attrs.cy < box.OY - 150), 'the stars sit high above the city, apart from it');
436
+ });
437
+
438
+ await test('a week part-way through grows each tower part-way: the slider glides, it does not jump', () => {
439
+ const m = Draw.cityModel(FIXTURE);
440
+ const a = m.areas.find((x) => x.build[4] + x.art[4] > 0);
441
+ const at = (t) => { const d = Draw.doneAt(a, t); return d.build + d.art; };
442
+ assert.ok(at(3) < at(3.5) && at(3.5) < at(4), 'half a week in is half of that week\'s work');
443
+ assert.equal(at(3.5), (at(3) + at(4)) / 2);
444
+ });
445
+
379
446
  await test('both modes draw with their own faces', () => {
380
447
  const m = Draw.cityModel(FIXTURE);
381
448
  assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'dark' }).nodes).some((n) => n.attrs.fill === '#34343a'));
382
449
  assert.ok(all(Draw.citySvg(m, m.W - 1, { mode: 'light' }).nodes).some((n) => n.attrs.fill === '#f6f5f8'));
383
450
  });
384
451
 
452
+ // ---------------------------------------------------------------------------
453
+ // the ideator's sky (task 1005059)
454
+ // ---------------------------------------------------------------------------
455
+
456
+ await test('an idea carries its area, the week it became work, its votes, and whether it waits on the viewer', () => {
457
+ const idea = (o) => Object.assign({ id: '1', title: 'T', by: '2', wk: 3, goal_id: null, aim_id: null, became_wk: null, votes: 0, voted: false, status: 'open' }, o);
458
+ const out = city.shapeCity(raw({
459
+ ideas: [
460
+ idea({ id: 'work', goal_id: '10', aim_id: '20', became_wk: 9, votes: 4 }), // became work: its task's goal wins over the aim; the week is clamped to the window
461
+ idea({ id: 'aim', aim_id: '20', wk: 2 }), // aimed: placed in that area, not yet work
462
+ idea({ id: 'far', aim_id: '99' }), // aimed outside this version: not placed
463
+ idea({ id: 'early', goal_id: '10', became_wk: 1, wk: 2 }), // a task made before the filing reads as the filing week
464
+ idea({ id: 'mine', by: '1' }), idea({ id: 'voted', voted: true }), idea({ id: 'old', wk: 1 }), idea({ id: 'shut', status: 'promoted' }),
465
+ ],
466
+ ideaVotes: [{ id: '1', wk: 2, n: 3 }, { id: '77', wk: 2, n: 9 }],
467
+ taskTotal: 50,
468
+ }), { id: '1', rank: 'metic' });
469
+ const by = Object.fromEntries(out.ideas.map((x) => [x.id, x]));
470
+ assert.deepEqual([by.work.area_id, by.work.goal_id, by.work.became, by.work.votes], ['10', '10', 3, 4]);
471
+ assert.deepEqual([by.aim.area_id, by.aim.became], ['20', null]);
472
+ assert.equal(by.far.area_id, null);
473
+ assert.equal(by.early.became, 2);
474
+ assert.deepEqual(out.ideas.filter((x) => x.waits).map((x) => x.id), ['far'], 'new, open, in no area, not mine, not voted on — and only these two weeks');
475
+ assert.deepEqual(out.people.find((p) => p.id === '1').vote, [0, 0, 3, 0], 'idea votes per week, known builders only');
476
+ assert.equal(out.version.tasks, 50);
477
+ assert.ok(city.shapeCity(raw(), null).ideas.every((x) => !x.waits), 'nothing waits on a signed-out viewer');
478
+ });
479
+
480
+ await test('the votes are read only where the ideas module keeps them; the viewer id rides only with that read', async () => {
481
+ const off = fakePool();
482
+ await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: off, getFocusVersionId: async () => null });
483
+ const q = off.seen.find((x) => /FROM idea_inbox i\s+LEFT JOIN/.test(x.sql));
484
+ assert.doesNotMatch(q.sql, /ideas_votes/);
485
+ assert.equal(q.params.length, 1);
486
+ const on = fakePool({ tables: { ideas_votes: true } });
487
+ await city.readCity({ weeks: 10, viewer: { id: '1', rank: 'metic' } }, { pool: on, getFocusVersionId: async () => null });
488
+ const q2 = on.seen.find((x) => /FROM idea_inbox i\s+LEFT JOIN/.test(x.sql));
489
+ assert.match(q2.sql, /LEFT JOIN LATERAL \(SELECT[\s\S]*?FROM ideas_votes v WHERE v\.target_idea_id = i\.id\) iv ON true/); assert.equal(q2.sql.split('FROM ideas_votes').length - 1, 1, 'one look-up per idea, not two'); assert.deepEqual(q2.params.slice(1), ['1']);
490
+ assert.ok(/LIMIT \d+/.test(q2.sql), 'still the bounded newest-first list');
491
+ });
492
+
493
+ await test('the skyline IS the city: every building at its own screen x and height, seen from the street', () => {
494
+ const m = Draw.cityModel(FIXTURE);
495
+ const t = m.W - 1;
496
+ const sil = Draw.skylineOf(m, t, Gov);
497
+ const OX = Draw.viewBoxOf(m).OX, sx = (x, y) => OX + (x - y) * 25;
498
+ m.areas.forEach((a) => {
499
+ const s = sil.find((x) => x.k === 'area' && x.id === a.id);
500
+ const d = Draw.doneAt(a, t);
501
+ assert.equal(s.l, sx(a.x, a.y + 1.8)); assert.equal(s.r, sx(a.x + 1.8, a.y));
502
+ assert.equal(s.h, 4 + Math.max(2, (d.build + d.art) * m.px), 'as tall as its tower in the city');
503
+ });
504
+ // the tower's own drawing agrees: its body's left and right faces span exactly the silhouette's edges
505
+ const I = Draw.theme('dark');
506
+ const xs = (n) => n.attrs.d.slice(1, -1).split('L').map((p) => Number(p.split(' ')[0]));
507
+ const towers = Draw.citySvg(m, t, { mode: 'dark', gov: Gov }).nodes.filter((n) => n.attrs.class === 'hc-tower');
508
+ assert.equal(towers.length, sil.filter((s) => s.k === 'area').length);
509
+ towers.forEach((tw) => {
510
+ const s = sil.find((x) => tw.attrs.href === `/goals#/goal/${x.id}`);
511
+ const left = tw.kids.find((k) => k.attrs.fill === I.work.l), right = tw.kids.find((k) => k.attrs.fill === I.work.r);
512
+ assert.ok(Math.abs(Math.min(...xs(left)) - s.l) <= 0.1 && Math.abs(Math.max(...xs(right)) - s.r) <= 0.1, `${s.id} stands where its tower does`);
513
+ });
514
+ const civic = sil.filter((s) => s.k === 'civic');
515
+ assert.equal(civic.length, Gov.CIVIC_SLOTS.length);
516
+ civic.forEach((s) => assert.equal(s.h, Gov.ROOF[s.id] * 0.82, 'a civic building at the core\'s own scale'));
517
+ assert.equal(sil.filter((s) => s.k === 'museum').length, 2, 'the museum block and its atrium');
518
+ for (let n = 1; n < sil.length; n++) assert.ok(sil[n - 1].depth <= sil[n].depth, 'back to front');
519
+ // it grows with the slider
520
+ const tallest = (u) => Math.max(...Draw.skylineOf(m, u, Gov).filter((s) => s.k === 'area').map((s) => s.h));
521
+ assert.ok(tallest(0) < tallest(t));
522
+ });
523
+
524
+ await test('outlines never show through a nearer building, and the three doors lie on top', () => {
525
+ const m = Draw.cityModel(FIXTURE);
526
+ const ns = all(Draw.skySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
527
+ const sky = ns.find((n) => n.attrs.class === 'hc-skyline').kids;
528
+ // each silhouette's outline comes straight after its own fill, so every LATER (nearer) fill covers it
529
+ const fills = sky.filter((n) => n.attrs.class === 'hc-sil');
530
+ assert.equal(fills.length, Draw.skylineOf(m, m.W - 1, Gov).length);
531
+ sky.forEach((n, i) => { if (/hc-ol\b/.test(n.attrs.class || '')) assert.equal(sky[i - 1].attrs.d, n.attrs.d); });
532
+ const hits = ns.filter((n) => n.tag === 'a' && n.attrs.class === 'hc-hit');
533
+ assert.equal(hits.length, fills.length, 'one hit per silhouette, back to front, so the nearest takes the pointer');
534
+ const order = ns.indexOf(ns.find((n) => n.attrs.class === 'hc-hits'));
535
+ assert.ok(order > ns.indexOf(ns.find((n) => n.attrs.class === 'hc-skyline')), 'the hit layer is drawn over the skyline');
536
+ const group = (k) => hits.filter((h) => h.attrs['data-group'] === k);
537
+ assert.ok(group('area').every((h) => h.attrs.href === '/goals'), 'Building opens the goals');
538
+ assert.ok(group('civic').every((h) => h.attrs.href === Gov.STREET_HREF), 'Government opens the street');
539
+ assert.ok(group('museum').every((h) => h.attrs['data-craft-door'] === 'artistry'), 'Art switches to Artistry');
540
+ ['area', 'civic', 'museum'].forEach((k) => {
541
+ const g = group(k);
542
+ assert.ok(g.length > 0);
543
+ assert.equal(g.filter((h) => h.attrs.tabindex !== '-1').length, 1, 'one keyboard stop per group');
544
+ assert.ok(g[0].attrs['aria-label']);
545
+ g.forEach((h) => { const y = Math.min(...h.kids[0].attrs.d.match(/V(-?[\d.]+)/g).map((v) => Number(v.slice(1)))); assert.ok(Draw.SKY.h - y >= 30, 'a low building still offers a band to point at'); });
546
+ });
547
+ });
548
+
549
+ await test('the sky: constellations from real areas, a field for the unplaced, rings only at Live, work falls into its tower', () => {
550
+ const m = Draw.cityModel(FIXTURE);
551
+ const ns = all(Draw.skySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
552
+ const consts = ns.filter((n) => n.attrs.class === 'hc-const');
553
+ const { groups } = Draw.skyPlaces(m);
554
+ assert.equal(consts.length, groups.length);
555
+ assert.ok(groups.every((g) => m.areas.includes(g.area)), 'a constellation per working area of this version');
556
+ assert.ok(ns.some((n) => n.attrs.class === 'hc-loose'));
557
+ const rings = (u) => all(Draw.skySvg(m, u, { mode: 'dark', gov: Gov }).nodes).filter((n) => n.attrs.class === 'hc-ring').length;
558
+ assert.equal(rings(m.W - 1), m.ideas.filter((x) => x.waits).length);
559
+ assert.equal(rings(m.W - 2), 0, 'what waits on you is now, not history');
560
+ const falls = ns.filter((n) => n.attrs.class === 'hc-fall').length;
561
+ assert.equal(falls, m.ideas.filter((x) => x.became === m.W - 1 && m.areas.some((a) => a.id === x.goal)).length);
562
+ const stars = ns.filter((n) => n.attrs.class === 'hc-star');
563
+ assert.equal(stars.length, m.ideas.filter((x) => !(x.became != null && x.became < m.W - 1)).length, 'an idea that became work earlier lit its window instead');
564
+ assert.ok(stars.every((s) => s.kids.some((k) => k.attrs.r >= 12)), 'each star offers a 24-unit target for its tip');
565
+ const built = Draw.versionBuilt(m, m.W - 1);
566
+ assert.ok(built > 0 && built < 1);
567
+ assert.ok(Draw.versionBuilt(m, 0) < built, 'the moon waxes with the slider');
568
+ assert.equal(Draw.versionBuilt(Object.assign({}, m, { version: null }), 1), null);
569
+ });
570
+
571
+ await test('the ideator\'s ladder ranks ideas, votes and work; the builder\'s ladder is unchanged', () => {
572
+ const m = Draw.cityModel(FIXTURE);
573
+ const i = m.W - 1;
574
+ const rows = Draw.ladderFor(m, i, FIXTURE.viewer.id, 'ideating');
575
+ rows.forEach((r) => assert.deepEqual(Object.keys(r.v), ['idea', 'vote', 'work']));
576
+ rows.forEach((r) => assert.equal(r.v.work, m.ideas.filter((x) => x.by === r.id && x.became === i).length));
577
+ Draw.ladderFor(m, i, FIXTURE.viewer.id).forEach((r) => assert.deepEqual(Object.keys(r.v), ['art', 'build', 'idea', 'gov']));
578
+ });
579
+
385
580
  summary();