diff --git a/changelog.d/1397-scope-controls-hud-readability.md b/changelog.d/1397-scope-controls-hud-readability.md index 45a7addea..83f3779ba 100644 --- a/changelog.d/1397-scope-controls-hud-readability.md +++ b/changelog.d/1397-scope-controls-hud-readability.md @@ -1,4 +1,5 @@ - Add a Scope checkbox and extend feathering to 200%, retaining scope settings - across HUD layouts, share links and scenes. Give Tactical, Operator and Minimal - text subtle black backings, including vertical metadata, while keeping map - attribution clear (bilawalsidhu, #1397). + 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 030081659..773e23bee 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -194,11 +194,12 @@ 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. +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, softly -edged black backing, including vertical metadata and multiline AIS. Glyphs keep +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 @@ -208,7 +209,7 @@ 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 +restore, text-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 @@ -5519,10 +5520,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 @@ -5592,8 +5594,9 @@ 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`, +- **Scope: `ON`, feather: `100%`** — a broad, soft scope-mask edge (revised on + 2026-10-25 from the prior `11%`; earlier defaults were `0%`, `8%` and `35%`). + `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 spans 0–200, and an explicit `0` is still the hard-crop path — pinned, so moving the diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs index 2572fb1e4..a77211b81 100644 --- a/scripts/qa-scope-hud.mjs +++ b/scripts/qa-scope-hud.mjs @@ -3,7 +3,8 @@ * 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, + * 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'; @@ -55,12 +56,7 @@ const readScope = () => }; }); -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='; +async function openApplication(url) { await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( () => window.__godsEyeView?.styleManager?._applicationShortcuts, @@ -76,6 +72,36 @@ try { }, { 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, { @@ -87,12 +113,13 @@ try { }); const initial = await readScope(); check( - 'default Scope checkbox and 11% feather agree with the mask', + 'Austin fixture restores Scope ON and 100% feather', initial.enabled && initial.checked && !initial.disabled && - initial.feather === 0.11 && - initial.value === 11 && + initial.feather === 1 && + initial.value === 100 && + initial.readout === '100%' && initial.cornerAlpha > 0, initial, ); @@ -112,7 +139,7 @@ try { !off.enabled && !off.checked && off.disabled && - off.feather === 0.11 && + off.feather === 1 && off.cornerAlpha === 0, off, ); @@ -124,7 +151,7 @@ try { on.enabled && on.checked && !on.disabled && - on.feather === 0.11 && + on.feather === 1 && on.cornerAlpha > 0, on, ); @@ -243,7 +270,8 @@ try { const color = backing && getComputedStyle(backing).backgroundColor; if ( !backing || - !color?.includes('0.34') || + color !== 'rgba(0, 0, 0, 0.7)' || + getComputedStyle(backing).boxShadow !== 'none' || backing.parentElement.closest('.hud-readout') ) missing.push(node.textContent.trim()); @@ -252,6 +280,30 @@ try { (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', @@ -291,6 +343,7 @@ try { visible, missing, empty, + overlaps, multiline, edges, broadBackings: broadBackings.length, @@ -304,6 +357,11 @@ try { 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), diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 98abb5b68..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%, + assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="100"/, + 'index.html: the feather slider ships at 100'); + assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%, 'index.html: and its readout agrees with the handle'); + assert.match(indexHtml, /]*\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,7 +121,13 @@ 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. @@ -128,8 +142,7 @@ test('the subtle default did not weaken the feather control, and 0 is still reac 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 14c9f65bd..f439593e8 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -49,13 +49,10 @@ 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 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. + * 1.0 since 2026-10-25 (owner preference), superseding the 11% default. + * The slider spans 0..200; this is only where it starts. An explicit feather + * from a share link, scene or the operator overrides the default immediately, + * including the hard-crop path at 0. * * 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 @@ -65,7 +62,7 @@ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 }; * carries `scf=0` explicitly because the generator always writes the field. * Pinned in reasonableDefaults.test.mjs. */ -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. diff --git a/src/sharelink.js b/src/sharelink.js index fbf91593c..652e84953 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -113,10 +113,9 @@ 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; + // Mirrors the first-run mask and slider default. The historical missing-scf + // parse fallback below remains 35 because it restores an older authored view. + 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; diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index a76573630..676355a84 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -386,11 +386,13 @@ ) .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); + background: rgba(0, 0, 0, 0.7); + box-shadow: none; border-radius: 2px; - padding: 1px 4px; - margin: -1px -4px; + padding-block: 0; + padding-inline: 6px; + margin-block: 0; + margin-inline: -6px; width: fit-content; max-width: 100%; box-sizing: content-box; @@ -416,7 +418,7 @@ [data-variant='tactical'] ) .hud-readout - :not(#hud-rec-dot) { + :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), @@ -431,7 +433,7 @@ :is(.hud-top-right, .hud-bottom-right) .hud-readout, :root:not([data-ui-theme='cyber']) #style-indicator .scene-text-holdout { - margin-left: auto; + margin-inline-start: auto; } /* Rotating the backing with its text also covers both vertical annotations. */ @@ -443,7 +445,7 @@ .hud-edge .hud-readout > * { - opacity: 0.72; + opacity: 0.78; } #intel-hud:is( @@ -483,6 +485,11 @@ text-overflow: ellipsis; } +/* 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 { + padding-block: 0.12em; +} + :root:not([data-ui-theme='cyber']) #title-bar .subtitle { margin-top: 8px; } diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 8b8e6566a..e30d24f7d 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -113,13 +113,11 @@