diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index b444589b9..030081659 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -186,6 +186,31 @@ Regression coverage lives in `src/tools/area.test.mjs`,
`src/tools/queries/environment.test.mjs` and
`src/layers/perimeters/cards.test.mjs`.
+## Scope controls and HUD text readability
+
+Display uses a native, visibly checked Scope checkbox. Turning it off disables
+its feather slider while retaining the value. Scope enabled state and feather
+are independent of Tactical, Operator, Minimal and Cyber layout changes. Share
+links (`sc`/`scf`) and scene visual state restore both choices, including feather
+above 100%. The supported feather range is 0–200% of the keyhole radius: 0% is a
+hard edge and 200% extends the gradient to the center while keeping its midpoint
+on the same keyhole edge. First run remains 11%; links predating `scf` retain
+their historical 35% parse fallback.
+
+Tactical, Operator and Minimal give every telemetry readout a small, softly
+edged black backing, including vertical metadata and multiline AIS. Glyphs keep
+the active style hue with stronger alpha, while the red REC dot retains its own
+color. Readout backing sits outside each glyph's opacity; Operator's corner and
+bar frames stay transparent rather than stacking another dark panel. Summary
+ellipsis and desktop single-line coordinates remain intact. The title, tagline,
+optional FPS and active-style readout use the same small backing. Minimal's
+lower coordinates clear map attribution. Cyber retains its existing skin.
+
+`node scripts/qa-scope-hud.mjs` exercises native checkbox click and Space,
+feather range and keyboard boundaries, all four layout transitions, share/scene
+restore, text-backing coverage and geometry, multiline AIS, summary clipping and
+narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode screenshots.
+
## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -5570,8 +5595,8 @@ easier to meet (detection is now on more often), but does not create it.
- **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
+ `_scopeFeatherPct` in `sharelink.js`. The slider spans
+ 0–200, and an explicit `0` is still the hard-crop path — pinned, so moving the
default cannot quietly delete it. The `scf` PARSE fallback deliberately stays
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.
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index f88be2b6b..83a191c10 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 68a57066a..69b67fb8c 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -805,7 +805,9 @@
"src/ui/keyGuidance.js",
"src/ui/layerNames.js",
"src/ui/statusText.js",
- "src/ui/statusBadge.js"
+ "src/ui/statusBadge.js",
+ "src/ui/scopeControls.js",
+ "src/scopeMaskPolicy.js"
],
"external": ["cesium"]
},
@@ -2575,7 +2577,9 @@
"src/voice/geminiAudio.js",
"src/voice/geminiCaptureWorklet.js",
"src/ui/statusBadge.js",
- "src/sceneCommandRelease.js"
+ "src/sceneCommandRelease.js",
+ "src/ui/scopeControls.js",
+ "src/scopeMaskPolicy.js"
],
"external": [
"hls.js",
@@ -3297,7 +3301,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/ui/scopeControls.js",
+ "src/scopeMaskPolicy.js"
],
"external": ["cesium"]
},
diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs
new file mode 100644
index 000000000..2572fb1e4
--- /dev/null
+++ b/scripts/qa-scope-hud.mjs
@@ -0,0 +1,366 @@
+#!/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 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,
+ };
+ });
+
+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');
+ url.hash =
+ 'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=11&l=';
+ 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 },
+ );
+ 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(
+ 'default Scope checkbox and 11% feather agree with the mask',
+ initial.enabled &&
+ initial.checked &&
+ !initial.disabled &&
+ initial.feather === 0.11 &&
+ initial.value === 11 &&
+ 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 === 0.11 &&
+ 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 === 0.11 &&
+ 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);
+ 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?.includes('0.34') ||
+ 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 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 {
+ visible,
+ missing,
+ empty,
+ multiline,
+ edges,
+ broadBackings: broadBackings.length,
+ };
+ });
+ check(
+ `${layout}: all visible HUD text has one backing`,
+ coverage.visible > 0 &&
+ coverage.missing.length === 0 &&
+ coverage.empty === 0 &&
+ coverage.broadBackings === 0,
+ coverage,
+ );
+ check(
+ `${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
+ coverage.multiline.length === 0 && coverage.edges.every(Boolean),
+ coverage,
+ );
+ if (process.env.QA_SCREENSHOT_DIR) {
+ fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
+ await page.screenshot({
+ path: path.join(
+ process.env.QA_SCREENSHOT_DIR,
+ `scope-off-${layout}.png`,
+ ),
+ });
+ }
+ }
+ const details = await page.evaluate(() => {
+ const summary = document.getElementById('hud-summary');
+ summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
+ const ais = document.getElementById('hud-ais-vessel');
+ ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
+ return {
+ summaryClipped:
+ summary.scrollWidth > summary.clientWidth &&
+ getComputedStyle(summary).textOverflow === 'ellipsis',
+ aisMultiline:
+ getComputedStyle(ais).whiteSpace === 'pre-line' &&
+ ais.textContent.split('\n').length === 2 &&
+ ais.getBoundingClientRect().height + 0.5 >=
+ parseFloat(getComputedStyle(ais).lineHeight) * 2,
+ recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
+ };
+ });
+ check(
+ 'summary clipping, multiline AIS and red REC dot retain their behavior',
+ details.summaryClipped &&
+ details.aisMultiline &&
+ details.recColor === 'rgb(255, 51, 51)',
+ details,
+ );
+ await page.setViewport({ width: 390, height: 844 });
+ await page.select('#hud-layout-select', 'minimal');
+ const narrow = await page.evaluate(() =>
+ [...document.querySelectorAll('#intel-hud .hud-readout')]
+ .filter(
+ (el) =>
+ el.getClientRects().length &&
+ getComputedStyle(el).display !== 'contents',
+ )
+ .every((el) => {
+ const box = el.getBoundingClientRect();
+ return box.left >= 0 && box.right <= innerWidth;
+ }),
+ );
+ check('narrow HUD text backings stay within the viewport', narrow);
+ check('runtime has no uncaught browser errors', errors.length === 0, errors);
+} finally {
+ await browser.close();
+}
+console.log(`RESULT: ${failures} failures`);
+process.exitCode = failures ? 1 : 0;
diff --git a/src/hud.js b/src/hud.js
index cf7983da7..25fd88f3d 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..98abb5b68 100644
--- a/src/reasonableDefaults.test.mjs
+++ b/src/reasonableDefaults.test.mjs
@@ -117,11 +117,11 @@ test('the subtle default did not weaken the feather control, and 0 is still reac
// 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;
diff --git a/src/scopeMask.js b/src/scopeMask.js
index 775a257f7..14c9f65bd 100644
--- a/src/scopeMask.js
+++ b/src/scopeMask.js
@@ -1,4 +1,10 @@
import { getKeyholeGeometry } from './celestialRing.js';
+import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
+export {
+ SCOPE_FEATHER_RATIO_MAX,
+ SCOPE_FEATHER_MAX_PCT,
+ clampScopeFeatherPct,
+} from './scopeMaskPolicy.js';
/**
* Scope mask — the app's signature circular viewport treatment, made real.
@@ -46,8 +52,8 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
* 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
+ * retired 35 % halo. The slider spans 0..200; this is
+ * only where it STARTS. 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.
*
@@ -344,7 +350,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;
@@ -522,11 +531,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();
}
diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs
index 7f5dcba62..72efc46f6 100644
--- a/src/scopeMask.test.mjs
+++ b/src/scopeMask.test.mjs
@@ -18,6 +18,9 @@ import {
SCOPE_TERMINUS_MIN_PCT,
SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT,
+ SCOPE_FEATHER_RATIO_MAX,
+ clampScopeFeatherPct,
+ getScopeMaskFeather,
SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M,
@@ -47,16 +50,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
assert.equal(geo.outerR, keyholeR);
});
-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);
diff --git a/src/scopeMaskPolicy.js b/src/scopeMaskPolicy.js
new file mode 100644
index 000000000..4238189b1
--- /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;
+export const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100;
+
+/** Normalize a UI/share-link feather percentage to the supported integer range. */
+export function clampScopeFeatherPct(value) {
+ return Math.max(
+ 0,
+ Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)),
+ );
+}
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..fbf91593c 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,
@@ -227,10 +228,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 +492,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/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs
index ddb57589a..78a91519e 100644
--- a/src/tooling/skyAssets.paintOut.test.mjs
+++ b/src/tooling/skyAssets.paintOut.test.mjs
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
+import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import {
CUBE_FACES,
@@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn
}));
for (const face of CUBE_FACES) {
const { data, info } = await sharp(
- new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
+ fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)),
)
.raw()
.toBuffer({ resolveWithObject: true });
diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js
index ba325f6e9..2e935e714 100644
--- a/src/ui/aircraftDisplay.js
+++ b/src/ui/aircraftDisplay.js
@@ -70,7 +70,7 @@ 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.
+ // Keep the pressed state explicit, matching the other toggle buttons.
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..0f874b33b 100644
--- a/src/ui/displayBindings.js
+++ b/src/ui/displayBindings.js
@@ -1,3 +1,5 @@
+import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
+import { syncScopeControls } from './scopeControls.js';
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls } from './displayControls.js';
@@ -92,6 +94,11 @@ export class DisplayBindings {
},
});
+ syncScopeControls(
+ this._scopeBtn,
+ this._scopeFeatherSlider,
+ isScopeMaskEnabled(),
+ );
this._displayControls?.destroy();
this._displayControls = bindDisplayControls({
elements: {
@@ -100,7 +107,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 +141,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..166d7c5ed 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 [
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/scopeControls.js b/src/ui/scopeControls.js
new file mode 100644
index 000000000..5d3cccd24
--- /dev/null
+++ b/src/ui/scopeControls.js
@@ -0,0 +1,5 @@
+/** Keep the native Scope checkbox and feather availability in step with the mask. */
+export function syncScopeControls(toggle, featherSlider, enabled) {
+ if (toggle) toggle.checked = enabled;
+ if (featherSlider) featherSlider.disabled = !enabled;
+}
diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css
index eef3c6001..f61cd9190 100644
--- a/src/ui/styles/command-dock-trays.css
+++ b/src/ui/styles/command-dock-trays.css
@@ -475,7 +475,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
- bottom: calc(2vh + 5.4rem);
+ bottom: calc(2vh + 6.5rem);
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css
index f11378bb9..56650df37 100644
--- a/src/ui/styles/controls.css
+++ b/src/ui/styles/controls.css
@@ -501,6 +501,28 @@ html:not([lang='en']) .panel-collapse-btn {
color: var(--accent);
}
+/* A native checkbox gives Scope a visible, keyboard-operable on/off state. */
+.pp-scope-toggle input {
+ width: 14px;
+ height: 14px;
+ margin: 0 0 0 auto;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.pp-scope-toggle:has(input:checked) .pp-label {
+ color: var(--accent);
+}
+
+.pp-scope-toggle:has(input:focus-visible) {
+ outline: 1px solid var(--accent);
+ outline-offset: 2px;
+}
+
+#scope-slider-row:has(input:disabled) {
+ opacity: 0.45;
+}
+
/* ── Bloom Slider (inline with toggle) ───────── */
.pp-toggle-group {
display: flex;
diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css
index 227d2d6d0..a76573630 100644
--- a/src/ui/styles/overlays.css
+++ b/src/ui/styles/overlays.css
@@ -371,3 +371,131 @@
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
max-width: 280px;
}
+
+/* Text-sized black backing keeps bright imagery from washing out telemetry.
+ The backing wraps each readout outside its existing opacity, so dim metadata
+ keep their hierarchy as contrast increases; the REC dot stays red. */
+.hud-readout {
+ display: contents;
+}
+
+#intel-hud:is(
+ [data-variant='minimal'],
+ [data-variant='operator'],
+ [data-variant='tactical']
+ )
+ .hud-readout,
+:root:not([data-ui-theme='cyber']) .scene-text-holdout {
+ background: rgba(0, 0, 0, 0.34);
+ box-shadow: 0 0 6px 3px rgba(0, 0, 0, 0.16);
+ border-radius: 2px;
+ padding: 1px 4px;
+ margin: -1px -4px;
+ width: fit-content;
+ max-width: 100%;
+ box-sizing: content-box;
+ white-space: nowrap;
+}
+
+#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;
+}
+
+#intel-hud:is(
+ [data-variant='minimal'],
+ [data-variant='operator'],
+ [data-variant='tactical']
+ )
+ .hud-readout
+ :not(#hud-rec-dot) {
+ color: rgb(from var(--hud-color) r g b / 0.86);
+ text-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.8),
+ 0 0 4px var(--hud-glow);
+}
+
+#intel-hud:is(
+ [data-variant='minimal'],
+ [data-variant='operator'],
+ [data-variant='tactical']
+ )
+ :is(.hud-top-right, .hud-bottom-right)
+ .hud-readout,
+:root:not([data-ui-theme='cyber']) #style-indicator .scene-text-holdout {
+ margin-left: auto;
+}
+
+/* Rotating the backing with its text also covers both vertical annotations. */
+#intel-hud:is([data-variant='operator'], [data-variant='tactical']) .hud-edge {
+ opacity: 1;
+}
+
+#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
+ .hud-edge
+ .hud-readout
+ > * {
+ opacity: 0.72;
+}
+
+#intel-hud:is(
+ [data-variant='minimal'],
+ [data-variant='operator'],
+ [data-variant='tactical']
+ )
+ :is(
+ .hud-top-bar-left,
+ .hud-top-bar-right,
+ .hud-summary-label,
+ .hud-orbital,
+ .hud-ais-vessel:not(.active)
+ ) {
+ opacity: 0.78;
+}
+
+/* One backing per line; the operator's frames stay light and transparent. */
+#intel-hud[data-variant='operator'] .hud-corner,
+#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
+ :is(.hud-top-bar, .hud-bottom-bar) {
+ background: transparent;
+}
+
+#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;
+}
+
+#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:not([data-ui-theme='cyber']) #title-bar .subtitle {
+ margin-top: 8px;
+}
+
+:root:not([data-ui-theme='cyber']) #style-indicator .indicator-value {
+ margin-top: 4px;
+}
+
+#intel-hud[data-variant='minimal']
+ .hud-readout:has(> :is(.hud-classification, .hud-system)) {
+ display: none;
+}
+
+:root: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..8b8e6566a 100644
--- a/src/ui/templates/display-controls.html
+++ b/src/ui/templates/display-controls.html
@@ -106,10 +106,11 @@
diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html
index b3c9785f1..83c5db017 100644
--- a/src/ui/templates/scene-chrome.html
+++ b/src/ui/templates/scene-chrome.html
@@ -10,14 +10,14 @@
-
GOD'S EYE VIEW
-
NO PLACE LEFT BEHIND
+
GOD'S EYE VIEW
+
NO PLACE LEFT BEHIND
- ACTIVE STYLE
- NORMAL
+ ACTIVE STYLE
+ NORMAL
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js
index c804ea342..f41fe2638 100644
--- a/src/ui/visualSettings.js
+++ b/src/ui/visualSettings.js
@@ -1,3 +1,5 @@
+import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
+import { syncScopeControls } from './scopeControls.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}%`;