@bongos/core 1.21.43 → 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.
@@ -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');