@bongos/core 1.24.28 → 1.24.30

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 (63) hide show
  1. package/.bongos-core.json +100 -70
  2. package/.claude/skills/builder-claim/SKILL.md +1 -1
  3. package/.claude/skills/builder-ship/SKILL.md +1 -1
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +4 -0
  6. package/clients/bongos-client/index.cjs +4 -0
  7. package/clients/bongos-client/index.d.ts +4 -0
  8. package/clients/bongos-client/index.mjs +4 -0
  9. package/docs/api/openapi.json +36 -1
  10. package/docs/api-reference.md +7 -1
  11. package/docs/copy-inventory.md +247 -280
  12. package/docs/copy-registry.json +381 -691
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +5 -10
  15. package/docs/page-readings.json +327 -393
  16. package/modules/builder-settings/interaction-prefs.js +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/builders.js +3 -79
  21. package/modules/hall-ui/public/hall-render.js +6 -81
  22. package/modules/hall-ui/public/hall-widgets.js +4 -45
  23. package/modules/hall-ui/public/home-city-draw.js +275 -0
  24. package/modules/hall-ui/public/home-city.css +66 -0
  25. package/modules/hall-ui/public/home-city.js +223 -0
  26. package/modules/hall-ui/public/index.html +5 -6
  27. package/modules/hall-ui/public/index.states.json +73 -8
  28. package/modules/hall-ui/public/my-agents.js +35 -2
  29. package/modules/hall-ui/public/palette.js +8 -0
  30. package/modules/hall-ui/public/settings-moved.js +41 -0
  31. package/modules/hall-ui/public/settings.css +4 -28
  32. package/modules/hall-ui/public/settings.html +19 -107
  33. package/modules/hall-ui/public/settings.js +20 -215
  34. package/modules/hall-ui/public/settings.states.json +2 -4
  35. package/modules/hall-ui/records/home-city.md +66 -0
  36. package/modules/hall-ui/records/my-agents.md +3 -2
  37. package/modules/hall-ui/records/panel-family.md +2 -0
  38. package/modules/lifecycle/home-city.js +340 -0
  39. package/modules/lifecycle/module.json +1 -0
  40. package/modules/lifecycle/routes/home-city.js +29 -0
  41. package/modules/lifecycle/ship-card.js +2 -2
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/release-notes.json +16 -0
  45. package/src/module-api.js +1 -1
  46. package/tests/claim_from_task_and_home.mjs +8 -43
  47. package/tests/government_tidy_up.mjs +21 -24
  48. package/tests/hall_board_world.mjs +10 -12
  49. package/tests/hall_home_ships_bounded.mjs +4 -3
  50. package/tests/hall_my_agents.mjs +88 -2
  51. package/tests/hall_palette.mjs +12 -0
  52. package/tests/hall_settings_world.mjs +64 -100
  53. package/tests/helpers.mjs +6 -5
  54. package/tests/home_bounded_reads.mjs +16 -31
  55. package/tests/home_city.mjs +385 -0
  56. package/tests/home_city_widget.mjs +120 -0
  57. package/tests/interaction_posture.mjs +3 -1
  58. package/tests/leaderboard_roles.mjs +3 -2
  59. package/tests/speciality_offer_ui.mjs +6 -5
  60. package/tests/speciality_walkthrough_ui.mjs +7 -5
  61. package/modules/hall-ui/public/settings-interaction.js +0 -132
  62. package/modules/hall-ui/public/settings-law-locks.js +0 -53
  63. package/modules/hall-ui/public/settings-render.js +0 -138
@@ -0,0 +1,275 @@
1
+ // modules/hall-ui/public/home-city-draw.js — Home's project city, as data (task 1005054).
2
+ //
3
+ // WHAT THIS IS. The drawing half of Home's city (the owner's v14 mock,
4
+ // docs/design/mocks/hall/home/v14.html): one isometric tower per working area,
5
+ // as tall as what has shipped there; artist work clads it in glass; the sites
6
+ // worked in a week carry a crane and the face of who built there; that week's
7
+ // ideas are lights in the sky; a salmon dot marks where something waits on the
8
+ // viewer. Beside it, the ladder: each builder's week as one line split by craft.
9
+ // home-city.js owns the page half (the read, the slider, the ladder's DOM,
10
+ // hover); this file only turns the weekly summary into shapes and rows.
11
+ //
12
+ // PURE. No DOM, no fetch, no clock: the same summary and week give the same
13
+ // shapes, so tests run it in node (the roadmap-timeline.js precedent). The scene
14
+ // comes back as a tree of plain shape objects, never markup (see citySvg).
15
+ //
16
+ // THE PALETTE is the city's own drawing data (city-draw.js isoTheme, trimmed to
17
+ // what this scene spends: the dark blocks in the black, the white paper model in
18
+ // light). Everything around the drawing takes the hall's tokens; salmon
19
+ // (var(--accent)) is spent only on what waits on the viewer.
20
+ (function (root) {
21
+ 'use strict';
22
+
23
+ const FOOT = 1.8; // a tower's footprint, in tiles
24
+ const PER_ROW = 5; // towers per row; rows run back (tallest) to front
25
+ const ROW_GAP = 4.2; // tiles between rows
26
+ const COL_GAP = 3.0; // tiles between towers in a row
27
+ const MAX_AREAS = 15; // three rows; the rest are reported, not drawn
28
+ const TOWER_PX = 170; // the tallest tower at the latest week
29
+ const TW = 56, TH = 28; // iso tile, in viewBox units
30
+ const LADDER_ROWS = 7;
31
+
32
+ const r1 = (n) => Math.round(n * 10) / 10;
33
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
34
+ const sum = (a) => (Array.isArray(a) ? a.reduce((s, v) => s + num(v), 0) : 0);
35
+
36
+ function theme(mode) {
37
+ if (mode === 'light') {
38
+ return {
39
+ work: { t: '#f6f5f8', l: '#e6e5e9', r: '#d2d1d6', s: 'rgba(23,24,26,0.22)' },
40
+ plinth: { t: '#efeef1', l: '#e2e1e5', r: '#cfced3', s: 'rgba(23,24,26,0.22)' },
41
+ glassL: 'rgba(91,111,208,0.30)', glassR: 'rgba(91,111,208,0.42)', glassT: 'rgba(91,111,208,0.22)',
42
+ 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)',
43
+ slab: { t: '#d9d8dd', l: '#c4c3c9', r: '#b2b1b8', s: 'rgba(23,24,26,0.4)' },
44
+ ground: 'rgba(23,24,26,0.035)', groundLine: 'rgba(23,24,26,0.08)',
45
+ orb: '#d98a4e', orbGlow: 'rgba(217,138,78,0.30)', ideaLine: 'rgba(217,138,78,0.55)',
46
+ av: '#e4e3e7', avAgent: '#d3d8f2', avInk: '#17181a',
47
+ };
48
+ }
49
+ return {
50
+ work: { t: '#34343a', l: '#1c1c20', r: '#111114', s: 'rgba(255,255,255,0.12)' },
51
+ plinth: { t: '#27272c', l: '#141417', r: '#0b0b0d', s: 'rgba(255,255,255,0.08)' },
52
+ glassL: 'rgba(110,130,230,0.30)', glassR: 'rgba(110,130,230,0.20)', glassT: 'rgba(150,170,255,0.30)',
53
+ 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)',
54
+ slab: { t: '#5a5a62', l: '#3a3a40', r: '#2a2a2f', s: 'rgba(255,255,255,0.2)' },
55
+ ground: 'rgba(255,255,255,0.018)', groundLine: 'rgba(255,255,255,0.05)',
56
+ orb: '#ffe2c2', orbGlow: 'rgba(255,214,170,0.35)', ideaLine: 'rgba(255,214,170,0.45)',
57
+ av: '#2c2c32', avAgent: '#2b3157', avInk: '#ffffff',
58
+ };
59
+ }
60
+
61
+ // The summary as the drawing needs it: areas placed on the ground (tallest at
62
+ // the back), one shared height scale, people by id. A summary the server could
63
+ // not fill (no version being built, no areas) gives a model with no areas and
64
+ // the page renders nothing rather than an empty plate.
65
+ function cityModel(data) {
66
+ const d = data || {};
67
+ const weeks = Array.isArray(d.weeks) ? d.weeks : [];
68
+ const W = weeks.length;
69
+ const all = (Array.isArray(d.areas) ? d.areas : []).map((a) => {
70
+ const build = Array.from({ length: W }, (_, i) => num(a.build && a.build[i]));
71
+ const art = Array.from({ length: W }, (_, i) => num(a.art && a.art[i]));
72
+ const lead = Array.from({ length: W }, (_, i) => (a.lead && a.lead[i] != null ? String(a.lead[i]) : null));
73
+ 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 };
74
+ });
75
+ all.forEach((a) => { a.total = a.baseBuild + a.baseArt + sum(a.build) + sum(a.art); });
76
+ all.sort((p, q) => q.total - p.total || p.id.localeCompare(q.id));
77
+ const areas = all.slice(0, MAX_AREAS);
78
+ const rows = Math.max(1, Math.ceil(areas.length / PER_ROW));
79
+ areas.forEach((a, n) => {
80
+ const row = Math.floor(n / PER_ROW), col = n % PER_ROW;
81
+ a.x = 0.8 + col * COL_GAP + (row % 2 ? 1.5 : 0);
82
+ a.y = 0.8 + row * ROW_GAP;
83
+ });
84
+ const tallest = Math.max(1, ...areas.map((a) => a.total));
85
+ const people = {};
86
+ (Array.isArray(d.people) ? d.people : []).forEach((p) => {
87
+ const row = (k) => Array.from({ length: W }, (_, i) => num(p[k] && p[k][i]));
88
+ 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') };
89
+ });
90
+ const now = d.now || {};
91
+ const sites = {};
92
+ (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 }; });
93
+ const waiting = {};
94
+ (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); });
95
+ const ideas = (Array.isArray(d.ideas) ? d.ideas : []).map((x) => ({ 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), goal: x.goal_id != null ? String(x.goal_id) : null }));
96
+ return {
97
+ weeks, W, areas, rows, unplaced: all.length - areas.length,
98
+ px: TOWER_PX / tallest, people, sites, waiting, ideas,
99
+ width: 16.2, depth: rows * ROW_GAP - 0.6,
100
+ };
101
+ }
102
+
103
+ // What an area has built by the end of week i (the week's own ships included).
104
+ function doneAt(a, i) {
105
+ let build = a.baseBuild, art = a.baseArt;
106
+ for (let w = 0; w <= i && w < a.build.length; w++) { build += a.build[w]; art += a.art[w]; }
107
+ return { build, art };
108
+ }
109
+
110
+ // The sites worked in week i, goal → { builder, agent }: the latest week shows
111
+ // who is on each site now (open claims; an Autobongos runner working one makes it
112
+ // an agent's site); an earlier week shows its four busiest areas and the builder
113
+ // who shipped most there.
114
+ function sitesFor(m, i) {
115
+ if (i === m.W - 1 && Object.keys(m.sites).length) return Object.assign({}, m.sites);
116
+ const out = {};
117
+ m.areas.map((a) => ({ a, n: a.build[i] + a.art[i] }))
118
+ .filter((x) => x.n > 0 && x.a.lead[i])
119
+ .sort((p, q) => q.n - p.n || p.a.id.localeCompare(q.a.id))
120
+ .slice(0, 4)
121
+ .forEach((x) => { out[x.a.id] = { builder: x.a.lead[i], agent: false }; });
122
+ return out;
123
+ }
124
+
125
+ // The ladder for week i: builders by what they did that week, the top seven,
126
+ // and the viewer appended when they fall below (a phone leaderboard's "you").
127
+ function ladderFor(m, i, viewerId) {
128
+ const rows = Object.values(m.people).map((p) => {
129
+ const v = { art: p.art[i] || 0, build: p.build[i] || 0, idea: p.idea[i] || 0, gov: p.gov[i] || 0 };
130
+ return { id: p.id, name: p.name, avatar: p.avatar, agent: p.agent, me: viewerId != null && p.id === String(viewerId), v, total: v.art + v.build + v.idea + v.gov };
131
+ }).filter((r) => r.total > 0 || r.me)
132
+ .sort((p, q) => q.total - p.total || p.name.localeCompare(q.name));
133
+ const top = rows.slice(0, LADDER_ROWS);
134
+ const me = rows.find((r) => r.me);
135
+ if (me && !top.includes(me)) top.push(me);
136
+ return top;
137
+ }
138
+
139
+ // The scene's viewBox for a model: the ground and the tallest tower plus room
140
+ // for its crane and face, trimmed so no empty sky stands above it.
141
+ function viewBoxOf(m) {
142
+ const OY = TOWER_PX + 110, h = OY + (m.width + m.depth) * TH / 2 + 24;
143
+ return { w: 1000, h: Math.round(h), OX: 500 - (m.width - m.depth) * TW / 4, OY };
144
+ }
145
+
146
+ // The scene for week i as SHAPES, never markup: a tree of { tag, attrs, kids,
147
+ // text } that home-city.js turns into SVG nodes with createElementNS and
148
+ // setAttribute (city-draw.js's split exactly — "city.js only turns the lists into
149
+ // SVG nodes"). So no ledger string is ever parsed as markup: a title is an
150
+ // attribute value or a text node, which is also why this file needs no escaper.
151
+ function citySvg(m, i, opts) {
152
+ const o = opts || {};
153
+ const I = theme(o.mode);
154
+ const vb = viewBoxOf(m);
155
+ const latest = i === m.W - 1;
156
+ const P = (x, y, z) => [vb.OX + (x - y) * TW / 2, vb.OY + (x + y) * TH / 2 - (z || 0)];
157
+ const pts = (a) => 'M' + a.map((p) => r1(p[0]) + ' ' + r1(p[1])).join('L') + 'Z';
158
+ const node = (tag, attrs, kids, text) => ({ tag, attrs: attrs || {}, kids: kids || [], text: text == null ? null : String(text) });
159
+ const face = (a, fill, stroke) => node('path', { d: pts(a), fill, stroke: stroke || 'none', 'stroke-width': 1, 'stroke-linejoin': 'round', class: 'hc-face' });
160
+ 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 } : {}));
161
+ const circle = (cx, cy, r, attrs) => node('circle', Object.assign({ cx: r1(cx), cy: r1(cy), r }, attrs));
162
+ const box = (x0, y0, x1, y1, z0, h, t) => {
163
+ const z1 = z0 + h;
164
+ return [
165
+ face([P(x0, y1, z1), P(x1, y1, z1), P(x1, y1, z0), P(x0, y1, z0)], t.l, t.s),
166
+ face([P(x1, y1, z1), P(x1, y0, z1), P(x1, y0, z0), P(x1, y1, z0)], t.r, t.s),
167
+ face([P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)], t.t, t.s),
168
+ ];
169
+ };
170
+
171
+ const out = [
172
+ node('defs', {}, [node('radialGradient', { id: 'hc-orb' }, [node('stop', { offset: 0, 'stop-color': I.orbGlow }), node('stop', { offset: 1, 'stop-color': 'transparent' })])]),
173
+ face([P(0, 0), P(m.width, 0), P(m.width, m.depth), P(0, m.depth)], I.ground, I.groundLine),
174
+ ];
175
+
176
+ const sites = sitesFor(m, i);
177
+ const tops = {}, over = [];
178
+ m.areas.slice().sort((p, q) => (p.x + p.y) - (q.x + q.y)).forEach((a) => {
179
+ const x0 = a.x, y0 = a.y, x1 = x0 + FOOT, y1 = y0 + FOOT, base = 4;
180
+ const d = doneAt(a, i), built = Math.max(2, (d.build + d.art) * m.px), clad = d.art * m.px;
181
+ const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
182
+ const wait = latest ? (m.waiting[a.id] || 0) : 0;
183
+ const g = [];
184
+ g.push(...box(x0 - 0.12, y0 - 0.12, x1 + 0.12, y1 + 0.12, 0, base, I.plinth));
185
+ g.push(...box(x0, y0, x1, y1, base, built, I.work));
186
+ if (clad > 1) {
187
+ const c = base + Math.min(clad, built);
188
+ g.push(face([P(x0, y1, c), P(x1, y1, c), P(x1, y1, base), P(x0, y1, base)], I.glassL));
189
+ g.push(face([P(x1, y1, c), P(x1, y0, c), P(x1, y0, base), P(x1, y1, base)], I.glassR));
190
+ 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));
191
+ }
192
+ 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));
193
+ const top = base + built;
194
+ tops[a.id] = P((x0 + x1) / 2, (y0 + y1) / 2, top);
195
+ if (site) {
196
+ // the floor going up: lit, wrapped in scaffolding, a crane lifting the next slab
197
+ if (built > 14) {
198
+ const zt = top - 9, zb = top - 3;
199
+ 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));
200
+ 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));
201
+ }
202
+ const ov = 0.14, s0 = Math.max(base, top - 12), s1 = top + 22;
203
+ [x0 - ov, (x0 + x1) / 2, x1 + ov].forEach((x) => g.push(line(P(x, y1 + ov, s0), P(x, y1 + ov, s1), I.scaffold)));
204
+ [y1 + ov, (y0 + y1) / 2, y0 - ov].forEach((y) => g.push(line(P(x1 + ov, y, s0), P(x1 + ov, y, s1), I.scaffold)));
205
+ 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));
206
+ const mx = x0 + 0.5, my = y0 + 0.5, ct = top + 56;
207
+ 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));
208
+ 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));
209
+ g.push(line(P(mx - 0.9, my, ct), P(mx + 2.2, my, ct), I.steel, 1.5));
210
+ 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));
211
+ g.push(...box(mx - 0.95, my - 0.12, mx - 0.6, my + 0.12, ct - 8, 8, I.slab));
212
+ const hx = mx + 1.8;
213
+ g.push(line(P(hx, my, ct), P(hx, my, top - 6), I.steel, 1));
214
+ 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)));
215
+ if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
216
+ }
217
+ if (wait) over.push({ k: 'dot', p: P((x0 + x1) / 2, (y0 + y1) / 2, top + (site ? 12 : 22)), a, n: wait });
218
+ const tip = `${d.build + d.art} shipped${d.art ? ` · ${d.art} by artists` : ''}${who ? ` · ${agent ? `${who.name}'s agent` : who.name} on site` : ''}`;
219
+ out.push(node('g', { class: 'hc-tower', 'data-tip': a.title, 'data-tip-body': tip }, g));
220
+ });
221
+
222
+ // the sky: the week's ideas bright, the week before dim; one that became work
223
+ // drops a line to the tower it went into
224
+ m.ideas.forEach((x) => {
225
+ const age = i - x.week;
226
+ if (age < 0 || age > 1) return;
227
+ const h = hash(x.id), bright = age === 0;
228
+ const cx = vb.OX + 40 + (h % 1000) / 1000 * 300, cy = 40 + (Math.floor(h / 1000) % 1000) / 1000 * 150;
229
+ const by = x.by && m.people[x.by] ? m.people[x.by].name : '';
230
+ const g = [];
231
+ if (bright && x.goal && tops[x.goal]) g.push(line([cx, cy], tops[x.goal], I.ideaLine, 1, '2 4'));
232
+ g.push(circle(cx, cy, bright ? 14 : 9, Object.assign({ fill: 'url(#hc-orb)' }, bright ? { class: 'hc-orb' } : {})));
233
+ g.push(circle(cx, cy, bright ? 4 : 2.6, Object.assign({ fill: I.orb }, bright ? {} : { opacity: 0.45 })));
234
+ g.push(circle(cx, cy, 12, { fill: 'transparent' }));
235
+ out.push(node('g', { class: 'hc-idea', 'data-tip': x.title, 'data-tip-body': by ? `idea · ${by}` : 'idea' }, g));
236
+ });
237
+
238
+ over.forEach((v, n) => {
239
+ if (v.k === 'face') { out.push(avatarNode(v.p, v.who, v.agent, v.a, I, n, node, line, circle)); return; }
240
+ const cx = v.p[0] - 30, cy = v.p[1];
241
+ out.push(node('g', { class: 'hc-wait', 'data-tip': v.a.title, 'data-tip-body': `${v.n} waiting on you` }, [
242
+ circle(cx, cy, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
243
+ node('text', { x: r1(cx), y: r1(cy + 4), 'text-anchor': 'middle', class: 'hc-wait__n' }, [], v.n),
244
+ ]));
245
+ });
246
+ return { viewBox: `0 0 ${vb.w} ${vb.h}`, nodes: out };
247
+ }
248
+
249
+ // A face over a crane: the builder's avatar clipped to a circle over their
250
+ // initial (so a picture that fails to load still leaves a face), or the robot
251
+ // glyph when an agent is building the site.
252
+ function avatarNode(p, who, agent, a, I, n, node, line, circle) {
253
+ const x = p[0], y = p[1], id = `hc-av-${n}`;
254
+ 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 })];
255
+ if (agent) {
256
+ 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 }));
257
+ g.push(circle(x - 3, y + 0.5, 1.5, { fill: I.avInk }), circle(x + 3, y + 0.5, 1.5, { fill: I.avInk }));
258
+ } else {
259
+ g.push(node('text', { x: r1(x), y: r1(y + 5), 'text-anchor': 'middle', fill: I.avInk, class: 'hc-av__i' }, [], initial(who.name)));
260
+ if (who.avatar && /^https:\/\//.test(who.avatar)) {
261
+ g.push(node('clipPath', { id }, [circle(x, y, 14.5, {})]));
262
+ 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' }));
263
+ }
264
+ }
265
+ return node('g', { class: 'hc-face-pin', 'data-tip': agent ? `${who.name}'s agent` : who.name, 'data-tip-body': a.title }, g);
266
+ }
267
+
268
+ function initial(name) { const s = String(name || '').trim(); return s ? s[0].toUpperCase() : '?'; }
269
+ // a stable spread for an idea's place in the sky (FNV-1a over its id)
270
+ 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); }
271
+
272
+ const api = { cityModel, doneAt, sitesFor, ladderFor, citySvg, viewBoxOf, theme, initial, LADDER_ROWS, MAX_AREAS };
273
+ if (typeof module === 'object' && module.exports) module.exports = api;
274
+ if (root) root.OTBHomeCityDraw = api;
275
+ })(typeof window !== 'undefined' ? window : null);
@@ -0,0 +1,66 @@
1
+ /* modules/hall-ui/public/home-city.css — Home's project city (task 1005054).
2
+ The scene card, the ladder of builders beside it and the time slider under it
3
+ (home-city.js; record modules/hall-ui/records/home-city.md). Every colour is a
4
+ hall token or a mix of one; the tower faces are drawing data in
5
+ home-city-draw.js. One sky for both the city and the ladder, so the two read
6
+ as one scene. No :root here; motion is CSS and the hall's reduced-motion rule (style.css, `prefers-reduced-motion: reduce`)
7
+ stops it. */
8
+
9
+ .home-city { padding: 0; overflow: hidden; background: linear-gradient(var(--bg-card), var(--bg)); }
10
+ .hc-stage { display: flex; align-items: center; gap: 4px; }
11
+ .hc-svg { display: block; flex: 1; min-width: 0; height: auto; }
12
+ .hc-svg .hc-wait__n { font: 700 11px var(--font-display); fill: var(--accent-on); }
13
+ .hc-svg .hc-av__i { font: 600 13px var(--font-display); }
14
+ .hc-svg [data-tip] { cursor: default; }
15
+ .hc-svg .hc-tower:hover .hc-face { filter: brightness(1.3); }
16
+
17
+ /* the ladder: a small face and one thin line split by craft */
18
+ .hc-ladder { position: relative; flex: none; width: 230px; margin: 24px 28px 24px 0; }
19
+ .hc-rung { position: absolute; left: 0; right: 0; height: 34px; display: grid; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 12px; transition: top .55s cubic-bezier(.2, .7, .2, 1); }
20
+ .hc-av { position: relative; width: 28px; height: 28px; border-radius: 50%; background: var(--rule-soft); display: grid; place-items: center; overflow: hidden; font: 600 11px var(--font-display); color: var(--ink-soft); }
21
+ .hc-av img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
22
+ .hc-av--agent { background: color-mix(in srgb, var(--sea) 26%, var(--bg-card)); }
23
+ .hc-av svg { width: 15px; height: 15px; }
24
+ .hc-rung--me .hc-av { color: var(--ink); box-shadow: 0 0 0 1.5px var(--ink-soft); }
25
+ .hc-line { display: flex; gap: 3px; align-items: flex-end; height: 22px; transition: width .55s cubic-bezier(.2, .7, .2, 1); }
26
+ .hc-seg { display: flex; flex-direction: column; justify-content: flex-end; gap: 3px; min-width: 26px; color: var(--ink-faint); }
27
+ .hc-seg b { display: flex; align-items: center; gap: 3px; font: 600 10.5px/1 var(--font-display); font-variant-numeric: tabular-nums; white-space: nowrap; }
28
+ .hc-seg .hc-ico { width: 11px; height: 11px; flex: none; opacity: .8; }
29
+ .hc-seg i { display: block; height: 3px; border-radius: 2px; background: var(--rule); }
30
+ .hc-rung--me .hc-seg { color: var(--ink-soft); }
31
+ .hc-rung--me .hc-seg i { background: var(--ink-soft); }
32
+ .hc-seg:hover { color: var(--ink); }
33
+ .hc-seg:hover i { background: var(--ink); }
34
+
35
+ /* the time slider */
36
+ .hc-time { display: flex; align-items: center; gap: 16px; padding: 6px 24px 18px; background: var(--bg-card); }
37
+ .hc-play { flex: none; width: 46px; height: 46px; border-radius: 50%; border: 1px solid var(--rule); background: transparent; color: var(--ink); cursor: pointer; display: grid; place-items: center; }
38
+ .hc-play svg { width: 16px; height: 16px; }
39
+ .hc-play .hc-play__off, .hc-play.is-playing .hc-play__on { display: none; }
40
+ .hc-play.is-playing .hc-play__off { display: block; }
41
+ .hc-play:focus-visible, .hc-slider:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
42
+ .hc-track { flex: 1; min-width: 0; }
43
+ .hc-slider { width: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; height: 24px; cursor: pointer; }
44
+ .hc-slider::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--rule-soft); }
45
+ .hc-slider::-moz-range-track { height: 4px; border-radius: 2px; background: var(--rule-soft); }
46
+ .hc-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--ink); margin-top: -8px; border: 3px solid var(--bg-card); }
47
+ .hc-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--ink); border: 3px solid var(--bg-card); }
48
+ .hc-ticks { display: flex; justify-content: space-between; font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
49
+
50
+ .hc-tip { position: fixed; pointer-events: none; z-index: var(--z-modal); background: var(--ink); color: var(--bg-card); font-size: 12px; line-height: 1.35; padding: 8px 11px; border-radius: 9px; max-width: 260px; opacity: 0; transform: translate(-50%, calc(-100% - 12px)); }
51
+ .hc-tip.is-on { opacity: 1; }
52
+ .hc-tip b { display: block; font-size: 13px; }
53
+
54
+ /* live, and nothing more: a crane's slab climbs, this week's ideas glow (20s+) */
55
+ @keyframes hc-lift { 0%, 12% { transform: translateY(0); } 55%, 70% { transform: translateY(-24px); } 100% { transform: translateY(0); } }
56
+ @keyframes hc-glow { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
57
+ .hc-slab { animation: hc-lift 20s ease-in-out infinite; }
58
+ .hc-orb { animation: hc-glow 24s ease-in-out infinite; }
59
+
60
+ /* a phone: the ladder runs full width under the city */
61
+ @media (max-width: 700px) {
62
+ .hc-stage { flex-direction: column; align-items: stretch; }
63
+ .hc-svg { width: 100%; }
64
+ .hc-ladder { width: auto; margin: 12px 18px 4px; }
65
+ .hc-time { padding: 6px 16px 16px; }
66
+ }
@@ -0,0 +1,223 @@
1
+ // modules/hall-ui/public/home-city.js — Home's project city (task 1005054).
2
+ //
3
+ // The page half of the owner's Home redesign (docs/design/mocks/hall/home/v14.html,
4
+ // record modules/hall-ui/records/home-city.md): the city scene, the ladder of
5
+ // builders beside it, and the time slider under it. It asks the server for ONE
6
+ // bounded weekly summary (GET /home/city) and never a feed it would slice — the
7
+ // rule tests/home_bounded_reads.mjs holds Home to. The drawing is
8
+ // home-city-draw.js (pure); this file owns the read, the DOM, the slider and hover.
9
+ //
10
+ // Motion stays CSS: play steps the slider a week at a time on a timer (state, not
11
+ // animation); the ladder's rows slide and a crane's slab lifts in CSS, which the
12
+ // hall's reduced-motion rule stops.
13
+ (function () {
14
+ 'use strict';
15
+ if (!window.OTB || typeof window.OTB.contributeHallWidget !== 'function') return;
16
+ const Draw = window.OTBHomeCityDraw;
17
+ if (!Draw) return;
18
+ const API = '/api/bongos';
19
+ const WEEKS = 10;
20
+ const STEP_MS = 800;
21
+ const RUNG = 40;
22
+ const CRAFTS = [
23
+ ['art', 'artistry', 'Art', 'pages designed'],
24
+ ['build', 'building', 'Build', 'shipped'],
25
+ ['idea', 'ideating', 'Ideas', 'ideas'],
26
+ ['gov', 'governing', 'Govern', 'votes and decisions'],
27
+ ];
28
+
29
+ let host = null, model = null, viewerId = null, week = 0, timer = null;
30
+ const rungs = new Map();
31
+
32
+ function mode() {
33
+ const t = document.documentElement.dataset.theme;
34
+ if (t === 'light' || t === 'dark') return t;
35
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
36
+ }
37
+
38
+ function weekName(i) {
39
+ if (!model || i === model.W - 1) return 'this week';
40
+ const w = model.weeks[i];
41
+ const d = w && w.start ? new Date(w.start + 'T00:00:00Z') : null;
42
+ return d && !isNaN(d) ? 'week of ' + d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : 'that week';
43
+ }
44
+
45
+ function craftIcon(key) {
46
+ const CM = window.OTBCraftMode;
47
+ return CM && typeof CM.iconHtml === 'function' ? CM.iconHtml(key, 'hc-ico') : '';
48
+ }
49
+
50
+ // ---- the scene: the drawing's shapes built as SVG nodes (city.js's split),
51
+ // so every name and title lands as an attribute value or text, never markup ----
52
+ const NS = 'http://www.w3.org/2000/svg';
53
+ function build(n) {
54
+ const e = document.createElementNS(NS, n.tag);
55
+ Object.keys(n.attrs).forEach((k) => e.setAttribute(k, String(n.attrs[k])));
56
+ if (n.text != null) e.textContent = n.text;
57
+ n.kids.forEach((c) => e.appendChild(build(c)));
58
+ return e;
59
+ }
60
+ function drawScene() {
61
+ const svg = host.querySelector('.hc-svg');
62
+ const out = Draw.citySvg(model, week, { mode: mode() });
63
+ svg.setAttribute('viewBox', out.viewBox);
64
+ svg.replaceChildren(...out.nodes.map(build));
65
+ }
66
+
67
+ // ---- the ladder: a small face and one line split by craft, ranked ----
68
+ function rungFor(row) {
69
+ let r = rungs.get(row.id);
70
+ if (r) return r;
71
+ r = document.createElement('div');
72
+ r.className = 'hc-rung';
73
+ const av = document.createElement('span');
74
+ av.className = 'hc-av' + (row.agent ? ' hc-av--agent' : '');
75
+ if (row.agent) av.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="8" width="14" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="9.5" cy="13.5" r="1.5" fill="currentColor"/><circle cx="14.5" cy="13.5" r="1.5" fill="currentColor"/><path d="M12 8V5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
76
+ else {
77
+ av.textContent = Draw.initial(row.name);
78
+ if (row.avatar && /^https:\/\//.test(row.avatar)) {
79
+ const img = document.createElement('img');
80
+ img.src = row.avatar; img.alt = ''; img.loading = 'lazy';
81
+ img.addEventListener('error', () => img.remove());
82
+ av.appendChild(img);
83
+ }
84
+ }
85
+ const line = document.createElement('span');
86
+ line.className = 'hc-line';
87
+ r.append(av, line);
88
+ host.querySelector('.hc-ladder').appendChild(r);
89
+ rungs.set(row.id, r);
90
+ return r;
91
+ }
92
+
93
+ function drawLadder() {
94
+ const rows = Draw.ladderFor(model, week, viewerId);
95
+ const ladder = host.querySelector('.hc-ladder');
96
+ ladder.style.height = (Math.max(1, rows.length) * RUNG) + 'px';
97
+ const top = Math.max(1, ...rows.map((r) => r.total));
98
+ const room = Math.max(60, ladder.clientWidth - 40);
99
+ const shown = new Set();
100
+ rows.forEach((row, n) => {
101
+ const r = rungFor(row);
102
+ shown.add(row.id);
103
+ r.hidden = false;
104
+ r.classList.toggle('hc-rung--me', row.me);
105
+ r.style.top = (n * RUNG) + 'px';
106
+ r.dataset.tip = row.name + (row.me ? ' (you)' : '') + ' · ' + row.total;
107
+ r.dataset.tipBody = weekName(week);
108
+ const line = r.querySelector('.hc-line');
109
+ line.textContent = '';
110
+ const crafts = CRAFTS.filter((c) => row.v[c[0]] > 0);
111
+ line.style.width = Math.max(crafts.length * 29, room * row.total / top).toFixed(1) + 'px';
112
+ crafts.forEach((c) => {
113
+ const seg = document.createElement('span');
114
+ seg.className = 'hc-seg';
115
+ seg.style.flex = row.v[c[0]] + ' 1 0';
116
+ seg.dataset.tip = row.name + ' · ' + c[2];
117
+ seg.dataset.tipBody = row.v[c[0]] + ' ' + c[3] + ' ' + weekName(week);
118
+ const lab = document.createElement('b');
119
+ lab.innerHTML = craftIcon(c[1]);
120
+ lab.appendChild(document.createTextNode(String(row.v[c[0]])));
121
+ seg.append(lab, document.createElement('i'));
122
+ line.appendChild(seg);
123
+ });
124
+ });
125
+ rungs.forEach((r, id) => { if (!shown.has(id)) r.hidden = true; });
126
+ }
127
+
128
+ function setWeek(i) {
129
+ week = Math.max(0, Math.min(model.W - 1, i));
130
+ const s = host.querySelector('.hc-slider');
131
+ s.value = String(week);
132
+ s.setAttribute('aria-valuetext', weekName(week));
133
+ drawScene();
134
+ drawLadder();
135
+ }
136
+
137
+ // ---- play: a week at a time, then stop on now ----
138
+ function stop() {
139
+ if (timer) clearTimeout(timer);
140
+ timer = null;
141
+ const b = host && host.querySelector('.hc-play');
142
+ if (b) { b.classList.remove('is-playing'); b.setAttribute('aria-label', 'Play the weeks from the start'); }
143
+ }
144
+ function play() {
145
+ if (timer) { stop(); return; }
146
+ const b = host.querySelector('.hc-play');
147
+ b.classList.add('is-playing'); b.setAttribute('aria-label', 'Pause');
148
+ let i = 0;
149
+ const step = () => { setWeek(i); if (i++ < model.W - 1) timer = setTimeout(step, STEP_MS); else stop(); };
150
+ step();
151
+ }
152
+
153
+ // ---- one tooltip for the whole widget; labels are ledger data, so text only ----
154
+ function wireTip() {
155
+ let tip = document.getElementById('hc-tip');
156
+ if (!tip) { tip = document.createElement('div'); tip.id = 'hc-tip'; tip.className = 'hc-tip'; tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip); }
157
+ host.addEventListener('pointerover', (e) => {
158
+ const n = e.target.closest('[data-tip]');
159
+ if (!n || !host.contains(n)) return;
160
+ const r = n.getBoundingClientRect();
161
+ tip.textContent = '';
162
+ const b = document.createElement('b'); b.textContent = n.dataset.tip; tip.appendChild(b);
163
+ if (n.dataset.tipBody) tip.appendChild(document.createTextNode(n.dataset.tipBody));
164
+ tip.style.left = (r.left + r.width / 2) + 'px';
165
+ tip.style.top = r.top + 'px';
166
+ tip.classList.add('is-on');
167
+ });
168
+ host.addEventListener('pointerout', (e) => { if (e.target.closest('[data-tip]')) tip.classList.remove('is-on'); });
169
+ }
170
+
171
+ function ticks() {
172
+ const t = host.querySelector('.hc-ticks');
173
+ const first = model.weeks[0] && model.weeks[0].start ? new Date(model.weeks[0].start + 'T00:00:00Z') : null;
174
+ const mid = model.weeks[Math.floor(model.W / 2)] && model.weeks[Math.floor(model.W / 2)].start ? new Date(model.weeks[Math.floor(model.W / 2)].start + 'T00:00:00Z') : null;
175
+ const mon = (d) => (d && !isNaN(d) ? d.toLocaleDateString('en-GB', { month: 'short', timeZone: 'UTC' }) : '');
176
+ t.innerHTML = '';
177
+ [mon(first), mon(mid), 'Now'].forEach((s) => { const sp = document.createElement('span'); sp.textContent = s; t.appendChild(sp); });
178
+ }
179
+
180
+ async function load() {
181
+ const api = window.BongosClient && window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
182
+ if (!api) return;
183
+ let res;
184
+ try { res = await api.request('GET', `${API}/home/city?weeks=${WEEKS}`); } catch (_) { return; }
185
+ if (!res || !res.ok || !res.data) return; // signed out, gated or failed: Home keeps its other cards, never an empty plate
186
+ model = Draw.cityModel(res.data);
187
+ viewerId = res.data.viewer && res.data.viewer.id != null ? String(res.data.viewer.id) : null;
188
+ if (!model.W || !model.areas.length) return;
189
+ const s = host.querySelector('.hc-slider');
190
+ s.max = String(model.W - 1);
191
+ ticks();
192
+ host.hidden = false;
193
+ setWeek(model.W - 1);
194
+ }
195
+
196
+ function mount(hostEl) {
197
+ host = hostEl;
198
+ host.innerHTML =
199
+ '<h2 id="home-city-h" class="sr-only">The project this week</h2>' +
200
+ '<div class="hc-stage">' +
201
+ '<svg class="hc-svg" role="img" aria-label="The project as a city: one tower per working area, as tall as what has shipped there, with the builders of the week beside it"></svg>' +
202
+ '<div class="hc-ladder" aria-label="This week\'s builders, ranked by what they did"></div>' +
203
+ '</div>' +
204
+ '<div class="hc-time">' +
205
+ '<button type="button" class="hc-play" aria-label="Play the weeks from the start">' +
206
+ '<svg class="hc-play__on" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2.5v11l9-5.5z" fill="currentColor"/></svg>' +
207
+ '<svg class="hc-play__off" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor"/></svg>' +
208
+ '</button>' +
209
+ '<div class="hc-track"><input type="range" class="hc-slider" min="0" max="0" step="1" value="0" aria-label="Week"><div class="hc-ticks"></div></div>' +
210
+ '</div>';
211
+ host.querySelector('.hc-slider').addEventListener('input', (e) => { stop(); setWeek(Number(e.target.value)); });
212
+ host.querySelector('.hc-play').addEventListener('click', play);
213
+ wireTip();
214
+ new MutationObserver(() => { if (model) drawScene(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
215
+ window.addEventListener('resize', () => { if (model) drawLadder(); });
216
+ load();
217
+ }
218
+
219
+ window.OTB.contributeHallWidget({
220
+ id: 'home-city', label: 'The project', order: 15, core: true, mount,
221
+ view: 'home', zone: 'home-stats', sectionClass: 'scroll home-city', labelledby: 'home-city-h', hiddenByDefault: true,
222
+ });
223
+ })();
@@ -21,6 +21,7 @@
21
21
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
22
22
  <link rel="stylesheet" href="/builders/welcome-boot.css?v=2026-10-23-city1">
23
23
  <link rel="stylesheet" href="/builders/hall-ask.css">
24
+ <link rel="stylesheet" href="/builders/home-city.css">
24
25
  </head>
25
26
  <body data-page="home">
26
27
 
@@ -138,8 +139,10 @@
138
139
  <script src="/builders/hall-widgets.js?v=2026-09-30-founding" defer></script>
139
140
  <!-- The genesis home (task 1004423): a Home banner card that renders only in founding mode. -->
140
141
  <script src="/builders/genesis-home.js?v=2026-10-01-genesis" defer></script>
141
- <!-- BV1.R78 (task 1694): the Goals widget registers into the same registry. -->
142
- <script src="/builders/goals.js?v=2026-07-01-goals" defer></script>
142
+ <!-- task 1005054: the project city — the drawing (pure) and the widget — in the
143
+ home-stats zone; the Goals card (goals.js) left Home with it. -->
144
+ <script src="/builders/home-city-draw.js" defer></script>
145
+ <script src="/builders/home-city.js" defer></script>
143
146
  <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
144
147
  both goal-inbox.js's attention-inbox slice below and the /blockers page.
145
148
  Loaded BEFORE goal-inbox.js, which calls it while rendering. -->
@@ -159,10 +162,6 @@
159
162
  window.OTBProfileNudge; goal-inbox.js's mount() calls it once it has
160
163
  created the #attention-profile div, so load order is not load-bearing. -->
161
164
  <script src="/builders/profile-nudge.js?v=2026-08-25-profile-nudge" defer></script>
162
- <!-- task 1003459: the hall's ONE claim/release action (task 1003458) — the
163
- Home "Next up" row's Claim button consumes it rather than a second
164
- hand-rolled POST /claims. Loaded BEFORE builders.js, which wires it. -->
165
- <script src="/builders/claim-action.js?v=2026-09-26-claimaction1" defer></script>
166
165
  <script src="/builders/builders.js?v=2026-09-28-nextupclaim" defer></script>
167
166
  <!-- task 1004727: the ONE karma vote control every task/idea list renders -->
168
167
  <script src="/builders/vote-control.js"></script>