diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index a13f5c04b..ecccd21a1 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -188,7 +188,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c 'focus mode must hide every collapsed sibling, including presentation-only auto-collapses', ); assert.doesNotMatch(css, /layout-focus > \[data-panel-id\]\.collapsed:not\(\.layout-auto-collapsed\)/); - assert.match(leftRail, /const hiddenSibling =\s*shouldFocus && panel\.classList\.contains\('collapsed'\);/); + assert.match(leftRail, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { @@ -198,7 +198,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre assert.match( ui, - /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, + /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, 'responsive auto-collapse must serialize the explicit expanded preference', ); assert.match( diff --git a/src/rightRailPolicy.js b/src/rightRailPolicy.js index 02d7972c4..08eab4fb0 100644 --- a/src/rightRailPolicy.js +++ b/src/rightRailPolicy.js @@ -1,5 +1,3 @@ -export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js'; - const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([ 'contacts', 'space-missions', diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..dde4c64be 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -2,50 +2,20 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import { - shouldExpandGlobalContextPanel, - shouldHideCollapsedRightPanels, -} from './rightRailPolicy.js'; +import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js'; -test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'tactical', - hasExpandedPanel: true, - }), true); -}); - -test('collapsed launchers remain when Tactical has no expanded panel', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'tactical', - hasExpandedPanel: false, - }), false); -}); - -test('other HUD layouts keep collapsed right-rail launchers visible', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'minimal', - hasExpandedPanel: true, - }), false); - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'full', - hasExpandedPanel: true, - }), false); -}); - -test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { - const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); +test('desktop side rails hide collapsed panels in every HUD layout, never on phones', () => { + 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)); - assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); - assert.match( - ui, - /!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/, - ); - assert.doesNotMatch( - ui, - /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, - ); - assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); + 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(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); + assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index 607e13083..a0de82746 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -21,6 +21,7 @@ import { measurePanelNaturalHeight } from './panelMeasurement.js'; * @param {Function} [options.getComputedStyle] Optional DOM style reader override. * @param {Map} options.collapsedHeights Caller-owned measurement cache. * @param {Function} options.onAligned Notify the caller after the left rail moves. + * @param {Document} [options.documentRef] Document supplying current keyboard focus. */ export function layoutLeftPanelRail({ stack, @@ -32,6 +33,7 @@ export function layoutLeftPanelRail({ onRetry, collapsedHeights, onAligned, + documentRef = stack?.ownerDocument, getComputedStyle = (element) => windowRef.getComputedStyle(element), }) { if (!stack) return; @@ -49,20 +51,49 @@ export function layoutLeftPanelRail({ // The existing narrow-screen composition has its own full-width stack. // Keep this desktop lane engine from fighting those dedicated rules. - if (windowRef.matchMedia('(max-width: 720px)').matches) { + const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; + if (isMobile) { stack.classList.remove('layout-focus'); stack.classList.remove('layout-tail'); stack.style.removeProperty('--left-stack-safe-top'); stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-centered-height'); + stack.classList.remove('layout-exclusive'); stack.dataset.layoutMode = 'mobile'; 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; + stack.classList.toggle('layout-exclusive', exclusive); + const laidOutPanels = panels.filter( + (panel) => !exclusive || !panel.classList.contains('collapsed'), + ); + const viewportHeight = Math.max(1, windowRef.innerHeight); const stackRect = stack.getBoundingClientRect(); const baseTop = viewportHeight * 0.26; @@ -162,7 +193,7 @@ export function layoutLeftPanelRail({ (sum, height) => sum + height, 0, ); - const siblingHeight = panels.reduce((total, panel) => { + const siblingHeight = laidOutPanels.reduce((total, panel) => { if (!panel.classList.contains('collapsed')) return total; const measured = collapsedHeights.get(panel.id); return total + (measured || panel.getBoundingClientRect().height || 0); @@ -170,12 +201,8 @@ export function layoutLeftPanelRail({ let requiredHeight = siblingHeight; const rowGap = parseFloat(getComputedStyle(stack).rowGap) || 0; - if (expandedPanels.length) { - requiredHeight += naturalExpandedHeight; - requiredHeight += rowGap * Math.max(0, panels.length - 1); - } else { - requiredHeight += rowGap * Math.max(0, panels.length - 1); - } + if (expandedPanels.length) requiredHeight += naturalExpandedHeight; + requiredHeight += rowGap * Math.max(0, laidOutPanels.length - 1); const wasFocused = stack.classList.contains('layout-focus'); const wasTail = stack.classList.contains('layout-tail'); @@ -189,7 +216,7 @@ export function layoutLeftPanelRail({ const tailRequiredHeight = naturalExpandedHeight + siblingHeight + - rowGap * Math.max(0, panels.length - 1); + rowGap * Math.max(0, laidOutPanels.length - 1); // A compact expansion should not make the whole control stack jump down // merely to center a few short rows. Preserve the normal top anchor when // the centered stack would begin below it; tall stacks can still grow @@ -296,8 +323,8 @@ export function layoutLeftPanelRail({ // CockpitView.syncSignalLayout() owns `--cockpit-utility-top` instead. for (const panel of panels) { - const hiddenSibling = shouldFocus && panel.classList.contains('collapsed'); - if (hiddenSibling) panel.setAttribute('aria-hidden', 'true'); + if (exclusive && panel.classList.contains('collapsed')) + panel.setAttribute('aria-hidden', 'true'); else panel.removeAttribute('aria-hidden'); } // The right controls share this top baseline; update them after the left diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index 2363fd9ed..07c21a46b 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -208,7 +208,7 @@ export function initMobileShell({ return ( classes.contains('collapsed') && !classes.contains('layout-auto-collapsed') && - !classes.contains('cyber-accordion-collapsed') + !classes.contains('accordion-collapsed') ); }; diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index bcf730f60..459694967 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -362,7 +362,7 @@ export class PanelChrome { // restore the user's explicit expanded preference at its own viewport. const collapsed = panelEl.classList.contains('layout-auto-collapsed') || - panelEl.classList.contains('cyber-accordion-collapsed') + panelEl.classList.contains('accordion-collapsed') ? false : panelEl.classList.contains('collapsed'); // A panel the phone shell only borrowed for a sheet shares its own state. @@ -420,19 +420,22 @@ export class PanelChrome { const nextCollapsed = Boolean(collapsed); const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed') || - panelEl.classList.contains('cyber-accordion-collapsed'); - // A user opening a Cyber rail panel owns the whole accordion, including - // peers that were only presentation-collapsed during a saved-state restore. + 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. + const sideStack = [this._leftPanelStack, this._rightPanelStack].find( + (stack) => stack && panelEl.parentElement === stack, + ); if ( explicit && !restore && !nextCollapsed && - document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack && + sideStack && // Phone sheets show several rail panels at once. !document.body?.classList?.contains('mobile-shell') ) { - for (const peer of this._rightPanelStack.children) { + for (const peer of sideStack.children) { if ( peer !== panelEl && peer.matches('[data-panel-id]') && @@ -480,7 +483,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 @@ -507,10 +509,7 @@ export class PanelChrome { } return; } - panelEl.classList.remove( - 'layout-auto-collapsed', - 'cyber-accordion-collapsed', - ); + panelEl.classList.remove('layout-auto-collapsed', 'accordion-collapsed'); if ( !nextCollapsed && this.cockpitView?.active && @@ -522,20 +521,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/panelRailGeometry.js b/src/ui/panelRailGeometry.js index 8045286b0..b5a129c21 100644 --- a/src/ui/panelRailGeometry.js +++ b/src/ui/panelRailGeometry.js @@ -60,19 +60,3 @@ export function resolveHudRailLayout({ constrained: panelHeight > availableHeight, }; } - -/** - * Tactical HUD gives an expanded right-rail panel the whole control lane. - * Other HUD layouts keep collapsed launchers visible for quick switching. - * - * @param {object} input Current rail state. - * @param {string} input.hudVariant Active HUD layout variant. - * @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded. - * @returns {boolean} Whether collapsed sibling launchers should be hidden. - */ -export function shouldHideCollapsedRightPanels({ - hudVariant, - hasExpandedPanel, -}) { - return hudVariant === 'tactical' && Boolean(hasExpandedPanel); -} diff --git a/src/ui/panelRails.js b/src/ui/panelRails.js index a67371cc7..e447d6302 100644 --- a/src/ui/panelRails.js +++ b/src/ui/panelRails.js @@ -1,10 +1,7 @@ export { layoutLeftPanelRail } from './leftPanelRail.js'; export { layoutRightPanelRail } from './rightPanelRail.js'; export { measurePanelNaturalHeight } from './panelMeasurement.js'; -export { - resolveHudRailLayout, - shouldHideCollapsedRightPanels, -} from './panelRailGeometry.js'; +export { resolveHudRailLayout } from './panelRailGeometry.js'; export { allocatePanelStackHeights, panelStackAutoCollapseIndices, diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 546cad87b..e7f0d07cf 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -20,61 +20,57 @@ test('Display uses an inner scroll body only in Cyber', () => { assert.equal(displayPanelScroller(null), null); }); -test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => { - for (const mobile of [false]) { - const f = fixture('right', { - mobile, - hud: { visible: true, variant: 'cyber' }, - }); +for (const side of ['left', 'right']) { + test(`${side} desktop rail keeps one expanded owner in every HUD layout`, () => { + for (const variant of ['tactical', 'minimal', 'cyber']) { + const f = fixture(side, { hud: { visible: true, variant } }); + 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.second.classList.contains('collapsed'), false); + assert.equal(f.stack.classList.contains('layout-exclusive'), true); + assert.equal(f.first.getAttribute('aria-hidden'), 'true'); + assert.equal(f.retries(), 0, 'the accordion needs no auto-collapse pass'); + f.run(); + assert.equal( + f.first.classList.contains('collapsed'), + true, + 'layout does not reopen a peer', + ); + // Phone sheets restore the presentation-only collapse. + f.options.windowRef.matchMedia = () => ({ matches: true }); + f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('accordion-collapsed'), false); + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + } + }); + + test(`${side} rail keeps no accordion on phones, where it is presented as tab sheets`, () => { + // A sheet (for example Cameras: CCTV with Street Level) shows several of the + // rail's panels at once; the accordion would collapse all but one. + const f = fixture(side, { mobile: true }); f.expand(f.first, 250); f.expand(f.second, 250); f.options.preferredPanelId = f.second.id; f.run(); - assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true); - assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - f.run(); - assert.equal( - f.first.classList.contains('collapsed'), - true, - 'layout does not reopen a peer', - ); - f.options.hud.variant = 'minimal'; - f.run(); assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal( - f.first.classList.contains('cyber-accordion-collapsed'), - false, - ); + assert.equal(f.first.classList.contains('accordion-collapsed'), false); assert.equal(f.second.classList.contains('collapsed'), false); - } -}); - -test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => { - // A sheet (for example Cameras: CCTV with Street Level) shows several of the - // rail's panels at once; the accordion would collapse all but one. - const f = fixture('right', { - mobile: true, - hud: { visible: true, variant: 'cyber' }, }); - f.expand(f.first, 250); - f.expand(f.second, 250); - f.options.preferredPanelId = f.second.id; - f.run(); - assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false); - assert.equal(f.second.classList.contains('collapsed'), false); -}); -test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => { - const f = fixture('right', { hud: { visible: true, variant: 'cyber' } }); - f.expand(f.first, 250); - f.expand(f.second, 250); - f.options.documentRef.activeElement = f.second; - f.run(); - assert.equal(f.first.classList.contains('collapsed'), true); - assert.equal(f.second.classList.contains('collapsed'), false); -}); + test(`${side} restored accordion prefers keyboard focus when no explicit owner exists`, () => { + const f = fixture(side); + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.documentRef.activeElement = f.second; + f.run(); + assert.equal(f.first.classList.contains('collapsed'), true); + assert.equal(f.second.classList.contains('collapsed'), false); + }); +} function element( id, @@ -250,16 +246,16 @@ for (const side of ['left', 'right']) { ); assert.equal(f.first.getAttribute('aria-hidden'), undefined); }); - test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => { + test(`${side} constrained layout keeps the preferred panel without another pass`, () => { const f = fixture(side); f.expand(f.first, 900); f.expand(f.second, 900); f.options.preferredPanelId = 'second'; f.run(); assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); assert.deepEqual(f.collapsed, ['first']); - assert.equal(f.retries(), 1); + assert.equal(f.retries(), 0); }); test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => { const f = fixture(side, { hud: { visible: false, variant: 'tactical' } }); @@ -277,7 +273,7 @@ test('right layout uses keyboard focus when there is no preferred panel', () => f.expand(f.second, 900); f.options.documentRef.activeElement = f.second; f.run(); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); assert.equal(f.second.classList.contains('collapsed'), false); }); @@ -532,26 +528,27 @@ test('right rail enters focus only above available height and leaves below the f assert.equal(f.stack.classList.contains('layout-focus'), false); }); -test('right rail auto-collapse requests only one retry and settles within two passes', () => { +test('right rail settles a second tall panel through the accordion in one pass', () => { const f = thrashingRightRail({ weatherOpen: true }); const modes = []; for (let pass = 0; pass < 10; pass++) { f.run(); - modes.push(f.stack.classList.contains('layout-focus')); + modes.push(f.stack.dataset.layoutMode); } assert.deepEqual(f.collapsed, ['weather-panel']); - assert.equal(f.retries(), 1); - assert.deepEqual(modes.slice(1), Array(9).fill(true)); + assert.equal(f.weather.classList.contains('accordion-collapsed'), true); + assert.equal(f.retries(), 0); + assert.deepEqual(modes.slice(1), Array(9).fill(modes[0])); }); -test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => { +test('right rail reopening a peer outside the rail keeps one owner without retrying', () => { const f = thrashingRightRail({ weatherOpen: true }); f.run(); - assert.equal(f.retries(), 1); - f.weather.classList.remove('collapsed', 'layout-auto-collapsed'); + f.weather.classList.remove('collapsed', 'accordion-collapsed'); f.run(); - assert.equal(f.retries(), 1); - assert.equal(f.stack.dataset.layoutMode, 'focus'); + assert.equal(f.retries(), 0); + assert.equal(f.weather.classList.contains('collapsed'), true); + assert.equal(f.cctv.classList.contains('collapsed'), false); }); test('right rail restores presentation even when intrinsic measurement throws', () => { @@ -595,14 +592,14 @@ test('right rail measuring pass keeps an opted-in scroller position', () => { }); for (const variant of ['minimal', 'full']) { - test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => { + test(`right rail settles two tall panels without retrying in the ${variant} HUD`, () => { const f = fixture('right', { hud: { visible: true, variant } }); f.expand(f.first, 900); f.expand(f.second, 900); for (let pass = 0; pass < 10; pass++) f.run(); assert.equal(f.retries(), 0); assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('accordion-collapsed'), true); }); } // ── Narrow-screen rail overflow pin ────────────────────────────────────────── @@ -699,10 +696,11 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity f.run(); - assert.equal(f.stack.classList.contains('layout-exclusive'), false); + // The rail lists the collapsed panels; the CSS-hidden one claims nothing. + assert.equal(f.stack.classList.contains('layout-exclusive'), true); assert.equal(f.stack.dataset.expandedCount, '0'); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - assert.equal(f.second.getAttribute('aria-hidden'), undefined); + assert.equal(weather.classList.contains('collapsed'), false); + assert.equal(weather.getAttribute('aria-hidden'), undefined); assert.equal( weather.style.getPropertyValue('--right-panel-allocated-height'), '', @@ -730,9 +728,10 @@ test('right layout still restores panels that exclusive presentation hides', () f.options.hud.visible = false; f.run(); - assert.equal(f.second.classList.contains('collapsed'), false); + // Restored from the space-based collapse, it yields to the accordion owner. assert.equal(f.second.classList.contains('layout-auto-collapsed'), false); - assert.deepEqual(f.collapsed, ['second']); + assert.equal(f.second.classList.contains('accordion-collapsed'), true); + assert.equal(f.first.classList.contains('collapsed'), false); }); test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { @@ -766,10 +765,10 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', ( const retriesBefore = f.retries(); f.run(); assert.ok( - f.collapsed.includes('recent-imagery-panel'), - 'focus mode collapses the later panel and asks for another pass', + imagery.classList.contains('accordion-collapsed'), + 'the accordion keeps the first panel and collapses the later one', ); - assert.equal(f.retries(), retriesBefore + 1); + assert.equal(f.retries(), retriesBefore); f.run(); assert.equal(f.stack.dataset.expandedCount, '1'); assert.ok( diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 5d3e5e40c..5afdce2b7 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -2,10 +2,7 @@ import { allocatePanelStackHeights, panelStackAutoCollapseIndices, } from '../panelStackLayout.js'; -import { - resolveHudRailLayout, - shouldHideCollapsedRightPanels, -} from './panelRailGeometry.js'; +import { resolveHudRailLayout } from './panelRailGeometry.js'; import { displayPanelScroller } from './displayPanelScroll.js'; @@ -89,11 +86,12 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - // Normalize restored state and theme entry 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. 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. const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; - if (hud.variant === 'cyber' && !isMobile) { + if (!isMobile) { const expanded = panels.filter( (panel) => !panel.classList.contains('collapsed'), ); @@ -103,29 +101,20 @@ export function layoutRightPanelRail({ expanded[0]; for (const panel of expanded) { if (panel === owner) continue; - panel.classList.add('collapsed', 'cyber-accordion-collapsed'); + panel.classList.add('collapsed', 'accordion-collapsed'); onCollapse(panel); } } else { for (const panel of panels) { - if (!panel.classList.contains('cyber-accordion-collapsed')) continue; - panel.classList.remove('collapsed', 'cyber-accordion-collapsed'); + if (!panel.classList.contains('accordion-collapsed')) continue; + panel.classList.remove('collapsed', 'accordion-collapsed'); onCollapse(panel); } } - const hasExpandedPanel = panels.some( - (panel) => - !panel.classList.contains('collapsed') && - (!isMobile || panel.id !== 'pp-toggles'), - ); + // 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. - const exclusive = - !isMobile && - shouldHideCollapsedRightPanels({ - hudVariant: hud.variant, - hasExpandedPanel, - }); + const exclusive = !isMobile; stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { if (exclusive && panel.classList.contains('collapsed')) diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs similarity index 60% rename from src/ui/cyberPanelDisclosure.test.mjs rename to src/ui/sidePanelDisclosure.test.mjs index e2a666ba5..41b0977f7 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/sidePanelDisclosure.test.mjs @@ -19,6 +19,10 @@ function fixture(theme = 'cyber') { nodes.set(id, value); return value; }; + const left = node('left-stack'); + const layers = node('data-panel', false), scenes = node('scene-panel'); + left.children = [layers, scenes]; + for (const panel of left.children) panel.parentElement = left; const rail = node('rail'); const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel'); rail.children = [display, context, cctv]; @@ -27,6 +31,7 @@ function fixture(theme = 'cyber') { radio.parentElement = context; const saved = [], claimed = []; const owner = { + _leftPanelStack: left, _rightPanelStack: rail, _panelLayout: {}, _lifetime: { frame() {} }, @@ -36,29 +41,36 @@ function fixture(theme = 'cyber') { shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} }, }; owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner); - return { display, context, cctv, radio, owner, saved, claimed, + return { layers, scenes, display, context, cctv, radio, owner, saved, claimed, doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) }, }; } -test('explicit Cyber opening collapses peers and claims their restoration lanes', () => { - const f = fixture(), prior = globalThis.document; - globalThis.document = f.doc; - try { - f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true }); - f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); - assert.equal(f.display.classList.contains('collapsed'), false); - assert.equal(f.context.classList.contains('collapsed'), true); - assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed)); - assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel')); - f.cctv.classList.add('cyber-accordion-collapsed'); - f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true }); - assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false); - } finally { globalThis.document = prior; } +test('explicit opening collapses side peers and claims their restoration lanes in every theme', () => { + for (const theme of ['tactical', 'minimal', 'cyber']) { + const f = fixture(theme), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true }); + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + assert.equal(f.display.classList.contains('collapsed'), false); + assert.equal(f.context.classList.contains('collapsed'), true); + assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed)); + assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel')); + f.cctv.classList.add('accordion-collapsed'); + f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true }); + assert.equal(f.cctv.classList.contains('accordion-collapsed'), false); + // The left side has its own accordion; the right side is untouched. + f.owner.setPanelCollapsed('scene-panel', false, { explicit: true }); + assert.equal(f.scenes.classList.contains('collapsed'), false); + assert.equal(f.layers.classList.contains('collapsed'), true); + assert.equal(f.display.classList.contains('collapsed'), false); + } finally { globalThis.document = prior; } + } }); test('opening already-expanded Radio reveals Context and closes Display', () => { - const f = fixture(), prior = globalThis.document; + const f = fixture('tactical'), prior = globalThis.document; globalThis.document = f.doc; try { f.owner.setPanelCollapsed('radio-panel', false, { explicit: true }); @@ -71,17 +83,17 @@ test('opening already-expanded Radio reveals Context and closes Display', () => } finally { globalThis.document = prior; } }); -test('non-Cyber explicit panels and restored preferences retain their existing policy', () => { +test('restored preferences keep every saved panel open for the layout to normalize', () => { for (const theme of ['tactical', 'minimal', 'cyber']) { const f = fixture(theme), prior = globalThis.document; globalThis.document = f.doc; try { - const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true }; + const options = { restore: true, persist: false }; f.owner.setPanelCollapsed('pp-toggles', false, options); f.owner.setPanelCollapsed('cctv-panel', false, options); assert.equal(f.display.classList.contains('collapsed'), false); assert.equal(f.cctv.classList.contains('collapsed'), false); - if (theme === 'cyber') assert.deepEqual(f.saved, []); + assert.deepEqual(f.saved, []); } finally { globalThis.document = prior; } } }); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 0f8ed5522..e71cd381c 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -260,6 +260,9 @@ #right-context-rail.layout-exclusive > [data-panel-id].collapsed { display: none; } +#left-panel-stack.layout-exclusive > [data-panel-id].collapsed { + display: none; +} #right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto;