@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.
- package/.bongos-core.json +100 -70
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +36 -1
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +247 -280
- package/docs/copy-registry.json +381 -691
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -10
- package/docs/page-readings.json +327 -393
- package/modules/builder-settings/interaction-prefs.js +2 -2
- package/modules/government/db.js +29 -0
- package/modules/government/docket.js +61 -4
- package/modules/government/routes/government.js +2 -0
- package/modules/hall-ui/public/builders.js +3 -79
- package/modules/hall-ui/public/hall-render.js +6 -81
- package/modules/hall-ui/public/hall-widgets.js +4 -45
- package/modules/hall-ui/public/home-city-draw.js +275 -0
- package/modules/hall-ui/public/home-city.css +66 -0
- package/modules/hall-ui/public/home-city.js +223 -0
- package/modules/hall-ui/public/index.html +5 -6
- package/modules/hall-ui/public/index.states.json +73 -8
- package/modules/hall-ui/public/my-agents.js +35 -2
- package/modules/hall-ui/public/palette.js +8 -0
- package/modules/hall-ui/public/settings-moved.js +41 -0
- package/modules/hall-ui/public/settings.css +4 -28
- package/modules/hall-ui/public/settings.html +19 -107
- package/modules/hall-ui/public/settings.js +20 -215
- package/modules/hall-ui/public/settings.states.json +2 -4
- package/modules/hall-ui/records/home-city.md +66 -0
- package/modules/hall-ui/records/my-agents.md +3 -2
- package/modules/hall-ui/records/panel-family.md +2 -0
- package/modules/lifecycle/home-city.js +340 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/home-city.js +29 -0
- package/modules/lifecycle/ship-card.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/tests/claim_from_task_and_home.mjs +8 -43
- package/tests/government_tidy_up.mjs +21 -24
- package/tests/hall_board_world.mjs +10 -12
- package/tests/hall_home_ships_bounded.mjs +4 -3
- package/tests/hall_my_agents.mjs +88 -2
- package/tests/hall_palette.mjs +12 -0
- package/tests/hall_settings_world.mjs +64 -100
- package/tests/helpers.mjs +6 -5
- package/tests/home_bounded_reads.mjs +16 -31
- package/tests/home_city.mjs +385 -0
- package/tests/home_city_widget.mjs +120 -0
- package/tests/interaction_posture.mjs +3 -1
- package/tests/leaderboard_roles.mjs +3 -2
- package/tests/speciality_offer_ui.mjs +6 -5
- package/tests/speciality_walkthrough_ui.mjs +7 -5
- package/modules/hall-ui/public/settings-interaction.js +0 -132
- package/modules/hall-ui/public/settings-law-locks.js +0 -53
- 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
|
-
<!--
|
|
142
|
-
|
|
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>
|