@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.
@@ -40,9 +40,11 @@
40
40
  // * Review. The prototype's story opens with Review a vacant lot; in this
41
41
  // project Review is held (the grader and the testers sign work off), so the
42
42
  // held recipe is drawn and the parcels leave through Release.
43
- // * Pins. A building can hold more matters than the prototype's one; up to
44
- // three pins stand side by side over it (needs first, the docket's own
45
- // order), and the line under the button carries the full counts.
43
+ // * Pins (task 1004569). A building can hold more matters than the prototype's
44
+ // one, and three pins abreast crowded each other; now ONE pin stands over a
45
+ // building, where the prototype stands its pin, in the importance tier of
46
+ // its most important matter (buildingPin below, shared with the street so
47
+ // both views agree), with a count when more than one matter is open there.
46
48
 
47
49
  (function (root) {
48
50
  'use strict';
@@ -244,7 +246,6 @@
244
246
  Object.freeze({ x0: 12, x1: 15, y0: 12, y1: 15, tall: 60, route: null }),
245
247
  ]);
246
248
  const MAX_DISTRICTS = BASE_LOTS.length * 2;
247
- const MAX_PINS_PER_BUILDING = 3;
248
249
 
249
250
  function halve(L) {
250
251
  const w = L.x1 - L.x0 + 1, h = L.y1 - L.y0 + 1;
@@ -327,6 +328,39 @@
327
328
  return state === 'needs' || state === 'moving' || state === 'settled' ? state : 'moving';
328
329
  }
329
330
 
331
+ // ---------- importance tiers (task 1004569) ----------
332
+ // One pin per building, in the tier of its most important matter, so the city
333
+ // and the street read the same at a glance. Colour AND size carry the tier
334
+ // (city.css, street.css), so it also reads without colour:
335
+ // urgent needs you and due within 24 hours (or overdue): largest, orange, pulsing
336
+ // needs needs you: orange, medium (the prototype's pin)
337
+ // moving others are deciding: ink, small
338
+ // settled settled recently: a small grey dot, no label, no count
339
+ const TIERS = Object.freeze(['urgent', 'needs', 'moving', 'settled']);
340
+ const URGENT_MS = 24 * 3600 * 1000;
341
+ function matterTier(m, now) {
342
+ const st = pinState(m && m.state);
343
+ if (st !== 'needs') return st;
344
+ const t = m.deadline ? Date.parse(m.deadline) : NaN;
345
+ return Number(now) > 0 && isFinite(t) && t - Number(now) <= URGENT_MS ? 'urgent' : 'needs';
346
+ }
347
+ // A building's matters → its one pin: the tier of the most important, the
348
+ // count of the open ones (settled is not open), and every matter, most
349
+ // important first (the docket's own order inside a tier), for the card pager.
350
+ function buildingPin(matters, now) {
351
+ const ranked = (Array.isArray(matters) ? matters : []).filter(Boolean)
352
+ .map((m, i) => ({ m: m, t: matterTier(m, now), i: i }))
353
+ .sort((a, b) => TIERS.indexOf(a.t) - TIERS.indexOf(b.t) || a.i - b.i);
354
+ if (!ranked.length) return null;
355
+ return { tier: ranked[0].t, top: ranked[0].m, count: ranked.filter((r) => r.t !== 'settled').length, matters: ranked.map((r) => r.m) };
356
+ }
357
+ // What a pin says to a screen reader: '3 open at charter, the first needs you within a day'.
358
+ const TIER_WORDS = Object.freeze({ urgent: 'needs you within a day', needs: 'needs you', moving: 'is moving without you', settled: 'is settled' });
359
+ function pinAria(building, bp) {
360
+ const lead = bp.count > 1 ? bp.count + ' open at ' + building + ', the first ' : bp.count === 1 ? 'One open at ' + building + ': it ' : 'At ' + building + ': ';
361
+ return lead + TIER_WORDS[bp.tier] + '. ' + String(bp.top.title || bp.top.short || '');
362
+ }
363
+
330
364
  // The home line, when the docket could not be read.
331
365
  const FALLBACK_LINE = 'the docket could not be read just now';
332
366
 
@@ -335,6 +369,7 @@
335
369
  // goals GET /goals goals[] (any status; only 'open' ones are drawn)
336
370
  // rollups GET /goals/rollups rollups[] (goal_id → task counts)
337
371
  // mode 'dark' | 'light'
372
+ // now ms since the epoch (a deadline inside a day is urgent)
338
373
  function cityModel(input) {
339
374
  const opts = input || {};
340
375
  const docket = opts.docket && typeof opts.docket === 'object' ? opts.docket : null;
@@ -385,7 +420,7 @@
385
420
  });
386
421
  blds.sort((a, b) => a.depth - b.depth);
387
422
 
388
- // a pin over each open matter's building (up to three abreast)
423
+ // one pin over each building with a matter, in its most important matter's tier
389
424
  const slotOf = {};
390
425
  CIVIC_SLOTS.forEach((s) => { slotOf[s[0]] = s; });
391
426
  const matters = docket && Array.isArray(docket.matters) ? docket.matters : [];
@@ -397,14 +432,16 @@
397
432
  const pins = [];
398
433
  let n = 0;
399
434
  CIVIC_SLOTS.forEach((s) => {
400
- const here = (atBuilding[s[0]] || []).slice(0, MAX_PINS_PER_BUILDING);
435
+ const bp = buildingPin(atBuilding[s[0]], opts.now);
436
+ if (!bp) return;
401
437
  const top = P(s[1] + 1, s[2] + 1, ROOF[s[0]] + 8);
402
- here.forEach((m, i) => {
403
- const g = glyph(m.rule, 7, 7, 13);
404
- const dx = (i - (here.length - 1) / 2) * 26;
405
- pins.push({ key: String(m.id), building: s[0], state: pinState(m.state), title: String(m.short || m.title || ''), x: r1(top[0] - 15 + dx), y: r1(top[1] - 52), h: 52, gf: g.fill, gl: g.line, delay: -(n * 0.9) });
406
- n += 1;
438
+ const m = bp.top;
439
+ const g = glyph(m.rule, 7, 7, 13);
440
+ pins.push({
441
+ key: String(m.id), building: s[0], state: bp.tier, count: bp.tier === 'settled' ? 0 : bp.count, matters: bp.matters.map((x) => String(x.id)),
442
+ title: String(m.short || m.title || ''), aria: pinAria(s[0], bp), x: r1(top[0] - 15), y: r1(top[1] - 52), h: 52, gf: g.fill, gl: g.line, delay: -(n * 0.9),
407
443
  });
444
+ n += 1;
408
445
  });
409
446
 
410
447
  // work on the move: parcels ride the ring road to Review, and leave through Release
@@ -423,7 +460,7 @@
423
460
 
424
461
  const line = docket && docket.line ? String(docket.line) : FALLBACK_LINE;
425
462
  const pinCount = { needs: 0, moving: 0, settled: 0 };
426
- pins.forEach((p) => { pinCount[p.state] += 1; });
463
+ pins.forEach((p) => { pinCount[p.state === 'urgent' ? 'needs' : p.state] += 1; });
427
464
  const label = 'The governor city: the nine civic buildings round the plaza and '
428
465
  + (districts.length === 1 ? 'one district' : districts.length + ' districts') + ', one for each open goal'
429
466
  + (pins.length ? '. Pins: ' + pinCount.needs + ' need you, ' + pinCount.moving + ' moving, ' + pinCount.settled + ' settled.' : '.');
@@ -439,7 +476,8 @@
439
476
  isoTheme: isoTheme, isoKit: isoKit, isoBox: isoBox, isoGable: isoGable, isoHip: isoHip, isoSaw: isoSaw,
440
477
  isoTree: isoTree, isoLight: isoLight, isoInstitution: isoInstitution, isoWork: isoWork, glyph: glyph, rnd: rnd,
441
478
  lotsFor: lotsFor, districtWork: districtWork, cityModel: cityModel, rulesAtWork: rulesAtWork,
442
- CIVIC_SLOTS: CIVIC_SLOTS, ROOF: ROOF, BASE_LOTS: BASE_LOTS, MAX_DISTRICTS: MAX_DISTRICTS, MAX_PINS_PER_BUILDING: MAX_PINS_PER_BUILDING,
479
+ matterTier: matterTier, buildingPin: buildingPin, pinAria: pinAria, TIERS: TIERS, URGENT_MS: URGENT_MS,
480
+ CIVIC_SLOTS: CIVIC_SLOTS, ROOF: ROOF, BASE_LOTS: BASE_LOTS, MAX_DISTRICTS: MAX_DISTRICTS,
443
481
  FALLBACK_LINE: FALLBACK_LINE, STREET_HREF: STREET_HREF,
444
482
  };
445
483
  root.GovCity = api;
@@ -84,8 +84,15 @@ html[data-fullscreen="on"] .city-top { right: 184px; }
84
84
  .city-pin__head path + path { fill: none; stroke: currentColor; stroke-width: 1.2; }
85
85
  .city-pin__stem { width: 0; flex-grow: 1; border-left: 1px solid transparent; }
86
86
  .city-pin__ring { position: absolute; inset: -1px; border-radius: 999px; border: 1px solid var(--accent); }
87
- .city-pin--needs .city-pin__head { background: var(--accent); color: var(--accent-on); box-shadow: var(--city-glow); }
88
- .city-pin--needs .city-pin__stem { border-left-color: color-mix(in srgb, var(--accent) 60%, transparent); }
87
+ /* importance tiers (task 1004569): colour AND size, so a tier reads without colour.
88
+ urgent 26 > needs 22 (the prototype's) > moving 16 > settled 8, a plain dot */
89
+ .city-pin--urgent .city-pin__head, .city-pin--needs .city-pin__head { background: var(--accent); color: var(--accent-on); box-shadow: var(--city-glow); }
90
+ .city-pin--urgent .city-pin__head { width: 26px; height: 26px; }
91
+ .city-pin--urgent .city-pin__stem, .city-pin--needs .city-pin__stem { border-left-color: color-mix(in srgb, var(--accent) 60%, transparent); }
92
+ .city-pin--moving .city-pin__head { width: 16px; height: 16px; }
93
+ .city-pin--moving .city-pin__head svg { width: 10px; height: 10px; }
94
+ .city-pin--settled .city-pin__head { width: 8px; height: 8px; margin-top: 14px; }
95
+ .city-pin__count { position: absolute; top: -6px; right: -9px; min-width: 14px; height: 14px; padding: 0 3px; box-sizing: border-box; border-radius: 999px; background: var(--bg-card); color: var(--ink); border: 1px solid currentColor; font-size: 9.5px; font-weight: 600; line-height: 12px; text-align: center; font-variant-numeric: tabular-nums; }
89
96
  .city-pin--moving .city-pin__head { background: var(--ink); color: var(--bg-card); }
90
97
  .city-pin--moving .city-pin__stem { border-left-color: color-mix(in srgb, var(--ink) 45%, transparent); }
91
98
  .city-pin--settled .city-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); }
@@ -12,7 +12,7 @@
12
12
  (tests/hall_tokens.mjs: no hall page hardcodes a web-font stylesheet).
13
13
  The prototype carried its own Google Fonts link for the same two faces. -->
14
14
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
15
- <link rel="stylesheet" href="/builders/city.css?v=2026-10-02-city1">
15
+ <link rel="stylesheet" href="/builders/city.css?v=2026-10-04-pins1">
16
16
  </head>
17
17
  <!-- THE GOVERNOR CITY, HOME (task 1004524, goal 1000125, R02). The home view
18
18
  of docs/design/mocks/governor-city/Street.dc.html (homeView and the home
@@ -87,7 +87,7 @@
87
87
  <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
88
88
  <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
89
89
  <script src="/builders/palette.js" defer></script>
90
- <script src="/builders/city-draw.js?v=2026-10-02-city1"></script>
91
- <script src="/builders/city.js?v=2026-10-02-city1"></script>
90
+ <script src="/builders/city-draw.js?v=2026-10-04-pins1"></script>
91
+ <script src="/builders/city.js?v=2026-10-04-pins1"></script>
92
92
  </body>
93
93
  </html>
@@ -132,22 +132,33 @@
132
132
  var el = span('city-pin city-pin--' + pin.state);
133
133
  el.setAttribute('data-matter', pin.key);
134
134
  el.setAttribute('data-building', pin.building);
135
+ if (pin.aria) el.setAttribute('title', pin.aria);
135
136
  el.style.left = pin.x + 'px';
136
137
  el.style.top = pin.y + 'px';
137
138
  el.style.height = pin.h + 'px';
138
139
  el.style.animationDelay = pin.delay + 's';
139
140
  var head = span('city-pin__head');
140
- if (pin.state === 'needs') head.appendChild(span('city-pin__ring'));
141
- var svg = document.createElementNS(SVG_NS, 'svg');
142
- svg.setAttribute('width', '14');
143
- svg.setAttribute('height', '14');
144
- var fill = document.createElementNS(SVG_NS, 'path');
145
- fill.setAttribute('d', pin.gf || '');
146
- var line = document.createElementNS(SVG_NS, 'path');
147
- line.setAttribute('d', pin.gl || '');
148
- svg.appendChild(fill);
149
- svg.appendChild(line);
150
- head.appendChild(svg);
141
+ // only the urgent tier pulses; a settled pin is a plain grey dot, no mark (task 1004569)
142
+ if (pin.state === 'urgent') head.appendChild(span('city-pin__ring'));
143
+ if (pin.state !== 'settled') {
144
+ var svg = document.createElementNS(SVG_NS, 'svg');
145
+ svg.setAttribute('width', '14');
146
+ svg.setAttribute('height', '14');
147
+ svg.setAttribute('viewBox', '0 0 14 14');
148
+ var fill = document.createElementNS(SVG_NS, 'path');
149
+ fill.setAttribute('d', pin.gf || '');
150
+ var line = document.createElementNS(SVG_NS, 'path');
151
+ line.setAttribute('d', pin.gl || '');
152
+ svg.appendChild(fill);
153
+ svg.appendChild(line);
154
+ head.appendChild(svg);
155
+ }
156
+ // the count of the building's open matters, when there is more than one
157
+ if (pin.count > 1) {
158
+ var badge = span('city-pin__count');
159
+ badge.textContent = String(pin.count);
160
+ head.appendChild(badge);
161
+ }
151
162
  el.appendChild(head);
152
163
  el.appendChild(span('city-pin__stem'));
153
164
  return el;
@@ -162,7 +173,7 @@
162
173
  }
163
174
 
164
175
  function draw(mode) {
165
- var model = City.cityModel({ docket: data.docket, goals: data.goals, rollups: data.rollups, mode: mode });
176
+ var model = City.cityModel({ docket: data.docket, goals: data.goals, rollups: data.rollups, mode: mode, now: Date.now() });
166
177
  var flat = $('cityFlat'), blds = $('cityBlds'), parcels = $('cityParcels'), pins = $('cityPins');
167
178
  clear(flat); clear(blds); clear(parcels); clear(pins);
168
179
  model.flat.forEach(function (p) { flat.appendChild(pathEl(p)); });
@@ -2,35 +2,169 @@
2
2
  "_": "THE GOVERNOR CITY's home (task 1004524, goal 1000125) through modules/ui-design/kit/render.js. The page draws GET /government/docket, GET /goals and GET /goals/rollups; against the kit's own stub none exists, so every state renders the city with no districts and no pins and the line saying the docket could not be read: a real state the floors must hold in. To look at the populated city, run it against the hall-preview harness, whose government__docket.json fixture carries the reference design's seven sample matters (3 need you, 2 moving, 2 settled) and whose goals fixtures carry the open goals:\n\n node scripts/hall-preview/server.js --port 4624 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/city.states.json --base-in http://localhost:4624 --base-out http://localhost:4624\n\nThe page follows the OS mode until the switch is pressed (then the hall's own 'hall-theme' preference), so the kit's dark/light emulation renders both modes.",
3
3
  "page": "/builders/city",
4
4
  "surface": "hall-ui",
5
- "stub": { "prefix": "/builders/" },
5
+ "stub": {
6
+ "prefix": "/builders/"
7
+ },
6
8
  "modeQuery": false,
7
- "ignoreRequests": ["/api/(gds|bongos)/", "fonts\\.g(oogleapis|static)\\.com"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b403\\b", "\\b404\\b", "Unauthorized", "Forbidden", "Not Found", "load failed", "fonts\\.g(oogleapis|static)\\.com"],
9
+ "ignoreRequests": [
10
+ "/api/(gds|bongos)/",
11
+ "fonts\\.g(oogleapis|static)\\.com"
12
+ ],
13
+ "ignoreConsole": [
14
+ "/api/(gds|bongos)/",
15
+ "\\b401\\b",
16
+ "\\b403\\b",
17
+ "\\b404\\b",
18
+ "Unauthorized",
19
+ "Forbidden",
20
+ "Not Found",
21
+ "load failed",
22
+ "fonts\\.g(oogleapis|static)\\.com"
23
+ ],
9
24
  "states": {
10
25
  "city": {
11
26
  "auth": true,
12
27
  "_": "The home: the wordmark, the switch, the city, start governing and the one line.",
13
- "actions": [["wait", 900]],
14
- "expect": { "visible": ["#cityStage", "#cityStart", "#cityLine", "#cityMode", "#app-topbar", "#fullscreen-toggle"], "href": [["#cityStart", "/builders/street"]] }
28
+ "actions": [
29
+ [
30
+ "wait",
31
+ 900
32
+ ]
33
+ ],
34
+ "expect": {
35
+ "visible": [
36
+ "#cityStage",
37
+ "#cityStart",
38
+ "#cityLine",
39
+ "#cityMode",
40
+ "#app-topbar",
41
+ "#fullscreen-toggle"
42
+ ],
43
+ "href": [
44
+ [
45
+ "#cityStart",
46
+ "/builders/street"
47
+ ]
48
+ ]
49
+ }
15
50
  },
16
51
  "fullscreen": {
17
52
  "auth": true,
18
53
  "_": "In the hall since task 1004544: the shell's full-screen switch pressed, the sidebar and the top bar gone, the city filling the window and the one exit in the corner (the sky's mechanism, task 1004327).",
19
- "actions": [["wait", 900], ["click", "#fullscreen-toggle"], ["wait", 500]],
20
- "expect": { "visible": ["#cityStage", "#cityStart", "#cityMode", "#fullscreen-toggle"], "hidden": ["#app-sidebar", "#topbar-title"], "attr": [["#fullscreen-toggle", "aria-pressed", "true"]] }
54
+ "actions": [
55
+ [
56
+ "wait",
57
+ 900
58
+ ],
59
+ [
60
+ "click",
61
+ "#fullscreen-toggle"
62
+ ],
63
+ [
64
+ "wait",
65
+ 500
66
+ ]
67
+ ],
68
+ "expect": {
69
+ "visible": [
70
+ "#cityStage",
71
+ "#cityStart",
72
+ "#cityMode",
73
+ "#fullscreen-toggle"
74
+ ],
75
+ "hidden": [
76
+ "#app-sidebar",
77
+ "#topbar-title"
78
+ ],
79
+ "attr": [
80
+ [
81
+ "#fullscreen-toggle",
82
+ "aria-pressed",
83
+ "true"
84
+ ]
85
+ ]
86
+ }
21
87
  },
22
88
  "switched": {
23
89
  "auth": true,
24
90
  "_": "The sun/moon switch pressed once: the other mode, the city redrawn in it.",
25
- "actions": [["wait", 600], ["click", "#cityMode"], ["wait", 400]],
26
- "expect": { "visible": ["#cityStage", "#cityStart"] }
91
+ "actions": [
92
+ [
93
+ "wait",
94
+ 600
95
+ ],
96
+ [
97
+ "click",
98
+ "#cityMode"
99
+ ],
100
+ [
101
+ "wait",
102
+ 400
103
+ ]
104
+ ],
105
+ "expect": {
106
+ "visible": [
107
+ "#cityStage",
108
+ "#cityStart"
109
+ ]
110
+ }
27
111
  },
28
112
  "focus": {
29
113
  "auth": true,
30
114
  "_": "Keyboard: the start button focused, its ring drawn.",
31
- "actions": [["wait", 600], ["focus", "#cityStart"], ["wait", 100]],
32
- "expect": { "activeId": "cityStart" }
115
+ "actions": [
116
+ [
117
+ "wait",
118
+ 600
119
+ ],
120
+ [
121
+ "focus",
122
+ "#cityStart"
123
+ ],
124
+ [
125
+ "wait",
126
+ 100
127
+ ]
128
+ ],
129
+ "expect": {
130
+ "activeId": "cityStart"
131
+ }
132
+ },
133
+ "pins": {
134
+ "auth": true,
135
+ "_": "The home city over the dense docket with the same pin tiers as the street (task 1004569): one pin per building, urgent largest and pulsing, needs you orange, moving small and ink, a count where a building holds more than one open matter.",
136
+ "actions": [
137
+ [
138
+ "wait",
139
+ 900
140
+ ],
141
+ [
142
+ "clickIfVisible",
143
+ "[data-craft-close]"
144
+ ],
145
+ [
146
+ "wait",
147
+ 300
148
+ ]
149
+ ],
150
+ "expect": {
151
+ "visible": [
152
+ "#cityPins",
153
+ ".city-pin--urgent",
154
+ ".city-pin--needs",
155
+ ".city-pin--moving",
156
+ ".city-pin__count"
157
+ ]
158
+ }
33
159
  }
34
160
  },
35
- "reducedMotion": { "auth": true, "actions": [["wait", 600]] }
161
+ "reducedMotion": {
162
+ "auth": true,
163
+ "actions": [
164
+ [
165
+ "wait",
166
+ 600
167
+ ]
168
+ ]
169
+ }
36
170
  }
@@ -84,6 +84,12 @@ body[data-page="street"] {
84
84
 
85
85
  /* more: the detail, in place */
86
86
  .mcard-more__toggle { margin-top: -2px; }
87
+ /* the pager: "1 of N" with previous and next, when a pin holds more than one matter (task 1004569) */
88
+ .mcard-pager { display: flex; align-items: center; gap: 4px; margin: -4px 0 2px; }
89
+ .mcard-pager__btn { width: 32px; height: 32px; border-radius: 999px; border: 1px solid var(--street-rule-strong); background: none; color: var(--ink); font-family: var(--font-body); font-size: 17px; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; padding: 0 0 2px; }
90
+ .mcard-pager__btn:hover { border-color: var(--ink-faint); }
91
+ .mcard-pager__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
92
+ .mcard-pager__at { min-width: 52px; text-align: center; font-size: 12px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
87
93
  .mcard-more { display: flex; flex-direction: column; gap: 6px; padding-top: 10px; border-top: 1px solid var(--street-rule); }
88
94
  .mcard-more__text { margin: 0 0 4px; font-size: 14px; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
89
95
  .mcard-more__meta { margin: 0; font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
@@ -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;