diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md
new file mode 100644
index 000000000..38cfdf48e
--- /dev/null
+++ b/changelog.d/1410-scope-controls-hud-readability.md
@@ -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.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 0ace86811..b4ed2bacb 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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).
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 20e28122a..e60413624 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -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",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 89589091a..d15897175 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -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"]
},
diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs
new file mode 100644
index 000000000..7fece45ee
--- /dev/null
+++ b/scripts/qa-scope-hud.mjs
@@ -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;
diff --git a/src/hud.js b/src/hud.js
index 1428f8503..954e3ea94 100644
--- a/src/hud.js
+++ b/src/hud.js
@@ -282,28 +282,28 @@ export class IntelHUD {
- TOP SECRET // SI-TK // NOFORN
- ${this._missionId}
- PAGE 1/1
+ TOP SECRET // SI-TK // NOFORN
+ ${this._missionId}
+ PAGE 1/1
┌
-
TOP SECRET // SI-TK // NOFORN
-
${this._missionId} ${this._sensorId}
-
${t(MODE_LABELS.normal)}
+
TOP SECRET // SI-TK // NOFORN
+
${this._missionId} ${this._sensorId}
+
-
${t('SUMMARY')}
-
${t('Awaiting telemetry...')}
+
+
${t('Awaiting telemetry...')}
-
● ${t('REC')} 2026-01-01 00:00:00Z
-
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
+
● ${t('REC')} 2026-01-01 00:00:00Z
+
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
┐
@@ -311,33 +311,33 @@ export class IntelHUD {
└
-
MGRS: ---
-
--°--'--"N ---°--'--"W
+
+
-
GSD: --m NIIRS: --
-
ALT: --m SUN: --° EL
-
${t('AIS: --')}
+
+
+
┘
-
COLL: --:--:--Z
-
ONA: --°
+
+
-
BAND: PAN
-
BITS: 11
-
LVL: 1A
+
+
+
- LAT: -- LON: -- MGRS: ---
+ LAT: -- LON: -- MGRS: ---
`;
this._el.dataset.variant = this._variant;
diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs
index dfd951377..766079aa4 100644
--- a/src/reasonableDefaults.test.mjs
+++ b/src/reasonableDefaults.test.mjs
@@ -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, /]*\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;
diff --git a/src/scopeMask.js b/src/scopeMask.js
index 775a257f7..c43108377 100644
--- a/src/scopeMask.js
+++ b/src/scopeMask.js
@@ -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();
diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs
index 7f5dcba62..d37b6b23b 100644
--- a/src/scopeMask.test.mjs
+++ b/src/scopeMask.test.mjs
@@ -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 {
diff --git a/src/scopeMaskPolicy.js b/src/scopeMaskPolicy.js
new file mode 100644
index 000000000..13515fb43
--- /dev/null
+++ b/src/scopeMaskPolicy.js
@@ -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)),
+ );
+}
diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs
index 230843584..746d5b3bc 100644
--- a/src/sharelink.celestial.test.mjs
+++ b/src/sharelink.celestial.test.mjs
@@ -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'],
diff --git a/src/sharelink.js b/src/sharelink.js
index 7e0e78b35..c184a59f5 100644
--- a/src/sharelink.js
+++ b/src/sharelink.js
@@ -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') {
diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js
index ba325f6e9..6e14e0adf 100644
--- a/src/ui/aircraftDisplay.js
+++ b/src/ui/aircraftDisplay.js
@@ -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),
diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs
index 68710a36a..03ee75f08 100644
--- a/src/ui/cyberTheme.test.mjs
+++ b/src/ui/cyberTheme.test.mjs
@@ -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');
diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js
index 329681798..3ae874c3c 100644
--- a/src/ui/displayBindings.js
+++ b/src/ui/displayBindings.js
@@ -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);
diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js
index e0d76d338..ceab61d91 100644
--- a/src/ui/displayControls.js
+++ b/src/ui/displayControls.js
@@ -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;
+}
diff --git a/src/ui/displayControls.test.mjs b/src/ui/displayControls.test.mjs
index 705a9f775..f7c5bcafb 100644
--- a/src/ui/displayControls.test.mjs
+++ b/src/ui/displayControls.test.mjs
@@ -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],
+ ]);
+});
diff --git a/src/ui/frameRateMonitor.js b/src/ui/frameRateMonitor.js
index 3bad73a20..2f6ab8e8d 100644
--- a/src/ui/frameRateMonitor.js
+++ b/src/ui/frameRateMonitor.js
@@ -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 —';
diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css
index eef3c6001..547037de7 100644
--- a/src/ui/styles/command-dock-trays.css
+++ b/src/ui/styles/command-dock-trays.css
@@ -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);
}
diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css
index f11378bb9..59f275109 100644
--- a/src/ui/styles/controls.css
+++ b/src/ui/styles/controls.css
@@ -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;
diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css
index 227d2d6d0..7f491439b 100644
--- a/src/ui/styles/overlays.css
+++ b/src/ui/styles/overlays.css
@@ -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;
+}
diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html
index f31453b6c..f6af31127 100644
--- a/src/ui/templates/display-controls.html
+++ b/src/ui/templates/display-controls.html
@@ -106,19 +106,15 @@
diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html
index b3c9785f1..ba786c30b 100644
--- a/src/ui/templates/scene-chrome.html
+++ b/src/ui/templates/scene-chrome.html
@@ -11,13 +11,13 @@
GOD'S EYE VIEW
-
NO PLACE LEFT BEHIND
+
NO PLACE LEFT BEHIND
- ACTIVE STYLE
- NORMAL
+ ACTIVE STYLE
+ NORMAL
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js
index c804ea342..1d5f16a28 100644
--- a/src/ui/visualSettings.js
+++ b/src/ui/visualSettings.js
@@ -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}%`;