@bongos/core 1.24.38 → 1.24.40

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.
@@ -3,7 +3,8 @@
3
3
  // Metic+ monitoring view (Archon-only until ADR 0157 delegated the operational
4
4
  // permissions to the working rank). One self-contained page that stitches together
5
5
  // the oversight surfaces: pending access requests, security (red-team reports +
6
- // override queue + audit log), the builder roster, per-builder + project-wide
6
+ // override queue + audit log), the stale-claim tally (the roster itself is the
7
+ // Builders page since task 1005048), per-builder + project-wide
7
8
  // review grades, estimation drift, repo health, and a tally of recorded sessions.
8
9
  //
9
10
  // Note the page floor and the panel floors are no longer identical: the page and
@@ -165,7 +166,7 @@
165
166
  // NOTE: loadMarks is the deliberate outlier — its failure state is a plain
166
167
  // "empty" message, not "unavailable", so it keeps its own try/catch.
167
168
  // sealedText — name the floor the DENIAL reported, never one written here.
168
- // Each `sealedMsg` below is the SUBJECT clause only ("The roster is not visible
169
+ // Each `sealedMsg` below is the SUBJECT clause only ("The audit log is not visible
169
170
  // at your rank."); the rank sentence in front of it is composed from the 403.
170
171
  // That is what keeps this page from telling a builder a permission model the
171
172
  // server stopped implementing, which is the whole of task 1003449 — and why the
@@ -865,164 +866,19 @@
865
866
  }
866
867
 
867
868
  // =========================================================================
868
- // 3. ROSTER — every builder
869
+ // 3. STALE CLAIMS — a tally only (task 1005048)
869
870
  // =========================================================================
870
871
 
871
- // Live rank options for the per-row <select>. The server validates against
872
- // db.RANKS; these are the live, builder-facing tiers (ADR 0034). If a builder
873
- // sits at a rank outside this set, we inject it so the select still shows it.
874
- const RANK_OPTIONS = ['xenos', 'thetes', 'metic', 'archon'];
875
-
876
- // Who is reading this roster (task 1003720). `load()` already fetches /me to
877
- // resolve the page's floor and then threw the id away, so the roster — which
878
- // returns EVERY builder, the viewer included — could not tell which row was
879
- // the reader's own. See renderRoster for why that mattered.
880
- let viewerId = null;
881
- const isViewer = (b) => viewerId != null && b && b.id != null && String(b.id) === String(viewerId);
882
-
883
- function renderRoster(data, target) {
884
- const builders = Array.isArray(data.builders) ? data.builders : [];
885
- setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
886
- target.innerHTML = '';
887
- if (!builders.length) {
888
- target.innerHTML = emptyStateHtml('No builders on the roster.');
889
- return;
890
- }
891
- ledger(target, {
892
- columns: [
893
- 'Builder', 'Rank',
894
- { label: (window.OTBBranding?.currencyLabel?.({ caps: true }) ?? 'Credits'), class: 'num' },
895
- { label: 'Karma', class: 'num' }, { label: 'Active claims', class: 'num' },
896
- 'Standing', 'Action',
897
- ],
898
- pager: makePager('watch-roster', 'otb.watch.rosterPerPage'),
899
- rowHtml: (b) => {
900
- // THE VIEWER'S OWN ROW OFFERS NOTHING (task 1003720, re-filed from 1002951
901
- // — "I cannot press offboard", reported by the owner from Discord).
902
- //
903
- // The roster returns every builder, so the reader's own row is in it. Both
904
- // routes behind that row refuse a self-change OUTRIGHT and always will:
905
- // PATCH /builders/:id/rank answers cannot_change_own_rank and
906
- // /builders/:id/status answers cannot_change_own_status — the guard that
907
- // stops the last Archon demoting or offboarding themselves and locking
908
- // everyone out. The row painted a live rank <select> and a live Offboard
909
- // pill anyway, so from the owner's seat the page carried a control that
910
- // could only ever answer with a raw refusal slug in a toast. That is what
911
- // "I cannot press offboard" describes.
912
- //
913
- // This is not a SOFT gate (the primer's acknowledge control, which opens
914
- // once you have read enough and so stays in the tab order as
915
- // aria-disabled) — it is an action that can never open for this reader.
916
- // So the row renders no control at all and the section's foot line says
917
- // why, rather than painting a dead one.
918
- const self = isViewer(b);
919
- const opts = RANK_OPTIONS.slice();
920
- if (b.rank && !opts.includes(b.rank)) opts.unshift(b.rank);
921
- const rankCell = self
922
- ? `<span class="watch-self">${escapeHtml(rankLabel(b.rank))}</span>`
923
- : `<select data-roster-rank data-id="${escapeHtml(b.id)}" data-current="${escapeHtml(b.rank || '')}" aria-label="Rank">` +
924
- opts.map((rk) => `<option value="${escapeHtml(rk)}"${rk === b.rank ? ' selected' : ''}>${escapeHtml(rankLabel(rk))}</option>`).join('') +
925
- '</select>';
926
- const isInactive = String(b.status) === 'inactive';
927
- const statusBtn = isInactive
928
- ? ghost(`data-roster-status="active" data-id="${escapeHtml(b.id)}"`, 'Reactivate')
929
- : ghost(`data-roster-status="inactive" data-id="${escapeHtml(b.id)}"`, 'Offboard', true);
930
- const actionCell = self
931
- ? '<span class="watch-self">That\'s you</span>'
932
- : `<div class="watch-actions">${statusBtn}</div>`;
933
- const claims = num(b.active_claim_count);
934
- const staleFlag = b.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
935
- // task 1467: the roster name links to the builder's public profile page.
936
- const rosterName = escapeHtml(b.display_name || b.github_login || ('#' + b.id));
937
- const rosterNameCell = b.id != null
938
- ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${rosterName}</a>`
939
- : rosterName;
940
- return (
941
- '<tr>' +
942
- `<td>${rosterNameCell}` +
943
- (b.github_login ? ` <span class="ov-mono">@${escapeHtml(b.github_login)}</span>` : '') +
944
- '</td>' +
945
- `<td>${rankCell}</td>` +
946
- `<td class="num">${fmtInt(b.total_credits)}</td>` +
947
- `<td class="num">${fmtInt(b.karma)}</td>` +
948
- `<td class="num${b.stale_claim ? ' warn' : ''}">${fmtInt(claims)}${staleFlag}</td>` +
949
- `<td><span class="badge badge--status">${escapeHtml(b.status || '—')}</span></td>` +
950
- `<td>${actionCell}</td>` +
951
- '</tr>'
952
- );
953
- },
954
- }).render(builders);
955
-
956
- // The one asymmetry in the table, stated once under it rather than repeated
957
- // on the row. Appended as an ELEMENT, not with `innerHTML +=`: re-parsing the
958
- // body would drop the pager's own wiring. The rank is written from
959
- // rankLabel(), never a hardcoded "Archon", so the floor cannot drift.
960
- if (builders.some(isViewer)) {
961
- const note = document.createElement('p');
962
- note.className = 'ov-foot';
963
- note.textContent = `Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last ${rankLabel('archon')} locking everyone out — so another ${rankLabel('archon')} does it.`;
964
- target.appendChild(note);
965
- }
966
-
967
- // Rank change via the <select>. Bespoke: a 'change' listener that rolls the
968
- // select back to its previous value on failure, delegated so a page flip
969
- // cannot strand a row without one.
970
- if (!target['wired:rank']) {
971
- target['wired:rank'] = true;
972
- target.addEventListener('change', async (e) => {
973
- const sel = e.target.closest('[data-roster-rank]');
974
- if (!sel || !target.contains(sel)) return;
975
- const id = sel.getAttribute('data-id');
976
- const rank = sel.value;
977
- const prev = sel.getAttribute('data-current');
978
- if (rank === prev) return;
979
- sel.disabled = true;
980
- try {
981
- await patchJson(`/builders/${encodeURIComponent(id)}/rank`, { rank });
982
- toast(`Rank set to ${rankLabel(rank)}.`);
983
- await loadRoster();
984
- } catch (err) {
985
- if (err && err.kind === '401') return;
986
- sel.value = prev; // roll back the select on failure
987
- sel.disabled = false;
988
- toast(`Could not change rank: ${err.message || err}`);
989
- }
990
- });
991
- }
992
-
993
- // Status change (offboard / reactivate). Bespoke: it gathers an offboarding
994
- // reason via window.prompt BEFORE disabling, so not delegate().
995
- if (!target['wired:status']) {
996
- target['wired:status'] = true;
997
- target.addEventListener('click', async (e) => {
998
- const btn = e.target.closest('[data-roster-status]');
999
- if (!btn || !target.contains(btn)) return;
1000
- const id = btn.getAttribute('data-id');
1001
- const status = btn.getAttribute('data-roster-status');
1002
- let reason = null;
1003
- if (status === 'inactive') {
1004
- reason = window.prompt('Reason for offboarding (optional):', '') || null;
1005
- }
1006
- btn.disabled = true;
1007
- try {
1008
- await patchJson(`/builders/${encodeURIComponent(id)}/status`, { status, reason });
1009
- toast(status === 'inactive' ? 'Builder offboarded.' : 'Builder reactivated.');
1010
- await loadRoster();
1011
- } catch (err) {
1012
- if (err && err.kind === '401') return;
1013
- btn.disabled = false;
1014
- toast(`Could not change standing: ${err.message || err}`);
1015
- }
1016
- });
1017
- }
1018
- }
1019
-
1020
- function loadRoster() {
1021
- return section($('#roster-body'), () => fetchJson('/builders/roster'), renderRoster, {
1022
- sealedMsg: 'The roster is not visible at your rank.',
1023
- errMsg: 'Could not load the roster.',
1024
- name: 'roster',
1025
- });
872
+ // The roster that lived here is the Builders page now (owner decision on idea
873
+ // 1001313: one list of builders). The attention tile keeps its count, read
874
+ // from the same route, and links to that list filtered to the stale claims.
875
+ function loadStaleCount() {
876
+ return fetchJson('/builders/roster')
877
+ .then((data) => {
878
+ const builders = Array.isArray(data && data.builders) ? data.builders : [];
879
+ setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
880
+ })
881
+ .catch(() => setAttention('att-stale', null));
1026
882
  }
1027
883
 
1028
884
  // =========================================================================
@@ -1411,7 +1267,7 @@
1411
1267
 
1412
1268
  function revealSections() {
1413
1269
  [
1414
- 'watch-attention', 'watch-tabs', 'gate-scroll', 'watch-scroll', 'roster-scroll',
1270
+ 'watch-attention', 'watch-tabs', 'gate-scroll', 'watch-scroll',
1415
1271
  'marks-scroll', 'mason-marks-scroll', 'surveyor-scroll', 'ledger-scroll', 'sessions-scroll',
1416
1272
  ].forEach((id) => {
1417
1273
  const el = document.getElementById(id);
@@ -1441,11 +1297,6 @@
1441
1297
  if (!res.ok) throw new Error(`me → ${res.status}`);
1442
1298
  const payload = res.data;
1443
1299
  builder = (payload && payload.builder) ? payload.builder : payload;
1444
- // Kept for the roster, which must know which row is the reader's own
1445
- // (task 1003720). Null when /me answered without one — isViewer then
1446
- // matches nothing, so the roster degrades to the old every-row-acts shape
1447
- // rather than marking the wrong row as yours.
1448
- viewerId = (builder && builder.id != null) ? builder.id : null;
1449
1300
  } catch (err) {
1450
1301
  console.error('[watch] failed to read viewer', err);
1451
1302
  showSealed('Could not check your access. Reload to try again.', 'Could not load');
@@ -1454,7 +1305,7 @@
1454
1305
  }
1455
1306
 
1456
1307
  // ADR 0157: Watch sits at the `page.view.government` floor (metic), and every panel
1457
- // it reads (roster, security reports, audit log, sessions, box costs) delegated to
1308
+ // it reads (stale claims, security reports, audit log, sessions, box costs) delegated to
1458
1309
  // metic with it. Cosmetic — requireGovernmentPage + the route gates are the wall.
1459
1310
  if (!builder || (builder.rank !== 'metic' && builder.rank !== 'archon')) {
1460
1311
  showSealed(`Available from the ${rankLabel('metic')} rank up. Your current rank does not grant access.`);
@@ -1469,7 +1320,7 @@
1469
1320
  loadEngagementDocs();
1470
1321
  loadOverrides();
1471
1322
  loadAudit();
1472
- loadRoster();
1323
+ loadStaleCount();
1473
1324
  loadMarks();
1474
1325
  loadMasonMarks();
1475
1326
  loadSurveyor();
@@ -37,3 +37,5 @@ The owner's Studio idea 1001297 (a circuit-trace image as a table divider; mock
37
37
  - **The pseudo-elements sit on the row's first `td`, never on the `tr`.** On the `tr` they became two extra table cells and pushed every cell in the row two columns right (seen in the first render). The `tr` only carries `position: relative`, so the `td`'s absolute children size against the whole row.
38
38
  - **Two masks, two tokens.** Traces are `--ink-faint` and the orange nodes `--accent`, each shown through an SVG mask, so light and dark follow the world with no hard-coded colour. Left cap, right cap and the stretching hairline are three layers of one mask.
39
39
  - **The empty-state row has no trace** (`:not(:has(.ledger__empty))`). Checked at desktop and 375px, light and dark: the table scrolls inside its card on a phone and the divider spans it; the page does not scroll sideways.
40
+
41
+ **Since task 1005048 it is the one list.** Watch's own roster folded in: a viewer the `GET /builders/roster` route lets in also sees active claims, the stale flag, standing and the rank/offboard controls, and the route's refusal leaves this page exactly the public list described above.
@@ -106,3 +106,15 @@ Owner decision 2026-10-04: the street should read at a glance without words, so
106
106
  - **The evening view is unchanged** ("nothing waits on you." and its line still show when nothing needs you).
107
107
 
108
108
  Tests: `tests/hall_street.mjs` asserts the model has no hint field, the module exports no `HINT`, and none of the street's four files carries the line; putting the markup or the constant back reds it.
109
+
110
+ ## The hall is for work: the street as a square, the building as a place to work (owner, 2026-11-04)
111
+
112
+ The owner's direction: the builders' hall is where you go to do things, not to look at them. No menu on menu, no pop-ups (go to the place), no page header or footer that a web app does not need. On the street that meant:
113
+
114
+ - **The street is the governance square.** The working areas are the city's own buildings, so no district stands on the street: `streetModel()` draws one only with `districts: true` (the kit's tests still hold that row to the prototype), and without it the civic row is centred on the artboard. A wide window scales the street to the row (`FOCUS_W`, 1120), not the whole 1440 artboard, so the buildings are as large as the height allows and the haze either side is cropped; a phone still scrolls it sideways and opens at the first building.
115
+ - **The header strip and the six readings are gone** (`.street-top`, `.street-signs` are not displayed; the markup stays, so the counts, the deck link and the readings still compute). The shell's top bar names the page and the buildings carry their own state lines.
116
+ - **A pin takes you into its building** (`#building=<key>`), where each matter is a desk row you act on. It no longer opens the short card; the card is reached only by a pin with no building, and retiring it is a follow-up.
117
+ - **Inside a building the drawing is the floor picker.** On a wide window (`.inside--work`) the interior is laid out, not scaled as one 1440 x 708 picture: the section is cropped to the building (`CROP` in `fitInside`, 590 x 580 of the 800 x 708 section), at 0.5 to 0.72 so a floor stays a 24 px target, in its own column; the chosen floor's desk takes the rest of the window and scrolls on its own. The building's name sits on one line, then the desk, then the government layer, who holds it and how it decides, then the record. A phone keeps its column with the same order. A district keeps its scaled board.
118
+ - **A desk row is one full line.** The board's 20 / 26 / 24 caps were sized for a desk in a corner of the interior; `LIMITS` is now name 90 (14 words), fact 60, facts 48.
119
+
120
+ Tests: `tests/hall_street.mjs` (nine doors and no district on the page, a pin goes inside and fills no card, the row scaled and centred, a phone opening at the first building), `tests/hall_street_inside.mjs` (the cropped picker, never under half size, the board not scaled, a district untouched), `tests/hall_street_depth.mjs` (with no plot to fly from every agent rides the road), `tests/hall_street_desk.mjs` (the new limits).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.38",
3
+ "version": "1.24.40",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.38",
9
+ "version": "1.24.40",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.38",
3
+ "version": "1.24.40",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11191,5 +11191,17 @@
11191
11191
  "id": "1005071",
11192
11192
  "text": "The hall sidebar is now seven labelled sections (Work, Make, Community, You, Govern, Run the project, Help), and every craft can reach Help."
11193
11193
  }
11194
+ ],
11195
+ "1.24.39": [
11196
+ {
11197
+ "id": "1005077",
11198
+ "text": "The governor street is now a work-first square: the working areas left the street, the extra header and footer are gone, a pin takes you straight into its building, and inside a building the drawing is a compact floor picker s"
11199
+ }
11200
+ ],
11201
+ "1.24.40": [
11202
+ {
11203
+ "id": "1005048",
11204
+ "text": "The Builders page is now the one list of everyone who builds here. If you hold a government seat you also see each builder's active claims, any stale claim, their standing, and the controls to change a rank or offboard someone"
11205
+ }
11194
11206
  ]
11195
11207
  }
@@ -146,7 +146,9 @@ const discordIsLinked = (builderId) =>
146
146
  // buttons for a viewer who cannot vote on ideas, without re-deriving it from rank.
147
147
  // `blocker.review` (task 1004824): the Blockers page and its overlay offer Resolve and
148
148
  // Link only to a builder who holds it; POST /blockers/:id/{resolve,link} stays the wall.
149
- const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage', 'board.item.open', 'idea.vote', 'blocker.review']);
149
+ // `builder.roster.read` (task 1005048): the Builders page asks GET /builders/roster
150
+ // for its government columns only of a viewer who holds it; the route stays the wall.
151
+ const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage', 'board.item.open', 'idea.vote', 'blocker.review', 'builder.roster.read']);
150
152
 
151
153
  // navPermissionsFor(builderId) → { '<atom>': boolean } over NAV_PERMISSION_ATOMS,
152
154
  // or null when authority could not be established (government off, port missing,
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.38'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.40'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -290,9 +290,16 @@ const BASE = (d) => ({
290
290
  '/api/bongos/government/constitution': { board: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true } },
291
291
  });
292
292
 
293
- test('a pin opens the full card: the ballot is read, then the seats, counsel and acts are drawn as text', async () => {
293
+ // A pin goes into its building now (owner, 2026-11-04: no pop-ups); the card
294
+ // still opens for a pin with no building, so these tests open it directly.
295
+ const openCard = (p) => {
296
+ const pin = p.byId.get('streetPins').children[0];
297
+ p.window.GovStreetPage.openCard(pin.attrs['data-matter'], pin, pin.attrs['data-matters'].split(' '));
298
+ };
299
+
300
+ test('the full card: the ballot is read, then the seats, counsel and acts are drawn as text', async () => {
294
301
  const p = await bootPage({ feeds: BASE(docket([matter({ title: '<b>x</b>' })])) });
295
- p.byId.get('streetPins').children[0].fire('click');
302
+ openCard(p);
296
303
  await settle(); await settle();
297
304
  assert.ok(p.calls.some((c) => c.url === '/api/bongos/government/board/items'), 'the room is read when a sitting opens');
298
305
  const body = p.byId.get('streetCardBody');
@@ -308,7 +315,7 @@ test('vote for: one POST to the vote route, a toast, the docket re-read, and the
308
315
  const feeds = BASE((() => { const d0 = docket([matter()]); return () => (reads++ === 0 ? d0 : after); })());
309
316
  feeds['/api/bongos/government/docket'] = (() => { const d0 = docket([matter()]); return () => (reads++ === 0 ? d0 : after); })();
310
317
  const p = await bootPage({ feeds, posts: { 'POST /api/bongos/government/board/items/41/votes': { ok: true, vote: {}, already_voted: false, karma_delta: 1 } } });
311
- p.byId.get('streetPins').children[0].fire('click');
318
+ openCard(p);
312
319
  await settle(); await settle();
313
320
  byAct(p.byId.get('streetCardBody'), 'for').fire('click');
314
321
  await settle(); await settle(); await settle();
@@ -324,7 +331,7 @@ test('vote for: one POST to the vote route, a toast, the docket re-read, and the
324
331
 
325
332
  test('object: "no" opens the desk row\'s one-tap reasons, and a tap puts the reason and the part on the record', async () => {
326
333
  const p = await bootPage({ feeds: BASE(docket([matter()])), posts: { 'POST /api/bongos/government/board/items/41/votes': { ok: true, vote: {}, item_closed: true, outcome: 'returned' } } });
327
- p.byId.get('streetPins').children[0].fire('click');
334
+ openCard(p);
328
335
  await settle(); await settle();
329
336
  const body = () => p.byId.get('streetCardBody');
330
337
  assert.equal(byAct(body(), 'object').textContent, D.ACT_WORDS.object, 'the desk row\'s word');
@@ -341,7 +348,7 @@ test('object: "no" opens the desk row\'s one-tap reasons, and a tap puts the rea
341
348
 
342
349
  test('a refused act says why and records nothing; abstain sends nothing at all', async () => {
343
350
  const p = await bootPage({ feeds: BASE(docket([matter()])), posts: { 'POST /api/bongos/government/board/items/41/votes': { __status: 403, error: { code: 'not_board_member' } } } });
344
- p.byId.get('streetPins').children[0].fire('click');
351
+ openCard(p);
345
352
  await settle(); await settle();
346
353
  byAct(p.byId.get('streetCardBody'), 'for').fire('click');
347
354
  await settle(); await settle();
@@ -361,7 +368,7 @@ test('release: the override is approved, the card closes, Release rises, and wit
361
368
  feeds['/api/bongos/government/docket'] = () => (reads++ === 0 ? docket([ov]) : docket([]));
362
369
  const p = await bootPage({ feeds, posts: { 'POST /api/bongos/override-requests/9/decide': { ok: true } } });
363
370
  assert.equal(p.byId.get('streetEvening').hidden, true);
364
- p.byId.get('streetPins').children[0].fire('click');
371
+ openCard(p);
365
372
  byAct(p.byId.get('streetCardBody'), 'release').fire('click');
366
373
  await settle(); await settle(); await settle();
367
374
  assert.deepEqual(p.calls.filter((c) => c.method !== 'GET').map((c) => [c.method, c.url, c.body]), [['POST', '/api/bongos/override-requests/9/decide', { decision: 'approve' }]]);
@@ -476,7 +483,7 @@ test('more expands the same card in place: the full text, who and when, and the
476
483
  const { d } = SOURCE_FIXTURES();
477
484
  d.matters = d.matters.filter((m) => m.source === 'blockers');
478
485
  const p = await bootPage({ feeds: { ...BASE(d) } });
479
- p.byId.get('streetPins').children[0].fire('click');
486
+ openCard(p);
480
487
  await settle(); await settle();
481
488
  const body = () => p.byId.get('streetCardBody');
482
489
  const toggle = () => byAct(body(), 'more');
@@ -72,7 +72,9 @@ function docket(matters = [], over = {}) {
72
72
  }
73
73
  const goal = (id, status = 'open', title = `Goal ${id}`) => ({ id: String(id), title, status });
74
74
  const rollup = (id, c) => ({ goal_id: String(id), done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, ...c });
75
- const model = (over = {}) => S.streetModel({ docket: docket(), goals: [], rollups: [], mode: 'dark', now: NOW, ...over });
75
+ // The kit is held to the prototype's row, districts and all (`districts: true`); the
76
+ // page itself draws the civic row alone (owner, 2026-11-04), pinned further down.
77
+ const model = (over = {}) => S.streetModel({ docket: docket(), goals: [], rollups: [], mode: 'dark', now: NOW, districts: true, ...over });
76
78
 
77
79
  // ── the drawing is the prototype's ───────────────────────────────────────────
78
80
 
@@ -299,7 +301,7 @@ test('the collision pass places in tier order: when only one of two can show, th
299
301
  });
300
302
 
301
303
  test('no docket: no pins, the readings say so, and nothing is invented', () => {
302
- const m = S.streetModel({ docket: null, goals: [goal(1)], rollups: [], mode: 'dark', now: NOW });
304
+ const m = S.streetModel({ docket: null, goals: [goal(1)], rollups: [], mode: 'dark', now: NOW, districts: true });
303
305
  assert.equal(m.pins.length, 0);
304
306
  assert.equal(m.crates.length, 0);
305
307
  assert.ok(m.signs.every((s) => s.figure === '—' && !s.hot && s.line === S.FALLBACK_LINE));
@@ -617,32 +619,30 @@ test('the page reads the four feeds and draws the docket\'s pins, the readings a
617
619
  assert.equal(p.byId.get('hudMoving').textContent, '2');
618
620
  assert.equal(p.byId.get('hudShare').textContent, '$24');
619
621
  assert.equal(p.byId.get('hudDot').className, 'street-hud__dot street-hud__dot--hot');
620
- assert.equal(p.byId.get('streetDoors').children.length, 10, 'nine buildings and one district, each a door');
622
+ assert.equal(p.byId.get('streetDoors').children.length, 9, 'the nine civic buildings, each a door; no district stands on the street (owner, 2026-11-04)');
623
+ assert.ok(p.byId.get('streetDoors').children.every((a) => !/district=/.test(a.attrs.href)), 'no door to a working area');
621
624
  assert.equal(p.byId.get('streetHome').attrs.href, '/builders/city');
622
625
  });
623
626
 
624
- test('a pin opens its short card, Escape closes it and focus goes back to the pin', async () => {
625
- const p = await bootPage({ feeds: FEEDS([matter({ id: 'board:1', building: 'charter', title: '<img src=x onerror=alert(1)>', href: '/builders/board-room' })]) });
627
+ // Owner, 2026-11-04: no pop-ups, go to the place. A pin takes you into its
628
+ // building, where each matter is a desk row you act on; it opens no card.
629
+ test('a pin takes you into its building and opens no card', async () => {
630
+ const p = await bootPage({ feeds: FEEDS([matter({ id: 'board:1', building: 'charter', title: 'A vote', href: '/builders/board-room' })]) });
626
631
  const pin = p.byId.get('streetPins').children[0];
632
+ assert.equal(pin.attrs['data-building'], 'charter');
627
633
  pin.fire('click');
628
- const card = p.byId.get('streetCard');
629
- assert.equal(card.hidden, false);
630
- assert.equal(p.byId.get('streetCardTitle').textContent, '<img src=x onerror=alert(1)>', 'docket text lands as text');
631
- assert.equal(p.byId.get('streetCardOpen').attrs.href, undefined, 'the card links nowhere: it acts in place (task 1004566)');
632
- assert.equal(p.focused.el, p.byId.get('streetCardClose'));
633
- assert.equal(p.byId.get('streetPins').children[0].attrs['aria-expanded'], 'true');
634
- const e = { key: 'Escape', prevented: false, preventDefault() { this.prevented = true; } };
635
- p.key('keydown', e);
636
- assert.equal(card.hidden, true);
637
- assert.equal(e.prevented, true, 'the press is used, so nothing else answers it');
638
- assert.equal(p.focused.el.attrs['data-matter'], 'board:1');
639
- });
640
-
641
- 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 () => {
634
+ assert.equal(p.window.location.hash, 'building=charter', 'the address names the building, so its inside opens');
635
+ assert.equal(p.byId.get('streetCardTitle').textContent, '', 'no card is filled');
636
+ assert.notEqual(p.byId.get('streetPins').children[0].attrs['aria-expanded'], 'true', 'and the pin does not say one is open');
637
+ });
638
+
639
+ // The card itself still works for the code paths that open it (a pin with no
640
+ // building); its pager is held here by opening it directly.
641
+ test('the card pager: a card over several matters opens the most important with "1 of N", and previous/next reach every matter there', async () => {
642
642
  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' })];
643
643
  const p = await bootPage({ feeds: FEEDS(ms) });
644
644
  const pin = p.byId.get('streetPins').children.find((s) => s.attrs['data-building'] === 'planning');
645
- pin.fire('click');
645
+ p.window.GovStreetPage.openCard(pin.attrs['data-matter'], pin, pin.attrs['data-matters'].split(' '));
646
646
  const body = p.byId.get('streetCardBody');
647
647
  const pager = () => body.children[0];
648
648
  const btn = (dir) => pager().children.find((c) => c.attrs['data-act'] === 'page-' + dir);
@@ -664,7 +664,8 @@ test('the card pager: a pin with several matters opens the most important with "
664
664
  assert.equal(p.focused.el.attrs['data-building'], 'planning', 'Escape returns focus to the pin');
665
665
  // one matter: no pager
666
666
  const q = await bootPage({ feeds: FEEDS([matter({ id: 'board:1', building: 'charter' })]) });
667
- q.byId.get('streetPins').children[0].fire('click');
667
+ const one = q.byId.get('streetPins').children[0];
668
+ q.window.GovStreetPage.openCard(one.attrs['data-matter'], one, one.attrs['data-matters'].split(' '));
668
669
  assert.ok(!q.byId.get('streetCardBody').children.some((c) => c.className === 'mcard-pager'), 'one matter, no pager');
669
670
  });
670
671
 
@@ -737,17 +738,22 @@ test('arriving through a door marks that building, and a failed docket says so',
737
738
  assert.equal(q.byId.get('hudShare').textContent, '—');
738
739
  });
739
740
 
740
- // task 1004718: a wide window scales the street to its width, and the height
741
- // it does not need goes under the readings, never as empty sky over the street.
742
- test('a stage taller than the street gives its spare height to the foot, not the sky', async () => {
741
+ // Owner, 2026-11-04 (supersedes task 1004718's foot): the readings left the
742
+ // page, so a wide window scales the street to its civic row, not the whole
743
+ // artboard, crops the haze either side, and centres the street in its stage.
744
+ test('a wide window scales the street to its civic row and centres it; a phone scrolls it from the first building', async () => {
743
745
  const p = await bootPage({ feeds: FEEDS([]), width: 1440 });
744
- const win = p.byId.get('streetWindow'), sizer = p.byId.get('streetSizer');
745
- win.clientHeight = 900;
746
- p.key('resize', {});
747
- const h = parseFloat(sizer.style.height);
748
- assert.ok(h < 900, 'the street is width-bound here');
749
- assert.equal(win.style.flex, '0 0 ' + h + 'px', 'the window shrinks to the street, so the readings sit right under it');
750
- win.clientHeight = 300;
746
+ const win = p.byId.get('streetWindow'), sizer = p.byId.get('streetSizer'), board = p.byId.get('streetBoard');
747
+ win.clientWidth = 1376; win.clientHeight = 844;
751
748
  p.key('resize', {});
752
- assert.equal(win.style.flex, '', 'a height-bound street fills its window, nothing to give back');
749
+ const s = Number(board.style['--street-scale']);
750
+ assert.ok(s > 1376 / 1440, 'larger than the whole artboard would allow');
751
+ assert.equal(win.style.flex, '', 'the window keeps the stage, so the street sits centred in it');
752
+ assert.equal(win.style.overflowX, 'hidden', 'no sideways scroll on a wide window');
753
+ assert.equal(parseFloat(sizer.style.width), 1376, 'the sizer is the window');
754
+ assert.ok(parseFloat(board.style.left) < 0, 'the artboard is centred, its haze cropped either side');
755
+ const q = await bootPage({ feeds: FEEDS([]), width: 390 });
756
+ const qwin = q.byId.get('streetWindow');
757
+ assert.equal(qwin.style.overflowX, '', 'a phone scrolls the street sideways');
758
+ assert.ok(qwin.scrollLeft > 0, 'starting at the first building, not the empty haze before it');
753
759
  });
@@ -70,7 +70,7 @@ function feeds(over = {}) {
70
70
  function living(f) { return L.livingModel({ ...f, now: NOW }); }
71
71
  function street(over = {}, mode = 'dark') {
72
72
  const f = feeds(over);
73
- return S.streetModel({ docket: f.docket, goals: f.goals.goals, rollups: f.rollups.rollups, mode, now: NOW, depth: true, living: f.living === null ? null : living(f), roster: f.roster });
73
+ return S.streetModel({ docket: f.docket, goals: f.goals.goals, rollups: f.rollups.rollups, mode, now: NOW, depth: true, living: f.living === null ? null : living(f), roster: f.roster, districts: true });
74
74
  }
75
75
  const openGoals = () => fx('goals').goals.filter((g) => g.status === 'open');
76
76
 
@@ -338,7 +338,7 @@ test('an overlay handed in changes nothing; the Leads not read, no Lead, capsule
338
338
  const c = street({ leads: null }).depth;
339
339
  assert.deepEqual([c.plates.length, c.capsules.length, c.sites.length], [0, 0, 0], 'no Leads read: no plate, no capsule, no build-out going up');
340
340
  // without the living model at all the deep street still draws its real skyline and windows
341
- const none = S.streetModel({ docket: fx('government__docket'), goals: fx('goals').goals, rollups: fx('goals__rollups').rollups, mode: 'dark', now: NOW, depth: true, living: null, roster: null }).depth;
341
+ const none = S.streetModel({ docket: fx('government__docket'), goals: fx('goals').goals, rollups: fx('goals__rollups').rollups, mode: 'dark', now: NOW, depth: true, living: null, roster: null, districts: true }).depth;
342
342
  assert.deepEqual(none.clusters, a.clusters);
343
343
  assert.deepEqual(none.windows, street({ leads: null, docket: fx('government__docket') }).depth.windows, 'no living model and the hub as it is light the same, the docket\'s own');
344
344
  assert.equal(none.agents, 0);
@@ -361,7 +361,7 @@ test('street-depth.js: its model half is pure, and the two drawing files require
361
361
  const before = globalThis.GovStreetDepth;
362
362
  delete globalThis.GovStreetDepth;
363
363
  try {
364
- const flat = S.streetModel({ docket: fx('government__docket'), goals: openGoals(), rollups: fx('goals__rollups').rollups, mode: 'dark', now: NOW, depth: true });
364
+ const flat = S.streetModel({ docket: fx('government__docket'), goals: openGoals(), rollups: fx('goals__rollups').rollups, mode: 'dark', now: NOW, depth: true, districts: true });
365
365
  assert.equal(flat.depth, undefined);
366
366
  } finally { globalThis.GovStreetDepth = before; }
367
367
  });
@@ -500,9 +500,11 @@ test('the page draws the living street: the skyline, a pod per claim, the plates
500
500
  assert.equal(cls('streetDepthFar', 'street-depth-sil__face').length, 1);
501
501
  const claims = fx('tasks__claimable').active_claims.length;
502
502
  assert.equal(cls('streetDepthSky', 'street-depth-pod').length + cls('streetDepthFront', 'street-depth-pod').length, claims, 'one pod per agent');
503
- const pod = cls('streetDepthSky', 'street-depth-pod')[0];
504
- assert.match(pod.style.offsetPath, /^path\('M/);
505
- assert.match(pod.style.animationDelay, /^-[\d.]+s$/);
503
+ // The page stands no district (owner, 2026-11-04), so no claim flies a skylane
504
+ // from its goal's plot: every agent rides the road (the kit's skylanes are held
505
+ // with districts on, above).
506
+ assert.equal(cls('streetDepthSky', 'street-depth-pod').length, 0, 'no plot to fly from');
507
+ assert.equal(cls('streetDepthFront', 'street-depth-pod--road').length, claims, 'every agent on the road');
506
508
  assert.equal(cls('streetDepthFront', 'street-depth-plate').length, 9);
507
509
  assert.equal(all('streetDepthFront').concat(all('streetDepthSky')).filter((e) => 'data-proto' in e.attrs).length, 0, 'nothing is marked prototype');
508
510
  assert.equal(cls('streetDepthFront', 'street-depth-jib').length, 1, 'the live site\'s crane');
@@ -105,11 +105,14 @@ test('fit() is the board\'s fit(), with a word cap on top', () => {
105
105
  for (const n of [5, 12, 20]) for (const w of [1, 2, 3]) assert.ok(D.fit(LONG, n, w).length <= n, 'never longer than its cap');
106
106
  });
107
107
 
108
- test('the limits are the board\'s numbers: name 20, fact 26, facts 24 (three), status 44, record 30 (four), three tiles, ten pips', () => {
108
+ // A row's name and fact were the board's 20 / 26 / 24 while the desk was a
109
+ // corner of the interior; the desk takes the window's width now (owner,
110
+ // 2026-11-04), so a row is one full line: name 90 (14 words), fact 60, facts 48.
111
+ test('the limits: a row is one full line (name 90, fact 60, facts 48, three), status 44, record 30 (four), three tiles, ten pips', () => {
109
112
  const L = D.LIMITS;
110
- assert.deepEqual([L.name, L.nameWords, L.fact, L.factLen, L.facts, L.actions, L.status, L.recordLen, L.record, L.tiles, L.tileWords, L.todayTarget], [20, 3, 26, 24, 3, 2, 44, 30, 4, 3, 2, 10]);
113
+ assert.deepEqual([L.name, L.nameWords, L.fact, L.factLen, L.facts, L.actions, L.status, L.recordLen, L.record, L.tiles, L.tileWords, L.todayTarget], [90, 14, 60, 48, 3, 2, 44, 30, 4, 3, 2, 10]);
111
114
  const src = fs.readFileSync(path.join(ROOT, 'docs/design/mocks/governor-city/Desks.dc.html'), 'utf8');
112
- for (const pin of ['this.fit(r.t, 20)', 'this.fit(r.f, 26)', 'this.fit(t, 24)', '.slice(0, 3)', "this.fit('nothing needs you · ' + F.quiet, 44)", "this.fit(t + ' · now', 30)", '.slice(0, 4)', 'const target = 10']) assert.ok(src.includes(pin), `the board says ${pin}`);
115
+ for (const pin of ['.slice(0, 3)', "this.fit('nothing needs you · ' + F.quiet, 44)", "this.fit(t + ' · now', 30)", '.slice(0, 4)', 'const target = 10']) assert.ok(src.includes(pin), `the board says ${pin}`);
113
116
  });
114
117
 
115
118
  test('THE LINE RULE: no row, tile or status any docket produces breaks it, on every floor of every building', () => {
@@ -140,8 +143,8 @@ test('breaks() names every way a desk can break the rule (so the rule test can f
140
143
  const good = D.deskModel({ key: 'people', floor: 4, docket: dk, now: NOW });
141
144
  assert.deepEqual(D.breaks(good), []);
142
145
  const bad = j(good);
143
- bad.rows[0].name = 'Four words in here';
144
- bad.rows[0].fact = 'x'.repeat(27);
146
+ bad.rows[0].name = Array(16).fill('word').join(' ');
147
+ bad.rows[0].fact = 'x'.repeat(61);
145
148
  bad.rows[0].actions = [{ label: 'a' }, { label: 'b' }, { label: 'c' }];
146
149
  bad.rows[0].facts = ['a', 'b', 'c', 'd'];
147
150
  bad.tiles = [{ v: '0', l: 'need you' }, { v: '3', l: 'three whole words' }, { v: '4', l: 'ok' }, { v: '5', l: 'ok' }];
@@ -555,6 +555,24 @@ test('the markup carries the interior: one persistent lift, two flip blocks, the
555
555
  assert.match(html, /<button type="button" class="inside-textbtn" id="insideAmend"/);
556
556
  });
557
557
 
558
+ // Owner, 2026-11-04: a building is a place to work. On a wide window the
559
+ // drawing is a floor picker cropped to the building, at half size or more (a
560
+ // floor stays a 24 px target), and the desk has the rest of the window; the
561
+ // whole interior is no longer one scaled picture. A district keeps its board.
562
+ test('inside a building the drawing is a cropped floor picker, never under half size, and the board is not scaled', async () => {
563
+ const p = await bootPage({ hash: '#building=planning', feeds: FEEDS([]) });
564
+ const box = p.byId.get('insideArtBox'), art = p.byId.get('insideArt'), board = p.byId.get('insideBoard');
565
+ const k = Number(box.style['--inside-art-scale']);
566
+ assert.ok(k >= 0.5 && k <= 0.72, `picker scale ${k}`);
567
+ assert.equal(board.style['--inside-scale'], '1', 'the interior is laid out, not shrunk as one picture');
568
+ assert.equal(art.style['--inside-art-w'], Math.round(590 * k) + 'px', 'cropped to the building, not the 800 px section');
569
+ assert.equal(box.style['--inside-art-x'], Math.round(-50 * k) + 'px');
570
+ assert.ok(47 * k >= 24, 'a floor stays a 24 px target');
571
+ const d = await bootPage({ hash: '#district=1', feeds: FEEDS([]) });
572
+ assert.equal(d.byId.get('insideArtBox').style['--inside-art-scale'], '1', 'a district keeps its whole board');
573
+ assert.equal(d.byId.get('insideArtBox').style['--inside-art-x'], undefined, 'and is not cropped');
574
+ });
575
+
558
576
  test('a building address draws the interior in place of the street, from the docket', async () => {
559
577
  const ms = [matter({ id: 'access_requests:1', source: 'access_requests', building: 'people', state: 'needs', title: 'Admit Kai' })];
560
578
  const p = await bootPage({ hash: '#building=people', feeds: FEEDS(ms) });