From 95523e09cdfc675f5857dbc2f1a3ed200f920296 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 23:51:47 -0700 Subject: [PATCH] feat(panels): render each panel's state classes from the store A panel's collapsed, dock-pinned, dock-pinned-top, layout-auto-collapsed and accordion-collapsed classes are drawn by its root's v-panel-state from the gev-panel-chrome store. PanelChrome, its restore, the layout controller, both rails and the side accordion present each panel's state there instead of writing its classes; the rails call onCollapse(panel, {collapsed, auto}). The directive applies a change in a synchronous watcher, since the rails measure a panel in the task that opens or closes it. Destroying the chrome inside Cockpit now puts back the panels Cockpit's entry closed while it can still present them. Without a shell, the voice tools' set_panel_open reports that there is no panel to open. QA scripts present the states they set instead of writing the classes. --- CHANGELOG.md | 10 +++ docs/VUE-MIGRATION.md | 8 +- docs/panel-surfaces.md | 6 +- scripts/package-boundaries.json | 12 ++- scripts/qa-cockpit-utility.mjs | 4 +- scripts/qa-radio.mjs | 23 ++++-- scripts/qa-visual-input.mjs | 2 +- scripts/qa-weather-journey.mjs | 2 +- src/cockpitMarkup.test.mjs | 2 +- src/mapSourceFocus.test.mjs | 13 +++- src/panelStackLayout.test.mjs | 6 +- src/radioMarkup.test.mjs | 2 +- src/testSupport/panelState.mjs | 21 +++++ src/tooling/panelStorageDocs.test.mjs | 5 +- src/ui/applicationShell.js | 3 +- src/ui/leftPanelRail.js | 9 +-- src/ui/panelAccordion.js | 9 +-- src/ui/panelChrome.js | 55 ++++++++------ src/ui/panelChromeButtons.test.mjs | 97 +++++++++++++++++++++++- src/ui/panelLayoutController.js | 15 ++-- src/ui/panelPositionControls.js | 7 +- src/ui/panelRails.test.mjs | 7 +- src/ui/rail/panelState.js | 70 +++++++++++++++++ src/ui/rightPanelRail.js | 9 +-- src/ui/shellFacade.js | 11 +-- src/ui/shellLifecycle.test.mjs | 24 +++--- src/ui/sidePanelDisclosure.test.mjs | 3 +- src/ui/stores/panelChrome.js | 42 ++++++---- src/ui/views/CommandDockView.vue | 5 +- src/ui/views/ContextView.vue | 7 +- src/ui/views/LayerPanelsView.vue | 5 +- src/ui/views/panels/CctvPanel.vue | 3 +- src/ui/views/panels/DisplayPanel.vue | 5 +- src/ui/views/panels/RadioPanel.vue | 3 +- src/ui/views/panels/StreetLevelPanel.vue | 3 +- src/ui/visualSettings.js | 6 +- src/voice/gevActions.js | 25 ++++-- 37 files changed, 404 insertions(+), 135 deletions(-) create mode 100644 src/testSupport/panelState.mjs create mode 100644 src/ui/rail/panelState.js 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, - /