mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #410 from bilawalsidhu/refactor/ui-shell-composition
refactor: compose UI shell and organize component styles
This commit is contained in:
68 files changed
+18519
-15158
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"
|
||||
}
|
||||
}
|
||||
@@ -213,5 +213,38 @@
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs"
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/styles.js",
|
||||
"src/ui/shellLifecycle.test.mjs",
|
||||
"src/testSupport/readStylesheet.mjs",
|
||||
"src/splitFlap.js",
|
||||
"style.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/command-dock-trays.css",
|
||||
"src/ui/styles/location.css",
|
||||
"src/ui/styles/recording.css",
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/command-dock.css",
|
||||
"src/ui/styles/command-dock-sliding.css",
|
||||
"src/ui/styles/provider-settings.css",
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/command-dock-compact.css"
|
||||
]
|
||||
@@ -423,5 +423,130 @@
|
||||
"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,
|
||||
};
|
||||
@@ -946,7 +946,7 @@ try {
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow);
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 200);
|
||||
dataManager.getAll = () => [];
|
||||
styleManager._loadingFeedbackEvent = {
|
||||
styleManager._feedback._loadingFeedbackEvent = {
|
||||
type: 'visibility-failed',
|
||||
layerId: 'qa-unrelated-layer',
|
||||
error: new Error('QA offline'),
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -192,6 +192,13 @@ try {
|
||||
window.__godsEyeView.styleManager.getControlState().recording,
|
||||
),
|
||||
);
|
||||
// A focused panel owns Escape to collapse one level. Release panel focus
|
||||
// before testing the global playback shortcut; the canvas is not tabbable.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
check(
|
||||
'global playback shortcut has document focus',
|
||||
await page.evaluate(() => document.activeElement === document.body),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
@@ -208,6 +215,15 @@ 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,11 @@ 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;
|
||||
@@ -109,10 +113,20 @@ try {
|
||||
if (type === 'resize' && callback === resizeHandler) counts.resize++;
|
||||
return removeEventListener.call(this, type, callback, options);
|
||||
};
|
||||
const { setSplitFlapText } = await import('/src/splitFlap.js');
|
||||
const feedbackLabel = document.getElementById('global-loading-label');
|
||||
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
|
||||
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
|
||||
const permanentText =
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
|
||||
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 &&
|
||||
@@ -162,7 +176,11 @@ try {
|
||||
counts[field] === 1 && ui._contextControls[field] === null,
|
||||
),
|
||||
observersReleased: observed.every(
|
||||
(name) => counts[name] === 1 && ui[name] === null,
|
||||
(name) =>
|
||||
counts[name] === 1 &&
|
||||
(name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout)[name] === null,
|
||||
),
|
||||
subscriptionReleased:
|
||||
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||
@@ -171,6 +189,19 @@ try {
|
||||
ui._radioControls.destroyed &&
|
||||
ui._locationControls.destroyed &&
|
||||
ui._cctvControls.destroyed,
|
||||
feedbackTextPreserved:
|
||||
permanentText?.nodeType === Node.TEXT_NODE &&
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
|
||||
permanentText &&
|
||||
permanentText.data === 'LOAD COMPLETE' &&
|
||||
!feedbackLabel.classList.contains('gev-flap-active') &&
|
||||
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
|
||||
0,
|
||||
shellWorkReleased:
|
||||
ui._lifetime.destroyed &&
|
||||
ui._lifetime.frames.size === 0 &&
|
||||
ui._lifetime.timers.size === 0 &&
|
||||
ui._lifetime.removers.size === 0,
|
||||
idempotent: once === JSON.stringify(counts),
|
||||
};
|
||||
} finally {
|
||||
@@ -210,6 +241,14 @@ try {
|
||||
'Cockpit cleanup releases listeners and returns all Display groups home',
|
||||
result.cockpitReleased,
|
||||
);
|
||||
check(
|
||||
'feedback teardown preserves the permanent accessible text node',
|
||||
result.feedbackTextPreserved,
|
||||
);
|
||||
check(
|
||||
'shell teardown cancels deferred work and releases listeners',
|
||||
result.shellWorkReleased,
|
||||
);
|
||||
check('repeated UI disposal is inert', result.idempotent);
|
||||
check(
|
||||
'disposal and subsequent resize produce no uncaught browser errors',
|
||||
|
||||
+18
-12
@@ -1,3 +1,4 @@
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -7,7 +8,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');
|
||||
@@ -15,6 +16,11 @@ const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'u
|
||||
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
|
||||
|
||||
function body(source, pattern, label) {
|
||||
const name = pattern.source.match(/^(\w+)/)?.[1];
|
||||
if (source === ui && typeof StyleManager.prototype[name] === 'function') {
|
||||
const method = StyleManager.prototype[name].toString();
|
||||
return method.slice(method.indexOf(') {') + 3, -1);
|
||||
}
|
||||
const match = source.match(pattern);
|
||||
assert.ok(match, `${label} is missing`);
|
||||
return match[1];
|
||||
@@ -40,7 +46,7 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
|
||||
], 'Cockpit takeover');
|
||||
assert.match(
|
||||
ui,
|
||||
/onCameraTakeover: \(\) => this\._stampNavigation\(\{ cancelPendingSelection: false \}\),/,
|
||||
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
|
||||
'Cockpit retires stale camera work without clearing the aircraft selection it adopts',
|
||||
);
|
||||
});
|
||||
@@ -51,14 +57,14 @@ test('one explicit tracking selection clears sibling IDs before publishing its d
|
||||
/_persistAwarenessSelection\(event, cleared = false\) \{([\s\S]*?)\n \}/,
|
||||
'tracking persistence',
|
||||
);
|
||||
assert.match(persist, /adoptLayerParams\?\.\(layerId,/);
|
||||
assert.match(persist, /adoptLayerParams\?\.\(\s*layerId,/);
|
||||
assert.match(persist, /\['flights', 'selectedFlightsTrackingId'\]/);
|
||||
assert.match(persist, /\['military', 'selectedMilitaryTrackingId'\]/);
|
||||
assert.match(persist, /\['satellites', 'selectedSatTrackingId'\]/);
|
||||
assert.match(persist, /if \(otherLayerId === layerId\) continue;/);
|
||||
assert.match(
|
||||
persist,
|
||||
/for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(otherLayerId,[\s\S]*?adoptLayerParams\?\.\(layerId,/,
|
||||
/for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(\s*otherLayerId,[\s\S]*?adoptLayerParams\?\.\(\s*layerId,/,
|
||||
'the previous family clears before Flight/Military/Satellite publishes the new durable ID',
|
||||
);
|
||||
});
|
||||
@@ -73,9 +79,9 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res
|
||||
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /if \(!passivelyClearedShareSelection && !flightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedFlightsTrackingId: null/);
|
||||
assert.match(stamp, /if \(!passivelyClearedShareSelection && !militaryFlightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedMilitaryTrackingId: null/);
|
||||
assert.match(stamp, /if \(!passivelyClearedShareSelection && !satellitesLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedSatTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry reaches the camera only through stamping seams', () => {
|
||||
@@ -108,7 +114,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\.trackedEntityChanged\.addEventListener\(\(entity\) => \{\s*if \(entity && !this\._disposed\) this\._stampNavigation\(\{ cancelPendingSelection: false \}\);/,
|
||||
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||
'tracker handoff must stamp',
|
||||
);
|
||||
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
|
||||
@@ -251,7 +257,7 @@ test('deferred search releases only after its final authority check', () => {
|
||||
], 'deferred search component');
|
||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /isCurrent: \(generation\) => !this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /onSettled: \(generation\) => this\._settleLocationSearchUi\(generation\)/);
|
||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||
});
|
||||
@@ -263,15 +269,15 @@ test('a direct globe gesture retires delayed camera and selection restore only',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\?\.canvas\?\.addEventListener\('pointerdown', this\._initialShareGestureHandler/,
|
||||
/viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\?\.canvas\?\.addEventListener\('wheel', this\._initialShareGestureHandler/,
|
||||
/viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/removeEventListener\('pointerdown', this\._initialShareGestureHandler\)[\s\S]*?removeEventListener\('wheel', this\._initialShareGestureHandler\)/,
|
||||
/removeEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler,?\s*\)[\s\S]*?removeEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler,?\s*\)/,
|
||||
);
|
||||
const stamp = body(
|
||||
ui,
|
||||
|
||||
@@ -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');
|
||||
assert.match(uiSource, /shouldFocus: \(\) => !this\._disposed && this\._dataManager\.isEnabled\('cctv'\)/);
|
||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*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/,
|
||||
|
||||
+33
-29
@@ -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');
|
||||
@@ -84,13 +87,13 @@ test('Cockpit vision cycle exposes exactly five real visual styles without NONE'
|
||||
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
|
||||
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(this\.stages, this\.transitions\);/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
|
||||
);
|
||||
assert.match(
|
||||
@@ -178,7 +181,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.cockpit-context-window\[data-state="lost"\]/,
|
||||
/\.cockpit-context-window\[data-state='lost'\]/,
|
||||
'CONTACT LOST reuses the panel-level data-state cue that "uncertain" already uses',
|
||||
);
|
||||
});
|
||||
@@ -252,15 +255,15 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
const cockpitRadio = html.match(/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<\/aside>/);
|
||||
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
|
||||
assert.doesNotMatch(cockpitRadio[0], /context-radio-details-btn|radio-filter|radio-tuner/);
|
||||
assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(--cockpit-utility-top/);
|
||||
assert.match(css, /\[data-cockpit-launcher="display"\]\s*\{\s*width:\s*var\(--left-collapsed-width, 176px\)/);
|
||||
assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/);
|
||||
assert.match(css, /\[data-cockpit-launcher='display'\]\s*\{\s*width:\s*var\(\s*--left-collapsed-width, 176px\)/);
|
||||
assert.match(css, /--display-panel-expanded-width:\s*272px/);
|
||||
assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(--display-panel-expanded-width\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)\s*\{[\s\S]*?width:\s*var\(--display-panel-expanded-width\)/);
|
||||
assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(\s*--display-panel-expanded-width\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)\s*\{[\s\S]*?width:\s*var\(\s*--display-panel-expanded-width\)/);
|
||||
assert.doesNotMatch(ui, /--cockpit-display-expanded-width|dataPanelWidth/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, 0\.42\)/);
|
||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
|
||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
|
||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/);
|
||||
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
|
||||
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
|
||||
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
|
||||
@@ -272,8 +275,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
assert.match(css, /--cockpit-radio-expanded-width:\s*232px/);
|
||||
assert.doesNotMatch(css, /--map-stack-(?:collapsed|expanded)-width/);
|
||||
assert.doesNotMatch(html, /id="stack-panel"/, 'the retired Map Stack panel must not remain in Cockpit layout');
|
||||
assert.match(css, /\[data-cockpit-launcher="radio"\]\s*\{\s*width:\s*var\(--cockpit-radio-collapsed-width, 148px\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="radio"\]\)[\s\S]*?--cockpit-radio-expanded-width/);
|
||||
assert.match(css, /\[data-cockpit-launcher='radio'\]\s*\{\s*width:\s*var\(\s*--cockpit-radio-collapsed-width, 148px\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='radio'\]\)[\s\S]*?--cockpit-radio-expanded-width/);
|
||||
assert.doesNotMatch(ui, /--cockpit-radio-tab-width/);
|
||||
assert.doesNotMatch(css, /\.cockpit-utility-launcher:hover/);
|
||||
const desktopUtilityCss = css.slice(
|
||||
@@ -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(
|
||||
@@ -345,11 +348,11 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel = !this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||
);
|
||||
assert.match(setContextCollapsed.toString(),
|
||||
@@ -401,7 +404,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
);
|
||||
assert.match(
|
||||
callback[1],
|
||||
/_cockpitPanelRestore\.set\(panelId, panel\.classList\.contains\('collapsed'\)\)/,
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
|
||||
'entry must remember each panel\'s exact collapsed state before hiding it',
|
||||
);
|
||||
assert.match(
|
||||
@@ -446,12 +449,12 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
});
|
||||
|
||||
test('real disclosure changes reconsider only their own temporary panel lane', () => {
|
||||
const collapseStart = ui.indexOf('setPanelCollapsed(panelId, collapsed, {');
|
||||
const collapseStart = ui.indexOf(' setPanelCollapsed(');
|
||||
const collapse = ui.slice(
|
||||
collapseStart,
|
||||
ui.indexOf('toggleCleanView(forceEnabled)', collapseStart),
|
||||
);
|
||||
assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed && !wasAutoCollapsed[\s\S]*?return;/);
|
||||
assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/);
|
||||
assert.match(collapse, /_rightPanelStack\?\.contains\(panelEl\)[\s\S]*?_scheduleRightPanelLayout\(\{ reconsiderAutoCollapse: true \}\)/);
|
||||
assert.match(collapse, /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/);
|
||||
});
|
||||
@@ -459,7 +462,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
|
||||
test('Cockpit hides the complete top-center globe action group', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\) \{ display: none !important; \}/,
|
||||
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -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',
|
||||
);
|
||||
@@ -583,7 +587,7 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
|
||||
assert.match(
|
||||
css,
|
||||
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/,
|
||||
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(\s*--cockpit-utility-top,\s*var\(\s*--left-stack-safe-top,\s*var\(\s*--left-stack-top\)\s*\)\s*\)/,
|
||||
'the first frame before Cockpit publishes an anchor still needs the fallback chain',
|
||||
);
|
||||
assert.match(css, /body\.cockpit-mode #left-panel-stack\s*\{[\s\S]*?transition:\s*none;/);
|
||||
@@ -855,7 +859,7 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(
|
||||
state,
|
||||
/const entryAllowed = !active && Boolean\(/,
|
||||
/const entryAllowed =\s*!active &&\s*Boolean\(/,
|
||||
'entry is impossible while already inside, unconditionally',
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
|
||||
@@ -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.
|
||||
@@ -165,7 +166,7 @@ function flattenRules(source) {
|
||||
rules.push({
|
||||
order: rules.length,
|
||||
media: [...mediaStack],
|
||||
parts: splitTopLevel(head, ',').map((part) => part.trim()).filter(Boolean),
|
||||
parts: splitTopLevel(head, ',').map((part) => part.trim().replace(/\(\s+/g, '(').replace(/\s+\)/g, ')')).filter(Boolean),
|
||||
decls,
|
||||
});
|
||||
index = close + 1;
|
||||
@@ -436,20 +437,21 @@ 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, layout.indexOf(' _scheduleAdaptivePanelLayout(', start));
|
||||
// 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;/);
|
||||
assert.match(writer, /const lowerPinnedHeight = [\s\S]{0,160}?getBoundingClientRect\(\)\.height \|\| 0;/);
|
||||
assert.match(writer, /const locationHeight =\s*[\s\S]{0,180}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
|
||||
assert.match(writer, /const presetsHeight =\s*[\s\S]{0,180}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
|
||||
assert.match(writer, /const lowerPinnedHeight =\s*[\s\S]{0,220}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
|
||||
assert.match(writer, /const locationHeightPx = Math\.ceil\(locationHeight\);/);
|
||||
assert.match(writer, /const presetsHeightPx = Math\.ceil\(presetsHeight\);/);
|
||||
assert.match(writer, /'--dock-location-pinned-height', `\$\{locationHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-presets-pinned-height', `\$\{presetsHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-lower-pinned-height', `\$\{Math\.ceil\(lowerPinnedHeight\)\}px`/);
|
||||
assert.match(writer, /'--dock-location-pinned-height',\s*`\$\{locationHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-presets-pinned-height',\s*`\$\{presetsHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-lower-pinned-height',\s*`\$\{Math\.ceil\(lowerPinnedHeight\)\}px`/);
|
||||
assert.match(writer, /'--dock-pinned-stack-height', stackHeight/);
|
||||
assert.match(writer, /const stackHeight = pinnedCount > 1[\s\S]{0,160}?`calc\(\$\{locationHeightPx\}px \+ \$\{presetsHeightPx\}px \+ 1\.2rem\)`/);
|
||||
assert.match(writer, /const stackHeight =\s*pinnedCount > 1[\s\S]{0,160}?`calc\(\$\{locationHeightPx\}px \+ \$\{presetsHeightPx\}px \+ 1\.2rem\)`/);
|
||||
});
|
||||
|
||||
test('the inputs behind the measured constants are unchanged', () => {
|
||||
|
||||
@@ -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';/,
|
||||
@@ -379,7 +379,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
|
||||
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
|
||||
assert.match(ui, /this\._models3dBtn\?\.setAttribute\('aria-pressed', String\(this\._models3dEnabled\)\)/,
|
||||
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\._models3dEnabled\),?\s*\)/,
|
||||
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only');
|
||||
assert.match(html, /class="pp-slider-row visible" id="models3d-mode-row"/,
|
||||
'index.html: and the Proximity/All row paints open with it');
|
||||
|
||||
@@ -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\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
||||
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'/);
|
||||
@@ -106,7 +107,7 @@ test('share-follow failures use the universal top-center status instead of the b
|
||||
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
|
||||
assert.match(handler, /canPresentDeferredStatusNotice\(/);
|
||||
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/);
|
||||
assert.match(handler, /result\.classification === 'followed' \|\| result\.classification === 'cancelled'/);
|
||||
assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/);
|
||||
});
|
||||
|
||||
test('universal notice masks active loading only for its own fixed dwell', () => {
|
||||
@@ -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,14 +236,14 @@ 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;/,
|
||||
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\) #global-loading-detail\s*\{\s*display:\s*none;/,
|
||||
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -655,7 +656,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s
|
||||
// Persistent ACQUIRING notices remain visible without a 60ms timer.
|
||||
assert.match(
|
||||
arm,
|
||||
/const noticeNeedsTicker = Number\.isFinite\(this\._globalStatusNotice\?\.hideAt\);[\s\S]*?if \(this\._loadingFeedbackState\?\.phase === 'idle' && !noticeNeedsTicker\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/,
|
||||
/const noticeNeedsTicker = Number\.isFinite\(\s*this\._globalStatusNotice\?\.hideAt,?\s*\);[\s\S]*?if \(\s*this\._loadingFeedbackState\?\.phase === 'idle'\s*&&\s*!noticeNeedsTicker\s*\) \{\s*this\._stopLoadingFeedbackTicker\(\);\s*\}/,
|
||||
'an idle phase with no expiring notice must stop the ticker',
|
||||
);
|
||||
assert.match(
|
||||
@@ -667,21 +668,21 @@ 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\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/,
|
||||
'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,
|
||||
/document\.removeEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/,
|
||||
/document\.removeEventListener\(\s*'visibilitychange',\s*this\._loadingVisibilityHandler,?\s*\);/,
|
||||
'the resample handler must be removed on teardown',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -5,7 +6,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). */
|
||||
@@ -24,7 +25,7 @@ test('the ACTIVE STYLE indicator is written from the style name and nothing else
|
||||
assert.equal(writes.length, 1, 'the style indicator must have exactly one writer');
|
||||
assert.match(
|
||||
ui.slice(writes[0].index, writes[0].index + 160),
|
||||
/this\._styleIndicator\.textContent = displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/,
|
||||
/this\._styleIndicator\.textContent =\s*displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/,
|
||||
);
|
||||
|
||||
const handler = locationSearchHandler();
|
||||
@@ -66,15 +67,15 @@ test('any other camera destination clears the search label too', () => {
|
||||
// Voice navigation, the globe reset, camera takeover and entity selection
|
||||
// all funnel through _stampNavigation; without a clear there, a searched
|
||||
// label outlives the place it named.
|
||||
const start = ui.indexOf(' _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) {');
|
||||
const start = ui.indexOf(' _stampNavigation(');
|
||||
assert.ok(start > 0, '_stampNavigation is missing');
|
||||
assert.match(ui.slice(start, start + 700), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/);
|
||||
assert.match(StyleManager.prototype._stampNavigation.toString(), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/);
|
||||
|
||||
// The shared funnel is what the reset and voice seams actually reach.
|
||||
for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(noun, navigate']) {
|
||||
for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(']) {
|
||||
const at = ui.indexOf(seam);
|
||||
assert.ok(at > 0, `missing navigation seam "${seam}"`);
|
||||
assert.match(ui.slice(at, at + 900), /_stampNavigation\(/, `"${seam}" must stamp navigation`);
|
||||
assert.match(StyleManager.prototype[seam.match(/^(\w+)/)[1]].toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`);
|
||||
}
|
||||
|
||||
// Public seam, so a camera owner that flies on its own can invalidate it.
|
||||
@@ -86,11 +87,11 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
|
||||
// the stamp blanked a still-true readout whenever the lookup failed, was
|
||||
// superseded, or was refused — no camera ever moved. The deferred begin opts
|
||||
// out; the reassert seam, reached only once the flight is granted, clears.
|
||||
const begin = ui.indexOf(' _beginDeferredNavigation(noun = ');
|
||||
const begin = ui.indexOf(' _beginDeferredNavigation(');
|
||||
assert.ok(begin > 0, '_beginDeferredNavigation is missing');
|
||||
assert.match(
|
||||
ui.slice(begin, begin + 700),
|
||||
/stamp: \(\) => this\._stampNavigation\(\{ cancelPendingSelection, clearSearchedLocation: false \}\)/,
|
||||
StyleManager.prototype._beginDeferredNavigation.toString(),
|
||||
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
|
||||
);
|
||||
|
||||
const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {');
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
||||
import test from 'node:test';
|
||||
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>/);
|
||||
const locationToggleAttributes = Object.fromEntries(
|
||||
[...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]),
|
||||
);
|
||||
const initStart = source.indexOf(' _initAutoHoverPanel(');
|
||||
const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart);
|
||||
const escapeStart = source.indexOf(' _collapsePanelOnEscape(');
|
||||
const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart);
|
||||
const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {');
|
||||
const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart);
|
||||
const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {');
|
||||
const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart);
|
||||
assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart
|
||||
&& closeStart >= 0 && closeEnd > closeStart);
|
||||
assert.ok(syncStart >= 0 && syncEnd > syncStart);
|
||||
|
||||
function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
||||
let now = 0;
|
||||
@@ -116,13 +106,15 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
||||
window.performance = { now: () => now };
|
||||
document.defaultView = window;
|
||||
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
|
||||
`return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)(
|
||||
`return ({${StyleManager.prototype._initAutoHoverPanel.toString()},\n${StyleManager.prototype._collapsePanelOnEscape.toString()},\n${StyleManager.prototype.setPanelCollapsed.toString()},\n${StyleManager.prototype._syncPanelCollapseButton.toString()}});`)(
|
||||
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {},
|
||||
);
|
||||
const saves = [];
|
||||
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(
|
||||
@@ -345,7 +346,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
||||
);
|
||||
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
|
||||
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
|
||||
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panel\.querySelector\('\.map-stack-chip'\)/);
|
||||
assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/);
|
||||
|
||||
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||
|
||||
@@ -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/,
|
||||
@@ -177,8 +178,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
||||
/panel !== displayPanel[\s\S]*?removeProperty\('--right-panel-allocated-height'\)[\s\S]*?const naturalHeight/,
|
||||
'right intrinsic measurement must retain Display allocation while clearing other panel allocations',
|
||||
);
|
||||
assert.match(css, /var\(--left-panel-allocated-height/);
|
||||
assert.match(css, /var\(--right-panel-allocated-height/);
|
||||
assert.match(css, /var\(\s*--left-panel-allocated-height/);
|
||||
assert.match(css, /var\(\s*--right-panel-allocated-height/);
|
||||
assert.match(
|
||||
css,
|
||||
/#left-panel-stack\.layout-focus > \[data-panel-id\]\.collapsed\s*\{\s*display:\s*none;/,
|
||||
@@ -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,11 +213,11 @@ 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/);
|
||||
assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
|
||||
assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
|
||||
assert.match(
|
||||
ui,
|
||||
@@ -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,
|
||||
@@ -242,13 +243,13 @@ test('expanded Display uses its container shell instead of a nested header card'
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(--right-collapsed-width, 132px\);/,
|
||||
/#pp-toggles\.collapsed \.pp-header-row\s*\{[\s\S]*?width:\s*var\(\s*--right-collapsed-width, 132px\);/,
|
||||
'collapsed Display must retain its standalone launcher sizing',
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -256,13 +257,13 @@ test('expanded left panels integrate their headers with the container shell', ()
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
/#left-panel-stack > \[data-panel-id\]:not\(\.collapsed\) \.panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -283,10 +284,10 @@ test('expanded right panels highlight the title divider without changing collaps
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
/#right-context-rail \[data-panel-id\]:not\(\.collapsed\) \.panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\);/,
|
||||
/#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\);/,
|
||||
);
|
||||
});
|
||||
@@ -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() {
|
||||
@@ -206,7 +207,7 @@ test('Radio volume and mission speed share the Sharpen slider visual language',
|
||||
assert.match(css, /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/);
|
||||
assert.match(css, /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/);
|
||||
assert.match(css, /\.gev-quantitative-slider:focus-visible\s*\{[\s\S]*?outline: 1px solid/);
|
||||
assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: \.42;[\s\S]*?cursor: not-allowed;/);
|
||||
assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: 0\.42;[\s\S]*?cursor: not-allowed;/);
|
||||
assert.match(css, /\.gev-slider-value\s*\{[\s\S]*?color: var\(--accent\);[\s\S]*?font-size: 9px;/);
|
||||
assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/);
|
||||
});
|
||||
@@ -244,13 +245,13 @@ test('Radio is nested inside Context with separate disclosure and power controls
|
||||
assert.match(css, /\.radio-tuner\.is-static/);
|
||||
assert.match(css, /\.radio-tuner-tick\s*\{/);
|
||||
assert.doesNotMatch(css, /radio-tuner-scale-(?:left|right)/);
|
||||
assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left \.18s ease-out;/);
|
||||
assert.match(css, /\.radio-tuner-needle\s*\{[\s\S]*?transition: left 0\.18s ease-out;/);
|
||||
assert.match(css, /\.radio-tuner\.is-dragging \.radio-tuner-needle,[\s\S]*?\.radio-tuner\.is-dragging \.radio-tuner-tick\s*\{\s*transition: none;/);
|
||||
assert.match(css, /\.radio-tuner\s*\{[\s\S]*?max-width: 100%;[\s\S]*?overflow: hidden;/);
|
||||
assert.match(css, /#radio-tuner-slider\s*\{[\s\S]*?max-width: 100%;[\s\S]*?touch-action: none;/);
|
||||
assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::before/);
|
||||
assert.match(css, /#title-bar\.radio-broadcasting \.title-logo::after/);
|
||||
assert.match(css, /--radio-broadcast-opacity: \.17/);
|
||||
assert.match(css, /--radio-broadcast-opacity: 0\.17/);
|
||||
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
|
||||
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
|
||||
assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
|
||||
@@ -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 \} = \{\}\)/);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { test } from 'node:test';
|
||||
@@ -6,9 +7,11 @@ 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 name = start.trim().match(/^(?:async )?(\w+)\(/)?.[1];
|
||||
if (typeof StyleManager.prototype[name] === 'function') return StyleManager.prototype[name].toString();
|
||||
const startIndex = uiSource.indexOf(start);
|
||||
const endIndex = uiSource.indexOf(end, startIndex + start.length);
|
||||
assert.ok(startIndex >= 0, `missing source block start: ${start}`);
|
||||
@@ -194,7 +197,7 @@ test('a shared view reserves its own camera without cancelling its saved Follow'
|
||||
);
|
||||
// The shared view's own `cancelPendingSelection` must reach the stamp; other
|
||||
// stamp options may ride alongside it.
|
||||
assert.match(deferred, /_stampNavigation\(\{ cancelPendingSelection[^)]*\}\)/);
|
||||
assert.match(deferred, /_stampNavigation\(\{\s*cancelPendingSelection[^)]*\}\)/);
|
||||
});
|
||||
|
||||
test('copy timestamp parsing is strict and rejects malformed or future values', () => {
|
||||
@@ -472,7 +475,7 @@ test('explicit Context transitions claim the visual restore lane before transiti
|
||||
'the Context authority helper must claim the visual lane',
|
||||
);
|
||||
|
||||
assert.match(uiSource, /claimVisualAuthority: \(\) => this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/);
|
||||
assert.match(uiSource, /claimVisualAuthority: \(\) =>\s*this\.shareLinkManager\?\.claimRestoreLane\?\.\('visual'\)/);
|
||||
const contextPanel = _initGlobalContextPanel.toString();
|
||||
for (const [start, end, label] of [
|
||||
["this.listen(this._globalContextFlightsBtn, 'click'", "this.listen(this._globalContextMissionsBtn, 'click'", 'Contacts tab'],
|
||||
|
||||
+41
-16
@@ -107,7 +107,8 @@ function positiveNumber(value, fallback) {
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
return typeof performance !== 'undefined' &&
|
||||
typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
@@ -235,7 +236,9 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) {
|
||||
const turnRatio = Number.isFinite(Number(options.turnRatio))
|
||||
? Number(options.turnRatio)
|
||||
: FLAP_TURN_RATIO;
|
||||
const elapsed = Number.isFinite(Number(elapsedMs)) ? Math.max(0, Number(elapsedMs)) : 0;
|
||||
const elapsed = Number.isFinite(Number(elapsedMs))
|
||||
? Math.max(0, Number(elapsedMs))
|
||||
: 0;
|
||||
const turn = charMs * turnRatio;
|
||||
let out = '';
|
||||
for (const cell of plan?.cells || []) {
|
||||
@@ -246,9 +249,11 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) {
|
||||
|
||||
/** Whether the viewer asked for reduced motion. */
|
||||
function prefersReducedMotion() {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,7 +279,11 @@ function isVisible(element) {
|
||||
if (element.getClientRects().length === 0) return false;
|
||||
const view = element.ownerDocument?.defaultView;
|
||||
if (typeof view?.getComputedStyle !== 'function') return true;
|
||||
for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
|
||||
for (
|
||||
let node = element;
|
||||
node && node.nodeType === 1;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const style = view.getComputedStyle(node);
|
||||
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
||||
if (Number(style.opacity) === 0) return false;
|
||||
@@ -301,10 +310,12 @@ function ensureHost(element) {
|
||||
// nothing changed.
|
||||
const text = element.firstElementChild;
|
||||
const cells = text?.nextElementSibling;
|
||||
if (text?.classList?.contains(TEXT_CLASS)
|
||||
&& text.firstChild?.nodeType === 3
|
||||
&& cells?.classList?.contains(CELLS_CLASS)
|
||||
&& !cells.nextElementSibling) {
|
||||
if (
|
||||
text?.classList?.contains(TEXT_CLASS) &&
|
||||
text.firstChild?.nodeType === 3 &&
|
||||
cells?.classList?.contains(CELLS_CLASS) &&
|
||||
!cells.nextElementSibling
|
||||
) {
|
||||
return { text, cells };
|
||||
}
|
||||
|
||||
@@ -363,7 +374,8 @@ function easeWidth(element, fromWidth, toWidth, durationMs) {
|
||||
element.style.width = `${toWidth}px`;
|
||||
|
||||
const finish = (event) => {
|
||||
if (event && (event.target !== element || event.propertyName !== 'width')) return;
|
||||
if (event && (event.target !== element || event.propertyName !== 'width'))
|
||||
return;
|
||||
cancelWidthEase(element);
|
||||
clearSizing(element);
|
||||
};
|
||||
@@ -449,16 +461,19 @@ export function setSplitFlapText(element, text, options = {}) {
|
||||
// previous label's glyphs, and those are what must flap away.
|
||||
const state = flapStates.get(element);
|
||||
const displayed = state
|
||||
? visibleGlyphs(state.plan, nowMs() - state.startedAt, { charMs: state.charMs })
|
||||
? visibleGlyphs(state.plan, nowMs() - state.startedAt, {
|
||||
charMs: state.charMs,
|
||||
})
|
||||
: settled;
|
||||
clearFlapTimer(element);
|
||||
|
||||
const beforeWidth = measureWidth(element);
|
||||
|
||||
const animate = SPLIT_FLAP_ENABLED
|
||||
&& options.immediate !== true
|
||||
&& !prefersReducedMotion()
|
||||
&& isVisible(element);
|
||||
const animate =
|
||||
SPLIT_FLAP_ENABLED &&
|
||||
options.immediate !== true &&
|
||||
!prefersReducedMotion() &&
|
||||
isVisible(element);
|
||||
|
||||
const plan = animate ? planSplitFlap(displayed, next, options) : null;
|
||||
|
||||
@@ -519,3 +534,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,153 @@
|
||||
/** 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,16 @@
|
||||
/** 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,485 @@
|
||||
/** Own rail measurement, layout scheduling and dock tray observation. */
|
||||
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
|
||||
const COCKPIT_LAYOUT_SETTLE_MS = 240;
|
||||
/**
|
||||
* Fixed UI regions that can occupy the left accordion's vertical lane.
|
||||
* Rectangles are filtered at runtime for visibility and horizontal overlap,
|
||||
* so right-side/center controls do not reduce the lane unless they actually
|
||||
* intersect it at the current viewport size.
|
||||
*/
|
||||
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(', ');
|
||||
/**
|
||||
* Fixed UI regions that can occupy the right control lane. Runtime rectangle
|
||||
* filtering keeps the rail clear of whichever HUD variant is currently
|
||||
* visible without tying the layout to one screen height.
|
||||
*/
|
||||
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,294 @@
|
||||
/** Own panel position preferences, viewport clamping and drag listeners. */
|
||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||
/**
|
||||
* Position keys are versioned separately from collapsed-state keys so layout
|
||||
* default changes (e.g. right-rail origin) can reset positions without also
|
||||
* resetting every panel's open/closed preference.
|
||||
*/
|
||||
const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
||||
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
|
||||
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) {
|
||||
if (this.destroyed || !target) return;
|
||||
const listener = (event) => {
|
||||
if (!this.destroyed) callback(event);
|
||||
};
|
||||
target.addEventListener(type, listener);
|
||||
this.removers.push(() => target.removeEventListener(type, listener));
|
||||
}
|
||||
_reclampDraggablePanels() {
|
||||
if (this.destroyed) return;
|
||||
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() {
|
||||
if (this.destroyed) return;
|
||||
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();
|
||||
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,97 @@
|
||||
/** 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,177 @@
|
||||
/** 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,208 @@
|
||||
/** 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,269 @@
|
||||
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);
|
||||
owner._makePanelDraggable('sample', panel, handle);
|
||||
owner._initPanelDrag();
|
||||
owner._ppToggles.style.top = '10000px';
|
||||
owner._reclampDraggablePanels();
|
||||
assert.equal(owner._ppToggles.style.top, '10000px');
|
||||
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,351 @@
|
||||
/* ── 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,433 @@
|
||||
/* ── 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,698 @@
|
||||
/* ── 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,511 @@
|
||||
/* ── 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,309 @@
|
||||
/* ── 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,383 @@
|
||||
/* ── 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
+624
@@ -0,0 +1,624 @@
|
||||
/* ═══════════════════════════════════════════════
|
||||
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: 0.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: 0.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: 0.28s;
|
||||
}
|
||||
|
||||
@keyframes title-radio-broadcast {
|
||||
0%,
|
||||
100% {
|
||||
opacity: calc(var(--radio-broadcast-opacity) * 0.55);
|
||||
transform: translateX(-50%) scale(0.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,300 @@
|
||||
/* ── 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,373 @@
|
||||
/* ── 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,395 @@
|
||||
/* ============================================================
|
||||
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,802 @@
|
||||
/* ── 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, 0.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, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(2, 15, 20, 0.42);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
#radio-panel .panel-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.radio-layer-state {
|
||||
color: var(--text-dim);
|
||||
font: 8px/1 var(--font-mono);
|
||||
letter-spacing: 0.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: 0.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, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #c5faff;
|
||||
background: rgba(5, 24, 31, 0.88);
|
||||
font: 9px/1.2 var(--font-mono);
|
||||
}
|
||||
.radio-station-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(65, 220, 231, 0.18);
|
||||
border-radius: 7px;
|
||||
background: rgba(2, 15, 20, 0.48);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.radio-station-card strong {
|
||||
color: #e8feff;
|
||||
font: 11px/1.25 var(--font-mono);
|
||||
letter-spacing: 0.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, 0.22);
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(1, 9, 13, 0.92),
|
||||
rgba(4, 24, 30, 0.72)
|
||||
);
|
||||
box-shadow: inset 0 0 18px rgba(0, 212, 255, 0.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: 0.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, 0.3);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
transparent 23px,
|
||||
rgba(65, 220, 231, 0.8) 24px,
|
||||
transparent 25px,
|
||||
transparent 28px,
|
||||
rgba(65, 220, 231, 0.6) 29px,
|
||||
transparent 30px
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(39, 177, 191, 0.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, 0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
.radio-tuner-tick {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 0;
|
||||
min-width: 1px;
|
||||
border-left: 1px solid rgba(85, 224, 235, 0.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, 0.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, 0.65);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: left 0.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, 0.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, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #8feaf2;
|
||||
background: rgba(14, 69, 78, 0.14);
|
||||
font: 8px/1 var(--font-mono);
|
||||
letter-spacing: 0.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: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#radio-play-btn.active {
|
||||
color: #fff;
|
||||
border-color: rgba(101, 247, 255, 0.8);
|
||||
background: rgba(65, 220, 231, 0.18);
|
||||
box-shadow: 0 0 12px rgba(65, 220, 231, 0.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: 0.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: 0.06em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.radio-links a:hover,
|
||||
.radio-links a:focus-visible {
|
||||
color: #fff;
|
||||
}
|
||||
.radio-privacy {
|
||||
margin: 0;
|
||||
color: rgba(190, 230, 230, 0.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,32 @@
|
||||
/* ── 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,215 @@
|
||||
/* ── 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,440 @@
|
||||
/* ── 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,390 @@
|
||||
/* ── 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,79 @@
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 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