mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat: add scope checkbox and improve HUD text readability
This commit is contained in:
1 parent
2a3e0d669f
commit
c3e782ef73
25 files changed
+747
-72
No files matched your search
+27
-2
@@ -186,6 +186,31 @@ Regression coverage lives in `src/tools/area.test.mjs`,
|
||||
`src/tools/queries/environment.test.mjs` and
|
||||
`src/layers/perimeters/cards.test.mjs`.
|
||||
|
||||
## Scope controls and HUD text readability
|
||||
|
||||
Display uses a native, visibly checked Scope checkbox. Turning it off disables
|
||||
its feather slider while retaining the value. Scope enabled state and feather
|
||||
are independent of Tactical, Operator, Minimal and Cyber layout changes. Share
|
||||
links (`sc`/`scf`) and scene visual state restore both choices, including feather
|
||||
above 100%. The supported feather range is 0–200% of the keyhole radius: 0% is a
|
||||
hard edge and 200% extends the gradient to the center while keeping its midpoint
|
||||
on the same keyhole edge. First run remains 11%; links predating `scf` retain
|
||||
their historical 35% parse fallback.
|
||||
|
||||
Tactical, Operator and Minimal give every telemetry readout a small, softly
|
||||
edged black backing, including vertical metadata and multiline AIS. Glyphs keep
|
||||
the active style hue with stronger alpha, while the red REC dot retains its own
|
||||
color. Readout backing sits outside each glyph's opacity; Operator's corner and
|
||||
bar frames stay transparent rather than stacking another dark panel. Summary
|
||||
ellipsis and desktop single-line coordinates remain intact. The title, tagline,
|
||||
optional FPS and active-style readout use the same small backing. Minimal's
|
||||
lower coordinates clear map attribution. Cyber retains its existing skin.
|
||||
|
||||
`node scripts/qa-scope-hud.mjs` exercises native checkbox click and Space,
|
||||
feather range and keyboard boundaries, all four layout transitions, share/scene
|
||||
restore, text-backing coverage and geometry, multiline AIS, summary clipping and
|
||||
narrow viewport containment. Optional `QA_SCREENSHOT_DIR` saves mode screenshots.
|
||||
|
||||
## Cyber HUD — September 30, 2026
|
||||
|
||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||
@@ -5570,8 +5595,8 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
- **Scope feather: `11%`** — a soft scope-mask edge (moved `0% → 8% → 11%`; `0%` hard crop for one day,
|
||||
`35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`,
|
||||
mirrored by `#scope-feather-slider`'s markup value AND readout and
|
||||
`_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans
|
||||
0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the
|
||||
`_scopeFeatherPct` in `sharelink.js`. The slider spans
|
||||
0–200, and an explicit `0` is still the hard-crop path — pinned, so moving the
|
||||
default cannot quietly delete it. The `scf` PARSE fallback deliberately stays
|
||||
at `35`: a link predating `scf` was authored when 35 was what its author saw,
|
||||
and restoring the author's view is what a share link is for.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -805,7 +805,9 @@
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/ui/layerNames.js",
|
||||
"src/ui/statusText.js",
|
||||
"src/ui/statusBadge.js"
|
||||
"src/ui/statusBadge.js",
|
||||
"src/ui/scopeControls.js",
|
||||
"src/scopeMaskPolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -2575,7 +2577,9 @@
|
||||
"src/voice/geminiAudio.js",
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/ui/statusBadge.js",
|
||||
"src/sceneCommandRelease.js"
|
||||
"src/sceneCommandRelease.js",
|
||||
"src/ui/scopeControls.js",
|
||||
"src/scopeMaskPolicy.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3297,7 +3301,9 @@
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/zh-CN.json"
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/ui/scopeControls.js",
|
||||
"src/scopeMaskPolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scope and HUD browser acceptance. Requires the development server.
|
||||
* Run: node scripts/qa-scope-hud.mjs
|
||||
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
|
||||
* Proves native checkbox click/Space, feather 0/11/100/200, layout independence,
|
||||
* share/scene restore, and visible text backing including rotated metadata.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, passed, detail) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) {
|
||||
failures += 1;
|
||||
if (detail) console.log(JSON.stringify(detail));
|
||||
}
|
||||
};
|
||||
const readScope = () =>
|
||||
page.evaluate(() => {
|
||||
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
|
||||
const canvas = document.getElementById('scope-mask');
|
||||
const context = canvas.getContext('2d');
|
||||
const radialX = Math.round(
|
||||
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
|
||||
);
|
||||
return {
|
||||
enabled: scope.enabled,
|
||||
feather: scope.featherPct / 100,
|
||||
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
|
||||
radialAlpha: context.getImageData(
|
||||
radialX,
|
||||
Math.round(canvas.height / 2),
|
||||
1,
|
||||
1,
|
||||
).data[3],
|
||||
checked: document.getElementById('scope-toggle').checked,
|
||||
disabled: document.getElementById('scope-feather-slider').disabled,
|
||||
value: Number(document.getElementById('scope-feather-slider').value),
|
||||
readout: document.getElementById('scope-feather-value').textContent,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||
url.searchParams.set('welcome', '0');
|
||||
url.hash =
|
||||
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=11&l=';
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return (
|
||||
cover?.classList.contains('hidden') &&
|
||||
Number(getComputedStyle(cover).opacity) === 0
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
manager.hud.setMode('on');
|
||||
manager.setStyle('normal');
|
||||
});
|
||||
const initial = await readScope();
|
||||
check(
|
||||
'default Scope checkbox and 11% feather agree with the mask',
|
||||
initial.enabled &&
|
||||
initial.checked &&
|
||||
!initial.disabled &&
|
||||
initial.feather === 0.11 &&
|
||||
initial.value === 11 &&
|
||||
initial.cornerAlpha > 0,
|
||||
initial,
|
||||
);
|
||||
const name = await page.$eval('#scope-toggle', (el) => ({
|
||||
type: el.type,
|
||||
label: el.labels?.[0]?.textContent.trim(),
|
||||
}));
|
||||
check(
|
||||
'Scope uses a native checkbox with a visible label',
|
||||
name.type === 'checkbox' && name.label?.includes('Scope'),
|
||||
name,
|
||||
);
|
||||
await page.click('#scope-toggle');
|
||||
const off = await readScope();
|
||||
check(
|
||||
'click disables Scope and its feather slider',
|
||||
!off.enabled &&
|
||||
!off.checked &&
|
||||
off.disabled &&
|
||||
off.feather === 0.11 &&
|
||||
off.cornerAlpha === 0,
|
||||
off,
|
||||
);
|
||||
await page.focus('#scope-toggle');
|
||||
await page.keyboard.press('Space');
|
||||
const on = await readScope();
|
||||
check(
|
||||
'Space re-enables the native checkbox without losing feather',
|
||||
on.enabled &&
|
||||
on.checked &&
|
||||
!on.disabled &&
|
||||
on.feather === 0.11 &&
|
||||
on.cornerAlpha > 0,
|
||||
on,
|
||||
);
|
||||
|
||||
const paintedAlpha = [];
|
||||
for (const pct of [0, 11, 100, 200]) {
|
||||
await page.$eval(
|
||||
'#scope-feather-slider',
|
||||
(el, value) => {
|
||||
el.value = String(value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
pct,
|
||||
);
|
||||
const state = await readScope();
|
||||
paintedAlpha.push(state.radialAlpha);
|
||||
check(
|
||||
`feather ${pct}% reaches the mask and readout`,
|
||||
state.value === pct &&
|
||||
state.feather === pct / 100 &&
|
||||
state.readout === `${pct}%`,
|
||||
state,
|
||||
);
|
||||
}
|
||||
check(
|
||||
'painted gradient widens beyond the old 100% ceiling',
|
||||
paintedAlpha[0] === 0 &&
|
||||
paintedAlpha[1] === 0 &&
|
||||
paintedAlpha[2] > 0 &&
|
||||
paintedAlpha[3] > paintedAlpha[2],
|
||||
paintedAlpha,
|
||||
);
|
||||
await page.focus('#scope-feather-slider');
|
||||
await page.keyboard.press('Home');
|
||||
check('native Home reaches the hard edge', (await readScope()).feather === 0);
|
||||
await page.keyboard.press('End');
|
||||
check(
|
||||
'native End reaches the expanded 200% maximum',
|
||||
(await readScope()).feather === 2,
|
||||
);
|
||||
await page.click('#scope-toggle');
|
||||
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
const state = await readScope();
|
||||
check(
|
||||
`Scope OFF and 200% survive ${layout}`,
|
||||
!state.enabled &&
|
||||
!state.checked &&
|
||||
state.disabled &&
|
||||
state.feather === 2 &&
|
||||
state.value === 200 &&
|
||||
state.cornerAlpha === 0,
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
const restored = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const links = manager.shareLinkManager;
|
||||
manager._syncShareState();
|
||||
const params = links._buildHashParams();
|
||||
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
|
||||
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
|
||||
await manager._visualSettings.restoreShareState({
|
||||
scopeEnabled: encoded.enabled === '1',
|
||||
scopeFeatherPct: Number(encoded.feather),
|
||||
});
|
||||
const shared = manager.getVisualState().scope;
|
||||
await manager.applyVisualState({
|
||||
scope: { enabled: true, featherPct: 200 },
|
||||
});
|
||||
const scene = manager.getVisualState().scope;
|
||||
await manager.applyVisualState({
|
||||
scope: { enabled: false, featherPct: 200 },
|
||||
});
|
||||
return { encoded, shared, scene };
|
||||
});
|
||||
check(
|
||||
'share state serializes and restores Scope OFF with 200%',
|
||||
restored.encoded.enabled === '0' &&
|
||||
restored.encoded.feather === '200' &&
|
||||
restored.shared.enabled === false &&
|
||||
restored.shared.featherPct === 200,
|
||||
restored,
|
||||
);
|
||||
check(
|
||||
'scene state restores Scope ON with 200%',
|
||||
restored.scene.enabled === true && restored.scene.featherPct === 200,
|
||||
restored,
|
||||
);
|
||||
|
||||
for (const layout of ['minimal', 'operator', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
const coverage = await page.evaluate(() => {
|
||||
const hud = document.getElementById('intel-hud');
|
||||
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
|
||||
const missing = [];
|
||||
let visible = 0;
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (
|
||||
!node.textContent.trim() ||
|
||||
node.parentElement.closest('.hud-bracket')
|
||||
)
|
||||
continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
if (
|
||||
![...range.getClientRects()].some(
|
||||
(rect) => rect.width > 0 && rect.height > 0,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
visible += 1;
|
||||
const backing = node.parentElement.closest('.hud-readout');
|
||||
const color = backing && getComputedStyle(backing).backgroundColor;
|
||||
if (
|
||||
!backing ||
|
||||
!color?.includes('0.34') ||
|
||||
backing.parentElement.closest('.hud-readout')
|
||||
)
|
||||
missing.push(node.textContent.trim());
|
||||
}
|
||||
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
|
||||
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
|
||||
);
|
||||
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
|
||||
const lineIds = [
|
||||
'hud-mgrs',
|
||||
'hud-latlon',
|
||||
'hud-gsd',
|
||||
'hud-alt',
|
||||
'hud-timestamp',
|
||||
'hud-bottom-line',
|
||||
];
|
||||
const multiline = lineIds.filter((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el.getClientRects().length) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
return (
|
||||
range.getBoundingClientRect().height >
|
||||
parseFloat(getComputedStyle(el).lineHeight) * 1.5
|
||||
);
|
||||
});
|
||||
const edges = [...hud.querySelectorAll('.hud-edge')]
|
||||
.filter((el) => getComputedStyle(el).display !== 'none')
|
||||
.map((el) => {
|
||||
const box = el.querySelector('.hud-readout').getBoundingClientRect();
|
||||
return (
|
||||
getComputedStyle(el).transform !== 'none' &&
|
||||
box.height > box.width &&
|
||||
Number(getComputedStyle(el).opacity) === 1
|
||||
);
|
||||
});
|
||||
const broadBackings = [
|
||||
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
|
||||
].filter(
|
||||
(el) =>
|
||||
el.offsetWidth > 0 &&
|
||||
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
return {
|
||||
visible,
|
||||
missing,
|
||||
empty,
|
||||
multiline,
|
||||
edges,
|
||||
broadBackings: broadBackings.length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${layout}: all visible HUD text has one backing`,
|
||||
coverage.visible > 0 &&
|
||||
coverage.missing.length === 0 &&
|
||||
coverage.empty === 0 &&
|
||||
coverage.broadBackings === 0,
|
||||
coverage,
|
||||
);
|
||||
check(
|
||||
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
|
||||
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
|
||||
coverage,
|
||||
);
|
||||
if (process.env.QA_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
process.env.QA_SCREENSHOT_DIR,
|
||||
`scope-off-${layout}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
const details = await page.evaluate(() => {
|
||||
const summary = document.getElementById('hud-summary');
|
||||
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
|
||||
const ais = document.getElementById('hud-ais-vessel');
|
||||
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
|
||||
return {
|
||||
summaryClipped:
|
||||
summary.scrollWidth > summary.clientWidth &&
|
||||
getComputedStyle(summary).textOverflow === 'ellipsis',
|
||||
aisMultiline:
|
||||
getComputedStyle(ais).whiteSpace === 'pre-line' &&
|
||||
ais.textContent.split('\n').length === 2 &&
|
||||
ais.getBoundingClientRect().height + 0.5 >=
|
||||
parseFloat(getComputedStyle(ais).lineHeight) * 2,
|
||||
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'summary clipping, multiline AIS and red REC dot retain their behavior',
|
||||
details.summaryClipped &&
|
||||
details.aisMultiline &&
|
||||
details.recColor === 'rgb(255, 51, 51)',
|
||||
details,
|
||||
);
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.select('#hud-layout-select', 'minimal');
|
||||
const narrow = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('#intel-hud .hud-readout')]
|
||||
.filter(
|
||||
(el) =>
|
||||
el.getClientRects().length &&
|
||||
getComputedStyle(el).display !== 'contents',
|
||||
)
|
||||
.every((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.left >= 0 && box.right <= innerWidth;
|
||||
}),
|
||||
);
|
||||
check('narrow HUD text backings stay within the viewport', narrow);
|
||||
check('runtime has no uncaught browser errors', errors.length === 0, errors);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
+21
-21
@@ -282,28 +282,28 @@ export class IntelHUD {
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
<div class="hud-top-bar">
|
||||
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
|
||||
<span class="hud-top-bar-center">${this._missionId}</span>
|
||||
<span class="hud-top-bar-right">PAGE 1/1</span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
|
||||
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-left">
|
||||
<div class="hud-bracket">┌</div>
|
||||
<div class="hud-content">
|
||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
|
||||
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
|
||||
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
|
||||
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
|
||||
<div class="hud-summary-wrap">
|
||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
||||
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
|
||||
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
||||
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
|
||||
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
|
||||
</div>
|
||||
<div class="hud-bracket">┐</div>
|
||||
</div>
|
||||
@@ -311,33 +311,33 @@ export class IntelHUD {
|
||||
<div class="hud-corner hud-bottom-left">
|
||||
<div class="hud-bracket">└</div>
|
||||
<div class="hud-content">
|
||||
<div id="hud-mgrs">MGRS: ---</div>
|
||||
<div id="hud-latlon">--°--'--"N ---°--'--"W</div>
|
||||
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
|
||||
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-bottom-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
||||
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
|
||||
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
|
||||
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
|
||||
</div>
|
||||
<div class="hud-bracket">┘</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-edge hud-left-edge">
|
||||
<div id="hud-coll">COLL: --:--:--Z</div>
|
||||
<div id="hud-ona">ONA: --°</div>
|
||||
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
|
||||
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
|
||||
</div>
|
||||
|
||||
<div class="hud-edge hud-right-edge">
|
||||
<div>BAND: PAN</div>
|
||||
<div>BITS: 11</div>
|
||||
<div>LVL: 1A</div>
|
||||
<div class="hud-readout"><div>BAND: PAN</div></div>
|
||||
<div class="hud-readout"><div>BITS: 11</div></div>
|
||||
<div class="hud-readout"><div>LVL: 1A</div></div>
|
||||
</div>
|
||||
|
||||
<div class="hud-bottom-bar">
|
||||
<span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span>
|
||||
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
|
||||
</div>
|
||||
`;
|
||||
this._el.dataset.variant = this._variant;
|
||||
|
||||
@@ -117,11 +117,11 @@ test('the subtle default did not weaken the feather control, and 0 is still reac
|
||||
// The cheap way to move a default would be to nerf the control. Prove the
|
||||
// slider still spans its full range and the geometry is still DERIVED from
|
||||
// the ratio — a check that would pass vacuously if it only ever saw one value.
|
||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/,
|
||||
assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/,
|
||||
'the slider still offers the whole range');
|
||||
const previous = getScopeMaskFeather();
|
||||
try {
|
||||
for (const ratio of [0.35, 0.7, 1]) {
|
||||
for (const ratio of [0.35, 0.7, 1, 1.75, 2]) {
|
||||
setScopeMaskFeather(ratio);
|
||||
const geo = scopeMaskGeometry(1200, 900);
|
||||
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||
|
||||
+17
-5
@@ -1,4 +1,10 @@
|
||||
import { getKeyholeGeometry } from './celestialRing.js';
|
||||
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
|
||||
export {
|
||||
SCOPE_FEATHER_RATIO_MAX,
|
||||
SCOPE_FEATHER_MAX_PCT,
|
||||
clampScopeFeatherPct,
|
||||
} from './scopeMaskPolicy.js';
|
||||
|
||||
/**
|
||||
* Scope mask — the app's signature circular viewport treatment, made real.
|
||||
@@ -46,8 +52,8 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 };
|
||||
* 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on
|
||||
* 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that
|
||||
* set it to zero: a subtle soft edge rather than either the hard crop or the
|
||||
* retired 35 % halo. The slider is untouched and still spans 0..100; this is
|
||||
* only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a
|
||||
* retired 35 % halo. The slider spans 0..200; this is
|
||||
* only where it STARTS. Any feather a
|
||||
* share link carries, or the operator dials in, overrides this immediately, and
|
||||
* the hard-crop path at 0 is still reachable from the handle.
|
||||
*
|
||||
@@ -344,7 +350,10 @@ function teardownDevicePixelRatioWatch() {
|
||||
export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) {
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
if (!(keyhole.radius > 0)) return null;
|
||||
const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0));
|
||||
const feather = Math.max(
|
||||
0,
|
||||
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0),
|
||||
);
|
||||
// Feather straddles the keyhole edge so the visible radius stays anchored
|
||||
// to the shared geometry every keyhole consumer (labels, cards) fades on.
|
||||
const half = keyhole.radius * feather * 0.5;
|
||||
@@ -522,11 +531,14 @@ export function isScopeMaskEnabled() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1].
|
||||
* @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2].
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setScopeMaskFeather(ratio) {
|
||||
_featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
|
||||
_featherRatio = Math.max(
|
||||
0,
|
||||
Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0),
|
||||
);
|
||||
draw();
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -18,6 +18,9 @@ import {
|
||||
SCOPE_TERMINUS_MIN_PCT,
|
||||
SCOPE_TERMINUS_MAX_PCT,
|
||||
SCOPE_FEATHER_RATIO_DEFAULT,
|
||||
SCOPE_FEATHER_RATIO_MAX,
|
||||
clampScopeFeatherPct,
|
||||
getScopeMaskFeather,
|
||||
SCOPE_OUTSIDE_ALPHA,
|
||||
SCOPE_TERMINUS_ALPHA_NEAR,
|
||||
SCOPE_TERMINUS_FAR_M,
|
||||
@@ -47,16 +50,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => {
|
||||
assert.equal(geo.outerR, keyholeR);
|
||||
});
|
||||
|
||||
test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => {
|
||||
test('feather ratio reaches the center at 200% and clamps larger input', () => {
|
||||
const wide = scopeMaskGeometry(1000, 800, 7);
|
||||
const one = scopeMaskGeometry(1000, 800, 1);
|
||||
assert.equal(wide.innerR, one.innerR);
|
||||
assert.equal(wide.outerR, one.outerR);
|
||||
const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX);
|
||||
const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||
assert.equal(maximum.innerR, 0);
|
||||
assert.equal(maximum.outerR, keyholeR * 2);
|
||||
assert.equal(wide.innerR, maximum.innerR);
|
||||
assert.equal(wide.outerR, maximum.outerR);
|
||||
for (const ratio of [1, 1.5, 2]) {
|
||||
setScopeMaskFeather(ratio);
|
||||
assert.equal(getScopeMaskFeather(), ratio);
|
||||
const geometry = scopeMaskGeometry(1000, 800);
|
||||
assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR);
|
||||
assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio);
|
||||
}
|
||||
setScopeMaskFeather(7);
|
||||
assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX);
|
||||
const nan = scopeMaskGeometry(1000, 800, Number.NaN);
|
||||
const zero = scopeMaskGeometry(1000, 800, 0);
|
||||
assert.equal(nan.innerR, zero.innerR);
|
||||
});
|
||||
|
||||
test('UI and share feather percentages share one bounded integer range', () => {
|
||||
for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) {
|
||||
assert.equal(clampScopeFeatherPct(value), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('degenerate viewport yields null instead of a broken gradient', () => {
|
||||
assert.equal(scopeMaskGeometry(0, 0), null);
|
||||
assert.equal(scopeMaskGeometry(-5, 100), null);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */
|
||||
export const SCOPE_FEATHER_RATIO_MAX = 2;
|
||||
export const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100;
|
||||
|
||||
/** Normalize a UI/share-link feather percentage to the supported integer range. */
|
||||
export function clampScopeFeatherPct(value) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)),
|
||||
);
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
+3
-8
@@ -5,6 +5,7 @@ import {
|
||||
normalizeAllocationStrategy,
|
||||
} from './data/detectionPolicy.js';
|
||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||
import {
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
@@ -227,10 +228,7 @@ export class ShareLinkManager {
|
||||
// (`_scopeFeatherPct` in the constructor tracks the default: that one
|
||||
// mirrors live state for the link this session generates, so it must match
|
||||
// the mask, not the archive.)
|
||||
scopeFeatherPct: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(parseOr(params.get('scf'), 35))),
|
||||
),
|
||||
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
|
||||
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
||||
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
||||
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
|
||||
@@ -494,10 +492,7 @@ export class ShareLinkManager {
|
||||
if (typeof extras.scopeEnabled === 'boolean')
|
||||
this._scopeEnabled = extras.scopeEnabled;
|
||||
if (typeof extras.scopeFeatherPct === 'number') {
|
||||
this._scopeFeatherPct = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(extras.scopeFeatherPct)),
|
||||
);
|
||||
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
|
||||
}
|
||||
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
||||
else if (typeof extras.scopeTerminusPct === 'number') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
CUBE_FACES,
|
||||
@@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn
|
||||
}));
|
||||
for (const face of CUBE_FACES) {
|
||||
const { data, info } = await sharp(
|
||||
new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname,
|
||||
fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)),
|
||||
)
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
@@ -70,7 +70,7 @@ export class AircraftDisplay {
|
||||
// a screen reader without this. It matters more now that the button ships
|
||||
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
||||
// tech reported was an unpressed-looking control over an armed layer.
|
||||
// Mirrors #scope-toggle, which has always carried aria-pressed.
|
||||
// Keep the pressed state explicit, matching the other toggle buttons.
|
||||
this._models3dBtn?.setAttribute(
|
||||
'aria-pressed',
|
||||
String(this._models3dEnabled),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { syncScopeControls } from './scopeControls.js';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
@@ -92,6 +94,11 @@ export class DisplayBindings {
|
||||
},
|
||||
});
|
||||
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
isScopeMaskEnabled(),
|
||||
);
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
@@ -100,7 +107,7 @@ export class DisplayBindings {
|
||||
bloomSlider: this._bloomSlider,
|
||||
sharpenButton: this._sharpenBtn,
|
||||
sharpenSlider: this._sharpenSlider,
|
||||
scopeButton: this._scopeBtn,
|
||||
scopeToggle: this._scopeBtn,
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
@@ -134,17 +141,15 @@ export class DisplayBindings {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
toggleScope: () => {
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const next = !isScopeMaskEnabled();
|
||||
setScopeMaskEnabled(next);
|
||||
this._scopeBtn.classList.toggle('active', next);
|
||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||
setScopeMaskEnabled(enabled);
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const pct = Math.max(0, Math.min(100, value || 0));
|
||||
const pct = clampScopeFeatherPct(value);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
setScopeMaskFeather(pct / 100);
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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 —';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Keep the native Scope checkbox and feather availability in step with the mask. */
|
||||
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||
if (toggle) toggle.checked = enabled;
|
||||
if (featherSlider) featherSlider.disabled = !enabled;
|
||||
}
|
||||
@@ -475,7 +475,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
|
||||
@@ -501,6 +501,28 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* A native checkbox gives Scope a visible, keyboard-operable on/off state. */
|
||||
.pp-scope-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 0 0 auto;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:checked) .pp-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:focus-visible) {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#scope-slider-row:has(input:disabled) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── Bloom Slider (inline with toggle) ───────── */
|
||||
.pp-toggle-group {
|
||||
display: flex;
|
||||
|
||||
@@ -371,3 +371,131 @@
|
||||
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Text-sized black backing keeps bright imagery from washing out telemetry.
|
||||
The backing wraps each readout outside its existing opacity, so dim metadata
|
||||
keep their hierarchy as contrast increases; the REC dot stays red. */
|
||||
.hud-readout {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout,
|
||||
:root:not([data-ui-theme='cyber']) .scene-text-holdout {
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
box-shadow: 0 0 6px 3px rgba(0, 0, 0, 0.16);
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
margin: -1px -4px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout
|
||||
:not(#hud-rec-dot) {
|
||||
color: rgb(from var(--hud-color) r g b / 0.86);
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.8),
|
||||
0 0 4px var(--hud-glow);
|
||||
}
|
||||
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root:not([data-ui-theme='cyber']) #style-indicator .scene-text-holdout {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Rotating the backing with its text also covers both vertical annotations. */
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical']) .hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge
|
||||
.hud-readout
|
||||
> * {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
:is(
|
||||
.hud-top-bar-left,
|
||||
.hud-top-bar-right,
|
||||
.hud-summary-label,
|
||||
.hud-orbital,
|
||||
.hud-ais-vessel:not(.active)
|
||||
) {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
/* One backing per line; the operator's frames stay light and transparent. */
|
||||
#intel-hud[data-variant='operator'] .hud-corner,
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar)
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber']) #title-bar .subtitle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber']) #style-indicator .indicator-value {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber']) #title-bar .frame-rate-readout {
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -106,10 +106,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
|
||||
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label" data-i18n>Scope</span>
|
||||
</button>
|
||||
<input type="checkbox" id="scope-toggle" checked />
|
||||
</label>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
@@ -117,7 +118,7 @@
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
<!-- Title -->
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span class="scene-text-holdout" translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
|
||||
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { syncScopeControls } from './scopeControls.js';
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -136,11 +138,10 @@ export class VisualSettings {
|
||||
}
|
||||
if (typeof scopeEnabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeEnabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeEnabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
|
||||
}
|
||||
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeFeatherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
@@ -1302,11 +1303,14 @@ export class VisualSettings {
|
||||
const scopeState = state.scope || {};
|
||||
if (typeof scopeState.enabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeState.enabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
scopeState.enabled,
|
||||
);
|
||||
}
|
||||
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeState.featherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
|
||||
Reference in new issue
Block a user