From a3a19883b4f9ebb879040e2a6969c689355c3008 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:24:27 -0500 Subject: [PATCH] feat: add Scope controls and conditional HUD text backplates (#1410) * feat: add scope controls and conditional HUD backplates * docs: assign scope changelog to PR 1410 * fix: stabilize title backplate and brighten secondary labels --------- Co-authored-by: Bilawal Sidhu --- .../1410-scope-controls-hud-readability.md | 8 + docs/CURRENT-STATE.md | 36 +- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 10 +- scripts/qa-scope-hud.mjs | 681 ++++++++++++++++++ src/hud.js | 42 +- src/reasonableDefaults.test.mjs | 49 +- src/scopeMask.js | 46 +- src/scopeMask.test.mjs | 55 +- src/scopeMaskPolicy.js | 11 + src/sharelink.celestial.test.mjs | 20 +- src/sharelink.js | 16 +- src/ui/aircraftDisplay.js | 1 - src/ui/cyberTheme.test.mjs | 30 + src/ui/displayBindings.js | 20 +- src/ui/displayControls.js | 8 +- src/ui/displayControls.test.mjs | 26 + src/ui/frameRateMonitor.js | 2 +- src/ui/styles/command-dock-trays.css | 6 + src/ui/styles/controls.css | 21 + src/ui/styles/overlays.css | 131 ++++ src/ui/templates/display-controls.html | 14 +- src/ui/templates/scene-chrome.html | 6 +- src/ui/visualSettings.js | 16 +- 24 files changed, 1134 insertions(+), 122 deletions(-) create mode 100644 changelog.d/1410-scope-controls-hud-readability.md create mode 100644 scripts/qa-scope-hud.mjs create mode 100644 src/scopeMaskPolicy.js 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(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..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%]*\svalue="100"/, + 'index.html: the feather slider ships at 100'); + assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%]*\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 @@
- + +
Feather - - - 11% + + 100%
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}%`;