Show HUD backplates only with scope off and preserve original text styling

This commit is contained in:
Bilawal Sidhu committed 2026-10-25 23:48:00 -05:00
1 parent cfbdf22121
commit 40b471f1e3
7 files changed
+210 -63

No files matched your search

@@ -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).
+11 -11
View File
@@ -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
+115 -1
View File
@@ -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,
+12
View File
@@ -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();
+26 -1
View File
@@ -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 {
+6
View File
@@ -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);
}
+37 -48
View File
@@ -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;
}