From bf77ae61b8421ea1e9d3ec5e83d03e3c775fc398 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 19:53:17 -0700 Subject: [PATCH 1/3] refactor(ui): extract adaptive panel rail layout --- CHANGELOG.md | 3 + docs/CODE-BOUNDARIES.md | 18 ++ docs/CURRENT-STATE.md | 15 ++ package.json | 3 +- scripts/package-boundaries.json | 14 + src/cockpitMarkup.test.mjs | 14 +- src/cockpitMath.js | 44 +-- src/creditAttribution.test.mjs | 5 +- src/panelStackLayout.test.mjs | 33 +-- src/rightRailPolicy.js | 13 +- src/rightRailPolicy.test.mjs | 4 +- src/ui.js | 462 ++------------------------------ src/ui/leftPanelRail.js | 254 ++++++++++++++++++ src/ui/panelMeasurement.js | 33 +++ src/ui/panelRailGeometry.js | 55 ++++ src/ui/panelRails.js | 5 + src/ui/panelRails.test.mjs | 145 ++++++++++ src/ui/rightPanelRail.js | 217 +++++++++++++++ 18 files changed, 816 insertions(+), 521 deletions(-) create mode 100644 src/ui/leftPanelRail.js create mode 100644 src/ui/panelMeasurement.js create mode 100644 src/ui/panelRailGeometry.js create mode 100644 src/ui/panelRails.js create mode 100644 src/ui/panelRails.test.mjs create mode 100644 src/ui/rightPanelRail.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bfab34d86..6f5fa2eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Extract adaptive panel rail placement and measurement into reusable UI modules, + preserving obstacle clearance, responsive allocation, disclosure and scroll behavior. + - Extract shared surface keyboard handling for the welcome launcher and Provider Settings, preserving Tab/Escape behavior and releasing the listener on teardown. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 272605ef6..8b7a4c90e 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -213,3 +213,21 @@ current visible/enabled controls for each key; ordinary movement within those boundaries remains native. The component honors already-handled keys and has no app, server, storage or network dependencies. Its package boundary is checked independently from the standalone screens that consume it. + +## Panel rail layout + +`gods-eye-view/ui/layout` exports synchronous `layoutLeftPanelRail` and +`layoutRightPanelRail` passes, `measurePanelNaturalHeight`, and the existing pure +corridor/allocation helpers. Separate modules own left placement, right placement, +DOM height measurement and rail geometry. They import no application, renderer, +server, storage or network modules; package checks build this entry independently. + +Callers supply rail/obstacle DOM nodes, the viewport, HUD state, the preferred +panel, disclosure/retry callbacks and the left measurement cache. Right layout +reads the caller's Display scroll value at measurement time and restores it +within the resulting scroll range. The left pass notifies its caller after +alignment so the right pass can follow. Neither pass installs listeners, timers +or observers; construction/import does no work. Scheduling, preference writes, +share restoration and movement of controls between containers remain caller-owned. +Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain +compatible through re-exports. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 431ed2426..359a9850f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,20 @@ # God's Eye View Current State +## Adaptive panel rail layout + +`ui/layout` supplies the left/right rail layout passes, natural-height +measurement and pure corridor/allocation helpers. The UI facade passes live DOM +nodes, obstacle nodes, HUD presentation, preferred panels and callbacks. It +retains observers, frame scheduling, saved/share preferences and Cockpit portals. + +The left rail keeps its measured collapsed heights and obstacle-safe corridor; +the right rail follows its top baseline and preserves Display's scroll owner. +Automatic collapse remains presentation only, prioritizes the latest explicit +panel, and honors keyboard focus on the right. Mobile layout still releases +desktop allocation styles. Stable Display allocation avoids unnecessary style +writes. This extraction does not change panel positions or layout defaults. + + ## Surface keyboard lifecycle `ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return diff --git a/package.json b/package.json index 702540200..eb4358836 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ }, "./search": "./src/search/index.js", "./ui/panels": "./src/ui/panelDisclosure.js", - "./ui/surfaces": "./src/ui/surfaceKeyboard.js" + "./ui/surfaces": "./src/ui/surfaceKeyboard.js", + "./ui/layout": "./src/ui/panelRails.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 02aff268b..cd348fb43 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -273,5 +273,19 @@ "exports": ["./ui/surfaces"], "modules": ["src/ui/surfaceKeyboard.js"], "external": [] + }, + "panel-rail-layout": { + "exports": [ + "./ui/layout" + ], + "modules": [ + "src/ui/panelRails.js", + "src/ui/leftPanelRail.js", + "src/ui/rightPanelRail.js", + "src/ui/panelMeasurement.js", + "src/ui/panelRailGeometry.js", + "src/panelStackLayout.js" + ], + "external": [] } } diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 297a6b7a6..2090667c8 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -525,12 +525,10 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/); assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline > div/); assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline > div/); - const leftLayout = ui.match( - /_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/, - ); + const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); assert.ok(leftLayout, 'left accordion layout pass is missing'); assert.doesNotMatch( - leftLayout[1], + leftLayout, /setProperty\('--cockpit-utility-top'/, 'the right margin must not borrow the left accordion corridor: it is solved ' + 'against left-lane obstacles and put the strip through the briefing card', @@ -585,17 +583,15 @@ test('an expanded Cockpit left panel stays above Contact, HUD, and attribution', /COCKPIT_PASSABLE_LEFT_OBSTACLE_SELECTOR|cockpitOverlaysPassable/, 'Cockpit obstacles must never be bypassed by an expanded map panel', ); - const leftLayout = ui.match( - /_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/, - ); + const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); assert.ok(leftLayout, 'left accordion layout pass is missing'); assert.match( - leftLayout[1], + leftLayout, /rect\.top >= baseTop\) \{\s*\n\s*bottomObstacles\.push\(\{ top: rect\.top \}\);/, 'every rendered lower-lane obstacle must constrain the panel corridor', ); assert.match( - leftLayout[1], + leftLayout, /safeBottom = resolveLeftStackBottomBoundary\(\{[\s\S]*?safeGap,\s*\n\s*\}\);/, ); const cockpitHud = css.match(/#cockpit-hud\s*\{[\s\S]*?z-index:\s*(\d+);/); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 1d2c80205..fc3e2135f 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -1,3 +1,5 @@ +export { resolveHudRailLayout } from './ui/panelRailGeometry.js'; + /** Normalize a heading into the [0, 360) range. */ export function normalizeHeading(value) { if (!Number.isFinite(value)) return 0; @@ -282,45 +284,3 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } = contactLost, }; } - -/** - * Resolve a vertically centered panel slot inside a HUD rail while respecting - * live rectangles that intersect that rail above or below the viewport center. - */ -export function resolveHudRailLayout({ - viewportHeight, - panelHeight, - laneLeft, - laneRight, - obstacles = [], - baseTop, - baseBottom, - gap = 12, - align = 'center', -}) { - if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom] - .every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null; - const midpoint = viewportHeight * 0.5; - let safeTop = Math.max(0, baseTop); - let safeBottom = Math.min(viewportHeight, baseBottom); - - for (const rect of obstacles) { - if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue; - if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue; - if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap); - else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap); - } - - safeBottom = Math.max(safeTop, safeBottom); - const availableHeight = Math.max(0, safeBottom - safeTop); - const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight); - return { - top: align === 'start' - ? safeTop - : safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5), - maxHeight: availableHeight, - safeTop, - safeBottom, - constrained: panelHeight > availableHeight, - }; -} diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index c2c950321..dc1c17136 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -479,9 +479,10 @@ test('the full-width rail cannot inherit a height that overrides its floor', () // over-constrained. It is safe only because the rail's layout pass switches // to a mobile mode at the SAME breakpoint and removes both the class and the // custom property. Pin that, or the exemption above is unearned. - const gate = ui.indexOf("window.matchMedia('(max-width: 720px)')"); + const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8'); + const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')"); assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)'); - const mobileBranch = ui.slice(gate, ui.indexOf("layoutMode = 'mobile'", gate) + 40); + const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40); assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/); assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/); }); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 20d65dd17..c9d0c0fee 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +const leftRail = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); +const rightRail = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); +const rails = leftRail + rightRail; import { allocatePanelStackHeights, panelStackAutoCollapseIndices, @@ -135,7 +138,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/); - assert.match(ui, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/); + assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/); assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/); assert.match(ui, /reconsiderAutoCollapse/); assert.match( @@ -146,32 +149,32 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c ui, /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/, ); - assert.match(ui, /collapseLaterPanels: shouldFocus && this\.hud\.getVariant\(\) === 'tactical'/); + assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/); assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); assert.match( - ui, + leftRail, /preferredExpandedPanel[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened left panel must receive primary allocation', ); assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match( - ui, - /panel\.id === this\._rightStackPreferredPanelId[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/, + rightRail, + /panel\.id === preferredPanelId[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened right panel must receive primary allocation', ); assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/); - assert.match(ui, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\(panel\) => panel\.contains\(document\.activeElement\)\)/); + assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\(panel\) => panel\.contains\(documentRef\.activeElement\)\)/); assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/); - assert.match(ui, /--left-panel-allocated-height/); - assert.match(ui, /--right-panel-allocated-height/); + assert.match(leftRail, /--left-panel-allocated-height/); + assert.match(rightRail, /--right-panel-allocated-height/); assert.match( - ui, - /expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?_measureLeftPanelNaturalHeight/, + leftRail, + /expandedPanels[\s\S]*?removeProperty\('--left-panel-allocated-height'\)[\s\S]*?measurePanelNaturalHeight/, 'left intrinsic measurement must clear the prior allocation first', ); assert.match( - ui, - /panel !== this\._ppToggles[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/, + rightRail, + /panel !== displayPanel[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/, 'right intrinsic measurement must retain Display allocation while clearing other panel allocations', ); assert.match(css, /var\(--left-panel-allocated-height/); @@ -182,7 +185,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(ui, /const hiddenSibling = shouldFocus && panel\.classList\.contains\('collapsed'\);/); + assert.match(leftRail, /const hiddenSibling = shouldFocus && panel\.classList\.contains\('collapsed'\);/); }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { @@ -213,8 +216,8 @@ test('parameterized Display presets keep one stable scroll owner', () => { const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/); - assert.match(ui, /const displayScrollTop = this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); - assert.match(ui, /this\._ppToggles\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); + assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/); + assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/); assert.match( ui, /this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/, diff --git a/src/rightRailPolicy.js b/src/rightRailPolicy.js index cac4315fa..06d74ac44 100644 --- a/src/rightRailPolicy.js +++ b/src/rightRailPolicy.js @@ -1,15 +1,4 @@ -/** - * 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); -} +export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js'; const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([ 'contacts', diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2524684b7..46c3d2178 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -32,9 +32,9 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => { }); test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); - assert.match(ui, /const isMobile = window\.matchMedia\('\(max-width: 720px\)'\)\.matches/); + assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); assert.match( ui, /!panel\.classList\.contains\('collapsed'\) && \(!isMobile \|\| panel\.id !== 'pp-toggles'\)/, diff --git a/src/ui.js b/src/ui.js index 702584540..f78158775 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js'; import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js'; import * as Cesium from 'cesium'; import { retroShader } from './styles/retro.js'; @@ -109,14 +110,7 @@ import { } from './contextModePolicy.js'; import { shouldExpandGlobalContextPanel, - shouldHideCollapsedRightPanels, } from './rightRailPolicy.js'; -import { - allocatePanelStackHeights, - panelStackAutoCollapseIndices, - resolveLeftStackBottomBoundary, - resolvePanelStackCorridor, -} from './panelStackLayout.js'; import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, @@ -6868,187 +6862,20 @@ export class StyleManager { * @returns {void} */ _syncRightPanelAdaptiveLayout() { - const stack = this._rightPanelStack; - if (!stack) return; - - const panels = [...stack.children].filter((panel) => panel.matches('[data-panel-id]')); - if (!this.hud.visible || this.hud.getVariant() !== 'tactical') { - for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - } - const isMobile = window.matchMedia('(max-width: 720px)').matches; - const hasExpandedPanel = panels.some((panel) => ( - !panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles') - )); - const exclusive = shouldHideCollapsedRightPanels({ - hudVariant: this.hud.getVariant(), - hasExpandedPanel, + layoutRightPanelRail({ + stack: this._rightPanelStack, + obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR), + windowRef: window, + hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, + preferredPanelId: this._rightStackPreferredPanelId, + onCollapse: (panel) => this._syncPanelCollapseButton(panel), + onRetry: () => this._scheduleRightPanelLayout(), + leftStack: this._leftPanelStack, + displayPanel: this._ppToggles, + readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard' + ? this._standardDisplayScrollTop + : (this._ppToggles?.scrollTop || 0), }); - stack.classList.toggle('layout-exclusive', exclusive); - for (const panel of panels) { - if (exclusive && panel.classList.contains('collapsed')) panel.setAttribute('aria-hidden', 'true'); - else panel.removeAttribute('aria-hidden'); - } - - if (isMobile) { - stack.classList.remove('layout-focus'); - stack.style.removeProperty('--right-stack-safe-top'); - stack.style.removeProperty('--right-stack-max-height'); - for (const panel of panels) panel.style.removeProperty('--right-panel-allocated-height'); - stack.dataset.layoutMode = 'mobile'; - return; - } - - const viewportHeight = Math.max(1, window.innerHeight); - const safeGap = Math.max(8, viewportHeight * 0.012); - const stackRect = stack.getBoundingClientRect(); - const leftStackTop = this._leftPanelStack?.getBoundingClientRect().top; - const alignedTop = Number.isFinite(leftStackTop) - ? leftStackTop - : viewportHeight * 0.26; - const obstacleRects = []; - - for (const obstacle of document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR)) { - if (stack.contains(obstacle)) continue; - let hiddenByAncestor = false; - for (let element = obstacle; element; element = element.parentElement) { - const style = getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { - hiddenByAncestor = true; - break; - } - } - if (hiddenByAncestor) continue; - const rect = obstacle.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) continue; - obstacleRects.push({ - left: rect.left, - right: rect.right, - top: rect.top, - bottom: rect.bottom, - }); - } - - const visiblePanels = panels.filter((panel) => ( - !exclusive || !panel.classList.contains('collapsed') - )); - const displayScrollTop = this._displayPortalScrollRestoreOwner === 'standard' - ? this._standardDisplayScrollTop - : (this._ppToggles?.scrollTop || 0); - // Measure intrinsic content, not the allocation written by the previous - // layout pass. Display is the exception: its own scrollHeight already - // exposes every control, and removing its live allocation can reset the - // user's scroll position while HUD or preset content is settling. - for (const panel of visiblePanels) { - if (!panel.classList.contains('collapsed') && panel !== this._ppToggles) { - panel.style.removeProperty('--right-panel-allocated-height'); - } - } - const gap = parseFloat(getComputedStyle(stack).rowGap) || 0; - const naturalHeight = visiblePanels.reduce((total, panel) => ( - total + Math.max( - panel.getBoundingClientRect().height, - panel.scrollHeight || 0, - panel.classList.contains('collapsed') ? 42 : 0, - ) - ), 0) + gap * Math.max(0, visiblePanels.length - 1); - const layout = resolveHudRailLayout({ - viewportHeight, - panelHeight: naturalHeight, - laneLeft: stackRect.left, - laneRight: stackRect.right, - obstacles: obstacleRects, - baseTop: alignedTop, - baseBottom: viewportHeight * 0.96, - gap: safeGap, - align: 'start', - }); - if (!layout) return; - const { safeTop, safeBottom, maxHeight: availableHeight } = layout; - const stabilityBand = viewportHeight * 0.01; - const wasFocused = stack.classList.contains('layout-focus'); - const shouldFocus = wasFocused - ? naturalHeight > availableHeight - stabilityBand * 2 - : naturalHeight > availableHeight - stabilityBand; - const layoutTop = shouldFocus ? safeTop : layout.top; - const collapsedHeight = visiblePanels.reduce((total, panel) => ( - panel.classList.contains('collapsed') - ? total + panel.getBoundingClientRect().height - : total - ), 0); - const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed')); - const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(document.activeElement)); - const preferredExpandedPanel = expandedPanelsInDomOrder.find( - (panel) => panel.id === this._rightStackPreferredPanelId, - ) || focusedExpandedPanel; - // Match the left lane: allocation order follows the latest explicit - // disclosure, not DOM order. A focused panel is the fallback owner so - // temporary presentation collapse never strands keyboard focus. - const expandedPanels = preferredExpandedPanel - ? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] - : expandedPanelsInDomOrder; - const expandedAvailableHeight = Math.max( - 0, - safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1), - ); - const expandedHeights = allocatePanelStackHeights({ - naturalHeights: expandedPanels.map((panel) => Math.max( - panel.getBoundingClientRect().height, - panel.scrollHeight || 0, - )), - availableHeight: expandedAvailableHeight, - }); - const autoCollapseIndices = this.hud.visible ? panelStackAutoCollapseIndices({ - naturalHeights: expandedPanels.map((panel) => Math.max( - panel.getBoundingClientRect().height, - panel.scrollHeight || 0, - )), - allocatedHeights: expandedHeights, - collapseLaterPanels: shouldFocus && this.hud.getVariant() === 'tactical', - }) : []; - if (autoCollapseIndices.length) { - for (const index of autoCollapseIndices) { - const panel = expandedPanels[index]; - panel.classList.add('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - this._scheduleRightPanelLayout(); - return; - } - // Write-if-changed. This pass runs on the 500 ms stats cadence, and an - // unconditional REMOVE-then-SET of an unchanged allocation is two style - // mutations per tick on `#pp-toggles` (the one panel the measure-strip - // above deliberately skips) — churn that reads as a genuine panel move to - // the world-overlay host's occluder observer and defeats parked-idle - // render savings. Only a real allocation change may touch the attribute. - expandedPanels.forEach((panel, index) => { - const next = `${expandedHeights[index].toFixed(1)}px`; - if (panel.style.getPropertyValue('--right-panel-allocated-height') !== next) { - panel.style.setProperty('--right-panel-allocated-height', next); - } - }); - for (const panel of panels) { - if (expandedPanels.includes(panel)) continue; - panel.style.removeProperty('--right-panel-allocated-height'); - } - - stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`); - stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`); - stack.classList.toggle('layout-focus', shouldFocus); - stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal'; - stack.dataset.safeTop = layoutTop.toFixed(1); - stack.dataset.safeBottom = safeBottom.toFixed(1); - stack.dataset.availableHeight = availableHeight.toFixed(1); - stack.dataset.requiredHeight = naturalHeight.toFixed(1); - stack.dataset.expandedCount = String(expandedPanels.length); - - if (this._ppToggles && expandedPanels.includes(this._ppToggles)) { - const maxScrollTop = Math.max(0, this._ppToggles.scrollHeight - this._ppToggles.clientHeight); - this._ppToggles.scrollTop = Math.min(displayScrollTop, maxScrollTop); - } - } /** @@ -7152,40 +6979,6 @@ export class StyleManager { }); } - /** - * Estimates an expanded panel's unconstrained content height from its - * visible direct children and their scroll extents. This avoids treating a - * flex-grown panel as naturally tall while still accounting for nested lists. - * @param {HTMLElement} panel - Expanded accordion panel. - * @returns {number} Natural height in rendered CSS pixels. - */ - _measureLeftPanelNaturalHeight(panel) { - const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); - if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height); - - const innerRect = inner.getBoundingClientRect(); - const panelStyle = getComputedStyle(panel); - const innerStyle = getComputedStyle(inner); - const paddingBottom = parseFloat(innerStyle.paddingBottom) || 0; - let contentBottom = parseFloat(innerStyle.paddingTop) || 0; - - for (const child of inner.children) { - const childStyle = getComputedStyle(child); - if (childStyle.display === 'none' || childStyle.visibility === 'hidden') continue; - const childRect = child.getBoundingClientRect(); - const marginBottom = parseFloat(childStyle.marginBottom) || 0; - const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0); - const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom; - contentBottom = Math.max(contentBottom, childBottom); - } - - const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0) - + (parseFloat(panelStyle.borderBottomWidth) || 0) - + (parseFloat(panelStyle.paddingTop) || 0) - + (parseFloat(panelStyle.paddingBottom) || 0); - return Math.ceil(contentBottom + paddingBottom + wrapperChrome); - } - /** * Measures a live obstacle-free corridor for the left accordion and toggles * focus mode only when the expanded panel plus sibling labels cannot fit. @@ -7193,224 +6986,17 @@ export class StyleManager { * @returns {void} */ _syncLeftPanelAdaptiveLayout() { - const stack = this._leftPanelStack; - if (!stack) return; - - const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')]; - if (!panels.length) return; - if (!this.hud.visible || this.hud.getVariant() !== 'tactical') { - for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) { - panel.classList.remove('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - } - - // The existing narrow-screen composition has its own full-width stack. - // Keep this desktop lane engine from fighting those dedicated rules. - if (window.matchMedia('(max-width: 720px)').matches) { - 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.dataset.layoutMode = 'mobile'; - for (const panel of panels) { - panel.removeAttribute('aria-hidden'); - panel.style.removeProperty('--left-panel-allocated-height'); - } - return; - } - - const viewportHeight = Math.max(1, window.innerHeight); - const stackRect = stack.getBoundingClientRect(); - const baseTop = viewportHeight * 0.26; - const baseBottomInset = viewportHeight * 0.04; - const safeGap = viewportHeight * 0.012; - let obstacleSafeTop = viewportHeight * 0.04; - let safeTop = baseTop; - let safeBottom = viewportHeight - baseBottomInset; - const bottomObstacles = []; - - for (const obstacle of document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR)) { - if (stack.contains(obstacle)) continue; - let hiddenByAncestor = false; - for (let element = obstacle; element; element = element.parentElement) { - const style = getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { - hiddenByAncestor = true; - break; - } - } - if (hiddenByAncestor) continue; - const rect = obstacle.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) continue; - const overlapsHorizontally = rect.right > stackRect.left && rect.left < stackRect.right; - if (!overlapsHorizontally) continue; - - if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) { - const obstacleBottom = rect.bottom + safeGap; - obstacleSafeTop = Math.max(obstacleSafeTop, obstacleBottom); - safeTop = Math.max(safeTop, obstacleBottom); - } else if (rect.top >= baseTop) { - bottomObstacles.push({ top: rect.top }); - } - } - safeBottom = resolveLeftStackBottomBoundary({ - baseBottom: safeBottom, - obstacles: bottomObstacles, - safeGap, + layoutLeftPanelRail({ + stack: this._leftPanelStack, + obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR), + windowRef: window, + hud: { visible: this.hud.visible, variant: this.hud.getVariant() }, + preferredPanelId: this._leftStackPreferredPanelId, + onCollapse: (panel) => this._syncPanelCollapseButton(panel), + onRetry: () => this._scheduleLeftPanelLayout(), + collapsedHeights: this._leftStackCollapsedHeights, + onAligned: () => this._scheduleRightPanelLayout(), }); - - const obstacleSafeBottom = safeBottom; - - // Keep the accordion visually centered when the balanced corridor remains - // useful. During live viewport-height changes, retain the aligned lane - // instead of extending a tiny midpoint corridor through a lower obstacle. - const minimumLaneHeight = viewportHeight * 0.16; - ({ safeTop, safeBottom } = resolvePanelStackCorridor({ - viewportHeight, - safeTop, - safeBottom, - obstacleSafeTop, - obstacleSafeBottom, - minimumHeight: minimumLaneHeight, - })); - const viewportMidpoint = viewportHeight * 0.5; - for (const panel of panels) { - const rect = panel.getBoundingClientRect(); - if (panel.classList.contains('collapsed') && rect.height > 0) { - this._leftStackCollapsedHeights.set(panel.id, rect.height); - } - } - - const expandedPanelsInDomOrder = panels.filter((panel) => !panel.classList.contains('collapsed')); - const preferredExpandedPanel = expandedPanelsInDomOrder.find( - (panel) => panel.id === this._leftStackPreferredPanelId, - ); - // Auto-collapse is a presentation fallback, not permission to undo the - // user's newest disclosure. Measure and allocate that explicitly opened - // panel first so an older expanded sibling yields when the corridor cannot - // usefully present both (for example Map Stack followed by Scenes). - const expandedPanels = preferredExpandedPanel - ? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] - : expandedPanelsInDomOrder; - // Clear the prior pass before reading intrinsic heights. The allocated - // outer height and the inner scroller otherwise feed their constrained - // size back into the next HUD-mode calculation. - for (const panel of expandedPanels) { - panel.style.removeProperty('--left-panel-allocated-height'); - } - const availableHeight = Math.max(0, safeBottom - safeTop); - const naturalExpandedHeights = expandedPanels.map((panel) => this._measureLeftPanelNaturalHeight(panel)); - const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0); - const siblingHeight = panels.reduce((total, panel) => { - if (!panel.classList.contains('collapsed')) return total; - const measured = this._leftStackCollapsedHeights.get(panel.id); - return total + (measured || panel.getBoundingClientRect().height || 0); - }, 0); - 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); - } - - const wasFocused = stack.classList.contains('layout-focus'); - const wasTail = stack.classList.contains('layout-tail'); - const wasConstrained = wasFocused || wasTail; - const stabilityBand = viewportHeight * 0.01; - const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained - ? requiredHeight > availableHeight - stabilityBand * 2 - : requiredHeight > availableHeight - stabilityBand); - const tailRequiredHeight = naturalExpandedHeight - + siblingHeight - + rowGap * Math.max(0, panels.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 - // upward around the viewport midpoint as their content requires. - const centeredTailTop = viewportMidpoint - tailRequiredHeight * 0.5; - const tailLayoutTop = Math.min(centeredTailTop, safeTop); - const tailLayoutBottom = tailLayoutTop + tailRequiredHeight; - const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop); - const tailTolerance = wasTail ? stabilityBand : -stabilityBand; - const shouldTail = expandedPanels.length > 0 - && tailLayoutTop >= obstacleSafeTop - tailTolerance - && tailLayoutBottom <= obstacleSafeBottom + tailTolerance; - const shouldFocus = exceedsCenteredCorridor && !shouldTail; - // Focus mode owns the lane, so let every expanded panel share the full - // obstacle-safe corridor. Tail/normal layouts keep the balanced - // viewport centering used for compact accordion stacks. - const layoutTop = shouldFocus - ? obstacleSafeTop - : shouldTail ? tailLayoutTop : safeTop; - const layoutBottom = shouldFocus - ? obstacleSafeBottom - : shouldTail ? tailLayoutBottom : safeBottom; - const topPct = (layoutTop / viewportHeight) * 100; - const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100; - const topValue = `${topPct.toFixed(3)}vh`; - const bottomValue = `${bottomPct.toFixed(3)}vh`; - const expandedAvailableHeight = shouldFocus - ? Math.max(0, layoutBottom - layoutTop - - rowGap * Math.max(0, expandedPanels.length - 1)) - : naturalExpandedHeight; - const allocatedExpandedHeights = allocatePanelStackHeights({ - naturalHeights: naturalExpandedHeights, - availableHeight: expandedAvailableHeight, - }); - const autoCollapseIndices = this.hud.visible ? panelStackAutoCollapseIndices({ - naturalHeights: naturalExpandedHeights, - allocatedHeights: allocatedExpandedHeights, - collapseLaterPanels: shouldFocus && this.hud.getVariant() === 'tactical', - }) : []; - if (autoCollapseIndices.length) { - for (const index of autoCollapseIndices) { - const panel = expandedPanels[index]; - panel.classList.add('collapsed', 'layout-auto-collapsed'); - this._syncPanelCollapseButton(panel); - } - this._scheduleLeftPanelLayout(); - return; - } - if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) { - stack.style.setProperty('--left-stack-safe-top', topValue); - } - if (stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue) { - stack.style.setProperty('--left-stack-safe-bottom', bottomValue); - } - stack.style.removeProperty('--left-stack-centered-height'); - for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height'); - expandedPanels.forEach((panel, index) => { - panel.style.setProperty('--left-panel-allocated-height', `${allocatedExpandedHeights[index].toFixed(1)}px`); - }); - - stack.classList.toggle('layout-focus', shouldFocus); - stack.classList.toggle('layout-tail', shouldTail); - stack.dataset.layoutMode = shouldFocus ? 'focus' : shouldTail ? 'tail' : 'normal'; - stack.dataset.safeTopPct = topPct.toFixed(2); - stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2); - stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2); - stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2); - stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2); - stack.dataset.expandedCount = String(expandedPanels.length); - - // Cockpit Display/Radio live in the opposite margin and no longer borrow - // this corridor: the left accordion's top is solved against left-lane - // obstacles, which put the strip straight through the briefing card. - // 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'); - else panel.removeAttribute('aria-hidden'); - } - // The right controls share this top baseline; update them after the left - // accordion commits an HUD-variant or obstacle-driven position change. - this._scheduleRightPanelLayout(); } /** diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js new file mode 100644 index 000000000..b2e8221e3 --- /dev/null +++ b/src/ui/leftPanelRail.js @@ -0,0 +1,254 @@ +import { + allocatePanelStackHeights, + panelStackAutoCollapseIndices, + resolveLeftStackBottomBoundary, + resolvePanelStackCorridor, +} from '../panelStackLayout.js'; +import { measurePanelNaturalHeight } from './panelMeasurement.js'; + +/** + * Measure and place the left panel rail for one synchronous layout pass. + * The caller owns scheduling, obstacle selection, disclosure preferences and + * persistence. Auto-collapse is presentation only and reports through callbacks. + * @param {object} options Live DOM and caller policy. + * @param {HTMLElement} options.stack Rail element. + * @param {Iterable} options.obstacles Caller-selected obstacle nodes. + * @param {Window} options.windowRef Viewport and style reader. + * @param {{visible: boolean, variant: string}} options.hud Current HUD presentation. + * @param {string} options.preferredPanelId Most recently opened panel. + * @param {Function} options.onCollapse Update a panel's disclosure chrome. + * @param {Function} options.onRetry Request another pass after automatic collapse. + * @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. + */ +export function layoutLeftPanelRail({ + stack, + obstacles, + windowRef, + hud, + preferredPanelId, + onCollapse, + onRetry, + collapsedHeights, + onAligned, + getComputedStyle = (element) => windowRef.getComputedStyle(element) +}) { + if (!stack) return; + + const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')]; + if (!panels.length) return; + if (!hud.visible || hud.variant !== 'tactical') { + for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) { + panel.classList.remove('collapsed', 'layout-auto-collapsed'); + onCollapse(panel); + } + } + + // 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) { + 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.dataset.layoutMode = 'mobile'; + for (const panel of panels) { + panel.removeAttribute('aria-hidden'); + panel.style.removeProperty('--left-panel-allocated-height'); + } + return; + } + + const viewportHeight = Math.max(1, windowRef.innerHeight); + const stackRect = stack.getBoundingClientRect(); + const baseTop = viewportHeight * 0.26; + const baseBottomInset = viewportHeight * 0.04; + const safeGap = viewportHeight * 0.012; + let obstacleSafeTop = viewportHeight * 0.04; + let safeTop = baseTop; + let safeBottom = viewportHeight - baseBottomInset; + const bottomObstacles = []; + + for (const obstacle of obstacles) { + if (stack.contains(obstacle)) continue; + let hiddenByAncestor = false; + for (let element = obstacle; element; element = element.parentElement) { + const style = getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + hiddenByAncestor = true; + break; + } + } + if (hiddenByAncestor) continue; + const rect = obstacle.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + const overlapsHorizontally = rect.right > stackRect.left && rect.left < stackRect.right; + if (!overlapsHorizontally) continue; + + if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) { + const obstacleBottom = rect.bottom + safeGap; + obstacleSafeTop = Math.max(obstacleSafeTop, obstacleBottom); + safeTop = Math.max(safeTop, obstacleBottom); + } else if (rect.top >= baseTop) { + bottomObstacles.push({ top: rect.top }); + } + } + safeBottom = resolveLeftStackBottomBoundary({ + baseBottom: safeBottom, + obstacles: bottomObstacles, + safeGap, + }); + + const obstacleSafeBottom = safeBottom; + + // Keep the accordion visually centered when the balanced corridor remains + // useful. During live viewport-height changes, retain the aligned lane + // instead of extending a tiny midpoint corridor through a lower obstacle. + const minimumLaneHeight = viewportHeight * 0.16; + ({ safeTop, safeBottom } = resolvePanelStackCorridor({ + viewportHeight, + safeTop, + safeBottom, + obstacleSafeTop, + obstacleSafeBottom, + minimumHeight: minimumLaneHeight, + })); + const viewportMidpoint = viewportHeight * 0.5; + for (const panel of panels) { + const rect = panel.getBoundingClientRect(); + if (panel.classList.contains('collapsed') && rect.height > 0) { + collapsedHeights.set(panel.id, rect.height); + } + } + + const expandedPanelsInDomOrder = panels.filter((panel) => !panel.classList.contains('collapsed')); + const preferredExpandedPanel = expandedPanelsInDomOrder.find( + (panel) => panel.id === preferredPanelId, + ); + // Auto-collapse is a presentation fallback, not permission to undo the + // user's newest disclosure. Measure and allocate that explicitly opened + // panel first so an older expanded sibling yields when the corridor cannot + // usefully present both (for example Map Stack followed by Scenes). + const expandedPanels = preferredExpandedPanel + ? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] + : expandedPanelsInDomOrder; + // Clear the prior pass before reading intrinsic heights. The allocated + // outer height and the inner scroller otherwise feed their constrained + // size back into the next HUD-mode calculation. + for (const panel of expandedPanels) { + panel.style.removeProperty('--left-panel-allocated-height'); + } + const availableHeight = Math.max(0, safeBottom - safeTop); + const naturalExpandedHeights = expandedPanels.map((panel) => measurePanelNaturalHeight(panel, getComputedStyle)); + const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0); + const siblingHeight = panels.reduce((total, panel) => { + if (!panel.classList.contains('collapsed')) return total; + const measured = collapsedHeights.get(panel.id); + return total + (measured || panel.getBoundingClientRect().height || 0); + }, 0); + 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); + } + + const wasFocused = stack.classList.contains('layout-focus'); + const wasTail = stack.classList.contains('layout-tail'); + const wasConstrained = wasFocused || wasTail; + const stabilityBand = viewportHeight * 0.01; + const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained + ? requiredHeight > availableHeight - stabilityBand * 2 + : requiredHeight > availableHeight - stabilityBand); + const tailRequiredHeight = naturalExpandedHeight + + siblingHeight + + rowGap * Math.max(0, panels.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 + // upward around the viewport midpoint as their content requires. + const centeredTailTop = viewportMidpoint - tailRequiredHeight * 0.5; + const tailLayoutTop = Math.min(centeredTailTop, safeTop); + const tailLayoutBottom = tailLayoutTop + tailRequiredHeight; + const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop); + const tailTolerance = wasTail ? stabilityBand : -stabilityBand; + const shouldTail = expandedPanels.length > 0 + && tailLayoutTop >= obstacleSafeTop - tailTolerance + && tailLayoutBottom <= obstacleSafeBottom + tailTolerance; + const shouldFocus = exceedsCenteredCorridor && !shouldTail; + // Focus mode owns the lane, so let every expanded panel share the full + // obstacle-safe corridor. Tail/normal layouts keep the balanced + // viewport centering used for compact accordion stacks. + const layoutTop = shouldFocus + ? obstacleSafeTop + : shouldTail ? tailLayoutTop : safeTop; + const layoutBottom = shouldFocus + ? obstacleSafeBottom + : shouldTail ? tailLayoutBottom : safeBottom; + const topPct = (layoutTop / viewportHeight) * 100; + const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100; + const topValue = `${topPct.toFixed(3)}vh`; + const bottomValue = `${bottomPct.toFixed(3)}vh`; + const expandedAvailableHeight = shouldFocus + ? Math.max(0, layoutBottom - layoutTop + - rowGap * Math.max(0, expandedPanels.length - 1)) + : naturalExpandedHeight; + const allocatedExpandedHeights = allocatePanelStackHeights({ + naturalHeights: naturalExpandedHeights, + availableHeight: expandedAvailableHeight, + }); + const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({ + naturalHeights: naturalExpandedHeights, + allocatedHeights: allocatedExpandedHeights, + collapseLaterPanels: shouldFocus && hud.variant === 'tactical', + }) : []; + if (autoCollapseIndices.length) { + for (const index of autoCollapseIndices) { + const panel = expandedPanels[index]; + panel.classList.add('collapsed', 'layout-auto-collapsed'); + onCollapse(panel); + } + onRetry(); + return; + } + if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) { + stack.style.setProperty('--left-stack-safe-top', topValue); + } + if (stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue) { + stack.style.setProperty('--left-stack-safe-bottom', bottomValue); + } + stack.style.removeProperty('--left-stack-centered-height'); + for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height'); + expandedPanels.forEach((panel, index) => { + panel.style.setProperty('--left-panel-allocated-height', `${allocatedExpandedHeights[index].toFixed(1)}px`); + }); + + stack.classList.toggle('layout-focus', shouldFocus); + stack.classList.toggle('layout-tail', shouldTail); + stack.dataset.layoutMode = shouldFocus ? 'focus' : shouldTail ? 'tail' : 'normal'; + stack.dataset.safeTopPct = topPct.toFixed(2); + stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2); + stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2); + stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2); + stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2); + stack.dataset.expandedCount = String(expandedPanels.length); + + // Cockpit Display/Radio live in the opposite margin and no longer borrow + // this corridor: the left accordion's top is solved against left-lane + // obstacles, which put the strip straight through the briefing card. + // 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'); + else panel.removeAttribute('aria-hidden'); + } + // The right controls share this top baseline; update them after the left + // accordion commits an HUD-variant or obstacle-driven position change. + onAligned(); +} diff --git a/src/ui/panelMeasurement.js b/src/ui/panelMeasurement.js new file mode 100644 index 000000000..bb73648fa --- /dev/null +++ b/src/ui/panelMeasurement.js @@ -0,0 +1,33 @@ +/** + * Measure unconstrained visible content without feeding the allocated outer + * height back into the next layout pass. + * @param {HTMLElement} panel Panel whose natural height is needed. + * @param {Function} getComputedStyle Style reader for the panel's document. + * @returns {number} Natural height in CSS pixels. + */ +export function measurePanelNaturalHeight(panel, getComputedStyle) { + const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); + if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height); + + const innerRect = inner.getBoundingClientRect(); + const panelStyle = getComputedStyle(panel); + const innerStyle = getComputedStyle(inner); + const paddingBottom = parseFloat(innerStyle.paddingBottom) || 0; + let contentBottom = parseFloat(innerStyle.paddingTop) || 0; + + for (const child of inner.children) { + const childStyle = getComputedStyle(child); + if (childStyle.display === 'none' || childStyle.visibility === 'hidden') continue; + const childRect = child.getBoundingClientRect(); + const marginBottom = parseFloat(childStyle.marginBottom) || 0; + const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0); + const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom; + contentBottom = Math.max(contentBottom, childBottom); + } + + const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0) + + (parseFloat(panelStyle.borderBottomWidth) || 0) + + (parseFloat(panelStyle.paddingTop) || 0) + + (parseFloat(panelStyle.paddingBottom) || 0); + return Math.ceil(contentBottom + paddingBottom + wrapperChrome); +} diff --git a/src/ui/panelRailGeometry.js b/src/ui/panelRailGeometry.js new file mode 100644 index 000000000..5f9b3809a --- /dev/null +++ b/src/ui/panelRailGeometry.js @@ -0,0 +1,55 @@ +/** + * Resolve a vertically centered panel slot inside a HUD rail while respecting + * live rectangles that intersect that rail above or below the viewport center. + */ +export function resolveHudRailLayout({ + viewportHeight, + panelHeight, + laneLeft, + laneRight, + obstacles = [], + baseTop, + baseBottom, + gap = 12, + align = 'center', +}) { + if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom] + .every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null; + const midpoint = viewportHeight * 0.5; + let safeTop = Math.max(0, baseTop); + let safeBottom = Math.min(viewportHeight, baseBottom); + + for (const rect of obstacles) { + if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue; + if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue; + if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap); + else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap); + } + + safeBottom = Math.max(safeTop, safeBottom); + const availableHeight = Math.max(0, safeBottom - safeTop); + const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight); + return { + top: align === 'start' + ? safeTop + : safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5), + maxHeight: availableHeight, + safeTop, + safeBottom, + 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 new file mode 100644 index 000000000..e671bf607 --- /dev/null +++ b/src/ui/panelRails.js @@ -0,0 +1,5 @@ +export { layoutLeftPanelRail } from './leftPanelRail.js'; +export { layoutRightPanelRail } from './rightPanelRail.js'; +export { measurePanelNaturalHeight } from './panelMeasurement.js'; +export { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js'; +export { allocatePanelStackHeights, panelStackAutoCollapseIndices, resolveLeftStackBottomBoundary, resolvePanelStackCorridor } from '../panelStackLayout.js'; diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs new file mode 100644 index 000000000..6d51bb5db --- /dev/null +++ b/src/ui/panelRails.test.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { layoutLeftPanelRail, layoutRightPanelRail, measurePanelNaturalHeight } from './panelRails.js'; + +function element(id, { height = 42, top = 234, left = 20, width = 272, collapsed = false } = {}) { + const classes = new Set(collapsed ? ['collapsed'] : []); + const properties = new Map(); + const attributes = new Map(); + const writes = []; + const node = { + id, children: [], dataset: {}, parentElement: null, scrollHeight: height, + clientHeight: height, scrollTop: 0, + computed: { display: 'block', visibility: 'visible', opacity: '1', rowGap: '12px' }, + rect: { top, left, width, height, right: left + width, bottom: top + height }, + classList: { + contains: (name) => classes.has(name), + add: (...names) => names.forEach(name => classes.add(name)), + remove: (...names) => names.forEach(name => classes.delete(name)), + toggle(name, value) { if (value) classes.add(name); else classes.delete(name); }, + }, + style: { + setProperty(name, value) { writes.push(['set', name, value]); properties.set(name, value); }, + getPropertyValue: name => properties.get(name) || '', + removeProperty(name) { writes.push(['remove', name]); properties.delete(name); }, + }, + getBoundingClientRect() { return this.rect; }, + matches: selector => selector === '[data-panel-id]', + contains(target) { return target === this || this.children.some(child => child.contains(target)); }, + querySelectorAll() { return this.children; }, + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: name => attributes.delete(name), + getAttribute: name => attributes.get(name), + writes, + }; + return node; +} +function fixture(side, { mobile = false, hud = { visible: true, variant: 'tactical' } } = {}) { + const first = element('first', {height: 42, collapsed: true}); + const second = element('second', {height: 42, collapsed: true}); + const stack = element('rail', {height: 96, left: side === 'left' ? 20 : 1100}); + stack.children = [first, second]; + first.parentElement = second.parentElement = stack; + const documentRef = { activeElement: null }; + stack.ownerDocument = documentRef; + const collapsed = []; + let retries = 0, aligned = 0; + const options = { + stack, hud, documentRef, obstacles: [], collapsedHeights: new Map(), + windowRef: {innerHeight: 900, matchMedia: () => ({matches: mobile}), getComputedStyle: node => node.computed}, + onCollapse: panel => collapsed.push(panel.id), + onRetry: () => { retries += 1; }, + onAligned: () => { aligned += 1; }, + displayPanel: null, readDisplayScrollTop: () => 0, leftStack: element('left'), + }; + const run = () => (side === 'left' ? layoutLeftPanelRail : layoutRightPanelRail)(options); + const expand = (panel, height) => {panel.classList.remove('collapsed'); panel.scrollHeight = height; panel.rect.height = height; panel.rect.bottom = panel.rect.top + height;}; + return {options, first, second, stack, run, expand, collapsed, retries: () => retries, aligned: () => aligned}; +} + +test('missing rails are inert without browser globals', () => { + layoutLeftPanelRail({}); + layoutRightPanelRail({}); +}); + +test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => { + const f = fixture('left'); f.run(); + assert.equal(f.options.collapsedHeights.get('first'), 42); + assert.equal(f.first.classList.contains('collapsed'), true); + assert.equal(f.aligned(), 1); + assert.equal(f.retries(), 0); + assert.equal(f.stack.dataset.layoutMode, 'normal'); +}); + +test('left corridor respects a lower obstacle but ignores an obstacle hidden by its parent', () => { + const f = fixture('left'); + const blocker = element('blocker', {top: 600, height: 80}); + const hidden = element('hidden', {top: 300, height: 80}); + hidden.parentElement = element('hidden-parent'); hidden.parentElement.computed.opacity = '0'; + f.options.obstacles = [blocker, hidden]; f.run(); + assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47); +}); + +for (const side of ['left', 'right']) { + test(`${side} mobile layout releases desktop height/position styles and labels`, () => { + const f = fixture(side, {mobile: true}); + f.stack.classList.add('layout-focus'); + f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px'); + f.first.style.setProperty(`--${side}-panel-allocated-height`, '99px'); + f.first.setAttribute('aria-hidden','true'); f.run(); + assert.equal(f.stack.dataset.layoutMode, 'mobile'); + assert.equal(f.stack.style.getPropertyValue(`--${side}-stack-safe-top`), ''); + assert.equal(f.first.style.getPropertyValue(`--${side}-panel-allocated-height`), ''); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + }); + test(`${side} constrained layout preserves the preferred panel and requests 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.deepEqual(f.collapsed, ['first']); + assert.equal(f.retries(), 1); + }); + test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => { + const f = fixture(side, {hud: {visible: false, variant: 'tactical'}}); + f.first.classList.add('layout-auto-collapsed'); f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('collapsed'), true); + assert.deepEqual(f.collapsed, ['first']); + }); +} + +test('right layout uses keyboard focus when there is no preferred panel', () => { + const f = fixture('right'); f.expand(f.first, 900); 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.second.classList.contains('collapsed'), false); +}); + +test('right layout retains Display allocation during measurement and caps restored scroll', () => { + const f = fixture('right'); f.first.id = 'pp-toggles'; f.expand(f.first, 900); + f.first.clientHeight = 400; f.options.displayPanel = f.first; f.options.readDisplayScrollTop = () => 800; + f.first.style.setProperty('--right-panel-allocated-height', '600px'); f.first.writes.length = 0; + f.run(); + assert.equal(f.first.scrollTop, 500); + assert.equal(f.first.writes.some(([op, name]) => op === 'remove' && name === '--right-panel-allocated-height'), false); + f.first.writes.length = 0; f.run(); + assert.equal(f.first.writes.some(([,name]) => name === '--right-panel-allocated-height'), false, 'stable allocation must not churn the style attribute'); +}); + +test('right rail aligns to the current left rail and excludes hidden obstacles', () => { + const f = fixture('right'); f.options.leftStack.rect.top = 200; + const hidden = element('hidden', {left: 1100, top: 100, height: 200}); + hidden.computed.display = 'none'; f.options.obstacles = [hidden]; f.run(); + assert.equal(f.stack.dataset.safeTop, '200.0'); +}); + +test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => { + const panel = element('panel'); + const inner = element('inner', {top: 100}); inner.computed.paddingTop = '10px'; inner.computed.paddingBottom = '5px'; + const row = element('row', {top: 110, height: 40}); row.scrollHeight = 80; row.computed.marginBottom = '3px'; + const hidden = element('hidden', {top: 1000, height: 900}); hidden.computed.visibility = 'hidden'; + panel.computed.borderTopWidth = '1px'; panel.computed.borderBottomWidth = '1px'; + panel.children = [inner]; inner.children = [row, hidden]; + assert.equal(measurePanelNaturalHeight(panel, node => node.computed), 100); +}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js new file mode 100644 index 000000000..2f70ce00c --- /dev/null +++ b/src/ui/rightPanelRail.js @@ -0,0 +1,217 @@ +import { + allocatePanelStackHeights, + panelStackAutoCollapseIndices, +} from '../panelStackLayout.js'; +import { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js'; + +/** + * Measure and place the right panel rail for one synchronous layout pass. + * The caller owns scheduling, obstacle selection, disclosure preferences and + * persistence. Auto-collapse is presentation only and reports through callbacks. + * @param {object} options Live DOM and caller policy. + * @param {HTMLElement} options.stack Rail element. + * @param {Iterable} options.obstacles Caller-selected obstacle nodes. + * @param {Window} options.windowRef Viewport and style reader. + * @param {{visible: boolean, variant: string}} options.hud Current HUD presentation. + * @param {string} options.preferredPanelId Most recently opened panel. + * @param {Function} options.onCollapse Update a panel's disclosure chrome. + * @param {Function} options.onRetry Request another pass after automatic collapse. + * @param {Function} [options.getComputedStyle] Optional DOM style reader override. + * @param {HTMLElement} options.leftStack Rail supplying the shared top baseline. + * @param {HTMLElement} options.displayPanel Panel whose allocation owns its scroll. + * @param {Function} options.readDisplayScrollTop Read the caller's scroll restoration value. + * @param {Document} [options.documentRef] Document supplying current keyboard focus. + */ +export function layoutRightPanelRail({ + stack, + obstacles, + windowRef, + hud, + preferredPanelId, + onCollapse, + onRetry, + leftStack, + displayPanel, + readDisplayScrollTop, + documentRef = stack?.ownerDocument, + getComputedStyle = (element) => windowRef.getComputedStyle(element) +}) { + if (!stack) return; + + const panels = [...stack.children].filter((panel) => panel.matches('[data-panel-id]')); + if (!hud.visible || hud.variant !== 'tactical') { + for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) { + panel.classList.remove('collapsed', 'layout-auto-collapsed'); + onCollapse(panel); + } + } + const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; + const hasExpandedPanel = panels.some((panel) => ( + !panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles') + )); + const exclusive = shouldHideCollapsedRightPanels({ + hudVariant: hud.variant, + hasExpandedPanel, + }); + stack.classList.toggle('layout-exclusive', exclusive); + for (const panel of panels) { + if (exclusive && panel.classList.contains('collapsed')) panel.setAttribute('aria-hidden', 'true'); + else panel.removeAttribute('aria-hidden'); + } + + if (isMobile) { + stack.classList.remove('layout-focus'); + stack.style.removeProperty('--right-stack-safe-top'); + stack.style.removeProperty('--right-stack-max-height'); + for (const panel of panels) panel.style.removeProperty('--right-panel-allocated-height'); + stack.dataset.layoutMode = 'mobile'; + return; + } + + const viewportHeight = Math.max(1, windowRef.innerHeight); + const safeGap = Math.max(8, viewportHeight * 0.012); + const stackRect = stack.getBoundingClientRect(); + const leftStackTop = leftStack?.getBoundingClientRect().top; + const alignedTop = Number.isFinite(leftStackTop) + ? leftStackTop + : viewportHeight * 0.26; + const obstacleRects = []; + + for (const obstacle of obstacles) { + if (stack.contains(obstacle)) continue; + let hiddenByAncestor = false; + for (let element = obstacle; element; element = element.parentElement) { + const style = getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + hiddenByAncestor = true; + break; + } + } + if (hiddenByAncestor) continue; + const rect = obstacle.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + obstacleRects.push({ + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + }); + } + + const visiblePanels = panels.filter((panel) => ( + !exclusive || !panel.classList.contains('collapsed') + )); + + const displayScrollTop = readDisplayScrollTop(); + // Measure intrinsic content, not the allocation written by the previous + // layout pass. Display is the exception: its own scrollHeight already + // exposes every control, and removing its live allocation can reset the + // user's scroll position while HUD or preset content is settling. + for (const panel of visiblePanels) { + if (!panel.classList.contains('collapsed') && panel !== displayPanel) { + panel.style.removeProperty('--right-panel-allocated-height'); + } + } + const gap = parseFloat(getComputedStyle(stack).rowGap) || 0; + const naturalHeight = visiblePanels.reduce((total, panel) => ( + total + Math.max( + panel.getBoundingClientRect().height, + panel.scrollHeight || 0, + panel.classList.contains('collapsed') ? 42 : 0, + ) + ), 0) + gap * Math.max(0, visiblePanels.length - 1); + const layout = resolveHudRailLayout({ + viewportHeight, + panelHeight: naturalHeight, + laneLeft: stackRect.left, + laneRight: stackRect.right, + obstacles: obstacleRects, + baseTop: alignedTop, + baseBottom: viewportHeight * 0.96, + gap: safeGap, + align: 'start', + }); + if (!layout) return; + const { safeTop, safeBottom, maxHeight: availableHeight } = layout; + const stabilityBand = viewportHeight * 0.01; + const wasFocused = stack.classList.contains('layout-focus'); + const shouldFocus = wasFocused + ? naturalHeight > availableHeight - stabilityBand * 2 + : naturalHeight > availableHeight - stabilityBand; + const layoutTop = shouldFocus ? safeTop : layout.top; + const collapsedHeight = visiblePanels.reduce((total, panel) => ( + panel.classList.contains('collapsed') + ? total + panel.getBoundingClientRect().height + : total + ), 0); + const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed')); + const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(documentRef.activeElement)); + const preferredExpandedPanel = expandedPanelsInDomOrder.find( + (panel) => panel.id === preferredPanelId, + ) || focusedExpandedPanel; + // Match the left lane: allocation order follows the latest explicit + // disclosure, not DOM order. A focused panel is the fallback owner so + // temporary presentation collapse never strands keyboard focus. + const expandedPanels = preferredExpandedPanel + ? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] + : expandedPanelsInDomOrder; + const expandedAvailableHeight = Math.max( + 0, + safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1), + ); + const expandedHeights = allocatePanelStackHeights({ + naturalHeights: expandedPanels.map((panel) => Math.max( + panel.getBoundingClientRect().height, + panel.scrollHeight || 0, + )), + availableHeight: expandedAvailableHeight, + }); + const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({ + naturalHeights: expandedPanels.map((panel) => Math.max( + panel.getBoundingClientRect().height, + panel.scrollHeight || 0, + )), + allocatedHeights: expandedHeights, + collapseLaterPanels: shouldFocus && hud.variant === 'tactical', + }) : []; + if (autoCollapseIndices.length) { + for (const index of autoCollapseIndices) { + const panel = expandedPanels[index]; + panel.classList.add('collapsed', 'layout-auto-collapsed'); + onCollapse(panel); + } + onRetry(); + return; + } + // Write-if-changed. This pass runs on the 500 ms stats cadence, and an + // unconditional REMOVE-then-SET of an unchanged allocation is two style + // mutations per tick on `#pp-toggles` (the one panel the measure-strip + // above deliberately skips) — churn that reads as a genuine panel move to + // the world-overlay host's occluder observer and defeats parked-idle + // render savings. Only a real allocation change may touch the attribute. + expandedPanels.forEach((panel, index) => { + const next = `${expandedHeights[index].toFixed(1)}px`; + if (panel.style.getPropertyValue('--right-panel-allocated-height') !== next) { + panel.style.setProperty('--right-panel-allocated-height', next); + } + }); + for (const panel of panels) { + if (expandedPanels.includes(panel)) continue; + panel.style.removeProperty('--right-panel-allocated-height'); + } + + stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`); + stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`); + stack.classList.toggle('layout-focus', shouldFocus); + stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal'; + stack.dataset.safeTop = layoutTop.toFixed(1); + stack.dataset.safeBottom = safeBottom.toFixed(1); + stack.dataset.availableHeight = availableHeight.toFixed(1); + stack.dataset.requiredHeight = naturalHeight.toFixed(1); + stack.dataset.expandedCount = String(expandedPanels.length); + + if (displayPanel && expandedPanels.includes(displayPanel)) { + const maxScrollTop = Math.max(0, displayPanel.scrollHeight - displayPanel.clientHeight); + displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop); + } +} From e13a680b538b0d69dbb8ac69715987e9398219d2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 19:55:02 -0700 Subject: [PATCH 2/3] style(ui): format panel rail modules and retain source invariants --- scripts/format-scope.json | 9 +- scripts/package-boundaries.json | 4 +- src/panelStackLayout.js | 17 ++- src/panelStackLayout.test.mjs | 8 +- src/rightRailPolicy.test.mjs | 4 +- src/ui/leftPanelRail.js | 118 ++++++++++----- src/ui/panelMeasurement.js | 29 ++-- src/ui/panelRailGeometry.js | 43 ++++-- src/ui/panelRails.js | 12 +- src/ui/panelRails.test.mjs | 250 ++++++++++++++++++++++++-------- src/ui/rightPanelRail.js | 144 ++++++++++++------ 11 files changed, 460 insertions(+), 178 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 866a92dcf..62a34944c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -122,5 +122,12 @@ "src/standalone/placeSearch.js", "src/ui/panelDisclosure.js", "src/ui/surfaceKeyboard.js", - "src/ui/surfaceKeyboard.test.mjs" + "src/ui/surfaceKeyboard.test.mjs", + "src/ui/panelRails.js", + "src/ui/leftPanelRail.js", + "src/ui/rightPanelRail.js", + "src/ui/panelMeasurement.js", + "src/ui/panelRailGeometry.js", + "src/ui/panelRails.test.mjs", + "src/panelStackLayout.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cd348fb43..00bce3fd2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -275,9 +275,7 @@ "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", diff --git a/src/panelStackLayout.js b/src/panelStackLayout.js index 48c9851da..b118d113b 100644 --- a/src/panelStackLayout.js +++ b/src/panelStackLayout.js @@ -14,7 +14,9 @@ export function allocatePanelStackHeights({ availableHeight, minimumHeight = 96, }) { - const natural = naturalHeights.map((height) => Math.max(0, Number(height) || 0)); + const natural = naturalHeights.map((height) => + Math.max(0, Number(height) || 0), + ); if (!natural.length) return []; const available = Math.max(0, Number(availableHeight) || 0); @@ -31,10 +33,14 @@ export function allocatePanelStackHeights({ } const remaining = available - baseTotal; - const unmet = natural.map((height, index) => Math.max(0, height - base[index])); + const unmet = natural.map((height, index) => + Math.max(0, height - base[index]), + ); const unmetTotal = unmet.reduce((sum, height) => sum + height, 0); if (unmetTotal <= 0) return base; - return base.map((height, index) => height + remaining * (unmet[index] / unmetTotal)); + return base.map( + (height, index) => height + remaining * (unmet[index] / unmetTotal), + ); } /** @@ -125,7 +131,10 @@ export function resolvePanelStackCorridor({ boundaryTop, Math.min(height, Number(obstacleSafeBottom) || 0), ); - let top = Math.max(boundaryTop, Math.min(boundaryBottom, Number(safeTop) || 0)); + let top = Math.max( + boundaryTop, + Math.min(boundaryBottom, Number(safeTop) || 0), + ); let bottom = Math.max(top, Math.min(boundaryBottom, Number(safeBottom) || 0)); const minimum = Math.max(0, Number(minimumHeight) || 0); const midpoint = height * 0.5; diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index c9d0c0fee..f6e2b2644 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -153,17 +153,17 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/); assert.match( leftRail, - /preferredExpandedPanel[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/, + /preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened left panel must receive primary allocation', ); assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match( rightRail, - /panel\.id === preferredPanelId[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/, + /panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/, 'the latest explicitly opened right panel must receive primary allocation', ); assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/); - assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\(panel\) => panel\.contains\(documentRef\.activeElement\)\)/); + assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/); assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/); assert.match(leftRail, /--left-panel-allocated-height/); assert.match(rightRail, /--right-panel-allocated-height/); @@ -185,7 +185,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 = shouldFocus && panel\.classList\.contains\('collapsed'\);/); + assert.match(leftRail, /const hiddenSibling =\s*shouldFocus && panel\.classList\.contains\('collapsed'\);/); }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 46c3d2178..17d07f04c 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -37,13 +37,13 @@ test('desktop Display participates in Tactical exclusivity without changing mobi assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); assert.match( ui, - /!panel\.classList\.contains\('collapsed'\) && \(!isMobile \|\| panel\.id !== 'pp-toggles'\)/, + /!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'\)\) panel\.setAttribute\('aria-hidden', 'true'\)/); + assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index b2e8221e3..607e13083 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -32,14 +32,16 @@ export function layoutLeftPanelRail({ onRetry, collapsedHeights, onAligned, - getComputedStyle = (element) => windowRef.getComputedStyle(element) + getComputedStyle = (element) => windowRef.getComputedStyle(element), }) { if (!stack) return; const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')]; if (!panels.length) return; if (!hud.visible || hud.variant !== 'tactical') { - for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) { + for (const panel of panels.filter((item) => + item.classList.contains('layout-auto-collapsed'), + )) { panel.classList.remove('collapsed', 'layout-auto-collapsed'); onCollapse(panel); } @@ -76,7 +78,11 @@ export function layoutLeftPanelRail({ let hiddenByAncestor = false; for (let element = obstacle; element; element = element.parentElement) { const style = getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity) === 0 + ) { hiddenByAncestor = true; break; } @@ -84,7 +90,8 @@ export function layoutLeftPanelRail({ if (hiddenByAncestor) continue; const rect = obstacle.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) continue; - const overlapsHorizontally = rect.right > stackRect.left && rect.left < stackRect.right; + const overlapsHorizontally = + rect.right > stackRect.left && rect.left < stackRect.right; if (!overlapsHorizontally) continue; if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) { @@ -123,7 +130,9 @@ export function layoutLeftPanelRail({ } } - const expandedPanelsInDomOrder = panels.filter((panel) => !panel.classList.contains('collapsed')); + const expandedPanelsInDomOrder = panels.filter( + (panel) => !panel.classList.contains('collapsed'), + ); const preferredExpandedPanel = expandedPanelsInDomOrder.find( (panel) => panel.id === preferredPanelId, ); @@ -132,7 +141,12 @@ export function layoutLeftPanelRail({ // panel first so an older expanded sibling yields when the corridor cannot // usefully present both (for example Map Stack followed by Scenes). const expandedPanels = preferredExpandedPanel - ? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] + ? [ + preferredExpandedPanel, + ...expandedPanelsInDomOrder.filter( + (panel) => panel !== preferredExpandedPanel, + ), + ] : expandedPanelsInDomOrder; // Clear the prior pass before reading intrinsic heights. The allocated // outer height and the inner scroller otherwise feed their constrained @@ -141,8 +155,13 @@ export function layoutLeftPanelRail({ panel.style.removeProperty('--left-panel-allocated-height'); } const availableHeight = Math.max(0, safeBottom - safeTop); - const naturalExpandedHeights = expandedPanels.map((panel) => measurePanelNaturalHeight(panel, getComputedStyle)); - const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0); + const naturalExpandedHeights = expandedPanels.map((panel) => + measurePanelNaturalHeight(panel, getComputedStyle), + ); + const naturalExpandedHeight = naturalExpandedHeights.reduce( + (sum, height) => sum + height, + 0, + ); const siblingHeight = panels.reduce((total, panel) => { if (!panel.classList.contains('collapsed')) return total; const measured = collapsedHeights.get(panel.id); @@ -162,12 +181,15 @@ export function layoutLeftPanelRail({ const wasTail = stack.classList.contains('layout-tail'); const wasConstrained = wasFocused || wasTail; const stabilityBand = viewportHeight * 0.01; - const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained - ? requiredHeight > availableHeight - stabilityBand * 2 - : requiredHeight > availableHeight - stabilityBand); - const tailRequiredHeight = naturalExpandedHeight - + siblingHeight - + rowGap * Math.max(0, panels.length - 1); + const exceedsCenteredCorridor = + expandedPanels.length > 0 && + (wasConstrained + ? requiredHeight > availableHeight - stabilityBand * 2 + : requiredHeight > availableHeight - stabilityBand); + const tailRequiredHeight = + naturalExpandedHeight + + siblingHeight + + rowGap * Math.max(0, panels.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 @@ -177,36 +199,47 @@ export function layoutLeftPanelRail({ const tailLayoutBottom = tailLayoutTop + tailRequiredHeight; const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop); const tailTolerance = wasTail ? stabilityBand : -stabilityBand; - const shouldTail = expandedPanels.length > 0 - && tailLayoutTop >= obstacleSafeTop - tailTolerance - && tailLayoutBottom <= obstacleSafeBottom + tailTolerance; + const shouldTail = + expandedPanels.length > 0 && + tailLayoutTop >= obstacleSafeTop - tailTolerance && + tailLayoutBottom <= obstacleSafeBottom + tailTolerance; const shouldFocus = exceedsCenteredCorridor && !shouldTail; // Focus mode owns the lane, so let every expanded panel share the full // obstacle-safe corridor. Tail/normal layouts keep the balanced // viewport centering used for compact accordion stacks. const layoutTop = shouldFocus ? obstacleSafeTop - : shouldTail ? tailLayoutTop : safeTop; + : shouldTail + ? tailLayoutTop + : safeTop; const layoutBottom = shouldFocus ? obstacleSafeBottom - : shouldTail ? tailLayoutBottom : safeBottom; + : shouldTail + ? tailLayoutBottom + : safeBottom; const topPct = (layoutTop / viewportHeight) * 100; const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100; const topValue = `${topPct.toFixed(3)}vh`; const bottomValue = `${bottomPct.toFixed(3)}vh`; const expandedAvailableHeight = shouldFocus - ? Math.max(0, layoutBottom - layoutTop - - rowGap * Math.max(0, expandedPanels.length - 1)) + ? Math.max( + 0, + layoutBottom - + layoutTop - + rowGap * Math.max(0, expandedPanels.length - 1), + ) : naturalExpandedHeight; const allocatedExpandedHeights = allocatePanelStackHeights({ naturalHeights: naturalExpandedHeights, availableHeight: expandedAvailableHeight, }); - const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({ - naturalHeights: naturalExpandedHeights, - allocatedHeights: allocatedExpandedHeights, - collapseLaterPanels: shouldFocus && hud.variant === 'tactical', - }) : []; + const autoCollapseIndices = hud.visible + ? panelStackAutoCollapseIndices({ + naturalHeights: naturalExpandedHeights, + allocatedHeights: allocatedExpandedHeights, + collapseLaterPanels: shouldFocus && hud.variant === 'tactical', + }) + : []; if (autoCollapseIndices.length) { for (const index of autoCollapseIndices) { const panel = expandedPanels[index]; @@ -219,23 +252,42 @@ export function layoutLeftPanelRail({ if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) { stack.style.setProperty('--left-stack-safe-top', topValue); } - if (stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue) { + if ( + stack.style.getPropertyValue('--left-stack-safe-bottom') !== bottomValue + ) { stack.style.setProperty('--left-stack-safe-bottom', bottomValue); } stack.style.removeProperty('--left-stack-centered-height'); - for (const panel of panels) panel.style.removeProperty('--left-panel-allocated-height'); + for (const panel of panels) + panel.style.removeProperty('--left-panel-allocated-height'); expandedPanels.forEach((panel, index) => { - panel.style.setProperty('--left-panel-allocated-height', `${allocatedExpandedHeights[index].toFixed(1)}px`); + panel.style.setProperty( + '--left-panel-allocated-height', + `${allocatedExpandedHeights[index].toFixed(1)}px`, + ); }); stack.classList.toggle('layout-focus', shouldFocus); stack.classList.toggle('layout-tail', shouldTail); - stack.dataset.layoutMode = shouldFocus ? 'focus' : shouldTail ? 'tail' : 'normal'; + stack.dataset.layoutMode = shouldFocus + ? 'focus' + : shouldTail + ? 'tail' + : 'normal'; stack.dataset.safeTopPct = topPct.toFixed(2); stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2); - stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2); - stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2); - stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2); + stack.dataset.availableHeightPct = ( + (availableHeight / viewportHeight) * + 100 + ).toFixed(2); + stack.dataset.requiredHeightPct = ( + (requiredHeight / viewportHeight) * + 100 + ).toFixed(2); + stack.dataset.tailAvailableHeightPct = ( + (tailAvailableHeight / viewportHeight) * + 100 + ).toFixed(2); stack.dataset.expandedCount = String(expandedPanels.length); // Cockpit Display/Radio live in the opposite margin and no longer borrow diff --git a/src/ui/panelMeasurement.js b/src/ui/panelMeasurement.js index bb73648fa..dcd9de464 100644 --- a/src/ui/panelMeasurement.js +++ b/src/ui/panelMeasurement.js @@ -6,8 +6,13 @@ * @returns {number} Natural height in CSS pixels. */ export function measurePanelNaturalHeight(panel, getComputedStyle) { - const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); - if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height); + const inner = [...panel.children].find( + (child) => !child.classList.contains('panel-glow'), + ); + if (!inner) + return Math.ceil( + panel.scrollHeight || panel.getBoundingClientRect().height, + ); const innerRect = inner.getBoundingClientRect(); const panelStyle = getComputedStyle(panel); @@ -17,17 +22,23 @@ export function measurePanelNaturalHeight(panel, getComputedStyle) { for (const child of inner.children) { const childStyle = getComputedStyle(child); - if (childStyle.display === 'none' || childStyle.visibility === 'hidden') continue; + if (childStyle.display === 'none' || childStyle.visibility === 'hidden') + continue; const childRect = child.getBoundingClientRect(); const marginBottom = parseFloat(childStyle.marginBottom) || 0; - const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0); - const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom; + const naturalChildHeight = Math.max( + childRect.height, + child.scrollHeight || 0, + ); + const childBottom = + childRect.top - innerRect.top + naturalChildHeight + marginBottom; contentBottom = Math.max(contentBottom, childBottom); } - const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0) - + (parseFloat(panelStyle.borderBottomWidth) || 0) - + (parseFloat(panelStyle.paddingTop) || 0) - + (parseFloat(panelStyle.paddingBottom) || 0); + const wrapperChrome = + (parseFloat(panelStyle.borderTopWidth) || 0) + + (parseFloat(panelStyle.borderBottomWidth) || 0) + + (parseFloat(panelStyle.paddingTop) || 0) + + (parseFloat(panelStyle.paddingBottom) || 0); return Math.ceil(contentBottom + paddingBottom + wrapperChrome); } diff --git a/src/ui/panelRailGeometry.js b/src/ui/panelRailGeometry.js index 5f9b3809a..8045286b0 100644 --- a/src/ui/panelRailGeometry.js +++ b/src/ui/panelRailGeometry.js @@ -13,26 +13,47 @@ export function resolveHudRailLayout({ gap = 12, align = 'center', }) { - if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom] - .every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null; + if ( + ![ + viewportHeight, + panelHeight, + laneLeft, + laneRight, + baseTop, + baseBottom, + ].every(Number.isFinite) || + viewportHeight <= 0 || + laneRight <= laneLeft + ) + return null; const midpoint = viewportHeight * 0.5; let safeTop = Math.max(0, baseTop); let safeBottom = Math.min(viewportHeight, baseBottom); for (const rect of obstacles) { - if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue; - if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue; + if ( + ![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite) + ) + continue; + if ( + rect.right <= laneLeft || + rect.left >= laneRight || + rect.bottom <= rect.top + ) + continue; if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap); - else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap); + else if (rect.top >= midpoint) + safeBottom = Math.min(safeBottom, rect.top - gap); } safeBottom = Math.max(safeTop, safeBottom); const availableHeight = Math.max(0, safeBottom - safeTop); const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight); return { - top: align === 'start' - ? safeTop - : safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5), + top: + align === 'start' + ? safeTop + : safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5), maxHeight: availableHeight, safeTop, safeBottom, @@ -49,7 +70,9 @@ export function resolveHudRailLayout({ * @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded. * @returns {boolean} Whether collapsed sibling launchers should be hidden. */ -export function shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }) { +export function shouldHideCollapsedRightPanels({ + hudVariant, + hasExpandedPanel, +}) { return hudVariant === 'tactical' && Boolean(hasExpandedPanel); } - diff --git a/src/ui/panelRails.js b/src/ui/panelRails.js index e671bf607..a67371cc7 100644 --- a/src/ui/panelRails.js +++ b/src/ui/panelRails.js @@ -1,5 +1,13 @@ export { layoutLeftPanelRail } from './leftPanelRail.js'; export { layoutRightPanelRail } from './rightPanelRail.js'; export { measurePanelNaturalHeight } from './panelMeasurement.js'; -export { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js'; -export { allocatePanelStackHeights, panelStackAutoCollapseIndices, resolveLeftStackBottomBoundary, resolvePanelStackCorridor } from '../panelStackLayout.js'; +export { + resolveHudRailLayout, + shouldHideCollapsedRightPanels, +} from './panelRailGeometry.js'; +export { + allocatePanelStackHeights, + panelStackAutoCollapseIndices, + resolveLeftStackBottomBoundary, + resolvePanelStackCorridor, +} from '../panelStackLayout.js'; diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 6d51bb5db..46fbebc5f 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -1,60 +1,138 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { layoutLeftPanelRail, layoutRightPanelRail, measurePanelNaturalHeight } from './panelRails.js'; +import { + layoutLeftPanelRail, + layoutRightPanelRail, + measurePanelNaturalHeight, +} from './panelRails.js'; -function element(id, { height = 42, top = 234, left = 20, width = 272, collapsed = false } = {}) { +function element( + id, + { height = 42, top = 234, left = 20, width = 272, collapsed = false } = {}, +) { const classes = new Set(collapsed ? ['collapsed'] : []); const properties = new Map(); const attributes = new Map(); const writes = []; const node = { - id, children: [], dataset: {}, parentElement: null, scrollHeight: height, - clientHeight: height, scrollTop: 0, - computed: { display: 'block', visibility: 'visible', opacity: '1', rowGap: '12px' }, - rect: { top, left, width, height, right: left + width, bottom: top + height }, + id, + children: [], + dataset: {}, + parentElement: null, + scrollHeight: height, + clientHeight: height, + scrollTop: 0, + computed: { + display: 'block', + visibility: 'visible', + opacity: '1', + rowGap: '12px', + }, + rect: { + top, + left, + width, + height, + right: left + width, + bottom: top + height, + }, classList: { contains: (name) => classes.has(name), - add: (...names) => names.forEach(name => classes.add(name)), - remove: (...names) => names.forEach(name => classes.delete(name)), - toggle(name, value) { if (value) classes.add(name); else classes.delete(name); }, + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + toggle(name, value) { + if (value) classes.add(name); + else classes.delete(name); + }, }, style: { - setProperty(name, value) { writes.push(['set', name, value]); properties.set(name, value); }, - getPropertyValue: name => properties.get(name) || '', - removeProperty(name) { writes.push(['remove', name]); properties.delete(name); }, + setProperty(name, value) { + writes.push(['set', name, value]); + properties.set(name, value); + }, + getPropertyValue: (name) => properties.get(name) || '', + removeProperty(name) { + writes.push(['remove', name]); + properties.delete(name); + }, + }, + getBoundingClientRect() { + return this.rect; + }, + matches: (selector) => selector === '[data-panel-id]', + contains(target) { + return ( + target === this || this.children.some((child) => child.contains(target)) + ); + }, + querySelectorAll() { + return this.children; }, - getBoundingClientRect() { return this.rect; }, - matches: selector => selector === '[data-panel-id]', - contains(target) { return target === this || this.children.some(child => child.contains(target)); }, - querySelectorAll() { return this.children; }, setAttribute: (name, value) => attributes.set(name, value), - removeAttribute: name => attributes.delete(name), - getAttribute: name => attributes.get(name), + removeAttribute: (name) => attributes.delete(name), + getAttribute: (name) => attributes.get(name), writes, }; return node; } -function fixture(side, { mobile = false, hud = { visible: true, variant: 'tactical' } } = {}) { - const first = element('first', {height: 42, collapsed: true}); - const second = element('second', {height: 42, collapsed: true}); - const stack = element('rail', {height: 96, left: side === 'left' ? 20 : 1100}); +function fixture( + side, + { mobile = false, hud = { visible: true, variant: 'tactical' } } = {}, +) { + const first = element('first', { height: 42, collapsed: true }); + const second = element('second', { height: 42, collapsed: true }); + const stack = element('rail', { + height: 96, + left: side === 'left' ? 20 : 1100, + }); stack.children = [first, second]; first.parentElement = second.parentElement = stack; const documentRef = { activeElement: null }; stack.ownerDocument = documentRef; const collapsed = []; - let retries = 0, aligned = 0; + let retries = 0, + aligned = 0; const options = { - stack, hud, documentRef, obstacles: [], collapsedHeights: new Map(), - windowRef: {innerHeight: 900, matchMedia: () => ({matches: mobile}), getComputedStyle: node => node.computed}, - onCollapse: panel => collapsed.push(panel.id), - onRetry: () => { retries += 1; }, - onAligned: () => { aligned += 1; }, - displayPanel: null, readDisplayScrollTop: () => 0, leftStack: element('left'), + stack, + hud, + documentRef, + obstacles: [], + collapsedHeights: new Map(), + windowRef: { + innerHeight: 900, + matchMedia: () => ({ matches: mobile }), + getComputedStyle: (node) => node.computed, + }, + onCollapse: (panel) => collapsed.push(panel.id), + onRetry: () => { + retries += 1; + }, + onAligned: () => { + aligned += 1; + }, + displayPanel: null, + readDisplayScrollTop: () => 0, + leftStack: element('left'), + }; + const run = () => + (side === 'left' ? layoutLeftPanelRail : layoutRightPanelRail)(options); + const expand = (panel, height) => { + panel.classList.remove('collapsed'); + panel.scrollHeight = height; + panel.rect.height = height; + panel.rect.bottom = panel.rect.top + height; + }; + return { + options, + first, + second, + stack, + run, + expand, + collapsed, + retries: () => retries, + aligned: () => aligned, }; - const run = () => (side === 'left' ? layoutLeftPanelRail : layoutRightPanelRail)(options); - const expand = (panel, height) => {panel.classList.remove('collapsed'); panel.scrollHeight = height; panel.rect.height = height; panel.rect.bottom = panel.rect.top + height;}; - return {options, first, second, stack, run, expand, collapsed, retries: () => retries, aligned: () => aligned}; } test('missing rails are inert without browser globals', () => { @@ -63,7 +141,8 @@ test('missing rails are inert without browser globals', () => { }); test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => { - const f = fixture('left'); f.run(); + const f = fixture('left'); + f.run(); assert.equal(f.options.collapsedHeights.get('first'), 42); assert.equal(f.first.classList.contains('collapsed'), true); assert.equal(f.aligned(), 1); @@ -73,36 +152,49 @@ test('left layout records collapsed heights and aligns the right rail without ch test('left corridor respects a lower obstacle but ignores an obstacle hidden by its parent', () => { const f = fixture('left'); - const blocker = element('blocker', {top: 600, height: 80}); - const hidden = element('hidden', {top: 300, height: 80}); - hidden.parentElement = element('hidden-parent'); hidden.parentElement.computed.opacity = '0'; - f.options.obstacles = [blocker, hidden]; f.run(); + const blocker = element('blocker', { top: 600, height: 80 }); + const hidden = element('hidden', { top: 300, height: 80 }); + hidden.parentElement = element('hidden-parent'); + hidden.parentElement.computed.opacity = '0'; + f.options.obstacles = [blocker, hidden]; + f.run(); assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47); }); for (const side of ['left', 'right']) { test(`${side} mobile layout releases desktop height/position styles and labels`, () => { - const f = fixture(side, {mobile: true}); + const f = fixture(side, { mobile: true }); f.stack.classList.add('layout-focus'); f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px'); f.first.style.setProperty(`--${side}-panel-allocated-height`, '99px'); - f.first.setAttribute('aria-hidden','true'); f.run(); + f.first.setAttribute('aria-hidden', 'true'); + f.run(); assert.equal(f.stack.dataset.layoutMode, 'mobile'); - assert.equal(f.stack.style.getPropertyValue(`--${side}-stack-safe-top`), ''); - assert.equal(f.first.style.getPropertyValue(`--${side}-panel-allocated-height`), ''); + assert.equal( + f.stack.style.getPropertyValue(`--${side}-stack-safe-top`), + '', + ); + assert.equal( + f.first.style.getPropertyValue(`--${side}-panel-allocated-height`), + '', + ); assert.equal(f.first.getAttribute('aria-hidden'), undefined); }); test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => { - const f = fixture(side); f.expand(f.first, 900); f.expand(f.second, 900); - f.options.preferredPanelId = 'second'; f.run(); + 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.deepEqual(f.collapsed, ['first']); assert.equal(f.retries(), 1); }); test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => { - const f = fixture(side, {hud: {visible: false, variant: 'tactical'}}); - f.first.classList.add('layout-auto-collapsed'); f.run(); + const f = fixture(side, { hud: { visible: false, variant: 'tactical' } }); + f.first.classList.add('layout-auto-collapsed'); + f.run(); assert.equal(f.first.classList.contains('collapsed'), false); assert.equal(f.second.classList.contains('collapsed'), true); assert.deepEqual(f.collapsed, ['first']); @@ -110,36 +202,70 @@ for (const side of ['left', 'right']) { } test('right layout uses keyboard focus when there is no preferred panel', () => { - const f = fixture('right'); f.expand(f.first, 900); f.expand(f.second, 900); - f.options.documentRef.activeElement = f.second; f.run(); + const f = fixture('right'); + f.expand(f.first, 900); + 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.second.classList.contains('collapsed'), false); }); test('right layout retains Display allocation during measurement and caps restored scroll', () => { - const f = fixture('right'); f.first.id = 'pp-toggles'; f.expand(f.first, 900); - f.first.clientHeight = 400; f.options.displayPanel = f.first; f.options.readDisplayScrollTop = () => 800; - f.first.style.setProperty('--right-panel-allocated-height', '600px'); f.first.writes.length = 0; + const f = fixture('right'); + f.first.id = 'pp-toggles'; + f.expand(f.first, 900); + f.first.clientHeight = 400; + f.options.displayPanel = f.first; + f.options.readDisplayScrollTop = () => 800; + f.first.style.setProperty('--right-panel-allocated-height', '600px'); + f.first.writes.length = 0; f.run(); assert.equal(f.first.scrollTop, 500); - assert.equal(f.first.writes.some(([op, name]) => op === 'remove' && name === '--right-panel-allocated-height'), false); - f.first.writes.length = 0; f.run(); - assert.equal(f.first.writes.some(([,name]) => name === '--right-panel-allocated-height'), false, 'stable allocation must not churn the style attribute'); + assert.equal( + f.first.writes.some( + ([op, name]) => + op === 'remove' && name === '--right-panel-allocated-height', + ), + false, + ); + f.first.writes.length = 0; + f.run(); + assert.equal( + f.first.writes.some( + ([, name]) => name === '--right-panel-allocated-height', + ), + false, + 'stable allocation must not churn the style attribute', + ); }); test('right rail aligns to the current left rail and excludes hidden obstacles', () => { - const f = fixture('right'); f.options.leftStack.rect.top = 200; - const hidden = element('hidden', {left: 1100, top: 100, height: 200}); - hidden.computed.display = 'none'; f.options.obstacles = [hidden]; f.run(); + const f = fixture('right'); + f.options.leftStack.rect.top = 200; + const hidden = element('hidden', { left: 1100, top: 100, height: 200 }); + hidden.computed.display = 'none'; + f.options.obstacles = [hidden]; + f.run(); assert.equal(f.stack.dataset.safeTop, '200.0'); }); test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => { const panel = element('panel'); - const inner = element('inner', {top: 100}); inner.computed.paddingTop = '10px'; inner.computed.paddingBottom = '5px'; - const row = element('row', {top: 110, height: 40}); row.scrollHeight = 80; row.computed.marginBottom = '3px'; - const hidden = element('hidden', {top: 1000, height: 900}); hidden.computed.visibility = 'hidden'; - panel.computed.borderTopWidth = '1px'; panel.computed.borderBottomWidth = '1px'; - panel.children = [inner]; inner.children = [row, hidden]; - assert.equal(measurePanelNaturalHeight(panel, node => node.computed), 100); + const inner = element('inner', { top: 100 }); + inner.computed.paddingTop = '10px'; + inner.computed.paddingBottom = '5px'; + const row = element('row', { top: 110, height: 40 }); + row.scrollHeight = 80; + row.computed.marginBottom = '3px'; + const hidden = element('hidden', { top: 1000, height: 900 }); + hidden.computed.visibility = 'hidden'; + panel.computed.borderTopWidth = '1px'; + panel.computed.borderBottomWidth = '1px'; + panel.children = [inner]; + inner.children = [row, hidden]; + assert.equal( + measurePanelNaturalHeight(panel, (node) => node.computed), + 100, + ); }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 2f70ce00c..3dd513d9f 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -2,7 +2,10 @@ import { allocatePanelStackHeights, panelStackAutoCollapseIndices, } from '../panelStackLayout.js'; -import { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js'; +import { + resolveHudRailLayout, + shouldHideCollapsedRightPanels, +} from './panelRailGeometry.js'; /** * Measure and place the right panel rail for one synchronous layout pass. @@ -34,28 +37,35 @@ export function layoutRightPanelRail({ displayPanel, readDisplayScrollTop, documentRef = stack?.ownerDocument, - getComputedStyle = (element) => windowRef.getComputedStyle(element) + getComputedStyle = (element) => windowRef.getComputedStyle(element), }) { if (!stack) return; - const panels = [...stack.children].filter((panel) => panel.matches('[data-panel-id]')); + const panels = [...stack.children].filter((panel) => + panel.matches('[data-panel-id]'), + ); if (!hud.visible || hud.variant !== 'tactical') { - for (const panel of panels.filter((item) => item.classList.contains('layout-auto-collapsed'))) { + for (const panel of panels.filter((item) => + item.classList.contains('layout-auto-collapsed'), + )) { panel.classList.remove('collapsed', 'layout-auto-collapsed'); onCollapse(panel); } } const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; - const hasExpandedPanel = panels.some((panel) => ( - !panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles') - )); + const hasExpandedPanel = panels.some( + (panel) => + !panel.classList.contains('collapsed') && + (!isMobile || panel.id !== 'pp-toggles'), + ); const exclusive = shouldHideCollapsedRightPanels({ hudVariant: hud.variant, hasExpandedPanel, }); stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { - if (exclusive && panel.classList.contains('collapsed')) panel.setAttribute('aria-hidden', 'true'); + if (exclusive && panel.classList.contains('collapsed')) + panel.setAttribute('aria-hidden', 'true'); else panel.removeAttribute('aria-hidden'); } @@ -63,7 +73,8 @@ export function layoutRightPanelRail({ stack.classList.remove('layout-focus'); stack.style.removeProperty('--right-stack-safe-top'); stack.style.removeProperty('--right-stack-max-height'); - for (const panel of panels) panel.style.removeProperty('--right-panel-allocated-height'); + for (const panel of panels) + panel.style.removeProperty('--right-panel-allocated-height'); stack.dataset.layoutMode = 'mobile'; return; } @@ -82,7 +93,11 @@ export function layoutRightPanelRail({ let hiddenByAncestor = false; for (let element = obstacle; element; element = element.parentElement) { const style = getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + if ( + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity) === 0 + ) { hiddenByAncestor = true; break; } @@ -98,9 +113,9 @@ export function layoutRightPanelRail({ }); } - const visiblePanels = panels.filter((panel) => ( - !exclusive || !panel.classList.contains('collapsed') - )); + const visiblePanels = panels.filter( + (panel) => !exclusive || !panel.classList.contains('collapsed'), + ); const displayScrollTop = readDisplayScrollTop(); // Measure intrinsic content, not the allocation written by the previous @@ -113,13 +128,18 @@ export function layoutRightPanelRail({ } } const gap = parseFloat(getComputedStyle(stack).rowGap) || 0; - const naturalHeight = visiblePanels.reduce((total, panel) => ( - total + Math.max( - panel.getBoundingClientRect().height, - panel.scrollHeight || 0, - panel.classList.contains('collapsed') ? 42 : 0, - ) - ), 0) + gap * Math.max(0, visiblePanels.length - 1); + const naturalHeight = + visiblePanels.reduce( + (total, panel) => + total + + Math.max( + panel.getBoundingClientRect().height, + panel.scrollHeight || 0, + panel.classList.contains('collapsed') ? 42 : 0, + ), + 0, + ) + + gap * Math.max(0, visiblePanels.length - 1); const layout = resolveHudRailLayout({ viewportHeight, panelHeight: naturalHeight, @@ -139,41 +159,58 @@ export function layoutRightPanelRail({ ? naturalHeight > availableHeight - stabilityBand * 2 : naturalHeight > availableHeight - stabilityBand; const layoutTop = shouldFocus ? safeTop : layout.top; - const collapsedHeight = visiblePanels.reduce((total, panel) => ( - panel.classList.contains('collapsed') - ? total + panel.getBoundingClientRect().height - : total - ), 0); - const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed')); - const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(documentRef.activeElement)); - const preferredExpandedPanel = expandedPanelsInDomOrder.find( - (panel) => panel.id === preferredPanelId, - ) || focusedExpandedPanel; + const collapsedHeight = visiblePanels.reduce( + (total, panel) => + panel.classList.contains('collapsed') + ? total + panel.getBoundingClientRect().height + : total, + 0, + ); + const expandedPanelsInDomOrder = visiblePanels.filter( + (panel) => !panel.classList.contains('collapsed'), + ); + const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => + panel.contains(documentRef.activeElement), + ); + const preferredExpandedPanel = + expandedPanelsInDomOrder.find((panel) => panel.id === preferredPanelId) || + focusedExpandedPanel; // Match the left lane: allocation order follows the latest explicit // disclosure, not DOM order. A focused panel is the fallback owner so // temporary presentation collapse never strands keyboard focus. const expandedPanels = preferredExpandedPanel - ? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] + ? [ + preferredExpandedPanel, + ...expandedPanelsInDomOrder.filter( + (panel) => panel !== preferredExpandedPanel, + ), + ] : expandedPanelsInDomOrder; const expandedAvailableHeight = Math.max( 0, - safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1), + safeBottom - + layoutTop - + collapsedHeight - + gap * Math.max(0, visiblePanels.length - 1), ); const expandedHeights = allocatePanelStackHeights({ - naturalHeights: expandedPanels.map((panel) => Math.max( - panel.getBoundingClientRect().height, - panel.scrollHeight || 0, - )), + naturalHeights: expandedPanels.map((panel) => + Math.max(panel.getBoundingClientRect().height, panel.scrollHeight || 0), + ), availableHeight: expandedAvailableHeight, }); - const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({ - naturalHeights: expandedPanels.map((panel) => Math.max( - panel.getBoundingClientRect().height, - panel.scrollHeight || 0, - )), - allocatedHeights: expandedHeights, - collapseLaterPanels: shouldFocus && hud.variant === 'tactical', - }) : []; + const autoCollapseIndices = hud.visible + ? panelStackAutoCollapseIndices({ + naturalHeights: expandedPanels.map((panel) => + Math.max( + panel.getBoundingClientRect().height, + panel.scrollHeight || 0, + ), + ), + allocatedHeights: expandedHeights, + collapseLaterPanels: shouldFocus && hud.variant === 'tactical', + }) + : []; if (autoCollapseIndices.length) { for (const index of autoCollapseIndices) { const panel = expandedPanels[index]; @@ -191,7 +228,9 @@ export function layoutRightPanelRail({ // render savings. Only a real allocation change may touch the attribute. expandedPanels.forEach((panel, index) => { const next = `${expandedHeights[index].toFixed(1)}px`; - if (panel.style.getPropertyValue('--right-panel-allocated-height') !== next) { + if ( + panel.style.getPropertyValue('--right-panel-allocated-height') !== next + ) { panel.style.setProperty('--right-panel-allocated-height', next); } }); @@ -200,8 +239,14 @@ export function layoutRightPanelRail({ panel.style.removeProperty('--right-panel-allocated-height'); } - stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`); - stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`); + stack.style.setProperty( + '--right-stack-safe-top', + `${layoutTop.toFixed(1)}px`, + ); + stack.style.setProperty( + '--right-stack-max-height', + `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`, + ); stack.classList.toggle('layout-focus', shouldFocus); stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal'; stack.dataset.safeTop = layoutTop.toFixed(1); @@ -211,7 +256,10 @@ export function layoutRightPanelRail({ stack.dataset.expandedCount = String(expandedPanels.length); if (displayPanel && expandedPanels.includes(displayPanel)) { - const maxScrollTop = Math.max(0, displayPanel.scrollHeight - displayPanel.clientHeight); + const maxScrollTop = Math.max( + 0, + displayPanel.scrollHeight - displayPanel.clientHeight, + ); displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop); } } From 5ca085e7092caa7cf531d65dfcac4fad2d68067e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 20:01:13 -0700 Subject: [PATCH 3/3] test(ui): await responsive rail transitions before focus checks --- scripts/qa-map-source-tray.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 900292ac2..2e13c6a3e 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -1466,6 +1466,20 @@ try { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); + // Resizing schedules rail placement on animation frames. Wait for that + // pass and its CSS transitions before comparing a focus rectangle with + // hit testing; a fixed Tab delay can observe two different positions. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + await page.waitForFunction((expectedWidth) => { + const stack = document.getElementById('left-panel-stack'); + if (innerWidth !== expectedWidth || !stack) return false; + if ((stack.dataset.layoutMode === 'mobile') !== (expectedWidth <= 720)) return false; + return !stack.getAnimations({ subtree: true }).some((animation) => ( + animation instanceof CSSTransition && animation.playState === 'running' + )); + }, { timeout: 5_000 }, width); await page.focus('#data-panel .panel-collapse-btn'); const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]]; for (const [id, label, enabled] of targets) {