Merge current main into S2 GhostMaps and preserve updated import and voice behavior

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-26 16:25:49 +01:00
commit 4da002bd42
515 files changed
+17681 -2202

No files matched your search

-1
View File
@@ -70,7 +70,6 @@ export class AircraftDisplay {
// a screen reader without this. It matters more now that the button ships
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
// tech reported was an unpressed-looking control over an armed layer.
// Mirrors #scope-toggle, which has always carried aria-pressed.
this._models3dBtn?.setAttribute(
'aria-pressed',
String(this._models3dEnabled),
+49
View File
@@ -3,6 +3,7 @@ import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
import { LayerBindings } from './layerBindings.js';
import { PanelChrome } from './panelChrome.js';
import { initMobileShell } from './mobileShell.js';
import { VisualSettings } from './visualSettings.js';
import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
@@ -124,6 +125,8 @@ export class StyleManager extends ShellFacade {
readInitialShare: () => this._initialShareState,
readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner,
readDisplayScrollTop: () => this._standardDisplayScrollTop,
readSharedCollapsed: (panelId) =>
this._mobileShell?.sharedCollapsed(panelId),
});
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
@@ -144,6 +147,7 @@ export class StyleManager extends ShellFacade {
},
searchInput: this._locationSearch,
interruptCameraMotion: services.interruptCameraMotion,
interruptGlobeRotation: services.interruptGlobeRotation,
isCockpitActive: () => !!this.cockpitView?.active,
clearLocation: () => this.clearSearchedLocation(),
cancelShareSelection: () => this._shareRestoration.cancelSelection(),
@@ -319,6 +323,7 @@ export class StyleManager extends ShellFacade {
GLOBE_VIEW: services.GLOBE_VIEW,
flyToGlobeView: services.flyToGlobeView,
interruptCameraMotion: services.interruptCameraMotion,
interruptGlobeRotation: services.interruptGlobeRotation,
flightsLayer: services.flightsLayer,
militaryFlightsLayer: services.militaryFlightsLayer,
satellitesLayer: services.satellitesLayer,
@@ -587,6 +592,7 @@ export class StyleManager extends ShellFacade {
this._initStreetLevelPanel();
this._initGlobalContextPanel();
this._initLocationBar();
this._initMobileShell();
this._initShareButton();
this._initCameraOrientationControls();
this._initClearSelectedLayersButton();
@@ -848,6 +854,48 @@ export class StyleManager extends ShellFacade {
}
}
/**
* Phone layout: a bottom tab bar presenting one panel sheet at a time
* (src/ui/mobileShell.js). Inactive above 720px.
* @returns {void}
*/
_initMobileShell() {
this._mobileShell = initMobileShell({
setPanelCollapsed: (panelId, collapsed, options) =>
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
onSheetsClosed: () => this._syncShareState(),
onTabOpened: (sheet) => {
void this._onMobileTabOpened(sheet);
},
});
}
/** Reveal a tracked aircraft through Contacts when its phone tab is opened. */
async _onMobileTabOpened(sheet) {
const context = this.getContextModeState();
if (
sheet !== 'context' ||
this._disposed ||
!context ||
context.mode ||
context.changing ||
context.entering ||
!this.getAircraftTrackingTarget()
)
return false;
try {
const result = await this.setContextMode('contacts');
if (result?.ok) return true;
if (result?.cancelled || this._disposed) return false;
} catch {
if (this._disposed) return false;
}
this._showToast(
t('Contacts could not complete the requested transition; try again'),
);
return false;
}
/**
* Renders the owner-approved map stack chip row from the matching controller
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
@@ -1707,6 +1755,7 @@ export class StyleManager extends ShellFacade {
this._localePicker?.destroy();
this._localePicker = null;
this._recording.destroy();
this._mobileShell?.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
+2 -2
View File
@@ -107,7 +107,7 @@ export function readCameraTargetFrame(viewer) {
}
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
export function setCameraTargetFrame(viewer, frame) {
function setCameraTargetFrame(viewer, frame) {
const camera = viewer?.camera;
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
try {
@@ -156,7 +156,7 @@ export function setCameraTargetFrame(viewer, frame) {
*
* @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}.
*/
export function frameIsTilted(frame) {
function frameIsTilted(frame) {
const pitch = Number(frame?.pitch);
return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false;
}
+3 -2
View File
@@ -20,6 +20,7 @@ const SOURCE_KIND_LABELS = Object.freeze({
});
const SOURCE_STATUS_LABELS = Object.freeze({
ok: msg('OK'),
degraded: msg('DEGRADED'),
error: msg('ERROR'),
unknown: msg('UNKNOWN'),
@@ -45,14 +46,14 @@ const SOURCE_LABELS = Object.freeze({
});
/** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */
export function cctvSourceKindLabel(kind) {
function cctvSourceKindLabel(kind) {
const key = String(kind || 'unknown').toLowerCase();
return SOURCE_KIND_LABELS[key]
? t(SOURCE_KIND_LABELS[key])
: key.toUpperCase();
}
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
export function cctvSourceStatusLabel(status) {
const key = String(status || 'unknown').toLowerCase();
return SOURCE_STATUS_LABELS[key]
+2 -2
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', {
label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
}),
installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
+2 -1
View File
@@ -64,7 +64,8 @@ export function onKeyDown(event) {
event.preventDefault();
event.stopImmediatePropagation();
if (!this.active && !this.isEntryAllowed()) return;
const changed = this.active ? this.exit() : this.enter();
if (this.active) this.exit();
else this.enter();
return;
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ import {
formatAltitudeRulerTick,
formatCompassDivision,
formatCockpitEmergency,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -238,7 +239,7 @@ export function updateRoute(info) {
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
bearing: `${formatHeadingReadout(destinationBearing)}°`,
});
}
}
+7 -4
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -145,17 +145,20 @@ export function formatCockpitCoordinate(value, positive, negative) {
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
return formatQuantity(distanceM / 1000, 'km', {
const distance = formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).toUpperCase();
});
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
}
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
// 359.5° and above is north. Modulo keeps that from printing 360°.
const degrees = Math.round(normalized) % 360;
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
+10
View File
@@ -0,0 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatCockpitWindDirection } from './cockpitPresentation.js';
test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
assert.equal(formatCockpitWindDirection(0), 'N · 0°');
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
});
+5 -1
View File
@@ -9,7 +9,10 @@ import {
flyToPOI,
searchAndFlyTo,
} from '../locations.js';
import { interruptCameraMotion } from '../cameraVerbs.js';
import {
interruptCameraMotion,
interruptGlobeRotation,
} from '../cameraVerbs.js';
import { IntelHUD } from '../hud.js';
import { ShareLinkManager } from '../sharelink.js';
import { OrbitController } from '../orbit.js';
@@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell {
flyToPOI,
searchAndFlyTo,
interruptCameraMotion,
interruptGlobeRotation,
IntelHUD,
ShareLinkManager,
OrbitController,
+30
View File
@@ -1,3 +1,4 @@
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(voice.owner.activeStyle, 'snow');
});
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
const { owner } = styleOwner(t, 'tactical');
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
owner._scopeBtn = { checked: true };
owner._scopeFeatherSlider = { value: '11', disabled: false };
owner._scopeFeatherValue = {};
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
assert.equal(owner._scopeBtn.checked, false);
assert.equal(owner._scopeFeatherSlider.disabled, true);
assert.equal(owner._scopeFeatherSlider.value, '200');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
owner._setHudVariant(layout, { applyVisualDefaults: true });
assert.equal(isScopeMaskEnabled(), false, layout);
assert.equal(getScopeMaskFeather(), 2, layout);
assert.equal(owner._scopeBtn.checked, false, layout);
}
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
assert.equal(isScopeMaskEnabled(), true);
assert.equal(getScopeMaskFeather(), 1.75);
assert.equal(owner._scopeBtn.checked, true);
assert.equal(owner._scopeFeatherSlider.disabled, false);
assert.equal(owner._scopeFeatherValue.textContent, '175%');
await owner.applyVisualState({ scope: { featherPct: 500 } });
assert.equal(getScopeMaskFeather(), 2);
assert.equal(owner._scopeFeatherSlider.value, '200');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
+12 -8
View File
@@ -1,6 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls } from './displayControls.js';
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
import { canonicalizeDensity } from '../data/detectionPolicy.js';
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
@@ -92,6 +93,11 @@ export class DisplayBindings {
},
});
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
isScopeMaskEnabled(),
);
this._displayControls?.destroy();
this._displayControls = bindDisplayControls({
elements: {
@@ -100,7 +106,7 @@ export class DisplayBindings {
bloomSlider: this._bloomSlider,
sharpenButton: this._sharpenBtn,
sharpenSlider: this._sharpenSlider,
scopeButton: this._scopeBtn,
scopeToggle: this._scopeBtn,
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
@@ -134,17 +140,15 @@ export class DisplayBindings {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setSharpenEnabled(!this.sharpenEnabled);
},
toggleScope: () => {
setScopeEnabled: (enabled) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const next = !isScopeMaskEnabled();
setScopeMaskEnabled(next);
this._scopeBtn.classList.toggle('active', next);
this._scopeBtn.setAttribute('aria-pressed', String(next));
setScopeMaskEnabled(enabled);
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
this._syncShareState();
},
setScopeFeather: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const pct = Math.max(0, Math.min(100, value || 0));
const pct = clampScopeFeatherPct(value);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
setScopeMaskFeather(pct / 100);
+7 -1
View File
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
for (const [name, action] of [
['bloomButton', 'toggleBloom'],
['sharpenButton', 'toggleSharpen'],
['scopeButton', 'toggleScope'],
['cleanViewButton', 'toggleCleanView'],
['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'],
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
['scopeFeatherSlider', 'setScopeFeather'],
])
listen(elements[name], 'input', action, integer);
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
},
};
}
/** Keep the native Scope checkbox and feather availability in step with the mask. */
export function syncScopeControls(toggle, featherSlider, enabled) {
if (toggle) toggle.checked = enabled;
if (featherSlider) featherSlider.disabled = !enabled;
}
+26
View File
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
assert.deepEqual(calls, ['b']);
two.destroy();
});
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
const scopeToggle = element();
scopeToggle.checked = true;
const scopeFeatherSlider = element('200');
const calls = [];
const controls = bindDisplayControls({
elements: { scopeToggle, scopeFeatherSlider },
actions: {
setScopeEnabled: (value) => calls.push(['enabled', value]),
setScopeFeather: (value) => calls.push(['feather', value]),
},
});
scopeToggle.checked = false;
scopeToggle.dispatchEvent(new Event('change'));
scopeToggle.checked = true;
scopeToggle.dispatchEvent(new Event('change'));
scopeFeatherSlider.dispatchEvent(new Event('input'));
controls.destroy();
scopeToggle.dispatchEvent(new Event('change'));
assert.deepEqual(calls, [
['enabled', false],
['enabled', true],
['feather', 200],
]);
});
+1 -1
View File
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
if (!host || !frameEvent) return { destroy() {} };
const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout';
readout.className = 'frame-rate-readout scene-text-holdout';
readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
+4 -4
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es'],
['ru', 'en', 'es', 'zh-CN'],
);
assert.deepEqual(
codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru'],
['es', 'en', 'ru', 'zh-CN'],
);
assert.deepEqual(
codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru'],
['en', 'es', 'ru', 'zh-CN'],
);
const rows = buildLocaleOptions({
locales: MANY,
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'es');
assert.equal(model.activeOption.code, 'zh-CN');
model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' });
+1
View File
@@ -283,6 +283,7 @@ export class LocationNavigation {
return;
}
this.services.interruptGlobeRotation?.('orbit-intent');
const isActive = this.orbitController.toggle(this._currentTarget, {
radius: this._currentPoi?.alt || 500,
pitch: this._currentPoi?.pitch || -30,
+520
View File
@@ -0,0 +1,520 @@
/**
* Phone shell for narrow viewports (≤720px). The globe stays full screen and a
* bottom tab bar opens one sheet at a time; each sheet shows existing panels,
* which stay where they are in the rails and dock while CSS presents them
* (styles/mobile-shell.css). Desktop never sees the class or the sheet state.
*
* Sheets borrow panels: the panels a sheet expands are put back exactly as
* they were when the sheets close, and nothing a sheet does is persisted or
* treated as the user's own panel choice.
*
* Adapted from the phone tab-shell proposed in public PR #203 by blearymoth.
*/
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
import { msg, t } from '../i18n/index.js';
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
export const MOBILE_SHELL_CLASS = 'mobile-shell';
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
/**
* Panels each sheet expands. A sheet also shows its rail's other panels
* collapsed (Scenes under Layers), per styles/mobile-shell.css.
*/
export const MOBILE_SHEET_PANELS = Object.freeze({
layers: Object.freeze(['data-panel']),
cameras: Object.freeze(['cctv-panel', 'street-level-panel']),
context: Object.freeze(['global-context-panel']),
search: Object.freeze(['location-bar']),
more: Object.freeze(['control-panel', 'pp-toggles']),
});
export const MOBILE_TABS = Object.freeze([
Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }),
Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }),
Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }),
Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }),
Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }),
]);
/**
* Surfaces that own the screen close the open sheet and keep sheets closed.
* Scene playback is the exception: its STOP control lives in the Scenes panel
* (Layers sheet), and a phone has no Escape key to stop it otherwise.
*/
const SHEET_BLOCKING_CLASSES = Object.freeze(
EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'),
);
/** Every panel any sheet borrows; the dock trays close each other. */
const BORROWED_PANELS = Object.freeze([
...new Set(Object.values(MOBILE_SHEET_PANELS).flat()),
]);
const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false });
/**
* @param {string} panelId
* @returns {string|null} The sheet that presents `panelId`.
*/
export function sheetForPanelId(panelId) {
for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) {
if (ids.includes(panelId)) return sheet;
}
return null;
}
/**
* The sheet after a tab press: the same tab closes its sheet, another opens.
* @param {string|null} current
* @param {string|null} pressed
* @returns {string|null}
*/
export function nextSheet(current, pressed) {
if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null;
return current === pressed ? null : pressed;
}
function sheetBlocked(body) {
return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name));
}
function currentSheetOf(body) {
const value = body.getAttribute(MOBILE_SHEET_ATTR);
return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null;
}
function buildChrome(documentRef) {
const backdrop = documentRef.createElement('div');
backdrop.id = 'mobile-sheet-backdrop';
backdrop.hidden = true;
const tabBar = documentRef.createElement('nav');
tabBar.id = 'mobile-tab-bar';
tabBar.setAttribute('aria-label', t('Panels'));
for (const tab of MOBILE_TABS) {
const button = documentRef.createElement('button');
button.type = 'button';
button.className = 'mobile-tab';
button.dataset.mobileSheet = tab.sheet;
button.setAttribute('aria-expanded', 'false');
const icon = documentRef.createElement('span');
icon.className = 'material-symbols-outlined mobile-tab-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = tab.icon;
const label = documentRef.createElement('span');
label.className = 'mobile-tab-label';
label.textContent = t(tab.label);
button.append(icon, label);
tabBar.append(button);
}
documentRef.body.append(backdrop, tabBar);
return { backdrop, tabBar };
}
/**
* Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus,
* and closing for full-screen modes.
* @param {object} input
* @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed
* @param {Document} [input.documentRef]
* @param {(query: string) => MediaQueryList} [input.matchMedia]
* @param {typeof MutationObserver} [input.MutationObserverImpl]
* @param {typeof ResizeObserver} [input.ResizeObserverImpl]
* @param {(callback: () => void) => number} [input.requestFrame]
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
* and its panels are put back (e.g. to refresh the share link).
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
* opens a sheet from its tab, never on close or programmatic presentation.
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
*/
export function initMobileShell({
setPanelCollapsed,
documentRef = globalThis.document,
matchMedia = globalThis.matchMedia?.bind(globalThis),
MutationObserverImpl = globalThis.MutationObserver,
ResizeObserverImpl = globalThis.ResizeObserver,
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
onSheetsClosed = () => {},
onTabOpened = () => {},
} = {}) {
const body = documentRef.body;
const { backdrop, tabBar } = buildChrome(documentRef);
const viewport = documentRef.querySelector('meta[name="viewport"]');
const viewportContent = viewport?.getAttribute('content') ?? null;
const removers = [];
const listen = (target, type, handler, options) => {
target.addEventListener(type, handler, options);
removers.push(() => target.removeEventListener(type, handler, options));
};
const element = (id) => documentRef.getElementById(id);
const rails = ['left-panel-stack', 'right-context-rail']
.map(element)
.filter(Boolean);
const dock = element('command-dock');
const shown = (node) =>
node && globalThis.getComputedStyle?.(node).display !== 'none';
/**
* Panel states from before the first sheet opened (`before`) and right after
* the open sheet was set up (`applied`); null when closed.
*/
let borrowed = null;
let returnFocus = null;
let swipe = null;
const written = new Map();
const writeVar = (name, value) => {
if (written.get(name) === value) return;
written.set(name, value);
body.style.setProperty(name, value);
};
// The dock (Presets, Search) sits on top of whichever rail is the sheet,
// and the required map attribution sits above everything the sheet covers.
const measureSheet = () => {
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
const height = Math.max(
0,
...rails.map((rail) =>
shown(rail) ? rail.getBoundingClientRect().height : 0,
),
);
writeVar('--mobile-sheet-height', `${Math.round(height)}px`);
const viewportHeight = globalThis.innerHeight || 0;
const cover = Math.max(
0,
...[...rails, currentSheetOf(body) ? dock : null]
.filter(shown)
.map((node) => viewportHeight - node.getBoundingClientRect().top),
);
writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`);
};
const resize =
typeof ResizeObserverImpl === 'function'
? new ResizeObserverImpl(measureSheet)
: null;
removers.push(() => resize?.disconnect());
const isCollapsed = (panelId) =>
element(panelId)?.classList.contains('collapsed') ?? true;
// The user's preference, as share links record it: a collapse that layout
// applies only for space (Tactical, Cyber) still counts as expanded.
const prefersCollapsed = (panelId) => {
const classes = element(panelId)?.classList;
if (!classes) return true;
return (
classes.contains('collapsed') &&
!classes.contains('layout-auto-collapsed') &&
!classes.contains('cyber-accordion-collapsed')
);
};
const panelStates = () =>
new Map(
BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]),
);
// Undo only what opening the sheet did (including side effects, such as dock
// trays closing each other). A panel the user changed since stays as they
// left it, and becomes the state later sheets put back.
const restoreBorrowed = () => {
if (!borrowed) return;
for (const [panelId, before] of borrowed.before) {
const now = prefersCollapsed(panelId);
if (now !== borrowed.applied.get(panelId))
borrowed.before.set(panelId, now);
else if (now !== before)
setPanelCollapsed(panelId, before, BORROW_OPTIONS);
}
};
const present = (sheet) => {
if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet);
else body.removeAttribute(MOBILE_SHEET_ATTR);
backdrop.hidden = !sheet;
for (const button of tabBar.querySelectorAll('[data-mobile-sheet]'))
button.setAttribute(
'aria-expanded',
String(button.dataset.mobileSheet === sheet),
);
measureSheet();
};
/** The first visible control of the sheet's first panel, for focus on open. */
const sheetFocusTarget = (sheet) => {
if (sheet === 'search') return element('location-search');
const panel = element(MOBILE_SHEET_PANELS[sheet][0]);
const controls =
panel?.querySelectorAll?.(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [];
// Sheets hide some panel chrome (tray toggles, pins) that stays in the
// DOM, and a panel's own collapse control is a poor first stop.
return (
[...controls].find(
(control) =>
control.getClientRects().length > 0 &&
!control.matches?.('.panel-collapse-btn'),
) ?? null
);
};
const setSheet = (requested) => {
swipe = null;
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
const sheet = sheetBlocked(body) ? null : requested;
const previous = currentSheetOf(body);
if (previous === sheet) return;
if (!previous) {
borrowed = { before: panelStates(), applied: panelStates() };
returnFocus = documentRef.activeElement ?? null;
} else {
if (previous === 'search') element('location-search')?.blur?.();
restoreBorrowed();
}
present(sheet);
if (!sheet) {
borrowed = null;
onSheetsClosed();
const focus = returnFocus;
returnFocus = null;
if (focus && focus !== body && body.contains?.(focus)) focus.focus?.();
return;
}
for (const panelId of MOBILE_SHEET_PANELS[sheet])
if (isCollapsed(panelId))
setPanelCollapsed(panelId, false, BORROW_OPTIONS);
borrowed.applied = panelStates();
// Rail layout settles on a later frame; what it does is part of opening.
const opening = (borrowed.opening ?? 0) + 1;
borrowed.opening = opening;
const opened = borrowed;
requestFrame?.(() => {
if (borrowed === opened && opened.opening === opening)
opened.applied = panelStates();
});
measureSheet();
sheetFocusTarget(sheet)?.focus?.();
};
const tabFor = (sheet) =>
[...tabBar.querySelectorAll('[data-mobile-sheet]')].find(
(button) => button.dataset.mobileSheet === sheet,
);
const onMedia = (matches) => {
if (matches) {
body.classList.add(MOBILE_SHELL_CLASS);
// Lets the page draw under the notch and system bars; the shell's CSS
// keeps its chrome clear of them.
if (viewport && !/viewport-fit\s*=/.test(viewportContent || ''))
viewport.setAttribute(
'content',
`${viewportContent || ''}, viewport-fit=cover`,
);
for (const node of [...rails, dock].filter(Boolean))
resize?.observe(node);
measureSheet();
} else {
// Crossing to desktop closes the sheet, which puts its panels back.
setSheet(null);
body.classList.remove(MOBILE_SHELL_CLASS);
present(null);
resize?.disconnect();
if (viewport && viewportContent !== null)
viewport.setAttribute('content', viewportContent);
}
tabBar.setAttribute('aria-hidden', String(!matches));
};
const media =
typeof matchMedia === 'function'
? matchMedia(MOBILE_SHELL_MEDIA_QUERY)
: null;
onMedia(Boolean(media?.matches));
const onChange = (event) => onMedia(event.matches);
if (media?.addEventListener) {
media.addEventListener('change', onChange);
removers.push(() => media.removeEventListener('change', onChange));
} else if (media?.addListener) {
// Safari before 14 only offers the legacy listener API.
media.addListener(onChange);
removers.push(() => media.removeListener(onChange));
}
listen(tabBar, 'click', (event) => {
const button = event.target?.closest?.('[data-mobile-sheet]');
if (!button) return;
event.preventDefault();
const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet);
// Closing from the tab bar returns focus to the tab that was pressed.
if (!sheet) returnFocus = button;
setSheet(sheet);
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
});
listen(backdrop, 'click', () => setSheet(null));
// Touch events let native scrolling continue until a downward pull at the
// top is intentional. Pointer capture/touch-action:none would also seize
// ordinary vertical scrolling inside the borrowed panels.
const sheetRoot = (target, sheet) => {
const roots =
sheet === 'layers'
? [element('left-panel-stack')]
: sheet === 'search'
? [dock]
: sheet === 'more'
? [element('right-context-rail'), dock]
: [element('right-context-rail')];
return roots.find((root) => root?.contains(target));
};
const scrolled = (target, root) => {
for (let node = target; node; node = node.parentElement) {
if (node.scrollTop > 0) return true;
if (node === root) break;
}
return false;
};
listen(
documentRef,
'touchstart',
(event) => {
swipe = null;
const sheet = currentSheetOf(body);
const target = event.target;
if (
!sheet ||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
event.defaultPrevented ||
event.touches.length !== 1 ||
target?.closest?.(
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
)
)
return;
const root = sheetRoot(target, sheet);
if (!root || scrolled(target, root)) return;
const touch = event.touches[0];
swipe = {
sheet,
target,
root,
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
claimed: false,
};
},
{ passive: true },
);
listen(
documentRef,
'touchmove',
(event) => {
if (!swipe) return;
const touch = event.touches[0];
if (
event.defaultPrevented ||
event.touches.length !== 1 ||
touch.identifier !== swipe.id ||
currentSheetOf(body) !== swipe.sheet ||
scrolled(swipe.target, swipe.root)
) {
swipe = null;
return;
}
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
if (!swipe.claimed) {
// Claim direction before the browser crosses its native scroll slop.
// Android can make the next move non-cancelable after a ~9px first move.
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
swipe = null;
return;
}
swipe.claimed = true;
}
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
// native, and suppresses the synthetic click at the end of a dismissal.
event.preventDefault();
},
{ passive: false },
);
listen(
documentRef,
'touchend',
(event) => {
const pull = swipe;
swipe = null;
if (!pull?.claimed || event.touches.length) return;
const touch = [...event.changedTouches].find(
(item) => item.identifier === pull.id,
);
if (!touch || currentSheetOf(body) !== pull.sheet) return;
const dx = Math.abs(touch.clientX - pull.x);
const dy = touch.clientY - pull.y;
if (dy < 64 || dy <= dx * 1.25) return;
returnFocus = tabFor(pull.sheet) ?? returnFocus;
setSheet(null);
},
{ passive: true },
);
listen(
documentRef,
'touchcancel',
() => {
swipe = null;
},
{ passive: true },
);
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
// dialog keeps its own native close.
listen(documentRef, 'keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
const sheet = currentSheetOf(body);
if (!sheet || documentRef.querySelector('dialog[open]')) return;
event.preventDefault();
returnFocus = tabFor(sheet) ?? returnFocus;
setSheet(null);
});
if (typeof MutationObserverImpl === 'function') {
const observer = new MutationObserverImpl(() => {
if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null);
});
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
removers.push(() => observer.disconnect());
}
return {
setSheet,
getSheet: () => currentSheetOf(body),
/**
* The collapsed state to share for a panel a sheet borrowed: its state
* before the sheets, or the user's own change since. Undefined otherwise.
*/
sharedCollapsed(panelId) {
if (!borrowed?.before.has(panelId)) return undefined;
const now = prefersCollapsed(panelId);
return now !== borrowed.applied.get(panelId)
? now
: borrowed.before.get(panelId);
},
destroy() {
setSheet(null);
for (const remove of removers.splice(0)) remove();
body.classList.remove(MOBILE_SHELL_CLASS);
body.removeAttribute(MOBILE_SHEET_ATTR);
body.style.removeProperty('--mobile-sheet-height');
body.style.removeProperty('--mobile-sheet-cover');
if (viewport && viewportContent !== null)
viewport.setAttribute('content', viewportContent);
backdrop.remove();
tabBar.remove();
},
};
}
+865
View File
@@ -0,0 +1,865 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
import { ContextControls } from './contextControls.js';
import {
MOBILE_SHEET_PANELS,
MOBILE_TABS,
initMobileShell,
nextSheet,
sheetForPanelId,
} from './mobileShell.js';
function element(tag, document) {
const node = new EventTarget();
const attributes = new Map();
const classes = new Set();
Object.assign(node, {
tagName: tag.toUpperCase(),
children: [],
dataset: {},
hidden: false,
textContent: '',
className: '',
parent: null,
scrollTop: 0,
parentElement: null,
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
},
style: {
values: new Map(),
setProperty(name, value) {
this.values.set(name, value);
},
removeProperty(name) {
this.values.delete(name);
},
},
setAttribute: (key, value) => attributes.set(key, String(value)),
getAttribute: (key) => attributes.get(key) ?? null,
removeAttribute: (key) => attributes.delete(key),
append(...children) {
for (const child of children) {
child.parent = node;
child.parentElement = node;
node.children.push(child);
if (child.id) document.ids.set(child.id, child);
}
},
remove() {
const siblings = node.parent?.children;
siblings?.splice(siblings.indexOf(node), 1);
document.ids.delete(node.id);
},
contains(other) {
for (let current = other; current; current = current.parent)
if (current === node) return true;
return false;
},
closest(selector) {
for (let current = node; current; current = current.parent) {
if (
selector.startsWith('button, a') &&
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
current.tagName,
) ||
['slider', 'button'].includes(current.getAttribute('role')) ||
(current.getAttribute('contenteditable') !== null &&
current.getAttribute('contenteditable') !== 'false'))
)
return current;
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
return current;
}
return null;
},
querySelectorAll(selector) {
const found = [];
const matches = (child) =>
selector === '[data-mobile-sheet]'
? Boolean(child.dataset.mobileSheet)
: child.tagName === 'BUTTON';
const walk = (parent) => {
for (const child of parent.children) {
if (matches(child)) found.push(child);
walk(child);
}
};
walk(node);
return found;
},
getBoundingClientRect: () => ({ top: 0, height: 0 }),
getClientRects: () => (node.hidden ? [] : [{}]),
matches: (selector) =>
selector === '.panel-collapse-btn' &&
node.className.split(' ').includes('panel-collapse-btn'),
focus() {
document.activeElement = node;
},
blur() {
if (document.activeElement === node)
document.activeElement = document.body;
},
});
return node;
}
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
function fixture({
mobile = true,
expanded = [],
onTabOpened = () => {},
} = {}) {
const document = new EventTarget();
document.ids = new Map();
document.createElement = (tag) => element(tag, document);
document.getElementById = (id) => document.ids.get(id) ?? null;
const viewport = element('meta', document);
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
document.querySelector = (selector) =>
selector === 'meta[name="viewport"]'
? viewport
: selector === 'dialog[open]' && document.openDialog
? {}
: null;
document.body = element('body', document);
document.activeElement = document.body;
const search = element('input', document);
search.id = 'location-search';
document.body.append(search);
const rails = {};
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
rails[id] = element('div', document);
rails[id].id = id;
document.body.append(rails[id]);
}
for (const id of PANEL_IDS) {
const panel = element('section', document);
panel.id = id;
if (!expanded.includes(id)) panel.classList.add('collapsed');
// A hidden tray toggle and a collapse control first, like the panels'
// own chrome in a sheet.
const toggle = element('button', document);
toggle.hidden = true;
const collapse = element('button', document);
collapse.className = 'panel-collapse-btn';
panel.append(toggle, collapse, element('button', document));
const root =
id === 'data-panel'
? 'left-panel-stack'
: ['location-bar', 'control-panel'].includes(id)
? 'command-dock'
: 'right-context-rail';
rails[root].append(panel);
}
const media = new EventTarget();
media.matches = mobile;
const calls = [];
const frames = [];
let closedCount = 0;
let observed = null;
class FakeMutationObserver {
constructor(callback) {
observed = callback;
}
observe() {}
disconnect() {
observed = null;
}
}
const shell = initMobileShell({
documentRef: document,
onTabOpened,
matchMedia: () => media,
MutationObserverImpl: FakeMutationObserver,
ResizeObserverImpl: null,
requestFrame: (callback) => frames.push(callback),
onSheetsClosed: () => {
closedCount += 1;
},
setPanelCollapsed: (panelId, collapsed, options) => {
calls.push({ panelId, collapsed, ...options });
const panel = document.getElementById(panelId);
if (collapsed) panel.classList.add('collapsed');
else panel.classList.remove('collapsed');
},
});
const tab = (sheet) =>
document
.getElementById('mobile-tab-bar')
.children.find((button) => button.dataset.mobileSheet === sheet);
const press = (sheet) => {
const event = new Event('click', { cancelable: true });
Object.defineProperty(event, 'target', { value: tab(sheet) });
document.getElementById('mobile-tab-bar').dispatchEvent(event);
};
const resize = (matches) => {
media.matches = matches;
const event = new Event('change');
event.matches = matches;
media.dispatchEvent(event);
};
const setBodyClass = (name) => {
document.body.classList.add(name);
observed?.();
};
const collapsed = (id) =>
document.getElementById(id).classList.contains('collapsed');
const runFrames = () => frames.splice(0).forEach((callback) => callback());
return {
document,
shell,
calls,
runFrames,
closedCount: () => closedCount,
press,
tab,
resize,
viewport,
search,
setBodyClass,
collapsed,
};
}
test('every tab names a sheet and every sheet panel maps back to its sheet', () => {
assert.deepEqual(
MOBILE_TABS.map(({ sheet }) => sheet),
Object.keys(MOBILE_SHEET_PANELS),
);
for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS))
for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet);
assert.equal(sheetForPanelId('unknown-panel'), null);
});
test('pressing a tab opens its sheet, pressing it again closes it', () => {
assert.equal(nextSheet(null, 'layers'), 'layers');
assert.equal(nextSheet('layers', 'layers'), null);
assert.equal(nextSheet('layers', 'more'), 'more');
assert.equal(nextSheet('layers', 'nonsense'), null);
assert.equal(nextSheet(null, 'constructor'), null);
});
test('one sheet at a time, presented with aria-expanded and a backdrop', () => {
const { document, shell, press, tab } = fixture();
assert.equal(document.body.classList.contains('mobile-shell'), true);
press('layers');
assert.equal(shell.getSheet(), 'layers');
assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers');
assert.equal(tab('layers').getAttribute('aria-expanded'), 'true');
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false);
press('more');
assert.equal(shell.getSheet(), 'more');
assert.equal(tab('layers').getAttribute('aria-expanded'), 'false');
assert.equal(tab('more').getAttribute('aria-expanded'), 'true');
press('more');
assert.equal(shell.getSheet(), null);
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true);
});
test('sheets borrow panels and put them back exactly as they were', () => {
// The user left Data Layers and Presets open on desktop.
const kept = ['data-panel', 'control-panel'];
const { calls, press, collapsed } = fixture({ expanded: kept });
press('cameras');
assert.equal(collapsed('cctv-panel'), false);
assert.equal(collapsed('street-level-panel'), false);
press('search');
assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch');
assert.equal(collapsed('location-bar'), false);
press('search');
for (const id of PANEL_IDS)
assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`);
// Nothing a sheet does is the user's choice, persisted or shared.
assert.ok(calls.length > 0);
for (const call of calls) {
assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`);
assert.equal(call.persist, false);
assert.equal(call.syncShare, false);
}
// Already-open panels are not touched.
assert.ok(!calls.some(({ panelId }) => kept.includes(panelId)));
});
test('a panel the user changes inside a sheet stays as they left it', () => {
const { press, collapsed, document } = fixture();
press('cameras');
// The user collapses CCTV inside the sheet (their own, persisted choice).
document.getElementById('cctv-panel').classList.add('collapsed');
press('cameras');
assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept');
assert.equal(
collapsed('street-level-panel'),
true,
'the borrowed one is put back',
);
// Expanding one in a sheet is kept too, and later sheets respect it.
press('layers');
document.getElementById('cctv-panel').classList.remove('collapsed');
press('more');
press('more');
assert.equal(collapsed('cctv-panel'), false);
});
test('focus moves into the sheet and returns to the tab on close', () => {
const { document, press, search, tab } = fixture();
press('search');
assert.equal(document.activeElement, search);
press('search');
assert.equal(document.activeElement, tab('search'));
press('layers');
const dataPanel = document.getElementById('data-panel');
// The first visible control that is not the panel's own collapse button.
assert.equal(document.activeElement, dataPanel.children[2]);
const escape = new Event('keydown', { cancelable: true });
escape.key = 'Escape';
document.dispatchEvent(escape);
assert.equal(escape.defaultPrevented, true);
assert.equal(document.activeElement, tab('layers'));
});
test('the share link keeps the state from before the sheets', () => {
const { shell, press, document, closedCount } = fixture({
expanded: ['street-level-panel'],
});
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
press('layers');
// Data Layers is open only because the sheet borrowed it.
assert.equal(shell.sharedCollapsed('data-panel'), true);
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined);
// A user change inside the sheet is theirs to share.
document.getElementById('cctv-panel').classList.remove('collapsed');
assert.equal(shell.sharedCollapsed('cctv-panel'), false);
press('more');
assert.equal(closedCount(), 0, 'switching sheets is not closing');
press('more');
assert.equal(closedCount(), 1, 'closing refreshes the share link once');
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
});
test('Escape closes the sheet only when nothing else took it', () => {
const { document, shell, press } = fixture();
const escape = () => {
const event = new Event('keydown', { cancelable: true, bubbles: true });
event.key = 'Escape';
return event;
};
press('layers');
// A field cancelling its edit (or a lightbox closing) handles it first.
const handled = escape();
handled.preventDefault();
document.dispatchEvent(handled);
assert.equal(shell.getSheet(), 'layers');
// An open dialog keeps Escape for its native close.
document.openDialog = true;
document.dispatchEvent(escape());
assert.equal(shell.getSheet(), 'layers');
document.openDialog = false;
const plain = escape();
document.dispatchEvent(plain);
assert.equal(shell.getSheet(), null);
assert.equal(plain.defaultPrevented, true);
});
test('share state and restores follow the preference, not layout collapse', () => {
const { shell, press, collapsed, document } = fixture();
// Layout collapsed Street Level only for space; the user left it open.
const street = document.getElementById('street-level-panel');
street.classList.add('layout-auto-collapsed');
press('cameras');
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
press('cameras');
assert.equal(
collapsed('street-level-panel'),
false,
'not turned into a real collapse',
);
});
test('layout settling after a sheet opens is part of opening', () => {
const { press, runFrames, collapsed, document } = fixture({
expanded: ['street-level-panel'],
});
press('cameras');
// A rail layout pass on the next frame collapses Street Level.
document.getElementById('street-level-panel').classList.add('collapsed');
runFrames();
press('cameras');
assert.equal(collapsed('street-level-panel'), false, 'put back as it was');
});
test('the backdrop closes the open sheet', () => {
const { document, shell, press } = fixture();
press('context');
document
.getElementById('mobile-sheet-backdrop')
.dispatchEvent(new Event('click'));
assert.equal(shell.getSheet(), null);
});
test('surfaces that own the screen close sheets; scene playback does not', () => {
for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) {
const { shell, press, setBodyClass, collapsed } = fixture();
press('layers');
setBodyClass(mode);
assert.equal(shell.getSheet(), null, `${mode} closes the sheet`);
assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`);
press('layers');
assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`);
}
// Scene STOP lives in the Scenes panel of the Layers sheet.
const playback = fixture();
playback.setBodyClass('scene-playback-mode');
playback.press('layers');
assert.equal(playback.shell.getSheet(), 'layers');
});
test('desktop widths never see the shell; crossing to desktop restores it', () => {
const desktop = fixture({ mobile: false });
assert.equal(desktop.document.body.classList.contains('mobile-shell'), false);
desktop.press('layers');
assert.equal(desktop.shell.getSheet(), null);
assert.equal(
desktop.viewport.getAttribute('content'),
'width=device-width, initial-scale=1.0',
);
const phone = fixture({ expanded: ['street-level-panel'] });
assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/);
phone.press('cameras');
phone.resize(false);
assert.equal(phone.shell.getSheet(), null);
assert.equal(phone.document.body.classList.contains('mobile-shell'), false);
assert.equal(phone.collapsed('cctv-panel'), true);
assert.equal(phone.collapsed('street-level-panel'), false);
assert.equal(
phone.viewport.getAttribute('content'),
'width=device-width, initial-scale=1.0',
);
});
test('destroy puts panels back and removes the shell', () => {
const { document, shell, press, viewport, collapsed } = fixture();
press('layers');
shell.destroy();
assert.equal(collapsed('data-panel'), true);
assert.equal(document.getElementById('mobile-tab-bar'), null);
assert.equal(document.getElementById('mobile-sheet-backdrop'), null);
assert.equal(document.body.classList.contains('mobile-shell'), false);
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
assert.equal(document.body.style.values.size, 0);
assert.equal(
viewport.getAttribute('content'),
'width=device-width, initial-scale=1.0',
);
});
test('the phone shell keeps the required attribution visible', () => {
// Whitespace-normalised, so formatter line wrapping cannot hide a rule.
const css = readFileSync(
new URL('./styles/mobile-shell.css', import.meta.url),
'utf8',
)
.replace(/\s+/g, ' ')
.replace(/\( /g, '(')
.replace(/ \)/g, ')');
/** The declaration block of the rule whose selector list ends with `selector`. */
const rule = (selector) => {
const end = css.indexOf(`${selector} {`);
assert.notEqual(end, -1, `missing rule ending in ${selector}`);
const open = css.indexOf('{', end);
return css.slice(open, css.indexOf('}', open));
};
const lift = (block) =>
Number(
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
);
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
const idleCredit = rule(
'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
);
const dock = rule('body.mobile-shell #command-dock');
const voice = rule('body.mobile-shell #command-dock #gev-voice-control');
const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16;
// Idle: the credit line clears the voice bar, which clears the tab bar.
assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx);
// It must beat the dock's :has() placements, so the offset is !important.
assert.match(idleCredit, /\) !important;/);
assert.doesNotMatch(
idleCredit,
/display:\s*none|visibility:\s*hidden|opacity:\s*0/,
);
// Open sheet: above everything the sheet covers, drawn above the sheets.
const sheetCredit = rule(
'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
);
assert.match(
sheetCredit,
/bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/,
);
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
const railSheet = rule(
"body.mobile-shell[data-mobile-sheet='more'] #right-context-rail",
);
assert.ok(z(sheetCredit) > z(openDock));
assert.ok(z(sheetCredit) > z(railSheet));
});
function touchEvent(
document,
type,
target,
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
) {
const event = new Event(type, { cancelable });
const touch = { identifier: id, clientX: x, clientY: y };
Object.defineProperty(event, 'target', { value: target });
event.touches =
type === 'touchend' || type === 'touchcancel'
? []
: Array.from({ length: count }, (_, i) => ({
...touch,
identifier: id + i,
}));
event.changedTouches = [touch];
document.dispatchEvent(event);
return event;
}
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
const phone = fixture();
const { document, shell, press, tab } = phone;
press(sheet);
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
touchEvent(document, 'touchstart', target);
const move = touchEvent(document, 'touchmove', target, { y: 90 });
assert.equal(move.defaultPrevented, true);
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
touchEvent(document, 'touchend', target, { y: 90 });
assert.equal(shell.getSheet(), null);
assert.equal(phone.closedCount(), 1);
assert.equal(document.activeElement, tab(sheet));
for (const id of MOBILE_SHEET_PANELS[sheet])
assert.equal(phone.collapsed(id), true);
assert.ok(
phone.calls.every(
(call) => call.persist === false && call.syncShare === false,
),
);
shell.destroy();
}
});
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
for (const nested of [false, true]) {
const { document, shell, press } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
const scroller = nested
? panel
: document.getElementById('left-panel-stack');
scroller.scrollTop = 40;
touchEvent(document, 'touchstart', panel);
scroller.scrollTop = 0;
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', panel, { y: 120 });
assert.equal(shell.getSheet(), 'layers');
shell.destroy();
}
});
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
for (const path of [
[],
[{ x: 12, y: 14 }],
[{ y: 40 }],
[{ y: -30 }, { y: 120 }],
[{ x: 60, y: 12 }, { y: 120 }],
]) {
const { document, shell, press } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel);
for (const point of path) touchEvent(document, 'touchmove', panel, point);
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
assert.equal(shell.getSheet(), 'layers');
shell.destroy();
}
});
test('native controls and custom sliders keep touch gestures', () => {
for (const tag of [
'button',
'a',
'input',
'select',
'textarea',
'label',
'slider',
'editable',
]) {
const { document, shell, press } = fixture();
press('more');
const control = document.createElement(
['slider', 'editable'].includes(tag) ? 'div' : tag,
);
if (tag === 'slider') control.setAttribute('role', 'slider');
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
const child = document.createElement('span');
control.append(child);
document.getElementById('control-panel').append(control);
touchEvent(document, 'touchstart', child);
assert.equal(
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', child, { y: 120 });
assert.equal(shell.getSheet(), 'more');
shell.destroy();
}
});
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
for (const cancel of [
'multitouch',
'cancel',
'sheet',
'desktop',
'noncancelable',
]) {
const { document, shell, press, resize } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel);
if (cancel === 'multitouch')
touchEvent(document, 'touchstart', document.body, { count: 2 });
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
if (cancel === 'sheet') press('context');
if (cancel === 'desktop') {
resize(false);
resize(true);
press('layers');
}
if (cancel === 'noncancelable')
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', panel, { y: 120 });
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
shell.destroy();
}
});
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
const { document, shell, press } = fixture();
press('layers');
const hiddenPanel = document.getElementById('global-context-panel');
touchEvent(document, 'touchstart', hiddenPanel);
assert.equal(
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
assert.equal(shell.getSheet(), 'layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel);
shell.destroy();
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
false,
);
touchEvent(document, 'touchend', panel, { y: 120 });
assert.equal(shell.getSheet(), null);
});
test('the tab-open callback reports only a successful manual opening', () => {
const opened = [];
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
phone.shell.setSheet('context');
assert.deepEqual(opened, []);
phone.press('context');
assert.deepEqual(opened, [], 'closing is not a new context request');
phone.press('context');
assert.deepEqual(opened, ['context']);
phone.setBodyClass('cockpit-mode');
phone.press('context');
assert.deepEqual(
opened,
['context'],
'blocked sheets cannot activate a mode',
);
phone.shell.destroy();
});
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
const calls = [];
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
const entity = viewer.trackedEntity;
const enabled = new Set(['flights', 'weather']);
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: () => calls.push('claim'),
syncDetection() {},
scheduleLayout() {},
showToast() {},
},
});
controls.connect({
layers: new Map(
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
),
getEnabledLayerIds: () => [...enabled],
getLayerParams: () => ({}),
isEffectivelyEnabled: (id) => enabled.has(id),
isEnabled: (id) => enabled.has(id),
setLayerParams: (id, params) => calls.push({ id, params }),
setEnabled: async (id, value) => {
calls.push({ id, value });
if (!value) enabled.delete(id);
if (id === 'flights' && !value) viewer.trackedEntity = null;
return true;
},
_setEnabledWithIntent: (id, value) => {
calls.push({ id, value });
enabled.add(id);
return { intentEpoch: 1, promise: Promise.resolve(true) };
},
});
const shell = {
cockpitView: {
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
},
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
getContextModeState: () => controls.getContextModeState(),
setContextMode: (mode) => controls.setContextMode(mode),
_showToast: () => assert.fail('successful entry must not report a failure'),
};
try {
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
true,
);
assert.equal(controls.getContextModeState().mode, 'flights');
assert.equal(viewer.trackedEntity, entity);
assert.deepEqual(shell.getAircraftTrackingTarget(), {
layerId: 'flights',
id: 'abc123',
});
assert.ok(enabled.has('flights'));
assert.ok(
calls.some(
(call) => call.id === 'military-awareness' && call.value === true,
),
);
assert.ok(!calls.some((call) => call.id === 'flights'));
const count = calls.length;
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
false,
);
assert.equal(
calls.length,
count,
'an already active Contacts session is not restarted',
);
} finally {
controls.stop();
controls.disconnect();
}
});
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
for (const state of [
{ mode: 'space-missions' },
{ mode: 'flights' },
{ changing: true },
{ entering: 'space-missions' },
{ noTrack: true },
{ disposed: true },
{ sheet: 'layers' },
]) {
const shell = {
_disposed: state.disposed,
getContextModeState: () => state,
getAircraftTrackingTarget: () =>
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
setContextMode: () =>
assert.fail('guarded tab must not mutate Context or tracking'),
};
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(
shell,
state.sheet || 'context',
),
false,
);
}
});
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
const notices = [];
const shell = {
getContextModeState: () => ({ mode: null }),
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
setContextMode: async () => {
if (outcome === 'throw') throw new Error('source unavailable');
if (outcome === 'disposed') shell._disposed = true;
return { ok: false, cancelled: outcome === 'cancelled' };
},
_showToast: (message) => notices.push(message),
};
assert.equal(
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
false,
);
assert.equal(
notices.length,
['failure', 'throw'].includes(outcome) ? 1 : 0,
);
}
});
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
const { document, shell, press } = fixture();
press('layers');
const panel = document.getElementById('data-panel');
touchEvent(document, 'touchstart', panel, { y: 100 });
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
false,
'tap jitter is left native',
);
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
true,
'cancel the first directional move while Android still allows it',
);
assert.equal(
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
true,
);
touchEvent(document, 'touchend', panel, { y: 175 });
assert.equal(shell.getSheet(), null);
shell.destroy();
});
+3
View File
@@ -14,6 +14,7 @@ export class NavigationController {
tracking,
searchInput,
interruptCameraMotion,
interruptGlobeRotation = () => {},
isCockpitActive,
clearLocation,
cancelShareSelection,
@@ -27,6 +28,7 @@ export class NavigationController {
tracking,
searchInput,
interruptCameraMotion,
interruptGlobeRotation,
isCockpitActive,
clearLocation,
cancelShareSelection,
@@ -46,6 +48,7 @@ export class NavigationController {
cancelPendingSelection = true,
clearSearchedLocation = true,
} = {}) {
this.interruptGlobeRotation('navigation-intent');
cancelCameraArrival(this.viewer);
this.cancelOrientation();
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
+17 -3
View File
@@ -46,6 +46,7 @@ export class PanelChrome {
readInitialShare,
readScrollRestoreOwner,
readDisplayScrollTop,
readSharedCollapsed = () => undefined,
}) {
Object.assign(this, elements, operations, {
readHud,
@@ -54,6 +55,7 @@ export class PanelChrome {
readInitialShare,
readScrollRestoreOwner,
readDisplayScrollTop,
readSharedCollapsed,
});
this._disposed = false;
this._lifetime = new UiLifetime();
@@ -149,6 +151,9 @@ export class PanelChrome {
}
_collapsePanelOnEscape(event, panelId) {
// In a phone sheet, Escape closes the sheet (mobileShell.js), not a panel.
if (globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'))
return false;
return collapsePanelOnEscape(event, {
panel: document.getElementById(panelId),
onChange: (collapsed, options) =>
@@ -250,7 +255,10 @@ export class PanelChrome {
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
openDelayMs,
closeDelayMs,
isActive: () => !this._disposed,
// A phone sheet holds its trays open; the tab bar closes them.
isActive: () =>
!this._disposed &&
!globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'),
onChange: (collapsed, options) =>
this.setPanelCollapsed(panelId, collapsed, options),
onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
@@ -380,7 +388,11 @@ export class PanelChrome {
panelEl.classList.contains('cyber-accordion-collapsed')
? false
: panelEl.classList.contains('collapsed');
const entry = { id: spec.id, collapsed };
// A panel the phone shell only borrowed for a sheet shares its own state.
const entry = {
id: spec.id,
collapsed: this.readSharedCollapsed(spec.id) ?? collapsed,
};
if (spec.pinnable)
entry.pinned = panelEl.classList.contains('dock-pinned');
specs.push(entry);
@@ -439,7 +451,9 @@ export class PanelChrome {
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack
panelEl.parentElement === this._rightPanelStack &&
// Phone sheets show several rail panels at once.
!document.body?.classList?.contains('mobile-shell')
) {
for (const peer of this._rightPanelStack.children) {
if (
+17 -1
View File
@@ -21,7 +21,7 @@ test('Display uses an inner scroll body only in Cyber', () => {
});
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
for (const mobile of [false, true]) {
for (const mobile of [false]) {
const f = fixture('right', {
mobile,
hud: { visible: true, variant: 'cyber' },
@@ -50,6 +50,22 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the
}
});
test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => {
// A sheet (for example Cameras: CCTV with Street Level) shows several of the
// rail's panels at once; the accordion would collapse all but one.
const f = fixture('right', {
mobile: true,
hud: { visible: true, variant: 'cyber' },
});
f.expand(f.first, 250);
f.expand(f.second, 250);
f.options.preferredPanelId = f.second.id;
f.run();
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false);
assert.equal(f.second.classList.contains('collapsed'), false);
});
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
f.expand(f.first, 250);
+6 -5
View File
@@ -114,13 +114,14 @@ export function renderRadioState(state) {
...state.categories.map((category) => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`;
const label = t('{category} ({count})', {
category: category.label,
count: category.count,
});
option.textContent = `● ${label}`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute(
'aria-label',
`${category.label} (${category.count})`,
);
option.setAttribute('aria-label', label);
return option;
}),
);
+11 -6
View File
@@ -91,7 +91,9 @@ export function layoutRightPanelRail({
}
// Normalize restored state and theme entry without overwriting saved panel
// preferences. This marker is distinct from Tactical's space-based collapse.
if (hud.variant === 'cyber') {
// Phones present the rail as tab sheets that show several panels at once.
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
if (hud.variant === 'cyber' && !isMobile) {
const expanded = panels.filter(
(panel) => !panel.classList.contains('collapsed'),
);
@@ -111,16 +113,19 @@ export function layoutRightPanelRail({
onCollapse(panel);
}
}
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some(
(panel) =>
!panel.classList.contains('collapsed') &&
(!isMobile || panel.id !== 'pp-toggles'),
);
const exclusive = shouldHideCollapsedRightPanels({
hudVariant: hud.variant,
hasExpandedPanel,
});
// Phones present the rail as tab sheets (mobileShell.js), which choose what
// shows; hiding collapsed siblings would empty a sheet and its a11y tree.
const exclusive =
!isMobile &&
shouldHideCollapsedRightPanels({
hudVariant: hud.variant,
hasExpandedPanel,
});
stack.classList.toggle('layout-exclusive', exclusive);
for (const panel of panels) {
if (exclusive && panel.classList.contains('collapsed'))
+1 -1
View File
@@ -218,7 +218,7 @@ function statusPatternText(piece) {
}
/** One piece of a status line in the UI language, or as it came. */
export function translateStatusPiece(piece) {
function translateStatusPiece(piece) {
const known = translateKnown(piece);
if (known !== piece) return known;
return statusPatternText(piece) ?? piece;
+51
View File
@@ -0,0 +1,51 @@
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
intel HUD readouts and content marked translate="no", including its containers. */
html:lang(zh)
body
:not(
#intel-hud,
#intel-hud *,
[translate='no'],
[translate='no'] *,
:has([translate='no'])
) {
letter-spacing: 0.02em !important;
}
/* Keep small Chinese labels legible at a minimum of 9px. */
html:lang(zh) #command-dock .gev-mic-label,
html:lang(zh) #command-dock #gev-voice-detail,
html:lang(zh) .gev-voice-tier-btn,
html:lang(zh) .panel-layer-toggle {
font-size: 9px;
}
/* A two-glyph button label must not break between its glyphs. */
html:lang(zh) .pp-mode-btn {
white-space: nowrap;
}
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
html:lang(zh) #intel-hud *,
html:lang(zh) #cockpit-hud * {
text-transform: none;
}
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Leave room for the complete sensor name and resolution on two lines. */
html:lang(zh) .recent-imagery-readout .ri-strip {
height: 131px;
}
html:lang(zh) .recent-imagery-readout .ri-card {
height: 123px;
grid-template-rows: 42px 13px 22px 11px 18px;
}
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
white-space: normal;
text-overflow: clip;
}
+6
View File
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
bottom: calc(2vh + 5.4rem);
}
:root[data-scope-mask='off']
#intel-hud[data-variant='minimal']
.hud-bottom-left {
bottom: calc(2vh + 6.5rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 9rem);
}
+21
View File
@@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn {
color: var(--accent);
}
.pp-scope-toggle input {
width: 14px;
height: 14px;
margin: 0 0 0 auto;
accent-color: var(--accent);
cursor: pointer;
}
.pp-scope-toggle:has(input:checked) .pp-label {
color: var(--accent);
}
.pp-scope-toggle:has(input:focus-visible) {
outline: 1px solid var(--accent);
outline-offset: 2px;
}
#scope-slider-row:has(input:disabled) {
opacity: 0.45;
}
/* ── Bloom Slider (inline with toggle) ───────── */
.pp-toggle-group {
display: flex;
+46 -5
View File
@@ -209,6 +209,49 @@
letter-spacing: 0.055rem;
}
.first-run-dismiss {
position: relative;
padding: 0 0 0 0.75rem;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
/* A 44px touch target drawn outside the layout, so the footer row keeps the
height of its one line of text; a 44px button box would nearly treble it. */
.first-run-dismiss::before {
content: '';
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 44px;
transform: translateY(-50%);
}
.first-run-dismiss:hover,
.first-run-dismiss:focus-visible {
color: rgba(54, 220, 255, 0.86);
}
.first-run-dismiss-touch {
display: none;
}
/* No hover and a coarse pointer: a phone or tablet, where ESC means nothing. */
@media (hover: none) and (pointer: coarse) {
.first-run-dismiss-key {
display: none;
}
.first-run-dismiss-touch {
display: inline;
}
}
.first-run-suppress {
display: flex;
align-items: center;
@@ -270,8 +313,9 @@ body.scene-playback-mode #first-run-launcher {
#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. */
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows
tighten. The dismiss button stays: it is the only way to close the card
without choosing a mission on a phone, which has no ESC key. */
#first-run-launcher h2 {
margin-top: 0.45rem;
}
@@ -289,9 +333,6 @@ body.scene-playback-mode #first-run-launcher {
.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
+419
View File
@@ -0,0 +1,419 @@
/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ─────────
* The globe is full screen. A bottom tab bar opens one sheet; the sheet shows
* the panels of that tab from the rails or dock, everything else stays hidden.
*/
:root {
--mobile-tab-height: 58px;
--mobile-bottom-inset: env(safe-area-inset-bottom, 0px);
--mobile-top-inset: env(safe-area-inset-top, 0px);
--mobile-tab-space: calc(
var(--mobile-tab-height) + var(--mobile-bottom-inset)
);
--mobile-sheet-max-height: min(62vh, calc(100vh - 168px));
--mobile-dock-sheet-max-height: 34vh;
--mobile-rail-under-dock-max-height: 40vh;
}
/* Dynamic viewport units keep sheets inside the area a mobile browser's
toolbars leave visible. */
@supports (height: 1dvh) {
:root {
--mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px));
--mobile-dock-sheet-max-height: 34dvh;
--mobile-rail-under-dock-max-height: 40dvh;
}
}
#mobile-tab-bar,
#mobile-sheet-backdrop {
display: none;
}
body.mobile-shell #mobile-tab-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 170;
display: grid;
grid-template-columns: repeat(5, 1fr);
height: var(--mobile-tab-space);
padding: 0 max(4px, env(safe-area-inset-right, 0px))
var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px));
background: rgba(8, 14, 22, 0.94);
border-top: 1px solid var(--glass-border);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
body.mobile-shell .mobile-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
padding: 6px 0 4px;
color: var(--text-secondary);
background: none;
border: 0;
font: 500 10px/1.2 var(--font-sans);
letter-spacing: 0.04em;
-webkit-tap-highlight-color: transparent;
}
body.mobile-shell .mobile-tab-icon {
font-size: 24px;
line-height: 1;
}
body.mobile-shell .mobile-tab[aria-expanded='true'] {
color: var(--accent);
}
body.mobile-shell .mobile-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -4px;
}
body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
position: fixed;
inset: 0 0 var(--mobile-tab-space);
z-index: 155;
display: block;
background: rgba(0, 0, 0, 0.18);
}
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
body.mobile-shell #title-bar {
top: calc(var(--mobile-top-inset) + 10px);
left: 14px;
}
body.mobile-shell #title-bar .title-logo {
width: 58px;
}
body.mobile-shell #title-bar h1 > :not(.title-logo),
body.mobile-shell #title-bar .subtitle {
display: none;
}
body.mobile-shell #top-center-actions {
top: calc(var(--mobile-top-inset) + 10px);
right: 12px;
}
/* ── HUD: one compact readout under the top bar ────────────────────────── */
body.mobile-shell #intel-hud .hud-edge,
body.mobile-shell #intel-hud .hud-corner,
body.mobile-shell #intel-hud .hud-top-bar,
body.mobile-shell #intel-hud .hud-bottom-bar {
display: none;
}
body.mobile-shell #intel-hud .hud-bottom-left {
top: calc(var(--mobile-top-inset) + 62px);
bottom: auto;
left: 16px;
font-size: 9px;
opacity: 0.8;
}
/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */
body.mobile-shell #left-panel-stack,
body.mobile-shell #right-context-rail {
display: none;
}
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack,
body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail,
body.mobile-shell[data-mobile-sheet='context'] #right-context-rail,
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
position: fixed;
top: auto;
right: 0;
bottom: var(--mobile-tab-space);
left: 0;
z-index: 160;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: auto;
max-width: none;
max-height: var(--mobile-sheet-max-height);
padding: 14px 10px 10px;
overflow-y: auto;
overscroll-behavior: contain;
pointer-events: auto;
background: rgba(8, 14, 22, 0.96);
border: 1px solid var(--glass-border);
border-bottom: 0;
border-radius: 18px 18px 0 0;
transform: none;
}
body.mobile-shell #left-panel-stack > *,
body.mobile-shell #right-context-rail > *,
/* Rail rules show expanded Weather and Recent Imagery by id; on phones they
belong to the Context sheet only. */
body.mobile-shell #right-context-rail > #weather-panel,
body.mobile-shell #right-context-rail > #recent-imagery-panel {
display: none;
}
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
hiding collapsed siblings) never take a panel out of an open sheet. */
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
body.mobile-shell[data-mobile-sheet='cameras']
#right-context-rail
> #cctv-panel,
body.mobile-shell[data-mobile-sheet='cameras']
#right-context-rail
> #street-level-panel,
body.mobile-shell[data-mobile-sheet='context']
#right-context-rail
> #global-context-panel,
body.mobile-shell[data-mobile-sheet='context']
#right-context-rail
> #weather-panel:not([hidden]),
body.mobile-shell[data-mobile-sheet='context']
#right-context-rail
> #recent-imagery-panel:not([hidden]),
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles {
position: relative;
inset: auto;
display: block;
width: 100%;
max-width: none;
margin: 0;
transform: none;
}
/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */
body.mobile-shell #command-dock {
/* The dock's tray rules size voice by this basis; a column makes it a height. */
--dock-voice-width: auto;
top: auto;
bottom: calc(var(--mobile-tab-space) + 10px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
height: auto;
min-height: 0;
max-height: none;
padding: 0 10px;
pointer-events: none;
background: none;
border: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* The desktop dock centres itself with left: 50% and a translate under
:has() selectors that outrank any phone selector; on phones it spans the
width, so these few geometry properties must win. */
body.mobile-shell #command-dock {
right: 0 !important;
left: 0 !important;
width: auto !important;
max-width: none !important;
transform: none !important;
translate: none !important;
}
body.mobile-shell #command-dock > * {
pointer-events: auto;
}
body.mobile-shell #location-bar,
body.mobile-shell #control-panel {
display: none;
}
body.mobile-shell[data-mobile-sheet] #command-dock {
z-index: 165;
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px);
}
body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar,
body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel {
position: relative;
inset: auto;
display: flex;
flex: none;
width: 100%;
max-width: none;
margin: 0;
transform: none;
}
/* Search and More: the dock becomes the top of the sheet, flush with the rail
sheet below it (More) or alone above the tab bar (Search). */
body.mobile-shell[data-mobile-sheet='search'] #command-dock,
body.mobile-shell[data-mobile-sheet='more'] #command-dock {
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px));
align-items: stretch;
max-height: var(--mobile-dock-sheet-max-height);
padding: 14px 10px 8px;
overflow-x: hidden;
overflow-y: auto;
pointer-events: auto;
background: rgba(8, 14, 22, 0.96);
border: 1px solid var(--glass-border);
border-bottom: 0;
border-radius: 18px 18px 0 0;
}
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
max-height: var(--mobile-rail-under-dock-max-height);
padding-top: 4px;
border-top: 0;
border-radius: 0;
}
/* Presets and Location show their popover inline instead of as a floating
tray; the dock scrolls, so a tray above it would be clipped. */
body.mobile-shell[data-mobile-sheet='more']
#command-dock
#control-panel
.panel-inner,
body.mobile-shell[data-mobile-sheet='more']
#command-dock
#control-panel-popover,
body.mobile-shell[data-mobile-sheet='search']
#command-dock
#location-bar
.location-inner,
body.mobile-shell[data-mobile-sheet='search']
#command-dock
#location-bar-popover {
position: static;
inset: auto;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: none;
max-height: none;
margin: 0;
opacity: 1;
visibility: visible;
transform: none;
pointer-events: auto;
}
/* The tab names the sheet; the tray toggle and pin have nothing to do here. */
body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle,
body.mobile-shell[data-mobile-sheet='more']
#command-dock
#control-panel
.dock-pin-btn,
body.mobile-shell[data-mobile-sheet='search']
#command-dock
#location-bar-toggle,
body.mobile-shell[data-mobile-sheet='search']
#command-dock
#location-bar
.panel-collapse-btn,
body.mobile-shell[data-mobile-sheet='search']
#command-dock
#location-bar
.dock-pin-btn {
display: none;
}
body.mobile-shell[data-mobile-sheet='more'] #style-buttons {
flex-wrap: wrap;
}
/* Search: the field takes the full width; the place pills scroll beneath it. */
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row {
flex-wrap: wrap;
row-gap: 8px;
}
body.mobile-shell[data-mobile-sheet='search']
#location-bar
.location-search-wrap {
flex: 1 1 100%;
}
body.mobile-shell[data-mobile-sheet='search'] #location-search {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills {
flex: 1 1 100%;
max-width: 100%;
overflow-x: auto;
}
/* With a sheet open, voice steps aside; Search and More own the dock. */
body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control {
display: none;
}
body.mobile-shell #command-dock #gev-voice-control {
position: relative;
inset: auto;
flex: none;
align-self: center;
width: min(280px, 80vw);
height: 5.35rem;
margin: 0;
transform: none;
}
/* ── Credits and corner chips clear the tab bar ────────────────────────── */
/* While the tab bar shows, the credit line sits above the voice bar. The
dock's placements (including the Minimal HUD's :has() variant) outrank any
phone selector, so the offset must win. Clean view keeps its own corner. */
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
#cesium-credits {
right: auto;
bottom: calc(var(--mobile-tab-space) + 106px) !important;
left: 10px;
max-width: calc(100vw - 20px);
transform-origin: left bottom;
scale: 0.8;
}
/* An open sheet lifts the required attribution above everything it covers. */
body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
.cockpit-mode
):not(.ui-clean-view)
#cesium-credits {
bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important;
z-index: 166;
}
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
body.mobile-shell:not(.cockpit-mode) #view-switcher {
right: 12px;
bottom: calc(var(--mobile-tab-space) + 106px);
}
body.mobile-shell #corner-actions {
top: calc(var(--mobile-top-inset) + 60px);
right: 12px;
bottom: auto;
}
/* Full-screen modes hide the phone chrome like the desktop chrome. Scene
playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */
body.mobile-shell.ui-clean-view #mobile-tab-bar,
body.mobile-shell.recording-mode #mobile-tab-bar,
body.mobile-shell.cockpit-mode #mobile-tab-bar {
display: none;
}
body.mobile-shell.ui-clean-view #command-dock,
body.mobile-shell.cockpit-mode #command-dock {
bottom: calc(var(--mobile-bottom-inset) + 10px);
}
+131
View File
@@ -371,3 +371,134 @@
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px;
}
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
.hud-readout {
display: contents;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
background: rgba(0, 0, 0, 0.7);
box-shadow: none;
border-radius: 2px;
padding-block: 0;
padding-inline: 6px;
margin-block: 0;
margin-inline: -6px;
width: fit-content;
max-width: 100%;
box-sizing: content-box;
white-space: nowrap;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
.hud-readout {
display: block;
width: max-content;
min-width: 0;
box-sizing: content-box;
white-space: nowrap;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
:is(.hud-top-right, .hud-bottom-right)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#style-indicator
.scene-text-holdout {
margin-inline-start: auto;
}
/* Preserve glyph opacity without dimming the vertical backplates. */
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge {
opacity: 1;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge
.hud-readout
> * {
opacity: 0.5;
}
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar) {
background: transparent;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar) {
width: fit-content;
max-width: calc(100vw - 120px);
gap: 24px;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar)
.hud-readout {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
h1::before {
content: '';
position: absolute;
inset: -0.12em -6px;
z-index: -1;
border-radius: 2px;
background: rgba(0, 0, 0, 0.7);
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
color: var(--text-secondary);
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
margin-top: 8px;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#style-indicator
.indicator-value {
margin-top: 4px;
}
:root[data-scope-mask='off']
#intel-hud[data-variant='minimal']
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
display: none;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
.frame-rate-readout {
margin-top: 6px;
}
+5 -9
View File
@@ -106,19 +106,15 @@
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n>Scope</span>
</button>
<input type="checkbox" id="scope-toggle" checked />
</label>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">100%</span>
</div>
</div>
<div class="pp-toggle-group">
+3 -3
View File
@@ -11,13 +11,13 @@
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
</div>
<!-- Top-center camera and share actions -->
+7 -1
View File
@@ -44,7 +44,13 @@
<input type="checkbox" data-first-run-suppress />
<span data-i18n>Don't show this again</span>
</label>
<span data-i18n>ESC to dismiss</span>
<!-- A button, not just a hint: phones and tablets have no ESC key. It
closes exactly as ESC does, for this session only. Touch screens
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
<button type="button" class="first-run-dismiss" data-first-run-dismiss>
<span class="first-run-dismiss-key" data-i18n>ESC to dismiss</span>
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
</button>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
a live region that also holds layout makes the row jump as it speaks. -->
+13 -1
View File
@@ -159,18 +159,30 @@ export class VisualEffects {
this.requestRender('bloom');
}
/**
* Sharpen runs only when it is on and its slider is above 0, as the style
* stages do: the pass costs the same at any amount (~1.5 ms GPU per frame
* on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0.
*/
syncSharpenEnabled() {
if (this.stopped || !this.sharpenStage) return;
this.sharpenStage.enabled =
this.sharpenEnabled && this.sharpenIntensity > 0.001;
}
applySharpenIntensity(value) {
if (this.stopped) return;
this.sharpenIntensity = value;
if (this.sharpenStage)
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
this.syncSharpenEnabled();
this.requestRender('sharpen');
}
setSharpenEnabled(enabled) {
if (this.stopped) return;
this.sharpenEnabled = !!enabled;
if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled;
this.syncSharpenEnabled();
this.requestRender('sharpen');
}
+18
View File
@@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled',
f.effects.destroy();
});
test('sharpen at 0 runs no pass, and comes back with the slider', () => {
const f = fixture();
f.effects.initPostProcess(0.49);
f.effects.setSharpenEnabled(true);
assert.equal(f.effects.sharpenStage.enabled, true);
f.effects.applySharpenIntensity(0);
assert.equal(
f.effects.sharpenStage.enabled,
false,
'no residual sharpen at 0',
);
f.effects.setSharpenEnabled(true);
assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off');
f.effects.applySharpenIntensity(0.2);
assert.equal(f.effects.sharpenStage.enabled, true);
f.effects.destroy();
});
test('stop revokes pending work immediately but retains stages until final destruction', () => {
const f = fixture();
f.effects.initStyles();
+15 -7
View File
@@ -1,3 +1,5 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './displayControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
@@ -10,7 +12,6 @@ import {
MILITARY_DETECTION_PRESET,
} from './effects.js';
import { createStyleParameters } from './visualInput.js';
import * as Cesium from 'cesium';
import {
BLOOM_SCALE_VERSION,
clampBloomIntensity,
@@ -137,11 +138,10 @@ export class VisualSettings {
}
if (typeof scopeEnabled === 'boolean') {
setScopeMaskEnabled(scopeEnabled);
this._scopeBtn?.classList.toggle('active', scopeEnabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
}
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
const pct = clampScopeFeatherPct(scopeFeatherPct);
this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
@@ -1303,11 +1303,14 @@ export class VisualSettings {
const scopeState = state.scope || {};
if (typeof scopeState.enabled === 'boolean') {
setScopeMaskEnabled(scopeState.enabled);
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
scopeState.enabled,
);
}
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
const pct = clampScopeFeatherPct(scopeState.featherPct);
this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
@@ -1538,6 +1541,11 @@ export class VisualSettings {
preserveCyberRestore = false,
} = {},
) {
if (
typeof styleName !== 'string' ||
(styleName !== 'normal' && !Object.hasOwn(STYLES, styleName))
)
return;
const { setDetectionStyle } = this.services;
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
if (this.hud.getVariant() === 'cyber') {