diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4d656502b..cf4da7d57 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -246,8 +246,8 @@ red hover border in both map and cockpit, so clipping does not hide the highligh Cyber's map-side right rail opens one panel at a time while keeping collapsed launchers available. Display, CCTV and Context scroll their contents inside fixed headers and decorative frames. The narrow-screen rail remains scrollable to reach -each panel. Radio retains the shared nested Context player and compact -header disclosure, without relocating playback controls on theme changes. +each panel. Radio retains the shared nested Context player without relocating +playback controls on theme changes. Voice help/error popups and Location/Visual Presets pins extend beyond their Cyber frames without being clipped: the shapes decorate non-interactive @@ -2986,15 +2986,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > stacking. Narrow mobile Cockpit > viewports suppress the legacy layer stack, peripheral HUD, and secondary > Context/briefing panes so the flight instruments and primary controls remain -> unobstructed. Outside Cockpit, Radio starts collapsed while off. When Context -> is collapsed, its Radio header icon opens compact controls whose explicit -> Enable/Disable action owns power; stable close and full-panel buttons own -> compact dismissal and one-way detailed expansion independently. When Context -> is already expanded, that same icon skips the floating compact card, expands -> the embedded Radio section, scrolls it into view, and moves keyboard focus to -> its disclosure control. The detailed panel's normal accordion control owns -> its collapse. Compact/detailed state and playback continuity remain shared. -> Radio volume in the full, compact, and Cockpit surfaces, plus Space Mission +> unobstructed. Outside Cockpit, Radio starts collapsed while off and lives +> only in its section inside Context; the detailed panel's normal accordion +> control owns its disclosure and its Enable/Disable action owns power. +> Radio volume in the full and Cockpit surfaces, plus Space Mission > replay speed, use Display / Sharpen's muted 3px rail, circular cyan thumb, > glow, and mono value treatment. Their larger transparent hit areas, keyboard > focus indication, ranges, disabled states, and mission speed scale remain @@ -4770,13 +4765,6 @@ synchronous playback-control observers run, so reentrant voice cleanup cannot overwrite a released stream with a stale paused state. Layer destruction likewise retains an enabled manager entry when module disable or destroy fails semantically, preventing an active orphan and allowing retry. -When Context is collapsed, explicitly activating its Radio header icon reveals -the compact transport; hover and focus alone do not open it. When Context is -expanded, the icon instead expands and scrolls to the embedded Radio section. -Its accessible label, controlled region, and expanded state keep describing the -current route throughout enabling, enabled, disabling, and uncertain lifecycle -renders. Both routes preserve Radio power, playback, station, filter, and volume -state. Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5947f8811..95ebbd734 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -703,7 +703,6 @@ "src/ui/shareRestoration.js", "src/ui/visualSettings.js", "src/ui/panelChrome.js", - "src/ui/panelChrome.radioOwnership.test.mjs", "src/testSupport/readShellSource.mjs", "src/ui/stateOwners.test.mjs", "src/layers/flights/records.js", diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index cdca7a55c..a0a6d1ca9 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -177,7 +177,7 @@ const MUTATIONS = [ { // Anchored past the call so it lands on the Cockpit utility disclosure's // Escape claim and nowhere else. - defect: 'a Cockpit utility disclosure returns to stopPropagation, so one ESC does two things', + defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC', file: 'radio', from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';", to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';", diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 03f402363..0c5c5bb2c 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -83,6 +83,32 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Native pick buffers and clustered overlay entries settle on rendered frames, // not on wall-clock sleeps, particularly with software rendering. +// Radio lives only inside Context; a user reaches its controls by expanding +// Context and then Radio. Returns the prior collapse state so a step can put +// the panels back for the scenarios that follow. +async function revealRadioControls(page, targetId) { + const prior = await page.evaluate(async (id) => { + const manager = window.__godsEyeView.styleManager; + const collapsed = (panelId) => document.getElementById(panelId).classList.contains('collapsed'); + const state = { context: collapsed('global-context-panel'), radio: collapsed('radio-panel') }; + manager.setPanelCollapsed('global-context-panel', false, { explicit: true }); + manager.setPanelCollapsed('radio-panel', false, { explicit: true }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + document.getElementById(id).scrollIntoView({ block: 'nearest' }); + return state; + }, targetId); + await page.waitForSelector(`#${targetId}`, { visible: true }); + return prior; +} + +async function restoreRadioPanels(page, prior) { + await page.evaluate(({ context, radio }) => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('radio-panel', radio); + manager.setPanelCollapsed('global-context-panel', context); + }, prior); +} + async function settleRadioFrames(page) { await page.evaluate(() => new Promise((resolve, reject) => { const scene = window.__godsEyeView.viewer.scene; @@ -285,13 +311,16 @@ async function main() { window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true); }); + const enablePanels = await revealRadioControls(page, 'radio-enable-btn'); const enableStartedAt = Date.now(); - await page.$eval('#radio-enable-btn', (button) => button.click()); + await page.click('#radio-enable-btn'); await page.waitForFunction(() => { const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(); return state.enabled && !state.loading && state.stationCount === 750; }); const enableElapsedMs = Date.now() - enableStartedAt; + // Later scenarios start from the collapsed rail this step began with. + await restoreRadioPanels(page, enablePanels); const initial = await page.evaluate(() => { const module = window.__godsEyeView.dataManager.layers.get('radio').module; const state = module.getUIState(); @@ -320,7 +349,7 @@ async function main() { check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`); check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped'); check( - 'detailed Radio power control reflects the layer manager while the panel is collapsed', + 'Radio power control reflects the layer manager after the panels collapse again', initial.enablePressed === 'true', ); check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`); @@ -1537,7 +1566,7 @@ async function main() { JSON.stringify(directoryRefreshContinuity), ); await page.select('#radio-filter', 'news'); - check('collapsed Radio power enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext); + check('enabled Radio stays nested in Context and leaves both panels collapsed', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext); check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station'); const multiExpandedPanelLayout = await page.evaluate(async () => { @@ -2326,13 +2355,15 @@ async function main() { window.__qaFirstPlayFlyToCalls = []; window.__qaFirstPlayOriginalFlyTo = camera.flyTo; camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options); - document.getElementById('radio-play-btn').focus(); }); - await page.$eval('#radio-play-btn', (button) => button.click()); + const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn'); + await page.focus('#radio-play-btn'); + await page.click('#radio-play-btn'); await page.waitForFunction(() => { const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState(); return state.audioState === 'playing' || state.audioState === 'error'; }); + await restoreRadioPanels(page, firstPlayPanels); const postMicroDragPlayback = await page.evaluate(async () => { const mod = window.__godsEyeView.dataManager.layers.get('radio').module; let state = mod.getUIState(); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 56ff53ef5..3ce11c165 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -439,7 +439,7 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing'); assert.doesNotMatch( cockpitRadio[0], - /context-radio-details-btn|radio-filter|radio-tuner/, + /radio-filter|radio-tuner/, ); assert.match( css, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index bfb1c283f..ca44d4c25 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -146,3 +146,36 @@ test('Context async action buttons remain focused while busy', () => { assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/); assert.doesNotMatch(control, /button\.disabled\s*=/); }); + +test('Context header holds only its title and collapse control', () => { + const panel = html.match(/