From e03bf008f32575f779e0ac419769d432052deec7 Mon Sep 17 00:00:00 2001 From: Manjunath <301209272+manjunath22466@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:50:31 +0530 Subject: [PATCH] fix(ui): harden Cyber panel layout and preset restoration --- CHANGELOG.md | 8 ++ docs/CURRENT-STATE.md | 19 ++- docs/panel-surfaces.md | 150 ++++++++++++++++++++ src/ui/cyberTheme.test.mjs | 209 +++++++++++++++++++++++++++- src/ui/styles/cyber.css | 73 ++++++++-- src/ui/styles/panel-surfaces.css | 230 +++++++++++++++++++++++++++++++ src/ui/visualSettings.js | 21 +++ style.css | 1 + 8 files changed, 695 insertions(+), 16 deletions(-) create mode 100644 docs/panel-surfaces.md create mode 100644 src/ui/styles/panel-surfaces.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..c2688159f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,6 +155,14 @@ panel method. - On phones the title bar sits 16 px from the top so both Radio broadcast waves stay on-screen. +- Keep Cyber Voice help/error popups and Location/Visual Presets pins clear of + their decorative frames. Leave space above attribution for its full logo row. + Add shared panel surface hooks for theme styling, rail input and bounded body + scrolling without changing panel disclosure or visibility policies. +- Restore the user's previous visual preset when they explicitly switch from + Cyber to another HUD layout, without overriding scene or shared-link state. +- Move the aligned Cyber side-panel rails upward on desktop so the left stack + clears the lower coordinate card; keep Cockpit's independent visor layout. - Keep Cyber right-rail panels mutually exclusive and Display, CCTV and Context headers and frames fixed during content scrolling. Restore Radio's nested Context placement and compact player. Add Cyber Sonar voice controls with settings and effect-state readback. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..45be529e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,10 +1,11 @@ # God's Eye View Current State -## Cyber HUD — September 23, 2026 +## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice action and shared visual state. An explicit first transition into Cyber selects -FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain +FLIR with Ironbow 0.42. An explicit switch back to another HUD layout restores +the visual preset used before Cyber; restored links and scenes remain authoritative. The skin uses shared red/slate panel treatments in map and cockpit, with compact 200px collapsed controls and wider expanded panels. Other HUD layouts retain their existing presentation. @@ -17,6 +18,20 @@ headers and decorative frames. The narrow-screen rail remains scrollable to reac each panel. Radio retains the shared nested Context player and compact header disclosure, 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 +pseudo-elements. The lower-left telemetry card leaves room for attribution's +full logo row. New panels can use the [shared surface contract](panel-surfaces.md) +for theme tokens, rail input and a fixed header with a bounded scroll body. +Their existing owners still control disclosure, persistence and placement; +small-screen rail popup clipping and other integration limits are documented +in that contract. + +On desktop, Cyber raises the left rail so it clears the lower coordinate card. +`layoutRightPanelRail` reads that rendered top as its measured `baseTop`, which +keeps the right rail aligned. Cockpit retains its separate rail placement for +its visor layout. + Sonar has one contact-highlighting method. Native Cesium points, billboards and labels are treated in GPU draw commands without replacing their positions, identities or pick commands. Model and canvas/DOM overlays retain their own diff --git a/docs/panel-surfaces.md b/docs/panel-surfaces.md new file mode 100644 index 000000000..2d18023e7 --- /dev/null +++ b/docs/panel-surfaces.md @@ -0,0 +1,150 @@ +# Panel surface contract + +`src/ui/styles/panel-surfaces.css` supplies an opt-in visual surface for new +panels. It uses the application's default color, font, border and radius tokens; +Cyber supplies its existing red/slate palette, monospace typography and shaped +frame. This is CSS shared by the existing panel system, not a panel registry or +a controller. A panel ID does not need a new theme selector. + +## Markup + +A rail panel uses an outer owner with its existing stable `id`, matching +`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the +surface. Keep the header and any outward popup outside the scrolling body: + +```html +
+
+
+

Example

+ +
+
+ + + +
+ +
+
+``` + +Use native controls and accessible names. `.panel-surface-control` is an +optional skin for buttons, text fields, selects and textareas; it provides +hover, focus, disabled and invalid-state styling. Native `disabled` owns actual +disablement; `aria-disabled` alone only describes a state and must be enforced +by the controller. `.panel-surface-error` supplies error color, not announcement +or validation behavior. Keep checkbox/radio semantics and labels intact. + +`data-panel-header` stays outside `data-panel-body`, so it remains reachable +when content scrolls. Give the body a bounded height through its containing +surface. The rail's allocation supplies that bound; a dock or dialog owner must +supply its own `max-height` or height. Avoid a flex body whose contents shrink to +fit when those contents should instead scroll. + +## Placement and ownership + +| Mount | Shared contract | Owner responsibilities | +| ------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Direct child of `#left-panel-stack` | An owner with a direct `[data-panel-surface]` child receives relative positioning and allocated height; inner surface/body skin | Add the wrapper shown above, use the existing layout/disclosure owners, and retain left-rail focus/auto-collapse rules. | +| Direct child of `#right-context-rail` | The same hooks with right-rail allocation and intrinsic measurement | Keep `data-rail-scroller` on the body so measurement preserves its scroll position. Respect Cyber's one-expanded-panel policy. | +| Surface inside `#command-dock` | Surface, controls, popup decoration and inherited dock concealment | Supply geometry, stacking, disclosure, pin behavior if wanted, safe viewport bounds and dismissal. This CSS does not extend the existing Location/Visual Presets tray stack manager to arbitrary panels. | +| `dialog[data-panel-surface]` | The same surface/body skin; a closed dialog remains hidden | Set width/max-height, use `showModal()` or `show()` and `close()`, and own labels, initial/return focus and dismissal. Do not put `panel-collapsible` on a dialog. | + +For rails, `--panel-expanded-width` and `--panel-collapsed-width` customize +the desired widths within the rail; otherwise Cyber uses its existing left, +right and collapsed panel widths. `--left-panel-allocated-height` and +`--right-panel-allocated-height` belong +to the existing layout owner, not to individual panels. The surface must be +the wrapper's first non-glow child because left-rail intrinsic measurement +inspects that child. Do not put an absolutely positioned popup before the +surface or inside its body. Position a popup beside or above its surface using +component CSS; the shared rules deliberately do not choose a direction that +could run beyond the viewport. + +Rail measurement still considers visible child extents. A popup extending +below the surface can therefore increase its measured height. Prefer a popup +beside or above the surface, or integrate an external overlay host with the +feature's layout owner; this CSS does not change measurement algorithms. + +At viewport widths of 720px or less, both rails are intentional scroll +containers. Their overflow can clip outward popups even when the panel's own +surface is unclipped. Use a popup contained within the rail's visible bounds, +or an explicitly owned overlay host outside the rail; the surface contract +does not provide automatic popup escape from these small-screen scroll regions. + +For dock disclosure, use a semantic button with `.panel-surface-control` and +the feature's own accessible name, `aria-expanded` state and event binding. +Do not copy the rail example's `.panel-collapse-btn` class into the dock: +existing dock rules intentionally hide that class. The generic surface skin +does not replace the dock's disclosure policy. + +The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]` +at initialization and routes state through `setPanelCollapsed`. For additions +present at startup, keep those hooks and the existing chrome lifecycle. A +dynamically mounted panel must bind and dispose its own disclosure using +`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with +the application owning state changes. 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 +content that changes size may require an owner-scheduled layout pass or an +owned `ResizeObserver`; adding CSS attributes does not register new observers. + +Persisted/share-link state, pinning, provider loading, keyboard shortcuts and +Cockpit-specific mounting still require deliberate integration with their +current owners. This contract does not add entries to named persistence, dock, +Cockpit or share-state policies. Dispose listeners, observers and timers, +dismiss open popups/dialogs and restore focus before removing a dynamic panel. + +## Decoration, popups and visibility + +The shared surface reserves `::before` for its non-interactive background. The +Cyber frame clips this pseudo-element only; the surface stays unclipped with +visible overflow. This follows the existing Display and Global Context pattern. +Putting the shape on an interactive ancestor clips all descendant popups even +if their `z-index` is high. A scrolling body also clips descendants by design, +so outward popups must be siblings of the body, or use an explicitly owned +overlay host outside every clipping ancestor. + +`data-panel-popup` gives a positioned popup the same decorative and control +tokens. It is not a popover API, focus trap, collision solver or top-layer +manager. The owner must keep it inside the viewport, above relevant siblings, +and reachable by pointer and keyboard. A native modal dialog participates in +the browser top layer; a surface in a rail does not. Transform, containment, +mask, clipping or stacking rules on an additional ancestor can still obstruct +either an ordinary panel or its popup. Arbitrary future markup is not covered. + +`hidden`, a closed native dialog and collapsed bodies/popups remain hidden. +Clean UI and recording conceal conforming surfaces, including outward popups; +owners must close modal dialogs when entering these modes so an invisible +modal cannot leave the application inert. Existing Cockpit rail and dock +exclusions remain in force. New left-rail/Cockpit functionality needs an +explicit product integration; the surface attribute grants no mode exception. +Never use theme styling to alter stored preferences or force hidden content +visible. + +## Check an addition + +Use the real application with the default and Cyber themes. Check open, +collapsed, hidden, disabled/error, theme round trips, clean UI/recording and +the intended Cockpit behavior. Exercise keyboard focus, Escape, pointer input, +the last scrolling item, resize without reload and any pin/unpin controls. +For an outward popup, test visible pixels and pointer hit-testing outside the +surface; a positive bounding box alone does not prove it escaped clipping. +Check each mount the addition actually supports. The CSS contract cannot +establish provider correctness, disclosure lifecycle or responsive placement +without those feature-specific checks. diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 033d0053e..7df28c3ed 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -9,6 +9,9 @@ import { normalizeHudLayout, } from '../hudLayouts.js'; import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; +import { StyleManager } from './applicationShell.js'; +import { bindDisplayControls } from './displayControls.js'; import { VisualSettings } from './visualSettings.js'; import { STYLES } from './visualPresets.js'; @@ -137,6 +140,102 @@ test('non-Cyber style presets and explicit HUD choices remain authoritative', (t } }); +test('leaving an explicitly selected Cyber layout restores the prior visual preset', (t) => { + const { owner, hud } = styleOwner(t, 'tactical'); + owner.setStyle('noir'); + + owner._setHudVariant('cyber', { applyVisualDefaults: true }); + assert.equal(owner.activeStyle, 'thermal'); + assert.equal(owner._preCyberStyle, 'noir'); + + // A temporary choice made while Cyber is active does not replace the preset + // that was active before the user entered Cyber. + owner.setStyle('snow'); + owner._setHudVariant('cyber', { applyVisualDefaults: true }); + assert.equal(owner._preCyberStyle, 'noir'); + owner._setHudVariant('operator', { applyVisualDefaults: true }); + assert.equal(hud.getVariant(), 'operator'); + assert.equal(owner.activeStyle, 'noir'); + assert.equal(owner._preCyberStyle, null); +}); + +test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => { + const makeOwner = () => { + const { owner, hud } = styleOwner(t, 'tactical'); + owner.setStyle('noir'); + owner.setHudLayout = (...args) => + StyleManager.prototype.setHudLayout.call(owner, ...args); + owner.getControlState = () => ({ + hud: { layout: hud.getVariant(), visible: true }, + }); + return { owner, hud }; + }; + + const display = makeOwner(); + const hudLayout = new EventTarget(); + hudLayout.value = 'cyber'; + const controls = bindDisplayControls({ + elements: { hudLayout }, + actions: { setHudLayout: (value) => display.owner.setHudLayout(value) }, + }); + hudLayout.dispatchEvent(new Event('change')); + assert.equal(display.hud.getVariant(), 'cyber'); + assert.equal(display.owner.activeStyle, 'thermal'); + hudLayout.value = 'operator'; + hudLayout.dispatchEvent(new Event('change')); + assert.equal(display.hud.getVariant(), 'operator'); + assert.equal(display.owner.activeStyle, 'noir'); + controls.destroy(); + + const voice = makeOwner(); + const eventSource = { addEventListener() {}, removeEventListener() {} }; + const viewer = { + camera: { moveEnd: { addEventListener() {} } }, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: eventSource }, + }; + const runVoiceAction = createGevActionRunner({ + viewer, + styleManager: voice.owner, + dataManager: {}, + }); + const entered = await runVoiceAction('set_hud', { layout: 'cyber' }); + assert.equal(entered.ok, true); + assert.equal(voice.hud.getVariant(), 'cyber'); + assert.equal(voice.owner.activeStyle, 'thermal'); + const exited = await runVoiceAction('set_hud', { layout: 'operator' }); + assert.equal(exited.ok, true); + assert.equal(voice.hud.getVariant(), 'operator'); + assert.equal(voice.owner.activeStyle, 'noir'); +}); + +test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => { + const { owner, hud } = styleOwner(t, 'tactical'); + owner.setStyle('noir'); + owner._setHudVariant('cyber', { applyVisualDefaults: true }); + + await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } }); + assert.equal(hud.getVariant(), 'cyber'); + assert.equal(owner.activeStyle, 'retro'); + assert.equal(owner._preCyberStyle, null); + owner._setHudVariant('operator', { applyVisualDefaults: true }); + assert.equal(owner.activeStyle, 'retro'); + + owner.setStyle('surveillance'); + owner._setHudVariant('cyber', { applyVisualDefaults: true }); + assert.equal(owner._preCyberStyle, 'surveillance'); + await owner.restoreShareState({ + style: 'thermal', + hudVariant: 'cyber', + }); + assert.equal(hud.getVariant(), 'cyber'); + assert.equal(owner.activeStyle, 'thermal'); + assert.equal(owner._preCyberStyle, null); + owner._setHudVariant('tactical', { applyVisualDefaults: true }); + assert.equal(owner.activeStyle, 'thermal'); + assert.equal(owner._preCyberStyle, null); +}); + test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => { const { owner, hud } = styleOwner(t); await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } }); @@ -179,6 +278,104 @@ test('Cyber setup uses a compact toolbar icon and highlights the north-up letter }); const cyberStyles = read('./styles/cyber.css'); +// Source guards supplement rendered popup/hit-target checks. Inspect one rule +// at a time so a safe declaration on a child cannot excuse a clipped ancestor. +function cssRuleBody(styles, selector) { + const match = styles.match(new RegExp(`${selector.source}\\s*\\{([^}]+)\\}`)); + assert.ok(match, `Missing CSS rule: ${selector.source}`); + return match[1]; +} + +test('Cyber Voice clips its decoration without clipping descendant help and errors', () => { + const owner = cssRuleBody( + cyberStyles, + /:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control/, + ); + assert.match(owner, /isolation:\s*isolate;/); + assert.match(owner, /overflow:\s*visible;/); + assert.match(owner, /clip-path:\s*none;/); + + const decoration = cssRuleBody( + cyberStyles, + /:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control::before/, + ); + assert.match(decoration, /clip-path:\s*polygon\(/); + assert.match(decoration, /pointer-events:\s*none;/); + assert.match(decoration, /inset:\s*0;/); + assert.match(decoration, /z-index:\s*-1;/); +}); + +test('Cyber dock pins escape the tray mask and its decoration overrides the legacy arrow geometry', () => { + const owner = cssRuleBody( + cyberStyles, + /:root\[data-ui-theme='cyber'\] #command-dock \.dock-popover-content/, + ); + assert.match(owner, /clip-path:\s*none;/); + assert.match(owner, /isolation:\s*isolate;/); + + // Including the tray owners beats #control-panel's more specific old + // ::after arrow positioning. Otherwise left:auto can collapse the frame. + const decoration = cssRuleBody( + cyberStyles, + /:root\[data-ui-theme='cyber'\]\s+#command-dock\s+:is\(#location-bar, #control-panel\)\s+\.dock-popover-content::after/, + ); + assert.match(decoration, /inset:\s*0;/); + assert.match(decoration, /width:\s*auto;/); + assert.match(decoration, /height:\s*auto;/); + assert.match(decoration, /transform:\s*none;/); + assert.match(decoration, /clip-path:\s*var\(--cyber-panel-frame\);/); + assert.match(decoration, /pointer-events:\s*none;/); +}); + +test('Cyber lower-left telemetry tracks the credit anchor with clearance for its logo row', () => { + const telemetry = cssRuleBody( + cyberStyles, + /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, + ); + const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/); + assert.ok(offset, 'telemetry must retain the attribution viewport anchor'); + const credits = read('./styles/command-dock-trays.css'); + const creditOffsets = [ + ...credits.matchAll( + /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g, + ), + ]; + assert.ok(creditOffsets.length > 0); + for (const credit of creditOffsets) { + assert.ok( + Number(offset[1]) - Number(credit[1]) >= 2.75, + 'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size', + ); + } + assert.doesNotMatch(cyberStyles, /\.hud-bottom-left\s*\{\s*bottom:\s*60px;/); +}); + +test('shared panel hooks affect only opted-in rail surfaces without overriding disclosure and concealment', () => { + const styles = read('./styles/panel-surfaces.css'); + const rail = cssRuleBody( + styles, + /:is\(#left-panel-stack, #right-context-rail\)\s+> \[data-panel-id\]:has\(> \[data-panel-surface\]\)/, + ); + assert.match(rail, /pointer-events:\s*auto;/); + assert.doesNotMatch(rail, /(?:display|visibility):/); + assert.doesNotMatch( + styles, + /:is\(#left-panel-stack, #right-context-rail\)\s*> \[data-panel-id\]\s*\{/, + ); + assert.match( + styles, + /\[data-panel-surface\]:not\(\[hidden\]\):not\(dialog:not\(\[open\]\)\)\s*\{\s*display: flex;/, + ); + assert.match( + styles, + /dialog\[data-panel-surface\]:not\(\[open\]\)\s*\{\s*display: none !important;/, + ); + assert.match( + styles, + /body:is\(\.ui-clean-view, \.recording-mode\)[^{]+\{[^}]*visibility: hidden !important;[^}]*pointer-events: none !important;/, + ); +}); + test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => { assert.match( cyberStyles, @@ -330,6 +527,7 @@ test('Cyber visual defaults update the style, thermal palette and render state', test('the Display selector, voice schema and final stylesheet expose Cyber', () => { const display = read('./templates/display-controls.html'); const stylesheet = read('../../style.css'); + const rightRail = read('./rightPanelRail.js'); const hud = read('../hud.js'); const detection = read('../data/detection.js'); const civilRendering = read('../layers/flights/rendering.js'); @@ -415,21 +613,24 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/); assert.match( cyberStyles, - /--cyber-left-rail-top: max\([\s\S]*?252px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 146px\)/, + /--cyber-left-rail-top: max\([\s\S]*?220px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 120px\)/, ); assert.match( cyberStyles, /\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/, ); - assert.match(cyberStyles, /\.hud-bottom-left \{\s*bottom: 60px;/); assert.match( cyberStyles, - /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;/, + /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/, ); assert.match( cyberStyles, /\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/, ); + assert.match( + rightRail, + /const leftStackTop = leftStack\?\.getBoundingClientRect\(\)\.top;[\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/, + ); assert.match( cyberStyles, /--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/, @@ -453,7 +654,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () assert.match(cyberStyles, /#left-panel-stack:has/); assert.match(cyberStyles, /left: 54px;/); assert.match(cyberStyles, /right: 62px;/); - assert.match(cyberStyles, /bottom: calc\(2vh \+ 11rem\);/); + assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/); assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/); assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /#search-toggle::before/); diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 6bbdca741..a9bdbae20 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -185,6 +185,11 @@ 0 8px 20px rgba(0, 0, 0, 0.28); } +/* Compact Cyber rails must wrap multi-word actions within their own button. */ +:root[data-ui-theme='cyber'] #scene-panel .scene-controls .scene-btn { + white-space: normal; +} + /* Carry the raised voice-module silhouette across the primary panel system. The six-pixel calibration bay is decorative and stays outside panel content. */ :root[data-ui-theme='cyber'] @@ -924,7 +929,24 @@ --cyber-voice-wave: 105, 203, 209; --cyber-voice-peak: 226, 240, 240; --cyber-voice-alert: 196, 84, 84; - border-color: rgba(196, 84, 84, 0.58); + /* Popups extend above this root. Only its decorative layer may be clipped. */ + isolation: isolate; + overflow: visible; + clip-path: none; + border-color: transparent; + background: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +:root[data-ui-theme='cyber'] #command-dock > #gev-voice-control::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + border: 1px solid rgba(196, 84, 84, 0.58); border-radius: 0; background: linear-gradient(180deg, rgba(196, 84, 84, 0.14), transparent 34%), @@ -1192,8 +1214,30 @@ :root[data-ui-theme='cyber'] #command-dock .dock-popover-content { padding-top: 1.65rem; - border: 1px solid rgba(196, 84, 84, 0.62); + /* The pin straddles the tray edge; keep it outside the decorative mask. */ + isolation: isolate; + clip-path: none; + border-color: transparent; border-radius: 0; + background: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +:root[data-ui-theme='cyber'] + #command-dock + :is(#location-bar, #control-panel) + .dock-popover-content::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + width: auto; + height: auto; + transform: none; + pointer-events: none; + border: 1px solid rgba(196, 84, 84, 0.62); background: linear-gradient(135deg, rgba(62, 86, 94, 0.22), transparent 46%), linear-gradient(rgba(188, 211, 218, 0.045) 1px, transparent 1px), @@ -1456,6 +1500,12 @@ bottom: calc(2vh + 6.5rem); } +/* Attribution's logo row extends above its text baseline. Reserve the whole + row plus a gap above the dock-relative 2vh + 5rem credit position. */ +#intel-hud[data-variant='cyber'] .hud-bottom-left { + bottom: calc(2vh + 7.75rem); +} + #intel-hud[data-variant='cyber'] .hud-corner { position: fixed; isolation: isolate; @@ -1493,12 +1543,21 @@ /* Cyber also owns a distinct desktop composition. Existing components keep their controls and semantics, but move onto asymmetric mission-deck rails. */ +@media (min-width: 961px) and (max-height: 649px) { + /* The compact-height layout drops the desktop rail composition one pixel + below its 650px threshold. Keep the left rail inside the same telemetry + and attribution-safe floor while its panel bodies take over scrolling. */ + :root[data-ui-theme='cyber'] #left-panel-stack { + bottom: calc(2vh + 13rem); + } +} + @media (min-width: 961px) and (min-height: 650px) { :root[data-ui-theme='cyber'] { /* Compose the upper mission card and both panel rails as one compact lane. The shared rail anchor keeps expanded left/right panels aligned. */ --cyber-hud-card-top: max(100px, 10.25vh); - --cyber-left-rail-top: max(252px, calc(var(--cyber-hud-card-top) + 146px)); + --cyber-left-rail-top: max(220px, calc(var(--cyber-hud-card-top) + 120px)); } /* Keep the rotated COLL / ONA telemetry in the upper clearance lane. Its @@ -1510,7 +1569,7 @@ :root[data-ui-theme='cyber'] #left-panel-stack { top: var(--cyber-left-rail-top) !important; - bottom: calc(2vh + 11rem); + bottom: calc(2vh + 13rem); left: 54px; width: 300px; padding-left: 10px; @@ -1709,12 +1768,6 @@ right: 28px; bottom: 28px; } - - /* Match the lower telemetry cards by their top edge while leaving the - shorter coordinate card clear of Cesium attribution. */ - #intel-hud[data-variant='cyber'] .hud-bottom-left { - bottom: 60px; - } } @media (max-width: 720px) { diff --git a/src/ui/styles/panel-surfaces.css b/src/ui/styles/panel-surfaces.css new file mode 100644 index 000000000..f4d4939c3 --- /dev/null +++ b/src/ui/styles/panel-surfaces.css @@ -0,0 +1,230 @@ +/* Shared structural hooks for panels admitted by the existing rail owners. + Specific legacy component rules retain priority; no panel IDs are registered + here. The rail itself remains pointer-transparent over the globe. */ +:is(#left-panel-stack, #right-context-rail) + > [data-panel-id]:has(> [data-panel-surface]) { + position: relative; + inset: auto; + flex: 0 0 auto; + min-height: 0; + pointer-events: auto; +} + +#left-panel-stack + > [data-panel-id]:has(> [data-panel-surface]):not(.collapsed) { + flex: 0 1 var(--left-panel-allocated-height, auto); + height: var(--left-panel-allocated-height, auto); + max-height: var(--left-panel-allocated-height, 100%); +} + +#left-panel-stack.layout-tail + > [data-panel-id]:has(> [data-panel-surface]):not(.collapsed) { + flex: 0 0 var(--left-panel-allocated-height); +} + +#right-context-rail.layout-focus + > [data-panel-id]:has(> [data-panel-surface]):not(.collapsed) { + flex: 0 1 var(--right-panel-allocated-height); + height: var(--right-panel-allocated-height); + max-height: var(--right-panel-allocated-height); +} + +/* Opt-in skin: the positioned surface can contain an outward popup. Only the + non-interactive decoration is clipped; only data-panel-body scrolls. */ +:root { + --panel-surface-font: var(--font-sans); + --panel-surface-detail: none; + --panel-surface-frame: none; + --panel-surface-shadow: 0 8px 24px rgba(0, 0, 0, 0.38); + --panel-surface-title: var(--text-secondary); + --panel-surface-error: #ff9b8e; + --panel-surface-width: 300px; + --panel-surface-collapsed-width: var(--left-collapsed-width); +} + +:root[data-ui-theme='cyber'] { + --panel-surface-font: var(--font-mono); + --panel-surface-detail: var(--cyber-panel-surface-detail); + --panel-surface-frame: var(--cyber-panel-frame); + --panel-surface-shadow: var(--cyber-panel-shadow); + --panel-surface-title: var(--cyber-red-bright); + --panel-surface-error: var(--cyber-red-bright); + --panel-surface-width: var(--cyber-panel-width); + --panel-surface-collapsed-width: var(--cyber-panel-collapsed-width); +} + +:root[data-ui-theme='cyber'] + #right-context-rail + > [data-panel-id]:has(> [data-panel-surface]) { + --panel-surface-width: var(--cyber-right-panel-width); +} + +:is([data-panel-surface], [data-panel-popup]) { + isolation: isolate; + min-width: 0; + min-height: 0; + padding: 12px; + border: 1px solid transparent; + border-radius: var(--panel-radius); + background: none; + color: var(--text-primary); + font: 12px/1.5 var(--panel-surface-font); + overflow: visible; +} + +[data-panel-surface]:not(dialog) { + position: relative; +} + +:is([data-panel-surface], [data-panel-popup])::before { + content: ''; + position: absolute; + inset: -1px; + z-index: -1; + border: 1px solid var(--glass-border); + border-radius: inherit; + background-color: var(--glass-bg); + background-image: var(--panel-surface-detail); + box-shadow: var(--panel-surface-shadow); + clip-path: var(--panel-surface-frame); + pointer-events: none; +} + +/* A closed native dialog must retain the browser's display:none. */ +[data-panel-surface]:not([hidden]):not(dialog:not([open])) { + display: flex; + flex-direction: column; + gap: 10px; +} + +:is(#left-panel-stack, #right-context-rail) + > [data-panel-id]:has(> [data-panel-surface]) { + width: min(100%, var(--panel-expanded-width, var(--panel-surface-width))); + background: none; + box-shadow: none; +} + +:is(#left-panel-stack, #right-context-rail) + > [data-panel-id] + > [data-panel-surface] { + height: 100%; + max-height: 100%; +} + +:is(#left-panel-stack, #right-context-rail) + > [data-panel-id].collapsed:has(> [data-panel-surface]) { + width: min( + 100%, + var(--panel-collapsed-width, var(--panel-surface-collapsed-width)) + ); +} + +#right-context-rail[data-rail-measuring] + > [data-panel-id] + > [data-panel-surface] { + height: auto; + max-height: none; +} + +[data-panel-header] { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 8px; + min-height: 30px; + padding-bottom: 8px; + border-bottom: 1px solid var(--glass-border); +} + +.panel-surface-title { + flex: 1; + min-width: 0; + color: var(--panel-surface-title); + font: 600 11px/1.4 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; + overflow-wrap: anywhere; +} + +[data-panel-body] { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--glass-border-hover) transparent; + /* Keep an inset focus ring inside the intentional scrolling boundary. */ + padding: 3px; +} + +.panel-surface-control { + min-width: 0; + min-height: 30px; + max-width: 100%; + padding: 5px 8px; + border: 1px solid var(--glass-border); + border-radius: var(--btn-radius); + background: var(--bg-dark); + color: var(--text-primary); + font: inherit; + accent-color: var(--accent); +} + +button.panel-surface-control { + cursor: pointer; +} + +.panel-surface-control:hover:not(:disabled):not([aria-disabled='true']) { + border-color: var(--glass-border-hover); + background: var(--accent-dim); +} + +.panel-surface-control:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: -3px; +} + +.panel-surface-control:is(:disabled, [aria-disabled='true']) { + opacity: 0.5; + cursor: not-allowed; +} + +.panel-surface-error, +.panel-surface-control[aria-invalid='true'] { + color: var(--panel-surface-error); + border-color: var(--panel-surface-error); +} + +[data-panel-popup] { + position: absolute; + z-index: 5; + /* The owner supplies placement, width, collision handling and disclosure. */ +} + +[data-panel-surface].collapsed > :is([data-panel-body], [data-panel-popup]), +[data-panel-id].collapsed + > [data-panel-surface] + > :is([data-panel-body], [data-panel-popup]), +:is(#left-panel-stack, #right-context-rail) + > [data-panel-id]:has(> [data-panel-surface])[hidden], +:is( + [data-panel-surface], + [data-panel-header], + [data-panel-body], + [data-panel-popup] +)[hidden], +dialog[data-panel-surface]:not([open]) { + display: none !important; +} + +/* Conforming additions follow the same concealment policy as existing chrome. + Cockpit continues to own rail/dock placement and exclusions in cockpit.css. */ +body:is(.ui-clean-view, .recording-mode) + :is([data-panel-surface], [data-panel-popup]), +body:is(.ui-clean-view, .recording-mode) + :is(#left-panel-stack, #right-context-rail) + > [data-panel-id]:has(> [data-panel-surface]) { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index a23b6f301..5bef8decd 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -196,6 +196,7 @@ export class VisualSettings { releaseRender: services.releaseContinuousRender, }); this.activeStyle = 'normal'; + this._preCyberStyle = null; document.documentElement.dataset.gevStyle = this.activeStyle; this._detectionUserOverridden = false; this._cockpitVisionMode = 'optical'; @@ -614,6 +615,16 @@ export class VisualSettings { const previousVariant = this.hud.getVariant(); this.hud.setVariant(variantName); const nextVariant = this.hud.getVariant(); + const enteredCyber = previousVariant !== 'cyber' && nextVariant === 'cyber'; + const leftCyber = previousVariant === 'cyber' && nextVariant !== 'cyber'; + if (!applyVisualDefaults) { + // Scene/share/programmatic state is authoritative even when it reapplies + // the current Cyber variant. Do not let an older explicit entry restore + // stale local preset memory on the next user-selected exit. + this._preCyberStyle = null; + } else if (enteredCyber) { + this._preCyberStyle = this.activeStyle; + } if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) { this._hudLayoutSelect.value = nextVariant; } @@ -623,6 +634,16 @@ export class VisualSettings { { explicit: applyVisualDefaults }, ); if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults); + if (leftCyber) { + const restoreStyle = applyVisualDefaults ? this._preCyberStyle : null; + this._preCyberStyle = null; + if (restoreStyle && restoreStyle !== this.activeStyle) { + this.setStyle(restoreStyle, { + applyPreset: false, + revealParameters: false, + }); + } + } this._syncCyberSonarControl(); this._syncShareState(); this._scheduleAdaptivePanelLayout({ settle: true }); diff --git a/style.css b/style.css index 8c62283a8..82a67025a 100644 --- a/style.css +++ b/style.css @@ -22,4 +22,5 @@ @import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/weather.css'; +@import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/cyber.css';