From 97b7307b14f3b4b00ebf17714f1c9aef2ae4dc9a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 12:55:47 +0000 Subject: [PATCH] Fix the format gate and the Cyber Context styling, and pin the redesign CI failed at format:check: the format scope still listed the deleted panelChrome.radioOwnership test, and panelRails.test.mjs needed Prettier after its glow list shrank. The new Context title colour outranked Cyber's red panel titles, and the collapsed Context launcher came out brighter than its siblings; the rule now applies only to the expanded panel outside Cyber. Cyber's layout-focus standby card had relied on the old dashed border and centring, so it drew a 3px frame with its text off the radar art; it now names its own 1px frame, padding and centring. Cyber also drops the shared segmented track, which drew a third frame around its already framed mode buttons. Tests now pin the Context header to its title and collapse control, the segmented control, 10px type, the missing halo and the scoped title colour, plus the Cyber standby and track overrides. Each fails on the previous CSS or markup. qa-radio enables Radio and starts the first Play from the visible controls, expanding Context and Radio the way a person must, then puts the panels back. The Escape test and its mutation describe what they still protect. CURRENT-STATE no longer describes the removed Radio icon or compact player. --- docs/CURRENT-STATE.md | 24 +++++------------- scripts/format-scope.json | 1 - scripts/qa-firstrun-mutations.mjs | 2 +- scripts/qa-radio.mjs | 41 +++++++++++++++++++++++++++---- src/cockpitMarkup.test.mjs | 2 +- src/contextTabKeyboard.test.mjs | 33 +++++++++++++++++++++++++ src/firstRunExperience.test.mjs | 14 ++++++----- src/ui/cyberTheme.test.mjs | 10 ++++++++ src/ui/panelRails.test.mjs | 6 +---- src/ui/styles/cyber.css | 16 ++++++++++-- src/ui/styles/layers.css | 7 ++++-- 11 files changed, 115 insertions(+), 41 deletions(-) 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(/
([\s\S]*?)<\/div>\s*
`${tag}.${cls}`); + assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']); +}); + +test('Context reads as one segmented control with legible type and no halo', () => { + const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]; + const clean = (selector) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim(); + const rule = (selector) => rules.find(([, s]) => clean(s) === selector)?.[2] || ''; + const px = (body, prop) => Number(body.match(new RegExp(`${prop}:[^;]*?(\\d+(?:\\.\\d+)?)px`))?.[1] || 0); + + assert.match(rule('#global-context-panel .panel-glow'), /display:\s*none/); + const modes = rule('.global-context-modes'); + assert.match(modes, /border:\s*1px solid/); + assert.match(modes, /padding:\s*2px/); + assert.ok(px(rule('.context-mode-button'), 'font') >= 10, 'mode labels must stay at least 10px'); + assert.doesNotMatch(rule('.context-mode-button.active'), /box-shadow/); + const standby = rule('.context-mode-standby'); + assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px'); + assert.doesNotMatch(standby, /dashed/); + assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10); + // Default theme only, expanded only: Cyber keeps its red titles and the + // collapsed launcher matches its sibling launchers. + assert.match( + rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"), + /color:\s*var\(--text-secondary\)/, + ); +}); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 4ab6b2bf7..5619383fe 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -374,16 +374,18 @@ test('an overlay with NO class to watch still disarms the launcher', () => { assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/); }); -test('one ESC does one thing — a Cockpit utility disclosure stops the launcher outright', () => { +test('one ESC does one thing — a Cockpit utility disclosure stops later document listeners', () => { const ui = fs.readFileSync( new URL('./ui/radioBindings.js', import.meta.url), 'utf8', ); - // stopPropagation() does NOT stop later listeners on the SAME document, so the - // disclosure's earlier capture handler closed the disclosure and the launcher - // dismissed itself off the same key. The earlier listener is the only one that - // can stop the later one — and only the immediate form does it. + // stopPropagation() does NOT stop later listeners on the SAME document. The + // Context Radio disclosure that once raced the launcher this way is gone, and + // Cockpit's utility disclosures never share the screen with the launcher, but + // they own the same contract: their capture handler is the only one that can + // keep a later document listener (Cockpit exit, the launcher) off the key, and + // only the immediate form does it. const utilityEsc = ui.slice(ui.indexOf('if (!displayOpen && !radioOpen) return;')); const claim = utilityEsc.slice(0, utilityEsc.indexOf('setCockpitDisclosure(kind, false')); assert.match(claim, /event\.preventDefault\(\);/); @@ -391,7 +393,7 @@ test('one ESC does one thing — a Cockpit utility disclosure stops the launcher assert.doesNotMatch( claim, /event\.stopPropagation\(\);/, - 'the plain form leaves the launcher listening and is the defect itself', + 'the plain form leaves later document listeners acting on the same key', ); // Belt on the launcher side: a key another surface already marked is not ours, diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 03ee75f08..8fe5bef1b 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -762,6 +762,16 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/); assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/); assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/); + // The base standby is borderless plain text, so the focus card must name its + // own frame width and centring rather than inherit them. + assert.match( + cyberStyles, + /\.context-mode-standby:not\(\[hidden\]\) \{[^}]*place-content: center;[^}]*border: 1px solid[^}]*text-align: center;/, + ); + assert.match( + cyberStyles, + /:root\[data-ui-theme='cyber'\] \.global-context-modes \{[^}]*border: 0;/, + ); assert.match(cyberStyles, /mask-image: linear-gradient\(/); assert.match(cyberStyles, /flex: 1 1 auto;/); assert.match(cyberStyles, /#left-panel-stack:has/); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 08c5a5d67..9b4ae9e06 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -654,11 +654,7 @@ function flatRules(block) { test('narrow-screen rails pin every hosted panel glow inside its panel box', () => { const css = readStylesheet(new URL('../../style.css', import.meta.url)); - for (const panel of [ - 'data-panel', - 'scene-panel', - 'cctv-panel', - ]) { + for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) { assert.match( css, new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`), diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index e25bbae2d..a014b81fa 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1447,6 +1447,15 @@ text-shadow: 0 0 6px var(--cyber-red); } +/* Cyber frames each mode button itself; the shared segmented track would + draw a third, blue frame around them. */ +:root[data-ui-theme='cyber'] .global-context-modes { + gap: 7px; + padding: 0; + border: 0; + background: none; +} + :root[data-ui-theme='cyber'] .dock-tray-toggle, :root[data-ui-theme='cyber'] .context-mode-button { background-color: rgba(38, 52, 58, 0.95); @@ -1798,8 +1807,11 @@ isolation: isolate; min-height: 132px; flex: 1 1 auto; - border-style: solid; - border-color: rgba(206, 222, 226, 0.14); + place-content: center; + padding: 12px; + border: 1px solid rgba(206, 222, 226, 0.14); + border-radius: 7px; + text-align: center; overflow: hidden; background: linear-gradient( diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 2a3297508..aeb58c450 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -989,8 +989,11 @@ #global-context-panel .panel-glow { display: none; } -/* Its section titles (Context, Radio) stay legible over bright imagery. */ -#global-context-panel .panel-title { +/* Expanded, its section titles (Context, Radio) stay legible over bright + imagery. Cyber keeps its own red titles; collapsed launchers match siblings. */ +:root:not([data-ui-theme='cyber']) + #global-context-panel:not(.collapsed) + .panel-title { color: var(--text-secondary); } .global-context-panel-inner {