From 945a80999445838168e838e13d13c4c4a187667a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 04:49:02 -0700 Subject: [PATCH] fix: retain shell feedback bindings and stop disposed panel work --- scripts/qa-ui-disposal.mjs | 38 +++++++++++++++++++++++++++++++-- src/ui/applicationShell.js | 24 +-------------------- src/ui/panelLayoutController.js | 11 ++++++++++ src/ui/panelPositionControls.js | 14 ++++++++---- src/ui/shellLifecycle.test.mjs | 5 +++++ 5 files changed, 63 insertions(+), 29 deletions(-) diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 7bf0b7649..a52d5d4b9 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -33,7 +33,10 @@ try { const ui = window.__godsEyeView.styleManager; const counts = {}; const watchObserver = (name) => { - const owner = name === '_draggableResizeObserver' ? ui._panelPosition : ui._panelLayout; + const owner = + name === '_draggableResizeObserver' + ? ui._panelPosition + : ui._panelLayout; const observer = owner[name]; if (!observer) return false; const disconnect = observer.disconnect.bind(observer); @@ -110,6 +113,12 @@ try { if (type === 'resize' && callback === resizeHandler) counts.resize++; return removeEventListener.call(this, type, callback, options); }; + const { setSplitFlapText } = await import('/src/splitFlap.js'); + const feedbackLabel = document.getElementById('global-loading-label'); + setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA'); + setSplitFlapText(feedbackLabel, 'LOAD COMPLETE'); + const permanentText = + feedbackLabel.querySelector('.gev-flap-text')?.firstChild; try { const disposal = ui.dispose(); const focusBefore = document.activeElement; @@ -167,7 +176,11 @@ try { counts[field] === 1 && ui._contextControls[field] === null, ), observersReleased: observed.every( - (name) => counts[name] === 1 && ui[name] === null, + (name) => + counts[name] === 1 && + (name === '_draggableResizeObserver' + ? ui._panelPosition + : ui._panelLayout)[name] === null, ), subscriptionReleased: counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, @@ -176,6 +189,19 @@ try { ui._radioControls.destroyed && ui._locationControls.destroyed && ui._cctvControls.destroyed, + feedbackTextPreserved: + permanentText?.nodeType === Node.TEXT_NODE && + feedbackLabel.querySelector('.gev-flap-text')?.firstChild === + permanentText && + permanentText.data === 'LOAD COMPLETE' && + !feedbackLabel.classList.contains('gev-flap-active') && + feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length === + 0, + shellWorkReleased: + ui._lifetime.destroyed && + ui._lifetime.frames.size === 0 && + ui._lifetime.timers.size === 0 && + ui._lifetime.removers.size === 0, idempotent: once === JSON.stringify(counts), }; } finally { @@ -215,6 +241,14 @@ try { 'Cockpit cleanup releases listeners and returns all Display groups home', result.cockpitReleased, ); + check( + 'feedback teardown preserves the permanent accessible text node', + result.feedbackTextPreserved, + ); + check( + 'shell teardown cancels deferred work and releases listeners', + result.shellWorkReleased, + ); check('repeated UI disposal is inert', result.idempotent); check( 'disposal and subsequent resize produce no uncaught browser errors', diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ab10cbdaf..dc9d7bb7b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1,3 +1,4 @@ +import { setSplitFlapText } from '../splitFlap.js'; import { UiLifetime } from './uiLifetime.js'; import { RecordingControls } from './recordingControls.js'; import { readShellElements } from './shellElements.js'; @@ -86,8 +87,6 @@ import { stampInitialShareGesture, } from '../navigationPolicy.js'; -/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ - const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'control-panel', pinnable: true }, { id: 'location-bar', pinnable: true }, @@ -108,28 +107,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'global-context-panel', 'radio-panel', ]); -/** - * Position keys are versioned separately from collapsed-state keys so layout - * default changes (e.g. right-rail origin) can reset positions without also - * resetting every panel's open/closed preference. - */ - const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1'; -/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ - -/** - * Fixed UI regions that can occupy the left accordion's vertical lane. - * Rectangles are filtered at runtime for visibility and horizontal overlap, - * so right-side/center controls do not reduce the lane unless they actually - * intersect it at the current viewport size. - */ - -/** - * Fixed UI regions that can occupy the right control lane. Runtime rectangle - * filtering keeps the rail clear of whichever HUD variant is currently - * visible without tying the layout to one screen height. - */ - /** Display labels shown in the mini-status readout for each active style. */ const STYLE_STATUS_LABELS = { normal: 'NORMAL', diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index b3ca22cd1..f7bdd1977 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -1,6 +1,12 @@ /** Own rail measurement, layout scheduling and dock tray observation. */ import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js'; const COCKPIT_LAYOUT_SETTLE_MS = 240; +/** + * Fixed UI regions that can occupy the left accordion's vertical lane. + * Rectangles are filtered at runtime for visibility and horizontal overlap, + * so right-side/center controls do not reduce the lane unless they actually + * intersect it at the current viewport size. + */ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#cockpit-hud .cockpit-topline', '#cockpit-hud .cockpit-topline > div', @@ -26,6 +32,11 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#pp-toggles', '#param-slider-panel', ].join(', '); +/** + * Fixed UI regions that can occupy the right control lane. Runtime rectangle + * filtering keeps the rail clear of whichever HUD variant is currently + * visible without tying the layout to one screen height. + */ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cockpit-hud .cockpit-topline', '#cockpit-hud .cockpit-topline > div', diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 7b4468358..40d1c319f 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,6 +1,13 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ +/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; +/** + * Position keys are versioned separately from collapsed-state keys so layout + * default changes (e.g. right-rail origin) can reset positions without also + * resetting every panel's open/closed preference. + */ const PANEL_POSITION_STORAGE_VERSION = 'v8'; +/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; export class PanelPositionControls { @@ -22,6 +29,7 @@ export class PanelPositionControls { this.destroyed = false; } listen(target, type, callback) { + if (this.destroyed || !target) return; const listener = (event) => { if (!this.destroyed) callback(event); }; @@ -29,6 +37,7 @@ export class PanelPositionControls { this.removers.push(() => target.removeEventListener(type, listener)); } _reclampDraggablePanels() { + if (this.destroyed) return; const el = this._ppToggles; if (!el || !el.style.top || el.style.top === 'auto') return; const top = parseInt(el.style.top, 10); @@ -56,6 +65,7 @@ export class PanelPositionControls { } _initPanelDrag() { + if (this.destroyed) return; const dragSpecs = [ { id: 'pp-toggles', @@ -258,10 +268,6 @@ export class PanelPositionControls { }; const onUp = () => { cancel(); - panelEl.classList.remove('panel-dragging'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); if (panelId === 'pp-toggles') { this._pinPanelToRight(panelEl); } diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs index eb2dbb6bc..a53c97e47 100644 --- a/src/ui/shellLifecycle.test.mjs +++ b/src/ui/shellLifecycle.test.mjs @@ -201,6 +201,11 @@ test('panel disposal cancels a live drag without saving or accepting later point window.dispatchEvent(new Event('pointerup')); assert.deepEqual(panel.style, before); assert.equal(panel.classList.contains('panel-dragging'), false); + owner._makePanelDraggable('sample', panel, handle); + owner._initPanelDrag(); + owner._ppToggles.style.top = '10000px'; + owner._reclampDraggablePanels(); + assert.equal(owner._ppToggles.style.top, '10000px'); assert.equal(owner.removers.length, 0); assert.deepEqual(f.writes, []); } finally {