diff --git a/changelog.d/1738-visual-panel.md b/changelog.d/1738-visual-panel.md new file mode 100644 index 000000000..748591426 --- /dev/null +++ b/changelog.d/1738-visual-panel.md @@ -0,0 +1,4 @@ +- Group the Visual panel into Look, Map source, Overlay and Effects with a + Reset at its foot, and add a Scope opacity slider (50–100%) that share links + carry when it is below full; Reset keeps the HUD layout, and the HUD gets + shadows when Scope is off (manjunath22466, samehkhamis, #1738). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9d1b5abda..83529861b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -646,6 +646,53 @@ panel that holds its row, and `set_panel_open` reaches it as `utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone it sits collapsed in the Layers sheet under Data Layers. +## The Visual panel — November 14, 2026 + +Visual (`#pp-toggles`, `src/ui/views/panels/DisplayPanel.vue`) is headed +VISUAL, has the edge switch in its header like the other side panels, and is +grouped from the top: Look, the six style presets with their 1–6 keys, and +Map source, the map source chips (`VisualPresets.vue`); Overlay, the HUD, +detection, style parameters, 3D aircraft, Scope and Clean UI; Effects, +Celestial, Bloom and Sharpen; and Reset to default at the foot. Each control +is a card (`src/ui/styles/visual-panel.css`) whose header row ends in an +on/off pill worded in the current language (Clean UI, an action, has none; +the buttons carry their state as `aria-pressed`). Under the Cyber HUD the +cards keep Cyber's own red skin. The presets and chips call the +same Display store actions as the dock's Visual Presets tray, which stays on +desktop and phone as before, along with Location; voice `Visual Presets` +still opens that tray. On a phone the More sheet shows that tray right above +Visual, so Visual leaves out its own presets and chips there. In Cockpit the +groups Cockpit's Display popover takes +leave the panel as before and keep their own styling there. + +Scope has an Opacity slider (`#scope-opacity-slider`, 50–100%, default 100%; +`SCOPE_OPACITY_MIN_PCT` in `src/ui/visualSettings.js`, since a fainter mask +reads as Scope off while labels still fade outside the keyhole) +that fades the whole mask (`setScopeMaskOpacity` in `src/scopeMask.js`, the +canvas's CSS opacity) without repainting it or changing the altitude +terminus; it is disabled while Scope is off and keeps its value. Share links +carry it as `sco` in the long form and field 30 in the compact form, written +only below 100%, so links made before it keep their codes and open at full +opacity, and a link without it sets the opacity back to 100%; a link asking +for less than 50% opens at 50%. Scenes do not +save it: a scene shot leaves the opacity as it is. + +Reset to default (`resetVisual` in `src/ui/displayBindings.js`) brings back +the out-of-the-box look: the Normal style, the first-run post defaults +(bloom off, sharpen on at 49%, the HUD shown, Dense detection at 75% +with its fade and outside opacity, the celestial ring off), Elastic +allocation, Scope on at 100% feather and 100% opacity, and 3D aircraft on in +Proximity. The HUD layout follows the Normal style, as choosing it does: the +Tactical HUD, except that the Cyber HUD stays Cyber. Inside Contacts, which +lends detection for the session, the reset's detection becomes what leaving +Contacts puts back and what a share link carries. It leaves the map source, the pinned scope terminus, Sonar's +settings, other styles' parameters, the layers and the panels alone. + +With Scope off the side rails, open panels and the dock's trays carry a soft +drop shadow and the rails' labels and icons a text shadow +(`src/ui/styles/scope-off-shadows.css`), so their edges hold against bright +imagery. + ## Side rails and the screen edge — November 14, 2026 Visual (`#pp-toggles`, the display settings, rail item `palette`) is the last @@ -768,6 +815,8 @@ Display's native Scope checkbox defaults ON with feather at 100%, adjustable from 0–200%. OFF disables the slider while retaining its value. HUD layout changes preserve both settings; share links and scenes restore them. The historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge. +Its Opacity slider (0–100%, default 100%) fades the mask; share links +restore it (`sco`), scenes do not (see The Visual panel). With Scope OFF, Tactical and Minimal add text-sized black backplates at 70% opacity behind telemetry, vertical metadata and scene chrome. Original diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 4c3254a99..3b76eb04e 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -148,6 +148,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | | 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | | 29 | `library` | LEN | none | Data Library ledger tokens, two base-36 characters each, concatenated | +| 30 | `scopeOpacityPct` | uint | 100 | 0–100, how strongly the scope mask shows | Field 1 absent means `thing` when field 13 is present and `place` otherwise, so v1 never writes it; a later view kind writes its code. @@ -170,6 +171,10 @@ tokens in `src/library/ledger.json` (the long form's `lib=ab.cd`). A share without it switches no library entry on; the recipient's added entries stay in their panel, off. Tokens this build does not know are skipped. +Field 30 is how strongly the scope mask shows, from the Visual panel's +Opacity slider (the long form's `sco`). It is written only below 100, so a +link made before it, which opens at full opacity, keeps its code. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/index.html b/index.html index 806a8b41b..24bc148b7 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4136ddb8a..49f71deb8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1459,5 +1459,7 @@ "src/ui/styles/left-tools.css", "src/ui/styles/panel-edge.css", "src/data/placementProvider.test.mjs", - "src/layers/vessels/placement.test.mjs" + "src/layers/vessels/placement.test.mjs", + "src/ui/styles/visual-panel.css", + "src/ui/styles/scope-off-shadows.css" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4aa4d4a7c..a48c717ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -102,6 +102,7 @@ "src/ui/stores/streetLevel.js", "src/ui/rail/RailChips.vue", "src/ui/views/panels/DisplayPanel.vue", + "src/ui/views/panels/VisualPresets.vue", "src/ui/views/panels/GeoImport.vue", "src/ui/views/panels/DrawControls.vue", "src/ui/views/panels/DrawMarkList.vue", diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 06db3aeff..3f461c392 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -530,12 +530,12 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', ); }); -test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => { +test('Visual orders Parameters below Detection, 3D below them, and Clean UI above the Effects', () => { const display = html.match(/id="pp-toggles"[\s\S]*?id="clean-view-exit"/); assert.ok(display, 'Display markup is missing'); assert.match( display[0], - /id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"[\s\S]*?id="celestial-toggle"[\s\S]*?id="clean-view-toggle"/, + /id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"[\s\S]*?id="scope-toggle"[\s\S]*?id="clean-view-toggle"[\s\S]*?class="pp-section-heading">Effects<[\s\S]*?id="celestial-toggle"/, ); assert.equal( (html.match(/id="param-slider-panel"/g) || []).length, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index e882d6f29..894076638 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3969,5 +3969,13 @@ "Funicular": "قطار جبلي مائل", "Gondola": "تلفريك", "Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط", - "Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة" + "Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة", + "panel|VISUAL": "المرئيات", + "Look": "المظهر", + "Overlay": "التراكب", + "Effects": "المؤثرات", + "Scope opacity": "عتامة المنظار", + "How dark the scope shades the view outside the keyhole": "مدى تعتيم المنظار للعرض خارج الفتحة", + "Reset to default": "إعادة التعيين إلى الافتراضي", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "إعادة المرئيات إلى إعداداتها الافتراضية: المظهر العادي وHUD والمنظار والتسميات والمؤثرات" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 4dbecb4d1..408ddbc3a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3621,5 +3621,13 @@ "Funicular": "Standseilbahn", "Gondola": "Gondelbahn", "Move panels back beside the rail": "Bedienfelder zurück neben die Leiste", - "Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben" + "Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben", + "panel|VISUAL": "DARSTELLUNG", + "Look": "Look", + "Overlay": "Overlay", + "Effects": "Effekte", + "Scope opacity": "Scope-Deckkraft", + "How dark the scope shades the view outside the keyhole": "Wie stark der Scope die Ansicht außerhalb des Schlüssellochs abdunkelt", + "Reset to default": "Auf Standard zurücksetzen", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Darstellung auf die Standardwerte zurücksetzen: normaler Look, HUD, Scope, Beschriftungen und Effekte" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f63dae030..a7d626a28 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3708,5 +3708,13 @@ "Funicular": "Funicular", "Gondola": "Góndola", "Move panels back beside the rail": "Devolver los paneles junto a la barra", - "Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde" + "Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde", + "panel|VISUAL": "VISUAL", + "Look": "Aspecto", + "Overlay": "Superposición", + "Effects": "Efectos", + "Scope opacity": "Opacidad de la mirilla", + "How dark the scope shades the view outside the keyhole": "Cuánto oscurece la mirilla la vista fuera del ojo de cerradura", + "Reset to default": "Restablecer valores predeterminados", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Restablecer Visual a sus valores predeterminados: aspecto Normal, HUD, mirilla, etiquetas y efectos" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8f1996cde..d580354a3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3708,5 +3708,13 @@ "Funicular": "Funiculaire", "Gondola": "Télécabine", "Move panels back beside the rail": "Remettre les panneaux à côté de la barre", - "Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord" + "Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord", + "panel|VISUAL": "VISUEL", + "Look": "Aspect", + "Overlay": "Surcouche", + "Effects": "Effets", + "Scope opacity": "Opacité de la lunette", + "How dark the scope shades the view outside the keyhole": "À quel point la lunette assombrit la vue hors de l'ouverture", + "Reset to default": "Rétablir les valeurs par défaut", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Rétablir les valeurs par défaut de Visuel : aspect Normal, HUD, lunette, libellés et effets" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index c848be0bd..661cf2fef 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3708,5 +3708,13 @@ "Funicular": "פוניקולר", "Gondola": "רכבל", "Move panels back beside the rail": "החזרת הלוחות לצד הסרגל", - "Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה" + "Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה", + "panel|VISUAL": "חזותי", + "Look": "מראה", + "Overlay": "שכבת על", + "Effects": "אפקטים", + "Scope opacity": "אטימות הסקופ", + "How dark the scope shades the view outside the keyhole": "עד כמה הסקופ מכהה את התצוגה מחוץ לחור המנעול", + "Reset to default": "איפוס לברירת המחדל", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "איפוס החזותי לברירות המחדל: מראה רגיל, HUD, סקופ, תוויות ואפקטים" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d93a963a5..cbd65beb7 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3708,5 +3708,13 @@ "Funicular": "Funicolare", "Gondola": "Cabinovia", "Move panels back beside the rail": "Riporta i pannelli accanto alla barra", - "Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo" + "Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo", + "panel|VISUAL": "VISUALE", + "Look": "Aspetto", + "Overlay": "Overlay", + "Effects": "Effetti", + "Scope opacity": "Opacità dello scope", + "How dark the scope shades the view outside the keyhole": "Quanto lo scope scurisce la vista fuori dal foro", + "Reset to default": "Ripristina predefiniti", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Ripristina Visuale ai valori predefiniti: aspetto Normale, HUD, scope, etichette ed effetti" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3fb7ad023..24fa10f79 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3534,5 +3534,13 @@ "Funicular": "ケーブルカー", "Gondola": "ゴンドラ", "Move panels back beside the rail": "パネルをレールの横に戻す", - "Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動" + "Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動", + "panel|VISUAL": "ビジュアル", + "Look": "見た目", + "Overlay": "オーバーレイ", + "Effects": "エフェクト", + "Scope opacity": "スコープの不透明度", + "How dark the scope shades the view outside the keyhole": "キーホール外のビューをスコープがどれだけ暗くするか", + "Reset to default": "既定に戻す", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "ビジュアルを既定に戻す:標準の見た目、HUD、スコープ、ラベル、エフェクト" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a42bf0448..86b9f3aca 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3708,5 +3708,13 @@ "Funicular": "Funicular", "Gondola": "Teleférico", "Move panels back beside the rail": "Voltar os painéis para junto da barra", - "Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda" + "Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda", + "panel|VISUAL": "VISUAL", + "Look": "Aparência", + "Overlay": "Sobreposição", + "Effects": "Efeitos", + "Scope opacity": "Opacidade da mira", + "How dark the scope shades the view outside the keyhole": "Quanto a mira escurece a vista fora do visor", + "Reset to default": "Redefinir para o padrão", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Redefinir o Visual para o padrão: aparência Normal, HUD, mira, rótulos e efeitos" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 96efc6e5e..ac149c01f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3795,5 +3795,13 @@ "Funicular": "Фуникулёр", "Gondola": "Канатная дорога", "Move panels back beside the rail": "Вернуть панели к боковой панели", - "Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю" + "Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю", + "panel|VISUAL": "ВИЗУАЛ", + "Look": "Вид", + "Overlay": "Наложение", + "Effects": "Эффекты", + "Scope opacity": "Непрозрачность прицела", + "How dark the scope shades the view outside the keyhole": "Насколько прицел затемняет вид за пределами маски", + "Reset to default": "Сбросить по умолчанию", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Сбросить «Визуал» к настройкам по умолчанию: обычный вид, HUD, прицел, подписи и эффекты" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 63304ba65..d6acc93a0 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3621,5 +3621,13 @@ "Funicular": "Bergbana", "Gondola": "Kabinbana", "Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen", - "Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten" + "Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten", + "panel|VISUAL": "VISUELLT", + "Look": "Utseende", + "Overlay": "Överlägg", + "Effects": "Effekter", + "Scope opacity": "Scope-opacitet", + "How dark the scope shades the view outside the keyhole": "Hur mycket scopet mörkar vyn utanför nyckelhålet", + "Reset to default": "Återställ till standard", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Återställ Visuellt till standard: Normal stil, HUD, scope, etiketter och effekter" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 47db81c3e..6f49092f4 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3795,5 +3795,13 @@ "Funicular": "Фунікулер", "Gondola": "Канатна дорога", "Move panels back beside the rail": "Повернути панелі до бічної смуги", - "Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю" + "Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю", + "panel|VISUAL": "ВІЗУАЛ", + "Look": "Вигляд", + "Overlay": "Накладка", + "Effects": "Ефекти", + "Scope opacity": "Непрозорість прицілу", + "How dark the scope shades the view outside the keyhole": "Наскільки приціл затемнює вигляд за межами прицілу", + "Reset to default": "Скинути до типових", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Скинути «Візуал» до типових налаштувань: звичайний вигляд, HUD, приціл, підписи та ефекти" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 511b2f4ae..56a5aa354 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3534,5 +3534,13 @@ "Funicular": "缆索铁路", "Gondola": "缆车", "Move panels back beside the rail": "将面板移回侧栏旁", - "Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘" + "Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘", + "panel|VISUAL": "视觉", + "Look": "外观", + "Overlay": "叠加层", + "Effects": "效果", + "Scope opacity": "视域不透明度", + "How dark the scope shades the view outside the keyhole": "视域遮罩使视域之外的画面变暗的程度", + "Reset to default": "恢复默认", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "将视觉恢复为默认设置:常规外观、HUD、视域、标签和效果" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 385b18064..6f177a2cf 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3534,5 +3534,13 @@ "Funicular": "纜索鐵路", "Gondola": "纜車", "Move panels back beside the rail": "將面板移回側欄旁", - "Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣" + "Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣", + "panel|VISUAL": "視覺", + "Look": "外觀", + "Overlay": "疊加層", + "Effects": "效果", + "Scope opacity": "視域不透明度", + "How dark the scope shades the view outside the keyhole": "視域遮罩使視域之外的畫面變暗的程度", + "Reset to default": "還原預設", + "Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "將視覺還原為預設設定:常規外觀、HUD、視域、標籤和效果" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index e95d5afc4..b8f50bfe7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -113,6 +113,7 @@ "Layout", "Live", "London", + "Look", "Los Angeles", "MIL", "MINI", @@ -140,6 +141,7 @@ "Oldies", "OpenStreetMap + optional Google Maps Places", "Operator", + "Overlay", "PAUSE", "PLAY", "POS: LIVE", @@ -383,6 +385,7 @@ "mph", "nominal", "panel", + "panel|VISUAL", "view band|GLOBAL", "voice|ERROR", "{hours} h", @@ -732,6 +735,7 @@ "ON", "ON/OFF", "Oldies", + "Overlay", "PAYLOAD", "PLAY", "POS: LIVE", @@ -1047,6 +1051,7 @@ "mini", "mph", "nominal", + "panel|VISUAL", "via {source}", "view band|GLOBAL", "{hours} h", diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 5f053e52a..aa7cd8fd2 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -286,7 +286,7 @@ test('expanded right panels highlight the title divider without changing collaps assert.match( html, - /class="compact pp-header-row"[\s\S]*?class="pp-header-label">DISPLAY<\/span>[\s\S]*?class="panel-divider"/, + /class="compact pp-header-row"[\s\S]*?class="pp-header-label">VISUAL<\/span>[\s\S]*?class="panel-divider"/, ); assert.match( css, diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 9f90ba1be..bc9f125fd 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -253,7 +253,7 @@ test('detection-on-by-default is a default, not an operator override', () => { // A factory default is not that. If applying the baseline set the flag, a // fresh session would silently lose the style auto-enable behaviour — a // separate landed feature, taken out by an unrelated change. - const applyDefaults = uiBlock(' _applyGlobalPostDefaults() {', '\n }\n'); + const applyDefaults = uiBlock(' _applyGlobalPostDefaults({ keepHudVariant = false } = {}) {', '\n }\n'); assert.match(applyDefaults, /this\._setDetectionMode\(defaults\.detectionMode\)/, 'the baseline still goes through the real detection path'); assert.doesNotMatch(applyDefaults, /_detectionUserOverridden/, diff --git a/src/scopeMask.js b/src/scopeMask.js index c43108377..b7b60c711 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,23 @@ 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} @@ -567,6 +591,7 @@ export function _resetScopeMaskForTest() { _viewer = null; _enabled = true; _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT; + _maskOpacity = 1; _terminusAlpha = SCOPE_OUTSIDE_ALPHA; _terminusOverride = null; _terminusRepaints = 0; diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index d37b6b23b..ef612493b 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -19,6 +19,8 @@ import { SCOPE_TERMINUS_MAX_PCT, SCOPE_FEATHER_RATIO_DEFAULT, getScopeMaskFeather, + setScopeMaskOpacity, + getScopeMaskOpacity, SCOPE_OUTSIDE_ALPHA, SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_FAR_M, @@ -205,6 +207,37 @@ test('scope appearance follows direct state changes and install/destroy lifecycl } }); +test('scope opacity fades the painted mask without repainting it', () => { + const dom = stubScopeMaskDom(); + try { + assert.equal(getScopeMaskOpacity(), 1, 'full by default'); + setScopeMaskOpacity(0.4); + installScopeMask({ container: dom.container }); + assert.equal(dom.canvas.style.opacity, '0.4', 'a choice made before install applies'); + const paints = { ...dom.ops }; + const terminus = getScopeTerminusAlpha(); + setScopeMaskOpacity(0.25); + assert.equal(dom.canvas.style.opacity, '0.25'); + assert.deepEqual(dom.ops, paints, 'the mask is not repainted'); + assert.equal(getScopeTerminusAlpha(), terminus, 'the terminus is its own setting'); + setScopeMaskOpacity(3); + assert.equal(getScopeMaskOpacity(), 1); + setScopeMaskOpacity(-1); + assert.equal(getScopeMaskOpacity(), 0); + setScopeMaskOpacity('soft'); + assert.equal(getScopeMaskOpacity(), 1, 'not a number is full'); + setScopeMaskOpacity(0.5); + destroyScopeMask(); + installScopeMask({ container: dom.container }); + assert.equal(dom.canvas.style.opacity, '0.5', 'reinstall keeps it'); + _resetScopeMaskForTest(); + assert.equal(getScopeMaskOpacity(), 1, 'test reset restores full'); + } finally { + destroyScopeMask(); + dom.restore(); + } +}); + test('a DPR change with no resize still repaints the backing store', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { 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..1b444d2b9 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,12 @@ 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..7b876269a 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -119,6 +119,7 @@ function randomState(random) { detectionFadePct: maybe(0.5, int(0, 40)), detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeOpacityPct: maybe(0.5, int(0, 100)), scopeTerminusPct: maybe( 0.3, int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), @@ -210,6 +211,7 @@ test('each setting costs bytes only when it differs from its default', () => { detectionFadePct: 8, detectionOutsideOpacityPct: 2, scopeFeatherPct: 99, + scopeOpacityPct: 40, scopeTerminusPct: 95, }; const defaults = createDefaultShareViewState(); @@ -355,6 +357,7 @@ test('every registered field round-trips at once', () => { celestialRing: true, scopeEnabled: false, scopeFeatherPct: 35, + scopeOpacityPct: 60, scopeTerminusPct: 97, mapStack: 'esri-imagery', layerState, @@ -509,6 +512,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( @@ -1586,3 +1590,50 @@ test('Data Library tokens round-trip and malformed ones are skipped', () => { assert.equal(normalizeLibraryTokens(null), null); assert.deepEqual(normalizeLibraryTokens('x'.repeat(500)), []); }); + +test('scope opacity is field 30; a link made before it opens at full opacity', () => { + // Made before field 30 existed: thermal, Cyber, Scope off at 35% feather + // and a pinned terminus. + const before = 'ARD-7j4YuxMguBcoUEgQcAOAAQPIASPQAWGa'; + const old = decodeOk(before).state; + assert.equal(old.scopeOpacityPct, 100); + assert.equal(old.scopeFeatherPct, 35); + assert.equal(encodeShareView(old), before, 'it still encodes to itself'); + const state = { + lat: 51.5007, + lon: -0.1246, + alt: 1500, + heading: 40, + style: 'thermal', + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + hudVariant: 'cyber', + }; + assert.equal(encodeShareView(state), before); + assert.equal(encodeShareView({ ...state, scopeOpacityPct: 100 }), before); + const faded = assertRoundTrip({ ...state, scopeOpacityPct: 45 }); + assert.notEqual(faded, before); + assert.equal(decodeOk(faded).state.scopeOpacityPct, 45); + assert.equal( + decodeOk(assertRoundTrip({ scopeOpacityPct: 0 })).state.scopeOpacityPct, + 0, + ); + // Out of range is clamped; anything not a number is the default. + assert.equal( + normalizeShareViewState({ scopeOpacityPct: 150 }).scopeOpacityPct, + 100, + ); + assert.equal( + normalizeShareViewState({ scopeOpacityPct: -5 }).scopeOpacityPct, + 0, + ); + assert.equal( + normalizeShareViewState({ scopeOpacityPct: 33.6 }).scopeOpacityPct, + 34, + ); + assert.equal( + normalizeShareViewState({ scopeOpacityPct: 'x' }).scopeOpacityPct, + 100, + ); +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 0e77e8303..a7326d12b 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -121,6 +121,29 @@ test('scope feather above 100% survives share parsing and serialization', () => clearTimeout(manager._debounceTimer); }); +test('scope opacity rides as sco only below full, and through the compact link', () => { + // Links without `sco`, every link made before it, open at full opacity. + assert.equal(makeManager('#v=2&lat=10&lon=20&scf=35').parseInitialHash().scopeOpacityPct, 100); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=40').parseInitialHash().scopeOpacityPct, 40); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=250').parseInitialHash().scopeOpacityPct, 100); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=-3').parseInitialHash().scopeOpacityPct, 0); + assert.equal(makeManager('#v=2&lat=10&lon=20&sco=x').parseInitialHash().scopeOpacityPct, 100); + + const manager = makeManager(); + manager.onToggleChange(false, true, {}); + const before = manager._buildHashParams().toString(); + assert.equal(manager._buildHashParams().has('sco'), false, 'full opacity writes nothing'); + manager.onToggleChange(false, true, { scopeOpacityPct: 100 }); + assert.equal(manager._buildHashParams().toString(), before); + manager.onToggleChange(false, true, { scopeOpacityPct: 45.4 }); + assert.equal(manager._buildHashParams().get('sco'), '45'); + assert.equal(sharedState(manager.buildLink()).scopeOpacityPct, 45); + manager.onToggleChange(false, true, { scopeOpacityPct: 100 }); + assert.equal(manager._buildHashParams().has('sco'), false); + assert.equal(sharedState(manager.buildLink()).scopeOpacityPct, 100); + clearTimeout(manager._debounceTimer); +}); + test('share-link serialization emits the current celestial state', () => { const manager = makeManager(); manager.onToggleChange(false, false, { celestialRingEnabled: false }); diff --git a/src/sharelink.js b/src/sharelink.js index 6577afeae..5715d581c 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,10 @@ 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 +466,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 +633,12 @@ 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 +754,8 @@ 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/PanelRailView.vue b/src/ui/PanelRailView.vue index 8ac9fb256..9f9712674 100644 --- a/src/ui/PanelRailView.vue +++ b/src/ui/PanelRailView.vue @@ -25,7 +25,9 @@ const items = computed(() => id: panel.id, icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON, // Static markup is translated at boot, so a host's title already is. - label: item ? t(item.label) : panel.title, + label: item + ? t(item.label, item.context && { context: item.context }) + : panel.title, }; }), ); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 564578e60..064024920 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -226,6 +226,7 @@ export class StyleManager extends ShellFacade { getDetectionTuning: services.getDetectionTuning, getKeyholeFadeTuning: services.getKeyholeFadeTuning, getScopeMaskFeather: services.getScopeMaskFeather, + getScopeMaskOpacity: services.getScopeMaskOpacity, getScopeTerminusOverride: services.getScopeTerminusOverride, governorRequestRender: services.governorRequestRender, holdContinuousRender: services.holdContinuousRender, @@ -239,6 +240,7 @@ export class StyleManager extends ShellFacade { setKeyholeFadeTuning: services.setKeyholeFadeTuning, setScopeMaskEnabled: services.setScopeMaskEnabled, setScopeMaskFeather: services.setScopeMaskFeather, + setScopeMaskOpacity: services.setScopeMaskOpacity, }, elements: { _ppToggles: this._ppToggles, @@ -594,6 +596,10 @@ export class StyleManager extends ShellFacade { this._setDetectionAllocation(...args), _setDetectionFade: (...args) => this._setDetectionFade(...args), _setScope: (...args) => this._visualSettings._setScope(...args), + _applyGlobalPostDefaults: (...args) => + this._applyGlobalPostDefaults(...args), + _adoptDetectionAsRestore: () => + this._visualSettings.adoptDetectionAsRestore(), setCelestialRingEnabled: (...args) => this.setCelestialRingEnabled(...args), _setModels3dEnabled: (...args) => this._setModels3dEnabled(...args), 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..193680eed 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -1,4 +1,11 @@ -import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js'; +import { + getScopeMaskFeather, + getScopeMaskOpacity, + isScopeMaskEnabled, + setScopeMaskEnabled, + setScopeMaskFeather, + setScopeMaskOpacity, +} from '../scopeMask.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -282,7 +289,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 +299,44 @@ 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 shared scope opacity is restored and rides in the next share state', async (t) => { + const { owner } = styleOwner(t, 'tactical'); + const previous = getScopeMaskOpacity(); + t.after(() => setScopeMaskOpacity(previous)); + Object.assign(owner.services, { + setScopeMaskEnabled() {}, + setScopeMaskFeather() {}, + setScopeMaskOpacity, + }); + let shown = null; + owner.store = { publish: (view) => (shown = view.scope) }; + await owner.restoreShareState({ scopeOpacityPct: 60 }); + assert.equal(getScopeMaskOpacity(), 0.6); + assert.equal(shown.opacityPct, 60); + // A state without it leaves the opacity alone. + await owner.restoreShareState({ scopeFeatherPct: 80 }); + assert.equal(getScopeMaskOpacity(), 0.6); + assert.equal(shown.opacityPct, 60); + Object.assign(owner.services, { + getDetectionTuning: () => ({ allocationStrategy: 'ELASTIC' }), + getDetectionMode: () => 'OFF', + isScopeMaskEnabled: () => true, + getScopeMaskFeather: () => 0.8, + getScopeMaskOpacity, + getScopeTerminusOverride: () => null, + }); + owner.readCelestialRing = () => null; + assert.equal(owner._readShareState().options.scopeOpacityPct, 60); + // A link asking for less than the floor opens at the floor, and says so. + await owner.restoreShareState({ scopeOpacityPct: 20 }); + assert.equal(getScopeMaskOpacity(), 0.5); + assert.equal(owner._readShareState().options.scopeOpacityPct, 50); }); 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..aae9c1e9e 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,28 @@ 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 }); + // The HUD layout follows the Normal style, as choosing it does: the + // Cyber HUD stays Cyber. + this._applyGlobalPostDefaults({ keepHudVariant: true }); + this._setDetectionAllocation('ELASTIC'); + this._setScope({ enabled: true, featherPct: 100, opacityPct: 100 }); + this._setModels3dEnabled(true); + this._setModels3dMode('proximity'); + this._syncModels3dModeRow?.(); + // Inside Contacts the detection shown is lent for the session, and + // leaving puts back the state from before. A reset there is the user's + // choice, so the defaults become what leaving puts back and what a + // share link carries. + this.claimDetection(); + this._adoptDetectionAsRestore?.(); + this._syncShareState(); + }, }); this._detachDisplayControls = () => store.detach(token); } diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index 5185f9ab5..c9550a5d2 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -108,7 +108,11 @@ test('bloom and sharpen render their state, slider and readout', async (t) => { await nextTick(); assert.equal($('bloom-toggle').classList.contains('active'), false); assert.equal($('bloom-slider-row').classList.contains('visible'), false); - assert.equal($('bloom-intensity-value').textContent, '140%', 'the amount stays'); + assert.equal( + $('bloom-intensity-value').textContent, + '140%', + 'the amount stays', + ); }); test('a publish never rewrites a slider the user is dragging', async (t) => { @@ -314,6 +318,73 @@ test('Scope and Celestial render what VisualSettings shows', async (t) => { assert.equal(celestial.title, 'Celestial ring — reveal the full globe'); }); +test('scope opacity renders what VisualSettings shows and sets the mask', async (t) => { + const { store, $ } = fixture(t); + const { owner } = settings(store); + const mask = { opacity: 1 }; + owner.services = { + ...owner.services, + setScopeMaskEnabled() {}, + setScopeMaskOpacity: (value) => (mask.opacity = value), + }; + assert.equal($('scope-opacity-slider').value, '100'); + assert.equal($('scope-opacity-value').textContent, '100%'); + + owner._setScope({ opacityPct: 62.4 }); + await nextTick(); + assert.equal(mask.opacity, 0.62); + assert.equal($('scope-opacity-slider').value, '62'); + assert.equal($('scope-opacity-value').textContent, '62%'); + assert.equal(store.view.scope.opacityPct, 62); + + // No fainter than the floor, where the view would read as Scope off. + owner._setScope({ opacityPct: 10 }); + await nextTick(); + assert.equal(mask.opacity, 0.5, 'floored'); + assert.equal($('scope-opacity-value').textContent, '50%'); + assert.equal($('scope-opacity-slider').min, '50'); + + // A value that is not a number changes nothing. + owner._setScope({ opacityPct: Number.NaN }); + await nextTick(); + assert.equal($('scope-opacity-value').textContent, '50%'); + + owner._setScope({ opacityPct: 180 }); + await nextTick(); + assert.equal(mask.opacity, 1, 'clamped'); + assert.equal($('scope-opacity-value').textContent, '100%'); + + owner._setScope({ opacityPct: 70 }, { apply: false }); + await nextTick(); + assert.equal(mask.opacity, 1, 'only shown'); + assert.equal($('scope-opacity-value').textContent, '70%'); + + owner._setScope({ enabled: false }); + await nextTick(); + assert.equal($('scope-opacity-slider').disabled, true, 'off with the scope'); + assert.equal(store.view.scope.opacityPct, 70, 'kept while off'); +}); + +test('the Visual panel groups its controls and words its on/off pills', (t) => { + const { $ } = fixture(t); + const panel = $('pp-toggles'); + assert.deepEqual( + [ + ...panel.querySelectorAll( + '.pp-section-heading, .visual-presets__heading', + ), + ].map((el) => el.textContent.trim()), + ['Look', 'MAP SOURCE 3D', 'Overlay', 'Effects'], + ); + assert.equal(panel.querySelector('.pp-header-label').textContent, 'VISUAL'); + const body = panel.querySelector('.pp-panel-body'); + assert.equal(body.style.getPropertyValue('--pp-pill-on'), '"ON"'); + assert.equal(body.style.getPropertyValue('--pp-pill-off'), '"OFF"'); + const reset = panel.querySelector('.pp-reset-btn'); + assert.equal(reset, body.lastElementChild, 'the reset closes the panel'); + assert.equal(reset.textContent.trim(), 'restart_altReset to default'); +}); + test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => { const { store, $ } = fixture(t); const params = []; @@ -331,7 +402,10 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => { assert.equal(coreClasses(button), 'pp-toggle-btn active'); assert.equal(button.getAttribute('aria-pressed'), 'true'); assert.equal(row.classList.contains('visible'), true); - assert.equal($('models3d-mode-proximity').getAttribute('aria-checked'), 'true'); + assert.equal( + $('models3d-mode-proximity').getAttribute('aria-checked'), + 'true', + ); // The button leaves the row to its own sync. owner._setModels3dEnabled(false); @@ -347,7 +421,10 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => { await nextTick(); assert.equal(all.classList.contains('active'), true); assert.equal(all.getAttribute('aria-checked'), 'true'); - assert.equal($('models3d-mode-proximity').classList.contains('active'), false); + assert.equal( + $('models3d-mode-proximity').classList.contains('active'), + false, + ); assert.deepEqual(params.at(-1), ['military', { models3dMode: 'all' }]); owner._syncModels3dFromLayerState({ @@ -376,12 +453,18 @@ test('the Draw controls render what the draw tool publishes', async (t) => { assert.equal(segment('area').getAttribute('aria-label'), 'Area'); assert.equal(segment('area').title, 'Area'); assert.equal(segment('area').textContent, 'pentagon'); - const swatch = (color) => document.querySelector(`#draw-colors [data-color="${color}"]`); + const swatch = (color) => + document.querySelector(`#draw-colors [data-color="${color}"]`); assert.equal(swatch('primary').getAttribute('aria-checked'), 'true'); assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber'); assert.equal($('draw-hint').textContent, ''); - store.publishDraw({ active: true, shape: 'line', color: 'amber', hint: 'Click to add points.' }); + store.publishDraw({ + active: true, + shape: 'line', + color: 'amber', + hint: 'Click to add points.', + }); await nextTick(); assert.equal(swatch('amber').getAttribute('aria-checked'), 'true'); assert.equal(swatch('primary').classList.contains('active'), false); @@ -477,7 +560,10 @@ test('the imported files render what the file import publishes and keep focus', assert.equal(document.activeElement === button(first, 'toggle'), true); assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'true'); assert.equal(button(first, 'fly').title, 'Fly to a.geojson'); - assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove a.geojson'); + assert.equal( + button(first, 'remove').getAttribute('aria-label'), + 'Remove a.geojson', + ); await press(button(first, 'remove')); assert.equal(rowsNow().length, 1); @@ -593,7 +679,11 @@ test('VisualSettings publishes a style’s parameters and writes a moved slider assert.equal(store.parameters, null, 'the sliders go with their owner'); assert.equal($('param-slider-panel').classList.contains('active'), false); assert.equal(store.act('setStyleParameter', 'grainAmt', 0.2), undefined); - assert.equal(owner.stages.noir.uniforms.grainAmt, 0.3, 'a stopped owner no longer answers'); + assert.equal( + owner.stages.noir.uniforms.grainAmt, + 0.3, + 'a stopped owner no longer answers', + ); // An older owner's stop leaves a newer owner's rows. const newer = { style: 'noir', rows: [] }; owner._parameters = { style: 'noir', rows: [] }; @@ -605,10 +695,16 @@ test('VisualSettings publishes a style’s parameters and writes a moved slider test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => { const { store, $ } = fixture(t); const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id); - const ids = ['hud-toggle', 'detection-toggle', 'param-slider-panel', 'models3d-toggle']; + const ids = [ + 'hud-toggle', + 'detection-toggle', + 'param-slider-panel', + 'models3d-toggle', + ]; const body = $('pp-toggles').querySelector('.pp-panel-body'); const order = () => - [...body.children].filter((el) => el.matches('.pp-toggle-group, #param-slider-panel')) + [...body.children] + .filter((el) => el.matches('.pp-toggle-group, #param-slider-panel')) .map((el) => el.id || el.querySelector('[id]')?.id); const home = order(); const popover = document.body.appendChild(document.createElement('div')); diff --git a/src/ui/displayPanelActions.test.mjs b/src/ui/displayPanelActions.test.mjs index 061a13fba..feb123cba 100644 --- a/src/ui/displayPanelActions.test.mjs +++ b/src/ui/displayPanelActions.test.mjs @@ -8,6 +8,7 @@ import { createApp, h, nextTick } from 'vue'; import { createPinia } from 'pinia'; import { useDisplayStore } from './stores/display.js'; import { DisplayBindings } from './displayBindings.js'; +import { VisualSettings } from './visualSettings.js'; import CommandDockView from './views/CommandDockView.vue'; import DisplayControlsView from './views/DisplayControlsView.vue'; import DrawControls from './views/panels/DrawControls.vue'; @@ -231,3 +232,125 @@ test('several owners answer their own actions, the newest first', (t) => { ['display', 'toggleBloom'], ]); }); + +test('the Visual panel’s scope opacity, reset and presets call their actions', async (t) => { + const store = mount(t); + const { calls } = recorder(store); + const selected = []; + store.connectMapSource({ select: async (id) => selected.push(id) }); + store.publishMapSource({ + status: { text: 'OSM', warn: false }, + chips: [ + { id: 'photoreal', label: 'Google 3D', available: false }, + { id: 'osm', label: 'OSM', available: true, active: true }, + ], + }); + await nextTick(); + input($('scope-opacity-slider'), 60); + document.querySelector('#pp-toggles .pp-reset-btn').click(); + const presets = document.querySelector('#pp-toggles .visual-presets'); + presets.querySelector('[data-style="noir"]').click(); + const chips = presets.querySelectorAll('.visual-presets__chip'); + chips[0].click(); + chips[1].click(); + assert.deepEqual(calls, [ + ['setScopeOpacity', 60], + ['resetVisual'], + ['setStyle', 'noir'], + ]); + assert.deepEqual(selected, ['osm'], 'an unavailable source is not selected'); + // The presets follow the published style, as the dock's do. + assert.equal( + presets.querySelector('[data-style="normal"]').getAttribute('aria-pressed'), + 'true', + ); + store.publishStyle({ name: 'noir', label: 'NOIR' }); + await nextTick(); + assert.equal( + presets.querySelector('[data-style="noir"]').getAttribute('aria-pressed'), + 'true', + ); + assert.equal( + presets.querySelector('[data-style="normal"]').getAttribute('aria-pressed'), + 'false', + ); + // The dock keeps its own presets and map source beside them. + assert.ok( + document.querySelector('#control-panel .style-btn[data-style="noir"]'), + ); + assert.ok($('map-stack-chips')); +}); + +test('DisplayBindings sets the scope opacity and resets Visual to its defaults', (t) => { + const store = mount(t); + const calls = []; + const bindings = new DisplayBindings({ + viewer: null, + services: { isScopeMaskEnabled: () => true, cycleDetectionMode: () => {} }, + elements: { _locationSearch: document.createElement('input') }, + operations: { + setStyle: (style, options) => calls.push(['style', style, options]), + _applyGlobalPostDefaults: (options) => + calls.push(['postDefaults', options]), + _setDetectionAllocation: (value) => calls.push(['allocation', value]), + _setScope: (scope) => calls.push(['scope', scope]), + _setModels3dEnabled: (on) => calls.push(['models3d', on]), + _setModels3dMode: (mode) => calls.push(['models3dMode', mode]), + _syncModels3dModeRow: () => calls.push(['models3dRow']), + _syncShareState: () => calls.push(['share']), + _adoptDetectionAsRestore: () => calls.push(['adoptDetection']), + }, + readState: () => ({ + shareLinkManager: { + claimRestoreLane: (lane) => calls.push(['claim', lane]), + }, + }), + claimDetection: () => calls.push(['claimDetection']), + displayStore: store, + }); + bindings._initUI(); + t.after(() => bindings.destroy()); + calls.length = 0; + input($('scope-opacity-slider'), 65); + assert.deepEqual(calls, [ + ['claim', 'visual'], + ['scope', { opacityPct: 65 }], + ['share'], + ]); + calls.length = 0; + document.querySelector('#pp-toggles .pp-reset-btn').click(); + assert.deepEqual(calls, [ + ['claim', 'visual'], + ['style', 'normal', { userInitiated: true }], + ['postDefaults', { keepHudVariant: true }], + ['allocation', 'ELASTIC'], + ['scope', { enabled: true, featherPct: 100, opacityPct: 100 }], + ['models3d', true], + ['models3dMode', 'proximity'], + ['models3dRow'], + ['claimDetection'], + ['adoptDetection'], + ['share'], + ]); +}); + +test('a reset inside Contacts makes the shown detection what leaving puts back', () => { + const adopt = VisualSettings.prototype.adoptDetectionAsRestore; + const live = { detectionMode: 'DENSE', densityPct: 75 }; + const inContacts = { + _contactsDetectionRestore: { mode: 'OFF', densityPct: 50 }, + getDetectionState: () => live, + }; + adopt.call(inContacts); + assert.deepEqual(inContacts._contactsDetectionRestore, { + mode: 'DENSE', + densityPct: 75, + }); + // Outside Contacts there is no saved state to change. + const outside = { + _contactsDetectionRestore: null, + getDetectionState: () => live, + }; + adopt.call(outside); + assert.equal(outside._contactsDetectionRestore, null); +}); diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js index 6e0b0a876..854214a2a 100644 --- a/src/ui/panelRailCatalog.js +++ b/src/ui/panelRailCatalog.js @@ -9,7 +9,7 @@ export const PANEL_RAIL_FALLBACK_ICON = 'more_horiz'; /** * How each side panel appears on its rail. Labels are the panels' own titles. * `shownBy` names a class the panel needs to be shown at all (`f` toggles - * Data Layers' `active`). + * Data Layers' `active`). `context` is the label's translation context. */ export const PANEL_RAIL_ITEMS = Object.freeze({ 'data-panel': { @@ -19,7 +19,9 @@ export const PANEL_RAIL_ITEMS = Object.freeze({ }, 'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') }, 'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, - 'pp-toggles': { icon: 'palette', label: msg('VISUAL') }, + // `context` keeps the panel's name apart from the satellite class of the + // same English word. + 'pp-toggles': { icon: 'palette', label: msg('VISUAL'), context: 'panel' }, 'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, 'news-panel': { icon: 'public', label: msg('NEWS') }, diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 7d770d762..3da384199 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -599,7 +599,10 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja)) #pp-toggles.collapsed .pp-toggle-group, #pp-toggles.collapsed #clean-view-toggle, -#pp-toggles.collapsed #param-slider-panel { +#pp-toggles.collapsed #param-slider-panel, +#pp-toggles.collapsed .visual-presets, +#pp-toggles.collapsed .pp-section-heading, +#pp-toggles.collapsed .pp-reset-btn { display: none; } diff --git a/src/ui/styles/scope-off-shadows.css b/src/ui/styles/scope-off-shadows.css new file mode 100644 index 000000000..8ad2cdeee --- /dev/null +++ b/src/ui/styles/scope-off-shadows.css @@ -0,0 +1,26 @@ +/* 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..6cd029703 --- /dev/null +++ b/src/ui/styles/visual-panel.css @@ -0,0 +1,349 @@ +/* ── 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 phone's More sheet shows the dock's Visual Presets tray right above + Visual, so the panel leaves out its own copy there. */ +body.mobile-shell #pp-toggles .visual-presets { + display: none; +} + +/* 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; +} + +/* The card skin below is the default look; the Cyber HUD keeps its own red + skin for these controls (cyber.css), so the skin stands aside there. */ +:root:not([data-ui-theme='cyber']) #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. */ +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + > .pp-toggle-btn, +:root:not([data-ui-theme='cyber']) #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; +} + +:root:not([data-ui-theme='cyber']) #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; +} + +:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn .pp-icon { + width: 18px; + font-size: 15px; + text-align: center; + color: rgba(199, 214, 222, 0.75); +} + +:root:not([data-ui-theme='cyber']) #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). */ +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + > button.pp-toggle-btn::after, +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-panel-body + > button.pp-toggle-btn::after { + /* The state is the button's own (aria-pressed); the pill only shows it. */ + content: var(--pp-pill-off, '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; +} + +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + > button.pp-toggle-btn.active::after, +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + > button.pp-toggle-btn[aria-pressed='true']::after, +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-panel-body + > button.pp-toggle-btn.active::after { + content: var(--pp-pill-on, 'ON') / ''; + color: #7af0ff; + border-color: rgba(54, 220, 255, 0.5); + background: rgba(54, 220, 255, 0.1); +} + +:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn.active .pp-icon, +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-btn[aria-pressed='true'] + .pp-icon, +:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn.active .pp-label, +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-btn[aria-pressed='true'] + .pp-label { + color: #7af0ff; +} + +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + > .pp-toggle-btn:hover, +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-panel-body + > .pp-toggle-btn:hover { + background: rgba(255, 255, 255, 0.04) !important; +} + +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-scope-toggle + input[type='checkbox'] { + margin-inline-start: auto; +} + +/* Settings inside a card: separated by hairlines, no boxes of their own. */ +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + .pp-slider-row.visible, +:root:not([data-ui-theme='cyber']) + #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. */ +:root:not([data-ui-theme='cyber']) + #pp-toggles + .pp-toggle-group + > .pp-toggle-btn.panoptic, +:root:not([data-ui-theme='cyber']) + #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); +} + +/* Clean UI is an action, not a setting, so it has no on/off pill. */ +#pp-toggles #clean-view-toggle::after { + content: none; +} diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 3cd202161..b3adbb6f1 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, @@ -21,6 +23,12 @@ const { view, models3d, parameters, cockpitPortal } = storeToRefs(display); // A control's change goes to its owner (DisplayBindings). const act = (name, ...args) => display.act(name, ...args); const pct = (value) => `${value}%`; +// The words of the on/off pill each card header draws (visual-panel.css), as +// CSS strings. +const pillWords = computed(() => ({ + '--pp-pill-on': JSON.stringify(t('ON')), + '--pp-pill-off': JSON.stringify(t('OFF')), +})); const hud = computed(() => view.value?.hud ?? null); const sonar = computed(() => view.value?.sonar ?? null); const sonarOn = computed(() => sonar.value?.enabled ?? true); @@ -29,6 +37,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,15 +60,18 @@ 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..c88de8ec0 --- /dev/null +++ b/src/ui/views/panels/VisualPresets.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 736e4a49e..ad11f86c4 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -46,6 +46,13 @@ import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js'; const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1'; /** Own visual preferences, detection overrides and display-control state. */ +/** + * The faintest the Scope mask goes. Below about half, the view reads as if + * Scope were off while labels still fade outside the keyhole and the HUD gets + * no backplates, so the slider stops here. + */ +export const SCOPE_OPACITY_MIN_PCT = 50; + export class VisualSettings { async restoreShareState(state) { const { setScopeTerminusOverride, clampScopeTerminusPct } = this.services; @@ -66,6 +73,7 @@ export class VisualSettings { celestialRing, scopeEnabled, scopeFeatherPct, + scopeOpacityPct, scopeTerminusPct, mapStack, panelState, @@ -133,7 +141,12 @@ 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); @@ -219,6 +232,7 @@ export class VisualSettings { // What the Scope and Celestial controls show; null until first set. this._scopeEnabledShown = null; this._scopeFeatherShown = null; + this._scopeOpacityShown = null; this._celestialShown = null; this.activeStyle = 'normal'; this._preCyberStyle = null; @@ -348,6 +362,20 @@ export class VisualSettings { this._syncShareState(); } + /** + * While Contacts holds detection, make the detection shown now the state + * leaving Contacts puts back (and a share link carries): the user chose it, + * as with Reset in Visual. Nothing changes outside Contacts. + */ + adoptDetectionAsRestore() { + if (!this._contactsDetectionRestore) return; + const state = this.getDetectionState(); + this._contactsDetectionRestore = { + mode: state.detectionMode, + densityPct: state.densityPct, + }; + } + _setCockpitVision(mode, active, { revealParameters = false } = {}) { const next = active ? normalizeCockpitVisionMode(mode) : 'optical'; if (!this.stages) return; @@ -479,6 +507,7 @@ export class VisualSettings { scope: { enabled: this._scopeEnabledShown, featherPct: this._scopeFeatherShown, + opacityPct: this._scopeOpacityShown ?? 100, }, celestial: this._celestialShown, }; @@ -488,7 +517,15 @@ 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 (Number.isFinite(opacityPct)) { + const pct = Math.max( + SCOPE_OPACITY_MIN_PCT, + 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; @@ -819,7 +856,11 @@ export class VisualSettings { if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase()); } - _applyGlobalPostDefaults() { + /** + * @param {{keepHudVariant?: boolean}} [options] `keepHudVariant` leaves the + * HUD layout to the style, as choosing a style does (Visual's reset). + */ + _applyGlobalPostDefaults({ keepHudVariant = false } = {}) { const defaults = GLOBAL_POST_DEFAULTS; if (typeof defaults.bloom?.intensity === 'number') { this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), { @@ -838,7 +879,7 @@ export class VisualSettings { this._setSharpenEnabled(defaults.sharpen.enabled); } - if (defaults.hudVariant) { + if (defaults.hudVariant && !keepHudVariant) { this._setHudVariant(defaults.hudVariant); } if (typeof defaults.hudVisible === 'boolean') { @@ -883,6 +924,7 @@ export class VisualSettings { getDetectionTuning, isScopeMaskEnabled, getScopeMaskFeather, + getScopeMaskOpacity, getScopeTerminusOverride, } = this.services; const detection = this._shareableDetectionState(); @@ -903,6 +945,7 @@ export class VisualSettings { celestialRingEnabled: this.celestialRingEnabled, scopeEnabled: isScopeMaskEnabled(), scopeFeatherPct: Math.round(getScopeMaskFeather() * 100), + scopeOpacityPct: Math.round((getScopeMaskOpacity?.() ?? 1) * 100), // null when adaptive — the share layer omits `sce` entirely in that case. scopeTerminusPct: getScopeTerminusOverride() == null diff --git a/style.css b/style.css index 012714e4e..2d79212fb 100644 --- a/style.css +++ b/style.css @@ -49,5 +49,7 @@ @import './src/ui/styles/rail-split.css'; @import './src/ui/styles/left-tools.css'; @import './src/ui/styles/panel-edge.css'; +@import './src/ui/styles/visual-panel.css'; @import './src/ui/styles/ai-chat.css'; +@import './src/ui/styles/scope-off-shadows.css'; @import './src/ui/styles/cyber.css';