@bongos/core 1.24.29 → 1.24.31

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 (113) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +174 -1
  8. package/docs/api-reference.md +12 -2
  9. package/docs/copy-inventory.md +226 -224
  10. package/docs/copy-registry.json +583 -549
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +18 -37
  13. package/docs/page-readings.json +306 -361
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/government/db.js +29 -0
  18. package/modules/government/docket.js +61 -4
  19. package/modules/government/routes/government.js +2 -0
  20. package/modules/hall-ui/public/ai-studio.html +1 -1
  21. package/modules/hall-ui/public/atlas.html +1 -1
  22. package/modules/hall-ui/public/blockers.html +1 -1
  23. package/modules/hall-ui/public/board-room.html +1 -1
  24. package/modules/hall-ui/public/builders.js +3 -79
  25. package/modules/hall-ui/public/city.html +1 -1
  26. package/modules/hall-ui/public/collab.html +1 -1
  27. package/modules/hall-ui/public/commands.html +1 -1
  28. package/modules/hall-ui/public/copy-desk.html +1 -1
  29. package/modules/hall-ui/public/craft-mode.js +1 -1
  30. package/modules/hall-ui/public/deploy.html +1 -1
  31. package/modules/hall-ui/public/diagrams.html +1 -1
  32. package/modules/hall-ui/public/drachmae.html +1 -1
  33. package/modules/hall-ui/public/fleet.html +1 -1
  34. package/modules/hall-ui/public/gate.html +1 -1
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/hall-render.js +68 -167
  39. package/modules/hall-ui/public/hall-widgets.js +4 -45
  40. package/modules/hall-ui/public/home-city-draw.js +583 -0
  41. package/modules/hall-ui/public/home-city.css +102 -0
  42. package/modules/hall-ui/public/home-city.js +339 -0
  43. package/modules/hall-ui/public/home-gallery.css +129 -0
  44. package/modules/hall-ui/public/home-gallery.js +349 -0
  45. package/modules/hall-ui/public/home-studio.css +72 -0
  46. package/modules/hall-ui/public/home-studio.js +203 -0
  47. package/modules/hall-ui/public/idea.html +1 -1
  48. package/modules/hall-ui/public/ideas.html +1 -1
  49. package/modules/hall-ui/public/index.html +9 -7
  50. package/modules/hall-ui/public/index.states.json +73 -8
  51. package/modules/hall-ui/public/market-watch-item.html +1 -1
  52. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  53. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  54. package/modules/hall-ui/public/market-watch.html +141 -54
  55. package/modules/hall-ui/public/market-watch.js +54 -0
  56. package/modules/hall-ui/public/market-watch.states.json +2 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/my-agents.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/project-settings.html +1 -1
  62. package/modules/hall-ui/public/ranks.html +1 -1
  63. package/modules/hall-ui/public/roadmap.html +1 -1
  64. package/modules/hall-ui/public/roster.html +1 -1
  65. package/modules/hall-ui/public/sessions.html +1 -1
  66. package/modules/hall-ui/public/settings.html +1 -1
  67. package/modules/hall-ui/public/shell.js +2 -3
  68. package/modules/hall-ui/public/street.html +1 -1
  69. package/modules/hall-ui/public/studio.html +9 -1
  70. package/modules/hall-ui/public/style.css +74 -27
  71. package/modules/hall-ui/public/task.html +1 -1
  72. package/modules/hall-ui/public/thinking.html +1 -1
  73. package/modules/hall-ui/public/tweak-editor.html +1 -1
  74. package/modules/hall-ui/public/watch.html +1 -1
  75. package/modules/hall-ui/public/work.html +1 -1
  76. package/modules/hall-ui/records/home-city.md +189 -0
  77. package/modules/hall-ui/records/loader.md +1 -1
  78. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  79. package/modules/hall-ui/records/market-watch.md +3 -1
  80. package/modules/lifecycle/home-city.js +388 -0
  81. package/modules/lifecycle/home-gallery.js +320 -0
  82. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  83. package/modules/lifecycle/module.json +2 -0
  84. package/modules/lifecycle/routes/home-city.js +29 -0
  85. package/modules/lifecycle/routes/home-gallery.js +37 -0
  86. package/modules/market-watch/routes/settings.js +1 -1
  87. package/package-lock.json +2 -2
  88. package/package.json +1 -1
  89. package/release-notes.json +38 -0
  90. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  91. package/scripts/hall-preview/README.md +1 -0
  92. package/scripts/hall-preview/server.js +10 -0
  93. package/src/bongos/serve-internal.js +17 -3
  94. package/src/module-api.js +1 -1
  95. package/tests/claim_from_task_and_home.mjs +8 -43
  96. package/tests/hall_board_world.mjs +10 -12
  97. package/tests/hall_builders_page_pass.mjs +18 -27
  98. package/tests/hall_home_ships_bounded.mjs +4 -3
  99. package/tests/hall_loading.mjs +1 -1
  100. package/tests/hall_nav.mjs +11 -4
  101. package/tests/hall_page_gate_map.mjs +7 -4
  102. package/tests/home_bounded_reads.mjs +16 -31
  103. package/tests/home_city.mjs +580 -0
  104. package/tests/home_city_widget.mjs +260 -0
  105. package/tests/home_gallery.mjs +255 -0
  106. package/tests/home_gallery_widget.mjs +220 -0
  107. package/tests/home_studio.mjs +169 -0
  108. package/tests/home_studio_widget.mjs +157 -0
  109. package/tests/leaderboard_roles.mjs +3 -2
  110. package/tests/market_watch_pages.mjs +162 -0
  111. package/tests/nav_permission_atoms.mjs +2 -2
  112. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  113. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -0,0 +1,583 @@
1
+ // modules/hall-ui/public/home-city-draw.js — Home's project city, as data (task 1005054;
2
+ // v16, task 1005058).
3
+ //
4
+ // WHAT THIS IS. The drawing half of Home's city (the owner's v14 mock,
5
+ // docs/design/mocks/hall/home/v14.html): one isometric tower per working area,
6
+ // as tall as what has shipped there; artist work clads it in glass; the sites
7
+ // worked in a week carry a crane and the face of who built there; that week's
8
+ // ideas are lights in the sky; a salmon dot marks where something waits on the
9
+ // viewer. Beside it, the ladder: each builder's week as one line split by craft.
10
+ // home-city.js owns the page half (the read, the slider, the ladder's DOM,
11
+ // hover); this file only turns the weekly summary into shapes and rows.
12
+ //
13
+ // v16 (the owner's round of 2026-11-04, docs/design/mocks/hall/home/v16-building.html):
14
+ // ONE city for every craft. The Governor City's nine civic buildings stand at the
15
+ // centre, drawn by city-draw.js itself (GovCity.isoInstitution at GovCity.CIVIC_SLOTS,
16
+ // handed in as opts.gov — never copied), always built: the Cloud Bongos core. The
17
+ // working areas stand on a ring of plots around it, tallest at the back so none hides
18
+ // the core; the art museum and its garden (after the Cleveland Museum of Art) stand on
19
+ // the front edge. On the builder's Home the core and the museum are faded so the new
20
+ // buildings lead (opts.focus names the one district that stays in colour, for the
21
+ // other crafts' Homes). Each district is ONE link: the core opens the street, the
22
+ // museum the artist's Home, the band of stars high above the city the ideator's
23
+ // sky; a working area's building opens its goal. The week may be fractional — the
24
+ // slider glides through the weeks in between, so a building grows smoothly.
25
+ //
26
+ // THE IDEATOR'S SKY (task 1005059, docs/design/mocks/hall/home/v16-ideating.html): the
27
+ // same city seen from the street. skylineOf turns every building citySvg stands — the
28
+ // working areas, the nine civic buildings, the museum — into a silhouette at the SAME
29
+ // left-to-right place (the iso screen x of its footprint) and the same height, drawn
30
+ // back to front so a nearer building covers the one behind it, outline included. Over
31
+ // it skySvg hangs the ideas: a constellation per working area, a field for the ones no
32
+ // area holds yet, size by votes, a salmon ring on what waits on the viewer, a falling
33
+ // star and a lit window for an idea that became work; the moon is the version being
34
+ // built, its phase how far along. Three hit groups lie over the skyline, one door each.
35
+ //
36
+ // PURE. No DOM, no fetch, no clock: the same summary and week give the same
37
+ // shapes, so tests run it in node (the roadmap-timeline.js precedent). The scene
38
+ // comes back as a tree of plain shape objects, never markup (see citySvg).
39
+ //
40
+ // THE PALETTE is the city's own drawing data (city-draw.js isoTheme, trimmed to
41
+ // what this scene spends: the dark blocks in the black, the white paper model in
42
+ // light). Everything around the drawing takes the hall's tokens; salmon
43
+ // (var(--accent)) is spent only on what waits on the viewer.
44
+ (function (root) {
45
+ 'use strict';
46
+
47
+ const FOOT = 1.8; // a tower's footprint, in tiles
48
+ const GROUND = { w: 16, d: 19.4 }; // the city's ground, in tiles; the garden runs past y 16
49
+ const CORE = { x0: 4.3, y0: 4.3, x1: 11.7, y1: 11.7 }; // the civic plaza
50
+ const CIVIC_S = 0.82; // the civic buildings' height scale (city-draw.js kit units)
51
+ // The ring of plots around the core: the back edges (y≈0 and x≈0) first, so the tallest
52
+ // areas stand where they hide nothing, then the sides, then the two front corners.
53
+ const RING = (function () {
54
+ const p = [];
55
+ [0.7, 3.3, 5.9, 8.5, 11.1, 13.7].forEach((x) => p.push([x, 0.7]));
56
+ [3.3, 5.9, 8.5, 11.1].forEach((y) => { p.push([0.7, y]); p.push([13.7, y]); });
57
+ p.push([0.7, 13.7], [13.7, 13.7]);
58
+ return p.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]) || a[0] - b[0]);
59
+ })();
60
+ const MAX_AREAS = RING.length; // sixteen; the rest are reported, not drawn
61
+ const TOWER_PX = 150; // the tallest tower at the latest week
62
+ const TW = 50, TH = 25; // iso tile, in viewBox units
63
+ const SKY_H = 84; // the band of stars above the city
64
+ const LADDER_ROWS = 7;
65
+ const SKY = { w: 1000, h: 560 }; // the ideator's sky; the skyline stands on its bottom edge
66
+ const SKY_SLOTS = [[160, 95], [400, 78], [640, 92], [280, 205], [520, 190], [110, 310], [370, 318], [790, 176], [60, 196], [900, 270]];
67
+ const LOOSE = { cx: 690, cy: 312, rx: 150, ry: 54 };
68
+ const MOON = { x: 905, y: 70, r: 28 };
69
+ // the museum as the skyline sees it: the marble block with its portico, the atrium behind
70
+ const MUSEUM_SIL = [{ k: 'museum', x0: 4.9, y0: 12.4, x1: 11.1, y1: 13.7, h: 43, portico: true }, { k: 'museum', x0: 5.6, y0: 10.9, x1: 10.4, y1: 12.3, h: 40 }];
71
+
72
+ const r1 = (n) => Math.round(n * 10) / 10;
73
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
74
+ const sum = (a) => (Array.isArray(a) ? a.reduce((s, v) => s + num(v), 0) : 0);
75
+
76
+ // The sky's own drawing data, as theme() is the city's: night over black, dusk over paper.
77
+ function skyTheme(mode) {
78
+ if (mode === 'light') {
79
+ return { star: '#b8732f', glow: 'rgba(214,140,70,0.28)', line: 'rgba(150,95,40,0.38)', label: 'rgba(23,24,26,0.58)', near: '#b9b6c2', far: '#d4d2dc', civic: '#c9bfb2', win: 'rgba(214,140,70,0.85)', moon: '#3b3f52', shade: '#e8eaf3', streak: 'rgba(214,140,70,0.9)', field: 'rgba(23,24,26,0.2)', beacon: '#fb9569', outline: '#8a5523' };
80
+ }
81
+ return { star: '#ffe2c2', glow: 'rgba(255,214,170,0.35)', line: 'rgba(255,214,170,0.32)', label: 'rgba(255,255,255,0.5)', near: '#060609', far: '#14141d', civic: '#0d0c12', win: 'rgba(255,214,160,0.9)', moon: '#f4ead8', shade: '#08080e', streak: 'rgba(255,226,194,0.95)', field: 'rgba(255,255,255,0.18)', beacon: '#fff4e6', outline: '#ffe2c2' };
82
+ }
83
+
84
+ function theme(mode) {
85
+ if (mode === 'light') {
86
+ return {
87
+ work: { t: '#f6f5f8', l: '#e6e5e9', r: '#d2d1d6', s: 'rgba(23,24,26,0.22)' },
88
+ plinth: { t: '#efeef1', l: '#e2e1e5', r: '#cfced3', s: 'rgba(23,24,26,0.22)' },
89
+ glassL: 'rgba(91,111,208,0.30)', glassR: 'rgba(91,111,208,0.42)', glassT: 'rgba(91,111,208,0.22)',
90
+ floor: 'rgba(23,24,26,0.10)', steel: 'rgba(23,24,26,0.62)', scaffold: 'rgba(23,24,26,0.40)', lit: 'rgba(226,150,90,0.55)',
91
+ slab: { t: '#d9d8dd', l: '#c4c3c9', r: '#b2b1b8', s: 'rgba(23,24,26,0.4)' },
92
+ ground: 'rgba(23,24,26,0.035)', groundLine: 'rgba(23,24,26,0.08)',
93
+ orb: '#d98a4e', orbGlow: 'rgba(217,138,78,0.30)', ideaLine: 'rgba(217,138,78,0.55)',
94
+ av: '#e4e3e7', avAgent: '#d3d8f2', avInk: '#17181a',
95
+ star: '#b8732f',
96
+ marble: { t: '#fbfaf7', l: '#ece9e2', r: '#d8d4cb', s: 'rgba(23,24,26,0.28)' }, granite: { t: '#cfcdd2', l: '#bcbac0', r: '#a9a7ae', s: 'rgba(23,24,26,0.25)' },
97
+ mglass: { t: 'rgba(91,111,208,0.16)', l: 'rgba(91,111,208,0.22)', r: 'rgba(91,111,208,0.3)', s: 'rgba(60,80,180,0.5)' },
98
+ grass: '#c9ddbf', grassLine: 'rgba(40,70,40,0.2)', path: '#ece6da', water: '#a9cbe3', tree: ['#8fb27c', '#7aa36b', '#9dbd89'], col: '#ffffff', dark: 'rgba(23,24,26,0.35)',
99
+ };
100
+ }
101
+ return {
102
+ work: { t: '#34343a', l: '#1c1c20', r: '#111114', s: 'rgba(255,255,255,0.12)' },
103
+ plinth: { t: '#27272c', l: '#141417', r: '#0b0b0d', s: 'rgba(255,255,255,0.08)' },
104
+ glassL: 'rgba(110,130,230,0.30)', glassR: 'rgba(110,130,230,0.20)', glassT: 'rgba(150,170,255,0.30)',
105
+ floor: 'rgba(255,255,255,0.06)', steel: 'rgba(255,255,255,0.70)', scaffold: 'rgba(255,255,255,0.40)', lit: 'rgba(255,200,150,0.55)',
106
+ slab: { t: '#5a5a62', l: '#3a3a40', r: '#2a2a2f', s: 'rgba(255,255,255,0.2)' },
107
+ ground: 'rgba(255,255,255,0.018)', groundLine: 'rgba(255,255,255,0.05)',
108
+ orb: '#ffe2c2', orbGlow: 'rgba(255,214,170,0.35)', ideaLine: 'rgba(255,214,170,0.45)',
109
+ av: '#2c2c32', avAgent: '#2b3157', avInk: '#ffffff',
110
+ star: '#ffe2c2',
111
+ marble: { t: '#dcd8d0', l: '#bfbab0', r: '#9f9a90', s: 'rgba(255,255,255,0.22)' }, granite: { t: '#3c3c42', l: '#28282d', r: '#1c1c20', s: 'rgba(255,255,255,0.12)' },
112
+ mglass: { t: 'rgba(150,170,255,0.24)', l: 'rgba(120,140,230,0.2)', r: 'rgba(100,120,210,0.16)', s: 'rgba(170,190,255,0.45)' },
113
+ grass: '#273f2d', grassLine: 'rgba(160,220,160,0.10)', path: '#3d3a36', water: '#34577a', tree: ['#4f7a4c', '#43683f', '#5b8656'], col: '#e8e4dc', dark: 'rgba(0,0,0,0.55)',
114
+ };
115
+ }
116
+
117
+ // The summary as the drawing needs it: areas placed on the ground (tallest at
118
+ // the back), one shared height scale, people by id. A summary the server could
119
+ // not fill (no version being built, no areas) gives a model with no areas and
120
+ // the page renders nothing rather than an empty plate.
121
+ function cityModel(data) {
122
+ const d = data || {};
123
+ const weeks = Array.isArray(d.weeks) ? d.weeks : [];
124
+ const W = weeks.length;
125
+ const all = (Array.isArray(d.areas) ? d.areas : []).map((a) => {
126
+ const build = Array.from({ length: W }, (_, i) => num(a.build && a.build[i]));
127
+ const art = Array.from({ length: W }, (_, i) => num(a.art && a.art[i]));
128
+ const lead = Array.from({ length: W }, (_, i) => (a.lead && a.lead[i] != null ? String(a.lead[i]) : null));
129
+ return { id: String(a.goal_id), title: a.title == null ? 'A private area' : String(a.title), baseBuild: num(a.base_build), baseArt: num(a.base_art), build, art, lead };
130
+ });
131
+ all.forEach((a) => { a.total = a.baseBuild + a.baseArt + sum(a.build) + sum(a.art); });
132
+ all.sort((p, q) => q.total - p.total || p.id.localeCompare(q.id));
133
+ const areas = all.slice(0, MAX_AREAS);
134
+ areas.forEach((a, n) => { a.x = RING[n][0]; a.y = RING[n][1]; });
135
+ const tallest = Math.max(1, ...areas.map((a) => a.total));
136
+ const people = {};
137
+ (Array.isArray(d.people) ? d.people : []).forEach((p) => {
138
+ const row = (k) => Array.from({ length: W }, (_, i) => num(p[k] && p[k][i]));
139
+ people[String(p.id)] = { id: String(p.id), login: p.login || '', name: p.name || p.login || 'Builder', avatar: p.avatar_url || null, agent: !!p.agent, build: row('build'), art: row('art'), idea: row('idea'), gov: row('gov'), vote: row('vote') };
140
+ });
141
+ const now = d.now || {};
142
+ const sites = {};
143
+ (Array.isArray(now.sites) ? now.sites : []).forEach((s) => { if (s && s.goal_id != null && s.builder_id != null) sites[String(s.goal_id)] = { builder: String(s.builder_id), agent: !!s.agent }; });
144
+ const waiting = {};
145
+ (Array.isArray(now.waiting) ? now.waiting : []).forEach((w) => { if (w && w.goal_id != null && num(w.count) > 0) waiting[String(w.goal_id)] = num(w.count); });
146
+ const ideas = (Array.isArray(d.ideas) ? d.ideas : []).map((x) => ({
147
+ id: String(x.id), week: num(x.week), by: x.by != null ? String(x.by) : null, title: x.title == null ? 'A private idea' : String(x.title),
148
+ goal: x.goal_id != null ? String(x.goal_id) : null,
149
+ area: x.area_id != null ? String(x.area_id) : (x.goal_id != null ? String(x.goal_id) : null),
150
+ became: x.became != null && x.goal_id != null && Number.isFinite(Number(x.became)) ? Number(x.became) : null,
151
+ votes: Math.max(0, num(x.votes)), waits: !!x.waits,
152
+ }));
153
+ const v = d.version || null;
154
+ const version = v ? { name: String(v.name || v.id || 'The version'), tasks: Number.isFinite(Number(v.tasks)) && Number(v.tasks) > 0 ? Number(v.tasks) : null } : null;
155
+ return {
156
+ weeks, W, areas, unplaced: all.length - areas.length,
157
+ px: TOWER_PX / tallest, people, sites, waiting, ideas, version,
158
+ width: GROUND.w, depth: GROUND.d,
159
+ };
160
+ }
161
+
162
+ // What an area has built by the end of week i (the week's own ships included). A
163
+ // fractional i is part-way into the next week, so the slider's glide grows a tower
164
+ // smoothly instead of in weekly jumps.
165
+ function doneAt(a, i) {
166
+ let build = a.baseBuild, art = a.baseArt;
167
+ const whole = Math.floor(i), f = i - whole;
168
+ for (let w = 0; w <= whole && w < a.build.length; w++) { build += a.build[w]; art += a.art[w]; }
169
+ if (f > 0 && whole + 1 < a.build.length) { build += a.build[whole + 1] * f; art += a.art[whole + 1] * f; }
170
+ return { build, art };
171
+ }
172
+
173
+ // The sites worked in week i, goal → { builder, agent }: the latest week shows
174
+ // who is on each site now (open claims; an Autobongos runner working one makes it
175
+ // an agent's site); an earlier week shows its four busiest areas and the builder
176
+ // who shipped most there.
177
+ function sitesFor(m, i) {
178
+ if (i === m.W - 1 && Object.keys(m.sites).length) return Object.assign({}, m.sites);
179
+ const out = {};
180
+ m.areas.map((a) => ({ a, n: a.build[i] + a.art[i] }))
181
+ .filter((x) => x.n > 0 && x.a.lead[i])
182
+ .sort((p, q) => q.n - p.n || p.a.id.localeCompare(q.a.id))
183
+ .slice(0, 4)
184
+ .forEach((x) => { out[x.a.id] = { builder: x.a.lead[i], agent: false }; });
185
+ return out;
186
+ }
187
+
188
+ // The ladder for week i: builders by what they did that week, the top seven,
189
+ // and the viewer appended when they fall below (a phone leaderboard's "you").
190
+ // On the ideator's Home (craft 'ideating') the line is the ideator's week instead:
191
+ // ideas filed, votes cast, and ideas of theirs that became work that week.
192
+ function ladderFor(m, i, viewerId, craft) {
193
+ const rows = Object.values(m.people).map((p) => {
194
+ const v = craft === 'ideating'
195
+ ? { idea: p.idea[i] || 0, vote: (p.vote && p.vote[i]) || 0, work: m.ideas.filter((x) => x.by === p.id && x.became === i).length }
196
+ : { art: p.art[i] || 0, build: p.build[i] || 0, idea: p.idea[i] || 0, gov: p.gov[i] || 0 };
197
+ return { id: p.id, name: p.name, avatar: p.avatar, agent: p.agent, me: viewerId != null && p.id === String(viewerId), v, total: Object.values(v).reduce((a, b) => a + b, 0) };
198
+ }).filter((r) => r.total > 0 || r.me)
199
+ .sort((p, q) => q.total - p.total || p.name.localeCompare(q.name));
200
+ const top = rows.slice(0, LADDER_ROWS);
201
+ const me = rows.find((r) => r.me);
202
+ if (me && !top.includes(me)) top.push(me);
203
+ return top;
204
+ }
205
+
206
+ // The scene's viewBox for a model: the ground and the tallest tower plus room
207
+ // for its crane and face, trimmed so no empty sky stands above it.
208
+ function viewBoxOf(m) {
209
+ const OY = SKY_H + TOWER_PX + 100, h = OY + (m.width + m.depth) * TH / 2 + 16;
210
+ return { w: 1000, h: Math.round(h), OX: 500 - (m.width - m.depth) * TW / 4, OY };
211
+ }
212
+
213
+ // The scene for week i as SHAPES, never markup: a tree of { tag, attrs, kids,
214
+ // text } that home-city.js turns into SVG nodes with createElementNS and
215
+ // setAttribute (city-draw.js's split exactly — "city.js only turns the lists into
216
+ // SVG nodes"). So no ledger string is ever parsed as markup: a title is an
217
+ // attribute value or a text node, which is also why this file needs no escaper.
218
+ function citySvg(m, t, opts) {
219
+ const o = opts || {};
220
+ const I = theme(o.mode);
221
+ const Gov = o.gov || null;
222
+ const vb = viewBoxOf(m);
223
+ const i = Math.max(0, Math.min(m.W - 1, Math.floor(t + 1e-6)));
224
+ const latest = t >= m.W - 1 - 1e-3;
225
+ const P = (x, y, z) => [vb.OX + (x - y) * TW / 2, vb.OY + (x + y) * TH / 2 - (z || 0)];
226
+ const pts = (a) => 'M' + a.map((p) => r1(p[0]) + ' ' + r1(p[1])).join('L') + 'Z';
227
+ const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
228
+ const face = (a, fill, stroke) => node('path', { d: pts(a), fill, stroke: stroke || 'none', 'stroke-width': 1, 'stroke-linejoin': 'round', class: 'hc-face' });
229
+ const line = (a, b, s, w, dash) => node('line', Object.assign({ x1: r1(a[0]), y1: r1(a[1]), x2: r1(b[0]), y2: r1(b[1]), stroke: s, 'stroke-width': w || 1, 'stroke-linecap': 'round' }, dash ? { 'stroke-dasharray': dash } : {}));
230
+ const circle = (cx, cy, r, attrs) => node('circle', Object.assign({ cx: r1(cx), cy: r1(cy), r }, attrs));
231
+ const box = (x0, y0, x1, y1, z0, h, tn) => {
232
+ const z1 = z0 + h;
233
+ return [
234
+ face([P(x0, y1, z1), P(x1, y1, z1), P(x1, y1, z0), P(x0, y1, z0)], tn.l, tn.s),
235
+ face([P(x1, y1, z1), P(x1, y0, z1), P(x1, y0, z0), P(x1, y1, z0)], tn.r, tn.s),
236
+ face([P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)], tn.t, tn.s),
237
+ ];
238
+ };
239
+ const quiet = (key) => (o.focus === key ? '' : ' hc-quiet');
240
+
241
+ const out = [
242
+ node('defs', {}, [node('radialGradient', { id: 'hc-orb' }, [node('stop', { offset: 0, 'stop-color': I.orbGlow }), node('stop', { offset: 1, 'stop-color': 'transparent' })])]),
243
+ face([P(0, 0), P(m.width, 0), P(m.width, m.depth), P(0, m.depth)], I.ground, I.groundLine),
244
+ ];
245
+
246
+ // the ideas: a thin band of stars high above the city, apart from it — one door to the sky
247
+ const seen = m.ideas.filter((x) => x.week <= i);
248
+ const fresh = m.ideas.filter((x) => x.week === i).length;
249
+ const band = [node('rect', { x: 60, y: 8, width: 880, height: SKY_H - 16, fill: 'transparent' })];
250
+ seen.forEach((x) => {
251
+ const h = hash(x.id), recent = i - x.week <= 1;
252
+ band.push(circle(80 + (h % 1000) / 1000 * 840, 14 + (Math.floor(h / 1000) % 1000) / 1000 * (SKY_H - 34), recent ? 1.7 : 1.1, Object.assign({ fill: I.star, opacity: recent ? 0.75 : 0.28 }, x.week === i ? { class: 'hc-twinkle' } : {})));
253
+ });
254
+ out.push(node('a', { href: '/', class: 'hc-door hc-sky', 'data-craft-door': 'ideating', 'aria-label': `The ideas: ${fresh} new this week. Open the sky.`, 'data-tip': 'Ideas', 'data-tip-body': `${fresh} new this week. Click to look up at the sky.` }, band));
255
+
256
+ // every building on the ground, back to front; the civic core draws as one district
257
+ const items = m.areas.map((a) => ({ k: 'area', a, depth: a.x + a.y + FOOT }));
258
+ if (Gov) items.push({ k: 'core', depth: CORE.x0 + CORE.y0 + 1 });
259
+ items.push({ k: 'museum', depth: 15.8 });
260
+ items.sort((p, q) => p.depth - q.depth);
261
+
262
+ const sites = sitesFor(m, i);
263
+ const tops = {}, over = [];
264
+ items.forEach((it) => {
265
+ if (it.k === 'core') { out.push(coreNode()); return; }
266
+ if (it.k === 'museum') { out.push(museumNode()); return; }
267
+ const a = it.a, x0 = a.x, y0 = a.y, x1 = x0 + FOOT, y1 = y0 + FOOT, base = 4;
268
+ const d = doneAt(a, t), built = Math.max(2, (d.build + d.art) * m.px), clad = d.art * m.px;
269
+ const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
270
+ const wait = latest ? (m.waiting[a.id] || 0) : 0;
271
+ const g = [];
272
+ g.push(...box(x0 - 0.12, y0 - 0.12, x1 + 0.12, y1 + 0.12, 0, base, I.plinth));
273
+ g.push(...box(x0, y0, x1, y1, base, built, I.work));
274
+ if (clad > 1) {
275
+ const c = base + Math.min(clad, built);
276
+ g.push(face([P(x0, y1, c), P(x1, y1, c), P(x1, y1, base), P(x0, y1, base)], I.glassL));
277
+ g.push(face([P(x1, y1, c), P(x1, y0, c), P(x1, y0, base), P(x1, y1, base)], I.glassR));
278
+ if (clad >= built - 0.5) g.push(face([P(x0, y0, base + built), P(x1, y0, base + built), P(x1, y1, base + built), P(x0, y1, base + built)], I.glassT));
279
+ }
280
+ for (let z = base + 16; z < base + built - 2; z += 16) g.push(line(P(x0, y1, z), P(x1, y1, z), I.floor), line(P(x1, y1, z), P(x1, y0, z), I.floor));
281
+ const top = base + built;
282
+ tops[a.id] = P((x0 + x1) / 2, (y0 + y1) / 2, top);
283
+ if (site) {
284
+ // the floor going up: lit, wrapped in scaffolding, a crane lifting the next slab
285
+ if (built > 14) {
286
+ const zt = top - 9, zb = top - 3;
287
+ g.push(face([P(x0 + 0.15, y1, zb), P(x1 - 0.15, y1, zb), P(x1 - 0.15, y1, zt), P(x0 + 0.15, y1, zt)], I.lit));
288
+ g.push(face([P(x1, y1 - 0.15, zb), P(x1, y0 + 0.15, zb), P(x1, y0 + 0.15, zt), P(x1, y1 - 0.15, zt)], I.lit));
289
+ }
290
+ const ov = 0.14, s0 = Math.max(base, top - 12), s1 = top + 22;
291
+ [x0 - ov, (x0 + x1) / 2, x1 + ov].forEach((x) => g.push(line(P(x, y1 + ov, s0), P(x, y1 + ov, s1), I.scaffold)));
292
+ [y1 + ov, (y0 + y1) / 2, y0 - ov].forEach((y) => g.push(line(P(x1 + ov, y, s0), P(x1 + ov, y, s1), I.scaffold)));
293
+ for (let zz = s0; zz <= s1 + 0.1; zz += 11) g.push(line(P(x0 - ov, y1 + ov, zz), P(x1 + ov, y1 + ov, zz), I.scaffold), line(P(x1 + ov, y1 + ov, zz), P(x1 + ov, y0 - ov, zz), I.scaffold));
294
+ const mx = x0 + 0.5, my = y0 + 0.5, ct = top + 56;
295
+ g.push(line(P(mx - 0.08, my, top), P(mx - 0.08, my, ct), I.steel, 1.3), line(P(mx + 0.08, my, top), P(mx + 0.08, my, ct), I.steel, 1.3));
296
+ for (let zc = top, k = 0; zc + 8 <= ct; zc += 8, k++) g.push(line(P(mx + (k % 2 ? 0.08 : -0.08), my, zc), P(mx + (k % 2 ? -0.08 : 0.08), my, zc + 8), I.steel, 0.8));
297
+ g.push(line(P(mx - 0.9, my, ct), P(mx + 2.2, my, ct), I.steel, 1.5));
298
+ g.push(line(P(mx, my, ct + 11), P(mx + 2.2, my, ct), I.steel, 0.9), line(P(mx, my, ct + 11), P(mx - 0.9, my, ct), I.steel, 0.9));
299
+ g.push(...box(mx - 0.95, my - 0.12, mx - 0.6, my + 0.12, ct - 8, 8, I.slab));
300
+ const hx = mx + 1.8;
301
+ g.push(line(P(hx, my, ct), P(hx, my, top - 6), I.steel, 1));
302
+ g.push(node('g', { class: 'hc-slab' }, box(hx - 0.3, my - 0.2, hx + 0.3, my + 0.2, top - 38, 3, I.slab)));
303
+ if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
304
+ }
305
+ if (wait) over.push({ k: 'dot', p: P((x0 + x1) / 2, (y0 + y1) / 2, top + (site ? 12 : 22)), a, n: wait });
306
+ const shipped = Math.round(d.build + d.art), byArt = Math.round(d.art);
307
+ const tip = `${shipped} shipped${byArt ? ` · ${byArt} by artists` : ''}${who ? ` · ${agent ? `${who.name}'s agent` : who.name} on site` : ''} · click to open the goal`;
308
+ out.push(node('a', { href: `/goals#/goal/${encodeURIComponent(a.id)}`, class: 'hc-tower', 'aria-label': `${a.title}: ${shipped} shipped. Open the goal.`, 'data-tip': a.title, 'data-tip-body': tip }, g));
309
+ });
310
+
311
+ over.forEach((v, n) => {
312
+ if (v.k === 'face') { out.push(avatarNode(v.p, v.who, v.agent, v.a, I, n, node, line, circle)); return; }
313
+ const cx = v.p[0] - 30, cy = v.p[1];
314
+ out.push(node('g', { class: 'hc-wait', 'data-tip': v.a.title, 'data-tip-body': `${v.n} waiting on you` }, [
315
+ circle(cx, cy, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
316
+ node('text', { x: r1(cx), y: r1(cy + 4), 'text-anchor': 'middle', class: 'hc-wait__n' }, [], v.n),
317
+ ]));
318
+ });
319
+ return { viewBox: `0 0 ${vb.w} ${vb.h}`, nodes: out };
320
+
321
+ // The government district: the plaza and the nine civic buildings, drawn by
322
+ // city-draw.js's own recipes in its own palette — one hover, one door, to the street.
323
+ function coreNode() {
324
+ const CI = Gov.isoTheme(o.mode === 'light' ? 'light' : 'dark');
325
+ const k = Gov.isoKit(TW, TH, vb.OX, vb.OY, CIVIC_S, CI, o.mode !== 'light');
326
+ const kids = [face([P(CORE.x0, CORE.y0), P(CORE.x1, CORE.y0), P(CORE.x1, CORE.y1), P(CORE.x0, CORE.y1)], CI.plaza, CI.plazaLine)];
327
+ Gov.CIVIC_SLOTS.slice().sort((p, q) => (p[1] + p[2]) - (q[1] + q[2])).forEach((sl) => {
328
+ const list = [];
329
+ Gov.isoInstitution(list, k, sl[0], sl[1] - 0.6, sl[2] - 0.6, {});
330
+ list.forEach((pa) => kids.push(node('path', { d: pa.d, fill: pa.f, stroke: pa.s, 'stroke-width': pa.w, 'stroke-dasharray': pa.dash, 'stroke-linejoin': 'round', class: 'hc-face' })));
331
+ });
332
+ return node('a', { href: Gov.STREET_HREF || '/street', class: 'hc-door hc-core' + quiet('core'), 'aria-label': 'The government district: nine buildings, one for each part of the government. Walk the street.', 'data-tip': 'Government', 'data-tip-body': 'The Cloud Bongos core: nine buildings, one for each part of the government. Click to walk the street.' }, kids);
333
+ }
334
+
335
+ // The art museum and its garden, after the Cleveland Museum of Art: a long white
336
+ // marble block with a columned portico and wide steps facing the park, the glass
337
+ // atrium between two dark granite wings behind it, and in front the Fine Arts
338
+ // Garden — a fountain on the axis, the lagoon beyond, trees down both sides.
339
+ function museumNode() {
340
+ const g = [];
341
+ g.push(face([P(3.4, 15.0), P(12.6, 15.0), P(12.6, 19.2), P(3.4, 19.2)], I.grass, I.grassLine));
342
+ g.push(face([P(7.75, 14.9), P(8.25, 14.9), P(8.25, 19.2), P(7.75, 19.2)], I.path));
343
+ g.push(face([P(3.4, 15.9), P(12.6, 15.9), P(12.6, 16.25), P(3.4, 16.25)], I.path));
344
+ const lc = P(8, 17.9, 0);
345
+ g.push(node('ellipse', { cx: r1(lc[0]), cy: r1(lc[1]), rx: 75, ry: 16, fill: I.water, transform: `rotate(26.57 ${r1(lc[0])} ${r1(lc[1])})` }));
346
+ const fc = P(8, 16.07, 0);
347
+ g.push(node('ellipse', { cx: r1(fc[0]), cy: r1(fc[1]), rx: 11, ry: 5.5, fill: I.water }));
348
+ g.push(...box(4.6, 12.3, 11.4, 14.0, 0, 6, I.marble));
349
+ g.push(...box(5.6, 10.9, 10.4, 12.3, 0, 30, I.granite));
350
+ g.push(...box(6.2, 11.2, 9.8, 12.3, 30, 10, I.mglass));
351
+ g.push(...box(5.0, 12.5, 11.0, 13.6, 6, 34, I.marble));
352
+ for (let z = 14; z < 38; z += 9) g.push(face([P(5.25, 13.6, z), P(10.75, 13.6, z), P(10.75, 13.6, z + 0.8), P(5.25, 13.6, z + 0.8)], I.dark));
353
+ for (let wx = 5.45; wx < 10.7; wx += 0.62) if (wx < 7.1 || wx > 8.9) g.push(face([P(wx, 13.6, 16), P(wx + 0.22, 13.6, 16), P(wx + 0.22, 13.6, 30), P(wx, 13.6, 30)], I.dark));
354
+ g.push(...box(4.9, 12.4, 11.1, 13.7, 40, 3, I.marble));
355
+ for (let st = 0; st < 4; st++) g.push(...box(6.7 - st * 0.08, 13.6 + st * 0.3, 9.3 + st * 0.08, 13.9 + st * 0.3, 0, 6 - st * 1.5, I.marble));
356
+ g.push(...box(7.0, 13.6, 9.0, 14.05, 37, 6, I.marble));
357
+ for (let c = 0; c < 6; c++) {
358
+ const cx = 7.15 + c * 0.34, a = P(cx, 13.98, 6), b = P(cx, 13.98, 37);
359
+ g.push(line(a, b, I.col, 3.2), line([a[0] + 1.2, a[1]], [b[0] + 1.2, b[1]], I.dark, 0.8));
360
+ }
361
+ g.push(face([P(7.25, 13.62, 8), P(8.75, 13.62, 8), P(8.75, 13.62, 30), P(7.25, 13.62, 30)], I.dark));
362
+ [[3.9, 15.4], [4.6, 15.5], [11.4, 15.4], [12.1, 15.5], [3.8, 16.9], [12.2, 16.8], [4.2, 18.6], [11.8, 18.5], [5.1, 19.0], [10.9, 19.0], [3.9, 17.8], [12.1, 17.7]].forEach((tr, n) => {
363
+ const p = P(tr[0], tr[1], 0);
364
+ g.push(line(p, [p[0], p[1] - 9], I.dark, 1.6), circle(p[0], p[1] - 16, 8.5 + (n % 3) * 1.5, { fill: I.tree[n % 3] }));
365
+ });
366
+ return node('a', { href: '/', class: 'hc-door hc-museum' + quiet('museum'), 'data-craft-door': 'artistry', 'aria-label': 'The art museum, where the artists’ works hang. Go in.', 'data-tip': 'The art museum', 'data-tip-body': 'The art district: where the artists’ works hang, beside the park. Click to go in.' }, g);
367
+ }
368
+ }
369
+
370
+ // ---- the ideator's sky ----
371
+
372
+ // The skyline: every building of the city as seen from the street, back to front.
373
+ // A box from (x0,y0) to (x1,y1) spans screen x P(x0,y1) to P(x1,y0) in citySvg's own
374
+ // frame (viewBoxOf's OX), so a silhouette stands exactly where its building does.
375
+ // Heights are the city's: a tower its plinth plus what it has built by week t, a civic
376
+ // building city-draw.js's ROOF at the core's scale, the museum its marble and atrium.
377
+ function skylineOf(m, t, gov) {
378
+ const OX = viewBoxOf(m).OX;
379
+ const sx = (x, y) => OX + (x - y) * TW / 2;
380
+ const sil = [];
381
+ m.areas.forEach((a) => {
382
+ const d = doneAt(a, t);
383
+ sil.push({ k: 'area', id: a.id, title: a.title, l: sx(a.x, a.y + FOOT), r: sx(a.x + FOOT, a.y), h: 4 + Math.max(2, (d.build + d.art) * m.px), depth: a.x + a.y + FOOT });
384
+ });
385
+ if (gov && Array.isArray(gov.CIVIC_SLOTS) && gov.ROOF) {
386
+ gov.CIVIC_SLOTS.forEach((s) => {
387
+ const x = s[1] - 0.6, y = s[2] - 0.6;
388
+ sil.push({ k: 'civic', id: s[0], l: sx(x + 0.3, y + 1.7), r: sx(x + 1.7, y + 0.3), h: num(gov.ROOF[s[0]]) * CIVIC_S, depth: x + y + 2, charter: s[0] === 'charter' });
389
+ });
390
+ }
391
+ MUSEUM_SIL.forEach((b) => sil.push({ k: 'museum', id: b.portico ? 'museum' : 'atrium', l: sx(b.x0, b.y1), r: sx(b.x1, b.y0), h: b.h, depth: b.x1 + b.y1, portico: !!b.portico }));
392
+ return sil.sort((p, q) => p.depth - q.depth || p.l - q.l);
393
+ }
394
+
395
+ // The ideas placed in the sky: a constellation per working area that has any (the
396
+ // most ideas first, up to the slots), the rest of the sky's ideas in the loose field.
397
+ function skyPlaces(m) {
398
+ const count = {};
399
+ m.ideas.forEach((x) => { if (x.area) count[x.area] = (count[x.area] || 0) + 1; });
400
+ const byId = new Map(m.areas.map((a) => [a.id, a]));
401
+ const groups = Object.keys(count).filter((id) => byId.has(id))
402
+ .sort((p, q) => count[q] - count[p] || p.localeCompare(q)).slice(0, SKY_SLOTS.length)
403
+ .map((id, n) => ({ area: byId.get(id), cx: SKY_SLOTS[n][0], cy: SKY_SLOTS[n][1] }));
404
+ const slot = new Map(groups.map((g) => [g.area.id, g]));
405
+ const spread = groups.length > 6 ? 0.75 : 1;
406
+ const at = new Map();
407
+ m.ideas.forEach((x) => {
408
+ const h = hash(x.id), a = (h % 1000) / 1000 * Math.PI * 2, f = (Math.floor(h / 1000) % 1000) / 1000;
409
+ const g = x.area ? slot.get(x.area) : null;
410
+ if (g) { const r = (20 + f * 56) * spread; at.set(x.id, { x: g.cx + Math.cos(a) * r * 1.4, y: g.cy + Math.sin(a) * r * 0.7, g, a }); }
411
+ else { const r = Math.sqrt(f); at.set(x.id, { x: LOOSE.cx + Math.cos(a) * r * LOOSE.rx * 0.9, y: LOOSE.cy + Math.sin(a) * r * LOOSE.ry * 0.8, g: null, a }); }
412
+ });
413
+ return { groups, at };
414
+ }
415
+
416
+ // How far along the version is by week t: what its areas have shipped over every task
417
+ // under them. Null when the server could not say how many tasks there are.
418
+ function versionBuilt(m, t) {
419
+ if (!m.version || !m.version.tasks) return null;
420
+ const done = m.areas.reduce((s, a) => { const d = doneAt(a, t); return s + d.build + d.art; }, 0);
421
+ return Math.max(0, Math.min(1, done / m.version.tasks));
422
+ }
423
+
424
+ // The sky for week t as shapes (citySvg's contract: a tree of plain nodes, never markup).
425
+ function skySvg(m, t, opts) {
426
+ const o = opts || {};
427
+ const C = skyTheme(o.mode);
428
+ const Gov = o.gov || null;
429
+ const i = Math.max(0, Math.min(m.W - 1, Math.floor(t + 1e-6)));
430
+ const latest = t >= m.W - 1 - 1e-3;
431
+ const HOR = SKY.h;
432
+ const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
433
+ const circle = (cx, cy, r, attrs) => node('circle', Object.assign({ cx: r1(cx), cy: r1(cy), r: r1(r) }, attrs));
434
+ const label = (x, y, s) => node('text', { x: r1(x), y: r1(y), 'text-anchor': 'middle', fill: C.label, class: 'hc-sky__label' }, [], s);
435
+ const when = i === m.W - 1 ? 'this week' : 'that week';
436
+ const out = [node('defs', {}, [
437
+ node('radialGradient', { id: 'hc-sky-orb' }, [node('stop', { offset: 0, 'stop-color': C.glow }), node('stop', { offset: 1, 'stop-color': 'transparent' })]),
438
+ node('linearGradient', { id: 'hc-sky-tail', x1: 0, y1: 0, x2: 1, y2: 1 }, [node('stop', { offset: 0, 'stop-color': C.streak, 'stop-opacity': 0 }), node('stop', { offset: 1, 'stop-color': C.streak })]),
439
+ ])];
440
+
441
+ // the dust: the same faint field every week
442
+ for (let n = 0; n < 90; n++) { const h = hash('dust' + n); out.push(circle((h % 1000) / 1000 * SKY.w, (Math.floor(h / 1000) % 1000) / 1000 * (HOR - 170), (h % 7) ? 0.7 : 1.1, { fill: C.star, opacity: 0.18 })); }
443
+
444
+ // the moon: the version being built, its phase how far along
445
+ const built = versionBuilt(m, t);
446
+ if (m.version) {
447
+ const mk = [circle(MOON.x, MOON.y, MOON.r + 26, { fill: 'url(#hc-sky-orb)' }), circle(MOON.x, MOON.y, MOON.r, { fill: C.moon })];
448
+ if (built != null) mk.push(circle(MOON.x + MOON.r * 2 * built, MOON.y - 2, MOON.r, { fill: C.shade, opacity: 0.94 }));
449
+ out.push(node('g', { class: 'hc-moon', 'data-tip': `${m.version.name} — the version being built`, 'data-tip-body': built == null ? 'Its building has begun.' : `${Math.round(built * 100)}% built ${when}` }, mk));
450
+ }
451
+
452
+ // THE SKYLINE IS THE CITY: each silhouette's fill then its outline, back to front, so a
453
+ // nearer building covers the outline of the one behind it.
454
+ const sil = skylineOf(m, t, Gov);
455
+ const shape = (s, y) => {
456
+ if (s.charter) {
457
+ const w = s.r - s.l, h = HOR - y, at = (f) => r1(HOR - h * f);
458
+ return `M${r1(s.l)} ${HOR}V${at(0.33)}H${r1(s.l + w * 0.1)}V${at(0.58)}H${r1(s.l + w * 0.22)}V${at(0.8)}H${r1(s.l + w * 0.35)}V${r1(y)}H${r1(s.r - w * 0.35)}V${at(0.8)}H${r1(s.r - w * 0.22)}V${at(0.58)}H${r1(s.r - w * 0.1)}V${at(0.33)}H${r1(s.r)}V${HOR}Z`;
459
+ }
460
+ if (s.portico) { const pm = (s.l + s.r) / 2; return `M${r1(s.l)} ${HOR}V${r1(y)}H${r1(pm - 28)}V${r1(y - 4)}H${r1(pm + 28)}V${r1(y)}H${r1(s.r)}V${HOR}Z`; }
461
+ return `M${r1(s.l)} ${HOR}V${r1(y)}H${r1(s.r)}V${HOR}Z`;
462
+ };
463
+ const maxD = Math.max(1, ...sil.map((s) => s.depth));
464
+ const fills = [], hits = [], tops = {};
465
+ sil.forEach((s) => {
466
+ const y = HOR - s.h, d = shape(s, y);
467
+ const fill = s.k === 'area' ? (s.depth / maxD < 0.5 ? C.far : C.near) : C.civic;
468
+ fills.push(node('path', { d, fill, class: 'hc-sil' }));
469
+ fills.push(node('path', { d, fill: 'none', stroke: C.outline, 'stroke-width': 1.4, 'stroke-linejoin': 'round', class: `hc-ol hc-ol-${s.k}` }));
470
+ if (s.k === 'civic' && (s.id === 'charter' || s.id === 'automation' || s.id === 'release')) fills.push(circle((s.l + s.r) / 2, y - 4, 2.4, { fill: C.beacon }));
471
+ if (s.k === 'area') tops[s.id] = { s, y };
472
+ // the hit floor: a low silhouette still offers a band tall enough to point at
473
+ hits.push({ s, d: shape(s, Math.min(y, HOR - 30)) });
474
+ });
475
+ out.push(node('g', { class: 'hc-skyline' }, fills));
476
+
477
+ // a window lights in a working area for every one of its ideas that became work
478
+ const wins = [];
479
+ Object.values(tops).forEach(({ s, y }) => {
480
+ const lit = m.ideas.filter((x) => x.goal === s.id && x.became != null && x.became <= i).length;
481
+ const cols = Math.max(1, Math.floor((s.r - s.l - 8) / 9)), rows = Math.max(0, Math.floor((s.h - 14) / 12));
482
+ for (let q = 0; q < Math.min(lit, cols * rows); q++) wins.push(node('rect', { x: r1(s.l + 6 + (q % cols) * 9), y: r1(y + 8 + Math.floor(q / cols) * 12), width: 4.5, height: 6.5, fill: C.win }));
483
+ });
484
+ if (wins.length) out.push(node('g', { class: 'hc-wins', 'aria-hidden': 'true' }, wins));
485
+
486
+ // the three doors over the skyline: one link per silhouette, back to front like the
487
+ // fills, so the nearest building takes the pointer; hovering any of a group outlines
488
+ // the whole group (home-city.css). One stop per group on the keyboard.
489
+ const DOORS = {
490
+ area: { href: '/goals', tip: 'Building', body: 'Every goal building in the city. Click to open the goals.', aria: 'The goal buildings. Open the goals.' },
491
+ civic: { href: (Gov && Gov.STREET_HREF) || '/builders/street', tip: 'Government', body: 'The Cloud Bongos core. Click to walk the street.', aria: 'The government district. Walk the street.' },
492
+ museum: { href: '/', door: 'artistry', tip: 'Art', body: 'The art museum. Click to go into the gallery.', aria: 'The art museum. Go into the gallery.' },
493
+ };
494
+ const first = {};
495
+ out.push(node('g', { class: 'hc-hits' }, hits.map(({ s, d }) => {
496
+ const D = DOORS[s.k], lead = !first[s.k];
497
+ first[s.k] = true;
498
+ const attrs = { href: D.href, class: 'hc-hit', 'data-group': s.k, 'data-tip': D.tip, 'data-tip-body': D.body };
499
+ if (D.door) attrs['data-craft-door'] = D.door;
500
+ if (lead) attrs['aria-label'] = D.aria; else { attrs.tabindex = '-1'; attrs['aria-hidden'] = 'true'; }
501
+ return node('a', attrs, [node('path', { d, fill: 'transparent' })]);
502
+ })));
503
+
504
+ // the ideas: what was filed by week t and has not yet fallen into its tower
505
+ const { groups, at } = skyPlaces(m);
506
+ const live = m.ideas.filter((x) => x.week <= i && !(x.became != null && x.became < i));
507
+ groups.forEach((g) => {
508
+ const stars = live.filter((x) => at.get(x.id).g === g).sort((p, q) => at.get(p.id).a - at.get(q.id).a);
509
+ if (!stars.length) return;
510
+ const kids = [];
511
+ for (let q = 1; q < stars.length; q++) { const a = at.get(stars[q - 1].id), b = at.get(stars[q].id); kids.push(node('line', { x1: r1(a.x), y1: r1(a.y), x2: r1(b.x), y2: r1(b.y), stroke: C.line, 'stroke-width': 0.9 })); }
512
+ const top = Math.min(...stars.map((x) => at.get(x.id).y));
513
+ const name = g.area.title.length > 30 ? g.area.title.slice(0, 29) + '…' : g.area.title;
514
+ kids.push(label(g.cx, Math.max(14, Math.min(top, g.cy - 30) - 12), name.toUpperCase()));
515
+ out.push(node('g', { class: 'hc-const', 'data-tip': g.area.title, 'data-tip-body': `${stars.length} idea${stars.length === 1 ? '' : 's'} in this constellation` }, kids));
516
+ });
517
+ const loose = live.filter((x) => !at.get(x.id).g);
518
+ if (loose.length) {
519
+ out.push(node('g', { class: 'hc-loose', 'data-tip': 'Not placed yet', 'data-tip-body': `${loose.length} idea${loose.length === 1 ? '' : 's'} no working area holds yet` }, [
520
+ node('ellipse', { cx: LOOSE.cx, cy: LOOSE.cy, rx: LOOSE.rx, ry: LOOSE.ry, fill: 'none', stroke: C.field, 'stroke-dasharray': '2 6' }),
521
+ label(LOOSE.cx, LOOSE.cy - LOOSE.ry - 10, 'NOT PLACED YET'),
522
+ ]));
523
+ }
524
+ // a falling star: an idea that became work this week drops into its area's tower
525
+ m.ideas.forEach((x) => {
526
+ if (x.became !== i || !tops[x.goal]) return;
527
+ const p = at.get(x.id), tw = tops[x.goal], tx = tw.s.l + 14, ty = tw.y - 4;
528
+ out.push(node('g', { class: 'hc-fall', 'data-tip': x.title, 'data-tip-body': `became work ${when} · ${tw.s.title}` }, [
529
+ node('path', { d: `M${r1(p.x)} ${r1(p.y)}Q${r1((p.x + tx) / 2 + 40)} ${r1(p.y + 20)} ${r1(tx)} ${r1(ty)}`, fill: 'none', stroke: 'url(#hc-sky-tail)', 'stroke-width': 1.6, 'stroke-dasharray': '3 4' }),
530
+ circle(tx, ty, 3, { fill: C.streak }),
531
+ ]));
532
+ });
533
+ let waiting = 0;
534
+ const areaName = new Map(m.areas.map((a) => [a.id, a.title]));
535
+ live.forEach((x) => {
536
+ const p = at.get(x.id), fresh = x.week === i, size = 1.6 + Math.min(12, x.votes) * 0.32, ring = latest && x.waits;
537
+ const who = x.by && m.people[x.by] ? m.people[x.by].name : 'Someone';
538
+ const where = p.g ? areaName.get(x.area) : 'not placed yet';
539
+ const kids = [
540
+ circle(p.x, p.y, size * 4, Object.assign({ fill: 'url(#hc-sky-orb)' }, fresh ? { class: 'hc-twinkle' } : {})),
541
+ circle(p.x, p.y, size, { fill: C.star, opacity: fresh ? 1 : r1(0.55 + Math.min(0.4, x.votes / 20)) }),
542
+ ];
543
+ if (ring) { waiting++; kids.push(circle(p.x, p.y, size + 5, { fill: 'none', stroke: 'var(--accent)', 'stroke-width': 1.6, class: 'hc-ring' })); }
544
+ kids.push(circle(p.x, p.y, 12, { fill: 'transparent' }));
545
+ out.push(node('g', { class: 'hc-star', 'data-tip': x.title, 'data-tip-body': `${who} · ${x.votes} vote${x.votes === 1 ? '' : 's'} · ${where}${ring ? ' · waits on you' : ''}` }, kids));
546
+ });
547
+ if (waiting) {
548
+ const bx = LOOSE.cx + LOOSE.rx - 6, by = LOOSE.cy - LOOSE.ry + 8;
549
+ out.push(node('g', { class: 'hc-wait', 'data-tip': 'Waiting on you', 'data-tip-body': `${waiting} new idea${waiting === 1 ? '' : 's'} to place or vote on — the ringed stars` }, [
550
+ circle(bx, by, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
551
+ node('text', { x: r1(bx), y: r1(by + 4), 'text-anchor': 'middle', class: 'hc-wait__n' }, [], waiting),
552
+ ]));
553
+ }
554
+ return { viewBox: `0 0 ${SKY.w} ${SKY.h}`, nodes: out };
555
+ }
556
+
557
+ // A face over a crane: the builder's avatar clipped to a circle over their
558
+ // initial (so a picture that fails to load still leaves a face), or the robot
559
+ // glyph when an agent is building the site.
560
+ function avatarNode(p, who, agent, a, I, n, node, line, circle) {
561
+ const x = p[0], y = p[1], id = `hc-av-${n}`;
562
+ const g = [line([x, y + 16], [x, y + 28], I.steel, 1), circle(x, y, 16, { fill: agent ? I.avAgent : I.av, stroke: 'var(--bg-card)', 'stroke-width': 3 })];
563
+ if (agent) {
564
+ g.push(node('rect', { x: r1(x - 7), y: r1(y - 5), width: 14, height: 11, rx: 3, fill: 'none', stroke: I.avInk, 'stroke-width': 1.5 }));
565
+ g.push(circle(x - 3, y + 0.5, 1.5, { fill: I.avInk }), circle(x + 3, y + 0.5, 1.5, { fill: I.avInk }));
566
+ } else {
567
+ g.push(node('text', { x: r1(x), y: r1(y + 5), 'text-anchor': 'middle', fill: I.avInk, class: 'hc-av__i' }, [], initial(who.name)));
568
+ if (who.avatar && /^https:\/\//.test(who.avatar)) {
569
+ g.push(node('clipPath', { id }, [circle(x, y, 14.5, {})]));
570
+ g.push(node('image', { href: who.avatar, x: r1(x - 14.5), y: r1(y - 14.5), width: 29, height: 29, 'clip-path': `url(#${id})`, preserveAspectRatio: 'xMidYMid slice' }));
571
+ }
572
+ }
573
+ return node('g', { class: 'hc-face-pin', 'data-tip': agent ? `${who.name}'s agent` : who.name, 'data-tip-body': a.title }, g);
574
+ }
575
+
576
+ function initial(name) { const s = String(name || '').trim(); return s ? s[0].toUpperCase() : '?'; }
577
+ // a stable spread for an idea's place in the sky (FNV-1a over its id)
578
+ function hash(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return Math.abs(h); }
579
+
580
+ const api = { cityModel, doneAt, sitesFor, ladderFor, citySvg, viewBoxOf, theme, initial, LADDER_ROWS, MAX_AREAS, RING, CORE, skySvg, skylineOf, skyPlaces, versionBuilt, skyTheme, SKY };
581
+ if (typeof module === 'object' && module.exports) module.exports = api;
582
+ if (root) root.OTBHomeCityDraw = api;
583
+ })(typeof window !== 'undefined' ? window : null);