feat(cockpit): render the utility strip's measured layout from the store

The Cockpit layout pass publishes the utility strip's fit to the
gev-cockpit store: the HUD's --cockpit-utility-* lengths, whether only the
expanded control fits, and whether a collapsed control beside an expanded
one is hidden from assistive technology. CockpitView renders the lengths
on the HUD and CockpitUtilityControls the strip's class and each control's
aria-hidden, which now also follows a popover opened between passes. Focus
leaves a control before the strip hides it. Exit publishes no layout,
which also clears the expanded height, class and aria-hidden the old exit
left behind. The briefing and Contact cards' positions stay imperative:
the same pass measures them straight after writing them.
This commit is contained in:
Sameh Khamis committed 2026-11-01 14:36:51 -08:00
1 parent c3c69a91cd
commit 4c92f517db
12 files changed
+236 -70

No files matched your search

+3 -3
View File
@@ -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,
+2
View File
@@ -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),
+1
View File
@@ -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,
+14 -1
View File
@@ -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);
});
+39 -56
View File
@@ -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 };
}
+1 -2
View File
@@ -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;
+114
View File
@@ -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);
});
+22
View File
@@ -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,
};
});
+13 -4
View File
@@ -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) =>
<template>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" :hidden="!active" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" data-i18n-aria-label :data-layer="hud.layer ?? undefined">
<section id="cockpit-hud" aria-label="Aircraft cockpit view" :hidden="!active" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" data-i18n-aria-label :data-layer="hud.layer ?? undefined">
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
+15 -3
View File
@@ -5,6 +5,7 @@ import { t } from '../../../i18n/index.js';
import { vClassToggle } from '../../rail/classToggle.js';
import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import UiButton from '../../kit/UiButton.vue';
// The Cockpit's Display and Radio launchers and their popovers. RadioControls
@@ -18,6 +19,17 @@ const { act } = store;
const { view: radio, utility } = storeToRefs(store);
const open = computed(() => utility.value ?? { display: false, radio: false });
const cockpit = computed(() => radio.value?.cockpit ?? null);
// The Cockpit's layout pass measures which controls fit; until it runs, the
// controls carry no aria-hidden.
const { utilityLayout: layout } = storeToRefs(useCockpitStore());
const ariaHidden = (expanded) =>
layout.value
? String(
layout.value.hideCollapsed &&
!expanded &&
(open.value.display || open.value.radio),
)
: undefined;
const displayLabel = computed(() =>
open.value.display
? t('Collapse Cockpit display options')
@@ -31,8 +43,8 @@ const radioLabel = computed(() =>
</script>
<template>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.display }">
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" v-class-toggle="{ 'layout-primary-only': !!layout?.primaryOnly }" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.display }" :aria-hidden="ariaHidden(open.display)">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span data-i18n>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
@@ -45,7 +57,7 @@ const radioLabel = computed(() =>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.radio }">
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.radio }" :aria-hidden="ariaHidden(open.radio)">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span data-i18n>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>