From 33ccdad744f7c5a46219b2c8dc0dcc65671d43d4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 14:00:44 +0400 Subject: [PATCH] fix(ui): right rail review follow-ups for Radio reveals and CCTV restores - A Radio pick reopens a closed Context around an expanded Radio section in every HUD layout, not only Cyber. - The Radio reveal's scrollend landing gives way to a user scroll, key or pointer, and stops after two seconds. - The compact player's Details button scrolls the Radio section and its Enable into view inside Context. - A camera layer switched on by a share link or a saved session no longer opens the CCTV panel over the restored tab; a user switch-on still does. Co-Authored-By: Claude Opus 5.5 --- src/ui/applicationShell.js | 9 ++++ src/ui/cctvControls.js | 9 ++++ src/ui/cctvControls.test.mjs | 20 ++++++++ src/ui/cctvPresentation.js | 6 ++- src/ui/panelChrome.js | 15 ------ src/ui/panelChrome.radioOwnership.test.mjs | 46 +++++++++++++++++ src/ui/radioBindings.js | 6 +++ src/ui/radioControls.js | 57 +++++++++++++++------- src/ui/radioControls.test.mjs | 56 +++++++++++++++++++++ 9 files changed, 191 insertions(+), 33 deletions(-) diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index f5dfb1595..695187b58 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -41,6 +41,7 @@ import { } from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; +import { LAYER_RESTORE_ORIGINS } from '../data/layerState.js'; import { describeUnsavedWork, @@ -1222,6 +1223,14 @@ export class StyleManager extends ShellFacade { setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), + // Tells the panel whether a switch-on restores saved or shared state. + subscribeEnableRequests: (listener) => + this._dataManager?.subscribeVisibilityRequests?.((change) => { + if (change?.layerId === 'cctv' && change.enabled) + listener( + Object.values(LAYER_RESTORE_ORIGINS).includes(change.origin), + ); + }) || null, setSplitFlapText, }, }); diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 2626fb719..92e7ba1b9 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -31,6 +31,8 @@ export class CctvControls { this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; this._lastSeenCctvActiveId = null; + this._cctvRestoredEnable = false; + this._unsubscribeEnableRequests = null; this._cctvChipHideTimer = null; this._cctvChipWasBusy = false; this._cctvFrameRequestToken = 0; @@ -65,6 +67,11 @@ export class CctvControls { this._cctvUnsubscribe?.(); this._cctvUnsubscribe = null; if (this.destroyed) return; + this._unsubscribeEnableRequests?.(); + this._unsubscribeEnableRequests = + this.actions.subscribeEnableRequests?.((restored) => { + this._cctvRestoredEnable = restored; + }) || null; this._cctvUnsubscribe = this.cctv.subscribe?.((state) => this._renderCctvState(state), ); @@ -118,6 +125,8 @@ export class CctvControls { this.listeners.abort(); this._cctvUnsubscribe?.(); this._cctvUnsubscribe = null; + this._unsubscribeEnableRequests?.(); + this._unsubscribeEnableRequests = null; this._calibrationEdit?.(false); this._clearCctvFrame(); clearInterval(this._cctvSummaryTypingTimer); diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 6a47ff91f..8b43768a4 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -221,3 +221,23 @@ test('disposing during camera enable prevents the delayed focus and future click assert.equal(enables, 1); assert.equal(focuses, 0); }); + +test('a restored camera layer leaves its panel to the restored panel state', (t) => { + const { controls } = fixture(t); + let request; + const opened = []; + controls.actions.subscribeEnableRequests = (listener) => { + request = listener; + return () => {}; + }; + controls.actions.setPanelCollapsed = (...args) => opened.push(args); + controls.actions.syncViewport = () => {}; + controls.connect(); + + request(true); + controls._renderCctvState({ enabled: true, activeCameraId: 'a' }); + assert.deepEqual(opened, []); + + controls._renderCctvState({ enabled: true, activeCameraId: 'b' }); + assert.deepEqual(opened, [['cctv-panel', false, { explicit: false }]]); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 070c47f64..168d7833a 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -43,12 +43,16 @@ export function _renderCctvState(state) { if ( effectiveActiveId && effectiveActiveId !== this._lastSeenCctvActiveId && - (!state?.autoHop || isFirstActivation) + (!state?.autoHop || isFirstActivation) && + // A restored layer leaves the panel to the restored panel state, so its + // first camera does not take the rail from the restored tab. + !this._cctvRestoredEnable ) { this.actions.setPanelCollapsed('cctv-panel', false, { explicit: Boolean(state?.explicitSelection), }); } + if (effectiveActiveId) this._cctvRestoredEnable = false; this._lastSeenCctvActiveId = effectiveActiveId; this._updateCctvSyncChip(state?.loading, enabled); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 9bf11a251..80d327a37 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -501,7 +501,6 @@ export class PanelChrome { // Reveal Radio's owner before the no-op check: the nested section may // already be expanded while its Context parent is closed. if ( - document.documentElement?.dataset.uiTheme === 'cyber' && !nextCollapsed && panelId === 'radio-panel' && document @@ -540,20 +539,6 @@ export class PanelChrome { this.cockpitView.setContextCollapsed(true); } } - if ( - !nextCollapsed && - panelId === 'radio-panel' && - document.documentElement?.dataset.uiTheme !== 'cyber' && - document - .getElementById('global-context-panel') - ?.classList.contains('collapsed') - ) { - this.setPanelCollapsed('global-context-panel', false, { - restore, - persist, - syncShare, - }); - } if (!nextCollapsed && !restore && panelId === 'location-bar') { const otherPanel = document.getElementById('control-panel'); if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { diff --git a/src/ui/panelChrome.radioOwnership.test.mjs b/src/ui/panelChrome.radioOwnership.test.mjs index ec041247f..7e1f3f389 100644 --- a/src/ui/panelChrome.radioOwnership.test.mjs +++ b/src/ui/panelChrome.radioOwnership.test.mjs @@ -129,3 +129,49 @@ test('adaptive rail restore reveals Context without touching the compact player' assert.equal(dock.classList.contains('disclosure-open'), true); assert.deepEqual(calls, ['button:global-context-panel', 'layout']); }); + +// A globe station pick opens Radio, which may already be expanded inside a +// closed Context. +for (const theme of ['tactical', 'cyber']) { + test(`${theme}: a radio pick reopens the closed Context around an expanded Radio section`, (t) => { + const priorDocument = globalThis.document; + const contextPanel = { + id: 'global-context-panel', + classList: classList(['collapsed']), + }; + const panels = { + 'global-context-panel': contextPanel, + 'radio-panel': { id: 'radio-panel', classList: classList() }, + }; + globalThis.document = { + documentElement: { dataset: { uiTheme: theme } }, + body: { classList: classList() }, + getElementById: (id) => panels[id] || null, + }; + t.after(() => { + globalThis.document = priorDocument; + }); + const owner = { + _leftPanelStack: null, + _rightPanelStack: { contains: (el) => el === contextPanel }, + _panelLayout: { + _leftStackPreferredPanelId: null, + _rightStackPreferredPanelId: 'pp-toggles', + }, + _syncPanelCollapseButton() {}, + _scheduleLeftPanelLayout() {}, + _scheduleRightPanelLayout() {}, + _savePanelCollapsedState() {}, + _syncCctvPanelViewport() {}, + _updateCommandDockTrayStack() {}, + _lifetime: { frame() {} }, + shareLinkManager: null, + cockpitView: null, + }; + owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed; + + owner.setPanelCollapsed('radio-panel', false); + + assert.equal(contextPanel.classList.contains('collapsed'), false); + }); +} diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 16286afcb..82e00d0dc 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -425,6 +425,12 @@ export function bindRadioControls() { this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); setRadioDisclosure(false); this._radioEnableBtn?.focus({ preventScroll: true }); + if (this._radioPanel && this._radioEnableBtn) + void this._revealInsideContext( + this._radioPanel, + this._radioEnableBtn, + () => !this._radioPanel.classList.contains('collapsed'), + ); }); this.listen(this._cockpitRadioToggleBtn, 'click', () => { const open = diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index 82f1bf8ff..399c93d8d 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -59,37 +59,49 @@ export class RadioControls { * @returns {Promise} Whether the internal scroller moved. */ async _revealRadioControlsAfterExplicitEnable(trigger) { + const directory = this._radioPanel?.querySelector('.radio-directory-row'); + const transport = this._radioPanel?.querySelector('.radio-transport'); + if (!directory || !transport || !this._radioState?.enabled) return false; + return this._revealInsideContext( + directory, + transport, + () => this._radioState?.enabled && trigger?.isConnected, + ); + } + + /** + * Scroll Context so `top` through `bottom` show, preferring `bottom` when + * both do not fit, once the rail has laid out the opened panels. + * @param {HTMLElement} top + * @param {HTMLElement} bottom + * @param {() => boolean} stillWanted Rechecked after the layout frames. + * @returns {Promise} Whether the internal scroller moved. + */ + async _revealInsideContext(top, bottom, stillWanted) { const contextPanel = document.getElementById('global-context-panel'); const scroller = (document.documentElement?.dataset.uiTheme === 'cyber' && contextPanel?.querySelector('.cyber-panel-body')) || contextPanel?.querySelector('.global-context-panel-inner'); - const directory = this._radioPanel?.querySelector('.radio-directory-row'); - const transport = this._radioPanel?.querySelector('.radio-transport'); if ( !contextPanel || contextPanel.classList.contains('collapsed') || - !scroller || - !directory || - !transport || - !this._radioState?.enabled + !scroller ) return false; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), ); - if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) - return false; + if (this.destroyed || !stillWanted()) return false; const viewport = scroller.getBoundingClientRect(); - const directoryRect = directory.getBoundingClientRect(); - const transportRect = transport.getBoundingClientRect(); + const topRect = top.getBoundingClientRect(); + const bottomRect = bottom.getBoundingClientRect(); const margin = 10; const minimum = - scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); - const maximum = - scroller.scrollTop + directoryRect.top - (viewport.top + margin); + scroller.scrollTop + bottomRect.bottom - (viewport.bottom - margin); + const maximum = scroller.scrollTop + topRect.top - (viewport.top + margin); const desired = minimum <= maximum ? Math.min(Math.max(scroller.scrollTop, minimum), maximum) @@ -108,16 +120,27 @@ export class RadioControls { }); // A rail layout pass that measures the panels mid-scroll puts the scroll // offset back where it was, which cancels the smooth scroll: land it when - // the scroll ends short. - if (!reducedMotion) + // the scroll ends short, unless the user took the scroll over meanwhile. + if (!reducedMotion) { + const landing = new AbortController(); + const signal = AbortSignal.any([ + this.listeners.signal, + landing.signal, + AbortSignal.timeout(2000), + ]); + const cancel = () => landing.abort(); + for (const type of ['keydown', 'wheel', 'pointerdown', 'touchstart']) + scroller.addEventListener(type, cancel, { signal, passive: true }); scroller.addEventListener( 'scrollend', () => { - if (!this.destroyed && Math.abs(scroller.scrollTop - next) >= 1) + cancel(); + if (Math.abs(scroller.scrollTop - next) >= 1) scroller.scrollTop = next; }, - { once: true }, + { signal }, ); + } return true; } diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index b99c96a18..eba9744d8 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -172,3 +172,59 @@ test('destruction releases an active tuner pointer after revoking listeners', () f.cleanup(); } }); + +test('a user scroll during the Radio reveal is not undone by the scrollend landing', async () => { + const prior = { + document: globalThis.document, + window: globalThis.window, + requestAnimationFrame: globalThis.requestAnimationFrame, + }; + const rect = (top, bottom) => ({ top, bottom }); + const scroller = Object.assign(new EventTarget(), { + scrollTop: 0, + scrollHeight: 1200, + clientHeight: 300, + getBoundingClientRect: () => rect(100, 400), + scrollTo() {}, + }); + const below = (top) => ({ + getBoundingClientRect: () => + rect(top - scroller.scrollTop, top + 30 - scroller.scrollTop), + }); + const parts = { + '.radio-directory-row': below(500), + '.radio-transport': below(700), + }; + const context = { + classList: { contains: () => false }, + querySelector: (sel) => + sel === '.global-context-panel-inner' ? scroller : null, + }; + globalThis.document = { + documentElement: { dataset: {} }, + getElementById: (id) => (id === 'global-context-panel' ? context : null), + }; + globalThis.window = { matchMedia: () => ({ matches: false }) }; + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + const controls = Object.assign(Object.create(RadioControls.prototype), { + destroyed: false, + listeners: new AbortController(), + _radioPanel: { querySelector: (sel) => parts[sel] ?? null }, + _radioState: { enabled: true }, + }); + try { + assert.equal( + await controls._revealRadioControlsAfterExplicitEnable({ + isConnected: true, + }), + true, + ); + scroller.dispatchEvent(new Event('keydown')); + scroller.scrollTop = 0; + scroller.dispatchEvent(new Event('scrollend')); + assert.equal(scroller.scrollTop, 0); + } finally { + controls.listeners.abort(); + Object.assign(globalThis, prior); + } +});