refactor: compose UI shell with explicit application operations and component owners

This commit is contained in:
Sameh Khamis committed 2026-09-13 04:26:09 -07:00
1 parent 8a226a2fe3
commit f5ef9091f0
67 files changed
+15799 -15059

No files matched your search

+4
View File
@@ -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.
+11
View File
@@ -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.
+14
View File
@@ -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
+3 -1
View File
@@ -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"
}
}
+131
View File
@@ -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": []
}
}
+3 -3
View File
@@ -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,
};
+28 -28
View File
@@ -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);
}
+5
View File
@@ -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)),
+6 -1
View File
@@ -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 &&
+1 -1
View File
@@ -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');
+2 -2
View File
@@ -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'\)/);
});
+1 -1
View File
@@ -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/,
+12 -8
View File
@@ -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',
);
+1 -1
View File
@@ -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');
/**
+1 -1
View File
@@ -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();
+3 -2
View File
@@ -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']) {
+6 -4
View File
@@ -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;/);
+1 -1
View File
@@ -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/);
+1 -1
View File
@@ -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';/,
+1 -1
View File
@@ -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`);
+6 -5
View File
@@ -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.
+2 -1
View File
@@ -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);
+15 -14
View File
@@ -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, /<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>/);
});
@@ -235,7 +236,7 @@ test('reveals sustained loading and then a bounded completion state', () => {
});
test('terminal loading feedback centers its label without an empty detail slot', () => {
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
css,
/#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
@@ -531,8 +532,8 @@ test('the settled traffic chip shows exactly one percentage — the coverage it
});
test('the chip renderer clears the progress slot instead of stranding the last value', () => {
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/shellFeedback.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
const start = ui.indexOf(' _updateTrafficSyncChip(');
assert.ok(start > 0, '_updateTrafficSyncChip is missing');
const body = ui.slice(start, ui.indexOf('\n }', start));
@@ -620,7 +621,7 @@ test('aggregates Mapped Installations refresh beside CCTV without changing eithe
// stack layout contract — the ticker's lifecycle is pinned against ui.js
// source. (perf rebase 2026-08-17)
test('the loading ticker never runs hidden and stops after loading and notices settle', () => {
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
// Scope every assertion to _armLoadingFeedbackTicker's own body. The
// neighbouring _startTrafficChipTicker is a deliberately PERMANENT 500ms
// safety-net poll, so its `if (document.hidden) return;` is correct there
@@ -667,17 +668,17 @@ test('the loading ticker never runs hidden and stops after loading and notices s
// 4. Returning to visible resamples, so a batch that finished while hidden
// is reconciled and a still-running one re-arms its ticker. The handler
// must live on the class that OWNS _updateGlobalLoadingFeedback
// (StyleManager) — wiring it into a neighbouring controller instead
// (ShellFeedback) — wiring it into a neighbouring controller instead
// throws on every visibilitychange — and must be torn down with the rest.
assert.match(
ui,
/this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*\};\s*document\.addEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/,
/this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*else this\._stopLoadingFeedbackTicker\(\);\s*\};\s*document\.addEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/,
'visibilitychange must resample the chip on return',
);
const styleManager = ui.slice(ui.indexOf('export class StyleManager'));
const styleManager = ui.slice(ui.indexOf('export class ShellFeedback'));
assert.ok(
styleManager.includes('this._loadingVisibilityHandler'),
'the resample handler must be owned by StyleManager, which defines _updateGlobalLoadingFeedback',
'the resample handler must be owned by ShellFeedback, which defines _updateGlobalLoadingFeedback',
);
assert.match(
ui,
+1 -1
View File
@@ -5,7 +5,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 director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
/** Source of the free-text LOCATION search handler (Enter on #location-search). */
+3 -1
View File
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
// Exercise the installed event routes and central close method, without WebGL.
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
@@ -123,6 +123,8 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
const claims = [];
let shareSyncs = 0;
const manager = {
_lifetime: { frame() {} },
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
...methods,
_savePanelCollapsedState(...args) { saves.push(args); },
_scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {},
+4 -3
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
// MAP STACK chip row — the dropdown's replacement control surface.
//
// The owner's complaint was two clicks (open panel → open dropdown) to change
@@ -262,7 +263,7 @@ test('a missing row or document is inert rather than throwing during boot', () =
});
test('the active cyan survives hover', () => {
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
const hover = css.indexOf('.map-stack-chip:hover');
const active = css.indexOf('.map-stack-chip.active {');
const unavailable = css.indexOf('.map-stack-chip.unavailable');
@@ -282,7 +283,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// `outline: none` — so a focus state built only from those properties is
// INVISIBLE on the active chip. The ring must live on a property no other
// chip-state rule sets.
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
.map(([, selector, body], order) => ({
@@ -329,7 +330,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
assert.match(
+4 -3
View File
@@ -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';
@@ -467,7 +468,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
'the surface is runtime host-owned, not static markup',
);
assert.match(
readFileSync(new URL('../../style.css', import.meta.url), 'utf8'),
readStylesheet(new URL('../../style.css', import.meta.url)),
/#world-overlay-detection-surface,\n#world-overlay-canvas \{\n position: absolute;\n inset: 0;[\s\S]*?pointer-events: none;/,
'both host surfaces share absolute positioning and pointer passthrough',
);
@@ -484,7 +485,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
});
/** The shipped stylesheet, read once — fixtures must stack as production does. */
const SHIPPED_CSS = readFileSync(new URL('../../style.css', import.meta.url), 'utf8');
const SHIPPED_CSS = readStylesheet(new URL('../../style.css', import.meta.url));
/**
* Position + z-index the SHIPPED stylesheet gives a selector. Fixtures are built
@@ -551,7 +552,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene'
// builds and evaluates each node against the shipped stylesheet.
const env = installMockEnvironment();
initWorldOverlay(env.viewer);
const css = readFileSync(new URL('../../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../../style.css', import.meta.url));
const surface = env.document.getElementById('world-overlay-detection-surface');
assert.ok(surface, 'the host owns a detection surface');
+1 -1
View File
@@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const source = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
function method(name, nextName) {
const start = source.indexOf(` ${name}(`);
+13 -12
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -135,11 +136,11 @@ test('minimum panel corridor expands upward without crossing the lower obstacle
});
test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => {
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));
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(ui, /reconsiderAutoCollapse/);
assert.match(
ui,
@@ -150,13 +151,13 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
/_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/,
);
assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/);
assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
assert.match(ui, /this\._panelLayout\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
assert.match(
leftRail,
/preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
'the latest explicitly opened left panel must receive primary allocation',
);
assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
assert.match(
rightRail,
/panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
@@ -189,7 +190,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
});
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
assert.match(
@@ -212,8 +213,8 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
});
test('parameterized Display presets keep one stable scroll owner', () => {
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));
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
@@ -234,7 +235,7 @@ test('parameterized Display presets keep one stable scroll owner', () => {
});
test('expanded Display uses its container shell instead of a nested header card', () => {
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
css,
@@ -248,7 +249,7 @@ test('expanded Display uses its container shell instead of a nested header card'
});
test('expanded left panels integrate their headers with the container shell', () => {
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
css,
@@ -262,7 +263,7 @@ test('expanded left panels integrate their headers with the container shell', ()
test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(html, /id="stack-panel"/);
assert.match(html, /id="control-panel"[\s\S]*?class="map-source-section"[\s\S]*?id="map-stack-chips"/);
@@ -275,7 +276,7 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', ()
test('expanded right panels highlight the title divider without changing collapsed launchers', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
html,
+4 -3
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
@@ -8,12 +9,12 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8');
const rocketLaunches = readFileSync(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
/** Parse the Realtime tool array out of the Vite config as real data. */
function realtimeTools() {
@@ -302,7 +303,7 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
);
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
assert.match(ui, /\.hidden = !/);
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
+1 -1
View File
@@ -42,7 +42,7 @@ import {
import { ShareLinkManager } from './sharelink.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');
const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
+2 -1
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -33,7 +34,7 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => {
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
assert.match(
ui,
+1 -1
View File
@@ -483,7 +483,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', (
// another setStack() arrives, and a winning state that omits `mapStack`
// never issues one — every normalized scene shot omits it — so a stale
// switch would otherwise stand on the globe.
const source = fs.readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
const source = fs.readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/);
assert.ok(method, 'applyVisualState is missing from ui.js');
assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/);
+1 -1
View File
@@ -6,7 +6,7 @@ import fs from 'node:fs';
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.js';
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');
function sourceBlock(start, end) {
const startIndex = uiSource.indexOf(start);
+10
View File
@@ -519,3 +519,13 @@ export function setSplitFlapText(element, text, options = {}) {
});
return true;
}
/** Stop decoration without replacing the label's permanent accessible text. */
export function disposeSplitFlap(element) {
if (!element) return;
clearFlapTimer(element);
cancelWidthEase(element);
clearSizing(element);
const cells = element.querySelector?.('.gev-flap-cells');
if (cells) rest(element, { cells });
}
+133
View File
@@ -0,0 +1,133 @@
/** Compose UI controls with the application's existing engines and layer instances. */
import { StyleManager as ApplicationShell } from '../ui/applicationShell.js';
import { LocationSearch } from '../ui/location.js';
import { CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from '../locations.js';
import { interruptCameraMotion } from '../cameraVerbs.js';
import { IntelHUD } from '../hud.js';
import { ShareLinkManager } from '../sharelink.js';
import { OrbitController } from '../orbit.js';
import {
CelestialRing,
getKeyholeFadeTuning,
isCelestialRingStyleSupported,
setKeyholeFadeTuning,
} from '../celestialRing.js';
import { destroyTrackedReadout, initTrackedReadout } from '../data/trackedReadout.js';
import { destroyWorldOverlay, initWorldOverlay } from '../overlays/worldOverlay.js';
import {
destroyDetection,
initDetection,
cycleMode as cycleDetectionMode,
getDetectionDiagnostics as readDetectionDiagnostics,
getDetectionTuning,
getMode as getDetectionMode,
setMode as setDetectionModeByLabel,
suspendDetection,
resumeDetection,
setDetectionStyle,
setDetectionTuning,
} from '../data/detection.js';
import trafficLayer from '../data/traffic.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
import satellitesLayer from '../data/satellites.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import {
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
} from '../data/groundFloor.js';
import { sampleMeshFloorCells } from '../data/meshFloorSampler.js';
import { holdContinuousRender, releaseContinuousRender, governorRequestRender } from '../renderGovernor.js';
import {
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
} from '../scopeMask.js';
import {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
} from '../data/regionalBrief.js';
export class StyleManager extends ApplicationShell {
constructor(viewer, options = {}) {
super(viewer, { ...options, services: {
CITY_POIS,
GLOBE_VIEW,
flyToGlobeView,
flyToPresetLocation,
flyToPOI,
searchAndFlyTo,
interruptCameraMotion,
IntelHUD,
ShareLinkManager,
OrbitController,
CelestialRing,
getKeyholeFadeTuning,
isCelestialRingStyleSupported,
setKeyholeFadeTuning,
destroyTrackedReadout,
initTrackedReadout,
destroyWorldOverlay,
initWorldOverlay,
destroyDetection,
initDetection,
cycleDetectionMode,
readDetectionDiagnostics,
getDetectionTuning,
getDetectionMode,
setDetectionModeByLabel,
suspendDetection,
resumeDetection,
setDetectionStyle,
setDetectionTuning,
trafficLayer,
flightsLayer,
militaryFlightsLayer,
isTr3b,
toggleTr3b,
satellitesLayer,
cctvLayer,
radioLayer,
bikeshareLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
militaryInstallationsLayer,
rocketLaunchesLayer,
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
sampleMeshFloorCells,
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
LocationSearch
} });
}
}
+11
View File
@@ -0,0 +1,11 @@
/** Resolve the actual ordered local CSS imports used by the application entry. */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export function readStylesheet(file, ancestors = new Set()) {
const resolved = path.resolve(file instanceof URL ? fileURLToPath(file) : file);
if (ancestors.has(resolved)) throw new Error('Circular stylesheet import');
const next = new Set(ancestors).add(resolved);
return readFileSync(resolved, 'utf8').replace(/@import ['"]([^'"]+)['"];\s*/g,
(_, imported) => readStylesheet(path.resolve(path.dirname(resolved), imported), next));
}
+2 -5270
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+386
View File
@@ -0,0 +1,386 @@
/** Own rail measurement, layout scheduling and dock tray observation. */
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
const COCKPIT_LAYOUT_SETTLE_MS = 240;
const LEFT_STACK_OBSTACLE_SELECTOR = [
'#cockpit-hud .cockpit-topline',
'#cockpit-hud .cockpit-topline > div',
'#title-bar',
'#style-indicator',
'#top-center-actions',
'#traffic-sync-chip',
'#cctv-sync-chip',
'#intel-hud .hud-top-left',
'#intel-hud .hud-top-right',
'#intel-hud .hud-bottom-left',
'#intel-hud .hud-bottom-right',
'#intel-hud .hud-top-bar',
'#intel-hud .hud-bottom-bar',
'#intel-hud .hud-left-edge',
'#intel-hud .hud-right-edge',
'#cockpit-context',
'#cesium-credits .cesium-credit-logoContainer',
'#cesium-credits .cesium-credit-textContainer',
'#location-bar',
'#control-panel',
'#gev-voice-control',
'#pp-toggles',
'#param-slider-panel',
].join(', ');
const RIGHT_STACK_OBSTACLE_SELECTOR = [
'#cockpit-hud .cockpit-topline',
'#cockpit-hud .cockpit-topline > div',
'#title-bar',
'#style-indicator',
'#top-center-actions',
'#traffic-sync-chip',
'#cctv-sync-chip',
'#intel-hud .hud-top-left',
'#intel-hud .hud-top-right',
'#intel-hud .hud-bottom-left',
'#intel-hud .hud-bottom-right',
'#intel-hud .hud-top-bar',
'#intel-hud .hud-bottom-bar',
'#intel-hud .hud-left-edge',
'#intel-hud .hud-right-edge',
'#cockpit-context',
'#cockpit-signal-stream',
'#cesium-credits .cesium-credit-logoContainer',
'#cesium-credits .cesium-credit-textContainer',
'#command-dock',
'#gev-voice-control',
].join(', ');
export class PanelLayoutController {
constructor({ readHud, scheduleCockpitLayout, syncPanelCollapseButton, readDisplayScrollTop }) {
this.readHud = readHud;
this.scheduleCockpitLayout = scheduleCockpitLayout;
this._syncPanelCollapseButton = syncPanelCollapseButton;
this.readDisplayScrollTop = readDisplayScrollTop;
this.destroyed = false;
this._adaptivePanelSettleTimer = null;
this._commandDockTrayObserver = null;
this._leftStackCockpitModeHandler = null;
this._leftStackCollapsedHeights = new Map();
this._leftStackHudTransitionHandler = null;
this._leftStackLayoutFrame = null;
this._leftStackMutationObserver = null;
this._leftStackPreferredPanelId = null;
this._leftStackReconsiderAutoCollapse = false;
this._leftStackResizeObserver = null;
this._rightStackHudTransitionHandler = null;
this._rightStackLayoutFrame = null;
this._rightStackMutationObserver = null;
this._rightStackPreferredPanelId = null;
this._rightStackReconsiderAutoCollapse = false;
this._rightStackResizeObserver = null;
this._cockpitLayoutFrame = null;
this._cockpitLayoutTimer = null;
this._leftPanelStack = document.getElementById('left-panel-stack');
this._rightPanelStack = document.getElementById('right-context-rail');
this._ppToggles = document.getElementById('pp-toggles');
this._cctvPanel = document.getElementById('cctv-panel');
this._sliderPanel = document.getElementById('param-slider-panel');
this._detectionBtn = document.getElementById('detection-toggle');
}
_scheduleAdaptivePanelLayout({ settle = false } = {}) {
if (this.destroyed) return;
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
this.scheduleCockpitLayout();
if (!settle) return;
clearTimeout(this._adaptivePanelSettleTimer);
this._adaptivePanelSettleTimer = setTimeout(() => {
if (this.destroyed) return;
this._adaptivePanelSettleTimer = null;
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
this.scheduleCockpitLayout();
}, COCKPIT_LAYOUT_SETTLE_MS);
}
_initCommandDockTrayMetrics() {
if (this.destroyed) return;
const dock = document.getElementById('command-dock');
if (!dock) return;
this._commandDockTrayObserver?.disconnect?.();
if (typeof ResizeObserver === 'function') {
this._commandDockTrayObserver = new ResizeObserver(() => this._updateCommandDockTrayStack());
dock.querySelectorAll('.dock-popover-content').forEach((tray) => {
this._commandDockTrayObserver.observe(tray);
});
}
this._updateCommandDockTrayStack();
}
_updateCommandDockTrayStack() {
if (this.destroyed) return;
const dock = document.getElementById('command-dock');
if (!dock) return;
const locationPanel = dock.querySelector('#location-bar.dock-pinned:not(.collapsed)');
const presetsPanel = dock.querySelector('#control-panel.dock-pinned:not(.collapsed)');
const locationHeight = locationPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0;
const presetsHeight = presetsPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0;
const pinnedCount = Number(locationHeight > 0) + Number(presetsHeight > 0);
const locationHeightPx = Math.ceil(locationHeight);
const presetsHeightPx = Math.ceil(presetsHeight);
const topPinnedPanel = dock.querySelector('.dock-pinned-top.dock-pinned:not(.collapsed)');
const lowerPinnedPanel = topPinnedPanel?.id === 'location-bar' ? presetsPanel : locationPanel;
const lowerPinnedHeight = lowerPinnedPanel
?.querySelector('.dock-popover-content')
?.getBoundingClientRect().height || 0;
const stackHeight = pinnedCount > 1
? `calc(${locationHeightPx}px + ${presetsHeightPx}px + 1.2rem)`
: `${locationHeightPx + presetsHeightPx}px`;
dock.style.setProperty('--dock-location-pinned-height', `${locationHeightPx}px`);
dock.style.setProperty('--dock-presets-pinned-height', `${presetsHeightPx}px`);
dock.style.setProperty('--dock-lower-pinned-height', `${Math.ceil(lowerPinnedHeight)}px`);
dock.style.setProperty('--dock-pinned-stack-height', stackHeight);
dock.classList.toggle('dock-has-pinned-tray', pinnedCount > 0);
dock.classList.toggle('dock-has-two-pinned-trays', pinnedCount > 1);
}
_initRightPanelAdaptiveLayout() {
if (this.destroyed) return;
const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return;
this._ppToggles.style.removeProperty('top');
this._ppToggles.style.removeProperty('right');
this._ppToggles.style.removeProperty('bottom');
this._ppToggles.style.removeProperty('left');
this._ppToggles.style.removeProperty('z-index');
this._ppToggles.classList.remove('panel-draggable', 'panel-dragging');
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
if (this._cctvPanel) {
this._cctvPanel.style.removeProperty('top');
this._cctvPanel.style.removeProperty('right');
this._cctvPanel.style.removeProperty('bottom');
this._cctvPanel.style.removeProperty('left');
this._cctvPanel.style.removeProperty('z-index');
this._cctvPanel.classList.remove('panel-draggable', 'panel-dragging');
stack.insertBefore(this._cctvPanel, globalContextPanel);
this._syncPanelCollapseButton(this._cctvPanel);
}
if (this._sliderPanel) {
this._sliderPanel.style.removeProperty('top');
this._sliderPanel.style.removeProperty('right');
this._sliderPanel.style.removeProperty('bottom');
this._sliderPanel.style.removeProperty('left');
this._sliderPanel.style.removeProperty('max-height');
const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group');
if (detectionGroup) detectionGroup.after(this._sliderPanel);
else this._ppToggles.append(this._sliderPanel);
}
if (typeof ResizeObserver !== 'undefined') {
this._rightStackResizeObserver = new ResizeObserver(() => {
this._scheduleRightPanelLayout();
});
this._rightStackResizeObserver.observe(stack);
for (const panel of [this._ppToggles, this._cctvPanel, globalContextPanel]) {
if (panel) this._rightStackResizeObserver.observe(panel);
}
document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR).forEach((element) => {
this._rightStackResizeObserver.observe(element);
});
}
if (typeof MutationObserver !== 'undefined') {
this._rightStackMutationObserver = new MutationObserver(() => {
this._scheduleRightPanelLayout();
});
this._rightStackMutationObserver.observe(stack, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['class', 'hidden', 'data-variant'],
});
const hud = document.getElementById('intel-hud');
if (hud) {
this._rightStackMutationObserver.observe(hud, {
subtree: true,
attributes: true,
attributeFilter: ['class', 'hidden', 'data-variant'],
});
}
}
const transitionHud = document.getElementById('intel-hud');
if (transitionHud) {
this._rightStackHudTransitionHandler = (event) => {
if (event.propertyName === 'opacity' || event.propertyName === 'visibility') {
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
}
};
transitionHud.addEventListener('transitionend', this._rightStackHudTransitionHandler);
}
this._scheduleRightPanelLayout();
}
_scheduleRightPanelLayout({ reconsiderAutoCollapse = false } = {}) {
if (this.destroyed) return;
if (reconsiderAutoCollapse) this._rightStackReconsiderAutoCollapse = true;
if (!this._rightPanelStack || this._rightStackLayoutFrame !== null) return;
this._rightStackLayoutFrame = requestAnimationFrame(() => {
if (this.destroyed) return;
this._rightStackLayoutFrame = null;
if (this._rightStackReconsiderAutoCollapse) {
this._rightStackReconsiderAutoCollapse = false;
for (const panel of this._rightPanelStack.querySelectorAll('.layout-auto-collapsed')) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
}
}
this._syncRightPanelAdaptiveLayout();
});
}
_syncRightPanelAdaptiveLayout() {
if (this.destroyed) return;
layoutRightPanelRail({
stack: this._rightPanelStack,
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
windowRef: window,
hud: this.readHud(),
preferredPanelId: this._rightStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
onRetry: () => this._scheduleRightPanelLayout(),
leftStack: this._leftPanelStack,
displayPanel: this._ppToggles,
readDisplayScrollTop: this.readDisplayScrollTop,
});
}
_initLeftPanelAdaptiveLayout() {
if (this.destroyed) return;
const stack = this._leftPanelStack;
if (!stack) return;
if (typeof ResizeObserver !== 'undefined') {
this._leftStackResizeObserver = new ResizeObserver(() => {
this._scheduleLeftPanelLayout();
});
this._leftStackResizeObserver.observe(stack);
stack.querySelectorAll(':scope > [data-panel-id]').forEach((panel) => {
this._leftStackResizeObserver.observe(panel);
const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow'));
if (inner) this._leftStackResizeObserver.observe(inner);
});
document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR).forEach((element) => {
this._leftStackResizeObserver.observe(element);
});
}
if (typeof MutationObserver !== 'undefined') {
this._leftStackMutationObserver = new MutationObserver(() => {
this._scheduleLeftPanelLayout();
});
this._leftStackMutationObserver.observe(stack, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['class'],
});
const hud = document.getElementById('intel-hud');
if (hud) {
this._leftStackMutationObserver.observe(hud, {
attributes: true,
attributeFilter: ['class', 'data-variant'],
});
}
const credits = document.getElementById('cesium-credits');
if (credits) {
this._leftStackMutationObserver.observe(credits, {
subtree: true,
childList: true,
});
}
}
const transitionHud = document.getElementById('intel-hud');
if (transitionHud) {
this._leftStackHudTransitionHandler = (event) => {
if (event.propertyName === 'opacity' || event.propertyName === 'visibility') {
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
// The Cockpit strip hangs off the HUD's REC readout, so it has to
// remeasure on the same event: the readout keeps its rect through
// the whole fade and only stops counting once the HUD has retired.
this.scheduleCockpitLayout();
}
};
transitionHud.addEventListener('transitionend', this._leftStackHudTransitionHandler);
}
this._leftStackCockpitModeHandler = () => {
if (this.destroyed) return;
// Cockpit mode repositions the peripheral HUD and reveals its own
// bottom-left context card. Measure after those styles have committed so
// the accordion remains in the same obstacle-safe lane instead of
// jumping to a cockpit-specific top anchor.
this._scheduleLeftPanelLayout();
if (this._cockpitLayoutFrame !== null) cancelAnimationFrame(this._cockpitLayoutFrame);
this._cockpitLayoutFrame = requestAnimationFrame(() => { this._cockpitLayoutFrame = null; this._scheduleLeftPanelLayout(); });
clearTimeout(this._cockpitLayoutTimer);
this._cockpitLayoutTimer = setTimeout(() => { this._cockpitLayoutTimer = null; this._scheduleLeftPanelLayout(); }, 300);
};
window.addEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler);
this._scheduleLeftPanelLayout();
}
_scheduleLeftPanelLayout({ reconsiderAutoCollapse = false } = {}) {
if (this.destroyed) return;
if (reconsiderAutoCollapse) this._leftStackReconsiderAutoCollapse = true;
if (!this._leftPanelStack || this._leftStackLayoutFrame !== null) return;
this._leftStackLayoutFrame = requestAnimationFrame(() => {
if (this.destroyed) return;
this._leftStackLayoutFrame = null;
if (this._leftStackReconsiderAutoCollapse) {
this._leftStackReconsiderAutoCollapse = false;
for (const panel of this._leftPanelStack.querySelectorAll('.layout-auto-collapsed')) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
this._syncPanelCollapseButton(panel);
}
}
this._syncLeftPanelAdaptiveLayout();
});
}
_syncLeftPanelAdaptiveLayout() {
if (this.destroyed) return;
layoutLeftPanelRail({
stack: this._leftPanelStack,
obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR),
windowRef: window,
hud: this.readHud(),
preferredPanelId: this._leftStackPreferredPanelId,
onCollapse: (panel) => this._syncPanelCollapseButton(panel),
onRetry: () => this._scheduleLeftPanelLayout(),
collapsedHeights: this._leftStackCollapsedHeights,
onAligned: () => this._scheduleRightPanelLayout(),
});
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
for (const field of ['_leftStackLayoutFrame', '_rightStackLayoutFrame', '_cockpitLayoutFrame']) {
if (this[field] !== null) cancelAnimationFrame(this[field]);
this[field] = null;
}
clearTimeout(this._adaptivePanelSettleTimer);
clearTimeout(this._cockpitLayoutTimer);
this._adaptivePanelSettleTimer = this._cockpitLayoutTimer = null;
for (const field of ['_commandDockTrayObserver', '_leftStackResizeObserver', '_leftStackMutationObserver', '_rightStackResizeObserver', '_rightStackMutationObserver']) {
this[field]?.disconnect(); this[field] = null;
}
for (const field of ['_leftStackHudTransitionHandler', '_rightStackHudTransitionHandler']) {
if (this[field]) document.getElementById('intel-hud')?.removeEventListener('transitionend', this[field]);
this[field] = null;
}
if (this._leftStackCockpitModeHandler) window.removeEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler);
this._leftStackCockpitModeHandler = null;
}
}
+260
View File
@@ -0,0 +1,260 @@
/** Own panel position preferences, viewport clamping and drag listeners. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
const PANEL_POSITION_STORAGE_VERSION = 'v8';
const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139;
export class PanelPositionControls {
constructor({ syncPanelCollapseButton, layoutRightPanels, syncCctvPanelViewport, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
this._layoutRightPanels = layoutRightPanels;
this._syncCctvPanelViewport = syncCctvPanelViewport;
this._showToast = showToast;
this._ppToggles = document.getElementById('pp-toggles');
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
this.removers = [];
this.destroyed = false;
}
listen(target, type, callback) {
const listener = (event) => { if (!this.destroyed) callback(event); };
target.addEventListener(type, listener);
this.removers.push(() => target.removeEventListener(type, listener));
}
_reclampDraggablePanels() {
const el = this._ppToggles;
if (!el || !el.style.top || el.style.top === 'auto') return;
const top = parseInt(el.style.top, 10);
if (!Number.isFinite(top)) return;
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
this._pinPanelToRight(el);
}
_maybeNotifyLayoutReset() {
try {
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
if (localStorage.getItem(marker)) return;
localStorage.setItem(marker, '1');
const hadOldPositions = Object.keys(localStorage)
.some((key) => key.startsWith('godsEyeView.v6.panelPos.'));
if (hadOldPositions) {
this._showToast('Panel layout updated — positions reset to new defaults');
}
} catch {
// storage unavailable
}
}
_initPanelDrag() {
const dragSpecs = [
{
id: 'pp-toggles',
panel: this._ppToggles,
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
},
].filter(Boolean);
for (const spec of dragSpecs) {
if (!spec.panel || !spec.handle) continue;
this._restorePanelPosition(spec.id, spec.panel);
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
}
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change.
if (this._ppToggles && typeof ResizeObserver !== 'undefined') {
this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels());
this._draggableResizeObserver.observe(this._ppToggles);
}
}
_panelStorageKey(panelId) {
return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`;
}
_panelCollapseStorageKey(panelId) {
return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`;
}
_restorePanelCollapsedState(panelId, { allowStored = true } = {}) {
const panelEl = document.getElementById(panelId);
if (!panelEl) return;
let collapsed = panelEl.classList.contains('collapsed');
let stored = null;
if (allowStored) {
try {
stored = localStorage.getItem(this._panelCollapseStorageKey(panelId));
if (stored === '1') collapsed = true;
if (stored === '0') collapsed = false;
} catch {
// storage unavailable
}
}
// DISPLAY starts COLLAPSED for a first-time visitor, then respects the
// user's persisted choice like every other panel.
//
// It used to start expanded, to advertise the HUD / DETECT / 3D toggles.
// That reason expired when those became ON by default: the rail now opens
// to offer controls for things already happening, while competing with the
// first-run mission card for the one first impression there is. A stored
// choice still wins in both directions, so anyone who opens it keeps it.
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
panelEl.classList.toggle('collapsed', collapsed);
this._syncPanelCollapseButton(panelEl);
}
_savePanelCollapsedState(panelId, collapsed) {
try {
localStorage.setItem(this._panelCollapseStorageKey(panelId), collapsed ? '1' : '0');
} catch {
// storage unavailable
}
}
_pinPanelToRight(panelEl) {
if (!panelEl) return;
const rect = panelEl.getBoundingClientRect();
const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right));
panelEl.style.right = `${rightOffset}px`;
panelEl.style.left = 'auto';
}
_restorePanelPosition(panelId, panelEl) {
try {
const raw = localStorage.getItem(this._panelStorageKey(panelId));
if (!raw) return;
const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return;
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too.
const { left, top } = this._clampToViewport(Math.round(pos.left), Math.round(pos.top), panelEl);
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
} catch {
// ignore malformed saved panel position
}
}
_clampToViewport(left, top, panelEl) {
const rect = panelEl.getBoundingClientRect();
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
return {
left: Math.max(6, Math.min(maxLeft, left)),
top: Math.max(6, Math.min(maxTop, top)),
};
}
_savePanelPosition(panelId, panelEl) {
const rect = panelEl.getBoundingClientRect();
try {
localStorage.setItem(this._panelStorageKey(panelId), JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top),
}));
} catch {
// storage unavailable
}
}
_promotePanelZ(panelEl) {
this._panelZCounter += 1;
if (this._panelZCounter > PANEL_Z_MAX) {
const promoted = [...document.querySelectorAll('.panel-draggable')]
.filter((el) => el.style.zIndex)
.sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex));
let z = PANEL_Z_BASE + 1;
for (const el of promoted) {
el.style.zIndex = String(z);
z += 1;
}
this._panelZCounter = z;
}
panelEl.style.zIndex = String(this._panelZCounter);
}
_makePanelDraggable(panelId, panelEl, handleEl) {
// Z-order promotion: bring clicked panel to front of the stacking context
this.listen(panelEl, 'pointerdown', () => {
this._promotePanelZ(panelEl);
});
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return;
if (event.target.closest('.panel-collapse-btn')) return;
if (event.target.closest('input, select, option, button:not(.panel-collapse-btn)')) return;
this._cancelDrag?.();
event.preventDefault();
const rect = panelEl.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const offsetX = startX - rect.left;
const offsetY = startY - rect.top;
panelEl.style.left = `${rect.left}px`;
panelEl.style.top = `${rect.top}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
const onMove = (moveEvent) => {
const nextLeftRaw = moveEvent.clientX - offsetX;
const nextTopRaw = moveEvent.clientY - offsetY;
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
panelEl.style.left = `${nextLeft}px`;
panelEl.style.top = `${nextTop}px`;
if (panelId === 'pp-toggles') {
this._layoutRightPanels();
}
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
};
const cancel = () => {
panelEl.classList.remove('panel-dragging');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
panelEl.classList.remove('panel-dragging');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
});
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._cancelDrag?.();
for (const remove of this.removers.splice(0)) remove();
this._draggableResizeObserver?.disconnect();
this._draggableResizeObserver = null;
}
}
+74
View File
@@ -0,0 +1,74 @@
/** Own recording chrome, safe frames and the pre-recording HUD snapshot. */
export class RecordingControls {
constructor({ syncShareState }) {
this._syncShareState = syncShareState;
this.destroyed = false;
this._recordingMode = false;
this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' };
this._preRecordingHudState = null;
this._safeFrameOverlay = document.getElementById('safe-frame-overlay');
this._safeFrameBox = document.getElementById('safe-frame-box');
this._hudLayoutSelect = document.getElementById('hud-layout-select');
this._hudBtn = document.getElementById('hud-toggle');
this.hud = null;
}
_initRecordingOverlay() {
if (this.destroyed) return;
if (!this._safeFrameOverlay || !this._safeFrameBox) return;
this._safeFrameOverlay.classList.remove('active', 'ratio-9-16', 'ratio-16-9');
}
setRecordingMode(enabled, options = {}) {
if (this.destroyed) return;
const { hidePanels = true, hudMode = 'minimal', safeFrame = '16:9' } = options;
this._recordingMode = !!enabled;
this._recordingConfig = { hidePanels, hudMode, safeFrame };
document.body.classList.toggle('recording-mode', this._recordingMode && hidePanels);
if (this._safeFrameOverlay) {
this._safeFrameOverlay.classList.remove('ratio-9-16', 'ratio-16-9');
this._safeFrameOverlay.classList.toggle('active', this._recordingMode);
this._safeFrameOverlay.classList.add(safeFrame === '9:16' ? 'ratio-9-16' : 'ratio-16-9');
}
if (this._recordingMode) {
// Snapshot the user's HUD state once per recording session so exit can
// restore it (re-entrant calls must not capture mid-recording state).
if (!this._preRecordingHudState) {
this._preRecordingHudState = {
mode: this.hud.getMode(),
variant: this.hud.getVariant(),
};
}
if (hudMode === 'off') {
this.hud.setMode('off');
} else if (hudMode === 'full' || hudMode === 'minimal') {
this.hud.setMode('on');
this.hud.setVariant(hudMode === 'minimal' ? 'minimal' : 'tactical');
if (this._hudLayoutSelect) this._hudLayoutSelect.value = this.hud.getVariant();
} else {
this.hud.setMode('auto');
}
} else {
const saved = this._preRecordingHudState;
this._preRecordingHudState = null;
if (saved) {
this.hud.setVariant(saved.variant);
if (this._hudLayoutSelect) this._hudLayoutSelect.value = this.hud.getVariant();
}
this.hud.setMode(saved ? saved.mode : 'auto');
if (this._safeFrameOverlay) {
this._safeFrameOverlay.classList.remove('active', 'ratio-9-16', 'ratio-16-9');
}
}
this._hudBtn.classList.toggle('active', this.hud.visible);
this._syncShareState();
}
destroy() {
if (this.destroyed) return;
// Restore an active session while its supplied HUD is still alive.
if (this._recordingMode) this.setRecordingMode(false);
this.destroyed = true;
}
}
+141
View File
@@ -0,0 +1,141 @@
/** Resolve the fixed DOM nodes used to assemble the application controls. */
export function readShellElements(document = globalThis.document) {
return {
_rightPanelStack: document.getElementById('right-context-rail'),
_styleIndicator: document.getElementById('active-style-name'),
_sliderPanel: document.getElementById('param-slider-panel'),
_sliderContainer: document.getElementById('param-sliders'),
_ppToggles: document.getElementById('pp-toggles'),
_bloomBtn: document.getElementById('bloom-toggle'),
_bloomSliderRow: document.getElementById('bloom-slider-row'),
_bloomSlider: document.getElementById('bloom-intensity-slider'),
_bloomSliderValue: document.getElementById('bloom-intensity-value'),
_sharpenBtn: document.getElementById('sharpen-toggle'),
_sharpenSliderRow: document.getElementById('sharpen-slider-row'),
_sharpenSlider: document.getElementById('sharpen-intensity-slider'),
_sharpenSliderValue: document.getElementById('sharpen-intensity-value'),
_hudBtn: document.getElementById('hud-toggle'),
_hudLayoutRow: document.getElementById('hud-layout-row'),
_hudLayoutSelect: document.getElementById('hud-layout-select'),
_detectionSliderRow: document.getElementById('detection-slider-row'),
_detectionDensitySlider: document.getElementById('detection-density-slider'),
_detectionDensityValue: document.getElementById('detection-density-value'),
_detectionAllocationRow: document.getElementById('detection-allocation-row'),
_detectionFadeRow: document.getElementById('detection-fade-row'),
_detectionFadeSlider: document.getElementById('detection-fade-slider'),
_detectionFadeValue: document.getElementById('detection-fade-value'),
_detectionOpacityRow: document.getElementById('detection-opacity-row'),
_detectionOpacitySlider: document.getElementById('detection-opacity-slider'),
_detectionOpacityValue: document.getElementById('detection-opacity-value'),
_celestialBtn: document.getElementById('celestial-toggle'),
_scopeBtn: document.getElementById('scope-toggle'),
_scopeFeatherSlider: document.getElementById('scope-feather-slider'),
_scopeFeatherValue: document.getElementById('scope-feather-value'),
_mapStackChips: document.getElementById('map-stack-chips'),
_mapStackStatus: document.getElementById('map-stack-status'),
_cleanViewBtn: document.getElementById('clean-view-toggle'),
_cleanViewExitBtn: document.getElementById('clean-view-exit'),
_dataPanel: document.getElementById('data-panel'),
_scenePanel: document.getElementById('scene-panel'),
_cctvPanel: document.getElementById('cctv-panel'),
_radioPanel: document.getElementById('radio-panel'),
_contextRadioDock: document.getElementById('context-radio-dock'),
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
_contextRadioMini: document.getElementById('context-radio-mini'),
_contextRadioMiniEnableBtn: document.getElementById('context-radio-mini-enable-btn'),
_contextRadioDetailsBtn: document.getElementById('context-radio-details-btn'),
_contextRadioMiniCloseBtn: document.getElementById('context-radio-mini-close-btn'),
_contextRadioMiniStation: document.getElementById('context-radio-mini-station'),
_contextRadioMiniPrevBtn: document.getElementById('context-radio-mini-prev-btn'),
_contextRadioMiniPlayBtn: document.getElementById('context-radio-mini-play-btn'),
_contextRadioMiniNextBtn: document.getElementById('context-radio-mini-next-btn'),
_contextRadioMiniVolume: document.getElementById('context-radio-mini-volume'),
_contextRadioMiniVolumeValue: document.getElementById('context-radio-mini-volume-value'),
_cockpitUtilityControls: document.getElementById('cockpit-utility-controls'),
_cockpitDisplayToggleBtn: document.getElementById('cockpit-display-toggle-btn'),
_cockpitDisplayPanel: document.getElementById('cockpit-display-panel'),
_cockpitRadioToggleBtn: document.getElementById('cockpit-radio-toggle-btn'),
_cockpitRadioPanel: document.getElementById('cockpit-radio-panel'),
_cockpitRadioEnableBtn: document.getElementById('cockpit-radio-enable-btn'),
_cockpitRadioStation: document.getElementById('cockpit-radio-station'),
_cockpitRadioPrevBtn: document.getElementById('cockpit-radio-prev-btn'),
_cockpitRadioPlayBtn: document.getElementById('cockpit-radio-play-btn'),
_cockpitRadioNextBtn: document.getElementById('cockpit-radio-next-btn'),
_cockpitRadioVolume: document.getElementById('cockpit-radio-volume'),
_cockpitRadioVolumeValue: document.getElementById('cockpit-radio-volume-value'),
_radioLayerState: document.getElementById('radio-layer-state'),
_radioEnableBtn: document.getElementById('radio-enable-btn'),
_radioFilter: document.getElementById('radio-filter'),
_radioStationName: document.getElementById('radio-station-name'),
_radioStationMeta: document.getElementById('radio-station-meta'),
_radioStationTags: document.getElementById('radio-station-tags'),
_radioTuner: document.getElementById('radio-tuner'),
_radioTunerSlider: document.getElementById('radio-tuner-slider'),
_radioTunerNeedle: document.getElementById('radio-tuner-needle'),
_radioTunerBandLabel: document.getElementById('radio-tuner-band-label'),
_radioTunerValue: document.getElementById('radio-tuner-value'),
_radioTunerStation: document.getElementById('radio-tuner-station'),
_radioPrevBtn: document.getElementById('radio-prev-btn'),
_radioPlayBtn: document.getElementById('radio-play-btn'),
_radioNextBtn: document.getElementById('radio-next-btn'),
_radioStopBtn: document.getElementById('radio-stop-btn'),
_radioVolume: document.getElementById('radio-volume'),
_radioVolumeValue: document.getElementById('radio-volume-value'),
_radioPlaybackState: document.getElementById('radio-playback-state'),
_radioStationHomepage: document.getElementById('radio-station-homepage'),
_globalContextFlightsBtn: document.getElementById('global-context-flights-btn'),
_globalContextMissionsBtn: document.getElementById('global-context-missions-btn'),
_contextModeStandby: document.getElementById('context-mode-standby'),
_contextFlightsView: document.getElementById('context-flights-view'),
_contextMissionsView: document.getElementById('context-missions-view'),
_installationsSearchBtn: document.getElementById('installations-search-btn'),
_leftPanelStack: document.getElementById('left-panel-stack'),
_cctvEnableBtn: document.getElementById('cctv-enable-btn'),
_cctvNearestBtn: document.getElementById('cctv-nearest-btn'),
_cctvPrevBtn: document.getElementById('cctv-prev-btn'),
_cctvNextBtn: document.getElementById('cctv-next-btn'),
_cctvSelect: document.getElementById('cctv-camera-select'),
_cctvFocusBtn: document.getElementById('cctv-focus-btn'),
_cctvCoverageBtn: document.getElementById('cctv-coverage-btn'),
_cctvAutoHopBtn: document.getElementById('cctv-auto-hop-btn'),
_cctvProjectionBtn: document.getElementById('cctv-projection-btn'),
_cctvQualityChip: document.getElementById('cctv-quality-chip'),
_cctvAdjustBtn: document.getElementById('cctv-adjust-btn'),
_cctvCalReadout: document.getElementById('cctv-cal-readout'),
_cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'),
_cctvCalibResetBtn: document.getElementById('cctv-calib-reset-btn'),
_cctvFrame: document.getElementById('cctv-frame'),
_cctvFrameWrap: document.getElementById('cctv-frame-wrap'),
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
_shareBtn: document.getElementById('share-btn'),
_clearSelectedLayersBtn: document.getElementById('clear-selected-layers'),
_globalLoadingStatus: document.getElementById('global-loading-status'),
_globalLoadingLabel: document.getElementById('global-loading-label'),
_globalLoadingDetail: document.getElementById('global-loading-detail'),
_resetGlobeBtn: document.getElementById('reset-globe-view'),
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
_styleButtons: document.getElementById('style-buttons'),
_trafficSyncChip: document.getElementById('traffic-sync-chip'),
_trafficSyncLabel: document.getElementById('traffic-sync-label'),
_trafficSyncProgress: document.getElementById('traffic-sync-progress'),
_cctvSyncChip: document.getElementById('cctv-sync-chip'),
_cctvSyncLabel: document.getElementById('cctv-sync-label'),
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'),
_searchToggle: document.getElementById('search-toggle'),
_locationPills: document.getElementById('location-pills'),
_poiRow: document.getElementById('poi-row'),
_locationBarDivider: document.getElementById('location-bar-divider'),
_styleMiniValue: document.getElementById('style-mini-value'),
_locationMiniCity: document.getElementById('location-mini-city'),
_locationMiniPoi: document.getElementById('location-mini-poi'),
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
_safeFrameBox: document.getElementById('safe-frame-box'),
_detectionBtn: document.getElementById('detection-toggle'),
_models3dBtn: document.getElementById('models3d-toggle'),
_models3dModeRow: document.getElementById('models3d-mode-row'),
};
}
+168
View File
@@ -0,0 +1,168 @@
/** Own loading notices, traffic status, toast timing and visibility cleanup. */
import { aggregateLayerLoading, createGlobalStatusNotice, createLoadingFeedbackState, createTrafficSyncFeedbackState, presentGlobalLoadingStatus, reduceLoadingFeedback, reduceTrafficSyncFeedback } from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
export class ShellFeedback {
constructor({ readLayers }) {
this.readLayers = readLayers;
this.destroyed = false;
this._loadingFeedbackState = createLoadingFeedbackState();
this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
this._loadingFeedbackEvent = null;
this._globalStatusNotice = null;
this._loadingFeedbackTicker = this._trafficChipTicker = this._toastTimer = null;
this._loadingVisibilityHandler = null;
this._lastLoadingFeedbackUpdateAt = 0;
this._globalLoadingStatus = document.getElementById('global-loading-status');
this._globalLoadingLabel = document.getElementById('global-loading-label');
this._globalLoadingDetail = document.getElementById('global-loading-detail');
this._trafficSyncChip = document.getElementById('traffic-sync-chip');
this._trafficSyncLabel = document.getElementById('traffic-sync-label');
this._trafficSyncProgress = document.getElementById('traffic-sync-progress');
this._toast = document.getElementById('toast');
}
observeVisibility() {
if (this.destroyed || this._loadingVisibilityHandler) return;
this._loadingVisibilityHandler = () => {
if (!document.hidden) this._updateGlobalLoadingFeedback();
else this._stopLoadingFeedbackTicker();
};
document.addEventListener('visibilitychange', this._loadingVisibilityHandler);
}
_updateTrafficSyncChip(forceShow = false, now = performance.now()) {
if (this.destroyed) return;
if (!this._trafficSyncChip || !this._trafficSyncLabel || !this._trafficSyncProgress) return;
const layers = this.readLayers();
const traffic = Array.isArray(layers) ? layers.find((layer) => layer.id === 'traffic') : null;
this._trafficSyncFeedbackState = reduceTrafficSyncFeedback(
this._trafficSyncFeedbackState,
{ enabled: traffic?.enabled === true, stats: traffic?.stats || {}, forceShow },
now,
);
const presentation = this._trafficSyncFeedbackState;
// setSplitFlapText carries the same unchanged-text guard internally, and
// the flap keeps textContent equal to the settled label throughout, so
// this stays a no-op on the repeat ticks exactly as it did before.
if (presentation.label) setSplitFlapText(this._trafficSyncLabel, presentation.label);
// Written on every change INCLUDING the empty settled value — the reducer
// clears the progress number once the sync lands, and a truthiness guard
// here would strand the last "..." beside the settled label.
if (this._trafficSyncProgress.textContent !== presentation.progressText) {
this._trafficSyncProgress.textContent = presentation.progressText;
}
this._trafficSyncChip.classList.toggle('visible', presentation.visible);
}
_updateGlobalLoadingFeedback(now = performance.now()) {
if (this.destroyed) return;
if (!this._globalLoadingStatus) return;
const summary = aggregateLayerLoading(this.readLayers() || []);
this._loadingFeedbackState = reduceLoadingFeedback(
this._loadingFeedbackState,
summary,
now,
this._loadingFeedbackEvent,
);
this._loadingFeedbackEvent = null;
const presentation = presentGlobalLoadingStatus(
this._globalStatusNotice,
this._loadingFeedbackState,
summary,
now,
);
if (this._globalStatusNotice?.persistent !== true
&& Number.isFinite(this._globalStatusNotice?.hideAt)
&& now >= this._globalStatusNotice.hideAt) {
this._globalStatusNotice = null;
}
// Loading phases and universal notices both have time-driven transitions.
// Compute this after arbitration: a queued finite notice starts its dwell
// only on its first visible frame, then keeps the ticker alive to expiry.
const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt);
if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) {
this._armLoadingFeedbackTicker();
}
this._globalLoadingStatus.hidden = !presentation;
if (!presentation) {
delete this._globalLoadingStatus.dataset.state;
return;
}
this._globalLoadingStatus.dataset.state = presentation.state;
// Split-flap the LABEL only ("LOADING LIVE DATA" -> "LOAD COMPLETE").
// setSplitFlapText is a no-op when the text is unchanged, which matters
// here: this runs on every 60 ms and 500 ms tick. The detail line is the
// live layer roster inside an ellipsised, width-capped span — flapping a
// list that churns as layers join would be noise, not delight.
setSplitFlapText(this._globalLoadingLabel, presentation.label);
this._globalLoadingDetail.textContent = presentation.detail;
}
_showGlobalStatusNotice(message, options = {}) {
if (this.destroyed) return;
const now = performance.now();
this._globalStatusNotice = createGlobalStatusNotice(message, now, options);
this._updateGlobalLoadingFeedback(now);
}
_startTrafficChipTicker() {
if (this.destroyed) return;
if (this._trafficChipTicker) return;
this._trafficChipTicker = setInterval(() => {
if (document.hidden) return;
this._updateTrafficSyncChip();
this._updateGlobalLoadingFeedback();
}, 500);
}
_armLoadingFeedbackTicker() {
if (this.destroyed) return;
// Never arm behind a hidden tab: the reducer cannot usefully advance a
// chip nobody can see, and the old `return` INSIDE the interval left the
// 60ms timer scheduled for the entire hidden period (a batch completing
// while hidden could never clear it — the idle check sat behind the
// hidden guard). visibilitychange resamples and re-arms on return.
if (this._loadingFeedbackTicker || document.hidden) return;
this._loadingFeedbackTicker = setInterval(() => {
if (document.hidden) {
this._stopLoadingFeedbackTicker();
return;
}
const now = performance.now();
this._lastLoadingFeedbackUpdateAt = now;
this._updateGlobalLoadingFeedback(now);
const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt);
if (this._loadingFeedbackState?.phase === 'idle' && !noticeNeedsTicker) {
this._stopLoadingFeedbackTicker();
}
}, 60);
}
_stopLoadingFeedbackTicker() {
if (!this._loadingFeedbackTicker) return;
clearInterval(this._loadingFeedbackTicker);
this._loadingFeedbackTicker = null;
}
_showToast(message) {
if (this.destroyed) return;
this._toast.textContent = message;
this._toast.classList.add('visible');
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => {
this._toast.classList.remove('visible');
}, 2000);
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._stopLoadingFeedbackTicker();
clearInterval(this._trafficChipTicker);
clearTimeout(this._toastTimer);
this._trafficChipTicker = this._toastTimer = null;
if (this._loadingVisibilityHandler) document.removeEventListener('visibilitychange', this._loadingVisibilityHandler);
this._loadingVisibilityHandler = null;
disposeSplitFlap(this._globalLoadingLabel);
disposeSplitFlap(this._trafficSyncLabel);
this._toast?.classList.remove('visible');
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
}
}
+139
View File
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { ShellFeedback } from './shellFeedback.js';
import { RecordingControls } from './recordingControls.js';
function fixture() {
const saved = Object.fromEntries(['document', 'window', 'localStorage', 'requestAnimationFrame', 'cancelAnimationFrame', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'].map((key) => [key, globalThis[key]]));
const frames = new Map(), timers = new Map();
let next = 0;
const element = () => {
const classes = new Set();
return Object.assign(new EventTarget(), {
style: {}, dataset: {}, hidden: false, textContent: '',
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, active) => active ? classes.add(name) : classes.delete(name),
},
querySelector: () => null, querySelectorAll: () => [], closest: () => null,
getBoundingClientRect: () => ({ left: 20, top: 30, width: 100, height: 80 }),
});
};
const nodes = new Map(['left-panel-stack', 'right-context-rail', 'pp-toggles', 'toast', 'global-loading-status', 'global-loading-detail', 'safe-frame-overlay', 'safe-frame-box', 'hud-toggle', 'hud-layout-select'].map((id) => [id, element()]));
globalThis.document = Object.assign(new EventTarget(), { hidden: false, getElementById: (id) => nodes.get(id) || null, querySelectorAll: () => [], body: element() });
globalThis.window = Object.assign(new EventTarget(), { innerWidth: 600, innerHeight: 400 });
const writes = [];
globalThis.localStorage = { setItem: (...args) => writes.push(args), getItem: () => null };
globalThis.requestAnimationFrame = (callback) => { frames.set(++next, callback); return next; };
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
globalThis.setTimeout = globalThis.setInterval = (callback) => { timers.set(++next, callback); return next; };
globalThis.clearTimeout = globalThis.clearInterval = (id) => timers.delete(id);
return { frames, timers, nodes, element, writes, restore() { Object.assign(globalThis, saved); } };
}
test('UI lifetime revokes listeners and nested presentation work synchronously', () => {
const f = fixture();
try {
const owner = new UiLifetime(), target = new EventTarget();
let calls = 0;
owner.listen(target, 'change', () => calls++);
owner.frame(() => { calls++; owner.frame(() => calls++); });
owner.timeout(() => calls++, 100);
const retainedFrame = [...f.frames.values()][0];
owner.destroy(); owner.destroy();
target.dispatchEvent(new Event('change'));
retainedFrame();
owner.frame(() => calls++); owner.timeout(() => calls++, 1);
assert.equal(calls, 0);
assert.equal(f.frames.size + f.timers.size + owner.removers.size, 0);
} finally { f.restore(); }
});
test('one-time UI listeners and cancelled notice timers release their registrations', () => {
const f = fixture();
try {
const owner = new UiLifetime(), target = new EventTarget();
let calls = 0;
owner.listen(target, 'change', () => calls++, { once: true });
target.dispatchEvent(new Event('change')); target.dispatchEvent(new Event('change'));
owner.cancelTimeout(owner.timeout(() => calls++, 100));
assert.equal(calls, 1);
assert.equal(owner.removers.size + owner.timers.size + f.timers.size, 0);
owner.destroy();
} finally { f.restore(); }
});
test('panel layout coalesces each rail and cannot rearm after destruction', () => {
const f = fixture();
try {
let layouts = 0;
const owner = new PanelLayoutController({ readHud: () => ({}), scheduleCockpitLayout() {}, syncPanelCollapseButton() {}, readDisplayScrollTop: () => 0 });
owner._syncLeftPanelAdaptiveLayout = () => layouts++;
owner._scheduleLeftPanelLayout(); owner._scheduleLeftPanelLayout();
assert.equal(f.frames.size, 1);
owner._scheduleAdaptivePanelLayout({ settle: true });
assert.equal(f.frames.size, 2);
assert.equal(f.timers.size, 1);
const late = [...f.frames.values(), ...f.timers.values()];
owner.destroy(); owner.destroy();
late.forEach((callback) => callback());
owner._scheduleAdaptivePanelLayout({ settle: true });
assert.equal(layouts, 0);
assert.equal(f.frames.size + f.timers.size, 0);
} finally { f.restore(); }
});
test('panel disposal cancels a live drag without saving or accepting later pointer movement', () => {
const f = fixture();
try {
const owner = new PanelPositionControls({ syncPanelCollapseButton() {}, layoutRightPanels() {}, syncCctvPanelViewport() {}, showToast() {} });
const panel = f.element(), handle = f.element();
owner._makePanelDraggable('sample', panel, handle);
handle.dispatchEvent(Object.assign(new Event('pointerdown', { cancelable: true }), { button: 0, clientX: 30, clientY: 40 }));
assert.equal(panel.classList.contains('panel-dragging'), true);
owner.destroy();
const before = { ...panel.style };
window.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 500, clientY: 300 }));
window.dispatchEvent(new Event('pointerup'));
assert.deepEqual(panel.style, before);
assert.equal(panel.classList.contains('panel-dragging'), false);
assert.equal(owner.removers.length, 0);
assert.deepEqual(f.writes, []);
} finally { f.restore(); }
});
test('feedback disposal clears toast and polling work and rejects retained notices', () => {
const f = fixture();
try {
const owner = new ShellFeedback({ readLayers: () => [] });
owner.observeVisibility(); owner._startTrafficChipTicker(); owner._showToast('Saved');
assert.equal(f.timers.size, 2);
owner.destroy();
owner._showToast('Late'); owner._showGlobalStatusNotice('Late'); owner._startTrafficChipTicker();
document.dispatchEvent(new Event('visibilitychange'));
assert.equal(f.timers.size, 0);
assert.equal(f.nodes.get('toast').textContent, 'Saved');
assert.equal(f.nodes.get('toast').classList.contains('visible'), false);
assert.equal(f.nodes.get('global-loading-status').hidden, true);
} finally { f.restore(); }
});
test('recording restores the original HUD after repeated entry and revokes retained controls', () => {
const f = fixture();
try {
let mode = 'auto', variant = 'tactical';
const owner = new RecordingControls({ syncShareState() {} });
owner.hud = { getMode: () => mode, getVariant: () => variant, setMode: (value) => { mode = value; }, setVariant: (value) => { variant = value; }, visible: true };
owner.setRecordingMode(true, { hudMode: 'minimal', safeFrame: '9:16' });
owner.setRecordingMode(true, { hudMode: 'off' });
owner.destroy(); owner.setRecordingMode(true);
assert.equal(mode, 'auto'); assert.equal(variant, 'tactical');
assert.equal(document.body.classList.contains('recording-mode'), false);
assert.equal(f.nodes.get('safe-frame-overlay').classList.contains('active'), false);
} finally { f.restore(); }
});
+2
View File
@@ -0,0 +1,2 @@
/** Load the ordered application control stylesheet. */
import '../../style.css';
+338
View File
@@ -0,0 +1,338 @@
/* ── CCTV Panel ─────────────────────────────── */
#cctv-panel {
--panel-expanded-width: 360px;
position: fixed;
top: var(--left-stack-top);
left: var(--left-stack-x);
right: auto;
bottom: auto;
z-index: 100;
width: var(--panel-expanded-width);
max-height: calc(100vh - 24px);
}
#cctv-panel .panel-glow {
position: absolute;
inset: -18px;
background: radial-gradient(ellipse at center, rgba(0, 212, 255, 0.16) 0%, transparent 65%);
filter: blur(16px);
opacity: 0.25;
pointer-events: none;
}
.cctv-panel-inner {
position: relative;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 24px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
}
.cctv-panel-inner::-webkit-scrollbar {
width: 8px;
}
.cctv-panel-inner::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
}
.cctv-panel-inner::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(0, 212, 255, 0.6), rgba(0, 212, 255, 0.28));
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 999px;
}
.cctv-controls {
display: flex;
gap: 8px;
margin-bottom: 0;
}
.cctv-calibration-block {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
padding: 8px 9px;
background: rgba(255, 255, 255, 0.02);
}
.cctv-calibration-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
}
.cctv-calibration-header .cctv-summary-label {
margin: 0;
}
/* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact
value chips replace the seven offset sliders. */
.cctv-cal-readout {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.cctv-cal-value {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
color: #91edff;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
padding: 4px 7px;
cursor: text;
transition: border-color 0.15s ease, background 0.15s ease;
}
.cctv-cal-value:hover:not(:disabled) {
border-color: rgba(0, 212, 255, 0.45);
background: rgba(0, 212, 255, 0.08);
}
.cctv-cal-input {
width: 58px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
color: var(--text-primary);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.5);
border-radius: 4px;
padding: 1px 3px;
outline: none;
-moz-appearance: textfield;
}
.cctv-cal-input::-webkit-outer-spin-button,
.cctv-cal-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.cctv-calibration-actions {
margin-top: 8px;
}
#cctv-panel input:disabled,
#cctv-panel button:disabled,
#cctv-panel select:disabled {
opacity: 0.45;
}
#cctv-camera-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
}
#cctv-camera-select:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
#cctv-camera-select:disabled {
opacity: 0.45;
}
#cctv-enable-btn.active,
#cctv-coverage-btn.active,
#cctv-auto-hop-btn.active,
#cctv-projection-btn.active {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
#cctv-quality-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 90px;
padding: 6px 8px;
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: #8fe7ff;
background: rgba(0, 212, 255, 0.08);
text-transform: uppercase;
}
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
in-world tint). Three visually distinct states: calibrated / curated /
raw-prior. */
#cctv-quality-chip[data-cal-badge='calibrated'] {
color: #adffbf;
border-color: rgba(141, 255, 135, 0.42);
background: rgba(141, 255, 135, 0.12);
}
#cctv-quality-chip[data-cal-badge='curated'] {
color: #ffe6a2;
border-color: rgba(255, 217, 122, 0.35);
background: rgba(255, 217, 122, 0.1);
}
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
#cctv-quality-chip[data-cal-badge='edited'] {
color: #ffd08a;
border-color: rgba(255, 176, 82, 0.5);
background: rgba(255, 176, 82, 0.14);
}
.cctv-frame-wrap {
position: relative;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background:
radial-gradient(circle at 20% 20%, rgba(0, 244, 255, 0.09), transparent 44%),
linear-gradient(180deg, rgba(8, 14, 19, 0.88), rgba(4, 7, 11, 0.96));
min-height: 150px;
/* .cctv-panel-inner is a scrolling column flex container, so the default
flex-shrink:1 squeezed this wrap down to its 150px floor while the 16:9
image inside is taller — and the wrap is overflow:hidden, which clipped
the bottom of the frame right back off. Never shrink the preview. */
flex-shrink: 0;
}
/* Lightweight loading shimmer while the active frame is fetching */
.cctv-frame-wrap::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 24%,
rgba(0, 212, 255, 0.08) 44%,
rgba(170, 244, 255, 0.14) 50%,
rgba(0, 212, 255, 0.08) 56%,
transparent 76%
);
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.cctv-frame-wrap.loading::after {
opacity: 1;
animation: cctv-frame-shimmer 1.4s linear infinite;
}
/* "Acquiring" caption only when no previous frame is showing underneath */
.cctv-frame-wrap.loading:not(.has-frame)::before {
content: 'ACQUIRING FRAME';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 2.2px;
color: rgba(145, 237, 255, 0.62);
pointer-events: none;
}
@keyframes cctv-frame-shimmer {
to {
transform: translateX(100%);
}
}
#cctv-frame {
width: 100%;
/* A fixed 16:9 box with the frame CONTAINED inside it. Providers burn their
timestamp into a corner (TfL top-left, Austin bottom-left) and the old
150px-tall box was ~2.2:1, so object-fit: cover ate ~18px off the top and
bottom — exactly where those stamps live (owner field test 2026-07-31).
Sources are not all 16:9 either (a TfL frame measures 352x288), so
`contain` is what actually guarantees nothing is cut; the ratio just keeps
the panel height stable as you switch cameras. */
aspect-ratio: 16 / 9;
height: auto;
object-fit: contain;
display: block;
opacity: 0;
transition: opacity 180ms ease;
}
#cctv-frame.active {
opacity: 1;
}
#cctv-meta {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: var(--text-secondary);
}
#cctv-source-badge {
align-self: flex-start;
border: 1px solid rgba(0, 212, 255, 0.32);
border-radius: 999px;
background: rgba(0, 212, 255, 0.09);
color: #9defff;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.3px;
padding: 4px 8px;
}
#cctv-source-badge[data-frame-state='loading'] {
color: #ffe59b;
border-color: rgba(255, 205, 92, 0.45);
background: rgba(255, 205, 92, 0.1);
}
#cctv-source-badge[data-frame-state='error'] {
color: #ffb0b0;
border-color: rgba(255, 102, 102, 0.5);
background: rgba(255, 72, 72, 0.11);
}
.cctv-summary-label {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.7px;
color: var(--text-dim);
}
#cctv-summary {
min-height: 38px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.8px;
line-height: 1.45;
color: #a8f7ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.18);
}
File diff suppressed because it is too large. Load diff
+404
View File
@@ -0,0 +1,404 @@
/* ── Compact accordion command dock ──────────── */
#command-dock {
--dock-side-compact: clamp(9.5rem, 12vw, 10.5rem);
--dock-location-open: clamp(28rem, 40vw, 33rem);
--dock-presets-open: clamp(25rem, 36vw, 29rem);
--dock-voice-width: clamp(13.5rem, 17vw, 15rem);
display: flex;
grid-template-areas: none;
grid-template-columns: none;
align-items: center;
width: fit-content;
max-width: calc(100vw - 4vw);
min-height: 3.75rem;
padding: 0.2rem;
border-radius: 0.4rem;
transform: translateX(-50%);
transition:
transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity var(--transition-smooth),
visibility var(--transition-smooth);
}
#command-dock:has(#location-bar:not(.collapsed)) {
transform: translateX(calc(-50% + (var(--dock-location-open) - var(--dock-side-compact)) / 2));
}
#command-dock:has(#control-panel:not(.collapsed)) {
transform: translateX(calc(-50% - (var(--dock-presets-open) - var(--dock-side-compact)) / 2));
}
#command-dock > #location-bar,
#command-dock > #control-panel {
flex: 0 0 var(--dock-side-compact);
align-self: stretch;
min-width: 0;
height: auto;
overflow: hidden;
border: 0;
transition: flex-basis 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#command-dock > #location-bar:not(.collapsed) {
flex-basis: var(--dock-location-open);
}
#command-dock > #control-panel:not(.collapsed) {
flex-basis: var(--dock-presets-open);
}
#command-dock > #location-bar {
border-right: 1px solid rgba(123, 189, 211, 0.18);
}
#command-dock > #control-panel {
border-left: 1px solid rgba(123, 189, 211, 0.18);
}
#command-dock .panel-inner,
#command-dock .location-inner,
#command-dock #control-panel.collapsed .panel-inner,
#command-dock #location-bar.collapsed .location-inner {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 3.75rem;
padding: 0.45rem 0.75rem;
}
#command-dock .panel-header,
#command-dock .location-toolbar,
#command-dock #control-panel.collapsed .panel-header {
flex: 0 0 auto;
min-width: calc(var(--dock-side-compact) - 1.5rem);
min-height: 1rem;
margin: 0;
}
#command-dock .panel-title,
#command-dock .location-toolbar-label {
width: max-content;
font-size: 0.45rem;
letter-spacing: 0.12rem;
white-space: nowrap;
}
#command-dock .panel-title::after,
#command-dock .location-toolbar-label::after {
content: '›';
margin-left: 0.45rem;
color: rgba(54, 220, 255, 0.68);
font-size: 0.7rem;
}
#command-dock #control-panel .panel-title::after {
content: '‹';
margin-right: 0.45rem;
margin-left: 0;
float: left;
}
#command-dock #location-bar.collapsed .location-city-row,
#command-dock #location-bar.collapsed .poi-row-container,
#command-dock #location-bar.collapsed .location-bar-divider,
#command-dock #control-panel.collapsed .button-grid {
display: none !important;
}
#command-dock #location-bar:not(.collapsed) .location-toolbar,
#command-dock #control-panel:not(.collapsed) .panel-header {
margin-bottom: 0.35rem;
}
#command-dock #location-bar:not(.collapsed) .location-city-row,
#command-dock #control-panel:not(.collapsed) .button-grid {
display: flex !important;
animation: command-dock-reveal 220ms ease-out both;
}
#command-dock .location-city-row {
flex-wrap: nowrap;
gap: 0.35rem;
overflow: hidden;
}
#command-dock .location-search-wrap {
flex: 0 0 10.5rem;
min-width: 0;
padding-right: 0.4rem;
}
#command-dock .search-toggle-btn {
width: 1.75rem;
height: 1.75rem;
}
#command-dock #location-search,
#command-dock #location-search.expanded {
height: 1.75rem;
padding: 0.3rem 0.45rem;
font-size: 0.62rem;
}
#command-dock .location-pills {
display: flex;
gap: 0.3rem;
overflow-x: auto;
scrollbar-width: none;
}
#command-dock .location-pills::-webkit-scrollbar {
display: none;
}
#command-dock .location-pill,
#command-dock .poi-pill {
flex: 0 0 auto;
min-height: 1.75rem;
padding: 0.3rem 0.55rem;
border-radius: 0.2rem;
font-size: 0.52rem;
white-space: nowrap;
}
#command-dock .poi-row-container.expanded {
display: flex;
justify-content: flex-start;
gap: 0.3rem;
max-height: 2.1rem;
margin: 0 0 0.35rem;
overflow-x: auto;
overflow-y: hidden;
animation: command-dock-reveal 180ms ease-out both;
scrollbar-width: none;
}
#command-dock .poi-row-container.expanded::-webkit-scrollbar {
display: none;
}
#command-dock .location-bar-divider.visible {
display: none;
}
#command-dock .button-grid {
flex-wrap: nowrap;
gap: 0.3rem;
overflow-x: auto;
scrollbar-width: none;
}
#command-dock .button-grid::-webkit-scrollbar {
display: none;
}
#command-dock .style-btn {
flex: 0 0 3.2rem;
min-width: 3.2rem;
min-height: 2.45rem;
gap: 0.1rem;
padding: 0.3rem 0.25rem;
border-radius: 0.2rem;
}
#command-dock .btn-icon {
font-size: 0.78rem;
}
#command-dock .btn-label {
font-size: 0.43rem;
letter-spacing: 0.02rem;
}
#command-dock .btn-key {
display: none;
}
#command-dock > #gev-voice-control {
position: relative;
inset: auto;
z-index: 3;
display: grid;
grid-template-columns: 3.2rem 1fr 3.6rem;
grid-template-rows: 0.8rem 2.55rem 0.8rem;
flex: 0 0 var(--dock-voice-width);
width: var(--dock-voice-width);
min-width: 0;
height: 4.9rem;
margin: -0.65rem 0;
padding: 0.3rem 0.65rem;
gap: 0 0.35rem;
overflow: hidden;
border-radius: var(--panel-radius);
transform: none;
}
#command-dock .gev-voice-kicker {
position: static;
grid-column: 2 / 4;
grid-row: 1;
align-self: center;
color: rgba(190, 221, 230, 0.58);
font-size: 0.39rem;
letter-spacing: 0.07rem;
text-align: center;
}
#command-dock #gev-voice-button {
grid-column: 1;
grid-row: 1 / 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
align-self: center;
gap: 0.15rem;
padding: 0;
border-radius: 50%;
box-shadow:
0 0 0 0.2rem rgba(0, 212, 255, 0.06),
0 0 1rem rgba(0, 212, 255, 0.12);
}
#command-dock .gev-mic-orbit {
display: grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
}
#command-dock .gev-mic-orbit img {
width: 1.55rem;
height: 1.55rem;
}
#command-dock .gev-mic-label {
font-size: 0.35rem;
color: rgba(190, 221, 230, 0.72);
letter-spacing: 0.02rem;
}
#command-dock .gev-voice-visualizer {
grid-column: 2;
grid-row: 2;
display: flex;
align-items: end;
justify-content: center;
gap: 0.12rem;
height: 2.2rem;
padding: 0.2rem 0.1rem;
border-bottom: 1px solid rgba(54, 220, 255, 0.34);
}
#command-dock .gev-voice-visualizer span {
width: 0.18rem;
height: 0.55rem;
background: linear-gradient(to top, #28b9db, #71eaff);
box-shadow: 0 0 0.35rem rgba(54, 220, 255, 0.28);
transform-origin: bottom;
}
#command-dock .gev-voice-visualizer span:nth-child(3n) { height: 1.35rem; }
#command-dock .gev-voice-visualizer span:nth-child(4n) { height: 0.95rem; }
#command-dock .gev-voice-visualizer span:nth-child(5n) { height: 1.7rem; }
#command-dock .gev-voice-visualizer span:nth-child(7n) { height: 0.75rem; }
#command-dock #gev-voice-control[data-status='listening'] .gev-voice-visualizer span,
#command-dock #gev-voice-control[data-status='executing'] .gev-voice-visualizer span {
animation: gev-voice-bar 680ms ease-in-out infinite alternate;
animation-delay: calc(var(--bar) * -45ms);
}
#command-dock .gev-voice-readout {
display: contents;
}
#command-dock #gev-voice-status {
grid-column: 3;
grid-row: 2;
align-self: start;
padding-top: 0.2rem;
font-size: 0.45rem;
text-align: right;
}
#command-dock #gev-voice-detail {
grid-column: 2 / 4;
grid-row: 3;
align-self: center;
max-width: none;
margin: 0;
font-size: 0.39rem;
letter-spacing: 0.08rem;
text-align: center;
}
@keyframes command-dock-reveal {
from { opacity: 0; transform: translateY(0.2rem); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gev-voice-bar {
from { transform: scaleY(0.35); opacity: 0.5; }
to { transform: scaleY(1); opacity: 1; }
}
/* Compact dock clearance keeps the reference-like low profile. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
bottom: calc(2vh + 4.7rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 5.4rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 8.9rem);
}
@media (max-width: 900px) {
#command-dock {
--dock-popover-mobile-width: calc(100vw - 1.5rem);
}
#command-dock {
--dock-side-compact: 4.25rem;
--dock-location-open: calc(100vw - 7rem);
--dock-presets-open: calc(100vw - 7rem);
--dock-voice-width: 11.5rem;
max-width: calc(100vw - 1rem);
}
#command-dock:has(#location-bar:not(.collapsed)),
#command-dock:has(#control-panel:not(.collapsed)) {
transform: translateX(-50%);
}
#command-dock:has(#location-bar:not(.collapsed)) > #control-panel,
#command-dock:has(#control-panel:not(.collapsed)) > #location-bar {
flex-basis: 0;
border: 0;
}
#command-dock:has(#location-bar:not(.collapsed)) > #gev-voice-control,
#command-dock:has(#control-panel:not(.collapsed)) > #gev-voice-control {
flex-basis: 4.75rem;
width: 4.75rem;
}
#command-dock:has(#location-bar:not(.collapsed)) .gev-voice-visualizer,
#command-dock:has(#location-bar:not(.collapsed)) .gev-voice-kicker,
#command-dock:has(#location-bar:not(.collapsed)) .gev-voice-readout,
#command-dock:has(#control-panel:not(.collapsed)) .gev-voice-visualizer,
#command-dock:has(#control-panel:not(.collapsed)) .gev-voice-kicker,
#command-dock:has(#control-panel:not(.collapsed)) .gev-voice-readout {
visibility: hidden;
}
#command-dock .location-search-wrap {
flex-basis: 8.5rem;
}
}
+631
View File
@@ -0,0 +1,631 @@
/* ── Slide-up command trays ──────────────────── */
#command-dock,
#command-dock:has(#location-bar:not(.collapsed)),
#command-dock:has(#control-panel:not(.collapsed)) {
transform: translateX(-50%);
}
#command-dock > #location-bar,
#command-dock > #location-bar:not(.collapsed),
#command-dock > #control-panel,
#command-dock > #control-panel:not(.collapsed) {
position: relative;
flex-basis: var(--dock-side-compact);
width: var(--dock-side-compact);
overflow: visible;
}
#command-dock .location-inner,
#command-dock .panel-inner,
#command-dock #location-bar.collapsed .location-inner,
#command-dock #control-panel.collapsed .panel-inner {
position: relative;
overflow: visible;
}
#command-dock .dock-popover-content {
position: absolute;
bottom: calc(100% + 0.65rem);
z-index: 5;
display: flex;
flex-direction: column;
width: var(--dock-location-open);
padding: 1.55rem 0.6rem 0.6rem;
visibility: hidden;
opacity: 0;
background: rgba(8, 20, 30, 0.94);
border: 1px solid rgba(123, 189, 211, 0.32);
border-radius: 0.42rem;
box-shadow:
0 0.9rem 2.4rem rgba(0, 0, 0, 0.58),
0 0 1.5rem rgba(0, 212, 255, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.025) inset;
backdrop-filter: blur(28px) saturate(1.3);
-webkit-backdrop-filter: blur(28px) saturate(1.3);
pointer-events: none;
transform: translateY(0.55rem) scale(0.985);
transform-origin: bottom left;
transition:
opacity 180ms ease,
visibility 0s linear 180ms,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#command-dock #location-bar .dock-popover-content {
left: -0.75rem;
}
#command-dock #control-panel .dock-popover-content {
right: -0.75rem;
left: auto;
width: var(--dock-presets-open);
transform-origin: bottom right;
}
#command-dock #location-bar:not(.collapsed) .dock-popover-content,
#command-dock #control-panel:not(.collapsed) .dock-popover-content {
visibility: visible;
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
margin-bottom: 10px;
/* Make newly revealed controls enter the Tab order immediately. The base
rule still delays visibility on close so its opacity fade can finish. */
transition-delay: 0s;
}
#command-dock #location-bar.collapsed .dock-popover-content,
#command-dock #control-panel.collapsed .dock-popover-content {
display: flex !important;
}
#command-dock .dock-popover-content::after {
/* content: ''; */
position: absolute;
bottom: -0.34rem;
left: 1.65rem;
width: 0.6rem;
height: 0.6rem;
background: rgba(8, 20, 30, 0.96);
border-right: 1px solid rgba(123, 189, 211, 0.28);
border-bottom: 1px solid rgba(123, 189, 211, 0.28);
transform: rotate(45deg);
}
#command-dock #control-panel .dock-popover-content::after {
right: 1.65rem;
left: auto;
}
#command-dock .dock-popover-content .location-city-row,
#command-dock .dock-popover-content .button-grid {
display: flex !important;
}
#command-dock .dock-popover-content .poi-row-container:not(.expanded) {
display: none !important;
}
#command-dock .dock-pin-btn {
position: absolute;
top: 0.4rem;
right: 0.5rem;
z-index: 2;
display: none;
flex: 0 0 auto;
min-width: 1.65rem;
height: 1rem;
padding: 0 0.28rem;
color: rgba(190, 221, 230, 0.5);
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(123, 189, 211, 0.2);
border-radius: 0.2rem;
font-family: var(--font-mono);
font-size: 0.35rem;
letter-spacing: 0.04rem;
cursor: pointer;
}
#command-dock #location-bar:not(.collapsed) .dock-pin-btn,
#command-dock #control-panel:not(.collapsed) .dock-pin-btn {
display: block;
}
#command-dock .dock-pin-btn:hover,
#command-dock .dock-pin-btn[aria-pressed='true'] {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.62);
background: rgba(0, 212, 255, 0.1);
box-shadow: 0 0 0.55rem rgba(0, 212, 255, 0.12);
}
#command-dock .panel-divider {
min-width: 0.25rem;
}
@media (max-width: 900px) {
#command-dock:has(#location-bar:not(.collapsed)) > #control-panel,
#command-dock:has(#control-panel:not(.collapsed)) > #location-bar {
flex-basis: var(--dock-side-compact);
border: inherit;
}
#command-dock:has(#location-bar:not(.collapsed)) > #gev-voice-control,
#command-dock:has(#control-panel:not(.collapsed)) > #gev-voice-control {
flex-basis: var(--dock-voice-width);
width: var(--dock-voice-width);
}
#command-dock:has(#location-bar:not(.collapsed)) .gev-voice-visualizer,
#command-dock:has(#location-bar:not(.collapsed)) .gev-voice-kicker,
#command-dock:has(#location-bar:not(.collapsed)) .gev-voice-readout,
#command-dock:has(#control-panel:not(.collapsed)) .gev-voice-visualizer,
#command-dock:has(#control-panel:not(.collapsed)) .gev-voice-kicker,
#command-dock:has(#control-panel:not(.collapsed)) .gev-voice-readout {
visibility: visible;
}
#command-dock .dock-popover-content {
width: var(--dock-popover-mobile-width);
}
#command-dock #location-bar .dock-popover-content {
left: calc((var(--dock-side-compact) + var(--dock-side-compact) + var(--dock-voice-width) - var(--dock-popover-mobile-width)) / 2);
}
#command-dock #control-panel .dock-popover-content {
/* NOT a duplicate of the `#command-dock .dock-popover-content` width above:
that selector is (1 id, 1 class) and loses to the unconditional
`#command-dock #control-panel .dock-popover-content { width:
var(--dock-presets-open) }`, which is 34rem here. Without this override
the tray renders 544 px wide inside a 480 px viewport and hangs off the
left edge — pinned by the 480 px bounds assert in
`scripts/qa-map-source-tray.mjs`. */
width: var(--dock-popover-mobile-width);
right: calc((var(--dock-side-compact) + var(--dock-side-compact) + var(--dock-voice-width) - var(--dock-popover-mobile-width)) / 2);
}
/* ── Required-attribution clearance ─────────────────────────────────
At this width the tray widens to nearly the whole viewport (the rule
above), and at its stock 0.65rem offset it lands squarely on the
Google/Cesium credit line in the bottom-left corner — a notice the
Maps Platform and Cesium terms require to stay visible in EVERY state.
The transient popover yields, not the permanent notice: the credit
keeps the position every other state gives it.
The tray hangs off the dock, so its clearance is
dock bottom + dock height 62px + offset − (credit bottom + 28px).
Across 721–900px the dock is anchored at 2vh and the credit at
calc(2vh + 5rem), so those terms cancel and 3rem + the tray's own 10px
margin leaves a flat 12px. That cancellation is NOT universal: at 720px
and below the dock re-anchors to a flat 8px while the credit keeps its
2vh base, which is what the max-width:720px override further down
repairs. Nothing here may be reasoned about "at every viewport height"
without checking the dock anchor that actually applies.
Keep these in step with the base offsets (0.65rem, and 1.2rem for the
pinned variants); src/creditAttribution.test.mjs walks the real cascade
over a viewport grid and fails if any of them re-enters the credit
band. */
#command-dock #location-bar .dock-popover-content,
#command-dock #control-panel .dock-popover-content {
bottom: calc(100% + 3rem);
}
#command-dock.dock-has-pinned-tray #location-bar:not(.collapsed):not(.dock-pinned) .dock-popover-content,
#command-dock.dock-has-pinned-tray #control-panel:not(.collapsed):not(.dock-pinned) .dock-popover-content {
bottom: calc(100% + 3rem + var(--dock-pinned-stack-height));
}
/* Written with the panel IDs on purpose. The stock two-pinned selector
carries one ID (#command-dock) against five classes, and the ordinary
narrow rule above carries two IDs — so the stock form LOSES here and the
top tray drops var(--dock-lower-pinned-height), landing on top of its
pinned sibling. Naming the panel makes it (2,5,0), ahead of both the
ordinary (2,1,0) and single-pinned (2,4,0) rules. */
#command-dock.dock-has-two-pinned-trays #location-bar.dock-pinned-top.dock-pinned:not(.collapsed) .dock-popover-content,
#command-dock.dock-has-two-pinned-trays #control-panel.dock-pinned-top.dock-pinned:not(.collapsed) .dock-popover-content {
bottom: calc(100% + 3rem + var(--dock-lower-pinned-height));
}
}
/* The dock re-anchors from 2vh to a flat 8px at this width (see the
max-width:720px block above), while the credit keeps its 2vh base — so the
clearance above stops being height-independent and goes negative on tall
narrow viewports (0 at 1000px high, −4px at 1200px). Carrying the 2vh back
into the tray's own offset restores the same flat 12px the wider band gets.
This block must stay AFTER the max-width:900px rules to win the cascade. */
@media (max-width: 720px) {
#command-dock #location-bar .dock-popover-content,
#command-dock #control-panel .dock-popover-content {
bottom: calc(100% + 2vh + 2.5rem);
}
#command-dock.dock-has-pinned-tray #location-bar:not(.collapsed):not(.dock-pinned) .dock-popover-content,
#command-dock.dock-has-pinned-tray #control-panel:not(.collapsed):not(.dock-pinned) .dock-popover-content {
bottom: calc(100% + 2vh + 2.5rem + var(--dock-pinned-stack-height));
}
/* Panel IDs for the same specificity reason as the max-width:900px block. */
#command-dock.dock-has-two-pinned-trays #location-bar.dock-pinned-top.dock-pinned:not(.collapsed) .dock-popover-content,
#command-dock.dock-has-two-pinned-trays #control-panel.dock-pinned-top.dock-pinned:not(.collapsed) .dock-popover-content {
bottom: calc(100% + 2vh + 2.5rem + var(--dock-lower-pinned-height));
}
}
/* Final top-right positioning for the floating SVG tray pins. */
#command-dock .dock-popover-content {
padding: 0.7rem;
}
#command-dock .dock-popover-content .button-grid {
align-items: stretch;
min-height: 3.7rem;
}
#command-dock .dock-popover-content .style-btn {
flex: 1 1 0;
min-width: 3.6rem;
min-height: 3.7rem;
height: 100%;
padding: 0.45rem 0.3rem;
}
#command-dock .dock-popover-content .btn-icon {
font-size: 1.15rem;
line-height: 1;
}
#command-dock .btn-label {
padding-top: 10px;
}
#command-dock .dock-pin-btn {
position: absolute;
top: -1rem;
right: -1rem;
z-index: 2;
place-items: center;
width: 2.05rem;
min-width: 2.05rem;
height: 2.05rem;
margin: 0;
padding: 0;
border-radius: 50%;
background: rgba(8, 24, 34, 0.96);
box-shadow: 0 0.35rem 0.9rem rgba(0, 0, 0, 0.5);
transform: none;
}
#command-dock #location-bar .dock-pin-btn {
top: -1rem;
right: -1rem;
}
#command-dock #control-panel .dock-pin-btn {
top: -1rem;
right: -1rem;
}
#command-dock #location-bar:not(.collapsed) .dock-pin-btn,
#command-dock #control-panel:not(.collapsed) .dock-pin-btn {
display: grid;
}
#command-dock .dock-pin-btn img {
width: 1.15rem;
height: 1.15rem;
}
/* Five-pixel standby baseline; live microphone analysis sets --audio-level. */
#command-dock .gev-voice-visualizer span,
#command-dock .gev-voice-visualizer span:nth-child(3n),
#command-dock .gev-voice-visualizer span:nth-child(4n),
#command-dock .gev-voice-visualizer span:nth-child(5n),
#command-dock .gev-voice-visualizer span:nth-child(7n) {
height: var(--audio-level, 5px);
opacity: var(--audio-opacity, 0.56);
animation: none;
transition: height 70ms linear, opacity 90ms linear;
}
#command-dock #gev-voice-control[data-status='listening'] .gev-voice-visualizer span,
#command-dock #gev-voice-control[data-status='executing'] .gev-voice-visualizer span {
animation: none;
}
#command-dock #gev-voice-control[data-speaker='user'] :is(#gev-voice-status, #gev-voice-detail) {
color: #ffbf69;
}
#command-dock #gev-voice-control[data-speaker='user'] .gev-voice-visualizer span {
background: linear-gradient(to top, #d98a35, #ffcf8a);
box-shadow: 0 0 0.35rem rgba(255, 191, 105, 0.32);
}
#command-dock #gev-voice-control[data-speaker='ai'] :is(#gev-voice-status, #gev-voice-detail) {
color: var(--accent);
}
#command-dock #gev-voice-control[data-speaker='ai'] .gev-voice-visualizer span {
background: linear-gradient(to top, #28b9db, #71eaff);
box-shadow: 0 0 0.35rem rgba(54, 220, 255, 0.28);
}
/* Center every slide-up tray on the complete unified dock, not its side wing. */
#command-dock #location-bar .dock-popover-content {
left: calc((var(--dock-unified-width) - var(--dock-location-open)) / 2);
}
#command-dock #control-panel .dock-popover-content {
right: calc((var(--dock-unified-width) - var(--dock-presets-open)) / 2);
}
@media (max-width: 900px) {
#command-dock #location-bar .dock-popover-content {
left: calc((var(--dock-unified-width) - var(--dock-popover-mobile-width)) / 2);
}
#command-dock #control-panel .dock-popover-content {
right: calc((var(--dock-unified-width) - var(--dock-popover-mobile-width)) / 2);
}
}
/* Final joined-dock geometry: only the three controls own visible surfaces. */
#command-dock {
padding: 0;
background: none;
border: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#command-dock > #location-bar {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
#command-dock > #location-bar > .location-inner {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
#command-dock > #control-panel {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
#command-dock > #control-panel > .panel-inner {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
#command-dock .dock-popover-content .style-btn:hover {
transform: none;
}
#command-dock .dock-popover-content :is(.location-pill, .poi-pill):hover {
transform: none;
}
/* Keep the microphone neutral at rest; cyan signals hover and live states. */
#command-dock .gev-mic-orbit {
background: rgba(232, 234, 237, 0.04);
border-color: rgba(232, 234, 237, 0.36);
box-shadow:
0 0 0 0.18rem rgba(232, 234, 237, 0.04),
0 0 0.75rem rgba(232, 234, 237, 0.08);
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
#command-dock .gev-mic-orbit img {
filter: grayscale(1) saturate(0) brightness(1.65);
opacity: 0.68;
transition: filter var(--transition-fast), opacity var(--transition-fast);
}
#command-dock #gev-voice-button:hover .gev-mic-orbit,
#command-dock #gev-voice-control:is(
[data-status='connecting'],
[data-status='listening']:not([data-microphone='muted']),
[data-status='executing']
) .gev-mic-orbit {
background: rgba(0, 212, 255, 0.06);
border-color: rgba(0, 212, 255, 0.48);
box-shadow: 0 0 0 0.18rem rgba(0, 212, 255, 0.06), 0 0 0.9rem rgba(0, 212, 255, 0.12);
}
#command-dock #gev-voice-button:hover .gev-mic-orbit img,
#command-dock #gev-voice-control:is(
[data-status='connecting'],
[data-status='listening']:not([data-microphone='muted']),
[data-status='executing']
) .gev-mic-orbit img {
filter: none;
opacity: 1;
}
/* Match compact dock wings to the glass treatment used by left-panel buttons. */
#command-dock > #location-bar,
#command-dock > #control-panel {
box-sizing: border-box;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
transition:
border-color 160ms ease,
background-color 160ms ease,
box-shadow 160ms ease;
}
#command-dock > :is(#location-bar, #control-panel):hover {
background: var(--glass-bg);
border-color: var(--glass-border-hover);
color: var(--accent);
}
#command-dock > :is(#location-bar, #control-panel).dock-pinned {
border-color: rgba(0, 212, 255, 0.55);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.08) inset,
0 0 1rem rgba(0, 212, 255, 0.12);
}
#command-dock #location-bar .location-toolbar {
justify-content: flex-start;
width: 100%;
}
#command-dock #location-bar .location-toolbar-label {
justify-content: flex-start;
width: 100%;
text-align: left;
}
#command-dock #location-bar .location-toolbar-label::after {
content: none;
}
#command-dock #control-panel .panel-header {
justify-content: flex-end;
width: 100%;
}
#command-dock #control-panel .panel-title {
justify-content: flex-end;
width: 100%;
text-align: right;
}
/* These must follow the shared wing skin so the visible borders stay joined. */
#command-dock > #location-bar {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
#command-dock > #control-panel {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
#command-dock #location-bar .location-toolbar,
#command-dock #control-panel .panel-header {
position: relative;
}
#command-dock #control-panel .panel-header {
flex-direction: row;
gap: 0;
}
#command-dock #control-panel .panel-title {
flex: 0 0 auto;
width: auto;
}
#command-dock #control-panel .panel-title::before {
content: none;
}
#command-dock #control-panel .panel-divider {
display: none;
}
#gev-voice-control .gev-voice-help-tray {
position: absolute;
bottom: calc(100% + 0.7rem);
left: 50%;
z-index: 7;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.22rem;
box-sizing: border-box;
width: max-content;
max-width: min(22rem, calc(100vw - 2rem));
padding: 0.62rem 0.72rem;
visibility: hidden;
opacity: 0;
color: rgba(223, 245, 250, 0.92);
background: rgba(7, 22, 31, 0.96);
border: 1px solid rgba(54, 220, 255, 0.34);
border-radius: 0.38rem;
box-shadow:
0 0.85rem 2rem rgba(0, 0, 0, 0.58),
0 0 1.2rem rgba(0, 212, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.3);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
pointer-events: none;
transform: translate(-50%, 0.65rem) scale(0.985);
transform-origin: bottom center;
transition:
opacity 160ms ease,
visibility 160ms ease,
transform 210ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#gev-voice-control .gev-voice-help-kicker {
color: rgba(54, 220, 255, 0.72);
font-family: var(--font-mono);
font-size: 0.46rem;
font-weight: 600;
letter-spacing: 0.1rem;
}
#gev-voice-control .gev-voice-help-detail {
font-family: var(--font-sans);
font-size: 0.7rem;
line-height: 1.35;
white-space: nowrap;
}
#gev-voice-control:not([data-status='error']):hover .gev-voice-help-tray,
#gev-voice-control:not([data-status='error']) #gev-voice-button:focus-visible ~ .gev-voice-help-tray {
visibility: visible;
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
#command-dock.dock-has-pinned-tray .gev-voice-help-tray {
bottom: calc(100% + 1.7rem + var(--dock-pinned-stack-height));
}
@media (prefers-reduced-motion: reduce) {
#gev-voice-control .gev-voice-help-tray {
transition: none;
}
.radio-tuner-needle,
.radio-tuner-tick {
transition: none;
}
}
#command-dock #location-bar .location-toolbar::after,
#command-dock #control-panel .panel-header::after {
content: '';
position: absolute;
right: 0.45rem;
bottom: 0.12rem;
left: 0.45rem;
height: 1px;
pointer-events: none;
}
#command-dock #location-bar .location-toolbar::after {
background: linear-gradient(90deg, rgba(232, 234, 237, 0.24), rgba(232, 234, 237, 0));
}
#command-dock #control-panel .panel-header::after {
background: linear-gradient(90deg, rgba(232, 234, 237, 0), rgba(232, 234, 237, 0.24));
}
+483
View File
@@ -0,0 +1,483 @@
/* ── Command dock refinement: stacked trays + voice states ── */
#command-dock {
--dock-voice-width: 8.75rem;
--dock-location-pinned-height: 0px;
--dock-presets-pinned-height: 0px;
--dock-pinned-stack-height: 0px;
--dock-lower-pinned-height: 0px;
--dock-unified-width: calc(var(--dock-side-compact) * 2 + var(--dock-voice-width));
--dock-location-open: max(34rem, calc(var(--dock-unified-width) + 0.75rem));
--dock-presets-open: max(34rem, calc(var(--dock-unified-width) + 0.75rem));
}
#command-dock .panel-title,
#command-dock .location-toolbar-label {
display: inline-flex;
align-items: center;
gap: 0.42rem;
color: rgba(206, 232, 239, 0.72);
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.13rem;
}
#command-dock .dock-label-icon {
display: inline-block;
flex: 0 0 1rem;
width: 1rem;
height: 1rem;
background: rgba(232, 234, 237, 0.3);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
transition: background-color 160ms ease, filter 160ms ease;
}
#command-dock .dock-label-icon-location {
mask-image: url('/location.svg');
-webkit-mask-image: url('/location.svg');
}
#command-dock .dock-label-icon-presets {
mask-image: url('/visual-presets.svg');
-webkit-mask-image: url('/visual-presets.svg');
}
#command-dock :is(#location-bar, #control-panel):hover .dock-label-icon,
#command-dock :is(#location-bar, #control-panel).dock-pinned .dock-label-icon {
background: var(--accent);
filter: drop-shadow(0 0 0.35rem rgba(0, 212, 255, 0.28));
}
#command-dock :is(#location-bar, #control-panel):hover .location-toolbar-label,
#command-dock :is(#location-bar, #control-panel):hover .panel-title{
color: var(--accent);
}
#command-dock #control-panel .panel-header {
justify-content: center;
}
#command-dock #location-bar .dock-tray-toggle {
display: flex;
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font: inherit;
cursor: pointer;
outline: none;
}
#command-dock #location-bar .dock-tray-toggle:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 3px;
border-radius: 3px;
}
#command-dock #control-panel .dock-tray-toggle {
box-sizing: border-box;
width: 100%;
color: inherit;
background: transparent;
border: 0;
font: inherit;
cursor: pointer;
outline: none;
}
#command-dock #control-panel .dock-tray-toggle:focus-visible {
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
outline-offset: 3px;
border-radius: 3px;
}
#command-dock #control-panel .panel-title {
justify-content: center;
width: 100%;
text-align: center;
}
#command-dock #control-panel .panel-divider {
display: none;
}
#command-dock .location-toolbar-label,
#command-dock #control-panel .panel-title {
position: relative;
padding-bottom: 0.4rem;
}
#command-dock .location-toolbar-label::after,
#command-dock #control-panel .panel-title::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: block;
height: 1px;
}
#command-dock .location-toolbar-label::after {
background: linear-gradient(to right, rgba(232, 234, 237, 0.3), transparent);
}
#command-dock #control-panel .panel-title::after {
background: linear-gradient(to left, rgba(232, 234, 237, 0.3), transparent);
}
#command-dock .panel-title::after,
#command-dock .location-toolbar-label::after,
#command-dock #control-panel .panel-title::after {
content: none;
display: none;
}
#command-dock .dock-popover-content {
padding: 0.7rem;
}
#command-dock .dock-pin-btn {
position: relative;
top: auto;
right: auto;
align-self: flex-end;
display: none;
place-items: center;
width: 1.65rem;
min-width: 1.65rem;
height: 1.65rem;
margin: -1.48rem -0.22rem 0.25rem 0;
padding: 0;
border-radius: 50%;
background: rgba(8, 24, 34, 0.96);
box-shadow: 0 0.35rem 0.9rem rgba(0, 0, 0, 0.5);
transform: translateY(-0.12rem);
}
#command-dock #location-bar:not(.collapsed) .dock-pin-btn,
#command-dock #control-panel:not(.collapsed) .dock-pin-btn {
display: grid;
}
#command-dock .dock-pin-btn img {
width: 0.9rem;
height: 0.9rem;
opacity: 0.58;
transition: opacity 160ms ease, transform 160ms ease;
}
#command-dock #location-bar .dock-pin-btn {
align-self: flex-start;
margin-right: 0;
margin-left: -0.22rem;
}
#command-dock .dock-pin-btn:hover img,
#command-dock .dock-pin-btn[aria-pressed='true'] img {
opacity: 1;
transform: rotate(-12deg) scale(1.08);
}
#command-dock.dock-has-pinned-tray #location-bar:not(.collapsed):not(.dock-pinned) .dock-popover-content,
#command-dock.dock-has-pinned-tray #control-panel:not(.collapsed):not(.dock-pinned) .dock-popover-content {
bottom: calc(100% + 1.2rem + var(--dock-pinned-stack-height));
}
#command-dock.dock-has-two-pinned-trays .dock-pinned-top.dock-pinned:not(.collapsed) .dock-popover-content {
bottom: calc(100% + 1.2rem + var(--dock-lower-pinned-height));
}
#command-dock.dock-has-pinned-tray .gev-voice-error-tray {
bottom: calc(100% + 1.7rem + var(--dock-pinned-stack-height));
}
#command-dock .location-pill,
#command-dock .poi-pill,
#command-dock .btn-label {
font-size: 0.56rem;
}
#command-dock #location-search,
#command-dock #location-search.expanded {
font-size: 0.68rem;
}
#command-dock > #gev-voice-control {
grid-template-columns: 3rem minmax(0, 1fr);
grid-template-rows: 0.8rem 2.25rem 0.85rem;
gap: 0.12rem 0.28rem;
width: var(--dock-voice-width);
flex-basis: var(--dock-voice-width);
height: 5.35rem;
margin-top: -0.97rem;
margin-bottom: -0.97rem;
padding: 0.32rem 0.38rem 0.3rem;
overflow: visible;
}
#command-dock .gev-voice-heading {
grid-column: 2;
grid-row: 1;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
min-width: 0;
}
#command-dock .gev-voice-kicker {
position: static;
padding-right: 0;
font-size: 0.46rem;
font-weight: 500;
letter-spacing: 0.08rem;
}
#command-dock #gev-voice-button {
grid-column: 1;
grid-row: 1 / 4;
align-self: center;
width: 2.65rem;
height: 4.15rem;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
}
#command-dock .gev-mic-orbit {
flex: 0 0 2.45rem;
width: 2.45rem;
height: 2.45rem;
aspect-ratio: 1;
box-sizing: border-box;
background: rgba(0, 212, 255, 0.06);
border: 1px solid rgba(0, 212, 255, 0.48);
border-radius: 50%;
box-shadow: 0 0 0 0.18rem rgba(0, 212, 255, 0.06), 0 0 0.9rem rgba(0, 212, 255, 0.12);
}
#command-dock .gev-mic-label {
display: block;
margin-top: 0.18rem;
font-size: 0.42rem;
letter-spacing: 0.035rem;
}
#command-dock .gev-voice-visualizer {
grid-column: 2;
grid-row: 2;
align-self: stretch;
box-sizing: border-box;
height: 2.25rem;
padding: 0.3rem 0.05rem 0.12rem;
overflow: hidden;
visibility: visible;
opacity: 0.54;
transform: none;
transition: opacity 180ms ease;
}
#command-dock #gev-voice-control[data-status='connecting'] .gev-voice-visualizer,
#command-dock #gev-voice-control[data-status='listening'] .gev-voice-visualizer,
#command-dock #gev-voice-control[data-status='executing'] .gev-voice-visualizer {
visibility: visible;
opacity: 1;
transform: none;
}
#command-dock #gev-voice-status {
position: static;
width: auto;
padding-top: 0;
color: var(--accent);
font-size: 0.43rem;
text-align: left;
}
#command-dock #gev-voice-detail {
grid-column: 2;
grid-row: 3;
align-self: start;
padding-top: 0.12rem;
max-width: 100%;
overflow: hidden;
font-size: 0.39rem;
font-weight: 500;
letter-spacing: 0.035rem;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
#command-dock #gev-voice-control[data-status='connecting'] #gev-voice-detail,
#command-dock #gev-voice-control[data-status='listening'] #gev-voice-detail,
#command-dock #gev-voice-control[data-status='executing'] #gev-voice-detail {
grid-row: 3;
font-size: 0.43rem;
}
#command-dock #gev-voice-control[data-status='error'] {
display: grid;
align-items: initial;
width: var(--dock-voice-width);
border-color: rgba(255, 92, 110, 0.58);
border-radius: 0.4rem;
}
#command-dock #gev-voice-control[data-status='error'] .gev-voice-readout {
display: contents;
}
#command-dock #gev-voice-control[data-status='error'] #gev-voice-detail {
max-width: none;
overflow: hidden;
color: #ffb4bd;
line-height: 1.2;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
#command-dock .gev-voice-error-tray {
position: absolute;
bottom: calc(100% + 0.7rem);
left: 50%;
z-index: 8;
width: clamp(19rem, 29vw, 24rem);
padding: 0.85rem 0.9rem;
visibility: hidden;
opacity: 0;
color: rgba(255, 220, 224, 0.92);
background: rgba(34, 10, 16, 0.96);
border: 1px solid rgba(255, 92, 110, 0.5);
border-radius: 0.42rem;
box-shadow: 0 0.9rem 2.2rem rgba(0, 0, 0, 0.62), 0 0 1.4rem rgba(255, 92, 110, 0.08);
backdrop-filter: blur(28px) saturate(1.25);
-webkit-backdrop-filter: blur(28px) saturate(1.25);
pointer-events: none;
transform: translate(-50%, 0.55rem) scale(0.985);
transform-origin: bottom center;
transition: opacity 180ms ease, visibility 180ms ease, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#command-dock #gev-voice-control[data-status='error']:not(.error-dismissed) .gev-voice-error-tray {
visibility: visible;
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0) scale(1);
}
#command-dock .gev-voice-error-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
color: #ff8f9d;
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1rem;
}
#command-dock .gev-voice-error-dismiss {
padding: 0.25rem 0.42rem;
color: rgba(255, 220, 224, 0.72);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 143, 157, 0.32);
border-radius: 0.2rem;
font-family: var(--font-mono);
font-size: 0.43rem;
letter-spacing: 0.06rem;
cursor: pointer;
}
#command-dock #gev-voice-error-detail {
margin-top: 0.65rem;
color: rgba(255, 232, 235, 0.92);
font-family: var(--font-sans);
font-size: 0.78rem;
line-height: 1.45;
}
#command-dock .gev-voice-error-hint {
margin-top: 0.5rem;
color: rgba(255, 196, 203, 0.58);
font-family: var(--font-mono);
font-size: 0.52rem;
line-height: 1.45;
letter-spacing: 0.035rem;
}
/* The compact dock leaves both lower corners free for required attribution and telemetry. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits,
body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minimal'].active) #cesium-credits {
right: auto;
bottom: 0.75rem;
left: 1.5rem;
width: auto !important;
max-width: calc(50vw - 15rem);
text-align: left;
}
#intel-hud[data-variant='tactical'] .hud-bottom-left,
#intel-hud[data-variant='tactical'] .hud-bottom-right,
#intel-hud[data-variant='operator'] .hud-bottom-left,
#intel-hud[data-variant='operator'] .hud-bottom-right {
bottom: calc(2vh + 6.5rem);
}
#intel-hud[data-variant='operator'] .hud-bottom-bar {
bottom: calc(2vh + 9.8rem);
width: min(48vw, 680px);
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 5.4rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 9rem);
}
@media (max-width: 900px) {
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits,
body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minimal'].active) #cesium-credits {
bottom: calc(2vh + 5rem);
left: 0.75rem;
max-width: calc(100vw - 1.5rem);
}
#command-dock .gev-voice-error-tray {
width: min(24rem, calc(100vw - 1.5rem));
}
}
@media (max-width: 620px) {
#command-dock {
--dock-side-compact: 3.25rem;
--dock-voice-width: 8.75rem;
}
#command-dock .panel-title,
#command-dock .location-toolbar-label {
max-width: 2.3rem;
overflow: hidden;
text-overflow: ellipsis;
}
#left-panel-stack {
bottom: calc(50vh + 8px);
}
}
+301
View File
@@ -0,0 +1,301 @@
/* ── Unified Command Dock ────────────────────── */
#command-dock {
position: fixed;
right: auto;
bottom: 2vh;
left: 50%;
z-index: 145;
display: grid;
grid-template-areas: 'locations voice presets';
grid-template-columns: minmax(300px, 1fr) minmax(230px, 270px) minmax(390px, 1.15fr);
align-items: stretch;
width: min(94vw, 1240px);
padding: 6px;
overflow: visible;
background: rgba(9, 18, 27, 0.84);
border: 1px solid rgba(123, 189, 211, 0.28);
border-radius: 12px;
box-shadow:
0 12px 36px rgba(0, 0, 0, 0.58),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 0 28px rgba(0, 212, 255, 0.06);
backdrop-filter: blur(28px) saturate(1.35);
-webkit-backdrop-filter: blur(28px) saturate(1.35);
transform: translateX(-50%);
transition: opacity var(--transition-smooth), visibility var(--transition-smooth);
}
#command-dock > #location-bar,
#command-dock > #control-panel,
#command-dock > #gev-voice-control {
position: relative;
inset: auto;
width: auto;
max-width: none;
transform: none;
}
#command-dock > #location-bar {
grid-area: locations;
min-width: 0;
border-right: 1px solid rgba(123, 189, 211, 0.16);
}
#command-dock > #control-panel {
grid-area: presets;
min-width: 0;
border-left: 1px solid rgba(123, 189, 211, 0.16);
}
#command-dock > #gev-voice-control {
grid-area: voice;
z-index: 2;
align-self: stretch;
justify-content: center;
min-width: 0;
margin: -14px 0;
padding: 19px 14px 12px;
border-color: rgba(0, 212, 255, 0.36);
border-radius: 12px;
background: rgba(8, 31, 42, 0.94);
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.56),
0 0 26px rgba(0, 212, 255, 0.12),
0 0 0 1px rgba(0, 212, 255, 0.08) inset;
}
#command-dock .gev-voice-kicker {
position: absolute;
top: 6px;
right: 14px;
left: 14px;
overflow: hidden;
color: rgba(190, 221, 230, 0.52);
font-family: var(--font-mono);
font-size: 7px;
letter-spacing: 1.5px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
#command-dock #gev-voice-button {
width: 78px;
height: 42px;
}
#command-dock .gev-voice-readout {
flex: 1;
text-align: left;
}
#command-dock #gev-voice-detail {
max-width: 110px;
}
#command-dock .panel-glow {
display: none;
}
#command-dock .panel-inner,
#command-dock .location-inner,
#command-dock #control-panel.collapsed .panel-inner,
#command-dock #location-bar.collapsed .location-inner {
height: 100%;
padding: 8px 14px;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#command-dock .panel-header,
#command-dock .location-toolbar,
#command-dock #control-panel.collapsed .panel-header {
min-height: 18px;
margin: 0 0 6px;
padding: 0;
}
#command-dock .panel-title,
#command-dock .location-toolbar-label {
font-size: 8px;
letter-spacing: 1.6px;
color: rgba(190, 221, 230, 0.58);
}
#command-dock .panel-collapse-btn,
#command-dock .panel-mini-status,
#command-dock .location-mini-status {
display: none !important;
}
#command-dock #control-panel.collapsed .button-grid,
#command-dock #location-bar.collapsed .location-city-row {
display: flex !important;
}
#command-dock .button-grid {
gap: 6px;
}
#command-dock .style-btn {
min-width: 52px;
gap: 4px;
padding: 9px 8px 7px;
border-radius: 7px;
}
#command-dock .btn-icon {
font-size: 16px;
}
#command-dock .btn-label {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
#command-dock .btn-key {
top: 3px;
right: 4px;
font-size: 7px;
}
#command-dock .location-city-row {
min-width: 0;
gap: 8px;
}
#command-dock .location-search-wrap {
order: -1;
flex: 0 1 190px;
min-width: 132px;
padding-right: 8px;
border-right: 1px solid rgba(123, 189, 211, 0.14);
}
#command-dock .search-toggle-btn {
width: 30px;
height: 30px;
border-radius: 6px;
}
#command-dock #location-search,
#command-dock #location-search.expanded {
width: 100%;
min-width: 0;
padding: 6px 8px;
font-size: 11px;
}
#command-dock .location-pills {
flex: 1;
min-width: 0;
}
#command-dock .location-pill {
padding: 7px 10px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
}
#command-dock .poi-row-container.expanded {
margin-bottom: 4px;
}
/* Keep required map attribution clear of the full-width command surface. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
bottom: calc(2vh + 7rem);
}
body:not(.ui-clean-view):not(.recording-mode):has(#intel-hud[data-variant='minimal'].active) #cesium-credits {
right: 36px;
left: auto;
width: min(340px, calc(100vw - 72px)) !important;
text-align: right;
}
body.ui-clean-view #command-dock,
body.recording-mode #command-dock {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
body.ui-clean-view #cesium-credits,
body.recording-mode #cesium-credits {
right: auto;
bottom: 36px;
left: 36px;
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 7.5rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 11.5rem);
}
@media (max-width: 980px) {
#command-dock {
grid-template-areas:
'locations voice'
'presets presets';
grid-template-columns: minmax(0, 1fr) minmax(170px, 210px);
width: calc(100vw - 24px);
}
#command-dock > #location-bar {
border-right: 1px solid rgba(123, 189, 211, 0.16);
border-bottom: 1px solid rgba(123, 189, 211, 0.16);
}
#command-dock > #control-panel {
border-top: 1px solid rgba(123, 189, 211, 0.16);
border-left: 0;
}
#command-dock > #gev-voice-control {
margin: -10px 0 0;
}
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
bottom: calc(2vh + 9.5rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 12rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 16rem);
}
}
@media (max-width: 720px) {
#command-dock {
bottom: 8px;
}
#command-dock .location-search-wrap {
flex-basis: 150px;
}
#command-dock .panel-inner,
#command-dock .location-inner,
#command-dock #control-panel.collapsed .panel-inner,
#command-dock #location-bar.collapsed .location-inner {
padding: 7px 9px;
}
#left-panel-stack {
bottom: calc(50vh + 8px);
}
}
File diff suppressed because it is too large. Load diff
+315
View File
@@ -0,0 +1,315 @@
/* ── First-run mission launcher ───────────────────────────────────
Compact and non-modal: it gives a new visitor a meaningful starting
choice without blocking manual map interaction or spending API quota. */
#first-run-launcher {
position: fixed;
z-index: 175;
top: 50%;
left: 50%;
width: min(34rem, calc(100vw - 2rem));
/* Five tiles do not fit a short landscape phone (667 x 390), where the card
would otherwise render past BOTH ends of the viewport. Cap it to the
viewport and let the mission list scroll inside — the heading, the checkbox
and the status line must stay reachable at every height. */
max-height: calc(100vh - 1.5rem);
max-height: calc(100dvh - 1.5rem);
display: flex;
flex-direction: column;
padding: 1.15rem;
overflow: hidden;
color: var(--text-primary);
background:
linear-gradient(145deg, rgba(10, 27, 37, 0.97), rgba(7, 13, 22, 0.95)),
rgba(7, 17, 25, 0.96);
border: 1px solid rgba(54, 220, 255, 0.42);
border-radius: 0.65rem;
box-shadow:
0 1.4rem 4.5rem rgba(0, 0, 0, 0.72),
0 0 2.5rem rgba(0, 212, 255, 0.09),
inset 0 0 0 1px rgba(255, 255, 255, 0.025);
backdrop-filter: blur(28px) saturate(1.25);
-webkit-backdrop-filter: blur(28px) saturate(1.25);
opacity: 0;
transform: translate(-50%, calc(-50% + 0.8rem)) scale(0.985);
transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
/* Inert until revealed AND again while fading out, so a click during the
dismiss animation cannot launch a second mission behind the fade. */
pointer-events: none;
}
/* An AUTHOR `display` on this id beats the UA's `[hidden] { display: none }` in
the cascade, so the flex above would otherwise leave the card in the
accessibility tree from page parse until it is revealed — or, on a share
link, until it is removed. Restore the attribute's meaning explicitly. */
#first-run-launcher[hidden] {
display: none;
}
#first-run-launcher.visible {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
.first-run-scanline {
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(54, 220, 255, 0.8), transparent);
box-shadow: 0 0 1rem rgba(54, 220, 255, 0.45);
pointer-events: none;
}
.first-run-header,
.first-run-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: rgba(171, 210, 220, 0.6);
font-family: var(--font-mono);
font-size: 0.58rem;
letter-spacing: 0.1rem;
text-transform: uppercase;
}
.first-run-kicker { color: rgba(54, 220, 255, 0.86); }
#first-run-launcher h2 {
margin-top: 0.6rem;
font-family: var(--font-mono);
font-size: clamp(1.25rem, 2.4vw, 1.75rem);
font-weight: 500;
letter-spacing: -0.045rem;
}
#first-run-description {
max-width: 29rem;
margin-top: 0.3rem;
color: rgba(215, 231, 235, 0.66);
font-size: 0.78rem;
line-height: 1.5;
}
.first-run-choices {
display: grid;
gap: 0.42rem;
margin-top: 1rem;
/* The one part of the card allowed to scroll when the viewport is too short.
min-height:0 is what actually lets this flex child shrink below its content. */
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scroll-behavior: smooth;
/* A visible, non-overlaying affordance that the list continues. */
scrollbar-width: thin;
scrollbar-color: rgba(54, 220, 255, 0.45) transparent;
scrollbar-gutter: stable;
}
.first-run-choices::-webkit-scrollbar { width: 6px; }
.first-run-choices::-webkit-scrollbar-thumb {
background: rgba(54, 220, 255, 0.45);
border-radius: 3px;
}
.first-run-choices button {
display: grid;
grid-template-columns: 1.7rem 1fr 1.3rem;
align-items: center;
gap: 0.7rem;
width: 100%;
min-height: 3.55rem;
padding: 0.6rem 0.7rem;
color: rgba(225, 244, 248, 0.92);
text-align: left;
background: rgba(29, 69, 81, 0.18);
border: 1px solid rgba(100, 200, 220, 0.18);
border-radius: 0.42rem;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.first-run-choices button:hover,
.first-run-choices button:focus-visible {
background: rgba(25, 151, 178, 0.16);
border-color: rgba(54, 220, 255, 0.62);
outline: none;
transform: translateX(2px);
}
/* aria-disabled, not :disabled — the JS keeps the buttons focusable while a
mission runs so the keyboard is never dumped out of the launcher mid-flight. */
.first-run-choices button[aria-disabled='true'] {
cursor: wait;
opacity: 0.48;
transform: none;
pointer-events: none;
}
.first-run-choices button > .material-symbols-outlined:first-child {
color: #55dff5;
font-size: 1.35rem;
}
.first-run-choices button > span:nth-child(2) {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.first-run-choices strong {
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08rem;
}
.first-run-choices small {
color: rgba(198, 220, 226, 0.55);
font-size: 0.66rem;
}
.first-run-arrow {
color: rgba(54, 220, 255, 0.4);
font-size: 1rem;
transition: transform var(--transition-fast), color var(--transition-fast);
}
.first-run-choices button:hover .first-run-arrow,
.first-run-choices button:focus-visible .first-run-arrow {
color: rgba(54, 220, 255, 0.9);
transform: translateX(2px);
}
/* Ordered after the shared button rule rather than shouting !important at it. */
.first-run-choices button.first-run-explore {
background: rgba(255, 255, 255, 0.025);
}
.first-run-footer {
margin-top: 0.85rem;
font-size: 0.48rem;
letter-spacing: 0.055rem;
}
.first-run-suppress {
display: flex;
align-items: center;
gap: 0.4rem;
color: rgba(199, 225, 231, 0.66);
text-transform: none;
font-size: 0.62rem;
letter-spacing: 0.02rem;
cursor: pointer;
}
.first-run-suppress input {
width: 0.82rem;
height: 0.82rem;
margin: 0;
accent-color: #36dcff;
cursor: pointer;
}
.first-run-suppress:hover { color: rgba(225, 244, 248, 0.92); }
/* The live region carries mission progress and failures, so it owns a full row
and reserves its height — otherwise the card resizes as it speaks. */
.first-run-note {
min-height: 1.1rem;
margin-top: 0.5rem;
color: rgba(199, 225, 231, 0.62);
font-family: var(--font-mono);
font-size: 0.62rem;
line-height: 1.35;
}
#first-run-launcher[data-state='loading'] .first-run-note {
color: rgba(54, 220, 255, 0.85);
}
/* Every surface that claims the screen hides the launcher. This list is the CSS
half of the ESC arbitration: src/firstRunExperience.js watches these same
classes and makes the launcher step aside, so a hidden card is never left
holding a keyboard handler. Keep the two lists in step —
EXCLUSIVE_SURFACE_CLASSES pins them together. */
body.ui-clean-view #first-run-launcher,
body.recording-mode #first-run-launcher,
body.cockpit-mode #first-run-launcher,
body.scene-playback-mode #first-run-launcher {
display: none;
}
@media (max-width: 620px) {
#first-run-launcher {
top: auto;
bottom: 5.8rem;
padding: 0.9rem;
transform: translate(-50%, 0.8rem) scale(0.985);
}
#first-run-launcher.visible { transform: translate(-50%, 0) scale(1); }
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows tighten
and the redundant "ESC to dismiss" hint (no ESC key on a phone) drops. */
#first-run-launcher h2 { margin-top: 0.45rem; }
#first-run-description { margin-top: 0.25rem; font-size: 0.72rem; }
.first-run-choices { margin-top: 0.75rem; }
.first-run-choices button { min-height: 3rem; padding: 0.45rem 0.6rem; }
.first-run-choices small { font-size: 0.62rem; }
.first-run-footer > span:last-child { display: none; }
}
/* Short viewports — landscape phones, small laptop windows. The chrome tightens
so the mission list keeps a usable share of the height. The intro line STAYS:
it is the only copy that says what this thing is, and with four tiles it
fits. (It was dropped here while there were five; the tile that went is
exactly why it can come back.) */
@media (max-height: 620px) {
#first-run-launcher { padding: 0.75rem 0.85rem; }
#first-run-launcher h2 { margin-top: 0.3rem; font-size: 1.1rem; }
#first-run-description { margin-top: 0.2rem; font-size: 0.7rem; line-height: 1.4; }
.first-run-choices { margin-top: 0.55rem; }
.first-run-choices button { min-height: 2.7rem; padding: 0.36rem 0.6rem; }
.first-run-choices strong { font-size: 0.68rem; }
.first-run-choices small { font-size: 0.6rem; }
.first-run-footer { margin-top: 0.5rem; }
.first-run-note { margin-top: 0.3rem; min-height: 0; }
}
/* A soft edge on the list, so a tile cut by the fold reads as "more below"
rather than as a rendering seam. Gated on `data-scrollable`, which the JS sets
only when the list ACTUALLY overflows — a fade on a list that fits would
promise content that is not there, which is the opposite of the affordance
it is meant to be. Decorative only: the list scrolls under it. */
.first-run-choices[data-scrollable='true'] {
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.4rem), transparent 100%);
mask-image: linear-gradient(to bottom, #000 calc(100% - 1.4rem), transparent 100%);
}
/* Scrolled to the end, there is nothing left to promise, so the fade lifts.
Browsers without scroll-driven animations keep the static fade, which is
still honest — there IS more list, it is just above rather than below. */
@supports (animation-timeline: scroll(self block)) {
.first-run-choices[data-scrollable='true'] {
animation: first-run-scroll-fade linear both;
animation-timeline: scroll(self block);
animation-range: contain 80% contain 100%;
}
}
@keyframes first-run-scroll-fade {
to {
-webkit-mask-image: linear-gradient(to bottom, #000 100%, transparent 100%);
mask-image: linear-gradient(to bottom, #000 100%, transparent 100%);
}
}
@media (prefers-reduced-motion: reduce) {
#first-run-launcher,
.first-run-choices button,
.first-run-arrow { transition: none; }
/* Scrolling a tile into view must not animate either. */
.first-run-choices { scroll-behavior: auto; }
}
+611
View File
@@ -0,0 +1,611 @@
/* ═══════════════════════════════════════════════
GOD'S EYE VIEW — Dark Sci-Fi UI
Apple meets Blade Runner
═══════════════════════════════════════════════ */
:root {
--bg-dark: #0a0a0f;
--glass-bg: rgba(12, 12, 20, 0.72);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-border-hover: rgba(255, 255, 255, 0.15);
--accent: #00d4ff;
--accent-dim: rgba(0, 212, 255, 0.15);
--accent-glow: rgba(0, 212, 255, 0.4);
--text-primary: #e8eaed;
--text-secondary: rgba(232, 234, 237, 0.5);
--text-dim: rgba(232, 234, 237, 0.3);
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--panel-radius: 16px;
--btn-radius: 10px;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--left-stack-x: 52px;
--right-rail-x: 52px;
--left-stack-top: 26vh;
--left-stack-gap: 72px;
--left-collapsed-width: 176px;
--display-panel-expanded-width: 272px;
--cockpit-radio-collapsed-width: 148px;
--cockpit-radio-expanded-width: 232px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Keyboard focus is an app-wide state and must survive selected/active button
skins. Individual controls can choose an inset/outset offset, but no local
`outline: none` rule may erase the ring. */
:where(
button,
a[href],
input,
select,
textarea,
summary,
[tabindex],
[role='button'],
[role='checkbox'],
[role='combobox'],
[role='link'],
[role='listbox'],
[role='menuitem'],
[role='menuitemcheckbox'],
[role='menuitemradio'],
[role='option'],
[role='radio'],
[role='searchbox'],
[role='slider'],
[role='spinbutton'],
[role='switch'],
[role='tab'],
[role='textbox'],
[role='treeitem']
):focus-visible {
outline: 2px solid var(--text-primary) !important;
outline-offset: 2px;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-dark);
font-family: var(--font-sans);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* ── Cesium Container ─────────────────────────── */
#cesiumContainer {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* Unified world-overlay host: detection uses one host-owned blend surface
beneath the shared text/card canvas; both share one frame path. The root
holds ONLY the card canvas — it is a stacking context (z-index below), and
a canvas that blends against the scene cannot live inside one. */
#world-overlay-root {
position: absolute;
inset: 0;
z-index: 6;
overflow: hidden;
pointer-events: none;
}
#world-overlay-detection-surface,
#world-overlay-canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Scope mask — the explicit circular viewport treatment (see
src/scopeMask.js for the emergent-artifact history). z2 keeps it above
the WebGL scene but under the z5 detection surface, preserving the old
compositing order where detection's blend rode the masked scene. */
#scope-mask {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
/* z1 like the celestial ring; the ring is appended LATER so DOM order
keeps it ABOVE the mask — matching the old stacking, where the DOM
ring rode above the in-scene scope artifact. Detection (z5) and the
overlay root (z6) stay above both. */
z-index: 1;
pointer-events: none;
}
/* The detection surface is host-owned but parented into #cesiumContainer
(position:absolute, z-index:auto — NOT a stacking context) so its
`mix-blend-mode: screen` composites against the WebGL scene. z5 keeps it
under the z6 root that carries the shared card canvas; do not move this
surface inside #world-overlay-root, which would isolate the blend. */
#world-overlay-detection-surface {
z-index: 5;
}
/* Canvas cards retain their spatial renderer; this parallel focusable list
exposes only the currently painted actionable targets to assistive tech. */
.world-overlay-accessibility {
position: fixed;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ── Source-backed cockpit weather ─────────────
Low-resolution GPU cloud pass composited only inside the cockpit keyhole. */
#cockpit-cloud-effects {
position: fixed;
inset: 0;
z-index: 7;
display: none;
width: 100vw;
height: 100vh;
opacity: 0;
pointer-events: none;
clip-path: circle(min(40vw, 52vh) at 50% 50%);
filter: blur(0.7px) saturate(0.72) contrast(1.08);
transform: translateZ(0);
transition: opacity 900ms ease;
}
body.cockpit-mode #cockpit-cloud-effects.active {
display: block;
opacity: 0.86;
}
/* ── Full-globe celestial keyhole ring ─────── */
.celestial-ring-overlay {
position: absolute;
inset: 0;
/* Above the z1 scope mask regardless of DOM order (the ring is built by
StyleManager before the scope installs) — the ring always rode above
the scope treatment. Detection (z5) and the overlay root (z6) stay on
top of both. */
z-index: 2;
overflow: hidden;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 300ms ease, visibility 300ms ease;
}
.celestial-ring-overlay.visible {
opacity: 1;
visibility: visible;
}
.celestial-ring-overlay.disabled {
display: none;
}
.celestial-ring-canvas {
position: absolute;
inset: 0;
display: block;
transform-origin: 50% 50%;
will-change: transform, opacity;
}
.celestial-ring-outline {
position: absolute;
box-sizing: border-box;
border: 0.8px solid rgba(151, 218, 232, 0.095);
border-radius: 50%;
will-change: width, height;
}
.celestial-marker {
position: absolute;
z-index: 2;
transform: translate(-50%, -50%);
font-family: 'Material Symbols Outlined';
font-size: 20px;
font-weight: 400;
font-style: normal;
line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
transition: opacity 120ms linear;
user-select: none;
}
.celestial-sun {
color: rgba(231, 194, 72, 0.92);
text-shadow: 0 0 7px rgba(231, 194, 72, 0.72), 0 0 18px rgba(231, 194, 72, 0.3);
}
.celestial-moon {
color: rgba(55, 211, 239, 0.84);
text-shadow: 0 0 7px rgba(55, 211, 239, 0.65), 0 0 17px rgba(55, 211, 239, 0.25);
}
#celestial-toggle:disabled {
cursor: not-allowed;
opacity: 0.42;
}
/* Hide Cesium default UI elements */
.cesium-viewer-toolbar,
.cesium-viewer-animationContainer,
.cesium-viewer-timelineContainer,
.cesium-viewer-bottom,
.cesium-viewer-fullscreenContainer,
.cesium-viewer-vrContainer,
.cesium-viewer-geocoderContainer,
.cesium-viewer-selectionIndicatorContainer,
.cesium-viewer-infoBoxContainer {
display: none !important;
}
/* Data-source attribution (Google Maps / 3D Tiles, etc.).
Required by the Google Maps Platform Terms of Service — keep it visible.
Styled to be unobtrusive but legible against the dark UI. */
#cesium-credits {
position: fixed;
/* Bottom-LEFT corner, mirrored from the GEV mic pill (right:36/bottom:36) so the two bottom
corners are symmetric; sits below the coordinate HUD (.hud-bottom-left, bottom:180). Was
dead-center at the bottom, tucked under the LOCATIONS/STYLE panels. Must stay visible (Google
/Cesium attribution ToS). */
bottom: 36px;
left: 36px;
z-index: 90;
max-width: 90vw;
/* Keep the Google logo + "Data attribution" link on ONE line — they were wrapping to a second
row (looked like it was falling over). nowrap + a roomy max-width holds them inline. */
white-space: nowrap;
pointer-events: auto;
font-family: var(--font-mono);
font-size: 10px;
color: rgba(232, 234, 237, 0.55);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
}
#cesium-credits .cesium-credit-logoContainer,
#cesium-credits .cesium-credit-textContainer,
#cesium-credits .cesium-credit-expand-link {
display: inline-block;
vertical-align: middle;
}
#cesium-credits a,
#cesium-credits .cesium-credit-expand-link {
color: rgba(232, 234, 237, 0.7);
}
/* Cesium's stock attribution lightbox grows with every registered data source.
Keep the title and close affordance fixed while the complete, unabridged
credit list wraps and scrolls inside a compact panel. */
.cesium-credit-lightbox-overlay {
z-index: 200 !important;
}
.cesium-credit-lightbox {
display: flex;
flex-direction: column;
width: 470px;
max-width: calc(100vw - 2rem);
max-height: min(70dvh, 36rem);
overflow: hidden;
}
.cesium-credit-lightbox > ul {
flex: 1;
min-height: 0;
margin: 0;
padding: 8px 20px 14px 40px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
font-size: 12px !important;
line-height: 1.35;
white-space: normal;
}
.cesium-credit-lightbox > ul > li {
padding-bottom: 5px;
overflow-wrap: anywhere;
}
@media (max-width: 575px) {
.cesium-credit-lightbox {
width: 100% !important;
max-width: none !important;
height: 100% !important;
max-height: 100dvh !important;
margin: 0 !important;
}
}
/* Clean-view / recording: the Google + Cesium attribution MUST stay visible.
Google Maps Platform ToS and Cesium's terms require the credit whenever the
content is displayed — and these are exactly the modes used to record demos
and launch videos, so hiding #cesium-credits here (as it was before) shipped
non-compliant footage. The full credit line — Cesium/Ion logo, "Google Maps",
and the "Data attribution" link that opens the per-layer license popover —
stays on screen; it's already styled small and unobtrusive above. The GEV
chrome (panels/HUD) still fades via its own rules; only the required notice
is exempt. (Note: Cesium re-applies inline styles on the credit elements each
frame, so the on-screen credit content is owned by Cesium's update loop, not
CSS overrides — we deliberately don't fight it here.) */
#gev-voice-control {
position: fixed;
right: 36px;
bottom: 36px;
/* Z ladder: panels 100-139, voice pill 150, toast 200, clean-view-exit 300 */
z-index: 150;
display: flex;
align-items: center;
gap: 10px;
min-width: 214px;
padding: 9px 14px;
color: var(--text-primary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 999px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 0 20px rgba(0, 212, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
font-family: var(--font-mono);
pointer-events: auto;
}
#gev-voice-button {
width: 72px;
height: 34px;
flex: 0 0 auto;
color: var(--accent);
background: rgba(0, 212, 255, 0.06);
border: 1px solid rgba(0, 212, 255, 0.28);
border-radius: var(--btn-radius);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0;
cursor: pointer;
}
#gev-voice-button:hover {
color: #ffffff;
border-color: var(--accent);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.22);
}
.gev-voice-readout {
min-width: 0;
line-height: 1.2;
text-align: right;
}
#gev-voice-status {
font-size: 10px;
color: var(--accent);
}
#gev-voice-detail {
max-width: 132px;
margin-top: 2px;
overflow: hidden;
color: var(--text-secondary);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
#gev-voice-control[data-status='listening'] {
border-color: rgba(92, 255, 198, 0.45);
}
#gev-voice-control[data-status='listening'] #gev-voice-status {
color: #5cffc6;
}
#gev-voice-control[data-status='executing'] {
border-color: rgba(255, 220, 120, 0.55);
}
#gev-voice-control[data-status='executing'] #gev-voice-status {
color: #ffdc78;
}
#gev-voice-control[data-status='error'] {
align-items: flex-start;
width: min(440px, calc(100vw - 72px));
border-color: rgba(255, 92, 110, 0.6);
/* Multi-line error block reads as a panel, not a capsule */
border-radius: var(--panel-radius);
}
#gev-voice-control[data-status='error'] #gev-voice-status {
color: #ff5c6e;
}
#gev-voice-control[data-status='error'] .gev-voice-readout {
flex: 1;
text-align: left;
}
#gev-voice-control[data-status='error'] #gev-voice-detail {
max-width: none;
overflow: visible;
color: #ffb4bd;
line-height: 1.45;
text-overflow: clip;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
/* ── Title Bar ────────────────────────────────── */
#title-bar {
position: fixed;
top: 32px;
left: 36px;
z-index: 100;
pointer-events: none;
user-select: none;
}
#title-bar .title-glow {
position: absolute;
top: -20px;
left: -20px;
width: 200px;
height: 80px;
background: radial-gradient(ellipse, var(--accent-dim) 0%, transparent 70%);
filter: blur(20px);
opacity: 0.6;
}
#title-bar h1 {
display: flex;
align-items: center;
gap: 12px;
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
letter-spacing: 8px;
color: var(--text-primary);
text-shadow: 0 0 30px var(--accent-glow);
position: relative;
}
#title-bar .title-logo {
position: relative;
display: block;
width: 52px;
height: auto;
filter: drop-shadow(0 0 12px rgba(0, 246, 255, 0.5));
}
#title-bar .title-logo::before,
#title-bar .title-logo::after {
--radio-broadcast-opacity: .28;
content: '';
position: absolute;
z-index: 1;
left: 50%;
top: -9px;
width: 13px;
height: 7px;
border: 1px solid var(--accent);
border-bottom: 0;
border-radius: 999px 999px 0 0;
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transform-origin: 50% 100%;
}
#title-bar .title-logo::after {
--radio-broadcast-opacity: .17;
top: -15px;
width: 27px;
height: 13px;
}
#title-bar.radio-broadcasting .title-logo::before,
#title-bar.radio-broadcasting .title-logo::after {
opacity: var(--radio-broadcast-opacity);
animation: title-radio-broadcast 1.8s ease-in-out infinite;
}
#title-bar.radio-broadcasting .title-logo::after { animation-delay: .28s; }
@keyframes title-radio-broadcast {
0%, 100% {
opacity: calc(var(--radio-broadcast-opacity) * .55);
transform: translateX(-50%) scale(.94);
}
50% {
opacity: var(--radio-broadcast-opacity);
transform: translateX(-50%) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
#title-bar.radio-broadcasting .title-logo::before,
#title-bar.radio-broadcasting .title-logo::after {
animation: none;
}
}
.brand-logo {
aspect-ratio: 775 / 520;
overflow: visible;
pointer-events: none;
}
.brand-logo > img,
.brand-logo > svg {
display: block;
width: 100%;
height: 100%;
}
#title-bar .title-accent,
.loader-content .title-accent {
color: var(--accent);
font-weight: 300;
}
#title-bar .subtitle {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 5px;
color: var(--text-dim);
margin-top: 8px;
text-transform: uppercase;
}
/* ── Style Indicator ──────────────────────────── */
#style-indicator {
position: fixed;
top: 32px;
right: 36px;
z-index: 100;
pointer-events: none;
user-select: none;
text-align: right;
}
#style-indicator .indicator-label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 3px;
color: var(--text-dim);
text-transform: uppercase;
}
#style-indicator .indicator-value {
display: block;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 500;
letter-spacing: 5px;
color: var(--accent);
margin-top: 4px;
text-shadow: 0 0 20px var(--accent-glow);
transition: var(--transition-smooth);
}
File diff suppressed because it is too large. Load diff
+292
View File
@@ -0,0 +1,292 @@
/* ── Location Bar ────────────────────────────── */
#location-bar {
position: fixed;
bottom: 130px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
width: auto;
max-width: 720px;
}
#location-bar .panel-glow {
position: absolute;
inset: -20px;
background: radial-gradient(ellipse at center, var(--accent-dim) 0%, transparent 60%);
filter: blur(20px);
opacity: 0.25;
pointer-events: none;
}
.location-inner {
position: relative;
display: flex;
flex-direction: column;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 8px 12px;
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.location-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
padding: 0 4px;
}
.location-toolbar-label {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 2px;
color: var(--text-dim);
}
.location-mini-status {
display: none;
gap: 2px;
padding: 0 4px 4px;
}
#location-bar.collapsed .location-mini-status {
display: grid;
}
.location-mini-city {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.8px;
color: #bdefff;
}
.location-mini-poi {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1px;
color: var(--text-secondary);
max-width: 190px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-pills {
display: flex;
gap: 6px;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
}
.location-pills::-webkit-scrollbar {
display: none;
}
.location-pill {
padding: 6px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
outline: none;
white-space: nowrap;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.3px;
}
.location-pill:focus-visible,
.poi-pill:focus-visible,
.search-toggle-btn:focus-visible,
#location-search:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.location-pill:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--glass-border-hover);
transform: translateY(-1px);
}
.location-pill.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
}
/* ── Expandable POI Row ──────────────────────── */
.poi-row-container {
display: flex;
gap: 6px;
justify-content: center;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 250ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 200ms ease,
padding 250ms cubic-bezier(0.4, 0, 0.2, 1);
padding: 0 4px;
}
.poi-row-container.expanded {
max-height: 50px;
opacity: 1;
padding: 4px 4px 0;
}
.location-bar-divider {
height: 0;
border: none;
border-top: 1px solid var(--glass-border);
margin: 0 4px;
opacity: 0;
transition: opacity 200ms ease, margin 250ms ease;
}
.location-bar-divider.visible {
opacity: 1;
margin: 6px 4px;
}
.location-city-row {
display: flex;
align-items: center;
gap: 8px;
}
.poi-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
outline: none;
white-space: nowrap;
flex-shrink: 0;
}
.poi-pill:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--glass-border-hover);
transform: translateY(-1px);
}
.poi-pill.active {
background: var(--accent-dim);
border-color: var(--accent);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
}
.poi-pill-name {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.3px;
}
.poi-pill.active .poi-pill-name {
color: var(--accent);
}
.poi-pill-key {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1px;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 2px 5px;
border-radius: 3px;
flex-shrink: 0;
}
.poi-pill.active .poi-pill-key {
color: var(--accent);
background: rgba(0, 212, 255, 0.12);
}
/* ── Location Search ─────────────────────────── */
.location-search-wrap {
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
}
.search-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast);
outline: none;
font-size: 14px;
flex-shrink: 0;
}
.search-toggle-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--glass-border-hover);
}
#location-search {
width: 0;
padding: 0;
border: none;
background: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 13px;
letter-spacing: 0.3px;
transition: width var(--transition-smooth), padding var(--transition-smooth);
caret-color: var(--accent);
}
#location-search.expanded {
width: 180px;
padding: 6px 10px;
}
#location-search::placeholder {
color: var(--text-dim);
font-style: italic;
}
#location-search.searching {
opacity: 0.5;
}
+364
View File
@@ -0,0 +1,364 @@
/* ── Safe Frame Overlay ─────────────────────── */
#safe-frame-overlay {
position: fixed;
inset: 0;
z-index: 95;
pointer-events: none;
display: none;
}
#safe-frame-overlay.active {
display: block;
}
#safe-frame-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(90vw, calc(90vh * (16 / 9)));
aspect-ratio: 16 / 9;
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.35),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
#safe-frame-overlay.ratio-9-16 #safe-frame-box {
width: min(58vh, 76vw);
aspect-ratio: 9 / 16;
}
/* Rule-of-thirds guides */
#safe-frame-box::before,
#safe-frame-box::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
}
#safe-frame-box::before {
background:
linear-gradient(to right,
transparent 33.333%,
rgba(255, 255, 255, 0.2) 33.333%,
rgba(255, 255, 255, 0.2) 33.7%,
transparent 33.7%,
transparent 66.333%,
rgba(255, 255, 255, 0.2) 66.333%,
rgba(255, 255, 255, 0.2) 66.7%,
transparent 66.7%
),
linear-gradient(to bottom,
transparent 33.333%,
rgba(255, 255, 255, 0.2) 33.333%,
rgba(255, 255, 255, 0.2) 33.7%,
transparent 33.7%,
transparent 66.333%,
rgba(255, 255, 255, 0.2) 66.333%,
rgba(255, 255, 255, 0.2) 66.7%,
transparent 66.7%
);
}
/* ── Orbit Indicator ─────────────────────────── */
#orbit-indicator {
position: fixed;
top: 70px;
right: 36px;
z-index: 100;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 2px;
color: var(--accent);
text-shadow: 0 0 12px var(--accent-glow);
pointer-events: none;
user-select: none;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-smooth), visibility var(--transition-smooth);
}
#orbit-indicator.active {
opacity: 1;
visibility: visible;
}
.orbit-icon {
font-size: 13px;
vertical-align: middle;
margin-right: 2px;
}
/* ── Intelligence HUD Overlay ────────────────── */
#intel-hud {
position: fixed;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.4s ease, visibility 0.4s ease;
--hud-color: rgba(0, 255, 255, 0.6);
--hud-glow: rgba(0, 255, 255, 0.4);
--hud-border: rgba(0, 255, 255, 0.15);
}
#intel-hud.active {
opacity: 1;
visibility: visible;
}
#intel-hud * {
font-family: var(--font-mono);
color: var(--hud-color);
text-shadow: 0 0 4px var(--hud-glow);
text-transform: uppercase;
line-height: 1.6;
}
.hud-top-bar,
.hud-bottom-bar {
display: none;
position: absolute;
left: 50%;
transform: translateX(-50%);
width: min(980px, calc(100vw - 120px));
border: 1px solid var(--hud-border);
background: rgba(0, 0, 0, 0.34);
padding: 4px 10px;
font-size: 10px;
letter-spacing: 1.4px;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
.hud-top-bar {
top: 70px;
}
.hud-bottom-bar {
bottom: 110px;
justify-content: center;
}
.hud-top-bar-center {
opacity: 0.84;
}
.hud-top-bar-left,
.hud-top-bar-right {
opacity: 0.6;
}
/* Corner blocks */
.hud-corner {
position: absolute;
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px;
}
.hud-top-left {
top: 120px;
left: 36px;
}
.hud-top-right {
top: 120px;
right: 36px;
}
.hud-bottom-left {
bottom: 180px;
left: 36px;
}
.hud-bottom-right {
bottom: 180px;
right: 36px;
}
.hud-bracket {
font-size: 22px;
line-height: 1;
opacity: 0.6;
}
.hud-content {
font-size: 13px;
letter-spacing: 2px;
}
.hud-ais-vessel {
margin-top: 6px;
max-width: 340px;
white-space: pre-line;
line-height: 1.35;
opacity: 0.64;
}
.hud-ais-vessel.active {
opacity: 0.95;
}
.hud-classification {
font-size: 12px;
letter-spacing: 2.5px;
font-weight: 600;
margin-bottom: 3px;
}
.hud-system {
font-size: 13px;
opacity: 0.8;
}
.hud-mode {
font-size: 16px;
font-weight: 700;
letter-spacing: 4px;
margin-top: 3px;
}
.hud-summary-wrap {
margin-top: 6px;
max-width: 420px;
}
.hud-summary-label {
font-size: 9px;
letter-spacing: 1.8px;
opacity: 0.55;
}
.hud-summary {
margin-top: 2px;
min-height: 16px;
font-size: 11px;
letter-spacing: 1.2px;
opacity: 0.88;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hud-rec {
font-size: 13px;
letter-spacing: 2px;
}
#hud-rec-dot {
color: #ff3333;
text-shadow: 0 0 6px rgba(255, 51, 51, 0.8);
font-size: 14px;
vertical-align: middle;
}
.hud-orbital {
font-size: 12px;
opacity: 0.7;
letter-spacing: 1.5px;
}
/* Edge annotations (vertical text) */
.hud-edge {
position: absolute;
font-size: 10px;
letter-spacing: 1.5px;
opacity: 0.5;
}
.hud-left-edge {
left: 36px;
top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: left center;
white-space: nowrap;
display: flex;
gap: 20px;
}
.hud-right-edge {
right: 36px;
top: 50%;
transform: translateY(-50%) rotate(90deg);
transform-origin: right center;
white-space: nowrap;
display: flex;
gap: 20px;
}
#intel-hud[data-variant='tactical'] .hud-top-bar,
#intel-hud[data-variant='tactical'] .hud-bottom-bar {
display: none;
}
#intel-hud[data-variant='operator'] .hud-top-bar,
#intel-hud[data-variant='operator'] .hud-bottom-bar {
display: flex;
}
#intel-hud[data-variant='operator'] .hud-top-bar {
top: max(10vh, 90px);
left: 50%;
right: auto;
width: min(52vw, 720px);
transform: translateX(-50%);
border-color: rgba(255, 255, 255, 0.24);
background: linear-gradient(90deg, rgba(0, 0, 0, 0.56), rgba(8, 26, 34, 0.5));
box-shadow: 0 0 24px rgba(0, 255, 255, 0.08);
}
#intel-hud[data-variant='tactical'] .hud-top-left,
#intel-hud[data-variant='tactical'] .hud-top-right,
#intel-hud[data-variant='operator'] .hud-top-left,
#intel-hud[data-variant='operator'] .hud-top-right {
top: max(14vh, 136px);
}
#intel-hud[data-variant='operator'] .hud-bottom-bar {
border-color: rgba(255, 255, 255, 0.2);
background: linear-gradient(90deg, rgba(0, 0, 0, 0.52), rgba(8, 22, 18, 0.52));
}
#intel-hud[data-variant='operator'] .hud-corner {
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: rgba(0, 0, 0, 0.36);
}
#intel-hud[data-variant='operator'] .hud-bracket {
display: none;
}
#intel-hud[data-variant='minimal'] .hud-top-right,
#intel-hud[data-variant='minimal'] .hud-bottom-right,
#intel-hud[data-variant='minimal'] .hud-left-edge,
#intel-hud[data-variant='minimal'] .hud-right-edge,
#intel-hud[data-variant='minimal'] .hud-classification,
#intel-hud[data-variant='minimal'] .hud-system {
display: none;
}
#intel-hud[data-variant='minimal'] .hud-top-left {
top: 88px;
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: 12vh;
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
display: flex;
bottom: calc(12vh + 4rem);
}
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px;
}
+355
View File
@@ -0,0 +1,355 @@
/* ============================================================
POWER UP — in-app key setup (dev server only, src/keySetup.js)
Same voice as the first-run launcher: glass card, cyan trim,
mono kickers. The chip retires once every key is configured.
============================================================ */
#key-setup-chip {
position: fixed;
right: 0.9rem;
bottom: 0.9rem;
z-index: 60;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
color: rgba(225, 244, 248, 0.92);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.09rem;
background: linear-gradient(145deg, rgba(10, 27, 37, 0.92), rgba(7, 13, 22, 0.9));
border: 1px solid rgba(54, 220, 255, 0.38);
border-radius: 0.42rem;
box-shadow:
0 0.4rem 1.6rem rgba(0, 0, 0, 0.5),
0 0 1.2rem rgba(0, 212, 255, 0.07);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
cursor: pointer;
transition: border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-fast);
}
#key-setup-chip:hover,
#key-setup-chip:focus-visible {
background: linear-gradient(145deg, rgba(14, 38, 51, 0.95), rgba(9, 18, 28, 0.93));
border-color: rgba(54, 220, 255, 0.7);
outline: none;
transform: translateY(-1px);
}
#key-setup-chip .material-symbols-outlined {
color: #55dff5;
font-size: 0.95rem;
}
#key-setup-chip[hidden] { display: none; }
#key-setup {
position: fixed;
z-index: 190; /* above the first-run launcher (175), below attribution (200) */
top: 50%;
left: 50%;
width: min(36rem, calc(100vw - 2rem));
max-height: calc(100vh - 1.5rem);
max-height: calc(100dvh - 1.5rem);
display: flex;
flex-direction: column;
padding: 1.15rem;
overflow: hidden;
color: var(--text-primary);
background:
linear-gradient(145deg, rgba(10, 27, 37, 0.97), rgba(7, 13, 22, 0.95)),
rgba(7, 17, 25, 0.96);
border: 1px solid rgba(54, 220, 255, 0.42);
border-radius: 0.65rem;
box-shadow:
0 1.4rem 4.5rem rgba(0, 0, 0, 0.72),
0 0 2.5rem rgba(0, 212, 255, 0.09),
inset 0 0 0 1px rgba(255, 255, 255, 0.025);
backdrop-filter: blur(28px) saturate(1.25);
-webkit-backdrop-filter: blur(28px) saturate(1.25);
opacity: 0;
transform: translate(-50%, calc(-50% + 0.8rem)) scale(0.985);
transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
/* Same cascade note as the launcher: an author display beats [hidden]. */
#key-setup[hidden] { display: none; }
#key-setup.visible {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
.key-setup-scanline {
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(54, 220, 255, 0.8), transparent);
box-shadow: 0 0 1rem rgba(54, 220, 255, 0.45);
pointer-events: none;
}
.key-setup-header,
.key-setup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: rgba(171, 210, 220, 0.6);
font-family: var(--font-mono);
font-size: 0.58rem;
letter-spacing: 0.1rem;
text-transform: uppercase;
}
.key-setup-kicker { color: rgba(54, 220, 255, 0.86); }
.key-setup-close {
display: grid;
place-items: center;
padding: 0.15rem;
color: rgba(171, 210, 220, 0.6);
background: none;
border: 1px solid transparent;
border-radius: 0.3rem;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.key-setup-close:hover,
.key-setup-close:focus-visible {
color: rgba(225, 244, 248, 0.95);
border-color: rgba(54, 220, 255, 0.5);
outline: none;
}
.key-setup-close .material-symbols-outlined { font-size: 1rem; }
#key-setup h2 {
margin-top: 0.6rem;
font-family: var(--font-mono);
font-size: clamp(1.25rem, 2.4vw, 1.75rem);
font-weight: 500;
letter-spacing: -0.045rem;
}
#key-setup-description {
max-width: 31rem;
margin-top: 0.3rem;
color: rgba(215, 231, 235, 0.66);
font-size: 0.78rem;
line-height: 1.5;
}
#key-setup-description code {
padding: 0.05rem 0.25rem;
font-size: 0.92em;
background: rgba(54, 220, 255, 0.08);
border-radius: 0.2rem;
}
.key-setup-rows {
display: grid;
gap: 0.42rem;
margin-top: 1rem;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(54, 220, 255, 0.45) transparent;
scrollbar-gutter: stable;
}
.key-setup-rows::-webkit-scrollbar { width: 6px; }
.key-setup-rows::-webkit-scrollbar-thumb {
background: rgba(54, 220, 255, 0.45);
border-radius: 3px;
}
.key-setup-row {
display: grid;
gap: 0.35rem;
padding: 0.6rem 0.7rem;
background: rgba(29, 69, 81, 0.14);
border: 1px solid rgba(100, 200, 220, 0.15);
border-radius: 0.42rem;
}
.key-setup-row[data-set='true'] {
background: rgba(30, 81, 57, 0.12);
border-color: rgba(84, 227, 156, 0.32);
}
.key-setup-row-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
}
.key-setup-row-head strong {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08rem;
}
.key-setup-led {
width: 0.5rem;
height: 0.5rem;
flex: none;
background: rgba(148, 178, 188, 0.35);
border-radius: 50%;
}
.key-setup-row[data-set='true'] .key-setup-led {
background: #54e39c;
box-shadow: 0 0 0.5rem rgba(84, 227, 156, 0.66);
}
.key-setup-tier { font-size: 0.55rem; }
.key-setup-exposed {
padding: 0.05rem 0.3rem;
color: rgba(255, 200, 120, 0.75);
font-size: 0.5rem;
letter-spacing: 0.06rem;
text-transform: uppercase;
border: 1px solid rgba(255, 200, 120, 0.3);
border-radius: 0.3rem;
}
.key-setup-get {
margin-left: auto;
color: rgba(54, 220, 255, 0.75);
font-size: 0.58rem;
letter-spacing: 0.08rem;
text-decoration: none;
white-space: nowrap;
}
.key-setup-get:hover,
.key-setup-get:focus-visible {
color: rgba(54, 220, 255, 1);
text-decoration: underline;
outline: none;
}
.key-setup-unlocks {
margin: 0;
color: rgba(198, 220, 226, 0.6);
font-size: 0.66rem;
}
.key-setup-fields {
display: grid;
gap: 0.3rem;
}
.key-setup-fields input {
width: 100%;
padding: 0.42rem 0.55rem;
color: rgba(225, 244, 248, 0.92);
font-family: var(--font-mono);
font-size: 0.66rem;
background: rgba(4, 14, 20, 0.72);
border: 1px solid rgba(100, 200, 220, 0.22);
border-radius: 0.32rem;
}
.key-setup-fields input:focus {
border-color: rgba(54, 220, 255, 0.65);
outline: none;
}
.key-setup-fields input::placeholder { color: rgba(148, 178, 188, 0.42); }
.key-setup-footer { margin-top: 0.85rem; }
.key-setup-apply {
padding: 0.5rem 0.95rem;
color: #04141c;
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.09rem;
background: linear-gradient(180deg, #45e0ff, #1fb9dd);
border: none;
border-radius: 0.38rem;
cursor: pointer;
transition: filter var(--transition-fast);
}
.key-setup-apply:hover,
.key-setup-apply:focus-visible {
filter: brightness(1.08);
outline: none;
}
.key-setup-apply[aria-disabled='true'] {
cursor: wait;
opacity: 0.5;
pointer-events: none;
}
.key-setup-note {
min-height: 1.1rem;
margin-top: 0.5rem;
color: rgba(199, 225, 231, 0.62);
font-family: var(--font-mono);
font-size: 0.62rem;
line-height: 1.35;
}
/* The same exclusive surfaces that hide the launcher hide this surface —
nothing about key setup belongs in a recording, a cockpit, or a scene. */
body.ui-clean-view #key-setup-chip,
body.recording-mode #key-setup-chip,
body.cockpit-mode #key-setup-chip,
body.scene-playback-mode #key-setup-chip,
body.ui-clean-view #key-setup,
body.recording-mode #key-setup,
body.cockpit-mode #key-setup,
body.scene-playback-mode #key-setup {
display: none;
}
@media (max-width: 620px) {
/* Clear the dock. Desktop-first app; this is a courtesy, not a layout. */
#key-setup-chip { bottom: 5.8rem; }
}
/* Provider Settings additions: externally-managed badge + per-row remove. */
.key-setup-external {
padding: 0.05rem 0.3rem;
color: rgba(148, 200, 220, 0.7);
font-size: 0.5rem;
letter-spacing: 0.06rem;
text-transform: uppercase;
border: 1px solid rgba(148, 200, 220, 0.28);
border-radius: 0.3rem;
white-space: nowrap;
}
.key-setup-remove {
justify-self: start;
padding: 0.28rem 0.55rem;
color: rgba(255, 170, 150, 0.8);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.08rem;
background: none;
border: 1px solid rgba(255, 170, 150, 0.3);
border-radius: 0.32rem;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.key-setup-remove:hover,
.key-setup-remove:focus-visible {
color: rgba(255, 190, 170, 1);
border-color: rgba(255, 170, 150, 0.65);
outline: none;
}
+576
View File
@@ -0,0 +1,576 @@
/* ── Radio companion section inside Context ── */
#radio-panel {
position: relative;
width: 100%;
min-height: 0;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(65, 220, 231, .16);
}
.radio-panel-inner {
position: relative;
display: flex;
min-height: 0;
flex-direction: column;
gap: 9px;
padding: 10px;
border: 1px solid rgba(65, 220, 231, .18);
border-radius: 8px;
background: rgba(2, 15, 20, .42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035);
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, .46) rgba(255, 255, 255, .06);
}
#radio-panel .panel-header { margin-bottom: 0; }
.radio-layer-state {
color: var(--text-dim);
font: 8px/1 var(--font-mono);
letter-spacing: .12em;
}
.radio-layer-state.active { color: #65f7ff; }
.radio-directory-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 8px;
align-items: end;
}
.radio-filter-label {
grid-column: 2;
color: var(--text-dim);
font: 7px/1 var(--font-mono);
letter-spacing: .12em;
}
#radio-enable-btn { grid-row: 1 / span 2; min-height: 36px; }
#radio-filter {
min-width: 0;
padding: 7px 24px 7px 8px;
border: 1px solid rgba(65, 220, 231, .28);
border-radius: 5px;
color: #c5faff;
background: rgba(5, 24, 31, .88);
font: 9px/1.2 var(--font-mono);
}
.radio-station-card {
display: grid;
gap: 4px;
padding: 10px;
border: 1px solid rgba(65, 220, 231, .18);
border-radius: 7px;
background: rgba(2, 15, 20, .48);
overflow-wrap: anywhere;
}
.radio-station-card strong {
color: #e8feff;
font: 11px/1.25 var(--font-mono);
letter-spacing: .05em;
}
.radio-station-card span {
color: var(--text-dim);
font: 8px/1.35 var(--font-mono);
}
#radio-station-tags { color: #72dce6; }
.radio-tuner {
display: grid;
width: 100%;
min-width: 0;
max-width: 100%;
gap: 5px;
padding: 8px;
overflow: hidden;
border: 1px solid rgba(65, 220, 231, .22);
border-radius: 7px;
background: linear-gradient(180deg, rgba(1, 9, 13, .92), rgba(4, 24, 30, .72));
box-shadow: inset 0 0 18px rgba(0, 212, 255, .045);
}
.radio-tuner[hidden] { display: none; }
.radio-tuner-header,
.radio-tuner-readout {
display: flex;
justify-content: space-between;
gap: 8px;
color: #6daeb5;
font: 7px/1.2 var(--font-mono);
letter-spacing: .1em;
}
#radio-tuner-value {
min-width: 0;
color: #a8f7ff;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-tuner-dial {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
height: 51px;
overflow: hidden;
contain: paint;
border-block: 1px solid rgba(72, 225, 237, .3);
background:
linear-gradient(180deg, transparent 23px, rgba(65, 220, 231, .8) 24px, transparent 25px, transparent 28px, rgba(65, 220, 231, .6) 29px, transparent 30px),
radial-gradient(ellipse at center, rgba(39, 177, 191, .12), transparent 70%);
}
.radio-tuner-scale {
position: absolute;
inset: 0;
overflow: hidden;
color: #58cbd6;
font: 9px/1 var(--font-mono);
text-shadow: 0 0 7px rgba(65, 220, 231, .5);
pointer-events: none;
}
.radio-tuner-tick {
position: absolute;
top: 6px;
bottom: 0;
min-width: 1px;
border-left: 1px solid rgba(85, 224, 235, .38);
color: #58cbd6;
padding-left: 3px;
white-space: nowrap;
transform: translateX(-50%);
transition: left 80ms linear;
}
.radio-tuner-tick.is-current {
border-left-color: rgba(168, 247, 255, .9);
color: #a8f7ff;
}
.radio-tuner-needle {
position: absolute;
z-index: 1;
top: 2px;
bottom: 2px;
left: calc(7px + (100% - 14px) * var(--radio-tuner-ratio, 0));
width: 3px;
border-radius: 1px;
background: #ff6f4f;
box-shadow: 0 0 4px #ff6f4f, 0 0 10px rgba(255, 77, 50, .65);
pointer-events: none;
transform: translateX(-50%);
transition: left .18s ease-out;
}
.radio-tuner.is-dragging .radio-tuner-needle,
.radio-tuner.is-dragging .radio-tuner-tick { transition: none; }
#radio-tuner-slider {
position: absolute;
z-index: 2;
inset: 0;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%;
margin: 0;
appearance: none;
-webkit-appearance: none;
cursor: ew-resize;
touch-action: none;
background: transparent;
}
#radio-tuner-slider::-webkit-slider-runnable-track {
height: 100%;
background: transparent;
}
#radio-tuner-slider::-webkit-slider-thumb {
width: 14px;
height: 47px;
margin-top: 2px;
border: 0;
border-radius: 1px;
appearance: none;
-webkit-appearance: none;
background: transparent;
box-shadow: none;
}
#radio-tuner-slider::-moz-range-track { height: 100%; background: transparent; }
#radio-tuner-slider::-moz-range-thumb {
width: 14px;
height: 47px;
border: 0;
border-radius: 1px;
background: transparent;
box-shadow: none;
}
.radio-tuner.is-static .radio-tuner-dial {
box-shadow: inset 0 0 14px rgba(255, 111, 79, .1);
}
.radio-tuner.is-static #radio-tuner-station { color: #ff9b7e; }
.radio-tuner-readout span:first-child {
min-width: 0;
color: #7fe6ef;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-tuner-readout span:last-child {
flex: 0 0 auto;
color: #517f84;
}
.radio-transport {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
.radio-transport button {
min-height: 30px;
padding: 6px 3px;
border: 1px solid rgba(65, 220, 231, .28);
border-radius: 5px;
color: #8feaf2;
background: rgba(14, 69, 78, .14);
font: 8px/1 var(--font-mono);
letter-spacing: .06em;
cursor: pointer;
}
.radio-transport button:hover:not(:disabled),
.radio-transport button:focus-visible:not(:disabled) {
border-color: #65f7ff;
color: #fff;
outline: none;
}
.radio-transport button:disabled,
#radio-filter:disabled { opacity: .42; cursor: not-allowed; }
#radio-play-btn.active {
color: #fff;
border-color: rgba(101, 247, 255, .8);
background: rgba(65, 220, 231, .18);
box-shadow: 0 0 12px rgba(65, 220, 231, .12);
}
.radio-volume-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 32px;
gap: 8px;
align-items: center;
color: var(--text-dim);
font: 7px/1 var(--font-mono);
letter-spacing: .1em;
}
.radio-volume-row input { width: 100%; }
.radio-playback-state {
min-height: 12px;
color: #79d7df;
font: 8px/1.35 var(--font-mono);
}
.radio-playback-state.error { color: #ff9d8f; }
.radio-links {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 5px 10px;
}
.radio-links a {
color: #64cbd5;
font: 7px/1.3 var(--font-mono);
letter-spacing: .06em;
text-decoration: none;
}
.radio-links a:hover, .radio-links a:focus-visible { color: #fff; }
.radio-privacy {
margin: 0;
color: rgba(190, 230, 230, .48);
font: 7px/1.4 var(--font-mono);
}
#military-awareness-panel {
width: 100%;
min-height: 0;
overflow: auto;
box-sizing: border-box;
padding: 2px 1px 0;
color: #dceeff;
background: transparent;
border: 0;
font: 11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
}
#military-awareness-panel[hidden] { display: none; }
.military-awareness-subject { margin: 0 0 7px; color: #fff; }
.military-awareness-controls { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5px; margin: 0 0 8px; }
.military-awareness-controls button { border: 1px solid rgba(98,181,255,0.34); border-radius: 4px; padding: 4px 3px; color: #a9d9ff; background: rgba(42,113,171,0.12); font: 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.07em; cursor: pointer; }
.military-awareness-controls button:hover, .military-awareness-controls button:focus-visible { color: #fff; border-color: rgba(127,200,255,0.85); background: rgba(66,148,211,0.24); outline: none; }
.military-awareness-controls button:disabled { color: #627181; border-color: rgba(98,181,255,0.12); background: transparent; cursor: default; }
.military-awareness-row { border-top: 1px solid rgba(255,255,255,0.1); padding: 7px 0; }
.military-awareness-row > div { display: flex; justify-content: space-between; }
.military-awareness-row b { color: #7fc8ff; }
.military-awareness-row.unknown b { color: #8fcfff; }
.military-awareness-row small, .military-awareness-note { color: #a9b7c7; }
.military-awareness-row ul { margin: 4px 0 0; padding: 0; list-style: none; }
.military-awareness-row li { display: block; }
.military-awareness-target { display: flex; justify-content: space-between; gap: 10px; width: 100%; border: 0; padding: 1px 0; color: #dceeff; background: transparent; font: inherit; text-align: left; cursor: pointer; }
.military-awareness-target.unavailable { color: var(--text-dim); cursor: default; }
.military-awareness-target:hover, .military-awareness-target:focus-visible { color: #fff; outline: none; text-shadow: 0 0 8px rgba(114, 202, 255, 0.85); }
.military-awareness-target:focus-visible { box-shadow: inset 0 -1px 0 #7fc8ff; }
.military-awareness-target span { color: #7fc8ff; white-space: nowrap; }
.military-awareness-note { margin: 8px 0 0; font-size: 10px; }
.military-awareness-standby {
display: grid;
gap: 7px;
padding: 12px 4px 4px;
border-top: 1px solid rgba(98, 181, 255, 0.16);
}
.military-awareness-standby strong { color: #79c2ff; font-size: 10px; letter-spacing: 0.1em; }
.military-awareness-standby span { color: #8192a6; font-size: 9px; letter-spacing: 0.05em; }
.military-awareness-standby button {
justify-self: start;
padding: 4px 12px;
border: 1px solid rgba(98, 181, 255, 0.34);
border-radius: 4px;
color: #a9d9ff;
background: rgba(42, 113, 171, 0.12);
font: 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.08em;
cursor: pointer;
}
#military-awareness-direction-overlay { position: absolute; inset: 0; z-index: 4; overflow: hidden; pointer-events: none; }
#military-awareness-direction-overlay[hidden] { display: none; }
.military-awareness-compass-ring {
--compass-rotation: 0deg;
position: absolute;
box-sizing: border-box;
border: 1px solid rgba(111,196,255,0.2);
outline: 1px dashed rgba(111,196,255,0.28);
outline-offset: -5px;
border-radius: 50%;
box-shadow:
inset 0 0 0 5px rgba(34,128,196,0.018),
inset 0 0 24px rgba(34,128,196,0.035),
0 0 14px rgba(34,128,196,0.05);
}
.military-awareness-compass-ring::before { content: ''; position: absolute; left: 50%; top: -7px; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-bottom: 8px solid rgba(127,200,255,0.88); transform: translateX(-50%); filter: drop-shadow(0 0 5px rgba(87,184,255,0.7)); }
.military-awareness-compass-ring::after {
content: '';
position: absolute;
inset: -5px;
border-radius: 50%;
background: repeating-conic-gradient(
from 0deg,
rgba(125, 205, 255, 0.38) 0deg 0.7deg,
transparent 0.7deg 15deg
);
-webkit-mask: radial-gradient(circle, transparent calc(100% - 8px), #000 calc(100% - 8px) calc(100% - 4px), transparent calc(100% - 4px));
mask: radial-gradient(circle, transparent calc(100% - 8px), #000 calc(100% - 8px) calc(100% - 4px), transparent calc(100% - 4px));
transform: rotate(var(--compass-rotation));
transition: transform 80ms linear;
}
.military-awareness-compass-label, .military-awareness-compass-heading { position: absolute; transform: translate(-50%, -50%); color: rgba(156,213,255,0.7); font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.08em; text-shadow: 0 0 7px rgba(53,161,235,0.55); }
.military-awareness-compass-label.cardinal-n { color: rgba(255,188,87,0.92); }
.military-awareness-compass-heading { color: rgba(190,225,249,0.76); font-size: 8px; }
.military-awareness-direction-marker { position: absolute; width: 1px; height: 1px; transform: translate(-50%, -50%); transition: opacity 100ms linear; }
.military-awareness-direction-marker[hidden] { display: none; }
.military-awareness-direction-arrow { position: absolute; left: 50%; top: 50%; color: #74dbff; font: 23px/1 sans-serif; transform-origin: 50% 50%; text-shadow: 0 0 5px rgba(87,223,255,0.9), 0 0 13px rgba(87,223,255,0.35); }
.military-awareness-direction-label { position: absolute; left: 50%; top: 50%; padding: 3px 6px; border: 1px solid rgba(116,219,255,0.24); border-radius: 3px; color: #bceeff; background: rgba(5,12,20,0.68); font: 8px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; white-space: pre; text-shadow: 0 0 5px rgba(116,219,255,0.38); }
.data-toggle-row.gev-voice-focus {
background: rgba(92, 255, 198, 0.1);
box-shadow: inset 0 0 0 1px rgba(92, 255, 198, 0.72), 0 0 18px rgba(92, 255, 198, 0.18);
}
.data-toggle-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.data-toggle-left {
display: flex;
align-items: center;
gap: 8px;
}
.data-icon {
font-size: 16px;
line-height: 1;
}
.data-name {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.3px;
}
.data-toggle-right {
display: flex;
align-items: center;
gap: 10px;
}
.data-count {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.5px;
color: var(--text-secondary);
min-width: 30px;
text-align: right;
}
.data-toggle-btn {
padding: 3px 10px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.5px;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
min-width var(--transition-fast),
letter-spacing var(--transition-fast);
outline: none;
min-width: 38px;
text-align: center;
}
/* An inset ring stays visible inside the scrolling layer list. */
.data-toggle-btn:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: -3px;
}
.data-toggle-btn.feed-loading,
.data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale,
.data-toggle-btn.feed-fallback,
.data-toggle-btn.feed-unavailable {
min-width: 82px;
letter-spacing: 0.8px;
}
.data-toggle-btn:hover {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
}
.data-toggle-btn.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-dim);
box-shadow: 0 0 10px rgba(0, 212, 255, 0.15);
}
.data-toggle-btn.active.feed-stale,
.data-toggle-btn.active.feed-fallback {
color: #ffd27a;
border-color: rgba(255, 190, 82, 0.75);
background: rgba(255, 174, 51, 0.1);
box-shadow: 0 0 10px rgba(255, 174, 51, 0.12);
}
.data-toggle-btn.active.feed-degraded {
color: #ffad72;
border-color: rgba(255, 137, 72, 0.78);
background: rgba(255, 112, 48, 0.1);
box-shadow: 0 0 10px rgba(255, 112, 48, 0.12);
}
.data-toggle-btn.active.feed-unavailable {
color: #ff8585;
border-color: rgba(255, 92, 92, 0.78);
background: rgba(255, 60, 60, 0.1);
box-shadow: 0 0 10px rgba(255, 60, 60, 0.12);
}
.data-toggle-meta {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
color: var(--text-dim);
padding-left: 26px;
margin-top: 2px;
}
/* Optional per-layer sub-controls: mode chips plus a color legend. Aligned
under the layer name like .data-toggle-meta, and wrapping so a legend can
grow without widening the rail. */
.data-toggle-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
padding-left: 26px;
margin-top: 5px;
}
.data-toggle-controls[hidden] {
display: none;
}
.data-toggle-chip {
padding: 2px 7px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.2px;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
outline: none;
}
.data-toggle-chip:hover,
.data-toggle-chip:focus-visible {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
.data-toggle-chip.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-dim);
}
/* Async sub-controls: a chip must never read ACTIVE before the data it turns
on actually exists, so loading and failure are their own visible states. */
.data-toggle-chip:disabled {
cursor: wait;
opacity: 0.7;
}
.data-toggle-chip.chip-loading {
color: var(--text-secondary);
border-color: rgba(255, 255, 255, 0.18);
}
.data-toggle-chip.chip-error {
color: #ff8585;
border-color: rgba(255, 92, 92, 0.6);
background: rgba(255, 60, 60, 0.08);
}
.data-toggle-legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.5px;
color: var(--text-dim);
cursor: default;
}
.data-toggle-legend-swatch {
width: 6px;
height: 6px;
border-radius: 50%;
/* The swatch IS the datum — keep it exactly the point color, with only a
hairline ring so a near-black class still reads on the panel glass. */
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
flex: none;
}
+33
View File
@@ -0,0 +1,33 @@
/* ── Recording Mode ─────────────────────────── */
body.recording-mode #title-bar,
body.recording-mode #style-indicator,
body.recording-mode #top-center-actions,
body.recording-mode #global-loading-status,
body.recording-mode #traffic-sync-chip,
body.recording-mode #cctv-sync-chip,
body.recording-mode #pp-toggles,
body.recording-mode #clean-view-exit,
body.recording-mode #param-slider-panel,
body.recording-mode #control-panel,
body.recording-mode #location-bar,
body.recording-mode #data-panel,
body.recording-mode #cctv-panel,
body.recording-mode #radio-panel,
body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
body.recording-mode #scene-runtime.active {
opacity: 1;
visibility: visible;
}
/* Scene playback owns layer and camera sequencing until Stop or completion. */
body.scene-playback-mode :is(#clear-selected-layers, #reset-globe-view) {
display: none !important;
}
+216
View File
@@ -0,0 +1,216 @@
/* ── Responsive ───────────────────────────────── */
@media (max-width: 720px) {
#pp-toggles {
--pp-expanded-width: 176px;
position: relative;
inset: auto;
gap: 6px;
}
.pp-toggle-group,
#clean-view-toggle {
width: 176px;
}
.button-grid {
flex-wrap: nowrap;
}
#control-panel {
left: 16px;
right: 16px;
transform: none;
max-width: none;
}
#title-bar {
top: 20px;
left: 20px;
}
#title-bar h1 {
font-size: 16px;
letter-spacing: 4px;
}
#title-bar h1 {
gap: 8px;
}
#title-bar .title-logo {
width: 36px;
}
#location-bar {
left: 16px;
right: 16px;
transform: none;
max-width: none;
bottom: 120px;
}
#gev-voice-control {
right: 16px;
bottom: 16px;
min-width: 196px;
}
#gev-voice-detail {
max-width: 112px;
}
.hud-top-left,
.hud-bottom-left {
left: 16px;
}
.hud-top-right,
.hud-bottom-right {
right: 16px;
}
.hud-left-edge,
.hud-right-edge {
display: none;
}
.hud-top-bar,
.hud-bottom-bar {
width: calc(100vw - 24px);
left: 12px;
transform: none;
}
.hud-top-bar {
top: 54px;
font-size: 9px;
}
#intel-hud[data-variant='operator'] .hud-top-bar {
left: 12px;
right: auto;
width: calc(100vw - 24px);
transform: none;
}
.hud-bottom-bar {
bottom: 90px;
font-size: 9px;
}
#data-panel {
top: auto;
left: 16px;
bottom: 200px;
width: 260px;
}
#data-panel.collapsed {
width: 260px;
}
#cctv-panel {
top: auto;
left: 16px;
right: 16px;
width: auto;
bottom: 308px;
}
#cctv-panel.collapsed {
width: auto;
}
/* Narrow viewports: cap the preview height to keep the panel compact. Cap
the IMAGE, not the wrap — the wrap is overflow:hidden, so capping it would
re-crop the very timestamp this is protecting. object-fit: contain then
letterboxes inside the shorter box. */
#cctv-frame {
aspect-ratio: auto;
height: 120px;
}
.cctv-frame-wrap {
min-height: 0;
}
#scene-panel {
top: auto;
right: 16px;
left: 16px;
width: auto;
bottom: 12px;
}
#scene-panel.collapsed {
width: auto;
}
#left-panel-stack {
top: 70px;
right: 16px;
bottom: calc(50vh + 8px);
left: 16px;
width: auto;
max-height: none;
overflow-y: auto;
}
#right-context-rail {
top: calc(50vh + 8px);
right: 16px;
/* The rail goes edge-to-edge at this width, so its floor is what decides
whether the required Google/Cesium credit line survives. The credit's
top edge sits at calc(2vh + 5rem) plus its own 28px, so a flat 100px
floor cut into it at EVERY viewport height — the DISPLAY toggles were
drawn straight over the notice. Track the credit's own anchor instead
and the 12px of clear air holds regardless of height; the rail already
scrolls (overflow-y below), so it loses reach, not content. */
bottom: calc(2vh + 7.5rem);
left: 16px;
width: auto;
max-height: none;
overflow-y: auto;
}
#right-context-rail > #pp-toggles {
width: 100%;
}
#right-context-rail #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel,
#right-context-rail #cctv-panel.collapsed,
#right-context-rail #radio-panel,
#right-context-rail #radio-panel.collapsed,
#right-context-rail #global-context-panel,
#right-context-rail #global-context-panel.collapsed {
width: 100%;
}
.context-radio-mini {
right: -38px;
width: min(244px, calc(100vw - 32px));
}
#left-panel-stack > #data-panel,
#left-panel-stack > #cctv-panel,
#left-panel-stack > #global-context-panel,
#left-panel-stack > #scene-panel,
#left-panel-stack > #data-panel.collapsed,
#left-panel-stack > #cctv-panel.collapsed,
#left-panel-stack > #global-context-panel.collapsed,
#left-panel-stack > #scene-panel.collapsed {
position: relative;
top: auto;
right: auto;
bottom: auto;
left: auto;
width: 100%;
}
#scene-runtime {
top: 16px;
max-width: calc(100vw - 24px);
text-align: center;
}
}
+412
View File
@@ -0,0 +1,412 @@
/* ── Scene Director Panel ───────────────────── */
#scene-panel {
--panel-expanded-width: 360px;
position: fixed;
top: calc(var(--left-stack-top) + (var(--left-stack-gap) * 2));
left: var(--left-stack-x);
right: auto;
bottom: auto;
z-index: 100;
width: var(--panel-expanded-width);
max-height: calc(100vh - 24px);
}
#left-panel-stack > #data-panel,
#left-panel-stack > #cctv-panel,
#left-panel-stack > #global-context-panel,
#left-panel-stack > #scene-panel {
position: relative;
top: auto;
right: auto;
bottom: auto;
left: auto;
z-index: auto;
max-height: none;
flex: 0 0 auto;
min-height: 0;
pointer-events: auto;
}
#left-panel-stack > #data-panel {
order: 1;
}
#left-panel-stack > #cctv-panel {
order: 2;
}
#left-panel-stack > #scene-panel { order: 4; }
/*
* Runtime focus mode: the layout engine applies this only when the expanded
* panel's measured natural height plus sibling labels cannot fit inside the
* live HUD/attribution-safe corridor.
*/
#left-panel-stack.layout-focus > [data-panel-id].collapsed {
display: none;
}
#left-panel-stack > #data-panel:not(.collapsed),
#left-panel-stack > #cctv-panel:not(.collapsed),
#left-panel-stack > #scene-panel:not(.collapsed) {
box-sizing: border-box;
flex: 0 1 var(--left-panel-allocated-height, auto);
height: var(--left-panel-allocated-height, auto);
max-height: var(--left-panel-allocated-height, 100%);
}
/*
* When an expanded panel and its collapsed siblings fit together, preserve
* the canonical control order and center their combined stack at 50vh.
*/
#left-panel-stack.layout-tail > :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) {
box-sizing: border-box;
flex: 0 0 var(--left-panel-allocated-height);
height: var(--left-panel-allocated-height);
max-height: var(--left-panel-allocated-height);
}
#left-panel-stack > #data-panel:not(.collapsed) .data-panel-inner,
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
height: 100%;
max-height: 100%;
}
#left-panel-stack > #data-panel:not(.collapsed) .data-panel-inner {
overflow: hidden;
}
#left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner,
#left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner {
overflow-y: auto;
scrollbar-gutter: stable;
}
#left-panel-stack > [data-panel-id]:not(.collapsed) .panel-header {
position: sticky;
top: 0;
z-index: 2;
background: transparent;
}
#left-panel-stack > [data-panel-id]:not(.collapsed) .panel-divider {
background: linear-gradient(90deg, rgb(0 212 255 / 28%), rgba(0, 212, 255, 0.18) 58%, transparent);
box-shadow: 0 0 7px rgba(0, 212, 255, 0.22);
}
#right-context-rail [data-panel-id]:not(.collapsed) .panel-divider {
background: linear-gradient(90deg, rgb(0 212 255 / 28%), rgba(0, 212, 255, 0.18) 58%, transparent);
box-shadow: 0 0 7px rgba(0, 212, 255, 0.22);
}
#scene-panel .panel-glow {
position: absolute;
inset: -18px;
background: radial-gradient(ellipse at center, rgba(0, 212, 255, 0.16) 0%, transparent 65%);
filter: blur(16px);
opacity: 0.25;
pointer-events: none;
}
.scene-panel-inner {
position: relative;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
padding: 14px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 24px);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
}
.scene-controls {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
#scene-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
outline: none;
}
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
padding: 6px 9px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
white-space: nowrap;
flex: 1;
min-width: 72px;
}
#scene-start-btn,
#scene-stop-btn,
#scene-next-btn {
flex: 1 1 0;
}
.scene-shot-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 160px;
min-height: 96px;
flex: 1;
overflow-y: auto;
padding: 4px 2px 6px;
margin-bottom: 8px;
}
.scene-shot-list::-webkit-scrollbar {
width: 6px;
}
.scene-shot-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.16);
border-radius: 999px;
}
.scene-shot-empty {
border: 1px dashed rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 10px 8px;
text-align: center;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1px;
color: var(--text-dim);
}
.scene-shot-row {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9px;
padding: 7px 8px;
background: rgba(255, 255, 255, 0.03);
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.scene-shot-row.active {
border-color: var(--accent);
background: rgba(0, 212, 255, 0.08);
}
.scene-shot-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.scene-shot-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--text-primary);
}
.scene-shot-meta {
margin-top: 4px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
color: var(--text-dim);
}
.scene-shot-actions {
display: flex;
gap: 6px;
}
.scene-shot-btn {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.8px;
padding: 4px 6px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.scene-shot-btn:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.scene-shot-btn.scene-shot-danger:hover {
color: #ff7e7e;
border-color: #ff7e7e;
}
.scene-btn:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
.scene-btn:disabled {
opacity: 0.45;
cursor: default;
}
.scene-btn-danger:hover:not(:disabled) {
color: #ff7272;
border-color: #ff7272;
background: rgba(255, 88, 88, 0.15);
}
.scene-progress {
margin-top: 8px;
width: 100%;
height: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
}
#scene-progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, rgba(0, 212, 255, 0.2), rgba(0, 212, 255, 0.75));
color: #d5f7ff;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 6px;
transition: width 100ms linear;
text-shadow: 0 0 6px rgba(0, 212, 255, 0.6);
}
#scene-status {
margin-top: 8px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--text-secondary);
}
#scene-runtime {
position: fixed;
top: 34px;
left: 50%;
transform: translateX(-50%);
z-index: 105;
opacity: 0;
visibility: hidden;
transition: opacity 180ms ease, visibility 180ms ease;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 2px;
color: #bff4ff;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(0, 212, 255, 0.36);
border-radius: 999px;
padding: 6px 14px;
text-shadow: 0 0 8px rgba(0, 212, 255, 0.55);
pointer-events: none;
}
#scene-runtime.active {
opacity: 1;
visibility: visible;
}
#control-panel.collapsed .button-grid,
#data-panel.collapsed .data-toggle-list,
#global-context-panel.collapsed #radio-panel,
#global-context-panel.collapsed #military-awareness-panel,
#global-context-panel.collapsed .global-context-actions,
#global-context-panel.collapsed .global-context-modes,
#global-context-panel.collapsed .context-mode-view,
#global-context-panel.collapsed > .global-context-panel-inner > .context-mode-standby,
#radio-panel.collapsed .radio-panel-inner > :not(.panel-header),
#cctv-panel.collapsed .cctv-controls,
#cctv-panel.collapsed .cctv-calibration-block,
#cctv-panel.collapsed .cctv-frame-wrap,
#cctv-panel.collapsed #cctv-source-badge,
#cctv-panel.collapsed #cctv-meta,
#cctv-panel.collapsed .cctv-summary-label,
#cctv-panel.collapsed #cctv-summary,
#scene-panel.collapsed .scene-controls,
#scene-panel.collapsed .scene-shot-list,
#scene-panel.collapsed .scene-progress,
#scene-panel.collapsed #scene-status,
#param-slider-panel.collapsed #param-sliders,
#location-bar.collapsed .poi-row-container,
#location-bar.collapsed .location-bar-divider,
#location-bar.collapsed .location-city-row {
display: none !important;
}
#data-panel.collapsed,
#cctv-panel.collapsed,
#scene-panel.collapsed {
width: var(--left-collapsed-width);
}
#global-context-panel #radio-panel.collapsed { width: 100%; }
#data-panel.collapsed .panel-title,
#cctv-panel.collapsed .panel-title,
#radio-panel.collapsed .panel-title,
#scene-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#control-panel.collapsed .panel-inner,
#data-panel.collapsed .data-panel-inner,
#cctv-panel.collapsed .cctv-panel-inner,
#radio-panel.collapsed .radio-panel-inner,
#scene-panel.collapsed .scene-panel-inner,
#param-slider-panel.collapsed,
#location-bar.collapsed .location-inner {
padding-bottom: 10px;
}
#control-panel.collapsed .panel-header,
#data-panel.collapsed .panel-header,
#cctv-panel.collapsed .panel-header,
#radio-panel.collapsed .panel-header,
#global-context-panel.collapsed .panel-header,
#scene-panel.collapsed .panel-header,
#param-slider-panel.collapsed .param-panel-header {
margin-bottom: 2px;
}
+334
View File
@@ -0,0 +1,334 @@
/* ── Top-center Globe Actions ────────────────── */
#top-center-actions {
position: fixed;
top: 32px;
left: 50%;
z-index: 100;
display: flex;
gap: 8px;
transform: translateX(-50%);
}
#top-center-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 50%;
color: var(--text-secondary);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast),
transform var(--transition-fast);
outline: none;
}
#top-center-actions button:hover,
#top-center-actions button:focus-visible {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
transform: scale(1.1);
}
#top-center-actions button:active {
transform: scale(0.95);
}
#top-center-actions button:disabled {
cursor: wait;
opacity: 0.55;
transform: none;
}
#top-center-actions .material-symbols-outlined {
font-size: 18px;
}
#global-loading-status {
position: fixed;
top: 74px;
left: 50%;
z-index: 102;
display: inline-flex;
align-items: center;
gap: 10px;
max-width: calc(100vw - 32px);
padding: 6px 10px;
border-radius: 7px;
border: 1px solid rgba(0, 212, 255, 0.24);
background: rgba(3, 10, 18, 0.78);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
color: rgba(170, 252, 255, 0.92);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.35px;
text-transform: uppercase;
transform: translateX(-50%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34), 0 0 14px rgba(0, 212, 255, 0.08);
pointer-events: none;
}
#global-loading-status[hidden] { display: none; }
#global-loading-label {
color: rgba(184, 244, 255, 0.92);
}
#global-loading-detail {
min-width: 34px;
max-width: min(220px, 38vw);
overflow: hidden;
color: #00e1ff;
font-weight: 600;
letter-spacing: 1.6px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
#global-loading-status:is([data-state='complete'], [data-state='cancelled'], [data-state='error']) {
justify-content: center;
text-align: center;
}
#global-loading-status:is([data-state='complete'], [data-state='cancelled'], [data-state='error']) #global-loading-detail {
display: none;
}
#global-loading-status[data-state='error'] { border-color: rgba(255, 98, 82, 0.55); color: #ff9b8e; }
#global-loading-status[data-state='error'] #global-loading-label,
#global-loading-status[data-state='error'] #global-loading-detail { color: #ff9b8e; }
#global-loading-status[data-state='cancelled'] { color: #ffd488; }
#global-loading-status[data-state='retry'] { border-color: rgba(255, 212, 136, 0.55); }
#global-loading-status[data-state='retry'] #global-loading-label,
#global-loading-status[data-state='retry'] #global-loading-detail { color: #ffd488; }
#global-loading-status[data-state='cancelled'] #global-loading-label,
#global-loading-status[data-state='cancelled'] #global-loading-detail { color: #ffd488; }
#global-loading-status[data-state='acquiring'] {
border-color: rgba(0, 212, 255, 0.42);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34), 0 0 18px rgba(0, 212, 255, 0.12);
}
.share-icon {
font-size: 16px;
line-height: 1;
}
#traffic-sync-chip,
#cctv-sync-chip {
position: fixed;
top: 112px;
left: 50%;
transform: translateX(-50%);
z-index: 101;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 7px;
border: 1px solid rgba(0, 212, 255, 0.24);
background: rgba(3, 10, 18, 0.78);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
color: rgba(170, 252, 255, 0.92);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.35px;
text-transform: uppercase;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34), 0 0 14px rgba(0, 212, 255, 0.08);
}
/* Stacked below the traffic chip so both can show at once */
#cctv-sync-chip {
top: 146px;
}
#traffic-sync-chip.visible,
#cctv-sync-chip.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#traffic-sync-label,
#cctv-sync-label {
color: rgba(184, 244, 255, 0.92);
}
#traffic-sync-progress,
#cctv-sync-progress {
color: #00e1ff;
font-weight: 600;
letter-spacing: 1.6px;
min-width: 34px;
text-align: right;
}
/* The settled traffic chip carries no progress number — the label's coverage
figure is the honest one. Collapse the empty slot so the chip closes up
instead of trailing a 34px stub and its flex gap. */
#traffic-sync-progress:empty {
display: none;
}
/* ── Split-flap chip text ─────────────────────────────────────────
Per-character departure-board flip when a chip label changes.
Driven entirely by src/splitFlap.js, which rebuilds the label as
one span per character on the change and strips them again once
the cascade lands.
The DOM text is the truth and its node never moves. A chip label is
upgraded once into a permanent shell — a .gev-flap-text span holding
one long-lived Text node, plus an aria-hidden .gev-flap-cells
sibling — and from then on a label change is only `node.data = next`.
During a cascade the host takes .gev-flap-active, which clips the
real text out of view and lets the cells stand in for it. The cells
carry no text at all: both glyphs are generated content (::before =
outgoing, ::after = incoming), and generated content never reaches
textContent. That is what keeps QA pins and the traffic chip's own
`textContent !==` guard honest, and it lets the cells be rebuilt
freely while the aria-live region sees only one characterData
change per label update.
Motion matches the cockpit odometer roll (.cockpit-roll-token):
same cubic-bezier, same reduced-motion treatment. Only the
left-to-right stagger is new — that cascade is what makes it read
as a Solari board rather than a counter.
------------------------------------------------------------------ */
/* While the flaps play, the real text is clipped out of view but stays in
the DOM and in the accessibility tree — same clipped-but-present treatment
as .world-overlay-accessibility. It is never removed or reparented. */
.gev-flap-host.gev-flap-active > .gev-flap-text {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Permanent sibling: empty (and so zero-width) whenever no cascade is running. */
.gev-flap-cells {
white-space: pre;
}
.gev-flap-cell {
display: inline-block;
/* Chip labels carry real spaces; a collapsed space would drop a column. */
white-space: pre;
}
/* The incoming glyph is in flow, so it — not any text node — is what gives
the column its natural width. EVERY column has one, including a column the
new string does not reach: that one carries a blank, so it holds its width
and its index for the whole cascade instead of collapsing. Collapsing would
stack the absolutely-positioned outgoing glyphs on one x and would let a
later glyph slide into an earlier column. */
.gev-flap-cell::after {
content: attr(data-flap-next);
display: inline-block;
white-space: pre;
}
.gev-flap-cell.is-flapping {
position: relative;
/* Relative to the chip's own font size, so the foreshortening reads the
same on the 8px status chips as it would anywhere else. */
perspective: 4em;
}
.gev-flap-cell.is-flapping::before,
.gev-flap-cell.is-flapping::after {
transform-origin: 50% 50%;
backface-visibility: hidden;
}
.gev-flap-cell.is-flapping::after {
animation: gev-flap-in var(--gev-flap-dur, 190ms)
cubic-bezier(0.2, 0.75, 0.25, 1) var(--gev-flap-delay, 0ms) both;
}
/* Out of flow, so the outgoing glyph overlays its own column without
affecting layout — one per column, each anchored to its own cell box. */
.gev-flap-cell.is-flapping::before {
content: attr(data-flap-prev);
position: absolute;
top: 0;
left: 0;
white-space: pre;
animation: gev-flap-out var(--gev-flap-dur, 190ms)
cubic-bezier(0.2, 0.75, 0.25, 1) var(--gev-flap-delay, 0ms) both;
}
/* Length changes are eased, never snapped. A GROWING label reserves its new
columns the moment the cells go in, so splitFlap.js eases from the previous
on-screen width to the cascade width at the start. A SHRINKING one holds
every column for the whole cascade, so its ease runs at SETTLEMENT instead,
from the cascade width down to the natural width — one smooth transition
after the flaps have landed, rather than columns collapsing underneath
them. */
.gev-flap-sizing {
display: inline-block;
transition: width var(--gev-flap-total, 500ms) cubic-bezier(0.2, 0.75, 0.25, 1);
}
/* The old leaf hinges down and away on the cell's midline. */
@keyframes gev-flap-out {
0% { transform: rotateX(0deg); opacity: 1; }
48% { transform: rotateX(-88deg); opacity: 0.5; }
100% { transform: rotateX(-90deg); opacity: 0; }
}
/* The new leaf waits for the old one to clear, then drops in and settles
with a single small overshoot — the mechanical clack. */
@keyframes gev-flap-in {
0% { transform: rotateX(90deg); opacity: 0; }
46% { transform: rotateX(90deg); opacity: 0; }
78% { transform: rotateX(-7deg); opacity: 1; }
100% { transform: rotateX(0deg); opacity: 1; }
}
/* Matches the .cockpit-roll-track treatment: collapse the duration rather
than dropping the animation, so the final frame still lands the glyph.
src/splitFlap.js also checks the query and takes the instant path, so
this is the belt to that braces. */
@media (prefers-reduced-motion: reduce) {
.gev-flap-cell.is-flapping::before,
.gev-flap-cell.is-flapping::after {
animation-duration: 1ms;
animation-delay: 0ms;
}
.gev-flap-sizing {
transition-duration: 1ms;
}
}
/* ── Toast Notification ──────────────────────── */
#toast {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 200;
padding: 10px 24px;
background: var(--glass-bg);
border: 1px solid var(--accent);
border-radius: 8px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 2px;
color: var(--accent);
text-transform: uppercase;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.3s ease;
box-shadow: 0 0 20px rgba(0, 212, 255, 0.15);
}
#toast.visible {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
+78
View File
@@ -0,0 +1,78 @@
/* ------------------------------------------------------------------ *
* Voice cost control — model-tier toggle + running session estimate.
* Appended (not woven into the dock passes above) so the three existing
* #gev-voice-control cascades stay untouched; these rules only add the
* new .gev-voice-cost cluster inside the existing .gev-voice-heading row.
* ------------------------------------------------------------------ */
.gev-voice-cost {
display: flex;
align-items: center;
gap: 0.22rem;
margin-left: auto; /* pin right; kicker + status keep their left cluster */
flex: 0 0 auto;
}
.gev-voice-tier-btn {
padding: 0 0.24rem;
height: 0.72rem;
min-width: 1.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.06);
border: 1px solid rgba(0, 212, 255, 0.34);
border-radius: 0.18rem;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.4rem;
letter-spacing: 0.045rem;
line-height: 1;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast),
background var(--transition-fast);
}
.gev-voice-tier-btn:hover {
color: var(--text-primary);
border-color: rgba(0, 212, 255, 0.6);
background: rgba(0, 212, 255, 0.12);
}
/* MINI engaged — amber, distinct from the cyan default so the cheaper tier
is legible at a glance without reading the label. */
.gev-voice-tier-btn[aria-pressed='true'] {
color: #ffc46b;
border-color: rgba(255, 196, 107, 0.55);
background: rgba(255, 196, 107, 0.12);
}
.gev-voice-cost-value {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.4rem;
letter-spacing: 0.02rem;
line-height: 1;
font-variant-numeric: tabular-nums;
transition: color var(--transition-fast);
}
.gev-voice-cost-value[data-level='warn'] {
color: #ffc46b;
}
.gev-voice-cost-value[data-level='cap'] {
color: #ff6b6b;
font-weight: 600;
}
/* The dock heading row is 0.8rem tall; keep the cluster inside it. */
#command-dock .gev-voice-cost {
max-height: 0.8rem;
}
/* The dock collapses the kicker/readout when a tray is open — the cost
cluster follows the same rule so the row never overflows. */
#command-dock.tray-open .gev-voice-cost {
display: none;
}
+54
View File
@@ -0,0 +1,54 @@
/** Own deferred UI presentation and listeners until synchronous teardown. */
export class UiLifetime {
constructor() {
this.destroyed = false;
this.removers = new Set();
this.frames = new Set();
this.timers = new Set();
}
listen(target, type, callback, options) {
if (this.destroyed || !target) return () => {};
const remove = () => {
target.removeEventListener(type, listener, options);
this.removers.delete(remove);
};
const listener = (event) => {
if (options?.once) remove();
if (!this.destroyed) return callback(event);
};
target.addEventListener(type, listener, options);
this.removers.add(remove);
return remove;
}
frame(callback) {
if (this.destroyed) return null;
const frame = requestAnimationFrame((time) => {
this.frames.delete(frame);
if (!this.destroyed) callback(time);
});
this.frames.add(frame);
return frame;
}
timeout(callback, delay) {
if (this.destroyed) return null;
const timer = setTimeout(() => {
this.timers.delete(timer);
if (!this.destroyed) callback();
}, delay);
this.timers.add(timer);
return timer;
}
cancelTimeout(timer) {
clearTimeout(timer);
this.timers.delete(timer);
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
for (const remove of this.removers) remove();
for (const frame of this.frames) cancelAnimationFrame(frame);
for (const timer of this.timers) clearTimeout(timer);
this.frames.clear();
this.timers.clear();
}
}
+20 -9685
View File
File diff suppressed because it is too large. Load diff