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"
+ 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. 'panel0') writes that pane's sparse override, so one split
+ // pane can be framed independently of the others.
+ let _aspectEditTarget = '';
+ // Set when a renderer reports a pane key/label we haven't seen, so the panel
+ // rebuilds the Target dropdown on its next readout tick.
+ let _aspectPanesDirty = true;
- // Get-or-create the live bridge object, seeded from defaults + localStorage.
+ // Get-or-create the shared bridge object, seeded from defaults + localStorage.
+ // May carry a sparse `__panels` map of per-pane overrides.
function _aspectTune() {
let t = window.__h3dAspectTune;
if (!t || typeof t !== 'object') {
@@ -1703,44 +1709,124 @@
try {
const t = _aspectTune(), out = {};
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) {}
}
+ // 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.
+ function _resolveTuneFor(paneKey) {
+ const base = _aspectTune();
+ const ov = base.__panels && base.__panels[paneKey];
+ if (!ov) return base;
+ const out = {};
+ 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) {
+ const reg = window.__h3dAspectPanes || (window.__h3dAspectPanes = {});
+ let label;
+ if (paneKey === 'main') { label = 'Main'; }
+ else { const n = parseInt(paneKey.slice(5), 10); label = 'Panel ' + ((isFinite(n) ? n : 0) + 1); }
+ if (arrangement) label += ' — ' + arrangement;
+ if (!reg[paneKey] || reg[paneKey].label !== label) {
+ reg[paneKey] = { label };
+ _aspectPanesDirty = true;
+ }
+ }
+
+ // Read/write against the current edit target ('' → base, else pane override).
+ function _aspectReadVal(k) {
+ const base = _aspectTune();
+ if (!_aspectEditTarget) return base[k];
+ const ov = base.__panels && base.__panels[_aspectEditTarget];
+ return (ov && (k in ov)) ? ov[k] : base[k];
+ }
+ function _aspectWriteVal(k, v) {
+ const base = _aspectTune();
+ if (!_aspectEditTarget) { base[k] = v; }
+ else {
+ const m = base.__panels || (base.__panels = {});
+ (m[_aspectEditTarget] || (m[_aspectEditTarget] = {}))[k] = v;
+ }
+ _aspectPersist();
+ }
+
+ // (Re)build the Target dropdown from the live pane registry, preserving the
+ // current selection when it's still valid.
+ function _aspectBuildTargets() {
+ if (!_aspectTargetSel) return;
+ const reg = window.__h3dAspectPanes || {};
+ const keys = Object.keys(reg).sort();
+ _aspectTargetSel.innerHTML = '';
+ const all = document.createElement('option');
+ all.value = ''; all.textContent = keys.length > 1 ? 'All panes' : 'All';
+ _aspectTargetSel.appendChild(all);
+ keys.forEach((pk) => {
+ const o = document.createElement('option');
+ o.value = pk; o.textContent = reg[pk].label;
+ _aspectTargetSel.appendChild(o);
+ });
+ if (_aspectEditTarget && !reg[_aspectEditTarget]) _aspectEditTarget = '';
+ _aspectTargetSel.value = _aspectEditTarget;
+ _aspectPanesDirty = false;
+ }
+
function _ensureAspectPanel() {
if (_aspectPanelEl || typeof document === 'undefined') return;
- const t = _aspectTune();
const wrap = document.createElement('div');
wrap.id = 'h3d-aspect-tuner';
wrap.style.cssText = [
'position:fixed', 'top:64px', 'right:12px', 'z-index:99999',
- 'width:230px', 'padding:10px 12px', 'border-radius:8px',
+ 'width:236px', 'padding:10px 12px', 'border-radius:8px',
'background:rgba(12,18,28,0.92)', 'border:1px solid rgba(120,150,200,0.35)',
'box-shadow:0 6px 24px rgba(0,0,0,0.5)', 'color:#cfe0f5',
'font:11px/1.35 system-ui,sans-serif', 'user-select:none',
'pointer-events:auto',
].join(';');
+ // Header: title + close (×). Close hides the panel; the feature keeps
+ // whatever enabled state it had — this is a dismiss, not an A/B toggle.
+ const hdr = document.createElement('div');
+ hdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
const title = document.createElement('div');
- title.textContent = 'Wide-pane framing (A/B)';
- title.style.cssText = 'font-weight:700;margin-bottom:6px;color:#e8c040;';
- wrap.appendChild(title);
+ title.textContent = 'Wide-pane framing';
+ title.style.cssText = 'font-weight:700;color:#e8c040;';
+ const close = document.createElement('button');
+ close.textContent = '×';
+ close.title = 'Close (Shift+A)';
+ close.setAttribute('aria-label', 'Close');
+ close.style.cssText = 'border:none;background:transparent;color:#cfe0f5;font-size:17px;line-height:1;cursor:pointer;padding:0 2px;';
+ close.addEventListener('click', () => _setAspectPanelVisible(false));
+ hdr.appendChild(title); hdr.appendChild(close); wrap.appendChild(hdr);
- // enabled + splitOnly checkboxes
- [['enabled', 'Enabled (Shift+A)'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
+ // Target selector — which pane the controls below edit.
+ const tgtRow = document.createElement('div'); tgtRow.style.cssText = 'margin:2px 0 7px;';
+ const tgtLab = document.createElement('div');
+ tgtLab.textContent = 'Target'; tgtLab.style.cssText = 'color:#9fb0c8;margin-bottom:2px;';
+ _aspectTargetSel = document.createElement('select');
+ _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();
+ });
+ tgtRow.appendChild(tgtLab); tgtRow.appendChild(_aspectTargetSel); wrap.appendChild(tgtRow);
+ _aspectBuildTargets();
+
+ // enabled + splitOnly checkboxes (per-target)
+ [['enabled', 'Enabled'], ['splitOnly', 'Split panes only']].forEach(([k, lbl]) => {
const row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:2px 0;cursor:pointer;';
const cb = document.createElement('input');
- cb.type = 'checkbox'; cb.checked = !!t[k]; cb.dataset.k = k;
- cb.addEventListener('change', () => {
- _aspectTune()[k] = cb.checked; _aspectPersist();
- if (k === 'enabled') _setAspectPanelVisible(cb.checked);
- });
+ cb.type = 'checkbox'; cb.checked = !!_aspectReadVal(k); cb.dataset.k = k;
+ cb.addEventListener('change', () => { _aspectWriteVal(k, cb.checked); });
const span = document.createElement('span'); span.textContent = lbl;
row.appendChild(cb); row.appendChild(span); wrap.appendChild(row);
});
- // numeric sliders
+ // numeric sliders (per-target)
_ASPECT_FIELDS.forEach((f) => {
const row = document.createElement('div');
row.style.cssText = 'margin:5px 0;';
@@ -1752,13 +1838,14 @@
head.appendChild(lab); head.appendChild(val); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = f.min; sl.max = f.max; sl.step = f.step;
- sl.value = Number.isFinite(t[f.k]) ? t[f.k] : _ASPECT_DEFAULTS[f.k];
+ const rv = _aspectReadVal(f.k);
+ sl.value = Number.isFinite(rv) ? rv : _ASPECT_DEFAULTS[f.k];
sl.dataset.k = f.k;
sl.style.cssText = 'width:100%;';
const show = () => { val.textContent = (+sl.value).toFixed(f.step < 1 ? 2 : 0); };
show();
sl.addEventListener('input', () => {
- _aspectTune()[f.k] = parseFloat(sl.value); show(); _aspectPersist();
+ _aspectWriteVal(f.k, parseFloat(sl.value)); show();
});
row.appendChild(sl); wrap.appendChild(row);
});
@@ -1769,23 +1856,24 @@
const head = document.createElement('label');
head.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const cb = document.createElement('input');
- cb.type = 'checkbox'; cb.checked = Number.isFinite(t.hfovDeg);
+ cb.type = 'checkbox'; cb.checked = Number.isFinite(_aspectReadVal('hfovDeg'));
const lbl = document.createElement('span'); lbl.textContent = 'Override held hFOV°';
head.appendChild(cb); head.appendChild(lbl); row.appendChild(head);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = 40; sl.max = 160; sl.step = 1;
- sl.value = Number.isFinite(t.hfovDeg) ? t.hfovDeg : 102;
+ const hv = _aspectReadVal('hfovDeg');
+ sl.value = Number.isFinite(hv) ? hv : 102;
sl.disabled = !cb.checked;
sl.style.cssText = 'width:100%;';
cb.addEventListener('change', () => {
sl.disabled = !cb.checked;
- _aspectTune().hfovDeg = cb.checked ? parseFloat(sl.value) : null;
- _aspectPersist();
+ _aspectWriteVal('hfovDeg', cb.checked ? parseFloat(sl.value) : null);
});
sl.addEventListener('input', () => {
- if (cb.checked) { _aspectTune().hfovDeg = parseFloat(sl.value); _aspectPersist(); }
+ if (cb.checked) _aspectWriteVal('hfovDeg', parseFloat(sl.value));
});
row.appendChild(sl); wrap.appendChild(row);
+ _aspectHfovCb = cb; _aspectHfovSl = sl;
}
// live readout
@@ -1804,17 +1892,25 @@
b.addEventListener('click', fn);
return b;
};
+ // Reset: for "All" restores the shared defaults (enabled); for a pane
+ // clears that pane's override so it inherits the shared base again.
btnRow.appendChild(mkBtn('Reset', () => {
- const t2 = _aspectTune();
- Object.keys(_ASPECT_DEFAULTS).forEach((k) => { t2[k] = _ASPECT_DEFAULTS[k]; });
- t2.enabled = true; // keep panel up after reset
+ const base = _aspectTune();
+ if (!_aspectEditTarget) {
+ Object.keys(_ASPECT_DEFAULTS).forEach((k) => { base[k] = _ASPECT_DEFAULTS[k]; });
+ base.enabled = true;
+ } else if (base.__panels) {
+ delete base.__panels[_aspectEditTarget];
+ }
_aspectPersist(); _syncAspectPanel();
}));
+ // Copy: the resolved values for the current target, as JSON.
btnRow.appendChild(mkBtn('Copy', () => {
- const t2 = _aspectTune(), out = {};
- Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t2[k]; });
+ const r = _aspectEditTarget ? _resolveTuneFor(_aspectEditTarget) : _aspectTune();
+ const out = {};
+ Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = r[k]; });
const json = JSON.stringify(out, null, 2);
- try { console.log('[h3d] wide-pane framing values:\n' + json); } catch (e) {}
+ try { console.log('[h3d] wide-pane framing values (' + (_aspectEditTarget || 'all') + '):\n' + json); } catch (e) {}
try { if (navigator.clipboard) navigator.clipboard.writeText(json); } catch (e) {}
}));
wrap.appendChild(btnRow);
@@ -1824,19 +1920,25 @@
_aspectPanelEl.style.display = 'none';
}
- // Push current bridge values back into the panel controls (after Reset or an
- // external edit). Cheap; only runs on demand.
+ // Push the current target's values back into the panel controls (after Reset,
+ // a target switch, or an external edit). Cheap; only runs on demand.
function _syncAspectPanel() {
if (!_aspectPanelEl) return;
- const t = _aspectTune();
+ _aspectBuildTargets();
_aspectPanelEl.querySelectorAll('input[type=checkbox][data-k]').forEach((cb) => {
- cb.checked = !!t[cb.dataset.k];
+ cb.checked = !!_aspectReadVal(cb.dataset.k);
});
_aspectPanelEl.querySelectorAll('input[type=range][data-k]').forEach((sl) => {
- const k = sl.dataset.k;
- if (Number.isFinite(t[k])) sl.value = t[k];
+ const v = _aspectReadVal(sl.dataset.k);
+ if (Number.isFinite(v)) sl.value = v;
sl.dispatchEvent(new Event('input')); // refresh the value label
});
+ if (_aspectHfovCb) {
+ const hv = _aspectReadVal('hfovDeg');
+ _aspectHfovCb.checked = Number.isFinite(hv);
+ _aspectHfovSl.disabled = !_aspectHfovCb.checked;
+ if (Number.isFinite(hv)) _aspectHfovSl.value = hv;
+ }
}
function _setAspectPanelVisible(on) {
@@ -1844,19 +1946,32 @@
if (!_aspectPanelEl) return;
_aspectPanelEl.style.display = on ? 'block' : 'none';
window.__h3dAspectPanelOpen = !!on; // gates the per-frame readout publish
+ if (on) { _aspectBuildTargets(); }
if (on && !_aspectPanelRAF) {
const tick = () => {
if (!window.__h3dAspectPanelOpen) { _aspectPanelRAF = 0; return; }
+ if (_aspectPanesDirty) _aspectBuildTargets();
const ro = window.__h3dAspectReadout;
- if (_aspectPanelRO && ro && Number.isFinite(ro.aspect)) {
- _aspectPanelRO.textContent =
- 'aspect ' + ro.aspect.toFixed(2) + ' · vFOV ' + ro.vfov.toFixed(1) + '°';
+ if (_aspectPanelRO && ro) {
+ const key = _aspectEditTarget || ro.__last;
+ const e = key && ro[key];
+ if (e && Number.isFinite(e.aspect)) {
+ _aspectPanelRO.textContent =
+ 'aspect ' + e.aspect.toFixed(2) + ' · vFOV ' + e.vfov.toFixed(1) + '°';
+ }
}
_aspectPanelRAF = requestAnimationFrame(tick);
};
_aspectPanelRAF = requestAnimationFrame(tick);
}
}
+ // Toggle the panel open/closed (the Shift+A dismiss/reveal).
+ function _toggleAspectPanel() {
+ _ensureAspectPanel();
+ const open = !(_aspectPanelEl && _aspectPanelEl.style.display !== 'none');
+ _setAspectPanelVisible(open);
+ if (open) _syncAspectPanel();
+ }
/* ======================================================================
* Background animations (issue #13)
@@ -14222,14 +14337,18 @@
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
- // _aspectTune() returns the live bridge object, seeded from defaults
- // + localStorage on first read so a persisted tuning session applies
- // on load without opening the panel. Every field is finite-coerced.
- // When disabled (or splitOnly and not in a split) the tune is treated
- // as null, so 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 _aspTune = _aspectTune();
+ // _resolveTuneFor(paneKey) returns the shared base with THIS pane's
+ // overrides (if any) laid on top, so a single split pane can be framed
+ // independently. The base is seeded from defaults + localStorage on
+ // first read, so a persisted tuning session applies on load without
+ // opening the panel. Every field is finite-coerced. When disabled (or
+ // splitOnly and not in a split) the tune is treated as null, so
+ // 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 _paneKey = _bgPanelKey(highwayCanvas);
+ _aspectRegisterPane(_paneKey, bundle && bundle.songInfo && bundle.songInfo.arrangement);
+ const _aspTune = _resolveTuneFor(_paneKey);
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
@@ -14238,12 +14357,14 @@
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
- // Publish a live readout for the tuner panel (only while it's open,
- // so the steady path stays allocation-free). Last pane to render wins
- // the slot — fine, all panes share the same aspect in a split layout.
+ // Publish a per-pane live readout for the tuner panel (only while it's
+ // open, so the steady path stays allocation-free). Keyed by pane so
+ // the panel can show the reading for whichever target is selected.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
- _ro.aspect = _paneAspect; _ro.vfov = _vfov;
+ const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
+ _slot.aspect = _paneAspect; _slot.vfov = _vfov;
+ _ro.__last = _paneKey;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
diff --git a/tests/js/highway_3d_wide_fov.test.js b/tests/js/highway_3d_wide_fov.test.js
index 936d933..2ddbce9 100644
--- a/tests/js/highway_3d_wide_fov.test.js
+++ b/tests/js/highway_3d_wide_fov.test.js
@@ -120,11 +120,11 @@ test('applySize caches the pane aspect for camUpdate', () => {
);
});
-test('camUpdate reads the live tune bridge and respects splitOnly', () => {
+test('camUpdate resolves a per-pane tune and respects splitOnly', () => {
assert.match(
src,
- /const\s+_aspTune\s*=\s*_aspectTune\(\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
- 'camUpdate must read the bridge via _aspectTune() and gate splitOnly on _ssActive()',
+ /const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
+ 'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly',
);
});
@@ -136,11 +136,50 @@ test('the tune bridge seeds from localStorage (persisted sessions apply on load)
);
});
-test('a floating tuner panel is built and toggled with the A/B state', () => {
+test('a floating tuner panel is built and can be shown/hidden', () => {
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
'_ensureAspectPanel() must exist to build the live panel');
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
- '_setAspectPanelVisible() must show/hide the panel with the feature');
+ '_setAspectPanelVisible() must show/hide the panel');
+});
+
+// ── Per-pane targeting ────────────────────────────────────────────────────────
+
+test('the tune resolves per pane with a sparse override map', () => {
+ // _resolveTuneFor overlays a pane's __panels[key] overrides onto the base so
+ // one split pane can be framed independently of the others.
+ assert.match(
+ src,
+ /function\s+_resolveTuneFor\s*\(\s*paneKey\s*\)[\s\S]*?base\.__panels\s*&&\s*base\.__panels\[\s*paneKey\s*\]/,
+ '_resolveTuneFor must overlay per-pane overrides from base.__panels',
+ );
+});
+
+test('panel writes route to the selected target (base or a pane override)', () => {
+ // _aspectWriteVal writes to the base when target is empty, else into the
+ // pane override sub-object; camUpdate consumes it via _resolveTuneFor.
+ assert.match(
+ src,
+ /function\s+_aspectWriteVal\s*\([\s\S]*?if\s*\(\s*!_aspectEditTarget\s*\)[\s\S]*?base\.__panels\b[\s\S]*?\[\s*_aspectEditTarget\s*\]/,
+ '_aspectWriteVal must target base for "all" and __panels[target] for a pane',
+ );
+});
+
+test('a Target select and pane registry drive the per-pane picker', () => {
+ assert.match(src, /_aspectTargetSel\s*=\s*document\.createElement\(\s*'select'\s*\)/,
+ 'the panel must build a 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', () => {