@bongos/core 1.21.42 → 1.21.44

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 (68) hide show
  1. package/.bongos-core.json +72 -72
  2. package/docs/copy-inventory.md +21 -22
  3. package/docs/copy-registry.json +24 -33
  4. package/docs/design/craft-mode-direction.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +5 -2
  7. package/docs/page-readings.json +3459 -3536
  8. package/modules/hall-ui/public/atlas.html +3 -3
  9. package/modules/hall-ui/public/blockers.html +3 -3
  10. package/modules/hall-ui/public/board-room.html +3 -3
  11. package/modules/hall-ui/public/city-draw.js +51 -13
  12. package/modules/hall-ui/public/city.css +9 -2
  13. package/modules/hall-ui/public/city.html +6 -6
  14. package/modules/hall-ui/public/city.js +23 -12
  15. package/modules/hall-ui/public/city.states.json +146 -12
  16. package/modules/hall-ui/public/collab.html +3 -3
  17. package/modules/hall-ui/public/commands.html +3 -3
  18. package/modules/hall-ui/public/copy-desk.html +3 -3
  19. package/modules/hall-ui/public/craft-mode.js +10 -15
  20. package/modules/hall-ui/public/deploy.html +3 -3
  21. package/modules/hall-ui/public/diagrams.html +3 -3
  22. package/modules/hall-ui/public/drachmae.html +3 -3
  23. package/modules/hall-ui/public/fleet.html +3 -3
  24. package/modules/hall-ui/public/gate.html +3 -3
  25. package/modules/hall-ui/public/goal-map.html +3 -3
  26. package/modules/hall-ui/public/goals.html +3 -3
  27. package/modules/hall-ui/public/government.html +3 -3
  28. package/modules/hall-ui/public/idea.html +3 -3
  29. package/modules/hall-ui/public/ideas.html +3 -3
  30. package/modules/hall-ui/public/index.html +3 -3
  31. package/modules/hall-ui/public/modules.html +3 -3
  32. package/modules/hall-ui/public/primer.html +3 -3
  33. package/modules/hall-ui/public/profile.html +3 -3
  34. package/modules/hall-ui/public/project-settings.html +3 -3
  35. package/modules/hall-ui/public/ranks.html +3 -3
  36. package/modules/hall-ui/public/roadmap.html +3 -3
  37. package/modules/hall-ui/public/roster.html +3 -3
  38. package/modules/hall-ui/public/sessions.html +3 -3
  39. package/modules/hall-ui/public/settings.html +3 -3
  40. package/modules/hall-ui/public/street-card.css +6 -0
  41. package/modules/hall-ui/public/street-card.js +31 -3
  42. package/modules/hall-ui/public/street-draw.js +96 -19
  43. package/modules/hall-ui/public/street.css +23 -7
  44. package/modules/hall-ui/public/street.html +11 -10
  45. package/modules/hall-ui/public/street.js +45 -13
  46. package/modules/hall-ui/public/street.states.json +68 -0
  47. package/modules/hall-ui/public/studio.html +3 -3
  48. package/modules/hall-ui/public/style.css +4 -22
  49. package/modules/hall-ui/public/task.html +3 -3
  50. package/modules/hall-ui/public/thinking.html +3 -3
  51. package/modules/hall-ui/public/tweak-editor.html +3 -3
  52. package/modules/hall-ui/public/watch.html +3 -3
  53. package/modules/hall-ui/public/work.html +3 -3
  54. package/modules/hall-ui/records/craft-mode.md +5 -5
  55. package/modules/hall-ui/records/governor-street.md +1 -0
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +12 -0
  59. package/scripts/hall-preview/server.js +5 -2
  60. package/src/module-api.js +1 -1
  61. package/tests/craft_mode.mjs +27 -10
  62. package/tests/craft_mode_looks.mjs +5 -5
  63. package/tests/craft_mode_nav.mjs +7 -7
  64. package/tests/hall_city.mjs +42 -11
  65. package/tests/hall_matter_card.mjs +17 -0
  66. package/tests/hall_street.mjs +185 -12
  67. package/tests/hall_tweak_editor.mjs +1 -1
  68. package/tests/page_notes_panel.mjs +1 -1
@@ -1,8 +1,8 @@
1
1
  // tests/craft_mode_nav.mjs — the hall nav follows the craft mode (task 1004493,
2
2
  // goal 1000095 WA6).
3
3
  //
4
- // Each craft mode shows only the tabs its craft needs; Legacy mode and no mode
5
- // show every tab, exactly as before. The owner's rule (2026-10-01): some tabs
4
+ // Each craft mode shows only the tabs its craft needs; no mode
5
+ // shows every tab, exactly as before. The owner's rule (2026-10-01): some tabs
6
6
  // stay in EVERY mode, Builders first among them, written down as ONE shared list
7
7
  // that each mode's list adds to; on 2026-10-03 Collab and Profile joined it. These cases boot the REAL shell-trail.js +
8
8
  // craft-mode.js + shell.js and read the sidebar the shell actually rendered, so a
@@ -157,15 +157,15 @@ test('Government shows only in Governing, and Governing is the shortest list', (
157
157
  assert.equal(Math.min(...lengths), lengths[CRAFTS.indexOf('governing')], 'outside its own Government group, Governing shows the fewest tabs');
158
158
  });
159
159
 
160
- test('NO MODE AND LEGACY ARE TODAY\'S NAV: every tab the gate allows, nothing marked', () => {
160
+ test('NO MODE IS TODAY\'S NAV: every tab the gate allows, nothing marked — and a stored Legacy is no mode', () => {
161
161
  const none = boot();
162
162
  none.signIn();
163
163
  const all = none.showing();
164
164
  assert.deepEqual(all, NAV_IDS, 'an archon with no mode sees every tab');
165
165
  const legacy = boot({ session: { 'hall-craft': 'legacy' } });
166
166
  legacy.signIn();
167
- assert.deepEqual(legacy.showing(), all, 'Legacy mode shows exactly the same');
168
- assert.equal(legacy.craft.tabsFor('legacy'), null);
167
+ assert.equal(legacy.root.dataset.craft, undefined, 'Legacy was removed (task 1004567), so it reads as no mode');
168
+ assert.deepEqual(legacy.showing(), all, 'and shows exactly the same');
169
169
  });
170
170
 
171
171
  test('each mode shows its own tabs, and switching updates the nav live', () => {
@@ -178,8 +178,8 @@ test('each mode shows its own tabs, and switching updates the nav live', () => {
178
178
  for (const id of ['roster', 'collab', 'profile']) assert.ok(p.showing().includes(id), `${id} stays in ${m}`);
179
179
  assert.equal(p.headShowing('government'), m === 'governing', `the Government head shows only where its tabs do (${m})`);
180
180
  }
181
- p.craft.choose('legacy');
182
- assert.deepEqual(p.showing(), NAV_IDS, 'back to Legacy, every tab returns without a reload');
181
+ p.craft.choose('building');
182
+ assert.deepEqual(new Set(p.showing()), new Set(p.craft.tabsFor('building')), 'back to Building, its tabs return without a reload');
183
183
  });
184
184
 
185
185
  test('a mode never shows a tab the gate hid, and a later gate pass keeps the mode', () => {
@@ -142,12 +142,38 @@ test('the line is the docket\'s own line, word for word', () => {
142
142
  assert.equal(m.line, '1 matter waits on you');
143
143
  });
144
144
 
145
- test('at most three pins stand over one building, needs first as the docket ordered them', () => {
146
- const ms = ['needs', 'needs', 'moving', 'moving', 'settled'].map((state, i) => matter({ id: `blockers:${i}`, state }));
147
- const m = City.cityModel({ docket: docket(ms), mode: 'dark' });
148
- assert.equal(m.pins.length, City.MAX_PINS_PER_BUILDING);
149
- assert.deepEqual(m.pins.map((p) => p.state), ['needs', 'needs', 'moving']);
150
- assert.equal(new Set(m.pins.map((p) => p.x)).size, 3, 'abreast, not stacked on one spot');
145
+ // task 1004569 (owner, 2026-10-04): deliberate change from the prototype's
146
+ // drawing — ONE pin per building, in its most important matter's tier, the
147
+ // same tiers as the street (GovCity.buildingPin is the one rule both use).
148
+ test('one pin per building, in the tier of its most important matter, with the count of its open matters', () => {
149
+ const NOW = Date.parse('2026-10-02T12:00:00.000Z');
150
+ const ms = ['moving', 'needs', 'moving', 'settled'].map((state, i) => matter({ id: `blockers:${i}`, state }));
151
+ ms.push(matter({ id: 'blockers:9', state: 'needs', deadline: '2026-10-03T06:00:00.000Z' }));
152
+ const m = City.cityModel({ docket: docket(ms), mode: 'dark', now: NOW });
153
+ assert.equal(m.pins.length, 1, 'one pin, never more');
154
+ assert.deepEqual([m.pins[0].key, m.pins[0].state, m.pins[0].count], ['blockers:9', 'urgent', 4], 'urgent (needs you, due within a day); four open');
155
+ assert.deepEqual(m.pins[0].matters, ['blockers:9', 'blockers:1', 'blockers:0', 'blockers:2', 'blockers:3']);
156
+ const k = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme('dark'), false);
157
+ const top = k.P(6, 6, City.ROOF.planning + 8);
158
+ assert.equal(m.pins[0].x, Math.round((top[0] - 15) * 10) / 10, 'where the prototype stands a single pin');
159
+ const tier = (list) => City.cityModel({ docket: docket(list), mode: 'dark', now: NOW }).pins[0].state;
160
+ assert.equal(tier([matter({ state: 'needs', deadline: '2026-10-05T12:00:00.000Z' })]), 'needs', 'due after a day');
161
+ assert.equal(tier([matter({ state: 'settled' }), matter({ id: 'b:2', state: 'moving' })]), 'moving');
162
+ const settled = City.cityModel({ docket: docket([matter({ state: 'settled' })]), mode: 'dark', now: NOW }).pins[0];
163
+ assert.deepEqual([settled.state, settled.count], ['settled', 0], 'settled: a dot, no count');
164
+ });
165
+
166
+ test('the city and the street agree: the same docket gives each building the same tier and the same top matter', () => {
167
+ const S = require(path.join(PUB, 'street-draw.js'));
168
+ const NOW = Date.parse('2026-10-02T12:00:00.000Z');
169
+ const ms = [
170
+ matter({ id: 'board:1', building: 'charter', state: 'needs', deadline: '2026-10-02T20:00:00.000Z' }), matter({ id: 'board:2', building: 'charter', state: 'moving' }),
171
+ matter({ id: 'blockers:3', state: 'moving' }), matter({ id: 'blockers:4', state: 'needs' }),
172
+ matter({ id: 'override_requests:5', building: 'release', state: 'settled' }),
173
+ ];
174
+ const city = City.cityModel({ docket: docket(ms), mode: 'dark', now: NOW }).pins.map((p) => [p.building, p.state, p.key, p.matters.join(' ')]).sort();
175
+ const street = S.streetModel({ docket: docket(ms), goals: [], rollups: [], mode: 'dark', now: NOW }).pins.map((p) => [p.building, p.state, p.key, p.matters.join(' ')]).sort();
176
+ assert.deepEqual(city, street);
151
177
  });
152
178
 
153
179
  test('a matter at a building the city does not have is not drawn, and an unknown state is never "needs"', () => {
@@ -346,16 +372,21 @@ const FEEDS = (ms, goals = [goal(1)], rollups = [rollup(1, { done: 2, active: 1
346
372
  });
347
373
 
348
374
  test('the page reads the three feeds and draws the docket\'s pins and line', async () => {
349
- const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs' }), matter({ id: 'blockers:2', state: 'needs' }), matter({ id: 'gate_approvals:3', building: 'release', state: 'moving' })];
375
+ const soon = new Date(Date.now() + 2 * 3600000).toISOString();
376
+ const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs', deadline: soon }), matter({ id: 'blockers:2', state: 'needs' }), matter({ id: 'blockers:4', state: 'moving' }), matter({ id: 'gate_approvals:3', building: 'release', state: 'moving' }), matter({ id: 'removal_proposals:5', building: 'disputes', state: 'settled' })];
350
377
  const p = await bootPage({ feeds: FEEDS(ms) });
351
378
  assert.deepEqual([...p.calls].sort(), ['/api/bongos/goals', '/api/bongos/goals/rollups', '/api/bongos/government/docket']);
352
379
  const pins = p.byId.get('cityPins').children;
353
- assert.equal(pins.length, 3);
354
- assert.deepEqual(pins.map((s) => s.className).sort(), ['city-pin city-pin--moving', 'city-pin city-pin--needs', 'city-pin city-pin--needs']);
355
- const needs = pins.find((s) => s.attrs['data-matter'] === 'board:1');
356
- assert.equal(needs.children[0].children[0].className, 'city-pin__ring', 'a needs pin pulses');
380
+ assert.equal(pins.length, 4, 'one per building');
381
+ assert.deepEqual(pins.map((s) => s.className).sort(), ['city-pin city-pin--moving', 'city-pin city-pin--needs', 'city-pin city-pin--settled', 'city-pin city-pin--urgent']);
382
+ const urgent = pins.find((s) => s.attrs['data-matter'] === 'board:1');
383
+ assert.equal(urgent.children[0].children[0].className, 'city-pin__ring', 'the urgent pin pulses');
384
+ const needs = pins.find((s) => s.className.includes('needs'));
385
+ assert.ok(!needs.children[0].children.some((c) => c.className === 'city-pin__ring'), 'a needs-you pin is solid orange, no ring');
386
+ assert.equal(needs.children[0].children.find((c) => c.className === 'city-pin__count').textContent, '2', 'it counts its building\'s two open matters');
357
387
  const moving = pins.find((s) => s.className.includes('moving'));
358
388
  assert.ok(!moving.children[0].children.some((c) => c.className === 'city-pin__ring'), 'a moving pin only bobs');
389
+ assert.equal(pins.find((s) => s.className.includes('settled')).children[0].children.length, 0, 'a settled pin is a plain dot');
359
390
  assert.equal(p.byId.get('cityLine').textContent, '2 matters wait on you · 1 moving');
360
391
  assert.equal(p.byId.get('cityStart').attrs.href, '/builders/street');
361
392
  assert.ok(p.byId.get('cityBlds').children.length > 9, 'the civic quarter and the district are drawn');
@@ -590,3 +590,20 @@ test('the Board Room labels a building\'s matter as that building\'s matter, and
590
590
  assert.equal(subjectLine({ subject_type: 'constitutional_amendment', subject: { kind: 'decision_rules' } }), 'Who decides what [amendment]');
591
591
  assert.equal(subjectLine({ subject_type: 'constitutional_amendment', subject: { proposed: {} } }), 'Constitutional amendment [amendment]');
592
592
  });
593
+
594
+ // task 1004569: a street pin stands for every matter at its building; the card
595
+ // pages through them so each is reachable without leaving the street.
596
+ test('the card pager: "i of N" over the pin\'s matters, previous and next wrapping; none for one matter', () => {
597
+ assert.equal(C.pager(['a'], 'a'), null, 'one matter, no pager');
598
+ assert.equal(C.pager(null, 'a'), null);
599
+ assert.equal(C.pager(['a', 'b'], 'zz'), null, 'a key the pin does not hold');
600
+ assert.deepEqual(C.pager(['a', 'b', 'c'], 'a'), { i: 0, n: 3, words: '1 of 3', prev: 'c', next: 'b' });
601
+ assert.deepEqual(C.pager(['a', 'b', 'c'], 'c'), { i: 2, n: 3, words: '3 of 3', prev: 'b', next: 'a' });
602
+ // walking next from the first visits every key exactly once
603
+ const keys = ['k1', 'k2', 'k3', 'k4', 'k5'];
604
+ const seen = [];
605
+ let k = keys[0];
606
+ for (let i = 0; i < keys.length; i++) { seen.push(k); k = C.pager(keys, k).next; }
607
+ assert.deepEqual(seen, keys);
608
+ assert.equal(k, keys[0]);
609
+ });
@@ -32,6 +32,7 @@ const PUB = path.join(ROOT, 'modules/hall-ui/public');
32
32
  const require = createRequire(import.meta.url);
33
33
  const City = require(path.join(PUB, 'city-draw.js'));
34
34
  const S = require(path.join(PUB, 'street-draw.js'));
35
+ require(path.join(PUB, 'street-desk.js')); // hangs GovDesk (the line rule's fit) on the global the street reads
35
36
  const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
36
37
  const NOW = Date.parse('2026-10-02T12:00:00.000Z'); // a Friday
37
38
 
@@ -185,17 +186,118 @@ test('every civic door is the prototype\'s door, and every door opens where its
185
186
 
186
187
  // ── what the street says comes from the docket, and only from it ───────────
187
188
 
188
- test('a pin per open matter at its building, in its state, up to three stacked, needs first as the docket ordered them', () => {
189
- const ms = ['needs', 'needs', 'moving', 'settled'].map((state, i) => matter({ id: `blockers:${i}`, state }));
189
+ // task 1004569 (owner, 2026-10-04): the pins stacked on top of each other and
190
+ // their text overlapped. Deliberate change from the prototype's drawing: ONE
191
+ // pin per building, in the tier of its most important matter.
192
+ test('one pin per building, never more: it carries the count of the building\'s open matters and every matter, most important first', () => {
193
+ const ms = [
194
+ matter({ id: 'blockers:0', state: 'moving' }), matter({ id: 'blockers:1', state: 'needs' }),
195
+ matter({ id: 'blockers:2', state: 'settled' }), matter({ id: 'blockers:3', state: 'needs', deadline: '2026-10-03T06:00:00.000Z' }),
196
+ matter({ id: 'board:9', building: 'charter', state: 'moving' }),
197
+ ];
190
198
  const m = model({ docket: docket(ms) });
191
199
  const here = m.pins.filter((p) => p.building === 'planning');
192
- assert.equal(here.length, S.MAX_PINS_PER_BUILDING);
193
- assert.deepEqual(here.map((p) => p.state), ['needs', 'needs', 'moving']);
194
- assert.equal(new Set(here.map((p) => p.y)).size, 3, 'stacked, one label above the other');
195
- assert.ok(here.every((p) => p.x === here[0].x));
200
+ assert.equal(here.length, 1, 'one pin, not one per matter');
201
+ assert.equal(m.pins.length, 2, 'one per building that has a matter');
202
+ assert.equal(here[0].count, '3', 'three open (the settled one is not open)');
203
+ assert.deepEqual(here[0].matters, ['blockers:3', 'blockers:1', 'blockers:0', 'blockers:2'], 'urgent, needs, moving, settled: the pager\'s order');
204
+ assert.equal(here[0].key, 'blockers:3', 'the pin opens on its most important matter');
205
+ assert.equal(here[0].state, 'urgent');
206
+ assert.match(here[0].aria, /^3 open at planning, the first needs you within a day/);
196
207
  assert.equal(m.cols.find((c) => c.key === 'planning').state, '0 goals open', 'Planning says how many goals are open');
197
208
  });
198
209
 
210
+ test('a building\'s pin takes the tier of its most important matter: urgent is needs-you due within 24 hours', () => {
211
+ const tierOf = (list) => model({ docket: docket(list) }).pins.find((p) => p.building === 'planning').state;
212
+ const at = (h) => new Date(NOW + h * 3600000).toISOString();
213
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'needs', deadline: at(23) })]), 'urgent', 'due inside a day');
214
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'needs', deadline: at(-5) })]), 'urgent', 'overdue');
215
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'needs', deadline: at(25) })]), 'needs', 'due after a day');
216
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'moving', deadline: at(1) })]), 'moving', 'a deadline never makes moving work urgent');
217
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'moving' }), matter({ id: 'a:2', state: 'needs' })]), 'needs', 'needs beats moving, wherever it stands in the docket');
218
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'settled' }), matter({ id: 'a:2', state: 'moving' })]), 'moving');
219
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'settled' })]), 'settled');
220
+ assert.equal(tierOf([matter({ id: 'a:1', state: 'weird' })]), 'moving', 'an unknown state is never needs');
221
+ });
222
+
223
+ test('labels: urgent and needs-you show one short label cut by the line rule; moving waits for a hover; settled is a dot with no label and no count', () => {
224
+ const Desk = require(path.join(PUB, 'street-desk.js'));
225
+ const ms = [
226
+ matter({ id: 'board:1', building: 'charter', state: 'needs', short: 'a very long matter name that runs on', deadline: '2026-10-06T12:00:00.000Z' }),
227
+ matter({ id: 'blockers:2', building: 'automation', state: 'moving', short: 'moving work' }),
228
+ matter({ id: 'removal_proposals:3', building: 'treasury', state: 'settled', short: 'settled thing' }),
229
+ ];
230
+ const m = model({ docket: docket(ms), goals: [1, 2, 3, 4].map((i) => goal(i)) });
231
+ const by = Object.fromEntries(m.pins.map((p) => [p.building, p]));
232
+ assert.equal(by.charter.label, Desk.fit('a very long matter name that runs on', S.PIN_LABEL - 6) + ' · tue', 'fit() cuts the name, the deadline survives');
233
+ assert.ok(by.charter.label.length <= S.PIN_LABEL);
234
+ assert.equal(by.charter.shown, true);
235
+ assert.deepEqual([by.automation.label, by.automation.shown], ['moving work', false], 'moving: the label is there for hover and focus, not shown');
236
+ assert.deepEqual([by.treasury.label, by.treasury.count, by.treasury.shown], ['', '', false], 'settled: no label, no count');
237
+ });
238
+
239
+ // A dense docket: every civic building holds several matters in every tier,
240
+ // with long names and deadlines, and work waits at Review.
241
+ function denseDocket() {
242
+ const keys = ['charter', 'rules', 'disputes', 'people', 'planning', 'automation', 'release', 'treasury'];
243
+ const ms = [];
244
+ keys.forEach((b, i) => {
245
+ const day = (h) => new Date(NOW + h * 3600000).toISOString();
246
+ ms.push(matter({ id: `dense:${b}:1`, building: b, state: i % 3 === 2 ? 'moving' : 'needs', short: `${b} needs a long careful decision`, deadline: i % 2 ? day(6) : day(30 + i * 24) }));
247
+ ms.push(matter({ id: `dense:${b}:2`, building: b, state: 'needs', short: `second ${b} matter` }));
248
+ ms.push(matter({ id: `dense:${b}:3`, building: b, state: 'moving', short: `moving at ${b}` }));
249
+ ms.push(matter({ id: `dense:${b}:4`, building: b, state: 'settled', short: `settled at ${b}` }));
250
+ });
251
+ [1, 2, 3, 4].forEach((i) => ms.push(matter({ id: `criteria_uat:${i}`, source: 'criteria_uat', building: 'review', state: 'needs', title: `Criterion ${i}` })));
252
+ return docket(ms);
253
+ }
254
+ const meets = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
255
+ function overlaps(boxes) {
256
+ const hits = [];
257
+ boxes.forEach((a, i) => boxes.slice(i + 1).forEach((b) => { if (meets(a, b)) hits.push([a.key, b.key]); }));
258
+ return hits;
259
+ }
260
+
261
+ test('no overlap, ever: on a dense docket the collision pass lifts or quiets labels until no two shown labels meet, both modes', () => {
262
+ for (const mode of ['dark', 'light']) {
263
+ const m = model({ mode, docket: denseDocket(), goals: [1, 2, 3, 4].map((i) => goal(i)) });
264
+ const shown = S.shownLabels(m.pins);
265
+ assert.deepEqual(overlaps(shown), [], `${mode}: no two shown labels meet`);
266
+ assert.ok(shown.length >= 7, 'the labels that need you show');
267
+ assert.equal(new Set(m.pins.map((p) => p.building)).size, m.pins.length, 'one pin per building');
268
+ // The street's roofs stagger the pins' heights, so its own row rarely
269
+ // collides. The worst case is every pin at one height (a future roof, a
270
+ // new building): the pass must still keep every shown label clear, lifting
271
+ // some and quieting the lowest tier, never a higher one over a lower one.
272
+ const flat = m.pins.map((p) => ({ ...p, y: 300, h: 78, shown: false, lifted: false }));
273
+ const naive = flat.filter((p) => ['urgent', 'needs', 'queue'].includes(p.rank)).map((p) => ({ key: p.key, ...S.labelBox(p) }));
274
+ assert.ok(overlaps(naive).length > 0, 'at one height, without the pass, labels would meet');
275
+ S.placeLabels(flat);
276
+ assert.deepEqual(overlaps(S.shownLabels(flat)), [], `${mode}: worst case, still no two shown labels meet`);
277
+ assert.ok(flat.some((p) => p.lifted), 'a label stepped up to the alternate height');
278
+ const T = ['urgent', 'needs', 'queue'];
279
+ flat.filter((p) => T.includes(p.rank) && !p.shown).forEach((q) => {
280
+ const at = (y) => S.labelBox({ x: q.x, y, label: q.label });
281
+ const blockers = flat.filter((p) => p.shown && (meets(S.labelBox(p), at(300)) || meets(S.labelBox(p), at(300 - S.PIN_STACK))));
282
+ assert.ok(blockers.length && blockers.every((b) => T.indexOf(b.rank) <= T.indexOf(q.rank)), `${q.key} gave way only to an equal or higher tier`);
283
+ });
284
+ }
285
+ });
286
+
287
+ test('the collision pass places in tier order: when only one of two can show, the lower tier waits for hover and keeps its count and colour', () => {
288
+ const pins = [
289
+ { key: 'lo', rank: 'needs', state: 'needs', x: 100, y: 400, h: 78, label: 'aaaaaaaaaaaaaaaa', count: '2' },
290
+ { key: 'hi', rank: 'urgent', state: 'urgent', x: 120, y: 400, h: 78, label: 'bbbbbbbbbbbbbbbb', count: '1' },
291
+ { key: 'mid', rank: 'needs', state: 'needs', x: 110, y: 400, h: 78, label: 'cccccccccccccccc', count: '3' },
292
+ ];
293
+ S.placeLabels(pins);
294
+ const by = Object.fromEntries(pins.map((p) => [p.key, p]));
295
+ assert.deepEqual([by.hi.shown, by.hi.lifted], [true, false], 'the urgent label keeps the pin\'s own height');
296
+ assert.deepEqual([by.lo.shown, by.lo.lifted, by.lo.y, by.lo.h], [true, true, 400 - S.PIN_STACK, 78 + S.PIN_STACK], 'the next steps up to the alternate height');
297
+ assert.equal(by.mid.shown, false, 'the third has nowhere to go: its label waits for hover');
298
+ assert.deepEqual([by.mid.state, by.mid.count], ['needs', '3'], 'its count and colour stay');
299
+ });
300
+
199
301
  test('no docket: no pins, the readings say so, and nothing is invented', () => {
200
302
  const m = S.streetModel({ docket: null, goals: [goal(1)], rollups: [], mode: 'dark', now: NOW });
201
303
  assert.equal(m.pins.length, 0);
@@ -443,8 +545,9 @@ function fakeDom() {
443
545
  return { document, byId, html, focused };
444
546
  }
445
547
 
446
- async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '', shell = undefined } = {}) {
548
+ async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '', shell = undefined, width = 1440 } = {}) {
447
549
  const { document, byId, html, focused } = fakeDom();
550
+ byId.get('streetWindow').clientWidth = width;
448
551
  const store = new Map(stored ? [['hall-theme', stored]] : []);
449
552
  const calls = [];
450
553
  const listeners = {};
@@ -468,6 +571,8 @@ async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '', s
468
571
  vm.runInContext(read('city-draw.js'), ctx);
469
572
  vm.runInContext(read('street-draw.js'), ctx);
470
573
  vm.runInContext(read('street-card.js'), ctx);
574
+ vm.runInContext(read('street-inside.js'), ctx);
575
+ vm.runInContext(read('street-desk.js'), ctx);
471
576
  vm.runInContext(read('street.js'), ctx);
472
577
  await window.GovStreetPage.ready;
473
578
  const key = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
@@ -482,16 +587,23 @@ const FEEDS = (ms, goals = [goal(1)], rollups = [rollup(1, { done: 2, active: 1
482
587
  });
483
588
 
484
589
  test('the page reads the four feeds and draws the docket\'s pins, the readings and the header', async () => {
485
- const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs' }), matter({ id: 'blockers:2', state: 'moving' })];
590
+ const soon = new Date(Date.now() + 3 * 3600000).toISOString();
591
+ const ms = [matter({ id: 'board:1', building: 'charter', state: 'needs', deadline: soon }), matter({ id: 'blockers:2', state: 'moving' }), matter({ id: 'blockers:3', state: 'moving' }), matter({ id: 'access_requests:4', building: 'people', state: 'needs' }), matter({ id: 'override_requests:5', building: 'release', state: 'settled' })];
486
592
  const p = await bootPage({ feeds: FEEDS(ms) });
487
593
  assert.deepEqual([...p.calls].sort(), ['/api/bongos/builders/95/earnings', '/api/bongos/goals', '/api/bongos/goals/rollups', '/api/bongos/government/docket']);
488
594
  const pins = p.byId.get('streetPins').children;
489
- assert.deepEqual(pins.map((s) => s.className).sort(), ['street-pin street-pin--moving', 'street-pin street-pin--needs']);
490
- assert.equal(pins.find((s) => s.attrs['data-matter'] === 'board:1').children[1].children[0].className, 'street-pin__ring', 'a needs pin pulses');
595
+ assert.deepEqual(pins.map((s) => s.className).sort(), ['street-pin street-pin--moving', 'street-pin street-pin--needs street-pin--shown', 'street-pin street-pin--settled', 'street-pin street-pin--urgent street-pin--shown']);
596
+ const pin = (k) => pins.find((s) => s.attrs['data-matter'] === k);
597
+ assert.equal(pin('board:1').children[1].children[0].className, 'street-pin__ring', 'only the urgent pin pulses');
598
+ assert.ok(!pin('access_requests:4').children[1].children.some((c) => c.className === 'street-pin__ring'), 'a needs-you pin is solid orange, no ring');
599
+ assert.equal(pin('blockers:2').attrs['data-matters'], 'blockers:2 blockers:3');
600
+ assert.deepEqual(pin('blockers:2').children[1].children.map((c) => c.className), ['', 'street-pin__badge'], 'the glyph, then the count of open matters');
601
+ assert.equal(pin('blockers:2').children[1].children[1].textContent, '2');
602
+ assert.equal(pin('override_requests:5').children[1].children.length, 0, 'a settled pin is a plain dot');
491
603
  assert.equal(p.byId.get('streetSigns').children.length, 6);
492
604
  assert.ok(p.byId.get('streetSigns').children.every((a) => /^\/builders\/street#building=/.test(a.attrs.href)));
493
- assert.equal(p.byId.get('hudNeeds').textContent, '1');
494
- assert.equal(p.byId.get('hudMoving').textContent, '1');
605
+ assert.equal(p.byId.get('hudNeeds').textContent, '2');
606
+ assert.equal(p.byId.get('hudMoving').textContent, '2');
495
607
  assert.equal(p.byId.get('hudShare').textContent, '$24');
496
608
  assert.equal(p.byId.get('hudDot').className, 'street-hud__dot street-hud__dot--hot');
497
609
  assert.equal(p.byId.get('streetDoors').children.length, 10, 'nine buildings and one district, each a door');
@@ -515,6 +627,67 @@ test('a pin opens its short card, Escape closes it and focus goes back to the pi
515
627
  assert.equal(p.focused.el.attrs['data-matter'], 'board:1');
516
628
  });
517
629
 
630
+ test('the card pager: a pin with several matters opens the most important with "1 of N", and previous/next reach every matter there', async () => {
631
+ const ms = [matter({ id: 'blockers:1', state: 'moving', title: 'Third' }), matter({ id: 'blockers:2', state: 'needs', title: 'First' }), matter({ id: 'blockers:3', state: 'needs', title: 'Second' }), matter({ id: 'blockers:4', state: 'settled', title: 'Fourth' })];
632
+ const p = await bootPage({ feeds: FEEDS(ms) });
633
+ const pin = p.byId.get('streetPins').children.find((s) => s.attrs['data-building'] === 'planning');
634
+ pin.fire('click');
635
+ const body = p.byId.get('streetCardBody');
636
+ const pager = () => body.children[0];
637
+ const btn = (dir) => pager().children.find((c) => c.attrs['data-act'] === 'page-' + dir);
638
+ assert.equal(p.byId.get('streetCardTitle').textContent, 'First', 'the most important matter first');
639
+ assert.equal(pager().className, 'mcard-pager');
640
+ assert.equal(pager().children[1].textContent, '1 of 4');
641
+ const seen = [p.byId.get('streetCardTitle').textContent];
642
+ for (let i = 0; i < 3; i++) { btn('next').fire('click'); seen.push(p.byId.get('streetCardTitle').textContent); }
643
+ assert.deepEqual(seen, ['First', 'Second', 'Third', 'Fourth'], 'next reaches every matter at the building');
644
+ assert.equal(pager().children[1].textContent, '4 of 4');
645
+ assert.equal(p.focused.el && p.focused.el.attrs['data-act'], 'page-next', 'focus stays on the arrow');
646
+ btn('next').fire('click');
647
+ assert.equal(p.byId.get('streetCardTitle').textContent, 'First', 'and wraps');
648
+ btn('prev').fire('click');
649
+ assert.equal(p.byId.get('streetCardTitle').textContent, 'Fourth', 'previous goes back, wrapping too');
650
+ assert.equal(p.byId.get('streetCard').hidden, false, 'all without leaving the street');
651
+ assert.equal(p.byId.get('streetPins').children.find((s) => s.attrs['data-building'] === 'planning').attrs['aria-expanded'], 'true', 'the pin still says its card is open');
652
+ p.key('keydown', { key: 'Escape', preventDefault() {} });
653
+ assert.equal(p.focused.el.attrs['data-building'], 'planning', 'Escape returns focus to the pin');
654
+ // one matter: no pager
655
+ const q = await bootPage({ feeds: FEEDS([matter({ id: 'board:1', building: 'charter' })]) });
656
+ q.byId.get('streetPins').children[0].fire('click');
657
+ assert.ok(!q.byId.get('streetCardBody').children.some((c) => c.className === 'mcard-pager'), 'one matter, no pager');
658
+ });
659
+
660
+ test('no two shown labels meet on a dense docket at 1440, 1024, 390 and 320 wide, as the page draws them', async () => {
661
+ for (const width of [1440, 1024, 390, 320]) {
662
+ const p = await bootPage({ feeds: { ...FEEDS([]), '/api/bongos/government/docket': denseDocket(), '/api/bongos/goals': { goals: [1, 2, 3, 4].map((i) => goal(i)) } }, width });
663
+ const s = Number(p.byId.get('streetBoard').style['--street-scale']);
664
+ assert.ok(s > 0, `${width}: the board is scaled`);
665
+ const pins = p.byId.get('streetPins').children;
666
+ assert.equal(pins.length, 9, `${width}: one pin per civic building`);
667
+ const shown = pins.filter((el) => /street-pin--shown/.test(el.className)).map((el) => {
668
+ const b = S.labelBox({ x: parseFloat(el.style.left), y: parseFloat(el.style.top), label: el.children[0].textContent });
669
+ return { key: el.attrs['data-matter'], x0: b.x0 * s, x1: b.x1 * s, y0: b.y0 * s, y1: b.y1 * s };
670
+ });
671
+ assert.ok(shown.length >= 5, `${width}: labels show`);
672
+ assert.deepEqual(overlaps(shown), [], `${width}: no two shown labels meet on screen`);
673
+ }
674
+ });
675
+
676
+ test('the header counts wear the pins\' colours, so they double as the legend', () => {
677
+ const html = read('street.html');
678
+ assert.match(html, /<span class="street-hud__dot" id="hudDot" aria-hidden="true"><\/span>\s*<span class="street-label">needs you<\/span>/);
679
+ assert.match(html, /<span class="street-hud__dot street-hud__dot--moving" aria-hidden="true"><\/span>\s*<span class="street-label">moving<\/span>/);
680
+ const css = read('street.css').replace(/\/\*[\s\S]*?\*\//g, '');
681
+ assert.match(css, /\.street-hud__dot \{[^}]*border: 1\.5px solid var\(--accent\)/, 'needs you is orange, a ring while nothing waits');
682
+ assert.match(css, /\.street-hud__dot--hot \{ background: var\(--accent\); \}/);
683
+ assert.match(css, /\.street-hud__dot--moving \{[^}]*background: var\(--ink\)/, 'moving is the moving pin\'s ink');
684
+ assert.match(css, /\.street-pin--moving \.street-pin__head \{[^}]*background: var\(--ink\)/);
685
+ assert.match(css, /\.street-pin--urgent \.street-pin__head, \.street-pin--needs \.street-pin__head \{ background: var\(--accent\)/);
686
+ // size carries the tier too, so it reads without colour
687
+ const size = (t) => Number((css.match(new RegExp(`\\.street-pin--${t} \\.street-pin__head \\{ width: (\\d+)px`)) || [])[1] || 28);
688
+ assert.ok(size('urgent') > size('needs') && size('needs') > size('moving') && size('moving') > size('settled'), 'urgent > needs > moving > settled');
689
+ });
690
+
518
691
  test('the switch reads and writes the hall\'s own theme preference and redraws the street', async () => {
519
692
  const p = await bootPage({ feeds: FEEDS([]), stored: 'light', osDark: true });
520
693
  assert.equal(p.html.attrs['data-theme'], 'light', 'the stored hall choice beats the OS');
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-04-palette/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-04-nolegacy/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -79,7 +79,7 @@ test('THE PALETTE: in the top bar in Artistry alone, right before the switch, an
79
79
  assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette']);
80
80
  assert.equal(p.tools()[0].nextSibling, p.sw, 'it sits right before the mode switch');
81
81
  assert.equal(p.tools()[0].getAttribute('aria-label'), 'Write feedback on this page');
82
- for (const m of ['building', 'ideating', 'governing', 'legacy']) {
82
+ for (const m of ['building', 'ideating', 'governing']) {
83
83
  p.craft.choose(m);
84
84
  assert.equal(p.tools().length, 0, `no palette in ${m}`);
85
85
  }