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

+6
View File
@@ -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
+6 -1
View File
@@ -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
+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>