@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
|
@@ -178,13 +178,110 @@
|
|
|
178
178
|
return { label: String(a.label), key: a.key ? String(a.key).toLowerCase() : '', bind: !!a.bind, lead: !!a.lead, run: a.run };
|
|
179
179
|
}),
|
|
180
180
|
keys: (Array.isArray(s.keys) ? s.keys : []).filter(function (k) { return k && k.label && k.key; }),
|
|
181
|
+
aim: !!s.aim,
|
|
181
182
|
};
|
|
183
|
+
if (!scene.aim) aimAt(null);
|
|
182
184
|
// Arriving at a place shows the whole glass for a moment; a scene that only registers
|
|
183
185
|
// again (a toggle's row changed) does not.
|
|
184
186
|
if (scene.name !== arrived) { arrived = scene.name; wake(ARRIVE_FOR); }
|
|
185
187
|
render();
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
// ---------- the sight is the cursor (task 1005305) ----------
|
|
191
|
+
// A scene that asks for it (`aim: true`: Home's city) has the sight follow the mouse, and the
|
|
192
|
+
// arrow is not drawn over the scene (visor.css html.visor-aim): the cursor IS the crosshair.
|
|
193
|
+
// Over anything that is not the scene (the glass's own controls, a window, a dialog, a field)
|
|
194
|
+
// the arrow is back and the sight steps aside. Before the mouse first moves, on touch, and
|
|
195
|
+
// when the mouse leaves the view, the sight rests at the centre as it always did.
|
|
196
|
+
var NOT_SCENE = '#visor, dialog, [aria-modal="true"], .opt-window, .game-menu, .hall-scrim, .toast, input, textarea, select, [contenteditable]';
|
|
197
|
+
function aimAt(e) {
|
|
198
|
+
var html = document.documentElement;
|
|
199
|
+
if (!html.classList) return;
|
|
200
|
+
var on = !!(e && scene && scene.aim && shown() && html.dataset.game === 'on');
|
|
201
|
+
var ui = on && !!(e.target && e.target.closest && e.target.closest(NOT_SCENE));
|
|
202
|
+
html.classList.toggle('visor-aim', on && !ui);
|
|
203
|
+
if (!root) return;
|
|
204
|
+
if (!on) { root.removeAttribute('data-aim'); root.style.removeProperty('--sight-x'); root.style.removeProperty('--sight-y'); return; }
|
|
205
|
+
root.setAttribute('data-aim', ui ? 'ui' : 'scene');
|
|
206
|
+
root.style.setProperty('--sight-x', e.clientX + 'px');
|
|
207
|
+
root.style.setProperty('--sight-y', e.clientY + 'px');
|
|
208
|
+
}
|
|
209
|
+
function onAimMove(e) { if (scene && scene.aim && (!e.pointerType || e.pointerType === 'mouse')) aimAt(e); }
|
|
210
|
+
|
|
211
|
+
// ---------- the scene's time (task 1005305) ----------
|
|
212
|
+
// A scene with a past (Home's city) hands the visor where it is in time: the strip under the
|
|
213
|
+
// top arc becomes a slider you drag, and while you are back in time the glass shows the date,
|
|
214
|
+
// play or pause, the speed and Back to live. { value 0…1, live, label, playing, speed, seek(f),
|
|
215
|
+
// play(), faster(), toLive() }, or null to take it away. The scene calls it on every change,
|
|
216
|
+
// so it paints in place and only re-renders the glass when a timeline comes or goes.
|
|
217
|
+
var timeline = null, scrubbing = false;
|
|
218
|
+
var ARC_X0 = 0.076, ARC_W = 0.848; // where the top arc starts and how wide it runs, of its box
|
|
219
|
+
function setTimeline(tl) {
|
|
220
|
+
var had = !!timeline;
|
|
221
|
+
timeline = tl && typeof tl.seek === 'function' ? tl : null;
|
|
222
|
+
if (!timeline) scrubbing = false;
|
|
223
|
+
if (had !== !!timeline || (timeline && root && !timeParts())) render();
|
|
224
|
+
else paintTimeline();
|
|
225
|
+
if (inPast()) wake(); // back in time, the date and the player stay on the glass
|
|
226
|
+
}
|
|
227
|
+
// The glass's time parts, found once per render (render drops them), not on every frame of a glide.
|
|
228
|
+
var tparts = null;
|
|
229
|
+
function timeParts() {
|
|
230
|
+
if (tparts && tparts.scrub.isConnected) return tparts;
|
|
231
|
+
var q = function (s) { return root.querySelector(s); };
|
|
232
|
+
var scrub = q('[data-visor-scrub]');
|
|
233
|
+
tparts = scrub ? { scrub: scrub, box: q('[data-visor-progress]'), words: q('[data-visor-progress-now]'), when: q('[data-visor-when]'), time: q('[data-visor-time]'), play: q('[data-visor-play]'), speed: q('[data-visor-speed]') } : null;
|
|
234
|
+
return tparts;
|
|
235
|
+
}
|
|
236
|
+
function paintTimeline() {
|
|
237
|
+
if (!timeline || !root) return;
|
|
238
|
+
var P = timeParts();
|
|
239
|
+
if (!P) return;
|
|
240
|
+
var scrub = P.scrub;
|
|
241
|
+
var pct = Math.max(0, Math.min(1, Number(timeline.value) || 0)) * 100, back = !timeline.live || !!timeline.playing;
|
|
242
|
+
var f = pct / 100;
|
|
243
|
+
scrub.style.setProperty('--scrub-x', (ARC_X0 + f * ARC_W) * 100 + '%');
|
|
244
|
+
scrub.style.setProperty('--scrub-y', Math.pow(1 - 2 * f, 2) * 100 + '%');
|
|
245
|
+
scrub.setAttribute('aria-valuenow', String(Math.round(pct)));
|
|
246
|
+
scrub.setAttribute('aria-valuetext', String(timeline.label || ''));
|
|
247
|
+
var box = P.box, words = P.words, when = P.when, time = P.time;
|
|
248
|
+
if (box) box.classList.toggle('visor__progress--back', back);
|
|
249
|
+
if (words) words.hidden = back;
|
|
250
|
+
if (when) { when.hidden = !back; when.textContent = String(timeline.label || ''); }
|
|
251
|
+
if (time) time.hidden = !back;
|
|
252
|
+
var play = P.play, speed = P.speed;
|
|
253
|
+
if (play) { play.setAttribute('aria-label', timeline.playing ? 'Pause' : 'Play'); play.classList.toggle('is-playing', !!timeline.playing); }
|
|
254
|
+
if (speed) { speed.textContent = String(timeline.speed || ''); speed.setAttribute('aria-label', 'Speed, ' + String(timeline.speed || '')); }
|
|
255
|
+
}
|
|
256
|
+
function seekTo(clientX) {
|
|
257
|
+
var P = timeline && root ? timeParts() : null;
|
|
258
|
+
if (!P) return;
|
|
259
|
+
var r = P.scrub.getBoundingClientRect();
|
|
260
|
+
if (r.width > 0) timeline.seek(Math.max(0, Math.min(1, ((clientX - r.left) / r.width - ARC_X0) / ARC_W)));
|
|
261
|
+
}
|
|
262
|
+
function onScrubDown(e) {
|
|
263
|
+
if (!timeline || e.button !== 0 || !e.target.closest || !e.target.closest('[data-visor-scrub]')) return;
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
scrubbing = true;
|
|
266
|
+
wake();
|
|
267
|
+
seekTo(e.clientX);
|
|
268
|
+
}
|
|
269
|
+
function onScrubMove(e) { if (scrubbing) seekTo(e.clientX); }
|
|
270
|
+
function onScrubUp() { scrubbing = false; }
|
|
271
|
+
// On the strip, the arrows step through time (and are spent there, so the city does not move).
|
|
272
|
+
function onScrubKey(e) {
|
|
273
|
+
if (!timeline || !e.target.closest || !e.target.closest('[data-visor-scrub]')) return;
|
|
274
|
+
var v = Number(timeline.value) || 0, to = null;
|
|
275
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') to = v - 0.02;
|
|
276
|
+
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') to = v + 0.02;
|
|
277
|
+
else if (e.key === 'Home') to = 0;
|
|
278
|
+
else if (e.key === 'End') to = 1;
|
|
279
|
+
if (to == null) return;
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
e.stopPropagation();
|
|
282
|
+
timeline.seek(Math.max(0, Math.min(1, to)));
|
|
283
|
+
}
|
|
284
|
+
|
|
188
285
|
// ---------- resting and waking (task 1005266, owner 2026-11-13) ----------
|
|
189
286
|
// The glass took a third of a laptop's height, so while you look, pan and scroll it RESTS:
|
|
190
287
|
// only the arcs, the sight and your picture stay (visor.css .visor--rest). It WAKES when you
|
|
@@ -194,7 +291,7 @@
|
|
|
194
291
|
// pointer is still there and while the keyboard is on it; it rests the moment you press or
|
|
195
292
|
// wheel on the scene, and after a few idle seconds. Only on a desk with a pointer: a touch
|
|
196
293
|
// screen has nothing to reach with, so a phone or tablet keeps the whole glass.
|
|
197
|
-
var REST_AFTER = 3000, ARRIVE_FOR = 4000, REACH = 160;
|
|
294
|
+
var REST_AFTER = 3000, ARRIVE_FOR = 4000, REACH = 160, REACH_TOP = 110;
|
|
198
295
|
var awake = true, reaching = false, restTimer = null, arrived = null, until = 0;
|
|
199
296
|
// The two questions are asked of the browser once; each list keeps its own answer current,
|
|
200
297
|
// so the pointer's every move reads two booleans and builds nothing.
|
|
@@ -207,7 +304,8 @@
|
|
|
207
304
|
function keyboardOn() { return !!(root && document.activeElement && root.contains(document.activeElement)); }
|
|
208
305
|
// A window the glass opened (the options, your profile, the game menu; task 1005253) holds it too.
|
|
209
306
|
function windowUp() { var c = document.documentElement.classList; return !!(c && (c.contains('opt-window-open') || c.contains('game-menu-open'))); }
|
|
210
|
-
function
|
|
307
|
+
function inPast() { return scrubbing || !!(timeline && (!timeline.live || timeline.playing)); } // task 1005305
|
|
308
|
+
function held() { return !!sheet || !!focus || reaching || keyboardOn() || windowUp() || inPast(); }
|
|
211
309
|
function resting() { return !awake && canRest(); }
|
|
212
310
|
function paintRest() { if (root && root.classList) root.classList.toggle('visor--rest', resting()); }
|
|
213
311
|
// Waking only moves the hour it may rest at; one clock is ever running, so a pointer
|
|
@@ -254,7 +352,7 @@
|
|
|
254
352
|
function onRoot(e) { return !!(root && e.target && e.target.nodeType === 1 && root.contains(e.target)); }
|
|
255
353
|
function onPointerMove(e) {
|
|
256
354
|
if (!shown() || e.buttons || e.pointerType === 'touch') return; // a drag is looking, not reaching
|
|
257
|
-
reaching = onRoot(e) || e.clientY >= window.innerHeight - REACH;
|
|
355
|
+
reaching = onRoot(e) || e.clientY >= window.innerHeight - REACH || (!!timeline && e.clientY <= REACH_TOP);
|
|
258
356
|
if (reaching) wake();
|
|
259
357
|
}
|
|
260
358
|
function onPointerDown(e) { if (!shown()) return; if (onRoot(e)) wake(); else rest(); }
|
|
@@ -417,6 +515,7 @@
|
|
|
417
515
|
var sprite = C && typeof C.portraitSrc === 'function' ? C.portraitSrc(id) : (C && typeof C.spriteSrc === 'function' ? C.spriteSrc(id) : '');
|
|
418
516
|
var n = credits();
|
|
419
517
|
return frameSvg() +
|
|
518
|
+
scrubHtml() + // before the top line, so the line's buttons stand over the drag band
|
|
420
519
|
'<div class="visor__top">' + (scene.place ? '<p class="visor__place">' + esc(scene.place) + '</p>' : '') + progressHtml() + '</div>' +
|
|
421
520
|
focusHtml() +
|
|
422
521
|
'<div class="visor__foot">' +
|
|
@@ -452,13 +551,30 @@
|
|
|
452
551
|
'<span class="visor__card-open">Open your profile</span></span>';
|
|
453
552
|
}
|
|
454
553
|
// The version's reading in words, beside the place; the four segments are the top arc
|
|
455
|
-
// (arcSegs, task 1005266), so the bars are never the only reading.
|
|
554
|
+
// (arcSegs, task 1005266), so the bars are never the only reading. With a scene's time
|
|
555
|
+
// (task 1005305) the date and the player stand beside it while you are back in time.
|
|
456
556
|
function progressHtml() {
|
|
457
|
-
if (!progress) return '';
|
|
458
|
-
var said = progress.id + (progress.name ? ', ' + progress.name : '') + ': ' + progress.pct + '% built';
|
|
459
|
-
|
|
460
|
-
'<
|
|
461
|
-
|
|
557
|
+
if (!progress && !timeline) return '';
|
|
558
|
+
var said = progress ? progress.id + (progress.name ? ', ' + progress.name : '') + ': ' + progress.pct + '% built' : '';
|
|
559
|
+
var words = progress ? '<p class="visor__progress-words" data-visor-progress-now><span class="sr-only">' + esc(said) + '</span>' +
|
|
560
|
+
'<span aria-hidden="true">' + esc(progress.id) + ' · ' + progress.pct + '%</span></p>' : '';
|
|
561
|
+
if (!timeline) return '<div class="visor__progress" data-visor-progress title="' + esc(said) + '">' + words + '</div>';
|
|
562
|
+
return '<div class="visor__progress visor__progress--time" data-visor-progress>' + words +
|
|
563
|
+
'<p class="visor__progress-words visor__when" data-visor-when hidden></p>' +
|
|
564
|
+
'<div class="visor__time" data-visor-time role="group" aria-label="Time" hidden>' +
|
|
565
|
+
'<button class="visor__tbtn visor__tbtn--play" type="button" data-visor-play aria-label="Play">' +
|
|
566
|
+
'<svg class="visor__tplay" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 3v10l8-5z"/></svg>' +
|
|
567
|
+
'<svg class="visor__tpause" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 3h2.4v10H4.5zM9.1 3h2.4v10H9.1z"/></svg></button>' +
|
|
568
|
+
'<button class="visor__tbtn" type="button" data-visor-speed></button>' +
|
|
569
|
+
'<button class="visor__tbtn" type="button" data-visor-live>Back to live</button>' +
|
|
570
|
+
'</div></div>';
|
|
571
|
+
}
|
|
572
|
+
// The top arc is the slider (task 1005305): a band over it takes the drag, and a mark rides
|
|
573
|
+
// the curve where you are in time. Same box as the arc's own svg, so the two always line up.
|
|
574
|
+
function scrubHtml() {
|
|
575
|
+
if (!timeline) return '';
|
|
576
|
+
return '<div class="visor__frame visor__frame--top visor__scrub" data-visor-scrub role="slider" tabindex="0" aria-orientation="horizontal" aria-valuemin="0" aria-valuemax="100" ' +
|
|
577
|
+
'aria-label="Time: drag along the top arc to go back through the weeks" title="Drag to go back in time"><i class="visor__scrub-at" aria-hidden="true"></i></div>';
|
|
462
578
|
}
|
|
463
579
|
// Options lives only in here (the owner, 2026-11-12), on a key of its own: Escape is Close.
|
|
464
580
|
function sheetHtml(id) {
|
|
@@ -482,7 +598,7 @@
|
|
|
482
598
|
if (!document.body) return;
|
|
483
599
|
var id = scene && !embedded() ? character() : null;
|
|
484
600
|
var html = document.documentElement;
|
|
485
|
-
if (!id) { if (html.classList) html.classList.remove('visor-on'); if (root) { closeSheet(false); root.hidden = true; } return; }
|
|
601
|
+
if (!id) { if (html.classList) html.classList.remove('visor-on'); if (root) { closeSheet(false); root.hidden = true; } aimAt(null); return; }
|
|
486
602
|
// A scene's own layers read this to keep clear of the glass (thinking.css), in game mode only.
|
|
487
603
|
if (html.classList) html.classList.toggle('visor-on', html.dataset.game === 'on');
|
|
488
604
|
if (!root) {
|
|
@@ -492,6 +608,8 @@
|
|
|
492
608
|
root.setAttribute('role', 'region');
|
|
493
609
|
root.setAttribute('aria-label', 'Visor');
|
|
494
610
|
root.addEventListener('click', onClick);
|
|
611
|
+
root.addEventListener('pointerdown', onScrubDown);
|
|
612
|
+
root.addEventListener('keydown', onScrubKey);
|
|
495
613
|
root.addEventListener('focusin', function (e) { wake(); nameHint(e); });
|
|
496
614
|
root.addEventListener('focusout', nameHint);
|
|
497
615
|
root.addEventListener('mouseover', nameHint);
|
|
@@ -504,6 +622,8 @@
|
|
|
504
622
|
root.hidden = false;
|
|
505
623
|
root.setAttribute('data-character', id);
|
|
506
624
|
root.innerHTML = glassHtml(id);
|
|
625
|
+
tparts = null;
|
|
626
|
+
paintTimeline();
|
|
507
627
|
if (open) openSheet();
|
|
508
628
|
}
|
|
509
629
|
function embedded() {
|
|
@@ -582,6 +702,9 @@
|
|
|
582
702
|
if (t.closest('[data-visor-unfocus]')) { dropFocus(); return; }
|
|
583
703
|
if (t.closest('[data-visor-lead]')) { if (focus && focus.lead && !focus.lead.busy) { closeSheet(false); focus.lead.run(); } return; }
|
|
584
704
|
if (t.closest('[data-visor-sheet]')) { openSheet(); return; }
|
|
705
|
+
if (timeline && t.closest('[data-visor-play]')) { timeline.play(); return; }
|
|
706
|
+
if (timeline && t.closest('[data-visor-speed]')) { timeline.faster(); return; }
|
|
707
|
+
if (timeline && t.closest('[data-visor-live]')) { timeline.toLive(); return; }
|
|
585
708
|
if (t.closest('[data-game-menu]')) { closeSheet(false); return; } // shell-game.js opens the menu
|
|
586
709
|
// You: your profile in the game's window, the place still drawn behind it. With no
|
|
587
710
|
// window (shell-game.js absent), the link opens the profile page.
|
|
@@ -644,6 +767,11 @@
|
|
|
644
767
|
|
|
645
768
|
function start() {
|
|
646
769
|
window.addEventListener('keydown', onKey);
|
|
770
|
+
window.addEventListener('pointermove', onAimMove);
|
|
771
|
+
window.addEventListener('pointermove', onScrubMove);
|
|
772
|
+
window.addEventListener('pointerup', onScrubUp);
|
|
773
|
+
window.addEventListener('pointercancel', onScrubUp);
|
|
774
|
+
document.addEventListener('pointerleave', function (e) { if (e.target === document.documentElement) aimAt(null); }, true);
|
|
647
775
|
// Resting and waking: all three listen in capture and are passive, so they see a press or
|
|
648
776
|
// a wheel a scene stops, and never slow or change one (ADR 0399 §2.1: the glass is not layout).
|
|
649
777
|
window.addEventListener('pointermove', onPointerMove, { capture: true, passive: true });
|
|
@@ -666,6 +794,8 @@
|
|
|
666
794
|
// a scene's pick: OTBVisor.focus({ title, status, lines, lead: { label, run }, onClose }) or focus(null)
|
|
667
795
|
focus: setFocus,
|
|
668
796
|
focused: function () { return focus ? focus.key : null; },
|
|
797
|
+
// a scene's time: OTBVisor.timeline({ value, live, label, playing, speed, seek, play, faster, toLive }) or timeline(null)
|
|
798
|
+
timeline: setTimeline,
|
|
669
799
|
render: render,
|
|
670
800
|
open: openSheet,
|
|
671
801
|
close: function () { closeSheet(true); },
|
|
@@ -221,6 +221,17 @@ The same city, repainted (owner direction, 2026-11-11; ADR 0402's amendment and
|
|
|
221
221
|
- **The hour** is `sky-time.js` (loaded before the painter): the viewer's clock, or `/?hour=13` to pin it.
|
|
222
222
|
- Pinned by `tests/home_city.mjs` (tokens only, the cloud's place in the draw order) and `tests/hall_sky_time.mjs`.
|
|
223
223
|
|
|
224
|
+
## Game mode: the city is the whole view (task 1005305)
|
|
225
|
+
|
|
226
|
+
The owner, 2026-11-14: in game mode the builder's Home is only the city, moved like a base-building game, with no scroll.
|
|
227
|
+
|
|
228
|
+
- **Where it applies.** Game mode, and Home showing the city (`home-city.js` `world()`, which stamps `html.hc-world`). The artist's gallery and classic mode are unchanged.
|
|
229
|
+
- **What left the scene.** The greeting, the banner, band and main zones (the cards under the city), the foot, the ladder and the time slider (`home-city.css`). The page cannot scroll. The cards hold actions (join requests, invitations, triage) that game mode now reaches nowhere; classic mode still shows them.
|
|
230
|
+
- **The camera** is `home-camera.js`, the scene's own (ADR 0399: the visor owns none). The city's `<svg>` is sized to 1.35 views at least on both axes and moved with a transform: the arrow keys, a drag (a drag's closing click is not a tap on a tower), and the cursor resting within 56px of an edge of the view. It never shows past an edge. It waits while a window, a dialog or the visor's sheet is up, and the cursor pushes only from the scene, never from over the glass's buttons.
|
|
231
|
+
- **Time is the visor's** (ADR 0403): `OTBVisor.timeline` gets the week, and the scene's rows are Play (P), Speed (S, 1× 2× 4×), Back to live (L) and Back to the middle of the city (H). See `visor.md`.
|
|
232
|
+
- **Not done:** zoom; the other game places (the gallery, the Governor City, the windows) still scroll where they did; a phone has no time strip on the glass (the rows in the Actions sheet work).
|
|
233
|
+
- Tests: `tests/home_camera.mjs` (the maths), `tests/home_city_world.mjs` (a real browser, game and classic).
|
|
234
|
+
|
|
224
235
|
## Private areas
|
|
225
236
|
|
|
226
237
|
A private goal the viewer is neither a member of nor an Archon over still stands as a tower (its counts are structure), but the server sends no title for it or for the ideas tied to it; the drawing labels them "A private area" and "A private idea" (ADR 0112, as the goal map and the sky apply it). Pinned in `tests/home_city.mjs`.
|
|
@@ -20,6 +20,12 @@ The owner: the design and the theme are right, but the glass took too much of th
|
|
|
20
20
|
|
|
21
21
|
Tests: `tests/hall_visor.mjs` (rest, wake, the drag that is not a reach, the sheet holding it awake, the unseen control, the key's name, the arc's pieces, a phone).
|
|
22
22
|
|
|
23
|
+
## The sight is the cursor, and a scene's time is on the glass (task 1005305, owner 2026-11-14)
|
|
24
|
+
|
|
25
|
+
- **The sight follows the mouse** in a scene registered with `aim: true` (Home's city is the first), and the arrow is not drawn over the scene (`html.visor-aim`), so the cursor is the crosshair. Over the glass's own controls, a window, a dialog or a field the arrow is back and the sight steps aside. Before the mouse moves, on touch, and when the mouse leaves the view, the sight rests at the centre. Other scenes are unchanged until they ask.
|
|
26
|
+
- **`OTBVisor.timeline({ value, live, label, playing, speed, seek, play, faster, toLive })`** (or `null`): the top arc, which is the version's progress (task 1005266), is also the slider. A band over the arc (`.visor__scrub`, `role="slider"`, the arc's own box) takes the drag, and a mark rides the curve where you are in time; focused, the arrows, Home and End step it. Back in time the version words give way to the date, with play or pause, the speed and Live beside it on the top line, and the glass stays awake; with a timeline, the pointer near the top of the view wakes the glass too. The scene calls it on every change, so it paints in place; the glass re-renders only when a timeline comes or goes.
|
|
27
|
+
- Tests: `tests/hall_visor.mjs` (a real mouse: the sight follows it and the arrow is gone over the scene and back over a control; a drag along the arc, the mark on the curve, the date, play, speed, Back to live, and the arrow keys on the slider) and `tests/home_city_world.mjs` (Home's side).
|
|
28
|
+
|
|
23
29
|
## A window the glass opens opens in the glass (task 1005253, owner 2026-11-13)
|
|
24
30
|
|
|
25
31
|
- **The bug.** The options window and your profile window (`shell-game.js` `openOverlay`, `.opt-window`), and the game menu (`openMenu`, `.game-menu`, task 1005250), are reached from the visor, yet they opened as a box over the whole screen and covered the visor itself: the arcs, the place, your credits, the accent button and the hints.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.29",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.25.
|
|
9
|
+
"version": "1.25.29",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.29",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11934,5 +11934,20 @@
|
|
|
11934
11934
|
"id": "1005197",
|
|
11935
11935
|
"text": "A hall check that always failed on Windows computers now works there, so it can again spot a page loading its scripts in the wrong order."
|
|
11936
11936
|
}
|
|
11937
|
+
],
|
|
11938
|
+
"1.25.28": [
|
|
11939
|
+
{
|
|
11940
|
+
"id": "1005305",
|
|
11941
|
+
"text": "In game mode the builder's Home is now just the city: no scrolling, you move around with the arrow keys, by dragging, or by resting the cursor at a screen edge, and you rewind and replay the city's history from the visor's top"
|
|
11942
|
+
}
|
|
11943
|
+
],
|
|
11944
|
+
"1.25.29": [
|
|
11945
|
+
{
|
|
11946
|
+
"text": "Cloud Bongos now has a test copy at sandbox.cloudbongos.com that gets every new release first, and the main site only updates when a person presses promote."
|
|
11947
|
+
},
|
|
11948
|
+
{
|
|
11949
|
+
"id": "1005299",
|
|
11950
|
+
"text": "The city's sky clock no longer says \"hour 24\" in the last three minutes before midnight, and its check no longer fails at random late at night."
|
|
11951
|
+
}
|
|
11937
11952
|
]
|
|
11938
11953
|
}
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.25.
|
|
84
|
+
const CORE_VERSION = '1.25.29'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/src/platform-server.js
CHANGED
|
@@ -225,6 +225,14 @@ function buildPlatformApp() {
|
|
|
225
225
|
// same reqId. Mounted before any surface so nothing logs uncorrelated.
|
|
226
226
|
app.use(requestIdMiddleware);
|
|
227
227
|
|
|
228
|
+
// THE PLATFORM'S OWN SANDBOX (task 1005292, ADR 0406 §7). Cloud Bongos runs a second
|
|
229
|
+
// copy of itself that takes every release first; that copy runs with BONGOS_SANDBOX=1
|
|
230
|
+
// and nothing else marks it. install() is the same one a project's app uses: every
|
|
231
|
+
// page carries the "Sandbox" band, and sends/charges/writes to known providers
|
|
232
|
+
// (Discord, GitHub's API, Stripe, mail) are refused and logged. On the live hub, where
|
|
233
|
+
// the variable is absent, it returns a pass-through and changes nothing.
|
|
234
|
+
app.use(require('./sandbox').install().middleware);
|
|
235
|
+
|
|
228
236
|
// THE request.divert SEAM (task 1004300, ADR 0349). A module may register a provider that
|
|
229
237
|
// takes a request BEFORE any surface answers and either handles it or passes it on —
|
|
230
238
|
// today the npm-release module's version preview, which hands a browser holding its
|
package/src/sandbox/gate.js
CHANGED
|
@@ -56,18 +56,32 @@ function credentialOf(headers) {
|
|
|
56
56
|
return '';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// NAMED EXCEPTIONS a sandbox may switch on with BONGOS_SANDBOX_ALLOW (comma-separated
|
|
60
|
+
// names), each one write and nothing near it (task 1005292). Cloud Bongos's own sandbox
|
|
61
|
+
// switches on `github-repo-create`, so a test project's creation walk can make its (free)
|
|
62
|
+
// repository, owner's choice 2026-11-14; every other GitHub change stays refused. An
|
|
63
|
+
// unknown name allows nothing.
|
|
64
|
+
const ALLOWANCES = {
|
|
65
|
+
'github-repo-create': { host: /^api\.github\.com$/i, method: 'POST', path: /^\/(user|orgs\/[A-Za-z0-9-]+)\/repos$/ },
|
|
66
|
+
};
|
|
67
|
+
function parseAllowances(raw) {
|
|
68
|
+
return String(raw || '').split(',').map((x) => x.trim()).filter((x) => Object.prototype.hasOwnProperty.call(ALLOWANCES, x));
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
/**
|
|
60
|
-
* The gate's whole policy. `{ method, host, path, headers }` → `{ allow: true }`
|
|
61
|
-
* `{ allow: false, provider, kind, reason }`. A host in no provider's row passes: the
|
|
72
|
+
* The gate's whole policy. `{ method, host, path, headers, allowances }` → `{ allow: true }`
|
|
73
|
+
* or `{ allow: false, provider, kind, reason }`. A host in no provider's row passes: the
|
|
62
74
|
* gate covers the providers it knows, and says so.
|
|
63
75
|
*/
|
|
64
|
-
function decide({ method, host, path, headers }) {
|
|
76
|
+
function decide({ method, host, path, headers, allowances = [] }) {
|
|
65
77
|
const m = String(method || 'GET').toUpperCase();
|
|
66
78
|
const h = String(host || '').toLowerCase().replace(/:\d+$/, '');
|
|
67
79
|
const p = PROVIDERS.find((x) => x.host.test(h));
|
|
68
80
|
if (!p) return { allow: true };
|
|
69
81
|
if (READ_METHODS.has(m)) return { allow: true, provider: p.name };
|
|
70
82
|
const pathOnly = String(path || '/').split('?')[0];
|
|
83
|
+
const named = allowances.find((n) => { const a = ALLOWANCES[n]; return a && a.method === m && a.host.test(h) && a.path.test(pathOnly); });
|
|
84
|
+
if (named) return { allow: true, provider: p.name, allowance: named };
|
|
71
85
|
if (m === 'POST' && (p.postReads || []).some((re) => re.test(pathOnly))) return { allow: true, provider: p.name };
|
|
72
86
|
if (p.testKey && p.testKey.test(credentialOf(headers))) return { allow: true, provider: p.name, testMode: true };
|
|
73
87
|
return {
|
|
@@ -97,13 +111,13 @@ class SandboxRefused extends Error {
|
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
/** Wrap a fetch: a refused call answers 403 with a body that says why, and never leaves. */
|
|
100
|
-
function wrapFetch(fetchImpl, log) {
|
|
114
|
+
function wrapFetch(fetchImpl, log, allowances = []) {
|
|
101
115
|
if (!fetchImpl || fetchImpl.__bongosSandbox) return fetchImpl;
|
|
102
116
|
const wrapped = async function sandboxFetch(input, init = {}) {
|
|
103
117
|
const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url);
|
|
104
118
|
const method = (init.method || (input && input.method) || 'GET');
|
|
105
119
|
const headers = init.headers || (input && input.headers) || {};
|
|
106
|
-
const d = decide({ method, host: url.host, path: url.pathname + url.search, headers: typeof headers.get === 'function' ? headers : lower(headers) });
|
|
120
|
+
const d = decide({ method, host: url.host, path: url.pathname + url.search, headers: typeof headers.get === 'function' ? headers : lower(headers), allowances });
|
|
107
121
|
if (d.allow) return fetchImpl(input, init);
|
|
108
122
|
log.add({ ...d, method: String(method).toUpperCase(), host: url.host, path: url.pathname });
|
|
109
123
|
return new Response(JSON.stringify({ error: { type: 'bongos_sandbox_refused', provider: d.provider, kind: d.kind, message: d.reason } }),
|
|
@@ -140,14 +154,14 @@ function refusedRequest(d, cb) {
|
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
/** Patch a node `http` / `https` module's request + get through the gate. */
|
|
143
|
-
function wrapHttp(mod, log) {
|
|
157
|
+
function wrapHttp(mod, log, allowances = []) {
|
|
144
158
|
if (!mod || mod.request.__bongosSandbox) return;
|
|
145
159
|
const realRequest = mod.request;
|
|
146
160
|
const realGet = mod.get;
|
|
147
161
|
const guarded = (real, isGet) => {
|
|
148
162
|
const fn = function sandboxRequest(...args) {
|
|
149
163
|
const t = targetOf(args);
|
|
150
|
-
const d = decide(t);
|
|
164
|
+
const d = decide({ ...t, allowances });
|
|
151
165
|
if (d.allow) return real.apply(mod, args);
|
|
152
166
|
log.add({ ...d, method: String(t.method).toUpperCase(), host: t.host, path: t.path });
|
|
153
167
|
const req = refusedRequest(d, args[args.length - 1]);
|
|
@@ -161,4 +175,4 @@ function wrapHttp(mod, log) {
|
|
|
161
175
|
mod.get = guarded(realGet, true);
|
|
162
176
|
}
|
|
163
177
|
|
|
164
|
-
module.exports = { decide, makeLog, wrapFetch, wrapHttp };
|
|
178
|
+
module.exports = { decide, makeLog, wrapFetch, wrapHttp, parseAllowances };
|
package/src/sandbox/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Fake data is the app's own to seed: `isSandbox()` lets it seed only here.
|
|
21
21
|
|
|
22
|
-
const { makeLog, wrapFetch, wrapHttp } = require('./gate');
|
|
22
|
+
const { makeLog, wrapFetch, wrapHttp, parseAllowances } = require('./gate');
|
|
23
23
|
|
|
24
24
|
const PAGE = '/__bongos/sandbox';
|
|
25
25
|
let state = null;
|
|
@@ -84,7 +84,9 @@ function pageHtml(log, env = process.env, { owner = false } = {}) {
|
|
|
84
84
|
: `<p>The project's owner sees what this copy refused from their project page.</p>`)
|
|
85
85
|
+ `<h2>What this cannot catch</h2>`
|
|
86
86
|
+ `<p>Reads to a known provider go out; sends, charges and writes to it are refused. It only sees calls this app makes through Node's own web requests, and only to the providers it knows. Code that goes around them, or a provider it does not know, is not stopped. A read-only key is the real protection; this is the second wall.</p>`
|
|
87
|
-
|
|
87
|
+
// Only true on Render's free plan (Render sets RENDER=true); the platform's own sandbox
|
|
88
|
+
// runs on a plain server, where saying it would be false (task 1005292).
|
|
89
|
+
+ (String(env.RENDER || '') === 'true' ? `<p>The free plan also blocks outgoing email ports, so this copy cannot send email directly.</p>` : '')
|
|
88
90
|
+ `</body></html>`;
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -131,9 +133,10 @@ function install({ env = process.env, fetchHost = globalThis, http = require('no
|
|
|
131
133
|
if (!isSandbox(env)) return { active: false, middleware: passThrough, refusals: [], bandHtml: () => '', isSandbox: false };
|
|
132
134
|
if (!state) {
|
|
133
135
|
const log = makeLog(write ? { write } : {});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
wrapHttp(
|
|
136
|
+
const allowances = parseAllowances(env.BONGOS_SANDBOX_ALLOW);
|
|
137
|
+
if (typeof fetchHost.fetch === 'function') fetchHost.fetch = wrapFetch(fetchHost.fetch, log, allowances);
|
|
138
|
+
wrapHttp(http, log, allowances);
|
|
139
|
+
wrapHttp(https, log, allowances);
|
|
137
140
|
state = { log, middleware: makeMiddleware(log, env) };
|
|
138
141
|
}
|
|
139
142
|
return { active: true, middleware: state.middleware, refusals: state.log.items, bandHtml, isSandbox: true };
|
package/tests/hall_sky_time.mjs
CHANGED
|
@@ -192,7 +192,13 @@ test('a pinned hour wins: the root attribute, then the query, then the hash; a b
|
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
// The page half, run against a stand-in document whose computed styles are style.css's own tokens.
|
|
195
|
-
|
|
195
|
+
// `now` fixes the viewer's clock (a local "HH:MM"), so no case depends on when the suite runs.
|
|
196
|
+
function clockAt(now) {
|
|
197
|
+
if (now == null) return Date;
|
|
198
|
+
const [hh, mm] = now.split(':').map(Number);
|
|
199
|
+
return class extends Date { constructor(...a) { if (a.length) super(...a); else super(2026, 0, 1, hh, mm, 0); } };
|
|
200
|
+
}
|
|
201
|
+
function page({ search = '', hash = '', attr = null, now = null } = {}) {
|
|
196
202
|
const set = {}; const attrs = attr == null ? {} : { 'data-sky-hour-at': attr };
|
|
197
203
|
const timers = []; let probes = 0; let computed = 0;
|
|
198
204
|
const html = {
|
|
@@ -216,7 +222,7 @@ function page({ search = '', hash = '', attr = null } = {}) {
|
|
|
216
222
|
return { color: `rgb(${v[0]}, ${v[1]}, ${v[2]})` };
|
|
217
223
|
},
|
|
218
224
|
setInterval(fn, ms) { timers.push({ fn, ms }); return timers.length; },
|
|
219
|
-
Date, Number, String, Math, Object, Array, parseInt, decodeURIComponent, isFinite,
|
|
225
|
+
Date: clockAt(now), Number, String, Math, Object, Array, parseInt, decodeURIComponent, isFinite,
|
|
220
226
|
};
|
|
221
227
|
vm.runInNewContext(read('sky-time.js'), ctx, { filename: 'sky-time.js' });
|
|
222
228
|
return { api: window.OTBSkyTime, set, attrs, timers, document, stats: () => ({ probes, computed }) };
|
|
@@ -242,12 +248,26 @@ test('on load the page wears the hour\'s palette as --sky-now-* on the root, fro
|
|
|
242
248
|
test('the override reaches the page: the hash and the root attribute pin the hour too', () => {
|
|
243
249
|
assert.equal(page({ hash: '#hour=18' }).set['--sky-now-cloud'], KEYS.dusk.cloud.toLowerCase());
|
|
244
250
|
assert.equal(page({ attr: '6', search: '?hour=13' }).set['--sky-now-cloud'], KEYS.dawn.cloud.toLowerCase());
|
|
245
|
-
// with nothing pinned the viewer's clock is the source
|
|
246
|
-
|
|
247
|
-
|
|
251
|
+
// with nothing pinned the viewer's clock is the source (fixed here, so the case never
|
|
252
|
+
// depends on when the suite runs)
|
|
253
|
+
const live = page({ now: '13:30' });
|
|
254
|
+
assert.equal(live.attrs['data-sky-hour'], '13.5');
|
|
248
255
|
assert.equal(live.api.hourNow({ document: live.document, location: { search: '?hour=4', hash: '' } }), 4);
|
|
249
256
|
});
|
|
250
257
|
|
|
258
|
+
// Task 1005299: 23:57 to 23:59 rounded to one decimal is 24.0, which is not an hour; the
|
|
259
|
+
// last minutes of the day say the next day's first hour, and wear its palette.
|
|
260
|
+
test('the hour the page says never reaches 24: the last minutes of the day wrap to 0', () => {
|
|
261
|
+
for (const now of ['23:57', '23:58', '23:59']) {
|
|
262
|
+
const p = page({ now });
|
|
263
|
+
assert.equal(p.attrs['data-sky-hour'], '0', `${now} says hour 0, not 24`);
|
|
264
|
+
assert.equal(p.set['--sky-now-mid'], KEYS.night.mid.toLowerCase(), `${now} wears night`);
|
|
265
|
+
}
|
|
266
|
+
assert.equal(page({ now: '23:56' }).attrs['data-sky-hour'], '23.9');
|
|
267
|
+
assert.equal(page({ now: '00:00' }).attrs['data-sky-hour'], '0');
|
|
268
|
+
assert.equal(page({ search: '?hour=23.98' }).attrs['data-sky-hour'], '0', 'a pinned hour wraps the same way');
|
|
269
|
+
});
|
|
270
|
+
|
|
251
271
|
test('no motion: the palette is recomputed at most once a minute and nothing animates between two', () => {
|
|
252
272
|
const p = page({ search: '?hour=6' });
|
|
253
273
|
assert.equal(p.timers.length, 1, 'one timer');
|
package/tests/hall_visor.mjs
CHANGED
|
@@ -653,3 +653,77 @@ test('a phone keeps the whole glass: there is no pointer to reach with (task 100
|
|
|
653
653
|
assert.deepEqual(await page.evaluate(() => [OTBVisor.resting(), document.getElementById('visor').classList.contains('visor--rest'), getComputedStyle(document.querySelector('.visor__main')).opacity]), [false, false, '1']);
|
|
654
654
|
await ctx.close();
|
|
655
655
|
});
|
|
656
|
+
|
|
657
|
+
// ---------- the sight is the cursor, and a scene's time rides the top arc (task 1005305) ----------
|
|
658
|
+
const withTime = (page) => page.evaluate(() => {
|
|
659
|
+
window.tl = { value: 1, live: true, label: 'Live', playing: false, speed: '1×', plays: 0, lives: 0 };
|
|
660
|
+
const hand = () => window.OTBVisor.timeline({
|
|
661
|
+
value: window.tl.value, live: window.tl.live, label: window.tl.label, playing: window.tl.playing, speed: window.tl.speed,
|
|
662
|
+
seek(f) { window.tl.value = f; window.tl.live = f > 0.999; window.tl.label = window.tl.live ? 'Live' : 'week ' + Math.round(f * 10); hand(); },
|
|
663
|
+
play() { window.tl.plays++; window.tl.playing = !window.tl.playing; hand(); },
|
|
664
|
+
faster() { window.tl.speed = '2×'; hand(); },
|
|
665
|
+
toLive() { window.tl.lives++; window.tl.playing = false; window.tl.value = 1; window.tl.live = true; window.tl.label = 'Live'; hand(); },
|
|
666
|
+
});
|
|
667
|
+
window.OTBVisor.scene(Object.assign({}, window.__visorScene, { aim: true }));
|
|
668
|
+
hand();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test('a scene that asks has the sight follow the mouse with no arrow over it; over a control of the glass the arrow is back', { skip }, async () => {
|
|
672
|
+
const { ctx, page, errors } = await open();
|
|
673
|
+
const before = await page.evaluate(() => { const r = document.querySelector('.visor__sight').getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]; });
|
|
674
|
+
await page.mouse.move(300, 420);
|
|
675
|
+
const plain = await page.evaluate(() => ({ aim: document.documentElement.classList.contains('visor-aim'), cursor: getComputedStyle(document.getElementById('world')).cursor }));
|
|
676
|
+
await withTime(page);
|
|
677
|
+
await page.mouse.move(310, 430);
|
|
678
|
+
const scene = await page.evaluate(() => { const r = document.querySelector('.visor__sight').getBoundingClientRect(); return { at: [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)], cursor: getComputedStyle(document.getElementById('world')).cursor, aim: document.getElementById('visor').dataset.aim }; });
|
|
679
|
+
const main = await page.evaluate(() => { const r = document.querySelector('.visor__main').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; });
|
|
680
|
+
await page.mouse.move(main[0], main[1]);
|
|
681
|
+
const ui = await page.evaluate(() => ({ aim: document.getElementById('visor').dataset.aim, on: document.documentElement.classList.contains('visor-aim'), sight: getComputedStyle(document.querySelector('.visor__sight')).visibility }));
|
|
682
|
+
await ctx.close();
|
|
683
|
+
assert.deepEqual(before, [640, 400], 'before the mouse moves the sight rests at the centre');
|
|
684
|
+
assert.deepEqual(plain, { aim: false, cursor: 'auto' }, 'a scene that does not ask keeps the arrow and the centred sight');
|
|
685
|
+
assert.deepEqual(scene, { at: [310, 430], cursor: 'none', aim: 'scene' }, 'the sight is where the mouse is, and the arrow is not drawn');
|
|
686
|
+
assert.deepEqual(ui, { aim: 'ui', on: false, sight: 'hidden' }, 'over the accent button the arrow is back and the sight steps aside');
|
|
687
|
+
assert.deepEqual(errors, []);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('dragging along the top arc goes back in time; the date and the player show, and its buttons and the arrow keys work', { skip }, async () => {
|
|
691
|
+
const { ctx, page, errors } = await open();
|
|
692
|
+
const none = await page.evaluate(() => !!document.querySelector('[data-visor-scrub]'));
|
|
693
|
+
await withTime(page);
|
|
694
|
+
const band = await page.evaluate(() => { const r = document.querySelector('[data-visor-scrub]').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, timeHidden: document.querySelector('[data-visor-time]').hidden, role: document.querySelector('[data-visor-scrub]').getAttribute('role') }; });
|
|
695
|
+
const xAt = (f) => band.x + band.w * (0.076 + 0.848 * f);
|
|
696
|
+
await page.mouse.move(xAt(0.8), band.y + band.h / 2);
|
|
697
|
+
await page.mouse.down();
|
|
698
|
+
await page.mouse.move(xAt(0.5), band.y + band.h / 2 + 200, { steps: 4 }); // the drag holds off the band too
|
|
699
|
+
await page.mouse.up();
|
|
700
|
+
const back = await page.evaluate(() => { const m = document.querySelector('.visor__scrub-at').getBoundingClientRect(), b = document.querySelector('[data-visor-scrub]').getBoundingClientRect(); return { value: window.tl.value, when: document.querySelector('[data-visor-when]').textContent, whenHidden: document.querySelector('[data-visor-when]').hidden, timeHidden: document.querySelector('[data-visor-time]').hidden, now: document.querySelector('[data-visor-scrub]').getAttribute('aria-valuenow'), markX: (m.x + m.width / 2 - b.x) / b.width, live: document.querySelector('[data-visor-live]').textContent, rest: document.getElementById('visor').classList.contains('visor--rest') }; });
|
|
701
|
+
await page.mouse.move(640, 400); // let go: moving on does not drag
|
|
702
|
+
const after = await page.evaluate(() => window.tl.value);
|
|
703
|
+
await page.click('[data-visor-play]');
|
|
704
|
+
await page.click('[data-visor-speed]');
|
|
705
|
+
const played = await page.evaluate(() => ({ plays: window.tl.plays, label: document.querySelector('[data-visor-play]').getAttribute('aria-label'), speed: document.querySelector('[data-visor-speed]').textContent }));
|
|
706
|
+
await page.focus('[data-visor-scrub]');
|
|
707
|
+
await page.keyboard.press('ArrowLeft');
|
|
708
|
+
const stepped = await page.evaluate(() => window.tl.value);
|
|
709
|
+
await page.keyboard.press('Home');
|
|
710
|
+
const home = await page.evaluate(() => window.tl.value);
|
|
711
|
+
await page.click('[data-visor-live]');
|
|
712
|
+
const live = await page.evaluate(() => ({ lives: window.tl.lives, timeHidden: document.querySelector('[data-visor-time]').hidden }));
|
|
713
|
+
await page.evaluate(() => window.OTBVisor.timeline(null));
|
|
714
|
+
const gone = await page.evaluate(() => !!document.querySelector('[data-visor-scrub]'));
|
|
715
|
+
await ctx.close();
|
|
716
|
+
assert.equal(none, false, 'a scene with no time has no slider');
|
|
717
|
+
assert.equal(band.role, 'slider');
|
|
718
|
+
assert.equal(band.timeHidden, true, 'at live the player is not shown');
|
|
719
|
+
assert.ok(Math.abs(back.value - 0.5) < 0.02, 'the drag ends half way back: ' + back.value);
|
|
720
|
+
assert.ok(Math.abs(back.markX - (0.076 + 0.848 * 0.5)) < 0.02, 'the mark rides the arc at the same place');
|
|
721
|
+
assert.deepEqual([back.when, back.whenHidden, back.timeHidden, back.now, back.live, back.rest], ['week 5', false, false, '50', 'Back to live', false]);
|
|
722
|
+
assert.equal(after, back.value, 'after the press ends, moving the mouse changes nothing');
|
|
723
|
+
assert.deepEqual(played, { plays: 1, label: 'Pause', speed: '2×' });
|
|
724
|
+
assert.ok(stepped < back.value && stepped > back.value - 0.05, 'the left arrow steps back a little');
|
|
725
|
+
assert.equal(home, 0, 'Home goes to the start');
|
|
726
|
+
assert.deepEqual(live, { lives: 1, timeHidden: true });
|
|
727
|
+
assert.equal(gone, false, 'the scene taking its time away takes the slider away');
|
|
728
|
+
assert.deepEqual(errors, []);
|
|
729
|
+
});
|