diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 3032a25fe..2b84f7d19 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -299,9 +299,26 @@ function resolve(candidates, prop, width, label) { return winner; } +/** + * Inline a custom property declared once, on a bare :root, so an offset written + * through it evaluates to the length it stands for. Anything else stays a + * var() for toPx to vet. + */ +function inlineRootVars(value) { + const inlined = value.replace(/var\((--[\w-]+)\)/g, (match, name) => { + const declarations = RULES.flatMap((rule) => + rule.decls.filter((decl) => decl.prop === name).map((decl) => ({ rule, decl }))); + if (declarations.length !== 1) return match; + const [{ rule, decl }] = declarations; + if (rule.media.length || rule.parts.join() !== ':root') return match; + return decl.value.replace(/^calc\((.*)\)$/, '$1'); + }); + return inlined === value || /^calc\(/.test(inlined) ? inlined : `calc(${inlined})`; +} + /** Evaluate a length. Anything the model cannot resolve exactly fails loudly. */ function toPx(value, viewportHeight, where) { - const trimmed = value.trim(); + const trimmed = inlineRootVars(value.trim()); const inner = /^calc\(/.test(trimmed) ? trimmed.slice(5, -1) : trimmed; assert.doesNotMatch(inner, /\bcalc\(/, `nested calc() in ${where}: "${value}"`); assert.doesNotMatch(inner, /\b(min|max|clamp|env|attr|round|mod)\(/, `unmodelled function in ${where}: "${value}"`); @@ -579,7 +596,7 @@ test('the dock anchor changes at 720px — the 2vh cancellation is band-limited' assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh'); assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px'); assert.equal( - resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value, + inlineRootVars(resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value), 'calc(2vh + 5rem)', 'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard', ); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 75746b6e3..7328b1352 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -429,24 +429,48 @@ test('Cyber dock pins escape the tray mask and its decoration overrides the lega assert.match(decoration, /pointer-events:\s*none;/); }); -test('Cyber lower-left telemetry tracks the credit anchor with clearance for its logo row', () => { - const telemetry = cssRuleBody( - cyberStyles, - /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, - ); - const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/); - assert.ok(offset, 'telemetry must retain the attribution viewport anchor'); +test('lower-left telemetry tracks the credit anchor with clearance for its logo row', () => { const credits = read('./styles/command-dock-trays.css'); + const anchor = cssRuleBody(credits, /:root/); + assert.match(anchor, /--gev-credits-bottom:\s*calc\(2vh \+ 5rem\);/); + const reserve = anchor.match(/--gev-credits-reserve:\s*([\d.]+)rem;/); + assert.ok( + Number(reserve?.[1]) >= 2.75, + 'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size', + ); const creditOffsets = [ - ...credits.matchAll( - /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g, - ), + ...credits.matchAll(/#cesium-credits\s*\{[^}]*bottom:\s*([^;]+);/g), ]; assert.ok(creditOffsets.length > 0); for (const credit of creditOffsets) { - assert.ok( - Number(offset[1]) - Number(credit[1]) >= 2.75, - 'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size', + assert.equal(credit[1], 'var(--gev-credits-bottom)'); + } + const reserved = + /bottom:\s*calc\(var\(--gev-credits-bottom\) \+ var\(--gev-credits-reserve\)\);/; + assert.match( + cssRuleBody(cyberStyles, /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/), + reserved, + ); + assert.match( + cssRuleBody( + credits, + /#intel-hud\[data-variant='tactical'\] \.hud-bottom-left,\s*#intel-hud\[data-variant='minimal'\] \.hud-bottom-left/, + ), + reserved, + ); + // Cockpit's corner offset outranks the layouts' rules, so it reserves too. + assert.match( + read('./styles/cockpit.css'), + new RegExp( + `\\nbody\\.cockpit-mode #intel-hud \\.hud-bottom-left\\s*\\{[^}]*${reserved.source}`, + ), + ); + // One rule owns each layout's lower-left offset; import order must not decide it. + for (const file of ['overlays.css', 'command-dock.css', 'command-dock-compact.css']) { + assert.doesNotMatch( + read(`./styles/${file}`), + /\[data-variant='\w+'\] \.hud-bottom-left\s*\{[^}]*bottom:/, + file, ); } assert.doesNotMatch(cyberStyles, /\.hud-bottom-left\s*\{\s*bottom:\s*60px;/); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index d7ad845b6..72dfea4cd 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -2059,10 +2059,16 @@ body.cockpit-mode #intel-hud .hud-top-left, body.cockpit-mode #intel-hud .hud-top-right { top: clamp(104px, 13vh, 138px); } -body.cockpit-mode #intel-hud .hud-bottom-left, body.cockpit-mode #intel-hud .hud-bottom-right { bottom: clamp(84px, 11vh, 118px); } +/* The lower-left readout sits over the attribution row, which keeps its map + position in Cockpit. Scaling from its bottom edge keeps it on that reserve + and below the context window's rail. */ +body.cockpit-mode #intel-hud .hud-bottom-left { + bottom: calc(var(--gev-credits-bottom) + var(--gev-credits-reserve)); + transform-origin: left bottom; +} body.cockpit-mode #intel-hud .hud-summary-wrap { max-width: min(34vw, 380px); } diff --git a/src/ui/styles/command-dock-compact.css b/src/ui/styles/command-dock-compact.css index 146128d07..40b4aaf36 100644 --- a/src/ui/styles/command-dock-compact.css +++ b/src/ui/styles/command-dock-compact.css @@ -380,10 +380,6 @@ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { bottom: calc(2vh + 4.7rem); } -#intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); -} - @media (max-width: 900px) { #command-dock #location-bar .location-toolbar-label { overflow: hidden; diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index 65d7687b2..342ac5054 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -448,6 +448,14 @@ letter-spacing: 0.035rem; } +/* The attribution row's anchor above the command dock. HUD readouts over the + row sit the reserve above it: the 28px logo row, baseline descent and 12px + clearance at the 16px root size. */ +:root { + --gev-credits-bottom: calc(2vh + 5rem); + --gev-credits-reserve: 2.75rem; +} + /* Keep the complete inline attribution row above the command dock. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits, body:not(.ui-clean-view):not(.recording-mode):has( @@ -455,21 +463,20 @@ body:not(.ui-clean-view):not(.recording-mode):has( ) #cesium-credits { right: auto; - bottom: calc(2vh + 5rem); + bottom: var(--gev-credits-bottom); left: 1.5rem; width: auto !important; max-width: calc(100vw - 3rem); text-align: left; } -#intel-hud[data-variant='tactical'] .hud-bottom-left, #intel-hud[data-variant='tactical'] .hud-bottom-right { bottom: calc(2vh + 6.5rem); } -/* Clear the attribution row, whose logo stands about 1.75rem tall. */ +#intel-hud[data-variant='tactical'] .hud-bottom-left, #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 7.25rem); + bottom: calc(var(--gev-credits-bottom) + var(--gev-credits-reserve)); } @media (max-width: 900px) { @@ -478,7 +485,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='minimal'].active ) #cesium-credits { - bottom: calc(2vh + 5rem); + bottom: var(--gev-credits-bottom); left: 0.75rem; max-width: calc(100vw - 1.5rem); } diff --git a/src/ui/styles/command-dock.css b/src/ui/styles/command-dock.css index 928349dde..5f8c0bcb4 100644 --- a/src/ui/styles/command-dock.css +++ b/src/ui/styles/command-dock.css @@ -243,10 +243,6 @@ body.recording-mode #cesium-credits { left: 36px; } -#intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 7.5rem); -} - @media (max-width: 980px) { #command-dock { grid-template-areas: @@ -273,10 +269,6 @@ body.recording-mode #cesium-credits { body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { bottom: calc(2vh + 9.5rem); } - - #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 12rem); - } } @media (max-width: 720px) { diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 4aaf5b1c2..2401cbc28 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1605,9 +1605,9 @@ } /* Attribution's logo row extends above its text baseline. Reserve the whole - row plus a gap above the dock-relative 2vh + 5rem credit position. */ + row plus a gap above the dock-relative credit anchor. */ #intel-hud[data-variant='cyber'] .hud-bottom-left { - bottom: calc(2vh + 7.75rem); + bottom: calc(var(--gev-credits-bottom) + var(--gev-credits-reserve)); } #intel-hud[data-variant='cyber'] .hud-corner { diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index f0b6aace4..8379f6efc 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -306,10 +306,6 @@ top: 88px; } -#intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: 12vh; -} - #intel-hud[data-variant='minimal'] .hud-summary-wrap { max-width: 280px; }