Compare commits

...
26 changed files with 1066 additions and 109 deletions

No files matched your search

@@ -0,0 +1,6 @@
- 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 text color, glow or
opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1397).
+35 -7
View File
@@ -186,6 +186,32 @@ Regression coverage lives in `src/tools/area.test.mjs`,
`src/tools/queries/environment.test.mjs` and `src/tools/queries/environment.test.mjs` and
`src/layers/perimeters/cards.test.mjs`. `src/layers/perimeters/cards.test.mjs`.
## Scope controls and HUD text readability
Display uses a native, visibly checked Scope checkbox. Turning it off disables
its feather slider while retaining the value. Scope enabled state and feather
are independent of Tactical, Operator, Minimal and Cyber layout changes. Share
links (`sc`/`scf`) and scene visual state restore both choices, including feather
above 100%. The supported feather range is 0–200% of the keyhole radius: 0% is a
hard edge and 200% extends the gradient to the center while keeping its midpoint
on the same keyhole edge. First run opens with Scope ON and feather 100%; links
predating `scf` retain their historical 35% parse fallback.
When Scope is OFF, Tactical, Operator and Minimal add small black backplates
at 70% opacity behind telemetry, vertical metadata, multiline AIS and scene
chrome. They have rounded corners, no halo and no overlap between adjacent lines.
Text keeps its original color, glow and opacity in both Scope states. The engine
publishes the OFF state as `data-scope-mask="off"` on the document root, so direct
setters, share links and scene restoration drive the same treatment. Scope ON
uses the original HUD layout, Operator frames and scene chrome, with no added
backplates. The OFF-only Minimal coordinate offset clears map attribution.
Cyber retains its existing skin.
`node scripts/qa-scope-hud.mjs` exercises native checkbox click and Space,
feather range and keyboard boundaries, all four layout transitions, share/scene
restore, original Scope ON styling, OFF-only backing coverage and non-overlapping
geometry, multiline AIS, summary clipping and narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode screenshots.
## Cyber HUD — September 30, 2026 ## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -5494,10 +5520,11 @@ the `_detectionUserOverridden` / explicit-intent flags that would suppress a
separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather, separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather,
detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D). 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 the first-run look is Detection DENSE
`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`, `75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather
and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so `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 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 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 edge; detection FADE is the label/card fading band around the keyhole. The
@@ -5567,11 +5594,12 @@ 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 calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the
default, and the mapping follows bracket brightness rather than slider default, and the mapping follows bracket brightness rather than slider
position. The `ko` PARSE fallback stays at `5`. 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, - **Scope: `ON`, feather: `100%`** — a broad, soft scope-mask edge (revised on
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`, 2026-10-25 from the prior `11%`; earlier defaults were `0%`, `8%` and `35%`).
`SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
mirrored by `#scope-feather-slider`'s markup value AND readout and mirrored by `#scope-feather-slider`'s markup value AND readout and
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans `_scopeFeatherPct` in `sharelink.js`. The slider spans
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the 0–200, 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 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, 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. and restoring the author's view is what a share link is for.
+1
View File
@@ -194,6 +194,7 @@
"src/ui/visualInput.js", "src/ui/visualInput.js",
"src/ui/visualInput.test.mjs", "src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs", "scripts/qa-visual-input.mjs",
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs", "scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js", "src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs", "src/ui/segmentedControl.test.mjs",
+9 -3
View File
@@ -805,7 +805,9 @@
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/statusBadge.js" "src/ui/statusBadge.js",
"src/ui/scopeControls.js",
"src/scopeMaskPolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -2575,7 +2577,9 @@
"src/voice/geminiAudio.js", "src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js", "src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js", "src/ui/statusBadge.js",
"src/sceneCommandRelease.js" "src/sceneCommandRelease.js",
"src/ui/scopeControls.js",
"src/scopeMaskPolicy.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3297,7 +3301,9 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/ui/scopeControls.js",
"src/scopeMaskPolicy.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
+593
View File
@@ -0,0 +1,593 @@
#!/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 };
});
const readUnexpectedBackings = () =>
page.evaluate(() =>
[
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
]
.map((el) => {
const style = getComputedStyle(el);
return {
selector: el.id ? `#${el.id}` : `.${el.className}`,
background: style.backgroundColor,
shadow: style.boxShadow,
transition: `${style.transitionProperty} ${style.transitionDuration}`,
};
})
.filter(
(style) =>
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
),
);
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);
}
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 },
}),
);
const transitioning = await readUnexpectedBackings();
if (transitioning.length)
console.log(
`[INFO] ${layout}: waiting for existing style transitions ${JSON.stringify(transitioning)}`,
);
await settleTextBackings(true);
const originalGlyphs = await readHudGlyphs();
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 unbacked = [...wrappers, ...scene].every(
(el) =>
getComputedStyle(el).backgroundColor === 'rgba(0, 0, 0, 0)' &&
getComputedStyle(el).boxShadow === 'none',
);
return {
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
unbacked,
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'),
};
});
original.unexpectedBackings = await readUnexpectedBackings();
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();
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);
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-sonar" aria-hidden="true"></div>
<div class="hud-top-bar"> <div class="hud-top-bar">
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span> <span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-top-bar-center">${this._missionId}</span> <span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-top-bar-right">PAGE 1/1</span> <span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</div> </div>
<div class="hud-corner hud-top-left"> <div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div> <div class="hud-bracket">┌</div>
<div class="hud-content"> <div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div> <div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div> <div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</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-wrap">
<div class="hud-summary-label">${t('SUMMARY')}</div> <div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div> <div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
</div> </div>
</div> </div>
</div> </div>
<div class="hud-corner hud-top-right"> <div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align: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-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-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div> <div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
</div> </div>
<div class="hud-bracket">┐</div> <div class="hud-bracket">┐</div>
</div> </div>
@@ -311,33 +311,33 @@ export class IntelHUD {
<div class="hud-corner hud-bottom-left"> <div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div> <div class="hud-bracket">└</div>
<div class="hud-content"> <div class="hud-content">
<div id="hud-mgrs">MGRS: ---</div> <div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
<div id="hud-latlon">--°--'--"N ---°--'--"W</div> <div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
</div> </div>
</div> </div>
<div class="hud-corner hud-bottom-right"> <div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align:right"> <div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div> <div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
<div id="hud-alt">ALT: --m SUN: --° EL</div> <div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div> <div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
</div> </div>
<div class="hud-bracket">┘</div> <div class="hud-bracket">┘</div>
</div> </div>
<div class="hud-edge hud-left-edge"> <div class="hud-edge hud-left-edge">
<div id="hud-coll">COLL: --:--:--Z</div> <div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
<div id="hud-ona">ONA: --°</div> <div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
</div> </div>
<div class="hud-edge hud-right-edge"> <div class="hud-edge hud-right-edge">
<div>BAND: PAN</div> <div class="hud-readout"><div>BAND: PAN</div></div>
<div>BITS: 11</div> <div class="hud-readout"><div>BITS: 11</div></div>
<div>LVL: 1A</div> <div class="hud-readout"><div>LVL: 1A</div></div>
</div> </div>
<div class="hud-bottom-bar"> <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> </div>
`; `;
this._el.dataset.variant = this._variant; 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 // actually decides fresh-boot layer state — including the early return that
// makes each layer's own initializer the operative default. // makes each layer's own initializer the operative default.
// 2. Scope feather. Owner: "I like to hide feather" set it to 0% on // 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% // 2026-08-22; later revisions were 8%, then 11%, then 100% on
// on 2026-08-24, a soft // 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available.
// edge. The hard crop is still one drag away, and that is pinned too.
// 3. Detection ON (Dense @ 75%) for EVERY style, Normal included. // 3. Detection ON (Dense @ 75%) for EVERY style, Normal included.
// Owner: "detect should also be on by default." // 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 // 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 { import {
SCOPE_FEATHER_RATIO_DEFAULT, SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather, getScopeMaskFeather,
isScopeMaskEnabled,
scopeMaskGeometry, scopeMaskGeometry,
setScopeMaskFeather, setScopeMaskFeather,
} from './scopeMask.js'; } 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', () => { test('first run opens with Scope ON and 100% feather at every default surface', () => {
assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11, assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled');
'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings'); assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25');
assert.equal(getScopeMaskFeather(), 0.11, assert.equal(getScopeMaskFeather(), 1,
'and the live module starts there, not merely documents it'); 'and the live module starts there, not merely documents it');
// The slider and its readout are the same default rendered as markup — a // 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 // fresh boot applies no restore, so a stale value here would show one number
// over a mask drawn at another. // over a mask drawn at another.
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/, assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/,
'index.html: the feather slider ships at 11'); 'index.html: the feather slider ships at 100');
assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%</, assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
'index.html: and its readout agrees with the handle'); '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, // The link this session generates must describe the mask this session draws,
// for the window before the first _syncShareState. // 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'); '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 // A link is authored state. The new default governs a session that said
// nothing; it must never overwrite one that said something. // 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=35').parseInitialHash().scopeFeatherPct, 35);
assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64); assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64);
assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0, 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'); '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 // 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 // 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. // 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'); 'the slider still offers the whole range');
const previous = getScopeMaskFeather(); const previous = getScopeMaskFeather();
try { try {
for (const ratio of [0.35, 0.7, 1]) { for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
setScopeMaskFeather(ratio); setScopeMaskFeather(ratio);
const geo = scopeMaskGeometry(1200, 900); const geo = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9, 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`); `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 // The first-run value draws a band derived from the same keyhole radius.
// near the retired 35 % halo.
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
const soft = scopeMaskGeometry(1200, 900); const soft = scopeMaskGeometry(1200, 900);
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
+32 -11
View File
@@ -1,4 +1,10 @@
import { getKeyholeGeometry } from './celestialRing.js'; import { getKeyholeGeometry } from './celestialRing.js';
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
export {
SCOPE_FEATHER_RATIO_MAX,
SCOPE_FEATHER_MAX_PCT,
clampScopeFeatherPct,
} from './scopeMaskPolicy.js';
/** /**
* Scope mask — the app's signature circular viewport treatment, made real. * Scope mask — the app's signature circular viewport treatment, made real.
@@ -43,13 +49,10 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
/** /**
* Default edge feather as a fraction of the keyhole radius. * 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 * 1.0 since 2026-10-25 (owner preference), superseding the 11% default.
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that * The slider spans 0..200; this is only where it starts. An explicit feather
* set it to zero: a subtle soft edge rather than either the hard crop or the * from a share link, scene or the operator overrides the default immediately,
* retired 35 % halo. The slider is untouched and still spans 0..100; this is * including the hard-crop path at 0.
* 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 * 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 * index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no
@@ -59,7 +62,7 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
* carries `scf=0` explicitly because the generator always writes the field. * carries `scf=0` explicitly because the generator always writes the field.
* Pinned in reasonableDefaults.test.mjs. * Pinned in reasonableDefaults.test.mjs.
*/ */
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 * Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so
* faint stars survive in the corners at globe scale. * faint stars survive in the corners at globe scale.
@@ -344,7 +347,10 @@ function teardownDevicePixelRatioWatch() {
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) { export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
const keyhole = getKeyholeGeometry(width, height); const keyhole = getKeyholeGeometry(width, height);
if (!(keyhole.radius > 0)) return null; 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 // Feather straddles the keyhole edge so the visible radius stays anchored
// to the shared geometry every keyhole consumer (labels, cards) fades on. // to the shared geometry every keyhole consumer (labels, cards) fades on.
const half = keyhole.radius * feather * 0.5; const half = keyhole.radius * feather * 0.5;
@@ -423,6 +429,14 @@ function draw() {
_painted = true; _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. * Install the scope mask into the viewer container. Idempotent.
* @param {Cesium.Viewer} viewer * @param {Cesium.Viewer} viewer
@@ -430,6 +444,7 @@ function draw() {
*/ */
export function installScopeMask(viewer) { export function installScopeMask(viewer) {
if (_canvas || !viewer?.container) return; if (_canvas || !viewer?.container) return;
syncScopeMaskAppearance();
_container = viewer.container; _container = viewer.container;
_viewer = viewer; _viewer = viewer;
_canvas = document.createElement('canvas'); _canvas = document.createElement('canvas');
@@ -505,6 +520,7 @@ export function setScopeMaskEnabled(enabled) {
const next = Boolean(enabled); const next = Boolean(enabled);
const reEnabled = next && !_enabled; const reEnabled = next && !_enabled;
_enabled = next; _enabled = next;
syncScopeMaskAppearance();
if (reEnabled) { if (reEnabled) {
// The camera moved freely while the scope was off and nothing sampled it, // 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 // so the painted terminus can be a whole altitude band stale. Re-sync once
@@ -522,11 +538,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} * @returns {void}
*/ */
export function setScopeMaskFeather(ratio) { 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(); draw();
} }
@@ -540,6 +559,7 @@ export function getScopeMaskFeather() {
* @returns {void} * @returns {void}
*/ */
export function destroyScopeMask() { export function destroyScopeMask() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect(); _resizeObserver?.disconnect();
_resizeObserver = null; _resizeObserver = null;
teardownDevicePixelRatioWatch(); teardownDevicePixelRatioWatch();
@@ -553,6 +573,7 @@ export function destroyScopeMask() {
/** Test seam. */ /** Test seam. */
export function _resetScopeMaskForTest() { export function _resetScopeMaskForTest() {
syncScopeMaskAppearance(true);
_resizeObserver?.disconnect(); _resizeObserver?.disconnect();
_resizeObserver = null; _resizeObserver = null;
teardownDevicePixelRatioWatch(); teardownDevicePixelRatioWatch();
+51 -5
View File
@@ -18,6 +18,9 @@ import {
SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MIN_PCT,
SCOPE_TERMINUS_MAX_PCT, SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT, SCOPE_FEATHER_RATIO_DEFAULT,
SCOPE_FEATHER_RATIO_MAX,
clampScopeFeatherPct,
getScopeMaskFeather,
SCOPE_OUTSIDE_ALPHA, SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M, SCOPE_TERMINUS_FAR_M,
@@ -47,16 +50,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
assert.equal(geo.outerR, keyholeR); 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 wide = scopeMaskGeometry(1000, 800, 7);
const one = scopeMaskGeometry(1000, 800, 1); const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
assert.equal(wide.innerR, one.innerR); const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
assert.equal(wide.outerR, one.outerR); 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 nan = scopeMaskGeometry(1000, 800, Number.NaN);
const zero = scopeMaskGeometry(1000, 800, 0); const zero = scopeMaskGeometry(1000, 800, 0);
assert.equal(nan.innerR, zero.innerR); 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', () => { test('degenerate viewport yields null instead of a broken gradient', () => {
assert.equal(scopeMaskGeometry(0, 0), null); assert.equal(scopeMaskGeometry(0, 0), null);
assert.equal(scopeMaskGeometry(-5, 100), null); assert.equal(scopeMaskGeometry(-5, 100), null);
@@ -136,12 +157,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
removeEventListener: (type, fn) => listeners.delete(fn), removeEventListener: (type, fn) => listeners.delete(fn),
}), }),
}; };
globalThis.document = { createElement: () => canvas }; const documentElement = { dataset: {} };
globalThis.document = { documentElement, createElement: () => canvas };
globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.ResizeObserver = class { observe() {} disconnect() {} };
const container = { clientWidth: width, clientHeight: height, appendChild() {} }; const container = { clientWidth: width, clientHeight: height, appendChild() {} };
return { return {
canvas, canvas,
container, container,
documentElement,
/** Canvas work counters: backing-store resizes, clears, fills. */ /** Canvas work counters: backing-store resizes, clears, fills. */
ops, ops,
/** Every fillStyle string assigned since install (hard-crop path). */ /** Every fillStyle string assigned since install (hard-crop path). */
@@ -160,6 +183,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', () => { test('a DPR change with no resize still repaints the backing store', () => {
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
try { 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;
export 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); 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', () => { test('share-link serialization emits the current celestial state', () => {
const manager = makeManager(); const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false }); 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'], ['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'],
['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'], ['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'],
['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'], ['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'],
['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'], ['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'],
['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'], ['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'],
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'], ['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'], ['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'],
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'], ['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
+6 -12
View File
@@ -5,6 +5,7 @@ import {
normalizeAllocationStrategy, normalizeAllocationStrategy,
} from './data/detectionPolicy.js'; } from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js'; import { clampScopeTerminusPct } from './scopeMask.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import { import {
decodeLayerStateParams, decodeLayerStateParams,
encodeLayerStateParams, encodeLayerStateParams,
@@ -112,10 +113,9 @@ export class ShareLinkManager {
this._detectionOutsideOpacityPct = 1; this._detectionOutsideOpacityPct = 1;
this._celestialRingEnabled = false; this._celestialRingEnabled = false;
this._scopeEnabled = true; this._scopeEnabled = true;
// Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24, // Mirrors the first-run mask and slider default. The historical missing-scf
// superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors // parse fallback below remains 35 because it restores an older authored view.
// SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value. this._scopeFeatherPct = 100;
this._scopeFeatherPct = 11;
// null = the altitude-adaptive terminus (the default). A number pins the // null = the altitude-adaptive terminus (the default). A number pins the
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17) // outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
this._scopeTerminusPct = null; this._scopeTerminusPct = null;
@@ -227,10 +227,7 @@ export class ShareLinkManager {
// (`_scopeFeatherPct` in the constructor tracks the default: that one // (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match // mirrors live state for the link this session generates, so it must match
// the mask, not the archive.) // the mask, not the archive.)
scopeFeatherPct: Math.max( scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
0,
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior; // Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED // 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 // 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -494,10 +491,7 @@ export class ShareLinkManager {
if (typeof extras.scopeEnabled === 'boolean') if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled; this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') { if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = Math.max( this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
0,
Math.min(100, Math.round(extras.scopeFeatherPct)),
);
} }
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null; if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') { else if (typeof extras.scopeTerminusPct === 'number') {
+2 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp'; import sharp from 'sharp';
import { import {
CUBE_FACES, CUBE_FACES,
@@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn
})); }));
for (const face of CUBE_FACES) { for (const face of CUBE_FACES) {
const { data, info } = await sharp( const { data, info } = await sharp(
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)),
) )
.raw() .raw()
.toBuffer({ resolveWithObject: true }); .toBuffer({ resolveWithObject: true });
+1 -1
View File
@@ -70,7 +70,7 @@ export class AircraftDisplay {
// a screen reader without this. It matters more now that the button ships // 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 // ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
// tech reported was an unpressed-looking control over an armed layer. // tech reported was an unpressed-looking control over an armed layer.
// Mirrors #scope-toggle, which has always carried aria-pressed. // Keep the pressed state explicit, matching the other toggle buttons.
this._models3dBtn?.setAttribute( this._models3dBtn?.setAttribute(
'aria-pressed', 'aria-pressed',
String(this._models3dEnabled), 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 assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import test from 'node:test'; 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'); 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) => { test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical'); const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir'); owner.setStyle('noir');
+12 -7
View File
@@ -1,3 +1,5 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './scopeControls.js';
import { createFrameRateMonitor } from './frameRateMonitor.js'; import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js'; import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls } from './displayControls.js'; import { bindDisplayControls } from './displayControls.js';
@@ -92,6 +94,11 @@ export class DisplayBindings {
}, },
}); });
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
isScopeMaskEnabled(),
);
this._displayControls?.destroy(); this._displayControls?.destroy();
this._displayControls = bindDisplayControls({ this._displayControls = bindDisplayControls({
elements: { elements: {
@@ -100,7 +107,7 @@ export class DisplayBindings {
bloomSlider: this._bloomSlider, bloomSlider: this._bloomSlider,
sharpenButton: this._sharpenBtn, sharpenButton: this._sharpenBtn,
sharpenSlider: this._sharpenSlider, sharpenSlider: this._sharpenSlider,
scopeButton: this._scopeBtn, scopeToggle: this._scopeBtn,
scopeFeatherSlider: this._scopeFeatherSlider, scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect, hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn, hudButton: this._hudBtn,
@@ -134,17 +141,15 @@ export class DisplayBindings {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
this._setSharpenEnabled(!this.sharpenEnabled); this._setSharpenEnabled(!this.sharpenEnabled);
}, },
toggleScope: () => { setScopeEnabled: (enabled) => {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
const next = !isScopeMaskEnabled(); setScopeMaskEnabled(enabled);
setScopeMaskEnabled(next); syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
this._scopeBtn.classList.toggle('active', next);
this._scopeBtn.setAttribute('aria-pressed', String(next));
this._syncShareState(); this._syncShareState();
}, },
setScopeFeather: (value) => { setScopeFeather: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
const pct = Math.max(0, Math.min(100, value || 0)); const pct = clampScopeFeatherPct(value);
if (this._scopeFeatherValue) if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`; this._scopeFeatherValue.textContent = `${pct}%`;
setScopeMaskFeather(pct / 100); setScopeMaskFeather(pct / 100);
+1 -1
View File
@@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
for (const [name, action] of [ for (const [name, action] of [
['bloomButton', 'toggleBloom'], ['bloomButton', 'toggleBloom'],
['sharpenButton', 'toggleSharpen'], ['sharpenButton', 'toggleSharpen'],
['scopeButton', 'toggleScope'],
['cleanViewButton', 'toggleCleanView'], ['cleanViewButton', 'toggleCleanView'],
['cleanViewExitButton', 'exitCleanView'], ['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'], ['celestialButton', 'toggleCelestial'],
@@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
['scopeFeatherSlider', 'setScopeFeather'], ['scopeFeatherSlider', 'setScopeFeather'],
]) ])
listen(elements[name], 'input', action, integer); listen(elements[name], 'input', action, integer);
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value); listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value); listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [ for (const [name, action] of [
+26
View File
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
assert.deepEqual(calls, ['b']); assert.deepEqual(calls, ['b']);
two.destroy(); 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() {} }; if (!host || !frameEvent) return { destroy() {} };
const readout = documentRef.createElement('div'); const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout'; readout.className = 'frame-rate-readout scene-text-holdout';
readout.hidden = true; readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language // i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —'; readout.textContent = 'FPS —';
+5
View File
@@ -0,0 +1,5 @@
/** 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;
}
+6
View File
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
bottom: calc(2vh + 5.4rem); 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 { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 9rem); bottom: calc(2vh + 9rem);
} }
+22
View File
@@ -501,6 +501,28 @@ html:not([lang='en']) .panel-collapse-btn {
color: var(--accent); color: var(--accent);
} }
/* A native checkbox gives Scope a visible, keyboard-operable on/off state. */
.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) ───────── */ /* ── Bloom Slider (inline with toggle) ───────── */
.pp-toggle-group { .pp-toggle-group {
display: flex; display: flex;
+124
View File
@@ -371,3 +371,127 @@
#intel-hud[data-variant='minimal'] .hud-summary-wrap { #intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px; 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;
}
/* Rotating the backing with its text also covers both vertical annotations. */
: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;
}
/* One backing per line; the operator's frames stay light and transparent. */
: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;
}
/* The title's inline text box needs a little vertical room around its glyphs. */
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
#title-bar
h1
> .scene-text-holdout {
padding-block: 0.12em;
}
: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;
}
+8 -9
View File
@@ -106,19 +106,18 @@
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <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-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n>Scope</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"> <div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n>Feather</span> <span class="pp-slider-mini-label" data-i18n>Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock <!-- First-run feather is 100% (owner preference 2026-10-25), matching
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings); SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather softens
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather the black mask edge; detection fading uses the separate Fade slider. -->
softens the black mask edge; label fading is the separate Fade <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 />
slider. --> <span class="pp-slider-value" id="scope-feather-value">100%</span>
<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>
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
+4 -4
View File
@@ -10,14 +10,14 @@
<!-- Title --> <!-- Title -->
<div id="title-bar"> <div id="title-bar">
<div class="title-glow"></div> <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> <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 class="scene-text-holdout" 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> </div>
<!-- Style indicator --> <!-- Style indicator -->
<div id="style-indicator"> <div id="style-indicator">
<span class="indicator-label" data-i18n>ACTIVE STYLE</span> <span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span> <span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
</div> </div>
<!-- Top-center camera and share actions --> <!-- Top-center camera and share actions -->
+10 -6
View File
@@ -1,3 +1,5 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './scopeControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js'; import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { syncSegmentedControl } from './segmentedControl.js'; import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
@@ -136,11 +138,10 @@ export class VisualSettings {
} }
if (typeof scopeEnabled === 'boolean') { if (typeof scopeEnabled === 'boolean') {
setScopeMaskEnabled(scopeEnabled); setScopeMaskEnabled(scopeEnabled);
this._scopeBtn?.classList.toggle('active', scopeEnabled); syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
} }
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { 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); this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue) if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`; this._scopeFeatherValue.textContent = `${pct}%`;
@@ -1302,11 +1303,14 @@ export class VisualSettings {
const scopeState = state.scope || {}; const scopeState = state.scope || {};
if (typeof scopeState.enabled === 'boolean') { if (typeof scopeState.enabled === 'boolean') {
setScopeMaskEnabled(scopeState.enabled); setScopeMaskEnabled(scopeState.enabled);
this._scopeBtn?.classList.toggle('active', scopeState.enabled); syncScopeControls(
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); this._scopeBtn,
this._scopeFeatherSlider,
scopeState.enabled,
);
} }
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { 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); this._scopeFeatherSlider.value = String(pct);
if (this._scopeFeatherValue) if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`; this._scopeFeatherValue.textContent = `${pct}%`;