refactor: isolate Context controls and layer restoration

This commit is contained in:
Sameh Khamis committed 2026-09-13 01:40:59 -07:00
1 parent 7f70b97536
commit fc369cc65a
26 files changed
+1471 -1271

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal.
- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal.
- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
+8
View File
@@ -302,3 +302,11 @@ subscriptions before ending the active tuning interaction.
presentation. It receives DOM elements, the existing camera port and explicit
application actions; it imports no provider, layer or camera engine. Selection,
placement, navigation and storage policy remain outside the component family.
### Context coordination
`ui/context` owns mode controls, transactions, session restoration and manager
subscriptions. Composition supplies the manager, installations search and
explicit visual/panel actions. `ui/context/policy` exposes the existing pure
mode and restoration rules. No source transport or renderer is imported by
these components; initial state and action results retain their existing shape.
+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State
## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions,
layer snapshots and restoration. The application supplies the existing manager,
installations search and camera/panel actions. Tab listeners and pending
presentation work stop during disposal; layer restoration retains its existing
compensation and latest-intent rules. Clear Selected Layers shares this owner,
so an older restore cannot replay over a newer Clear action.
## Camera panel ownership
CCTV controls receive the existing camera port and explicit application actions.
+3 -1
View File
@@ -131,6 +131,8 @@
"./ui/layers/feedback": "./src/loadingFeedback.js",
"./ui/location": "./src/ui/location.js",
"./ui/radio": "./src/ui/radio.js",
"./ui/cctv": "./src/ui/cctv.js"
"./ui/cctv": "./src/ui/cctv.js",
"./ui/context": "./src/ui/context.js",
"./ui/context/policy": "./src/contextModePolicy.js"
}
}
+21
View File
@@ -369,5 +369,26 @@
"src/ui/cctvPresentation.js"
],
"external": []
},
"context-controls": {
"exports": [
"./ui/context",
"./ui/context/policy"
],
"modules": [
"src/ui/context.js",
"src/ui/contextActions.js",
"src/ui/contextBindings.js",
"src/ui/contextControls.js",
"src/ui/contextLayerChanges.js",
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/contextModePolicy.js",
"src/rightRailPolicy.js",
"src/ui/panelRailGeometry.js"
],
"external": []
}
}
+48 -48
View File
@@ -184,21 +184,21 @@ try {
const toasts = [];
styleManager._showToast = (message) => { toasts.push(String(message)); };
try {
if (styleManager._contextMode) await styleManager._selectContextMode(null);
const contactsResult = await styleManager._selectContextMode('flights');
const missionResult = await styleManager._selectContextMode('space-missions');
if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null);
const contactsResult = await styleManager._contextControls._selectContextMode('flights');
const missionResult = await styleManager._contextControls._selectContextMode('space-missions');
const switched = {
contactsResult,
missionResult,
mode: styleManager._contextMode,
mode: styleManager._contextControls._contextMode,
missionsEnabled: dataManager.isEnabled('rocket-launches'),
toasts: [...toasts],
};
const exitResult = await styleManager._selectContextMode(null);
const exitResult = await styleManager._contextControls._selectContextMode(null);
return {
...switched,
exitResult,
modeAfterExit: styleManager._contextMode,
modeAfterExit: styleManager._contextControls._contextMode,
missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'),
};
} finally {
@@ -281,16 +281,16 @@ try {
const deadline = performance.now() + 5_000;
while (performance.now() < deadline) {
if (
styleManager._contextModeEntering === null
&& styleManager._contextSessionSnapshot === null
styleManager._contextControls._contextModeEntering === null
&& styleManager._contextControls._contextSessionSnapshot === null
&& dataManager.isEnabled(siblingId)
&& !dataManager.isEffectivelyEnabled('rocket-launches')
) break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
timedOut = !(
styleManager._contextModeEntering === null
&& styleManager._contextSessionSnapshot === null
styleManager._contextControls._contextModeEntering === null
&& styleManager._contextControls._contextSessionSnapshot === null
&& dataManager.isEnabled(siblingId)
&& !dataManager.isEffectivelyEnabled('rocket-launches')
);
@@ -298,8 +298,8 @@ try {
exercised: true,
transitionResult,
timedOut,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEnabled(siblingId),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts,
@@ -382,7 +382,7 @@ try {
rocketEntry.module.disable = async () => true;
styleManager._showToast = (message) => { toasts.push(String(message)); };
try {
const entry = styleManager._selectContextMode('space-missions');
const entry = styleManager._contextControls._selectContextMode('space-missions');
await updateStarted;
const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
releaseUpdate();
@@ -390,15 +390,15 @@ try {
const committed = {
entryResult,
replacementResult,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
missionEnabled: dataManager.isEnabled('rocket-launches'),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts: [...toasts],
};
const exitResult = await styleManager._selectContextMode(null);
const exitResult = await styleManager._contextControls._selectContextMode(null);
return {
exercised: true,
...committed,
@@ -482,7 +482,7 @@ try {
styleManager._showToast = (message) => { toasts.push(String(message)); };
try {
const entry = styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._selectContextMode(
(notificationToken) => styleManager._contextControls._selectContextMode(
'space-missions',
{ notificationToken },
),
@@ -498,9 +498,9 @@ try {
exercised: true,
entryResult,
offResult,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEnabled(siblingId),
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
toasts,
@@ -567,35 +567,35 @@ try {
await new Promise((resolve) => setTimeout(resolve, 80));
const voiceState = {
voiceOn,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
};
const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])];
const snapshotBeforeOff = [...(styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds || [])];
const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' });
const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size;
const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size;
await styleManager._waitForContextLayerSettlement();
const restoredSiblingEntry = dataManager.layers.get(siblingId);
const voiceExit = {
voiceOff,
snapshotBeforeOff,
reactionCountAfterOff,
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
siblingRestored: dataManager.isEffectivelyEnabled(siblingId),
siblingEnabled: restoredSiblingEntry?.enabled,
siblingLifecycle: restoredSiblingEntry?.lifecycleState,
restoreActive: Boolean(styleManager._contextRestoreState),
restoreActive: Boolean(styleManager._contextControls._contextRestoreState),
};
await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
await new Promise((resolve) => setTimeout(resolve, 40));
const internalState = {
mode: styleManager._contextMode,
entering: styleManager._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
mode: styleManager._contextControls._contextMode,
entering: styleManager._contextControls._contextModeEntering,
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'),
};
await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' });
@@ -707,7 +707,7 @@ try {
const blockerEntry = dataManager.layers.get(blockerId);
blockerEntry.initialized = true;
await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' });
const transition = styleManager._selectContextMode('flights');
const transition = styleManager._contextControls._selectContextMode('flights');
const disableStart = await settleWithin(disableStarted, gateTimeoutMs);
if (!disableStart.settled) {
releaseDisable();
@@ -725,7 +725,7 @@ try {
styleManager.cockpitView.syncEntry();
const entry = document.getElementById('cockpit-entry');
const pending = {
changing: styleManager._contextModeChanging,
changing: styleManager._contextControls._contextModeChanging,
entryHidden: Boolean(entry?.hidden),
enterResult: styleManager.cockpitView.enter(),
trackerPreserved: viewer.trackedEntity === trackedEntityBefore,
@@ -763,7 +763,7 @@ try {
// probe must not poison the checks that run after it.
releaseDisable();
if (!deferCleanup && dataManager.layers.has(blockerId)) {
if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null);
if (styleManager._contextControls._contextSessionSnapshot) await styleManager._contextControls._selectContextMode(null);
await window.__gevQaUnregisterLayer(dataManager, blockerId);
styleManager.cockpitView.syncEntry();
}
@@ -872,7 +872,7 @@ try {
let requestSeen = false;
let transition = null;
try {
await styleManager._selectContextMode(null);
await styleManager._contextControls._selectContextMode(null);
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
// Hold the first Overpass request open, honouring the module's own
@@ -913,7 +913,7 @@ try {
});
};
transition = styleManager._selectContextMode('flights');
transition = styleManager._contextControls._selectContextMode('flights');
// The activation promise stays PENDING while the first fetch is out, and
// the Contacts panel comes up behind it — that is what a deferred
// dependency is for. Verified live on :4272: the panel renders and reads
@@ -984,8 +984,8 @@ try {
released = true;
window.fetch = realFetch;
if (transition) await settleWithin(transition, 20_000);
if (styleManager._contextMode !== 'flights') {
await settleWithin(styleManager._selectContextMode('flights'), 15_000);
if (styleManager._contextControls._contextMode !== 'flights') {
await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000);
}
}
});
@@ -1060,7 +1060,7 @@ try {
return {
before: before ? { layerId: before.layerId, id: before.id } : null,
after: after ? { layerId: after.layerId, id: after.id } : null,
mode: styleManager._contextMode,
mode: styleManager._contextControls._contextMode,
released,
refocused,
trackedId: trackedInfo?.icao24 || trackedInfo?.id || null,
@@ -1206,9 +1206,9 @@ try {
// Start from outside both, so activation is a real edge.
cockpit.exit({ restoreTracking: true });
await settle();
await styleManager._selectContextMode(null);
await styleManager._contextControls._selectContextMode(null);
await settle(400);
const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null;
const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null;
const cleanupBlockerId = '__qa_slow_contacts_sibling__';
const cleanupUnregistered = await window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
@@ -1224,7 +1224,7 @@ try {
const overriddenBeforeContacts = styleManager._detectionUserOverridden;
// 1. Activating Contacts forces the tactical preset on.
const contactsOn = await styleManager._selectContextMode('flights');
const contactsOn = await styleManager._contextControls._selectContextMode('flights');
await settle(400);
const afterContactsOn = mode();
const afterContactsOnDensity = density();
@@ -1254,7 +1254,7 @@ try {
await settle();
// 4. Deactivating Contacts restores the pre-Contacts state.
await styleManager._selectContextMode(null);
await styleManager._contextControls._selectContextMode(null);
await settle(400);
const afterContactsOff = mode();
const afterContactsOffDensity = density();
@@ -1268,7 +1268,7 @@ try {
await settle();
// Leave the session exactly as the following checks expect it.
const restoredContacts = await styleManager._selectContextMode('flights');
const restoredContacts = await styleManager._contextControls._selectContextMode('flights');
await settle(400);
const restoredCockpit = await enterCockpit();
styleManager._detectionUserOverridden = originalOverridden;
@@ -1348,8 +1348,8 @@ try {
step,
cockpitActive: cockpit.active,
bodyCockpit: document.body.classList.contains('cockpit-mode'),
contextMode: styleManager._contextMode,
contextChanging: styleManager._contextModeChanging,
contextMode: styleManager._contextControls._contextMode,
contextChanging: styleManager._contextControls._contextModeChanging,
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
tracked: tracker?.gevTrackedId || null,
density: styleManager.getDetectionState().densityPct,
+2 -2
View File
@@ -87,9 +87,9 @@ try {
result.push([
'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
!!ui._clearSelectedLayersPromise,
!!ui._contextControls._clearSelectedLayersPromise,
]);
await ui._clearSelectedLayersPromise;
await ui._contextControls._clearSelectedLayersPromise;
result.push([
'clear settles through the existing layer transaction',
!manager.isEnabled(id) &&
+38 -38
View File
@@ -783,10 +783,10 @@ async function main() {
const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds;
const originalIsEnabled = dataManager.isEnabled;
const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled;
const originalClear = styleManager._clearLayersOutsideContextMode;
const originalCapture = styleManager._captureContextSessionSnapshot;
const originalContextMode = styleManager._contextMode;
const originalSnapshot = styleManager._contextSessionSnapshot;
const originalClear = styleManager._contextControls._clearLayersOutsideContextMode;
const originalCapture = styleManager._contextControls._captureContextSessionSnapshot;
const originalContextMode = styleManager._contextControls._contextMode;
const originalSnapshot = styleManager._contextControls._contextSessionSnapshot;
const originalShowToast = styleManager._showToast;
const installations = dataManager.layers.get('military-installations')?.module;
const originalSearchNearby = installations?.searchNearby;
@@ -797,12 +797,12 @@ async function main() {
const semanticFalseMessage = document.getElementById('toast').textContent;
const enabled = new Set();
styleManager._captureContextSessionSnapshot = () => {};
styleManager._contextControls._captureContextSessionSnapshot = () => {};
const runContextFailure = async (mode, phase, outcome) => {
enabled.clear();
enabled.add('local-datacenters');
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(enabled),
userAdded: new Set(),
userRemoved: new Set(),
@@ -822,7 +822,7 @@ async function main() {
return true;
};
const result = await styleManager._runUserFacingContextAction(
() => styleManager._selectContextMode(mode),
() => styleManager._contextControls._selectContextMode(mode),
`${mode} QA transition failed`,
);
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -831,8 +831,8 @@ async function main() {
phase,
outcome,
result,
visibleMode: styleManager._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null,
visibleMode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored: enabled.has('local-datacenters'),
entryEnabled: enabled.has(entryLayerId),
flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled,
@@ -870,8 +870,8 @@ async function main() {
for (const outcome of ['false', 'reject']) {
enabled.clear();
enabled.add('local-datacenters');
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(enabled),
userAdded: new Set(),
userRemoved: new Set(),
@@ -886,7 +886,7 @@ async function main() {
dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => {
if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) {
failureUsed = true;
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed',
layerId: candidateId,
enabled: shouldEnable,
@@ -905,7 +905,7 @@ async function main() {
enabled: true,
origin: 'user',
});
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-blocked',
layerId,
enabled: true,
@@ -925,10 +925,10 @@ async function main() {
reason,
retryReason,
toastMessages,
mode: styleManager._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null,
mode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored,
modeChanging: styleManager._contextModeChanging,
modeChanging: styleManager._contextControls._contextModeChanging,
});
}
}
@@ -937,8 +937,8 @@ async function main() {
const directActivationFailures = [];
for (const layerId of ['military-awareness', 'rocket-launches']) {
enabled.clear();
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(['local-datacenters']),
userAdded: new Set(),
userRemoved: new Set(),
@@ -948,7 +948,7 @@ async function main() {
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
return true;
};
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed',
layerId,
enabled: true,
@@ -957,8 +957,8 @@ async function main() {
await new Promise((resolve) => setTimeout(resolve, 50));
directActivationFailures.push({
layerId,
mode: styleManager._contextMode,
snapshotCleared: styleManager._contextSessionSnapshot === null,
mode: styleManager._contextControls._contextMode,
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
priorLayerRestored: enabled.has('local-datacenters'),
failedLayerDisabled: !enabled.has(layerId),
});
@@ -967,8 +967,8 @@ async function main() {
const directActivationRollbackFailures = [];
for (const layerId of ['military-awareness', 'rocket-launches']) {
enabled.clear();
styleManager._contextMode = null;
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = null;
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set(['local-datacenters']),
userAdded: new Set(),
userRemoved: new Set(),
@@ -985,7 +985,7 @@ async function main() {
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
return true;
};
styleManager._handleContextLayerChange({
styleManager._contextControls._handleContextLayerChange({
type: 'visibility-failed',
layerId,
enabled: true,
@@ -996,7 +996,7 @@ async function main() {
directActivationRollbackFailures.push({
layerId,
toastMessages,
retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
retryRetained: styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
});
}
@@ -1026,8 +1026,8 @@ async function main() {
window.__gevQaRegisterLayer(dataManager, qaLayer);
try {
const enabledBeforeExit = dataManager.getEnabledLayerIds();
styleManager._contextMode = 'flights';
styleManager._contextSessionSnapshot = {
styleManager._contextControls._contextMode = 'flights';
styleManager._contextControls._contextSessionSnapshot = {
enabledLayerIds: new Set([...enabledBeforeExit, layerId]),
userAdded: new Set(),
userRemoved: new Set(),
@@ -1039,13 +1039,13 @@ async function main() {
};
const unhandledBefore = unhandled.length;
const result = await styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
`QA Context exit ${outcome} surfaced once`,
);
const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
const retainedForRetry = styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
enableOutcome = 'success';
const retryResult = await styleManager._runUserFacingContextAction(
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
`QA Context exit ${outcome} retry failed`,
);
contextExitFailures.push({
@@ -1055,9 +1055,9 @@ async function main() {
toastMessages,
unhandledDelta: unhandled.length - unhandledBefore,
retainedForRetry,
mode: styleManager._contextMode,
modeChanging: styleManager._contextModeChanging,
snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null,
mode: styleManager._contextControls._contextMode,
modeChanging: styleManager._contextControls._contextModeChanging,
snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null,
enabledAfterRetry: dataManager.isEnabled(layerId),
toastRole: document.getElementById('toast').getAttribute('role'),
toastLive: document.getElementById('toast').getAttribute('aria-live'),
@@ -1163,10 +1163,10 @@ async function main() {
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
dataManager.isEnabled = originalIsEnabled;
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
styleManager._clearLayersOutsideContextMode = originalClear;
styleManager._captureContextSessionSnapshot = originalCapture;
styleManager._contextMode = originalContextMode;
styleManager._contextSessionSnapshot = originalSnapshot;
styleManager._contextControls._clearLayersOutsideContextMode = originalClear;
styleManager._contextControls._captureContextSessionSnapshot = originalCapture;
styleManager._contextControls._contextMode = originalContextMode;
styleManager._contextControls._contextSessionSnapshot = originalSnapshot;
styleManager._showToast = originalShowToast;
if (installations) installations.searchNearby = originalSearchNearby;
window.removeEventListener('unhandledrejection', onUnhandled);
+10
View File
@@ -58,6 +58,13 @@ try {
counts.cctv++;
return cctvUnsubscribe();
};
const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe'];
const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function');
for (const field of contextFields) {
const unsubscribe = ui._contextControls[field];
counts[field] = 0;
ui._contextControls[field] = () => { counts[field]++; return unsubscribe?.(); };
}
const resizeHandler = ui._windowResizeHandler;
const removeEventListener = window.removeEventListener;
counts.resize = 0;
@@ -79,6 +86,8 @@ try {
resizeHandler &&
observed.includes('_commandDockTrayObserver'),
),
contextReleased: contextConnected && ui._contextControls.destroyed
&& contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null),
observersReleased: observed.every(
(name) => counts[name] === 1 && ui[name] === null,
),
@@ -99,6 +108,7 @@ try {
'real UI has the expected live resources before disposal',
result.connected,
);
check('UI disposal releases all Context subscriptions and stops its controls', result.contextReleased);
check(
'UI disposal disconnects each active panel observer once',
result.observersReleased,
+2 -2
View File
@@ -300,7 +300,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
'this._disposed = true;',
'this._stampNavigation();',
'this._removeWorldRequestFocusListener?.();',
'await this._restoreContextSession();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
});
@@ -311,7 +311,7 @@ test('teardown synchronously closes immediate camera entry points', () => {
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
'await this._restoreContextSession();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
const navigation = body(
+11 -9
View File
@@ -1,3 +1,7 @@
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _restoreContextSession } from './ui/contextSession.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
@@ -183,9 +187,7 @@ test('the cockpit reads its aircraft from the layer that owns Cesium tracking',
});
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/);
assert.ok(handler, 'Context layer state handler is missing');
assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/);
assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/);
});
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
@@ -305,13 +307,13 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
manager,
/for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/,
);
assert.match(ui, /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
assert.match(_selectContextMode.toString(), /async function _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
assert.match(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(_restoreContextSession.toString(), /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
assert.match(clearSelectedLayers.toString(), /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
});
test('Cockpit Display portal retains both scroll owners across round trips', () => {
+4 -2
View File
@@ -1,3 +1,4 @@
import { _syncContextModeButtons } from './ui/contextPresentation.js';
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
// Contacts, detections should just turn on, and they should stay on in Cockpit
// or in third-person tracking inside Contacts").
@@ -292,10 +293,11 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
'ACTIVATION turns detection on REGARDLESS of the style-preset override flag',
);
assert.match(
uiSource,
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/,
_syncContextModeButtons.toString(),
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/,
'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through',
);
assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/);
// The cockpit vision hook — the old trigger — must be out of the detection
// business entirely, or leaving the cockpit turns detections off again.
const visionHook = uiSource.slice(
+29 -71
View File
@@ -1,11 +1,15 @@
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { connectContextManager } from './ui/contextSubscriptions.js';
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
import { _selectContextMode, _clearLayersOutsideContextMode } from './ui/contextTransactions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { setContextMode as contextModeAction } from './ui/contextActions.js';
import { ContextControls } from './ui/contextControls.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins for StyleManager's Context-session handler ordering.
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
// these read the shipped source — the idiom established by cockpitMarkup /
// cameraHandoff tests. Each pin guards a bug that shipped or nearly shipped:
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
// - session bookkeeping ran AFTER the exit early-return, so the compensating
// userAdded.delete never ran on the left-panel chip exit and restoration
// resurrected the mission layer the user just disabled;
@@ -21,10 +25,7 @@ import { fileURLToPath } from 'node:url';
const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8');
const handlerStart = src.indexOf('_handleContextLayerChange(change) {');
assert.ok(handlerStart > 0, 'handler found');
const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart);
const handler = src.slice(handlerStart, handlerEnd);
const handler = _handleContextLayerChange.toString();
test('context handler: session bookkeeping runs before the exit early-return', () => {
const record = handler.indexOf('recordContextSessionUserChange(');
@@ -56,9 +57,7 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi
});
test('direct Context entry captures on the synchronous request boundary', () => {
const attachStart = src.indexOf('attachDataManager(dataManager) {');
const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart);
const attach = src.slice(attachStart, attachEnd);
const attach = connectContextManager.toString();
assert.match(
attach,
/subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/,
@@ -70,9 +69,7 @@ test('direct Context entry captures on the synchronous request boundary', () =>
});
test('Clear All defers a newer explicit mission guard until its batch settles', () => {
const attachStart = src.indexOf('attachDataManager(dataManager) {');
const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart);
const attach = src.slice(attachStart, attachEnd);
const attach = connectContextManager.toString();
assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/);
assert.match(attach, /_contextModeDeferredEntryIntent = \{/);
assert.match(attach, /await this\._clearSelectedLayersManagerPromise/);
@@ -80,10 +77,7 @@ test('Clear All defers a newer explicit mission guard until its batch settles',
});
test('context restore replays explicit companion intent after the stale restore queue drains', () => {
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restore = _restoreContextSession.toString();
const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(');
const replay = restore.indexOf('await settleContextIntentReplay(');
assert.ok(restoreAwait > 0, 'restore queue is awaited');
@@ -92,10 +86,7 @@ test('context restore replays explicit companion intent after the stale restore
});
test('context restore settles the Contacts coordinator before dependency fanout', () => {
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restore = _restoreContextSession.toString();
const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'");
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle);
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', settle);
@@ -106,10 +97,7 @@ test('context restore settles the Contacts coordinator before dependency fanout'
});
test('context restore preserves its exact pending target after a failed transition', () => {
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _selectContextMode('),
);
const restore = _restoreContextSession.toString();
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/);
assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/);
@@ -130,7 +118,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio
assert.match(failedBranch, /const failureMessage =/);
assert.match(
failedBranch,
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\._showToast\(failureMessage\);\s*return true;/,
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/,
);
assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/);
assert.match(failedBranch, /_trackContextLayerReaction\(/);
@@ -139,10 +127,7 @@ test('context handler: either failed direct Context-shell start rolls the sessio
/else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/,
);
const deferredRestore = src.slice(
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
src.indexOf('async _selectContextMode('),
);
const deferredRestore = _restoreContextSessionAfterLayerSettles.toString();
assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/);
assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/);
assert.doesNotMatch(failedBranch, /excludeLayerIds/);
@@ -157,14 +142,11 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token
blockedBranch,
/!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/,
);
assert.match(blockedBranch, /this\._showToast\(/);
assert.match(blockedBranch, /this\.showToast\(/);
});
test('every user-facing Context exit route settles through the failure surface', () => {
const initPanel = src.slice(
src.indexOf('_initGlobalContextPanel() {'),
src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')),
);
const initPanel = _initGlobalContextPanel.toString();
assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3);
assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/);
assert.doesNotMatch(initPanel, /void this\._selectContextMode/);
@@ -198,10 +180,7 @@ test('only the expanded Radio Enable gesture requests the contained post-enable
});
test('right-rail context entry is transactional: activation result gates the mode', () => {
const select = src.slice(
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
const select = _selectContextMode.toString();
const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent(');
assert.ok(activation > 0, 'activation result captured');
const failureBlock = select.slice(activation);
@@ -220,10 +199,7 @@ test('right-rail context entry is transactional: activation result gates the mod
});
test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => {
const select = src.slice(
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
const select = _selectContextMode.toString();
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/);
assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/);
assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/);
@@ -255,12 +231,10 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi
// invalidates in-flight Context transactions so teardown cannot be raced into
// publishing a mode.
const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {')));
assert.match(dispose, /this\._contextModeGeneration \+= 1;/);
assert.match(dispose, /this\._contextControls\.stop\(\)/);
assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/);
const guard = src.slice(
src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'),
src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"),
);
const guard = connectContextManager.toString();
assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/);
assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
@@ -277,10 +251,7 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
// a bare "did not complete" while the operator's Context was silently gone.
// Text and state are derived from the same verdict so they cannot disagree,
// and the failed layer ids survive (Manjunath's honesty requirement).
const setter = src.slice(
src.indexOf(' async setContextMode(mode, {'),
src.indexOf(' getCockpitState() {'),
);
const setter = contextModeAction.toString();
assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost');
assert.match(
setter,
@@ -301,14 +272,10 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
});
test('Context cancellation reaches isolation and restore lifecycle mutations', () => {
const clearStart = src.indexOf('async _clearLayersOutsideContextMode(');
const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart));
const clear = _clearLayersOutsideContextMode.toString();
assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/);
const restore = src.slice(
src.indexOf('async _restoreContextSession('),
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
);
const restore = _restoreContextSession.toString();
assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/);
assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/);
assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/);
@@ -319,10 +286,7 @@ test('Context cancellation reaches isolation and restore lifecycle mutations', (
});
test('Context facade preserves success when cancellation arrives after commit', () => {
const setContextMode = src.slice(
src.indexOf('async setContextMode('),
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
);
const setContextMode = contextModeAction.toString();
assert.match(
setContextMode,
/transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/,
@@ -334,10 +298,7 @@ test('Context facade preserves success when cancellation arrives after commit',
});
test('Context production rollback paths merge primary and restore failed-layer identities', () => {
const select = src.slice(
src.indexOf('async _selectContextMode('),
src.indexOf('async _deactivateContextForLayerChange('),
);
const select = _selectContextMode.toString();
assert.equal(
(select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length,
2,
@@ -347,10 +308,7 @@ test('Context production rollback paths merge primary and restore failed-layer i
});
test('stale Context cancellation preserves rollback failed-layer identities', () => {
const setContextMode = src.slice(
src.indexOf('async setContextMode('),
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
);
const setContextMode = contextModeAction.toString();
assert.match(
setContextMode,
/if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/,
+27 -39
View File
@@ -1,3 +1,7 @@
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { clearSelectedLayers } from './ui/contextActions.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
@@ -18,16 +22,14 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/);
}
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
assert.match(sync[1], /button\.tabIndex = 0/);
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
const syncSource = _syncContextModeButtons.toString();
assert.match(syncSource, /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
assert.match(syncSource, /button\.tabIndex = 0/);
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
const syncSource = _syncContextModeButtons.toString();
const attributes = () => new Map();
const makeButton = () => {
@@ -56,6 +58,8 @@ test('Context transition state preserves focus and Tab availability until settle
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
@@ -68,7 +72,7 @@ test('Context transition state preserves focus and Tab availability until settle
_scheduleRightPanelLayout() {},
};
try {
Function(sync[1]).call(owner);
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
@@ -79,7 +83,7 @@ test('Context transition state preserves focus and Tab availability until settle
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
Function(sync[1]).call(owner);
_syncContextModeButtons.call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
@@ -93,32 +97,22 @@ test('Context transition state preserves focus and Tab availability until settle
});
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const select = ui.slice(
ui.indexOf('async _selectContextMode('),
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
const init = _initGlobalContextPanel.toString();
const select = _selectContextMode.toString();
const clear = clearSelectedLayers.toString();
assert.equal((init.match(/if \(this\.destroyed \|\| this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
});
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
assert.ok(init, 'Context panel initialization is missing');
assert.match(init[1], /event\.key === 'ArrowRight'/);
assert.match(init[1], /event\.key === 'ArrowLeft'/);
assert.match(init[1], /event\.key === 'Home'/);
assert.match(init[1], /event\.key === 'End'/);
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
const initSource = _initGlobalContextPanel.toString();
assert.match(initSource, /event\.key === 'ArrowRight'/);
assert.match(initSource, /event\.key === 'ArrowLeft'/);
assert.match(initSource, /event\.key === 'Home'/);
assert.match(initSource, /event\.key === 'End'/);
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
});
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
@@ -136,16 +130,10 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
});
test('Context async action buttons remain focused while busy', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const init = _initGlobalContextPanel.toString();
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
@@ -155,7 +143,7 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
assert.match(clear, /_clearLayersControl\?\.setBusy\(true\)/);
assert.match(clear, /this\.setClearBusy\(true\)/);
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
+11 -11
View File
@@ -1,3 +1,5 @@
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -464,19 +466,17 @@ test('explicit Context transitions claim the visual restore lane before transiti
assert.ok(claimIndex < mutationIndex, `${label} must claim before mutation`);
};
const helper = sourceBlock(
' _claimContextVisualAuthority() {',
' async _selectContextMode(mode, {',
);
const helper = _claimContextVisualAuthority.toString();
assert.ok(
helper.includes("claimRestoreLane?.('visual')"),
helper.includes('this.actions.claimVisualAuthority()'),
'the Context authority helper must claim the visual lane',
);
const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction(');
assert.match(uiSource, /claimVisualAuthority: \(\) => this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/);
const contextPanel = _initGlobalContextPanel.toString();
for (const [start, end, label] of [
["this._globalContextFlightsBtn?.addEventListener('click'", "this._globalContextMissionsBtn?.addEventListener('click'", 'Contacts tab'],
["this._globalContextMissionsBtn?.addEventListener('click'", 'CONTEXT_PANEL_END', 'Space Missions tab'],
["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'],
["this.listen(this._globalContextMissionsBtn, 'click'", 'CONTEXT_PANEL_END', 'Space Missions tab'],
]) {
const startIndex = contextPanel.indexOf(start);
const endIndex = end === 'CONTEXT_PANEL_END'
@@ -487,7 +487,7 @@ test('explicit Context transitions claim the visual restore lane before transiti
}
// The voice/tool facade validates the mode first, then transitions.
const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {');
const facade = setContextMode.toString();
assertContextClaimsBefore(facade, 'this._selectContextMode(', 'setContextMode facade');
// Authority is taken per validated branch, never ahead of validation. The
// OFF branch is validated by its own guard; the named-mode branch must claim
@@ -517,8 +517,8 @@ test('explicit Context transitions claim the visual restore lane before transiti
// detection: `_detectionUserOverridden` is what suppresses the military-style
// auto-enable for the rest of the session. Contacts entry is not that.
test('Context lane claims never set the session detection-override flag', () => {
const contextPanel = sourceBlock(' _initGlobalContextPanel() {', ' async _runUserFacingContextAction(');
const facade = sourceBlock(' async setContextMode(mode, {', ' getCockpitState() {');
const contextPanel = _initGlobalContextPanel.toString();
const facade = setContextMode.toString();
for (const [block, label] of [
[contextPanel, 'Context panel'],
[facade, 'setContextMode facade'],
+38 -1048
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
export { ContextControls } from './contextControls.js';
+219
View File
@@ -0,0 +1,219 @@
import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js';
export async function _runUserFacingContextAction(
operation,
message = 'Context could not restore every layer; try again',
{ falseIsFailure = true } = {},
) {
if (this.destroyed) return false;
const notificationToken = Symbol('user-facing-context-action');
this._userFacingContextNotificationTokens.add(notificationToken);
try {
return await settleUserFacingContextAction({
operation: () => operation(notificationToken),
falseIsFailure,
onFailure: (error) => {
if (this.destroyed) return;
console.warn('[Context] user-facing transition failed', error);
this.showToast(message);
},
});
} finally {
this._userFacingContextNotificationTokens.delete(notificationToken);
}
}
/**
* Claim the visual restore lane for an explicit Context transition.
*
* Contacts OWNS detection while active (forced Dense @ 75%), so entering or
* leaving it is a visual-lane gesture exactly like the HUD or detection
* controls. Without this claim, the shared-view restore that lands 1.5 s into
* startup re-applied the link's `dm`/`dd` over the forced preset and Contacts
* lost its own overlay mid-session.
*
* Deliberately does NOT set `_detectionUserOverridden`: that flag means the
* OPERATOR hand-edited detection and suppresses the military-style
* auto-enable for the rest of the session. Context entry is not that, and
* conflating them would silently disable a separate landed behavior.
*
* Call only for VALIDATED explicit transitions — never for programmatic or
* restore-driven ones, which must stay eligible for the shared visual state.
*/
export function _claimContextVisualAuthority() {
this.actions.claimVisualAuthority();
}
export function getContextModeState() {
return {
mode: this._contextMode || null,
active: Boolean(this._contextMode),
changing: Boolean(this._contextModeChanging),
entering: this._contextModeEntering || null,
canContact: !this._contextMode || this._contextMode === 'flights',
canMission: !this._contextMode || this._contextMode === 'space-missions',
snapshotCaptured: Boolean(this._contextSessionSnapshot),
};
}
export async function setContextMode(mode, {
notificationToken = null,
signal = null,
isCurrent = null,
claimVisualAuthority = true,
} = {}) {
const requestIsCurrent = () => !this.destroyed && !signal?.aborted
&& (typeof isCurrent !== 'function' || isCurrent());
const cancellationResult = () => ({
ok: false,
action: 'set_context_mode',
cancelled: true,
error: 'Context request was superseded by a newer voice turn',
...this.getContextModeState(),
...(this._contextTransitionFailedLayerIds?.length
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
: {}),
});
if (!requestIsCurrent()) return cancellationResult();
try {
if (!mode || mode === 'off') {
// Validated explicit transition — Context owns detection, so take the
// visual lane before a delayed shared restore can reclaim it. Internal
// Cockpit choreography opts out: it is not an operator Context request.
if (claimVisualAuthority) this._claimContextVisualAuthority();
const result = await this._selectContextMode(null, { notificationToken, signal });
if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult();
const state = this.getContextModeState();
return {
ok: result === true,
action: 'set_context_mode',
mode: state.mode,
...state,
...(result === true ? {} : { error: 'Context mode transition did not complete' }),
...(this._contextTransitionFailedLayerIds?.length
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
: {}),
};
}
const canonical = mode === 'contacts' ? 'flights' : mode;
if (!['flights', 'space-missions'].includes(canonical)) {
return {
ok: false,
action: 'set_context_mode',
error: `Unknown context mode: ${mode}`,
mode: this._contextMode,
...this.getContextModeState(),
};
}
const priorMode = this._contextMode;
// Claimed only after the mode enum validates above, so a rejected request
// takes no authority and leaves the shared visual state eligible. Internal
// Cockpit choreography opts out: it is not an operator Context request.
if (claimVisualAuthority) this._claimContextVisualAuthority();
const transitioned = await this._selectContextMode(canonical, { notificationToken, signal });
if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult();
const state = this.getContextModeState();
// A cross-mode switch tears the prior mode down before it commits, so a
// cancelled or failed switch rests on Context OFF. Say that plainly:
// reporting a bare "did not complete" while the operator's Context is
// gone is the dishonesty this whole path was fixed for. The state fields
// below carry the same verdict, so text and state cannot disagree.
const crossModeSwitchLost = transitioned !== true
&& Boolean(priorMode) && priorMode !== canonical && !state.mode;
return {
ok: transitioned === true,
action: 'set_context_mode',
mode: state.mode,
...state,
...(transitioned === true ? {} : {
// Named in the operator's vocabulary, not the internal id: this
// string is read by the voice model, which takes 'contacts'.
error: crossModeSwitchLost
? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off`
: 'Context mode transition did not complete',
...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}),
}),
...(this._contextTransitionFailedLayerIds?.length
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
: {}),
};
} catch (error) {
if (!requestIsCurrent()) {
return {
...cancellationResult(),
...(Array.isArray(error?.failedLayerIds)
? { failedLayerIds: [...error.failedLayerIds] }
: {}),
};
}
return {
ok: false,
action: 'set_context_mode',
error: error?.message || 'Context mode transition failed',
...(Array.isArray(error?.failedLayerIds)
? { failedLayerIds: [...error.failedLayerIds] }
: {}),
...this.getContextModeState(),
};
}
}
export function clearSelectedLayers() {
if (this.destroyed) return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [], cancelled: true });
if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise;
if (!this._dataManager?.clearSelectedLayers) {
return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] });
}
const generation = ++this._contextModeGeneration;
const notificationToken = Symbol('clear-selected-layers');
if (this._contextRestoreState) this._contextRestoreState.cancelled = true;
this._contextModeChanging = true;
this._contextMode = null;
this._contextModeEntering = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._contextSessionSnapshot = null;
this._contextRestoreState = null;
this._preservePanelStateDuringLayerClear = true;
this._syncContextModeButtons();
this._userFacingContextNotificationTokens.add(notificationToken);
this.setClearBusy(true);
const managerOperation = this._dataManager.clearSelectedLayers({
origin: 'user',
notificationToken,
});
this._clearSelectedLayersManagerPromise = managerOperation;
const operation = managerOperation.then((result) => {
if (result.targetIds.length === 0) {
this.showToast('No selected data layers');
} else if (result.notClearedIds.length > 0) {
this.showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`);
} else {
this.showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`);
}
return result;
}).catch((error) => {
console.warn('[Data] clear selected layers failed', error);
this.showToast('Selected data layers could not be cleared');
return {
targetIds: [],
items: [],
clearedIds: [],
notClearedIds: [],
error,
};
}).finally(() => {
this._userFacingContextNotificationTokens.delete(notificationToken);
if (generation === this._contextModeGeneration) {
this._contextModeChanging = false;
this._syncContextModeButtons();
}
this.setClearBusy(false);
this._preservePanelStateDuringLayerClear = false;
this._clearSelectedLayersManagerPromise = null;
this._clearSelectedLayersPromise = null;
});
this._clearSelectedLayersPromise = operation;
return operation;
}
+77
View File
@@ -0,0 +1,77 @@
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
export function _initGlobalContextPanel() {
const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean);
contextTabs.forEach((tab, index) => this.listen(tab, 'keydown', (event) => {
let nextIndex = null;
if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length;
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = contextTabs.length - 1;
if (nextIndex === null) return;
event.preventDefault();
contextTabs[nextIndex].focus({ preventScroll: true });
contextTabs[nextIndex].click();
}));
this.listen(this._globalContextFlightsBtn, 'click', () => {
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return;
const nextMode = this._contextMode === 'flights' ? null : 'flights';
this._claimContextVisualAuthority();
void this._runUserFacingContextAction(
(notificationToken) => this._selectContextMode(
nextMode,
{ notificationToken },
),
'Contacts could not complete the requested transition; try again',
).then((succeeded) => {
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({
action: 'contacts',
explicitUserAction: true,
succeeded: succeeded === true,
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
});
});
this.listen(this._globalContextMissionsBtn, 'click', () => {
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return;
const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
void this._runUserFacingContextAction(
(notificationToken) => this._selectContextMode(
nextMode,
{ notificationToken },
),
'Space Missions could not complete the requested transition; try again',
).then((succeeded) => {
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({
action: 'space-missions',
explicitUserAction: true,
succeeded: succeeded === true,
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
});
});
this.listen(this._installationsSearchBtn, 'click', () => {
if (this.destroyed || !this._dataManager?.layers?.has('military-installations')) return;
const button = this._installationsSearchBtn;
if (button.getAttribute('aria-busy') === 'true') return;
button.setAttribute('aria-disabled', 'true');
button.setAttribute('aria-busy', 'true');
void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled('military-installations', true, {
origin: 'user',
notificationToken,
});
if (this.destroyed || enabled === false || !this._dataManager.isEnabled('military-installations')) return false;
const searched = await this.installations.searchNearby?.();
if (this.destroyed || searched === false) return false;
const stats = this.installations.getStats?.();
this.showToast(stats?.statusMessage || (stats?.status === 'zoom-in'
? 'Zoom in to search mapped installations'
: 'Nearby installations refreshed'));
return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
});
});
}
+74
View File
@@ -0,0 +1,74 @@
import { _initGlobalContextPanel } from './contextBindings.js';
import { _syncContextModeButtons } from './contextPresentation.js';
import { _trackContextLayerReaction, _waitForContextLayerSettlement, _captureContextSessionSnapshot, _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './contextSession.js';
import { _selectContextMode, _deactivateContextForLayerChange, _clearLayersOutsideContextMode } from './contextTransactions.js';
import { _handleContextLayerChange } from './contextLayerChanges.js';
import { _runUserFacingContextAction, _claimContextVisualAuthority, getContextModeState, setContextMode, clearSelectedLayers } from './contextActions.js';
import { connectContextManager } from './contextSubscriptions.js';
/** Own Context transactions and controls through explicit layer and presentation ports. */
export class ContextControls {
constructor({ elements, installations, actions }) {
Object.assign(this, elements);
this.installations = installations;
this.actions = actions;
this.listeners = new AbortController();
this.destroyed = false;
this._dataManager = null;
this._contextMode = null;
this._contextModeChanging = false;
this._contextModeGeneration = 0;
this._contextModeEntering = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._contextModeDeferredEntryIntent = null;
this._contextSessionSnapshot = null;
this._contextRestoreState = null;
this._contextLayerReactionPromises = new Set();
this._preservePanelStateDuringLayerClear = false;
this._userFacingContextNotificationTokens = new Set();
this._clearSelectedLayersPromise = null;
this._clearSelectedLayersManagerPromise = null;
this._initGlobalContextPanel();
}
showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); }
setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); }
get cockpitView() { return this.actions.getCockpit(); }
listen(target, type, callback, options = {}) {
target?.addEventListener(type, callback, { ...options, signal: this.listeners.signal });
}
connect(manager) { return connectContextManager.call(this, manager); }
disconnect() {
for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) {
this[field]?.();
this[field] = null;
}
}
stop() {
if (this.destroyed) return;
this.destroyed = true;
this._contextModeGeneration++;
this.listeners.abort();
}
async restoreForDisposal() {
this._contextModeChanging = true;
this._contextMode = null;
await this._restoreContextSession();
}
_initGlobalContextPanel(...args) { return _initGlobalContextPanel.call(this, ...args); }
_syncContextModeButtons(...args) { return _syncContextModeButtons.call(this, ...args); }
_trackContextLayerReaction(...args) { return _trackContextLayerReaction.call(this, ...args); }
_waitForContextLayerSettlement(...args) { return _waitForContextLayerSettlement.call(this, ...args); }
_captureContextSessionSnapshot(...args) { return _captureContextSessionSnapshot.call(this, ...args); }
_restoreContextSession(...args) { return _restoreContextSession.call(this, ...args); }
_restoreContextSessionAfterLayerSettles(...args) { return _restoreContextSessionAfterLayerSettles.call(this, ...args); }
_selectContextMode(...args) { return _selectContextMode.call(this, ...args); }
_deactivateContextForLayerChange(...args) { return _deactivateContextForLayerChange.call(this, ...args); }
_clearLayersOutsideContextMode(...args) { return _clearLayersOutsideContextMode.call(this, ...args); }
_handleContextLayerChange(...args) { return _handleContextLayerChange.call(this, ...args); }
_runUserFacingContextAction(...args) { return _runUserFacingContextAction.call(this, ...args); }
_claimContextVisualAuthority(...args) { return _claimContextVisualAuthority.call(this, ...args); }
getContextModeState(...args) { return getContextModeState.call(this, ...args); }
setContextMode(...args) { return setContextMode.call(this, ...args); }
clearSelectedLayers(...args) { return clearSelectedLayers.call(this, ...args); }
}
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { ContextControls } from './contextControls.js';
class Button extends EventTarget {
constructor() { super(); this.attributes = new Map(); }
getAttribute(name) { return this.attributes.get(name); }
setAttribute(name, value) { this.attributes.set(name, value); }
}
const turn = () => new Promise((resolve) => setImmediate(resolve));
function fixture(t, elements = {}) {
const calls = [];
const controls = new ContextControls({
elements,
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: () => calls.push('claim'),
showToast: () => calls.push('notice'),
setClearBusy: () => {},
syncDetection: () => {},
scheduleLayout: () => {},
refreshRadio: () => {},
setPanelCollapsed: () => calls.push('expand'),
},
});
t.after(() => { controls.stop(); controls.disconnect(); });
return { controls, calls };
}
test('Context starts with an explicit idle snapshot', (t) => {
const { controls } = fixture(t);
assert.deepEqual(controls.getContextModeState(), {
mode: null, active: false, changing: false, entering: null,
canContact: true, canMission: true, snapshotCaptured: false,
});
});
test('a pending Context tab cannot reopen a disposed panel', async (t) => {
const tab = new Button();
const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab });
let release;
controls._selectContextMode = () => new Promise((resolve) => { release = resolve; });
tab.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['claim']);
controls.stop();
release(true);
await turn();
tab.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['claim']);
});
test('stopping during installations enable prevents the delayed search', async (t) => {
const button = new Button();
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
let release;
controls._dataManager = {
layers: new Map([['military-installations', {}]]),
setEnabled: () => new Promise((resolve) => { release = resolve; }),
isEnabled: () => true,
};
controls.installations.searchNearby = () => assert.fail('search started after stop');
button.dispatchEvent(new Event('click'));
controls.stop();
release(true);
await turn();
assert.equal(calls.length, 0);
assert.equal(button.getAttribute('aria-busy'), 'true', 'disposed controls are not mutated by completion');
});
test('a late installations search cannot publish a notice after disposal', async (t) => {
const button = new Button();
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
let release;
controls._dataManager = {
layers: new Map([['military-installations', {}]]),
setEnabled: async () => true, isEnabled: () => true,
};
controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; });
button.dispatchEvent(new Event('click'));
await turn();
controls.stop();
release(true);
await turn();
assert.deepEqual(calls, []);
});
test('reconnecting and disposal release each manager subscription exactly once', (t) => {
const { controls } = fixture(t);
const counts = new Map();
const manager = (name) => Object.fromEntries([
'subscribe', 'subscribeVisibilityRequests', 'addVisibilityGuard', 'subscribeBeforeDestroy',
].map((method) => [method, () => () => {
const key = `${name}:${method}`;
counts.set(key, (counts.get(key) || 0) + 1);
}]));
controls.connect(manager('a'));
controls.connect(manager('b'));
assert.equal(counts.size, 4);
controls.stop();
controls.disconnect();
controls.disconnect();
controls.connect(manager('c'));
assert.equal(counts.size, 8);
assert.ok([...counts.values()].every((count) => count === 1));
});
test('a rejected tracked reaction settles without an unhandled rejection', async (t) => {
const { controls } = fixture(t);
const failure = new Error('source unavailable');
const pending = controls._trackContextLayerReaction(Promise.reject(failure));
await assert.rejects(pending, failure);
await controls._waitForContextLayerSettlement();
assert.equal(controls._contextLayerReactionPromises.size, 0);
});
+187
View File
@@ -0,0 +1,187 @@
import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js';
export function _handleContextLayerChange(change) {
if (this.destroyed) return;
if (change?.layerId === 'radio' && [
'visibility-transition',
'visibility',
'visibility-cancelled',
'visibility-failed',
].includes(change.type)) {
this.actions.refreshRadio();
}
if (change?.type === 'visibility-transition') return;
// The effective mode must be read BEFORE the entering flag is cleared:
// the entry layer's own enable event is the one that clears it, and the
// session bookkeeping below needs to know a mode was being entered.
const effectiveContextMode = this._contextModeEntering || this._contextMode;
if (change?.type === 'visibility-cancelled') {
const cancellationDisposition = spaceMissionEntryCancellationDisposition({
change,
});
if (
this._contextModeDeferredEntryIntent?.layerId === change.layerId
&& this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch
) {
if (cancellationDisposition !== 'replacement') {
this._contextModeDeferredEntryIntent = null;
}
}
if (cancellationDisposition === 'replacement') {
this._contextModeEntering = 'space-missions';
const entryIntent = this._contextModeEntryIntent;
if (
entryIntent?.generation === this._contextModeGeneration
&& entryIntent.layerId === change.layerId
&& entryIntent.intentEpoch === change.intentEpoch
) {
this._contextModeReplacementIntent = {
generation: entryIntent.generation,
layerId: change.layerId,
intentEpoch: change.successorIntentEpoch,
};
}
} else if (cancellationDisposition === 'restore') {
this._contextModeEntering = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
if (this._contextSessionSnapshot && !this._contextModeChanging) {
this._contextMode = null;
void this._trackContextLayerReaction(this._runUserFacingContextAction(
async (notificationToken) => {
await this._restoreContextSessionAfterLayerSettles(
change.layerId,
{ notificationToken },
);
return true;
},
'Space Missions cancellation could not restore the previous layer state',
));
}
}
this._syncContextModeButtons();
return;
}
if (
change?.layerId === 'rocket-launches'
&& ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type)
) {
this._contextModeEntering = null;
}
if (change?.type === 'visibility-blocked') {
if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) {
this.showToast(change.reason || 'That layer is unavailable in the current Context mode');
}
this._syncContextModeButtons();
return;
}
if (change?.type === 'visibility-failed') {
const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`;
// A failed direct Context-shell START has already had its siblings
// cleared by the visibility guard. Wait outside the synchronous manager
// notification for this queue to settle, then reconcile the complete
// snapshot, including an uncertain failed shell.
const needsDeferredShellRestore = (
['military-awareness', 'rocket-launches'].includes(change.layerId)
&& change.enabled
&& this._contextSessionSnapshot
&& !this._contextModeChanging
);
if (needsDeferredShellRestore) {
this._contextMode = null;
void this._trackContextLayerReaction(this._runUserFacingContextAction(
async (notificationToken) => {
await this._restoreContextSessionAfterLayerSettles(
change.layerId,
{ notificationToken },
);
// The wrapper owns failure announcements. On a successful rollback
// announce the original activation failure here so the same direct
// action still produces exactly one accessible notification.
this.showToast(failureMessage);
return true;
},
failureMessage,
));
} else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) {
this.showToast(failureMessage);
}
this._syncContextModeButtons();
return;
}
if (change?.type === 'visibility-will-change') {
// Explicit entry capture happens on the synchronous visibility-requested
// boundary. Keeping this later branch side-effect free prevents an
// awaited Clear/guard from replacing that authoritative pre-entry view.
return;
}
// Session bookkeeping must run BEFORE any exit path below: the exit
// handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes
// a layer resurrected against the user's explicit disable.
recordContextSessionUserChange({
snapshot: this._contextSessionSnapshot,
change,
effectiveContextMode,
});
recordContextRestoreExplicitChange({
restoreState: this._contextRestoreState,
change,
});
if (shouldExitContextForLayerChange({
contextMode: this._contextMode,
globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'),
change,
})) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
this._deactivateContextForLayerChange({ notificationToken })
)));
return;
}
if (!this._contextModeChanging) {
if (change.layerId === 'military-awareness') {
// The coordinator remains manager-addressable for restoration and
// programmatic routes, but Contacts is selected only from the
// dedicated right-side Global Context chooser.
this._contextMode = change.enabled
? null
: (this._contextMode === 'flights' ? null : this._contextMode);
if (change.enabled) {
this._syncContextModeButtons();
} else if (this._contextSessionSnapshot) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
this._deactivateContextForLayerChange({ notificationToken })
)));
}
} else if (change.layerId === 'rocket-launches') {
const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId)
|| this._contextMode === 'space-missions'
|| effectiveContextMode === 'space-missions';
if (!ownsContextEntry) return;
this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode);
if (change.enabled) {
this._syncContextModeButtons();
} else if (this._contextSessionSnapshot) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
this._deactivateContextForLayerChange({ notificationToken })
)));
}
} else if (
this._contextMode === 'flights'
&& ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId)
&& !change.enabled
) {
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
this._deactivateContextForLayerChange({ notificationToken })
)));
}
}
if (this.cockpitView?.active && !cockpitEntryAllowed({
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
})) {
this.cockpitView.exit({ restoreTracking: false });
}
this._syncContextModeButtons();
}
+34
View File
@@ -0,0 +1,34 @@
export function _syncContextModeButtons() {
if (this.destroyed) return;
const flightsActive = this._contextMode === 'flights';
const missionsActive = this._contextMode === 'space-missions';
const panel = this._globalContextPanel;
panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
panel?.setAttribute('data-context-mode', this._contextMode || 'none');
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive));
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive));
const transitionBusy = Boolean(this._contextModeChanging);
// Both Context choices stay in the ordinary Tab sequence. Arrow keys still
// provide tablist navigation, but must not be the only way to reach Space
// Missions from the keyboard. Semantic busy state keeps them perceivable
// while synchronous click guards prevent a second transition.
for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) {
if (!button) continue;
button.disabled = false;
button.tabIndex = 0;
button.setAttribute('aria-disabled', String(transitionBusy));
button.setAttribute('aria-busy', String(transitionBusy));
}
if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive;
if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive;
if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive;
this.cockpitView?.syncEntry();
// Every _contextMode mutation funnels through here; the sync no-ops until
// the transaction settles, so this is the activation/deactivation edge.
this.actions.syncDetection();
this.actions.scheduleLayout();
}
+162
View File
@@ -0,0 +1,162 @@
import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js';
export function _trackContextLayerReaction(promise) {
const tracked = Promise.resolve(promise);
this._contextLayerReactionPromises.add(tracked);
void tracked.then(
() => this._contextLayerReactionPromises.delete(tracked),
() => this._contextLayerReactionPromises.delete(tracked),
);
return tracked;
}
export async function _waitForContextLayerSettlement() {
while (this._contextLayerReactionPromises.size > 0) {
await Promise.allSettled([...this._contextLayerReactionPromises]);
}
}
export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
if (!this._dataManager || this._contextSessionSnapshot) return;
const params = {};
for (const layerId of ['military-awareness', 'satellites']) {
const value = this._dataManager.getLayerParams(layerId);
if (value) params[layerId] = value;
}
this._contextSessionSnapshot = {
enabledLayerIds: contextSnapshotLayerIds(
this._dataManager.getEnabledLayerIds(),
this._contextRestoreState?.enabledLayerIds,
excludeLayerIds,
),
userAdded: new Set(),
userRemoved: new Set(),
params,
};
}
export async function _restoreContextSession({
excludeLayerIds = [],
notificationToken = null,
signal = null,
} = {}) {
const snapshot = this._contextSessionSnapshot;
if (!snapshot || !this._dataManager) return;
// Clear the stored session before emitting restore notifications so none
// of those transitions can be mistaken for a fresh Context entry.
this._contextSessionSnapshot = null;
const restoreState = {
enabledLayerIds: contextRestoreLayerIds(snapshot),
explicitLayerStates: new Map(),
};
this._contextRestoreState = restoreState;
for (const [layerId, params] of Object.entries(snapshot.params)) {
this._dataManager.setLayerParams(layerId, params);
}
let restoreError = null;
const restoreSnapshot = async (restoreSignal = null) => {
// Contacts owns the dependency intents it starts. Settle that
// coordinator before restoring the remaining snapshot, otherwise its
// dependency releases can supersede the restore's same-target requests
// and make a valid Contacts-to-Missions handoff look like a failure.
const contactsCoordinatorId = 'military-awareness';
const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId)
&& this._dataManager.isEffectivelyEnabled(contactsCoordinatorId);
if (settleContactsCoordinator) {
const coordinatorSettled = await this._dataManager.setEnabled(
contactsCoordinatorId,
false,
{
origin: 'context-restore',
...(notificationToken ? { notificationToken } : {}),
...(restoreSignal ? { signal: restoreSignal } : {}),
},
);
if (coordinatorSettled === false) {
const error = new Error('Failed to settle Contacts before restoring Context');
error.failedLayerIds = [contactsCoordinatorId];
throw error;
}
}
await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, {
origin: 'context-restore',
excludeLayerIds: settleContactsCoordinator
? [...excludeLayerIds, contactsCoordinatorId]
: excludeLayerIds,
notificationToken,
...(restoreSignal ? { signal: restoreSignal } : {}),
});
};
try {
await restoreSnapshot(signal);
} catch (error) {
restoreError = error;
// A caller abort can arrive after only part of the exact restore has
// settled. Finish that same target without the stale caller signal while
// this restoreState still records newer explicit intents; replay below
// then gives those newer intents final authority.
if (signal?.aborted && !restoreState.cancelled) {
try {
await restoreSnapshot(null);
restoreError = null;
} catch (compensationError) {
restoreError = mergeContextTransitionErrors(restoreError, compensationError);
}
}
} finally {
if (this._contextRestoreState === restoreState) this._contextRestoreState = null;
}
// Clear Selected Layers owns a newer global OFF intent. A restore that was
// already awaiting lifecycle work must not replay its captured companion
// intent or recreate the discarded session after Clear invalidates it.
if (restoreState.cancelled) return;
// A direct Radio command may finish after restore has already copied its
// target and queued the opposite state. Replay that newer intent only
// after the stale queue drains; the replay origin cannot recurse here.
const replaySignal = signal?.aborted ? null : signal;
const replayError = await settleContextIntentReplay({
restoreState,
setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled(
layerId,
enabled,
{ ...options, ...(replaySignal ? { signal: replaySignal } : {}) },
),
notificationToken,
});
restoreError = mergeContextTransitionErrors(restoreError, replayError);
if (restoreError && !this._contextSessionSnapshot) {
// Keep the exact still-pending target so a later exit/teardown can retry
// instead of silently losing the user's pre-Context layer state.
this._contextSessionSnapshot = {
enabledLayerIds: new Set(restoreState.enabledLayerIds),
userAdded: new Set(),
userRemoved: new Set(),
params: snapshot.params,
};
}
if (restoreError) throw restoreError;
}
/*
* Cross-mode cancellation and failure deliberately settle on Context OFF.
*
* A reinstatement transaction lived here for three review rounds and was
* removed on purpose. Restoring the prior mode is genuinely racy: the prior
* mode has to be read before the teardown, but a second request arriving
* while the first reinstatement is mid-activation reads `_contextMode` as
* null and inherits nothing, so two overlapping cancellations still land on
* OFF — and the only fix is a cross-transaction "logical prior mode" chain,
* which is new shared mutable state read while an earlier transaction is
* still awaiting. That trades a rare wrong resting state for a permanent
* interleaving hazard.
*
* The defect that started this was the LIE, not the OFF: the transition
* claimed to have cancelled cleanly while silently leaving Context off. So
* the resting state stays OFF and is REPORTED as such, with the failed layer
* ids preserved. A restore feature can be rebuilt post-launch on the
* generation discipline the surrounding transaction already follows.
*/
export async function _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) {
await this._dataManager?.waitForLayerSettled?.(layerId);
return this._restoreContextSession({ notificationToken });
}
+123
View File
@@ -0,0 +1,123 @@
import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js';
export function connectContextManager(manager) {
this.disconnect();
if (this.destroyed) return;
this._dataManager = manager || null;
this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => {
if (String(change?.type || '').startsWith('visibility')) this._handleContextLayerChange(change);
});
if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') {
this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => {
if (this.destroyed) return;
if (shouldCaptureContextSession(change)) {
// This event is synchronous with intent publication, before an
// awaited guard or Clear All can alter the rest of the layer set.
// Manager effective visibility already includes both the new entry
// intent and Clear's reserved OFF baseline.
this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] });
if (shouldDeferContextEntryDuringClear({
change,
clearInFlight: Boolean(this._clearSelectedLayersPromise),
})) {
this._contextModeDeferredEntryIntent = {
layerId: change.layerId,
intentEpoch: change.intentEpoch,
origin: change.origin,
};
this._contextModeEntering = 'space-missions';
this._syncContextModeButtons();
}
} else if (
change?.layerId === 'rocket-launches'
&& change.enabled === false
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
) {
this._contextModeDeferredEntryIntent = null;
if (this._clearSelectedLayersPromise) {
this._contextSessionSnapshot = null;
this._contextModeEntering = null;
this._syncContextModeButtons();
}
}
});
}
if (typeof this._dataManager?.addVisibilityGuard === 'function') {
this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => {
if (this.destroyed) return null;
const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId;
const reason = contextLayerEnableBlockReason({
contextMode: this._contextModeEntering || this._contextMode,
change,
layerName,
});
if (reason) return reason;
if (
change.enabled
&& ['military-awareness', 'rocket-launches'].includes(change.layerId)
&& shouldCaptureContextSession(change)
&& (
!this._contextModeChanging
|| (
change.layerId === 'rocket-launches'
&& this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch
)
)
) {
const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null;
const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch;
// A deferred entry owns the state after Clear settles. Restoring
// Clear's transient busy flag here would leave Context stuck.
const priorChanging = deferredClearEntry ? false : this._contextModeChanging;
const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry');
const ownsNotificationToken = !change.notificationToken;
if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.add(notificationToken);
}
this._contextModeEntering = entryMode;
this._contextModeChanging = true;
try {
if (deferredClearEntry) {
await this._clearSelectedLayersManagerPromise;
if (this.destroyed) return false;
if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false;
this._contextModeDeferredEntryIntent = null;
}
await this._clearLayersOutsideContextMode(entryMode, { notificationToken });
if (this.destroyed) return false;
} catch (error) {
this._contextModeEntering = null;
console.warn(`[Context] ${change.layerId} isolation failed`, error);
try {
await this._restoreContextSession({
excludeLayerIds: [change.layerId],
notificationToken,
});
} catch (restoreError) {
console.warn(`[Context] ${change.layerId} rollback failed`, restoreError);
}
return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`;
} finally {
if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.delete(notificationToken);
}
if (!this.destroyed) settleContextModeChange(this, priorChanging);
}
}
return null;
});
}
if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') {
this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => {
if (this.destroyed || !this._contextSessionSnapshot) return;
await runWithContextModeChanging(this, async () => {
this._contextMode = null;
this.cockpitView?.exit({ restoreTracking: false });
this._syncContextModeButtons();
await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] });
});
});
}
this._syncContextModeButtons();
}
+215
View File
@@ -0,0 +1,215 @@
import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js';
export async function _selectContextMode(mode, { notificationToken = null, signal = null } = {}) {
if (this.destroyed || !this._dataManager) return false;
if (this._clearSelectedLayersPromise) return false;
this._contextTransitionFailedLayerIds = [];
const generation = ++this._contextModeGeneration;
const isCurrent = () => !this.destroyed && generation === this._contextModeGeneration;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._contextModeChanging = true;
this._syncContextModeButtons();
try {
if (mode !== 'flights' && this.cockpitView?.active) {
this.cockpitView.exit({ restoreTracking: false });
}
if (!mode) {
this._contextMode = null;
this._syncContextModeButtons();
await this._restoreContextSession({ notificationToken, signal });
return isCurrent();
}
// A cross-mode switch dismantles the prior mode BEFORE the new one is
// committed. If the caller aborts in that window the switch never lands,
// and the resting state is Context OFF — reported as such by
// setContextMode rather than dressed up as a clean cancellation. See the
// note above _restoreContextSessionAfterLayerSettles.
const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode);
if (crossModeSwitch) {
this._contextMode = null;
await this._restoreContextSession({ notificationToken, signal });
if (!isCurrent()) return false;
if (signal?.aborted) return false;
}
this._captureContextSessionSnapshot();
this._contextMode = mode;
this._syncContextModeButtons();
// Replay isolation must settle before Space Missions starts. Contacts
// keeps its non-dependency teardown in the background so slow source
// shutdown does not delay cockpit entry.
try {
await this._clearLayersOutsideContextMode(mode, { notificationToken, signal });
} catch (error) {
if (!isCurrent()) return false;
let transitionError = error;
console.warn(`[Context] ${mode} isolation failed`, error);
this._contextMode = null;
this._syncContextModeButtons();
try {
await this._restoreContextSession({ notificationToken });
} catch (restoreError) {
transitionError = mergeContextTransitionErrors(transitionError, restoreError);
this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])];
throw transitionError;
}
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])];
return false;
}
if (!isCurrent()) return false;
// Entry is one transaction: isolation succeeded above, so a failed mode
// activation must roll the cleared layers back instead of stranding the
// user in a half-entered mode with an orphaned snapshot.
const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches';
if (mode === 'flights') {
this._dataManager.setLayerParams('military-awareness', { passive: false });
}
let activated = false;
let activationError = null;
let activationIntent = null;
let terminalIntentOutcome = null;
try {
activationIntent = this._dataManager._setEnabledWithIntent(
entryLayerId,
true,
{ notificationToken, ...(signal ? { signal } : {}) },
);
this._contextModeEntryIntent = {
generation,
layerId: entryLayerId,
intentEpoch: activationIntent.intentEpoch,
};
activated = await activationIntent.promise;
terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.(
entryLayerId,
activationIntent.intentEpoch,
);
} catch (error) {
activationError = error;
}
if (!isCurrent()) return false;
let replacementIntent = mode === 'space-missions'
&& this._contextModeReplacementIntent?.generation === generation
&& this._contextModeReplacementIntent.layerId === entryLayerId
? this._contextModeReplacementIntent
: null;
while (replacementIntent) {
const outcome = await this._dataManager._waitForVisibilityIntent?.(
entryLayerId,
replacementIntent.intentEpoch,
);
terminalIntentOutcome = outcome;
if (!isCurrent()) return false;
const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch
&& outcome.enabled === true
&& outcome.succeeded === true;
if (replacementOwnsMode) {
this._contextModeEntering = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._syncContextModeButtons();
return true;
}
const successorEpoch = outcome?.cancellationReason === 'superseded'
&& outcome.successorEnabled === true
&& Number.isInteger(outcome.successorIntentEpoch)
&& outcome.successorIntentEpoch > replacementIntent.intentEpoch
? outcome.successorIntentEpoch
: null;
replacementIntent = successorEpoch === null ? null : {
generation,
layerId: entryLayerId,
intentEpoch: successorEpoch,
};
}
if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) {
const cancelledAndSettled = terminalIntentOutcome?.succeeded === false
&& ['caller-abort', 'resource-abort', 'superseded'].includes(
terminalIntentOutcome.cancellationReason,
);
let transitionError = null;
if (!cancelledAndSettled) {
transitionError = activationError instanceof Error
? activationError
: new Error(`Context activation failed for: ${entryLayerId}`);
transitionError.failedLayerIds = [...new Set([
...(transitionError.failedLayerIds || []),
entryLayerId,
])];
this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds];
console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled');
}
this._contextMode = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._syncContextModeButtons();
try {
await this._restoreContextSession({
excludeLayerIds: [entryLayerId],
notificationToken,
});
} catch (restoreError) {
transitionError = mergeContextTransitionErrors(transitionError, restoreError);
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])];
throw transitionError;
}
// `null` means the requested entry was cancelled and its exact rollback
// completed. The action wrapper treats that as a silent non-commit,
// while callers still require literal `true` before expanding Context.
return cancelledAndSettled ? null : false;
}
this._contextModeEntryIntent = null;
return true;
} finally {
if (isCurrent()) {
this._contextModeChanging = false;
this._syncContextModeButtons();
}
}
}
export async function _deactivateContextForLayerChange({ notificationToken = null } = {}) {
this._contextModeGeneration += 1;
this._contextModeChanging = true;
this._contextMode = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false });
this._syncContextModeButtons();
try {
await this._restoreContextSession({ notificationToken });
} finally {
this._contextModeChanging = false;
this._syncContextModeButtons();
}
}
export async function _clearLayersOutsideContextMode(
mode = null,
{ notificationToken = null, signal = null } = {},
) {
const allowed = contextAllowedLayerIds(mode);
const pending = [];
for (const [layerId] of this._dataManager.layers || []) {
// Effective visibility: a disallowed layer still mid-ENABLING must be
// isolated too, or it settles ON inside the exclusive mode.
if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) {
pending.push({
layerId,
transition: this._dataManager.setEnabled(layerId, false, {
notificationToken,
...(signal ? { signal } : {}),
}),
});
}
}
const results = await Promise.all(pending.map(({ transition }) => transition));
const failed = pending
.filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId))
.map(({ layerId }) => layerId);
if (failed.length > 0) {
const error = new Error(`Context isolation failed for: ${failed.join(', ')}`);
error.failedLayerIds = failed;
throw error;
}
}