From b6d565a83f0345a14306111860fb0c4b8620605a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 16:53:28 -0700 Subject: [PATCH] feat(ui): render the Cockpit utility launchers and compact Radio controls from a store The Display and Radio launchers, their popovers and the compact Radio transport, Enable button and volume now render from the Radio store. RadioControls owns the disclosure state; Cockpit Escape and the Display reveal read that state instead of the DOM, and the Enable toggle guards re-presses synchronously. The compact transport reuses the panel's transport state and adds only its glyph and title. --- docs/VUE-MIGRATION.md | 6 +- scripts/package-boundaries.json | 1 + scripts/qa-cockpit-utility.mjs | 8 +- scripts/qa-radio.mjs | 4 + src/cockpitMarkup.test.mjs | 12 +- src/contextTabKeyboard.test.mjs | 8 +- src/ui/applicationShell.js | 16 +- src/ui/cockpitController.js | 5 + src/ui/cockpitCoordinator.js | 3 + src/ui/cockpitInput.js | 6 +- src/ui/cockpitUtilityControls.test.mjs | 297 ++++++++++++++++++ src/ui/radioBindings.js | 89 ++---- src/ui/radioControls.js | 1 + src/ui/radioPresentation.js | 104 +++--- src/ui/shellElements.js | 5 - src/ui/shellFacade.js | 4 + src/ui/stores/radio.js | 19 +- src/ui/views/CockpitView.vue | 38 +-- .../views/panels/CockpitUtilityControls.vue | 66 ++++ src/ui/visualSettings.js | 8 +- 20 files changed, 495 insertions(+), 205 deletions(-) create mode 100644 src/ui/cockpitUtilityControls.test.mjs create mode 100644 src/ui/views/panels/CockpitUtilityControls.vue diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index f27019e48..c65ddeb34 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -144,7 +144,11 @@ They contain no application state. every HUD update (about 10 Hz); in a dev build their Vue patch costs about 0.5 ms, close to the DOM writes it replaced. The controls keep the controller's listeners, and the cards' visibility, collapse and layout - stay with the controller. The radio controls follow). + stay with the controller. `CockpitUtilityControls` renders the Display + and Radio launchers, which popover is open and the compact Radio + controls from the `gev-radio` store that RadioControls publishes to; the + Display popover's slots stay static for the Cockpit display portal. The + Radio tuner follows). Layers that write into the right rail directly gain a slot instead. 4. **Cleanup.** The build-time template expansion and the attribute diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 61a03f65e..c14e65c7c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -32,6 +32,7 @@ "src/ui/views/panels/CockpitRoute.vue", "src/ui/views/panels/CockpitRollingValue.vue", "src/ui/views/panels/CockpitSignalList.vue", + "src/ui/views/panels/CockpitUtilityControls.vue", "src/ui/cockpitBriefPages.js", "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 0212b1a03..2bc983893 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -1720,6 +1720,9 @@ try { const priorSignalMaxHeight = signal.style.maxHeight; signal.style.maxHeight = '150px'; manager._setCockpitDisclosure(kind, true); + // Let Vue render the disclosure, but not the scheduled layout frame: the + // first syncSignalLayout below must be the first pass. + await Promise.resolve(); // One pass must be enough. Record the result of the FIRST layout call, then // confirm a second changes nothing, so a convergence regression cannot hide // behind a repeated call. @@ -1851,7 +1854,7 @@ try { JSON.stringify(portalScroll), ); - const boundary = await page.evaluate(() => { + const boundary = await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; const hud = document.getElementById('cockpit-hud'); const signal = document.getElementById('cockpit-signal-stream'); @@ -1866,6 +1869,7 @@ try { signal.hidden = false; manager._setCockpitDisclosure('radio', false); manager._setCockpitDisclosure('display', true); + await new Promise(requestAnimationFrame); const priorSignalMaxHeight = signal.style.maxHeight; const priorSignalHeight = signal.style.height; // The strip is pinned to the viewport ceiling once the briefing card grows @@ -2261,12 +2265,14 @@ try { signal.hidden = false; manager._setCockpitDisclosure('display', false); manager._setCockpitDisclosure('radio', false); + await new Promise(requestAnimationFrame); manager.cockpitView.syncSignalLayout(); const collapsed = { display: displayControl.getBoundingClientRect().toJSON(), radio: radioControl.getBoundingClientRect().toJSON(), }; display.click(); + await new Promise(requestAnimationFrame); manager.cockpitView.syncSignalLayout(); return { collapsed, diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 74ccc338b..91da8bafc 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2154,16 +2154,20 @@ async function main() { .every((control) => control.closest('.pp-toggle-group')?.parentElement ?.closest('#cockpit-display-panel')); display.click(); + await waitForLayout(); manager.cockpitView.syncSignalLayout(); radio.click(); + await waitForLayout(); const radioOpened = radio.getAttribute('aria-expanded') === 'true' && !document.getElementById('cockpit-radio-panel').hidden; const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false'; radio.click(); + await waitForLayout(); manager.cockpitView.syncSignalLayout(); radio.click(); + await waitForLayout(); const radioNavigationAvailable = [ document.getElementById('cockpit-radio-prev-btn'), diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 69dd2bf1f..a00a1a20e 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -235,7 +235,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => { const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/); + assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/); assert.match( keydown, /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, @@ -476,14 +476,6 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/, ); - assert.match( - radioBindings, - /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/, - ); - assert.match( - radioBindings, - /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/, - ); assert.match( css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/, @@ -1096,7 +1088,7 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls ); assert.match( ui, - /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/, + /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?classList\.remove\('collapsed'\)/, ); assert.match( radioBindings, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index e7de01feb..05e013e96 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -5,7 +5,6 @@ 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'; @@ -67,7 +66,6 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs', test('Context async action buttons remain focused while busy', () => { const init = _initGlobalContextPanel.toString(); const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,')); - const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('this._radioView = {')); const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8'); const clear = clearSelectedLayers.toString(); @@ -79,7 +77,11 @@ test('Context async action buttons remain focused while busy', () => { 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/); + const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8'); + const cockpitEnable = cockpitRadio.match(/ - - - -
-
- RADIO - - -
- -
- +
ESC EXIT · C TOGGLE
diff --git a/src/ui/views/panels/CockpitUtilityControls.vue b/src/ui/views/panels/CockpitUtilityControls.vue new file mode 100644 index 000000000..160cfb68f --- /dev/null +++ b/src/ui/views/panels/CockpitUtilityControls.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 83f41cf0c..59731804f 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -440,15 +440,11 @@ export class VisualSettings { !this._sliderPanel?.classList.contains('active') ) return; - if ( - openDisplay && - this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true' - ) { + if (openDisplay && !this._cockpitDisclosureOpen?.('display')) { this._setCockpitDisclosure?.('display', true); return; } - if (this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true') - return; + if (!this._cockpitDisclosureOpen?.('display')) return; this._sliderPanel.classList.remove('collapsed'); this._syncPanelCollapseButton(this._sliderPanel); this._lifetime.frame(() =>