Merge remote-tracking branch 'origin/main' into authoring-panel

# Conflicts:
#	docs/CURRENT-STATE.md
This commit is contained in:
Said Turksever committed 2026-10-29 18:40:02 +00:00
commit 2b05461232
142 files changed
+20740 -1917

No files matched your search

+2 -21
View File
@@ -114,19 +114,14 @@ export class StyleManager extends ShellFacade {
Object.assign(this, readShellElements());
this._panelChrome = new PanelChrome({
elements: {
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
_contextRadioDock: this._contextRadioDock,
_leftPanelStack: this._leftPanelStack,
_locationSearch: this._locationSearch,
_ppToggles: this._ppToggles,
_rightPanelStack: this._rightPanelStack,
},
operations: {
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
_syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args),
_syncContextRadioLauncherState: (...args) =>
this._syncContextRadioLauncherState(...args),
_showToast: (...args) => this._showToast(...args),
},
readHud: () => this.hud,
@@ -414,8 +409,6 @@ export class StyleManager extends ShellFacade {
this.getAircraftTrackingTarget(...args),
_scheduleRightPanelLayout: (...args) =>
this._scheduleRightPanelLayout(...args),
_syncContextRadioLauncherState: (...args) =>
this._syncContextRadioLauncherState(...args),
},
readDataManager: () => this._dataManager,
readContext: () => this.getContextModeState(),
@@ -1052,18 +1045,6 @@ export class StyleManager extends ShellFacade {
_cockpitRadioVolume: this._cockpitRadioVolume,
_cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
_cockpitUtilityControls: this._cockpitUtilityControls,
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
_contextRadioDock: this._contextRadioDock,
_contextRadioMini: this._contextRadioMini,
_contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn,
_contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn,
_contextRadioMiniNextBtn: this._contextRadioMiniNextBtn,
_contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn,
_contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn,
_contextRadioMiniStation: this._contextRadioMiniStation,
_contextRadioMiniVolume: this._contextRadioMiniVolume,
_contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue,
_contextRadioToggleBtn: this._contextRadioToggleBtn,
_radioEnableBtn: this._radioEnableBtn,
_radioFilter: this._radioFilter,
_radioLayerState: this._radioLayerState,
@@ -1337,7 +1318,7 @@ export class StyleManager extends ShellFacade {
/**
* Switches the HUD layout variant.
* @param {'tactical'|'operator'|'minimal'|'cyber'} variantName - Layout variant.
* @param {'tactical'|'minimal'|'cyber'} variantName - Layout variant.
* @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}}
*/
setHudLayout(variantName) {
@@ -1598,7 +1579,7 @@ export class StyleManager extends ShellFacade {
* 2. Crossfades the new shader stage intensity to 1.
* 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
* 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
-1
View File
@@ -5,7 +5,6 @@ const STYLE_KEYS = Object.freeze({
4: 'thermal',
5: 'anime',
6: 'noir',
7: 'snow',
});
/**
+6 -3
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
import { formatNumber, t, tn } from '../i18n/index.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -8,14 +8,17 @@ import {
relativeBearing,
resolveCockpitContextReadout,
} from '../cockpitMath.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
import {
cockpitUnitCase,
formatCockpitDistance,
} from './cockpitPresentation.js';
/** The Context scope line in the active language (see cockpitMath.js). */
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', {
label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${cockpitUnitCase(unitLabel('km'))}`,
}),
installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
-6
View File
@@ -8,12 +8,6 @@ export function onKeyDown(event) {
// focus. Its target handler closes the overlay and restores attribution
// focus; Cockpit must stay active behind it.
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
if (
document
.getElementById('context-radio-dock')
?.classList.contains('disclosure-open')
)
return;
if (
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
)
-2
View File
@@ -268,7 +268,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
thermal: t('FLIR'),
anime: t('ANIME'),
noir: t('NOIR'),
snow: t('SNOW'),
};
const names = {
optical: t('Normal'),
@@ -277,7 +276,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
thermal: t('Thermal'),
anime: t('Anime'),
noir: t('Noir'),
snow: t('Snow'),
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
+9 -1
View File
@@ -149,7 +149,15 @@ export function formatCockpitDistance(distanceM) {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
return cockpitUnitCase(distance);
}
/**
* Upper-case a cockpit unit the HUD way (`9.5 KM`), except in Chinese and
* Japanese, which have no case and keep units as the catalog writes them.
*/
export function cockpitUnitCase(text) {
return /^(zh|ja)(-|$)/.test(getLocale()) ? text : text.toUpperCase();
}
export function formatCockpitWindDirection(value) {
+1 -1
View File
@@ -72,7 +72,7 @@ test('opening already-expanded Radio reveals Context and closes Display', () =>
});
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
for (const theme of ['tactical', 'minimal', 'cyber']) {
const f = fixture(theme), prior = globalThis.document;
globalThis.document = f.doc;
try {
+1 -1
View File
@@ -132,7 +132,7 @@ test('rejects the whole invalid request before any mutation', () => {
});
test('requires Cyber and preserves off state and every omitted setting', () => {
for (const theme of ['tactical', 'operator', 'minimal']) {
for (const theme of ['tactical', 'minimal']) {
const f = fixture(theme);
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
assert.deepEqual(f.calls, []);
+58 -42
View File
@@ -54,8 +54,9 @@ function styleOwner(t, initialVariant = 'cyber') {
const hud = {
getVariant: () => variant,
setVariant: (value) => {
variant = value;
applyHudUiTheme(root, value);
// Normalize like the real HUD so retired layouts fall back.
variant = normalizeHudLayout(value);
applyHudUiTheme(root, variant);
},
setMode: (value) => calls.push(['mode', value]),
onStyleChange: (value) => calls.push(['style', value]),
@@ -105,7 +106,6 @@ test('visual styles retain Cyber through real preset application without droppin
'normal',
'noir',
'anime',
'snow',
'thermal',
'retro',
]) {
@@ -131,7 +131,7 @@ test('visual styles retain Cyber through real preset application without droppin
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
const { owner, hud, root } = styleOwner(t);
for (const variant of ['tactical', 'operator', 'minimal']) {
for (const variant of ['tactical', 'minimal']) {
owner._setHudVariant(variant);
assert.equal(hud.getVariant(), variant);
assert.equal(root.dataset.uiTheme, undefined);
@@ -150,8 +150,8 @@ test('leaving Cyber without a preset choice restores the prior visual preset', (
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'minimal');
assert.equal(owner.activeStyle, 'noir');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
@@ -162,26 +162,26 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner.activeStyle, 'snow');
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, true);
// Reselecting the active mode must not erase the user's preset intent.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'snow');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'minimal');
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
// A later untouched Cyber round trip restores the new pre-Cyber preset.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'snow');
assert.equal(owner._preCyberStyle, 'anime');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
});
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
@@ -192,7 +192,7 @@ test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
owner.setStyle('thermal', { userInitiated: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('operator', { applyVisualDefaults: true });
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
});
@@ -200,15 +200,15 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('cyber');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'anime');
});
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
@@ -226,10 +226,10 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const snowButton = new EventTarget();
snowButton.dataset = { style: 'snow' };
const animeButton = new EventTarget();
animeButton.dataset = { style: 'anime' };
const controls = bindDisplayControls({
elements: { hudLayout, styleButtons: [snowButton] },
elements: { hudLayout, styleButtons: [animeButton] },
actions: {
setHudLayout: (value) => display.owner.setHudLayout(value),
setStyle: (value) =>
@@ -239,12 +239,12 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
snowButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'snow');
hudLayout.value = 'operator';
animeButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'anime');
hudLayout.value = 'minimal';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
assert.equal(display.owner.activeStyle, 'snow');
assert.equal(display.hud.getVariant(), 'minimal');
assert.equal(display.owner.activeStyle, 'anime');
controls.destroy();
const voice = makeOwner();
@@ -263,13 +263,13 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(entered.ok, true);
assert.equal(voice.hud.getVariant(), 'cyber');
assert.equal(voice.owner.activeStyle, 'thermal');
const selected = await runVoiceAction('set_visual_style', { style: 'snow' });
const selected = await runVoiceAction('set_visual_style', { style: 'anime' });
assert.equal(selected.ok, true);
assert.equal(voice.owner.activeStyle, 'snow');
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
assert.equal(voice.owner.activeStyle, 'anime');
const exited = await runVoiceAction('set_hud', { layout: 'minimal' });
assert.equal(exited.ok, true);
assert.equal(voice.hud.getVariant(), 'operator');
assert.equal(voice.owner.activeStyle, 'snow');
assert.equal(voice.hud.getVariant(), 'minimal');
assert.equal(voice.owner.activeStyle, 'anime');
});
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
@@ -284,7 +284,7 @@ test('scope choice and expanded feather survive every HUD layout and both restor
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']) {
for (const layout of ['minimal', 'cyber', 'tactical']) {
owner._setHudVariant(layout, { applyVisualDefaults: true });
assert.equal(isScopeMaskEnabled(), false, layout);
assert.equal(getScopeMaskFeather(), 2, layout);
@@ -301,11 +301,18 @@ test('scope choice and expanded feather survive every HUD layout and both restor
assert.equal(owner._scopeFeatherSlider.value, '200');
});
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
await owner.applyVisualState({ style: 'snow' });
assert.equal(owner.activeStyle, 'normal');
});
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');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
@@ -313,13 +320,13 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset',
assert.equal(owner.activeStyle, 'retro');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'retro');
owner.setStyle('surveillance');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'surveillance');
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
@@ -342,7 +349,8 @@ test('scene and share restoration override Cyber and preserve restored Cyber par
style: 'surveillance',
hudVariant: 'operator',
});
assert.equal(hud.getVariant(), 'operator');
// A link saved with the retired Operator layout restores as Tactical.
assert.equal(hud.getVariant(), 'tactical');
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
@@ -564,8 +572,9 @@ test('Cyber side panels share one width and one framed surface material', () =>
});
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'minimal', 'cyber']);
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
assert.equal(normalizeHudLayout('operator'), 'tactical');
assert.equal(normalizeHudLayout('unknown'), 'tactical');
});
@@ -597,7 +606,7 @@ test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', ()
);
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
cyberVisualDefaultsForHudTransition('cyber', 'minimal', {
explicit: true,
}),
null,
@@ -714,10 +723,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /\.hud-top-left/);
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-bar \{\s*display: none;/,
);
assert.doesNotMatch(cyberStyles, /\.hud-top-bar/);
// Cyber shares the measured rail/credit keep-out rather than overriding it.
assert.doesNotMatch(
cyberStyles,
@@ -762,6 +768,16 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
// The base standby is borderless plain text, so the focus card must name its
// own frame width and centring rather than inherit them.
assert.match(
cyberStyles,
/\.context-mode-standby:not\(\[hidden\]\) \{[^}]*place-content: center;[^}]*border: 1px solid[^}]*text-align: center;/,
);
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\] \.global-context-modes \{[^}]*border: 0;/,
);
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
assert.match(cyberStyles, /#left-panel-stack:has/);
+5 -3
View File
@@ -136,9 +136,11 @@ export function layerKeyRequirementTooltip(layer = {}) {
const ROW_TEXT_SEPARATOR = ' · ';
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
// error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
// A short Latin code joined by hyphens: every part after a hyphen is capitals
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
// in an error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
const HYPHENATED_CODE_MAX = 16;
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
+9
View File
@@ -324,6 +324,15 @@ test('row text keeps a hyphenated code whole and never starts a line with the se
assert.equal(plain.textContent, 'Satellites');
assert.equal(plain.children.length, 0);
const compound = element();
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.deepEqual(
kept(compound),
[' ·', 'ADS-B'],
'a compound may break at its hyphen; the code inside it stays whole',
);
const long = element();
setRowText(long, 'see https-proxy-endpoint-config-x');
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
+22 -7
View File
@@ -31,6 +31,9 @@ const MANY = Object.keys(NAMES);
const codes = (rows) => rows.map((row) => row.code);
// Few enough languages that the picker has no search field.
const UNSEARCHED = ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'];
test('the current language comes first, the rest by their own names', () => {
assert.deepEqual(
codes(
@@ -40,7 +43,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
['ru', 'uk', 'de', 'en', 'es', 'fr', 'it', 'pt', 'zh-CN', 'zh-Hant', 'ja'],
);
assert.deepEqual(
codes(
@@ -50,7 +53,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
['es', 'de', 'en', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
);
assert.deepEqual(
codes(
@@ -60,7 +63,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
['en', 'de', 'es', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
);
const rows = buildLocaleOptions({
locales: MANY,
@@ -95,6 +98,8 @@ test('each row gets the interface-language name for search, unless it only repea
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
assert.equal(byCode.en.localName, 'английский');
assert.equal(byCode.es.localName, 'испанский');
assert.equal(byCode.de.localName, 'немецкий');
assert.equal(byCode.fr.localName, 'французский');
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
const english = buildLocaleOptions({
locales: LOCALES,
@@ -103,6 +108,7 @@ test('each row gets the interface-language name for search, unless it only repea
localName: createLocalNameReader('en'),
});
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
assert.equal(english.find((row) => row.code === 'fr').localName, 'French');
});
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
@@ -165,7 +171,10 @@ test('search folds case and diacritics, and matches either name or the code', ()
test('the search field appears only above the threshold', () => {
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
assert.equal(
createLocalePickerModel({ current: 'en' }).searchable,
LOCALES.length > LOCALE_SEARCH_THRESHOLD,
);
const eight = MANY.slice(0, 8);
assert.equal(
createLocalePickerModel({
@@ -250,6 +259,7 @@ test('type-ahead cycles by first letter and refines with more letters', () => {
test('keyboard model: open on the current language, move, choose, close', () => {
const model = createLocalePickerModel({
locales: UNSEARCHED,
current: 'ru',
localName: createLocalNameReader('ru'),
});
@@ -261,7 +271,8 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.openList();
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
// Russian collation puts the other Cyrillic name next.
assert.equal(model.activeOption.code, 'uk');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'zh-Hant');
model.keydown({ key: 'Home' });
@@ -275,7 +286,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.keydown({ key: 'ArrowDown' });
assert.deepEqual(model.keydown({ key: ' ' }), {
handled: true,
choose: 'en',
choose: 'uk',
});
assert.deepEqual(model.keydown({ key: 'Escape' }), {
handled: true,
@@ -292,7 +303,11 @@ test('keyboard model: open on the current language, move, choose, close', () =>
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
let clock = 0;
const model = createLocalePickerModel({ current: 'en', now: () => clock });
const model = createLocalePickerModel({
locales: UNSEARCHED,
current: 'en',
now: () => clock,
});
model.openList();
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
assert.equal(model.activeOption.code, 'es');
+1 -40
View File
@@ -71,8 +71,7 @@ export class PanelChrome {
variant: this.hud.getVariant(),
}),
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
syncPanelCollapseButton: (panel) =>
this._syncRailPanelCollapseButton(panel),
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
@@ -292,19 +291,6 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
_syncRailPanelCollapseButton(panelEl) {
// Adaptive rail layout may directly restore an auto-collapsed Context
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
if (
panelEl?.id === 'global-context-panel' &&
!panelEl.classList.contains('collapsed') &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
this._syncPanelCollapseButton(panelEl);
}
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
@@ -365,15 +351,6 @@ export class PanelChrome {
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
dockToggle.title = t(titleKey, { name: panelName });
}
if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
this._contextRadioDetailsBtn.setAttribute(
'aria-expanded',
String(!collapsed),
);
}
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
this._syncContextRadioLauncherState();
}
}
_buildSharePanelState() {
@@ -517,15 +494,6 @@ export class PanelChrome {
syncShare,
});
}
// A repeated Context-open request must clear a stale compact Radio mini
// even when no collapsed class changes.
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
@@ -554,13 +522,6 @@ export class PanelChrome {
this.cockpitView.setContextCollapsed(true);
}
}
if (
!nextCollapsed &&
panelId === 'global-context-panel' &&
this._contextRadioDock?.classList.contains('disclosure-open')
) {
this._setRadioDisclosure?.(false);
}
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
-200
View File
@@ -1,200 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelChrome } from './panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
function classList(initial = []) {
const values = new Set(initial);
return {
contains: (value) => values.has(value),
add: (...items) => items.forEach((item) => values.add(item)),
remove: (...items) => items.forEach((item) => values.delete(item)),
toggle(value, force) {
if (force === true) values.add(value);
else if (force === false) values.delete(value);
else if (values.has(value)) values.delete(value);
else values.add(value);
return values.has(value);
},
};
}
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
const priorDocument = globalThis.document;
const contextPanel = {
id: 'global-context-panel',
classList: classList(),
};
globalThis.document = {
documentElement: { dataset: { uiTheme: 'tactical' } },
getElementById(id) {
return id === 'global-context-panel' ? contextPanel : null;
},
};
t.after(() => {
globalThis.document = priorDocument;
});
const radioDock = { classList: classList(['disclosure-open']) };
const calls = [];
const owner = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(open);
radioDock.classList.toggle('disclosure-open', open);
},
_leftPanelStack: null,
_rightPanelStack: null,
_panelLayout: {
_leftStackPreferredPanelId: null,
_rightStackPreferredPanelId: null,
},
_syncPanelCollapseButton() {},
_scheduleLeftPanelLayout() {},
_scheduleRightPanelLayout() {},
shareLinkManager: null,
cockpitView: null,
};
PanelChrome.prototype.setPanelCollapsed.call(
owner,
'global-context-panel',
false,
);
assert.deepEqual(calls, [false]);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
});
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.requestAnimationFrame = priorRaf;
});
// Valid starting state: the tactical rail automatically collapsed Context,
// and the user opened the compact Radio controls from its header.
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const radioDock = { classList: classList(['disclosure-open']) };
const radioMini = { hidden: false };
const calls = [];
const chrome = {
_contextRadioDock: radioDock,
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
radioDock.classList.toggle('disclosure-open', open);
radioMini.hidden = !open;
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
};
const layout = {
destroyed: false,
_rightStackLayoutFrame: null,
_rightStackReconsiderAutoCollapse: false,
_rightPanelStack: {
querySelectorAll: (selector) =>
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
},
_syncPanelCollapseButton(panel) {
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
},
_syncRightPanelAdaptiveLayout() {
calls.push('layout');
},
};
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
reconsiderAutoCollapse: true,
});
assert.equal(contextPanel.classList.contains('collapsed'), true);
assert.equal(radioMini.hidden, false);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
assert.equal(radioDock.classList.contains('disclosure-open'), false);
assert.equal(radioMini.hidden, true);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
const priorDocument = globalThis.document;
const priorRaf = globalThis.requestAnimationFrame;
let nextFrame;
const contextPanel = {
id: 'global-context-panel',
classList: classList(['collapsed', 'layout-auto-collapsed']),
};
const rightRail = {
querySelectorAll(selector) {
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
},
};
globalThis.document = {
getElementById(id) {
return id === 'right-context-rail' ? rightRail : null;
},
};
globalThis.requestAnimationFrame = (callback) => {
nextFrame = callback;
return 1;
};
t.after(() => {
globalThis.document = priorDocument;
globalThis.requestAnimationFrame = priorRaf;
});
const dock = { classList: classList(['disclosure-open']) };
const calls = [];
const chrome = new PanelChrome({
elements: { _contextRadioDock: dock },
operations: {
_setRadioDisclosure(open) {
calls.push(`disclosure:${open}`);
dock.classList.toggle('disclosure-open', open);
},
_syncPanelCollapseButton(panel) {
calls.push(`button:${panel.id}`);
},
},
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
readCockpit: () => null,
readShareLinks: () => null,
readInitialShare: () => null,
readScrollRestoreOwner: () => null,
readDisplayScrollTop: () => 0,
});
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
calls.push('layout');
};
chrome._panelLayout._scheduleRightPanelLayout({
reconsiderAutoCollapse: true,
});
assert.equal(typeof nextFrame, 'function');
assert.equal(dock.classList.contains('disclosure-open'), true);
nextFrame();
assert.equal(contextPanel.classList.contains('collapsed'), false);
assert.equal(dock.classList.contains('disclosure-open'), false);
assert.deepEqual(calls, [
'disclosure:false',
'button:global-context-panel',
'layout',
]);
});
-2
View File
@@ -20,7 +20,6 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
'#intel-hud .hud-top-right',
'#intel-hud .hud-bottom-left',
'#intel-hud .hud-bottom-right',
'#intel-hud .hud-top-bar',
'#intel-hud .hud-bottom-bar',
'#intel-hud .hud-left-edge',
'#intel-hud .hud-right-edge',
@@ -50,7 +49,6 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [
'#intel-hud .hud-top-right',
'#intel-hud .hud-bottom-left',
'#intel-hud .hud-bottom-right',
'#intel-hud .hud-top-bar',
'#intel-hud .hud-bottom-bar',
'#intel-hud .hud-left-edge',
'#intel-hud .hud-right-edge',
+2 -7
View File
@@ -39,7 +39,7 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the
true,
'layout does not reopen a peer',
);
f.options.hud.variant = 'operator';
f.options.hud.variant = 'minimal';
f.run();
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(
@@ -654,12 +654,7 @@ function flatRules(block) {
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
for (const panel of [
'data-panel',
'scene-panel',
'cctv-panel',
'global-context-panel',
]) {
for (const panel of ['data-panel', 'scene-panel', 'cctv-panel']) {
assert.match(
css,
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
-90
View File
@@ -10,16 +10,6 @@ import {
export function bindRadioControls() {
if (!this._radioPanel) return;
const tunerListenerOptions = {};
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
if (this.destroyed) return;
const open = Boolean(expanded);
this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState();
if (!open && returnFocus)
this._contextRadioToggleBtn?.focus({ preventScroll: true });
};
this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (
kind,
expanded,
@@ -399,60 +389,16 @@ export function bindRadioControls() {
'click',
() => void toggleRadio(this._radioEnableBtn),
);
this.listen(
this._contextRadioMiniEnableBtn,
'click',
() => void toggleRadio(this._contextRadioMiniEnableBtn),
);
this.listen(
this._cockpitRadioEnableBtn,
'click',
() => void toggleRadio(this._cockpitRadioEnableBtn),
);
this.listen(this._contextRadioToggleBtn, 'click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector(
'[data-collapse-target="radio-panel"]',
),
});
return;
}
setRadioDisclosure(
!this._contextRadioDock?.classList.contains('disclosure-open'),
);
});
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
setRadioDisclosure(false, { returnFocus: true });
});
this.listen(this._contextRadioDetailsBtn, 'click', () => {
if (!this.actions.isCockpitActive())
this.actions.setPanelCollapsed('global-context-panel', false, {
explicit: true,
});
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true });
});
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open =
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
this.listen(
document,
'pointerdown',
(event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open'))
return;
if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false);
},
tunerListenerOptions,
);
this.listen(
document,
'pointerdown',
@@ -470,29 +416,6 @@ export function bindRadioControls() {
},
tunerListenerOptions,
);
this.listen(
document,
'keydown',
(event) => {
if (
event.key !== 'Escape' ||
!this._contextRadioDock?.classList.contains('disclosure-open')
)
return;
event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
const escapedFromDisclosure =
event.target === this._contextRadioToggleBtn ||
this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
},
{ capture: true },
);
this.listen(
document,
'keydown',
@@ -586,13 +509,6 @@ export function bindRadioControls() {
this._radioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
this.listen(
this._contextRadioMiniPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
@@ -607,12 +523,6 @@ export function bindRadioControls() {
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
this.listen(this._contextRadioMiniVolume, 'input', () => {
const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue)
this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue)
-102
View File
@@ -1,4 +1,3 @@
import { t, msg } from '../i18n/index.js';
import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
@@ -109,107 +108,6 @@ export class RadioControls {
return true;
}
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!this._radioPanel ||
this._radioPanel.classList.contains('collapsed')
)
return false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (
this.destroyed ||
contextPanel.classList.contains('collapsed') ||
this._radioPanel.classList.contains('collapsed')
)
return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the launcher truthful for the active theme and Context route. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = globalThis.document?.getElementById(
'global-context-panel',
);
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
if (contextExpanded) {
const radioExpanded = Boolean(
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
);
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(radioExpanded),
);
const label = t(
radioExpanded
? msg('Go to expanded Radio section')
: msg('Expand Radio section in Context'),
);
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
// This launcher toggles the compact player even when the detailed panel
// is open elsewhere. Its ARIA state must describe that same disclosure.
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
this._contextRadioToggleBtn.setAttribute(
'aria-controls',
'context-radio-mini',
);
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(compactOpen),
);
const launcherLabel = t(
compactOpen
? msg('Close compact Radio controls')
: msg('Open compact Radio controls'),
);
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
this._contextRadioToggleBtn.title = launcherLabel;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
-1
View File
@@ -102,7 +102,6 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown'
f.controls.destroy();
f.controls.destroy();
const snapshot = structuredClone(f.calls);
f.controls._setRadioDisclosure(true);
f.controls._setCockpitDisclosure('display', true);
f.next.dispatchEvent(new Event('click'));
f.document.dispatchEvent(new Event('gev:radio-selected'));
+1 -7
View File
@@ -3,15 +3,9 @@ export function syncRadioPanelPlacement(doc) {
if (!doc?.getElementById) return;
const panel = doc.getElementById('radio-panel');
const context = doc.getElementById('global-context-panel');
const dock = doc.getElementById('context-radio-dock');
const mini = doc.getElementById('context-radio-mini');
const parent =
context?.querySelector('.cyber-panel-body') ||
context?.querySelector('.global-context-panel-inner');
const header = context?.querySelector('.panel-header');
if (!panel || !parent || !header || !dock || !mini) return;
if (!panel || !parent) return;
if (panel.parentElement !== parent) parent.append(panel);
if (dock.parentElement !== header)
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
if (mini.parentElement !== dock) dock.append(mini);
}
+4 -32
View File
@@ -33,31 +33,15 @@ test('all themes keep the same Radio controls nested in Context', () => {
const panel = node(),
context = node(),
rail = node(),
dock = node(),
mini = node();
const inner = node(),
contextHeader = node(),
radioHeader = node();
const contextCollapse = node(),
radioCollapse = node();
inner = node(),
body = node();
context.selectors['.global-context-panel-inner'] = inner;
const body = node();
context.selectors['.cyber-panel-body'] = body;
context.selectors['.panel-header'] = contextHeader;
panel.selectors['.panel-header'] = radioHeader;
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
inner.append(panel);
contextHeader.append(dock);
contextHeader.append(contextCollapse);
radioHeader.append(radioCollapse);
dock.append(mini);
const nodes = {
'radio-panel': panel,
'global-context-panel': context,
'right-context-rail': rail,
'context-radio-dock': dock,
'context-radio-mini': mini,
};
const doc = {
documentElement: { dataset: {} },
@@ -67,31 +51,19 @@ test('all themes keep the same Radio controls nested in Context', () => {
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
const moves = [panel.moves, dock.moves, mini.moves];
const moves = panel.moves;
syncRadioPanelPlacement(doc);
assert.deepEqual(
[panel.moves, dock.moves, mini.moves],
moves,
'no mutation feedback loop',
);
assert.equal(panel.moves, moves, 'no mutation feedback loop');
delete doc.documentElement.dataset.uiTheme;
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
}
// Recover a hierarchy left by the previous Cyber implementation without
// recreating the player, changing playback, or mutating collapse state.
rail.append(panel);
radioHeader.insertBefore(dock, radioCollapse);
panel.append(mini);
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
});
test('placement is inert before the complete shell exists', () => {
-53
View File
@@ -26,11 +26,6 @@ export function renderRadioState(state) {
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
this._radioPanel.classList.toggle('radio-enabled', enabled);
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
this._contextRadioDock?.classList.toggle('active', enabled);
if (this._contextRadioToggleBtn) {
this._contextRadioToggleBtn.classList.toggle('active', enabled);
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
const lifecycleLabel =
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
@@ -66,26 +61,6 @@ export function renderRadioState(state) {
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute(
'aria-pressed',
String(enabled),
);
this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleLabel
: enableLabel;
this._contextRadioMiniEnableBtn.setAttribute('aria-label', enableAria);
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute(
'aria-disabled',
String(transitioning),
);
this._contextRadioMiniEnableBtn.setAttribute(
'aria-busy',
String(transitioning),
);
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
@@ -202,10 +177,6 @@ export function renderRadioState(state) {
this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._radioNextBtn)
this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn)
this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniNextBtn)
this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn)
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn)
@@ -232,13 +203,6 @@ export function renderRadioState(state) {
this._radioPlayBtn.textContent = playbackButtonLabel;
this._radioPlayBtn.setAttribute('aria-label', playAria);
}
if (this._contextRadioMiniPlayBtn) {
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute('aria-label', playAria);
this._contextRadioMiniPlayBtn.title = playbackAction;
}
if (this._cockpitRadioPlayBtn) {
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
@@ -255,17 +219,6 @@ export function renderRadioState(state) {
if (this._radioVolumeValue)
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (
this._contextRadioMiniVolume &&
document.activeElement !== this._contextRadioMiniVolume
) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
}
if (this._contextRadioMiniVolume)
this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (
this._cockpitRadioVolume &&
document.activeElement !== this._cockpitRadioVolume
@@ -277,12 +230,6 @@ export function renderRadioState(state) {
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? t('RADIO STATE UNCERTAIN')
: selected?.name ||
(state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? t('UNCERTAIN')
@@ -22,7 +22,6 @@ function render(audioError) {
destroyed: false,
_radioPanel: el(),
_radioPlaybackState: el(),
_syncContextRadioLauncherState() {},
actions: {
getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
@@ -27,7 +27,6 @@ for (const lifecycleState of ['enabling', 'disabling']) {
destroyed: false,
_radioPanel: el(),
_radioEnableBtn: enable,
_syncContextRadioLauncherState() {},
actions: {
getLifecycle: () => ({
lifecycleState,
-1
View File
@@ -12,7 +12,6 @@ const STYLE_LABELS = Object.freeze({
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
const DETECTION_MODE_LABELS = Object.freeze({
-30
View File
@@ -61,36 +61,6 @@ export function readShellElements(document = globalThis.document) {
_cctvPanel: document.getElementById('cctv-panel'),
_streetLevelPanel: document.getElementById('street-level-panel'),
_radioPanel: document.getElementById('radio-panel'),
_contextRadioDock: document.getElementById('context-radio-dock'),
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
_contextRadioMini: document.getElementById('context-radio-mini'),
_contextRadioMiniEnableBtn: document.getElementById(
'context-radio-mini-enable-btn',
),
_contextRadioDetailsBtn: document.getElementById(
'context-radio-details-btn',
),
_contextRadioMiniCloseBtn: document.getElementById(
'context-radio-mini-close-btn',
),
_contextRadioMiniStation: document.getElementById(
'context-radio-mini-station',
),
_contextRadioMiniPrevBtn: document.getElementById(
'context-radio-mini-prev-btn',
),
_contextRadioMiniPlayBtn: document.getElementById(
'context-radio-mini-play-btn',
),
_contextRadioMiniNextBtn: document.getElementById(
'context-radio-mini-next-btn',
),
_contextRadioMiniVolume: document.getElementById(
'context-radio-mini-volume',
),
_contextRadioMiniVolumeValue: document.getElementById(
'context-radio-mini-volume-value',
),
_cockpitUtilityControls: document.getElementById(
'cockpit-utility-controls',
),
-8
View File
@@ -949,14 +949,6 @@ export class ShellFacade {
return this._radioControls?._setCockpitDisclosure?.(...args);
}
_setRadioDisclosure(...args) {
return this._radioControls?._setRadioDisclosure?.(...args);
}
_syncContextRadioLauncherState(...args) {
return this._radioControls?._syncContextRadioLauncherState?.(...args);
}
_renderRadioState(...args) {
return this._radioControls?._renderRadioState?.(...args);
}
+1 -1
View File
@@ -281,7 +281,7 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const synced = [];
const chrome = { _syncContextRadioLauncherState() {} };
const chrome = {};
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
-2
View File
@@ -26,8 +26,6 @@ const KEYS = [
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a photo',
'Exit cockpit to fly to a shared view',
'Go to expanded Radio section',
'Expand Radio section in Context',
'New scene name',
'Delete scene "{scene}" and all shots?',
'Detailed outline unavailable',
+3 -2
View File
@@ -518,14 +518,15 @@ html:not([lang='en']) .bhote-event-story-current strong {
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
/* Phase / time / sources / imagery note run long in translation: allow up to
three lines so the last status is not cut off. */
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
margin-top: 3px;
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
.bhote-event-actions {
+44 -16
View File
@@ -1,6 +1,7 @@
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
intel HUD readouts and content marked translate="no", including its containers. */
html:lang(zh)
/* Chinese and Japanese text. Tight tracking keeps CJK labels readable;
preserve Latin tracking for intel HUD readouts and content marked
translate="no", including its containers. */
html:is(:lang(zh), :lang(ja))
body
:not(
#intel-hud,
@@ -12,40 +13,67 @@ html:lang(zh)
letter-spacing: 0.02em !important;
}
/* Keep small Chinese labels legible at a minimum of 9px. */
html:lang(zh) #command-dock .gev-mic-label,
html:lang(zh) #command-dock #gev-voice-detail,
html:lang(zh) .gev-voice-tier-btn,
html:lang(zh) .panel-layer-toggle {
/* Japanese line breaking: never start a line with ー, a small kana or closing
punctuation (プレビュ / ー). */
html:lang(ja) body {
line-break: strict;
}
/* Keep small CJK labels legible at a minimum of 9px. */
html:is(:lang(zh), :lang(ja)) #command-dock .gev-mic-label,
html:is(:lang(zh), :lang(ja)) #command-dock #gev-voice-detail,
html:is(:lang(zh), :lang(ja)) .gev-voice-tier-btn,
html:is(:lang(zh), :lang(ja)) .panel-layer-toggle {
font-size: 9px;
}
/* A two-glyph button label must not break between its glyphs. */
html:lang(zh) .pp-mode-btn {
html:is(:lang(zh), :lang(ja)) .pp-mode-btn {
white-space: nowrap;
}
/* Japanese segment labels are short words (すべて, 平面): keep each whole. */
html:lang(ja) .gev-segment {
white-space: nowrap;
}
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
html:lang(zh) #intel-hud *,
html:lang(zh) #cockpit-hud * {
html:is(:lang(zh), :lang(ja)) #intel-hud *,
html:is(:lang(zh), :lang(ja)) #cockpit-hud * {
text-transform: none;
}
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
/* Move short travel modes (步行 / 骑行, 徒歩 / 自転車) to the next line as
whole words. */
html:is(:lang(zh), :lang(ja)) [data-layer-id='directions'] .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Leave room for the complete sensor name and resolution on two lines. */
html:lang(zh) .recent-imagery-readout .ri-strip {
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-strip {
height: 131px;
}
html:lang(zh) .recent-imagery-readout .ri-card {
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card {
height: 123px;
grid-template-rows: 42px 13px 22px 11px 18px;
}
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card-sensor {
white-space: normal;
text-overflow: clip;
}
/* The Japanese imagery slot line wraps at its · separators, never inside a
word (プレ / ビュー); a run too long for the line still breaks. */
html:lang(ja) .recent-imagery-readout .ri-slot-value {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Japanese layer status lines wrap at their spaces and · separators, so an
age such as 1分以内 stays whole (更新 1 / 分以内); a run too long for the
line still breaks. */
html:lang(ja) .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
+2 -1
View File
@@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
}
.cockpit-vision-controls {
display: grid;
grid-template-columns: 48px 58px 48px;
/* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */
grid-template-columns: 48px minmax(58px, max-content) 48px;
align-items: stretch;
gap: 2px;
margin-top: 2px;
+1 -8
View File
@@ -463,17 +463,10 @@ body:not(.ui-clean-view):not(.recording-mode):has(
}
#intel-hud[data-variant='tactical'] .hud-bottom-left,
#intel-hud[data-variant='tactical'] .hud-bottom-right,
#intel-hud[data-variant='operator'] .hud-bottom-left,
#intel-hud[data-variant='operator'] .hud-bottom-right {
#intel-hud[data-variant='tactical'] .hud-bottom-right {
bottom: calc(2vh + 6.5rem);
}
#intel-hud[data-variant='operator'] .hud-bottom-bar {
bottom: calc(2vh + 9.8rem);
width: min(48vw, 680px);
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 5.4rem);
}
+14 -13
View File
@@ -747,12 +747,6 @@
.btn-icon::before {
content: '◐';
}
:root[data-ui-theme='cyber']
#command-dock
.style-btn[data-style='snow']
.btn-icon::before {
content: '❄︎';
}
:root[data-ui-theme='cyber'] :where(#bloom-toggle, #sharpen-toggle) {
font-size: 0;
@@ -1447,6 +1441,15 @@
text-shadow: 0 0 6px var(--cyber-red);
}
/* Cyber frames each mode button itself; the shared segmented track would
draw a third, blue frame around them. */
:root[data-ui-theme='cyber'] .global-context-modes {
gap: 7px;
padding: 0;
border: 0;
background: none;
}
:root[data-ui-theme='cyber'] .dock-tray-toggle,
:root[data-ui-theme='cyber'] .context-mode-button {
background-color: rgba(38, 52, 58, 0.95);
@@ -1558,10 +1561,6 @@
clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
}
#intel-hud[data-variant='cyber'] .hud-top-bar {
display: none;
}
/* Cyber keeps the useful style and locality readouts, not fictional IDs. */
#intel-hud[data-variant='cyber'] .hud-top-left .hud-classification,
#intel-hud[data-variant='cyber'] .hud-top-left .hud-system {
@@ -1798,8 +1797,11 @@
isolation: isolate;
min-height: 132px;
flex: 1 1 auto;
border-style: solid;
border-color: rgba(206, 222, 226, 0.14);
place-content: center;
padding: 12px;
border: 1px solid rgba(206, 222, 226, 0.14);
border-radius: 7px;
text-align: center;
overflow: hidden;
background:
linear-gradient(
@@ -1861,7 +1863,6 @@
background-size: 78px 78px;
}
#intel-hud[data-variant='cyber'] .hud-top-bar,
#intel-hud[data-variant='cyber'] .hud-bottom-bar {
display: none;
}
+43 -197
View File
@@ -985,17 +985,16 @@
animation: none;
}
}
/* Context is read for longer than other panels; it carries no halo. */
#global-context-panel .panel-glow {
position: absolute;
inset: -20px;
background: radial-gradient(
ellipse at center,
rgba(98, 181, 255, 0.2) 0%,
transparent 62%
);
filter: blur(20px);
opacity: 0.24;
pointer-events: none;
display: none;
}
/* Expanded, its section titles (Context, Radio) stay legible over bright
imagery. Cyber keeps its own red titles; collapsed launchers match siblings. */
:root:not([data-ui-theme='cyber'])
#global-context-panel:not(.collapsed)
.panel-title {
color: var(--text-secondary);
}
.global-context-panel-inner {
position: relative;
@@ -1012,158 +1011,6 @@
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
}
.context-radio-dock {
position: relative;
display: flex;
align-items: center;
}
.context-radio-toggle {
display: grid;
width: 28px;
height: 28px;
place-items: center;
padding: 0;
border: 1px solid rgba(98, 181, 255, 0.22);
border-radius: 6px;
color: rgba(143, 207, 255, 0.58);
background: rgba(42, 113, 171, 0.08);
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.context-radio-toggle .material-symbols-outlined {
font-size: 17px;
}
.context-radio-toggle:hover,
.context-radio-toggle:focus-visible {
border-color: #79c2ff;
color: #fff;
outline: none;
}
.context-radio-toggle.active {
border-color: rgba(101, 247, 255, 0.82);
color: #eaffff;
background: rgba(0, 212, 255, 0.16);
box-shadow:
inset 0 0 12px rgba(0, 212, 255, 0.1),
0 0 12px rgba(0, 212, 255, 0.2);
}
.context-radio-toggle:disabled {
opacity: 0.5;
cursor: wait;
}
.context-radio-mini {
position: absolute;
z-index: 5;
top: calc(100% + 20px);
right: -38px;
display: none;
width: 244px;
box-sizing: border-box;
gap: 8px;
padding: 10px;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 8px;
background: rgba(2, 15, 20, 0.96);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.62),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(22px) saturate(1.35);
-webkit-backdrop-filter: blur(22px) saturate(1.35);
}
.context-radio-mini[hidden] {
display: none !important;
}
.context-radio-dock.disclosure-open .context-radio-mini {
display: grid;
}
.context-radio-mini-heading {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
color: #65f7ff;
font: 7px/1.2 var(--font-mono);
letter-spacing: 0.12em;
}
.context-radio-mini-heading button {
display: grid;
width: 26px;
height: 26px;
place-items: center;
padding: 0;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 5px;
color: #8feaf2;
background: rgba(14, 69, 78, 0.16);
cursor: pointer;
}
.context-radio-mini-heading button:hover,
.context-radio-mini-heading button:focus-visible {
border-color: #65f7ff;
color: #fff;
outline: none;
}
.context-radio-mini-actions {
display: flex;
gap: 5px;
}
.context-radio-mini-heading .material-symbols-outlined {
font-size: 16px;
}
.context-radio-mini > .panel-layer-toggle {
width: 100%;
}
.context-radio-mini-heading strong {
overflow: hidden;
color: #e8feff;
font-size: 8px;
font-weight: 500;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-radio-mini-transport {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
}
.context-radio-mini-transport button {
min-height: 28px;
padding: 5px;
border: 1px solid rgba(65, 220, 231, 0.28);
border-radius: 5px;
color: #8feaf2;
background: rgba(14, 69, 78, 0.16);
font: 8px/1 var(--font-mono);
cursor: pointer;
}
.context-radio-mini-transport button:hover:not(:disabled),
.context-radio-mini-transport button:focus-visible:not(:disabled),
.context-radio-mini-transport button.active {
border-color: #65f7ff;
color: #fff;
outline: none;
}
.context-radio-mini-transport button:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.context-radio-mini-volume {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 30px;
gap: 7px;
align-items: center;
color: var(--text-dim);
font: 7px/1 var(--font-mono);
letter-spacing: 0.08em;
}
.context-radio-mini-volume input {
width: 100%;
}
.global-context-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -1229,40 +1076,44 @@
rather than relying on the chrome alone. */
filter: drop-shadow(0 0 5px rgba(150, 210, 255, 0.9));
}
/* One segmented control: the two modes read as a single choice. */
.global-context-modes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
margin: 0 0 10px;
gap: 2px;
margin: 0 0 12px;
padding: 2px;
border: 1px solid rgba(98, 181, 255, 0.22);
border-radius: 7px;
background: rgba(0, 0, 0, 0.22);
}
.context-mode-button {
display: flex;
min-width: 0;
min-height: 36px;
min-height: 34px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 8px;
border: 1px solid rgba(98, 181, 255, 0.28);
border-radius: 6px;
color: #8fcfff;
background: rgba(42, 113, 171, 0.09);
font: 8px/1.15 var(--font-mono);
letter-spacing: 0.09em;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 5px;
color: rgba(214, 236, 255, 0.72);
background: transparent;
font: 600 10px/1.2 var(--font-mono);
letter-spacing: 0.06em;
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
background 0.15s ease;
}
.context-mode-button .material-symbols-outlined {
font-size: 16px;
}
.context-mode-button:hover,
.context-mode-button:focus-visible {
border-color: #79c2ff;
color: #fff;
background: rgba(98, 181, 255, 0.08);
outline: none;
}
/* Keep focus distinct when the selected tab owns the accent colors. */
@@ -1272,12 +1123,9 @@
}
.context-mode-button.active {
border-color: rgba(0, 212, 255, 0.75);
color: #eaffff;
background: rgba(0, 212, 255, 0.14);
box-shadow:
inset 0 0 16px rgba(0, 212, 255, 0.08),
0 0 12px rgba(0, 212, 255, 0.12);
border-color: rgba(0, 212, 255, 0.55);
color: #fff;
background: rgba(0, 212, 255, 0.16);
}
.context-mode-button:disabled {
opacity: 0.55;
@@ -1287,23 +1135,20 @@
.context-mode-standby[hidden] {
display: none !important;
}
/* Plain guidance under the tabs, not a placeholder box. */
.context-mode-standby {
display: grid;
min-height: 72px;
place-content: center;
gap: 6px;
padding: 12px;
border: 1px dashed rgba(98, 181, 255, 0.2);
border-radius: 7px;
color: rgba(190, 230, 230, 0.5);
text-align: center;
font: 8px/1.45 var(--font-mono);
letter-spacing: 0.1em;
padding: 2px 2px 4px;
color: rgba(214, 236, 255, 0.72);
font: 10px/1.6 var(--font-mono);
letter-spacing: 0.02em;
}
.context-mode-standby strong {
color: #8fcfff;
font-size: 9px;
letter-spacing: 0.16em;
color: var(--text-primary);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
}
.context-mode-view {
min-height: 0;
@@ -1344,19 +1189,20 @@
}
.global-context-actions .panel-layer-toggle {
width: 100%;
min-height: 30px;
min-height: 32px;
padding: 6px 8px;
font-size: 8px;
font-size: 10px;
letter-spacing: 0.06em;
}
#global-context-panel #cockpit-entry {
position: static;
z-index: auto;
width: 100%;
min-height: 30px;
min-height: 32px;
padding: 5px 8px;
border-radius: 5px;
justify-content: center;
font-size: 8px;
font-size: 10px;
box-shadow: none;
}
#global-context-panel #cockpit-entry .material-symbols-outlined {
-1
View File
@@ -107,7 +107,6 @@ body.mobile-shell #top-center-actions {
/* ── HUD: one compact readout under the top bar ────────────────────────── */
body.mobile-shell #intel-hud .hud-edge,
body.mobile-shell #intel-hud .hud-corner,
body.mobile-shell #intel-hud .hud-top-bar,
body.mobile-shell #intel-hud .hud-bottom-bar {
display: none;
}
+12 -80
View File
@@ -124,7 +124,6 @@
line-height: 1.6;
}
.hud-top-bar,
.hud-bottom-bar {
display: none;
position: absolute;
@@ -141,24 +140,11 @@
pointer-events: none;
}
.hud-top-bar {
top: 70px;
}
.hud-bottom-bar {
bottom: 110px;
justify-content: center;
}
.hud-top-bar-center {
opacity: 0.84;
}
.hud-top-bar-left,
.hud-top-bar-right {
opacity: 0.6;
}
/* Corner blocks */
.hud-corner {
position: absolute;
@@ -298,54 +284,15 @@
gap: 20px;
}
#intel-hud[data-variant='tactical'] .hud-top-bar,
#intel-hud[data-variant='tactical'] .hud-bottom-bar {
display: none;
}
#intel-hud[data-variant='operator'] .hud-top-bar,
#intel-hud[data-variant='operator'] .hud-bottom-bar {
display: flex;
}
#intel-hud[data-variant='operator'] .hud-top-bar {
top: max(10vh, 90px);
left: 50%;
right: auto;
width: min(52vw, 720px);
transform: translateX(-50%);
border-color: rgba(255, 255, 255, 0.24);
background: linear-gradient(90deg, rgba(0, 0, 0, 0.56), rgba(8, 26, 34, 0.5));
box-shadow: 0 0 24px rgba(0, 255, 255, 0.08);
}
#intel-hud[data-variant='tactical'] .hud-top-left,
#intel-hud[data-variant='tactical'] .hud-top-right,
#intel-hud[data-variant='operator'] .hud-top-left,
#intel-hud[data-variant='operator'] .hud-top-right {
#intel-hud[data-variant='tactical'] .hud-top-right {
top: max(14vh, 136px);
}
#intel-hud[data-variant='operator'] .hud-bottom-bar {
border-color: rgba(255, 255, 255, 0.2);
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.52),
rgba(8, 22, 18, 0.52)
);
}
#intel-hud[data-variant='operator'] .hud-corner {
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: rgba(0, 0, 0, 0.36);
}
#intel-hud[data-variant='operator'] .hud-bracket {
display: none;
}
#intel-hud[data-variant='minimal'] .hud-top-right,
#intel-hud[data-variant='minimal'] .hud-bottom-right,
#intel-hud[data-variant='minimal'] .hud-left-edge,
@@ -378,11 +325,7 @@
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
#intel-hud:is([data-variant='minimal'], [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);
@@ -399,11 +342,7 @@
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
.hud-readout {
display: block;
width: max-content;
@@ -413,11 +352,7 @@
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
#intel-hud:is([data-variant='minimal'], [data-variant='tactical'])
:is(.hud-top-right, .hud-bottom-right)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
@@ -427,38 +362,35 @@
}
/* Preserve glyph opacity without dimming the vertical backplates. */
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge {
:root[data-scope-mask='off'] #intel-hud[data-variant='tactical'] .hud-edge {
opacity: 1;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
#intel-hud[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) {
#intel-hud[data-variant='minimal']
.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) {
#intel-hud[data-variant='minimal']
.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)
#intel-hud[data-variant='minimal']
.hud-bottom-bar
.hud-readout {
overflow: hidden;
white-space: nowrap;
+13 -5
View File
@@ -461,14 +461,16 @@ html:not([lang='en']) .radio-transport button {
border-top: 1px solid rgba(98, 181, 255, 0.16);
}
.military-awareness-standby strong {
color: #79c2ff;
color: var(--text-primary);
font-size: 10px;
letter-spacing: 0.1em;
font-weight: 600;
letter-spacing: 0.08em;
}
.military-awareness-standby span {
color: #8192a6;
font-size: 9px;
letter-spacing: 0.05em;
color: rgba(214, 236, 255, 0.72);
font-size: 10px;
line-height: 1.5;
letter-spacing: 0.02em;
}
.military-awareness-standby button {
justify-self: start;
@@ -688,6 +690,12 @@ html:not([lang='en']) .data-count {
text-wrap: balance;
}
/* The status column keeps its width, so a wide chip (WIRD AKTIVIERT) never
overlaps the name; the name wraps in the room left beside it. */
html:not([lang='en']) .data-toggle-right {
flex: none;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
html:not([lang='en']) .data-keep {
+1 -18
View File
@@ -98,25 +98,12 @@
display: none;
}
.hud-top-bar,
.hud-bottom-bar {
width: calc(100vw - 24px);
left: 12px;
transform: none;
}
.hud-top-bar {
top: 54px;
font-size: 9px;
}
#intel-hud[data-variant='operator'] .hud-top-bar {
left: 12px;
right: auto;
width: calc(100vw - 24px);
transform: none;
}
.hud-bottom-bar {
bottom: 90px;
font-size: 9px;
@@ -205,7 +192,7 @@
under the expanded panel plus a vertical scrollbar that scrolls nothing
but glow. Pin the glow to the panel box here — its blur is ink overflow,
which never scrolls — instead of clipping the rail, which would also clip
legitimate content such as the Context radio popover. */
legitimate content that extends past a panel's edge. */
#left-panel-stack .panel-glow,
#right-context-rail .panel-glow {
inset: 0;
@@ -228,10 +215,6 @@
#right-context-rail #global-context-panel.collapsed {
width: 100%;
}
.context-radio-mini {
right: -38px;
width: min(244px, calc(100vw - 32px));
}
#left-panel-stack > #data-panel,
#left-panel-stack > #cctv-panel,
-5
View File
@@ -43,11 +43,6 @@
<span class="btn-label" data-i18n>Noir</span>
<span class="btn-key">6</span>
</button>
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title>
<span class="btn-icon">❄</span>
<span class="btn-label" data-i18n>Snow</span>
<span class="btn-key">7</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
+1 -31
View File
@@ -30,36 +30,6 @@
<div class="panel-header">
<span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span>
<div id="context-radio-dock" class="context-radio-dock">
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
</button>
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label>
<div class="context-radio-mini-heading">
<span data-i18n>RADIO</span>
<strong id="context-radio-mini-station" data-i18n>RADIO READY</strong>
<span class="context-radio-mini-actions">
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
</button>
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</span>
</div>
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<div class="context-radio-mini-transport">
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label data-i18n-title>◀◀</button>
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label data-i18n-title>▶</button>
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label data-i18n-title>▶▶</button>
</div>
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
<span data-i18n>VOLUME</span>
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label />
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
</label>
</div>
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title>▶</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
@@ -122,7 +92,7 @@
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
<option value="all" data-i18n data-i18n-context="radio tag">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
-1
View File
@@ -15,7 +15,6 @@
<span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n>Minimal</option>
<option value="cyber" data-i18n>Cyber</option>
</select>
+13 -2
View File
@@ -1,4 +1,4 @@
import { formatNumber, msg, t } from '../i18n/index.js';
import { formatNumber, getLocale, msg, t } from '../i18n/index.js';
// Unit ids stay English in data, params and AI-tool readings; only the label
// shown on the page is translated. dBZ and °C read the same in every language.
@@ -23,6 +23,17 @@ export function unitLabel(unit) {
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
}
/**
* What goes between a number and its unit label: a no-break space, except
* before a Japanese unit word, which follows the number directly (5分, 1時間).
*/
export function quantitySeparator(label) {
return getLocale() === 'ja' &&
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(label)
? ''
: '\u00a0';
}
/**
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
* no digit grouping ("1008 hPa"), as on weather charts.
@@ -30,7 +41,7 @@ export function unitLabel(unit) {
export function formatQuantity(value, unit, options) {
const label = unitLabel(unit);
const number = formatNumber(value, { useGrouping: false, ...options });
return label ? `${number}\u00a0${label}` : number;
return label ? `${number}${quantitySeparator(label)}${label}` : number;
}
/**
+11
View File
@@ -44,3 +44,14 @@ test('other languages get their decimal separator and no digit grouping', () =>
setLocale('en');
}
});
test('Japanese unit words follow the number directly; symbols keep the space', () => {
try {
setLocale('ja');
assert.equal(formatQuantity(5, 'min'), '5分');
assert.equal(formatQuantity(1, 'h'), '1時間');
assert.equal(formatQuantity(95, 'kt'), '95\u00a0kt');
} finally {
setLocale('en');
}
});
+4 -4
View File
@@ -77,7 +77,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
assert.equal(f.frames.size, 0);
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 7);
assert.equal(f.stages.size, 6);
assert.ok(
Object.values(f.effects.stages).every(
(stage) => !stage.enabled && stage.uniforms.intensity === 0,
@@ -85,7 +85,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
);
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 7);
assert.equal(f.stages.size, 6);
f.effects.destroy();
});
@@ -252,7 +252,7 @@ test('stop revokes pending work immediately but retains stages until final destr
assert.equal(f.frames.size, 0);
assert.equal(
f.stages.size,
7,
6,
'Context and Cockpit may still be releasing these stages',
);
const intensity = f.effects.stages.retro.uniforms.intensity;
@@ -281,7 +281,7 @@ test('destroying one instance does not remove another pipeline or clock', () =>
a.effects.destroy();
b.tick(250);
assert.equal(a.stages.size, 0);
assert.equal(b.stages.size, 6);
assert.equal(b.stages.size, 5);
assert.equal(b.effects.stages.retro.uniforms.intensity, 0.5);
b.effects.destroy();
});
+2 -2
View File
@@ -73,13 +73,13 @@ function shortcuts() {
return { documentRef, searchInput, calls, controller, press };
}
test('number keys retain the seven style mappings', () => {
test('number keys retain the six style mappings', () => {
const f = shortcuts();
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space'])
f.press(key);
assert.deepEqual(
f.calls,
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map(
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir'].map(
(style) => ['setStyle', style],
),
);
-3
View File
@@ -1,7 +1,6 @@
import { retroShader } from '../styles/retro.js';
import { animeShader } from '../styles/anime.js';
import { noirShader } from '../styles/noir.js';
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';
@@ -16,7 +15,6 @@ export const STYLES = {
thermal: thermalShader,
anime: animeShader,
noir: noirShader,
snow: snowShader,
};
/**
@@ -156,5 +154,4 @@ export const STYLE_STATUS_LABELS = {
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
};
+4 -2
View File
@@ -1,6 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './displayControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
@@ -1272,8 +1273,9 @@ export class VisualSettings {
const superseded = () => typeof isCurrent === 'function' && !isCurrent();
if (superseded()) return false;
if (state.style && state.style !== this.activeStyle) {
this.setStyle(state.style, { applyPreset: false });
const style = RETIRED_STYLES.includes(state.style) ? 'normal' : state.style;
if (style && style !== this.activeStyle) {
this.setStyle(style, { applyPreset: false });
}
const bloomState = state.bloom || {};