feat: add Scope controls and conditional HUD text backplates (#1410)

* feat: add scope controls and conditional HUD backplates

* docs: assign scope changelog to PR 1410

* fix: stabilize title backplate and brighten secondary labels

---------

Co-authored-by: Bilawal Sidhu <[email protected]>
This commit is contained in:
Bilawal SidhuandBilawal Sidhu authored and GitHub committed 2026-10-26 00:24:27 -05:00
1 parent a7fcb3c232
commit a3a19883b4
24 files changed
+1134 -122

No files matched your search

@@ -0,0 +1,8 @@
- Add a Scope checkbox and extend feathering to 200%, retaining scope settings
across HUD layouts, share links and scenes. First run opens with Scope ON at
100% feather; explicit settings and historical links retain their values. Give
Tactical, Operator and Minimal text black backplates only when Scope is OFF,
including vertical metadata, without changing the original HUD text color, glow or
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410).
Keep title and tagline positions stable when toggling Scope, include the eye
in the title backing, and brighten the two secondary labels only with Scope OFF.
+25 -11
View File
@@ -196,6 +196,23 @@ Regression coverage lives in `src/tools/area.test.mjs`,
`src/tools/queries/environment.test.mjs` and
`src/layers/perimeters/cards.test.mjs`.
## Scope display controls
Display's native Scope checkbox defaults ON with feather at 100%, adjustable
from 0–200%. OFF disables the slider while retaining its value. HUD layout
changes preserve both settings; share links and scenes restore them. The
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline
and Active Style label with the shared secondary text color. The title plate
covers the eye and title without moving the title or tagline. Scope ON retains
the original HUD presentation; Cyber retains its existing skin.
`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence,
restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots.
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -5545,10 +5562,11 @@ the `_detectionUserOverridden` / explicit-intent flags that would suppress a
separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather,
detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D).
**The 2026-08-24 defaults** (superseding the interim 08-23 values of 8%/3%):
**Current defaults** (scope feather revised to 100% on 2026-10-25;
detection and outside opacity retain the 2026-08-24 defaults):
the first-run look is Detection DENSE
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`,
and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather
`100%`, and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so
brackets keep their approved brightness exactly at the new OUTSIDE default. Two
terms that must never be conflated: scope FEATHER softens the black scope-mask
edge; detection FADE is the label/card fading band around the keyhole. The
@@ -5618,14 +5636,10 @@ easier to meet (detection is now on more often), but does not create it.
calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the
default, and the mapping follows bracket brightness rather than slider
position. The `ko` PARSE fallback stays at `5`.
- **Scope feather: `11%`** — a soft scope-mask edge (moved `0% → 8% → 11%`; `0%` hard crop for one day,
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
mirrored by `#scope-feather-slider`'s markup value AND readout and
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the
default cannot quietly delete it. The `scf` PARSE fallback deliberately stays
at `35`: a link predating `scf` was authored when 35 was what its author saw,
and restoring the author's view is what a share link is for.
- **Scope: `ON`, feather: `100%`**. `SCOPE_FEATHER_RATIO_DEFAULT` in
`src/scopeMask.js` matches the slider, readout and share generator. The range
is 0–200%; explicit 0% is a hard edge. Missing-`scf` links retain the historical
35% parse fallback.
- **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section
above and the DISPLAY-rail entry below (was `OFF`).
- Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale).
+1
View File
@@ -194,6 +194,7 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
+7 -3
View File
@@ -805,7 +805,8 @@
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
"src/ui/statusText.js",
"src/ui/statusBadge.js"
"src/ui/statusBadge.js",
"src/scopeMaskPolicy.js"
],
"external": ["cesium"]
},
@@ -2574,7 +2575,8 @@
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js",
"src/sceneCommandRelease.js"
"src/sceneCommandRelease.js",
"src/scopeMaskPolicy.js"
],
"external": [
"hls.js",
@@ -3295,7 +3297,9 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
"src/i18n/locales/zh-CN.json",
"src/scopeMaskPolicy.js",
"src/ui/displayControls.js"
],
"external": ["cesium"]
},
+681
View File
@@ -0,0 +1,681 @@
#!/usr/bin/env node
/**
* Scope and HUD browser acceptance. Requires the development server.
* Run: node scripts/qa-scope-hud.mjs
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
* feather 0/11/100/200, layout independence,
* share/scene restore, and visible text backing including rotated metadata.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures += 1;
if (detail) console.log(JSON.stringify(detail));
}
};
const readScope = () =>
page.evaluate(() => {
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
const canvas = document.getElementById('scope-mask');
const context = canvas.getContext('2d');
const radialX = Math.round(
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
);
return {
enabled: scope.enabled,
feather: scope.featherPct / 100,
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
radialAlpha: context.getImageData(
radialX,
Math.round(canvas.height / 2),
1,
1,
).data[3],
checked: document.getElementById('scope-toggle').checked,
disabled: document.getElementById('scope-feather-slider').disabled,
value: Number(document.getElementById('scope-feather-slider').value),
readout: document.getElementById('scope-feather-value').textContent,
};
});
const readHudGlyphs = () =>
page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const probe = document.createElement('span');
probe.style.display = 'none';
hud.appendChild(probe);
const baseline = {
color: getComputedStyle(probe).color,
shadow: getComputedStyle(probe).textShadow,
};
probe.remove();
const glyphs = [
'.hud-mode',
'.hud-system',
'.hud-summary-label',
'.hud-summary',
'.hud-orbital',
'#hud-mgrs',
'#hud-coll',
].map((selector) => {
const el = hud.querySelector(selector);
const style = getComputedStyle(el);
let opacity = 1;
for (
let current = el;
current && current !== hud;
current = current.parentElement
)
opacity *= Number(getComputedStyle(current).opacity);
return {
selector,
color: style.color,
shadow: style.textShadow,
opacity,
};
});
return { baseline, glyphs };
});
async function settleTextBackings(enabled) {
// The pre-existing active-style value transitions all properties for 300ms.
// Wait for its backing to finish changing before inspecting or capturing it.
await page
.waitForFunction(
(scopeEnabled) => {
const expected = scopeEnabled
? 'rgba(0, 0, 0, 0)'
: 'rgba(0, 0, 0, 0.7)';
return [
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
].every(
(el) =>
getComputedStyle(el).backgroundColor === expected &&
getComputedStyle(el).boxShadow === 'none',
);
},
{ timeout: 2_000 },
enabled,
)
.catch(() => null);
}
const readSceneChrome = () =>
page.evaluate(() => {
const title = document.querySelector('#title-bar h1');
const eye = title.querySelector('.title-logo');
const subtitle = document.querySelector('#title-bar .subtitle');
const label = document.querySelector('#style-indicator .indicator-label');
const rect = (el, text = false) => {
if (!el.getClientRects().length) return null;
const range = document.createRange();
range.selectNodeContents(el);
const box = text
? range.getBoundingClientRect()
: el.getBoundingClientRect();
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
};
};
const titleBox = rect(title);
const eyeBox = rect(eye);
const backing = getComputedStyle(title, '::before');
const plate = {
left: titleBox.left + parseFloat(backing.left),
top: titleBox.top + parseFloat(backing.top),
right: titleBox.right - parseFloat(backing.right),
bottom: titleBox.bottom - parseFloat(backing.bottom),
};
const contains = (box) =>
Object.entries(box).every(([side, value]) =>
['left', 'top'].includes(side)
? plate[side] <= value + 0.5
: plate[side] >= value - 0.5,
);
const probe = document.createElement('span');
document.body.appendChild(probe);
probe.style.color = 'var(--text-dim)';
const dim = getComputedStyle(probe).color;
probe.style.color = 'var(--text-secondary)';
const secondary = getComputedStyle(probe).color;
probe.remove();
return {
positions: {
title: titleBox,
eye: eyeBox,
text: rect(title.querySelector('[translate="no"]'), true),
subtitle: rect(subtitle, true),
},
titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)',
titleCovered:
backing.position === 'absolute' &&
backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' &&
contains(titleBox) &&
contains(eyeBox),
leftAligned:
!rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5,
colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color],
dim,
secondary,
};
});
function checkSceneChrome(name, before, after) {
const stable = Object.keys(before.positions).every((key) => {
const a = before.positions[key];
const b = after.positions[key];
return a && b
? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5)
: a === b;
});
check(`${name}: title, eye and subtitle stay in place across Scope`, stable, {
before,
after,
});
check(
`${name}: OFF title plate covers the eye and aligns with the subtitle`,
before.titleUnbacked && after.titleCovered && after.leftAligned,
after,
);
check(
`${name}: secondary labels brighten only with Scope OFF`,
before.colors.every((color) => color === before.dim) &&
after.colors.every((color) => color === after.secondary),
{ before: before.colors, after: after.colors },
);
}
async function openApplication(url) {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
// This browser has a fresh profile. Start without authored state so the
// first assertion exercises the defaults rather than a share-link restore.
url.hash = '';
await openApplication(url);
const fresh = await readScope();
check(
'bare-URL fresh boot opens Scope ON with 100% feather',
fresh.enabled &&
fresh.checked &&
!fresh.disabled &&
fresh.feather === 1 &&
fresh.value === 100 &&
fresh.readout === '100%' &&
fresh.cornerAlpha > 0,
fresh,
);
// Use a known city view for canvas and readability checks below.
url.hash =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
// Changing only the hash does not rerun startup restoration. Leave the app
// document first so this fixture is applied by a new application boot.
await page.goto('about:blank');
await openApplication(url);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager.hud.setMode('on');
manager.setStyle('normal');
});
const initial = await readScope();
check(
'Austin fixture restores Scope ON and 100% feather',
initial.enabled &&
initial.checked &&
!initial.disabled &&
initial.feather === 1 &&
initial.value === 100 &&
initial.readout === '100%' &&
initial.cornerAlpha > 0,
initial,
);
const name = await page.$eval('#scope-toggle', (el) => ({
type: el.type,
label: el.labels?.[0]?.textContent.trim(),
}));
check(
'Scope uses a native checkbox with a visible label',
name.type === 'checkbox' && name.label?.includes('Scope'),
name,
);
await page.click('#scope-toggle');
const off = await readScope();
check(
'click disables Scope and its feather slider',
!off.enabled &&
!off.checked &&
off.disabled &&
off.feather === 1 &&
off.cornerAlpha === 0,
off,
);
await page.focus('#scope-toggle');
await page.keyboard.press('Space');
const on = await readScope();
check(
'Space re-enables the native checkbox without losing feather',
on.enabled &&
on.checked &&
!on.disabled &&
on.feather === 1 &&
on.cornerAlpha > 0,
on,
);
const paintedAlpha = [];
for (const pct of [0, 11, 100, 200]) {
await page.$eval(
'#scope-feather-slider',
(el, value) => {
el.value = String(value);
el.dispatchEvent(new Event('input', { bubbles: true }));
},
pct,
);
const state = await readScope();
paintedAlpha.push(state.radialAlpha);
check(
`feather ${pct}% reaches the mask and readout`,
state.value === pct &&
state.feather === pct / 100 &&
state.readout === `${pct}%`,
state,
);
}
check(
'painted gradient widens beyond the old 100% ceiling',
paintedAlpha[0] === 0 &&
paintedAlpha[1] === 0 &&
paintedAlpha[2] > 0 &&
paintedAlpha[3] > paintedAlpha[2],
paintedAlpha,
);
await page.focus('#scope-feather-slider');
await page.keyboard.press('Home');
check('native Home reaches the hard edge', (await readScope()).feather === 0);
await page.keyboard.press('End');
check(
'native End reaches the expanded 200% maximum',
(await readScope()).feather === 2,
);
await page.click('#scope-toggle');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout);
const state = await readScope();
check(
`Scope OFF and 200% survive ${layout}`,
!state.enabled &&
!state.checked &&
state.disabled &&
state.feather === 2 &&
state.value === 200 &&
state.cornerAlpha === 0,
state,
);
}
const restored = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const links = manager.shareLinkManager;
manager._syncShareState();
const params = links._buildHashParams();
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
await manager._visualSettings.restoreShareState({
scopeEnabled: encoded.enabled === '1',
scopeFeatherPct: Number(encoded.feather),
});
const shared = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: true, featherPct: 200 },
});
const scene = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: false, featherPct: 200 },
});
return { encoded, shared, scene };
});
check(
'share state serializes and restores Scope OFF with 200%',
restored.encoded.enabled === '0' &&
restored.encoded.feather === '200' &&
restored.shared.enabled === false &&
restored.shared.featherPct === 200,
restored,
);
check(
'scene state restores Scope ON with 200%',
restored.scene.enabled === true && restored.scene.featherPct === 200,
restored,
);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true, featherPct: 100 },
}),
);
await settleTextBackings(true);
const originalGlyphs = await readHudGlyphs();
const originalChrome = await readSceneChrome();
const original = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const wrappers = [...hud.querySelectorAll('.hud-readout')];
const scene = [...document.querySelectorAll('.scene-text-holdout')];
const unexpectedBackings = [...wrappers, ...scene]
.map((el) => {
const style = getComputedStyle(el);
return {
selector: el.id || el.className,
background: style.backgroundColor,
shadow: style.boxShadow,
};
})
.filter(
(style) =>
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
);
return {
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
unbacked: unexpectedBackings.length === 0,
unexpectedBackings,
wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents',
),
operatorFrame:
hud.dataset.variant !== 'operator' ||
(getComputedStyle(hud.querySelector('.hud-corner'))
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
getComputedStyle(hud.querySelector('.hud-top-bar'))
.backgroundImage !== 'none'),
};
});
check(
`${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent &&
original.unbacked &&
original.wrappersTransparent &&
original.operatorFrame,
original,
);
check(
`${layout}: original HUD color, glow and metadata alpha remain intact`,
originalGlyphs.glyphs.every(
(glyph) =>
glyph.color === originalGlyphs.baseline.color &&
glyph.shadow === originalGlyphs.baseline.shadow,
) &&
originalGlyphs.glyphs.find(
(glyph) => glyph.selector === '.hud-summary-label',
).opacity === 0.55 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
.opacity === 0.7 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
.opacity === 0.5,
originalGlyphs,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-on-${layout}.png`,
),
});
}
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
const backedGlyphs = await readHudGlyphs();
checkSceneChrome(layout, originalChrome, await readSceneChrome());
check(
`${layout}: Scope OFF adds backing without changing glyph styles`,
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
backedGlyphs,
);
const coverage = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
const missing = [];
let visible = 0;
let node;
while ((node = walker.nextNode())) {
if (
!node.textContent.trim() ||
node.parentElement.closest('.hud-bracket')
)
continue;
const range = document.createRange();
range.selectNodeContents(node);
if (
![...range.getClientRects()].some(
(rect) => rect.width > 0 && rect.height > 0,
)
)
continue;
visible += 1;
const backing = node.parentElement.closest('.hud-readout');
const color = backing && getComputedStyle(backing).backgroundColor;
if (
!backing ||
color !== 'rgba(0, 0, 0, 0.7)' ||
getComputedStyle(backing).boxShadow !== 'none' ||
backing.parentElement.closest('.hud-readout')
)
missing.push(node.textContent.trim());
}
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
);
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
const overlaps = [];
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
const siblings = wrapped.filter((el) => el.parentElement === parent);
for (let i = 1; i < siblings.length; i += 1) {
const previous = siblings[i - 1].getBoundingClientRect();
const current = siblings[i].getBoundingClientRect();
const horizontal =
Math.min(previous.right, current.right) -
Math.max(previous.left, current.left);
const vertical =
Math.min(previous.bottom, current.bottom) -
Math.max(previous.top, current.top);
// Horizontal bars share a vertical band. Only intersecting text boxes
// can stack their backing alpha; allow subpixel layout rounding.
if (horizontal > 0.5 && vertical > 0.5) {
overlaps.push({
parent: parent.className,
previous: siblings[i - 1].innerText,
current: siblings[i].innerText,
vertical,
});
}
}
}
const lineIds = [
'hud-mgrs',
'hud-latlon',
'hud-gsd',
'hud-alt',
'hud-timestamp',
'hud-bottom-line',
];
const multiline = lineIds.filter((id) => {
const el = document.getElementById(id);
if (!el.getClientRects().length) return false;
const range = document.createRange();
range.selectNodeContents(el);
return (
range.getBoundingClientRect().height >
parseFloat(getComputedStyle(el).lineHeight) * 1.5
);
});
const edges = [...hud.querySelectorAll('.hud-edge')]
.filter((el) => getComputedStyle(el).display !== 'none')
.map((el) => {
const box = el.querySelector('.hud-readout').getBoundingClientRect();
return (
getComputedStyle(el).transform !== 'none' &&
box.height > box.width &&
Number(getComputedStyle(el).opacity) === 1
);
});
const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
].filter(
(el) =>
el.offsetWidth > 0 &&
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
);
return {
scopeOff: document.documentElement.dataset.scopeMask === 'off',
visible,
missing,
empty,
overlaps,
multiline,
edges,
broadBackings: broadBackings.length,
};
});
check(
`${layout}: all visible HUD text has one backing`,
coverage.scopeOff &&
coverage.visible > 0 &&
coverage.missing.length === 0 &&
coverage.empty === 0 &&
coverage.broadBackings === 0,
coverage,
);
check(
`${layout}: adjacent readout backings do not overlap`,
coverage.overlaps.length === 0,
coverage,
);
check(
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
coverage,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-off-${layout}.png`,
),
});
}
}
const details = await page.evaluate(() => {
const summary = document.getElementById('hud-summary');
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
const ais = document.getElementById('hud-ais-vessel');
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
return {
summaryClipped:
summary.scrollWidth > summary.clientWidth &&
getComputedStyle(summary).textOverflow === 'ellipsis',
aisMultiline:
getComputedStyle(ais).whiteSpace === 'pre-line' &&
ais.textContent.split('\n').length === 2 &&
ais.getBoundingClientRect().height + 0.5 >=
parseFloat(getComputedStyle(ais).lineHeight) * 2,
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
};
});
check(
'summary clipping, multiline AIS and red REC dot retain their behavior',
details.summaryClipped &&
details.aisMultiline &&
details.recColor === 'rgb(255, 51, 51)',
details,
);
await page.setViewport({ width: 390, height: 844 });
await page.select('#hud-layout-select', 'minimal');
const narrow = await page.evaluate(() =>
[...document.querySelectorAll('#intel-hud .hud-readout')]
.filter(
(el) =>
el.getClientRects().length &&
getComputedStyle(el).display !== 'contents',
)
.every((el) => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth;
}),
);
check('narrow HUD text backings stay within the viewport', narrow);
for (const layout of ['minimal', 'operator', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true },
}),
);
await settleTextBackings(true);
const before = await readSceneChrome();
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
}
check('runtime has no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+21 -21
View File
@@ -282,28 +282,28 @@ export class IntelHUD {
<div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-top-bar">
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
<span class="hud-top-bar-center">${this._missionId}</span>
<span class="hud-top-bar-right">PAGE 1/1</span>
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</div>
<div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div>
<div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
</div>
<div class="hud-bracket">┐</div>
</div>
@@ -311,33 +311,33 @@ export class IntelHUD {
<div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div>
<div class="hud-content">
<div id="hud-mgrs">MGRS: ---</div>
<div id="hud-latlon">--°--'--"N ---°--'--"W</div>
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
</div>
</div>
<div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div>
<div id="hud-alt">ALT: --m SUN: --° EL</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
</div>
<div class="hud-bracket">┘</div>
</div>
<div class="hud-edge hud-left-edge">
<div id="hud-coll">COLL: --:--:--Z</div>
<div id="hud-ona">ONA: --°</div>
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
</div>
<div class="hud-edge hud-right-edge">
<div>BAND: PAN</div>
<div>BITS: 11</div>
<div>LVL: 1A</div>
<div class="hud-readout"><div>BAND: PAN</div></div>
<div class="hud-readout"><div>BITS: 11</div></div>
<div class="hud-readout"><div>LVL: 1A</div></div>
</div>
<div class="hud-bottom-bar">
<span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span>
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
</div>
`;
this._el.dataset.variant = this._variant;
+31 -18
View File
@@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js';
// actually decides fresh-boot layer state — including the early return that
// makes each layer's own initializer the operative default.
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on
// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11%
// on 2026-08-24, a soft
// edge. The hard crop is still one drag away, and that is pinned too.
// 2026-08-22; later revisions were 8%, then 11%, then 100% on
// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available.
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
// Owner: "detect should also be on by default."
// 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with
@@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js';
import {
SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather,
isScopeMaskEnabled,
scopeMaskGeometry,
setScopeMaskFeather,
} from './scopeMask.js';
@@ -75,32 +75,40 @@ function managerForHash(hash) {
}
// ---------------------------------------------------------------------------
// 2. Scope feather — a subtle soft edge on a first run
// 2. Scope ON with 100% feather on a first run
// ---------------------------------------------------------------------------
test('first run opens with a subtle scope feather, at every surface that decides it', () => {
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11,
'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings');
assert.equal(getScopeMaskFeather(), 0.11,
test('first run opens with Scope ON and 100% feather at every default surface', () => {
assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled');
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25');
assert.equal(getScopeMaskFeather(), 1,
'and the live module starts there, not merely documents it');
// The slider and its readout are the same default rendered as markup — a
// fresh boot applies no restore, so a stale value here would show one number
// over a mask drawn at another.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/,
'index.html: the feather slider ships at 11');
assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%</,
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/,
'index.html: the feather slider ships at 100');
assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
'index.html: and its readout agrees with the handle');
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/>/,
'the native Scope checkbox starts checked');
// The link this session generates must describe the mask this session draws,
// for the window before the first _syncShareState.
assert.match(shareSource, /this\._scopeFeatherPct = 11;/,
assert.match(shareSource, /this\._scopeFeatherPct = 100;/,
'sharelink.js: the generator starts from the same value the mask starts at');
const params = managerForHash('')._buildHashParams();
assert.equal(params.get('sc'), '1');
assert.equal(params.get('scf'), '100');
});
test('an explicit feather still wins over the subtle default', () => {
test('an explicit feather still wins over the 100% default', () => {
// A link is authored state. The new default governs a session that said
// nothing; it must never overwrite one that said something.
assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11,
'links carrying the previous default preserve their authored feather');
assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35);
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0,
@@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => {
'a pre-scf link restores the author\'s view, not the new default');
});
test('the subtle default did not weaken the feather control, and 0 is still reachable', () => {
test('share links preserve explicit Scope OFF and feather choices over the defaults', () => {
const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash();
assert.equal(shared.scopeEnabled, false);
assert.equal(shared.scopeFeatherPct, 11);
});
test('the 100% default retains the expanded feather range and hard edge', () => {
// The cheap way to move a default would be to nerf the control. Prove the
// slider still spans its full range and the geometry is still DERIVED from
// the ratio — a check that would pass vacuously if it only ever saw one value.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/,
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/,
'the slider still offers the whole range');
const previous = getScopeMaskFeather();
try {
for (const ratio of [0.35, 0.7, 1]) {
for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
setScopeMaskFeather(ratio);
const geo = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9,
`feather ${ratio} must still widen the band to that fraction of the keyhole`);
}
// The new default is a real, narrow band — not the hard crop, and nowhere
// near the retired 35 % halo.
// The first-run value draws a band derived from the same keyhole radius.
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
const soft = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
+25 -21
View File
@@ -1,4 +1,5 @@
import { getKeyholeGeometry } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
/**
* Scope mask — the app's signature circular viewport treatment, made real.
@@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js';
/** Matches the page background the emergent scope faded into. */
const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
/**
* Default edge feather as a fraction of the keyhole radius.
*
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
* set it to zero: a subtle soft edge rather than either the hard crop or the
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
* share link carries, or the operator dials in, overrides this immediately, and
* the hard-crop path at 0 is still reachable from the handle.
*
* Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in
* index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
* restore, so those literals ARE the first-run state. NOT the `scf` PARSE
* fallback, which stays at 35 on purpose: a link predating that field was
* authored when 35 was what its author saw, and a link from the feather-0 era
* carries `scf=0` explicitly because the generator always writes the field.
* Pinned in reasonableDefaults.test.mjs.
* First-run feather ratio; the Display slider and share defaults use 100%.
* The historical missing-scf parse fallback remains 35%.
*/
export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11;
export const SCOPE_FEATHER_RATIO_DEFAULT = 1;
/**
* Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
* faint stars survive in the corners at globe scale.
@@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() {
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
const keyhole = getKeyholeGeometry(width, height);
if (!(keyhole.radius > 0)) return null;
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0));
const feather = Math.max(
0,
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0),
);
// Feather straddles the keyhole edge so the visible radius stays anchored
// to the shared geometry every keyhole consumer (labels, cards) fades on.
const half = keyhole.radius * feather * 0.5;
@@ -423,6 +412,14 @@ function draw() {
_painted = true;
}
/** Expose only the OFF state; an absent attribute preserves the original HUD. */
function syncScopeMaskAppearance(enabled = _enabled) {
const dataset = globalThis.document?.documentElement?.dataset;
if (!dataset) return;
if (enabled) delete dataset.scopeMask;
else dataset.scopeMask = 'off';
}
/**
* Install the scope mask into the viewer container. Idempotent.
* @param {Cesium.Viewer} viewer
@@ -430,6 +427,7 @@ function draw() {
*/
export function installScopeMask(viewer) {
if (_canvas || !viewer?.container) return;
syncScopeMaskAppearance();
_container = viewer.container;
_viewer = viewer;
_canvas = document.createElement('canvas');
@@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) {
const next = Boolean(enabled);
const reEnabled = next && !_enabled;
_enabled = next;
syncScopeMaskAppearance();
if (reEnabled) {
// The camera moved freely while the scope was off and nothing sampled it,
// so the painted terminus can be a whole altitude band stale. Re-sync once
@@ -522,11 +521,14 @@ export function isScopeMaskEnabled() {
}
/**
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1].
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2].
* @returns {void}
*/
export function setScopeMaskFeather(ratio) {
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
_featherRatio = Math.max(
0,
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0),
);
draw();
}
@@ -540,6 +542,7 @@ export function getScopeMaskFeather() {
* @returns {void}
*/
export function destroyScopeMask() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect();
_resizeObserver = null;
teardownDevicePixelRatioWatch();
@@ -553,6 +556,7 @@ export function destroyScopeMask() {
/** Test seam. */
export function _resetScopeMaskForTest() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect();
_resizeObserver = null;
teardownDevicePixelRatioWatch();
+50 -5
View File
@@ -18,6 +18,7 @@ import {
SCOPE_TERMINUS_MIN_PCT,
SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather,
SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M,
@@ -26,6 +27,7 @@ import {
_resetScopeMaskForTest,
} from './scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js';
beforeEach(() => _resetScopeMaskForTest());
@@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
assert.equal(geo.outerR, keyholeR);
});
test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => {
test('feather ratio reaches the center at 200% and clamps larger input', () => {
const wide = scopeMaskGeometry(1000, 800, 7);
const one = scopeMaskGeometry(1000, 800, 1);
assert.equal(wide.innerR, one.innerR);
assert.equal(wide.outerR, one.outerR);
const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.equal(maximum.innerR, 0);
assert.equal(maximum.outerR, keyholeR * 2);
assert.equal(wide.innerR, maximum.innerR);
assert.equal(wide.outerR, maximum.outerR);
for (const ratio of [1, 1.5, 2]) {
setScopeMaskFeather(ratio);
assert.equal(getScopeMaskFeather(), ratio);
const geometry = scopeMaskGeometry(1000, 800);
assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR);
assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio);
}
setScopeMaskFeather(7);
assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX);
const nan = scopeMaskGeometry(1000, 800, Number.NaN);
const zero = scopeMaskGeometry(1000, 800, 0);
assert.equal(nan.innerR, zero.innerR);
});
test('UI and share feather percentages share one bounded integer range', () => {
for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) {
assert.equal(clampScopeFeatherPct(value), expected);
}
});
test('degenerate viewport yields null instead of a broken gradient', () => {
assert.equal(scopeMaskGeometry(0, 0), null);
assert.equal(scopeMaskGeometry(-5, 100), null);
@@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
removeEventListener: (type, fn) => listeners.delete(fn),
}),
};
globalThis.document = { createElement: () => canvas };
const documentElement = { dataset: {} };
globalThis.document = { documentElement, createElement: () => canvas };
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
const container = { clientWidth: width, clientHeight: height, appendChild() {} };
return {
canvas,
container,
documentElement,
/** Canvas work counters: backing-store resizes, clears, fills. */
ops,
/** Every fillStyle string assigned since install (hard-crop path). */
@@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
};
}
test('scope appearance follows direct state changes and install/destroy lifecycles', () => {
const dom = stubScopeMaskDom();
try {
setScopeMaskEnabled(false);
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
delete dom.documentElement.dataset.scopeMask;
installScopeMask({ container: dom.container });
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state');
destroyScopeMask();
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker');
installScopeMask({ container: dom.container });
assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference');
setScopeMaskEnabled(true);
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation');
setScopeMaskEnabled(false);
_resetScopeMaskForTest();
assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker');
} finally {
destroyScopeMask();
dom.restore();
}
});
test('a DPR change with no resize still repaints the backing store', () => {
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
try {
+11
View File
@@ -0,0 +1,11 @@
/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */
export const SCOPE_FEATHER_RATIO_MAX = 2;
const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100;
/** Normalize a UI/share-link feather percentage to the supported integer range. */
export function clampScopeFeatherPct(value) {
return Math.max(
0,
Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)),
);
}
+18 -2
View File
@@ -99,6 +99,22 @@ test('Nepal locator token is valid in v2 share links', () => {
assert.equal(parsed.layerStateInvalid, false);
});
test('scope feather above 100% survives share parsing and serialization', () => {
const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200');
const parsed = manager.parseInitialHash();
assert.equal(parsed.scopeEnabled, false);
assert.equal(parsed.scopeFeatherPct, 200);
assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175);
assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200);
manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 });
const params = manager._buildHashParams();
assert.equal(params.get('sc'), '0');
assert.equal(params.get('scf'), '200');
manager.onToggleChange(false, true, { scopeFeatherPct: 999 });
assert.equal(manager._buildHashParams().get('scf'), '200');
clearTimeout(manager._debounceTimer);
});
test('share-link serialization emits the current celestial state', () => {
const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false });
@@ -498,8 +514,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate
['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'],
['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'],
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
+4 -12
View File
@@ -5,6 +5,7 @@ import {
normalizeAllocationStrategy,
} from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import {
decodeLayerStateParams,
encodeLayerStateParams,
@@ -112,10 +113,7 @@ export class ShareLinkManager {
this._detectionOutsideOpacityPct = 1;
this._celestialRingEnabled = false;
this._scopeEnabled = true;
// Feather opens on a soft 11% 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 scopeMask.js and the slider's markup value.
this._scopeFeatherPct = 11;
this._scopeFeatherPct = 100;
// null = the altitude-adaptive terminus (the default). A number pins the
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
this._scopeTerminusPct = null;
@@ -227,10 +225,7 @@ export class ShareLinkManager {
// (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match
// the mask, not the archive.)
scopeFeatherPct: Math.max(
0,
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
),
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -494,10 +489,7 @@ export class ShareLinkManager {
if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = Math.max(
0,
Math.min(100, Math.round(extras.scopeFeatherPct)),
);
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
}
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') {
-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),
+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 —';
+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;
+131
View File
@@ -371,3 +371,134 @@
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px;
}
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
.hud-readout {
display: contents;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
background: rgba(0, 0, 0, 0.7);
box-shadow: none;
border-radius: 2px;
padding-block: 0;
padding-inline: 6px;
margin-block: 0;
margin-inline: -6px;
width: fit-content;
max-width: 100%;
box-sizing: content-box;
white-space: nowrap;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
.hud-readout {
display: block;
width: max-content;
min-width: 0;
box-sizing: content-box;
white-space: nowrap;
}
:root[data-scope-mask='off']
#intel-hud:is(
[data-variant='minimal'],
[data-variant='operator'],
[data-variant='tactical']
)
:is(.hud-top-right, .hud-bottom-right)
.hud-readout,
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#style-indicator
.scene-text-holdout {
margin-inline-start: auto;
}
/* Preserve glyph opacity without dimming the vertical backplates. */
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge {
opacity: 1;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
.hud-edge
.hud-readout
> * {
opacity: 0.5;
}
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar) {
background: transparent;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar) {
width: fit-content;
max-width: calc(100vw - 120px);
gap: 24px;
}
:root[data-scope-mask='off']
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
:is(.hud-top-bar, .hud-bottom-bar)
.hud-readout {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
h1::before {
content: '';
position: absolute;
inset: -0.12em -6px;
z-index: -1;
border-radius: 2px;
background: rgba(0, 0, 0, 0.7);
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
color: var(--text-secondary);
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
margin-top: 8px;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#style-indicator
.indicator-value {
margin-top: 4px;
}
:root[data-scope-mask='off']
#intel-hud[data-variant='minimal']
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
display: none;
}
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
.frame-rate-readout {
margin-top: 6px;
}
+5 -9
View File
@@ -106,19 +106,15 @@
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n>Scope</span>
</button>
<input type="checkbox" id="scope-toggle" checked />
</label>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">100%</span>
</div>
</div>
<div class="pp-toggle-group">
+3 -3
View File
@@ -11,13 +11,13 @@
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
</div>
<!-- Top-center camera and share actions -->
+10 -6
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';
@@ -136,11 +138,10 @@ export class VisualSettings {
}
if (typeof scopeEnabled === 'boolean') {
setScopeMaskEnabled(scopeEnabled);
this._scopeBtn?.classList.toggle('active', scopeEnabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
}
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
const pct = clampScopeFeatherPct(scopeFeatherPct);
this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
@@ -1302,11 +1303,14 @@ export class VisualSettings {
const scopeState = state.scope || {};
if (typeof scopeState.enabled === 'boolean') {
setScopeMaskEnabled(scopeState.enabled);
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
scopeState.enabled,
);
}
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
const pct = clampScopeFeatherPct(scopeState.featherPct);
this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;