fix(highway_3d): Venue desync, bind race, and a11y for the player background control (#1018)
ship-ci / ci (push) Has been cancelled

* Fix 3D Highway background controls under Venue override

When the Venue visualization override is active, the entire Background control group (style dropdown and intensity/reactive knobs) is now disabled since the venue scene owns rendering. The dropdown, intensity, and reactive controls are greyed out with a tooltip explaining the state.

Also fixes a cold-load bug where the screen hook subscription could fail to bind if the event bus wasn't ready during renderer init, by re-attempting binding on each retry tick.

Includes test coverage for both the Venue override greyout behavior and the cold-load screen hook binding fix.

* Add accessibility features and explicit global reads to background control

Refactor the player chrome background control to use an explicit `_bgReadGlobal()` function instead of relying on the implicit behavior of `_bgReadSetting(null, ...)`. The control is a single shared instance across splitscreen panels and must always read/write the global slot.

Add accessibility improvements:
- aria-pressed on toggle buttons to expose state to screen readers
- aria-label on select and intensity controls
- aria-describedby pointing disabled controls to a visually-hidden reason span
- The reason span carries dynamic explanatory text for why a control is greyed out

Add comprehensive tests verifying the new `_bgReadGlobal` helper ignores per-panel overrides and that all accessibility attributes are set and updated correctly.

* Gate player control slot on v3 UI version

Add explicit check for `window.feedBack.uiVersion === 'v3'` in _pcSlot() per docs/plugin-v3-ui.md. This prevents the plugin from attempting to mount player controls on non-v3 hosts (e.g., legacy v2 shell). Complements the existing `playerControlSlot` typeof check and improves compatibility robustness.

Updated test mocks to include `uiVersion: 'v3'` and added test case verifying that mounting is skipped when uiVersion is not v3, including a guard to ensure the retry loop terminates properly.

* Clarify 3D highway style control behavior

Document that the style controls group also greyes out when the Venue scene override is active, since the controls don't apply in that mode.

* Restore style dropdown tooltip when Venue override exits

The style dropdown's tooltip was cleared whenever the Venue override was inactive, permanently discarding the "Background style" hint set at mount time. Since the sync runs on every settings change, the tooltip was lost on the first sync and never returned.

This brings the dropdown in line with the intensity slider and reactive toggle, which already restore their base tooltip when they're re-enabled.

Includes a test asserting the tooltip returns after the Venue override exits.

* fix(highway_3d): skip player-control retry loop on non-v3 shells

_pcAcquire only runs once the renderer is viable inside the v3 player
chrome, and player-chrome.js sets uiVersion synchronously as it builds
that chrome — so a missing 'v3' at acquire means v2, not a not-yet-ready
v3. Bail before scheduling the retry loop instead of spinning it out to
the ~3s budget for a slot that will never appear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle
2026-07-20 09:18:12 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 35c0d0ea0d
commit a9be210f77
4 changed files with 289 additions and 26 deletions
+3 -1
View File
@@ -79,7 +79,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
style ignores are greyed out with a reason on hover (Custom video and style ignores are greyed out with a reason on hover (Custom video and
Butterchurn use neither; Custom image uses Intensity but not Reactive), so Butterchurn use neither; Custom image uses Intensity but not Reactive), so
a knob is never present-but-inert. The control disappears when a non-3D a knob is never present-but-inert. The control disappears when a non-3D
renderer is selected. renderer is selected. The whole group also greys out while the Venue scene
override is active, since none of the three controls reach a mounted style
in that mode.
### Changed ### Changed
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem - **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "highway_3d", "id": "highway_3d",
"name": "3D Highway", "name": "3D Highway",
"version": "3.34.0", "version": "3.34.1",
"type": "visualization", "type": "visualization",
"bundled": true, "bundled": true,
"script": "screen.js", "script": "screen.js",
+110 -22
View File
@@ -2786,6 +2786,21 @@
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal); if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
return BG_DEFAULTS[key]; return BG_DEFAULTS[key];
} }
// Read a setting's GLOBAL value, ignoring any per-panel override. The
// player-chrome control is a single shared instance, so it must always
// read (and write) the global slot. Passing null as a panelKey to
// _bgReadSetting happened to work only because 'h3d_bg_null_<key>' never
// exists; this states the intent directly and can't be shadowed if a
// panelKey of null is ever used deliberately. Mirrors the global half of
// _bgReadSetting exactly (mem-fallback precedence, then persisted, then
// default).
function _bgReadGlobal(key) {
let globalVal = null;
try { globalVal = localStorage.getItem('h3d_bg_' + key); } catch (_) { /* storage blocked */ }
if (key in _bgMemFallback) return _bgCoerce(key, _bgMemFallback[key]);
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
return BG_DEFAULTS[key];
}
// Shared "stored string -> bool" coercion for every boolean // Shared "stored string -> bool" coercion for every boolean
// setting. Mirrors settings.html's coerceBool so the renderer and // setting. Mirrors settings.html's coerceBool so the renderer and
// the UI hydration always agree on what a corrupted/unknown value // the UI hydration always agree on what a corrupted/unknown value
@@ -4024,8 +4039,10 @@
* add a style there and it shows up in both places automatically. * add a style there and it shows up in both places automatically.
* *
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer * MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
* instances but these settings are global, so N copies of the control * instances but these settings are global a panel may set a per-panel
* would be N ways to set one value. init() acquires, destroy() releases, * override, but this single shared control only ever reads/writes the
* global slot (via _bgReadGlobal), so N copies would be N ways to set
* one value. init() acquires, destroy() releases,
* and the last release unmounts so the control disappears when the user * and the last release unmounts so the control disappears when the user
* switches to a non-3D renderer instead of lingering as a dead knob. * switches to a non-3D renderer instead of lingering as a dead knob.
* *
@@ -4046,9 +4063,11 @@
// //
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its // Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
// build() reads settings.intensity, and uses `reactive` if its update() // build() reads settings.intensity, and uses `reactive` if its update()
// dereferences the `bands` argument. 'butterchurn' is not a BG_STYLES entry // dereferences the `bands` argument. 'butterchurn' is a mode, not a
// at all - _bgMountStyle falls through to BG_STYLES.off - and it drives its // BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
// own audio tap and opacity, so both are false for it. // drives its own audio tap and canvas opacity (only the fog-scenery half
// falls through to BG_STYLES.off). So neither knob here reaches it - both
// are false, and the tooltip points at Butterchurn's own controls.
// //
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity // KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
// and its row is not updated, the control stays greyed out and lies the // and its row is not updated, the control stays greyed out and lies the
@@ -4063,6 +4082,10 @@
image: { intensity: true, reactive: false, why: 'This background does not react to audio' }, image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' }, video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' }, butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
// active it is the EFFECTIVE style, so both knobs drive nothing.
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
}; };
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null; let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
// Non-disabled wrappers around the two greyable controls. A native-disabled // Non-disabled wrappers around the two greyable controls. A native-disabled
@@ -4070,7 +4093,7 @@
// shows on hover — the whole "greyed out, says why on hover" affordance // shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the // would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them. // disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null; let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0; let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host // The player chrome exposes this slot once it has initialised. A host
@@ -4078,7 +4101,12 @@
// page remains the way in. // page remains the way in.
function _pcSlot() { function _pcSlot() {
try { try {
const fn = window.feedBack && window.feedBack.ui && window.feedBack.ui.playerControlSlot; // Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
// precedent). The playerControlSlot typeof check below already
// covers the practical case - only v3 exposes it - but the
// documented checklist asks plugins to detect v3 explicitly.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
return typeof fn === 'function' ? fn() : null; return typeof fn === 'function' ? fn() : null;
} catch (_) { return null; } } catch (_) { return null; }
} }
@@ -4126,6 +4154,8 @@
btn._on = !!on && !disabled; btn._on = !!on && !disabled;
btn.disabled = !!disabled; btn.disabled = !!disabled;
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false'); btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
// A toggle button must expose its state, not just its label.
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
// pointer-events:none lets the hover fall through to _pcReactiveWrap, // pointer-events:none lets the hover fall through to _pcReactiveWrap,
// which carries the reason a disabled button's own title can't show. // which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : ''; btn.style.pointerEvents = disabled ? 'none' : '';
@@ -4151,22 +4181,51 @@
// whenever the settings bus reports one of our keys changed, so editing // whenever the settings bus reports one of our keys changed, so editing
// from the Settings page updates this control and vice-versa. // from the Settings page updates this control and vice-versa.
function _pcSync() { function _pcSync() {
// The active style is the EFFECTIVE one, not the stored one: while the
// Venue scene override is on it is what's mounted, and it ignores the
// whole Background group - picking a style writes `style` but
// _bgMountStyle resolves back to venue, so the dropdown would look
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
// and the user exits Venue from the visualization picker where they
// entered it. An unknown id enables everything rather than disabling
// it, so a style added without a _PC_USES row is merely unhelpful.
const venue = !!_venueSceneOverride;
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
const why = uses.why || 'This background style ignores this setting';
if (_pcReason) _pcReason.textContent = why;
// Point a screen reader at the reason, but only while a control is
// inert - cleared otherwise so an enabled control is not described by a
// stale reason.
const _pcDescribe = (el, inert) => {
if (!el) return;
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
else el.removeAttribute('aria-describedby');
};
_pcDescribe(_pcSel, venue);
_pcDescribe(_pcReactive, !uses.reactive);
_pcDescribe(_pcIntensity, !uses.intensity);
if (_pcSel) { if (_pcSel) {
// The custom slots stay unselectable until something is uploaded - // The custom slots stay unselectable until something is uploaded -
// same rule settings.html applies. // same rule settings.html applies.
const img = _pcSel.querySelector('option[value="image"]'); const img = _pcSel.querySelector('option[value="image"]');
const vid = _pcSel.querySelector('option[value="video"]'); const vid = _pcSel.querySelector('option[value="video"]');
if (img) img.disabled = !_bgReadSetting(null, 'customImageDataUrl'); if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
if (vid) vid.disabled = !_bgReadSetting(null, 'customVideoName'); if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
_pcSel.value = _bgReadSetting(null, 'style'); _pcSel.value = _bgReadGlobal('style');
// The dropdown still SHOWS the stored style (venue has no option),
// but it's inert while Venue owns the scene.
_pcSel.disabled = venue;
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
_pcSel.style.opacity = venue ? '.45' : '1';
_pcSel.style.cursor = venue ? 'not-allowed' : '';
// Restore the base tooltip when Venue exits — blanking it would
// permanently drop the mount-time 'Background style' hint. Matches
// how the intensity slider and Reactive pill restore theirs.
_pcSel.title = venue ? why : 'Background style';
} }
// Grey out whichever controls the ACTIVE style ignores (see _PC_USES).
// An unknown id enables both rather than disabling both, so a style
// added without a table row is merely unhelpful, never inert.
const uses = _PC_USES[_bgReadSetting(null, 'style')] || { intensity: true, reactive: true };
const why = uses.why || 'This background style ignores this setting';
if (_pcReactive) { if (_pcReactive) {
_pcPaint(_pcReactive, !!_bgReadSetting(null, 'reactive'), !uses.reactive, _pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why); uses.reactive ? 'React to the audio' : why);
} }
// The reason shows via the wrapper (see _pcReactiveWrap); empty when // The reason shows via the wrapper (see _pcReactiveWrap); empty when
@@ -4176,7 +4235,7 @@
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed'; _pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
} }
if (_pcIntensity) { if (_pcIntensity) {
_pcIntensity.value = String(_bgReadSetting(null, 'intensity')); _pcIntensity.value = String(_bgReadGlobal('intensity'));
_pcIntensity.disabled = !uses.intensity; _pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true'); _pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none'; _pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
@@ -4203,10 +4262,10 @@
function _pcSyncSettingsPanel() { function _pcSyncSettingsPanel() {
try { try {
const st = document.getElementById('h3d-bg-style'); const st = document.getElementById('h3d-bg-style');
if (st) st.value = _bgReadSetting(null, 'style'); if (st) st.value = _bgReadGlobal('style');
const re = document.getElementById('h3d-bg-reactive'); const re = document.getElementById('h3d-bg-reactive');
if (re) re.checked = !!_bgReadSetting(null, 'reactive'); if (re) re.checked = !!_bgReadGlobal('reactive');
const inten = _bgReadSetting(null, 'intensity'); const inten = _bgReadGlobal('intensity');
const ie = document.getElementById('h3d-bg-intensity'); const ie = document.getElementById('h3d-bg-intensity');
if (ie) ie.value = String(inten); if (ie) ie.value = String(inten);
// The panel prints the numeric value beside the slider; keep its // The panel prints the numeric value beside the slider; keep its
@@ -4226,6 +4285,16 @@
const box = document.createElement('div'); const box = document.createElement('div');
box.className = 'h3d-pc'; box.className = 'h3d-pc';
box.style.cssText = 'display:flex;flex-direction:column;width:100%;'; box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
// Visually-hidden text carrying the "why greyed out" reason to screen
// readers; disabled controls point aria-describedby here. A title alone
// is announced unreliably and never on touch. One span suffices - every
// greyed control shares the same reason (derived from the single
// effective style).
_pcReason = document.createElement('span');
_pcReason.id = 'h3d-pc-reason';
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
box.appendChild(_pcReason);
box.appendChild(_pcGroupLabel('Background')); box.appendChild(_pcGroupLabel('Background'));
// A dropdown, not pills: the style list is 8 entries and growing, and // A dropdown, not pills: the style list is 8 entries and growing, and
@@ -4234,6 +4303,7 @@
// raw <select>. // raw <select>.
_pcSel = document.createElement('select'); _pcSel = document.createElement('select');
_pcSel.title = 'Background style'; _pcSel.title = 'Background style';
_pcSel.setAttribute('aria-label', 'Background style');
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;' _pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;' + 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';'; + 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
@@ -4244,6 +4314,7 @@
_pcSel.appendChild(o); _pcSel.appendChild(o);
} }
_pcSel.addEventListener('change', () => { _pcSel.addEventListener('change', () => {
if (_pcSel.disabled) return; // inert under the Venue override
try { window.h3dBgSetStyle(_pcSel.value); } try { window.h3dBgSetStyle(_pcSel.value); }
catch (e) { console.error('[3D-Hwy] bg style set failed', e); } catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
}); });
@@ -4269,6 +4340,7 @@
_pcIntensity.type = 'range'; _pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05'; _pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
_pcIntensity.title = 'Background intensity'; _pcIntensity.title = 'Background intensity';
_pcIntensity.setAttribute('aria-label', 'Background intensity');
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;'; _pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
// 'change' (fires on release), NOT 'input'. Every write goes through // 'change' (fires on release), NOT 'input'. Every write goes through
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the // _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
@@ -4288,7 +4360,11 @@
_pcSync(); _pcSync();
_pcListener = (key) => { _pcListener = (key) => {
if (key === 'style' || key === 'reactive' || key === 'intensity' if (key === 'style' || key === 'reactive' || key === 'intensity'
|| key === 'customImageDataUrl' || key === 'customVideoName') { || key === 'customImageDataUrl' || key === 'customVideoName'
|| key === 'venueScene') {
// 'venueScene' has no dropdown/settings widget of its own, but
// toggling Venue changes the EFFECTIVE style, so the greying
// must re-evaluate (see _pcSync's effectiveStyle).
_pcSync(); _pcSync();
_pcSyncSettingsPanel(); _pcSyncSettingsPanel();
} }
@@ -4300,12 +4376,18 @@
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; } if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl); if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null; _pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
} }
function _pcAcquire() { function _pcAcquire() {
_pcRefs++; _pcRefs++;
_pcBindScreenHook(); _pcBindScreenHook();
if (_pcMount()) return; if (_pcMount()) return;
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
// the renderer is viable inside the v3 player chrome, and player-chrome.js
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
// spinning it out to the ~3s budget for a slot that will never appear.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
// The rail popover may not be built yet on a cold load. Retry a few // The rail popover may not be built yet on a cold load. Retry a few
// times, then give up quietly — Settings still works. // times, then give up quietly — Settings still works.
if (_pcRetryTimer) return; if (_pcRetryTimer) return;
@@ -4313,6 +4395,12 @@
const tick = () => { const tick = () => {
_pcRetryTimer = 0; _pcRetryTimer = 0;
if (_pcRefs <= 0) return; // renderer went away mid-retry if (_pcRefs <= 0) return; // renderer went away mid-retry
// Re-attempt the bus subscription too, not just the mount. On a cold
// load the renderer can init before window.feedBack.on exists; the
// first _pcBindScreenHook() then no-ops and, without this, the hook
// never binds and the control goes permanently deaf to screen
// changes. Idempotent via the _pcScreenHook guard.
_pcBindScreenHook();
if (_pcMount()) return; if (_pcMount()) return;
if (++_pcRetry > 12) return; // ~3s at 250ms if (++_pcRetry > 12) return; // ~3s at 250ms
_pcRetryTimer = setTimeout(tick, 250); _pcRetryTimer = setTimeout(tick, 250);
@@ -37,8 +37,9 @@ const END_LF = ' /* =========================================================
// table, which would only assert that the table equals itself. // table, which would only assert that the table equals itself.
// intensity: true => the style's build() reads settings.intensity // intensity: true => the style's build() reads settings.intensity
// reactive: true => the style's update() dereferences its `bands` argument // reactive: true => the style's update() dereferences its `bands` argument
// 'butterchurn' is not a BG_STYLES entry at all (mount falls through to // 'butterchurn' is a mode, not a BG_STYLES fog-scenery entry: _bcSyncMode
// BG_STYLES.off) and drives its own audio tap, so both are false. // owns its controller and drives its own audio tap + canvas opacity (only
// the fog-scenery half falls through to BG_STYLES.off), so both are false.
const EXPECTED_USES = { const EXPECTED_USES = {
off: { intensity: false, reactive: false }, off: { intensity: false, reactive: false },
particles: { intensity: true, reactive: true }, particles: { intensity: true, reactive: true },
@@ -73,6 +74,7 @@ function makeDom() {
} }
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); } addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
setAttribute(k, v) { this[k] = v; } setAttribute(k, v) { this[k] = v; }
removeAttribute(k) { delete this[k]; }
get isConnected() { get isConnected() {
let n = this; let n = this;
while (n.parentNode) n = n.parentNode; while (n.parentNode) n = n.parentNode;
@@ -127,7 +129,12 @@ function load({ store: initialStore } = {}) {
const sandbox = { const sandbox = {
console, console,
BG_STYLE_IDS, BG_STYLE_IDS,
// Module-scope in screen.js; the _pc* block reads it to resolve the
// effective style under the Venue override. Tests flip it via
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
_venueSceneOverride: false,
_bgReadSetting: (_panelKey, key) => store[key], _bgReadSetting: (_panelKey, key) => store[key],
_bgReadGlobal: (key) => store[key],
_bgSubscribe: (fn) => listeners.add(fn), _bgSubscribe: (fn) => listeners.add(fn),
_bgUnsubscribe: (fn) => listeners.delete(fn), _bgUnsubscribe: (fn) => listeners.delete(fn),
setTimeout: (fn) => { timers.push(fn); return timers.length; }, setTimeout: (fn) => { timers.push(fn); return timers.length; },
@@ -139,6 +146,7 @@ function load({ store: initialStore } = {}) {
}, },
window: { window: {
feedBack: { feedBack: {
uiVersion: 'v3', // _pcSlot gates on this (docs/plugin-v3-ui.md)
ui: { playerControlSlot: () => dom.slot }, ui: { playerControlSlot: () => dom.slot },
// The real bus is an EventTarget wrapper exposing on/off. Modelled // The real bus is an EventTarget wrapper exposing on/off. Modelled
// here so the screen:changed subscription — and its removal — are // here so the screen:changed subscription — and its removal — are
@@ -165,6 +173,7 @@ function load({ store: initialStore } = {}) {
+ ' get sel() { return _pcSel; },' + ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },' + ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },' + ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } })', + ' get refs() { return _pcRefs; } })',
sandbox, sandbox,
); );
@@ -173,6 +182,50 @@ function load({ store: initialStore } = {}) {
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks }; return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
} }
// Slice the real _bgReadSetting + _bgReadGlobal out of screen.js and run them
// against a localStorage stub. The main suite stubs both helpers identically,
// so it can't tell the #2 refactor from a no-op; this one proves the actual
// helper bodies differ where they must: _bgReadGlobal ignores a per-panel
// override that _bgReadSetting(panelKey, ...) still honours.
test('_bgReadGlobal reads the global slot, ignoring per-panel overrides', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const rgStart = src.indexOf(' function _bgReadSetting(panelKey, key) {');
const rgEnd = src.indexOf(' // Shared "stored string -> bool" coercion');
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'could not slice the read helpers');
const block = src.slice(rgStart, rgEnd);
const storage = new Map();
const sandbox = {
localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null) },
_bgCoerce: (_key, v) => v, // identity: we test key resolution, not coercion
_bgMemFallback: Object.create(null),
BG_DEFAULTS: { style: 'particles' },
};
sandbox.globalThis = sandbox;
const api = vm.runInNewContext(block + '\n({ _bgReadSetting, _bgReadGlobal, _bgMemFallback })', sandbox);
storage.set('h3d_bg_style', 'lights'); // global
storage.set('h3d_bg_panel3_style', 'geometric'); // a per-panel override
// The renderer, reading with a panel key, honours the per-panel override...
assert.equal(api._bgReadSetting('panel3', 'style'), 'geometric');
// ...but the shared control's global read must NOT see it - this is the
// whole point of #2 (previously _bgReadSetting(null, ...) relied on
// 'h3d_bg_null_style' never existing).
assert.equal(api._bgReadGlobal('style'), 'lights');
// In-memory staged value wins over the persisted global (matches
// _bgReadSetting's precedence).
api._bgMemFallback.style = 'aurora';
assert.equal(api._bgReadGlobal('style'), 'aurora');
delete api._bgMemFallback.style;
// Nothing stored -> BG_DEFAULTS.
assert.equal(api._bgReadGlobal('style'), 'lights');
storage.delete('h3d_bg_style');
assert.equal(api._bgReadGlobal('style'), 'particles');
});
test('mounts one control into the player-control slot', () => { test('mounts one control into the player-control slot', () => {
const { api, dom } = load(); const { api, dom } = load();
api._pcAcquire(); api._pcAcquire();
@@ -199,6 +252,29 @@ test('multiple renderer instances share a single control', () => {
assert.equal(api.el, null); assert.equal(api.el, null);
}); });
test('binds the screen hook on a retry when the bus was not ready at acquire', () => {
const ctl = load();
// Cold load: on a fresh page the renderer can init before the event bus is
// wired AND before the rail popover exists. Simulate both being absent.
const savedOn = ctl.sandbox.window.feedBack.on;
const savedUi = ctl.sandbox.window.feedBack.ui;
delete ctl.sandbox.window.feedBack.on;
ctl.sandbox.window.feedBack.ui = {}; // no playerControlSlot -> mount fails
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 0, 'nothing to bind to yet');
assert.equal(ctl.api.el, null, 'no slot yet, so nothing mounted');
// Bus + slot come online; the retry tick must bind the hook, not only mount.
ctl.sandbox.window.feedBack.on = savedOn;
ctl.sandbox.window.feedBack.ui = savedUi;
ctl.timers.shift()(); // run one retry tick
assert.equal(ctl.screenHooks(), 1, 'the retry tick failed to bind the screen hook');
assert.ok(ctl.api.el, 'and it should have mounted too');
ctl.api._pcRelease();
});
test('the last release unbinds the screen:changed hook', () => { test('the last release unbinds the screen:changed hook', () => {
const ctl = load(); const ctl = load();
ctl.api._pcAcquire(); ctl.api._pcAcquire();
@@ -261,6 +337,18 @@ test('re-mounts into a fresh slot when the player chrome is rebuilt', () => {
assert.equal(listenerCount(), 1, 'remount must not double-subscribe'); assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
}); });
test('a non-v3 host mounts nothing (uiVersion gate)', () => {
const ctl = load();
ctl.sandbox.window.feedBack.uiVersion = 'v2'; // pre-v3 shell
ctl.api._pcAcquire();
assert.equal(ctl.api.el, null, 'must not mount when uiVersion is not v3');
assert.equal(ctl.dom.slot.children.length, 0);
// A non-v3 shell has no slot and never will, so no retry should be scheduled
// at all — the loop is for a not-yet-built v3 slot, not for polling v2.
assert.equal(ctl.timers.length, 0, 'a non-v3 host must not schedule the retry loop');
ctl.api._pcRelease();
});
test('a host with no player-control slot mounts nothing and does not throw', () => { test('a host with no player-control slot mounts nothing and does not throw', () => {
const { api, dom, sandbox, timers } = load(); const { api, dom, sandbox, timers } = load();
sandbox.window.feedBack.ui = {}; sandbox.window.feedBack.ui = {};
@@ -300,6 +388,49 @@ test('the dropdown and Reactive pill drive the real setters', () => {
assert.ok(writes.some((w) => w[0] === 'reactive')); assert.ok(writes.some((w) => w[0] === 'reactive'));
}); });
test('exposes state and reasons to assistive tech', () => {
const ctl = load({ store: { style: 'image', reactive: true } }); // image: reactive inert
ctl.api._pcAcquire();
// The reason live-region must be a REAL mounted element with the id the
// controls reference - not a dangling pointer. Assert resolution, not a
// literal (a wrong id in code would still equal the literal).
const reason = ctl.api.reason;
assert.ok(reason, 'the reason span was not created');
assert.equal(reason.id, 'h3d-pc-reason');
assert.equal(reason.parentNode, ctl.api.el, 'the reason span must be mounted in the control');
// aria-pressed: a toggle button must expose its state. image greys
// Reactive, so not-pressed AND disabled, and it points at the reason.
assert.equal(ctl.api.react['aria-pressed'], 'false', 'greyed toggle is not pressed');
assert.equal(ctl.api.react['aria-disabled'], 'true');
// Pointer must resolve to the actual span's id (kills a wrong-id mutation),
// and the span must carry the current reason text (kills a never-set-text
// mutation).
assert.equal(ctl.api.react['aria-describedby'], reason.id, 'inert control must reference the reason span');
assert.equal(reason.textContent, 'This background does not react to audio', 'reason text must match the style');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'an ENABLED control carries no reason');
// The intensity describe path: a style where INTENSITY is inert.
ctl.store.style = 'video'; ctl.emit('style');
assert.equal(ctl.api.intens.disabled, true, 'precondition: video greys intensity');
assert.equal(ctl.api.intens['aria-describedby'], reason.id, 'inert intensity must reference the reason');
assert.equal(reason.textContent, 'The video plays as-is - nothing to adjust here');
// Both enabled: describedby drops, aria-pressed follows the value.
ctl.store.style = 'particles'; ctl.store.reactive = true; ctl.emit('style');
assert.equal(ctl.api.react['aria-describedby'], undefined, 'enabled control drops the reason');
assert.equal(ctl.api.intens['aria-describedby'], undefined);
assert.equal(ctl.api.react['aria-pressed'], 'true', 'reactive on for particles');
ctl.store.reactive = false; ctl.emit('reactive');
assert.equal(ctl.api.react['aria-pressed'], 'false', 'aria-pressed follows the value');
// Accessible names on the non-label controls.
assert.equal(ctl.api.sel['aria-label'], 'Background style');
assert.equal(ctl.api.intens['aria-label'], 'Background intensity');
ctl.api._pcRelease();
});
test('greys out exactly the controls each style ignores', () => { test('greys out exactly the controls each style ignores', () => {
const { api, store, emit } = load(); const { api, store, emit } = load();
api._pcAcquire(); api._pcAcquire();
@@ -311,6 +442,48 @@ test('greys out exactly the controls each style ignores', () => {
} }
}); });
test('the Venue override greys the whole Background group', () => {
const ctl = load({ store: { style: 'particles' } }); // a style that uses both
ctl.api._pcAcquire();
assert.equal(ctl.api.intens.disabled, false, 'precondition: both enabled off-venue');
assert.equal(ctl.api.react.disabled, false);
// Venue turns on: the effective style is now 'venue', which uses neither.
// The transition arrives on the settings bus as the 'venueScene' key.
ctl.sandbox._venueSceneOverride = true;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, true, 'intensity should grey under Venue');
assert.equal(ctl.api.react.disabled, true, 'reactive should grey under Venue');
assert.equal(ctl.api.sel.disabled, true, 'the dropdown should be inert under Venue too');
assert.match(ctl.api.intens.title, /venue/i, 'reason should mention Venue');
// All three inert controls point at the reason under Venue (kills a
// 'describe reactive only' regression on the select/intensity paths).
const vReason = ctl.api.reason.id;
assert.equal(ctl.api.sel['aria-describedby'], vReason, 'select must reference the reason under Venue');
assert.equal(ctl.api.intens['aria-describedby'], vReason, 'intensity must reference the reason under Venue');
assert.equal(ctl.api.react['aria-describedby'], vReason, 'reactive must reference the reason under Venue');
assert.match(ctl.api.reason.textContent, /venue/i, 'the reason span carries the Venue text');
// The dropdown still shows the stored style (venue has no option), but
// selecting must not write while it's inert.
assert.equal(ctl.api.sel.value, 'particles');
const before = ctl.writes.length;
ctl.api.sel.value = 'lights';
ctl.api.sel.fire('change');
assert.equal(ctl.writes.length, before, 'a disabled dropdown must not write');
// Venue off: controls come back per the stored style.
ctl.sandbox._venueSceneOverride = false;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, false, 'intensity re-enables when Venue exits');
assert.equal(ctl.api.react.disabled, false);
assert.equal(ctl.api.sel.disabled, false, 'the dropdown re-enables when Venue exits');
assert.equal(ctl.api.sel.title, 'Background style', 'the base tooltip must come back, not blank');
assert.equal(ctl.api.sel['aria-describedby'], undefined, 'select drops the reason off-Venue');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'intensity drops the reason off-Venue');
ctl.api._pcRelease();
});
test('an unknown style enables both controls (fails open)', () => { test('an unknown style enables both controls (fails open)', () => {
const { api, store, emit } = load(); const { api, store, emit } = load();
api._pcAcquire(); api._pcAcquire();