@bongos/core 1.21.12 → 1.21.14

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.
@@ -0,0 +1,176 @@
1
+ // tests/autobongos_host_access.mjs — the runner skips work it structurally
2
+ // cannot do (task 1004537).
3
+ //
4
+ // The runner machine holds no SSH key, no root and no service environment for a
5
+ // live droplet (ADR 0031 §6 / ADR 0055). Tasks 1003969 and 1003980 were still
6
+ // picked, three times between them, and each run burned a claim and filed an
7
+ // owner blocker saying the work was never doable there. These tests hold the
8
+ // fence that stops that: a host-access task is SKIPPED, before any claim, with
9
+ // the reason `needs_host_access` in nothing_claimable's skipped[], and an
10
+ // ordinary code task beside it is still picked.
11
+
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const fence = require('../modules/autonomy/fence.js');
20
+ const runner = require('../scripts/gds/autobongos-run.js');
21
+
22
+ const { needsHostAccess, decideTask } = fence;
23
+
24
+ // The two real shapes, excerpted from their task rows (touches, tags and
25
+ // manual_degree exactly as they were: nothing explicit marked either one).
26
+ const T1003969 = {
27
+ id: 1003969, goal_id: 1000119, kind: 'bug', status: 'ready', requires_rank: 'xenos',
28
+ touches: ['docs/recipes/upgrading-the-core.md'], manual_degree: null, automation_tag: null,
29
+ title: 'Install the core-update-subscription timer at the declared 15min cadence on the cloudbongos.com control plane',
30
+ description: [
31
+ 'WHAT REMAINS (host state, not in this repo): the systemd unit lives on the droplet.',
32
+ '/etc/systemd/system/cloudbongos-core-update-subscription.timer is still OnCalendar=daily with RandomizedDelaySec=1800. Change it to:',
33
+ ' OnCalendar=*:0/15',
34
+ 'then `sudo systemctl daemon-reload && sudo systemctl restart cloudbongos-core-update-subscription.timer`',
35
+ ].join('\n'),
36
+ };
37
+ const T1003980 = {
38
+ id: 1003980, goal_id: 1000119, kind: 'bug', status: 'ready', requires_rank: 'xenos',
39
+ touches: [], manual_degree: null, automation_tag: null,
40
+ title: 'Recover the five lost bug-attachment screenshots from the co-hosting box, or accept the loss on the record',
41
+ description: [
42
+ 'WHAT IS LEFT. ADR 0126 kept the old co-hosting droplet as a rollback net after the 2026-07-05 move.',
43
+ 'If its var/bug-attachments survived, the five files are recoverable by copying them onto the control-plane droplet at BUG_ATTACHMENTS_DIR.',
44
+ 'WHY IT IS A SEPARATE ROW. It needs an interactive session on two live boxes - look for var/bug-attachments on the old droplet,',
45
+ 'copy across, set BUG_ATTACHMENTS_DIR in the service environment, confirm the probe goes green.',
46
+ 'DONE WHEN: either the audit reports zero missing, or the files are confirmed unrecoverable.',
47
+ ].join('\n'),
48
+ };
49
+ const CODE_TASK = {
50
+ id: 9002, goal_id: 1000119, kind: 'feature', status: 'ready', requires_rank: 'xenos',
51
+ touches: ['scripts/gds/sequence.js'], manual_degree: null, automation_tag: null,
52
+ title: 'Teach the picker a new halt reason',
53
+ description: 'Add a halt reason to scripts/gds/sequence.js and cover it with a tests/*.mjs case. '.repeat(4)
54
+ + 'DONE WHEN: the new case passes and the old ones still do.',
55
+ };
56
+
57
+ // ── the predicate ───────────────────────────────────────────────────────────────
58
+
59
+ test('the 1003969 shape (systemd unit edited on the droplet via sudo) needs host access', () => {
60
+ const d = needsHostAccess(T1003969);
61
+ assert.equal(d.needs, true);
62
+ assert.equal(d.source, 'text');
63
+ assert.ok(d.signals.length, 'the matched phrase is named, so a false positive can be seen and overridden');
64
+ });
65
+
66
+ test('the 1003980 shape (copying files between two live boxes) needs host access', () => {
67
+ const d = needsHostAccess(T1003980);
68
+ assert.equal(d.needs, true);
69
+ assert.equal(d.source, 'text');
70
+ });
71
+
72
+ test('an ordinary code task does not', () => {
73
+ assert.equal(needsHostAccess(CODE_TASK).needs, false);
74
+ });
75
+
76
+ test("the explicit tag wins both ways: 'live-only' skips with no text, 'overnight-safe' overrides the text", () => {
77
+ const tagged = needsHostAccess({ ...CODE_TASK, automation_tag: 'live-only' });
78
+ assert.equal(tagged.needs, true);
79
+ assert.equal(tagged.source, 'automation_tag');
80
+ // An owner who reads the reason and disagrees marks the task safe; the phrase
81
+ // match must then stay quiet rather than refuse forever.
82
+ assert.equal(needsHostAccess({ ...T1003969, automation_tag: 'overnight-safe' }).needs, false);
83
+ });
84
+
85
+ test('code that merely talks ABOUT droplets is not mistaken for work ON one', () => {
86
+ const t = { ...CODE_TASK, description: 'The DigitalOcean droplet-create call in modules/box/do-client.js leaks a droplet when the API blips. Fix the retry so no droplet is orphaned. '.repeat(2) };
87
+ assert.equal(needsHostAccess(t).needs, false);
88
+ });
89
+
90
+ test('one passing mention of the box does not refuse a code task (the task 1004463 shape)', () => {
91
+ // A real ready task on 2026-10-02: a code fix whose description quotes the
92
+ // owner's current workaround. One phrase is description, not the work.
93
+ const t = { ...CODE_TASK, description: `${CODE_TASK.description} Today the owner's only route is \`ssh cloudbongos 'systemctl restart cloudbongos'\`; the hall should do it instead.` };
94
+ const d = needsHostAccess(t);
95
+ assert.equal(d.needs, false);
96
+ assert.deepEqual(d.signals, ['systemctl restart'], 'the lone phrase is still reported, so the threshold is visible');
97
+ // The same phrase twice over, as in the two real shapes, does refuse.
98
+ const twice = { ...t, description: `${t.description} Then sudo systemctl daemon-reload on the production droplet.` };
99
+ assert.equal(needsHostAccess(twice).needs, true);
100
+ });
101
+
102
+ test('a missing task is not a host-access task (decideTask refuses it on its own)', () => {
103
+ assert.equal(needsHostAccess(null).needs, false);
104
+ });
105
+
106
+ // ── decideTask refuses it, like protected_path ─────────────────────────────────
107
+
108
+ test('decideTask refuses a host-access task with code needs_host_access', () => {
109
+ const d = decideTask({ task: T1003969, goals: [1000119], protectedHits: [] });
110
+ assert.equal(d.go, false);
111
+ assert.equal(d.code, 'needs_host_access');
112
+ assert.match(d.reason, /live host/);
113
+ assert.equal(decideTask({ task: CODE_TASK, goals: [1000119], protectedHits: [] }).go, true);
114
+ });
115
+
116
+ // ── the picker: skipped with a reason, and the code task behind it is picked ───
117
+
118
+ function fakeApi(tasks, claimable) {
119
+ return {
120
+ me: { getMe: async () => ({ ok: true, data: { builder: { rank: 'archon' } } }) },
121
+ tasks: {
122
+ getTasks: async ({ query }) => ({ ok: true, data: { tasks: query.offset ? [] : tasks } }),
123
+ getTasksId: async ({ id }) => {
124
+ const t = tasks.find((x) => Number(x.id) === Number(id));
125
+ return t ? { ok: true, data: { task: { dependencies: [], dependents: [], ...t } } } : { ok: false };
126
+ },
127
+ getTasksClaimable: async () => ({ ok: true, data: { claimable: claimable.map((id) => ({ id })) } }),
128
+ },
129
+ };
130
+ }
131
+
132
+ test('pickTask walks past the host-access tasks to the code task behind them', async () => {
133
+ const api = fakeApi([T1003969, T1003980, CODE_TASK], [1003969, 1003980, 9002]);
134
+ const got = await runner.pickTask([1000119], { api, rank: 'archon' });
135
+ assert.equal(got.task && got.task.id, 9002, 'an ordinary code task is still picked');
136
+ });
137
+
138
+ test('when only host-access tasks are left, they appear in skipped[] as needs_host_access', async () => {
139
+ const api = fakeApi([T1003969, T1003980], [1003969, 1003980]);
140
+ const got = await runner.pickTask([1000119], { api, rank: 'archon' });
141
+ assert.equal(got.none, true, 'no claim is made on work the machine cannot do');
142
+ const byId = new Map(got.skipped.map((s) => [Number(s.id), s]));
143
+ for (const id of [1003969, 1003980]) {
144
+ const sk = byId.get(id);
145
+ assert.ok(sk, `task ${id} must be listed, not silently vanish`);
146
+ assert.ok(sk.reasons.some((r) => r.code === 'needs_host_access'), `task ${id} carries the reason`);
147
+ assert.equal(sk.goal_id, 1000119);
148
+ }
149
+ });
150
+
151
+ test('the nothing_claimable row the iteration writes carries needs_host_access', async () => {
152
+ const api = fakeApi([T1003969], [1003969]);
153
+ let claimed = false;
154
+ const row = await runner.iteration({ goals: [1000119] }, {
155
+ readFence: async () => ({ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }] }, graderBypassed: false }),
156
+ gauge: () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } }),
157
+ state: runner.newRunnerState(),
158
+ pickTask: (goals, d) => runner.pickTask(goals, { ...d, api, rank: 'archon' }),
159
+ run: async () => { claimed = true; return { ok: true, code: 0, stdout: '', stderr: '' }; },
160
+ // Inert stand-ins, so a regression that DOES pick the task fails this test
161
+ // without spawning a real worker or writing to a real ledger.
162
+ spawnWorker: async () => { claimed = true; return { code: 0, envelope: null, stdout: '', stderr: '' }; },
163
+ worktreeName: () => 'autobongos-test-host-access',
164
+ gradeLedgerPath: () => path.join(os.tmpdir(), `autobongos-host-access-${process.pid}.jsonl`),
165
+ api,
166
+ verifyDeps: {
167
+ task: { id: 1003969, status: 'shipped', updated_at: new Date().toISOString() },
168
+ probeArtifact: async () => ({ checked: true, onMain: true, sha: 'a'.repeat(40), subject: 'x' }),
169
+ fileBlocker: async () => ({ filed: false }),
170
+ },
171
+ record: (r) => r,
172
+ });
173
+ assert.equal(row.event, 'nothing_claimable');
174
+ assert.equal(claimed, false, 'no worktree is made and no claim is attempted');
175
+ assert.deepEqual(row.skipped, [{ id: 1003969, goal_id: 1000119, reasons: ['needs_host_access'] }]);
176
+ });
@@ -0,0 +1,387 @@
1
+ // tests/hall_matter_card.mjs — THE MATTER CARD THAT ACTS (task 1004528, goal
2
+ // 1000125).
3
+ //
4
+ // WHAT IT GUARDS. A pin on the street opens the prototype's full card()
5
+ // (docs/design/mocks/governor-city/Street.dc.html), built by street-card.js over
6
+ // the docket, the Board Room's read and the constitution in force, and its acts
7
+ // go to the EXISTING routes. What can go wrong:
8
+ // * the card says something the ballot does not (a seat, a tally, a voice, a
9
+ // counsel note, a wording change) or offers an act that is not the viewer's;
10
+ // * an act reaches a route other than the one that does it, with a body that
11
+ // route refuses (a yes with a reason, an objection with no section), or a
12
+ // "release" moves the live core pin from a card;
13
+ // * an act that records nothing (abstain, hold) sends a request anyway;
14
+ // * after an act the street does not answer: no toast, no re-read, no
15
+ // building that rises or lights, no evening when nothing waits;
16
+ // * the card reaches innerHTML, or its motion escapes the Kill Switch.
17
+ //
18
+ // Run: node tests/hall_matter_card.mjs
19
+
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { createRequire } from 'node:module';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
30
+ const require = createRequire(import.meta.url);
31
+ require(path.join(PUB, 'city-draw.js'));
32
+ require(path.join(PUB, 'street-draw.js'));
33
+ const C = require(path.join(PUB, 'street-card.js'));
34
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
35
+ const NOW = Date.parse('2026-10-02T12:00:00.000Z');
36
+ const VIEWER = '95';
37
+
38
+ // ── fixtures in the routes' own shapes ──────────────────────────────────────
39
+ // docket matter: modules/government/docket.js normalizeMatter / boardMatter
40
+ function matter(over = {}) {
41
+ return { id: 'board:41', source: 'board', building: 'charter', rule: 'unanimous', state: 'needs', title: 'Give Review a seat', short: 'amendment', where: 'charter · amendment', opened_at: '2026-10-01T09:00:00.000Z', deadline: '2026-10-03T09:00:00.000Z', proposer: { id: '4', login: 'ana', name: 'Ana Ruiz' }, tally: { for: 3, against: 0, open: 2, needs: 'needs everyone who votes' }, href: '/board-room?item=41', ref: { type: 'board_item', id: '41' }, ...over };
42
+ }
43
+ function docket(ms, over = {}) {
44
+ const counts = { needs: 0, moving: 0, settled: 0 };
45
+ ms.forEach((m) => { counts[m.state] += 1; });
46
+ return {
47
+ viewer: { id: VIEWER }, counts, line: '', matters: ms, readings: [], sources: [],
48
+ buildings: [{ key: 'review', counts: {}, matter_ids: [], decision: { who: 'whoever may mark work as meeting its checks', rule: 'decide', rule_words: 'decides alone', as_today: true, sitting: false } }],
49
+ vocabulary: {}, ...over,
50
+ };
51
+ }
52
+ const vote = (id, name, direction, over = {}) => ({ voter_id: String(id), github_login: name.toLowerCase(), display_name: name, direction, reason_md: null, section_key: null, cast_at: '2026-10-01T10:00:00.000Z', ...over });
53
+ // board item: board.js boardRoomView project()
54
+ function item(over = {}) {
55
+ return {
56
+ id: '41', subject_type: 'constitutional_amendment', subject_id: '7',
57
+ subject: { kind: 'decision_rules', proposed: { decision_rules: [{ area: 'review', decided_by: 'rank:tester', rule: 'signoff', who: 'the testers' }] }, rationale_md: 'Three pieces of finished work are waiting because no one is allowed to test them.\n\nMore below.', author_id: '4' },
58
+ opened_by: '4', opened_at: '2026-10-01T09:00:00.000Z', closes_at: '2026-10-03T09:00:00.000Z', remaining_seconds: 3600, expired: false, closed_at: null, outcome: null,
59
+ constitution: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true },
60
+ votes: [vote(4, 'Ana Ruiz', 'yes'), vote(5, 'Theo', 'yes'), vote(6, 'Mira', 'yes')],
61
+ ...over,
62
+ };
63
+ }
64
+ const room = (open = [], closed = []) => ({ viewer_id: VIEWER, open, closed });
65
+ const card = (ms, key, r = null, cons = null) => C.model({ docket: docket(ms), key, room: r, constitution: cons, now: NOW });
66
+
67
+ // ── the card for each rule ──────────────────────────────────────────────────
68
+
69
+ test('an open unanimous sitting that waits on you: the proposer\'s words, the change, the seats, counsel and the three acts', () => {
70
+ const cv = card([matter()], 'board:41', room([item()]));
71
+ assert.deepEqual([cv.where, cv.when, cv.title], ['charter · amendment', 'sat', 'Give Review a seat']);
72
+ assert.deepEqual(cv.quote, { by: 'Ana', words: 'Three pieces of finished work are waiting because no one is allowed to test them.' }, 'the first paragraph, in their own words');
73
+ assert.deepEqual(cv.change.rows, [{ label: 'Review', struck: 'whoever may mark work as meeting its checks, decides alone', inserted: 'the testers, signed off by someone who did not build it' }]);
74
+ assert.deepEqual(cv.seats.map((s) => [s.init, s.pos]), [['A', 'for'], ['T', 'for'], ['M', 'for'], ['you', 'you'], ['', 'open']], 'three for, your seat, one more open');
75
+ assert.equal(cv.tally, '3 for · 0 against · needs everyone who votes');
76
+ assert.match(cv.counsel.words, /^Everyone who votes has to agree, so one objection sends it back to Ana/);
77
+ assert.equal(cv.counsel.revise, false);
78
+ assert.deepEqual(cv.actions.map((a) => [a.key, a.label, a.tone]), [['for', 'vote for', 'primary'], ['object', 'object', 'quiet']]);
79
+ assert.deepEqual(cv.textActions.map((a) => a.key), ['abstain']);
80
+ assert.ok(cv.objectForm.sections.length >= 3);
81
+ assert.equal(cv.readOnly, false);
82
+ });
83
+
84
+ test('a majority sitting with an objection, after your yes: voices for and against, what it still needs', () => {
85
+ const it = item({ constitution: { membership: 'rank:metic+', pass_rule: 'majority', window_minutes: 1440, close_early_on_full_turnout: true }, votes: [vote(4, 'Ana Ruiz', 'yes'), vote(95, 'You', 'yes'), vote(7, 'Jun', 'no', { reason_md: 'Testers are volunteers. If they go quiet, nothing ships.', section_key: 'scope' })] });
86
+ const cv = card([matter({ rule: 'majority', state: 'moving', tally: { for: 2, against: 1, open: 2, needs: 'needs more than half' } })], 'board:41', room([it]));
87
+ assert.deepEqual(cv.voices, [{ side: 'for', by: 'for', words: 'Ana and You' }, { side: 'against', by: 'Jun · objects to what it changes', words: 'Testers are volunteers. If they go quiet, nothing ships.' }]);
88
+ assert.equal(cv.seats.find((s) => s.key === 'you').pos, 'for', 'your cast seat is filled');
89
+ assert.equal(cv.result, 'You voted for. It needs 1 more yes.', '5 seats: 3 carries it, 2 are in');
90
+ assert.match(cv.counsel.words, /^Jun objects to what it changes\. Under a majority/);
91
+ assert.deepEqual(cv.actions, [], 'no second vote');
92
+ assert.equal(cv.readOnly, true);
93
+ });
94
+
95
+ test('a sitting that came back to you: counsel answers the objection, revise and sit again, and the revision struck and inserted', () => {
96
+ const earlier = item({ id: '30', subject_type: 'review_matter', subject: { area: 'review', title: 'Testers sign off', detail_md: 'Testers sign off finished work.', author_id: VIEWER }, opened_at: '2026-09-20T09:00:00.000Z', closed_at: '2026-09-21T09:00:00.000Z', outcome: 'returned', votes: [vote(7, 'Jun', 'no', { reason_md: 'Volunteers go quiet.', section_key: 'scope' })] });
97
+ const back = item({ id: '44', subject_type: 'review_matter', subject: { area: 'review', title: 'Testers sign off', detail_md: 'Testers sign off finished work; after 3 days any council member can.', author_id: VIEWER }, opened_at: '2026-09-22T09:00:00.000Z', closed_at: '2026-09-23T09:00:00.000Z', outcome: 'returned', votes: [vote(7, 'Jun', 'no', { reason_md: 'Three days is too short.', section_key: 'success' })] });
98
+ const cv = card([matter({ id: 'board:44', building: 'review', state: 'settled', ref: { type: 'board_item', id: '44' }, proposer: { id: VIEWER, login: 'me', name: 'Me' } })], 'board:44', room([], [back, earlier]));
99
+ assert.equal(cv.result, 'It came back to you.');
100
+ assert.match(cv.counsel.words, /^Jun objected to how we will know it worked: “Three days is too short\.” Answer that/);
101
+ assert.equal(cv.counsel.revise, true);
102
+ assert.deepEqual(cv.reviseForm, { text: 'Testers sign off finished work; after 3 days any council member can.', label: 'the matter, in your words' });
103
+ assert.deepEqual(cv.revision, { struck: 'Testers sign off finished work.', inserted: 'Testers sign off finished work; after 3 days any council member can.' });
104
+ assert.equal(cv.readOnly, false);
105
+ });
106
+
107
+ test('passed, not yours, a Full Idea, and a ballot not read yet are read-only', () => {
108
+ const passed = card([matter({ state: 'settled' })], 'board:41', room([], [item({ closed_at: '2026-10-02T09:00:00.000Z', outcome: 'passed', votes: [vote(4, 'Ana', 'yes'), vote(5, 'Theo', 'yes')] })]));
109
+ assert.equal(passed.result, 'Passed 2–0. In force from 2026-10-02.');
110
+ assert.deepEqual([passed.readOnly, passed.actions.length, passed.textActions.length], [true, 0, 0]);
111
+ const notMine = card([matter({ state: 'settled', proposer: null })], 'board:41', room([item({ subject: { ...item().subject, author_id: '4' } })]));
112
+ assert.equal(notMine.result, 'Not yours to vote on. You will see how it ends.');
113
+ assert.equal(notMine.actions.length, 0);
114
+ assert.ok(!notMine.seats.some((s) => s.pos === 'you'), 'no seat drawn for you when you have none');
115
+ const idea = card([matter({ building: 'planning', state: 'needs' })], 'board:41', room([item({ subject_type: 'full_idea', subject: { title: 'An idea', author_id: '4', field_values: { purpose: 'It does a thing.' } } })]));
116
+ assert.equal(idea.actions.length, 0, 'a Full Idea is voted on with its sections in front of you');
117
+ assert.match(idea.result, /Board Room/);
118
+ assert.equal(idea.href, '/board-room?item=41');
119
+ const loading = card([matter()], 'board:41', null);
120
+ assert.deepEqual([loading.loading, loading.readOnly, loading.result], [true, true, 'Reading the sitting…']);
121
+ const gone = card([matter()], 'board:41', room([]));
122
+ assert.equal(gone.result, 'This sitting could not be read just now.');
123
+ });
124
+
125
+ test('a constitution amendment shows each changed setting struck and inserted against the constitution in force', () => {
126
+ const it = item({ subject: { proposed: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true }, rationale_md: 'Let the council decide.', author_id: '4' } });
127
+ const cv = card([matter()], 'board:41', room([it]), { board: { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true } });
128
+ assert.deepEqual(cv.change.rows, [
129
+ { label: 'who sits', struck: 'the archon rank', inserted: 'metic and above' },
130
+ { label: 'how it passes', struck: 'the first yes decides', inserted: 'everyone has to agree' },
131
+ { label: 'how long it sits', struck: 'no clock', inserted: '1 day' },
132
+ ], 'the unchanged setting is left out');
133
+ });
134
+
135
+ test('admissions: fine by me or object when it is yours, read-only when it is not', () => {
136
+ const m = { id: 'access_requests:7', source: 'access_requests', building: 'people', rule: 'decide', state: 'needs', title: 'Admit Kai as a builder', short: 'admit kai', where: 'people · waiting to join · vouched', proposer: { login: 'kai', name: 'Kai' }, tally: null, href: '/watch', ref: { type: 'access_request', id: '7' } };
137
+ const cv = card([m], 'access_requests:7');
138
+ assert.equal(cv.quote.words, 'Kai asked to join as a builder, and someone vouched for them.');
139
+ assert.deepEqual(cv.actions.map((a) => [a.key, a.label]), [['fine', 'fine by me'], ['refuse', 'object']]);
140
+ const other = card([{ ...m, state: 'moving' }], 'access_requests:7');
141
+ assert.deepEqual([other.readOnly, other.actions.length], [true, 0]);
142
+ });
143
+
144
+ test('release or hold at the gate and for an override; a newer core is released where its check runs, never from the card', () => {
145
+ const o = card([{ id: 'override_requests:9', source: 'override_requests', building: 'release', rule: 'decide', state: 'needs', title: 'Release the new sign-up', where: 'release · past a failed check or grade', proposer: null, href: '/task/12', ref: { type: 'override_request', id: '9' } }], 'override_requests:9');
146
+ assert.deepEqual(o.actions.map((a) => [a.key, a.tone]), [['release', 'primary'], ['hold', 'quiet']]);
147
+ const core = card([{ id: 'core_update:1.21.0', source: 'core_update', building: 'release', rule: 'decide', state: 'needs', title: 'Take core 1.21.0', where: 'release · core update', proposer: null, href: '/settings#software-update', ref: { type: 'core_version', id: '1.21.0' } }], 'core_update:1.21.0');
148
+ assert.equal(core.actions[0].go, '/settings#software-update', 'a link to where the pin is moved with its dry run');
149
+ assert.equal(C.request(core, 'release', {}), null, 'the card itself never moves the pin');
150
+ const uat = card([{ id: 'criteria_uat:3', source: 'criteria_uat', building: 'review', rule: 'signoff', state: 'needs', title: 'Sign-up works', where: 'review', href: '/goals', ref: { type: 'criterion', id: '3' } }], 'criteria_uat:3');
151
+ assert.deepEqual([uat.readOnly, uat.actions.length], [true, 0]);
152
+ });
153
+
154
+ // ── act → the existing route ────────────────────────────────────────────────
155
+
156
+ test('every act maps to the route that does it, with the body that route accepts', () => {
157
+ const sit = card([matter()], 'board:41', room([item()]));
158
+ assert.deepEqual(C.request(sit, 'for'), { method: 'POST', url: '/api/bongos/government/board/items/41/votes', body: { direction: 'yes' } });
159
+ assert.deepEqual(C.request(sit, 'object', { reason: ' Volunteers go quiet. ', section: 'scope' }), { method: 'POST', url: '/api/bongos/government/board/items/41/votes', body: { direction: 'no', reason_md: 'Volunteers go quiet.', section_key: 'scope' } });
160
+ assert.equal(C.request(sit, 'abstain'), null, 'abstaining records nothing');
161
+ assert.deepEqual(C.request(sit, 'revise', { text: 'New words' }), { method: 'POST', url: '/api/bongos/government/board/decision-rules', body: { rules: item().subject.proposed.decision_rules, rationale_md: 'New words' } });
162
+ const amend = card([matter()], 'board:41', room([item({ subject: { proposed: { membership: 'rank:archon', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true }, rationale_md: 'x', author_id: VIEWER } })]));
163
+ assert.deepEqual(C.request(amend, 'revise', { text: 'Why, again' }), { method: 'POST', url: '/api/bongos/government/board/items', body: { subject_type: 'constitutional_amendment', proposal: { membership: 'rank:archon', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true }, rationale_md: 'Why, again' } });
164
+ const mat = card([matter()], 'board:41', room([item({ subject_type: 'people_matter', subject: { area: 'people', title: 'Make Kai a tester', detail_md: 'd', action: { type: 'assign_rank', builder_id: 12, rank_key: 'tester' }, author_id: VIEWER } })]));
165
+ assert.deepEqual(C.request(mat, 'revise', { text: 'Kai tests well.' }), { method: 'POST', url: '/api/bongos/government/board/matters', body: { area: 'people', title: 'Make Kai a tester', detail_md: 'Kai tests well.', action: { type: 'assign_rank', builder_id: 12, rank_key: 'tester' } } });
166
+
167
+ const acc = card([{ id: 'access_requests:7', source: 'access_requests', building: 'people', state: 'needs', title: 'Admit Kai', proposer: { login: 'kai' }, ref: { type: 'access_request', id: '7' } }], 'access_requests:7');
168
+ assert.deepEqual(C.request(acc, 'fine'), { method: 'PATCH', url: '/api/bongos/access-requests/7', body: { status: 'invited' } });
169
+ assert.deepEqual(C.request(acc, 'refuse'), { method: 'PATCH', url: '/api/bongos/access-requests/7', body: { status: 'dismissed' } });
170
+ const join = card([{ id: 'goal_join_requests:5', source: 'goal_join_requests', building: 'people', state: 'needs', title: 'Join', proposer: { login: 'bo' }, href: '/goals#/goal/1000125', ref: { type: 'goal_membership_request', id: '5' } }], 'goal_join_requests:5');
171
+ assert.deepEqual(C.request(join, 'fine'), { method: 'POST', url: '/api/bongos/goals/1000125/requests/5/respond', body: { action: 'approve' } });
172
+ assert.deepEqual(C.request(join, 'refuse'), { method: 'POST', url: '/api/bongos/goals/1000125/requests/5/respond', body: { action: 'deny' } });
173
+ const ov = card([{ id: 'override_requests:9', source: 'override_requests', building: 'release', state: 'needs', title: 'Release', ref: { type: 'override_request', id: '9' } }], 'override_requests:9');
174
+ assert.deepEqual(C.request(ov, 'release'), { method: 'POST', url: '/api/bongos/override-requests/9/decide', body: { decision: 'approve' } });
175
+ assert.equal(C.request(ov, 'hold'), null, 'holding records nothing');
176
+ const gate = card([{ id: 'gate_approvals:812', source: 'gate_approvals', building: 'release', state: 'needs', title: 'A PR', ref: { type: 'pull_request', id: 812 } }], 'gate_approvals:812');
177
+ assert.deepEqual(C.request(gate, 'release'), { method: 'POST', url: '/api/bongos/gate-approvals/812/approve', body: {} });
178
+ });
179
+
180
+ test('no act on any card reaches the core pin or a provisioning route', () => {
181
+ const src = read('street-card.js').replace(/^\s*\/\/.*$/gm, '');
182
+ assert.doesNotMatch(src, /core-upgrade|provisioning|\/core-update/);
183
+ });
184
+
185
+ test('an objection names its reason and its part before anything is sent; the parts are the server\'s own keys', () => {
186
+ const sit = card([matter()], 'board:41', room([item()]));
187
+ assert.match(C.check(sit, 'object', { reason: ' ', section: 'scope' }), /^Say why/);
188
+ assert.match(C.check(sit, 'object', { reason: 'Because.', section: '' }), /^Pick the part/);
189
+ assert.equal(C.check(sit, 'object', { reason: 'Because.', section: 'scope' }), null);
190
+ assert.match(C.check(sit, 'revise', { text: '' }), /^Write the revised words/);
191
+ const board = fs.readFileSync(path.join(ROOT, 'modules/government/board.js'), 'utf8');
192
+ const keys = [...board.match(/const FULL_IDEA_SECTION_KEYS = Object\.freeze\(\[([\s\S]*?)\]\)/)[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1]);
193
+ assert.ok(keys.length >= 5);
194
+ for (const s of C.SECTIONS) assert.ok(keys.includes(s.key), `${s.key} is a key the vote route accepts`);
195
+ });
196
+
197
+ test('after an act the street answers: what rises, what lights, what settles, and when the card closes', () => {
198
+ const sit = card([matter()], 'board:41', room([item()]));
199
+ assert.deepEqual(C.answer(sit, 'for', { item_closed: true, outcome: 'passed' }), { toast: 'Passed. It is in force from today.', building: 'charter', kind: 'rise', close: false });
200
+ assert.deepEqual(C.answer(sit, 'for', {}), { toast: 'Your yes is on the record.', building: 'charter', kind: 'light', close: false });
201
+ assert.equal(C.answer(sit, 'object', { item_closed: true, outcome: 'returned' }).kind, 'settle');
202
+ assert.deepEqual(C.answer(sit, 'abstain', {}).building, null, 'nothing recorded, nothing moves');
203
+ const ov = card([{ id: 'override_requests:9', source: 'override_requests', building: 'release', state: 'needs', title: 'Release', ref: { type: 'override_request', id: '9' } }], 'override_requests:9');
204
+ assert.deepEqual(C.answer(ov, 'release', { ok: true }), { toast: 'Released. It goes live.', building: 'release', kind: 'rise', close: true });
205
+ assert.equal(C.refusal(403, { error: { code: 'not_board_member' } }), 'Only the members of this sitting may vote on it.');
206
+ assert.equal(C.refusal(403, { error: { code: 'forbidden' } }), 'That is not yours to do.');
207
+ });
208
+
209
+ // ── the page ────────────────────────────────────────────────────────────────
210
+
211
+ function fakeDom() {
212
+ const byId = new Map();
213
+ const focused = { el: null };
214
+ const mk = (tag) => ({
215
+ tagName: tag, attrs: {}, children: [], style: { setProperty(k, v) { this[k] = v; } }, className: '', listeners: {}, _text: '', hidden: false, value: '',
216
+ clientWidth: 1440, clientHeight: 708, offsetLeft: 0, scrollLeft: 0, parentNode: null,
217
+ get firstChild() { return this.children[0] || null; },
218
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
219
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
220
+ appendChild(c) { this.children.push(c); c.parentNode = this; return c; },
221
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); return c; },
222
+ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
223
+ fire(t) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, preventDefault() {} })); },
224
+ focus() { focused.el = this; },
225
+ set textContent(v) { this._text = String(v); this.children = []; },
226
+ get textContent() { return this._text; },
227
+ get innerHTML() { throw new Error('the card must not read innerHTML'); },
228
+ set innerHTML(v) { throw new Error('the card must not write innerHTML'); },
229
+ });
230
+ const ids = ['streetMode', 'streetModeIcon', 'streetModeWord', 'streetWindow', 'streetSizer', 'streetBoard', 'streetSvg', 'streetFlat', 'streetBlds', 'streetCrates', 'streetCols', 'streetGroups', 'streetDoors', 'streetPins', 'streetSigns', 'streetHint', 'streetEvening', 'streetEveningLine', 'hudNeeds', 'hudMoving', 'hudShare', 'hudDot', 'streetHome', 'streetCard', 'streetCardWhere', 'streetCardWhen', 'streetCardTitle', 'streetCardBody', 'streetCardOpen', 'streetCardClose', 'streetToast'];
231
+ for (const id of ids) mk('x').setAttribute('id', id);
232
+ byId.get('streetToast').hidden = true; // as the page ships it
233
+ const document = { documentElement: mk('html'), getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
234
+ return { document, byId, focused };
235
+ }
236
+ const walk = (el, pred, out = []) => { if (pred(el)) out.push(el); (el.children || []).forEach((c) => walk(c, pred, out)); return out; };
237
+ const byAct = (el, key) => walk(el, (n) => n.attrs && n.attrs['data-act'] === key)[0];
238
+ const settle = () => new Promise((r) => setImmediate(r));
239
+
240
+ // feeds: url → body, or (call) → body; a POST answers from `posts`.
241
+ async function bootPage({ feeds, posts = {} }) {
242
+ const { document, byId, focused } = fakeDom();
243
+ const calls = [];
244
+ const window = {
245
+ innerWidth: 1440, innerHeight: 900, location: { hash: '' },
246
+ localStorage: { getItem: () => null, setItem() {} },
247
+ matchMedia: () => ({ matches: true, addEventListener() {} }),
248
+ addEventListener() {},
249
+ };
250
+ const fetch = (url, opts = {}) => {
251
+ const method = opts.method || 'GET';
252
+ calls.push({ url, method, body: opts.body ? JSON.parse(opts.body) : null });
253
+ const src = method === 'GET' ? feeds[url] : posts[`${method} ${url}`];
254
+ const ans = typeof src === 'function' ? src(calls) : src;
255
+ if (ans === undefined) return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
256
+ const status = ans && ans.__status ? ans.__status : 200;
257
+ return Promise.resolve({ ok: status < 400, status, json: async () => ans });
258
+ };
259
+ const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite });
260
+ ctx.globalThis = ctx;
261
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
262
+ await window.GovStreetPage.ready;
263
+ return { window, byId, calls, focused };
264
+ }
265
+ const BASE = (d) => ({
266
+ '/api/bongos/government/docket': d,
267
+ '/api/bongos/goals': { goals: [] },
268
+ '/api/bongos/goals/rollups': { rollups: [] },
269
+ '/api/bongos/builders/95/earnings': { days: [] },
270
+ '/api/bongos/government/board/items': room([item()]),
271
+ '/api/bongos/government/constitution': { board: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true } },
272
+ });
273
+
274
+ test('a pin opens the full card: the ballot is read, then the seats, counsel and acts are drawn as text', async () => {
275
+ const p = await bootPage({ feeds: BASE(docket([matter({ title: '<b>x</b>' })])) });
276
+ p.byId.get('streetPins').children[0].fire('click');
277
+ await settle(); await settle();
278
+ assert.ok(p.calls.some((c) => c.url === '/api/bongos/government/board/items'), 'the room is read when a sitting opens');
279
+ const body = p.byId.get('streetCardBody');
280
+ assert.equal(p.byId.get('streetCardTitle').textContent, '<b>x</b>');
281
+ assert.equal(walk(body, (n) => /mcard-seat /.test(n.className)).length, 5);
282
+ assert.ok(byAct(body, 'for') && byAct(body, 'object') && byAct(body, 'abstain'));
283
+ assert.equal(walk(body, (n) => n.className === 'mcard-counsel').length, 1);
284
+ });
285
+
286
+ test('vote for: one POST to the vote route, a toast, the docket re-read, and the Charter lights with its pin settling', async () => {
287
+ let reads = 0;
288
+ const after = docket([matter({ state: 'moving', tally: { for: 4, against: 0, open: 1, needs: 'needs everyone who votes' } })]);
289
+ const feeds = BASE((() => { const d0 = docket([matter()]); return () => (reads++ === 0 ? d0 : after); })());
290
+ feeds['/api/bongos/government/docket'] = (() => { const d0 = docket([matter()]); return () => (reads++ === 0 ? d0 : after); })();
291
+ const p = await bootPage({ feeds, posts: { 'POST /api/bongos/government/board/items/41/votes': { ok: true, vote: {}, already_voted: false, karma_delta: 1 } } });
292
+ p.byId.get('streetPins').children[0].fire('click');
293
+ await settle(); await settle();
294
+ byAct(p.byId.get('streetCardBody'), 'for').fire('click');
295
+ await settle(); await settle(); await settle();
296
+ const posts = p.calls.filter((c) => c.method !== 'GET');
297
+ assert.deepEqual(posts, [{ url: '/api/bongos/government/board/items/41/votes', method: 'POST', body: { direction: 'yes' } }]);
298
+ assert.equal(p.byId.get('streetToast').textContent, 'Your yes is on the record.');
299
+ assert.equal(p.byId.get('streetToast').hidden, false);
300
+ assert.ok(reads >= 2, 'the docket is read again');
301
+ const charter = p.byId.get('streetBlds').children.find((g) => g.attrs['data-key'] === 'charter');
302
+ assert.equal(charter.attrs.class, 'street-answer street-answer--light');
303
+ assert.match(p.byId.get('streetPins').children[0].className, /street-pin--moving street-pin--answer/, 'the pin moved from needs to moving');
304
+ });
305
+
306
+ test('object: the form asks for the reason and the part, then puts both on the record', async () => {
307
+ 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' } } });
308
+ p.byId.get('streetPins').children[0].fire('click');
309
+ await settle(); await settle();
310
+ byAct(p.byId.get('streetCardBody'), 'object').fire('click');
311
+ assert.equal(p.focused.el, p.byId.get('mcardReason'), 'focus goes to the reason');
312
+ byAct(p.byId.get('streetCardBody'), 'object-send').fire('click');
313
+ await settle();
314
+ assert.equal(p.calls.filter((c) => c.method !== 'GET').length, 0, 'an empty objection is refused before it is sent');
315
+ assert.match(walk(p.byId.get('streetCardBody'), (n) => n.className === 'mcard-error')[0].textContent, /^Say why/);
316
+ p.byId.get('mcardReason').value = 'Volunteers go quiet.';
317
+ p.byId.get('mcardSection').value = 'scope';
318
+ byAct(p.byId.get('streetCardBody'), 'object-send').fire('click');
319
+ await settle(); await settle(); await settle();
320
+ assert.deepEqual(p.calls.filter((c) => c.method !== 'GET').map((c) => c.body), [{ direction: 'no', reason_md: 'Volunteers go quiet.', section_key: 'scope' }]);
321
+ assert.equal(p.byId.get('streetToast').textContent, 'Your objection is on the record. It goes back to its author to revise.');
322
+ });
323
+
324
+ test('a refused act says why and records nothing; abstain sends nothing at all', async () => {
325
+ const p = await bootPage({ feeds: BASE(docket([matter()])), posts: { 'POST /api/bongos/government/board/items/41/votes': { __status: 403, error: { code: 'not_board_member' } } } });
326
+ p.byId.get('streetPins').children[0].fire('click');
327
+ await settle(); await settle();
328
+ byAct(p.byId.get('streetCardBody'), 'for').fire('click');
329
+ await settle(); await settle();
330
+ assert.equal(walk(p.byId.get('streetCardBody'), (n) => n.className === 'mcard-error')[0].textContent, 'Only the members of this sitting may vote on it.');
331
+ assert.equal(p.byId.get('streetToast').hidden, true, 'no toast for a refusal');
332
+ const before = p.calls.length;
333
+ byAct(p.byId.get('streetCardBody'), 'abstain').fire('click');
334
+ await settle();
335
+ assert.equal(p.calls.length, before, 'abstaining sends nothing');
336
+ assert.equal(p.byId.get('streetCard').hidden, true);
337
+ });
338
+
339
+ test('release: the override is approved, the card closes, Release rises, and with nothing left the street turns to evening', async () => {
340
+ const ov = { id: 'override_requests:9', source: 'override_requests', building: 'release', rule: 'decide', state: 'needs', title: 'Release the new sign-up', where: 'release · past a failed check or grade', proposer: { login: 'lu' }, href: '/task/12', ref: { type: 'override_request', id: '9' } };
341
+ let reads = 0;
342
+ const feeds = BASE(null);
343
+ feeds['/api/bongos/government/docket'] = () => (reads++ === 0 ? docket([ov]) : docket([]));
344
+ const p = await bootPage({ feeds, posts: { 'POST /api/bongos/override-requests/9/decide': { ok: true } } });
345
+ assert.equal(p.byId.get('streetEvening').hidden, true);
346
+ p.byId.get('streetPins').children[0].fire('click');
347
+ byAct(p.byId.get('streetCardBody'), 'release').fire('click');
348
+ await settle(); await settle(); await settle();
349
+ 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' }]]);
350
+ assert.equal(p.byId.get('streetCard').hidden, true);
351
+ assert.equal(p.byId.get('streetToast').textContent, 'Released. It goes live.');
352
+ assert.equal(p.byId.get('streetBlds').children.find((g) => g.attrs['data-key'] === 'release').attrs.class, 'street-answer street-answer--rise');
353
+ assert.equal(p.byId.get('streetEvening').hidden, false, 'nothing waits on you');
354
+ assert.equal(p.byId.get('hudNeeds').textContent, '0');
355
+ });
356
+
357
+ // ── the sheet ───────────────────────────────────────────────────────────────
358
+
359
+ test('the card\'s motion is CSS only and dies under reduced motion; tokens only; no innerHTML', () => {
360
+ const css = read('street-card.css').replace(/\/\*[\s\S]*?\*\//g, '');
361
+ assert.doesNotMatch(css, /:root/);
362
+ assert.doesNotMatch(css, /#fb9569/i, 'the accent is the token');
363
+ const literals = [...css.matchAll(/#[0-9a-f]{3,8}\b/gi)].map((m) => m[0].toLowerCase());
364
+ assert.ok(literals.every((h) => h === '#000000' || h === '#ffffff'), `only black or white in a mix: ${literals}`);
365
+ for (const k of ['street-rise', 'street-light', 'street-settle', 'street-seat']) assert.match(css, new RegExp(`@keyframes ${k} `));
366
+ const kill = css.match(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\}\s*$/)[1];
367
+ for (const c of ['.street-answer', '.street-pin--answer', '.mcard-seat--fresh', '.street-toast']) assert.ok(kill.includes(c), `${c} stops under reduced motion`);
368
+ assert.match(kill, /animation: none !important; transition: none !important;/);
369
+ const js = read('street-card.js').replace(/^\s*\/\/.*$/gm, '');
370
+ assert.doesNotMatch(js, /innerHTML|insertAdjacentHTML|requestAnimationFrame|setInterval|setTimeout/);
371
+ const html = read('street.html');
372
+ assert.match(html, /<link rel="stylesheet" href="\/builders\/street-card\.css/);
373
+ assert.match(html, /id="streetToast" role="status" aria-live="polite"/);
374
+ });
375
+
376
+ // ── the Board Room names the new subjects ───────────────────────────────────
377
+
378
+ test('the Board Room labels a building\'s matter as that building\'s matter, and a who-decides change as its own amendment', () => {
379
+ const src = read('board-room.js');
380
+ const fn = src.slice(src.indexOf(' function subjectLine(item) {'), src.indexOf(' function clockLine(item) {'));
381
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
382
+ const subjectLine = new Function('escapeHtml', 'pill', `${fn}\nreturn subjectLine;`)(esc, (tone, t) => `[${t}]`);
383
+ assert.equal(subjectLine({ subject_type: 'people_matter', subject: { title: 'Make <Kai> a tester' } }), 'Make &lt;Kai&gt; a tester [people matter]');
384
+ assert.equal(subjectLine({ subject_type: 'treasury_matter', subject: null }), 'A matter at treasury [treasury matter]');
385
+ assert.equal(subjectLine({ subject_type: 'constitutional_amendment', subject: { kind: 'decision_rules' } }), 'Who decides what [amendment]');
386
+ assert.equal(subjectLine({ subject_type: 'constitutional_amendment', subject: { proposed: {} } }), 'Constitutional amendment [amendment]');
387
+ });
@@ -377,7 +377,7 @@ test('the page holds the prototype\'s header, the street, the readings and the c
377
377
  for (const id of ['hudNeeds', 'hudMoving', 'hudShare', 'streetMode', 'streetSigns', 'streetPins', 'streetDoors', 'streetCard']) assert.match(html, new RegExp(`id="${id}"`), id);
378
378
  assert.doesNotMatch(html, /app-sidebar|shell\.js/, 'no hall chrome, as the city');
379
379
  const scripts = [...html.matchAll(/<script src="\/builders\/([a-z-]+)\.js/g)].map((m) => m[1]);
380
- assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-inside', 'street'], 'the shared kit loads first, then the interiors (task 1004526)');
380
+ assert.deepEqual(scripts, ['city-draw', 'street-draw', 'street-card', 'street-inside', 'street'], 'the shared kit loads first, then the card (task 1004528) and the interiors (task 1004526)');
381
381
  assert.equal(City.STREET_HREF, '/builders/street', 'the city\'s door is this page');
382
382
  });
383
383
 
@@ -422,7 +422,7 @@ function fakeDom() {
422
422
  };
423
423
  const focused = { el: null };
424
424
  const html = mk('html');
425
- const ids = ['streetMode', 'streetModeIcon', 'streetModeWord', 'streetWindow', 'streetSizer', 'streetBoard', 'streetSvg', 'streetFlat', 'streetBlds', 'streetCrates', 'streetCols', 'streetGroups', 'streetDoors', 'streetPins', 'streetSigns', 'streetHint', 'streetEvening', 'streetEveningLine', 'hudNeeds', 'hudMoving', 'hudShare', 'hudDot', 'streetHome', 'streetCard', 'streetCardWhere', 'streetCardWhen', 'streetCardTitle', 'streetCardMeta', 'streetCardLines', 'streetCardResult', 'streetCardOpen', 'streetCardClose'];
425
+ const ids = ['streetMode', 'streetModeIcon', 'streetModeWord', 'streetWindow', 'streetSizer', 'streetBoard', 'streetSvg', 'streetFlat', 'streetBlds', 'streetCrates', 'streetCols', 'streetGroups', 'streetDoors', 'streetPins', 'streetSigns', 'streetHint', 'streetEvening', 'streetEveningLine', 'hudNeeds', 'hudMoving', 'hudShare', 'hudDot', 'streetHome', 'streetCard', 'streetCardWhere', 'streetCardWhen', 'streetCardTitle', 'streetCardBody', 'streetCardOpen', 'streetCardClose', 'streetToast'];
426
426
  for (const id of ids) mk('x').setAttribute('id', id);
427
427
  const document = { documentElement: html, getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
428
428
  return { document, byId, html, focused };
@@ -449,6 +449,7 @@ async function bootPage({ feeds = {}, stored = null, osDark = true, hash = '' }
449
449
  // each file hangs its api on `window`, as the browser loads them
450
450
  vm.runInContext(read('city-draw.js'), ctx);
451
451
  vm.runInContext(read('street-draw.js'), ctx);
452
+ vm.runInContext(read('street-card.js'), ctx);
452
453
  vm.runInContext(read('street.js'), ctx);
453
454
  await window.GovStreetPage.ready;
454
455
  const key = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));
@@ -414,7 +414,7 @@ async function bootPage({ hash = '', feeds = {}, innerWidth = 1440 } = {}) {
414
414
  };
415
415
  const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, Date, console, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map });
416
416
  ctx.globalThis = ctx;
417
- for (const f of ['city-draw.js', 'street-draw.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
417
+ for (const f of ['city-draw.js', 'street-draw.js', 'street-card.js', 'street-inside.js', 'street.js']) vm.runInContext(read(f), ctx);
418
418
  await window.GovStreetPage.ready;
419
419
  await new Promise((r) => setTimeout(r, 0));
420
420
  const fireWin = (t, e) => (listeners[t] || []).forEach((fn) => fn(e));