Merge origin/main and sync Traditional Chinese catalog

This commit is contained in:
German Khokhlov committed 2026-10-27 10:25:07 +04:00
commit ba9db7fc41
366 files changed
+26422 -1292

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),
+75 -5
View File
@@ -35,6 +35,10 @@ import * as Cesium from 'cesium';
import { aircraftTrackingTarget } from '../cockpitTracking.js';
import { ShellFeedback } from './shellFeedback.js';
import {
applyShareFollowOffset,
readShareFollowSubject,
} from './shareFollow.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
*/
constructor(
viewer,
{ mapStackController = null, placeSearch, services, requestServices } = {},
{
mapStackController = null,
placeSearch,
services,
requestServices,
initialShareCode = null,
} = {},
) {
super();
const {
@@ -147,6 +157,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(),
@@ -164,6 +175,8 @@ export class StyleManager extends ShellFacade {
this._showGlobalStatusNotice(message, options),
feedback: this._feedback,
updateFeedback: () => this._updateGlobalLoadingFeedback(),
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
});
this._visualSettings = new VisualSettings({
@@ -322,6 +335,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,
@@ -334,6 +348,7 @@ export class StyleManager extends ShellFacade {
_poiRow: this._poiRow,
_locationBarDivider: this._locationBarDivider,
_locationSearch: this._locationSearch,
_locationSearchError: this._locationSearchError,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
@@ -428,7 +443,12 @@ export class StyleManager extends ShellFacade {
isNavigationCurrent: (generation) =>
generation === this._navigationGeneration,
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
// A host may open a share code without a `/share/<code>` address.
initialShareCode,
});
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
readShareFollowSubject({ viewer: this.viewer, nowMs }),
);
this.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(),
);
@@ -862,9 +882,38 @@ export class StyleManager extends ShellFacade {
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,
@@ -1621,14 +1670,35 @@ export class StyleManager extends ShellFacade {
// ── Share Button ─────────────────────────────
/**
* Wires the share button click to copy the current share link to the clipboard.
* Wires the share button. By default it copies the current share link to the
* clipboard; a host may pass `services.shareLink(url)` to share it another way
* (a system share sheet, for example). It resolves to 'shared', 'copied',
* 'cancelled' (no toast) or false (failed).
* @returns {void}
*/
_initShareButton() {
this._lifetime.listen(this._shareBtn, 'click', async () => {
const success = await this.shareLinkManager.copyLink();
if (!this._disposed)
this._showToast(success ? t('Link copied!') : t('Copy failed'));
const share = this.services.shareLink;
let result;
if (typeof share === 'function') {
// A host's hook may return a plain value or throw; both are answers.
result = await Promise.resolve()
.then(() => {
const href = this.shareLinkManager.buildLink();
return href ? share(href) : false;
})
.catch(() => false);
} else {
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
}
if (this._disposed || result === 'cancelled') return;
this._showToast(
result === 'shared'
? t('Link shared')
: result === 'copied'
? t('Link copied!')
: t('Copy failed'),
);
});
}
+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)}°`,
});
}
}
+3 -1
View File
@@ -156,7 +156,9 @@ 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,
+3
View File
@@ -86,6 +86,9 @@ export async function _restoreContextSession({
throw error;
}
}
// Clear All may have run while Contacts settled. It owns the selection
// now, so the snapshot must not come back behind it.
if (restoreState.cancelled) return;
await this._dataManager.restoreEnabledLayerIds(
restoreState.enabledLayerIds,
{
+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 —';
+1
View File
@@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'recent-imagery': msg('Recent Imagery'),
'fire-perimeters': msg('Fire Perimeters'),
'gnss-integrity': msg('GNSS Integrity'),
's2-ghostmaps': msg('S2 GhostMaps'),
wind: msg('Wind'),
'weather-radar': msg('Rain radar'),
'weather-satellite': msg('Satellite clouds'),
+1
View File
@@ -73,6 +73,7 @@ const PANEL_GROUPS = [
'local-firms',
'fire-perimeters',
'gnss-integrity',
's2-ghostmaps',
],
},
{
+6
View File
@@ -22,6 +22,7 @@ export class LocationControls {
onCity,
onPoi,
onSearch,
onSearchEdit,
onReset,
doc = document,
requestFrame = (callback) => requestAnimationFrame(callback),
@@ -74,6 +75,11 @@ export class LocationControls {
this.bind(elements.search, 'keydown', (event) => {
if (event.key === 'Enter') void onSearch(elements.search.value);
});
// Typing corrects a failed query; closing the field (which empties it) drops its error.
this.bind(elements.search, 'input', () => onSearchEdit?.());
this.bind(elements.search, 'blur', () => {
if (!elements.search.value) onSearchEdit?.();
});
for (const button of elements.resetButtons)
this.bind(button, 'click', onReset);
}
+11
View File
@@ -86,6 +86,7 @@ function fixture() {
onCity: (id) => calls.push(['city', id]),
onPoi: (id, index) => calls.push(['poi', id, index]),
onSearch: (query) => calls.push(['search', query]),
onSearchEdit: () => calls.push(['edit']),
onReset: () => calls.push(['reset']),
doc,
requestFrame: (fn) => {
@@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () =>
f.elements.resetButtons[1].fire('click');
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
});
test('typing clears a failed search error and closing the emptied field drops it too', () => {
const f = fixture();
f.elements.search.value = 'Pariss';
f.elements.search.fire('blur');
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
f.elements.search.fire('input');
f.elements.search.value = '';
f.elements.search.fire('blur');
assert.deepEqual(f.calls, [['edit'], ['edit']]);
});
+30 -5
View File
@@ -80,19 +80,27 @@ export class LocationNavigation {
_handleLocationSearchState(state, change) {
if (this._disposed || !change) return;
if (change.type === 'started')
if (change.type === 'started') {
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') {
this._setLocationSearchError(null);
} else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else if (change.type === 'missing')
this._showToast(t('Location not found'));
else if (change.type === 'failed') this._showToast(t('Search failed'));
this._setLocationSearchError(
`${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`,
);
else if (change.type === 'failed')
this._setLocationSearchError(
`${t('Search failed')}. ${t('Press Enter to try again.')}`,
);
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
this._settleLocationSearchUi(change.generation, {
keepQuery: state.status === 'missing' || state.status === 'failed',
});
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
@@ -101,6 +109,21 @@ export class LocationNavigation {
}
}
/** Show, or with null clear, the announced error under the search field. */
_setLocationSearchError(message) {
const input = this._locationSearch;
const error = this._locationSearchError;
if (error) error.textContent = message || '';
if (!input) return;
if (message) {
input.setAttribute('aria-invalid', 'true');
if (error?.id) input.setAttribute('aria-describedby', error.id);
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
}
_initLocationBar() {
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
this._locationControls?.destroy();
@@ -144,6 +167,7 @@ export class LocationNavigation {
onCity: (id) => this._onCityPillClick(id),
onPoi: (id, index) => this._onPoiClick(id, index),
onSearch: (query) => this._locationLookup.run(query),
onSearchEdit: () => this._setLocationSearchError(null),
onReset: () => this.resetToGlobeView(),
});
}
@@ -283,6 +307,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,
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { NavigationController } from './navigationController.js';
import { LocationNavigation } from './locationNavigation.js';
function input() {
const classes = new Set(['expanded', 'searching']);
const attributes = new Map();
return {
id: 'location-search',
value: 'Pariss',
focused: false,
selected: false,
classList: {
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
},
focus() {
this.focused = true;
},
blur() {
this.focused = false;
},
select() {
this.selected = true;
},
setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name) ?? null,
};
}
function settle(searchInput, options) {
const owner = { _activeLocationSearchGeneration: 4, searchInput };
NavigationController.prototype._settleLocationSearchUi.call(
owner,
4,
options,
);
return owner;
}
test('a search that found nothing keeps its query, the open field and focus', () => {
const field = input();
const owner = settle(field, { keepQuery: true });
assert.equal(owner._activeLocationSearchGeneration, null);
assert.equal(field.value, 'Pariss');
assert.equal(field.classList.contains('expanded'), true);
assert.equal(field.classList.contains('searching'), false);
assert.equal(field.focused, true);
assert.equal(field.selected, true);
});
test('a search that arrived still clears and closes the field', () => {
const field = input();
field.focused = true;
settle(field);
assert.equal(field.value, '');
assert.equal(field.classList.contains('expanded'), false);
assert.equal(field.focused, false);
});
function navigation() {
const field = input();
const error = { id: 'location-search-error', textContent: '' };
const settled = [];
const toasts = [];
const owner = {
_disposed: false,
_locationSearch: field,
_locationSearchError: error,
_activeLocationSearchGeneration: null,
_settleLocationSearchUi: (generation, options) =>
settled.push([generation, options]),
_showToast: (message) => toasts.push(message),
_setLocationSearchError:
LocationNavigation.prototype._setLocationSearchError,
};
const handle = (status, type) =>
LocationNavigation.prototype._handleLocationSearchState.call(
owner,
{ status, query: field.value },
{ type, generation: 7 },
);
return { owner, field, error, settled, toasts, handle };
}
for (const [status, text] of [
['missing', 'Location not found'],
['failed', 'Search failed'],
]) {
test(`a ${status} search announces a lasting error on the field instead of a toast`, () => {
const n = navigation();
n.handle('searching', 'started');
n.handle(status, status);
n.handle(status, 'settled');
assert.match(n.error.textContent, new RegExp(`^${text}\\. `));
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
assert.equal(
n.field.getAttribute('aria-describedby'),
'location-search-error',
);
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
assert.deepEqual(n.toasts, []);
n.handle('searching', 'started');
assert.equal(n.error.textContent, '');
assert.equal(n.field.getAttribute('aria-invalid'), null);
assert.equal(n.field.getAttribute('aria-describedby'), null);
});
}
+120
View File
@@ -125,6 +125,8 @@ function buildChrome(documentRef) {
* @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({
@@ -135,6 +137,7 @@ export function initMobileShell({
ResizeObserverImpl = globalThis.ResizeObserver,
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
onSheetsClosed = () => {},
onTabOpened = () => {},
} = {}) {
const body = documentRef.body;
const { backdrop, tabBar } = buildChrome(documentRef);
@@ -159,6 +162,7 @@ export function initMobileShell({
*/
let borrowed = null;
let returnFocus = null;
let swipe = null;
const written = new Map();
const writeVar = (name, value) => {
@@ -259,6 +263,7 @@ export function initMobileShell({
};
const setSheet = (requested) => {
swipe = null;
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
const sheet = sheetBlocked(body) ? null : requested;
const previous = currentSheetOf(body);
@@ -348,8 +353,123 @@ export function initMobileShell({
// 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.
+385 -3
View File
@@ -1,6 +1,8 @@
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,
@@ -21,6 +23,8 @@ function element(tag, document) {
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)),
@@ -41,6 +45,7 @@ function element(tag, document) {
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);
}
@@ -56,9 +61,20 @@ function element(tag, document) {
return false;
},
closest(selector) {
for (let current = node; current; current = current.parent)
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) {
@@ -94,7 +110,11 @@ function element(tag, document) {
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
function fixture({ mobile = true, expanded = [] } = {}) {
function fixture({
mobile = true,
expanded = [],
onTabOpened = () => {},
} = {}) {
const document = new EventTarget();
document.ids = new Map();
document.createElement = (tag) => element(tag, document);
@@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) {
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;
@@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) {
const collapse = element('button', document);
collapse.className = 'panel-collapse-btn';
panel.append(toggle, collapse, element('button', document));
document.body.append(panel);
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();
@@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) {
}
const shell = initMobileShell({
documentRef: document,
onTabOpened,
matchMedia: () => media,
MutationObserverImpl: FakeMutationObserver,
ResizeObserverImpl: null,
@@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => {
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();
});
+14 -4
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 } =
@@ -116,10 +119,17 @@ export class NavigationController {
return this._navigationGeneration;
}
_settleLocationSearchUi(generation) {
/** A search that found nothing keeps its query and focus so it can be corrected. */
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
if (this._activeLocationSearchGeneration !== generation) return;
this._activeLocationSearchGeneration = null;
this.searchInput?.classList.remove('searching', 'expanded');
this.searchInput?.classList.remove('searching');
if (keepQuery) {
this.searchInput?.focus();
this.searchInput?.select?.();
return;
}
this.searchInput?.classList.remove('expanded');
if (this.searchInput) this.searchInput.value = '';
this.searchInput?.blur();
}
@@ -238,7 +248,7 @@ export class NavigationController {
});
}
_reassertNavigationHandoff(generation) {
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
return reassertNavigationHandoff({
generation,
currentGeneration: this._navigationGeneration,
@@ -250,7 +260,7 @@ export class NavigationController {
// was refused by the cockpit leaves the old readout standing.
release: () => {
this.clearLocation();
return this._releaseFollowCamera();
return this._releaseFollowCamera({ trackingOrigin });
},
});
}
+160
View File
@@ -0,0 +1,160 @@
// The share button copies by default; a host's `services.shareLink` shares the
// link its own way and reports how it went.
import test from 'node:test';
import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js';
import { ShareLinkManager } from '../sharelink.js';
import { parseSharePath } from '../share/shareLinkPath.js';
import { decodeShareView } from '../share/viewCodec.js';
/** The decoded view of a `/share/<code>` link. */
const sharedState = (href) =>
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/;
function makeManager(href, { positioned = true } = {}) {
globalThis.window = { location: { href, hash: new URL(href).hash } };
globalThis.history = { replaceState() {} };
const viewer = {
camera: {
changed: { addEventListener() {} },
positionCartographic: positioned
? { latitude: 0, longitude: 0, height: 1000 }
: undefined,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
},
};
return new ShareLinkManager(viewer);
}
function installClipboard(writeText) {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { clipboard: { writeText } },
});
}
/** Wire the real share button handler onto a minimal shell; return a click. */
function shell({ manager, services = {} }) {
const toasts = [];
let click;
const owner = {
services,
shareLinkManager: manager,
_shareBtn: {},
_disposed: false,
_lifetime: { listen: (_el, _type, handler) => (click = handler) },
_showToast: (message) => toasts.push(message),
};
StyleManager.prototype._initShareButton.call(owner);
return { toasts, click: () => click() };
}
test('buildLink is a /share link stamped with its creation time, without ?lang', () => {
const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20');
const href = manager.buildLink({ nowMs: 5_000_000 });
assert.match(href, SHARE_LINK);
const link = new URL(href);
assert.equal(link.searchParams.get('lang'), null);
assert.equal(link.hash, '');
assert.equal(sharedState(href).sharedAtMs, 5_000_000);
// The camera is the live one, not the address bar's.
assert.equal(sharedState(href).lat, 0);
assert.equal(
makeManager('https://gev.example/', { positioned: false }).buildLink(),
null,
);
});
test('copyLink copies exactly what buildLink builds', async () => {
let copied = null;
installClipboard(async (url) => {
copied = url;
});
const manager = makeManager('https://gev.example/#v=2');
assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true);
assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 }));
});
test('without a host hook the button copies the link', async () => {
let copied = null;
installClipboard(async (url) => {
copied = url;
});
const { toasts, click } = shell({
manager: makeManager('https://gev.example/#v=2'),
});
await click();
assert.match(copied, SHARE_LINK);
assert.deepEqual(toasts, ['Link copied!']);
installClipboard(async () => {
throw new Error('denied');
});
await click();
assert.deepEqual(toasts, ['Link copied!', 'Copy failed']);
});
test('a host hook receives the link and its result picks the toast', async () => {
installClipboard(async () => assert.fail('the host shares instead'));
const shared = [];
const results = ['shared', 'copied', 'cancelled', false];
const { toasts, click } = shell({
manager: makeManager('https://gev.example/#v=2'),
services: {
shareLink: async (url) => {
shared.push(url);
return results.shift();
},
},
});
for (let i = 0; i < 4; i++) await click();
assert.equal(shared.length, 4);
assert.match(shared[0], SHARE_LINK);
// 'cancelled' (the user closed the sheet) shows nothing.
assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']);
});
test('a failing host hook, or no camera position, reports a failure', async () => {
const failing = shell({
manager: makeManager('https://gev.example/#v=2'),
services: { shareLink: async () => Promise.reject(new Error('no sheet')) },
});
await failing.click();
assert.deepEqual(failing.toasts, ['Copy failed']);
let called = false;
const unpositioned = shell({
manager: makeManager('https://gev.example/', { positioned: false }),
services: {
shareLink: async () => {
called = true;
return 'shared';
},
},
});
await unpositioned.click();
assert.equal(called, false);
assert.deepEqual(unpositioned.toasts, ['Copy failed']);
});
test('a host hook that answers without a promise, or throws, still gets a toast', async () => {
for (const [shareLink, toast] of [
[() => 'shared', 'Link shared'],
[
() => {
throw new Error('no sheet');
},
'Copy failed',
],
]) {
const { toasts, click } = shell({
manager: makeManager('https://gev.example/#v=2'),
services: { shareLink },
});
await click();
assert.deepEqual(toasts, [toast]);
}
});
+190
View File
@@ -0,0 +1,190 @@
import * as Cesium from 'cesium';
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
import {
SHARE_VIEW_ENTITY_KINDS,
followOffsetFromEnu,
followOffsetToEnu,
} from '../share/follow.js';
/**
* The bridge between the camera following an entity and a share link's thing
* view: what the camera follows now, in the share format's terms, and the
* stored follow offset applied to an entity the camera has started to follow.
*/
const KIND_BY_LAYER = new Map(
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]),
);
const MAX_FRAMES = 120;
/** `position`'s local east/north/up offset from `origin`, in metres. */
function enuOffset(origin, position) {
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
const local = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()),
position,
new Cesium.Cartesian3(),
);
return { east: local.x, north: local.y, up: local.z };
}
/**
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
* the camera's offset from it and its displayed position as of `nowMs`; null
* when the camera follows nothing a share link can name. A selected vessel is
* not followed (the camera stays where the user puts it), so a link made then
* keeps its own camera.
*/
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
const cameraPosition = viewer?.camera?.positionWC;
if (!cameraPosition) return null;
const tracked = viewer.trackedEntity;
const trackedId = String(tracked?.gevTrackedId || '');
const separator = trackedId.indexOf(':');
if (separator <= 0) return null;
const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator));
const id = trackedId.slice(separator + 1);
const target = kind
? trackedDisplayPositionForCamera(
tracked,
viewer.clock.currentTime,
new Cesium.Cartesian3(),
)
: null;
if (!kind || !id || !target) return null;
const offset = followOffsetFromEnu(enuOffset(target, cameraPosition));
const fix = Cesium.Cartographic.fromCartesian(target);
if (!offset || !fix) return null;
return {
kind,
id,
offset,
lastKnown: {
lat: Cesium.Math.toDegrees(fix.latitude),
lon: Cesium.Math.toDegrees(fix.longitude),
alt: fix.height,
observedAtMs: nowMs,
},
};
}
/** Largest turn, in radians, the camera makes around the entity per frame. */
const MAX_TURN_PER_FRAME = Math.PI / 3;
/**
* Put the camera at a shared follow offset from `entity` once following it
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
* the user orbits to. The camera swings round the entity at most 60° a frame:
* the follow's own guard treats a jump to the far side as the camera crossing
* through the entity and puts it back. Returns a disposer.
*/
export function applyShareFollowOffset(viewer, entity, offset) {
if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0))
return () => {};
const local = followOffsetToEnu(offset);
let frames = 0;
let remove = null;
const stop = () => {
remove?.();
remove = null;
};
remove = viewer.scene.preUpdate.addEventListener(() => {
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
stop();
return;
}
frames += 1;
const camera = viewer.camera;
// Let the follow frame the camera first (its first frame), then move it.
const framed =
frames > 1 &&
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
const target = framed
? trackedDisplayPositionForCamera(
entity,
viewer.clock.currentTime,
new Cesium.Cartesian3(),
)
: null;
if (!target) {
if (frames >= MAX_FRAMES) stop();
return;
}
// The offset is east/north/up at the entity; the follow's own frame may
// differ, so place the camera in world space and re-express it there.
const world = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
);
const goal = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
camera.transform,
new Cesium.Matrix4(),
),
world,
new Cesium.Cartesian3(),
);
const from = Cesium.Cartesian3.clone(
camera.position,
new Cesium.Cartesian3(),
);
const angle =
Cesium.Cartesian3.magnitude(from) > 0
? Cesium.Cartesian3.angleBetween(from, goal)
: 0;
const done = !(angle > MAX_TURN_PER_FRAME);
const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME);
camera.lookAtTransform(camera.transform, next);
viewer.scene.requestRender?.();
if (done) stop();
});
return stop;
}
/**
* `from` turned `step` radians toward `to`, with its length moved the same
* fraction of the way: one frame of the swing round the entity.
*/
function turnToward(from, to, step) {
const angle = Cesium.Cartesian3.angleBetween(from, to);
const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
// The direction perpendicular to `from` in the plane toward `to` (or any
// perpendicular when they are opposite).
let toward = Cesium.Cartesian3.subtract(
Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()),
Cesium.Cartesian3.multiplyByScalar(
unitFrom,
Math.cos(angle),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
if (Cesium.Cartesian3.magnitude(toward) < 1e-9)
toward = Cesium.Cartesian3.cross(
unitFrom,
Math.abs(unitFrom.z) < 0.9
? Cesium.Cartesian3.UNIT_Z
: Cesium.Cartesian3.UNIT_X,
new Cesium.Cartesian3(),
);
Cesium.Cartesian3.normalize(toward, toward);
const length =
Cesium.Cartesian3.magnitude(from) +
(Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) *
(step / angle);
const direction = Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
unitFrom,
Math.cos(step),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
toward,
Math.sin(step),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.multiplyByScalar(direction, length, direction);
}
+554
View File
@@ -0,0 +1,554 @@
// Short share links: `/share/<code>` opens through the start-up restore
// pipeline, the address then follows the live view, and the share button
// makes `/share/<code>` links (following an entity when the camera does).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { ShareLinkManager } from '../sharelink.js';
import { ShareRestoration, sharedFixNotice } from './shareRestoration.js';
import {
applyShareFollowOffset,
readShareFollowSubject,
} from './shareFollow.js';
import { createDefaultLayerState } from '../data/layerState.js';
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
import {
decodeShareView,
encodeShareView,
followCameraFor,
followOffsetToEnu,
} from '../share/viewCodec.js';
const NOW_MS = Date.now();
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 };
const OFFSET = { range: 5800, heading: 0, pitch: -35 };
const PLACE_CODE = encodeShareView({
lat: 48.8584,
lon: 2.2945,
alt: 1200,
heading: 120,
pitch: -25,
style: 'noir',
panelState: { specs: [{ id: 'data-panel', collapsed: false }] },
sharedAtMs: NOW_MS - 60_000,
});
function aircraftCode() {
const layerState = createDefaultLayerState();
layerState.enabledLayerIds = ['flights'];
return encodeShareView({
view: {
kind: 'thing',
entity: { kind: 'aircraft', id: 'a1b2c3' },
offset: OFFSET,
lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 },
},
layerState,
sharedAtMs: NOW_MS - 120_000,
});
}
/**
* A page at `href`. `history.replaceState` rewrites the address in place and
* is counted; `pushState` (a new history entry) is counted too.
*/
function installPage(t, href) {
const prior = { window: globalThis.window, history: globalThis.history };
const target = new EventTarget();
const location = new URL(href);
target.location = location;
globalThis.window = target;
const writes = { replaced: 0, pushed: 0 };
globalThis.history = {
replaceState(_state, _title, next) {
writes.replaced += 1;
location.href = new URL(next, location.href).href;
},
pushState() {
writes.pushed += 1;
},
};
t.after(() => Object.assign(globalThis, prior));
return { location, writes, target };
}
function installManualTimers(t) {
const prior = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
};
const timers = new Map();
let timerId = 0;
globalThis.setTimeout = (fn) => {
timers.set(++timerId, fn);
return timerId;
};
globalThis.clearTimeout = (id) => timers.delete(id);
t.after(() => Object.assign(globalThis, prior));
return {
timers,
runAll() {
for (const [id, fn] of [...timers]) {
timers.delete(id);
fn();
}
},
};
}
/** A viewer whose camera flights land at once. */
function fakeViewer() {
const flights = [];
return {
flights,
canvas: new EventTarget(),
camera: {
changed: { addEventListener: () => () => {} },
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
flyTo(options) {
flights.push(options);
options.complete?.();
},
setView() {},
},
};
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */
function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
const page = installPage(t, href);
const clock = installManualTimers(t);
const viewer = fakeViewer();
const restores = [];
const manager = new ShareLinkManager(viewer, {
onRestore: async (state) => restores.push(state),
isNavigationCurrent: () => true,
...managerOptions,
});
const notices = [];
let generation = 0;
const owner = new ShareRestoration({
viewer,
navigation: {
_beginDeferredNavigation: () => ++generation,
_reassertNavigationHandoff: () => true,
_stampNavigation() {},
},
syncShareState() {},
syncModels3d() {},
showStatus() {},
feedback: {},
updateFeedback() {},
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
});
// Notices are checked by message; their start-up-cover timing is not.
owner._presentAfterStartupCover = (message) => notices.push(message);
owner.attachLinks(manager);
owner._layerStateCoordinator = {
applyShareLayerState: () => Promise.resolve([]),
getDurableState: () => null,
cancelPendingShareTracking: () => false,
restoreShareTrackingSelection: async () => tracking?.(owner) ?? null,
destroy() {},
};
owner.start();
t.after(() => owner.destroy());
return { ...page, ...clock, viewer, manager, owner, restores, notices };
}
/** Run the delayed start-up restore and let it settle. */
async function restore(page) {
page.runAll();
const result = await page.owner.initialRestorePromise;
await flush();
return result;
}
test('a /share/<code> address opens its place view through the start-up restore', async (t) => {
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight');
assert.equal(page.manager._initialRestorePending, true);
const result = await restore(page);
assert.equal(result.status, 'settled');
assert.equal(result.share.camera, 'applied');
assert.equal(page.restores[0].style, 'noir');
assert.deepEqual(page.restores[0].panelState.specs, [
{ id: 'data-panel', collapsed: false, pinned: null },
]);
const destination = Cesium.Cartographic.fromCartesian(
page.viewer.flights[0].destination,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6,
);
assert.equal(page.manager._initialRestorePending, false);
assert.deepEqual(page.notices, []);
});
test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => {
for (const [href, root] of [
[`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'],
[
`http://localhost:4173/gev/share/${PLACE_CODE}`,
'http://localhost:4173/gev/',
],
]) {
const page = openPage(t, href);
await restore(page);
page.manager._updateHash();
assert.equal(`${page.location.origin}${page.location.pathname}`, root);
// The live view's long form, as written for any other page.
const params = new URLSearchParams(page.location.hash.slice(1));
assert.equal(params.get('v'), '2');
assert.ok(params.has('lat') && params.has('style'));
assert.equal(page.writes.pushed, 0);
assert.equal(page.writes.replaced, 1);
page.manager.destroy();
}
});
test('a host can pass the share code instead of the address', async (t) => {
const page = openPage(t, 'http://localhost/', {
managerOptions: { initialShareCode: PLACE_CODE },
});
await restore(page);
assert.equal(page.restores[0].style, 'noir');
});
test('a code that fails its check restores nothing and says so', async (t) => {
// One character changed: the check byte no longer matches.
const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A';
const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`;
const page = openPage(t, `http://localhost/share/${typo}`);
assert.equal(page.manager.rejectedShareCode, 'checksum');
assert.notEqual(page.owner._hasShareState, true);
assert.equal(page.timers.size, 0, 'nothing to restore');
assert.equal(
(await page.owner.initialRestorePromise).status,
'not-requested',
);
assert.deepEqual(page.notices, ["This share link can't be opened"]);
// The default start's first address write leaves the bad link behind.
page.manager._updateHash();
assert.equal(page.location.pathname, '/');
});
test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => {
let entity = null;
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
tracking: (owner) => {
// What the coordinator does when the aircraft is in the feed.
owner.viewer.trackedEntity = entity;
owner._handleShareTrackingRestoreStatus({
layerId: 'flights',
targetId: 'a1b2c3',
classification: 'followed',
});
return { classification: 'followed' };
},
});
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const listeners = [];
const looks = [];
Object.assign(page.viewer, {
clock: { currentTime: Cesium.JulianDate.now() },
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
isDestroyed: () => false,
});
Object.assign(page.viewer.camera, {
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
// The app's own follow framing: south of the aircraft and above it.
position: new Cesium.Cartesian3(0, -4800, 3300),
lookAtTransform(transform, offset) {
looks.push(offset);
this.position = Cesium.Cartesian3.clone(offset);
},
});
entity = {
gevTrackedId: 'flights:a1b2c3',
gevDisplayPosition: () => target,
};
const parsed = page.manager._parseShareCode(aircraftCode());
assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']);
assert.equal(
parsed.layerState.options.flights.selectedFlightsTrackingId,
'a1b2c3',
);
await restore(page);
// The opening flight goes where following the fix puts the camera.
const camera = followCameraFor(FIX, OFFSET);
const destination = Cesium.Cartographic.fromCartesian(
page.viewer.flights[0].destination,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3,
);
// Once followed, the camera takes the shared offset after the follow's
// own first frame.
assert.equal(listeners.length, 1);
listeners[0]();
assert.equal(looks.length, 0);
listeners[0]?.();
assert.equal(looks.length, 1);
const expected = followOffsetToEnu(OFFSET);
assert.ok(Math.abs(looks[0].x - expected.east) < 1);
assert.ok(Math.abs(looks[0].y - expected.north) < 1);
assert.ok(Math.abs(looks[0].z - expected.up) < 1);
assert.equal(listeners.length, 0, 'the offset is applied once');
assert.deepEqual(page.notices, []);
});
test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => {
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
tracking: (owner) => {
// The coordinator's verdict once the follow window has passed.
const result = {
layerId: 'flights',
targetId: 'a1b2c3',
label: 'flight',
classification: 'expired',
};
owner._handleShareTrackingRestoreStatus(result);
return result;
},
});
await restore(page);
await flush();
assert.equal(page.notices.length, 1);
assert.match(
page.notices[0],
/^A1B2C3 isn't reporting now — showing where it was at .+/,
);
// A long link names no fix and keeps the existing notice.
page.owner._initialShareState = { shareView: null };
page.owner._handleShareTrackingRestoreStatus({
layerId: 'flights',
targetId: 'a1b2c3',
label: 'flight',
classification: 'expired',
});
assert.equal(page.notices[1], 'Shared flight follow expired');
});
test('the not-reporting notice names the id and the time of the fix', () => {
const at = new Date(2026, 9, 26, 14, 5).getTime();
assert.match(
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
);
assert.equal(
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
"366999712 isn't reporting now — showing where it was last seen",
);
});
test('a shared vessel is selected once the vessel feed has it', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: NOW_MS });
const page = openPage(t, 'http://localhost/');
const selected = [];
let present = false;
page.owner._dataManager = {
layers: new Map([
[
'ais-live-vessels',
{
module: {
hasContact: (id) => present && id === '366999712',
selectById: (id) => selected.push(id) > 0,
},
},
],
]),
};
const view = {
kind: 'thing',
entity: { kind: 'vessel', id: '366999712' },
lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 },
};
const found = page.owner._restoreSharedVessel(view, () => true);
await flush();
present = true;
page.runAll();
assert.equal(await found, true);
assert.deepEqual(selected, ['366999712']);
present = false;
const missing = page.owner._restoreSharedVessel(view, () => true);
for (let i = 0; i < 61; i += 1) {
await flush();
t.mock.timers.tick(2_000);
page.runAll();
}
assert.equal(await missing, false);
assert.match(page.notices.at(-1), /^366999712 isn't reporting now/);
});
test('the share button links to /share/<code>, following the followed entity', (t) => {
installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2');
const manager = new ShareLinkManager(fakeViewer());
// A fresh session's look, as the app reports it at start-up.
manager.onToggleChange(false, true, {
hudVisible: true,
detectionMode: 'DENSE',
detectionDensity: 75,
});
// No entity: a place view at the live camera.
const place = manager.buildLink({ nowMs: NOW_MS });
assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/);
assert.equal(
decodeShareView(parseSharePath(new URL(place).pathname).code).state.view
.kind,
'place',
);
manager.setFollowSubjectProvider(() => ({
kind: 'aircraft',
id: 'a1b2c3',
offset: OFFSET,
lastKnown: { ...FIX, observedAtMs: NOW_MS },
}));
const href = manager.buildLink({ nowMs: NOW_MS });
const code = parseSharePath(new URL(href).pathname).code;
const { state } = decodeShareView(code, { nowMs: NOW_MS });
assert.deepEqual(state.view, {
kind: 'thing',
entity: { kind: 'aircraft', id: 'a1b2c3' },
offset: OFFSET,
lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 },
});
// The followed aircraft's layer is implied; the camera is derived.
assert.ok(code.length <= 45, `${code.length} characters`);
assert.equal(href, shareLinkUrl(code, 'https://gev.example/'));
manager.destroy();
});
test('the followed entity is read in the share format, and its offset round-trips', () => {
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const offset = { range: 2400, heading: 30, pitch: -20 };
const local = followOffsetToEnu(offset);
const cameraPosition = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
);
const viewer = {
camera: { positionWC: cameraPosition },
clock: { currentTime: Cesium.JulianDate.now() },
trackedEntity: {
gevTrackedId: 'military:ae1234',
gevDisplayPosition: () => target,
},
};
const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS });
assert.equal(subject.kind, 'military_aircraft');
assert.equal(subject.id, 'ae1234');
assert.deepEqual(subject.offset, offset);
assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9);
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3);
// A followed entity the share format has no kind for is not a thing view.
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
assert.equal(readShareFollowSubject({ viewer }), null);
// Nor is a camera that follows nothing (a selected vessel is not followed).
viewer.trackedEntity = undefined;
assert.equal(readShareFollowSubject({ viewer }), null);
});
test('a shared offset on the far side swings round, never jumping through the entity', () => {
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const listeners = [];
const looks = [];
const entity = { gevDisplayPosition: () => target };
const viewer = {
clock: { currentTime: Cesium.JulianDate.now() },
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
trackedEntity: entity,
camera: {
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
position: new Cesium.Cartesian3(0, -4800, 3300),
lookAtTransform(transform, offset) {
looks.push(Cesium.Cartesian3.clone(offset));
this.position = Cesium.Cartesian3.clone(offset);
},
},
};
// Looking south at the nose: the opposite side of the app's framing.
const offset = { range: 5800, heading: 180, pitch: -35 };
applyShareFollowOffset(viewer, entity, offset);
let previous = viewer.camera.position;
for (let frame = 0; frame < 10 && listeners.length; frame += 1) {
listeners[0]();
const now = viewer.camera.position;
// The follow's guard treats a turn of 90° or more as a crossing.
assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2);
previous = now;
}
assert.equal(listeners.length, 0, 'it arrives');
assert.ok(looks.length >= 2, `${looks.length} steps`);
const expected = followOffsetToEnu(offset);
const last = looks.at(-1);
assert.ok(Math.abs(last.x - expected.east) < 1);
assert.ok(Math.abs(last.y - expected.north) < 1);
assert.ok(Math.abs(last.z - expected.up) < 1);
});
test('applying a follow offset gives up when the follow ends', () => {
const listeners = [];
const viewer = {
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
trackedEntity: null,
};
applyShareFollowOffset(viewer, {}, OFFSET);
listeners[0]();
assert.equal(listeners.length, 0);
});
test('long links and hash navigation are unaffected by a share code start', async (t) => {
// A long link still opens from the hash.
const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg');
await restore(long);
assert.equal(long.restores[0].style, 'surveillance');
assert.equal(long.restores[0].shareView, undefined);
long.manager._updateHash();
assert.equal(long.location.pathname, '/');
// After a /share start, navigating to a long link restores it (#1416).
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
await restore(page);
page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal';
page.target.dispatchEvent(new Event('hashchange'));
await flush();
assert.equal(page.restores.length, 2);
assert.equal(page.restores[1].style, 'normal');
});
+292 -47
View File
@@ -1,9 +1,18 @@
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg } from '../i18n/index.js';
import { t, msg, formatDate } from '../i18n/index.js';
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
import { UiLifetime } from './uiLifetime.js';
/** How long a shared vessel may take to appear in the vessel feed. */
export const SHARED_VESSEL_WAIT_MS = 120_000;
const SHARED_VESSEL_POLL_MS = 2_000;
const DAY_MS = 86_400_000;
const ENTITY_BY_KIND = new Map(
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]),
);
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
// label) and outcome: a label dropped into a template cannot take the case or
// article the sentence needs in every language. The generic template stays
@@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) {
: t('Shared {subject} is unavailable', { subject });
}
/**
* The notice for a shared entity that is not reporting: the view shows where
* it was when the link was made.
* @param {{kind: string, id: string}} entity
* @param {number|null} observedAtMs
* @param {number} [nowMs]
* @returns {string}
*/
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
// ICAO addresses read as they are written on the aircraft: upper case.
const id =
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
? String(entity.id).toUpperCase()
: String(entity.id);
if (!Number.isFinite(observedAtMs))
return t("{id} isn't reporting now — showing where it was last seen", {
id,
});
const time = formatDate(
observedAtMs,
nowMs - observedAtMs < DAY_MS
? { hour: '2-digit', minute: '2-digit' }
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
);
return t("{id} isn't reporting now — showing where it was at {time}", {
id,
time,
});
}
/** Own initial share restoration, durable layer state and restoration notices. */
export class ShareRestoration {
constructor({
@@ -61,6 +100,7 @@ export class ShareRestoration {
showStatus,
feedback,
updateFeedback,
applyFollowOffset = null,
}) {
Object.assign(this, {
viewer,
@@ -70,6 +110,9 @@ export class ShareRestoration {
showStatus,
feedback,
updateFeedback,
// (entity, offset) => disposer: frame a followed entity from a shared
// follow offset (shareFollow.js).
applyFollowOffset,
});
this._lifetime = new UiLifetime();
this._disposed = false;
@@ -80,6 +123,14 @@ export class ShareRestoration {
this._initialShareRestoreTimeout = null;
this._layerStateCoordinator = null;
this._layerStateRestorePromise = null;
// Bumped by every share opened after start-up; an older restore that
// settles later must neither apply follow-up work nor release URL writes.
this._shareRestoreGeneration = 0;
this._navigatedShareRestoreActive = false;
this._stopFollowOffset = null;
// Bumped to abandon the wait for a shared vessel.
this._sharedVesselGeneration = 0;
this._sharedVesselPending = false;
}
attachLinks(shareLinkManager) {
this.shareLinkManager = shareLinkManager;
@@ -108,54 +159,35 @@ export class ShareRestoration {
const applyCamera =
Number.isInteger(generation) &&
this.navigation._reassertNavigationHandoff(generation);
void (async () => {
try {
const share = await this.shareLinkManager.applyState(savedState, {
applyCamera,
navigationToken: generation,
});
const layers = await (this._layerStateRestorePromise ||
Promise.resolve([]));
const tracking =
share.camera === 'applied'
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
: {
status: 'superseded',
cleared:
this._layerStateCoordinator?.cancelPendingShareTracking?.(
'shared-camera-superseded',
{ clearSelection: true },
) === true,
};
this.shareLinkManager.completeInitialRestore();
this._settleInitialShareRestore({
status: 'settled',
share,
layers,
tracking,
});
} catch (error) {
this.shareLinkManager.completeInitialRestore();
this._settleInitialShareRestore({
status: 'failed',
error: String(error?.message || error),
share: null,
layers: [],
});
}
})();
void this._runShareRestore(savedState, {
applyCamera,
navigationToken: generation,
layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result),
});
}, 1500);
} else {
this.syncShareState();
if (this.shareLinkManager?.rejectedShareCode)
this._presentAfterStartupCover(
t("This share link can't be opened"),
++this._shareTrackingNoticeGeneration,
);
}
// A recipient can orbit before or during the delayed share flight. That
// gesture keeps ordinary layer state but revokes the passive base camera
// and selected-subject Follow so delayed work cannot seize navigation.
this._initialShareGestureHandler = () => {
// The recipient taking the camera also ends the wait for a vessel.
if (this._sharedVesselPending) {
this._sharedVesselGeneration += 1;
this._sharedVesselPending = false;
}
if (
this._disposed ||
!this._hasShareState ||
!this._resolveInitialShareRestore
(!this._resolveInitialShareRestore &&
!this._navigatedShareRestoreActive)
)
return;
stampInitialShareGesture((options) =>
@@ -176,6 +208,126 @@ export class ShareRestoration {
passive: true,
},
);
// A different share opened in this tab later (pasted address, link,
// Back/Forward) runs the same restore as start-up.
this.shareLinkManager?.watchNavigation?.((state) =>
this._restoreNavigatedShare(state),
);
}
/**
* Apply one incoming share: camera, visual, map and panels, then layers and
* any shared Follow. Only the newest share may finish: a superseded run
* stops before follow-up work and leaves URL writes suppressed for the
* newer one.
*/
async _runShareRestore(
state,
{ applyCamera, navigationToken, layersPromise, settle },
) {
const generation = this._shareRestoreGeneration;
const current = () =>
!this._disposed && generation === this._shareRestoreGeneration;
try {
const share = await this.shareLinkManager.applyState(state, {
applyCamera,
navigationToken,
});
const layers = await (layersPromise || Promise.resolve([]));
if (!current()) return;
const tracking =
share.camera === 'applied'
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
: {
status: 'superseded',
cleared:
this._layerStateCoordinator?.cancelPendingShareTracking?.(
'shared-camera-superseded',
{ clearSelection: true },
) === true,
};
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
// Selecting a vessel leaves the camera alone, so it does not depend on
// the shared flight having landed.
const vessel = this._sharedVessel(state);
if (vessel) void this._restoreSharedVessel(vessel, current);
settle({ status: 'settled', share, layers, tracking });
} catch (error) {
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
settle({
status: 'failed',
error: String(error?.message || error),
share: null,
layers: [],
});
}
}
/** Restore a share opened in this tab after start-up, superseding any other. */
_restoreNavigatedShare(state) {
if (this._disposed || !state) return;
this._shareRestoreGeneration += 1;
const settle = (result) => {
this._navigatedShareRestoreActive = false;
window.dispatchEvent(
new CustomEvent('gev:share-navigation-restore-settled', {
detail: result,
}),
);
};
try {
return this._beginNavigatedShareRestore(state, settle);
} catch (error) {
// Never leave the address bar frozen on a restore that did not start.
this.shareLinkManager.completeInitialRestore(state.restoreToken);
settle({
status: 'failed',
error: String(error?.message || error),
share: null,
layers: [],
});
}
}
_beginNavigatedShareRestore(state, settle) {
// A start-up restore still waiting for its delayed flight never runs.
clearTimeout(this._initialShareRestoreTimeout);
this._initialShareRestoreTimeout = null;
this._settleInitialShareRestore({
status: 'superseded',
share: null,
layers: [],
});
this._initialShareState = state;
this._initialShareSelectionSuperseded = false;
this._hasShareState = true;
this._navigatedShareRestoreActive = true;
// A new deferred navigation generation retires any older share flight.
const navigationToken = this.navigation._beginDeferredNavigation(
'shared view',
{ cancelPendingSelection: false },
);
// Releasing the previous Follow is part of the passive share restore and
// must not be saved as the recipient's own layer preference.
const applyCamera =
Number.isInteger(navigationToken) &&
this.navigation._reassertNavigationHandoff(navigationToken, {
trackingOrigin: 'programmatic',
});
const layersPromise = this._layerStateCoordinator
? this._layerStateCoordinator.applyShareLayerState({
shareLayerState: state.layerState || null,
shareCreatedAtMs: state.sharedAtMs ?? null,
})
: null;
return this._runShareRestore(state, {
applyCamera,
navigationToken,
layersPromise,
settle,
});
}
connect(dataManager) {
this._dataManager = dataManager;
@@ -210,7 +362,79 @@ export class ShareRestoration {
});
}
}
/** The shared vessel a share's thing view selects, if it names one. */
_sharedVessel(state) {
const view = state?.shareView;
return view?.kind === 'thing' && view.entity.kind === 'vessel'
? view
: null;
}
/**
* Select a shared vessel once the vessel feed has it. Vessels are selected,
* not followed: the camera is already at the shared view. If the vessel does
* not appear in time, the view stays where it was when the link was made.
*/
async _restoreSharedVessel(view, current) {
const generation = ++this._sharedVesselGeneration;
const live = () => current() && generation === this._sharedVesselGeneration;
const deadline = Date.now() + SHARED_VESSEL_WAIT_MS;
const { id } = view.entity;
const { layerId } = ENTITY_BY_KIND.get(view.entity.kind);
this._sharedVesselPending = true;
try {
while (live()) {
const vessels = this._dataManager?.layers?.get?.(layerId)?.module;
if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
if (Date.now() >= deadline) break;
await new Promise((resolve) =>
this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS),
);
}
} finally {
if (generation === this._sharedVesselGeneration)
this._sharedVesselPending = false;
}
if (!live()) return false;
this._presentAfterStartupCover(
view.lastKnown
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
: t('Shared vessel is unavailable'),
++this._shareTrackingNoticeGeneration,
);
return false;
}
/** The current share's thing view when `result` is about its entity. */
_sharedThingFor(result) {
const view = this._initialShareState?.shareView;
if (view?.kind !== 'thing') return null;
const entry = ENTITY_BY_KIND.get(view.entity.kind);
return entry?.tracking === result?.layerId &&
String(view.entity.id) === String(result?.targetId)
? view
: null;
}
/** Frame a shared entity the camera now follows from the shared offset. */
_applySharedFollowOffset(result) {
const view = this._sharedThingFor(result);
const entity = this.viewer?.trackedEntity;
// Only a link made while following carries a real offset (with the fix);
// a long link's follow keeps the app's own framing.
if (
!view?.lastKnown ||
String(entity?.gevTrackedId || '') !==
`${result.layerId}:${result.targetId}`
)
return;
this._stopFollowOffset?.();
this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset);
}
cancelSelection() {
// An explicit navigation also ends the wait for a shared vessel.
this._sharedVesselGeneration += 1;
if (
this._hasShareState &&
this._resolveInitialShareRestore &&
@@ -256,17 +480,39 @@ export class ShareRestoration {
this.updateFeedback();
}
}
if (
result.classification === 'followed' ||
result.classification === 'cancelled'
)
if (result.classification === 'followed') {
this._applySharedFollowOffset(result);
return;
}
if (result.classification === 'cancelled') return;
// A stale terminal result must never replace a newer target's acquisition.
if (this._shareTrackingAcquiringKey) return;
const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration;
const message = shareTrackingRestoreNotice(result);
// A share link that knows where the entity was says so; the view is
// already there.
const fix = this._sharedThingFor(result)?.lastKnown;
const message = fix
? sharedFixNotice(
this._initialShareState.shareView.entity,
fix.observedAtMs,
)
: shareTrackingRestoreNotice(result);
if (this._resolveInitialShareRestore) {
void this.initialRestorePromise.then(() =>
this._presentAfterStartupCover(message, noticeGeneration),
);
return;
}
this._presentAfterStartupCover(message, noticeGeneration);
}
/**
* Show a status notice once the start-up cover is gone, unless a newer
* notice (generation) has taken its place.
*/
_presentAfterStartupCover(message, noticeGeneration) {
const showAfterStartupCover = () => {
this._lifetime.frame(() => {
if (
@@ -308,10 +554,6 @@ export class ShareRestoration {
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
});
};
if (this._resolveInitialShareRestore) {
void this.initialRestorePromise.then(showAfterStartupCover);
return;
}
showAfterStartupCover();
}
_settleInitialShareRestore(result) {
@@ -328,6 +570,9 @@ export class ShareRestoration {
this._disposed = true;
this._shareTrackingNoticeGeneration += 1;
this._shareTrackingAcquiringKey = null;
this._sharedVesselGeneration += 1;
this._stopFollowOffset?.();
this._stopFollowOffset = null;
this._layerStateCoordinator?.destroy();
this._layerStateCoordinator = null;
this._layerStateRestorePromise = null;
+1
View File
@@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) {
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'),
_locationSearchError: document.getElementById('location-search-error'),
_searchToggle: document.getElementById('search-toggle'),
_locationPills: document.getElementById('location-pills'),
_poiRow: document.getElementById('poi-row'),
+1 -1
View File
@@ -550,7 +550,7 @@ export class ShellFacade {
}
/** Settle only the search generation that still owns the shared input UI. */
_settleLocationSearchUi(generation) {
_settleLocationSearchUi(generation, options) {
return this._navigation._settleLocationSearchUi(...arguments);
}
+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
+17
View File
@@ -298,3 +298,20 @@
#location-search.searching {
opacity: 0.5;
}
.location-search-error {
/* Its own line under the search row, which clips overflow in the dock. */
margin: 6px 0 0;
padding: 6px 10px;
border: 1px solid rgba(255, 99, 99, 0.45);
border-radius: 6px;
background: rgba(30, 8, 10, 0.92);
color: #ffb3b3;
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.35;
}
.location-search-error:empty {
display: none;
}
+5 -1
View File
@@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
body.mobile-shell #title-bar {
top: calc(var(--mobile-top-inset) + 12px);
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;
+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;
}
+2 -1
View File
@@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
#param-slider-panel.collapsed #param-sliders,
#location-bar.collapsed .poi-row-container,
#location-bar.collapsed .location-bar-divider,
#location-bar.collapsed .location-city-row {
#location-bar.collapsed .location-city-row,
#location-bar.collapsed .location-search-error {
display: none !important;
}
+246
View File
@@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle {
animation: none;
}
}
/* Opt-in shared panel: header stays fixed while only the body scrolls. */
#gev-voice-control .gev-voice-panel {
position: absolute;
bottom: calc(100% + 0.7rem);
left: 50%;
z-index: 10;
display: flex;
flex-direction: column;
width: clamp(18rem, 26vw, 22rem);
max-width: calc(100vw - 1.5rem);
max-height: min(
28rem,
max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px)))
);
box-sizing: border-box;
padding: 0.6rem 0.7rem 0.65rem;
color: rgba(223, 245, 250, 0.92);
background: rgba(7, 22, 31, 0.96);
border: 1px solid rgba(54, 220, 255, 0.34);
border-radius: 0.6rem;
box-shadow:
0 0.85rem 2rem rgba(0, 0, 0, 0.58),
0 0 1.2rem rgba(0, 212, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.3);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
color-scheme: dark;
font: 0.75rem/1.45 var(--font-sans);
text-align: left;
transform: translateX(-50%);
}
:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel {
background-color: var(--cyber-panel-surface);
background-image: var(--cyber-panel-surface-detail);
border-color: var(--glass-border);
box-shadow: var(--cyber-panel-shadow);
}
#gev-voice-control .gev-voice-panel[hidden],
#gev-voice-control .gev-voice-panel [hidden] {
display: none;
}
#gev-voice-control .gev-voice-panel-header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
padding: 0 0 0.5rem;
border-bottom: 1px solid var(--glass-border);
}
.gev-voice-panel-title {
color: var(--voice-panel-accent, var(--accent));
font: 600 0.65rem/1 var(--font-mono);
letter-spacing: 0.14em;
}
.gev-voice-panel-tabs {
margin-left: auto;
}
#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
padding: 0;
}
#gev-voice-control .gev-voice-panel-tab-icon {
font-size: 18px;
}
.gev-voice-panel-header [role='tablist'] {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
min-width: 0;
}
#gev-voice-control
.gev-voice-panel
:is(.gev-voice-panel-header button, select, input[type='number']) {
min-height: 2rem;
box-sizing: border-box;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 0.3rem;
font: inherit;
}
#gev-voice-control .gev-voice-panel .gev-voice-panel-header button {
padding: 0 0.6rem;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.04em;
line-height: 1;
cursor: pointer;
}
#gev-voice-control #gev-voice-settings-close {
width: 2rem;
padding: 0;
font-size: 1rem;
}
#gev-voice-control .gev-voice-panel-header button:hover {
color: var(--text-primary);
border-color: var(--voice-panel-accent, var(--accent));
}
#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] {
color: var(--voice-panel-accent, var(--accent));
border-color: currentColor;
box-shadow: 0 -2px 0 currentColor inset;
}
#gev-voice-control .gev-voice-panel [role='tab']:focus,
#gev-voice-control .gev-voice-panel :focus-visible {
outline: 2px solid var(--voice-panel-accent, var(--accent));
outline-offset: 2px;
}
.gev-voice-panel-body {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.55rem 0.75rem 3px 3px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.gev-voice-panel-body .gev-voice-card-who {
overflow-wrap: break-word;
}
.gev-voice-panel-body p {
margin: 0;
}
.gev-voice-preference {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--text-primary);
}
.gev-voice-preference-toggle {
flex-direction: row-reverse;
justify-content: space-between;
min-height: 2.75rem;
font-weight: 600;
cursor: pointer;
}
.gev-voice-preference-toggle input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--voice-panel-accent, var(--accent));
}
.gev-voice-setting-title {
margin-bottom: 0.4rem;
font-weight: 600;
}
.gev-voice-timeout-group {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid var(--glass-border);
}
.gev-voice-timeout-controls {
display: flex;
align-items: center;
gap: 0.4rem;
}
#gev-voice-timeout-select {
flex: 1 1 auto;
min-width: 0;
padding: 0 0.45rem;
}
#gev-voice-timeout-input {
flex: 0 0 4.5rem;
width: 4.5rem;
padding: 0 0.45rem;
font-variant-numeric: tabular-nums;
}
.gev-voice-timeout-unit {
order: 1;
color: var(--text-dim);
font-size: 0.63rem;
}
.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden])
.gev-voice-timeout-unit {
display: none;
}
.gev-voice-settings-note {
color: rgba(190, 221, 230, 0.72);
font-size: 0.63rem;
line-height: 1.45;
}
.gev-voice-settings-note:not(:empty) {
margin-top: 0.25rem;
}
#gev-voice-control .gev-voice-panel .gev-voice-card {
position: static;
width: 100%;
max-height: none;
overflow: visible;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
transform: none;
animation: none;
}
#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) {
z-index: 101;
}
#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray {
display: none;
}
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel {
bottom: min(
calc(100% + 4.8rem + var(--gev-provider-lift)),
calc(100dvh - 7.75rem)
);
}
#command-dock.dock-has-pinned-tray
#gev-voice-control:not([data-provider-choice='true'])
.gev-voice-panel {
bottom: min(
calc(100% + 1.7rem + var(--dock-pinned-stack-height)),
calc(100dvh - 18rem)
);
}
@media (pointer: coarse) {
#gev-voice-control
.gev-voice-panel
:is(
.gev-voice-panel-header button,
select,
input[type='number'],
.gev-voice-card-dismiss
) {
min-height: 44px;
}
#gev-voice-control
.gev-voice-panel
:is(.gev-voice-panel-header button, .gev-voice-card-dismiss) {
min-width: 44px;
}
}
/* A connection error remains reachable even while the manual panel is open. */
#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray {
z-index: 12;
}
+333 -1
View File
@@ -62,6 +62,15 @@
color: #ffc46b;
}
.gev-voice-cost-warning {
margin-left: auto;
flex: 0 0 auto;
}
.gev-voice-cost-warning[data-level='unknown'] {
color: #ffc46b;
}
.gev-voice-cost-value[data-level='cap'] {
color: #ff6b6b;
font-weight: 600;
@@ -111,7 +120,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost {
padding-left: 0.1rem;
}
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn {
min-width: 0;
padding: 0 0.2rem;
}
@@ -232,3 +241,326 @@ html:not([lang='en'])
pointer-events: auto;
}
}
/* The opt-in provider and gear share a discoverable row at the existing anchor. */
#gev-voice-control:has(> .gev-voice-settings-button) {
--gev-voice-settings-gap: 0.75rem;
--gev-voice-tray-clearance: 0px;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field:not([hidden]) {
bottom: calc(
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap)
);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 46px 6px 6px;
border-color: rgba(123, 189, 211, 0.28);
background: rgba(9, 18, 27, 0.88);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46);
color-scheme: dark;
opacity: 1;
pointer-events: auto;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field
> span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field
select {
min-height: 34px;
font-size: 0.7rem;
}
#gev-voice-control .gev-voice-settings-button {
position: absolute;
top: 1rem;
right: 0.25rem;
z-index: 11;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
color: rgba(190, 221, 230, 0.72);
background: rgba(9, 18, 27, 0.92);
border: 1px solid var(--glass-border);
border-radius: 0.3rem;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
#gev-voice-control[data-provider-choice='true']:has(
.gev-voice-provider-field:not([hidden])
)
.gev-voice-settings-button {
top: auto;
right: 7px;
bottom: calc(
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px
);
}
#gev-voice-control
.gev-voice-settings-button:is(:hover, [aria-expanded='true']) {
color: var(--voice-panel-accent, var(--accent));
border-color: currentColor;
background: rgba(0, 212, 255, 0.12);
}
#gev-voice-control .gev-voice-settings-button:focus-visible {
outline: 2px solid var(--voice-panel-accent, var(--accent));
outline-offset: 2px;
}
:root[data-ui-theme='cyber']
#gev-voice-control:has(> .gev-voice-settings-button) {
--voice-panel-accent: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber']
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field {
background: var(--cyber-panel-surface);
border-color: var(--glass-border);
}
@media (pointer: coarse) {
#gev-voice-control .gev-voice-settings-button {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field:not([hidden]) {
padding-right: 56px;
}
#gev-voice-control:has(> .gev-voice-settings-button)
.gev-voice-provider-field
select {
min-height: 44px;
}
}
/* Keep the row and popup on the same lift, with a small gap above pinned
trays. On very short screens the shared lift still yields to the viewport. */
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
--gev-voice-settings-gap: 0.375rem;
--gev-provider-lift: min(
calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)),
max(0px, calc(100dvh - 20rem))
);
}
/* Match the attribution-aware tray offsets without moving the trays. The
popup can scroll before the shared viewport budget caps its lift. */
@media (max-width: 900px) {
#gev-voice-control:has(> .gev-voice-settings-button) {
--gev-voice-tray-clearance: calc(3rem - 1.2rem);
}
}
@media (max-width: 720px) {
#gev-voice-control:has(> .gev-voice-settings-button) {
--gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem);
}
}
/* Short touch landscapes have room beside the dock, not above its pinned
stack. Keep the same chooser and gear together in that empty lower gutter;
the open popup remains a bounded, scrollable overlay above the microphone. */
@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) {
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
),
#command-dock.dock-has-pinned-tray
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
--gev-provider-lift: 0px;
--gev-landscape-row-width: clamp(
108px,
calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem),
calc(100% - 2px)
);
}
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
> .gev-voice-provider-field:not([hidden]) {
left: calc(50% - 50vw + 0.5rem);
right: auto;
bottom: 0.375rem;
width: var(--gev-landscape-row-width);
box-sizing: border-box;
}
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
> .gev-voice-settings-button {
left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px);
right: auto;
top: auto;
bottom: calc(0.375rem + 7px);
}
}
/* The revealed row clears pinned wings without making the Voice column's
noninteractive gaps cover their controls. Keep hit surfaces stable so hover
promotion cannot oscillate when the pointer crosses root padding. */
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
pointer-events: none;
}
#command-dock
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
> :is(
#gev-voice-button,
.gev-voice-heading,
.gev-voice-readout,
.gev-voice-provider-field,
.gev-voice-settings-button,
.gev-voice-panel,
.gev-voice-error-tray
) {
pointer-events: auto;
}
@media (hover: hover) and (pointer: fine) {
#command-dock.dock-has-pinned-tray
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
):is(
:hover,
[data-row-grace],
:has(
:focus-visible,
#gev-voice-provider:focus,
.gev-voice-panel[data-view='settings']:not([hidden])
)
) {
z-index: 101;
}
}
@media (hover: none), (pointer: coarse) {
#command-dock.dock-has-pinned-tray
> #gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
) {
z-index: 101;
}
}
/* Pointer grace crosses pinned trays without covering their controls. Keyboard
focus, the provider select and an open panel hold the shared row visible. */
@media (hover: hover) and (pointer: fine) {
#command-dock
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
transition:
opacity 160ms ease,
transform 180ms ease;
}
#command-dock
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
):not(:hover):not([data-row-grace]):not(
:has(
:focus-visible,
#gev-voice-provider:focus,
.gev-voice-panel[data-view='settings']:not([hidden])
)
)
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
#command-dock:not(.dock-has-pinned-tray)
#gev-voice-control[data-provider-choice='true']:has(
> .gev-voice-settings-button
)
.gev-voice-provider-field::after {
content: '';
position: absolute;
top: 100%;
right: 0;
left: 0;
height: calc(var(--gev-voice-settings-gap) + 8px);
}
}
/* Existing tier and estimate controls are readable Settings content in panel mode. */
.gev-voice-cost-settings {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid var(--glass-border);
}
.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has(
#gev-voice-cost-value[hidden]
),
.gev-voice-cost-setting-row:has(> [hidden]) {
display: none;
}
.gev-voice-cost-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 2rem;
color: var(--text-primary);
font-size: 0.68rem;
}
.gev-voice-cost-setting-row > span {
min-width: 0;
}
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
flex: 0 0 auto;
min-height: 2rem;
min-width: 3.25rem;
padding: 0.3rem 0.55rem;
font-size: 0.67rem;
}
.gev-voice-cost-settings .gev-voice-cost-value {
flex: 0 0 auto;
font-size: 0.75rem;
}
.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] {
color: var(--voice-panel-accent, var(--accent));
}
#gev-voice-control .gev-voice-settings-icon {
font-size: 18px;
line-height: 1;
}
@media (pointer: coarse) {
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
min-height: 44px;
min-width: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
#command-dock
#gev-voice-control:has(> .gev-voice-settings-button)
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
transition: none;
}
}
+1
View File
@@ -91,6 +91,7 @@
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
</div>
</div>
<p id="location-search-error" class="location-search-error" role="alert"></p>
</div>
</div>
</div>
+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();
+1 -1
View File
@@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js';
import { nightVisionShader } from '../styles/surveillance.js';
import { thermalShader } from '../styles/thermal.js';
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
import { msg } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
/** Duration (ms) for shader intensity crossfade between style presets. */
export const TRANSITION_DURATION_MS = 500;
+23 -8
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';
@@ -73,13 +75,24 @@ export class VisualSettings {
panelState,
styleParams,
} = state || {};
// Ignore the retired 'ai-edit' style from older share links.
if (style && style !== 'normal' && style !== 'ai-edit') {
// Ignore the retired 'ai-edit' style from older share links. Normal is
// the start-up style, but a share opened later in the tab must still leave
// a filtered style for it.
if (
style &&
style !== 'ai-edit' &&
(style !== 'normal' || this.activeStyle !== 'normal')
) {
const alreadyActive = style === this.activeStyle;
this.setStyle(style, {
applyPreset: true,
revealParameters: false,
restore: true,
});
// setStyle leaves an already-active style untouched; the incoming share
// still starts from that preset before its own parameters apply.
if (alreadyActive && style !== 'normal')
this._applyStylePresetDefaults(style);
}
if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
@@ -136,11 +149,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}%`;
@@ -1302,11 +1314,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}%`;