Files
gods-eye-view/src/ui/panelRails.test.mjs
T
German KhokhlovandClaude Opus 5.5 fbbc4cda5c refactor(ui): remove floating, resizable rail panels
PR #768 bundled a portable-panel windowing system with the Street Level
layer. Remove it so CCTV and Street Level are ordinary docked right-rail
panels again, as on main before the merge.

Removed:
- header drag to float, 8-way edge/corner resize, double-press snap-back,
  dockOnCollapse, command-dock clearance, float hint toast, floating z floor,
  persisted floating geometry and ResizeObserver re-clamping
  (panelPositionControls.js restored to its pre-#768 version)
- src/ui/panelResize.js and its tests, panelDock.test.mjs
- onPanelResized/dockPanel wiring in PanelChrome, the application shell and
  StreetLevelControls (EXPAND/SHRINK no longer docks)
- :not(.panel-floating) guards and .panel-floating/.panel-portable/resize CSS
- qa:panel-resize, qa-panel-resize.mjs, qa-panelDrag.mjs, their tests, the
  CI "Portable panel gate" step, QA_FAIL_ON_RETRY, and the Street Level
  gate's float/resize/dock steps
- floating-panel docs in README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES

Kept: the Street Level panel as a docked rail panel (share/cockpit/rail
lists, layout controller, rail and Cyber CSS, EXPAND viewer), and the
independent right-rail alignment fix (settledLeftRailTop, 9dde7b4).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:39:33 +04:00

725 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { displayPanelScroller } from './displayPanelScroll.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
layoutLeftPanelRail,
layoutRightPanelRail,
measurePanelNaturalHeight,
} from './panelRails.js';
test('Display uses an inner scroll body only in Cyber', () => {
const body = { scrollTop: 95 };
const panel = {
ownerDocument: { documentElement: { dataset: { uiTheme: 'cyber' } } },
querySelector: () => body,
};
assert.equal(displayPanelScroller(panel), body);
panel.ownerDocument.documentElement.dataset.uiTheme = 'tactical';
assert.equal(displayPanelScroller(panel), panel);
assert.equal(displayPanelScroller(null), null);
});
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
for (const mobile of [false, true]) {
const f = fixture('right', {
mobile,
hud: { visible: true, variant: 'cyber' },
});
f.expand(f.first, 250);
f.expand(f.second, 250);
f.options.preferredPanelId = f.second.id;
f.run();
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
f.run();
assert.equal(
f.first.classList.contains('collapsed'),
true,
'layout does not reopen a peer',
);
f.options.hud.variant = 'operator';
f.run();
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(
f.first.classList.contains('cyber-accordion-collapsed'),
false,
);
assert.equal(f.second.classList.contains('collapsed'), false);
}
});
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
f.expand(f.first, 250);
f.expand(f.second, 250);
f.options.documentRef.activeElement = f.second;
f.run();
assert.equal(f.first.classList.contains('collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
});
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);
});
for (const collapsed of [true, false]) {
test(`right layout ignores a hidden ${collapsed ? 'collapsed' : 'expanded'} panel`, () => {
const baseline = fixture('right');
baseline.stack.children = [baseline.first];
baseline.expand(baseline.first, 600);
baseline.run();
const f = fixture('right');
f.expand(f.first, 600);
f.second.id = 'weather-panel';
f.second.hidden = true;
if (!collapsed) f.expand(f.second, 900);
f.options.preferredPanelId = 'weather-panel';
f.run();
assert.deepEqual(f.stack.dataset, baseline.stack.dataset);
assert.equal(
f.first.style.getPropertyValue('--right-panel-allocated-height'),
baseline.first.style.getPropertyValue('--right-panel-allocated-height'),
);
assert.equal(f.second.classList.contains('collapsed'), collapsed);
assert.deepEqual(f.second.writes, []);
assert.deepEqual(f.collapsed, []);
assert.equal(f.retries(), 0);
assert.equal(f.stack.classList.contains('layout-focus'), 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');
});
// The left rail's `top` animates after the right pass runs, and nothing
// re-runs it when the transition lands: the right rail must read the end value.
function animatingLeftRail({ keyframes = true } = {}) {
const f = fixture('right');
const left = f.options.leftStack;
left.rect.top = 250;
left.computed.top = '250px';
left.dataset.safeTopPct = '29.60';
left.getAnimations = () => [
{ transitionProperty: 'bottom', effect: null },
{
transitionProperty: 'top',
effect: {
getKeyframes: () =>
keyframes ? [{ top: '234px' }, { top: '266.4px' }] : [],
},
},
];
return f;
}
test('right rail aligns to where a mid-transition left rail settles', () => {
const f = animatingLeftRail();
f.run();
assert.equal(f.stack.dataset.safeTop, '266.4');
assert.equal(
f.stack.style.getPropertyValue('--right-stack-safe-top'),
'266.4px',
);
});
test('right rail falls back to the committed left target when the transition end is unreadable', () => {
const f = animatingLeftRail({ keyframes: false });
f.run();
assert.equal(f.stack.dataset.safeTop, '266.4');
});
test('right rail follows a settled left rail whose CSS overrides the committed target', () => {
// Cyber pins the left rail with `top: ... !important`, so the committed
// percentage is not where it renders and no `top` transition runs.
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
f.options.leftStack.rect.top = 220;
f.options.leftStack.dataset.safeTopPct = '26.00';
f.options.leftStack.getAnimations = () => [];
f.run();
assert.equal(f.stack.dataset.safeTop, '220.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,
);
});
// Reproduce the allocation-dependent readings seen beside the weather card.
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
// Removing only the allocation leaves focus CSS active: its next reading is
// 422 px, so the old decision exits focus and then re-enters on the 629 px read.
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
const f = fixture('right');
f.options.windowRef.innerHeight = 920;
f.options.leftStack.rect.top = 439.6;
f.stack.computed.rowGap = '8px';
const display = element('pp-toggles', { collapsed: true });
const cctv = element('cctv-panel', { height: 629 });
const weather = element('weather-panel', {
height: 444,
collapsed: !weatherOpen,
});
const context = element('global-context-panel', { collapsed: true });
if (!weatherOpen) weather.classList.add('layout-auto-collapsed');
f.stack.children = [display, cctv, weather, context];
f.stack.classList.toggle('layout-focus', focused);
f.options.preferredPanelId = cctv.id;
f.options.displayPanel = display;
const allocation = '--right-panel-allocated-height';
const measuring = () =>
f.stack.getAttribute('data-rail-measuring') !== undefined;
for (const panel of f.stack.children) {
panel.parentElement = f.stack;
panel.intrinsicHeight = panel.rect.height;
panel.style.setProperty(allocation, '391px');
panel.getBoundingClientRect = () => {
let height;
if (panel.classList.contains('collapsed')) {
height =
measuring() || f.stack.classList.contains('layout-focus') ? 44 : 0;
} else if (measuring()) {
height = panel.intrinsicHeight;
} else if (f.stack.classList.contains('layout-focus')) {
height = 391;
} else {
height = panel.intrinsicHeight;
}
return { ...panel.rect, height, bottom: panel.rect.top + height };
};
Object.defineProperty(panel, 'scrollHeight', {
get: () => {
if (measuring())
return panel.classList.contains('collapsed')
? 44
: panel.intrinsicHeight;
if (panel.classList.contains('collapsed'))
return panel.getBoundingClientRect().height;
return f.stack.classList.contains('layout-focus') &&
panel.style.getPropertyValue(allocation)
? 603
: 422;
},
});
}
return { ...f, cctv, weather };
}
for (const focused of [false, true]) {
test(`right rail settles mode-dependent CCTV and weather heights within two passes from ${focused ? 'focus' : 'normal'}`, () => {
const f = thrashingRightRail({ focused });
assert.equal(f.cctv.getBoundingClientRect().height, focused ? 391 : 629);
assert.equal(f.weather.getBoundingClientRect().height, focused ? 44 : 0);
const modes = [];
const needs = [];
for (let pass = 0; pass < 10; pass++) {
f.run();
modes.push(f.stack.classList.contains('layout-focus'));
needs.push(f.stack.dataset.requiredHeight);
assert.equal(f.stack.dataset.availableHeight, '443.6');
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
}
assert.deepEqual(modes.slice(1), Array(9).fill(true));
assert.deepEqual(needs, Array(10).fill('681.0'));
assert.equal(f.retries(), 0);
assert.equal(f.cctv.writes.filter(([op]) => op === 'remove').length, 0);
});
}
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
const f = thrashingRightRail();
f.run();
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
for (const height of [368, 398, 368, 398, 368]) {
f.cctv.intrinsicHeight = height;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
}
f.cctv.intrinsicHeight = 335; // Need 387 < 443.6 - 55.2.
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
f.cctv.intrinsicHeight = 365;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail enters focus only above available height and leaves below the full deadband', () => {
const f = thrashingRightRail();
f.options.windowRef.innerHeight = 1000;
f.options.leftStack.rect.top = 500;
f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
f.cctv.intrinsicHeight = 409;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.intrinsicHeight = 348; // Need equals the 400 px exit boundary.
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.intrinsicHeight = 347;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail auto-collapse requests only one retry and settles within two passes', () => {
const f = thrashingRightRail({ weatherOpen: true });
const modes = [];
for (let pass = 0; pass < 10; pass++) {
f.run();
modes.push(f.stack.classList.contains('layout-focus'));
}
assert.deepEqual(f.collapsed, ['weather-panel']);
assert.equal(f.retries(), 1);
assert.deepEqual(modes.slice(1), Array(9).fill(true));
});
test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => {
const f = thrashingRightRail({ weatherOpen: true });
f.run();
assert.equal(f.retries(), 1);
f.weather.classList.remove('collapsed', 'layout-auto-collapsed');
f.run();
assert.equal(f.retries(), 1);
assert.equal(f.stack.dataset.layoutMode, 'focus');
});
test('right rail restores presentation even when intrinsic measurement throws', () => {
const f = thrashingRightRail();
f.cctv.getBoundingClientRect = () => {
throw new Error('measurement failed');
};
assert.throws(() => f.run(), /measurement failed/);
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
});
test('right rail measuring pass keeps an opted-in scroller position', () => {
const f = fixture('right');
f.expand(f.first, 300);
let scrollTop = 240;
let writes = 0;
let clamp = true;
const body = {
get scrollTop() {
return scrollTop;
},
set scrollTop(value) {
writes++;
scrollTop = value;
},
};
f.stack.querySelectorAll = (selector) =>
selector === '[data-rail-scroller]' ? [body] : [];
const setAttribute = f.stack.setAttribute;
f.stack.setAttribute = (name, value) => {
setAttribute(name, value);
// The lifted max-height removes the overflow and clamps the offset.
if (clamp && name === 'data-rail-measuring') scrollTop = 0;
};
f.run();
assert.equal(scrollTop, 240);
assert.equal(writes, 1);
clamp = false;
f.run();
assert.equal(writes, 1, 'an unclamped offset is not rewritten');
});
for (const variant of ['minimal', 'full']) {
test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => {
const f = fixture('right', { hud: { visible: true, variant } });
f.expand(f.first, 900);
f.expand(f.second, 900);
for (let pass = 0; pass < 10; pass++) f.run();
assert.equal(f.retries(), 0);
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('collapsed'), false);
});
}
// ── Narrow-screen rail overflow pin ──────────────────────────────────────────
// At ≤720px both panel stacks become scroll containers (overflow-y: auto),
// which also makes their overflow-x compute to auto. Each panel's decorative
// .panel-glow is absolutely positioned with a negative inset, so inside a
// scroll container that overhang is no longer harmless paint: it becomes
// 18–20px of scrollable overflow on both axes, drawing a horizontal scrollbar
// band under the expanded CCTV/Context/Data panel plus a vertical scrollbar
// that scrolls nothing but glow. The narrow block therefore pins every hosted
// glow to its panel box.
/** Strip comments and return the bodies of every ≤720px media block. */
function narrowScreenBlocks(css) {
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
const blocks = [];
let from = 0;
for (;;) {
const start = source.indexOf('@media (max-width: 720px) {', from);
if (start === -1) break;
const open = source.indexOf('{', start);
let depth = 0;
let close = -1;
for (let index = open; index < source.length; index += 1) {
if (source[index] === '{') depth += 1;
if (source[index] === '}' && (depth -= 1) === 0) {
close = index;
break;
}
}
assert.notEqual(close, -1, 'unterminated narrow-screen media block');
blocks.push(source.slice(open + 1, close));
from = close + 1;
}
assert.ok(blocks.length, 'narrow-screen media block is missing');
return blocks;
}
/** Split a flat (non-nested) rule list into [selectors, declarations] pairs. */
function flatRules(block) {
assert.doesNotMatch(block, /@media/, 'nested media queries are not modelled');
return [...block.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
([, selectors, declarations]) => [
selectors.split(',').map((part) => part.trim().replace(/\s+/g, ' ')),
declarations,
],
);
}
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
for (const panel of [
'data-panel',
'scene-panel',
'cctv-panel',
'global-context-panel',
]) {
assert.match(
css,
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
`${panel} glow no longer overhangs its panel; revisit this pin`,
);
}
const narrow = narrowScreenBlocks(css).flatMap(flatRules);
const scrollingRails = [
...new Set(
narrow
.filter(([, declarations]) =>
/\boverflow(?:-y)?:\s*(?:auto|scroll)\b/.test(declarations),
)
.flatMap(([selectors]) => selectors)
.filter((selector) =>
/^#(?:left-panel-stack|right-context-rail)$/.test(selector),
),
),
].sort();
assert.deepEqual(scrollingRails, [
'#left-panel-stack',
'#right-context-rail',
]);
for (const rail of scrollingRails) {
assert.ok(
narrow.some(
([selectors, declarations]) =>
selectors.includes(`${rail} .panel-glow`) &&
/(?:^|;)\s*inset:\s*0\s*;/.test(declarations),
),
`${rail} scrolls at ≤720px but does not pin its panel glows (inset: 0)`,
);
}
});
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
const f = fixture('right');
const imagery = element('recent-imagery-panel', { height: 0 });
imagery.hidden = true;
imagery.scrollHeight = 0;
imagery.rect.height = 0;
f.stack.children.push(imagery);
imagery.parentElement = f.stack;
// A tall panel expands into focus mode: the hidden sibling is not among the
// later panels that focus mode collapses, and it counts for nothing.
f.expand(f.first, 900);
f.run();
assert.equal(f.stack.dataset.layoutMode, 'focus');
assert.equal(imagery.classList.contains('collapsed'), false);
assert.equal(imagery.classList.contains('layout-auto-collapsed'), false);
assert.equal(f.stack.dataset.expandedCount, '1');
assert.equal(
imagery.style.getPropertyValue('--right-panel-allocated-height'),
'',
);
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
const alone = parseFloat(
f.first.style.getPropertyValue('--right-panel-allocated-height'),
);
// Shown (and expanded) it joins the allocation like any other panel.
imagery.hidden = false;
imagery.scrollHeight = 300;
imagery.rect.height = 300;
const retriesBefore = f.retries();
f.run();
assert.ok(
f.collapsed.includes('recent-imagery-panel'),
'focus mode collapses the later panel and asks for another pass',
);
assert.equal(f.retries(), retriesBefore + 1);
f.run();
assert.equal(f.stack.dataset.expandedCount, '1');
assert.ok(
parseFloat(
f.first.style.getPropertyValue('--right-panel-allocated-height'),
) <= alone,
);
});