@bongos/core 1.25.27 → 1.25.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ // modules/hall-ui/public/home-camera.js — the camera over Home's city in game mode (task 1005305).
2
+ //
3
+ // WHY. In game mode the builder's Home is only the city (owner, 2026-11-14): no page scroll,
4
+ // the city a world larger than the view, moved the way a base-building game is moved. This
5
+ // file is that movement and nothing else: the arrow keys, a drag, and the cursor resting at an
6
+ // edge of the screen all pan the world; the page never scrolls. The scene owns its camera
7
+ // (ADR 0399 §2.2: the visor owns none), so this is Home's, attached by home-city.js.
8
+ //
9
+ // The world is the city's own <svg>, sized past the view on both axes and moved with a
10
+ // transform, so the drawing (home-city-draw.js) and every hover and tap on it are unchanged.
11
+ // The maths (worldSize, clamp, edgePush) is pure and exported for the tests.
12
+ (function (root) {
13
+ 'use strict';
14
+
15
+ var OVER = 1.35; // the world is this many views wide and tall, at least
16
+ var EDGE = 56; // px from an edge of the view where the cursor starts to push
17
+ var KEY_SPEED = 900; // px a second while an arrow key is held
18
+ var EDGE_SPEED = 1000; // px a second with the cursor on the very edge
19
+ var DRAG_SLOP = 5; // px a press may wander and still be a tap
20
+ var KEYS = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
21
+
22
+ // The world's size in px for a view and the drawing's own proportions (its viewBox): the
23
+ // smallest size that keeps the drawing's shape and is at least OVER views on both axes.
24
+ function worldSize(vw, vh, bw, bh) {
25
+ if (!(vw > 0 && vh > 0 && bw > 0 && bh > 0)) return { w: 0, h: 0 };
26
+ var s = OVER * Math.max(vw / bw, vh / bh);
27
+ return { w: Math.round(bw * s), h: Math.round(bh * s) };
28
+ }
29
+ // The camera is the world's offset in the view: 0 shows its left/top edge, view − world its
30
+ // right/bottom. It never shows past an edge.
31
+ function clamp(v, view, world) { return Math.min(0, Math.max(view - world, v)); }
32
+ // How hard the cursor pushes on one axis, −1…1: nothing until it is within EDGE of a side,
33
+ // then more the closer it is. Positive moves the world right/down (the view goes left/up).
34
+ function edgePush(p, view) {
35
+ if (!(view > 0) || p == null) return 0;
36
+ if (p < EDGE) return Math.min(1, (EDGE - Math.max(0, p)) / EDGE);
37
+ if (p > view - EDGE) return -Math.min(1, (Math.min(view, p) - (view - EDGE)) / EDGE);
38
+ return 0;
39
+ }
40
+
41
+ // Something above the scene has the keyboard or the pointer: a dialog, the game's windows,
42
+ // the character picker, the start screen, the visor's sheet.
43
+ // Asked on every frame of a pan, so the page is searched at most a few times a second.
44
+ var coverAt = 0, coverWas = false;
45
+ function covered() {
46
+ var now = Date.now();
47
+ if (now - coverAt < 200) return coverWas;
48
+ coverAt = now;
49
+ return (coverWas = isCovered());
50
+ }
51
+ function isCovered() {
52
+ var html = document.documentElement, c = html.classList;
53
+ if (c && (c.contains('craft-picker-open') || c.contains('gs-open') || c.contains('opt-window-open') || c.contains('game-menu-open'))) return true;
54
+ if (root.OTBVisor && typeof root.OTBVisor.isOpen === 'function' && root.OTBVisor.isOpen()) return true;
55
+ var up = document.querySelectorAll('dialog[open], [aria-modal="true"]');
56
+ for (var i = 0; i < up.length; i++) if (!up[i].closest('[hidden]')) return true;
57
+ return false;
58
+ }
59
+ function inField(el) {
60
+ var tag = el && el.tagName ? String(el.tagName).toLowerCase() : '';
61
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || !!(el && (el.isContentEditable || (el.closest && el.closest('[role="slider"]'))));
62
+ }
63
+
64
+ // attach(stage, world): `stage` is the element that fills the view, `world` the drawing moved
65
+ // inside it. Returns { set(on), fit(), centre(), at() }. Off, it holds no listener and
66
+ // leaves no style behind.
67
+ function attach(stage, world) {
68
+ var on = false, x = 0, y = 0, size = { w: 0, h: 0 }, placed = false;
69
+ var held = {}, mouse = null, raf = null, last = 0, drag = null, ate = false;
70
+
71
+ function view() { return { w: stage.clientWidth || root.innerWidth || 0, h: stage.clientHeight || root.innerHeight || 0 }; }
72
+ function box() {
73
+ var b = String(world.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number);
74
+ return b.length === 4 && b[2] > 0 && b[3] > 0 ? { w: b[2], h: b[3] } : null;
75
+ }
76
+ function paint() { world.style.transform = 'translate3d(' + Math.round(x) + 'px,' + Math.round(y) + 'px,0)'; }
77
+ function move(nx, ny) {
78
+ var v = view();
79
+ x = clamp(nx, v.w, size.w); y = clamp(ny, v.h, size.h);
80
+ paint();
81
+ }
82
+ // Size the world for this view; the first time, and whenever asked, look at its middle.
83
+ function fit(recentre) {
84
+ if (!on) return;
85
+ var v = view(), b = box();
86
+ if (!b) return;
87
+ var was = size, cx = placed && was.w ? (v.w / 2 - x) / was.w : 0.5, cy = placed && was.h ? (v.h / 2 - y) / was.h : 0.5;
88
+ size = worldSize(v.w, v.h, b.w, b.h);
89
+ world.style.width = size.w + 'px';
90
+ world.style.height = size.h + 'px';
91
+ if (recentre || !placed) { cx = 0.5; cy = 0.5; }
92
+ placed = true;
93
+ move(v.w / 2 - cx * size.w, v.h / 2 - cy * size.h);
94
+ }
95
+
96
+ function pushing() {
97
+ var dx = 0, dy = 0, k;
98
+ for (k in held) if (held[k] && KEYS[k]) { dx += KEYS[k][0]; dy += KEYS[k][1]; }
99
+ var ex = 0, ey = 0;
100
+ if (mouse && !drag) { var v = view(); ex = edgePush(mouse.x, v.w); ey = edgePush(mouse.y, v.h); }
101
+ return { x: dx * KEY_SPEED + ex * EDGE_SPEED, y: dy * KEY_SPEED + ey * EDGE_SPEED };
102
+ }
103
+ function frame(now) {
104
+ raf = null;
105
+ if (!on) return;
106
+ var p = covered() ? { x: 0, y: 0 } : pushing();
107
+ var dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016);
108
+ last = now;
109
+ if (!p.x && !p.y) { last = 0; return; }
110
+ move(x + p.x * dt, y + p.y * dt);
111
+ raf = root.requestAnimationFrame(frame);
112
+ }
113
+ function wake() { if (on && !raf) raf = root.requestAnimationFrame(frame); }
114
+
115
+ function onKeyDown(e) {
116
+ if (!KEYS[e.key] || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || inField(e.target) || covered()) return;
117
+ e.preventDefault(); // the arrows move the city; the page never scrolls
118
+ held[e.key] = true;
119
+ wake();
120
+ }
121
+ function onKeyUp(e) { if (KEYS[e.key]) held[e.key] = false; }
122
+ function onBlur() { held = {}; mouse = null; }
123
+ // The cursor pushes only from the scene: over the glass's own controls, or anything drawn
124
+ // above the stage, resting near an edge is reaching for a button, not for the city.
125
+ function onPointerMove(e) {
126
+ if (drag) {
127
+ var mx = e.clientX - drag.px, my = e.clientY - drag.py;
128
+ if (!drag.moved && Math.abs(mx) + Math.abs(my) < DRAG_SLOP) return;
129
+ drag.moved = true;
130
+ stage.classList.add('is-dragging');
131
+ move(drag.x + mx, drag.y + my);
132
+ return;
133
+ }
134
+ if (e.pointerType && e.pointerType !== 'mouse') return;
135
+ // the visor's time band lies over the top of the view; resting there still pushes up
136
+ var el = e.target && e.target.nodeType === 1 ? e.target : null;
137
+ var over = !!el && (stage.contains(el) || !!(el.closest && el.closest('[data-visor-scrub]')));
138
+ mouse = over ? { x: e.clientX, y: e.clientY } : null;
139
+ if (mouse) wake();
140
+ }
141
+ function onPointerLeave() { mouse = null; }
142
+ function onPointerDown(e) {
143
+ if (e.button !== 0 || !stage.contains(e.target) || covered()) return;
144
+ drag = { px: e.clientX, py: e.clientY, x: x, y: y, moved: false };
145
+ }
146
+ function onPointerUp() {
147
+ if (!drag) return;
148
+ ate = drag.moved; // the click that ends a drag is not a tap on a tower
149
+ drag = null;
150
+ stage.classList.remove('is-dragging');
151
+ if (ate) root.setTimeout(function () { ate = false; }, 0);
152
+ }
153
+ // Capture on window, so it runs before every tap handler on the page (home-runner.js
154
+ // listens in capture on document).
155
+ function onClick(e) {
156
+ if (!ate) return;
157
+ ate = false;
158
+ e.preventDefault();
159
+ e.stopPropagation();
160
+ }
161
+ function onResize() { fit(false); }
162
+ // A plain wheel has nowhere to scroll to; held so a trackpad cannot rubber-band the page.
163
+ function onWheel(e) { if (!e.ctrlKey && stage.contains(e.target)) e.preventDefault(); }
164
+
165
+ var wired = [
166
+ [root, 'keydown', onKeyDown], [root, 'keyup', onKeyUp], [root, 'blur', onBlur], [root, 'resize', onResize],
167
+ [root, 'pointermove', onPointerMove], [root, 'pointerdown', onPointerDown], [root, 'pointerup', onPointerUp],
168
+ [root, 'pointercancel', onPointerUp], [root, 'click', onClick, true],
169
+ [document.documentElement, 'pointerleave', onPointerLeave], [stage, 'wheel', onWheel, { passive: false }],
170
+ ];
171
+ function set(next) {
172
+ next = !!next;
173
+ if (next === on) { if (on) fit(false); return; }
174
+ on = next;
175
+ wired.forEach(function (w) { w[0][on ? 'addEventListener' : 'removeEventListener'](w[1], w[2], w[3] || false); });
176
+ if (on) { placed = false; fit(true); return; }
177
+ if (raf) root.cancelAnimationFrame(raf);
178
+ raf = null; held = {}; mouse = null; drag = null; last = 0;
179
+ world.style.transform = ''; world.style.width = ''; world.style.height = '';
180
+ stage.classList.remove('is-dragging');
181
+ }
182
+ return { set: set, fit: function () { fit(false); }, centre: function () { fit(true); }, at: function () { return { x: x, y: y, w: size.w, h: size.h, on: on }; } };
183
+ }
184
+
185
+ var api = { attach: attach, worldSize: worldSize, clamp: clamp, edgePush: edgePush, EDGE: EDGE, OVER: OVER };
186
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
187
+ if (root) root.OTBHomeCamera = api;
188
+ })(typeof window !== 'undefined' ? window : null);
@@ -137,3 +137,17 @@ html[data-theme="dark"] .home-city.hc-is-sky { background: linear-gradient(color
137
137
  .hc-ladder { width: auto; margin: 12px 18px 4px; }
138
138
  .hc-time { padding: 6px 16px 16px; }
139
139
  }
140
+
141
+ /* GAME MODE: THE CITY IS THE WHOLE VIEW (task 1005305, owner 2026-11-14). Where Home shows the
142
+ city in game mode (html.hc-world, set by home-city.js) the page does not scroll and holds
143
+ nothing but the city: the greeting, the cards under it, the ladder, the slider and the foot
144
+ are off the scene (time is on the visor, ADR 0403). The city is a world larger than the view,
145
+ sized and moved by home-camera.js: the arrow keys, a drag, or the cursor at an edge. Classic
146
+ mode, and the artist's gallery, are untouched. */
147
+ html.hc-world, html.hc-world body { overflow: hidden; overscroll-behavior: none; }
148
+ html.hc-world .page-head--hall, html.hc-world .app-foot, html.hc-world #zone-home-banner,
149
+ html.hc-world #zone-home-band, html.hc-world #zone-home-main, html.hc-world .hc-ladder, html.hc-world .hc-time { display: none; }
150
+ html.hc-world .home-city { position: fixed; inset: 0; z-index: 0; margin: 0; border-radius: 0; }
151
+ html.hc-world .hc-stage { position: absolute; inset: 0; display: block; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
152
+ html.hc-world .hc-svg { position: absolute; left: 0; top: 0; flex: none; max-width: none; will-change: transform; }
153
+ html.hc-world .hc-stage.is-dragging .hc-svg a { pointer-events: none; }
@@ -26,6 +26,12 @@
26
26
  // The governor's Home is the street (task 1005064; the v16 round's v16-governing.html):
27
27
  // choosing Governing on Home, or opening Home with it chosen, goes to the street page
28
28
  // (GovCity.STREET_HREF), which is never redrawn here.
29
+ //
30
+ // In game mode the city is the whole view (task 1005305, owner 2026-11-14): the page does
31
+ // not scroll, the cards under the city, the ladder and the slider are off the scene, and the
32
+ // city is a world moved by the arrow keys, a drag, or the cursor at an edge of the screen
33
+ // (home-camera.js). Time is the visor's there (ADR 0403): this file hands OTBVisor.timeline
34
+ // the week it is on and what a drag, play, the speed and Back to live do.
29
35
  (function () {
30
36
  'use strict';
31
37
  if (!window.OTB || typeof window.OTB.contributeHallWidget !== 'function') return;
@@ -37,6 +43,7 @@
37
43
  // the player's speeds, in weeks per frame at 60 fps: rewind and fast forward cross the
38
44
  // whole slider in about five and four seconds; play is about a week every 1.6 s
39
45
  const SPEED = { rw: -1 / 34, play: 1 / 96, ff: 1 / 26 };
46
+ const RATES = [1, 2, 4]; // how fast play runs on the visor (game mode): 1×, 2×, 4×
40
47
  const STILL = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
41
48
  const CRAFTS = [
42
49
  ['art', 'artist', 'Art', 'pages designed'],
@@ -57,7 +64,7 @@
57
64
  const SKY_LABEL = 'The project\'s ideas as a night sky over the city\'s real skyline, with this week\'s ideators ranked beside it';
58
65
  const CITY_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';
59
66
 
60
- let host = null, model = null, viewerId = null, t = 0, target = 0, raf = null, run = null, lastWeek = -1, shown = false, gallery = null, ideaBox = null;
67
+ let host = null, model = null, viewerId = null, t = 0, target = 0, raf = null, run = null, lastWeek = -1, shown = false, gallery = null, ideaBox = null, camera = null, rate = 1, rowsWere = '';
61
68
  const rungs = new Map();
62
69
 
63
70
  function mode() {
@@ -109,8 +116,10 @@
109
116
  host.classList.toggle('hc-is-sky', sky);
110
117
  svg.setAttribute('aria-label', sky ? SKY_LABEL : CITY_LABEL);
111
118
  host.querySelector('#home-city-h').textContent = sky ? 'The project\'s ideas this week' : 'The project this week';
119
+ const boxWas = svg.getAttribute('viewBox');
112
120
  svg.setAttribute('viewBox', out.viewBox);
113
121
  svg.replaceChildren(...out.nodes.map(build));
122
+ if (camera && boxWas !== out.viewBox) camera.fit();
114
123
  }
115
124
 
116
125
  // ---- the ladder: a small face and one line split by craft, ranked ----
@@ -202,6 +211,22 @@
202
211
  if (w !== lastWeek) { lastWeek = w; drawLadder(); }
203
212
  buttons();
204
213
  }
214
+ // ---- game mode: time is on the visor (task 1005305). The strip under the top arc is the
215
+ // slider; back in time the glass shows the date, play, the speed and Back to live. ----
216
+ function timeline() {
217
+ const V = window.OTBVisor;
218
+ if (!V || typeof V.timeline !== 'function') return;
219
+ if (!model || !world()) { V.timeline(null); return; }
220
+ const end = Math.max(1, model.W - 1);
221
+ V.timeline({
222
+ value: t / end, live: atLive(t), label: when(t), playing: run === 'play', speed: rate + '×',
223
+ seek: (f) => glideTo(f * end), play: () => press('play'), faster, toLive: () => glideTo(model.W - 1),
224
+ });
225
+ // the scene's rows change only with play, the speed or live: never on a frame of a glide
226
+ const rows = (run === 'play') + '|' + rate + '|' + atLive(t);
227
+ if (rows !== rowsWere) { rowsWere = rows; registerScene(); }
228
+ }
229
+ function faster() { rate = RATES[(RATES.indexOf(rate) + 1) % RATES.length]; buttons(); }
205
230
  function buttons() {
206
231
  const b = (k) => host.querySelector(`.hc-p-${k}`);
207
232
  b('play').classList.toggle('is-playing', run === 'play');
@@ -209,12 +234,13 @@
209
234
  ['rw', 'play', 'ff'].forEach((k) => b(k).setAttribute('aria-pressed', String(run === k)));
210
235
  b('rw').disabled = t <= 1e-3 && run !== 'rw';
211
236
  b('ff').disabled = atLive(t) && run !== 'ff';
237
+ timeline();
212
238
  }
213
239
  function halt() { if (raf) cancelAnimationFrame(raf); raf = null; run = null; target = t; if (host) buttons(); }
214
240
  function tick() {
215
241
  raf = requestAnimationFrame(() => {
216
242
  let v;
217
- if (run) { v = t + SPEED[run]; if (v <= 0 || v >= model.W - 1) { v = Math.max(0, Math.min(model.W - 1, v)); run = null; } target = v; }
243
+ if (run) { v = t + SPEED[run] * (run === 'play' ? rate : 1); if (v <= 0 || v >= model.W - 1) { v = Math.max(0, Math.min(model.W - 1, v)); run = null; } target = v; }
218
244
  else { v = t + (target - t) * 0.16; if (Math.abs(target - v) < 0.004) v = target; }
219
245
  setT(v);
220
246
  if (run || v !== target) tick(); else { raf = null; buttons(); }
@@ -307,6 +333,25 @@
307
333
  if (gallery) gallery.show(art);
308
334
  if (ideaBox) ideaBox.show(idea);
309
335
  host.hidden = !(art || idea || shown);
336
+ worldMode();
337
+ }
338
+
339
+ // ---- game mode: the city is the whole view (task 1005305) ----
340
+ // Only where the city is what Home shows: in the gallery (the artist's Home) nothing changes.
341
+ function world() {
342
+ return document.documentElement.dataset.game === 'on' && shown && !!host && !host.classList.contains('hc--gallery');
343
+ }
344
+ function worldMode() {
345
+ if (!host) return;
346
+ const on = world(), html = document.documentElement;
347
+ if (html.classList.contains('hc-world') !== on) {
348
+ html.classList.toggle('hc-world', on);
349
+ if (on) window.scrollTo(0, 0);
350
+ }
351
+ if (!camera && on && window.OTBHomeCamera) camera = window.OTBHomeCamera.attach(host.querySelector('.hc-stage'), host.querySelector('.hc-svg'));
352
+ if (camera) camera.set(on);
353
+ timeline();
354
+ registerScene();
310
355
  }
311
356
 
312
357
  // ---- the governor's Home is the street; tests swap the hop for a recorder ----
@@ -353,6 +398,7 @@
353
398
  document.addEventListener('otb:craft', craftHome);
354
399
  craftHome();
355
400
  new MutationObserver(() => { if (model) drawScene(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
401
+ new MutationObserver(worldMode).observe(document.documentElement, { attributes: true, attributeFilter: ['data-game'] });
356
402
  document.addEventListener('otb:craft', () => { if (model) { drawScene(); drawLadder(); } });
357
403
  window.addEventListener('resize', () => { if (model) drawLadder(); });
358
404
  document.addEventListener('otb:home-runner', () => { if (model) drawScene(); });
@@ -364,11 +410,20 @@
364
410
  // the gallery's own rows (home-gallery.js visorActions), registered again when they change.
365
411
  let sceneSig = null;
366
412
  function registerScene() {
367
- const actions = gallery && gallery.visorActions ? gallery.visorActions() : [];
368
- const sig = actions.map((a) => a.label + '|' + (a.key || '')).join('\n');
413
+ // task 1005305: in the city the scene's rows are its time (play, the speed, back to live)
414
+ // and the way back to its middle; its keys line says how to move; the sight is the cursor.
415
+ const city = world();
416
+ const actions = city ? [
417
+ { label: run === 'play' ? 'Pause the city' : 'Play the city over time', key: 'p', bind: true, run: () => press('play') },
418
+ { label: 'Speed · ' + rate + '×', key: 's', bind: true, run: faster },
419
+ ...(atLive(t) ? [] : [{ label: 'Back to live', key: 'l', bind: true, run: () => glideTo(model.W - 1) }]),
420
+ { label: 'Back to the middle of the city', key: 'h', bind: true, run: () => { if (camera) camera.centre(); } },
421
+ ] : (gallery && gallery.visorActions ? gallery.visorActions() : []);
422
+ const keys = city ? [{ label: 'Move around the city', key: 'Arrows' }] : [];
423
+ const sig = (city ? 'city\n' : '') + actions.map((a) => a.label + '|' + (a.key || '')).join('\n');
369
424
  if (sig === sceneSig && window.__visorScene) return;
370
425
  sceneSig = sig;
371
- window.__visorScene = { name: 'home', place: 'Home', actions };
426
+ window.__visorScene = { name: 'home', place: 'Home', actions, keys, aim: city };
372
427
  if (window.OTBVisor) window.OTBVisor.scene(window.__visorScene);
373
428
  }
374
429
 
@@ -19,11 +19,11 @@
19
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link rel="stylesheet" href="/builders/style.css?v=2026-11-11-helm4d">
22
- <link rel="stylesheet" href="/builders/visor.css?v=2026-11-12-runner">
22
+ <link rel="stylesheet" href="/builders/visor.css?v=2026-11-14-world">
23
23
  <link rel="stylesheet" href="/builders/welcome-boot.css?v=2026-11-07-brand-mark">
24
24
  <link rel="stylesheet" href="/builders/shell-game.css?v=2026-11-11-view2">
25
25
  <link rel="stylesheet" href="/builders/hall-ask.css">
26
- <link rel="stylesheet" href="/builders/home-city.css?v=2026-11-12-runner">
26
+ <link rel="stylesheet" href="/builders/home-city.css?v=2026-11-14-world">
27
27
  <link rel="stylesheet" href="/builders/home-gallery.css">
28
28
  <link rel="stylesheet" href="/builders/home-idea-box.css">
29
29
  </head>
@@ -109,7 +109,7 @@
109
109
  <script src="/builders/shell.js?v=2026-11-13-founding" defer></script>
110
110
  <script src="/builders/hall-dialog.js" defer></script>
111
111
  <!-- task 1005148: the visor (glass over the scene, game mode only; ADR 0399), after the shell and hall-dialog.js -->
112
- <script src="/builders/visor.js?v=2026-11-12-runner" defer></script>
112
+ <script src="/builders/visor.js?v=2026-11-14-world" defer></script>
113
113
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
114
114
  <script src="/builders/palette.js" defer></script>
115
115
  <!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
@@ -138,7 +138,9 @@
138
138
  <!-- task 1005243: your runner on Home's towers (game mode; the visor carries its card and button) -->
139
139
  <script src="/builders/runner-button.js" defer></script>
140
140
  <script src="/builders/home-runner.js" defer></script>
141
- <script src="/builders/home-city.js?v=2026-11-12-runner" defer></script>
141
+ <!-- task 1005305: the camera over the city in game mode (arrow keys, a drag, the cursor at an edge) -->
142
+ <script src="/builders/home-camera.js" defer></script>
143
+ <script src="/builders/home-city.js?v=2026-11-14-world" defer></script>
142
144
  <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
143
145
  both goal-inbox.js's attention-inbox slice below and the /blockers page.
144
146
  Loaded BEFORE goal-inbox.js, which calls it while rendering. -->
@@ -301,3 +301,40 @@ html.visor-on.game-menu-open .visor > :not(.visor__frame, .visor__notch) { opaci
301
301
 
302
302
  /* a phone (640px and narrower): the rules live in style.css’s shared phone block, so the
303
303
  visor adds no breakpoint of its own (the design-count ratchet). */
304
+
305
+ /* THE SIGHT IS THE CURSOR (task 1005305, owner 2026-11-14). In a scene that asks for it (Home's
306
+ city) the sight follows the mouse and the arrow is not drawn over the scene, so what you point
307
+ with is the crosshair. Over the glass's own controls, a window or a field the arrow is back
308
+ and the sight steps aside; before the mouse moves, and on touch, it rests at the centre. */
309
+ .visor__sight { left: var(--sight-x, 50%); top: var(--sight-y, 50%); }
310
+ html.visor-aim, html.visor-aim body, html.visor-aim body * { cursor: none !important; }
311
+ .visor[data-aim="ui"] .visor__sight { visibility: hidden; }
312
+
313
+ /* THE SCENE'S TIME ON THE GLASS (task 1005305): the top arc, which reads the version's progress,
314
+ is also a slider you drag to go back through the weeks. The band that takes the drag is the
315
+ arc's own box, and the mark rides the curve where you are in time. Back in time, the date and
316
+ the player (play or pause, the speed, Live) stand on the top line beside the place. The band
317
+ and the three buttons are the only parts here that take a pointer. */
318
+ .visor__scrub { pointer-events: auto; cursor: ew-resize; touch-action: none; }
319
+ .visor__scrub-at {
320
+ position: absolute; left: var(--scrub-x, 92.4%); top: var(--scrub-y, 100%); width: 10px; height: 10px; margin: -5px 0 0 -5px;
321
+ box-sizing: border-box; background: var(--ink); border: var(--line-strong) solid var(--bg); transform: rotate(45deg); pointer-events: none;
322
+ transition: opacity var(--ds-dur-base) var(--ds-ease);
323
+ }
324
+ .visor--rest .visor__scrub-at { opacity: 0; }
325
+ .visor__scrub:focus-visible { outline: 0; }
326
+ .visor__scrub:focus-visible .visor__scrub-at { box-shadow: var(--press-focus); }
327
+ .visor__progress--time { gap: var(--ds-space-hair); }
328
+ .visor__when { color: var(--ink); font-variant-numeric: tabular-nums; }
329
+ .visor__time { display: flex; align-items: center; gap: var(--ds-space-hair); }
330
+ .visor__tbtn {
331
+ pointer-events: auto; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
332
+ min-width: 28px; height: 24px; padding: 0 var(--ds-space-hair); border: var(--line-strong) solid currentColor; background: var(--bg);
333
+ color: var(--ink-soft); font-family: var(--name-face); font-weight: var(--name-weight); text-transform: var(--name-case);
334
+ font-size: var(--ds-text-xs); letter-spacing: var(--name-space); line-height: 1; cursor: pointer;
335
+ }
336
+ .visor__tbtn:hover { color: var(--ink); }
337
+ .visor__tbtn:focus-visible { outline: 0; box-shadow: var(--press-focus); }
338
+ .visor__tbtn svg { width: 12px; height: 12px; fill: currentColor; }
339
+ .visor__tbtn--play .visor__tpause, .visor__tbtn--play.is-playing .visor__tplay { display: none; }
340
+ .visor__tbtn--play.is-playing .visor__tpause { display: block; }