diff --git a/src/scopeMask.js b/src/scopeMask.js index c43108377..770539ca8 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -89,6 +89,12 @@ let _container = null; let _viewer = null; let _enabled = true; let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT; +/** + * How strongly the whole mask shows, from the Display panel's Opacity slider: + * a fade over the painted mask, separate from the altitude terminus above, + * so the terminus band and its repaint gate stay as they are. + */ +let _maskOpacity = 1; let _resizeObserver = null; let _dprQuery = null; let _dprListener = null; @@ -433,6 +439,7 @@ export function installScopeMask(viewer) { _canvas = document.createElement('canvas'); _canvas.id = 'scope-mask'; _canvas.setAttribute('aria-hidden', 'true'); + _canvas.style.opacity = String(_maskOpacity); _container.appendChild(_canvas); _resizeObserver = new ResizeObserver(() => draw()); _resizeObserver.observe(_container); @@ -537,6 +544,20 @@ export function getScopeMaskFeather() { return _featherRatio; } +/** + * @param {number} ratio - How strongly the mask shows, 0 (clear) to 1. + * @returns {void} + */ +export function setScopeMaskOpacity(ratio) { + _maskOpacity = Math.max(0, Math.min(1, Number.isFinite(Number(ratio)) ? Number(ratio) : 1)); + if (_canvas) _canvas.style.opacity = String(_maskOpacity); +} + +/** @returns {number} */ +export function getScopeMaskOpacity() { + return _maskOpacity; +} + /** * Tear the mask down (canvas + observer). Reinstall with installScopeMask. * @returns {void} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js index 391293afe..e941faeee 100644 --- a/src/share/legacyShareState.js +++ b/src/share/legacyShareState.js @@ -43,6 +43,7 @@ export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ 'celestialRing', 'scopeEnabled', 'scopeFeatherPct', + 'scopeOpacityPct', 'scopeTerminusPct', 'mapStack', 'layerState', diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index fd8059799..a95255789 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -393,6 +393,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([ // Data Library entries switched on, as their two-character ledger tokens // concatenated (src/library/ledger.json). Absent means none. { number: 29, key: 'library', type: 'bytes', default: null }, + { number: 30, key: 'scopeOpacityPct', type: 'uint', default: 100 }, ]); const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; @@ -780,6 +781,7 @@ export function normalizeShareViewState(input = {}) { state.scopeFeatherPct = finite(source.scopeFeatherPct) ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) : d.scopeFeatherPct; + state.scopeOpacityPct = intOr(source.scopeOpacityPct, d.scopeOpacityPct, 0, 100); state.scopeTerminusPct = intOr( source.scopeTerminusPct, null, diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 42dd6b547..f19261a16 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -509,6 +509,7 @@ test('the field registry is well formed and permanent', () => { [27, 'styleParams', BY_STYLE], [28, 'worldTime', null], [29, 'library', null], + [30, 'scopeOpacityPct', 100], ], ); assert.deepEqual( diff --git a/src/sharelink.js b/src/sharelink.js index 6577afeae..3b873ef30 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -104,6 +104,9 @@ export class ShareLinkManager { this._celestialRingEnabled = false; this._scopeEnabled = true; this._scopeFeatherPct = 100; + // How strongly the scope mask shows, 0..100; 100 (the full mask) is the + // default and leaves `sco` out of the link. + this._scopeOpacityPct = 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; @@ -286,6 +289,7 @@ export class ShareLinkManager { // mirrors live state for the link this session generates, so it must match // the mask, not the archive.) scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)), + scopeOpacityPct: Math.max(0, Math.min(100, Math.round(parseOr(params.get('sco'), 100)))), // 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 @@ -459,6 +463,7 @@ export class ShareLinkManager { celestialRing: visualCurrent ? state.celestialRing : undefined, scopeEnabled: visualCurrent ? state.scopeEnabled : undefined, scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined, + scopeOpacityPct: visualCurrent ? state.scopeOpacityPct : undefined, scopeTerminusPct: visualCurrent ? state.scopeTerminusPct : undefined, mapStack: mapCurrent ? state.mapStack : undefined, panelState, @@ -625,6 +630,9 @@ export class ShareLinkManager { if (typeof extras.scopeFeatherPct === 'number') { this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct); } + if (typeof extras.scopeOpacityPct === 'number') { + this._scopeOpacityPct = Math.max(0, Math.min(100, Math.round(extras.scopeOpacityPct))); + } if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null; else if (typeof extras.scopeTerminusPct === 'number') { this._scopeTerminusPct = clampScopeTerminusPct(extras.scopeTerminusPct); @@ -740,6 +748,7 @@ export class ShareLinkManager { params.set('cr', this._celestialRingEnabled ? '1' : '0'); params.set('sc', this._scopeEnabled ? '1' : '0'); params.set('scf', Math.round(this._scopeFeatherPct).toString()); + if (this._scopeOpacityPct !== 100) params.set('sco', String(this._scopeOpacityPct)); // Only written when pinned — an absent `sce` IS the adaptive default, so a // shared link never freezes the ramp for the recipient by accident. The // same 94..100 clamp applies on the way OUT, so a link can never carry an diff --git a/src/ui/composition.js b/src/ui/composition.js index 3b95c1a8c..169e73e53 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -56,6 +56,8 @@ import { isScopeMaskEnabled, setScopeMaskFeather, getScopeMaskFeather, + setScopeMaskOpacity, + getScopeMaskOpacity, setScopeTerminusOverride, getScopeTerminusOverride, clampScopeTerminusPct, @@ -112,6 +114,8 @@ export class StyleManager extends ApplicationShell { isScopeMaskEnabled, setScopeMaskFeather, getScopeMaskFeather, + setScopeMaskOpacity, + getScopeMaskOpacity, setScopeTerminusOverride, getScopeTerminusOverride, clampScopeTerminusPct, diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 4fecad9e7..fdbe64284 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -282,7 +282,7 @@ test('scope choice and expanded feather survive every HUD layout and both restor let shown = null; owner.store = { publish: (view) => (shown = view.scope) }; await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 }); - assert.deepEqual(shown, { enabled: false, featherPct: 200 }); + assert.deepEqual(shown, { enabled: false, featherPct: 200, opacityPct: 100 }); for (const layout of ['minimal', 'cyber', 'tactical']) { owner._setHudVariant(layout, { applyVisualDefaults: true }); assert.equal(isScopeMaskEnabled(), false, layout); @@ -292,10 +292,10 @@ test('scope choice and expanded feather survive every HUD layout and both restor await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } }); assert.equal(isScopeMaskEnabled(), true); assert.equal(getScopeMaskFeather(), 1.75); - assert.deepEqual(shown, { enabled: true, featherPct: 175 }); + assert.deepEqual(shown, { enabled: true, featherPct: 175, opacityPct: 100 }); await owner.applyVisualState({ scope: { featherPct: 500 } }); assert.equal(getScopeMaskFeather(), 2); - assert.deepEqual(shown, { enabled: true, featherPct: 200 }); + assert.deepEqual(shown, { enabled: true, featherPct: 200, opacityPct: 100 }); }); test('a scene shot saved with the retired Snow style plays on Normal', async (t) => { diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js index 73c4fbee6..40e4a1edb 100644 --- a/src/ui/displayBindings.js +++ b/src/ui/displayBindings.js @@ -105,6 +105,11 @@ export class DisplayBindings { this._setScope({ featherPct: value }); this._syncShareState(); }, + setScopeOpacity: (value) => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this._setScope({ opacityPct: Number(value) }); + this._syncShareState(); + }, setSharpenIntensity: (pct) => { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setSharpenIntensity(pct); @@ -168,6 +173,20 @@ export class DisplayBindings { this._syncModels3dModeRow(); }, setModelsMode: (mode) => this._setModels3dMode(mode), + // Back to the out-of-the-box look: Normal style, the global post + // defaults (bloom, sharpen, HUD, labels, celestial), scope on at full + // feather and opacity, 3D aircraft on in Proximity. + resetVisual: () => { + this.shareLinkManager?.claimRestoreLane?.('visual'); + this.setStyle('normal', { userInitiated: true }); + this._applyGlobalPostDefaults(); + this._setDetectionAllocation('ELASTIC'); + this._setScope({ enabled: true, featherPct: 100, opacityPct: 100 }); + this._setModels3dEnabled(true); + this._setModels3dMode('proximity'); + this._syncModels3dModeRow?.(); + this._syncShareState(); + }, }); this._detachDisplayControls = () => store.detach(token); } diff --git a/src/ui/styles/scope-off-shadows.css b/src/ui/styles/scope-off-shadows.css new file mode 100644 index 000000000..779c8f70d --- /dev/null +++ b/src/ui/styles/scope-off-shadows.css @@ -0,0 +1,24 @@ +/* With Scope off the map fills the whole screen. The HUD keeps its black + plates (overlays.css); the glass chrome lifts off the map with a soft + drop shadow. */ +:root[data-scope-mask='off'] { + --scope-off-panel-shadow: + 0 10px 30px rgba(0, 0, 0, 0.55), + 0 2px 8px rgba(0, 0, 0, 0.45); +} + +/* Glass surfaces (rails, the rails' name panels, open panels and + the dock) get a lift so their edges hold against bright imagery. */ +:root[data-scope-mask='off'] .ui-rail, +:root[data-scope-mask='off'] .gev-panel-rail .ui-rail--expanded::after, +:root[data-scope-mask='off'] :is(.data-panel-inner, .utilities-panel-inner, .ui-panel__inner), +:root[data-scope-mask='off'] #left-panel-stack > #pp-toggles, +:root[data-scope-mask='off'] #right-context-rail > [data-panel-id]:not(.collapsed), +:root[data-scope-mask='off'] #command-dock > * { + box-shadow: var(--scope-off-panel-shadow); +} + +:root[data-scope-mask='off'] .ui-rail__label, +:root[data-scope-mask='off'] .ui-rail__item .ui-icon { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} diff --git a/src/ui/styles/visual-panel.css b/src/ui/styles/visual-panel.css new file mode 100644 index 000000000..4511d0289 --- /dev/null +++ b/src/ui/styles/visual-panel.css @@ -0,0 +1,286 @@ +/* ── Visual panel: sections and one card style for every control ─────── + Look · Map source · Overlay (HUD, detection, 3D, Scope, Clean UI) · + Effects (Celestial, Bloom, Sharpen) · Reset. Each control is a card: a + header row with its icon, name and an on/off pill, and its settings + underneath. */ + +/* ── Presets and map source at the top of the panel ─────────────────── */ +.visual-presets { + display: grid; + gap: 8px; + padding: 2px 0 12px; + margin-bottom: 10px; + border-bottom: 1px solid var(--glass-border); +} + +.visual-presets__heading { + margin: 4px 0 0; + color: var(--text-secondary); + font: 600 10px/1.2 var(--font-mono); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.visual-presets__heading--row { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.visual-presets__status { + color: var(--accent); +} + +.visual-presets__status.warn { + color: #ff9f43; +} + +.visual-presets__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.visual-presets__style { + position: relative; + display: grid; + justify-items: center; + gap: 4px; + padding: 9px 4px 7px; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + border-radius: var(--btn-radius); + cursor: pointer; +} + +.visual-presets__style:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +.visual-presets__style.active { + color: var(--accent); + background: var(--accent-dim); + border-color: var(--accent-dim); +} + +.visual-presets__icon { + font-size: 15px; + line-height: 1; +} + +.visual-presets__label { + font: 500 11px/1 var(--font-sans); +} + +.visual-presets__key { + position: absolute; + top: 4px; + inset-inline-end: 5px; + color: var(--text-secondary); + font: 9px/1 var(--font-mono); + opacity: 0.6; +} + +.visual-presets__chips { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.visual-presets__chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 9px; + color: var(--text-secondary); + font: 500 11px/1.2 var(--font-sans); + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + border-radius: 999px; + cursor: pointer; +} + +.visual-presets__chip small { + font-size: 9px; + opacity: 0.6; +} + +.visual-presets__chip:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +.visual-presets__chip.active { + color: var(--accent); + background: var(--accent-dim); + border-color: var(--accent-dim); +} + +.visual-presets__chip.unavailable { + opacity: 0.45; + cursor: default; +} + +/* The body is display: contents, so its children are the panel's own flex + items: keep them full width and unshrunk. */ +#pp-toggles .pp-panel-body > * { + flex: none !important; + align-self: stretch !important; + box-sizing: border-box; +} + +#pp-toggles .pp-panel-body > .pp-toggle-group, +#pp-toggles .pp-panel-body > .pp-toggle-btn { + margin-bottom: 0.4rem !important; +} + +#pp-toggles .pp-section-heading, +#pp-toggles .visual-presets__heading { + margin: 0.7rem 2px 0.35rem; + text-align: start; + color: rgba(199, 214, 222, 0.55); + font-family: var(--font-mono); + font-size: 0.56rem; + font-weight: 500; + letter-spacing: 0.14rem; + text-transform: uppercase; +} + +#pp-toggles .visual-presets__heading:first-child { + margin-top: 0.2rem; +} + +#pp-toggles .pp-toggle-group { + width: 100%; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; +} + +/* The header row of a card, and the lone Clean UI button. */ +#pp-toggles .pp-toggle-group > .pp-toggle-btn, +#pp-toggles .pp-panel-body > .pp-toggle-btn { + position: relative; + box-sizing: border-box; + width: 100%; + min-height: 38px; + padding: 0 12px; + gap: 10px; + background: transparent !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +#pp-toggles .pp-panel-body > .pp-toggle-btn { + background: rgba(255, 255, 255, 0.03) !important; + border: 1px solid rgba(255, 255, 255, 0.07) !important; + border-radius: 10px !important; +} + +#pp-toggles .pp-toggle-btn .pp-icon { + width: 18px; + font-size: 15px; + text-align: center; + color: rgba(199, 214, 222, 0.75); +} + +#pp-toggles .pp-toggle-btn .pp-label { + color: rgba(236, 244, 248, 0.9); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.12rem; + text-transform: uppercase; +} + +/* An on/off pill at the right of every button header (Scope keeps its + checkbox, which already says it). */ +#pp-toggles .pp-toggle-group > button.pp-toggle-btn::after, +#pp-toggles .pp-panel-body > button.pp-toggle-btn::after { + content: 'OFF'; + margin-inline-start: auto; + padding: 2px 8px; + color: rgba(199, 214, 222, 0.5); + font-family: var(--font-mono); + font-size: 0.5rem; + letter-spacing: 0.12rem; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; +} + +#pp-toggles .pp-toggle-group > button.pp-toggle-btn.active::after, +#pp-toggles .pp-toggle-group > button.pp-toggle-btn[aria-pressed='true']::after, +#pp-toggles .pp-panel-body > button.pp-toggle-btn.active::after { + content: 'ON'; + color: #7af0ff; + border-color: rgba(54, 220, 255, 0.5); + background: rgba(54, 220, 255, 0.1); +} + +#pp-toggles .pp-toggle-btn.active .pp-icon, +#pp-toggles .pp-toggle-btn[aria-pressed='true'] .pp-icon, +#pp-toggles .pp-toggle-btn.active .pp-label, +#pp-toggles .pp-toggle-btn[aria-pressed='true'] .pp-label { + color: #7af0ff; +} + +#pp-toggles .pp-toggle-group > .pp-toggle-btn:hover, +#pp-toggles .pp-panel-body > .pp-toggle-btn:hover { + background: rgba(255, 255, 255, 0.04) !important; +} + +#pp-toggles .pp-scope-toggle input[type='checkbox'] { + margin-inline-start: auto; +} + +/* Settings inside a card: separated by hairlines, no boxes of their own. */ +#pp-toggles .pp-toggle-group .pp-slider-row.visible, +#pp-toggles .pp-toggle-group .pp-slider-row:not([class*='cyber-sonar']):is(.visible, #scope-slider-row, #scope-opacity-row) { + margin: 0; + padding: 7px 12px; + background: transparent; + border: 0; + border-top: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 0; +} + +/* The Dense button lights green elsewhere; in a card it uses the shared pill. */ +#pp-toggles .pp-toggle-group > .pp-toggle-btn.panoptic, +#pp-toggles .pp-toggle-group > .pp-toggle-btn.active { + color: inherit; +} + +/* Reset to default, at the foot of the Visual panel. */ +#pp-toggles .pp-reset-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + margin: 0.6rem 0 0.2rem !important; + padding: 0.55rem; + color: rgba(225, 236, 242, 0.85); + font-family: var(--font-mono); + font-size: 0.6rem; + letter-spacing: 0.12rem; + text-transform: uppercase; + background: transparent; + border: 1px dashed rgba(255, 255, 255, 0.18); + border-radius: 10px; + cursor: pointer; +} + +#pp-toggles .pp-reset-btn .material-symbols-outlined { + font-size: 15px; +} + +#pp-toggles .pp-reset-btn:hover, +#pp-toggles .pp-reset-btn:focus-visible { + color: #fff; + border-color: rgba(54, 220, 255, 0.5); + background: rgba(54, 220, 255, 0.06); +} diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 3cd202161..695c3bc25 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -5,12 +5,14 @@ import { t } from '../../../i18n/index.js'; import { vPanelState } from '../../rail/panelState.js'; import { vClassToggle } from '../../rail/classToggle.js'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; +import UiPanelEdge from '../../kit/UiPanelEdge.vue'; import UiSegmented from '../../kit/UiSegmented.vue'; import UiSlider from '../../kit/UiSlider.js'; import { useDisplayStore } from '../../stores/display.js'; import UiButton from '../../kit/UiButton.vue'; import UiSelect from '../../kit/UiSelect.js'; import { StyleParameterRows } from '../../styleParameters.js'; +import VisualPresets from './VisualPresets.vue'; // The Display panel. VisualSettings presents the HUD, Sonar, Scope, // Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group, @@ -29,6 +31,7 @@ const detectButton = computed(() => detection.value?.button ?? null); const allocation = computed(() => detection.value?.allocation ?? 'ELASTIC'); const scopeOn = computed(() => view.value?.scope.enabled ?? true); const scopeFeather = computed(() => view.value?.scope.featherPct ?? 100); +const scopeOpacity = computed(() => view.value?.scope.opacityPct ?? 100); const celestial = computed(() => view.value?.celestial ?? null); const celestialTitle = computed(() => celestial.value && !celestial.value.supported @@ -51,12 +54,15 @@ const allocationOptions = computed(() => [ + diff --git a/src/ui/views/panels/VisualPresets.vue b/src/ui/views/panels/VisualPresets.vue new file mode 100644 index 000000000..2905aae2b --- /dev/null +++ b/src/ui/views/panels/VisualPresets.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 736e4a49e..236d34816 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -66,6 +66,7 @@ export class VisualSettings { celestialRing, scopeEnabled, scopeFeatherPct, + scopeOpacityPct, scopeTerminusPct, mapStack, panelState, @@ -133,7 +134,11 @@ export class VisualSettings { focus: false, }); } - this._setScope({ enabled: scopeEnabled, featherPct: scopeFeatherPct }); + this._setScope({ + enabled: scopeEnabled, + featherPct: scopeFeatherPct, + opacityPct: typeof scopeOpacityPct === 'number' ? scopeOpacityPct : undefined, + }); // null restores the altitude-adaptive ramp; a number pins the terminus // (clamped to the supported 94..100 band, same as the `sce` hash key). if (scopeTerminusPct === null) setScopeTerminusOverride(null); @@ -479,6 +484,7 @@ export class VisualSettings { scope: { enabled: this._scopeEnabledShown, featherPct: this._scopeFeatherShown, + opacityPct: this._scopeOpacityShown ?? 100, }, celestial: this._celestialShown, }; @@ -488,7 +494,12 @@ export class VisualSettings { * Apply the Scope mask and show it on its controls; a field that is not * given stays as it is. `apply: false` only shows the mask's state. */ - _setScope({ enabled, featherPct } = {}, { apply = true } = {}) { + _setScope({ enabled, featherPct, opacityPct } = {}, { apply = true } = {}) { + if (typeof opacityPct === 'number') { + const pct = Math.max(0, Math.min(100, Math.round(opacityPct))); + if (apply) this.services.setScopeMaskOpacity?.(pct / 100); + this._scopeOpacityShown = pct; + } if (typeof enabled === 'boolean') { if (apply) this.services.setScopeMaskEnabled(enabled); this._scopeEnabledShown = enabled; @@ -903,6 +914,7 @@ export class VisualSettings { celestialRingEnabled: this.celestialRingEnabled, scopeEnabled: isScopeMaskEnabled(), scopeFeatherPct: Math.round(getScopeMaskFeather() * 100), + scopeOpacityPct: this._scopeOpacityShown ?? 100, // null when adaptive — the share layer omits `sce` entirely in that case. scopeTerminusPct: getScopeTerminusOverride() == null