@bongos/core 1.21.112 → 1.21.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/.bongos-core.json +616 -186
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +1 -1
  4. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  5. package/.claude/skills/builder-ship/SKILL.md +1 -0
  6. package/GOVERNMENT.md +128 -11
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +28 -0
  9. package/clients/bongos-client/index.cjs +28 -0
  10. package/clients/bongos-client/index.d.ts +45 -2
  11. package/clients/bongos-client/index.mjs +28 -0
  12. package/docs/adr/0372-an-ask-is-stored-per-docket-matter-and-the-server-refuses-slop.md +33 -0
  13. package/docs/adr/0373-an-ai-session-files-a-blocker-with-an-ask.md +31 -0
  14. package/docs/adr/0374-a-building-lead-is-a-held-position-appointed-by-a-people-matter.md +30 -0
  15. package/docs/adr/0375-who-may-build-without-a-vote-comes-from-the-form-of-government.md +38 -0
  16. package/docs/adr/0376-every-ask-is-routed-to-a-real-seat-on-the-server.md +30 -0
  17. package/docs/adr/0377-governance-is-a-held-position-read-on-me.md +24 -0
  18. package/docs/adr/0378-a-leads-build-outs-are-rows-built-at-once-or-by-a-passed-vote.md +33 -0
  19. package/docs/adr/0379-the-treasury-is-pots-per-goal-a-reserve-and-borrowing-over-real-spend.md +32 -0
  20. package/docs/adr/0380-a-strictness-preset-is-a-bundle-of-laws-switched-by-one-laws-vote.md +26 -0
  21. package/docs/adr/README.md +10 -1
  22. package/docs/api/openapi.json +989 -87
  23. package/docs/api-reference.md +18 -4
  24. package/docs/copy-inventory.md +343 -262
  25. package/docs/copy-registry.json +1223 -426
  26. package/docs/file-map.md +2 -1
  27. package/docs/module-api-changelog.md +4 -0
  28. package/docs/packs/engineer.md +2 -1
  29. package/docs/page-inventory.json +91 -6
  30. package/docs/page-readings.json +5017 -4458
  31. package/modules/builder-settings/law-locks.js +202 -0
  32. package/modules/builder-settings/routes/settings.js +66 -6
  33. package/modules/builder-settings/settings.js +7 -0
  34. package/modules/discord/ship-broadcast.js +22 -13
  35. package/modules/government/asks-db.js +54 -0
  36. package/modules/government/asks.js +269 -0
  37. package/modules/government/board-idea-reads.js +68 -0
  38. package/modules/government/board.js +21 -57
  39. package/modules/government/build-rights.js +148 -0
  40. package/modules/government/buildouts.js +160 -0
  41. package/modules/government/counts.js +81 -0
  42. package/modules/government/db.js +242 -0
  43. package/modules/government/decision-board.js +234 -8
  44. package/modules/government/decision-rules.js +2 -0
  45. package/modules/government/docket.js +114 -6
  46. package/modules/government/law-board.js +131 -0
  47. package/modules/government/laws.js +241 -0
  48. package/modules/government/leads.js +170 -0
  49. package/modules/government/migrations/government_027_laws.sql +143 -0
  50. package/modules/government/migrations/government_028_asks.sql +57 -0
  51. package/modules/government/migrations/government_029_leads.sql +135 -0
  52. package/modules/government/migrations/government_030_buildouts.sql +111 -0
  53. package/modules/government/migrations/government_031_treasury.sql +107 -0
  54. package/modules/government/module.json +2 -1
  55. package/modules/government/pollers/treasury-borrow.js +62 -0
  56. package/modules/government/positions.js +107 -0
  57. package/modules/government/presets.js +149 -0
  58. package/modules/government/routes/government.js +425 -2
  59. package/modules/government/routing.js +154 -0
  60. package/modules/government/treasury-db.js +166 -0
  61. package/modules/government/treasury.js +241 -0
  62. package/modules/hall-ui/public/atlas.html +2 -2
  63. package/modules/hall-ui/public/blockers.html +2 -2
  64. package/modules/hall-ui/public/board-room.html +2 -2
  65. package/modules/hall-ui/public/board-room.js +2 -0
  66. package/modules/hall-ui/public/city-draw.js +604 -11
  67. package/modules/hall-ui/public/city.css +157 -1
  68. package/modules/hall-ui/public/city.html +37 -7
  69. package/modules/hall-ui/public/city.js +441 -21
  70. package/modules/hall-ui/public/city.states.json +110 -5
  71. package/modules/hall-ui/public/collab.html +2 -2
  72. package/modules/hall-ui/public/commands.html +2 -2
  73. package/modules/hall-ui/public/copy-desk.html +2 -2
  74. package/modules/hall-ui/public/craft-mode.js +112 -29
  75. package/modules/hall-ui/public/deploy.html +2 -2
  76. package/modules/hall-ui/public/diagram-viewer.css +12 -0
  77. package/modules/hall-ui/public/diagram-viewer.js +4 -0
  78. package/modules/hall-ui/public/diagrams.html +2 -2
  79. package/modules/hall-ui/public/drachmae.html +2 -2
  80. package/modules/hall-ui/public/fleet.html +2 -2
  81. package/modules/hall-ui/public/gate.html +2 -2
  82. package/modules/hall-ui/public/goal-map.html +2 -2
  83. package/modules/hall-ui/public/goals.html +2 -2
  84. package/modules/hall-ui/public/gov-feeds.js +141 -0
  85. package/modules/hall-ui/public/gov-living.js +1002 -0
  86. package/modules/hall-ui/public/gov-positions.js +97 -0
  87. package/modules/hall-ui/public/government.html +2 -2
  88. package/modules/hall-ui/public/hall-render.js +3 -0
  89. package/modules/hall-ui/public/idea.html +2 -2
  90. package/modules/hall-ui/public/ideas.html +2 -2
  91. package/modules/hall-ui/public/index.html +3 -3
  92. package/modules/hall-ui/public/modules.html +2 -2
  93. package/modules/hall-ui/public/my-agents.css +334 -0
  94. package/modules/hall-ui/public/my-agents.html +72 -0
  95. package/modules/hall-ui/public/my-agents.js +603 -0
  96. package/modules/hall-ui/public/my-agents.states.json +37 -0
  97. package/modules/hall-ui/public/primer.html +2 -2
  98. package/modules/hall-ui/public/profile-connections.js +40 -28
  99. package/modules/hall-ui/public/profile.html +2 -2
  100. package/modules/hall-ui/public/project-settings.html +2 -2
  101. package/modules/hall-ui/public/ranks.html +2 -2
  102. package/modules/hall-ui/public/roadmap.html +2 -2
  103. package/modules/hall-ui/public/room.css +8 -0
  104. package/modules/hall-ui/public/roster.html +2 -2
  105. package/modules/hall-ui/public/sessions.html +2 -2
  106. package/modules/hall-ui/public/settings.html +2 -2
  107. package/modules/hall-ui/public/shell.js +116 -16
  108. package/modules/hall-ui/public/street-card.js +18 -1
  109. package/modules/hall-ui/public/street-deck.css +299 -0
  110. package/modules/hall-ui/public/street-deck.js +1145 -0
  111. package/modules/hall-ui/public/street-depth.js +712 -0
  112. package/modules/hall-ui/public/street-desk.js +5 -2
  113. package/modules/hall-ui/public/street-draw.js +109 -16
  114. package/modules/hall-ui/public/street-inside.js +137 -22
  115. package/modules/hall-ui/public/street-office.js +705 -0
  116. package/modules/hall-ui/public/street.css +161 -11
  117. package/modules/hall-ui/public/street.html +41 -11
  118. package/modules/hall-ui/public/street.js +78 -66
  119. package/modules/hall-ui/public/street.states.json +129 -21
  120. package/modules/hall-ui/public/studio.html +2 -2
  121. package/modules/hall-ui/public/style.css +5 -1
  122. package/modules/hall-ui/public/task.html +2 -2
  123. package/modules/hall-ui/public/thinking.html +2 -2
  124. package/modules/hall-ui/public/tweak-editor.html +2 -2
  125. package/modules/hall-ui/public/watch.html +2 -2
  126. package/modules/hall-ui/public/watch.js +14 -1
  127. package/modules/hall-ui/public/work.html +2 -2
  128. package/modules/hall-ui/records/living-city.md +57 -0
  129. package/modules/hall-ui/records/my-agents.md +21 -0
  130. package/modules/hall-ui/records/oversight-family.md +1 -0
  131. package/modules/hall-ui/records/profile-connections.md +10 -0
  132. package/modules/hall-ui/records/reading-rooms.md +2 -0
  133. package/modules/ideas/routes/blockers.js +92 -3
  134. package/modules/lifecycle/module.json +2 -1
  135. package/modules/lifecycle/moving.js +17 -1
  136. package/modules/lifecycle/routes/moving.js +6 -0
  137. package/modules/lifecycle/routes/project-settings.js +23 -0
  138. package/modules/onboarding/approval-broadcast.js +8 -0
  139. package/modules/onboarding/routes/access-requests.js +16 -6
  140. package/modules/platform-identity/connections.js +13 -2
  141. package/modules/platform-identity/routes/connections.js +7 -1
  142. package/modules/public-landing/public/account-private.states.json +2 -2
  143. package/modules/public-landing/public/account.html +65 -5
  144. package/modules/public-landing/public/account.states.json +3 -3
  145. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  146. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  147. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +13 -0
  148. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  149. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  150. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +13 -0
  151. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  152. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  153. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +13 -0
  154. package/modules/public-landing/public/assets/world.css +4 -4
  155. package/modules/public-landing/public/connections-empty.states.json +14 -0
  156. package/modules/public-landing/public/connections-inactive.states.json +14 -0
  157. package/modules/public-landing/public/connections.html +450 -0
  158. package/modules/public-landing/public/connections.probes.json +414 -0
  159. package/modules/public-landing/public/connections.states.json +17 -0
  160. package/modules/public-landing/public/contact.html +2 -2
  161. package/modules/public-landing/public/guild.html +2 -2
  162. package/modules/public-landing/public/index.html +2 -2
  163. package/modules/public-landing/public/invitations.html +2 -2
  164. package/modules/public-landing/public/privacy.html +2 -2
  165. package/modules/public-landing/public/projects.html +2 -2
  166. package/modules/public-landing/public/projects.probes.json +6 -2
  167. package/modules/public-landing/public/terms.html +2 -2
  168. package/modules/ui-design/kit/fixtures/connections-empty.json +12 -0
  169. package/modules/ui-design/kit/fixtures/connections-inactive.json +7 -0
  170. package/modules/ui-design/kit/fixtures/connections.json +22 -0
  171. package/modules/ui-design/kit/serve.js +95 -0
  172. package/package-lock.json +2 -2
  173. package/package.json +1 -1
  174. package/release-notes.json +128 -0
  175. package/scripts/discord/README.md +8 -5
  176. package/scripts/discord/post.js +12 -1
  177. package/scripts/gds/artifact-audit.js +20 -3
  178. package/scripts/gds/ask.js +164 -0
  179. package/scripts/gds/autobongos-run.js +20 -5
  180. package/scripts/gds/autobongos-tweak-lane.js +262 -0
  181. package/scripts/gds/autobongos-verify.js +21 -2
  182. package/scripts/gds/claim.js +2 -0
  183. package/scripts/gds/government-walk.js +638 -4
  184. package/scripts/gds/pulse.js +9 -6
  185. package/scripts/gds/ship-escalation.js +22 -3
  186. package/scripts/gds/ship-finish.js +5 -4
  187. package/scripts/gds/ship-merge-gate.js +18 -6
  188. package/scripts/gds/tweak-halt.js +1 -0
  189. package/scripts/hall-preview/README.md +41 -1
  190. package/scripts/hall-preview/pref-writes.js +140 -0
  191. package/scripts/hall-preview/refresh-fixtures.js +101 -7
  192. package/scripts/hall-preview/scrub-identity.js +366 -7
  193. package/scripts/hall-preview/server.js +27 -1
  194. package/scripts/render-diagrams.sh +4 -2
  195. package/src/bongos/auth-idp.js +6 -1
  196. package/src/bongos/project-settings.js +41 -0
  197. package/src/bongos/route-rank-check.js +27 -0
  198. package/src/bongos/routes/me.js +68 -7
  199. package/src/bongos/serve-internal.js +5 -1
  200. package/src/module-api.js +7 -1
  201. package/src/platform-server.js +10 -0
  202. package/tests/application_lifecycle.mjs +47 -5
  203. package/tests/autobongos_tweak_lane.mjs +208 -0
  204. package/tests/conductor_main_e2e.mjs +21 -0
  205. package/tests/connections_api.mjs +18 -0
  206. package/tests/craft_mode_nav.mjs +5 -3
  207. package/tests/craft_mode_tallies.mjs +52 -16
  208. package/tests/discord_approvals.mjs +11 -0
  209. package/tests/discord_channels.mjs +20 -0
  210. package/tests/fixtures/gov-seated.mjs +64 -0
  211. package/tests/fixtures/government-ask-cards.json +1284 -0
  212. package/tests/government_act_alone_buildings.mjs +1 -0
  213. package/tests/government_agent_asks.mjs +322 -0
  214. package/tests/government_asks.mjs +408 -0
  215. package/tests/government_build_rights.mjs +253 -0
  216. package/tests/government_buildouts.mjs +382 -0
  217. package/tests/government_counts.mjs +143 -0
  218. package/tests/government_decision_rules.mjs +5 -1
  219. package/tests/government_laws.mjs +488 -0
  220. package/tests/government_laws_session.mjs +321 -0
  221. package/tests/government_leads.mjs +375 -0
  222. package/tests/government_positions.mjs +127 -0
  223. package/tests/government_presets.mjs +215 -0
  224. package/tests/government_routes.mjs +8 -1
  225. package/tests/government_routing.mjs +118 -0
  226. package/tests/government_seed.mjs +5 -0
  227. package/tests/government_treasury.mjs +394 -0
  228. package/tests/governor_docket.mjs +26 -2
  229. package/tests/hall_buildouts.mjs +208 -0
  230. package/tests/hall_city.mjs +14 -6
  231. package/tests/hall_city_living.mjs +617 -0
  232. package/tests/hall_gov_counts.mjs +336 -0
  233. package/tests/hall_gov_living.mjs +636 -0
  234. package/tests/hall_gov_real_site.mjs +655 -0
  235. package/tests/hall_matter_card.mjs +6 -1
  236. package/tests/hall_my_agents.mjs +481 -0
  237. package/tests/hall_nav.mjs +107 -4
  238. package/tests/hall_page_gate_map.mjs +4 -0
  239. package/tests/hall_preview_governor_fixtures.mjs +30 -3
  240. package/tests/hall_preview_scrub.mjs +59 -1
  241. package/tests/hall_preview_security.mjs +169 -0
  242. package/tests/hall_reading_world.mjs +44 -0
  243. package/tests/hall_roster_world.mjs +6 -2
  244. package/tests/hall_street.mjs +18 -2
  245. package/tests/hall_street_amend.mjs +1 -1
  246. package/tests/hall_street_deck.mjs +609 -0
  247. package/tests/hall_street_depth.mjs +559 -0
  248. package/tests/hall_street_desk.mjs +1 -1
  249. package/tests/hall_street_inside.mjs +29 -5
  250. package/tests/hall_tweak_editor.mjs +4 -1
  251. package/tests/hub_account_page.mjs +94 -3
  252. package/tests/hub_connections_page.mjs +507 -0
  253. package/tests/hub_invite_badge.mjs +1 -1
  254. package/tests/idp_device_exchange.mjs +18 -0
  255. package/tests/landing_legal_pages.mjs +8 -5
  256. package/tests/landing_page.mjs +23 -4
  257. package/tests/nav_permission_atoms.mjs +4 -1
  258. package/tests/profile_connections_ui.mjs +88 -25
  259. package/tests/projects_hub.mjs +15 -0
  260. package/tests/render_check.mjs +2 -2
  261. package/tests/ship_broadcast_routing.mjs +57 -16
  262. package/tests/ship_broadcast_server.mjs +40 -7
  263. package/tests/ship_merge_gate.mjs +19 -0
  264. package/tests/ui_design_kit.mjs +2 -2
  265. package/tests/watch_applications_queue.mjs +23 -0
@@ -0,0 +1,617 @@
1
+ // tests/hall_city_living.mjs — THE LIVING CITY (task 1004685, the living-city
2
+ // prototype): the home city drawn from GovLiving.livingModel() over the real
3
+ // hall-preview fixtures and nothing else (task 1004717 deleted the prototype
4
+ // overlay). Five Leads are seated here as People votes would seat them, with a
5
+ // committee room and two build-outs, in the shape GET /government/leads answers,
6
+ // and the docket is routed to them by the server's own rule.
7
+ //
8
+ // WHAT IT GUARDS. The mapping from the model to the drawing, signal by signal:
9
+ // * sky: one pod per active claim, on a lane from the building its work
10
+ // answers to to its goal's district, at fixed spacing and one period;
11
+ // * civic: a capsule floor per Lead, committee room and built floor (structure is
12
+ // height); salmon windows only where an ask waits on the viewer's seat;
13
+ // the authorised build-out a live site, the proposal a ghost with a tally
14
+ // and a light shaft; quiet buildings darker;
15
+ // * ground: districts hazed by depth band; the spend conduit as wide as the
16
+ // real spend rate, a dry pot starved with a borrow beacon;
17
+ // * the plaque, the card (two doors: every answer is the deck's), the door
18
+ // and the line;
19
+ // * nothing in the drawing carries a prototype mark;
20
+ // * motion is CSS, 20s and longer, inside the moving-node budget, with a
21
+ // still frame set on every moving part; city-draw.js stays pure;
22
+ // * the page boots the living city from the feeds and acts in place.
23
+ //
24
+ // Run: node --test --test-reporter=tap tests/hall_city_living.mjs
25
+
26
+ import { test } from 'node:test';
27
+ import assert from 'node:assert/strict';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import vm from 'node:vm';
31
+ import { createRequire } from 'node:module';
32
+ import { fileURLToPath } from 'node:url';
33
+ // five Leads seated, a room and two build-outs, the docket routed to them (test data)
34
+ import { leadsRead, docketRouted } from './fixtures/gov-seated.mjs';
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
39
+ const FX = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
40
+ const City = require(path.join(PUB, 'city-draw.js'));
41
+ const G = require(path.join(PUB, 'gov-living.js'));
42
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
43
+ const fx = (name) => JSON.parse(fs.readFileSync(path.join(FX, name + '.json'), 'utf8'));
44
+ const clone = (v) => JSON.parse(JSON.stringify(v));
45
+
46
+ const NOW = Date.parse('2026-10-09T14:00:00.000Z');
47
+ function livingInputs(over = {}) {
48
+ return {
49
+ docket: docketRouted(), constitution: fx('government__constitution'), goals: fx('goals'), rollups: fx('goals__rollups'),
50
+ blockers: fx('blockers'), fence: fx('autonomy__fence'), claims: fx('tasks__claimable'), tasks: fx('tasks__status-active'),
51
+ moving: fx('moving'), cost: fx('public__cost-summary'), roster: fx('builders__roster'), me: fx('me'),
52
+ leads: leadsRead(), treasury: fx('government__treasury'), now: NOW, ...over,
53
+ };
54
+ }
55
+ function city(over = {}, extra = {}) {
56
+ const inp = livingInputs(over);
57
+ const living = G.livingModel(inp);
58
+ const docket = inp.docket;
59
+ return City.cityModel({
60
+ docket, goals: inp.goals.goals || inp.goals, rollups: inp.rollups.rollups || inp.rollups, mode: 'dark', now: NOW,
61
+ living, constitution: inp.constitution, ...extra,
62
+ });
63
+ }
64
+ const civicOf = (m, key) => m.civic.find((c) => c.key === key);
65
+ const bldOf = (m, key) => m.blds.find((b) => b.key === key);
66
+ const allPolys = (m) => {
67
+ const out = [];
68
+ m.flat.forEach((p) => out.push({ where: 'flat', p }));
69
+ m.blds.forEach((b) => {
70
+ b.polys.forEach((p) => out.push({ where: b.key, p }));
71
+ (b.parts || []).forEach((part) => part.polys.forEach((p) => out.push({ where: b.key, p, part })));
72
+ });
73
+ (m.sky || []).forEach((p) => out.push({ where: 'sky', p }));
74
+ return out;
75
+ };
76
+ const STRANGER = { builder: { id: '424242', github_login: 'builder-9999', display_name: 'Builder 9999', rank: 'xenos' } };
77
+
78
+ // ── the sky: one pod per agent ─────────────────────────────────────────────
79
+
80
+ test('one pod per active claim, on a lane from its building to its goal\'s district', () => {
81
+ const m = city();
82
+ const living = G.livingModel(livingInputs());
83
+ const claims = fx('tasks__claimable').active_claims;
84
+ assert.equal(m.counts.agents_at_work, claims.length);
85
+ assert.equal(m.lanes.reduce((n, L) => n + L.pods.length, 0), claims.length, 'every agent is a pod, no more, no less');
86
+ const k = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme('dark'), false);
87
+ const slot = Object.fromEntries(City.CIVIC_SLOTS.map((s) => [s[0], s]));
88
+ for (const c of living.city.traffic.claims) {
89
+ const L = m.lanes.find((x) => x.claims.some((y) => y.claim_id === c.claim_id));
90
+ assert.ok(L, `${c.task_id} rides a lane`);
91
+ assert.equal(L.building, c.building, 'the lane leaves the building its work answers to');
92
+ const s = slot[c.building];
93
+ const start = k.P(s[1] + 1, s[2] + 1, civicOf(m, c.building).top && 0);
94
+ assert.ok(L.d.startsWith('M' + Math.round(start[0] * 10) / 10 + ' ') || L.orbit, 'from that building');
95
+ const D = m.districts.find((d) => d.goal_id === c.goal_id);
96
+ if (D) {
97
+ const end = k.P((D.lot.x0 + D.lot.x1 + 1) / 2, (D.lot.y0 + D.lot.y1 + 1) / 2, 96).map((n) => Math.round(n * 10) / 10);
98
+ assert.ok(L.d.endsWith(end[0] + ' ' + end[1]), `${c.task_id}: to its goal's district`);
99
+ assert.equal(L.orbit, false);
100
+ } else {
101
+ assert.equal(L.orbit, true, `${c.task_id} has no district, so it circles its own building`);
102
+ }
103
+ }
104
+ assert.ok(m.sky.filter((p) => p.cls === 'city-lane').every((p) => !('proto' in p)), 'a lane carries no prototype mark');
105
+ });
106
+
107
+ test('pods keep a fixed spacing and one period on every lane: speed carries no data', () => {
108
+ const claims = clone(fx('tasks__claimable'));
109
+ const base = claims.active_claims[0];
110
+ claims.active_claims = [0, 1, 2].map((i) => ({ ...base, claim_id: 'c' + i, task_id: base.task_id }));
111
+ const m = city({ claims });
112
+ const L = m.lanes[0];
113
+ assert.equal(m.lanes.length, 1, 'three agents on one route share one lane');
114
+ assert.equal(L.pods.length, 3);
115
+ for (let i = 1; i < 3; i++) {
116
+ assert.equal(Math.round((L.pods[i].at - L.pods[i - 1].at) * 10) / 10, Math.round(City.POD_GAP * 1000) / 10, 'the still frame spaces them evenly');
117
+ assert.equal(Math.round((L.pods[i - 1].delay - L.pods[i].delay) * 10) / 10, Math.round(City.LANE_PERIOD * City.POD_GAP * 10) / 10, 'and so does the motion');
118
+ }
119
+ assert.ok(m.lanes.every((x) => x.pods.every((p) => p.dur === City.LANE_PERIOD)), 'one period everywhere');
120
+ assert.ok(City.LANE_PERIOD >= 20, 'an ambient loop is 20s or longer');
121
+ });
122
+
123
+ test('past the moving-node budget the sky keeps its pods and says how many more ride', () => {
124
+ const claims = clone(fx('tasks__claimable'));
125
+ const base = claims.active_claims[0];
126
+ claims.active_claims = Array.from({ length: 90 }, (_, i) => ({ ...base, claim_id: 'c' + i }));
127
+ const m = city({ claims });
128
+ const pods = m.lanes.reduce((n, L) => n + L.pods.length, 0);
129
+ assert.ok(pods <= City.MAX_PODS);
130
+ assert.equal(pods + m.lanes.reduce((n, L) => n + L.extra, 0), 90, 'every agent is a pod or in a lane\'s "+N"');
131
+ assert.equal(m.counts.agents_at_work, 90, 'the line still counts all of them');
132
+ const moving = Object.values(m.motion).reduce((n, x) => n + x, 0);
133
+ assert.ok(moving < 100, `${moving} moving nodes`);
134
+ });
135
+
136
+ // ── civic: structure is height ─────────────────────────────────────────────
137
+
138
+ test('a capsule floor per Lead, committee room and built floor: the building rises with its structure', () => {
139
+ const m = city();
140
+ const living = G.livingModel(livingInputs());
141
+ for (const b of living.buildings) {
142
+ const c = civicOf(m, b.key);
143
+ const n = (b.lead ? 1 : 0) + b.structure.committees.length + b.structure.built.length;
144
+ assert.equal(c.floors.length, n, `${b.key}: ${n} floors`);
145
+ if (b.lead) assert.equal(c.floors[0].kind, 'lead', `${b.key}: the Lead's floor is the first`);
146
+ const plugs = bldOf(m, b.key).parts.filter((p) => p.cls === 'city-plug' && p.floor);
147
+ assert.equal(plugs.length, n, `${b.key}: one capsule shell per floor`);
148
+ assert.ok(plugs.every((p) => !('proto' in p)), 'no prototype mark');
149
+ }
150
+ assert.deepEqual(civicOf(m, 'review').floors.map((f) => [f.kind, f.name]), [['lead', 'Builder 2014'], ['committee', 'Testers']]);
151
+ // a committee room put up at Planning: one more floor, FLOOR_H taller
152
+ const before = civicOf(m, 'planning');
153
+ const leads = leadsRead();
154
+ leads.buildings.find((b) => b.building === 'planning').structure = { standing: [{ id: 9, capsule: 'committee', name: 'Roadmap', line: 'x', state: 'built', progress: 1 }], proposed: [] };
155
+ const after = civicOf(city({ leads }), 'planning');
156
+ assert.equal(after.floors.length, before.floors.length + 1);
157
+ const k = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme('dark'), false);
158
+ assert.ok(Math.abs(before.top[1] - after.top[1] - City.FLOOR_H * k.S) <= 0.15, 'taller by exactly one floor (to the tenth of a pixel the drawing rounds to)');
159
+ // the Leads not read: no Leads, no capsules, every building at its recipe's roof
160
+ const bare = city({ leads: null });
161
+ for (const c of bare.civic) assert.equal(c.floors.length, 0, `${c.key}: no structure is invented`);
162
+ });
163
+
164
+ test('salmon only for what waits on the viewer: on the floor whose seat it waits on', () => {
165
+ const m = city();
166
+ const salmon = allPolys(m).filter((x) => /city-win--you/.test(x.p.cls || ''));
167
+ assert.ok(salmon.length > 0, 'the viewer has asks waiting');
168
+ for (const s of salmon) assert.ok(civicOf(m, s.where).on_you > 0, `${s.where}: salmon only where something waits on you`);
169
+ // the viewer leads Planning: what waits on that seat lights the Lead's own floor
170
+ const planning = civicOf(m, 'planning');
171
+ const leadFloor = planning.floors.find((f) => f.kind === 'lead');
172
+ assert.ok(leadFloor.on_you > 0, 'the Lead\'s floor holds what waits on its Lead');
173
+ const leadPane = bldOf(m, 'planning').parts.find((p) => p.floor === 'lead' && /city-plug--detail/.test(p.cls));
174
+ assert.match(leadPane.polys[0].cls, /city-win--you/);
175
+ // Review's 21 criteria wait on Review's own Lead: ink, never salmon
176
+ const review = civicOf(m, 'review');
177
+ assert.equal(review.on_you, 0);
178
+ assert.equal(review.on_others, 21);
179
+ assert.equal(allPolys(m).filter((x) => x.where === 'review' && /city-win--you/.test(x.p.cls || '')).length, 0);
180
+ assert.equal(civicOf(m, 'treasury').on_you, 0, 'the dry pot\'s borrow waits on the Treasury\'s Lead');
181
+ // a stranger: nothing waits on them, so nothing is salmon and the door is plain
182
+ const s = city({ me: STRANGER });
183
+ assert.equal(allPolys(s).filter((x) => /city-win--you/.test(x.p.cls || '')).length, 0);
184
+ assert.ok(s.pins.every((p) => p.state === 'moving' || p.state === 'settled'), 'no salmon pin');
185
+ assert.deepEqual(s.cta, City.ctaFor(0));
186
+ assert.equal(s.cta.label, 'start governing');
187
+ });
188
+
189
+ test('pins read the living asks: salmon with an age ring only when an ask waits on you', () => {
190
+ const m = city();
191
+ const planning = m.pins.find((p) => p.building === 'planning');
192
+ assert.equal(planning.state, 'needs');
193
+ assert.equal(planning.count, civicOf(m, 'planning').on_you);
194
+ assert.ok(planning.oldest_days >= 14 && planning.age === 1, 'over two weeks old: the ring is full');
195
+ const review = m.pins.find((p) => p.building === 'review');
196
+ assert.equal(review.state, 'moving', 'twenty-one criteria wait on Review\'s Lead, not on this viewer: ink');
197
+ assert.equal(review.age, null);
198
+ assert.equal(review.count, 21);
199
+ for (const p of m.pins) assert.equal(p.state === 'needs' || p.state === 'urgent', civicOf(m, p.building).on_you > 0, p.building);
200
+ assert.ok(m.pins.every((p) => !('proto' in p)), 'every pin is a real matter, routed by the server');
201
+ });
202
+
203
+ test('the authorised build-out is a live site; the proposal on a vote is a ghost, a tally and a shaft', () => {
204
+ const m = city();
205
+ const rel = civicOf(m, 'release');
206
+ assert.equal(rel.site.kind, 'live');
207
+ assert.equal(rel.site.progress, 0.55);
208
+ assert.equal(rel.site.built, 1, '55% of three floors: one built');
209
+ const parts = bldOf(m, 'release').parts;
210
+ const rise = parts.find((p) => p.cls === 'city-rise');
211
+ assert.equal(Number(rise.style['--rise']), 0.65, 'the next floor stands at its share in the still frame');
212
+ const climb = parts.find((p) => p.cls === 'city-climb');
213
+ assert.equal(parseFloat(climb.style['--climb-at']), Math.round(0.55 * parseFloat(climb.style['--climb']) * 10) / 10, 'the climber stands at the share built');
214
+ const jib = parts.find((p) => p.cls === 'city-jib');
215
+ assert.match(jib.style['--pivot'], /^\d+(\.\d)?px \d+(\.\d)?px$/, 'the jib swings about its mast head');
216
+ assert.ok(parts.every((p) => !('proto' in p)), 'the build-out is GET /government/leads\' own');
217
+ assert.equal(rel.shaft, null, 'a build-out the form of government allows needs no sitting');
218
+ const tre = civicOf(m, 'treasury');
219
+ assert.equal(tre.site.kind, 'pending');
220
+ const tparts = bldOf(m, 'treasury').parts;
221
+ assert.ok(tparts.some((p) => p.cls === 'city-ghost'), 'a dashed-glass ghost annex');
222
+ assert.ok(tparts.find((p) => p.cls === 'city-ghost').polys.filter((p) => p.f !== 'none').every((p) => p.dash === '4 3'), 'every face dashed');
223
+ assert.ok(!tparts.some((p) => /city-jib|city-rise|city-climb/.test(p.cls || '')), 'the crane is parked: nothing moves');
224
+ const holders = fx('government__docket').buildings.find((b) => b.key === 'charter').holders.length;
225
+ assert.deepEqual([tre.tally.seats, tre.tally.yes, tre.tally.no], [holders, 0, 0], 'one dot per board seat, none cast');
226
+ assert.ok(tre.shaft, 'the vote\'s sitting is a light shaft');
227
+ assert.deepEqual(m.shafts.map((s) => s.building), ['treasury']);
228
+ });
229
+
230
+ test('an open sitting in the real docket raises a shaft with its real tally; a settled one raises none', () => {
231
+ const docket = clone(fx('government__docket'));
232
+ docket.matters.push({ id: 'board:99', source: 'board', building: 'rules', rule: 'unanimous', state: 'needs', title: 'Adopt a rule', short: 'a rule', opened_at: '2026-10-08T10:00:00.000Z', tally: { for: 2, against: 1, open: 2, needs: 'x' }, ref: { type: 'board_item', id: '99' }, acts: [] });
233
+ const m = city({ docket });
234
+ const rules = civicOf(m, 'rules');
235
+ assert.equal(rules.site.kind, 'pending');
236
+ assert.deepEqual([rules.tally.seats, rules.tally.yes, rules.tally.no], [5, 2, 1]);
237
+ assert.ok(rules.shaft);
238
+ assert.equal(civicOf(m, 'planning').shaft, null, 'board:16 is settled: no shaft');
239
+ });
240
+
241
+ test('quiet buildings sit darker; busy ones keep the prototype\'s recipe exactly', () => {
242
+ const m = city();
243
+ const k = City.isoKit(60, 30, 720, 176, 30 / 28, City.isoTheme('dark'), false);
244
+ for (const s of City.CIVIC_SLOTS) {
245
+ const ref = [];
246
+ City.isoInstitution(ref, k, s[0], s[1], s[2], { fixes: City.rulesAtWork(fx('government__docket')) });
247
+ const b = bldOf(m, s[0]);
248
+ const recipe = b.polys.slice(0, ref.length).map((p) => p.f);
249
+ if (b.quiet) assert.notDeepEqual(recipe, ref.map((p) => p.f), `${s[0]} is quiet: its faces are mixed toward the void`);
250
+ else assert.deepEqual(recipe, ref.map((p) => p.f), `${s[0]} is busy: the recipe is untouched`);
251
+ }
252
+ assert.deepEqual(m.civic.filter((c) => c.quiet).map((c) => c.key).sort(), ['people', 'rules'], 'no agent and no ask: Rules and People');
253
+ assert.equal(City.mixToward('#808080', '#000000', 0.5), '#404040');
254
+ assert.equal(City.mixToward('rgba(255,255,255,0.4)', '#000000', 0.5), 'rgba(255,255,255,0.2)', 'a line thins out');
255
+ });
256
+
257
+ // ── ground: haze and the spend conduit ─────────────────────────────────────
258
+
259
+ test('districts haze by depth band; a district with an agent at work keeps less; the civic quarter never', () => {
260
+ const m = city();
261
+ const living = G.livingModel(livingInputs());
262
+ const agents = Object.fromEntries(living.districts.map((d) => [d.goal_id, d.agents]));
263
+ for (const D of m.districts) {
264
+ const band = City.HAZE[City.hazeBand(D.lot)];
265
+ assert.equal(D.haze, Math.round(band * (agents[D.goal_id] > 0 ? City.ACTIVE_HAZE : 1) * 100) / 100, D.goal_id);
266
+ }
267
+ assert.ok(City.HAZE[0] > City.HAZE[1] && City.HAZE[1] > City.HAZE[2], 'the far band is the haziest');
268
+ assert.equal(City.hazeBand({ x0: 0, x1: 3, y0: 0, y1: 3 }), 0, 'the back corner is far');
269
+ assert.equal(City.hazeBand({ x0: 12, x1: 15, y0: 12, y1: 15 }), 2, 'the front corner is near');
270
+ // without the living model nothing is hazed: the old city is the old city
271
+ const old = City.cityModel({ docket: fx('government__docket'), goals: fx('goals').goals, rollups: fx('goals__rollups').rollups, mode: 'dark', now: NOW });
272
+ assert.ok(old.districts.every((D) => D.haze === 0));
273
+ });
274
+
275
+ test('the spend conduit: the trunk is as wide as the real spend rate; a dry pot is starved and raises a beacon', () => {
276
+ const m = city();
277
+ const cost = fx('public__cost-summary');
278
+ const month = cost.by_month.find((x) => x.month === '2026-10').total_usd;
279
+ const rate = Math.round(Math.round(month * 100) / 100 / 9 * 100) / 100;
280
+ assert.equal(m.spend.rate_usd_per_day, rate, 'this month\'s real spend over the nine days so far');
281
+ const trunk = m.flat.find((p) => p.key === 'trunk');
282
+ assert.equal(trunk.w, City.trunkWidth(rate));
283
+ assert.equal('proto' in trunk, false, 'the spend is real');
284
+ assert.ok(City.trunkWidth(rate * 4) > City.trunkWidth(rate), 'more spend, a wider trunk');
285
+ assert.ok(m.flat.filter((p) => /^spur:/.test(p.key || '')).length > 0, 'the pots split it (task 1004712)');
286
+ const dry = m.flat.find((p) => p.key === 'spur:1000106');
287
+ assert.match(dry.cls, /city-conduit--dry/);
288
+ assert.equal(dry.dash, '2 3', 'starved');
289
+ assert.deepEqual(m.beacons.map((b) => [b.goal_id, b.ask_id]), [['1000106', 'treasury_spending:1']], 'the dry pot asks the reserve');
290
+ const wide = m.spend.spurs.reduce((a, b) => (b.share > a.share ? b : a));
291
+ assert.equal(wide.goal_id, '1000095', 'the busiest pot has the widest spur');
292
+ // no treasury read: the trunk alone, no split, no beacon
293
+ const bare = city({ treasury: null });
294
+ assert.ok(bare.flat.some((p) => p.key === 'trunk'));
295
+ assert.equal(bare.flat.filter((p) => /^spur:/.test(p.key || '')).length, 0);
296
+ assert.equal(bare.beacons.length, 0);
297
+ // no spend read: no conduit at all
298
+ assert.equal(city({ cost: null }).flat.filter((p) => /city-conduit/.test(p.cls || '')).length, 0);
299
+ });
300
+
301
+ // ── the plaque, the card, the door ─────────────────────────────────────────
302
+
303
+ test('the plaque: the form of government, laws, what is on you, this month\'s spend', () => {
304
+ const m = city();
305
+ const con = fx('government__constitution');
306
+ assert.deepEqual(m.plaque.map((t) => [t.key, t.figure, t.words]), [
307
+ ['form', con.charter.reads_as.name, 'government'], ['laws', '1', 'law'], ['you', String(m.counts.on_you), 'to do'], ['spend', '$6,319', 'this month'],
308
+ ]);
309
+ assert.ok(m.counts.on_you > 0);
310
+ assert.ok(m.plaque.every((t) => !('proto' in t)), 'every tile is a real read');
311
+ const s = city({ me: STRANGER });
312
+ assert.ok(!s.plaque.some((t) => t.key === 'you'), 'a tile is hidden at zero');
313
+ assert.ok(!city({ constitution: null }, { constitution: null }).plaque.some((t) => t.key === 'form'));
314
+ });
315
+
316
+ test('the card: who holds it, one fact, its count, two doors (its first ask is the deck\'s own card, task 1004702)', () => {
317
+ const m = city();
318
+ const p = civicOf(m, 'planning').card;
319
+ assert.equal(p.plate, 'Builder 2012');
320
+ assert.equal(p.fact.text, '16 of 55 kept', 'the docket\'s own reading');
321
+ assert.equal(p.on_you, civicOf(m, 'planning').on_you);
322
+ assert.equal(p.deck_href, '/builders/street#deck=planning');
323
+ assert.equal(p.enter_href, '/builders/street#building=planning');
324
+ for (const c of m.civic) assert.deepEqual(Object.keys(c.card).sort(), ['agents', 'deck_href', 'enter_href', 'fact', 'key', 'lead', 'name', 'on_others', 'on_you', 'open', 'plate'], c.key + ': every answer is the deck\'s');
325
+ assert.equal(civicOf(m, 'charter').card.plate, 'owner', 'an unseated building is the owner\'s');
326
+ assert.equal(civicOf(m, 'automation').card.fact.text, 'unattended off', 'the real fence');
327
+ assert.equal(civicOf(m, 'release').card.fact.text, '2 agents working');
328
+ });
329
+
330
+ test('an ask answered this visit (on the deck) leaves the counts, the card and the pin', () => {
331
+ const m = city();
332
+ const first = G.livingModel(livingInputs()).city.asks.find((a) => a.building === 'planning' && a.needs_you).id;
333
+ const a = city({}, { answered: [first] });
334
+ assert.equal(a.counts.on_you, m.counts.on_you - 1);
335
+ assert.equal(a.cta.label, 'decide ' + (m.counts.on_you - 1) + ' in a row');
336
+ assert.equal(civicOf(a, 'planning').card.on_you, civicOf(m, 'planning').card.on_you - 1);
337
+ assert.equal(a.pins.find((p) => p.building === 'planning').count, civicOf(m, 'planning').on_you - 1);
338
+ });
339
+
340
+ test('the door is "decide N in a row" to the deck; the line counts you, others and the agents', () => {
341
+ const m = city();
342
+ const c = m.counts;
343
+ assert.ok(c.on_you > 0 && c.on_others > 0);
344
+ assert.deepEqual([m.cta.label, m.cta.href], ['decide ' + c.on_you + ' in a row', '/builders/street#deck']);
345
+ assert.equal(m.line, c.on_you + ' to do · ' + c.on_others + ' wait on others · 5 agents working');
346
+ assert.equal(City.livingLine(1, 0, 1), '1 to do · 1 agent working');
347
+ assert.equal(City.livingLine(0, 3, 0), 'nothing to do · 3 wait on others');
348
+ assert.equal(city({ docket: null }).line, City.FALLBACK_LINE, 'an unread docket says so');
349
+ });
350
+
351
+ // ── all of it real ─────────────────────────────────────────────────────────
352
+
353
+ test('nothing in the drawing carries a prototype mark, and an overlay handed in changes nothing', () => {
354
+ const m = city();
355
+ const marked = allPolys(m).filter((x) => 'proto' in x.p || (x.part && 'proto' in x.part));
356
+ assert.deepEqual(marked.map((x) => x.where + ':' + (x.p.cls || x.part.cls)), [], 'no part carries a proto flag');
357
+ assert.ok(m.civic.every((c) => !('proto' in c.plate) && !('proto' in c.card)));
358
+ assert.equal('proto_note' in m, false, 'and there is no prototype key to show');
359
+ const inp = livingInputs({ overlay: { leads: [{ building: 'rules', lead: '3' }], laws: [{ id: 'x', text: 'An invented law' }], buildouts: [{ id: 'y', building: 'rules', authorised: true }] } });
360
+ const withOverlay = City.cityModel({ docket: inp.docket, goals: inp.goals.goals, rollups: inp.rollups.rollups, mode: 'dark', now: NOW, living: G.livingModel(inp), constitution: inp.constitution });
361
+ assert.deepEqual(withOverlay, m, 'the overlay is not read');
362
+ });
363
+
364
+ // ── motion, budget, purity ─────────────────────────────────────────────────
365
+
366
+ test('motion is CSS, long, inside the budget, with a still frame on every moving part', () => {
367
+ const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
368
+ for (const k of ['city-fly', 'city-rise', 'city-climb', 'city-swing', 'city-shaft']) assert.match(css, new RegExp(`@keyframes ${k} `), k);
369
+ const loops = [...css.matchAll(/animation:\s*city-(fly|rise|climb|swing|shaft)\s+(\d+)s/g)];
370
+ assert.equal(loops.length, 5);
371
+ for (const l of loops) assert.ok(Number(l[2]) >= 20, `${l[1]} runs ${l[2]}s: an ambient loop is 20s or longer`);
372
+ // the new keyframes move transform or offset-distance only
373
+ for (const k of ['city-fly', 'city-rise', 'city-climb', 'city-swing', 'city-shaft', 'city-plug', 'city-plug-in']) {
374
+ const body = css.match(new RegExp(`@keyframes ${k} \\{([\\s\\S]*?)\\}\\s*\\}`))[1];
375
+ const props = [...body.matchAll(/([a-z-]+)\s*:/g)].map((x) => x[1]);
376
+ assert.ok(props.every((p) => p === 'transform' || p === 'offset-distance'), `${k}: ${props}`);
377
+ }
378
+ assert.doesNotMatch(css, /stroke-dashoffset/);
379
+ assert.doesNotMatch(css, /blur\(/, 'no per-particle blur');
380
+ // the still frame of each moving part is the part's own value
381
+ assert.match(css, /\.city-pod \{[^}]*offset-distance: var\(--pod-at/);
382
+ assert.match(css, /\.city-rise \{[^}]*transform: scaleY\(var\(--rise/);
383
+ assert.match(css, /\.city-climb \{[^}]*transform: translateY\(var\(--climb-at/);
384
+ assert.match(css, /\.city-shaft__ring \{[^}]*transform: translateY\(-\d+px\)/);
385
+ assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
386
+ const m = city();
387
+ const moving = Object.values(m.motion).reduce((n, x) => n + x, 0);
388
+ assert.ok(moving < 100, `${moving} moving nodes`);
389
+ });
390
+
391
+ test('salmon is spent only on what waits on you (and the door, the focus ring)', () => {
392
+ const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
393
+ const ALLOWED = /city-win--you|city-pin--(urgent|needs)|city-pin__ring|city-pin__age|city-start|city-card__go--you|city-ask--you|city-plaque__tile\[data-key="you"\]|city-card__count--you|focus-visible|body\[data-page="city"\]\s*$/;
394
+ let checked = 0;
395
+ for (const r of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
396
+ if (!/var\(--accent(-deep|-lift)?\)/.test(r[2])) continue;
397
+ const sel = r[1].trim();
398
+ checked += 1;
399
+ assert.match(sel, ALLOWED, `"${sel}" spends the accent`);
400
+ }
401
+ assert.ok(checked >= 10, `${checked} accent rules were read, not a vacuous scan`);
402
+ assert.doesNotMatch(css, /\.city-parcel--out \{[^}]*var\(--accent\)/, 'work leaving through Release is ink');
403
+ });
404
+
405
+ test('city-draw.js is pure: no DOM, no fetch, no clock', () => {
406
+ const src = read('city-draw.js');
407
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
408
+ const tail = "typeof window !== 'undefined' ? window : globalThis";
409
+ assert.ok(code.includes(tail));
410
+ const body = code.replace(tail, '');
411
+ for (const [name, re] of Object.entries({ window: /\bwindow\b/, document: /\bdocument\b/, fetch: /\bfetch\b/, 'Date.now': /Date\.now/, 'new Date()': /new Date\(\s*\)/, setTimeout: /setTimeout|setInterval/, localStorage: /localStorage/ })) {
412
+ assert.doesNotMatch(body, re, `${name} must not appear in city-draw.js`);
413
+ }
414
+ const inp = livingInputs();
415
+ const before = JSON.stringify(inp);
416
+ const one = city();
417
+ assert.deepEqual(city(), one, 'the same reads give the same city');
418
+ assert.equal(JSON.stringify(inp), before);
419
+ });
420
+
421
+ // ── the page ────────────────────────────────────────────────────────────────
422
+
423
+ const IDS = ['cityMode', 'cityModeIcon', 'cityStage', 'cityFrame', 'cityFlat', 'cityBlds', 'citySkyLanes', 'cityParcels', 'citySky', 'cityPins', 'cityHits', 'cityLine', 'cityStart', 'cityPlaque', 'cityCard', 'hall-main'];
424
+ function fakeDom() {
425
+ const byId = new Map();
426
+ const all = [];
427
+ const mk = (tag) => {
428
+ const el = {
429
+ tagName: tag, attrs: {}, children: [], style: { setProperty(k, v) { this[k] = v; } }, className: '', listeners: {}, _text: '', parentNode: null,
430
+ get firstChild() { return this.children[0] || null; },
431
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') byId.set(String(v), this); },
432
+ removeAttribute(k) { delete this.attrs[k]; },
433
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
434
+ appendChild(c) { this.children.push(c); c.parentNode = this; return c; },
435
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); return c; },
436
+ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
437
+ fire(t, e) { (this.listeners[t] || []).forEach((fn) => fn({ type: t, target: this, ...e })); },
438
+ focus() { focused = this; },
439
+ // enough of querySelector for city.js: '.a, .b' and '[attr="v"]'
440
+ querySelector(sel) {
441
+ const want = sel.split(',').map((s) => s.trim());
442
+ const ok = (c) => want.some((w) => {
443
+ const a = /^\[([\w-]+)="([^"]*)"\]$/.exec(w);
444
+ return a ? c.attrs[a[1]] === a[2] : String(c.className).split(' ').includes(w.replace(/^\./, ''));
445
+ });
446
+ const walk = (n) => { for (const c of n.children) { if (ok(c)) return c; const f = walk(c); if (f) return f; } return null; };
447
+ return walk(this);
448
+ },
449
+ set textContent(v) { this._text = String(v); this.children = []; },
450
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },
451
+ get innerHTML() { throw new Error('city.js must not read innerHTML'); },
452
+ set innerHTML(v) { throw new Error('city.js must not write innerHTML'); },
453
+ };
454
+ all.push(el);
455
+ return el;
456
+ };
457
+ let focused = null;
458
+ const html = mk('html');
459
+ for (const id of IDS) mk('x').setAttribute('id', id);
460
+ const document = { documentElement: html, getElementById: (id) => byId.get(id) || null, createElement: (t) => mk(t), createElementNS: (_ns, t) => mk(t) };
461
+ return { document, byId, html, focused: () => focused };
462
+ }
463
+ const find = (el, pred) => { if (pred(el)) return el; for (const c of el.children) { const f = find(c, pred); if (f) return f; } return null; };
464
+ const findAll = (el, pred, out = []) => { if (pred(el)) out.push(el); el.children.forEach((c) => findAll(c, pred, out)); return out; };
465
+
466
+ // real past asks as stored asks: the server attaches a matter's stored ask as `ask`
467
+ const CARDS = JSON.parse(fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'government-ask-cards.json'), 'utf8')).cards;
468
+ function withAsks(d) {
469
+ d.matters.forEach((m) => {
470
+ const c = CARDS.find((x) => x.id === m.id);
471
+ if (c) m.ask = { question: c.question, options: c.options, recommend: c.recommend, waits_on: c.waits_on, if_nobody_answers: c.if_nobody_answers };
472
+ });
473
+ return d;
474
+ }
475
+ async function bootLiving({ asks = false, answer = null } = {}) {
476
+ const { document, byId, html, focused } = fakeDom();
477
+ const timers = [];
478
+ const window = {
479
+ innerWidth: 1440, innerHeight: 900, localStorage: { getItem: () => 'dark', setItem() {} },
480
+ matchMedia: () => ({ matches: true, addEventListener() {} }), addEventListener() {},
481
+ setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout() {},
482
+ };
483
+ const feeds = {
484
+ '/api/bongos/government/docket': asks ? withAsks(docketRouted()) : docketRouted(), '/api/bongos/goals': fx('goals'), '/api/bongos/goals/rollups': fx('goals__rollups'),
485
+ '/api/bongos/government/leads': leadsRead(), '/api/bongos/government/treasury': fx('government__treasury'),
486
+ '/api/bongos/government/constitution': fx('government__constitution'), '/api/bongos/tasks/claimable?limit=1': fx('tasks__claimable'),
487
+ '/api/bongos/tasks?status=active&limit=200': fx('tasks__status-active'), '/api/bongos/public/cost-summary': fx('public__cost-summary'),
488
+ '/api/bongos/builders/roster': fx('builders__roster'), '/api/bongos/me': fx('me'), '/api/bongos/blockers?limit=100': fx('blockers'),
489
+ '/api/bongos/autonomy/fence': fx('autonomy__fence'), '/api/bongos/moving': fx('moving'),
490
+ };
491
+ const calls = [];
492
+ const sent = [];
493
+ const fetch = (url, init) => {
494
+ if (init && init.method && init.method !== 'GET') {
495
+ const body = JSON.parse(init.body || '{}');
496
+ sent.push({ method: init.method, url, body });
497
+ const out = (answer && answer(url, body)) || { status: 501, body: { error: { code: 'preview_readonly' } } };
498
+ return Promise.resolve({ ok: out.status < 300, status: out.status, json: async () => out.body || {} });
499
+ }
500
+ calls.push(url); const b = feeds[url]; return Promise.resolve(b === undefined ? { ok: false, json: async () => ({}) } : { ok: true, json: async () => clone(b) }); };
501
+ class FixedDate extends Date { constructor(...a) { super(...(a.length ? a : [NOW])); } static now() { return NOW; } }
502
+ class MutationObserver { observe() {} }
503
+ const ctx = vm.createContext({ window, document, fetch, Promise, Math, String, Number, Array, Object, JSON, console, MutationObserver, Date: FixedDate, encodeURIComponent, decodeURIComponent, isNaN, isFinite, Map, Set, setTimeout, clearTimeout });
504
+ ctx.globalThis = ctx;
505
+ // both hang themselves on `window` (it exists in this context), as in the browser
506
+ vm.runInContext(read('gov-living.js'), ctx);
507
+ vm.runInContext(read('city-draw.js'), ctx);
508
+ assert.ok(window.GovLiving && window.GovCity);
509
+ // the deck's card and sender, as city.html loads them (task 1004702)
510
+ for (const f of ['street-draw.js', 'street-card.js', 'street-inside.js', 'street-desk.js', 'street-deck.js']) vm.runInContext(read(f), ctx);
511
+ assert.ok(window.GovDeck && window.GovDesk, 'the deck\'s scripts are on the page');
512
+ vm.runInContext(read('city.js'), ctx);
513
+ await window.GovCityPage.ready;
514
+ return { window, byId, html, calls, sent, timers, focused };
515
+ }
516
+
517
+ test('the page reads the living feeds and draws the sky, the plates, the plaque and the door', async () => {
518
+ const p = await bootLiving();
519
+ for (const url of Object.values(p.window.GovCityPage.LIVING_FEEDS)) assert.ok(p.calls.includes(url), url);
520
+ const pods = p.byId.get('citySky').children.filter((c) => /city-pod/.test(c.className));
521
+ assert.equal(pods.length, fx('tasks__claimable').active_claims.length, 'one pod per agent');
522
+ assert.ok(pods.every((s) => /^path\('M/.test(s.style.offsetPath) && /%$/.test(s.style['--pod-at'])), 'each on its lane, its still frame set');
523
+ assert.equal(p.byId.get('cityHits').children.length, 9, 'one button per civic building');
524
+ assert.ok(p.byId.get('cityHits').children.every((b) => b.tagName === 'button' && /, (led by|held by)/.test(b.attrs['aria-label'])));
525
+ assert.equal(p.byId.get('citySky').children.filter((c) => /city-shaft/.test(c.className)).length, 1);
526
+ const plaque = p.byId.get('cityPlaque');
527
+ assert.equal(plaque.children.length, 4);
528
+ assert.equal(plaque.attrs.hidden, undefined, 'the plaque shows');
529
+ const m = city();
530
+ const start = p.byId.get('cityStart');
531
+ assert.equal(start.textContent, m.cta.label);
532
+ assert.equal(start.attrs.href, '/builders/street#deck');
533
+ assert.equal(p.byId.get('cityLine').textContent, m.line);
534
+ for (const id of ['cityBlds', 'citySky', 'cityPins', 'cityPlaque']) assert.equal(findAll(p.byId.get(id), (n) => 'data-proto' in n.attrs).length, 0, id + ': nothing marked prototype');
535
+ assert.doesNotMatch(read('city.html'), /cityKey|cityToast|data-proto/, 'the page has no prototype key and no in-place answer toast');
536
+ });
537
+
538
+ test('a building opens its card in place: who holds it, one fact, what waits, its first ask and two doors to the deck and the street', async () => {
539
+ const p = await bootLiving();
540
+ const hit = p.byId.get('cityHits').children.find((b) => b.attrs['data-building'] === 'planning');
541
+ hit.fire('click');
542
+ const card = p.byId.get('cityCard');
543
+ assert.equal(card.attrs.hidden, undefined);
544
+ assert.equal(card.attrs['data-building'], 'planning');
545
+ assert.match(card.textContent, /Planning/);
546
+ // the viewer (builder 2012) leads Planning: "Your seat." (task 1004702: the card speaks to you)
547
+ assert.match(card.textContent, /Your seat\./);
548
+ const n = civicOf(city(), 'planning').on_you;
549
+ assert.match(card.textContent, new RegExp(n + ' wait on you\\.'));
550
+ assert.ok(/\bgov-glass\b/.test(card.className), 'the card is glass');
551
+ const doors = findAll(card, (x) => x.tagName === 'a' && /city-card__(go|enter)/.test(String(x.className)));
552
+ assert.deepEqual(doors.map((a) => [a.textContent, a.attrs.href]), [['decide all ' + n + ' in a row', '/builders/street#deck=planning'], ['go inside', '/builders/street#building=planning']], 'two doors');
553
+ // the building's first ask stands in place, as the deck draws it
554
+ const ask = findAll(card, (x) => /\bcity-ask\b/.test(String(x.className)));
555
+ assert.equal(ask.length, 1, 'its first ask, in place');
556
+ assert.ok(/\bask--here\b/.test(ask[0].className));
557
+ card.fire('keydown', { key: 'Escape', preventDefault() {} });
558
+ assert.equal(card.attrs.hidden, '', 'Escape closes it');
559
+ const again = p.byId.get('cityHits').children.find((b) => b.attrs['data-building'] === 'planning');
560
+ assert.ok(p.focused() === again, 'and focus goes back to the building (its button, redrawn)');
561
+ });
562
+
563
+ test('the city card answers its building\'s first ask in place, through the same route the deck takes (task 1004702)', async () => {
564
+ const p = await bootLiving({ asks: true, answer: (url) => (/\/resolve$/.test(url) ? { status: 200, body: { ok: true } } : null) });
565
+ p.byId.get('cityHits').children.find((b) => b.attrs['data-building'] === 'planning').fire('click');
566
+ const card = p.byId.get('cityCard');
567
+ const ask = findAll(card, (x) => /\bcity-ask\b/.test(String(x.className)))[0];
568
+ const id = ask.attrs['data-ask'];
569
+ assert.match(id, /^blockers:\d+$/, 'Planning\'s first ask is a blocker with its stored ask');
570
+ const opts = findAll(ask, (x) => x.tagName === 'button' && /\bask__opt\b/.test(String(x.className)));
571
+ assert.ok(opts.length >= 2 && opts.some((o) => findAll(o, (k) => /\bask__pick\b/.test(String(k.className)) && k.textContent === 'my pick').length), 'tap answers, the pick marked "my pick"');
572
+ assert.match(findAll(ask, (x) => /\bask__said\b/.test(String(x.className)))[0].textContent, /^I(?:'|\s)/, 'the said line is in the asker\'s voice');
573
+ // the same request the deck sends for the same answer
574
+ const Kd = p.window.GovDeck;
575
+ const op = opts[1];
576
+ const key = op.attrs['data-key'];
577
+ card.fire('keydown', { key: op.attrs['data-n'], preventDefault() {} });
578
+ await new Promise((r) => setTimeout(r, 20));
579
+ assert.equal(p.sent.length, 1, 'one request');
580
+ assert.equal(p.sent[0].method, 'POST');
581
+ assert.equal(p.sent[0].url, '/api/bongos/blockers/' + id.split(':')[1] + '/resolve', 'the blocker\'s own route, the one its desk row and the deck use');
582
+ assert.match(p.sent[0].body.resolution_note, /^Decided on the floor desk: /, 'the desk row words');
583
+ assert.ok(Kd && typeof Kd.sendAnswer === 'function' && !/fetch\(/.test(read('street-deck.js').split('function sendAnswer')[1].split('\n }\n')[0]), 'the card sends through the deck\'s sender, not a route of its own');
584
+ assert.equal(findAll(p.byId.get('cityCard'), (x) => x.attrs && x.attrs['data-ask'] === id).length, 0, 'the answered ask leaves the card');
585
+ });
586
+
587
+ test('a pointer finds a building by its own faces, its pin or its plate (the one in front wins)', async () => {
588
+ const p = await bootLiving();
589
+ const groups = p.byId.get('cityBlds').children.filter((g) => g.attrs['data-civic']);
590
+ assert.deepEqual(groups.map((g) => g.attrs['data-civic']).sort(), City.CIVIC_SLOTS.map((s) => s[0]).sort(), 'every civic building\'s faces answer a pointer');
591
+ assert.ok(p.byId.get('cityBlds').children.filter((g) => !g.attrs['data-civic']).every((g) => !(g.listeners.click || []).length), 'a district tower does not');
592
+ groups.find((g) => g.attrs['data-civic'] === 'charter').fire('click');
593
+ assert.equal(p.byId.get('cityCard').attrs['data-building'], 'charter');
594
+ const pin = p.byId.get('cityPins').children.find((s) => s.attrs['data-building'] === 'review');
595
+ pin.children[0].fire('click');
596
+ assert.equal(p.byId.get('cityCard').attrs['data-building'], 'review', 'a pin opens its own building, wherever it stands');
597
+ const plate = p.byId.get('citySky').children.find((s) => /city-plate/.test(s.className) && s.attrs['data-building'] === 'people');
598
+ plate.fire('click');
599
+ assert.equal(p.byId.get('cityCard').attrs['data-building'], 'people');
600
+ // the keyboard's buttons take no pointer events, so overlapping rectangles never steal a click
601
+ const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
602
+ assert.match(css, /\.city-hit \{[^}]*pointer-events: none;/);
603
+ assert.match(css, /\.city-blds g\[data-civic\] \{ pointer-events: visiblePainted;/);
604
+ });
605
+
606
+ // task 1004718: the back corner, the centre and the front corner stand in one
607
+ // screen column, so a building directly behind a taller one never shows its
608
+ // lit floor. Planning held the back corner behind the Charter (158 high) and
609
+ // its salmon windows were hidden; now nothing taller stands in front of it in
610
+ // its column, and the Charter (the tallest) rises from the back corner.
611
+ test('Planning stands behind nothing taller, so its lit floor shows', () => {
612
+ const at = City.CIVIC_SLOTS.map(([key, x, y]) => ({ key, col: x - y, depth: x + y, roof: City.ROOF[key] }));
613
+ const planning = at.find((b) => b.key === 'planning');
614
+ const front = at.filter((o) => o.col === planning.col && o.depth > planning.depth);
615
+ for (const o of front) assert.ok(o.roof < planning.roof, 'planning stands behind the taller ' + o.key);
616
+ assert.deepEqual(City.CIVIC_SLOTS.find((s) => s[0] === 'charter').slice(1), [5, 5], 'the Charter rises from the back corner');
617
+ });