feat(hud): Cyber HUD theme with GPU contact sonar (#706)

Add the opt-in Cyber HUD layout with coordinated map and cockpit panel
styling, GPU-only contact sonar (rings, range, power, opacity, sector) with
native-rendering fallback, and set_cyber_sonar voice controls. Sonar visual
style inspired by kk376's tactical naval sonar (#214).

Co-authored-by: Sameh Khamis <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-23 20:31:56 -07:00
1 parent f913066cf2
commit 513dc87a0e
74 files changed
+7320 -90

No files matched your search

+33 -3
View File
@@ -22,6 +22,11 @@ import { bindClearLayersControl } from './layers.js';
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
import { createMapSourceControls } from './mapSource.js';
import { STYLES } from './effects.js';
import { isHudLayout } from '../hudLayouts.js';
import {
getCyberSonarControlState,
setCyberSonarControls,
} from './cyberSonarControls.js';
import * as Cesium from 'cesium';
@@ -191,6 +196,17 @@ export class StyleManager extends ShellFacade {
_detectionOpacityValue: this._detectionOpacityValue,
_detectionSliderRow: this._detectionSliderRow,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRingsValue: this._cyberSonarRingsValue,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarRangeValue: this._cyberSonarRangeValue,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarIntensityValue: this._cyberSonarIntensityValue,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarOpacityValue: this._cyberSonarOpacityValue,
_cyberSonarSector: this._cyberSonarSector,
_cyberSonarSectorValue: this._cyberSonarSectorValue,
_hudLayoutRow: this._hudLayoutRow,
_hudLayoutSelect: this._hudLayoutSelect,
_ppToggles: this._ppToggles,
@@ -486,6 +502,12 @@ export class StyleManager extends ShellFacade {
_scopeFeatherSlider: this._scopeFeatherSlider,
_hudLayoutSelect: this._hudLayoutSelect,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarSector: this._cyberSonarSector,
_cleanViewBtn: this._cleanViewBtn,
_cleanViewExitBtn: this._cleanViewExitBtn,
_detectionDensitySlider: this._detectionDensitySlider,
@@ -511,6 +533,8 @@ export class StyleManager extends ShellFacade {
_applySharpenIntensity: (...args) =>
this._applySharpenIntensity(...args),
_setHudVariant: (...args) => this._setHudVariant(...args),
_setCyberSonarEnabled: (...args) => this._setCyberSonarEnabled(...args),
_setCyberSonarSetting: (...args) => this._setCyberSonarSetting(...args),
_applyDetectionDensityFromUi: (...args) =>
this._applyDetectionDensityFromUi(...args),
_setDetectionAllocation: (...args) =>
@@ -1071,16 +1095,16 @@ export class StyleManager extends ShellFacade {
/**
* Switches the HUD layout variant.
* @param {'tactical'|'operator'|'minimal'} variantName - Layout variant.
* @param {'tactical'|'operator'|'minimal'|'cyber'} variantName - Layout variant.
* @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}}
*/
setHudLayout(variantName) {
const variant = String(variantName ?? '').toLowerCase();
if (!['tactical', 'operator', 'minimal'].includes(variant)) {
if (!isHudLayout(variant)) {
return { ok: false, error: `Unknown HUD layout: ${variantName}` };
}
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(variant);
this._setHudVariant(variant, { applyVisualDefaults: true });
return {
ok: true,
layout: this.hud.getVariant(),
@@ -1196,6 +1220,11 @@ export class StyleManager extends ShellFacade {
};
}
/** Apply Cyber-only sonar settings through the shared visual controls. */
setCyberSonar(settings) {
return setCyberSonarControls(this, settings);
}
/**
* Full control-state snapshot — single source for voice read-back so the
* agent confirms from the same state it acted on.
@@ -1208,6 +1237,7 @@ export class StyleManager extends ShellFacade {
hud: {
visible: !!this.hud?.visible,
layout: this.hud?.getVariant?.() || null,
sonar: getCyberSonarControlState(),
},
detection: this.getDetectionState(),
bloom: {
+1
View File
@@ -293,6 +293,7 @@ export function bindCameraOrientationControls({
);
}
if (!applied || applied.heading !== next.heading) {
northButton?.setAttribute('data-north-up', String(next.heading === 0));
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
+27
View File
@@ -337,6 +337,33 @@ test('the needle follows a rotation too small for the camera-changed threshold',
controls.destroy();
});
test('north-up indicator follows real heading including the 360-degree seam', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
const northButton = new FakeButton();
const controls = bindCameraOrientationControls({
viewer,
elements: { northButton },
runNavigation: (_noun, navigate) => navigate(),
});
for (const [heading, active] of [
[0, true],
[15, false],
[359.8, true],
[359, false],
[360, true],
[90, false],
]) {
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(
northButton.getAttribute('data-north-up'),
String(active),
`heading ${heading}`,
);
}
controls.destroy();
});
test('a settled camera writes nothing, and teardown releases every signal', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
+12 -5
View File
@@ -1,3 +1,5 @@
import { displayPanelScroller } from './displayPanelScroll.js';
/** Move the existing Display groups between their two presentation surfaces. */
export class CockpitDisplayPortal {
constructor({ standardPanel, cockpitPanel, groups, layout }) {
@@ -20,15 +22,18 @@ export class CockpitDisplayPortal {
group.before(anchor);
return [{ name, group, slot, anchor }];
});
this.standardScrollTop = standardPanel?.scrollTop || 0;
this.standardScrollTop =
displayPanelScroller(standardPanel)?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
const options = { passive: true, signal: this.listeners.signal };
standardPanel?.addEventListener(
'scroll',
() => {
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
if (!this.active)
this.standardScrollTop =
displayPanelScroller(standardPanel).scrollTop;
},
options,
{ ...options, capture: true },
);
cockpitPanel?.addEventListener(
'scroll',
@@ -85,7 +90,8 @@ export class CockpitDisplayPortal {
if (nextActive && this.cockpitPanel)
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
if (!nextActive && this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
};
if (!settle) {
restoreScroll();
@@ -121,7 +127,8 @@ export class CockpitDisplayPortal {
}
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
if (this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
this.records = [];
this.restoreOwner = null;
}
+135 -6
View File
@@ -2,6 +2,7 @@
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
resolveCyberCockpitPanelLane,
} from '../cockpitUtilityLayout.js';
import {
COCKPIT_UTILITY_REC_GAP_PX,
@@ -90,7 +91,53 @@ export function syncContextLayout() {
'--cockpit-context-left',
`${desktopInset.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-top');
if (document.documentElement.dataset.uiTheme !== 'cyber') {
this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height');
return;
}
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
const dataPanel = document.querySelector(
'#left-panel-stack > #data-panel:not(.collapsed)',
);
let desiredTop = defaultTop;
if (this.contextCollapsed && dataPanel) {
desiredTop = Math.max(
desiredTop,
dataPanel.getBoundingClientRect().bottom + 8,
);
}
const contextBounds = this.context.getBoundingClientRect();
let safeBottom = window.innerHeight - 24;
for (const selector of [
'#cesium-credits',
'#command-dock',
'#view-switcher',
]) {
const obstacle = document.querySelector(selector);
if (!obstacle) continue;
const style = window.getComputedStyle(obstacle);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
Number(style.opacity) === 0
)
continue;
const bounds = obstacle.getBoundingClientRect();
const overlapsHorizontally =
bounds.right > contextBounds.left && bounds.left < contextBounds.right;
if (overlapsHorizontally && bounds.top > desiredTop) {
safeBottom = Math.min(safeBottom, bounds.top - 8);
}
}
const boundedTop = Math.max(
defaultTop,
Math.min(desiredTop, safeBottom - contextBounds.height),
);
this.context.style.setProperty(
'--cockpit-context-top',
`${boundedTop.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-max-height');
}
@@ -127,6 +174,34 @@ export function syncSignalLayout() {
);
this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
const cyber = document.documentElement.dataset.uiTheme === 'cyber';
const leftPanel = cyber
? document.querySelector('#left-panel-stack > #data-panel')
: null;
const alignedTop = isRenderedOnScreen(leftPanel)
? leftPanel.getBoundingClientRect().top
: null;
// Cyber's two rails share a top edge. Move the briefing below the collapsed
// strip rather than pulling the right rail upward to avoid it.
if (alignedTop !== null && utilityControls) {
const expanded = utilityControls.querySelector(
'.cockpit-utility-control.is-expanded',
);
if (!expanded) {
const signalTop = Math.max(
Math.max(255, Math.min(300, window.innerHeight * 0.28)),
alignedTop + utilityControls.getBoundingClientRect().height + 28,
);
this.signalStream.style.setProperty(
'--cockpit-signal-top',
`${signalTop.toFixed(1)}px`,
);
this.signalStream.style.setProperty(
'--cockpit-signal-max-height',
`${Math.max(44, window.innerHeight - signalTop - 80).toFixed(1)}px`,
);
}
}
const signalBounds = this.signalStream.getBoundingClientRect();
const utilityBounds = utilityControls?.getBoundingClientRect();
if (utilityBounds) {
@@ -151,9 +226,51 @@ export function syncSignalLayout() {
collapsedLauncher.scrollHeight,
)
: 0;
// Cockpit owns this anchor outright. The strip used to inherit the left
// accordion's committed top, which is solved against left-lane obstacles
// and dropped the strip straight through the briefing card below it.
if (alignedTop !== null) {
const lane = resolveCyberCockpitPanelLane({
viewportHeight: window.innerHeight,
top: alignedTop,
launcherHeight: Math.max(
collapsedHeight,
collapsedLauncher?.getBoundingClientRect().height || 60,
),
signalHeight: this.signalCollapsed ? signalBounds.height : 44,
contactHeight:
this.contextCollapsed && this.context
? this.context.getBoundingClientRect().height
: 64,
});
document.body.style.setProperty(
'--cyber-cockpit-panel-height',
`${lane.panelHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-top',
`${alignedTop.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${lane.utilityHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height',
`${lane.panelHeight.toFixed(2)}px`,
);
utilityControls.classList.remove('layout-primary-only');
utilityControls
.querySelectorAll('.cockpit-utility-control')
.forEach((control) => control.setAttribute('aria-hidden', 'false'));
if (expandedControl) {
this.signalStream.style.setProperty(
'--cockpit-signal-top',
`${lane.signalTop.toFixed(2)}px`,
);
}
this.syncContextLayout();
return;
}
// Non-Cyber themes retain their independent REC/briefing corridor. Cyber
// uses the rendered Data Layers top after reserving briefing space above.
// The readout only anchors the strip while it is genuinely on screen:
// the Minimal variant drops it with `display:none`, but HUD Off hides the
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
@@ -169,8 +286,20 @@ export function syncSignalLayout() {
collapsedHeight,
recGap: COCKPIT_UTILITY_REC_GAP_PX,
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
minTopFloor: alignedTop ?? COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: alignedTop !== null ? 0 : COCKPIT_UTILITY_MIN_TOP_RATIO,
signalCollapsed: this.signalCollapsed,
utilityExpanded: Boolean(expandedControl),
// Only Cyber relocates its collapsed briefing below the utility lane.
// Other themes keep the measured visible card as the lower boundary.
reserveViewportLane: document.documentElement.dataset.uiTheme === 'cyber',
// Cyber keeps the collapsed briefing reachable below the utility instead
// of hiding it. Reserve its actual height, including wrapped headings.
signalHeight:
document.documentElement.dataset.uiTheme === 'cyber' &&
this.signalCollapsed
? signalBounds.height
: 0,
});
const availableHeight = utilityAnchor.maxHeight;
this.hud?.style.setProperty(
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
function fixture(theme = 'cyber') {
const nodes = new Map();
const node = (id, collapsed = true) => {
const classes = new Set(collapsed ? ['collapsed'] : []);
const value = { id, hidden: false, children: [],
matches: () => true,
contains(target) { return this === target || this.children.some((child) => child.contains(target)); },
classList: {
contains: (name) => classes.has(name),
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle: (name, on) => on ? classes.add(name) : classes.delete(name),
},
};
nodes.set(id, value);
return value;
};
const rail = node('rail');
const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel');
rail.children = [display, context, cctv];
for (const panel of rail.children) panel.parentElement = rail;
context.children = [radio];
radio.parentElement = context;
const saved = [], claimed = [];
const owner = {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
};
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner);
return { display, context, cctv, radio, owner, saved, claimed,
doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) },
};
}
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
f.cctv.classList.add('cyber-accordion-collapsed');
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
} finally { globalThis.document = prior; }
});
test('opening already-expanded Radio reveals Context and closes Display', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.radio.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
assert.equal(f.context.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), true);
} finally { globalThis.document = prior; }
});
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
const f = fixture(theme), prior = globalThis.document;
globalThis.document = f.doc;
try {
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
f.owner.setPanelCollapsed('pp-toggles', false, options);
f.owner.setPanelCollapsed('cctv-panel', false, options);
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.cctv.classList.contains('collapsed'), false);
if (theme === 'cyber') assert.deepEqual(f.saved, []);
} finally { globalThis.document = prior; }
}
});
+86
View File
@@ -0,0 +1,86 @@
import {
isCyberSonarEnabled,
isCyberSonarActive,
isCyberContactSonarActive,
readCyberSonarSettings,
} from '../cyberSonar.js';
const SETTING_FIELDS = Object.freeze({
rings: ['rings', 3, 12],
rangePct: ['range', 60, 120],
intensityPct: ['intensity', 0, 100],
opacityPct: ['opacity', 35, 100],
sectorDeg: ['sector', 8, 60],
});
/** Read configuration separately from whether map effects can currently run. */
export function getCyberSonarControlState(doc = globalThis.document) {
const root = doc?.documentElement;
const settings = readCyberSonarSettings(root);
const options = {
root,
body: doc?.body,
hud: doc?.getElementById?.('intel-hud') ?? null,
};
return {
enabled: isCyberSonarEnabled(root),
rings: settings.rings,
rangePct: settings.range,
intensityPct: settings.intensity,
opacityPct: settings.opacity,
sectorDeg: settings.sector,
cyberSelected: root?.dataset?.uiTheme === 'cyber',
mapSweepActive: isCyberSonarActive(options),
contactSweepActive:
root?.dataset?.cyberSonarGpu === 'supported' &&
isCyberContactSonarActive(options),
contactRenderer: root?.dataset?.cyberSonarGpu ?? 'not-initialized',
};
}
/** Validate every field before mutating through the same owner as the sliders. */
export function setCyberSonarControls(
owner,
request,
doc = globalThis.document,
) {
const fail = (error) => ({
ok: false,
error,
sonar: getCyberSonarControlState(doc),
});
if (!request || typeof request !== 'object' || Array.isArray(request)) {
return fail('Provide Cyber sonar settings as an object.');
}
const fields = Object.keys(request);
if (!fields.length) return fail('Provide at least one Cyber sonar setting.');
for (const field of fields) {
const value = request[field];
if (field === 'enabled') {
if (typeof value !== 'boolean') return fail('enabled must be a boolean.');
continue;
}
if (!Object.hasOwn(SETTING_FIELDS, field)) {
return fail('Unknown Cyber sonar setting.');
}
const [, min, max] = SETTING_FIELDS[field];
if (!Number.isInteger(value) || value < min || value > max) {
return fail(`${field} must be an integer from ${min} to ${max}.`);
}
}
if (
owner.hud?.getVariant?.() !== 'cyber' ||
doc?.documentElement?.dataset?.uiTheme !== 'cyber'
) {
return fail('Cyber sonar controls require the Cyber HUD layout.');
}
for (const [field, [setting]] of Object.entries(SETTING_FIELDS)) {
if (Object.hasOwn(request, field)) {
owner._setCyberSonarSetting(setting, request[field]);
}
}
if (Object.hasOwn(request, 'enabled')) {
owner._setCyberSonarEnabled(request.enabled);
}
return { ok: true, sonar: getCyberSonarControlState(doc) };
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getCyberSonarControlState,
setCyberSonarControls,
} from './cyberSonarControls.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { StyleManager } from './applicationShell.js';
import { VisualSettings } from './visualSettings.js';
import { readFileSync } from 'node:fs';
function fixture(theme = 'cyber') {
const root = {
dataset: { uiTheme: theme, cyberSonarGpu: 'supported' },
style: { setProperty() {} },
};
const flags = new Set();
const hudFlags = new Set(['active']);
const doc = {
documentElement: root,
body: { classList: { contains: (name) => flags.has(name) } },
getElementById: () => ({
classList: { contains: (name) => hudFlags.has(name) },
}),
};
const calls = [];
const owner = {
hud: { getVariant: () => theme },
_setCyberSonarSetting(name, value) {
calls.push([name, value]);
const suffix = name[0].toUpperCase() + name.slice(1);
root.dataset['cyberSonar' + suffix] = String(value);
},
_setCyberSonarEnabled(value) {
calls.push(['enabled', value]);
root.dataset.cyberSonar = value ? 'on' : 'off';
},
};
return { doc, owner, calls, flags, hudFlags, root };
}
test('real runner and facade use visual settings, preserve visibility and return actual values', async () => {
const f = fixture();
const previousDocument = globalThis.document;
globalThis.document = f.doc;
const renders = [];
const visual = Object.create(VisualSettings.prototype);
visual.services = { governorRequestRender: (reason) => renders.push(reason) };
const owner = Object.create(StyleManager.prototype);
owner._visualSettings = visual;
Object.defineProperty(owner, 'hud', { value: f.owner.hud });
const viewer = {
camera: { moveEnd: { addEventListener() {} } },
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: new EventTarget() },
};
try {
const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} });
const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 });
assert.equal(result.ok, true);
assert.equal(result.sonar.intensityPct, 73);
assert.equal(result.sonar.sectorDeg, 9);
assert.equal(result.sonar.enabled, false);
assert.equal(f.root.dataset.uiTheme, 'cyber');
assert.equal(f.hudFlags.has('active'), true);
assert.equal(renders.length, 3);
const before = getCyberSonarControlState();
const invalid = await runner('set_cyber_sonar', { rings: 6, opacityPct: 0 });
assert.equal(invalid.ok, false);
assert.deepEqual(getCyberSonarControlState(), before);
assert.equal(renders.length, 3);
const zero = await runner('set_cyber_sonar', { intensityPct: 0 });
assert.equal(zero.sonar.intensityPct, 0);
assert.equal(zero.sonar.enabled, false);
const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} });
assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false);
} finally {
globalThis.document = previousDocument;
}
});
test('native sonar sliders represent every accepted integer without browser snapping', () => {
const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8');
for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) {
assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`));
}
});
test('sonar action is available to both shared and server voice schemas', () => {
for (const tools of [GEV_ACTION_SCHEMAS, GEV_REALTIME_TOOLS]) {
const action = tools.find((tool) => tool.name === 'set_cyber_sonar');
assert.ok(action);
assert.equal(action.parameters.additionalProperties, false);
assert.deepEqual(Object.keys(action.parameters.properties), [
'enabled', 'rings', 'rangePct', 'intensityPct', 'opacityPct', 'sectorDeg',
]);
assert.equal(action.parameters.properties.intensityPct.minimum, 0);
assert.equal(action.parameters.properties.opacityPct.minimum, 35);
}
});
test('maps all settings to the slider owner and reads actual state, including zero', () => {
const f = fixture();
const result = setCyberSonarControls(f.owner, {
enabled: false, rings: 6, rangePct: 80,
intensityPct: 0, opacityPct: 90, sectorDeg: 30,
}, f.doc);
assert.equal(result.ok, true);
assert.deepEqual(f.calls, [
['rings', 6], ['range', 80], ['intensity', 0],
['opacity', 90], ['sector', 30], ['enabled', false],
]);
assert.equal(result.sonar.enabled, false);
assert.equal(result.sonar.intensityPct, 0);
assert.equal(result.sonar.contactSweepActive, false);
assert.deepEqual(result.sonar, getCyberSonarControlState(f.doc));
});
test('rejects the whole invalid request before any mutation', () => {
for (const request of [
{}, null, [], { enabled: 'false' }, { rings: 2 }, { rangePct: 121 },
{ opacityPct: NaN }, { intensityPct: Infinity }, { sectorDeg: 8.5 },
{ intensityPct: '50' }, { enabled: false, unknown: 1 },
{ rings: 6, sectorDeg: 61 },
]) {
const f = fixture();
assert.equal(setCyberSonarControls(f.owner, request, f.doc).ok, false);
assert.deepEqual(f.calls, []);
}
});
test('requires Cyber and preserves off state and every omitted setting', () => {
for (const theme of ['tactical', 'operator', 'minimal']) {
const f = fixture(theme);
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
assert.deepEqual(f.calls, []);
}
const f = fixture();
f.root.dataset.cyberSonar = 'off';
const before = getCyberSonarControlState(f.doc);
const result = setCyberSonarControls(f.owner, { rings: 12 }, f.doc);
assert.deepEqual(result.sonar, { ...before, rings: 12 });
assert.deepEqual(f.calls, [['rings', 12]]);
});
test('reports inactive map effects in Cockpit, hidden HUD, unsupported and uninitialized GPU', () => {
const f = fixture();
f.flags.add('cockpit-mode');
assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, false);
f.flags.clear();
f.hudFlags.clear();
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
f.hudFlags.add('active');
for (const value of ['unsupported', undefined]) {
if (value === undefined) delete f.root.dataset.cyberSonarGpu;
else f.root.dataset.cyberSonarGpu = value;
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
}
});
+573
View File
@@ -0,0 +1,573 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
applyHudUiTheme,
CYBER_VISUAL_DEFAULTS,
cyberVisualDefaultsForHudTransition,
HUD_LAYOUTS,
normalizeHudLayout,
} from '../hudLayouts.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
for (const file of ['layer-panels.html', 'context.html']) {
const markup = readFileSync(
new URL(`./templates/${file}`, import.meta.url),
'utf8',
);
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
assert.ok(
markup.indexOf('class="panel-header"') <
markup.indexOf('class="cyber-panel-body"'),
);
}
const shared = readFileSync(
new URL('./styles/controls.css', import.meta.url),
'utf8',
);
const cyber = readFileSync(
new URL('./styles/cyber.css', import.meta.url),
'utf8',
);
assert.match(shared, /\.cyber-panel-body\s*\{\s*display: contents;/);
assert.match(
cyber,
/:root\[data-ui-theme='cyber'\] \.cyber-panel-body\s*\{[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain;/,
);
});
function styleOwner(t, initialVariant = 'cyber') {
const previous = { document: globalThis.document, window: globalThis.window };
const root = { dataset: {} };
globalThis.document = { documentElement: root, querySelectorAll: () => [] };
globalThis.window = { dispatchEvent() {} };
t.after(() => Object.assign(globalThis, previous));
let variant = initialVariant;
const calls = [];
const hud = {
getVariant: () => variant,
setVariant: (value) => {
variant = value;
applyHudUiTheme(root, value);
},
setMode: (value) => calls.push(['mode', value]),
onStyleChange: (value) => calls.push(['style', value]),
};
hud.setVariant(initialVariant);
const owner = Object.create(VisualSettings.prototype);
Object.assign(owner, {
activeStyle: 'normal',
readHud: () => hud,
readShareLinks: () => ({ onStyleChange() {} }),
_visualEffects: {
stages: Object.fromEntries(
Object.entries(STYLES).map(([name, config]) => [
name,
{ uniforms: { ...config.uniforms, intensity: 0 } },
]),
),
},
services: { governorRequestRender() {}, setDetectionStyle() {} },
_hudLayoutSelect: { value: initialVariant },
_styleIndicator: {},
_setBloomEnabled: (value) => calls.push(['bloom', value]),
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
_applyDetectionPreset: (value) => calls.push(['detection', value]),
_syncShareState: () => calls.push(['share', variant]),
});
for (const name of [
'setCelestialRingEnabled',
'_startTransition',
'_updateStyleMiniStatus',
'_updateSliderPanel',
'_updateHudButtonState',
'_syncIrBoost',
'_syncCockpitInheritedStyle',
'_syncCyberSonarControl',
'_scheduleAdaptivePanelLayout',
'_applyDetectionFadeFromUi',
])
owner[name] = () => {};
return { owner, hud, root, calls };
}
test('visual styles retain Cyber through real preset application without dropping other defaults', (t) => {
const { owner, hud, root, calls } = styleOwner(t);
for (const style of [
'surveillance',
'normal',
'noir',
'anime',
'snow',
'thermal',
'retro',
]) {
owner.setStyle(style);
assert.equal(owner.activeStyle, style);
assert.equal(hud.getVariant(), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(owner._hudLayoutSelect.value, 'cyber');
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
}
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
assert.equal(calls.filter(([name]) => name === 'detection').length, 3);
assert.equal(
calls.filter(([name, value]) => name === 'bloom' && value === false).length,
3,
);
assert.equal(
calls.filter(([name, value]) => name === 'sharpen' && value === true)
.length,
3,
);
});
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
const { owner, hud, root } = styleOwner(t);
for (const variant of ['tactical', 'operator', 'minimal']) {
owner._setHudVariant(variant);
assert.equal(hud.getVariant(), variant);
assert.equal(root.dataset.uiTheme, undefined);
owner.setStyle('normal');
owner.setStyle('surveillance');
assert.equal(hud.getVariant(), 'tactical');
}
});
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' } });
assert.equal(hud.getVariant(), 'minimal');
owner._setHudVariant('cyber');
await owner.restoreShareState({
style: 'surveillance',
hudVariant: 'operator',
});
assert.equal(hud.getVariant(), 'operator');
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
styleParams: { palette: 0.73 },
});
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
owner._setHudVariant('tactical');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
});
const read = (relative) =>
readFileSync(new URL(relative, import.meta.url), 'utf8');
test('Cyber setup uses a compact toolbar icon and highlights the north-up letter', () => {
const css = read('./styles/cyber.css');
assert.match(
css,
/#top-center-actions #key-setup-chip\s*\{[^}]*inset: auto;[^}]*width: 36px;/,
);
assert.match(css, /#key-setup-chip\[hidden\]\s*\{\s*display: none;/);
assert.match(
css,
/#north-up-view\[data-north-up='true'\]\s+\.camera-compass-n\s*\{[^}]*color: var\(--cyber-red-bright\)/,
);
});
const cyberStyles = read('./styles/cyber.css');
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+:is\(\s*\.panel-collapse-btn,\s*body\.cockpit-mode \.cockpit-utility-glyph\s*\):focus-visible\s*\{[^}]*outline: 2px solid var\(--cyber-red-bright\) !important;[^}]*outline-offset: -3px;[^}]*box-shadow: none;/,
);
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\):is\(\s*:hover,\s*:focus-visible\s*\)\s*\{[^}]*border-color: var\(--cyber-red-bright\);/,
);
});
test('Cyber Radio retains upstream nested Context ownership', () => {
assert.doesNotMatch(cyberStyles, /#right-context-rail\s*>\s*#radio-panel/);
});
test('Cyber cockpit launchers share Data Layers frame and expand-control treatment', () => {
assert.match(
cyberStyles,
/:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\)/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode \.cockpit-utility-launcher \{[^}]*width: var\(--cyber-panel-width\);[^}]*min-height: 60px;[^}]*clip-path: var\(--cyber-panel-frame\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-glyph::after \{\s*content: '\+';/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-glyph\[aria-expanded='true'\]::after \{\s*content: '−';/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s+\.cockpit-utility-launcher \{[^}]*width: 100%;[^}]*clip-path: none;/,
);
});
test('Cyber side panels share one width and one framed surface material', () => {
assert.match(cyberStyles, /--cyber-panel-width: 272px;/);
assert.match(cyberStyles, /--cyber-right-panel-width: 312px;/);
assert.match(cyberStyles, /--cyber-panel-collapsed-width: 200px;/);
assert.match(cyberStyles, /--cyber-panel-control-size: 30px;/);
assert.match(
cyberStyles,
/--cyber-panel-surface: rgba\(18, 24, 27, 0\.92\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded \{[\s\S]*?width: var\(--cyber-right-panel-width\);[\s\S]*?background-color: var\(--cyber-panel-surface\);[\s\S]*?background-image: var\(--cyber-panel-surface-detail\);[\s\S]*?clip-path: var\(--cyber-panel-frame\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded::after \{[\s\S]*?width: 36px;[\s\S]*?border-top: 1px solid rgba\(225, 102, 102, 0\.84\);/,
);
assert.match(
cyberStyles,
/#global-context-panel #radio-panel\.collapsed \{\s*width: 100%;/,
);
assert.match(
cyberStyles,
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/,
);
assert.doesNotMatch(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
'expanded Cockpit utilities must retain Display-then-Radio DOM order',
);
});
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
assert.equal(normalizeHudLayout('unknown'), 'tactical');
});
test('the Cyber HUD owns the global shell skin and leaving it restores defaults', () => {
const root = { dataset: { unrelated: 'preserved' } };
assert.equal(applyHudUiTheme(root, 'cyber'), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(applyHudUiTheme(root, 'minimal'), null);
assert.equal(root.dataset.uiTheme, undefined);
assert.equal(root.dataset.unrelated, 'preserved');
});
test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () => {
assert.deepEqual(CYBER_VISUAL_DEFAULTS, {
style: 'thermal',
ironbow: 0.42,
});
assert.equal(
cyberVisualDefaultsForHudTransition('tactical', 'cyber', {
explicit: true,
}),
CYBER_VISUAL_DEFAULTS,
);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'cyber', {
explicit: true,
}),
null,
);
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
explicit: true,
}),
null,
);
});
test('Cyber visual defaults update the style, thermal palette and render state', () => {
const calls = [];
const owner = {
stages: { thermal: { uniforms: { palette: 0 } } },
services: {
governorRequestRender: (reason) => calls.push(['render', reason]),
},
setStyle: (style, options) => calls.push(['style', style, options]),
_updateSliderPanel: (style, options) =>
calls.push(['sliders', style, options]),
};
VisualSettings.prototype._applyCyberVisualDefaults.call(
owner,
CYBER_VISUAL_DEFAULTS,
);
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
assert.deepEqual(calls, [
['style', 'thermal', { applyPreset: false, revealParameters: false }],
['sliders', 'thermal', { reveal: false }],
['render', 'cyber-visual-defaults'],
]);
});
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
const stylesheet = read('../../style.css');
const hud = read('../hud.js');
const detection = read('../data/detection.js');
const civilRendering = read('../layers/flights/rendering.js');
const militaryRendering = read('../layers/military/rendering.js');
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
assert.match(display, /<option value="cyber">Cyber<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
/id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/,
);
assert.match(display, /id="cyber-sonar-sector"/);
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
assert.match(stylesheet, /@import '\.\/src\/ui\/styles\/cyber\.css';\s*$/);
assert.match(cyberStyles, /:root\[data-ui-theme='cyber'\]/);
assert.match(cyberStyles, /\.material-symbols-outlined/);
assert.match(cyberStyles, /\.pp-label/);
assert.match(cyberStyles, /#intel-hud\[data-variant='cyber'\]/);
assert.match(cyberStyles, /#left-panel-stack/);
assert.match(cyberStyles, /#right-context-rail/);
assert.match(cyberStyles, /#command-dock/);
assert.match(cyberStyles, /--cyber-panel-frame: polygon/);
assert.match(cyberStyles, /\.data-panel-inner,/);
assert.match(cyberStyles, /#pp-toggles\.collapsed > \.pp-header-row/);
assert.match(
cyberStyles,
/\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/,
);
assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/);
assert.match(hud, /class="hud-sonar" aria-hidden="true"/);
assert.match(cyberStyles, /\.hud-sonar::before/);
assert.match(cyberStyles, /\.hud-sonar::after/);
assert.match(cyberStyles, /@keyframes cyber-sonar-sweep/);
assert.match(
cyberStyles,
/animation-delay:\s*var\(--cyber-sonar-phase-delay, 0ms\)/,
);
assert.doesNotMatch(cyberStyles, /--cyber-sonar-angle/);
assert.match(cyberStyles, /prefers-reduced-motion: reduce/);
assert.match(detection, /candidate\.sonarFactor \?\? 1/);
assert.match(detection, /sonarActive && !obj\.skipLabel/);
assert.match(detection, /const bracketAlpha = admissionAlpha \* sonarFactor/);
assert.match(detection, /sonarSampler\.label\(sonarFactor\)/);
assert.match(detection, /createCyberSonarSampler\(width, height, now\)/);
assert.match(cyberStyles, /data-cyber-sonar='off'/);
assert.match(detection, /governorRequestRender\('cyber-sonar-cadence'\)/);
assert.match(
detection,
/const animatingCount = countAnimatingRenderEntries\(renderEntries\);/,
);
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
assert.match(
civilRendering,
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
);
assert.match(
militaryRendering,
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
);
assert.match(
cyberStyles,
/#title-bar \.title-logo #globe \{[\s\S]*?fill: var\(--cyber-red\);[\s\S]*?stroke: var\(--cyber-red-bright\);/,
);
assert.match(cyberStyles, /#title-bar \.title-logo #globe_cage/);
assert.match(
cyberStyles,
/#top-center-actions button\s*\{[\s\S]*?border-radius:\s*0;[\s\S]*?clip-path:\s*polygon\(\s*29% 0,\s*71% 0,[\s\S]*?0 29%\s*\);/,
);
assert.match(cyberStyles, /#top-center-actions button::before/);
assert.match(cyberStyles, /\.hud-top-left/);
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-bar \{\s*display: none;/,
);
// Cyber shares the measured rail/credit keep-out rather than overriding it.
assert.doesNotMatch(
cyberStyles,
/#right-context-rail\s*\{[^}]*\b(?:top|bottom|height|max-height):/,
);
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\)/,
);
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;/,
);
assert.match(
cyberStyles,
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
);
assert.match(
cyberStyles,
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/clip-path: polygon\([\s\S]*?12px 0,[\s\S]*?0 12px/,
);
assert.match(cyberStyles, /\.hud-corner::before/);
assert.match(
cyberStyles,
/--cyber-telemetry-fill: rgba\(13, 20, 23, 0\.985\);/,
);
assert.match(cyberStyles, /var\(--cyber-telemetry-fill\)/);
assert.match(cyberStyles, />\s*\.panel-header\s+\.panel-title::before/);
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
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.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/);
assert.match(cyberStyles, /VISUAL MATRIX \/\/ 03/);
assert.match(cyberStyles, /body\.cockpit-mode #cockpit-hud/);
assert.match(cyberStyles, /TARGET SCANNING \/\/ LIVE VECTOR/);
assert.match(
cyberStyles,
/body\.cockpit-mode #global-loading-status \{[\s\S]*?top: 60px;[\s\S]*?z-index: 146;/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode #global-loading-detail \{[\s\S]*?color: var\(--cockpit-accent\);/,
);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-visor-shell::before/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-scan-array/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-side-arcs/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-ticks/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-core/);
assert.match(cyberStyles, /width: min\(48vw, 60vh\);/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-context-window/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-signal-window/);
assert.match(cyberStyles, /body\.cockpit-mode #intel-hud\.active/);
});
test('Cyber voice telemetry renders a live scan scope without replacing audio ownership', () => {
assert.match(
cyberStyles,
/#gev-voice-control\s*\{[\s\S]*?--cyber-voice-wave:/,
);
assert.match(cyberStyles, /\.gev-voice-visualizer::before/);
assert.match(cyberStyles, /\.gev-voice-visualizer::after/);
assert.match(cyberStyles, /@keyframes gev-cyber-voice-scan/);
assert.match(cyberStyles, /@keyframes gev-cyber-voice-lock/);
assert.match(cyberStyles, /\[data-speaker='user'\]/);
assert.match(cyberStyles, /\[data-speaker='ai'\]/);
assert.match(
cyberStyles,
/prefers-reduced-motion: reduce[\s\S]*?gev-voice-visualizer::after[\s\S]*?animation: none/,
);
});
test('Cyber Cockpit Display uses the released briefing lane instead of the legacy viewport cap', () => {
assert.match(
cyberStyles,
/body\.cockpit-mode\s+#cockpit-display-panel\s*\{[\s\S]*?max-height:\s*calc\(var\(--cockpit-utility-expanded-max-height, 72vh\) - 62px\)/,
);
assert.match(
cyberStyles,
/#cockpit-hud:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?\.cockpit-signal-window\[data-collapsed='true'\][\s\S]*?bottom: 80px;[\s\S]*?visibility:\s*visible/,
);
});
test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
assert.match(
cyberStyles,
/body:not\(\.cockpit-mode\)\s+#left-panel-stack:has\([\s\S]*?max-height:\s*none;/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
);
});
test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers frame', () => {
const desktop = cyberStyles.slice(
cyberStyles.indexOf('@media (min-width: 961px) and (min-height: 650px)'),
cyberStyles.indexOf('@media (max-width: 720px)'),
);
assert.match(
desktop,
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 8px 18px 10px 24px;/,
);
assert.match(
desktop,
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher::after\s*\{\s*bottom: 6px;/,
);
});
test('Cyber right panels preserve intrinsic sizing and the shared rail allocation', () => {
assert.doesNotMatch(
cyberStyles.slice(cyberStyles.indexOf(":root[data-ui-theme='cyber'] {")),
/#right-context-rail\s*>[^{}]+\{[^{}]*\b(?:flex|height|max-height):/,
);
});
test('Cyber panels retain clean edges without the added footer band', () => {
assert.doesNotMatch(cyberStyles, /--cyber-panel-foot-/);
assert.doesNotMatch(cyberStyles, /border-bottom: 24px solid transparent/);
});
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
const template = readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
'utf8',
);
assert.match(template, /id="cyber-sonar-toggle"/);
assert.doesNotMatch(template, /data-sonar-mode|cyber-sonar-mode-row/);
assert.doesNotMatch(cyberStyles, /cyber-scene-dimmer|data-cyber-sonar-mode/);
});
test('Cyber Display uses a red title without a panel-specific center tab', () => {
assert.match(
cyberStyles,
/#pp-toggles \.pp-header-label\s*\{\s*color: var\(--cyber-red-bright\);/,
);
assert.doesNotMatch(cyberStyles, /#pp-toggles[^,{]*::after\s*\{/);
assert.match(cyberStyles, /isolation: isolate;\s*clip-path: none;/);
assert.match(cyberStyles, /#pp-toggles:not\(\.collapsed\)::before/);
});
test('Cyber omits only the fictional classification and system header lines', () => {
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-classification,\s*#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-system\s*\{\s*display: none;\s*\}/,
);
});
+15 -1
View File
@@ -104,6 +104,12 @@ export class DisplayBindings {
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
sonarButton: this._cyberSonarBtn,
sonarRingsSlider: this._cyberSonarRings,
sonarRangeSlider: this._cyberSonarRange,
sonarIntensitySlider: this._cyberSonarIntensity,
sonarOpacitySlider: this._cyberSonarOpacity,
sonarSectorSlider: this._cyberSonarSector,
cleanViewButton: this._cleanViewBtn,
cleanViewExitButton: this._cleanViewExitBtn,
densitySlider: this._detectionDensitySlider,
@@ -153,7 +159,7 @@ export class DisplayBindings {
},
setHudLayout: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(value);
this._setHudVariant(value, { applyVisualDefaults: true });
},
toggleCleanView: () => this.toggleCleanView(),
exitCleanView: () => this.toggleCleanView(false),
@@ -191,6 +197,14 @@ export class DisplayBindings {
this._updateHudButtonState();
this._syncShareState();
},
toggleSonar: () => this._setCyberSonarEnabled(),
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
setSonarIntensity: (value) =>
this._setCyberSonarSetting('intensity', value),
setSonarOpacity: (value) =>
this._setCyberSonarSetting('opacity', value),
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
+9
View File
@@ -21,6 +21,7 @@ export function bindDisplayControls({ elements, actions }) {
['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'],
['hudButton', 'toggleHud'],
['sonarButton', 'toggleSonar'],
['detectionButton', 'cycleDetection'],
['modelsButton', 'toggleModels'],
])
@@ -33,6 +34,14 @@ export function bindDisplayControls({ elements, actions }) {
listen(elements[name], 'input', action, integer);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [
['sonarRingsSlider', 'setSonarRings'],
['sonarRangeSlider', 'setSonarRange'],
['sonarIntensitySlider', 'setSonarIntensity'],
['sonarOpacitySlider', 'setSonarOpacity'],
['sonarSectorSlider', 'setSonarSector'],
])
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const el of elements.allocationButtons || [])
+58
View File
@@ -9,6 +9,19 @@ function element(value = '') {
return el;
}
test('sonar on/off control cannot fire after disposal', () => {
const sonarButton = element();
const calls = [];
const control = bindDisplayControls({
elements: { sonarButton },
actions: { toggleSonar: () => calls.push('toggle') },
});
sonarButton.dispatchEvent(new Event('click'));
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['toggle']);
});
test('controls read current values without preventing native input behavior', () => {
const bloomSlider = element('24');
const hudLayout = element('tactical');
@@ -54,6 +67,51 @@ test('destroyed and replaced controls cannot issue stale actions', () => {
assert.equal(newCalls, 1);
});
test('the Cyber sonar control is an optional owned display action', () => {
const sonarButton = element();
const sonarRingsSlider = element('9');
const sonarRangeSlider = element('110');
const sonarIntensitySlider = element('65');
const sonarOpacitySlider = element('84');
const sonarSectorSlider = element('32');
const calls = [];
const control = bindDisplayControls({
elements: {
sonarButton,
sonarRingsSlider,
sonarRangeSlider,
sonarIntensitySlider,
sonarOpacitySlider,
sonarSectorSlider,
},
actions: {
toggleSonar: () => calls.push('toggle'),
setSonarRings: (value) => calls.push(['rings', value]),
setSonarRange: (value) => calls.push(['range', value]),
setSonarIntensity: (value) => calls.push(['intensity', value]),
setSonarOpacity: (value) => calls.push(['opacity', value]),
setSonarSector: (value) => calls.push(['sector', value]),
},
});
sonarButton.dispatchEvent(new Event('click'));
sonarRingsSlider.dispatchEvent(new Event('input'));
sonarRangeSlider.dispatchEvent(new Event('input'));
sonarIntensitySlider.dispatchEvent(new Event('input'));
sonarOpacitySlider.dispatchEvent(new Event('input'));
sonarSectorSlider.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
'toggle',
['rings', 9],
['range', 110],
['intensity', 65],
['opacity', 84],
['sector', 32],
]);
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.equal(calls.length, 6);
});
test('style, allocation and model choices retain their current data attributes', () => {
const style = element();
const allocation = element();
+6
View File
@@ -0,0 +1,6 @@
/** Cyber keeps the frame fixed; other layouts retain their existing scroller. */
export function displayPanelScroller(panel) {
return panel?.ownerDocument?.documentElement?.dataset.uiTheme === 'cyber'
? panel.querySelector('.pp-panel-body') || panel
: panel;
}
+1
View File
@@ -222,6 +222,7 @@ export class LayerPanel {
left.className = 'data-toggle-left';
const icon = document.createElement('span');
icon.className = 'data-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = layer.icon;
const name = document.createElement('span');
name.className = 'data-name';
+55 -8
View File
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
import {
@@ -71,7 +72,7 @@ export class PanelChrome {
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
: this._ppToggles?.scrollTop || 0,
: displayPanelScroller(this._ppToggles)?.scrollTop || 0,
});
}
get hud() {
@@ -345,9 +346,11 @@ export class PanelChrome {
if (!panelEl) continue;
// Responsive auto-collapse is presentation only; the recipient should
// restore the user's explicit expanded preference at its own viewport.
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
? false
: panelEl.classList.contains('collapsed');
const collapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('cyber-accordion-collapsed')
? false
: panelEl.classList.contains('collapsed');
const entry = { id: spec.id, collapsed };
if (spec.pinnable)
entry.pinned = panelEl.classList.contains('dock-pinned');
@@ -397,9 +400,32 @@ export class PanelChrome {
if (explicit && !restore)
this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
const nextCollapsed = Boolean(collapsed);
const wasAutoCollapsed = panelEl.classList.contains(
'layout-auto-collapsed',
);
const wasAutoCollapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack
) {
for (const peer of this._rightPanelStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
!peer.hidden
) {
this.setPanelCollapsed(peer.id, true, {
explicit,
persist,
syncShare: false,
});
}
}
}
const leftOwnerPanel = this._leftPanelStack?.contains(panelEl)
? panelEl
: null;
@@ -431,6 +457,23 @@ export class PanelChrome {
) {
this._panelLayout._rightStackPreferredPanelId = null;
}
// Reveal Radio's owner before the no-op check: the nested section may
// already be expanded while its Context parent is closed.
if (
document.documentElement?.dataset.uiTheme === 'cyber' &&
!nextCollapsed &&
panelId === 'radio-panel' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
) {
this.setPanelCollapsed('global-context-panel', false, {
explicit,
restore,
persist,
syncShare,
});
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
@@ -444,7 +487,10 @@ export class PanelChrome {
}
return;
}
panelEl.classList.remove('layout-auto-collapsed');
panelEl.classList.remove(
'layout-auto-collapsed',
'cyber-accordion-collapsed',
);
if (
!nextCollapsed &&
this.cockpitView?.active &&
@@ -466,6 +512,7 @@ export class PanelChrome {
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
document.documentElement?.dataset.uiTheme !== 'cyber' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
+3
View File
@@ -1,5 +1,6 @@
/** Own rail measurement, layout scheduling and dock tray observation. */
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
const COCKPIT_LAYOUT_SETTLE_MS = 240;
/**
* Fixed UI regions that can occupy the left accordion's vertical lane.
@@ -229,6 +230,7 @@ export class PanelLayoutController {
this._weatherPanel,
this._recentImageryPanel,
globalContextPanel,
document.getElementById('radio-panel'),
]) {
if (panel) this._rightStackResizeObserver.observe(panel);
}
@@ -301,6 +303,7 @@ export class PanelLayoutController {
_syncRightPanelAdaptiveLayout() {
if (this.destroyed) return;
syncRadioPanelPlacement(document);
layoutRightPanelRail({
stack: this._rightPanelStack,
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
+53
View File
@@ -1,4 +1,5 @@
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { displayPanelScroller } from './displayPanelScroll.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -7,6 +8,58 @@ import {
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 } = {},
+14 -4
View File
@@ -59,7 +59,10 @@ export class RadioControls {
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (
@@ -113,7 +116,10 @@ export class RadioControls {
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
@@ -154,10 +160,12 @@ export class RadioControls {
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
/** Keep the launcher truthful for the active theme and Context route. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextPanel = globalThis.document?.getElementById(
'global-context-panel',
);
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
@@ -177,6 +185,8 @@ export class RadioControls {
this._contextRadioToggleBtn.title = label;
return;
}
// This launcher toggles the compact player even when the detailed panel
// is open elsewhere. Its ARIA state must describe that same disclosure.
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
+17
View File
@@ -0,0 +1,17 @@
/** Keep Radio in Context in every theme, reusing the same playback controls. */
export function syncRadioPanelPlacement(doc) {
if (!doc?.getElementById) return;
const panel = doc.getElementById('radio-panel');
const context = doc.getElementById('global-context-panel');
const dock = doc.getElementById('context-radio-dock');
const mini = doc.getElementById('context-radio-mini');
const parent =
context?.querySelector('.cyber-panel-body') ||
context?.querySelector('.global-context-panel-inner');
const header = context?.querySelector('.panel-header');
if (!panel || !parent || !header || !dock || !mini) return;
if (panel.parentElement !== parent) parent.append(panel);
if (dock.parentElement !== header)
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
if (mini.parentElement !== dock) dock.append(mini);
}
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
function node() {
return {
children: [],
selectors: {},
parentElement: null,
moves: 0,
querySelector(selector) {
return this.selectors[selector] || null;
},
append(child) {
this.insertBefore(child, null);
},
insertBefore(child, before) {
if (child.parentElement) {
const siblings = child.parentElement.children;
siblings.splice(siblings.indexOf(child), 1);
}
const index = before
? this.children.indexOf(before)
: this.children.length;
this.children.splice(index, 0, child);
child.parentElement = this;
child.moves++;
},
};
}
test('all themes keep the same Radio controls nested in Context', () => {
const panel = node(),
context = node(),
rail = node(),
dock = node(),
mini = node();
const inner = node(),
contextHeader = node(),
radioHeader = node();
const contextCollapse = node(),
radioCollapse = node();
context.selectors['.global-context-panel-inner'] = inner;
const body = node();
context.selectors['.cyber-panel-body'] = body;
context.selectors['.panel-header'] = contextHeader;
panel.selectors['.panel-header'] = radioHeader;
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
inner.append(panel);
contextHeader.append(dock);
contextHeader.append(contextCollapse);
radioHeader.append(radioCollapse);
dock.append(mini);
const nodes = {
'radio-panel': panel,
'global-context-panel': context,
'right-context-rail': rail,
'context-radio-dock': dock,
'context-radio-mini': mini,
};
const doc = {
documentElement: { dataset: {} },
getElementById: (id) => nodes[id],
};
for (let cycle = 0; cycle < 3; cycle++) {
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
const moves = [panel.moves, dock.moves, mini.moves];
syncRadioPanelPlacement(doc);
assert.deepEqual(
[panel.moves, dock.moves, mini.moves],
moves,
'no mutation feedback loop',
);
delete doc.documentElement.dataset.uiTheme;
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
}
// Recover a hierarchy left by the previous Cyber implementation without
// recreating the player, changing playback, or mutating collapse state.
rail.append(panel);
radioHeader.insertBefore(dock, radioCollapse);
panel.append(mini);
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
});
test('placement is inert before the complete shell exists', () => {
assert.doesNotThrow(() => syncRadioPanelPlacement(null));
assert.doesNotThrow(() =>
syncRadioPanelPlacement({
documentElement: { dataset: { uiTheme: 'cyber' } },
getElementById: () => null,
}),
);
});
+27 -2
View File
@@ -7,6 +7,8 @@ import {
shouldHideCollapsedRightPanels,
} from './panelRailGeometry.js';
import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet();
/**
@@ -56,6 +58,28 @@ export function layoutRightPanelRail({
onCollapse(panel);
}
}
// Normalize restored state and theme entry without overwriting saved panel
// preferences. This marker is distinct from Tactical's space-based collapse.
if (hud.variant === 'cyber') {
const expanded = panels.filter(
(panel) => !panel.classList.contains('collapsed'),
);
const owner =
expanded.find((panel) => panel.id === preferredPanelId) ||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
onCollapse(panel);
}
} else {
for (const panel of panels) {
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
onCollapse(panel);
}
}
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some(
(panel) =>
@@ -276,10 +300,11 @@ export function layoutRightPanelRail({
stack.dataset.expandedCount = String(expandedPanels.length);
if (displayPanel && expandedPanels.includes(displayPanel)) {
const scroller = displayPanelScroller(displayPanel);
const maxScrollTop = Math.max(
0,
displayPanel.scrollHeight - displayPanel.clientHeight,
scroller.scrollHeight - scroller.clientHeight,
);
displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
scroller.scrollTop = Math.min(displayScrollTop, maxScrollTop);
}
}
+15
View File
@@ -17,6 +17,21 @@ export function readShellElements(document = globalThis.document) {
_hudBtn: document.getElementById('hud-toggle'),
_hudLayoutRow: document.getElementById('hud-layout-row'),
_hudLayoutSelect: document.getElementById('hud-layout-select'),
_cyberSonarBtn: document.getElementById('cyber-sonar-toggle'),
_cyberSonarRings: document.getElementById('cyber-sonar-rings'),
_cyberSonarRingsValue: document.getElementById('cyber-sonar-rings-value'),
_cyberSonarRange: document.getElementById('cyber-sonar-range'),
_cyberSonarRangeValue: document.getElementById('cyber-sonar-range-value'),
_cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'),
_cyberSonarIntensityValue: document.getElementById(
'cyber-sonar-intensity-value',
),
_cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'),
_cyberSonarOpacityValue: document.getElementById(
'cyber-sonar-opacity-value',
),
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
_cyberSonarSectorValue: document.getElementById('cyber-sonar-sector-value'),
_detectionSliderRow: document.getElementById('detection-slider-row'),
_detectionDensitySlider: document.getElementById(
'detection-density-slider',
+8
View File
@@ -762,6 +762,14 @@ export class ShellFacade {
return this._visualSettings._setHudVariant(...arguments);
}
_setCyberSonarEnabled(enabled) {
return this._visualSettings._setCyberSonarEnabled(...arguments);
}
_setCyberSonarSetting(name, value) {
return this._visualSettings._setCyberSonarSetting(...arguments);
}
/**
* Keeps both responsive panel lanes and Cockpit's utility strip on the same
* measured layout commit. HUD visibility transitions can outlive the first
+34 -3
View File
@@ -362,11 +362,20 @@
}
#pp-toggles:not(.collapsed) > .pp-header-row,
#pp-toggles:not(.collapsed) > .pp-toggle-group,
#pp-toggles:not(.collapsed) > #clean-view-toggle {
#pp-toggles:not(.collapsed) > .pp-panel-body > .pp-toggle-group,
#pp-toggles:not(.collapsed) > .pp-panel-body > #clean-view-toggle {
width: 100%;
}
.pp-panel-body,
.cyber-panel-body {
display: contents;
}
.panel-collapsible.collapsed > .global-context-panel-inner > .cyber-panel-body {
display: none;
}
.pp-toggle-btn {
display: flex;
align-items: center;
@@ -683,6 +692,28 @@
min-width: 200px;
}
.cyber-sonar-control {
display: none;
}
:root[data-ui-theme='cyber'] .cyber-sonar-control {
display: flex;
}
.cyber-sonar-toggle-row,
.cyber-sonar-setting {
min-width: 200px;
}
.cyber-sonar-toggle-row .pp-mode-btn {
margin-left: auto;
min-width: 48px;
}
:root[data-cyber-sonar='off'] .cyber-sonar-setting {
display: none;
}
/* Map Source tiles live with Visual Presets in the bottom command tray. */
.map-source-section {
display: grid;
@@ -1055,7 +1086,7 @@ body.ui-clean-view #scene-runtime.active {
/* Display owns the constrained desktop scroll surface. Keeping Parameters as
* a second scroll container traps wheel/touch input at its boundary and lets
* the flex column compress newly added preset rows below the visible panel. */
#pp-toggles:not(.collapsed) > #param-slider-panel.active {
#pp-toggles:not(.collapsed) > .pp-panel-body > #param-slider-panel.active {
flex: 0 0 auto;
max-height: none;
overflow-y: visible;
File diff suppressed because it is too large. Load diff
+44
View File
@@ -1,5 +1,49 @@
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor">
<path d="M42 106H234L266 126H334 M866 126H934L966 106H1158 M42 114H226L258 136H318 M882 136H942L974 114H1158"/>
<path d="M42 616H226L254 598H320 M880 598H946L974 616H1158 M42 624H238L266 606H330 M870 606H934L962 624H1158"/>
<path d="M322 650H878L858 622H728L708 634H492L472 622H342Z M348 659H852"/>
<path d="M72 160H144L158 174V186 M1056 160H1128L1142 174V186 M72 562H144L158 548V536 M1056 562H1128L1142 548V536"/>
</g>
<path class="cockpit-frame-warning" d="M338 645H476L490 654H710L724 645H862" fill="none"/>
<path class="cockpit-frame-meter" d="M372 642H418 M782 642H828"/>
</svg>
<div class="cockpit-scan-array" aria-hidden="true">
<svg class="cockpit-target-geometry" viewBox="0 0 600 600" fill="none" focusable="false">
<g class="cockpit-target-hairline">
<circle cx="300" cy="300" r="276"/>
<circle class="cockpit-target-drift" cx="300" cy="300" r="250" stroke-dasharray="1 13"/>
<circle cx="300" cy="300" r="164"/>
<path d="M190 237V363 M410 237V363 M99 227H139 M461 227H501 M99 373H139 M461 373H501"/>
<path d="M86 113H118L134 129 M514 113H482L466 129 M86 487H118L134 471 M514 487H482L466 471"/>
</g>
<g class="cockpit-target-side-arcs">
<path d="M70 167A266 266 0 0 0 70 433 M530 167A266 266 0 0 1 530 433"/>
</g>
<g class="cockpit-target-ticks">
<path d="M166 125A220 220 0 0 1 434 125 M166 475A220 220 0 0 0 434 475"/>
</g>
<g class="cockpit-target-dots">
<path d="M180 130A208 208 0 0 1 420 130 M180 470A208 208 0 0 0 420 470"/>
</g>
<g class="cockpit-target-warning-dots">
<path d="M172 112A227 227 0 0 1 428 112 M172 488A227 227 0 0 0 428 488"/>
</g>
<g class="cockpit-target-core">
<!-- One outer ring and a spaced rotor leave the aiming center open. -->
<circle class="cockpit-target-orbit" cx="300" cy="300" r="132" stroke-dasharray="186 21"/>
<circle class="cockpit-target-rotor" cx="300" cy="300" r="104" stroke-dasharray="128 35" transform="rotate(9 300 300)"/>
<path class="cockpit-target-spokes" d="M206 206L250 250 M350 350L394 394 M394 206L350 250 M250 350L206 394"/>
<path d="M168 291H180V309H168 M432 291H420V309H432"/>
<circle cx="206" cy="206" r="3"/><circle cx="394" cy="206" r="3"/>
<circle cx="206" cy="394" r="3"/><circle cx="394" cy="394" r="3"/>
</g>
</svg>
</div>
<div class="cockpit-visor-shell" aria-hidden="true">
<div class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
+2
View File
@@ -62,6 +62,7 @@
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode">
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
@@ -237,6 +238,7 @@
</div>
</div>
</section>
</div>
</div>
</div>
</aside>
+33
View File
@@ -5,6 +5,7 @@
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">&#x25C0;</button>
</div>
<div class="pp-panel-body" data-rail-scroller>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
<span class="pp-icon">&#x25CE;</span>
@@ -16,8 +17,39 @@
<option value="tactical">Tactical</option>
<option value="operator">Operator</option>
<option value="minimal">Minimal</option>
<option value="cyber">Cyber</option>
</select>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label">Sonar</span>
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true">ON</button>
</div>
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings">Rings</label>
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range">Range</label>
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">Power</label>
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">Opacity</label>
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector">Sector</label>
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
@@ -146,4 +178,5 @@
</div>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
+2
View File
@@ -21,6 +21,7 @@
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
@@ -66,6 +67,7 @@
</div>
<div class="cctv-summary-label">SCENE SUMMARY</div>
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
</div>
+84 -7
View File
@@ -32,6 +32,13 @@ import {
shareCacheNeedsHeal,
shareableDetectionState,
} from '../contactsDetectionPolicy.js';
import {
applyCyberSonarSettings,
isCyberSonarEnabled,
readCyberSonarSettings,
setCyberSonarEnabled,
} from '../cyberSonar.js';
import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js';
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
/** Own visual preferences, detection overrides and display-control state. */
@@ -602,19 +609,87 @@ export class VisualSettings {
this._syncShareState();
}
_setHudVariant(variantName) {
_setHudVariant(variantName, { applyVisualDefaults = false } = {}) {
if (!variantName) return;
const previousVariant = this.hud.getVariant();
this.hud.setVariant(variantName);
if (
this._hudLayoutSelect &&
this._hudLayoutSelect.value !== this.hud.getVariant()
) {
this._hudLayoutSelect.value = this.hud.getVariant();
const nextVariant = this.hud.getVariant();
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
this._hudLayoutSelect.value = nextVariant;
}
const visualDefaults = cyberVisualDefaultsForHudTransition(
previousVariant,
nextVariant,
{ explicit: applyVisualDefaults },
);
if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults);
this._syncCyberSonarControl();
this._syncShareState();
this._scheduleAdaptivePanelLayout({ settle: true });
}
_applyCyberVisualDefaults({ style, ironbow }) {
this.setStyle(style, {
applyPreset: false,
revealParameters: false,
});
const thermalUniforms = this.stages?.thermal?.uniforms;
if (!thermalUniforms || thermalUniforms.palette === undefined) return;
thermalUniforms.palette = ironbow;
this._updateSliderPanel('thermal', { reveal: false });
this.services.governorRequestRender('cyber-visual-defaults');
}
_syncCyberSonarControl() {
if (!this._cyberSonarBtn) return;
const enabled = isCyberSonarEnabled();
const settings = applyCyberSonarSettings(readCyberSonarSettings());
this._cyberSonarBtn.classList.toggle('active', enabled);
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
this._cyberSonarBtn.textContent = enabled ? 'ON' : 'OFF';
for (const [input, output, value, suffix] of [
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
[
this._cyberSonarIntensity,
this._cyberSonarIntensityValue,
settings.intensity,
'%',
],
[
this._cyberSonarOpacity,
this._cyberSonarOpacityValue,
settings.opacity,
'%',
],
[
this._cyberSonarSector,
this._cyberSonarSectorValue,
settings.sector,
'°',
],
]) {
if (input) input.value = String(value);
if (output) output.textContent = `${value}${suffix}`;
}
}
_setCyberSonarEnabled(enabled = !isCyberSonarEnabled()) {
const next = setCyberSonarEnabled(!!enabled);
this._syncCyberSonarControl();
this.services.governorRequestRender('cyber-sonar-toggle');
return next;
}
_setCyberSonarSetting(name, value) {
const settings = readCyberSonarSettings();
if (!Object.hasOwn(settings, name)) return settings;
const next = applyCyberSonarSettings({ ...settings, [name]: value });
this._syncCyberSonarControl();
this.services.governorRequestRender(`cyber-sonar-${name}`);
return next;
}
_applyStylePresetDefaults(styleName) {
const { governorRequestRender } = this.services;
const preset = STYLE_PRESET_DEFAULTS[styleName];
@@ -656,7 +731,9 @@ export class VisualSettings {
this._setSharpenEnabled(sharpenInput.enabled);
}
if (preset.hudVariant) {
// Cyber is an explicit shell choice, independent of the imagery filter.
// Scene/share restoration still applies its own HUD through _setHudVariant.
if (preset.hudVariant && this.hud.getVariant() !== 'cyber') {
this._setHudVariant(preset.hudVariant);
}
if (typeof preset.hudVisible === 'boolean') {