From eed7657516a6668e63bd3b155c568ee6ba01dace Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:41:29 -0700 Subject: [PATCH] fix(ui): let revealed panels own their side and keep Cockpit's left strip A panel a feature opens (Street Level's viewer, a selected radio station) now becomes its side's open panel instead of being collapsed again by the next layout pass. Cockpit keeps showing its collapsed Data Layers strip, which Cyber's Cockpit layout aligns to, and Cyber's focus layout no longer re-shows collapsed left panels. Both sidebars share one accordion helper. --- scripts/package-boundaries.json | 12 ++++++--- src/rightRailPolicy.test.mjs | 9 +++++-- src/ui/leftPanelRail.js | 36 ++++++++++--------------- src/ui/panelAccordion.js | 41 +++++++++++++++++++++++++++++ src/ui/panelChrome.js | 12 ++++----- src/ui/panelRails.test.mjs | 14 ++++++++++ src/ui/rightPanelRail.js | 34 +++++++----------------- src/ui/sidePanelDisclosure.test.mjs | 17 ++++++++++++ src/ui/styles/cyber.css | 7 +++++ 9 files changed, 124 insertions(+), 58 deletions(-) create mode 100644 src/ui/panelAccordion.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9a82fa11e..29476fb6f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -426,7 +426,8 @@ "src/ui/rightPanelRail.js", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", - "src/panelStackLayout.js" + "src/panelStackLayout.js", + "src/ui/panelAccordion.js" ], "external": [] }, @@ -874,7 +875,8 @@ "src/ui/statusText.js", "src/ui/statusBadge.js", "src/scopeMaskPolicy.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/ui/panelAccordion.js" ], "external": ["cesium"] }, @@ -2810,7 +2812,8 @@ "src/ui/statusBadge.js", "src/sceneCommandRelease.js", "src/scopeMaskPolicy.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/ui/panelAccordion.js" ], "external": [ "hls.js", @@ -3603,7 +3606,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/ui/panelAccordion.js" ], "external": [] }, diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index dde4c64be..89e5b4d73 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -4,16 +4,21 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js'; -test('desktop side rails hide collapsed panels in every HUD layout, never on phones', () => { +test('desktop side rails hide collapsed panels in every HUD layout, never on phones or beside Cockpit', () => { const right = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const left = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); const css = readStylesheet(new URL('../style.css', import.meta.url)); for (const ui of [right, left]) { assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); - assert.match(ui, /const exclusive = !isMobile;/); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); assert.doesNotMatch(ui, /hud\.variant === 'cyber'/); } + assert.match(right, /const exclusive = !isMobile;/); + // Cockpit keeps its collapsed Data Layers strip, which its layout aligns to. + assert.match( + left, + /const exclusive =\s*!isMobile && !documentRef\?\.body\?\.classList\?\.contains\('cockpit-mode'\);/, + ); assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index a0de82746..d9930676a 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -5,6 +5,7 @@ import { resolvePanelStackCorridor, } from '../panelStackLayout.js'; import { measurePanelNaturalHeight } from './panelMeasurement.js'; +import { settleSideAccordion } from './panelAccordion.js'; /** * Measure and place the left panel rail for one synchronous layout pass. @@ -60,35 +61,26 @@ export function layoutLeftPanelRail({ stack.style.removeProperty('--left-stack-centered-height'); stack.classList.remove('layout-exclusive'); stack.dataset.layoutMode = 'mobile'; + settleSideAccordion({ panels, enabled: false, onCollapse }); for (const panel of panels) { - if (panel.classList.contains('accordion-collapsed')) { - panel.classList.remove('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } panel.removeAttribute('aria-hidden'); panel.style.removeProperty('--left-panel-allocated-height'); } return; } - // One panel is open per desktop side and the panel rail lists the others, - // so collapsed panels are hidden. Normalize restored state without - // overwriting saved panel preferences. - const expandedBeforeAccordion = panels.filter( - (panel) => !panel.classList.contains('collapsed'), - ); - const accordionOwner = - expandedBeforeAccordion.find((panel) => panel.id === preferredPanelId) || - expandedBeforeAccordion.find((panel) => - panel.contains(documentRef?.activeElement), - ) || - expandedBeforeAccordion[0]; - for (const panel of expandedBeforeAccordion) { - if (panel === accordionOwner) continue; - panel.classList.add('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } - const exclusive = !isMobile; + // One panel is open per desktop side, and the panel rail lists the others, + // so collapsed panels are hidden. Cockpit keeps its collapsed Data Layers + // strip, which its layout aligns to, and shows no rail. + settleSideAccordion({ + panels, + enabled: true, + preferredPanelId, + documentRef, + onCollapse, + }); + const exclusive = + !isMobile && !documentRef?.body?.classList?.contains('cockpit-mode'); stack.classList.toggle('layout-exclusive', exclusive); const laidOutPanels = panels.filter( (panel) => !exclusive || !panel.classList.contains('collapsed'), diff --git a/src/ui/panelAccordion.js b/src/ui/panelAccordion.js new file mode 100644 index 000000000..5690bf71a --- /dev/null +++ b/src/ui/panelAccordion.js @@ -0,0 +1,41 @@ +/** + * Keep one side panel open on desktop, and on phones put back the collapse + * this applied, since a sheet shows several panels at once. The owner is the + * most recently opened panel, then the one holding keyboard focus, then the + * first. `accordion-collapsed` marks the collapse as presentation only, so + * saved and shared preferences still report the panel as the user left it. + * @param {object} options + * @param {HTMLElement[]} options.panels The side's laid-out panels. + * @param {boolean} options.enabled Whether the one-panel rule applies. + * @param {string} [options.preferredPanelId] Most recently opened panel. + * @param {Document} [options.documentRef] Document supplying keyboard focus. + * @param {Function} options.onCollapse Update a panel's disclosure chrome. + */ +export function settleSideAccordion({ + panels, + enabled, + preferredPanelId, + documentRef, + onCollapse, +}) { + if (!enabled) { + for (const panel of panels) { + if (!panel.classList.contains('accordion-collapsed')) continue; + panel.classList.remove('collapsed', 'accordion-collapsed'); + onCollapse(panel); + } + return; + } + const expanded = panels.filter( + (panel) => !panel.classList.contains('collapsed'), + ); + const owner = + expanded.find((panel) => panel.id === preferredPanelId) || + expanded.find((panel) => panel.contains(documentRef?.activeElement)) || + expanded[0]; + for (const panel of expanded) { + if (panel === owner) continue; + panel.classList.add('collapsed', 'accordion-collapsed'); + onCollapse(panel); + } +} diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 459694967..8f8bd219f 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -421,14 +421,14 @@ export class PanelChrome { const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed') || panelEl.classList.contains('accordion-collapsed'); - // One panel is open per desktop side: a user opening a side panel owns - // the whole accordion, including peers that were only - // presentation-collapsed during a saved-state restore. + // One panel is open per desktop side: opening a side panel, whether the + // user asked or a feature revealed it (Street Level's viewer, a selected + // radio station), owns the whole accordion, including peers that were + // only presentation-collapsed during a saved-state restore. const sideStack = [this._leftPanelStack, this._rightPanelStack].find( (stack) => stack && panelEl.parentElement === stack, ); if ( - explicit && !restore && !nextCollapsed && sideStack && @@ -460,7 +460,7 @@ export class PanelChrome { : null; const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId; const priorRightOwner = this._panelLayout._rightStackPreferredPanelId; - if (explicit && !restore && !nextCollapsed && leftOwnerPanel) { + if (!restore && !nextCollapsed && leftOwnerPanel) { this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id; } else if ( explicit && @@ -470,7 +470,7 @@ export class PanelChrome { ) { this._panelLayout._leftStackPreferredPanelId = null; } - if (explicit && !restore && !nextCollapsed && rightOwnerPanel) { + if (!restore && !nextCollapsed && rightOwnerPanel) { this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id; } else if ( explicit && diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index e7f0d07cf..291eaac6c 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -777,3 +777,17 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', ( ) <= alone, ); }); + +test('left rail keeps collapsed panels shown beside Cockpit, still one open', () => { + const f = fixture('left'); + f.options.documentRef.body = { + classList: { contains: (name) => name === 'cockpit-mode' }, + }; + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.preferredPanelId = f.second.id; + f.run(); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); +}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 5afdce2b7..1dcd4460f 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -5,6 +5,7 @@ import { import { resolveHudRailLayout } from './panelRailGeometry.js'; import { displayPanelScroller } from './displayPanelScroll.js'; +import { settleSideAccordion } from './panelAccordion.js'; const pendingCollapseRetries = new WeakSet(); @@ -86,31 +87,16 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - // One panel is open per desktop side. Normalize restored state without - // overwriting saved panel preferences; this marker is distinct from - // Tactical's space-based collapse. Phones present the rail as tab sheets - // that show several panels at once. + // One panel is open per desktop side; this marker is distinct from + // Tactical's space-based collapse. const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; - if (!isMobile) { - const expanded = panels.filter( - (panel) => !panel.classList.contains('collapsed'), - ); - const owner = - expanded.find((panel) => panel.id === preferredPanelId) || - expanded.find((panel) => panel.contains(documentRef?.activeElement)) || - expanded[0]; - for (const panel of expanded) { - if (panel === owner) continue; - panel.classList.add('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } - } else { - for (const panel of panels) { - if (!panel.classList.contains('accordion-collapsed')) continue; - panel.classList.remove('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } - } + settleSideAccordion({ + panels, + enabled: !isMobile, + preferredPanelId, + documentRef, + onCollapse, + }); // The desktop panel rail lists every panel, so collapsed ones are hidden. // Phones present the rail as tab sheets (mobileShell.js), which choose what // shows; hiding collapsed siblings would empty a sheet and its a11y tree. diff --git a/src/ui/sidePanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs index 41b0977f7..7badbd464 100644 --- a/src/ui/sidePanelDisclosure.test.mjs +++ b/src/ui/sidePanelDisclosure.test.mjs @@ -97,3 +97,20 @@ test('restored preferences keep every saved panel open for the layout to normali } finally { globalThis.document = prior; } } }); + +test('a feature revealing a side panel makes it the side owner', () => { + const f = fixture('tactical'), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + f.claimed.length = 0; + // Street Level's viewer or a selected radio station opens its panel + // without an explicit user disclosure. + f.owner.setPanelCollapsed('radio-panel', false); + assert.equal(f.context.classList.contains('collapsed'), false); + assert.equal(f.display.classList.contains('collapsed'), true); + assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel'); + // Only a user's choice claims restoration lanes. + assert.deepEqual(f.claimed, []); + } finally { globalThis.document = prior; } +}); diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 4dd7de5b3..b625b9f81 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1674,6 +1674,13 @@ flex: 0 0 auto; } + /* The panel rail lists the collapsed panels. */ + :root[data-ui-theme='cyber'] + #left-panel-stack.layout-focus.layout-exclusive + > [data-panel-id].collapsed { + display: none; + } + :root[data-ui-theme='cyber'] #left-panel-stack > :is(#data-panel, #scene-panel) {