diff --git a/CHANGELOG.md b/CHANGELOG.md
index b33f11f20..198b15a5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -108,6 +108,16 @@
or `onChange`. `UiButton` joins the component kit with ui-vue's props
(samehkhamis).
+- The panels' own state classes (`collapsed`, `dock-pinned`,
+ `dock-pinned-top`, `layout-auto-collapsed`, `accordion-collapsed`) render
+ from the `gev-panel-chrome` store: the shell, the rails and the accordion
+ present each panel's state there instead of writing its classes.
+ `layoutLeftPanelRail` and `layoutRightPanelRail` (`./ui/layout`) now call
+ `onCollapse(panel, {collapsed, auto})` and leave drawing it to the host. Without a
+ shell to own the panels, the voice tools' `set_panel_open` now reports
+ that there is no panel to open instead of writing its classes
+ (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 fafca91a9..b0b9448ae 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -67,7 +67,13 @@ a feature supplying only its accent.
mirrors each side's panels and the rail renders from it. Each panel's
collapse button (`PanelCollapseButton`), the dock tray toggles and the
dock pins render the open and pinned state PanelChrome presents to the
- `gev-panel-chrome` store, and their clicks call it there. Done.
+ `gev-panel-chrome` store, and their clicks call it there. Each panel's
+ root draws its own state from there too (`v-panel-state`: `collapsed`,
+ `dock-pinned`, `dock-pinned-top`, `layout-auto-collapsed`,
+ `accordion-collapsed`): PanelChrome, the rails and the accordion present
+ it rather than writing classes. The directive applies it in a synchronous
+ watcher, not on Vue's next render, because the rails measure a panel in
+ the same task that opens or closes it. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids,
classes, `data-i18n` text and CSS. The page places each view with a
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
diff --git a/docs/panel-surfaces.md b/docs/panel-surfaces.md
index e84865888..e2fa75728 100644
--- a/docs/panel-surfaces.md
+++ b/docs/panel-surfaces.md
@@ -101,7 +101,11 @@ Escape, bound and disposed with `bindPanelDisclosure` /
`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome
initializer just to add one panel. `layoutLeftPanelRail` and
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
-host owns layout scheduling, observations and collapse callbacks. Dynamic
+host owns layout scheduling, observations and collapse callbacks. A rail
+never writes a panel's classes: it calls `onCollapse(panel, {collapsed,
+auto})`, and the host draws that state (the application presents it to the
+panel chrome store, which each root's `v-panel-state` draws at once, so the
+rail can measure the panel in the same task). Dynamic
content that changes size may require an owner-scheduled layout pass or an
owned `ResizeObserver`; adding CSS attributes does not register new observers.
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 0d237d534..f1287db14 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -122,7 +122,8 @@
"src/firstRunCopy.js",
"src/ui/kit/UiButton.vue",
"src/ui/stores/panelChrome.js",
- "src/ui/rail/PanelCollapseButton.vue"
+ "src/ui/rail/PanelCollapseButton.vue",
+ "src/ui/rail/panelState.js"
],
"external": ["vue", "pinia"]
},
@@ -1048,7 +1049,8 @@
"src/ui/stores/worldOverlayActions.js",
"src/ui/stores/awarenessDirection.js",
"src/ui/stores/hud.js",
- "src/ui/stores/panelChrome.js"
+ "src/ui/stores/panelChrome.js",
+ "src/ui/rail/panelState.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -3054,7 +3056,8 @@
"src/ui/stores/hud.js",
"src/ui/stores/firstRun.js",
"src/firstRunCopy.js",
- "src/ui/stores/panelChrome.js"
+ "src/ui/stores/panelChrome.js",
+ "src/ui/rail/panelState.js"
],
"external": [
"hls.js",
@@ -3863,7 +3866,8 @@
"src/ui/panelRailCatalog.js",
"src/ui/panelAccordion.js",
"src/ui/vueRuntime.js",
- "src/ui/stores/panelChrome.js"
+ "src/ui/stores/panelChrome.js",
+ "src/ui/rail/panelState.js"
],
"external": ["vue", "pinia"]
},
diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs
index 2bc983893..e8b78e4bc 100644
--- a/scripts/qa-cockpit-utility.mjs
+++ b/scripts/qa-cockpit-utility.mjs
@@ -1799,7 +1799,7 @@ try {
manager._setCockpitDisplayPortalActive(false);
document.body.classList.remove('cockpit-mode');
await waitFrames();
- standard.classList.remove('collapsed');
+ manager._presentPanel(standard, { collapsed: false });
standard.style.height = '120px';
standard.style.maxHeight = '120px';
standard.style.overflowY = 'auto';
@@ -1834,7 +1834,7 @@ try {
cockpit.style.height = priorCockpitHeight;
cockpit.style.maxHeight = priorCockpitMaxHeight;
cockpit.style.overflowY = priorCockpitOverflow;
- standard.classList.toggle('collapsed', standardWasCollapsed);
+ manager._presentPanel(standard, { collapsed: standardWasCollapsed });
return {
standardBefore,
standardAfter,
diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs
index a03d956c0..f0cbfbef3 100644
--- a/scripts/qa-radio.mjs
+++ b/scripts/qa-radio.mjs
@@ -1716,7 +1716,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
- manager._syncPanelCollapseButton(panel);
+ // The restored classes are the panel's state again.
+ const has = (name) => panel.classList.contains(name);
+ manager._presentPanel(panel, {
+ collapsed: has('collapsed'),
+ pinned: has('dock-pinned'),
+ pinnedTop: has('dock-pinned-top'),
+ auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
+ });
}
for (const [id, state] of Object.entries(prior.stacks)) {
const stack = document.getElementById(id);
@@ -1974,7 +1981,7 @@ async function main() {
manager._setCockpitDisplayPortalActive(true);
hud.hidden = false;
signal.hidden = false;
- dataPanel.classList.remove('collapsed');
+ manager._presentPanel(dataPanel, { collapsed: false });
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
if (display.getAttribute('aria-expanded') === 'true') display.click();
@@ -2059,8 +2066,7 @@ async function main() {
contextCard.hidden = false;
// Expand the panel the same way this block already does at entry, so the
// portaled Display groups measured further down keep their DOM home.
- dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
- manager._syncPanelCollapseButton(dataPanel);
+ manager._presentPanel(dataPanel, { collapsed: false, auto: null });
manager._syncLeftPanelAdaptiveLayout();
await waitForLayout();
const panelRect = dataPanel.getBoundingClientRect();
@@ -2287,7 +2293,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
- manager._syncPanelCollapseButton(panel);
+ // The restored classes are the panel's state again.
+ const has = (name) => panel.classList.contains(name);
+ manager._presentPanel(panel, {
+ collapsed: has('collapsed'),
+ pinned: has('dock-pinned'),
+ pinnedTop: has('dock-pinned-top'),
+ auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
+ });
}
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear();
diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs
index 44525e6ab..545c50b88 100644
--- a/scripts/qa-visual-input.mjs
+++ b/scripts/qa-visual-input.mjs
@@ -57,7 +57,7 @@ try {
persist: false,
syncShare: false,
});
- manager._sliderPanel.classList.remove('collapsed');
+ manager._presentPanel(manager._sliderPanel, { collapsed: false });
const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider;
const name = 'pixelation';
diff --git a/scripts/qa-weather-journey.mjs b/scripts/qa-weather-journey.mjs
index 75cd2c269..f79bf759f 100644
--- a/scripts/qa-weather-journey.mjs
+++ b/scripts/qa-weather-journey.mjs
@@ -261,7 +261,7 @@ try {
for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled)
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
- document.querySelector('#data-panel')?.classList.remove('collapsed');
+ gev.styleManager._presentPanel('data-panel', { collapsed: false });
});
// 1. Earth view, clean.
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index b806bcaea..9d1b5973c 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -1088,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]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?classList\.remove\('collapsed'\)/,
+ /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?!this\._cockpitDisclosureOpen\?\.\('display'\)[\s\S]*?_presentPanel\(this\._sliderPanel, \{ collapsed: false \}\)/,
);
assert.match(
radioBindings,
diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs
index e843a077d..ee2f82eef 100644
--- a/src/mapSourceFocus.test.mjs
+++ b/src/mapSourceFocus.test.mjs
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia';
import { msg, t } from './i18n/index.js';
+import { followPanelState, readPanelState } from './ui/rail/panelState.js';
import { usePanelChromeStore } from './ui/stores/panelChrome.js';
// Exercise the installed event routes and central close method, without WebGL.
@@ -108,18 +109,22 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
window.clearTimeout = (id) => timers.delete(id);
window.performance = { now: () => now };
document.defaultView = window;
- const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't', 'msg',
- `return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
- createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg,
+ const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't', 'msg', 'readPanelState',
+ `return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_presentPanel').toString()}});`)(
+ createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg, readPanelState,
);
const saves = [];
const claims = [];
let shareSyncs = 0;
const chromeStore = usePanelChromeStore(createPinia());
+ const chromeToken = chromeStore.connect({});
+ // The trays' roots draw the store's state, as v-panel-state does.
+ followPanelState(chromeStore, 'control-panel', panel);
+ followPanelState(chromeStore, 'location-bar', location);
const manager = {
_lifetime: { frame() {} },
_chromeStore: chromeStore,
- _chromeToken: chromeStore.connect({}),
+ _chromeToken: chromeToken,
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
...methods,
_savePanelCollapsedState(...args) { saves.push(args); },
diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs
index 970de246e..e9340963c 100644
--- a/src/panelStackLayout.test.mjs
+++ b/src/panelStackLayout.test.mjs
@@ -141,8 +141,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
- assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
- assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
+ assert.match(rails, /onCollapse\(panel, \{ collapsed: true, auto: 'layout' \}\)/);
+ assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /_presentPanel\(panel, \{ collapsed: false, auto: null \}\)/);
assert.match(ui, /reconsiderAutoCollapse/);
assert.match(
ui,
@@ -169,7 +169,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/);
// Collapse buttons render aria-expanded from the state the chrome presents
// (panelChromeButtons.test.mjs covers the views).
- assert.match(ui, /_chromeStore\.publish\(this\._chromeToken, panelEl\.id, \{\s*collapsed: panelEl\.classList\.contains\('collapsed'\),/);
+ assert.match(ui, /this\._presentPanel\(panelEl, \{ collapsed: nextCollapsed, auto: null \}\)/);
assert.match(leftRail, /--left-panel-allocated-height/);
assert.match(rightRail, /--right-panel-allocated-height/);
assert.match(
diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs
index 44d509db5..97285dd23 100644
--- a/src/radioMarkup.test.mjs
+++ b/src/radioMarkup.test.mjs
@@ -359,7 +359,7 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
// The collapse buttons render aria-expanded from the state the chrome presents.
- assert.match(ui, /_chromeStore\.publish\(this\._chromeToken, panelEl\.id, \{/);
+ assert.match(ui, /this\._presentPanel\(panelEl, \{ collapsed: nextCollapsed, auto: null \}\)/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
diff --git a/src/testSupport/panelState.mjs b/src/testSupport/panelState.mjs
new file mode 100644
index 000000000..068d59bcf
--- /dev/null
+++ b/src/testSupport/panelState.mjs
@@ -0,0 +1,21 @@
+// src/testSupport/panelState.mjs — present a panel's state the way the shell
+// does (PanelChrome._presentPanel), drawn at once as its root's v-panel-state
+// would, for fixtures whose panels are plain objects with a classList.
+import { applyPanelState, readPanelState } from '../ui/rail/panelState.js';
+
+/**
+ * @param {object|string} panelOrId A panel, or its id in `documentRef`.
+ * @param {object} [patch] The state that changes.
+ * @param {Document} [documentRef]
+ */
+export function presentPanelLocally(
+ panelOrId,
+ patch = {},
+ documentRef = globalThis.document,
+) {
+ const panel =
+ typeof panelOrId === 'string'
+ ? documentRef?.getElementById(panelOrId)
+ : panelOrId;
+ if (panel) applyPanelState(panel, { ...readPanelState(panel), ...patch });
+}
diff --git a/src/tooling/panelStorageDocs.test.mjs b/src/tooling/panelStorageDocs.test.mjs
index 9c2c69b23..d1179a397 100644
--- a/src/tooling/panelStorageDocs.test.mjs
+++ b/src/tooling/panelStorageDocs.test.mjs
@@ -77,13 +77,14 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
// against the code that decides them.
const { PanelPositionControls } =
await import('../ui/panelPositionControls.js');
+ const { presentPanelLocally } = await import('../testSupport/panelState.mjs');
const html = await readFile(
path.join(root, 'src/ui/views/panels/CctvPanel.vue'),
'utf8',
);
assert.match(
html,
- /
]*class="panel-collapsible collapsed"/,
'the CCTV panel starts collapsed in the markup',
);
@@ -110,7 +111,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
};
try {
const controls = new PanelPositionControls({
- syncPanelCollapseButton: () => {},
+ presentPanel: presentPanelLocally,
layoutRightPanels: () => {},
syncCctvPanelViewport: () => {},
showToast: () => {},
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 697521c92..42bb65587 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -234,8 +234,7 @@ export class StyleManager extends ShellFacade {
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
_setMapStack: (...args) => this._setMapStack(...args),
- _syncPanelCollapseButton: (...args) =>
- this._syncPanelCollapseButton(...args),
+ _presentPanel: (...args) => this._presentPanel(...args),
_syncShareState: (...args) => this._syncShareState(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
},
diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js
index d9930676a..3df30677d 100644
--- a/src/ui/leftPanelRail.js
+++ b/src/ui/leftPanelRail.js
@@ -17,7 +17,8 @@ import { settleSideAccordion } from './panelAccordion.js';
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
- * @param {Function} options.onCollapse Update a panel's disclosure chrome.
+ * @param {Function} options.onCollapse Present a panel's collapse:
+ * `(panel, {collapsed, auto})`.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {Map
} options.collapsedHeights Caller-owned measurement cache.
@@ -45,8 +46,7 @@ export function layoutLeftPanelRail({
for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'),
)) {
- panel.classList.remove('collapsed', 'layout-auto-collapsed');
- onCollapse(panel);
+ onCollapse(panel, { collapsed: false, auto: null });
}
}
@@ -262,8 +262,7 @@ export function layoutLeftPanelRail({
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
- panel.classList.add('collapsed', 'layout-auto-collapsed');
- onCollapse(panel);
+ onCollapse(panel, { collapsed: true, auto: 'layout' });
}
onRetry();
return;
diff --git a/src/ui/panelAccordion.js b/src/ui/panelAccordion.js
index 5690bf71a..4144ca67f 100644
--- a/src/ui/panelAccordion.js
+++ b/src/ui/panelAccordion.js
@@ -9,7 +9,8 @@
* @param {boolean} options.enabled Whether the one-panel rule applies.
* @param {string} [options.preferredPanelId] Most recently opened panel.
* @param {Document} [options.documentRef] Document supplying keyboard focus.
- * @param {Function} options.onCollapse Update a panel's disclosure chrome.
+ * @param {Function} options.onCollapse Present a panel's collapse:
+ * `(panel, {collapsed, auto})`.
*/
export function settleSideAccordion({
panels,
@@ -21,8 +22,7 @@ export function settleSideAccordion({
if (!enabled) {
for (const panel of panels) {
if (!panel.classList.contains('accordion-collapsed')) continue;
- panel.classList.remove('collapsed', 'accordion-collapsed');
- onCollapse(panel);
+ onCollapse(panel, { collapsed: false, auto: null });
}
return;
}
@@ -35,7 +35,6 @@ export function settleSideAccordion({
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
- panel.classList.add('collapsed', 'accordion-collapsed');
- onCollapse(panel);
+ onCollapse(panel, { collapsed: true, auto: 'accordion' });
}
}
diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js
index a710ca97a..f389ff97c 100644
--- a/src/ui/panelChrome.js
+++ b/src/ui/panelChrome.js
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
+import { readPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { applicationPinia } from './vueRuntime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
@@ -81,7 +82,7 @@ export class PanelChrome {
this._cockpitPanelRestore = null;
this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({
- syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
+ presentPanel: (panel, patch) => this._presentPanel(panel, patch),
showToast: (message) => this._showToast(message),
});
this._panelLayout = new PanelLayoutController({
@@ -90,7 +91,7 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
- syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
+ presentPanel: (panel, patch) => this._presentPanel(panel, patch),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -221,18 +222,18 @@ export class PanelChrome {
typeof pin === 'boolean'
? pin
: !panelEl.classList.contains('dock-pinned');
- panelEl.classList.toggle('dock-pinned', shouldPin);
- this._chromeStore.publish(this._chromeToken, panelId, {
- collapsed: panelEl.classList.contains('collapsed'),
- pinned: shouldPin,
- });
- document
- .querySelectorAll('#command-dock .dock-pinned-top')
- .forEach((pinnedPanel) => {
- pinnedPanel.classList.remove('dock-pinned-top');
+ // The tray pinned last draws on top; unpinning hands the top to the
+ // other tray if it is pinned.
+ this._presentPanel(panelEl, { pinned: shouldPin, pinnedTop: shouldPin });
+ for (const otherId of PINNABLE_PANEL_IDS) {
+ if (otherId === panelId) continue;
+ const other = document.getElementById(otherId);
+ if (!other) continue;
+ this._presentPanel(other, {
+ pinnedTop: !shouldPin && other.classList.contains('dock-pinned'),
});
+ }
if (shouldPin) {
- panelEl.classList.add('dock-pinned-top');
this.setPanelCollapsed(panelId, false, {
explicit: !restore,
restore,
@@ -240,10 +241,6 @@ export class PanelChrome {
syncShare: false,
});
} else {
- const remainingPinnedPanel = document.querySelector(
- '#command-dock .dock-pinned',
- );
- remainingPinnedPanel?.classList.add('dock-pinned-top');
if (!restore && !panelEl.matches(':hover')) {
this.setPanelCollapsed(panelId, true, {
explicit: true,
@@ -323,11 +320,23 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
- /** Present a panel's open state to its collapse and dock buttons. */
- _syncPanelCollapseButton(panelEl) {
+ /**
+ * Present a panel's state, merged over what it shows now, to the panel
+ * chrome store; its root and buttons draw it from there.
+ * @param {Element|string} panelOrId
+ * @param {Partial} [patch]
+ */
+ _presentPanel(panelOrId, patch = {}) {
+ const panelEl =
+ typeof panelOrId === 'string'
+ ? document.getElementById(panelOrId)
+ : panelOrId;
if (!panelEl?.id) return;
+ const shown =
+ this._chromeStore.panels[panelEl.id] ?? readPanelState(panelEl);
this._chromeStore.publish(this._chromeToken, panelEl.id, {
- collapsed: panelEl.classList.contains('collapsed'),
+ ...shown,
+ ...patch,
});
}
@@ -478,7 +487,6 @@ export class PanelChrome {
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
) {
- this._syncPanelCollapseButton(panelEl);
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
}
@@ -487,7 +495,6 @@ export class PanelChrome {
}
return;
}
- panelEl.classList.remove('layout-auto-collapsed', 'accordion-collapsed');
if (
!nextCollapsed &&
this.cockpitView?.active &&
@@ -518,7 +525,7 @@ export class PanelChrome {
});
}
}
- panelEl.classList.toggle('collapsed', nextCollapsed);
+ this._presentPanel(panelEl, { collapsed: nextCollapsed, auto: null });
if (
nextCollapsed &&
this.cockpitView?.active &&
@@ -528,7 +535,6 @@ export class PanelChrome {
this._cockpitContextCollapsedForDataPanel = false;
this.cockpitView.setContextCollapsed(false);
}
- this._syncPanelCollapseButton(panelEl);
if (persist !== false)
this._savePanelCollapsedState(panelId, nextCollapsed);
if (panelId === 'pp-toggles') {
@@ -586,6 +592,9 @@ export class PanelChrome {
}
destroy() {
if (this._disposed) return;
+ // Cockpit's own teardown follows the chrome's: put back the panels its
+ // entry closed while this owner can still present them.
+ this.exitCockpit();
this._disposed = true;
this._lifetime.destroy();
this._chromeStore.detach(this._chromeToken);
diff --git a/src/ui/panelChromeButtons.test.mjs b/src/ui/panelChromeButtons.test.mjs
index dabce96df..b04ad67a4 100644
--- a/src/ui/panelChromeButtons.test.mjs
+++ b/src/ui/panelChromeButtons.test.mjs
@@ -4,9 +4,11 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
-import { createApp, h, nextTick } from 'vue';
+import { createApp, h, nextTick, withDirectives } from 'vue';
import { createPinia } from 'pinia';
+import { settleSideAccordion } from './panelAccordion.js';
import { PanelChrome } from './panelChrome.js';
+import { followPanelState, vPanelState } from './rail/panelState.js';
import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue';
import { usePanelChromeStore } from './stores/panelChrome.js';
@@ -30,6 +32,8 @@ test('the store republishes a panel only when its state changes', () => {
assert.deepEqual(store.panels['data-panel'], {
collapsed: true,
pinned: false,
+ pinnedTop: false,
+ auto: null,
});
store.publish(token, 'data-panel', { collapsed: true });
assert.ok(store.panels === first, 'an unchanged state is not republished');
@@ -175,6 +179,8 @@ test('PanelChrome takes clicks only once initialized, pins only dock trays, and
`;
const store = usePanelChromeStore(createPinia());
const chrome = panelChrome(store);
+ for (const id of ['data-panel', 'cctv-panel', 'control-panel'])
+ followPanelState(store, id, document.getElementById(id));
const calls = [];
const setPanelCollapsed = chrome.setPanelCollapsed.bind(chrome);
chrome.setPanelCollapsed = (id, collapsed, options) => {
@@ -196,6 +202,8 @@ test('PanelChrome takes clicks only once initialized, pins only dock trays, and
assert.deepEqual(store.panels['control-panel'], {
collapsed: false,
pinned: true,
+ pinnedTop: true,
+ auto: null,
});
assert.equal(
document.getElementById('control-panel').classList.contains('dock-pinned'),
@@ -206,8 +214,12 @@ test('PanelChrome takes clicks only once initialized, pins only dock trays, and
const before = calls.length;
store.act('toggle', 'data-panel');
assert.equal(calls.length, before, 'a destroyed owner gets no clicks');
- chrome._syncPanelCollapseButton(document.getElementById('cctv-panel'));
+ chrome._presentPanel('cctv-panel', { collapsed: true });
assert.equal(store.panels['cctv-panel'], undefined, 'nor publishes');
+ assert.equal(
+ document.getElementById('cctv-panel').classList.contains('collapsed'),
+ false,
+ );
});
test('a remounted chrome starts from its own states, not a pin left behind', () => {
@@ -221,10 +233,12 @@ test('a remounted chrome starts from its own states, not a pin left behind', ()
document.body.innerHTML = '';
const second = panelChrome(store);
assert.equal(store.panels['location-bar'], undefined);
- second._syncPanelCollapseButton(document.getElementById('location-bar'));
+ second._presentPanel('location-bar');
assert.deepEqual(store.panels['location-bar'], {
collapsed: true,
pinned: false,
+ pinnedTop: false,
+ auto: null,
});
second.destroy();
});
@@ -238,3 +252,80 @@ test('bindPanelDisclosure refuses the retired button wiring', () => {
);
}
});
+
+test('a panel root draws its state at once, keeping classes others own', async (t) => {
+ const store = mount(t, {
+ render: () =>
+ withDirectives(
+ h('div', { id: 'scene-panel', class: 'panel-collapsible collapsed' }),
+ [[vPanelState, 'scene-panel']],
+ ),
+ });
+ const panel = document.getElementById('scene-panel');
+ panel.classList.add('running');
+ assert.equal(panel.classList.contains('collapsed'), true, 'markup stands');
+ const token = store.connect({});
+ store.publish(token, 'scene-panel', { collapsed: true, auto: 'accordion' });
+ // No tick: a layout measures in the same task.
+ assert.equal(panel.classList.contains('accordion-collapsed'), true);
+ store.publish(token, 'scene-panel', { collapsed: false, auto: null });
+ assert.equal(panel.classList.contains('collapsed'), false);
+ assert.equal(panel.classList.contains('accordion-collapsed'), false);
+ assert.equal(panel.classList.contains('running'), true);
+ assert.equal(panel.classList.contains('panel-collapsible'), true);
+ store.publish(token, 'scene-panel', { pinned: true, pinnedTop: true });
+ assert.ok(panel.classList.contains('dock-pinned'));
+ assert.ok(panel.classList.contains('dock-pinned-top'));
+});
+
+test('destroying the chrome inside Cockpit puts back the panels its entry closed', () => {
+ document.body.innerHTML = `
+
+ `;
+ const store = usePanelChromeStore(createPinia());
+ const chrome = panelChrome(store);
+ const data = document.getElementById('data-panel');
+ followPanelState(store, 'data-panel', data);
+ chrome.enterCockpit();
+ assert.equal(data.classList.contains('collapsed'), true);
+ chrome.destroy();
+ assert.equal(data.classList.contains('collapsed'), false);
+ chrome.exitCockpit();
+ assert.equal(data.classList.contains('collapsed'), false);
+});
+
+test('the accordion closes peers through the real store, and opens them again', () => {
+ document.body.innerHTML = `
+
+ `;
+ const store = usePanelChromeStore(createPinia());
+ const chrome = panelChrome(store);
+ const panels = ['data-panel', 'scene-panel'].map((id) => {
+ const panel = document.getElementById(id);
+ followPanelState(store, id, panel);
+ return panel;
+ });
+ const onCollapse = (panel, patch) => chrome._presentPanel(panel, patch);
+ settleSideAccordion({
+ panels,
+ enabled: true,
+ preferredPanelId: 'scene-panel',
+ documentRef: document,
+ onCollapse,
+ });
+ assert.deepEqual(store.panels['data-panel'], {
+ collapsed: true,
+ pinned: false,
+ pinnedTop: false,
+ auto: 'accordion',
+ });
+ assert.equal(panels[0].className, 'collapsed accordion-collapsed');
+ assert.equal(
+ store.panels['scene-panel'],
+ undefined,
+ 'the owner is untouched',
+ );
+ settleSideAccordion({ panels, enabled: false, onCollapse });
+ assert.equal(panels[0].className, '');
+ chrome.destroy();
+});
diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js
index 9d9057fa0..d75ae3580 100644
--- a/src/ui/panelLayoutController.js
+++ b/src/ui/panelLayoutController.js
@@ -64,12 +64,12 @@ export class PanelLayoutController {
constructor({
readHud,
scheduleCockpitLayout,
- syncPanelCollapseButton,
+ presentPanel,
readDisplayScrollTop,
}) {
this.readHud = readHud;
this.scheduleCockpitLayout = scheduleCockpitLayout;
- this._syncPanelCollapseButton = syncPanelCollapseButton;
+ this._presentPanel = presentPanel;
this.readDisplayScrollTop = readDisplayScrollTop;
this.destroyed = false;
this._adaptivePanelSettleTimer = null;
@@ -198,7 +198,6 @@ export class PanelLayoutController {
]) {
if (!panel) continue;
stack.insertBefore(panel, globalContextPanel);
- this._syncPanelCollapseButton(panel);
}
if (this._sliderPanel) {
this._sliderPanel.style.removeProperty('top');
@@ -286,8 +285,7 @@ export class PanelLayoutController {
for (const panel of this._rightPanelStack.querySelectorAll(
'.layout-auto-collapsed',
)) {
- panel.classList.remove('collapsed', 'layout-auto-collapsed');
- this._syncPanelCollapseButton(panel);
+ this._presentPanel(panel, { collapsed: false, auto: null });
}
}
this._syncRightPanelAdaptiveLayout();
@@ -303,7 +301,7 @@ export class PanelLayoutController {
windowRef: window,
hud: this.readHud(),
preferredPanelId: this._rightStackPreferredPanelId,
- onCollapse: (panel) => this._syncPanelCollapseButton(panel),
+ onCollapse: (panel, patch) => this._presentPanel(panel, patch),
onRetry: () => this._scheduleRightPanelLayout(),
leftStack: this._leftPanelStack,
displayPanel: this._ppToggles,
@@ -422,8 +420,7 @@ export class PanelLayoutController {
for (const panel of this._leftPanelStack.querySelectorAll(
'.layout-auto-collapsed',
)) {
- panel.classList.remove('collapsed', 'layout-auto-collapsed');
- this._syncPanelCollapseButton(panel);
+ this._presentPanel(panel, { collapsed: false, auto: null });
}
}
this._syncLeftPanelAdaptiveLayout();
@@ -438,7 +435,7 @@ export class PanelLayoutController {
windowRef: window,
hud: this.readHud(),
preferredPanelId: this._leftStackPreferredPanelId,
- onCollapse: (panel) => this._syncPanelCollapseButton(panel),
+ onCollapse: (panel, patch) => this._presentPanel(panel, patch),
onRetry: () => this._scheduleLeftPanelLayout(),
collapsedHeights: this._leftStackCollapsedHeights,
onAligned: () => this._scheduleRightPanelLayout(),
diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js
index ff296bf75..4e0e4ca1b 100644
--- a/src/ui/panelPositionControls.js
+++ b/src/ui/panelPositionControls.js
@@ -14,8 +14,8 @@ const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
];
export class PanelPositionControls {
- constructor({ syncPanelCollapseButton, showToast }) {
- this._syncPanelCollapseButton = syncPanelCollapseButton;
+ constructor({ presentPanel, showToast }) {
+ this._presentPanel = presentPanel;
this._showToast = showToast;
}
@@ -75,7 +75,7 @@ export class PanelPositionControls {
// first-run mission card for the one first impression there is. A stored
// choice still wins in both directions, so anyone who opens it keeps it.
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
- panelEl.classList.toggle('collapsed', collapsed);
+ this._presentPanel(panelEl, { collapsed });
// Bodies that expand themselves on first appearance must not override a
// choice the user (or a share link) already made.
if (panelEl.dataset)
@@ -84,7 +84,6 @@ export class PanelPositionControls {
: stored === null
? 'default'
: 'stored';
- this._syncPanelCollapseButton(panelEl);
}
_savePanelCollapsedState(panelId, collapsed) {
diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs
index 291eaac6c..5650feb44 100644
--- a/src/ui/panelRails.test.mjs
+++ b/src/ui/panelRails.test.mjs
@@ -7,6 +7,7 @@ import {
layoutRightPanelRail,
measurePanelNaturalHeight,
} from './panelRails.js';
+import { applyPanelState, readPanelState } from './rail/panelState.js';
test('Display uses an inner scroll body only in Cyber', () => {
const body = { scrollTop: 95 };
@@ -169,7 +170,11 @@ function fixture(
matchMedia: () => ({ matches: mobile }),
getComputedStyle: (node) => node.computed,
},
- onCollapse: (panel) => collapsed.push(panel.id),
+ // The owner presents the patch; the panel's root draws it at once.
+ onCollapse: (panel, patch) => {
+ collapsed.push(panel.id);
+ applyPanelState(panel, { ...readPanelState(panel), ...patch });
+ },
onRetry: () => {
retries += 1;
},
diff --git a/src/ui/rail/panelState.js b/src/ui/rail/panelState.js
new file mode 100644
index 000000000..af57a7af5
--- /dev/null
+++ b/src/ui/rail/panelState.js
@@ -0,0 +1,70 @@
+import { watch } from 'vue';
+import { usePanelChromeStore } from '../stores/panelChrome.js';
+
+/** The classes a panel's state draws on its root. */
+const CLASSES = Object.freeze({
+ collapsed: (state) => state.collapsed,
+ 'dock-pinned': (state) => state.pinned,
+ 'dock-pinned-top': (state) => state.pinnedTop,
+ 'layout-auto-collapsed': (state) => state.auto === 'layout',
+ 'accordion-collapsed': (state) => state.auto === 'accordion',
+});
+
+/**
+ * Draw `state` on a panel's root with `classList`, leaving other classes.
+ * @param {Element} el
+ * @param {import('../stores/panelChrome.js').PanelState} state
+ */
+export function applyPanelState(el, state) {
+ for (const [name, on] of Object.entries(CLASSES))
+ if (el.classList.contains(name) !== on(state))
+ el.classList.toggle(name, on(state));
+}
+
+/**
+ * Keep `el` drawing the state `store` holds for `panelId`, synchronously:
+ * the panel layouts measure a panel in the same task that opens or closes
+ * it. Until the panel is presented its markup's classes stand.
+ * @returns {() => void} Stops following the store.
+ */
+export function followPanelState(store, panelId, el) {
+ return watch(
+ () => store.panels[panelId],
+ (state) => state && applyPanelState(el, state),
+ { flush: 'sync', immediate: true },
+ );
+}
+
+/**
+ * `v-panel-state="'data-panel'"`: the panel root draws its open, pinned and
+ * layout-collapsed state from the panel chrome store.
+ */
+export const vPanelState = {
+ mounted(el, binding) {
+ const store = usePanelChromeStore(binding.instance.$pinia);
+ el._stopPanelState = followPanelState(store, binding.value, el);
+ },
+ beforeUnmount(el) {
+ el._stopPanelState?.();
+ },
+};
+
+/**
+ * A panel's state as its classes draw it: the markup's state, before its
+ * owner first presents the panel.
+ * @param {Element} el
+ * @returns {import('../stores/panelChrome.js').PanelState}
+ */
+export function readPanelState(el) {
+ const has = (name) => el.classList.contains(name);
+ return {
+ collapsed: has('collapsed'),
+ pinned: has('dock-pinned'),
+ pinnedTop: has('dock-pinned-top'),
+ auto: has('layout-auto-collapsed')
+ ? 'layout'
+ : has('accordion-collapsed')
+ ? 'accordion'
+ : null,
+ };
+}
diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js
index 1dcd4460f..40a80f3ba 100644
--- a/src/ui/rightPanelRail.js
+++ b/src/ui/rightPanelRail.js
@@ -41,7 +41,8 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
- * @param {Function} options.onCollapse Update a panel's disclosure chrome.
+ * @param {Function} options.onCollapse Present a panel's collapse:
+ * `(panel, {collapsed, auto})`.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {HTMLElement} options.leftStack Rail supplying the shared top baseline.
@@ -83,8 +84,7 @@ export function layoutRightPanelRail({
for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'),
)) {
- panel.classList.remove('collapsed', 'layout-auto-collapsed');
- onCollapse(panel);
+ onCollapse(panel, { collapsed: false, auto: null });
}
}
// One panel is open per desktop side; this marker is distinct from
@@ -275,8 +275,7 @@ export function layoutRightPanelRail({
if (!isCollapseRetry && autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
- panel.classList.add('collapsed', 'layout-auto-collapsed');
- onCollapse(panel);
+ onCollapse(panel, { collapsed: true, auto: 'layout' });
}
pendingCollapseRetries.add(stack);
onRetry();
diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js
index 4939d2861..ce12d3211 100644
--- a/src/ui/shellFacade.js
+++ b/src/ui/shellFacade.js
@@ -1024,13 +1024,14 @@ export class ShellFacade {
}
/**
- * Updates collapse button glyphs based on panel state. Right-rail panels
- * use directional arrows; left-stack panels use +/- symbols.
- * @param {HTMLElement} panelEl - The panel DOM element.
+ * Presents a panel's state, merged over what it shows now; its root and
+ * buttons draw it from the panel chrome store.
+ * @param {HTMLElement|string} panelOrId - The panel or its id.
+ * @param {object} [patch] - The state that changes.
* @returns {void}
*/
- _syncPanelCollapseButton(panelEl) {
- return this._panelChrome._syncPanelCollapseButton(...arguments);
+ _presentPanel(panelOrId, patch) {
+ return this._panelChrome._presentPanel(...arguments);
}
_buildSharePanelState() {
diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs
index feebf1826..5773bfa96 100644
--- a/src/ui/shellLifecycle.test.mjs
+++ b/src/ui/shellLifecycle.test.mjs
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { createPinia } from 'pinia';
import { PanelChrome } from './panelChrome.js';
+import { followPanelState } from './rail/panelState.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { ShellFeedback } from './shellFeedback.js';
@@ -150,7 +151,7 @@ test('panel layout coalesces each rail and cannot rearm after destruction', () =
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
- syncPanelCollapseButton() {},
+ presentPanel() {},
readDisplayScrollTop: () => 0,
});
owner._syncLeftPanelAdaptiveLayout = () => layouts++;
@@ -257,7 +258,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
}
});
-test('weather rail orders between CCTV and Context, observes and syncs chrome', (t) => {
+test('weather rail orders between CCTV and Context, observes and presents chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
@@ -298,16 +299,16 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
}
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
- const synced = [];
const store = usePanelChromeStore(createPinia());
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
+ const presented = [];
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
readDisplayScrollTop: () => 0,
- syncPanelCollapseButton(panel) {
- synced.push(panel.id);
- PanelChrome.prototype._syncPanelCollapseButton.call(chrome, panel);
+ presentPanel(panel, patch) {
+ presented.push(panel.id);
+ PanelChrome.prototype._presentPanel.call(chrome, panel, patch);
},
});
owner._initRightPanelAdaptiveLayout();
@@ -315,12 +316,15 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
);
- assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
- assert.equal(store.panels['weather-panel'].collapsed, true);
- weather.classList.remove('collapsed');
- PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
+ // Moving a panel into the rail leaves its state as it was.
+ assert.deepEqual(presented, []);
+ followPanelState(store, 'weather-panel', weather);
+ PanelChrome.prototype._presentPanel.call(chrome, weather, {
+ collapsed: false,
+ });
assert.equal(store.panels['weather-panel'].collapsed, false);
+ assert.equal(weather.classList.contains('collapsed'), false);
owner.destroy();
assert.equal(observed.size, 0);
assert.equal(f.frames.size, 0);
diff --git a/src/ui/sidePanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs
index 7badbd464..1e7b89ac6 100644
--- a/src/ui/sidePanelDisclosure.test.mjs
+++ b/src/ui/sidePanelDisclosure.test.mjs
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
+import { presentPanelLocally } from '../testSupport/panelState.mjs';
function fixture(theme = 'cyber') {
const nodes = new Map();
@@ -35,7 +36,7 @@ function fixture(theme = 'cyber') {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
- _syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
+ _presentPanel: presentPanelLocally, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
diff --git a/src/ui/stores/panelChrome.js b/src/ui/stores/panelChrome.js
index 21f0d3e7f..b63a6fdae 100644
--- a/src/ui/stores/panelChrome.js
+++ b/src/ui/stores/panelChrome.js
@@ -8,15 +8,35 @@ import { shallowRef } from 'vue';
*/
/**
- * Each panel's disclosure state as its collapse, dock and pin buttons render
- * it. PanelChrome owns the panels: it publishes here whenever a panel opens,
- * closes or is pinned, and connects the buttons' clicks; the views only render
- * and forward clicks.
+ * @typedef {object} PanelState
+ * @property {boolean} collapsed Whether the panel is closed, by the person or
+ * by its layout.
+ * @property {boolean} pinned A dock tray kept open (`dock-pinned`).
+ * @property {boolean} pinnedTop The pinned tray drawn on top.
+ * @property {?('layout'|'accordion')} auto Why a layout closed the panel
+ * (`layout-auto-collapsed`, `accordion-collapsed`), or null when the
+ * person's own choice stands.
+ */
+
+/** A panel's state before its owner has said otherwise. */
+const BLANK = Object.freeze({
+ collapsed: false,
+ pinned: false,
+ pinnedTop: false,
+ auto: null,
+});
+
+/**
+ * Each panel's disclosure state, which its root (`vPanelState`) and its
+ * collapse, dock and pin buttons render. PanelChrome owns the panels: it and
+ * the layouts it runs publish here whenever a panel opens, closes or is
+ * pinned, and it connects the buttons' clicks; the views only render and
+ * forward clicks.
*/
export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
/**
- * `{[panelId]: {collapsed, pinned}}`, frozen; a panel is absent until its
- * owner first presents it, and its buttons keep their markup until then.
+ * `{[panelId]: PanelState}`, frozen; a panel is absent until its owner
+ * first presents it, and it keeps its markup's classes until then.
*/
const panels = shallowRef(Object.freeze({}));
/** @type {{handlers: PanelChromeHandlers}|null} */
@@ -44,17 +64,13 @@ export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
* unchanged state is not republished.
* @param {object} token
* @param {string} panelId
- * @param {{collapsed?: boolean, pinned?: boolean}} patch
+ * @param {Partial} patch
*/
function publish(token, panelId, patch) {
if (!owns(token) || !panelId) return;
const prior = panels.value[panelId];
- const next = { collapsed: false, pinned: false, ...prior, ...patch };
- if (
- prior &&
- prior.collapsed === next.collapsed &&
- prior.pinned === next.pinned
- )
+ const next = { ...BLANK, ...prior, ...patch };
+ if (prior && Object.keys(BLANK).every((key) => prior[key] === next[key]))
return;
panels.value = Object.freeze({
...panels.value,
diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue
index 299ca5129..c6553720e 100644
--- a/src/ui/views/CommandDockView.vue
+++ b/src/ui/views/CommandDockView.vue
@@ -1,6 +1,7 @@
-