diff --git a/CHANGELOG.md b/CHANGELOG.md index d79fb3129..5bff59f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,12 @@ `signalCollapsed` in its presence, and each card's `data-collapsed` and its toggle's state, words and icon follow it (samehkhamis). +- The Cockpit utility strip's measured layout renders from the + `gev-cockpit` store: the layout pass publishes `utilityLayout` (the HUD's + `--cockpit-utility-*` lengths, `primaryOnly` and `hideCollapsed`), and a + host constructing `CockpitController` passes `publish.utilityLayout` + beside its other publishers (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index f6d28b099..4afd3761d 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -314,7 +314,12 @@ a feature supplying only its accent. that presence too: each card's `data-collapsed` and its toggle's words and icon render from it. The controls keep the controller's listeners, and the cards' layout stays with the controller, which reads their - visibility when it lays them out. + visibility when it lays them out. Its layout pass publishes the utility + strip's measured fit (the HUD's anchor and height limits, whether only the + expanded control fits and which control is hidden from assistive + technology), which `CockpitView` and `CockpitUtilityControls` render; the + briefing and Contact cards' positions stay imperative, because the same + pass measures them straight after writing them. `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 and diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 0bc262348..4fe327381 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -527,7 +527,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', ); assert.match( syncSignalLayout.toString(), - /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/, + /primaryOnly: utilityLayout\.primaryOnly,\s*hideCollapsed: utilityLayout\.primaryOnly,/, ); }); @@ -934,7 +934,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { assert.ok(signalLayout, 'Cockpit signal layout method is missing'); assert.match( signalLayout, - /setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/, + /publishUtilityLayout\([\s\S]*?top: `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,/, 'Cockpit owns the utility strip anchor and republishes it every layout tick', ); assert.match( @@ -970,7 +970,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { ); assert.match( signalLayout, - /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/, + /maxHeight: `\$\{availableHeight\.toFixed\(2\)\}px`,/, ); assert.match( css, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 99ec6de86..6329785d0 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -400,6 +400,8 @@ export class StyleManager extends ShellFacade { useCockpitStore(applicationPinia()).publishVision(view), signals: (rows) => useCockpitStore(applicationPinia()).publishSignals(rows), + utilityLayout: (layout) => + useCockpitStore(applicationPinia()).publishUtilityLayout(layout), }, _layoutRightPanels: (...args) => this._layoutRightPanels(...args), _setCockpitVision: (...args) => this._setCockpitVision(...args), diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index a65b437fe..4eb96c4ad 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -150,6 +150,7 @@ export class CockpitViewController { this.publishRoute = publish.route ?? (() => {}); this.publishVision = publish.vision ?? (() => {}); this.publishSignals = publish.signals ?? (() => {}); + this.publishUtilityLayout = publish.utilityLayout ?? (() => {}); this.presence = { active: false, entryAvailable: false, diff --git a/src/ui/cockpitInstruments.test.mjs b/src/ui/cockpitInstruments.test.mjs index 8bcdbbcc8..60b225f18 100644 --- a/src/ui/cockpitInstruments.test.mjs +++ b/src/ui/cockpitInstruments.test.mjs @@ -301,7 +301,14 @@ test('re-renders keep the visibility and classes the controller publishes on the const hud = $('#cockpit-hud'); store.publishPresence({ active: true, context: false }); hud.classList.add('voice-highlight'); - hud.style.setProperty('--cockpit-utility-top', '120px'); + hud.style.setProperty('--voice-glow', '1'); + store.publishUtilityLayout({ + top: '120px', + maxHeight: null, + expandedMaxHeight: null, + primaryOnly: false, + hideCollapsed: false, + }); $('#cockpit-speed-rim').classList.add('external'); updateHud.call(owner, INFO, 1000, true); await nextTick(); @@ -310,7 +317,13 @@ test('re-renders keep the visibility and classes the controller publishes on the assert.equal(hud.hidden, false); assert.equal(hud.classList.contains('signals-active'), true); assert.equal(hud.classList.contains('voice-highlight'), true); + assert.equal(hud.style.getPropertyValue('--voice-glow'), '1'); + // The utility strip's measured anchor renders from the store. assert.equal(hud.style.getPropertyValue('--cockpit-utility-top'), '120px'); + store.publishUtilityLayout(null); + await nextTick(); + assert.equal(hud.style.getPropertyValue('--cockpit-utility-top'), ''); + assert.equal(hud.style.getPropertyValue('--voice-glow'), '1'); assert.equal($('#cockpit-speed-rim').classList.contains('external'), true); }); diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index 9e1dad26f..c0a00299d 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -133,20 +133,8 @@ export function syncSignalLayout() { const utilityControls = document.getElementById('cockpit-utility-controls'); if (window.matchMedia('(max-width: 760px)').matches) { this.signalStream.dataset.layoutMode = 'compact-top'; - utilityControls?.classList.remove('layout-primary-only'); - utilityControls - ?.querySelectorAll('.cockpit-utility-control') - .forEach((control) => { - const hiddenSibling = Boolean( - utilityControls.querySelector( - '.cockpit-utility-control.is-expanded', - ) && !control.classList.contains('is-expanded'), - ); - control.setAttribute('aria-hidden', String(hiddenSibling)); - }); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); - this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height'); + // The compact strip shows only an expanded control. + this.publishUtilityLayout(utilityLayoutView({ hideCollapsed: true })); this.signalStream.style.removeProperty('--cockpit-signal-right'); this.signalStream.style.removeProperty('--cockpit-signal-top'); this.signalStream.style.removeProperty('--cockpit-signal-max-height'); @@ -231,22 +219,13 @@ export function syncSignalLayout() { '--cyber-cockpit-panel-height', `${lane.panelHeight.toFixed(2)}px`, ); - this.hud?.style.setProperty( - '--cockpit-utility-top', - `${alignedTop.toFixed(2)}px`, + this.publishUtilityLayout( + utilityLayoutView({ + top: `${alignedTop.toFixed(2)}px`, + maxHeight: `${lane.utilityHeight.toFixed(2)}px`, + expandedMaxHeight: `${lane.panelHeight.toFixed(2)}px`, + }), ); - this.hud?.style.setProperty( - '--cockpit-utility-max-height', - `${lane.utilityHeight.toFixed(2)}px`, - ); - this.hud?.style.setProperty( - '--cockpit-utility-expanded-max-height', - `${lane.panelHeight.toFixed(2)}px`, - ); - utilityControls.classList.remove('layout-primary-only'); - utilityControls - .querySelectorAll('.cockpit-utility-control') - .forEach((control) => control.setAttribute('aria-hidden', 'false')); if (expandedControl) { this.signalStream.style.setProperty( '--cockpit-signal-top', @@ -289,14 +268,6 @@ export function syncSignalLayout() { : 0, }); const availableHeight = utilityAnchor.maxHeight; - this.hud?.style.setProperty( - '--cockpit-utility-top', - `${utilityAnchor.top.toFixed(1)}px`, - ); - this.hud?.style.setProperty( - '--cockpit-utility-max-height', - `${availableHeight.toFixed(2)}px`, - ); const utilityLayout = expandedControl && collapsedControl ? resolveCockpitUtilityLayout({ @@ -305,25 +276,37 @@ export function syncSignalLayout() { collapsedHeight, }) : { primaryOnly: false, expandedMaxHeight: availableHeight }; - utilityControls?.classList.toggle( - 'layout-primary-only', - utilityLayout.primaryOnly, + this.publishUtilityLayout( + utilityLayoutView({ + top: `${utilityAnchor.top.toFixed(1)}px`, + maxHeight: `${availableHeight.toFixed(2)}px`, + expandedMaxHeight: `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, + primaryOnly: utilityLayout.primaryOnly, + hideCollapsed: utilityLayout.primaryOnly, + }), ); - this.hud?.style.setProperty( - '--cockpit-utility-expanded-max-height', - `${utilityLayout.expandedMaxHeight.toFixed(2)}px`, - ); - utilityControls - ?.querySelectorAll('.cockpit-utility-control') - .forEach((control) => { - const hiddenSibling = - utilityLayout.primaryOnly && control === collapsedControl; - control.setAttribute('aria-hidden', String(hiddenSibling)); - if (hiddenSibling && control.contains(document.activeElement)) { - expandedControl - ?.querySelector('.cockpit-utility-glyph') - ?.focus({ preventScroll: true }); - } - }); + // Focus leaves a control the strip is about to hide. + if ( + utilityLayout.primaryOnly && + collapsedControl?.contains(document.activeElement) + ) { + expandedControl + ?.querySelector('.cockpit-utility-glyph') + ?.focus({ preventScroll: true }); + } } } + +/** + * The utility strip's layout as CockpitView and CockpitUtilityControls + * render it: its anchor and height limits on the HUD, and which controls fit. + */ +function utilityLayoutView({ + top = null, + maxHeight = null, + expandedMaxHeight = null, + primaryOnly = false, + hideCollapsed = false, +} = {}) { + return { top, maxHeight, expandedMaxHeight, primaryOnly, hideCollapsed }; +} diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 988860509..b945da4de 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -232,8 +232,7 @@ export function exit({ restoreTracking = true } = {}) { this.regionalBriefSubjectId = null; document.body.classList.remove('cockpit-mode'); this.onExited?.(); - this.hud?.style.removeProperty('--cockpit-utility-top'); - this.hud?.style.removeProperty('--cockpit-utility-max-height'); + this.publishUtilityLayout(null); this.presence.active = false; this.publishPresenceView(); this.routeView.visible = false; diff --git a/src/ui/cockpitUtilityControls.test.mjs b/src/ui/cockpitUtilityControls.test.mjs index 99f2fbb6d..8ae377b5f 100644 --- a/src/ui/cockpitUtilityControls.test.mjs +++ b/src/ui/cockpitUtilityControls.test.mjs @@ -10,6 +10,8 @@ import { setLocale } from '../i18n/index.js'; import { onKeyDown as cockpitKeyDown } from './cockpitInput.js'; import { RadioControls } from './radioControls.js'; import { renderRadioState } from './radioPresentation.js'; +import { syncSignalLayout } from './cockpitLayout.js'; +import { useCockpitStore } from './stores/cockpit.js'; import { useRadioStore } from './stores/radio.js'; import CockpitUtilityControls from './views/panels/CockpitUtilityControls.vue'; @@ -336,3 +338,115 @@ test('Cockpit Escape defers to a popover the moment its disclosure opens', async assert.equal(exits, 1); await nextTick(); }); + +test('the layout pass’s measured fit hides the collapsed control and marks the strip', async () => { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(CockpitUtilityControls).use(pinia); + app.mount(host); + const radio = useRadioStore(pinia); + const cockpit = useCockpitStore(pinia); + const strip = document.getElementById('cockpit-utility-controls'); + const [display, radioControl] = strip.querySelectorAll( + '.cockpit-utility-control', + ); + const layout = (fit) => ({ + top: '96.0px', + maxHeight: '300.00px', + expandedMaxHeight: '300.00px', + primaryOnly: false, + hideCollapsed: false, + ...fit, + }); + // Until the layout pass runs, neither control carries aria-hidden. + assert.equal(display.hasAttribute('aria-hidden'), false); + radio.publishUtility({ display: false, radio: true }); + cockpit.publishUtilityLayout( + layout({ primaryOnly: true, hideCollapsed: true }), + ); + await nextTick(); + assert.equal(strip.classList.contains('layout-primary-only'), true); + assert.equal(display.getAttribute('aria-hidden'), 'true'); + assert.equal(radioControl.getAttribute('aria-hidden'), 'false'); + // With nothing expanded there is no collapsed sibling to hide. + radio.publishUtility({ display: false, radio: false }); + await nextTick(); + assert.equal(display.getAttribute('aria-hidden'), 'false'); + cockpit.publishUtilityLayout(layout()); + await nextTick(); + assert.equal(strip.classList.contains('layout-primary-only'), false); + app.unmount(); +}); + +test('the real layout pass drives the mounted controls: compact hides the collapsed sibling, Cyber shows both', async (t) => { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(CockpitUtilityControls).use(pinia); + app.mount(host); + const radio = useRadioStore(pinia); + const cockpit = useCockpitStore(pinia); + const signalStream = document.body.appendChild( + document.createElement('aside'), + ); + const owner = { + signalStream, + signalCollapsed: false, + contextCollapsed: false, + context: null, + publishUtilityLayout: (layout) => cockpit.publishUtilityLayout(layout), + syncContextLayout() {}, + }; + const originalMatchMedia = window.matchMedia; + let compact = true; + window.matchMedia = (query) => ({ matches: compact, media: query }); + t.after(() => { + window.matchMedia = originalMatchMedia; + delete document.documentElement.dataset.uiTheme; + document.body.style.removeProperty('opacity'); + document.documentElement.style.removeProperty('opacity'); + app.unmount(); + }); + const strip = document.getElementById('cockpit-utility-controls'); + const [display, radioControl] = strip.querySelectorAll( + '.cockpit-utility-control', + ); + radio.publishUtility({ display: false, radio: true }); + await nextTick(); + syncSignalLayout.call(owner); + await nextTick(); + assert.equal(display.getAttribute('aria-hidden'), 'true'); + assert.equal(radioControl.getAttribute('aria-hidden'), 'false'); + assert.equal(cockpit.utilityLayout.top, null); + // Compact hides the sibling without marking the strip primary-only. + assert.equal(strip.classList.contains('layout-primary-only'), false); + // Cyber aligns the strip with the Data panel and keeps both controls. + compact = false; + document.documentElement.dataset.uiTheme = 'cyber'; + const stack = document.body.appendChild(document.createElement('div')); + stack.id = 'left-panel-stack'; + const dataPanel = stack.appendChild(document.createElement('section')); + dataPanel.id = 'data-panel'; + for (const node of [ + dataPanel, + stack, + document.body, + document.documentElement, + ]) + node.style.opacity = '1'; + dataPanel.getBoundingClientRect = () => ({ + top: 120, + bottom: 400, + left: 0, + right: 200, + width: 200, + height: 280, + }); + syncSignalLayout.call(owner); + await nextTick(); + assert.equal(display.getAttribute('aria-hidden'), 'false'); + assert.equal(radioControl.getAttribute('aria-hidden'), 'false'); + assert.equal(cockpit.utilityLayout.top, '120.00px'); + assert.equal(strip.classList.contains('layout-primary-only'), false); +}); diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index c28f98074..03f38f911 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -66,6 +66,26 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { signals.value = next && markRaw(next); } + /** + * The utility strip's measured layout: its top and height limits as CSS + * lengths (null leaves the stylesheet's), whether only the expanded control + * fits, and whether a collapsed control beside an expanded one is hidden + * from assistive technology. Null until the first layout pass. + */ + const utilityLayout = shallowRef(null); + + /** @param {?object} next A fresh layout; one equal to the current is dropped. */ + function publishUtilityLayout(next) { + const current = utilityLayout.value; + if ( + next && + current && + Object.keys(next).every((key) => next[key] === current[key]) + ) + return; + utilityLayout.value = next && markRaw(next); + } + return { presence, publishPresence, @@ -81,5 +101,7 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { publishVision, signals, publishSignals, + utilityLayout, + publishUtilityLayout, }; }); diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 4ec4ea9ab..ae65556b3 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -18,9 +18,10 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; // The Cockpit HUD. The Cockpit controller presents the instruments, the // destination arrow and the vision selector on every HUD update; until it // does, they show the state they start in. The controller also publishes -// which surfaces are shown; the HUD's layout stays with it, and the controls -// keep its listeners. -const { presence, instruments, route, vision } = storeToRefs(useCockpitStore()); +// which surfaces are shown and the utility strip's measured layout; the +// layout pass stays with it, and the controls keep its listeners. +const { presence, instruments, route, vision, utilityLayout } = + storeToRefs(useCockpitStore()); const active = computed(() => Boolean(presence.value?.active)); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); @@ -38,6 +39,14 @@ const visionLabel = computed(() => const visionTitle = computed(() => t('Current style: {style} — click for next', { style: v.value.name }), ); +// The utility strip's measured anchor and height limits; a null value leaves +// the stylesheet's fallback. +const utilityStyle = computed(() => ({ + '--cockpit-utility-top': utilityLayout.value?.top ?? null, + '--cockpit-utility-max-height': utilityLayout.value?.maxHeight ?? null, + '--cockpit-utility-expanded-max-height': + utilityLayout.value?.expandedMaxHeight ?? null, +})); const tickStyle = (tick) => tick.slot === null ? undefined @@ -46,7 +55,7 @@ const tickStyle = (tick) =>