feat(ui): render the Context mode switch from a store

ContextControls presents the selected mode, the transition busy state and the nearby sites search through a publish option, and ContextView renders them from the gev-context store. ./ui/context stays free of Vue. The Radio QA reads the search button's aria-busy, the state it actually carries.
This commit is contained in:
Sameh Khamis committed 2026-10-31 00:51:29 -07:00
1 parent b6c7c130f6
commit 749bfd5cd4
13 files changed
+273 -143

No files matched your search

+4 -1
View File
@@ -108,7 +108,10 @@ They contain no application state.
card that LocalSdrControls publishes beside it; fields a person edits take
their value through `v-field-value`, which never writes a focused field;
the tuner and the Cockpit radio controls keep their owners' element
bindings), the command dock,
bindings; `ContextView` renders the mode switch and the nearby sites
search from the `gev-context` store, which ContextControls reaches only
through its `publish` option, so `./ui/context` needs no Vue), the
command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
+3
View File
@@ -53,6 +53,7 @@
"src/ui/cctv/CctvPicker.vue",
"src/ui/stores/cctv.js",
"src/ui/stores/radio.js",
"src/ui/stores/context.js",
"src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue",
"src/ui/views/panels/DisplayPanel.vue",
@@ -967,6 +968,7 @@
"src/ui/stores/streetLevel.js",
"src/ui/stores/cctv.js",
"src/ui/stores/radio.js",
"src/ui/stores/context.js",
"src/ui/stores/display.js"
],
"external": ["cesium", "vue", "pinia"]
@@ -2936,6 +2938,7 @@
"src/ui/stores/streetLevel.js",
"src/ui/stores/cctv.js",
"src/ui/stores/radio.js",
"src/ui/stores/context.js",
"src/ui/stores/display.js"
],
"external": [
+4 -4
View File
@@ -1100,7 +1100,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationFalseMessage = document.getElementById('toast').textContent;
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) {
@@ -1111,7 +1111,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
@@ -1123,13 +1123,13 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationRejectionMessage = document.getElementById('toast').textContent;
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
if (installations) installations.searchNearby = async () => false;
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationSearchFalseMessage = document.getElementById('toast').textContent;
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
await new Promise((resolve) => setTimeout(resolve, 0));
return {
semanticFalseMessage,
+13 -72
View File
@@ -1,12 +1,12 @@
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
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');
const contextView = readRadioSource(new URL('./ui/views/ContextView.vue', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -22,75 +22,12 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
assert.match(button[0], /tabindex="0"/);
}
const syncSource = _syncContextModeButtons.toString();
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
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 attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_globalContextPanel: panel,
actions: { syncDetection() {}, scheduleLayout() {} },
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
_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);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
_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);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
// The view keeps both tabs' tabindex static and never disables them; the
// transition itself is covered over happy-dom in ui/contextPanel.test.mjs.
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
const tag = contextView.match(new RegExp(`<button id="${id}"[^>]*>`))?.[0];
assert.ok(tag, `${id} is missing from the view`);
assert.doesNotMatch(tag, /:tabindex|:disabled|\sdisabled/);
}
});
@@ -134,8 +71,12 @@ test('Context async action buttons remain focused while busy', () => {
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
const clear = clearSelectedLayers.toString();
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(init, /if \(this\._installationsSearchBusy\) return;/);
assert.doesNotMatch(init, /\.disabled\s*=\s*true/);
const search = contextView.match(/<button id="installations-search-btn"[^>]*>/)?.[0];
assert.ok(search, 'nearby sites search is missing');
assert.doesNotMatch(search, /:disabled|\sdisabled/);
assert.match(search, /:aria-busy=/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
assert.match(radioSync, /_cockpitRadioEnableBtn\.disabled = false/);
+3 -4
View File
@@ -53,6 +53,7 @@ import {
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -973,17 +974,15 @@ export class StyleManager extends ShellFacade {
_initGlobalContextPanel() {
const { radioLayer, militaryInstallationsLayer } = this.services;
const contextStore = useContextStore(applicationPinia());
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,
publish: (view) => contextStore.publish(view),
actions: {
getCockpit: () => this.cockpitView,
refreshRadio: () => this._renderRadioState(radioLayer.getUIState()),
+5 -6
View File
@@ -86,10 +86,9 @@ export function _initGlobalContextPanel() {
!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');
if (this._installationsSearchBusy) return;
this._installationsSearchBusy = true;
this._presentContext();
void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled(
'military-installations',
@@ -118,8 +117,8 @@ export function _initGlobalContextPanel() {
}, t('Nearby installations could not be refreshed; try again')).finally(
() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
this._installationsSearchBusy = false;
this._presentContext();
},
);
});
+15 -3
View File
@@ -1,5 +1,8 @@
import { _initGlobalContextPanel } from './contextBindings.js';
import { _syncContextModeButtons } from './contextPresentation.js';
import {
_syncContextModeButtons,
_presentContext,
} from './contextPresentation.js';
import {
_trackContextLayerReaction,
_waitForContextLayerSettlement,
@@ -22,12 +25,18 @@ import {
} from './contextActions.js';
import { connectContextManager } from './contextSubscriptions.js';
/** Own Context transactions and controls through explicit layer and presentation ports. */
/**
* Own Context transactions and controls through explicit layer and
* presentation ports. The mode switch is presented through `publish`
* (the app hands it the `gev-context` store) and rendered by the view.
*/
export class ContextControls {
constructor({ elements, installations, actions }) {
constructor({ elements, installations, actions, publish = () => {} }) {
Object.assign(this, elements);
this.installations = installations;
this.actions = actions;
this.publish = publish;
this._installationsSearchBusy = false;
this.listeners = new AbortController();
this.destroyed = false;
this._dataManager = null;
@@ -93,6 +102,9 @@ export class ContextControls {
_syncContextModeButtons(...args) {
return _syncContextModeButtons.call(this, ...args);
}
_presentContext(...args) {
return _presentContext.call(this, ...args);
}
_trackContextLayerReaction(...args) {
return _trackContextLayerReaction.call(this, ...args);
}
+8 -7
View File
@@ -17,7 +17,9 @@ class Button extends EventTarget {
const turn = () => new Promise((resolve) => setImmediate(resolve));
function fixture(t, elements = {}) {
const calls = [];
const views = [];
const controls = new ContextControls({
publish: (view) => views.push(view),
elements,
installations: {},
actions: {
@@ -35,7 +37,7 @@ function fixture(t, elements = {}) {
controls.stop();
controls.disconnect();
});
return { controls, calls };
return { controls, calls, views };
}
test('Context starts with an explicit idle snapshot', (t) => {
@@ -70,7 +72,9 @@ test('a pending Context tab cannot reopen a disposed panel', async (t) => {
test('stopping during installations enable prevents the delayed search', async (t) => {
const button = new Button();
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
const { controls, calls, views } = fixture(t, {
_installationsSearchBtn: button,
});
let release;
controls._dataManager = {
layers: new Map([['military-installations', {}]]),
@@ -87,11 +91,8 @@ test('stopping during installations enable prevents the delayed search', async (
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(views.length, 1, 'disposed controls publish nothing more');
assert.equal(views[0].installationsBusy, true);
});
test('a late installations search cannot publish a notice after disposal', async (t) => {
+163
View File
@@ -0,0 +1,163 @@
// src/ui/contextPanel.test.mjs — the Context mode switch renders what
// ContextControls presents (`_presentContext()`), over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { useContextStore } from './stores/context.js';
import ContextView from './views/ContextView.vue';
import { ContextControls } from './contextControls.js';
const turn = () => new Promise((resolve) => setImmediate(resolve));
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(ContextView).use(pinia);
app.mount(host);
const $ = (id) => document.getElementById(id);
const store = useContextStore(pinia);
const controls = new ContextControls({
elements: {
_globalContextFlightsBtn: $('global-context-flights-btn'),
_globalContextMissionsBtn: $('global-context-missions-btn'),
_installationsSearchBtn: $('installations-search-btn'),
},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority() {},
showToast() {},
setClearBusy() {},
syncDetection() {},
scheduleLayout() {},
refreshRadio() {},
setPanelCollapsed() {},
},
publish: (view) => store.publish(view),
});
t.after(() => {
controls.stop();
controls.disconnect();
app.unmount();
});
const sync = async () => {
controls._syncContextModeButtons();
await nextTick();
};
return { $, controls, sync };
}
test('before ContextControls renders, the switch shows its initial markup', (t) => {
const { $ } = fixture(t);
const panel = $('global-context-panel');
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(panel.hasAttribute('data-context-mode'), false);
for (const id of [
'global-context-flights-btn',
'global-context-missions-btn',
]) {
const tab = $(id);
assert.equal(tab.className, 'context-mode-button');
assert.equal(tab.getAttribute('aria-selected'), 'false');
assert.equal(tab.getAttribute('tabindex'), '0');
assert.equal(tab.hasAttribute('aria-busy'), false);
}
assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true);
assert.equal($('context-missions-view').hidden, true);
assert.equal($('installations-search-btn').hasAttribute('aria-busy'), false);
});
test('a transition keeps focus and Tab on both tabs until it settles', async (t) => {
const { $, controls, sync } = fixture(t);
const contacts = $('global-context-flights-btn');
const missions = $('global-context-missions-btn');
missions.focus();
controls._contextModeChanging = true;
await sync();
assert.equal(
document.activeElement === missions,
true,
'busy render keeps focus',
);
for (const tab of [contacts, missions]) {
assert.equal(tab.disabled, false);
assert.equal(tab.getAttribute('tabindex'), '0');
assert.equal(tab.getAttribute('aria-disabled'), 'true');
assert.equal(tab.getAttribute('aria-busy'), 'true');
}
controls._contextModeChanging = false;
controls._contextMode = 'space-missions';
await sync();
assert.equal(
document.activeElement === missions,
true,
'settled render keeps focus',
);
for (const tab of [contacts, missions]) {
assert.equal(tab.disabled, false);
assert.equal(tab.getAttribute('aria-disabled'), 'false');
assert.equal(tab.getAttribute('aria-busy'), 'false');
}
assert.equal(missions.classList.contains('active'), true);
assert.equal(missions.getAttribute('aria-selected'), 'true');
assert.equal(contacts.classList.contains('active'), false);
assert.equal(contacts.getAttribute('aria-selected'), 'false');
assert.equal($('context-mode-standby').hidden, true);
assert.equal($('context-flights-view').hidden, true);
assert.equal($('context-missions-view').hidden, false);
});
test('the panel carries the mode beside the classes its chrome writes', async (t) => {
const { $, controls, sync } = fixture(t);
const panel = $('global-context-panel');
panel.classList.remove('collapsed');
controls._contextMode = 'flights';
await sync();
assert.equal(panel.className, 'panel-collapsible context-enabled');
assert.equal(panel.getAttribute('data-context-mode'), 'flights');
assert.equal($('context-flights-view').hidden, false);
panel.classList.add('collapsed');
controls._contextMode = null;
await sync();
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(panel.getAttribute('data-context-mode'), 'none');
assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true);
});
test('the nearby sites search is busy, without native disable, until it settles', async (t) => {
const { $, controls } = fixture(t);
const button = $('installations-search-btn');
let release;
let enables = 0;
controls._dataManager = {
layers: new Map([['military-installations', {}]]),
setEnabled: () => {
enables += 1;
return new Promise((resolve) => (release = resolve));
},
isEnabled: () => true,
};
controls.installations.searchNearby = async () => true;
button.focus();
button.click();
await nextTick();
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.disabled, false);
assert.equal(document.activeElement === button, true);
button.click();
assert.equal(enables, 1, 'a busy search ignores a second click');
release(true);
await turn();
await nextTick();
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.getAttribute('aria-disabled'), 'false');
});
+21 -36
View File
@@ -1,44 +1,29 @@
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._presentContext();
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();
}
/** Publish the mode switch and the nearby sites search to the view. */
export function _presentContext() {
if (this.destroyed) return;
const flightsActive = this._contextMode === 'flights';
const missionsActive = this._contextMode === 'space-missions';
// Both Context choices stay in the ordinary Tab sequence and are never
// native-disabled. 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.
this.publish({
mode: this._contextMode || 'none',
enabled: flightsActive || missionsActive,
flightsActive,
missionsActive,
busy: Boolean(this._contextModeChanging),
installationsBusy: this._installationsSearchBusy,
});
}
-3
View File
@@ -85,9 +85,6 @@ export function readShellElements(document = globalThis.document) {
_globalContextMissionsBtn: document.getElementById(
'global-context-missions-btn',
),
_contextModeStandby: document.getElementById('context-mode-standby'),
_contextFlightsView: document.getElementById('context-flights-view'),
_contextMissionsView: document.getElementById('context-missions-view'),
_installationsSearchBtn: document.getElementById(
'installations-search-btn',
),
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The Context panel's mode switch as its view renders it. ContextControls
* presents the selected mode, the transition busy state and the nearby
* sites search (`_presentContext()`) and publishes them here; it
* keeps its listeners and transactions.
*/
export const useContextStore = defineStore('gev-context', () => {
/** The presented mode switch; null until the controls first render. */
const view = shallowRef(null);
/** @param {object} next A fresh presentation; every publish re-renders. */
function publish(next) {
view.value = markRaw(next);
}
return { view, publish };
});
+14 -7
View File
@@ -1,5 +1,12 @@
<script setup>
import { storeToRefs } from 'pinia';
import { vClassToggle } from '../rail/classToggle.js';
import { useContextStore } from '../stores/context.js';
import RadioPanel from './panels/RadioPanel.vue';
// ContextControls presents the mode switch and the nearby sites search and
// publishes them; until it does, the panel shows its initial markup.
const { view: ctx } = storeToRefs(useContextStore());
</script>
<template>
@@ -29,7 +36,7 @@ import RadioPanel from './panels/RadioPanel.vue';
<div id="recent-imagery-panel-body" data-rail-scroller></div>
</div>
</div>
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div id="global-context-panel" class="panel-collapsible collapsed" v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner" data-rail-scroller>
<div class="panel-header">
@@ -39,26 +46,26 @@ import RadioPanel from './panels/RadioPanel.vue';
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
<button id="global-context-flights-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.flightsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.flightsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span data-i18n>CONTACTS</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
<button id="global-context-missions-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.missionsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.missionsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span data-i18n>SPACE MISSIONS</span>
</button>
</div>
<div id="context-mode-standby" class="context-mode-standby">
<div id="context-mode-standby" class="context-mode-standby" :hidden="ctx?.enabled">
<strong data-i18n>SELECT CONTEXT</strong>
<span data-i18n-runtime>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
<button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span data-i18n>COCKPIT</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n>SEARCH NEARBY SITES</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button>
</div>
<aside id="military-awareness-panel">
@@ -68,7 +75,7 @@ import RadioPanel from './panels/RadioPanel.vue';
</div>
</aside>
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
<div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
<div class="space-mission-roster-header">