diff --git a/CHANGELOG.md b/CHANGELOG.md
index 198b15a5f..44bbcc175 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -118,6 +118,11 @@
that there is no panel to open instead of writing its classes
(samehkhamis).
+- The Cockpit Contact and briefing cards' collapse renders from the
+ `gev-cockpit` store: the controller publishes `contextCollapsed` and
+ `signalCollapsed` in its presence, and each card's `data-collapsed` and
+ its toggle's state, words and icon follow it (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 b0b9448ae..402010caa 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -240,9 +240,11 @@ a feature supplying only its accent.
publishes which surfaces are shown: the entry button and TR-3B chip in
the Context panel, the HUD with its exit and reset buttons and briefing
card, and the Contact card. Focus moves to the exit button, and back to
- the entry, only after Vue renders them. The controls keep the
- controller's listeners, and the cards' collapse and layout stay with the
- controller, which reads their visibility when it lays them out.
+ the entry, only after Vue renders them. The cards' collapse is part of
+ 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.
`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
diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs
index e8b78e4bc..c5a94018f 100644
--- a/scripts/qa-cockpit-utility.mjs
+++ b/scripts/qa-cockpit-utility.mjs
@@ -2087,7 +2087,7 @@ try {
}
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
- const signalTransition = await page.evaluate(() => {
+ const signalTransition = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const cockpit = manager.cockpitView;
const signalToggle = document.getElementById('cockpit-signal-toggle');
@@ -2114,6 +2114,8 @@ try {
manager._setCockpitDisclosure('radio', true);
manager._setCockpitDisclosure('radio', false);
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
+ // The toggle renders the published collapse on Vue's next flush.
+ await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion,
@@ -2140,7 +2142,7 @@ try {
&& signalTransition.signalAriaExpanded === 'false',
JSON.stringify(signalTransition),
);
- const contextTransition = await page.evaluate(() => {
+ const contextTransition = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const priorCollapsed = cockpit.contextCollapsed;
const contextToggle = document.getElementById('cockpit-context-toggle');
@@ -2152,6 +2154,8 @@ try {
cockpit.setContextCollapsed(false);
const afterExpansion = expandedEvents;
cockpit.setContextCollapsed(false);
+ // The toggle renders the published collapse on Vue's next flush.
+ await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion: expandedEvents,
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index 9d1b5973c..5845ca9c2 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -1197,9 +1197,13 @@ test('cockpit summary presents the focused item as Contact', () => {
);
assert.match(match[0], /aria-label="Collapse Contact panel"/);
assert.doesNotMatch(match[0], />GLOBAL CONTEXT);
+ // The toggle renders the collapse the controller presents.
assert.match(
- setContextCollapsed.toString(),
- /expanded \? t\('Collapse Contact panel'\) : t\('Expand Contact panel'\)/,
+ fs.readFileSync(
+ new URL('./ui/views/panels/CockpitContext.vue', import.meta.url),
+ 'utf8',
+ ),
+ /collapsed \? t\('Expand Contact panel'\) : t\('Collapse Contact panel'\)/,
);
});
diff --git a/src/ui/cockpitBriefing.test.mjs b/src/ui/cockpitBriefing.test.mjs
index 8494823ce..7018c8f79 100644
--- a/src/ui/cockpitBriefing.test.mjs
+++ b/src/ui/cockpitBriefing.test.mjs
@@ -15,6 +15,7 @@ import {
showBriefPage,
updateLocalPosition,
} from './cockpitBriefing.js';
+import { setSignalCollapsed } from './cockpitLayout.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitBriefing from './views/panels/CockpitBriefing.vue';
@@ -259,3 +260,30 @@ test('the card is labelled in the UI language', async (t) => {
await nextTick();
assert.equal(text('#cockpit-brief-kicker'), 'НОВОСТИ РЕГИОНА');
});
+
+test('the card and its toggle render the collapse the controller presents', async (t) => {
+ const { $, owner } = fixture(t);
+ const store = useCockpitStore();
+ Object.assign(owner, {
+ active: true,
+ signalCollapsed: false,
+ presence: { active: true, signalCollapsed: false },
+ publishPresenceView: () => store.publishPresence({ ...owner.presence }),
+ });
+ const card = $('#cockpit-signal-stream');
+ const toggle = $('#cockpit-signal-toggle');
+ assert.equal(card.hasAttribute('data-collapsed'), false);
+ assert.equal(toggle.getAttribute('aria-expanded'), 'true');
+ setSignalCollapsed.call(owner, true, { user: true });
+ await nextTick();
+ assert.equal(card.dataset.collapsed, 'true');
+ assert.equal(toggle.getAttribute('aria-expanded'), 'false');
+ assert.equal(toggle.getAttribute('aria-label'), 'Expand cockpit briefing panel');
+ assert.equal(toggle.title, 'Expand briefing panel');
+ assert.equal(toggle.textContent.trim(), 'right_panel_open');
+ assert.equal(owner.signalUserCollapsed, true);
+ setSignalCollapsed.call(owner, false);
+ await nextTick();
+ assert.equal(card.dataset.collapsed, 'false');
+ assert.equal(toggle.textContent.trim(), 'right_panel_close');
+});
diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs
index 7b0ad20f3..47d3719c7 100644
--- a/src/ui/cockpitContext.test.mjs
+++ b/src/ui/cockpitContext.test.mjs
@@ -13,6 +13,7 @@ import {
} from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import { syncWeatherToggle } from './cockpitInstruments.js';
+import { setContextCollapsed } from './cockpitLayout.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitContext from './views/panels/CockpitContext.vue';
@@ -218,11 +219,12 @@ test('the weather toggle renders its state and the action it offers', async (t)
assert.equal(owner.contextView.weather, true);
});
-test('re-renders leave the card visibility and layout to the controller', async (t) => {
+test('re-renders keep the collapse the controller presents and leave it the layout', async (t) => {
const { $, owner } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
const card = $('#cockpit-context');
- card.dataset.collapsed = 'true';
+ owner.presence.contextCollapsed = true;
+ owner.publishPresenceView();
card.dataset.layoutMode = 'compact-bottom';
owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) });
updateContext.call(owner, INFO, 0);
@@ -230,6 +232,11 @@ test('re-renders leave the card visibility and layout to the controller', async
assert.equal(card.hidden, false);
assert.equal(card.dataset.collapsed, 'true');
assert.equal(card.dataset.layoutMode, 'compact-bottom');
+ const toggle = $('#cockpit-context-toggle');
+ assert.equal(toggle.getAttribute('aria-expanded'), 'false');
+ assert.equal(toggle.getAttribute('aria-label'), 'Expand Contact panel');
+ assert.equal(toggle.title, 'Expand contact panel');
+ assert.equal(toggle.textContent.trim(), 'chevron_right');
});
test('the card is labelled in the UI language', async (t) => {
@@ -248,3 +255,26 @@ test('the card is labelled in the UI language', async (t) => {
translate('Disable cockpit weather effects'),
);
});
+
+test('collapsing the card publishes once per change', async (t) => {
+ const { $, owner } = fixture(t);
+ let published = 0;
+ const publish = owner.publishPresenceView;
+ Object.assign(owner, {
+ contextCollapsed: false,
+ presence: { context: true, contextCollapsed: false },
+ publishPresenceView: () => {
+ published += 1;
+ publish();
+ },
+ });
+ setContextCollapsed.call(owner, true);
+ setContextCollapsed.call(owner, true);
+ assert.equal(published, 1);
+ await nextTick();
+ assert.equal($('#cockpit-context').dataset.collapsed, 'true');
+ assert.equal(
+ $('#cockpit-context-toggle').getAttribute('aria-expanded'),
+ 'false',
+ );
+});
diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js
index 8b62e3b51..d8cb65dd2 100644
--- a/src/ui/cockpitController.js
+++ b/src/ui/cockpitController.js
@@ -156,6 +156,9 @@ export class CockpitViewController {
entryAvailable: false,
tr3b: null,
context: false,
+ // The Contact and briefing cards' collapse, which their toggles show.
+ contextCollapsed: false,
+ signalCollapsed: false,
};
this.instruments = createInstrumentState();
this.routeView = createRouteState();
diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js
index 4f0759d6a..9e1dad26f 100644
--- a/src/ui/cockpitLayout.js
+++ b/src/ui/cockpitLayout.js
@@ -1,5 +1,4 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
-import { t, msg } from '../i18n/index.js';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
@@ -35,20 +34,10 @@ export function scheduleContextLayout() {
export function setContextCollapsed(collapsed) {
const wasCollapsed = this.contextCollapsed;
this.contextCollapsed = Boolean(collapsed);
- if (this.context)
- this.context.dataset.collapsed = String(this.contextCollapsed);
- if (this.contextToggle) {
- const expanded = !this.contextCollapsed;
- this.contextToggle.setAttribute('aria-expanded', String(expanded));
- this.contextToggle.setAttribute(
- 'aria-label',
- expanded ? t('Collapse Contact panel') : t('Expand Contact panel'),
- );
- this.contextToggle.title = t(
- expanded ? msg('Collapse contact panel') : msg('Expand contact panel'),
- );
- const icon = this.contextToggle.querySelector('.material-symbols-outlined');
- if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
+ // The Contact card and its toggle render the collapse from the presence.
+ if (this.presence.contextCollapsed !== this.contextCollapsed) {
+ this.presence.contextCollapsed = this.contextCollapsed;
+ this.publishPresenceView();
}
if (this.active && wasCollapsed && !this.contextCollapsed) {
window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
@@ -60,25 +49,10 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
const wasCollapsed = this.signalCollapsed;
this.signalCollapsed = Boolean(collapsed);
if (user) this.signalUserCollapsed = this.signalCollapsed;
- if (this.signalStream)
- this.signalStream.dataset.collapsed = String(this.signalCollapsed);
- if (this.signalToggle) {
- const expanded = !this.signalCollapsed;
- this.signalToggle.setAttribute('aria-expanded', String(expanded));
- this.signalToggle.setAttribute(
- 'aria-label',
- t(
- expanded
- ? msg('Collapse cockpit briefing panel')
- : msg('Expand cockpit briefing panel'),
- ),
- );
- this.signalToggle.title = t(
- expanded ? msg('Collapse briefing panel') : msg('Expand briefing panel'),
- );
- const icon = this.signalToggle.querySelector('.material-symbols-outlined');
- if (icon)
- icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
+ // The briefing card and its toggle render the collapse from the presence.
+ if (this.presence.signalCollapsed !== this.signalCollapsed) {
+ this.presence.signalCollapsed = this.signalCollapsed;
+ this.publishPresenceView();
}
if (this.signalCollapsed) this.stopBriefRotation();
else this.startBriefRotation({ reset: true });
diff --git a/src/ui/views/panels/CockpitBriefing.vue b/src/ui/views/panels/CockpitBriefing.vue
index b13956f94..da0165603 100644
--- a/src/ui/views/panels/CockpitBriefing.vue
+++ b/src/ui/views/panels/CockpitBriefing.vue
@@ -1,6 +1,7 @@
-