diff --git a/CHANGELOG.md b/CHANGELOG.md index 9137393db..d3b655b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 4e705a627..bea9fd09a 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fcce42e0e..d2d624a2b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/package.json b/package.json index 62c51ed5e..4cca89785 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 83d976631..1b46e4339 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 1f67ea7db..48d350472 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -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, diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs index 189de40bc..453009929 100644 --- a/scripts/qa-layer-panel.mjs +++ b/scripts/qa-layer-panel.mjs @@ -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) && diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 1d4970d9f..4c9edcd86 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -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); diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 9aabdd75c..e6a8fa3ab 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -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, diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 39b1b27d0..e92ec2a56 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -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( diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index dc3201310..af93a0c65 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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', () => { diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 98b2401ef..4307cc182 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -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( diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 4bc5f6704..a1555c6e7 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -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\]/, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 092d15fa3..484770e2d 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -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*=/); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index fd076cbcf..22f29c23b 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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'], diff --git a/src/ui.js b/src/ui.js index 844bc3781..72e5f8a4c 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,3 +1,4 @@ +import { ContextControls } from './ui/context.js'; import { CctvControls } from './ui/cctv.js'; import { RadioControls } from './ui/radio.js'; import { LocationControls, LocationSearch } from './ui/location.js'; @@ -82,29 +83,8 @@ import { reduceTrafficSyncFeedback, } from './loadingFeedback.js'; import { setSplitFlapText } from './splitFlap.js'; -import { - cockpitEntryAllowed, - contextAllowedLayerIds, - contextLayerEnableBlockReason, - contextRestoreLayerIds, - contextSnapshotLayerIds, - isExplicitUserIntentOrigin, - mergeContextTransitionErrors, - recordContextRestoreExplicitChange, - recordContextSessionUserChange, - runWithContextModeChanging, - settleContextModeChange, - settleContextIntentReplay, - settleUserFacingContextAction, - shouldCaptureContextSession, - shouldDeferContextEntryDuringClear, - shouldExitContextForLayerChange, - spaceMissionEntryCancellationDisposition, - contextModeWord, -} from './contextModePolicy.js'; -import { - shouldExpandGlobalContextPanel, -} from './rightRailPolicy.js'; +import { cockpitEntryAllowed } from './contextModePolicy.js'; + import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, @@ -350,7 +330,6 @@ const STYLE_STATUS_LABELS = { snow: 'SNOW', }; - /** * Central UI orchestrator for the God's Eye View application. * @@ -2034,8 +2013,6 @@ export class StyleManager { this._shareTrackingAcquiringKey = null; this._shareTrackingNoticeGeneration = 0; this._globeResetPromise = null; - this._clearSelectedLayersPromise = null; - this._clearSelectedLayersManagerPromise = null; this._dataManager = null; this._leftStackLayoutFrame = null; @@ -2173,21 +2150,6 @@ export class StyleManager { this._contextModeStandby = document.getElementById('context-mode-standby'); this._contextFlightsView = document.getElementById('context-flights-view'); this._contextMissionsView = document.getElementById('context-missions-view'); - 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._dataManagerBeforeDestroyUnsubscribe = null; - this._dataManagerVisibilityGuardUnsubscribe = null; - this._dataManagerVisibilityRequestUnsubscribe = null; this._installationsSearchBtn = document.getElementById('installations-search-btn'); this._leftPanelStack = document.getElementById('left-panel-stack'); this._cctvEnableBtn = document.getElementById('cctv-enable-btn'); @@ -2622,7 +2584,6 @@ export class StyleManager { get _bloomStage() { return this._visualEffects.bloomStage; } get _sharpenStage() { return this._visualEffects.sharpenStage; } - /** Advance camera authority and settle any older search UI immediately. */ _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) { this._navigationGeneration += 1; @@ -3617,7 +3578,6 @@ export class StyleManager { this._trafficSyncChip.classList.toggle('visible', presentation.visible); } - /** * Initializes panel collapse buttons and restores persisted collapsed state. * Also sets up hover-expand behavior for the style presets and location bar panels. @@ -3933,18 +3893,6 @@ export class StyleManager { * @returns {void} */ attachDataManager(dataManager) { - if (this._dataManagerBeforeDestroyUnsubscribe) { - this._dataManagerBeforeDestroyUnsubscribe(); - this._dataManagerBeforeDestroyUnsubscribe = null; - } - if (this._dataManagerVisibilityGuardUnsubscribe) { - this._dataManagerVisibilityGuardUnsubscribe(); - this._dataManagerVisibilityGuardUnsubscribe = null; - } - if (this._dataManagerVisibilityRequestUnsubscribe) { - this._dataManagerVisibilityRequestUnsubscribe(); - this._dataManagerVisibilityRequestUnsubscribe = null; - } this._dataManager = dataManager || null; this.hud.attachDataManager(this._dataManager); this._updateTrafficSyncChip(); @@ -3952,123 +3900,14 @@ export class StyleManager { this._dataManagerUnsubscribe(); this._dataManagerUnsubscribe = null; } + this._contextControls.connect(this._dataManager); if (typeof this._dataManager?.subscribe === 'function') { this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => { - if (String(change?.type || '').startsWith('visibility')) { - this._handleContextLayerChange(change); - } this._loadingFeedbackEvent = change; this._updateGlobalLoadingFeedback(performance.now()); }); } this._updateGlobalLoadingFeedback(performance.now()); - if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { - this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => { - 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) => { - 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._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; - this._contextModeDeferredEntryIntent = null; - } - await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); - } 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); - } - settleContextModeChange(this, priorChanging); - } - } - return null; - }); - } - if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') { - this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => { - if (!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(); this._cctvControls.connect(); this._radioControls.connect(); @@ -4176,712 +4015,40 @@ export class StyleManager { showAfterStartupCover(); } + get _contextMode() { return this._contextControls?._contextMode ?? null; } + get _contextModeChanging() { return this._contextControls?._contextModeChanging ?? false; } + get _preservePanelStateDuringLayerClear() { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _initGlobalContextPanel() { - const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean); - contextTabs.forEach((tab, index) => tab.addEventListener('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._globalContextFlightsBtn?.addEventListener('click', () => { - if (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 (nextMode && shouldExpandGlobalContextPanel({ - action: 'contacts', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._globalContextMissionsBtn?.addEventListener('click', () => { - if (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 (nextMode && shouldExpandGlobalContextPanel({ - action: 'space-missions', - explicitUserAction: true, - succeeded: succeeded === true, - })) this.setPanelCollapsed('global-context-panel', false, { explicit: true }); - }); - }); - this._installationsSearchBtn?.addEventListener('click', () => { - if (!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 (enabled === false || !this._dataManager.isEnabled('military-installations')) return false; - const searched = await militaryInstallationsLayer.searchNearby?.(); - if (searched === false) return false; - const stats = militaryInstallationsLayer.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(() => { - button.setAttribute('aria-disabled', 'false'); - button.setAttribute('aria-busy', 'false'); - }); + this._contextControls = new ContextControls({ + elements: { + _globalContextPanel: document.getElementById('global-context-panel'), + _globalContextFlightsBtn: this._globalContextFlightsBtn, + _globalContextMissionsBtn: this._globalContextMissionsBtn, + _contextModeStandby: this._contextModeStandby, + _contextFlightsView: this._contextFlightsView, + _contextMissionsView: this._contextMissionsView, + _installationsSearchBtn: this._installationsSearchBtn, + }, + installations: militaryInstallationsLayer, + actions: { + getCockpit: () => this.cockpitView, + refreshRadio: () => this._renderRadioState(radioLayer.getUIState()), + claimVisualAuthority: () => this.shareLinkManager?.claimRestoreLane?.('visual'), + syncDetection: () => this._syncContactsDetection(), + scheduleLayout: () => this._scheduleRightPanelLayout(), + setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + showToast: (message) => { if (!this._disposed) this._showToast(message); }, + setClearBusy: (busy) => { if (!this._disposed) this._clearLayersControl?.setBusy(busy); }, + }, }); } - async _runUserFacingContextAction( - operation, - message = 'Context could not restore every layer; try again', - { falseIsFailure = true } = {}, - ) { - const notificationToken = Symbol('user-facing-context-action'); - this._userFacingContextNotificationTokens.add(notificationToken); - try { - return await settleUserFacingContextAction({ - operation: () => operation(notificationToken), - falseIsFailure, - onFailure: (error) => { - console.warn('[Context] user-facing transition failed', error); - this._showToast(message); - }, - }); - } finally { - this._userFacingContextNotificationTokens.delete(notificationToken); - } - } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); } - _trackContextLayerReaction(promise) { - const tracked = Promise.resolve(promise); - this._contextLayerReactionPromises.add(tracked); - void tracked.finally(() => this._contextLayerReactionPromises.delete(tracked)); - return tracked; - } + _waitForContextLayerSettlement(...args) { return this._contextControls?._waitForContextLayerSettlement(...args); } - async _waitForContextLayerSettlement() { - while (this._contextLayerReactionPromises.size > 0) { - await Promise.allSettled([...this._contextLayerReactionPromises]); - } - } - - _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, - }; - } - - async _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. - */ - - async _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) { - await this._dataManager?.waitForLayerSettled?.(layerId); - return this._restoreContextSession({ 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. - */ - _claimContextVisualAuthority() { - this.shareLinkManager?.claimRestoreLane?.('visual'); - } - - async _selectContextMode(mode, { notificationToken = null, signal = null } = {}) { - if (!this._dataManager) return false; - if (this._clearSelectedLayersPromise) return false; - this._contextTransitionFailedLayerIds = []; - const generation = ++this._contextModeGeneration; - const isCurrent = () => 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(); - } - } - } - - async _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(); - } - } - - async _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; - } - } - - _handleContextLayerChange(change) { - if (change?.layerId === 'radio' && [ - 'visibility-transition', - 'visibility', - 'visibility-cancelled', - 'visibility-failed', - ].includes(change.type)) { - this._renderRadioState(radioLayer.getUIState()); - } - 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(); - } - - _syncContextModeButtons() { - const flightsActive = this._contextMode === 'flights'; - const missionsActive = this._contextMode === 'space-missions'; - const panel = document.getElementById('global-context-panel'); - 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._syncContactsDetection(); - this._scheduleRightPanelLayout(); - } + _syncContextModeButtons(...args) { return this._contextControls?._syncContextModeButtons(...args); } /** Wire the independent Radio companion controls. */ _initRadioPanel() { @@ -5020,8 +4187,6 @@ export class StyleManager { }); } - - /** * Toggles the CCTV layer enabled state. When enabling and no camera is active, * auto-focuses on the nearest camera. @@ -5055,7 +4220,6 @@ export class StyleManager { return true; } - /** * Returns the versioned localStorage key for a panel's saved position. * @param {string} panelId - DOM id of the panel. @@ -6104,17 +5268,7 @@ export class StyleManager { * Reads global context mode state for voice/state-sync consumers. * @returns {{mode: 'flights'|'space-missions'|null, active: boolean, changing: boolean, entering: 'flights'|'space-missions'|null, snapshotCaptured: boolean}} */ - 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), - }; - } + getContextModeState(...args) { return this._contextControls?.getContextModeState(...args); } /** * Sets global context mode (Contacts / Space Missions / off) for voice. @@ -6130,107 +5284,7 @@ export class StyleManager { * operator and must stay inert, so they pass `false`. * @returns {Promise<{ok:boolean, mode:'flights'|'space-missions'|null, active:boolean, action:string, error?:string}>} */ - async setContextMode(mode, { - notificationToken = null, - signal = null, - isCurrent = null, - claimVisualAuthority = true, - } = {}) { - const requestIsCurrent = () => !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(), - }; - } - } + setContextMode(...args) { return this._contextControls?.setContextMode(...args); } /** * Returns cockpit status for voice/state sync and navigation operations. @@ -7441,64 +6495,7 @@ export class StyleManager { * established disable lifecycle. * @returns {Promise} Aggregate manager lifecycle truth for the batch. */ - clearSelectedLayers() { - 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._clearLayersControl?.setBusy(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._clearLayersControl?.setBusy(false); - this._preservePanelStateDuringLayerClear = false; - this._clearSelectedLayersManagerPromise = null; - this._clearSelectedLayersPromise = null; - }); - this._clearSelectedLayersPromise = operation; - return operation; - } + clearSelectedLayers(...args) { return this._contextControls?.clearSelectedLayers(...args); } /** * Release every camera owner and return to the canonical full-globe frame. @@ -7942,7 +6939,7 @@ export class StyleManager { // would. Without this, a reinstatement already past its awaits could // re-enable a mode's entry layer and republish `_contextMode` while the // rest of teardown is tearing those very layers down. - this._contextModeGeneration += 1; + this._contextControls.stop(); this._stampNavigation(); // Close camera-entry seams synchronously. Context restoration may await // layer work, so leaving these listeners attached until afterward lets a @@ -7957,9 +6954,7 @@ export class StyleManager { this._navigationOwnerChangedRemover = null; this._removeNavigationAuthorityListener?.(); this._removeNavigationAuthorityListener = null; - this._contextModeChanging = true; - this._contextMode = null; - await this._restoreContextSession(); + await this._contextControls.restoreForDisposal(); // IR boost teardown BEFORE detaching the data manager: restore fog and // un-boost both aircraft layers so a surviving viewer or replacement // manager doesn't inherit sensor state (review P2, 2026-08-16). @@ -7984,12 +6979,7 @@ export class StyleManager { this._cockpitDisplayScrollHandler = null; for (const record of this._cockpitDisplayPortalRecords) record.anchor.remove(); this._cockpitDisplayPortalRecords = []; - this._dataManagerBeforeDestroyUnsubscribe?.(); - this._dataManagerBeforeDestroyUnsubscribe = null; - this._dataManagerVisibilityGuardUnsubscribe?.(); - this._dataManagerVisibilityGuardUnsubscribe = null; - this._dataManagerVisibilityRequestUnsubscribe?.(); - this._dataManagerVisibilityRequestUnsubscribe = null; + this._contextControls.disconnect(); this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; diff --git a/src/ui/context.js b/src/ui/context.js new file mode 100644 index 000000000..76a6e9025 --- /dev/null +++ b/src/ui/context.js @@ -0,0 +1 @@ +export { ContextControls } from './contextControls.js'; diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js new file mode 100644 index 000000000..4be137f07 --- /dev/null +++ b/src/ui/contextActions.js @@ -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; + } diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js new file mode 100644 index 000000000..50b1b9f0c --- /dev/null +++ b/src/ui/contextBindings.js @@ -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'); + }); + }); + } diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js new file mode 100644 index 000000000..d8026e8df --- /dev/null +++ b/src/ui/contextControls.js @@ -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); } +} diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs new file mode 100644 index 000000000..77bbb929a --- /dev/null +++ b/src/ui/contextControls.test.mjs @@ -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); +}); diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js new file mode 100644 index 000000000..dc7003978 --- /dev/null +++ b/src/ui/contextLayerChanges.js @@ -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(); + } diff --git a/src/ui/contextPresentation.js b/src/ui/contextPresentation.js new file mode 100644 index 000000000..2c078304b --- /dev/null +++ b/src/ui/contextPresentation.js @@ -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(); + } diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js new file mode 100644 index 000000000..2b2376967 --- /dev/null +++ b/src/ui/contextSession.js @@ -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 }); + } diff --git a/src/ui/contextSubscriptions.js b/src/ui/contextSubscriptions.js new file mode 100644 index 000000000..54e5c31bb --- /dev/null +++ b/src/ui/contextSubscriptions.js @@ -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(); +} + diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js new file mode 100644 index 000000000..09b8f711c --- /dev/null +++ b/src/ui/contextTransactions.js @@ -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; + } + }