fix(ui): harden Cyber panel layout and preset restoration

This commit is contained in:
Manjunath committed 2026-10-01 01:50:31 +05:30
1 parent e7707d9a0f
commit e03bf008f3
8 files changed
+695 -16

No files matched your search

+205 -4
View File
@@ -9,6 +9,9 @@ import {
normalizeHudLayout,
} from '../hudLayouts.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { StyleManager } from './applicationShell.js';
import { bindDisplayControls } from './displayControls.js';
import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
@@ -137,6 +140,102 @@ test('non-Cyber style presets and explicit HUD choices remain authoritative', (t
}
});
test('leaving an explicitly selected Cyber layout restores the prior visual preset', (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'noir');
// A temporary choice made while Cyber is active does not replace the preset
// that was active before the user entered Cyber.
owner.setStyle('snow');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'noir');
assert.equal(owner._preCyberStyle, null);
});
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
const makeOwner = () => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner.setHudLayout = (...args) =>
StyleManager.prototype.setHudLayout.call(owner, ...args);
owner.getControlState = () => ({
hud: { layout: hud.getVariant(), visible: true },
});
return { owner, hud };
};
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const controls = bindDisplayControls({
elements: { hudLayout },
actions: { setHudLayout: (value) => display.owner.setHudLayout(value) },
});
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
hudLayout.value = 'operator';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
assert.equal(display.owner.activeStyle, 'noir');
controls.destroy();
const voice = makeOwner();
const eventSource = { addEventListener() {}, removeEventListener() {} };
const viewer = {
camera: { moveEnd: { addEventListener() {} } },
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: eventSource },
};
const runVoiceAction = createGevActionRunner({
viewer,
styleManager: voice.owner,
dataManager: {},
});
const entered = await runVoiceAction('set_hud', { layout: 'cyber' });
assert.equal(entered.ok, true);
assert.equal(voice.hud.getVariant(), 'cyber');
assert.equal(voice.owner.activeStyle, 'thermal');
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
assert.equal(exited.ok, true);
assert.equal(voice.hud.getVariant(), 'operator');
assert.equal(voice.owner.activeStyle, 'noir');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'retro');
assert.equal(owner._preCyberStyle, null);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'retro');
owner.setStyle('surveillance');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'surveillance');
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
});
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, null);
owner._setHudVariant('tactical', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, null);
});
test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => {
const { owner, hud } = styleOwner(t);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } });
@@ -179,6 +278,104 @@ test('Cyber setup uses a compact toolbar icon and highlights the north-up letter
});
const cyberStyles = read('./styles/cyber.css');
// Source guards supplement rendered popup/hit-target checks. Inspect one rule
// at a time so a safe declaration on a child cannot excuse a clipped ancestor.
function cssRuleBody(styles, selector) {
const match = styles.match(new RegExp(`${selector.source}\\s*\\{([^}]+)\\}`));
assert.ok(match, `Missing CSS rule: ${selector.source}`);
return match[1];
}
test('Cyber Voice clips its decoration without clipping descendant help and errors', () => {
const owner = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control/,
);
assert.match(owner, /isolation:\s*isolate;/);
assert.match(owner, /overflow:\s*visible;/);
assert.match(owner, /clip-path:\s*none;/);
const decoration = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control::before/,
);
assert.match(decoration, /clip-path:\s*polygon\(/);
assert.match(decoration, /pointer-events:\s*none;/);
assert.match(decoration, /inset:\s*0;/);
assert.match(decoration, /z-index:\s*-1;/);
});
test('Cyber dock pins escape the tray mask and its decoration overrides the legacy arrow geometry', () => {
const owner = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\] #command-dock \.dock-popover-content/,
);
assert.match(owner, /clip-path:\s*none;/);
assert.match(owner, /isolation:\s*isolate;/);
// Including the tray owners beats #control-panel's more specific old
// ::after arrow positioning. Otherwise left:auto can collapse the frame.
const decoration = cssRuleBody(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+#command-dock\s+:is\(#location-bar, #control-panel\)\s+\.dock-popover-content::after/,
);
assert.match(decoration, /inset:\s*0;/);
assert.match(decoration, /width:\s*auto;/);
assert.match(decoration, /height:\s*auto;/);
assert.match(decoration, /transform:\s*none;/);
assert.match(decoration, /clip-path:\s*var\(--cyber-panel-frame\);/);
assert.match(decoration, /pointer-events:\s*none;/);
});
test('Cyber lower-left telemetry tracks the credit anchor with clearance for its logo row', () => {
const telemetry = cssRuleBody(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
);
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
const credits = read('./styles/command-dock-trays.css');
const creditOffsets = [
...credits.matchAll(
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
),
];
assert.ok(creditOffsets.length > 0);
for (const credit of creditOffsets) {
assert.ok(
Number(offset[1]) - Number(credit[1]) >= 2.75,
'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size',
);
}
assert.doesNotMatch(cyberStyles, /\.hud-bottom-left\s*\{\s*bottom:\s*60px;/);
});
test('shared panel hooks affect only opted-in rail surfaces without overriding disclosure and concealment', () => {
const styles = read('./styles/panel-surfaces.css');
const rail = cssRuleBody(
styles,
/:is\(#left-panel-stack, #right-context-rail\)\s+> \[data-panel-id\]:has\(> \[data-panel-surface\]\)/,
);
assert.match(rail, /pointer-events:\s*auto;/);
assert.doesNotMatch(rail, /(?:display|visibility):/);
assert.doesNotMatch(
styles,
/:is\(#left-panel-stack, #right-context-rail\)\s*> \[data-panel-id\]\s*\{/,
);
assert.match(
styles,
/\[data-panel-surface\]:not\(\[hidden\]\):not\(dialog:not\(\[open\]\)\)\s*\{\s*display: flex;/,
);
assert.match(
styles,
/dialog\[data-panel-surface\]:not\(\[open\]\)\s*\{\s*display: none !important;/,
);
assert.match(
styles,
/body:is\(\.ui-clean-view, \.recording-mode\)[^{]+\{[^}]*visibility: hidden !important;[^}]*pointer-events: none !important;/,
);
});
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
assert.match(
cyberStyles,
@@ -330,6 +527,7 @@ test('Cyber visual defaults update the style, thermal palette and render state',
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
const stylesheet = read('../../style.css');
const rightRail = read('./rightPanelRail.js');
const hud = read('../hud.js');
const detection = read('../data/detection.js');
const civilRendering = read('../layers/flights/rendering.js');
@@ -415,21 +613,24 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/);
assert.match(
cyberStyles,
/--cyber-left-rail-top: max\([\s\S]*?252px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 146px\)/,
/--cyber-left-rail-top: max\([\s\S]*?220px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 120px\)/,
);
assert.match(
cyberStyles,
/\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/,
);
assert.match(cyberStyles, /\.hud-bottom-left \{\s*bottom: 60px;/);
assert.match(
cyberStyles,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;/,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/,
);
assert.match(
cyberStyles,
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
);
assert.match(
rightRail,
/const leftStackTop = leftStack\?\.getBoundingClientRect\(\)\.top;[\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/,
);
assert.match(
cyberStyles,
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
@@ -453,7 +654,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: 54px;/);
assert.match(cyberStyles, /right: 62px;/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 11rem\);/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/);