mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor: compose UI shell with explicit application operations and component owners
This commit is contained in:
1 parent
8a226a2fe3
commit
f5ef9091f0
67 files changed
+15799
-15059
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'\)/);
|
||||
});
|
||||
@@ -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/,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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;/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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';/,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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() {},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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}(`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 \} = \{\}\)/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
} });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Load the ordered application control stylesheet. */
|
||||
import '../../style.css';
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
Vendored
+611
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user