From 9f0d48cb1f6d8cbe2146841c6ba876fe4bdf5805 Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 01:53:45 -0400 Subject: [PATCH 01/10] 3D highway wide-pane tuner: dismiss + per-pane targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two usability gaps in the wide-pane framing tuner: - No way to dismiss the panel. Add a × close button to the header and make the Shift+A shortcut open/close the panel (reveal/dismiss). The A/B enabled toggle now lives as a checkbox in the panel, so closing the panel no longer changes the framing state. - Edits hit every split pane at once. Add a Target selector (All panes, or a specific pane labelled by its arrangement, e.g. "Panel 1 — Rhythm"). Per- pane edits write a sparse override map (__panels[key]); each renderer resolves the shared base with its own pane's overrides laid on top via _resolveTuneFor(paneKey), so one pane can be framed independently. Reset on a pane clears its override (re-inherits the base); Copy exports the resolved values for the selected target. The live readout is keyed per pane. Panes are discovered from the existing per-panel key (_bgPanelKey / feedBackSplitscreen.panelIndexFor) and self-register each frame for the picker. Overrides persist to localStorage alongside the base. Tests extended in tests/js/highway_3d_wide_fov.test.js. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 251 ++++++++++++++++++++------- tests/js/highway_3d_wide_fov.test.js | 59 ++++++- 2 files changed, 236 insertions(+), 74 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index ae7f8a7..beeaf12 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1607,14 +1607,13 @@ ss.isCanvasFocused(highwayCanvas)); } - // A/B toggle for the wide-pane horizontal-FOV-hold. Flips - // window.__h3dAspectTune.enabled so the running app can switch between the - // current framing (off, the baseline) and the Hor+ framing (on) with one - // keypress, across all panes at once. Registered once per session via a - // module-level guard (it toggles a shared global, so per-instance - // registration would stack duplicate handlers and cancel itself out); it's - // a harmless debug control, so it is never unregistered. No-ops where the - // core shortcut API isn't present (older core / borrowed contexts). + // Shortcut for the wide-pane framing tuner. Opens/closes the floating panel + // (the A/B on/off and the per-pane target live inside it now). Registered + // once per session via a module-level guard (it drives shared module state, + // so per-instance registration would stack duplicate handlers and cancel + // itself out); it's a harmless debug control, so it is never unregistered. + // No-ops where the core shortcut API isn't present (older core / borrowed + // contexts). let _abShortcutRegistered = false; function _registerAspectAbShortcut() { if (_abShortcutRegistered) return; @@ -1623,17 +1622,13 @@ try { window.registerShortcut({ key: 'A', // uppercase e.key → produced with Shift held (Shift+A) - description: '3D Highway: toggle wide-pane framing A/B (Shift+A)', + description: '3D Highway: open/close wide-pane framing tuner (Shift+A)', scope: 'player', handler: () => { - const t = _aspectTune(); - t.enabled = !t.enabled; - try { console.log('[h3d] wide-pane framing', t.enabled ? 'ON' : 'OFF'); } catch (e) {} - // Surface the live tuner panel whenever the feature is on, - // hide it when off. Built lazily on first use. - _ensureAspectPanel(); - _setAspectPanelVisible(t.enabled); - _syncAspectPanel(); + // Open/close the live tuner panel. The A/B on/off and the + // per-pane target now live in the panel itself, so the + // shortcut is just a dismiss/reveal. + _toggleAspectPanel(); }, }); } catch (e) { @@ -1685,8 +1680,19 @@ let _aspectPanelEl = null; // the floating panel root (built once) let _aspectPanelRO = null; // readout
let _aspectPanelRAF = 0; // readout poll handle + let _aspectTargetSel = null; // the "Target" '); + assert.match(src, /function\s+_aspectRegisterPane\s*\(/, + '_aspectRegisterPane must record live panes for the picker'); + assert.match(src, /_aspectRegisterPane\(\s*_paneKey\s*,/, + 'camUpdate must register its pane each frame'); +}); + +test('the panel has a dismiss (close) control', () => { + assert.match( + src, + /close\.textContent\s*=\s*'×'[\s\S]*?_setAspectPanelVisible\(\s*false\s*\)/, + 'the panel header must have a × button that hides the panel', + ); }); test('camUpdate only writes cam.fov when it actually changes', () => { @@ -153,14 +192,16 @@ test('camUpdate only writes cam.fov when it actually changes', () => { ); }); -// ── A/B toggle + lifecycle reset ────────────────────────────────────────────── +// ── Shortcut (open/close) + lifecycle reset ─────────────────────────────────── -test('an A/B toggle shortcut flips the tune enabled flag', () => { +test('the shortcut opens/closes the tuner panel', () => { assert.match( src, - /registerShortcut\(\{[\s\S]*?const\s+t\s*=\s*_aspectTune\(\)\s*;[\s\S]*?t\.enabled\s*=\s*!\s*t\.enabled/, - 'a registerShortcut handler must toggle the bridge enabled flag', + /registerShortcut\(\{[\s\S]*?_toggleAspectPanel\(\)/, + 'a registerShortcut handler must toggle the tuner panel', ); + assert.match(src, /function\s+_toggleAspectPanel\s*\(\)/, + '_toggleAspectPanel() must exist to reveal/dismiss the panel'); }); test('destroy() resets the pane aspect and restores the base fov', () => { From 6c42d83c313370aea7efffbbf5bb397d2aabf423 Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:01:41 -0400 Subject: [PATCH 02/10] Fix flickering / wrong panes in the wide-pane Target picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Target dropdown keyed panes off feedBackSplitscreen.panelIndexFor, which can return the focused index for any canvas — so both split panes' keys ping-ponged, rebuilding the is focused. - Hide the Target row entirely when there's a single pane. - Label panes by arrangement name, falling back to "Pane N". Per-pane overrides are now session-only (keyed by ephemeral instance ids), so they're no longer persisted to localStorage. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 73 +++++++++++++++++++++------- tests/js/highway_3d_wide_fov.test.js | 18 +++++++ 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index beeaf12..cad20cc 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1681,15 +1681,26 @@ let _aspectPanelRO = null; // readout
let _aspectPanelRAF = 0; // readout poll handle let _aspectTargetSel = null; // the "Target" . let _aspectPanesDirty = true; + // Monotonic counter handing each renderer instance a stable pane id. + let _aspectPaneCounter = 0; + function _aspectNowMs() { + try { return (performance && performance.now) ? performance.now() : 0; } catch (e) { return 0; } + } + function _aspectPaneLabel(arrangement, uid) { + const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; + return a || ('Pane ' + uid); + } // Get-or-create the shared bridge object, seeded from defaults + localStorage. // May carry a sparse `__panels` map of per-pane overrides. @@ -1708,8 +1719,10 @@ function _aspectPersist() { try { const t = _aspectTune(), out = {}; + // Persist only the shared base. Per-pane overrides (__panels) are + // keyed by ephemeral instance ids, so they're intentionally + // session-only — persisting them would leak stale keys forever. Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); - if (t.__panels) out.__panels = t.__panels; localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); } catch (e) {} } @@ -1724,18 +1737,27 @@ Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; }); return out; } - // Record a live pane so the Target dropdown can list it. Label prefers the - // arrangement name (e.g. "Panel 1 — Rhythm") so panes are easy to tell apart. - function _aspectRegisterPane(paneKey, arrangement) { + // Record a live pane so the Target dropdown can list it. Called every frame + // by each renderer with its stable pane key + arrangement. `seen` is + // refreshed each call for pruning; the dropdown is only marked dirty when a + // pane is newly added or its label first resolves — not on every re-report, + // which would flicker the . let _aspectPanesDirty = true; - // Monotonic counter handing each renderer instance a stable pane id. - let _aspectPaneCounter = 0; function _aspectNowMs() { try { return (performance && performance.now) ? performance.now() : 0; } catch (e) { return 0; } } - function _aspectPaneLabel(arrangement, uid) { + // Human label for a slot key: "Main", or "Panel N" (+ " — Arrangement"). + function _aspectPaneLabel(paneKey, arrangement) { + let base; + if (paneKey === 'main') { base = 'Main'; } + else { const n = parseInt(paneKey.slice(5), 10); base = 'Panel ' + ((isFinite(n) ? n : 0) + 1); } const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; - return a || ('Pane ' + uid); + return a ? (base + ' — ' + a) : base; } // Get-or-create the shared bridge object, seeded from defaults + localStorage. @@ -1719,10 +1721,11 @@ function _aspectPersist() { try { const t = _aspectTune(), out = {}; - // Persist only the shared base. Per-pane overrides (__panels) are - // keyed by ephemeral instance ids, so they're intentionally - // session-only — persisting them would leak stale keys forever. Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); + // Persist per-pane overrides too. Keys are durable split slots + // ('main' | 'panel'), a bounded set, so a pane's framing carries + // over when the user leaves a song and opens another in the same slot. + if (t.__panels) out.__panels = t.__panels; localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); } catch (e) {} } @@ -1742,9 +1745,9 @@ // refreshed each call for pruning; the dropdown is only marked dirty when a // pane is newly added or its label first resolves — not on every re-report, // which would flicker the . let _aspectPanesDirty = true; + // Monotonic counter for the per-instance fallback key (when a pane has no + // arrangement name to key by). + let _aspectPaneCounter = 0; function _aspectNowMs() { try { return (performance && performance.now) ? performance.now() : 0; } catch (e) { return 0; } } - // Human label for a slot key: "Main", or "Panel N" (+ " — Arrangement"). - function _aspectPaneLabel(paneKey, arrangement) { - let base; - if (paneKey === 'main') { base = 'Main'; } - else { const n = parseInt(paneKey.slice(5), 10); base = 'Panel ' + ((isFinite(n) ? n : 0) + 1); } + // Pane key: prefer the arrangement name ('arr:Bass') so a pane's framing is + // stable across songs AND distinct between split panes, with no dependency on + // the external splitscreen panel index (which isn't always available). Fall + // back to a per-instance id ('pane:3') when there's no arrangement. + function _aspectPaneKey(arrangement, uid) { const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; - return a ? (base + ' — ' + a) : base; + return a ? ('arr:' + a) : ('pane:' + uid); + } + // Human label derived from the key. + function _aspectPaneLabel(paneKey) { + if (paneKey.slice(0, 4) === 'arr:') return paneKey.slice(4); + if (paneKey.slice(0, 5) === 'pane:') return 'Pane ' + paneKey.slice(5); + return paneKey; } // Get-or-create the shared bridge object, seeded from defaults + localStorage. @@ -1722,10 +1731,17 @@ try { const t = _aspectTune(), out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); - // Persist per-pane overrides too. Keys are durable split slots - // ('main' | 'panel'), a bounded set, so a pane's framing carries - // over when the user leaves a song and opens another in the same slot. - if (t.__panels) out.__panels = t.__panels; + // Persist per-pane overrides keyed by arrangement ('arr:*') only, so a + // pane's framing carries across songs. Instance-id fallback keys + // ('pane:*') are session-only — persisting them would leak a new key + // every reload. + if (t.__panels) { + const p = {}; let any = false; + Object.keys(t.__panels).forEach((k) => { + if (k.slice(0, 4) === 'arr:') { p[k] = t.__panels[k]; any = true; } + }); + if (any) out.__panels = p; + } localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); } catch (e) {} } @@ -1741,13 +1757,12 @@ return out; } // Record a live pane so the Target dropdown can list it. Called every frame - // by each renderer with its stable pane key + arrangement. `seen` is - // refreshed each call for pruning; the dropdown is only marked dirty when a - // pane is newly added or its label first resolves — not on every re-report, - // which would flicker the . + function _aspectRegisterPane(paneKey) { const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {}); - const label = _aspectPaneLabel(paneKey, arrangement); + const label = _aspectPaneLabel(paneKey); let e = reg[paneKey]; if (!e) { e = reg[paneKey] = { label, seen: 0 }; _aspectPanesDirty = true; } else if (e.label !== label) { e.label = label; _aspectPanesDirty = true; } @@ -3918,14 +3933,11 @@ // __h3dAspectTune edits) without waiting for a resize. 0 until first // applySize(). let _paneAspect = 0; - // Latched split-slot key for the wide-pane tuner ('main' | 'panel'). - // Keyed by the durable split slot (via _bgPanelKey) so a pane's overrides - // persist across songs — the same slot means the same pane to the user. - // Latched to the last real slot so a transient null from panelIndexFor - // during a song/layout transition doesn't momentarily flip it to 'main' - // and drop the override for a frame. Reset in destroy() for instance - // reuse in a different slot. - let _paneKeyCached = ''; + // Per-instance fallback id for the wide-pane tuner's pane key, used only + // when this pane has no arrangement name to key by. Assigned once in + // init(); overrides keyed off arrangement persist across songs, this + // fallback is session-only. + let _paneUid = 0; // True once applySize() has pinned the .h3d-wrap overlay to the // highway canvas's offset box. Stays false while the canvas has no // layout yet (init() can run before #highway has a real box, where @@ -14387,10 +14399,9 @@ // effectiveVfov returns the base vertical fov and cam.fov is restored // to it. The fov write is guarded on an actual change so a steady pane // costs nothing. - const _pk0 = _bgPanelKey(highwayCanvas); - if (_pk0 !== 'main') _paneKeyCached = _pk0; // latch the real slot; ignore transient nulls - const _paneKey = _paneKeyCached || _pk0; - _aspectRegisterPane(_paneKey, bundle && bundle.songInfo && bundle.songInfo.arrangement); + const _paneKey = _aspectPaneKey( + bundle && bundle.songInfo && bundle.songInfo.arrangement, _paneUid); + _aspectRegisterPane(_paneKey); const _aspTune = _resolveTuneFor(_paneKey); const _aspActive = !!(_aspTune && _aspTune.enabled && !(_aspTune.splitOnly && !_ssActive())); @@ -14842,6 +14853,7 @@ } _destroyed = _isReady = false; _isFocused = true; + if (!_paneUid) _paneUid = ++_aspectPaneCounter; // fallback pane id (no-arrangement panes) _registerAspectAbShortcut(); // session-global tuner shortcut (self-guarded) const myToken = ++_initToken; highwayCanvas = canvas; @@ -15262,7 +15274,6 @@ _lastHwW = 0; _lastHwH = 0; _appliedW = 0; _appliedH = 0; _paneAspect = 0; - _paneKeyCached = ''; if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); } _wrapPinned = false; _unsubscribeFocus(); teardown(); diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index 060d522..57e9001 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -170,25 +170,31 @@ test('a Target select and pane registry drive the per-pane picker', () => { 'the panel must build a Target so screen readers can identify the control. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index c73e3e2..ac0c3a5 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1866,6 +1866,7 @@ title.textContent = 'Wide-pane framing'; title.style.cssText = 'font-weight:700;color:#e8c040;'; const close = document.createElement('button'); + close.type = 'button'; // never submit if nested in a
close.textContent = '×'; close.title = 'Close (Shift+A)'; close.setAttribute('aria-label', 'Close'); @@ -1879,6 +1880,7 @@ const tgtLab = document.createElement('div'); tgtLab.textContent = 'Target'; tgtLab.style.cssText = 'color:#9fb0c8;margin-bottom:2px;'; _aspectTargetSel = document.createElement('select'); + _aspectTargetSel.setAttribute('aria-label', 'Target pane'); _aspectTargetSel.style.cssText = 'width:100%;background:rgba(30,44,66,0.9);color:#cfe0f5;border:1px solid rgba(120,150,200,0.4);border-radius:4px;padding:3px;'; _aspectTargetSel.addEventListener('change', () => { _aspectEditTarget = _aspectTargetSel.value; _syncAspectPanel(); @@ -1961,6 +1963,7 @@ btnRow.style.cssText = 'display:flex;gap:6px;margin-top:8px;'; const mkBtn = (txt, fn) => { const b = document.createElement('button'); + b.type = 'button'; // never submit if nested in a b.textContent = txt; b.style.cssText = 'flex:1;padding:4px 0;border-radius:5px;border:1px solid rgba(120,150,200,0.4);background:rgba(40,60,90,0.6);color:#cfe0f5;cursor:pointer;font:11px system-ui;'; b.addEventListener('click', fn); From 58047e6ad6380360ce1c87b0d199249825d52f4a Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:38:58 -0400 Subject: [PATCH 07/10] Address review: force target to All when the pane picker is hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When only one pane is live the Target row is hidden, but _aspectEditTarget could remain a specific pane key — silently routing edits into a hidden (and persistent arr:*) override in single-player. Reset the edit target to "" in _aspectBuildTargets whenever the row is hidden (or the selected pane is gone), so single-pane edits always go to the shared base. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 8 +++++++- tests/js/highway_3d_wide_fov.test.js | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index ac0c3a5..6556367 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1837,7 +1837,13 @@ o.value = pk; o.textContent = reg[pk].label; _aspectTargetSel.appendChild(o); }); - if (_aspectEditTarget && !reg[_aspectEditTarget]) _aspectEditTarget = ''; + // Force the edit target back to "All" when the Target row is hidden + // (single pane) or the selected pane is gone — otherwise a stale pane + // target would silently route edits into a hidden (and persistent + // arr:*) override in single-player. + if (keys.length <= 1 || (_aspectEditTarget && !reg[_aspectEditTarget])) { + _aspectEditTarget = ''; + } _aspectTargetSel.value = _aspectEditTarget; // The Target row only matters with more than one pane (a split). With a // single pane there's nothing to disambiguate, so hide it. diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index abf80df..f3bee04 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -235,6 +235,14 @@ test('pruning drops the matching readout slot and a dangling __last', () => { ); }); +test('single-pane forces the edit target back to All (no hidden pane edits)', () => { + assert.match( + src, + /if\s*\(\s*keys\.length\s*<=\s*1\s*\|\|\s*\(\s*_aspectEditTarget\s*&&\s*!reg\[_aspectEditTarget\]\s*\)\s*\)\s*\{\s*_aspectEditTarget\s*=\s*''/, + '_aspectBuildTargets must reset the edit target to "" when the Target row is hidden', + ); +}); + test('the panel has a dismiss (close) control', () => { assert.match( src, From 24d24ef2cf8cfe9bf1e7b7e8cc4bbd13664090fe Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:47:08 -0400 Subject: [PATCH 08/10] Address review: resolve cache, Date.now fallback, prune-on-open, rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Memoize _resolveTuneFor per pane, invalidated by a revision bumped on every tune mutation (all writes funnel through _aspectPersist). Panes with an override no longer rebuild the merged object every frame; panes without one still return the base directly. - _aspectNowMs falls back to Date.now() when the Performance API is absent, so pane/readout pruning still works in older/borrowed contexts. - _setAspectPanelVisible prunes stale panes before the first dropdown build, so panes from a prior song/split don't flash until the first RAF tick. - Rename _abShortcutRegistered/_registerAspectAbShortcut to _tunerShortcutRegistered/_registerTunerShortcut — the shortcut opens/closes the tuner now, it isn't an A/B toggle. - Fix a stale 'pane1' example in a comment (keys are 'arr:'/'pane:'). Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 36 +++++++++++++++++++--------- tests/js/highway_3d_wide_fov.test.js | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 6556367..25198bf 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1614,11 +1614,11 @@ // itself out); it's a harmless debug control, so it is never unregistered. // No-ops where the core shortcut API isn't present (older core / borrowed // contexts). - let _abShortcutRegistered = false; - function _registerAspectAbShortcut() { - if (_abShortcutRegistered) return; + let _tunerShortcutRegistered = false; + function _registerTunerShortcut() { + if (_tunerShortcutRegistered) return; if (typeof window.registerShortcut !== 'function') return; - _abShortcutRegistered = true; + _tunerShortcutRegistered = true; try { window.registerShortcut({ key: 'A', // uppercase e.key → produced with Shift held (Shift+A) @@ -1632,7 +1632,7 @@ }, }); } catch (e) { - _abShortcutRegistered = false; // allow a later retry if it threw + _tunerShortcutRegistered = false; // allow a later retry if it threw } } @@ -1685,8 +1685,8 @@ let _aspectHfovCb = null; // hfov-override checkbox (synced explicitly) let _aspectHfovSl = null; // hfov-override slider // Which pane the panel edits. '' = all panes (writes the shared base object); - // a pane key (e.g. 'pane1') writes that pane's sparse override, so one split - // pane can be framed independently of the others. + // a pane key ('arr:' or the fallback 'pane:') writes that pane's + // sparse override, so one split pane can be framed independently. let _aspectEditTarget = ''; // Bumped when the SET of live panes changes (add/prune) so the panel rebuilds // the Target dropdown — never on a per-frame label re-report, which would @@ -1696,7 +1696,8 @@ // arrangement name to key by). let _aspectPaneCounter = 0; function _aspectNowMs() { - try { return (performance && performance.now) ? performance.now() : 0; } catch (e) { return 0; } + try { if (performance && performance.now) return performance.now(); } catch (e) {} + try { return Date.now(); } catch (e) { return 0; } // keep pruning functional } // Pane key: prefer the arrangement name ('arr:Bass') so a pane's framing is // stable across songs AND distinct between split panes, with no dependency on @@ -1727,7 +1728,11 @@ } return t; } + // Bumped on every tune mutation (all writes funnel through _aspectPersist) so + // the per-pane resolve cache below can invalidate cheaply. + let _aspectRev = 0; function _aspectPersist() { + _aspectRev++; try { const t = _aspectTune(), out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); @@ -1747,13 +1752,20 @@ } // Resolve the effective tune for a pane: the shared base, with that pane's - // override keys (if any) laid on top. Called every frame per renderer. + // override keys (if any) laid on top. Called every frame per renderer, so the + // merged object is memoized per pane and only rebuilt when the tune mutates + // (_aspectRev changes). Panes with no override return the base directly (no + // allocation). + const _aspectResolveCache = new Map(); // paneKey -> { rev, obj } function _resolveTuneFor(paneKey) { const base = _aspectTune(); const ov = base.__panels && base.__panels[paneKey]; if (!ov) return base; + const c = _aspectResolveCache.get(paneKey); + if (c && c.rev === _aspectRev) return c.obj; const out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; }); + _aspectResolveCache.set(paneKey, { rev: _aspectRev, obj: out }); return out; } // Record a live pane so the Target dropdown can list it. Called every frame @@ -2037,7 +2049,9 @@ if (!_aspectPanelEl) return; _aspectPanelEl.style.display = on ? 'block' : 'none'; window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish - if (on) { _aspectBuildTargets(); } + // Prune before the first build so panes from a prior song/split don't + // flash in the dropdown until the first RAF tick. + if (on) { _aspectPrunePanes(); _aspectBuildTargets(); } if (on && !_aspectPanelRAF) { const tick = () => { if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; } @@ -14898,7 +14912,7 @@ _destroyed = _isReady = false; _isFocused = true; if (!_paneUid) _paneUid = ++_aspectPaneCounter; // fallback pane id (no-arrangement panes) - _registerAspectAbShortcut(); // session-global tuner shortcut (self-guarded) + _registerTunerShortcut(); // session-global tuner shortcut (self-guarded) const myToken = ++_initToken; highwayCanvas = canvas; _invertedCached = !!(bundle && bundle.inverted); diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index f3bee04..ead7b58 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -243,6 +243,31 @@ test('single-pane forces the edit target back to All (no hidden pane edits)', () ); }); +test('resolved per-pane tune is memoized and invalidated by a revision', () => { + assert.match(src, /_aspectRev\s*\+\+/, 'a mutation revision must be bumped on persist'); + assert.match( + src, + /_aspectResolveCache\.get\(\s*paneKey\s*\)[\s\S]*?c\.rev\s*===\s*_aspectRev[\s\S]*?return\s+c\.obj/, + '_resolveTuneFor must return a cached object when the revision is unchanged', + ); +}); + +test('the pane clock falls back to Date.now so pruning keeps working', () => { + assert.match( + src, + /function\s+_aspectNowMs\s*\(\)[\s\S]*?performance\.now\(\)[\s\S]*?return\s+Date\.now\(\)/, + '_aspectNowMs must fall back to Date.now() when the Performance API is absent', + ); +}); + +test('opening the panel prunes before the first dropdown build', () => { + assert.match( + src, + /if\s*\(\s*on\s*\)\s*\{\s*_aspectPrunePanes\(\)\s*;\s*_aspectBuildTargets\(\)/, + '_setAspectPanelVisible must prune stale panes before building the dropdown', + ); +}); + test('the panel has a dismiss (close) control', () => { assert.match( src, From 1434eb63427e22254ef8b1bea6a8e387d43b6885 Mon Sep 17 00:00:00 2001 From: topkoa Date: Wed, 1 Jul 2026 02:54:15 -0400 Subject: [PATCH 09/10] Address review: only register panes while the tuner is open camUpdate registered every pane each frame regardless of whether the tuner had ever been opened, so window.__h3dAspectPanes could grow unbounded (prune runs only while the panel is open) and it ran even for users who never opt in. Gate _aspectRegisterPane behind __h3dAspectPanelOpen (same gate as the readout). The pane key is still resolved every frame so saved overrides keep applying; only the picker bookkeeping is deferred until the panel is open. Co-Authored-By: Claude Opus 4.8 Signed-off-by: topkoa --- plugins/highway_3d/screen.js | 6 +++++- tests/js/highway_3d_wide_fov.test.js | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 25198bf..53c6c08 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -14459,7 +14459,11 @@ // costs nothing. const _paneKey = _aspectPaneKey( bundle && bundle.songInfo && bundle.songInfo.arrangement, _paneUid); - _aspectRegisterPane(_paneKey); + // Only feed the Target-picker registry while the tuner is open (same + // gate as the readout). Closed → nothing is registered, so the registry + // can't grow for users who never open the panel; the key is still + // resolved below so any saved overrides keep applying. + if (window.__h3dAspectPanelOpen) _aspectRegisterPane(_paneKey); const _aspTune = _resolveTuneFor(_paneKey); const _aspActive = !!(_aspTune && _aspTune.enabled && !(_aspTune.splitOnly && !_ssActive())); diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js index ead7b58..475dc9c 100644 --- a/tests/js/highway_3d_wide_fov.test.js +++ b/tests/js/highway_3d_wide_fov.test.js @@ -170,8 +170,8 @@ test('a Target select and pane registry drive the per-pane picker', () => { 'the panel must build a Target