From 40b471f1e3bb5f21c045c6b9b6509ac40998e864 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Sun, 25 Oct 2026 23:48:00 -0500 Subject: [PATCH] Show HUD backplates only with scope off and preserve original text styling --- .../1397-scope-controls-hud-readability.md | 5 +- docs/CURRENT-STATE.md | 22 ++-- scripts/qa-scope-hud.mjs | 116 +++++++++++++++++- src/scopeMask.js | 12 ++ src/scopeMask.test.mjs | 27 +++- src/ui/styles/command-dock-trays.css | 6 + src/ui/styles/overlays.css | 85 ++++++------- 7 files changed, 210 insertions(+), 63 deletions(-) diff --git a/changelog.d/1397-scope-controls-hud-readability.md b/changelog.d/1397-scope-controls-hud-readability.md index 83f3779ba..ab1af80a1 100644 --- a/changelog.d/1397-scope-controls-hud-readability.md +++ b/changelog.d/1397-scope-controls-hud-readability.md @@ -1,5 +1,6 @@ - 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 darker black backings without halos or - overlapping lines, including vertical metadata, while keeping map attribution clear (bilawalsidhu, #1397). + Tactical, Operator and Minimal text black backplates only when Scope is OFF, + including vertical metadata, without changing the original text color, glow or + opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1397). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 773e23bee..f81297671 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -197,20 +197,20 @@ hard edge and 200% extends the gradient to the center while keeping its midpoint on the same keyhole edge. First run opens with Scope ON and feather 100%; links predating `scf` retain their historical 35% parse fallback. -Tactical, Operator and Minimal give every telemetry readout a small 70%-black -backing with rounded corners, including vertical metadata and multiline AIS. -These darker backings have no shadow halo or overlap between adjacent lines. 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. +When Scope is OFF, Tactical, Operator and Minimal add small black backplates +at 70% opacity behind telemetry, vertical metadata, multiline AIS and scene +chrome. They have rounded corners, no halo and no overlap between adjacent lines. +Text keeps its original color, glow and opacity in both Scope states. The engine +publishes the OFF state as `data-scope-mask="off"` on the document root, so direct +setters, share links and scene restoration drive the same treatment. Scope ON +uses the original HUD layout, Operator frames and scene chrome, with no added +backplates. The OFF-only Minimal coordinate offset clears 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 non-overlapping geometry, multiline AIS, summary clipping and -narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode screenshots. +restore, original Scope ON styling, OFF-only backing coverage and non-overlapping +geometry, multiline AIS, summary clipping and narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode screenshots. ## Cyber HUD — September 30, 2026 diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs index a77211b81..531f8228a 100644 --- a/scripts/qa-scope-hud.mjs +++ b/scripts/qa-scope-hud.mjs @@ -56,6 +56,45 @@ const readScope = () => }; }); +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 openApplication(url) { await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( @@ -245,6 +284,79 @@ try { 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 }, + }), + ); + const originalGlyphs = await readHudGlyphs(); + 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 unbacked = [...wrappers, ...scene].every( + (el) => + getComputedStyle(el).backgroundColor === 'rgba(0, 0, 0, 0)' && + getComputedStyle(el).boxShadow === 'none', + ); + return { + markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'), + unbacked, + 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 }, + }), + ); + const backedGlyphs = await readHudGlyphs(); + 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); @@ -340,6 +452,7 @@ try { getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)', ); return { + scopeOff: document.documentElement.dataset.scopeMask === 'off', visible, missing, empty, @@ -351,7 +464,8 @@ try { }); check( `${layout}: all visible HUD text has one backing`, - coverage.visible > 0 && + coverage.scopeOff && + coverage.visible > 0 && coverage.missing.length === 0 && coverage.empty === 0 && coverage.broadBackings === 0, diff --git a/src/scopeMask.js b/src/scopeMask.js index f439593e8..b8ff6c790 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -429,6 +429,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 @@ -436,6 +444,7 @@ function draw() { */ export function installScopeMask(viewer) { if (_canvas || !viewer?.container) return; + syncScopeMaskAppearance(); _container = viewer.container; _viewer = viewer; _canvas = document.createElement('canvas'); @@ -511,6 +520,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 @@ -549,6 +559,7 @@ export function getScopeMaskFeather() { * @returns {void} */ export function destroyScopeMask() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); @@ -562,6 +573,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 72efc46f6..007eb1e38 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -157,12 +157,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). */ @@ -181,6 +183,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/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index f61cd9190..547037de7 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -475,6 +475,12 @@ body:not(.ui-clean-view):not(.recording-mode):has( } #intel-hud[data-variant='minimal'] .hud-bottom-left { + bottom: calc(2vh + 5.4rem); +} + +:root[data-scope-mask='off'] + #intel-hud[data-variant='minimal'] + .hud-bottom-left { bottom: calc(2vh + 6.5rem); } diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index 676355a84..5ee056997 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -372,20 +372,19 @@ 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. */ +/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */ .hud-readout { display: contents; } -#intel-hud:is( +:root[data-scope-mask='off'] + #intel-hud:is( [data-variant='minimal'], [data-variant='operator'], [data-variant='tactical'] ) .hud-readout, -:root:not([data-ui-theme='cyber']) .scene-text-holdout { +: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; @@ -399,7 +398,8 @@ white-space: nowrap; } -#intel-hud:is( +:root[data-scope-mask='off'] + #intel-hud:is( [data-variant='minimal'], [data-variant='operator'], [data-variant='tactical'] @@ -412,72 +412,53 @@ white-space: nowrap; } -#intel-hud:is( - [data-variant='minimal'], - [data-variant='operator'], - [data-variant='tactical'] - ) - .hud-readout - :where(: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( +: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:not([data-ui-theme='cyber']) #style-indicator .scene-text-holdout { +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #style-indicator + .scene-text-holdout { margin-inline-start: auto; } /* Rotating the backing with its text also covers both vertical annotations. */ -#intel-hud:is([data-variant='operator'], [data-variant='tactical']) .hud-edge { +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='operator'], [data-variant='tactical']) + .hud-edge { opacity: 1; } -#intel-hud:is([data-variant='operator'], [data-variant='tactical']) +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='operator'], [data-variant='tactical']) .hud-edge .hud-readout > * { - opacity: 0.78; -} - -#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; + opacity: 0.5; } /* 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']) +: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; } -#intel-hud:is([data-variant='minimal'], [data-variant='operator']) +: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; } -#intel-hud:is([data-variant='minimal'], [data-variant='operator']) +: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; @@ -486,23 +467,31 @@ } /* The title's inline text box needs a little vertical room around its glyphs. */ -:root:not([data-ui-theme='cyber']) #title-bar h1 > .scene-text-holdout { +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #title-bar + h1 + > .scene-text-holdout { padding-block: 0.12em; } -:root:not([data-ui-theme='cyber']) #title-bar .subtitle { +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle { margin-top: 8px; } -:root:not([data-ui-theme='cyber']) #style-indicator .indicator-value { +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #style-indicator + .indicator-value { margin-top: 4px; } -#intel-hud[data-variant='minimal'] +:root[data-scope-mask='off'] + #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 { +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #title-bar + .frame-rate-readout { margin-top: 6px; }