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 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-29 14:00:44 +04:00
1 parent 8c788474db
commit 33ccdad744
9 files changed
+191 -33

No files matched your search

+9
View File
@@ -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,
},
});
+9
View File
@@ -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);
+20
View File
@@ -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 }]]);
});
+5 -1
View File
@@ -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);
-15
View File
@@ -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')) {
@@ -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);
});
}
+6
View File
@@ -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 =
+40 -17
View File
@@ -59,37 +59,49 @@ export class RadioControls {
* @returns {Promise<boolean>} 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<boolean>} 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;
}
+56
View File
@@ -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);
}
});