@bongos/core 1.21.43 → 1.21.45

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 (66) hide show
  1. package/.bongos-core.json +80 -70
  2. package/docs/copy-inventory.md +34 -22
  3. package/docs/copy-registry.json +144 -26
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +5 -2
  6. package/docs/page-readings.json +573 -583
  7. package/modules/hall-ui/public/atlas.html +3 -3
  8. package/modules/hall-ui/public/blockers.html +3 -3
  9. package/modules/hall-ui/public/board-room.html +3 -3
  10. package/modules/hall-ui/public/city-draw.js +51 -13
  11. package/modules/hall-ui/public/city.css +9 -2
  12. package/modules/hall-ui/public/city.html +6 -6
  13. package/modules/hall-ui/public/city.js +23 -12
  14. package/modules/hall-ui/public/city.states.json +146 -12
  15. package/modules/hall-ui/public/collab.html +3 -3
  16. package/modules/hall-ui/public/commands.html +3 -3
  17. package/modules/hall-ui/public/copy-desk.html +3 -3
  18. package/modules/hall-ui/public/craft-mode.js +8 -1
  19. package/modules/hall-ui/public/deploy.html +3 -3
  20. package/modules/hall-ui/public/diagrams.html +3 -3
  21. package/modules/hall-ui/public/drachmae.html +3 -3
  22. package/modules/hall-ui/public/fleet.html +3 -3
  23. package/modules/hall-ui/public/gate.html +3 -3
  24. package/modules/hall-ui/public/goal-map.html +3 -3
  25. package/modules/hall-ui/public/goals.html +3 -3
  26. package/modules/hall-ui/public/government.html +3 -3
  27. package/modules/hall-ui/public/idea.html +3 -3
  28. package/modules/hall-ui/public/ideas.html +3 -3
  29. package/modules/hall-ui/public/index.html +3 -3
  30. package/modules/hall-ui/public/modules.html +3 -3
  31. package/modules/hall-ui/public/paintbrush.js +398 -0
  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 +78 -0
  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 +9 -0
  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/hall_city.mjs +42 -11
  62. package/tests/hall_matter_card.mjs +17 -0
  63. package/tests/hall_street.mjs +185 -12
  64. package/tests/hall_tweak_editor.mjs +1 -1
  65. package/tests/page_notes_panel.mjs +4 -3
  66. package/tests/paintbrush.mjs +351 -0
@@ -852,12 +852,40 @@
852
852
  return b;
853
853
  }
854
854
 
855
+ // THE PAGER (task 1004569). A street pin stands for every matter at its
856
+ // building; the card opens on the most important, and when there are more it
857
+ // carries "1 of N" with previous and next, which wrap, so every matter there
858
+ // is reachable without leaving the street. Pure: the pin's keys (most
859
+ // important first) and the open key → the pager, or null for one matter.
860
+ function pager(keys, key) {
861
+ var ks = Array.isArray(keys) ? keys.map(str) : [];
862
+ var n = ks.length, i = ks.indexOf(str(key));
863
+ if (n < 2 || i < 0) return null;
864
+ return { i: i, n: n, words: (i + 1) + ' of ' + n, prev: ks[(i - 1 + n) % n], next: ks[(i + 1) % n] };
865
+ }
866
+ function pagerRow(doc, pg, on) {
867
+ var row = el(doc, 'div', 'mcard-pager');
868
+ var arrow = function (dir, glyph, words) {
869
+ var b = button(doc, 'mcard-pager__btn', glyph, 'page-' + dir, function () { on.page(pg[dir], dir); }, false);
870
+ b.setAttribute('aria-label', words);
871
+ return b;
872
+ };
873
+ row.appendChild(arrow('prev', '‹', 'Previous matter here'));
874
+ var at = el(doc, 'span', 'mcard-pager__at', pg.words);
875
+ at.setAttribute('aria-live', 'polite');
876
+ row.appendChild(at);
877
+ row.appendChild(arrow('next', '›', 'Next matter here'));
878
+ return row;
879
+ }
880
+
855
881
  // Build the card's body into `host` (emptied first). on: { act(key, input),
856
- // openForm(name), ask(key), more() }. ui: { form: 'revise'|null, ask: actKey|null,
857
- // more: false, error: '', busy: false, fresh: seatKey|null }.
882
+ // openForm(name), ask(key), more(), page(key, dir) }. ui: { form: 'revise'|null,
883
+ // ask: actKey|null, more: false, error: '', busy: false, fresh: seatKey|null,
884
+ // pager: pager()|null }.
858
885
  function render(doc, host, cv, on, ui) {
859
886
  var u = ui || {};
860
887
  while (host.firstChild) host.removeChild(host.firstChild);
888
+ if (u.pager && on && on.page) host.appendChild(pagerRow(doc, u.pager, on));
861
889
  if (cv.fact) host.appendChild(el(doc, 'p', 'mcard-fact', cv.fact));
862
890
  if (cv.quote && cv.quote.words) host.appendChild(el(doc, 'p', 'mcard-quote', (cv.quote.by ? cv.quote.by + ': ' : '') + cv.quote.words));
863
891
  if (cv.lines && cv.lines.length) {
@@ -1048,7 +1076,7 @@
1048
1076
 
1049
1077
  var api = {
1050
1078
  API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
1051
- model: model, actsCard: actsCard, moreOf: moreOf, request: request, orderRequest: orderRequest, check: check, answer: answer, refusal: refusal, render: render,
1079
+ model: model, actsCard: actsCard, moreOf: moreOf, request: request, orderRequest: orderRequest, check: check, answer: answer, refusal: refusal, render: render, pager: pager,
1052
1080
  memberWords: memberWords, windowWords: windowWords, LINE: LINE, compact: compact,
1053
1081
  // the wording change, for a drafted amendment's struck and inserted text (task 1004530)
1054
1082
  change: changeOf,
@@ -38,9 +38,20 @@
38
38
  // are the work queued at its door: up to three crates outside it and the
39
39
  // prototype's dashed count pin, "waiting for a tester", instead of one pin
40
40
  // each.
41
- // * Pins. A building can hold more matters than the prototype's one; up to
42
- // three stand over it, stacked one label above the other, needs first (the
43
- // docket's own order). The building's state line carries the full count.
41
+ // * Pins (task 1004569). A building can hold more matters than the prototype's
42
+ // one, and three stacked labels per building ran into their neighbours'.
43
+ // Now ONE pin stands over a building, where the prototype stands its pin,
44
+ // in the importance tier of its most important matter (GovCity.buildingPin,
45
+ // shared with the home city so both views agree): urgent (needs you within
46
+ // a day) largest and pulsing, needs you orange, moving small and ink,
47
+ // settled a grey dot. It carries the count of the building's open matters.
48
+ // Only urgent and needs-you pins show a label (cut by the line rule's fit(),
49
+ // street-desk.js); a moving pin shows its label on hover or focus. A
50
+ // collision pass (placeLabels) keeps every shown label clear of every other:
51
+ // two that would meet stand on two alternating heights, and when they still
52
+ // meet the lower tier's label waits for hover (its count and colour stay).
53
+ // Tapping a pin opens its most important matter's card, with "1 of N" and
54
+ // previous/next inside the card when the building holds more.
44
55
  // * State lines. The reading that reads a building gives its line when it is
45
56
  // live (people, money, fairness, rules at work); Planning says how many goals
46
57
  // are open; otherwise the line counts the building's matters.
@@ -264,8 +275,19 @@
264
275
  const MORE_KEY = 'more';
265
276
  const GOALS_HREF = '/builders/goals';
266
277
  const ARTBOARD_W = 1440;
267
- const MAX_PINS_PER_BUILDING = 3;
278
+ // A pin's label: at most PIN_LABEL characters (the line rule's fit()), about
279
+ // LABEL_CH px a character at 12 px (a little over, so the box never
280
+ // undercounts), centred on the stem and never wider than LABEL_MAX_W. A
281
+ // label that has to step aside stands PIN_STACK higher.
282
+ const PIN_W = 110;
283
+ const PIN_LABEL = 22;
284
+ const LABEL_CH = 6.6;
285
+ const LABEL_MAX_W = 150;
286
+ const LABEL_H = 15;
287
+ const LABEL_GAP = 4;
268
288
  const PIN_STACK = 40;
289
+ // the tiers whose label shows without a hover, most important first
290
+ const LABEL_TIERS = Object.freeze(['urgent', 'needs', 'queue']);
269
291
  const MAX_CRATES = 3;
270
292
  const HINT = 'work moves left to right · orange waits on you · white is moving without you';
271
293
  const READING_KEYS = Object.freeze(['flow', 'promises', 'money', 'people', 'fairness', 'rules']);
@@ -381,6 +403,56 @@
381
403
  return state === 'needs' || state === 'moving' || state === 'settled' ? state : 'moving';
382
404
  }
383
405
 
406
+ // The line rule's one formatter (street-desk.js), read when a street is
407
+ // drawn: street-desk.js loads after this file and itself requires this one,
408
+ // so it is found on the global it hangs itself on, never required from here
409
+ // (that would be a require cycle). The page always loads it before it draws.
410
+ function lineFit(s, n) {
411
+ const D = root.GovDesk;
412
+ return D && D.fit ? D.fit(s, n) : String(s == null ? '' : s);
413
+ }
414
+ // One short label: the matter's short name, then its deadline in the
415
+ // prototype's words, the name cut so the deadline always survives.
416
+ function pinLabel(short, when) {
417
+ if (!when) return lineFit(short, PIN_LABEL);
418
+ return lineFit(short, Math.max(4, PIN_LABEL - when.length - 3)) + ' · ' + when;
419
+ }
420
+ // The box a shown label takes on the board, padded by LABEL_GAP: it sits at
421
+ // the top of the pin's column, centred on the stem.
422
+ function labelBox(p) {
423
+ const w = Math.min(LABEL_MAX_W, Math.ceil(String(p.label).length * LABEL_CH));
424
+ const cx = p.x + PIN_W / 2;
425
+ return { x0: cx - w / 2 - LABEL_GAP, x1: cx + w / 2 + LABEL_GAP, y0: p.y - LABEL_GAP / 2, y1: p.y + LABEL_H + LABEL_GAP / 2 };
426
+ }
427
+ function meets(a, b) { return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; }
428
+ // The collision pass. Labels are placed most important first (then left to
429
+ // right): each takes the pin's own height if it is clear there, else the
430
+ // alternate height PIN_STACK above, else it is quiet (shown on hover or
431
+ // focus only; the count and colour stay). Moving and settled pins never
432
+ // show a label without a hover, so they take no room. Mutates pins.
433
+ function placeLabels(pins) {
434
+ const rank = (p) => LABEL_TIERS.indexOf(p.rank);
435
+ const placed = [];
436
+ pins.filter((p) => p.label && LABEL_TIERS.indexOf(p.rank) >= 0)
437
+ .sort((a, b) => rank(a) - rank(b) || a.x - b.x)
438
+ .forEach((p) => {
439
+ const base = { y: p.y, h: p.h };
440
+ for (const lift of [0, PIN_STACK]) {
441
+ const q = { x: p.x, y: base.y - lift, label: p.label };
442
+ const bx = labelBox(q);
443
+ if (!placed.some((o) => meets(o, bx))) {
444
+ p.y = q.y; p.h = base.h + lift; p.shown = true; p.lifted = lift > 0;
445
+ placed.push(bx);
446
+ return;
447
+ }
448
+ }
449
+ p.shown = false;
450
+ });
451
+ return pins;
452
+ }
453
+ // Every shown label's box, for the tests and the proof renders.
454
+ function shownLabels(pins) { return (pins || []).filter((p) => p.shown).map((p) => Object.assign({ key: p.key }, labelBox(p))); }
455
+
384
456
  const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
385
457
  const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
386
458
  // A deadline in the prototype's words: 'today', a weekday inside the week, else
@@ -498,28 +570,33 @@
498
570
  return { key: n, name: n, x: x0, w: x1 - x0 };
499
571
  });
500
572
 
501
- // pins, each with its label always showing (up to three stacked per building)
573
+ // one pin per building, in the tier of its most important matter (task 1004569)
502
574
  const pins = [];
503
575
  const byB = {};
504
576
  matters.forEach((m) => { if (m.building !== 'review') (byB[m.building] = byB[m.building] || []).push(m); });
505
577
  CIVIC_KEYS.forEach((k) => {
578
+ const bp = City.buildingPin(byB[k], now);
579
+ if (!bp) return;
506
580
  const s = at[k];
507
- const here2 = (byB[k] || []).slice(0, MAX_PINS_PER_BUILDING);
508
581
  const top = G - roofs[k] - 10;
509
- here2.forEach((m, i) => {
510
- const g = City.glyph(m.rule, 9, 9, 16);
511
- const when = whenWord(m.deadline, now);
512
- const short = String(m.short || m.title || '');
513
- pins.push({
514
- key: String(m.id), building: k, state: pinState(m.state), isCount: false, count: '',
515
- x: Math.round(s.x + s.w / 2 - 55 + (k === 'release' ? 4 : 0)), y: top - 78 - i * PIN_STACK, h: 78 + i * PIN_STACK,
516
- label: when ? short + ' · ' + when : short, aria: String(m.title || short), gf: g.fill, gl: g.line,
517
- });
582
+ const m = bp.top;
583
+ const g = City.glyph(m.rule, 9, 9, 16);
584
+ const short = String(m.short || m.title || '');
585
+ pins.push({
586
+ key: String(m.id), building: k, state: bp.tier, rank: bp.tier, isCount: false,
587
+ count: bp.tier === 'settled' ? '' : String(bp.count), matters: bp.matters.map((x) => String(x.id)),
588
+ x: Math.round(s.x + s.w / 2 - 55 + (k === 'release' ? 4 : 0)), y: top - 78, h: 78,
589
+ label: bp.tier === 'settled' ? '' : pinLabel(short, whenWord(m.deadline, now)), shown: false, lifted: false,
590
+ aria: City.pinAria(k, bp), gf: g.fill, gl: g.line,
518
591
  });
519
592
  });
520
593
  if (queue.length) {
521
- pins.push({ key: 'queue', building: 'review', state: 'queue', isCount: true, count: String(queue.length), x: Math.round(rv.x - 58), y: G - 70, h: 64, label: 'waiting for a tester', aria: (queue.length === 1 ? 'One piece of work waits' : queue.length + ' pieces of work wait') + ' for a tester at Review', gf: '', gl: '' });
594
+ // the queue's dashed count pin keeps the prototype's look; its label is
595
+ // placed in the rank of the most important work waiting
596
+ const qt = City.buildingPin(queue, now).tier;
597
+ pins.push({ key: 'queue', building: 'review', state: 'queue', rank: qt === 'urgent' || qt === 'needs' ? 'queue' : qt, isCount: true, count: String(queue.length), matters: ['queue'], x: Math.round(rv.x - 58), y: G - 70, h: 64, label: 'waiting for a tester', shown: false, lifted: false, aria: (queue.length === 1 ? 'One piece of work waits' : queue.length + ' pieces of work wait') + ' for a tester at Review', gf: '', gl: '' });
522
598
  }
599
+ placeLabels(pins);
523
600
 
524
601
  // every building and district on the street is a door
525
602
  const doors = segs.map((s) => s.k
@@ -543,7 +620,7 @@
543
620
  const moving = Math.max(0, Number(counts.moving) || 0), settled = Math.max(0, Number(counts.settled) || 0);
544
621
  const share = monthShare(opts.earnings, now);
545
622
  const pinCount = { needs: 0, moving: 0, settled: 0 };
546
- pins.forEach((p) => { if (pinCount[p.state] != null) pinCount[p.state] += 1; });
623
+ pins.forEach((p) => { const t = p.state === 'urgent' ? 'needs' : p.state; if (pinCount[t] != null) pinCount[t] += 1; });
547
624
  return {
548
625
  mode: mode, width: W, evening: evening, reviewHeld: reviewHeld,
549
626
  flat: flat, blds: blds, crates: crates, cols: cols, groups: groups, pins: pins, doors: doors, signs: signs,
@@ -582,9 +659,9 @@
582
659
  theme: theme, push: push, rect: rect, ellipse: ellipse, box: box, gable: gable, sawtooth: sawtooth, tree: tree, light: light, civic: civic, work: work,
583
660
  layout: layout, districtWidth: districtWidth, districtItems: districtItems, districtLine: districtLine, countsLine: countsLine,
584
661
  whenWord: whenWord, reviewHeldOf: reviewHeldOf, monthShare: monthShare, money: money, parseHash: parseHash, buildingHref: buildingHref, districtHref: districtHref,
585
- streetModel: streetModel, cardFor: cardFor,
662
+ streetModel: streetModel, cardFor: cardFor, placeLabels: placeLabels, labelBox: labelBox, shownLabels: shownLabels, pinLabel: pinLabel,
586
663
  G: G, SEGS: SEGS, GROUP_NAMES: GROUP_NAMES, CIVIC_KEYS: CIVIC_KEYS, READING_KEYS: READING_KEYS, ARTBOARD_W: ARTBOARD_W,
587
- MAX_PINS_PER_BUILDING: MAX_PINS_PER_BUILDING, MAX_CRATES: MAX_CRATES, MAX_DISTRICTS: MAX_DISTRICTS, GOALS_HREF: GOALS_HREF, HINT: HINT, FALLBACK_LINE: FALLBACK_LINE, HOME_HREF: '/builders/city',
664
+ PIN_W: PIN_W, PIN_LABEL: PIN_LABEL, LABEL_MAX_W: LABEL_MAX_W, PIN_STACK: PIN_STACK, MAX_CRATES: MAX_CRATES, MAX_DISTRICTS: MAX_DISTRICTS, GOALS_HREF: GOALS_HREF, HINT: HINT, FALLBACK_LINE: FALLBACK_LINE, HOME_HREF: '/builders/city',
588
665
  };
589
666
  root.GovStreet = api;
590
667
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
@@ -76,8 +76,11 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
76
76
  .street-kicker { margin: 0; font-size: 11.5px; font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-faint); white-space: nowrap; }
77
77
  .street-hud { display: flex; align-items: center; gap: 36px; margin-left: auto; }
78
78
  .street-hud__item { display: flex; align-items: center; gap: 12px; }
79
- .street-hud__dot { width: 8px; height: 8px; border-radius: 999px; background: var(--street-rule-strong); }
79
+ /* the header's dots are the pins' colours, so the counts double as the legend
80
+ (task 1004569): orange for needs you (a ring while nothing does), ink for moving */
81
+ .street-hud__dot { width: 8px; height: 8px; border-radius: 999px; box-sizing: border-box; background: transparent; border: 1.5px solid var(--accent); flex: none; }
80
82
  .street-hud__dot--hot { background: var(--accent); }
83
+ .street-hud__dot--moving { width: 6px; height: 6px; border: 0; background: var(--ink); }
81
84
  .street-hud__n { font-size: 24px; font-weight: 300; font-variant-numeric: tabular-nums; }
82
85
  .street-mode { margin-left: 36px; flex: none; background: none; border: 1px solid var(--street-rule-strong); border-radius: 999px; height: 36px; padding: 0 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--ink); font-family: var(--font-body); font-size: 13px; text-transform: lowercase; }
83
86
  .street-mode svg { display: block; stroke-width: 1.4; }
@@ -116,7 +119,7 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
116
119
 
117
120
  /* pins: orange and pulsing waits on you; ink is moving; grey is settled; dashed is the queue */
118
121
  .street-pin { position: absolute; z-index: 3; display: flex; flex-direction: column; align-items: center; gap: 6px; background: transparent; border: 0; padding: 0; cursor: pointer; font-family: var(--font-body); color: var(--ink); }
119
- .street-pin__label { max-width: 110px; overflow: hidden; text-overflow: ellipsis; font-size: 12px; line-height: 1.2; white-space: nowrap; }
122
+ .street-pin__label { max-width: 150px; flex-shrink: 0; min-height: 15px; overflow: hidden; text-overflow: ellipsis; font-size: 12px; line-height: 1.2; white-space: nowrap; visibility: hidden; }
120
123
  .street-pin__head { position: relative; width: 28px; height: 28px; flex-shrink: 0; border-radius: 999px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; transition: transform 200ms cubic-bezier(.2,.7,.3,1); }
121
124
  .street-pin:hover .street-pin__head { transform: scale(1.12); }
122
125
  .street-pin__head svg { display: block; }
@@ -125,14 +128,27 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
125
128
  .street-pin__stem { width: 0; flex-grow: 1; border-left: 1px solid transparent; }
126
129
  .street-pin__ring { position: absolute; inset: -1px; border-radius: 999px; border: 1px solid var(--accent); }
127
130
  .street-pin__count { font-size: 12.5px; font-weight: 500; font-variant-numeric: tabular-nums; }
131
+ /* importance tiers (task 1004569): one pin per building, colour AND size, so a
132
+ tier reads without colour. urgent 34 > needs 28 (the prototype's) > moving 20
133
+ > settled 10, a plain grey dot. A label shows only where the collision pass
134
+ placed it (--shown); any other shows on hover or focus, on a card so it reads
135
+ over its neighbours. */
136
+ .street-pin--shown .street-pin__label, .street-pin:hover .street-pin__label, .street-pin:focus-visible .street-pin__label { visibility: visible; }
137
+ .street-pin:hover, .street-pin:focus-visible { z-index: 4; }
138
+ .street-pin:not(.street-pin--shown):hover .street-pin__label, .street-pin:not(.street-pin--shown):focus-visible .street-pin__label { background: var(--bg-card); border-radius: 4px; padding: 0 4px; }
139
+ .street-pin--urgent .street-pin__label { color: var(--street-accent-text); font-weight: 500; }
128
140
  .street-pin--needs .street-pin__label { color: var(--street-accent-text); }
129
- .street-pin--needs .street-pin__head { background: var(--accent); color: var(--accent-on); box-shadow: var(--street-glow); }
130
- .street-pin--needs .street-pin__stem { border-left-color: color-mix(in srgb, var(--accent) 65%, transparent); }
131
- .street-pin--moving .street-pin__head { background: var(--ink); color: var(--bg-card); }
141
+ .street-pin--urgent .street-pin__head, .street-pin--needs .street-pin__head { background: var(--accent); color: var(--accent-on); box-shadow: var(--street-glow); }
142
+ .street-pin--urgent .street-pin__head { width: 34px; height: 34px; }
143
+ .street-pin--urgent .street-pin__head svg { width: 21px; height: 21px; }
144
+ .street-pin--urgent .street-pin__stem, .street-pin--needs .street-pin__stem { border-left-color: color-mix(in srgb, var(--accent) 65%, transparent); }
145
+ .street-pin--moving .street-pin__head { width: 20px; height: 20px; background: var(--ink); color: var(--bg-card); }
146
+ .street-pin--moving .street-pin__head svg { width: 13px; height: 13px; }
132
147
  .street-pin--moving .street-pin__stem { border-left-color: color-mix(in srgb, var(--ink) 45%, transparent); }
133
- .street-pin--settled .street-pin__label { color: var(--ink-faint); }
134
- .street-pin--settled .street-pin__head { background: color-mix(in srgb, var(--ink) 10%, var(--bg)); border: 1px solid color-mix(in srgb, var(--ink) 22%, transparent); color: var(--ink-faint); }
148
+ .street-pin--settled .street-pin__head { width: 10px; height: 10px; margin-top: 9px; background: color-mix(in srgb, var(--ink) 38%, var(--bg)); }
135
149
  .street-pin--settled .street-pin__stem { border-left-color: color-mix(in srgb, var(--ink) 22%, transparent); }
150
+ .street-pin__badge { position: absolute; top: -7px; right: -12px; min-width: 17px; height: 17px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--bg-card); border: 1px solid currentColor; color: var(--ink); font-size: 10.5px; font-weight: 600; line-height: 15px; text-align: center; font-variant-numeric: tabular-nums; }
151
+ .street-pin--urgent .street-pin__badge, .street-pin--needs .street-pin__badge { color: var(--street-accent-text); }
136
152
  .street-pin--queue .street-pin__head { background: transparent; border: 1px dashed var(--street-crate); color: var(--ink); }
137
153
  .street-pin--queue .street-pin__stem { border-left-color: color-mix(in srgb, var(--ink) 22%, transparent); }
138
154
 
@@ -11,8 +11,8 @@
11
11
  links, injected into this head by the server like every hall page's
12
12
  (tests/hall_tokens.mjs: no hall page hardcodes a web-font stylesheet). -->
13
13
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
14
- <link rel="stylesheet" href="/builders/street.css?v=2026-10-03-desk1">
15
- <link rel="stylesheet" href="/builders/street-card.css?v=2026-10-02-card1">
14
+ <link rel="stylesheet" href="/builders/street.css?v=2026-10-04-pins1">
15
+ <link rel="stylesheet" href="/builders/street-card.css?v=2026-10-04-pins1">
16
16
  </head>
17
17
  <!-- THE STREET (task 1004525, goal 1000125, R03). The street view of
18
18
  docs/design/mocks/governor-city/Street.dc.html (renderVals' street branch,
@@ -73,11 +73,12 @@
73
73
  <span class="street-today__n" id="streetTodayN">0 decided</span>
74
74
  </div>
75
75
  <div class="street-hud__item">
76
- <span class="street-hud__dot" id="hudDot"></span>
76
+ <span class="street-hud__dot" id="hudDot" aria-hidden="true"></span>
77
77
  <span class="street-label">needs you</span>
78
78
  <span class="street-hud__n" id="hudNeeds">—</span>
79
79
  </div>
80
80
  <div class="street-hud__item">
81
+ <span class="street-hud__dot street-hud__dot--moving" aria-hidden="true"></span>
81
82
  <span class="street-label">moving</span>
82
83
  <span class="street-hud__n" id="hudMoving">—</span>
83
84
  </div>
@@ -203,16 +204,16 @@
203
204
  </div>
204
205
  </div>
205
206
 
206
- <script src="/builders/shell-trail.js?v=2026-10-04-nolegacy"></script>
207
- <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
208
- <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
207
+ <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
208
+ <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
209
+ <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
209
210
  <script src="/builders/palette.js" defer></script>
210
- <script src="/builders/city-draw.js?v=2026-10-02-street1"></script>
211
- <script src="/builders/street-draw.js?v=2026-10-02-street1"></script>
212
- <script src="/builders/street-card.js?v=2026-10-02-amend1"></script>
211
+ <script src="/builders/city-draw.js?v=2026-10-04-pins1"></script>
212
+ <script src="/builders/street-draw.js?v=2026-10-04-pins1"></script>
213
+ <script src="/builders/street-card.js?v=2026-10-04-pins1"></script>
213
214
  <script src="/builders/street-inside.js?v=2026-10-03-desk1"></script>
214
215
  <script src="/builders/street-desk.js?v=2026-10-03-desk1"></script>
215
216
  <script src="/builders/street-amend.js?v=2026-10-02-amend1"></script>
216
- <script src="/builders/street.js?v=2026-10-03-desk1"></script>
217
+ <script src="/builders/street.js?v=2026-10-04-pins1"></script>
217
218
  </body>
218
219
  </html>
@@ -80,7 +80,9 @@
80
80
  var data = { docket: null, goals: [], rollups: [], earnings: null, loaded: false, tasks: {}, room: null, constitution: null };
81
81
  // ask: the act whose chips stand open; more: the detail is open in place;
82
82
  // pick: which of the review queue the queue's card shows (task 1004566)
83
- var card = { cv: null, form: null, ask: null, more: false, pick: null, error: '', busy: false, fresh: null, answer: null };
83
+ // page: the matters of the pin that opened the card, most important first,
84
+ // for its "1 of N" pager (task 1004569); null when one matter or a desk row.
85
+ var card = { cv: null, form: null, ask: null, more: false, pick: null, error: '', busy: false, fresh: null, answer: null, page: null };
84
86
  var view = { open: null, opener: null, model: null, scale: 1, mode: null, inside: null, insideModel: null };
85
87
  // the desk's own state: rows you acted on this visit, what you did per floor,
86
88
  // the floors you cleared, the row whose facts or chips are open
@@ -247,25 +249,31 @@
247
249
  return el;
248
250
  }
249
251
 
252
+ // One pin per building (task 1004569): its tier is its class, a label shows
253
+ // only when the collision pass placed it (street-pin--shown; the rest show
254
+ // on hover or focus), and a count badge says how many matters are open there.
250
255
  function drawPin(p) {
251
- var el = node('button', 'street-pin street-pin--' + p.state);
256
+ var keys = p.matters && p.matters.length ? p.matters : [p.key];
257
+ var el = node('button', 'street-pin street-pin--' + p.state + (p.shown ? ' street-pin--shown' : ''));
252
258
  el.setAttribute('type', 'button');
253
259
  el.setAttribute('aria-label', p.aria);
254
- el.setAttribute('aria-expanded', view.open === p.key ? 'true' : 'false');
260
+ el.setAttribute('aria-expanded', keys.indexOf(view.open) >= 0 ? 'true' : 'false');
255
261
  el.setAttribute('aria-controls', 'streetCard');
256
262
  el.setAttribute('data-matter', p.key);
263
+ el.setAttribute('data-matters', keys.join(' '));
257
264
  el.setAttribute('data-building', p.building);
258
- at(el, p.x, p.y, 110, p.h);
265
+ at(el, p.x, p.y, S.PIN_W, p.h);
259
266
  var label = node('span', 'street-pin__label', p.label);
260
- label.setAttribute('title', p.label);
267
+ if (p.label) label.setAttribute('title', p.label);
261
268
  el.appendChild(label);
262
269
  var head = node('span', 'street-pin__head');
263
- if (p.state === 'needs') head.appendChild(node('span', 'street-pin__ring'));
270
+ if (p.state === 'urgent') head.appendChild(node('span', 'street-pin__ring'));
264
271
  if (p.isCount) head.appendChild(node('span', 'street-pin__count', p.count));
265
- else {
272
+ else if (p.state !== 'settled') {
266
273
  var svg = document.createElementNS(SVG_NS, 'svg');
267
274
  svg.setAttribute('width', '18');
268
275
  svg.setAttribute('height', '18');
276
+ svg.setAttribute('viewBox', '0 0 18 18');
269
277
  svg.setAttribute('aria-hidden', 'true');
270
278
  var fill = document.createElementNS(SVG_NS, 'path');
271
279
  fill.setAttribute('d', p.gf || '');
@@ -275,9 +283,10 @@
275
283
  svg.appendChild(line);
276
284
  head.appendChild(svg);
277
285
  }
286
+ if (!p.isCount && Number(p.count) > 1) head.appendChild(node('span', 'street-pin__badge', p.count));
278
287
  el.appendChild(head);
279
288
  el.appendChild(node('span', 'street-pin__stem'));
280
- el.addEventListener('click', function () { openCard(p.key, el); });
289
+ el.addEventListener('click', function () { openCard(keys[0], el, keys); });
281
290
  return el;
282
291
  }
283
292
 
@@ -366,6 +375,7 @@
366
375
  $('hudMoving').textContent = m.hud.moving;
367
376
  $('hudShare').textContent = m.hud.share;
368
377
  var dot = $('hudDot');
378
+ // the header's dots are the pins' own colours, so they double as the legend
369
379
  if (dot) dot.className = 'street-hud__dot' + (m.hud.hot ? ' street-hud__dot--hot' : '');
370
380
  var board = $('streetBoard');
371
381
  if (board) board.setAttribute('aria-label', data.loaded ? m.label : 'The street, reading the docket');
@@ -1055,7 +1065,8 @@
1055
1065
  $('streetCardWhere').textContent = cv.where;
1056
1066
  $('streetCardWhen').textContent = cv.when;
1057
1067
  $('streetCardTitle').textContent = cv.title;
1058
- C.render(document, $('streetCardBody'), cv, { act: actOn, openForm: openForm, ask: askFor, more: toggleMore, pick: pickQueued }, { form: card.form, ask: card.ask, more: card.more, error: card.error, busy: card.busy, fresh: card.fresh });
1068
+ var pager = C.pager ? C.pager(card.page, view.open) : null;
1069
+ C.render(document, $('streetCardBody'), cv, { act: actOn, openForm: openForm, ask: askFor, more: toggleMore, pick: pickQueued, page: turnPage }, { form: card.form, ask: card.ask, more: card.more, error: card.error, busy: card.busy, fresh: card.fresh, pager: pager });
1059
1070
  var frame = $('streetCard'); if (frame) frame.setAttribute('data-more', card.more ? 'open' : 'closed');
1060
1071
  placeCard();
1061
1072
  return cv;
@@ -1073,6 +1084,26 @@
1073
1084
  var mb = $('streetCardBody'), t = mb && mb.querySelector ? mb.querySelector('[data-act="more"]') : null;
1074
1085
  if (t && t.focus) t.focus();
1075
1086
  }
1087
+ // The pager turns the card to another matter of the same pin, in place.
1088
+ function turnPage(key, dir) {
1089
+ view.open = key;
1090
+ card.form = null; card.ask = null; card.more = false; card.pick = null; card.error = ''; card.fresh = null;
1091
+ if (!fillCard()) return;
1092
+ draw();
1093
+ var b = findAct($('streetCardBody'), 'page-' + dir);
1094
+ if (b && b.focus) b.focus();
1095
+ if (card.cv && card.cv.loading) loadRoom();
1096
+ }
1097
+ // The first node under `host` with data-act=`act` (a walk, so it needs no selector engine).
1098
+ function findAct(host, act) {
1099
+ var kids = host && host.children ? Array.prototype.slice.call(host.children) : [];
1100
+ for (var i = 0; i < kids.length; i++) {
1101
+ if (kids[i].getAttribute && kids[i].getAttribute('data-act') === act) return kids[i];
1102
+ var hit = findAct(kids[i], act);
1103
+ if (hit) return hit;
1104
+ }
1105
+ return null;
1106
+ }
1076
1107
  function pickQueued(key) {
1077
1108
  card.pick = key; card.ask = null; card.more = false; card.form = null; card.error = '';
1078
1109
  fillCard();
@@ -1083,7 +1114,7 @@
1083
1114
  var card = $('streetCard');
1084
1115
  if (!card || !view.open || !view.model) return;
1085
1116
  var pin = null;
1086
- view.model.pins.forEach(function (p) { if (p.key === view.open) pin = p; });
1117
+ view.model.pins.forEach(function (p) { if (p.key === view.open || (p.matters && p.matters.indexOf(view.open) >= 0)) pin = p; });
1087
1118
  var win = $('streetWindow'), sizer = $('streetSizer');
1088
1119
  var stageW = (card.parentNode && card.parentNode.clientWidth) || window.innerWidth || 1440;
1089
1120
  var originX = (sizer && sizer.offsetLeft != null ? sizer.offsetLeft : 0) - (win && win.scrollLeft ? win.scrollLeft : 0);
@@ -1091,9 +1122,10 @@
1091
1122
  var x = Math.max(16, Math.min(stageW - CARD_W - 16, Math.round(cx - CARD_W / 2)));
1092
1123
  card.style.setProperty('--street-card-x', x + 'px');
1093
1124
  }
1094
- function openCard(key, opener) {
1125
+ function openCard(key, opener, keys) {
1095
1126
  view.open = key;
1096
1127
  view.opener = opener || null;
1128
+ card.page = keys && keys.length > 1 ? keys.slice() : null;
1097
1129
  card.form = null; card.ask = null; card.more = false; card.pick = null; card.error = ''; card.fresh = null;
1098
1130
  var el = $('streetCard');
1099
1131
  if (!fillCard()) return;
@@ -1107,7 +1139,7 @@
1107
1139
  var was = view.opener;
1108
1140
  view.open = null;
1109
1141
  view.opener = null;
1110
- card.cv = null; card.form = null; card.ask = null; card.more = false; card.pick = null; card.error = ''; card.busy = false; card.fresh = null;
1142
+ card.cv = null; card.form = null; card.ask = null; card.more = false; card.pick = null; card.error = ''; card.busy = false; card.fresh = null; card.page = null;
1111
1143
  var el = $('streetCard');
1112
1144
  if (el) el.hidden = true;
1113
1145
  draw();
@@ -1195,7 +1227,7 @@
1195
1227
  var blds = ($('streetBlds') && $('streetBlds').children) || [];
1196
1228
  for (var i = 0; i < blds.length; i++) if (blds[i].getAttribute('data-key') === a.building) blds[i].setAttribute('class', 'street-answer street-answer--' + a.kind);
1197
1229
  var pins = ($('streetPins') && $('streetPins').children) || [];
1198
- for (var j = 0; j < pins.length; j++) if (pins[j].getAttribute('data-matter') === a.matter) pins[j].className += ' street-pin--answer';
1230
+ for (var j = 0; j < pins.length; j++) if (String(pins[j].getAttribute('data-matters') || pins[j].getAttribute('data-matter')).split(' ').indexOf(a.matter) >= 0) pins[j].className += ' street-pin--answer';
1199
1231
  }
1200
1232
  var toastTimer = null;
1201
1233
  function say(text) {
@@ -805,6 +805,74 @@
805
805
  ".desk-status--hot"
806
806
  ]
807
807
  }
808
+ },
809
+ "pins": {
810
+ "auth": true,
811
+ "_": "The street over the dense docket (task 1004569): one pin per building in the tier of its most important matter — urgent (needs you within a day: people, rules) largest and pulsing, needs you orange, moving small and ink, settled a grey dot — with a count where a building holds more than one open matter. Only urgent and needs-you pins show a label, and no two labels meet. Render at 1440 and 390 in both modes.",
812
+ "actions": [
813
+ [
814
+ "wait",
815
+ 900
816
+ ],
817
+ [
818
+ "clickIfVisible",
819
+ "[data-craft-close]"
820
+ ],
821
+ [
822
+ "wait",
823
+ 300
824
+ ]
825
+ ],
826
+ "expect": {
827
+ "visible": [
828
+ "#streetBoard",
829
+ ".street-pin--urgent",
830
+ ".street-pin--needs",
831
+ ".street-pin__badge"
832
+ ]
833
+ }
834
+ },
835
+ "pins-pager": {
836
+ "auth": true,
837
+ "_": "Planning holds four matters: its pin opens the most important one with \"1 of 4\" and previous/next inside the card; next turns to the second without leaving the street (task 1004569).",
838
+ "actions": [
839
+ [
840
+ "wait",
841
+ 900
842
+ ],
843
+ [
844
+ "clickIfVisible",
845
+ "[data-craft-close]"
846
+ ],
847
+ [
848
+ "wait",
849
+ 300
850
+ ],
851
+ [
852
+ "dispatchClick",
853
+ ".street-pin[data-building=\"planning\"]"
854
+ ],
855
+ [
856
+ "wait",
857
+ 600
858
+ ],
859
+ [
860
+ "click",
861
+ "[data-act=\"page-next\"]"
862
+ ],
863
+ [
864
+ "wait",
865
+ 400
866
+ ]
867
+ ],
868
+ "expect": {
869
+ "visible": [
870
+ "#streetCard",
871
+ ".mcard-pager",
872
+ "[data-act=\"page-prev\"]",
873
+ "[data-act=\"page-next\"]"
874
+ ]
875
+ }
808
876
  }
809
877
  },
810
878
  "reducedMotion": {
@@ -176,9 +176,9 @@
176
176
 
177
177
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
178
178
 
179
- <script src="/builders/shell-trail.js?v=2026-10-04-nolegacy"></script>
180
- <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
181
- <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
179
+ <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
180
+ <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
181
+ <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
182
182
  <script src="/builders/palette.js" defer></script>
183
183
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
184
184
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure