refactor(h3d-carve-5): move H-section player-chrome bg-control to src/bg-control.js

Extracted _pc* subsystem (420 lines) from screen.js IIFE into
src/bg-control.js using a factory DI pattern (createBgControl({...})).

Exports: createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe,
         _bgUnsubscribe, getVenueSceneOverride })
         → { _pcAcquire, _pcRelease }
screen.js: const { _pcAcquire, _pcRelease } = createBgControl({...})

Beyond-subst (2):
1. Factory wrapper (IIFE-scope closure → DI params) — factory export pattern
   required because DI values are IIFE-scope, not ES module imports.
2. _venueSceneOverride → getVenueSceneOverride() (live accessor, 1 call site
   in _pcSync — mutable let at screen.js:1523 must be read per-call).

DI values all defined before the createBgControl call (screen.js):
- BG_STYLE_IDS: line 1435 | _bgReadGlobal: 1825
- _bgSubscribe/_bgUnsubscribe: 1917-18 | _venueSceneOverride: 1523
First _pcAcquire caller: init() in createFactory() (~line 14850 post-cut).
Construction order correct: createBgControl call before createFactory.

Surprise declared to god before commit (outbox/h3d-cut5-surprise.json):
No bc-panel.js dependency — §8's anticipation was wrong. The
_bcCreateController call at what was ~line 8082 is in _bcSyncMode
(P-section / factory scope), not the H-section. bg-control.js has ZERO
dependency on bc-panel.js.

Tests:
- tests/js/highway_3d_bg_control.test.js (new, 13 class-killers):
  stranded-caller (test 13, factory-adapted from bc-panel test 12),
  construction-order (tests 11-12), DI completeness (test 2),
  live-accessor enforcement (test 3), lifecycle (tests 4-7).
- plugins/highway_3d/tests/background_control.test.js: retargeted from
  screen.js slice → bg-control.js factory eval; all 20 existing behaviour
  tests preserved (load() uses vm.createContext + augmented return getters).
- tests/js/highway_3d_panel_controls.test.js: createBgControl stub added.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
  base 222/222 (f69c544) → tip 235/235 (+13: 13 new class-killers)
plugin.json: 3.40.0 → 3.41.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
byrongamatos
2026-09-05 09:25:44 +02:00
co-authored by Claude Sonnet 4.6
parent f69c544eea
commit c4eebe1c9f
6 changed files with 748 additions and 449 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.40.0",
"version": "3.41.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+14 -418
View File
@@ -10,6 +10,7 @@ import { geoFretX, dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, compute
import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2
import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount as _resolveStringCountBase, _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5, _BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8, _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning as _openStringPitchLabelsForTuningBase, _ssActive, _ssIsCanvasFocused } from './src/utils.js'; // h3d-carve-3
import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
(function () {
'use strict';
@@ -3055,424 +3056,19 @@ import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './
let _nextInstanceId = 0;
/* ======================================================================
* Player-chrome background control
* ======================================================================
* A Background picker mounted into the player's Plugins rail popover, so
* the background can be switched MID-SONG without leaving for Settings.
*
* It writes through the SAME global setters settings.html uses
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
* is persisted here, and the option list is generated from BG_STYLE_IDS
* add a style there and it shows up in both places automatically.
*
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
* instances but these settings are global a panel may set a per-panel
* 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
* switches to a non-3D renderer instead of lingering as a dead knob.
*
* Everything here is event-driven. No DOM work on a per-frame path.
*/
// Wording is kept verbatim in sync with settings.html's <option> text so
// the same style is not named two different things in two UIs that sit
// two clicks apart. An id with no entry here falls back to the raw id.
const _PC_LABELS = {
off: 'Off', particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image', video: 'Custom video',
};
// Which settings each background style actually consumes, so a control
// that would do nothing is greyed out instead of lying.
//
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
// build() reads settings.intensity, and uses `reactive` if its update()
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
// 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
// and its row is not updated, the control stays greyed out and lies the
// other way. An id missing from this table defaults to both-enabled, which
// is the safe direction: a new style is assumed to use its settings.
const _PC_USES = {
off: { intensity: false, reactive: false, why: 'No background to adjust' },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
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' },
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;
// Non-disabled wrappers around the two greyable controls. A native-disabled
// <button>/<input> receives no pointer events, so its `title` tooltip never
// shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host
// that does not provide it gets no control (and no error) - the Settings
// page remains the way in.
function _pcSlot() {
try {
// 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;
} catch (_) { return null; }
}
// Visual language: these controls sit in the player's Plugin Controls
// popover alongside pills from other plugins (Invert, Split, Tuner, the
// STEMS group...), so they follow the same look - small rounded pills,
// dark fill, brighter on hover, tinted when active.
//
// Styled INLINE rather than with the Tailwind classes those plugins use
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
// compiled stylesheet and several of those utilities are not in it, so
// using them would mean regenerating assets/plugin.css and bumping the
// manifest version. The values below are the resolved tokens from
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
// #d1d5db), so the result matches without the build step.
const _PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500 (inert controls)
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
onText: '#86efac', // text-green-300
};
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'transition:background-color .15s,color .15s;';
function _pcPill(label, title) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (title) b.title = title;
b.style.cssText = _PC_PILL;
// Hover is a pseudo-class we cannot express inline; these two
// listeners reproduce hover:bg-dark-500 for non-active pills only
// (an active pill keeps its tint on hover, as the other plugins do).
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
return b;
}
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
// when the active background style ignores the setting entirely (see
// _pcSync and _PC_USES) - the pill stays visible so the layout
// does not jump, but it is inert and says why on hover.
function _pcPaint(btn, on, disabled, reason) {
btn._on = !!on && !disabled;
btn.disabled = !!disabled;
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,
// which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : '';
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
btn.style.opacity = disabled ? '.45' : '1';
btn.title = reason || 'React to the audio';
if (disabled) {
btn.style.backgroundColor = _PC_C.idle;
btn.style.color = _PC_C.textDim;
return;
}
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
btn.style.color = on ? _PC_C.onText : _PC_C.text;
}
function _pcGroupLabel(text) {
const el = document.createElement('div');
el.textContent = text;
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
return el;
}
// Pull every control back to what is actually stored. Runs on mount and
// whenever the settings bus reports one of our keys changed, so editing
// from the Settings page updates this control and vice-versa.
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) {
// The custom slots stay unselectable until something is uploaded -
// same rule settings.html applies.
const img = _pcSel.querySelector('option[value="image"]');
const vid = _pcSel.querySelector('option[value="video"]');
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
_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';
}
if (_pcReactive) {
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why);
}
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
// enabled so the control's own title takes over.
if (_pcReactiveWrap) {
_pcReactiveWrap.title = uses.reactive ? '' : why;
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
}
if (_pcIntensity) {
_pcIntensity.value = String(_bgReadGlobal('intensity'));
_pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
}
if (_pcIntensityWrap) {
_pcIntensityWrap.title = uses.intensity ? '' : why;
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
}
}
// Mirror the current values into the Settings panel's controls when it's
// in the DOM.
//
// settings.html hydrates ONCE from localStorage when the panel is injected
// and never subscribes to the settings bus, so before this existed there
// was only one writer and it could not go stale. Adding the in-player
// picker made a second writer, and the panel had no way to hear about it —
// change the style mid-song and Settings would still show the old value.
//
// Assigning .value / .checked programmatically does NOT fire a 'change'
// event, so this cannot loop back into the setters.
function _pcSyncSettingsPanel() {
try {
const st = document.getElementById('h3d-bg-style');
if (st) st.value = _bgReadGlobal('style');
const re = document.getElementById('h3d-bg-reactive');
if (re) re.checked = !!_bgReadGlobal('reactive');
const inten = _bgReadGlobal('intensity');
const ie = document.getElementById('h3d-bg-intensity');
if (ie) ie.value = String(inten);
// The panel prints the numeric value beside the slider; keep its
// formatting identical to settings.html's own hydration.
const il = document.getElementById('h3d-bg-intensity-label');
if (il) il.textContent = Number(inten).toFixed(2);
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
}
function _pcMount() {
// A screen change can swap the popover out from under us, orphaning
// the control. Re-resolve only when the cached node is actually gone.
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
if (_pcEl) return true;
const slot = _pcSlot();
if (!slot) return false;
const box = document.createElement('div');
box.className = 'h3d-pc';
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'));
// A dropdown, not pills: the style list is 8 entries and growing, and
// a pill per style dominated a popover whose other controls are single
// toggles. Styled to match the surrounding pills rather than left as a
// raw <select>.
_pcSel = document.createElement('select');
_pcSel.title = 'Background style';
_pcSel.setAttribute('aria-label', 'Background style');
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
for (const id of BG_STYLE_IDS) {
const o = document.createElement('option');
o.value = id;
o.textContent = _PC_LABELS[id] || id;
_pcSel.appendChild(o);
}
_pcSel.addEventListener('change', () => {
if (_pcSel.disabled) return; // inert under the Venue override
try { window.h3dBgSetStyle(_pcSel.value); }
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
});
box.appendChild(_pcSel);
const optWrap = document.createElement('div');
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
_pcReactive = _pcPill('Reactive', 'React to the audio');
_pcReactive.addEventListener('click', () => {
if (_pcReactive.disabled) return;
try { window.h3dBgSetReactive(!_pcReactive._on); }
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
});
optWrap.appendChild(_pcReactive);
box.appendChild(optWrap);
box.appendChild(_pcGroupLabel('Intensity'));
// Wrapper carries the reason on hover when the slider is disabled — a
// native-disabled <input> shows no title of its own.
_pcIntensityWrap = document.createElement('div');
_pcIntensityWrap.style.cssText = 'width:100%;';
_pcIntensity = document.createElement('input');
_pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
_pcIntensity.title = 'Background intensity';
_pcIntensity.setAttribute('aria-label', 'Background intensity');
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
// 'change' (fires on release), NOT 'input'. Every write goes through
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
// background style down and re-runs build(). On 'input' a single drag
// across the range would trigger ~20 full scene rebuilds on the main
// thread mid-playback. settings.html's slider makes the same choice:
// oninput only repaints its label, onchange calls the setter.
_pcIntensity.addEventListener('change', () => {
if (_pcIntensity.disabled) return;
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
});
_pcIntensityWrap.appendChild(_pcIntensity);
box.appendChild(_pcIntensityWrap);
slot.appendChild(box);
_pcEl = box;
_pcSync();
_pcListener = (key) => {
if (key === 'style' || key === 'reactive' || key === 'intensity'
|| 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();
_pcSyncSettingsPanel();
}
};
_bgSubscribe(_pcListener);
return true;
}
function _pcTeardownDom() {
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
}
function _pcAcquire() {
_pcRefs++;
_pcBindScreenHook();
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
// times, then give up quietly — Settings still works.
if (_pcRetryTimer) return;
_pcRetry = 0;
const tick = () => {
_pcRetryTimer = 0;
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 (++_pcRetry > 12) return; // ~3s at 250ms
_pcRetryTimer = setTimeout(tick, 250);
};
_pcRetryTimer = setTimeout(tick, 250);
}
// Re-mount after the player chrome is rebuilt.
//
// _pcMount's isConnected check can only run when something calls it, and
// after the first successful mount nothing did - init() and the retry tick
// are the only callers, and the tick stops on success. So a popover that
// got swapped out left the control gone until the next song change. This
// listener gives that check a real trigger.
//
// Event-driven and cheap: one _pcMount() call per screen change, and it
// early-returns immediately when the cached node is still connected.
let _pcScreenHook = null;
function _pcBindScreenHook() {
if (_pcScreenHook) return;
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
try { bus.on('screen:changed', _pcScreenHook); }
catch (e) { _pcScreenHook = null; }
}
function _pcRelease() {
_pcRefs = Math.max(0, _pcRefs - 1);
if (_pcRefs > 0) return;
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
// Drop the screen:changed subscription too, not just the DOM. The
// refcount guard inside the hook makes a stale one harmless, but the
// listener and its closure would otherwise outlive the control for the
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
// again, binding another hook to the same bus while the old one stays.
// _pcBindScreenHook re-binds on the next acquire.
if (_pcScreenHook) {
try {
const bus = window.feedBack;
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
_pcScreenHook = null;
}
_pcTeardownDom();
}
* Player-chrome background control moved to src/bg-control.js (h3d-carve-5)
* _pcAcquire and _pcRelease are destructured from createBgControl() below.
* DI: BG_STYLE_IDS (1435), _bgReadGlobal (1825), _bgSubscribe/_bgUnsubscribe (1917-18),
* getVenueSceneOverride _venueSceneOverride (1523). All defined before this call.
* First _pcAcquire caller: init() in createFactory() below (~line 15198 pre-cut).
* ====================================================================== */
const { _pcAcquire, _pcRelease } = createBgControl({
BG_STYLE_IDS,
_bgReadGlobal,
_bgSubscribe,
_bgUnsubscribe,
getVenueSceneOverride: () => _venueSceneOverride,
});
/* ======================================================================
* Factory feedBack#36 setRenderer contract
+441
View File
@@ -0,0 +1,441 @@
// h3d-carve-5: player-chrome background control
//
// Verbatim move of the H-section from screen.js (_pc* symbols, lines 3057-3476
// pre-cut). IIFE-scope dependencies injected via factory DI so the module has
// no side-effects at import time.
//
// Beyond-subst (2):
// 1. Factory wrapper — closure vars become DI params.
// 2. `_venueSceneOverride` → `getVenueSceneOverride()` (live accessor, 1 call
// site in _pcSync at what was screen.js:3220).
//
// screen.js usage:
// import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
// const { _pcAcquire, _pcRelease } = createBgControl({
// BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,
// getVenueSceneOverride: () => _venueSceneOverride,
// });
export function createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe, getVenueSceneOverride }) {
/* ======================================================================
* Player-chrome background control
* ======================================================================
* A Background picker mounted into the player's Plugins rail popover, so
* the background can be switched MID-SONG without leaving for Settings.
*
* It writes through the SAME global setters settings.html uses
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
* is persisted here, and the option list is generated from BG_STYLE_IDS —
* add a style there and it shows up in both places automatically.
*
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
* instances but these settings are global — a panel may set a per-panel
* 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
* switches to a non-3D renderer instead of lingering as a dead knob.
*
* Everything here is event-driven. No DOM work on a per-frame path.
*/
// Wording is kept verbatim in sync with settings.html's <option> text so
// the same style is not named two different things in two UIs that sit
// two clicks apart. An id with no entry here falls back to the raw id.
const _PC_LABELS = {
off: 'Off', particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image', video: 'Custom video',
};
// Which settings each background style actually consumes, so a control
// that would do nothing is greyed out instead of lying.
//
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
// build() reads settings.intensity, and uses `reactive` if its update()
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
// 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
// and its row is not updated, the control stays greyed out and lies the
// other way. An id missing from this table defaults to both-enabled, which
// is the safe direction: a new style is assumed to use its settings.
const _PC_USES = {
off: { intensity: false, reactive: false, why: 'No background to adjust' },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
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' },
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;
// Non-disabled wrappers around the two greyable controls. A native-disabled
// <button>/<input> receives no pointer events, so its `title` tooltip never
// shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host
// that does not provide it gets no control (and no error) - the Settings
// page remains the way in.
function _pcSlot() {
try {
// 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;
} catch (_) { return null; }
}
// Visual language: these controls sit in the player's Plugin Controls
// popover alongside pills from other plugins (Invert, Split, Tuner, the
// STEMS group...), so they follow the same look - small rounded pills,
// dark fill, brighter on hover, tinted when active.
//
// Styled INLINE rather than with the Tailwind classes those plugins use
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
// compiled stylesheet and several of those utilities are not in it, so
// using them would mean regenerating assets/plugin.css and bumping the
// manifest version. The values below are the resolved tokens from
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
// #d1d5db), so the result matches without the build step.
const _PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500 (inert controls)
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
onText: '#86efac', // text-green-300
};
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'transition:background-color .15s,color .15s;';
function _pcPill(label, title) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (title) b.title = title;
b.style.cssText = _PC_PILL;
// Hover is a pseudo-class we cannot express inline; these two
// listeners reproduce hover:bg-dark-500 for non-active pills only
// (an active pill keeps its tint on hover, as the other plugins do).
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
return b;
}
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
// when the active background style ignores the setting entirely (see
// _pcSync and _PC_USES) - the pill stays visible so the layout
// does not jump, but it is inert and says why on hover.
function _pcPaint(btn, on, disabled, reason) {
btn._on = !!on && !disabled;
btn.disabled = !!disabled;
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,
// which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : '';
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
btn.style.opacity = disabled ? '.45' : '1';
btn.title = reason || 'React to the audio';
if (disabled) {
btn.style.backgroundColor = _PC_C.idle;
btn.style.color = _PC_C.textDim;
return;
}
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
btn.style.color = on ? _PC_C.onText : _PC_C.text;
}
function _pcGroupLabel(text) {
const el = document.createElement('div');
el.textContent = text;
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
return el;
}
// Pull every control back to what is actually stored. Runs on mount and
// whenever the settings bus reports one of our keys changed, so editing
// from the Settings page updates this control and vice-versa.
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 = !!getVenueSceneOverride(); // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride() // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride()
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) {
// The custom slots stay unselectable until something is uploaded -
// same rule settings.html applies.
const img = _pcSel.querySelector('option[value="image"]');
const vid = _pcSel.querySelector('option[value="video"]');
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
_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';
}
if (_pcReactive) {
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why);
}
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
// enabled so the control's own title takes over.
if (_pcReactiveWrap) {
_pcReactiveWrap.title = uses.reactive ? '' : why;
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
}
if (_pcIntensity) {
_pcIntensity.value = String(_bgReadGlobal('intensity'));
_pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
}
if (_pcIntensityWrap) {
_pcIntensityWrap.title = uses.intensity ? '' : why;
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
}
}
// Mirror the current values into the Settings panel's controls when it's
// in the DOM.
//
// settings.html hydrates ONCE from localStorage when the panel is injected
// and never subscribes to the settings bus, so before this existed there
// was only one writer and it could not go stale. Adding the in-player
// picker made a second writer, and the panel had no way to hear about it —
// change the style mid-song and Settings would still show the old value.
//
// Assigning .value / .checked programmatically does NOT fire a 'change'
// event, so this cannot loop back into the setters.
function _pcSyncSettingsPanel() {
try {
const st = document.getElementById('h3d-bg-style');
if (st) st.value = _bgReadGlobal('style');
const re = document.getElementById('h3d-bg-reactive');
if (re) re.checked = !!_bgReadGlobal('reactive');
const inten = _bgReadGlobal('intensity');
const ie = document.getElementById('h3d-bg-intensity');
if (ie) ie.value = String(inten);
// The panel prints the numeric value beside the slider; keep its
// formatting identical to settings.html's own hydration.
const il = document.getElementById('h3d-bg-intensity-label');
if (il) il.textContent = Number(inten).toFixed(2);
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
}
function _pcMount() {
// A screen change can swap the popover out from under us, orphaning
// the control. Re-resolve only when the cached node is actually gone.
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
if (_pcEl) return true;
const slot = _pcSlot();
if (!slot) return false;
const box = document.createElement('div');
box.className = 'h3d-pc';
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'));
// A dropdown, not pills: the style list is 8 entries and growing, and
// a pill per style dominated a popover whose other controls are single
// toggles. Styled to match the surrounding pills rather than left as a
// raw <select>.
_pcSel = document.createElement('select');
_pcSel.title = 'Background style';
_pcSel.setAttribute('aria-label', 'Background style');
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
for (const id of BG_STYLE_IDS) {
const o = document.createElement('option');
o.value = id;
o.textContent = _PC_LABELS[id] || id;
_pcSel.appendChild(o);
}
_pcSel.addEventListener('change', () => {
if (_pcSel.disabled) return; // inert under the Venue override
try { window.h3dBgSetStyle(_pcSel.value); }
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
});
box.appendChild(_pcSel);
const optWrap = document.createElement('div');
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
_pcReactive = _pcPill('Reactive', 'React to the audio');
_pcReactive.addEventListener('click', () => {
if (_pcReactive.disabled) return;
try { window.h3dBgSetReactive(!_pcReactive._on); }
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
});
optWrap.appendChild(_pcReactive);
box.appendChild(optWrap);
box.appendChild(_pcGroupLabel('Intensity'));
// Wrapper carries the reason on hover when the slider is disabled — a
// native-disabled <input> shows no title of its own.
_pcIntensityWrap = document.createElement('div');
_pcIntensityWrap.style.cssText = 'width:100%;';
_pcIntensity = document.createElement('input');
_pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
_pcIntensity.title = 'Background intensity';
_pcIntensity.setAttribute('aria-label', 'Background intensity');
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
// 'change' (fires on release), NOT 'input'. Every write goes through
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
// background style down and re-runs build(). On 'input' a single drag
// across the range would trigger ~20 full scene rebuilds on the main
// thread mid-playback. settings.html's slider makes the same choice:
// oninput only repaints its label, onchange calls the setter.
_pcIntensity.addEventListener('change', () => {
if (_pcIntensity.disabled) return;
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
});
_pcIntensityWrap.appendChild(_pcIntensity);
box.appendChild(_pcIntensityWrap);
slot.appendChild(box);
_pcEl = box;
_pcSync();
_pcListener = (key) => {
if (key === 'style' || key === 'reactive' || key === 'intensity'
|| 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();
_pcSyncSettingsPanel();
}
};
_bgSubscribe(_pcListener);
return true;
}
function _pcTeardownDom() {
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
}
function _pcAcquire() {
_pcRefs++;
_pcBindScreenHook();
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
// times, then give up quietly — Settings still works.
if (_pcRetryTimer) return;
_pcRetry = 0;
const tick = () => {
_pcRetryTimer = 0;
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 (++_pcRetry > 12) return; // ~3s at 250ms
_pcRetryTimer = setTimeout(tick, 250);
};
_pcRetryTimer = setTimeout(tick, 250);
}
// Re-mount after the player chrome is rebuilt.
//
// _pcMount's isConnected check can only run when something calls it, and
// after the first successful mount nothing did - init() and the retry tick
// are the only callers, and the tick stops on success. So a popover that
// got swapped out left the control gone until the next song change. This
// listener gives that check a real trigger.
//
// Event-driven and cheap: one _pcMount() call per screen change, and it
// early-returns immediately when the cached node is still connected.
let _pcScreenHook = null;
function _pcBindScreenHook() {
if (_pcScreenHook) return;
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
try { bus.on('screen:changed', _pcScreenHook); }
catch (e) { _pcScreenHook = null; }
}
function _pcRelease() {
_pcRefs = Math.max(0, _pcRefs - 1);
if (_pcRefs > 0) return;
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
// Drop the screen:changed subscription too, not just the DOM. The
// refcount guard inside the hook makes a stale one harmless, but the
// listener and its closure would otherwise outlive the control for the
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
// again, binding another hook to the same bus while the old one stays.
// _pcBindScreenHook re-binds on the next acquire.
if (_pcScreenHook) {
try {
const bus = window.feedBack;
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
_pcScreenHook = null;
}
_pcTeardownDom();
}
return { _pcAcquire, _pcRelease };
}
@@ -15,11 +15,10 @@
// style reads `intensity`, and none of them read audio bands under
// Butterchurn, so a live-looking knob that does nothing is a real bug.
//
// screen.js is a single ~16k-line IIFE, so the control cannot be imported. The
// self-contained `_pc*` block is sliced out of the real source and evaluated
// with its few collaborators stubbed (BG_STYLE_IDS, _bgReadSetting,
// _bgSubscribe/_bgUnsubscribe). The slice markers are asserted before use: move
// or rename the block and this fails loudly rather than testing nothing.
// h3d-carve-5: the _pc* block was moved from screen.js to src/bg-control.js.
// load() now evaluates the factory module (stripping the `export` keyword),
// calls createBgControl({DI}) with stubbed deps, and injects test-only getters
// into the return object so the private state vars remain observable.
const { test } = require('node:test');
const assert = require('node:assert/strict');
@@ -27,10 +26,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
const START = ' const _PC_LABELS = {';
const END_CRLF = ' /* ======================================================================\r\n * Factory';
const END_LF = ' /* ======================================================================\n * Factory';
const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
const BG_CONTROL_JS = path.join(__dirname, '..', 'src', 'bg-control.js');
// What each style is expected to consume, derived by reading the BG_STYLES
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
@@ -102,14 +99,24 @@ function makeDom() {
}
function load({ store: initialStore } = {}) {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const start = src.indexOf(START);
assert.notEqual(start, -1, 'could not find the _PC_LABELS marker in screen.js');
let end = src.indexOf(END_CRLF);
if (end === -1) end = src.indexOf(END_LF);
assert.notEqual(end, -1, 'could not find the Factory banner marker in screen.js');
assert.ok(end > start, 'slice markers found out of order in screen.js');
const block = src.slice(start, end);
// h3d-carve-5: load from src/bg-control.js (factory module) instead of
// slicing screen.js. Strip `export` for vm eval; inject test-only getters
// into the return object so private _pc* state vars remain observable.
const bgSrc = fs.readFileSync(BG_CONTROL_JS, 'utf8');
const stripped = bgSrc.replace(/^export\s+/gm, '');
// Augment the factory's return with accessor getters for private state so
// all existing test assertions (api.el, api.sel, api.refs, ...) keep working.
const instrumented = stripped.replace(
/return\s*\{\s*_pcAcquire\s*,\s*_pcRelease\s*\}/,
'return { _pcAcquire, _pcRelease,'
+ ' get el() { return _pcEl; },'
+ ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } }',
);
assert.notEqual(instrumented, stripped, 'return-augmentation anchor not found in bg-control.js');
const dom = makeDom();
const store = Object.assign({
@@ -126,14 +133,12 @@ function load({ store: initialStore } = {}) {
const writes = [];
const timers = [];
// DI dependencies as vm-globals. Tests mutate sandbox._venueSceneOverride
// directly; getVenueSceneOverride in the factory call reads it via the vm global.
const sandbox = {
console,
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],
_bgReadGlobal: (key) => store[key],
_bgSubscribe: (fn) => listeners.add(fn),
_bgUnsubscribe: (fn) => listeners.delete(fn),
@@ -165,18 +170,18 @@ function load({ store: initialStore } = {}) {
},
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
const api = vm.runInNewContext(
block
+ '\n({ _pcAcquire, _pcRelease,'
+ ' get el() { return _pcEl; },'
+ ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } })',
// Step 1: define createBgControl in the vm context.
vm.runInContext(instrumented, sandbox);
// Step 2: call the factory; DI values are vm-globals so the call names them directly.
const api = vm.runInContext(
'createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,'
+ ' getVenueSceneOverride: () => _venueSceneOverride })',
sandbox,
);
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
const screenHooks = () => (bus['screen:changed'] || []).length;
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
+253
View File
@@ -0,0 +1,253 @@
// Class-killer tests for src/bg-control.js — h3d-carve-5.
//
// bg-control.js uses a factory export (createBgControl({DI})) because its
// dependencies are IIFE-scope values that cannot be ES-module imports.
// screen.js destructures { _pcAcquire, _pcRelease } from the factory result.
//
// Test strategy:
// - Source-scan tests check structural invariants (critical paths, DI wiring,
// accessor call site, tombstone).
// - Screen.js wiring tests check the import clause and destructure form.
// - Generic stranded-caller test (adapted from bc-panel.js test 12) checks
// that every _pc* symbol in the bg-control.js factory return is also in
// screen.js's createBgControl destructure — a bare _pcFoo reference in the
// IIFE that isn't in the destructure is the same stranded-caller bug class.
// - Construction-order test: createBgControl call must appear AFTER all DI
// definitions in screen.js and BEFORE createFactory.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BG_CONTROL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bg-control.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() { if (!_src) _src = fs.readFileSync(BG_CONTROL_JS, 'utf8'); return _src; }
let _screenSrc;
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
// ── 1. createBgControl is exported (not private) ──────────────────────────────
test('createBgControl is exported from bg-control.js', () => {
// Mutation: remove `export` → screen.js import throws SyntaxError /
// "does not provide an export" at module-graph load time → highway never
// initialises; all 3D-Hwy users see a blank canvas.
assert.match(src(), /^export\s+function\s+createBgControl\s*\(/m,
'createBgControl must be a line-start export function declaration');
});
// ── 2. DI params declared (all five) ─────────────────────────────────────────
test('createBgControl destructures all five DI params', () => {
// Mutation: remove one DI param → that function is `undefined` inside the
// factory → every call to e.g. _bgReadGlobal throws TypeError: not a function.
const s = src();
const sig = s.match(/export\s+function\s+createBgControl\s*\(\s*\{([^}]+)\}/);
assert.ok(sig, 'createBgControl signature must use destructuring params');
const params = sig[1];
assert.match(params, /BG_STYLE_IDS/, 'DI must include BG_STYLE_IDS');
assert.match(params, /_bgReadGlobal/, 'DI must include _bgReadGlobal');
assert.match(params, /_bgSubscribe/, 'DI must include _bgSubscribe');
assert.match(params, /_bgUnsubscribe/, 'DI must include _bgUnsubscribe');
assert.match(params, /getVenueSceneOverride/, 'DI must include getVenueSceneOverride');
});
// ── 3. getVenueSceneOverride() called as function (not captured at construction) ──
test('_pcSync calls getVenueSceneOverride() not _venueSceneOverride directly', () => {
// Mutation: revert beyond-subst 2 to `!!_venueSceneOverride` → factory
// captures the initial `false` at construction time; the accessor is never
// called; Venue-active state is always `false` → UI never goes inert under
// Venue; user can "pick" a background while Venue scene is active but the
// pick goes nowhere because Venue owns the mount.
const s = src();
// Must call the accessor (with parens).
assert.match(s, /getVenueSceneOverride\(\)/,
'_pcSync must call getVenueSceneOverride() rather than capturing the var at construction');
// Must NOT contain the raw closure variable name in executable code (bare, without call
// parens). Strip line comments first so the comment-doc in the file header doesn't fire.
const noLineComments = s.replace(/\/\/[^\n]*/g, '');
assert.doesNotMatch(noLineComments, /\b_venueSceneOverride\b/,
'bg-control.js must not reference bare _venueSceneOverride in code — use getVenueSceneOverride()');
});
// ── 4. _bgSubscribe called inside _pcMount ────────────────────────────────────
test('_bgSubscribe is called inside _pcMount to register the settings listener', () => {
// Mutation: remove _bgSubscribe call → control mounts but never receives
// settings-bus events; a style change from Settings page never syncs back
// to the in-player picker; the two UIs drift permanently.
const s = src();
const mountIdx = s.indexOf('function _pcMount()');
assert.ok(mountIdx >= 0, '_pcMount must be defined in bg-control.js');
const mountBlock = s.slice(mountIdx, mountIdx + 6000); // _bgSubscribe ~5200 chars in
assert.match(mountBlock, /_bgSubscribe\s*\(/,
'_bgSubscribe must be called inside _pcMount to register the listener');
});
// ── 5. _bgUnsubscribe called inside _pcTeardownDom ───────────────────────────
test('_bgUnsubscribe is called inside _pcTeardownDom to deregister the listener', () => {
// Mutation: remove _bgUnsubscribe call → listener closure outlives the control;
// after release the stale closure still calls _pcSync on every settings change;
// null refs (_pcSel etc.) throw on first setting write post-teardown.
const s = src();
const teardownIdx = s.indexOf('function _pcTeardownDom()');
assert.ok(teardownIdx >= 0, '_pcTeardownDom must be defined in bg-control.js');
const teardownBlock = s.slice(teardownIdx, teardownIdx + 500);
assert.match(teardownBlock, /_bgUnsubscribe\s*\(/,
'_bgUnsubscribe must be called inside _pcTeardownDom to remove the listener');
});
// ── 6. _pcRelease calls _pcTeardownDom ───────────────────────────────────────
test('_pcRelease calls _pcTeardownDom when refcount reaches zero', () => {
// Mutation: remove _pcTeardownDom() call from _pcRelease → DOM node is
// never removed; the settings listener stays alive; under splitscreen each
// renderer destroys independently but the control never disappears → orphaned
// picker remains visible and partially interactive after 3D-Hwy is deselected.
const s = src();
const releaseIdx = s.indexOf('function _pcRelease()');
assert.ok(releaseIdx >= 0, '_pcRelease must be defined in bg-control.js');
const releaseBlock = s.slice(releaseIdx, releaseIdx + 1200); // _pcTeardownDom ~1040 chars in
assert.match(releaseBlock, /_pcTeardownDom\s*\(\s*\)/,
'_pcRelease must call _pcTeardownDom() when refcount reaches zero');
});
// ── 7. _pcAcquire and _pcRelease returned from factory ───────────────────────
test('createBgControl returns { _pcAcquire, _pcRelease }', () => {
// Mutation: remove either from return → screen.js destructure gets undefined;
// first call to _pcAcquire / _pcRelease from init()/destroy() throws
// TypeError: not a function → highway init crashes on every song load.
const s = src();
const returnMatch = s.match(/return\s*\{([^}]+)\}/);
assert.ok(returnMatch, 'createBgControl must have a return { ... } statement');
const returned = returnMatch[1];
assert.match(returned, /_pcAcquire/, 'createBgControl must return _pcAcquire');
assert.match(returned, /_pcRelease/, 'createBgControl must return _pcRelease');
});
// ── 8. screen.js imports createBgControl from src/bg-control.js ──────────────
test('screen.js imports createBgControl from src/bg-control.js', () => {
// Mutation: remove import → createBgControl is undefined in the IIFE;
// the destructure const { _pcAcquire, _pcRelease } = createBgControl({...})
// throws TypeError at module eval time → plugin never loads.
assert.match(screenSrc(),
/import\s+\{[^}]*createBgControl[^}]*\}\s+from\s+['"]\.\/src\/bg-control\.js['"]/,
'screen.js must import createBgControl from ./src/bg-control.js');
});
// ── 9. screen.js calls createBgControl with all five DI args ─────────────────
test('screen.js passes all five DI arguments to createBgControl', () => {
// Mutation: omit one DI arg → the corresponding param is `undefined` inside
// the factory closure; first call to it (on mount, on settings change) throws.
const s = screenSrc();
const callMatch = s.match(/createBgControl\s*\(\s*\{([^}]+)\}/);
assert.ok(callMatch, 'screen.js must call createBgControl({...})');
const args = callMatch[1];
assert.match(args, /BG_STYLE_IDS/, 'createBgControl call must pass BG_STYLE_IDS');
assert.match(args, /_bgReadGlobal/, 'createBgControl call must pass _bgReadGlobal');
assert.match(args, /_bgSubscribe/, 'createBgControl call must pass _bgSubscribe');
assert.match(args, /_bgUnsubscribe/, 'createBgControl call must pass _bgUnsubscribe');
assert.match(args, /getVenueSceneOverride/, 'createBgControl call must pass getVenueSceneOverride');
});
// ── 10. screen.js IIFE does not redefine _pcAcquire or _pcRelease ────────────
test('screen.js IIFE does not redeclare _pcAcquire or _pcRelease', () => {
// Mutation: re-add `function _pcAcquire()` to the IIFE → IIFE-scope function
// shadows the destructured import; the factory's _pcRelease holds a stale
// closure over the old _pcRefs; refcount goes out of sync; the control
// never unmounts.
const s = screenSrc();
const iife = s.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_pcAcquire\s*\(/,
'IIFE must not redeclare _pcAcquire');
assert.doesNotMatch(iife, /function\s+_pcRelease\s*\(/,
'IIFE must not redeclare _pcRelease');
});
// ── 11. Construction order: createBgControl called before createFactory ───────
test('createBgControl call appears before createFactory in screen.js', () => {
// Mutation: move createBgControl call inside createFactory → each renderer
// instance gets its own independent control (refcount broken across instances);
// or if moved after createFactory but before register, correct for
// single-instance but still wrong order risk. This test ensures the call
// stays at module scope BEFORE the factory.
const s = screenSrc();
const bgCallIdx = s.indexOf('createBgControl(');
const factoryIdx = s.indexOf('function createFactory()');
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
assert.ok(factoryIdx >= 0, 'createFactory must exist in screen.js');
assert.ok(bgCallIdx < factoryIdx,
'createBgControl must be called before createFactory in screen.js');
});
// ── 12. Construction order: DI values defined before createBgControl call ─────
test('all DI values are defined before the createBgControl call in screen.js', () => {
// Mutation: move createBgControl call before BG_STYLE_IDS / _bgReadGlobal /
// _bgSubscribe / _bgUnsubscribe / getVenueSceneOverride binding →
// undefined passed as DI params; factory closure captures undefined → TypeError.
const s = screenSrc();
const bgCallIdx = s.indexOf('createBgControl(');
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
const bgStyleIdsIdx = s.indexOf('BG_STYLE_IDS =');
const bgReadIdx = s.indexOf('function _bgReadGlobal(');
const bgSubIdx = s.indexOf('function _bgSubscribe(');
const venueIdx = s.indexOf('let _venueSceneOverride');
assert.ok(bgStyleIdsIdx < bgCallIdx, 'BG_STYLE_IDS must be defined before createBgControl call');
assert.ok(bgReadIdx < bgCallIdx, '_bgReadGlobal must be defined before createBgControl call');
assert.ok(bgSubIdx < bgCallIdx, '_bgSubscribe must be defined before createBgControl call');
assert.ok(venueIdx < bgCallIdx, '_venueSceneOverride must be defined before createBgControl call');
});
// ── 13. Generic stranded-caller: _pc* returned symbols in screen.js destructure ─
test('every _pc* symbol returned by createBgControl is in the screen.js destructure', () => {
// Adapted from bc-panel.js test 12 for the factory pattern.
// For a factory module, the stranded-caller class is: a symbol that appears
// in the `return { ... }` of createBgControl but is NOT in the `const { ... }
// = createBgControl(...)` destructure in screen.js — meaning the symbol is
// exported at runtime but screen.js never binds it, so any IIFE caller of
// that symbol hits ReferenceError (or the undefined stub from a stale
// function-scope redeclaration).
//
// Mutation: add `_pcNewFn` to bg-control.js return but not to screen.js
// destructure → leaked is non-empty → test RED.
const bgSrc = src();
const scrSrc = screenSrc();
// Symbols in the return { ... } of createBgControl.
const returnMatch = bgSrc.match(/return\s*\{([^}]+)\}/);
assert.ok(returnMatch, 'bg-control.js must have a return { ... } statement');
const returned = new Set(
returnMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
// Symbols in the screen.js destructure.
const destructureMatch = scrSrc.match(/const\s*\{\s*([^}]+)\}\s*=\s*createBgControl\s*\(/);
assert.ok(destructureMatch, 'screen.js must destructure the createBgControl result');
const destructured = new Set(
destructureMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
// Strip import lines and line comments from IIFE body to avoid false positives.
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
const noComments = noImports.replace(/\/\/[^\n]*/g, '');
// Returned symbols referenced in the IIFE body but absent from the destructure.
const leaked = [...returned].filter(
sym => new RegExp('\\b' + sym + '\\b').test(noComments) && !destructured.has(sym)
);
assert.deepStrictEqual(leaked, [],
'screen.js references createBgControl return symbols not in its destructure: ' +
leaked.join(', '));
});
@@ -100,6 +100,10 @@ function loadHighway3dStatics() {
reconnectAudio() { return false; }, chart() {}, tint() {}, render() {},
resize() {}, destroy() {},
}),
// h3d-carve-5: player-chrome bg-control moved to src/bg-control.js.
createBgControl: () => ({
_pcAcquire() {}, _pcRelease() {},
}),
};
vm.createContext(sandbox);
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });