From c3e782ef739b46ac7035ef22488cc77b7997b115 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Sun, 25 Oct 2026 17:46:16 -0500 Subject: [PATCH] feat: add scope checkbox and improve HUD text readability --- docs/CURRENT-STATE.md | 29 +- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 12 +- scripts/qa-scope-hud.mjs | 366 ++++++++++++++++++++++++ src/hud.js | 42 +-- src/reasonableDefaults.test.mjs | 4 +- src/scopeMask.js | 22 +- src/scopeMask.test.mjs | 29 +- src/scopeMaskPolicy.js | 11 + src/sharelink.celestial.test.mjs | 20 +- src/sharelink.js | 11 +- src/tooling/skyAssets.paintOut.test.mjs | 3 +- src/ui/aircraftDisplay.js | 2 +- src/ui/cyberTheme.test.mjs | 30 ++ src/ui/displayBindings.js | 19 +- src/ui/displayControls.js | 2 +- src/ui/displayControls.test.mjs | 26 ++ src/ui/frameRateMonitor.js | 2 +- src/ui/scopeControls.js | 5 + src/ui/styles/command-dock-trays.css | 2 +- src/ui/styles/controls.css | 22 ++ src/ui/styles/overlays.css | 128 +++++++++ src/ui/templates/display-controls.html | 7 +- src/ui/templates/scene-chrome.html | 8 +- src/ui/visualSettings.js | 16 +- 25 files changed, 747 insertions(+), 72 deletions(-) create mode 100644 scripts/qa-scope-hud.mjs create mode 100644 src/scopeMaskPolicy.js create mode 100644 src/ui/scopeControls.js 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(MODE_LABELS.normal)}
-
${t('SUMMARY')}
-
${t('Awaiting telemetry...')}
+
${t('SUMMARY')}
+
${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
+
MGRS: ---
+
--°--'--"N ---°--'--"W
-
GSD: --m NIIRS: --
-
ALT: --m SUN: --° EL
-
${t('AIS: --')}
+
GSD: --m NIIRS: --
+
ALT: --m SUN: --° EL
+
${t('AIS: --')}
┘
-
COLL: --:--:--Z
-
ONA: --°
+
COLL: --:--:--Z
+
ONA: --°
-
BAND: PAN
-
BITS: 11
-
LVL: 1A
+
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 @@
- + +
Feather - + 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}%`;