mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
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
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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 —';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}%`;
|
||||
|
||||
Reference in new issue
Block a user