@bongos/core 1.25.27 → 1.25.29
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 +56 -31
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +14 -11
- package/docs/copy-registry.json +42 -15
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +8 -8
- package/docs/recipes/platform-sandbox.md +54 -0
- package/modules/hall-ui/public/home-camera.js +188 -0
- package/modules/hall-ui/public/home-city.css +14 -0
- package/modules/hall-ui/public/home-city.js +60 -5
- package/modules/hall-ui/public/index.html +6 -4
- package/modules/hall-ui/public/sky-time.js +2 -1
- package/modules/hall-ui/public/visor.css +37 -0
- package/modules/hall-ui/public/visor.js +140 -10
- package/modules/hall-ui/records/home-city.md +11 -0
- package/modules/hall-ui/records/visor.md +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +15 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +8 -0
- package/src/sandbox/gate.js +22 -8
- package/src/sandbox/index.js +8 -5
- package/tests/hall_sky_time.mjs +25 -5
- package/tests/hall_visor.mjs +74 -0
- package/tests/home_camera.mjs +34 -0
- package/tests/home_city_world.mjs +119 -0
- package/tests/platform_sandbox.mjs +96 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Cloud Bongos's own sandbox — sandbox.cloudbongos.com
|
|
2
|
+
|
|
3
|
+
> Task [task 1005292](https://cloudbongos.com/builders#/task/1005292), under [ADR 0406](../adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md) §7. Stood up 2026-11-14.
|
|
4
|
+
|
|
5
|
+
The same rule every project follows, one level up: **every published core release reaches the sandbox first; the live hub moves only when a person promotes.** For Cloud Bongos, "main" is the published release (ADR 0161), so the sandbox follows releases and the hub holds a pin.
|
|
6
|
+
|
|
7
|
+
## Where it runs (owner's choices, 2026-11-14)
|
|
8
|
+
|
|
9
|
+
| | Sandbox | Live hub |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Address | `https://sandbox.cloudbongos.com` (one host: projects page and hall) | `cloudbongos.com`, `builders.`, `status.` |
|
|
12
|
+
| Box | DigitalOcean `cloudbongos-sandbox` (IP in the DigitalOcean console; reached with the control-plane key `cloudbongos_ed25519`), `s-1vcpu-2gb`, **NYC1**, $12/mo, tag `cloudbongos-sandbox` (NYC3 had no `s-1vcpu-2gb` left) | `cloudbongos`, NYC3 |
|
|
13
|
+
| Takes releases | **every one, as published** — `cloudbongos-sandbox-update.timer`, every 15 min, roster entry `follow: candidates`, `channel: minor`, `pinMode: local-commit` | **never by itself** — its row's update rule is `pinned` (set 2026-11-14) |
|
|
14
|
+
| Moves when | a release is published | a person presses the move on **Hall → Deploy** (`/deploy`) |
|
|
15
|
+
| Data | its own empty `cloudbongos` database | the real one |
|
|
16
|
+
|
|
17
|
+
The owner chose a separate server over the hub's box (which every project hall shares and which sits near its memory limit), and chose that patch releases stop going live by themselves once the sandbox exists.
|
|
18
|
+
|
|
19
|
+
## What makes it a sandbox
|
|
20
|
+
|
|
21
|
+
- `BONGOS_SANDBOX=1` in `/etc/cloudbongos/web.env`. The hub server installs `src/sandbox` (`src/platform-server.js`), so every page carries the yellow band and sends, charges and writes to known providers (Discord, GitHub's API, Stripe, mail) are refused and logged as `[bongos-sandbox] refused …` in `journalctl -u cloudbongos-sandbox`. The band appears once a release carrying task 1005292 is installed.
|
|
22
|
+
- **One named exception:** `BONGOS_SANDBOX_ALLOW=github-repo-create`, so a test project's creation can make its (free) GitHub repository. Every other GitHub change stays refused (owner's choice, 2026-11-14; `src/sandbox/gate.js` `ALLOWANCES`).
|
|
23
|
+
- **No outside credentials in its environment:** no push token, no box-source token, no Discord, no Cloudflare or DigitalOcean token. Its `web.env` copies only the hub's look and behaviour settings.
|
|
24
|
+
- Its own GitHub sign-in app ("Cloud Bongos sandbox", callback `https://sandbox.cloudbongos.com/api/gds/auth/web/callback`). Session cookies are host-only, and `cookieDomain` is `sandbox.cloudbongos.com`, so signing in on one site never touches the other. `firstAdmin` is `willHermesLines` (Will O'Brien, who started the goal): his first sign-in seats the sandbox's owner. The creation walk is task 1005314.
|
|
25
|
+
- `config/branding.json` addresses all point at `sandbox.cloudbongos.com` (committed locally in the sandbox checkout; it has no remote, so nothing reaches the hub's instance repo).
|
|
26
|
+
|
|
27
|
+
## Test projects
|
|
28
|
+
|
|
29
|
+
Projects created on the sandbox live at `<slug>.sandbox.cloudbongos.com`. The wildcard `*.sandbox.cloudbongos.com` is one grey-cloud Cloudflare record pointing at the sandbox, so the sandbox needs no Cloudflare token (`BOX_DNS_HOOK` unset, the runner skips the DNS step). Caddy gives each one a certificate on demand, through the same `on_demand_tls` ask as the hub. A test project (`modules/provisioning/test-project.js`) is archon-only and never shows on the public map. Created projects are built by `sandbox-provision-intent-runner.timer` (every 2 minutes; the live hub's twin is `selfhost-provision-intent-runner`), which carries no `box.env`, so it holds no Cloudflare or DigitalOcean token.
|
|
30
|
+
|
|
31
|
+
## Box layout
|
|
32
|
+
|
|
33
|
+
- Checkout `/home/lars/cloudbongos-instance` (a copy of the hub's instance repo with the `origin` remote removed). Service `cloudbongos-sandbox.service`, port 3002, user `lars`.
|
|
34
|
+
- Caddy `/etc/caddy/Caddyfile`: the hub's global block (Cloudflare trusted proxies, `on_demand_tls`), `import /etc/caddy/sites/*.caddy`, and the `sandbox.cloudbongos.com` site on the `*.cloudbongos.com` origin cert (proxied, orange cloud).
|
|
35
|
+
- 2 GB swap, `vm.swappiness=10`, ufw allowing 22/80/443.
|
|
36
|
+
- `/usr/local/sbin/sandbox-set-github` stores the sign-in app's id and secret (one per line on stdin) and restarts the service.
|
|
37
|
+
|
|
38
|
+
## Checks
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
curl -s https://sandbox.cloudbongos.com/version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
ssh -i ~/.ssh/cloudbongos_ed25519 root@<sandbox droplet IP> 'systemctl list-timers | grep sandbox; journalctl -u cloudbongos-sandbox-update -n 20 --no-pager'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The hub's own sweep must keep printing `cloudbongos: pinned — skipped`.
|
|
49
|
+
|
|
50
|
+
## Gotchas found standing it up
|
|
51
|
+
|
|
52
|
+
- The hub's Cloudflare token is locked to the hub's IPv4 address: call the API with `curl -4`, or it answers 9109 "Cannot use the access token from location".
|
|
53
|
+
- `@bongos/core` is public on npm, so the sandbox needs no registry token (`docs/recipes/core-release-pipeline.md` still describes an `NPM_TOKEN` file on the hub, which no longer exists there).
|
|
54
|
+
- The Claude terminal panel shows what is typed even at a hidden (`read -s`) prompt. Never have a person paste a secret into it; read it from the clipboard (`pbpaste`) without reading the keyboard at all.
|
|
@@ -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
|
-
|
|
368
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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. -->
|
|
@@ -179,7 +179,8 @@
|
|
|
179
179
|
if (!p) return null;
|
|
180
180
|
const el = d.documentElement;
|
|
181
181
|
Object.keys(p).forEach((name) => el.style.setProperty('--sky-now-' + name, String(p[name])));
|
|
182
|
-
|
|
182
|
+
// rounded to a tenth, 23:57 to 23:59 would say 24, which is no hour: wrap after rounding (task 1005299)
|
|
183
|
+
el.setAttribute('data-sky-hour', String(wrap(Math.round(h * 10) / 10)));
|
|
183
184
|
lastHour = h;
|
|
184
185
|
return p;
|
|
185
186
|
}
|
|
@@ -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; }
|