style(ui): format panel rail modules and retain source invariants

This commit is contained in:
Sameh Khamis committed 2026-09-12 19:55:02 -07:00
1 parent bf77ae61b8
commit e13a680b53
11 files changed
+460 -178

No files matched your search

+8 -1
View File
@@ -122,5 +122,12 @@
"src/standalone/placeSearch.js", "src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js", "src/ui/panelDisclosure.js",
"src/ui/surfaceKeyboard.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"
] ]
+1 -3
View File
@@ -275,9 +275,7 @@
"external": [] "external": []
}, },
"panel-rail-layout": { "panel-rail-layout": {
"exports": [ "exports": ["./ui/layout"],
"./ui/layout"
],
"modules": [ "modules": [
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
+13 -4
View File
@@ -14,7 +14,9 @@ export function allocatePanelStackHeights({
availableHeight, availableHeight,
minimumHeight = 96, 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 []; if (!natural.length) return [];
const available = Math.max(0, Number(availableHeight) || 0); const available = Math.max(0, Number(availableHeight) || 0);
@@ -31,10 +33,14 @@ export function allocatePanelStackHeights({
} }
const remaining = available - baseTotal; 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); const unmetTotal = unmet.reduce((sum, height) => sum + height, 0);
if (unmetTotal <= 0) return base; 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, boundaryTop,
Math.min(height, Number(obstacleSafeBottom) || 0), 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)); let bottom = Math.max(top, Math.min(boundaryBottom, Number(safeBottom) || 0));
const minimum = Math.max(0, Number(minimumHeight) || 0); const minimum = Math.max(0, Number(minimumHeight) || 0);
const midpoint = height * 0.5; const midpoint = height * 0.5;
+4 -4
View File
@@ -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(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
assert.match( assert.match(
leftRail, leftRail,
/preferredExpandedPanel[\s\S]*?\[preferredExpandedPanel, \.\.\.expandedPanelsInDomOrder/, /preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
'the latest explicitly opened left panel must receive primary allocation', 'the latest explicitly opened left panel must receive primary allocation',
); );
assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/); assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
assert.match( assert.match(
rightRail, 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', '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, /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(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
assert.match(leftRail, /--left-panel-allocated-height/); assert.match(leftRail, /--left-panel-allocated-height/);
assert.match(rightRail, /--right-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', '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.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', () => { test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
+2 -2
View File
@@ -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, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
assert.match( assert.match(
ui, ui,
/!panel\.classList\.contains\('collapsed'\) && \(!isMobile \|\| panel\.id !== 'pp-toggles'\)/, /!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/,
); );
assert.doesNotMatch( assert.doesNotMatch(
ui, ui,
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, /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 \{/); assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
}); });
+85 -33
View File
@@ -32,14 +32,16 @@ export function layoutLeftPanelRail({
onRetry, onRetry,
collapsedHeights, collapsedHeights,
onAligned, onAligned,
getComputedStyle = (element) => windowRef.getComputedStyle(element) getComputedStyle = (element) => windowRef.getComputedStyle(element),
}) { }) {
if (!stack) return; if (!stack) return;
const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')]; const panels = [...stack.querySelectorAll(':scope > [data-panel-id]')];
if (!panels.length) return; if (!panels.length) return;
if (!hud.visible || hud.variant !== 'tactical') { 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'); panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel); onCollapse(panel);
} }
@@ -76,7 +78,11 @@ export function layoutLeftPanelRail({
let hiddenByAncestor = false; let hiddenByAncestor = false;
for (let element = obstacle; element; element = element.parentElement) { for (let element = obstacle; element; element = element.parentElement) {
const style = getComputedStyle(element); 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; hiddenByAncestor = true;
break; break;
} }
@@ -84,7 +90,8 @@ export function layoutLeftPanelRail({
if (hiddenByAncestor) continue; if (hiddenByAncestor) continue;
const rect = obstacle.getBoundingClientRect(); const rect = obstacle.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue; 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 (!overlapsHorizontally) continue;
if (rect.top < baseTop && rect.bottom <= viewportHeight * 0.5) { 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( const preferredExpandedPanel = expandedPanelsInDomOrder.find(
(panel) => panel.id === preferredPanelId, (panel) => panel.id === preferredPanelId,
); );
@@ -132,7 +141,12 @@ export function layoutLeftPanelRail({
// panel first so an older expanded sibling yields when the corridor cannot // panel first so an older expanded sibling yields when the corridor cannot
// usefully present both (for example Map Stack followed by Scenes). // usefully present both (for example Map Stack followed by Scenes).
const expandedPanels = preferredExpandedPanel const expandedPanels = preferredExpandedPanel
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] ? [
preferredExpandedPanel,
...expandedPanelsInDomOrder.filter(
(panel) => panel !== preferredExpandedPanel,
),
]
: expandedPanelsInDomOrder; : expandedPanelsInDomOrder;
// Clear the prior pass before reading intrinsic heights. The allocated // Clear the prior pass before reading intrinsic heights. The allocated
// outer height and the inner scroller otherwise feed their constrained // outer height and the inner scroller otherwise feed their constrained
@@ -141,8 +155,13 @@ export function layoutLeftPanelRail({
panel.style.removeProperty('--left-panel-allocated-height'); panel.style.removeProperty('--left-panel-allocated-height');
} }
const availableHeight = Math.max(0, safeBottom - safeTop); const availableHeight = Math.max(0, safeBottom - safeTop);
const naturalExpandedHeights = expandedPanels.map((panel) => measurePanelNaturalHeight(panel, getComputedStyle)); const naturalExpandedHeights = expandedPanels.map((panel) =>
const naturalExpandedHeight = naturalExpandedHeights.reduce((sum, height) => sum + height, 0); measurePanelNaturalHeight(panel, getComputedStyle),
);
const naturalExpandedHeight = naturalExpandedHeights.reduce(
(sum, height) => sum + height,
0,
);
const siblingHeight = panels.reduce((total, panel) => { const siblingHeight = panels.reduce((total, panel) => {
if (!panel.classList.contains('collapsed')) return total; if (!panel.classList.contains('collapsed')) return total;
const measured = collapsedHeights.get(panel.id); const measured = collapsedHeights.get(panel.id);
@@ -162,12 +181,15 @@ export function layoutLeftPanelRail({
const wasTail = stack.classList.contains('layout-tail'); const wasTail = stack.classList.contains('layout-tail');
const wasConstrained = wasFocused || wasTail; const wasConstrained = wasFocused || wasTail;
const stabilityBand = viewportHeight * 0.01; const stabilityBand = viewportHeight * 0.01;
const exceedsCenteredCorridor = expandedPanels.length > 0 && (wasConstrained const exceedsCenteredCorridor =
? requiredHeight > availableHeight - stabilityBand * 2 expandedPanels.length > 0 &&
: requiredHeight > availableHeight - stabilityBand); (wasConstrained
const tailRequiredHeight = naturalExpandedHeight ? requiredHeight > availableHeight - stabilityBand * 2
+ siblingHeight : requiredHeight > availableHeight - stabilityBand);
+ rowGap * Math.max(0, panels.length - 1); const tailRequiredHeight =
naturalExpandedHeight +
siblingHeight +
rowGap * Math.max(0, panels.length - 1);
// A compact expansion should not make the whole control stack jump down // 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 // 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 // the centered stack would begin below it; tall stacks can still grow
@@ -177,36 +199,47 @@ export function layoutLeftPanelRail({
const tailLayoutBottom = tailLayoutTop + tailRequiredHeight; const tailLayoutBottom = tailLayoutTop + tailRequiredHeight;
const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop); const tailAvailableHeight = Math.max(0, obstacleSafeBottom - obstacleSafeTop);
const tailTolerance = wasTail ? stabilityBand : -stabilityBand; const tailTolerance = wasTail ? stabilityBand : -stabilityBand;
const shouldTail = expandedPanels.length > 0 const shouldTail =
&& tailLayoutTop >= obstacleSafeTop - tailTolerance expandedPanels.length > 0 &&
&& tailLayoutBottom <= obstacleSafeBottom + tailTolerance; tailLayoutTop >= obstacleSafeTop - tailTolerance &&
tailLayoutBottom <= obstacleSafeBottom + tailTolerance;
const shouldFocus = exceedsCenteredCorridor && !shouldTail; const shouldFocus = exceedsCenteredCorridor && !shouldTail;
// Focus mode owns the lane, so let every expanded panel share the full // Focus mode owns the lane, so let every expanded panel share the full
// obstacle-safe corridor. Tail/normal layouts keep the balanced // obstacle-safe corridor. Tail/normal layouts keep the balanced
// viewport centering used for compact accordion stacks. // viewport centering used for compact accordion stacks.
const layoutTop = shouldFocus const layoutTop = shouldFocus
? obstacleSafeTop ? obstacleSafeTop
: shouldTail ? tailLayoutTop : safeTop; : shouldTail
? tailLayoutTop
: safeTop;
const layoutBottom = shouldFocus const layoutBottom = shouldFocus
? obstacleSafeBottom ? obstacleSafeBottom
: shouldTail ? tailLayoutBottom : safeBottom; : shouldTail
? tailLayoutBottom
: safeBottom;
const topPct = (layoutTop / viewportHeight) * 100; const topPct = (layoutTop / viewportHeight) * 100;
const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100; const bottomPct = ((viewportHeight - layoutBottom) / viewportHeight) * 100;
const topValue = `${topPct.toFixed(3)}vh`; const topValue = `${topPct.toFixed(3)}vh`;
const bottomValue = `${bottomPct.toFixed(3)}vh`; const bottomValue = `${bottomPct.toFixed(3)}vh`;
const expandedAvailableHeight = shouldFocus const expandedAvailableHeight = shouldFocus
? Math.max(0, layoutBottom - layoutTop ? Math.max(
- rowGap * Math.max(0, expandedPanels.length - 1)) 0,
layoutBottom -
layoutTop -
rowGap * Math.max(0, expandedPanels.length - 1),
)
: naturalExpandedHeight; : naturalExpandedHeight;
const allocatedExpandedHeights = allocatePanelStackHeights({ const allocatedExpandedHeights = allocatePanelStackHeights({
naturalHeights: naturalExpandedHeights, naturalHeights: naturalExpandedHeights,
availableHeight: expandedAvailableHeight, availableHeight: expandedAvailableHeight,
}); });
const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({ const autoCollapseIndices = hud.visible
naturalHeights: naturalExpandedHeights, ? panelStackAutoCollapseIndices({
allocatedHeights: allocatedExpandedHeights, naturalHeights: naturalExpandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical', allocatedHeights: allocatedExpandedHeights,
}) : []; collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
})
: [];
if (autoCollapseIndices.length) { if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) { for (const index of autoCollapseIndices) {
const panel = expandedPanels[index]; const panel = expandedPanels[index];
@@ -219,23 +252,42 @@ export function layoutLeftPanelRail({
if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) { if (stack.style.getPropertyValue('--left-stack-safe-top') !== topValue) {
stack.style.setProperty('--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.setProperty('--left-stack-safe-bottom', bottomValue);
} }
stack.style.removeProperty('--left-stack-centered-height'); 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) => { 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-focus', shouldFocus);
stack.classList.toggle('layout-tail', shouldTail); 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.safeTopPct = topPct.toFixed(2);
stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2); stack.dataset.safeBottomPct = (100 - bottomPct).toFixed(2);
stack.dataset.availableHeightPct = ((availableHeight / viewportHeight) * 100).toFixed(2); stack.dataset.availableHeightPct = (
stack.dataset.requiredHeightPct = ((requiredHeight / viewportHeight) * 100).toFixed(2); (availableHeight / viewportHeight) *
stack.dataset.tailAvailableHeightPct = ((tailAvailableHeight / viewportHeight) * 100).toFixed(2); 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); stack.dataset.expandedCount = String(expandedPanels.length);
// Cockpit Display/Radio live in the opposite margin and no longer borrow // Cockpit Display/Radio live in the opposite margin and no longer borrow
+20 -9
View File
@@ -6,8 +6,13 @@
* @returns {number} Natural height in CSS pixels. * @returns {number} Natural height in CSS pixels.
*/ */
export function measurePanelNaturalHeight(panel, getComputedStyle) { export function measurePanelNaturalHeight(panel, getComputedStyle) {
const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow')); const inner = [...panel.children].find(
if (!inner) return Math.ceil(panel.scrollHeight || panel.getBoundingClientRect().height); (child) => !child.classList.contains('panel-glow'),
);
if (!inner)
return Math.ceil(
panel.scrollHeight || panel.getBoundingClientRect().height,
);
const innerRect = inner.getBoundingClientRect(); const innerRect = inner.getBoundingClientRect();
const panelStyle = getComputedStyle(panel); const panelStyle = getComputedStyle(panel);
@@ -17,17 +22,23 @@ export function measurePanelNaturalHeight(panel, getComputedStyle) {
for (const child of inner.children) { for (const child of inner.children) {
const childStyle = getComputedStyle(child); 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 childRect = child.getBoundingClientRect();
const marginBottom = parseFloat(childStyle.marginBottom) || 0; const marginBottom = parseFloat(childStyle.marginBottom) || 0;
const naturalChildHeight = Math.max(childRect.height, child.scrollHeight || 0); const naturalChildHeight = Math.max(
const childBottom = childRect.top - innerRect.top + naturalChildHeight + marginBottom; childRect.height,
child.scrollHeight || 0,
);
const childBottom =
childRect.top - innerRect.top + naturalChildHeight + marginBottom;
contentBottom = Math.max(contentBottom, childBottom); contentBottom = Math.max(contentBottom, childBottom);
} }
const wrapperChrome = (parseFloat(panelStyle.borderTopWidth) || 0) const wrapperChrome =
+ (parseFloat(panelStyle.borderBottomWidth) || 0) (parseFloat(panelStyle.borderTopWidth) || 0) +
+ (parseFloat(panelStyle.paddingTop) || 0) (parseFloat(panelStyle.borderBottomWidth) || 0) +
+ (parseFloat(panelStyle.paddingBottom) || 0); (parseFloat(panelStyle.paddingTop) || 0) +
(parseFloat(panelStyle.paddingBottom) || 0);
return Math.ceil(contentBottom + paddingBottom + wrapperChrome); return Math.ceil(contentBottom + paddingBottom + wrapperChrome);
} }
+33 -10
View File
@@ -13,26 +13,47 @@ export function resolveHudRailLayout({
gap = 12, gap = 12,
align = 'center', align = 'center',
}) { }) {
if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom] if (
.every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null; ![
viewportHeight,
panelHeight,
laneLeft,
laneRight,
baseTop,
baseBottom,
].every(Number.isFinite) ||
viewportHeight <= 0 ||
laneRight <= laneLeft
)
return null;
const midpoint = viewportHeight * 0.5; const midpoint = viewportHeight * 0.5;
let safeTop = Math.max(0, baseTop); let safeTop = Math.max(0, baseTop);
let safeBottom = Math.min(viewportHeight, baseBottom); let safeBottom = Math.min(viewportHeight, baseBottom);
for (const rect of obstacles) { for (const rect of obstacles) {
if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue; if (
if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue; ![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); 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); safeBottom = Math.max(safeTop, safeBottom);
const availableHeight = Math.max(0, safeBottom - safeTop); const availableHeight = Math.max(0, safeBottom - safeTop);
const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight); const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight);
return { return {
top: align === 'start' top:
? safeTop align === 'start'
: safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5), ? safeTop
: safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5),
maxHeight: availableHeight, maxHeight: availableHeight,
safeTop, safeTop,
safeBottom, safeBottom,
@@ -49,7 +70,9 @@ export function resolveHudRailLayout({
* @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded. * @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded.
* @returns {boolean} Whether collapsed sibling launchers should be hidden. * @returns {boolean} Whether collapsed sibling launchers should be hidden.
*/ */
export function shouldHideCollapsedRightPanels({ hudVariant, hasExpandedPanel }) { export function shouldHideCollapsedRightPanels({
hudVariant,
hasExpandedPanel,
}) {
return hudVariant === 'tactical' && Boolean(hasExpandedPanel); return hudVariant === 'tactical' && Boolean(hasExpandedPanel);
} }
+10 -2
View File
@@ -1,5 +1,13 @@
export { layoutLeftPanelRail } from './leftPanelRail.js'; export { layoutLeftPanelRail } from './leftPanelRail.js';
export { layoutRightPanelRail } from './rightPanelRail.js'; export { layoutRightPanelRail } from './rightPanelRail.js';
export { measurePanelNaturalHeight } from './panelMeasurement.js'; export { measurePanelNaturalHeight } from './panelMeasurement.js';
export { resolveHudRailLayout, shouldHideCollapsedRightPanels } from './panelRailGeometry.js'; export {
export { allocatePanelStackHeights, panelStackAutoCollapseIndices, resolveLeftStackBottomBoundary, resolvePanelStackCorridor } from '../panelStackLayout.js'; resolveHudRailLayout,
shouldHideCollapsedRightPanels,
} from './panelRailGeometry.js';
export {
allocatePanelStackHeights,
panelStackAutoCollapseIndices,
resolveLeftStackBottomBoundary,
resolvePanelStackCorridor,
} from '../panelStackLayout.js';
+188 -62
View File
@@ -1,60 +1,138 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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 classes = new Set(collapsed ? ['collapsed'] : []);
const properties = new Map(); const properties = new Map();
const attributes = new Map(); const attributes = new Map();
const writes = []; const writes = [];
const node = { const node = {
id, children: [], dataset: {}, parentElement: null, scrollHeight: height, id,
clientHeight: height, scrollTop: 0, children: [],
computed: { display: 'block', visibility: 'visible', opacity: '1', rowGap: '12px' }, dataset: {},
rect: { top, left, width, height, right: left + width, bottom: top + height }, 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: { classList: {
contains: (name) => classes.has(name), contains: (name) => classes.has(name),
add: (...names) => names.forEach(name => classes.add(name)), add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach(name => classes.delete(name)), remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle(name, value) { if (value) classes.add(name); else classes.delete(name); }, toggle(name, value) {
if (value) classes.add(name);
else classes.delete(name);
},
}, },
style: { style: {
setProperty(name, value) { writes.push(['set', name, value]); properties.set(name, value); }, setProperty(name, value) {
getPropertyValue: name => properties.get(name) || '', writes.push(['set', name, value]);
removeProperty(name) { writes.push(['remove', name]); properties.delete(name); }, 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), setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: name => attributes.delete(name), removeAttribute: (name) => attributes.delete(name),
getAttribute: name => attributes.get(name), getAttribute: (name) => attributes.get(name),
writes, writes,
}; };
return node; return node;
} }
function fixture(side, { mobile = false, hud = { visible: true, variant: 'tactical' } } = {}) { function fixture(
const first = element('first', {height: 42, collapsed: true}); side,
const second = element('second', {height: 42, collapsed: true}); { mobile = false, hud = { visible: true, variant: 'tactical' } } = {},
const stack = element('rail', {height: 96, left: side === 'left' ? 20 : 1100}); ) {
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]; stack.children = [first, second];
first.parentElement = second.parentElement = stack; first.parentElement = second.parentElement = stack;
const documentRef = { activeElement: null }; const documentRef = { activeElement: null };
stack.ownerDocument = documentRef; stack.ownerDocument = documentRef;
const collapsed = []; const collapsed = [];
let retries = 0, aligned = 0; let retries = 0,
aligned = 0;
const options = { const options = {
stack, hud, documentRef, obstacles: [], collapsedHeights: new Map(), stack,
windowRef: {innerHeight: 900, matchMedia: () => ({matches: mobile}), getComputedStyle: node => node.computed}, hud,
onCollapse: panel => collapsed.push(panel.id), documentRef,
onRetry: () => { retries += 1; }, obstacles: [],
onAligned: () => { aligned += 1; }, collapsedHeights: new Map(),
displayPanel: null, readDisplayScrollTop: () => 0, leftStack: element('left'), 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', () => { 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', () => { 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.options.collapsedHeights.get('first'), 42);
assert.equal(f.first.classList.contains('collapsed'), true); assert.equal(f.first.classList.contains('collapsed'), true);
assert.equal(f.aligned(), 1); 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', () => { test('left corridor respects a lower obstacle but ignores an obstacle hidden by its parent', () => {
const f = fixture('left'); const f = fixture('left');
const blocker = element('blocker', {top: 600, height: 80}); const blocker = element('blocker', { top: 600, height: 80 });
const hidden = element('hidden', {top: 300, height: 80}); const hidden = element('hidden', { top: 300, height: 80 });
hidden.parentElement = element('hidden-parent'); hidden.parentElement.computed.opacity = '0'; hidden.parentElement = element('hidden-parent');
f.options.obstacles = [blocker, hidden]; f.run(); hidden.parentElement.computed.opacity = '0';
f.options.obstacles = [blocker, hidden];
f.run();
assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47); assert.equal(Number(f.stack.dataset.safeBottomPct), 65.47);
}); });
for (const side of ['left', 'right']) { for (const side of ['left', 'right']) {
test(`${side} mobile layout releases desktop height/position styles and labels`, () => { 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.classList.add('layout-focus');
f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px'); f.stack.style.setProperty(`--${side}-stack-safe-top`, '300px');
f.first.style.setProperty(`--${side}-panel-allocated-height`, '99px'); 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.dataset.layoutMode, 'mobile');
assert.equal(f.stack.style.getPropertyValue(`--${side}-stack-safe-top`), ''); assert.equal(
assert.equal(f.first.style.getPropertyValue(`--${side}-panel-allocated-height`), ''); 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); assert.equal(f.first.getAttribute('aria-hidden'), undefined);
}); });
test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => { 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); const f = fixture(side);
f.options.preferredPanelId = 'second'; f.run(); 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.second.classList.contains('collapsed'), false);
assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); assert.equal(f.first.classList.contains('layout-auto-collapsed'), true);
assert.deepEqual(f.collapsed, ['first']); assert.deepEqual(f.collapsed, ['first']);
assert.equal(f.retries(), 1); assert.equal(f.retries(), 1);
}); });
test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => { test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => {
const f = fixture(side, {hud: {visible: false, variant: 'tactical'}}); const f = fixture(side, { hud: { visible: false, variant: 'tactical' } });
f.first.classList.add('layout-auto-collapsed'); f.run(); f.first.classList.add('layout-auto-collapsed');
f.run();
assert.equal(f.first.classList.contains('collapsed'), false); assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('collapsed'), true); assert.equal(f.second.classList.contains('collapsed'), true);
assert.deepEqual(f.collapsed, ['first']); 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', () => { 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); const f = fixture('right');
f.options.documentRef.activeElement = f.second; f.run(); 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.first.classList.contains('layout-auto-collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false); assert.equal(f.second.classList.contains('collapsed'), false);
}); });
test('right layout retains Display allocation during measurement and caps restored scroll', () => { 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); const f = fixture('right');
f.first.clientHeight = 400; f.options.displayPanel = f.first; f.options.readDisplayScrollTop = () => 800; f.first.id = 'pp-toggles';
f.first.style.setProperty('--right-panel-allocated-height', '600px'); f.first.writes.length = 0; 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(); f.run();
assert.equal(f.first.scrollTop, 500); assert.equal(f.first.scrollTop, 500);
assert.equal(f.first.writes.some(([op, name]) => op === 'remove' && name === '--right-panel-allocated-height'), false); assert.equal(
f.first.writes.length = 0; f.run(); f.first.writes.some(
assert.equal(f.first.writes.some(([,name]) => name === '--right-panel-allocated-height'), false, 'stable allocation must not churn the style attribute'); ([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', () => { test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
const f = fixture('right'); f.options.leftStack.rect.top = 200; const f = fixture('right');
const hidden = element('hidden', {left: 1100, top: 100, height: 200}); f.options.leftStack.rect.top = 200;
hidden.computed.display = 'none'; f.options.obstacles = [hidden]; f.run(); 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'); assert.equal(f.stack.dataset.safeTop, '200.0');
}); });
test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => { test('natural height includes visible content, margins and wrapper chrome, excluding hidden rows', () => {
const panel = element('panel'); const panel = element('panel');
const inner = element('inner', {top: 100}); inner.computed.paddingTop = '10px'; inner.computed.paddingBottom = '5px'; const inner = element('inner', { top: 100 });
const row = element('row', {top: 110, height: 40}); row.scrollHeight = 80; row.computed.marginBottom = '3px'; inner.computed.paddingTop = '10px';
const hidden = element('hidden', {top: 1000, height: 900}); hidden.computed.visibility = 'hidden'; inner.computed.paddingBottom = '5px';
panel.computed.borderTopWidth = '1px'; panel.computed.borderBottomWidth = '1px'; const row = element('row', { top: 110, height: 40 });
panel.children = [inner]; inner.children = [row, hidden]; row.scrollHeight = 80;
assert.equal(measurePanelNaturalHeight(panel, node => node.computed), 100); 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,
);
}); });
+96 -48
View File
@@ -2,7 +2,10 @@ import {
allocatePanelStackHeights, allocatePanelStackHeights,
panelStackAutoCollapseIndices, panelStackAutoCollapseIndices,
} from '../panelStackLayout.js'; } 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. * Measure and place the right panel rail for one synchronous layout pass.
@@ -34,28 +37,35 @@ export function layoutRightPanelRail({
displayPanel, displayPanel,
readDisplayScrollTop, readDisplayScrollTop,
documentRef = stack?.ownerDocument, documentRef = stack?.ownerDocument,
getComputedStyle = (element) => windowRef.getComputedStyle(element) getComputedStyle = (element) => windowRef.getComputedStyle(element),
}) { }) {
if (!stack) return; 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') { 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'); panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel); onCollapse(panel);
} }
} }
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some((panel) => ( const hasExpandedPanel = panels.some(
!panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles') (panel) =>
)); !panel.classList.contains('collapsed') &&
(!isMobile || panel.id !== 'pp-toggles'),
);
const exclusive = shouldHideCollapsedRightPanels({ const exclusive = shouldHideCollapsedRightPanels({
hudVariant: hud.variant, hudVariant: hud.variant,
hasExpandedPanel, hasExpandedPanel,
}); });
stack.classList.toggle('layout-exclusive', exclusive); stack.classList.toggle('layout-exclusive', exclusive);
for (const panel of panels) { 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'); else panel.removeAttribute('aria-hidden');
} }
@@ -63,7 +73,8 @@ export function layoutRightPanelRail({
stack.classList.remove('layout-focus'); stack.classList.remove('layout-focus');
stack.style.removeProperty('--right-stack-safe-top'); stack.style.removeProperty('--right-stack-safe-top');
stack.style.removeProperty('--right-stack-max-height'); 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'; stack.dataset.layoutMode = 'mobile';
return; return;
} }
@@ -82,7 +93,11 @@ export function layoutRightPanelRail({
let hiddenByAncestor = false; let hiddenByAncestor = false;
for (let element = obstacle; element; element = element.parentElement) { for (let element = obstacle; element; element = element.parentElement) {
const style = getComputedStyle(element); 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; hiddenByAncestor = true;
break; break;
} }
@@ -98,9 +113,9 @@ export function layoutRightPanelRail({
}); });
} }
const visiblePanels = panels.filter((panel) => ( const visiblePanels = panels.filter(
!exclusive || !panel.classList.contains('collapsed') (panel) => !exclusive || !panel.classList.contains('collapsed'),
)); );
const displayScrollTop = readDisplayScrollTop(); const displayScrollTop = readDisplayScrollTop();
// Measure intrinsic content, not the allocation written by the previous // 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 gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
const naturalHeight = visiblePanels.reduce((total, panel) => ( const naturalHeight =
total + Math.max( visiblePanels.reduce(
panel.getBoundingClientRect().height, (total, panel) =>
panel.scrollHeight || 0, total +
panel.classList.contains('collapsed') ? 42 : 0, Math.max(
) panel.getBoundingClientRect().height,
), 0) + gap * Math.max(0, visiblePanels.length - 1); panel.scrollHeight || 0,
panel.classList.contains('collapsed') ? 42 : 0,
),
0,
) +
gap * Math.max(0, visiblePanels.length - 1);
const layout = resolveHudRailLayout({ const layout = resolveHudRailLayout({
viewportHeight, viewportHeight,
panelHeight: naturalHeight, panelHeight: naturalHeight,
@@ -139,41 +159,58 @@ export function layoutRightPanelRail({
? naturalHeight > availableHeight - stabilityBand * 2 ? naturalHeight > availableHeight - stabilityBand * 2
: naturalHeight > availableHeight - stabilityBand; : naturalHeight > availableHeight - stabilityBand;
const layoutTop = shouldFocus ? safeTop : layout.top; const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce((total, panel) => ( const collapsedHeight = visiblePanels.reduce(
panel.classList.contains('collapsed') (total, panel) =>
? total + panel.getBoundingClientRect().height panel.classList.contains('collapsed')
: total ? total + panel.getBoundingClientRect().height
), 0); : total,
const expandedPanelsInDomOrder = visiblePanels.filter((panel) => !panel.classList.contains('collapsed')); 0,
const focusedExpandedPanel = expandedPanelsInDomOrder.find((panel) => panel.contains(documentRef.activeElement)); );
const preferredExpandedPanel = expandedPanelsInDomOrder.find( const expandedPanelsInDomOrder = visiblePanels.filter(
(panel) => panel.id === preferredPanelId, (panel) => !panel.classList.contains('collapsed'),
) || focusedExpandedPanel; );
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 // Match the left lane: allocation order follows the latest explicit
// disclosure, not DOM order. A focused panel is the fallback owner so // disclosure, not DOM order. A focused panel is the fallback owner so
// temporary presentation collapse never strands keyboard focus. // temporary presentation collapse never strands keyboard focus.
const expandedPanels = preferredExpandedPanel const expandedPanels = preferredExpandedPanel
? [preferredExpandedPanel, ...expandedPanelsInDomOrder.filter((panel) => panel !== preferredExpandedPanel)] ? [
preferredExpandedPanel,
...expandedPanelsInDomOrder.filter(
(panel) => panel !== preferredExpandedPanel,
),
]
: expandedPanelsInDomOrder; : expandedPanelsInDomOrder;
const expandedAvailableHeight = Math.max( const expandedAvailableHeight = Math.max(
0, 0,
safeBottom - layoutTop - collapsedHeight - gap * Math.max(0, visiblePanels.length - 1), safeBottom -
layoutTop -
collapsedHeight -
gap * Math.max(0, visiblePanels.length - 1),
); );
const expandedHeights = allocatePanelStackHeights({ const expandedHeights = allocatePanelStackHeights({
naturalHeights: expandedPanels.map((panel) => Math.max( naturalHeights: expandedPanels.map((panel) =>
panel.getBoundingClientRect().height, Math.max(panel.getBoundingClientRect().height, panel.scrollHeight || 0),
panel.scrollHeight || 0, ),
)),
availableHeight: expandedAvailableHeight, availableHeight: expandedAvailableHeight,
}); });
const autoCollapseIndices = hud.visible ? panelStackAutoCollapseIndices({ const autoCollapseIndices = hud.visible
naturalHeights: expandedPanels.map((panel) => Math.max( ? panelStackAutoCollapseIndices({
panel.getBoundingClientRect().height, naturalHeights: expandedPanels.map((panel) =>
panel.scrollHeight || 0, Math.max(
)), panel.getBoundingClientRect().height,
allocatedHeights: expandedHeights, panel.scrollHeight || 0,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical', ),
}) : []; ),
allocatedHeights: expandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
})
: [];
if (autoCollapseIndices.length) { if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) { for (const index of autoCollapseIndices) {
const panel = expandedPanels[index]; const panel = expandedPanels[index];
@@ -191,7 +228,9 @@ export function layoutRightPanelRail({
// render savings. Only a real allocation change may touch the attribute. // render savings. Only a real allocation change may touch the attribute.
expandedPanels.forEach((panel, index) => { expandedPanels.forEach((panel, index) => {
const next = `${expandedHeights[index].toFixed(1)}px`; 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); panel.style.setProperty('--right-panel-allocated-height', next);
} }
}); });
@@ -200,8 +239,14 @@ export function layoutRightPanelRail({
panel.style.removeProperty('--right-panel-allocated-height'); panel.style.removeProperty('--right-panel-allocated-height');
} }
stack.style.setProperty('--right-stack-safe-top', `${layoutTop.toFixed(1)}px`); stack.style.setProperty(
stack.style.setProperty('--right-stack-max-height', `${Math.max(0, safeBottom - layoutTop).toFixed(1)}px`); '--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.classList.toggle('layout-focus', shouldFocus);
stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal'; stack.dataset.layoutMode = shouldFocus ? 'focus' : 'normal';
stack.dataset.safeTop = layoutTop.toFixed(1); stack.dataset.safeTop = layoutTop.toFixed(1);
@@ -211,7 +256,10 @@ export function layoutRightPanelRail({
stack.dataset.expandedCount = String(expandedPanels.length); stack.dataset.expandedCount = String(expandedPanels.length);
if (displayPanel && expandedPanels.includes(displayPanel)) { 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); displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
} }
} }