diff --git a/CHANGELOG.md b/CHANGELOG.md index bc10802f5..f511bf044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate UI assembly from standalone engine wiring, with dedicated panel layout, position, notice and recording owners. +- Stop pending UI presentation and drag work during disposal; preserve accessible status text when stopping its decoration. +- Organize component styles behind the same ordered stylesheet entry and include 3D model controls in the current-state snapshot. + - Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback. - Preserve shot-label identity on selection so double-click rename can complete. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 2ba08e77b..1fc301747 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -327,3 +327,14 @@ original Display groups, independent scroll positions and current focus owner. presentation. It receives project reads and explicit actions, with no imports of the director, source modules, camera engine or storage. Replacement and disposal release listeners; pending action feedback is limited to its current owner. + +## UI assembly and styles + +`ui/shell` assembles controls from supplied existing layer, navigation, terrain, +rendering, HUD and share operations. `src/standalone/ui.js` provides the running +application's instances; `src/ui.js` remains the compatibility entry. The shell +imports no standalone bootstrap or concrete live layer implementation. +Panel layout, position/drag, notices, recording and deferred UI work have separate +owners with synchronous cleanup. Existing scene, share and HUD engines retain +their entry points. `ui/styles` loads the ordered stylesheet entry; component +files retain the original cascade, including responsive and dock refinements. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7bf3b3b00..92f38babb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## UI shell and component ownership + +The standalone entry composes the UI with the application's existing layer, +terrain, navigation and rendering operations. The shell receives those instances +and assembles the controls. Panel layout scheduling, position preferences and +active drags, loading notices, recording presentation and DOM lookup have focused +owners. Disposal revokes queued presentation and listeners before asynchronous +Context restoration, cancels an unfinished drag without saving it, restores the +recording HUD and releases status decoration without replacing accessible text. +The ordinary control snapshot includes the current 3D model toggle and mode. + +`style.css` imports component styles in their original cascade order. Scene, +share, HUD and layer engines retain their existing behavior and entry points. + ## Scene control ownership Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation diff --git a/package.json b/package.json index d0bead40e..e7fa939f5 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,8 @@ "./ui/cockpit/math": "./src/cockpitMath.js", "./ui/cockpit/layout": "./src/cockpitUtilityLayout.js", "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js", - "./ui/scenes": "./src/ui/scenes.js" + "./ui/scenes": "./src/ui/scenes.js", + "./ui/shell": "./src/ui/applicationShell.js", + "./ui/styles": "./src/ui/styles.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 16b60d839..0cc639119 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -423,5 +423,136 @@ "src/ui/scenePresentation.js" ], "external": [] + }, + "ui-shell": { + "exports": [ + "./ui/shell" + ], + "modules": [ + "src/bloom.js", + "src/cctvFocusPolicy.js", + "src/cctvFocusRequest.js", + "src/cockpitMath.js", + "src/cockpitTracking.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/contactsDetectionPolicy.js", + "src/contextModePolicy.js", + "src/data/detectionPolicy.js", + "src/data/installationFeedback.js", + "src/data/layerState.js", + "src/data/militaryAwarenessEngine.js", + "src/keySetupCore.mjs", + "src/loadingFeedback.js", + "src/locationStatus.js", + "src/mapStackChips.js", + "src/navigationPolicy.js", + "src/panelStackLayout.js", + "src/rightRailPolicy.js", + "src/splitFlap.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/ui/applicationShell.js", + "src/ui/applicationShortcuts.js", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js", + "src/ui/clearLayersControl.js", + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/ui/displayControls.js", + "src/ui/effects.js", + "src/ui/layerPanel.js", + "src/ui/layers.js", + "src/ui/leftPanelRail.js", + "src/ui/location.js", + "src/ui/locationControls.js", + "src/ui/locationSearch.js", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/panelDisclosure.js", + "src/ui/panelLayoutController.js", + "src/ui/panelMeasurement.js", + "src/ui/panelPositionControls.js", + "src/ui/panelRailGeometry.js", + "src/ui/panelRails.js", + "src/ui/radio.js", + "src/ui/radioBindings.js", + "src/ui/radioControls.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js", + "src/ui/recordingControls.js", + "src/ui/rightPanelRail.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/scenes.js", + "src/ui/shellElements.js", + "src/ui/shellFeedback.js", + "src/ui/styleParameters.js", + "src/ui/surfaceKeyboard.js", + "src/ui/visualEffects.js", + "src/ui/visualInput.js", + "src/ui/visualPresets.js", + "src/worldFocus.js", + "src/ui/uiLifetime.js" + ], + "external": [ + "cesium" + ] + }, + "ui-styles": { + "exports": [ + "./ui/styles" + ], + "modules": [ + "src/ui/styles.js", + "style.css", + "src/ui/styles/foundation.css", + "src/ui/styles/controls.css", + "src/ui/styles/location.css", + "src/ui/styles/status.css", + "src/ui/styles/cockpit.css", + "src/ui/styles/overlays.css", + "src/ui/styles/layers.css", + "src/ui/styles/radio.css", + "src/ui/styles/cctv.css", + "src/ui/styles/scenes.css", + "src/ui/styles/recording.css", + "src/ui/styles/responsive.css", + "src/ui/styles/command-dock.css", + "src/ui/styles/command-dock-compact.css", + "src/ui/styles/command-dock-trays.css", + "src/ui/styles/command-dock-sliding.css", + "src/ui/styles/voice-cost.css", + "src/ui/styles/first-run.css", + "src/ui/styles/provider-settings.css" + ], + "external": [] } } diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 5d5854bc6..149290ce9 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -918,15 +918,15 @@ try { }); const waitForQueuedNotice = async (label, timeoutMs = 1000) => { const deadline = performance.now() + timeoutMs; - while (styleManager._globalStatusNotice?.label !== label + while (styleManager._feedback._globalStatusNotice?.label !== label && performance.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 16)); } - return styleManager._globalStatusNotice?.label === label; + return styleManager._feedback._globalStatusNotice?.label === label; }; const baseNow = performance.now(); try { - styleManager._loadingFeedbackState = { + styleManager._feedback._loadingFeedbackState = { phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0, activeIds: [], batchOutcome: null, terminal: null, operation: null, }; diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 40f34240f..d3c26471c 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1702,8 +1702,8 @@ async function main() { dataset: { ...stack.dataset }, }]; })), - preferredLeftPanelId: manager._leftStackPreferredPanelId, - preferredRightPanelId: manager._rightStackPreferredPanelId, + preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId, + preferredRightPanelId: manager._panelLayout._rightStackPreferredPanelId, hudMode: manager.hud.getMode(), hudVariant: manager.hud.getVariant(), focusId: document.activeElement?.id || null, @@ -1767,7 +1767,7 @@ async function main() { const scenePanel = document.getElementById('scene-panel'); const contextPanel = document.getElementById('global-context-panel'); result.tacticalAutoCollapse = { - latestOpenedPanel: manager._leftStackPreferredPanelId, + latestOpenedPanel: manager._panelLayout._leftStackPreferredPanelId, scenesExpanded: !scenePanel.classList.contains('collapsed'), dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'), dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0 @@ -1814,13 +1814,13 @@ async function main() { manager.hud.setMode(prior.hudMode); manager._updateHudButtonState(); await new Promise((resolve) => setTimeout(resolve, 320)); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } for (const [id, state] of Object.entries(prior.panels)) { const panel = document.getElementById(id); @@ -1839,18 +1839,18 @@ async function main() { for (const key of Object.keys(stack.dataset)) delete stack.dataset[key]; Object.assign(stack.dataset, state.dataset); } - manager._leftStackPreferredPanelId = prior.preferredLeftPanelId; - manager._rightStackPreferredPanelId = prior.preferredRightPanelId; + manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; + manager._panelLayout._rightStackPreferredPanelId = prior.preferredRightPanelId; localStorage.clear(); for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } // Disclosure restoration can wake the installed layout observers. Let // those observers settle, then restore the captured stack presentation @@ -1880,8 +1880,8 @@ async function main() { result.restoreDetails = { panelsRestored, stacksRestored, - preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId, - preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId, + preferredPanelRestored: manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId, + preferredRightPanelRestored: manager._panelLayout._rightStackPreferredPanelId === prior.preferredRightPanelId, hudModeRestored: manager.hud.getMode() === prior.hudMode, hudVariantRestored: manager.hud.getVariant() === prior.hudVariant, storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage), @@ -2055,7 +2055,7 @@ async function main() { detectionMode: manager.getDetectionState().detectionMode, models3dEnabled: manager._models3dEnabled, models3dMode: manager._models3dMode, - preferredLeftPanelId: manager._leftStackPreferredPanelId, + preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId, panels: Object.fromEntries([ 'data-panel', 'global-context-panel', @@ -2371,13 +2371,13 @@ async function main() { if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop); else hud.style.removeProperty('--cockpit-utility-top'); await waitForLayout(); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } for (const [id, state] of Object.entries(prior.panels)) { const panel = document.getElementById(id); @@ -2388,7 +2388,7 @@ async function main() { else panel.setAttribute('aria-hidden', state.ariaHidden); manager._syncPanelCollapseButton(panel); } - manager._leftStackPreferredPanelId = prior.preferredLeftPanelId; + manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; localStorage.clear(); for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); @@ -2411,7 +2411,7 @@ async function main() { && panel.getAttribute('style') === state.style && panel.getAttribute('aria-hidden') === state.ariaHidden; }) - && manager._leftStackPreferredPanelId === prior.preferredLeftPanelId + && manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId && JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage) && (!prior.focusId || document.activeElement?.id === prior.focusId); } diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index 4c82c347f..916561b40 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -208,6 +208,11 @@ try { !window.__godsEyeView.styleManager.getControlState().recording, ), ); + // Escape can also collapse the Scene accordion. Reopen it through its + // installed disclosure before testing the next visible user action. + if (await page.$eval('#scene-panel', (panel) => panel.classList.contains('collapsed'))) + await page.click('[data-collapse-target="scene-panel"]'); + await page.waitForSelector('#scene-download-btn', { visible: true }); await page.click('#scene-download-btn'); const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1)), diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 2808adfa0..7bf0b7649 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -33,7 +33,8 @@ try { const ui = window.__godsEyeView.styleManager; const counts = {}; const watchObserver = (name) => { - const observer = ui[name]; + const owner = name === '_draggableResizeObserver' ? ui._panelPosition : ui._panelLayout; + const observer = owner[name]; if (!observer) return false; const disconnect = observer.disconnect.bind(observer); counts[name] = 0; @@ -113,6 +114,10 @@ try { const disposal = ui.dispose(); const focusBefore = document.activeElement; const stoppedBeforeRestoration = + ui._panelLayout.destroyed && + ui._panelPosition.destroyed && + ui._feedback.destroyed && + ui._recording.destroyed && cockpit.destroyed && portal.stopped && cockpit._listenerRemovers.length === 0 && diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 3ef065443..eecd53bbc 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -7,7 +7,7 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8'); const vessels = fs.readFileSync(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), 'utf8'); const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 5f4abd82e..3574bdf78 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -73,7 +73,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur assert.match(diagnostics[0][0], /before setEnabled await/); assert.match(diagnostics[1][0], /after setEnabled await/); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/); }); @@ -114,6 +114,6 @@ test('a disposed UI cannot activate a camera when enabling finishes late', async release(); await pending; assert.equal(activations, 0); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(uiSource, /shouldFocus: \(\) => !this\._disposed && this\._dataManager\.isEnabled\('cctv'\)/); }); diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index fee23eb5e..64b4c20de 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -57,7 +57,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once assert.strictEqual(added[0].callback, listener); assert.strictEqual(removed[0].callback, added[0].callback); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match( uiSource, /_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index e67fae046..aa4b83792 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,6 @@ +import { PanelLayoutController } from './ui/panelLayoutController.js'; +import { readShellElements } from './ui/shellElements.js'; +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; @@ -23,8 +26,8 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); -const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8'); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); +const css = readStylesheet(path.join(ROOT, 'style.css')); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8'); const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); @@ -299,7 +302,7 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below ); assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1, 'Parameters must have one DOM owner'); assert.match( - ui, + PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(), /const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/, ); assert.match( @@ -476,7 +479,7 @@ test('Reset releases Contact camera ownership through its selection-preserving r assert.ok(resetStart >= 0); assert.ok(contextRelease > resetStart); assert.ok(satelliteRelease > contextRelease); - assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/); + assert.match(readShellElements.toString(), /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/); const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8'); assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/); assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); @@ -530,8 +533,9 @@ test('Cockpit Radio station changes preserve first-person camera ownership', () }); test('Cockpit panel corridors reserve the owned topline readouts', () => { - const leftObstacles = ui.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); - const rightObstacles = ui.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); + const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); + const leftObstacles = layout.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); + const rightObstacles = layout.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); assert.ok(leftObstacles && rightObstacles, 'responsive panel obstacle selectors are missing'); assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline/); assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/); @@ -569,8 +573,8 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => { /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, ); assert.match( - ui, - /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.cockpitView\?\.scheduleContextLayout\(\);/, + layout, + /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.scheduleCockpitLayout\(\);/, 'the strip must remeasure on the same HUD fade the accordion does — the REC ' + 'readout keeps its rect until the transition ends', ); diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 4307cc182..331151f8e 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -28,7 +28,7 @@ import { import { canonicalizeDensity } from './data/detectionPolicy.js'; // Follow the UI wiring and its extracted preset definitions. -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8') +const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8') + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); /** diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 7fe6a8288..af836c793 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -23,7 +23,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8'); +const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8'); const handler = _handleContextLayerChange.toString(); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 20b972967..49a640d97 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _syncContextModeButtons } from './ui/contextPresentation.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js'; @@ -11,8 +12,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); -const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) { diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index dc1c17136..7dd7d0b70 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -5,8 +6,8 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); +const css = readStylesheet(path.join(ROOT, 'style.css')); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); /* * Required-attribution keep-out pin. @@ -436,9 +437,10 @@ test('custom properties inside modelled offsets are provably non-negative', () = assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`); } - const start = ui.indexOf('_updateCommandDockTrayStack() {'); + const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); + const start = layout.indexOf('_updateCommandDockTrayStack() {'); assert.ok(start > 0, '_updateCommandDockTrayStack is missing'); - const writer = ui.slice(start, start + 1800); + const writer = layout.slice(start, start + 1800); // Every value traces back to a rect height, floored at 0 and rounded up. assert.match(writer, /const locationHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/); assert.match(writer, /const presetsHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index b51776ff5..e76f6a75f 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -671,7 +671,7 @@ test('pathological detection paint holds alternate frames without freezing share test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => { const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); - const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8') + const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8') + '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/); assert.doesNotMatch(source, /postRender\.addEventListener/); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e75e..0080ef9ab 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -371,7 +371,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an assert.match(source, /^let _models3dMode = 'proximity';/m, `${name}: and starts in proximity, matching the codec default`); } - const ui = await readFile(new URL('../ui.js', import.meta.url), 'utf8'); + const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(ui, /^\s*this\._models3dEnabled = true;$/m, 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives'); assert.match(ui, /this\._models3dMode = 'proximity';/, diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index f7f00b0bd..1099127fb 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -270,7 +270,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( // personally clicked, and re-tracking the same aircraft after a layer restart // should still show the triangle. Only a page reload resets it — so no // production code may clear the registry. - for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) { + for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) { const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8'); assert.doesNotMatch(source, /clearTr3bRegistry/, `${name}: teardown must not clear session conversions`); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index b19f082ae..002959ba9 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.deepEqual( [...EXCLUSIVE_SURFACE_CLASSES].sort(), @@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', ( test('an overlay with NO class to watch still disarms the launcher', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // The repro, pinned as the stacking it actually is: the attribution lightbox // is full-screen ABOVE the card and announces itself with nothing. The card @@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', () test('the scroll fade only appears when the list really overflows', () => { const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); // A fade on a card where all five tiles fit promises a sixth mission that does // not exist, which is worse than no affordance at all. assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/); @@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', () test('markup, startup ordering and accessibility remain pinned', () => { const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8'); - const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4); @@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt }); test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => { - const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8'); // The rail opened by default to advertise HUD / DETECT / 3D. Those default ON // now, so it was opening to offer controls for things already happening — // while competing with the mission card for the one first impression there is. diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index 3e6cfa541..5244cffdc 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,8 +1,9 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); function ruleBody(selector) { const start = css.indexOf(selector); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 7537efb06..cb26795e6 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,3 +1,4 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -89,15 +90,15 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const start = ui.indexOf(' _handleShareTrackingRestoreStatus(result) {'); const end = ui.indexOf('\n _initGlobalContextPanel() {', start); const handler = ui.slice(start, end); assert.match(handler, /this\._showGlobalStatusNotice\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /requestAnimationFrame\(\(\) => \{/); - assert.match(handler, /startupCover\.addEventListener\('transitionend', showOnce, \{ once: true \}\)/); - assert.match(handler, /fallbackTimer = setTimeout\(showOnce, 1000\)/); + assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(handler, /this\._lifetime\.listen\(startupCover, 'transitionend', showOnce, \{ once: true \}\)/); + assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); assert.doesNotMatch(handler, /this\._showToast\(message\)/); assert.doesNotMatch(handler, /pushCockpitSignal/); assert.match(handler, /result\.classification === 'pending'/); @@ -195,13 +196,13 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed }); test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { - const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); - assert.match(dispose, /this\._globalStatusNotice = null;/); + assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/); assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/); assert.match(html, /