From 3e078ef49d96fc8fc951e0aacf59cc57fac54ca2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 01:41:01 -0700 Subject: [PATCH] style: format Context component modules --- scripts/format-scope.json | 12 +- scripts/qa-ui-disposal.mjs | 30 ++- src/ui/contextActions.js | 330 +++++++++++++----------- src/ui/contextBindings.js | 156 ++++++++---- src/ui/contextControls.js | 114 +++++++-- src/ui/contextControls.test.mjs | 75 ++++-- src/ui/contextLayerChanges.js | 376 +++++++++++++++------------ src/ui/contextPresentation.js | 72 +++--- src/ui/contextSession.js | 260 ++++++++++--------- src/ui/contextSubscriptions.js | 112 +++++--- src/ui/contextTransactions.js | 439 ++++++++++++++++++-------------- 11 files changed, 1175 insertions(+), 801 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d46a8d9bb..77e87bc49 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -181,5 +181,15 @@ "src/ui/cctvControls.test.mjs", "src/ui/cctvFrames.js", "src/ui/cctvPresentation.js", - "scripts/qa-camera-controls.mjs" + "scripts/qa-camera-controls.mjs", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextControls.test.mjs", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js" ] diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index e6a8fa3ab..6e75e8e26 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -58,12 +58,22 @@ try { counts.cctv++; return cctvUnsubscribe(); }; - const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe']; - const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function'); + 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?.(); }; + ui._contextControls[field] = () => { + counts[field]++; + return unsubscribe?.(); + }; } const resizeHandler = ui._windowResizeHandler; const removeEventListener = window.removeEventListener; @@ -86,8 +96,13 @@ try { resizeHandler && observed.includes('_commandDockTrayObserver'), ), - contextReleased: contextConnected && ui._contextControls.destroyed - && contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null), + 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, ), @@ -108,7 +123,10 @@ 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 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/ui/contextActions.js b/src/ui/contextActions.js index 4be137f07..6b08e5411 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -1,27 +1,30 @@ -import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js'; +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); - } + 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. @@ -41,159 +44,197 @@ export async function _runUserFacingContextAction( * restore-driven ones, which must stay eligible for the shared visual state. */ export function _claimContextVisualAuthority() { - this.actions.claimVisualAuthority(); - } + 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), - }; - } + 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, { +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 + } = {}, +) { + 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 transitioned = await this._selectContextMode(canonical, { notificationToken, signal }); - if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult(); + const result = await this._selectContextMode(null, { + notificationToken, + signal, + }); + if (result === null || (!requestIsCurrent() && result !== 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, + ok: result === 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 } : {}), - }), + ...(result === true + ? {} + : { error: 'Context mode transition did not complete' }), ...(this._contextTransitionFailedLayerIds?.length ? { failedLayerIds: [...this._contextTransitionFailedLayerIds] } : {}), }; - } catch (error) { - if (!requestIsCurrent()) { - return { - ...cancellationResult(), - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), - }; - } + } + const canonical = mode === 'contacts' ? 'flights' : mode; + if (!['flights', 'space-missions'].includes(canonical)) { return { ok: false, action: 'set_context_mode', - error: error?.message || 'Context mode transition failed', - ...(Array.isArray(error?.failedLayerIds) - ? { failedLayerIds: [...error.failedLayerIds] } - : {}), + 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, + if (this.destroyed) + return Promise.resolve({ + targetIds: [], + items: [], + clearedIds: [], + notClearedIds: [], + cancelled: true, }); - this._clearSelectedLayersManagerPromise = managerOperation; - const operation = managerOperation.then((result) => { + 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`); + 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'}`); + this.showToast( + `Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`, + ); } return result; - }).catch((error) => { + }) + .catch((error) => { console.warn('[Data] clear selected layers failed', error); this.showToast('Selected data layers could not be cleared'); return { @@ -203,7 +244,8 @@ export function clearSelectedLayers() { notClearedIds: [], error, }; - }).finally(() => { + }) + .finally(() => { this._userFacingContextNotificationTokens.delete(notificationToken); if (generation === this._contextModeGeneration) { this._contextModeChanging = false; @@ -214,6 +256,6 @@ export function clearSelectedLayers() { this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersPromise = null; }); - this._clearSelectedLayersPromise = operation; - return operation; - } + this._clearSelectedLayersPromise = operation; + return operation; +} diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index 50b1b9f0c..5d3f074ce 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -1,77 +1,121 @@ 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) => { + 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; + 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({ + }), + ); + 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.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({ + }); + 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.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, { + }); + 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'); - }); + }, + ); + 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 index d8026e8df..1c1729992 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -1,9 +1,25 @@ 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 { + _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 { + _runUserFacingContextAction, + _claimContextVisualAuthority, + getContextModeState, + setContextMode, + clearSelectedLayers, +} from './contextActions.js'; import { connectContextManager } from './contextSubscriptions.js'; /** Own Context transactions and controls through explicit layer and presentation ports. */ @@ -31,15 +47,31 @@ export class ContextControls { 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(); } + 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 }); + target?.addEventListener(type, callback, { + ...options, + signal: this.listeners.signal, + }); + } + connect(manager) { + return connectContextManager.call(this, manager); } - connect(manager) { return connectContextManager.call(this, manager); } disconnect() { - for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) { + for (const field of [ + '_contextManagerUnsubscribe', + '_dataManagerBeforeDestroyUnsubscribe', + '_dataManagerVisibilityGuardUnsubscribe', + '_dataManagerVisibilityRequestUnsubscribe', + ]) { this[field]?.(); this[field] = null; } @@ -55,20 +87,52 @@ export class ContextControls { 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); } + _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 index 77bbb929a..306c17f20 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -3,9 +3,16 @@ 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); } + 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 = {}) { @@ -24,15 +31,23 @@ function fixture(t, elements = {}) { setPanelCollapsed: () => calls.push('expand'), }, }); - t.after(() => { controls.stop(); controls.disconnect(); }); + 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, + mode: null, + active: false, + changing: false, + entering: null, + canContact: true, + canMission: true, + snapshotCaptured: false, }); }); @@ -40,7 +55,10 @@ 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; }); + controls._selectContextMode = () => + new Promise((resolve) => { + release = resolve; + }); tab.dispatchEvent(new Event('click')); assert.deepEqual(calls, ['claim']); controls.stop(); @@ -56,16 +74,24 @@ test('stopping during installations enable prevents the delayed search', async ( let release; controls._dataManager = { layers: new Map([['military-installations', {}]]), - setEnabled: () => new Promise((resolve) => { release = resolve; }), + setEnabled: () => + new Promise((resolve) => { + release = resolve; + }), isEnabled: () => true, }; - controls.installations.searchNearby = () => assert.fail('search started after stop'); + 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'); + 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) => { @@ -74,9 +100,13 @@ test('a late installations search cannot publish a notice after disposal', async let release; controls._dataManager = { layers: new Map([['military-installations', {}]]), - setEnabled: async () => true, isEnabled: () => true, + setEnabled: async () => true, + isEnabled: () => true, }; - controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; }); + controls.installations.searchNearby = () => + new Promise((resolve) => { + release = resolve; + }); button.dispatchEvent(new Event('click')); await turn(); controls.stop(); @@ -88,12 +118,21 @@ test('a late installations search cannot publish a notice after disposal', async 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); - }])); + 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); diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index dc7003978..53b2ba760 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -1,187 +1,227 @@ -import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js'; +import { + cockpitEntryAllowed, + isExplicitUserIntentOrigin, + recordContextRestoreExplicitChange, + recordContextSessionUserChange, + shouldExitContextForLayerChange, + spaceMissionEntryCancellationDisposition, +} from '../contextModePolicy.js'; export function _handleContextLayerChange(change) { if (this.destroyed) return; - if (change?.layerId === 'radio' && [ + 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; - } + ].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 ( - change?.layerId === 'rocket-launches' - && ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type) + this._contextModeDeferredEntryIntent?.layerId === change.layerId && + this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch ) { - 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'); + if (cancellationDisposition !== 'replacement') { + this._contextModeDeferredEntryIntent = null; } - 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 + if (cancellationDisposition === 'replacement') { + this._contextModeEntering = 'space-missions'; + const entryIntent = this._contextModeEntryIntent; + if ( + entryIntent?.generation === this._contextModeGeneration && + entryIntent.layerId === change.layerId && + entryIntent.intentEpoch === change.intentEpoch ) { - void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => ( - this._deactivateContextForLayerChange({ notificationToken }) - ))); + 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'), + ); } } - if (this.cockpitView?.active && !cockpitEntryAllowed({ + 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(); + }) + ) { + this.cockpitView.exit({ restoreTracking: false }); } + this._syncContextModeButtons(); +} diff --git a/src/ui/contextPresentation.js b/src/ui/contextPresentation.js index 2c078304b..d3b45cf18 100644 --- a/src/ui/contextPresentation.js +++ b/src/ui/contextPresentation.js @@ -1,34 +1,44 @@ - - 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(); + 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 index 2b2376967..241b5f168 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -1,141 +1,156 @@ -import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js'; +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; - } + 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]); - } + 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, - }; + 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 } : {}), - }, + 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', ); - if (coordinatorSettled === false) { - const error = new Error('Failed to settle Contacts before restoring Context'); - error.failedLayerIds = [contactsCoordinatorId]; - throw error; - } + error.failedLayerIds = [contactsCoordinatorId]; + throw error; } - await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, { + } + 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); - } + }, + ); + }; + 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; + } 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. @@ -156,7 +171,10 @@ export async function _restoreContextSession({ * 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 }); - } +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 index 54e5c31bb..1c067e9b5 100644 --- a/src/ui/contextSubscriptions.js +++ b/src/ui/contextSubscriptions.js @@ -1,25 +1,38 @@ -import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js'; +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 (String(change?.type || '').startsWith('visibility')) + this._handleContextLayerChange(change); }); - if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') { - this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((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._captureContextSessionSnapshot({ + excludeLayerIds: [change.layerId], + }); + if ( + shouldDeferContextEntryDuringClear({ + change, + clearInFlight: Boolean(this._clearSelectedLayersPromise), + }) + ) { this._contextModeDeferredEntryIntent = { layerId: change.layerId, intentEpoch: change.intentEpoch, @@ -29,9 +42,9 @@ export function connectContextManager(manager) { this._syncContextModeButtons(); } } else if ( - change?.layerId === 'rocket-launches' - && change.enabled === false - && isExplicitUserIntentOrigin(change.origin, change.layerId) + change?.layerId === 'rocket-launches' && + change.enabled === false && + isExplicitUserIntentOrigin(change.origin, change.layerId) ) { this._contextModeDeferredEntryIntent = null; if (this._clearSelectedLayersPromise) { @@ -41,11 +54,14 @@ export function connectContextManager(manager) { } } }); - } - if (typeof this._dataManager?.addVisibilityGuard === 'function') { - this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => { + } + 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 layerName = + this._dataManager?.layers?.get(change.layerId)?.module?.name || + change.layerId; const reason = contextLayerEnableBlockReason({ contextMode: this._contextModeEntering || this._contextMode, change, @@ -53,23 +69,26 @@ export function connectContextManager(manager) { }); 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 - ) - ) + 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; + 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 priorChanging = deferredClearEntry + ? false + : this._contextModeChanging; + const notificationToken = + change.notificationToken || Symbol('direct-context-shell-entry'); const ownsNotificationToken = !change.notificationToken; if (ownsNotificationToken) { this._userFacingContextNotificationTokens.add(notificationToken); @@ -80,10 +99,16 @@ export function connectContextManager(manager) { if (deferredClearEntry) { await this._clearSelectedLayersManagerPromise; if (this.destroyed) return false; - if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false; + if ( + this._contextModeDeferredEntryIntent?.intentEpoch !== + change.intentEpoch + ) + return false; this._contextModeDeferredEntryIntent = null; } - await this._clearLayersOutsideContextMode(entryMode, { notificationToken }); + await this._clearLayersOutsideContextMode(entryMode, { + notificationToken, + }); if (this.destroyed) return false; } catch (error) { this._contextModeEntering = null; @@ -94,30 +119,37 @@ export function connectContextManager(manager) { notificationToken, }); } catch (restoreError) { - console.warn(`[Context] ${change.layerId} rollback failed`, 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); + 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 (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] : [] }); + await this._restoreContextSession({ + excludeLayerIds: layerId ? [layerId] : [], + }); }); }); - } + } this._syncContextModeButtons(); } - diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index 09b8f711c..729edc654 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -1,215 +1,272 @@ -import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js'; +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; +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(); - // 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; - } + 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; - // 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; + 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 { - activationIntent = this._dataManager._setEnabledWithIntent( - entryLayerId, - true, - { notificationToken, ...(signal ? { signal } : {}) }, + await this._restoreContextSession({ notificationToken }); + } catch (restoreError) { + transitionError = mergeContextTransitionErrors( + transitionError, + restoreError, ); - this._contextModeEntryIntent = { - generation, - layerId: entryLayerId, - intentEpoch: activationIntent.intentEpoch, - }; - activated = await activationIntent.promise; - terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.( + 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 + } 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; + 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(); - 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(); + 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; } - } - -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 { + 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; + 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; + } +}