fix(ui): preserve Cyber and Cockpit visual state

This commit is contained in:
Manjunath committed 2026-10-01 17:00:10 +05:30
1 parent e03bf008f3
commit 1dbc8a4452
16 files changed
+361 -97

No files matched your search

+2
View File
@@ -1359,6 +1359,8 @@ export class StyleManager extends ShellFacade {
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
* @param {boolean} [options.preserveCyberRestore=false] - Whether an automatic style update keeps the Cyber entry snapshot.
* @returns {void}
*/
setStyle(
+5 -5
View File
@@ -52,11 +52,11 @@ export class CockpitViewController {
{
services,
onVisionChange = null,
getInitialVisionMode = null,
onCameraTakeover = null,
isEntryAllowed = null,
onEntered = null,
onExited = null,
getInheritedVisionLabel = null,
restoreTrackingFrame = null,
} = {},
) {
@@ -121,13 +121,13 @@ export class CockpitViewController {
this.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical';
this.onVisionChange = onVisionChange;
this.getInitialVisionMode =
typeof getInitialVisionMode === 'function'
? getInitialVisionMode
: () => 'optical';
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
this.getInheritedVisionLabel =
typeof getInheritedVisionLabel === 'function'
? getInheritedVisionLabel
: () => 'NORMAL';
this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame
+2 -4
View File
@@ -1,8 +1,8 @@
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
/** Own Cockpit entry/rollback, readouts and the single Display portal. */
export class CockpitCoordinator {
@@ -72,11 +72,9 @@ export class CockpitCoordinator {
},
onVisionChange: (mode, active, options) =>
this._setCockpitVision(mode, active, options),
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
onCameraTakeover: () =>
this._stampNavigation({ cancelPendingSelection: false }),
getInheritedVisionLabel: () =>
STYLE_STATUS_LABELS[this.activeStyle] ||
String(this.activeStyle || 'normal').toUpperCase(),
isEntryAllowed: () =>
cockpitEntryAllowed({
contextMode: this._contextMode,
+6 -5
View File
@@ -217,22 +217,23 @@ export function syncWeatherToggle(enabled) {
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
const inherited = String(
this.getInheritedVisionLabel?.() || 'NORMAL',
).toUpperCase();
const labels = {
optical: inherited,
optical: 'NORMAL',
crt: 'CRT',
nvg: 'NVG',
thermal: 'FLIR',
anime: 'ANIME',
noir: 'NOIR',
snow: 'SNOW',
};
const names = {
optical: inherited,
optical: 'Normal',
crt: 'CRT',
nvg: 'Night vision',
thermal: 'Thermal',
anime: 'Anime',
noir: 'Noir',
snow: 'Snow',
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
+1 -1
View File
@@ -194,7 +194,7 @@ export function enter() {
`${trackLabel} · COURSE ${trackHeading}°`,
);
this.updateHud(info, performance.now(), true);
this.setVisionMode(this.visionMode);
this.setVisionMode(this.getInitialVisionMode());
this.scheduleContextLayout();
this.mapViewButton?.focus({ preventScroll: true });
this.onEntered?.();
+95 -12
View File
@@ -140,23 +140,74 @@ 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) => {
test('leaving Cyber without a preset choice 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');
assert.equal(owner._cyberStyleUserSelected, false);
// 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);
assert.equal(owner._cyberStyleUserSelected, false);
});
test('a preset explicitly selected in Cyber survives exit and resets for re-entry', (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, true);
// Reselecting the active mode must not erase the user's preset intent.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
// A later untouched Cyber round trip restores the new pre-Cyber preset.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'snow');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
});
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('thermal', { userInitiated: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
});
test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('cyber');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
});
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
@@ -174,17 +225,25 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const snowButton = new EventTarget();
snowButton.dataset = { style: 'snow' };
const controls = bindDisplayControls({
elements: { hudLayout },
actions: { setHudLayout: (value) => display.owner.setHudLayout(value) },
elements: { hudLayout, styleButtons: [snowButton] },
actions: {
setHudLayout: (value) => display.owner.setHudLayout(value),
setStyle: (value) =>
display.owner.setStyle(value, { userInitiated: true }),
},
});
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
snowButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'snow');
hudLayout.value = 'operator';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
assert.equal(display.owner.activeStyle, 'noir');
assert.equal(display.owner.activeStyle, 'snow');
controls.destroy();
const voice = makeOwner();
@@ -203,27 +262,34 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(entered.ok, true);
assert.equal(voice.hud.getVariant(), 'cyber');
assert.equal(voice.owner.activeStyle, 'thermal');
const selected = await runVoiceAction('set_visual_style', { style: 'snow' });
assert.equal(selected.ok, true);
assert.equal(voice.owner.activeStyle, 'snow');
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');
assert.equal(voice.owner.activeStyle, 'snow');
});
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 });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'retro');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'retro');
owner.setStyle('surveillance');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'surveillance');
owner.setStyle('snow', { userInitiated: true });
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
@@ -231,6 +297,7 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset',
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('tactical', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, null);
@@ -451,6 +518,14 @@ test('Cyber side panels share one width and one framed surface material', () =>
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.match(
cyberStyles,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
);
assert.match(
cyberStyles,
/#right-context-rail[\s\S]*?:is\(\s*#cctv-panel,\s*#weather-panel,\s*#recent-imagery-panel,\s*#global-context-panel\s*\)\.collapsed[\s\S]*?> \.panel-header \{[\s\S]*?height: 48px;[\s\S]*?padding: 0 10px 0 14px;/,
);
assert.doesNotMatch(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
@@ -518,7 +593,15 @@ test('Cyber visual defaults update the style, thermal palette and render state',
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
assert.deepEqual(calls, [
['style', 'thermal', { applyPreset: false, revealParameters: false }],
[
'style',
'thermal',
{
applyPreset: false,
revealParameters: false,
preserveCyberRestore: true,
},
],
['sliders', 'thermal', { reveal: false }],
['render', 'cyber-visual-defaults'],
]);
@@ -726,7 +809,7 @@ test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers
);
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;/,
/: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: 9px 18px 9px 24px;/,
);
assert.match(
desktop,
+2 -2
View File
@@ -64,7 +64,7 @@ export class DisplayBindings {
documentRef: document,
searchInput: this._locationSearch,
actions: {
setStyle: (style) => this.setStyle(style),
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
dismissSearch: () => {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
@@ -121,7 +121,7 @@ export class DisplayBindings {
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
},
actions: {
setStyle: (style) => this.setStyle(style),
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
toggleBloom: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setBloomEnabled(!this.bloomEnabled);
+1 -1
View File
@@ -655,7 +655,7 @@ export class ShellFacade {
return this._visualSettings._syncIrBoost(...arguments);
}
/** Keep Cockpit's inherited label and restore target aligned with the active map preset. */
/** Keep Cockpit's exit restore target aligned with the active map preset. */
_syncCockpitInheritedStyle() {
return this._visualSettings._syncCockpitInheritedStyle(...arguments);
}
+60 -1
View File
@@ -229,6 +229,65 @@
border-bottom: 1px solid rgba(206, 222, 226, 0.08);
}
/* The right-rail surfaces use different content gutters, but their disclosure
controls should still read as one vertical column. Eight pixels here plus
the shared two-pixel header inset matches Display's eleven-pixel framed
edge inset once the surface border is included. */
:root[data-ui-theme='cyber']
#right-context-rail
:is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner
) {
padding-right: 8px;
}
/* Collapsed right-rail panels otherwise inherit different legacy gutters:
CCTV lands five pixels low while Context lands four pixels high. Give every
compact launcher the same centered 48px content row inside its 1px frame. */
:root[data-ui-theme='cyber']
#right-context-rail
:is(
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#global-context-panel
).collapsed
> :is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner
) {
padding: 0;
}
:root[data-ui-theme='cyber']
#right-context-rail
:is(
#cctv-panel,
#weather-panel,
#recent-imagery-panel,
#global-context-panel
).collapsed
> :is(
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner
)
> .panel-header {
box-sizing: border-box;
width: 100%;
height: 48px;
min-height: 48px;
margin: 0;
padding: 0 10px 0 14px;
border-bottom: 0;
}
:root[data-ui-theme='cyber']
:is(
.data-panel-inner,
@@ -1623,7 +1682,7 @@
.cockpit-utility-launcher {
height: 50px;
min-height: 50px;
padding: 8px 18px 10px 24px;
padding: 9px 18px 9px 24px;
}
:root[data-ui-theme='cyber']
+33 -15
View File
@@ -197,6 +197,7 @@ export class VisualSettings {
});
this.activeStyle = 'normal';
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
document.documentElement.dataset.gevStyle = this.activeStyle;
this._detectionUserOverridden = false;
this._cockpitVisionMode = 'optical';
@@ -342,23 +343,15 @@ export class VisualSettings {
);
}
if (next === 'optical') {
applyCockpitVisionStageIntensities(
this.stages,
next,
this._cockpitVisionRestore,
);
applyCockpitVisionStageIntensities(this.stages, next);
this._syncStagesEnabledFromIntensity();
this._cockpitVisionMode = next;
this._syncIrBoost();
this._updateSliderPanel(this.activeStyle, { reveal: false });
this._updateSliderPanel(null, { reveal: false });
this._revealCockpitStyleParameters({ openDisplay: revealParameters });
return;
}
const target = applyCockpitVisionStageIntensities(
this.stages,
next,
this._cockpitVisionRestore,
);
const target = applyCockpitVisionStageIntensities(this.stages, next);
this._syncStagesEnabledFromIntensity();
this._cockpitVisionMode = next;
this._syncIrBoost(); // Cockpit vision override ('nvg'/'thermal' boost; CRT/NOIR clear)
@@ -370,8 +363,11 @@ export class VisualSettings {
const cockpitMode = this.cockpitView?.active
? this._cockpitVisionMode
: null;
const effective =
cockpitMode && cockpitMode !== 'optical' ? cockpitMode : this.activeStyle;
const effective = cockpitMode
? cockpitMode === 'optical'
? 'normal'
: cockpitMode
: this.activeStyle;
const irBoost =
effective === 'surveillance' ||
effective === 'thermal' ||
@@ -385,7 +381,7 @@ export class VisualSettings {
new CustomEvent('gev:vision-change', {
detail: {
style: effective,
cockpit: Boolean(cockpitMode && cockpitMode !== 'optical'),
cockpit: Boolean(cockpitMode),
},
}),
);
@@ -622,8 +618,10 @@ export class VisualSettings {
// the current Cyber variant. Do not let an older explicit entry restore
// stale local preset memory on the next user-selected exit.
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
} else if (enteredCyber) {
this._preCyberStyle = this.activeStyle;
this._cyberStyleUserSelected = false;
}
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
this._hudLayoutSelect.value = nextVariant;
@@ -635,12 +633,17 @@ export class VisualSettings {
);
if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults);
if (leftCyber) {
const restoreStyle = applyVisualDefaults ? this._preCyberStyle : null;
const restoreStyle =
applyVisualDefaults && !this._cyberStyleUserSelected
? this._preCyberStyle
: null;
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
if (restoreStyle && restoreStyle !== this.activeStyle) {
this.setStyle(restoreStyle, {
applyPreset: false,
revealParameters: false,
preserveCyberRestore: true,
});
}
}
@@ -653,6 +656,7 @@ export class VisualSettings {
this.setStyle(style, {
applyPreset: false,
revealParameters: false,
preserveCyberRestore: true,
});
const thermalUniforms = this.stages?.thermal?.uniforms;
if (!thermalUniforms || thermalUniforms.palette === undefined) return;
@@ -1530,10 +1534,24 @@ export class VisualSettings {
applyPreset = true,
revealParameters = applyPreset,
restore = false,
userInitiated = false,
preserveCyberRestore = false,
} = {},
) {
const { setDetectionStyle } = this.services;
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
if (this.hud.getVariant() === 'cyber') {
if (userInitiated) {
// A direct preset choice owns the active style even when it selects the
// same value as Cyber's automatic FLIR default.
this._cyberStyleUserSelected = true;
} else if (!preserveCyberRestore) {
// Scene/share/reset state is authoritative and must not be overwritten
// by a remembered local Cyber entry when the user later exits.
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
}
}
if (styleName === this.activeStyle) {
if (revealParameters && styleName !== 'normal')
this._revealStyleParameters();