From decac29fbdcaf3a004fda98b8a5396343f9caace Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 12:05:19 -0700 Subject: [PATCH] feat(ui): render the active style in the dock and the ACTIVE STYLE corner from the store VisualSettings no longer toggles the style buttons' active class or writes the dock's style mini status and the ACTIVE STYLE corner. It publishes the active style and its label to the gev-display store; CommandDockView and SceneChromeView render them. The buttons keep their owner's listeners, and the initial label renders through t(). --- docs/VUE-MIGRATION.md | 5 +- src/locationReadouts.test.mjs | 11 +++-- src/ui/applicationShell.js | 4 +- src/ui/commandDock.test.mjs | 81 ++++++++++++++++++++++++++++++++ src/ui/cyberTheme.test.mjs | 3 +- src/ui/shellElements.js | 2 - src/ui/shellFacade.js | 4 +- src/ui/stores/display.js | 13 ++++- src/ui/views/CommandDockView.vue | 25 +++++++--- src/ui/views/SceneChromeView.vue | 11 ++++- src/ui/visualSettings.js | 26 +++++----- 11 files changed, 146 insertions(+), 39 deletions(-) create mode 100644 src/ui/commandDock.test.mjs diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 8a61e0cb4..a3c532026 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -115,7 +115,10 @@ They contain no application state. publishes through `subscribePanel()`, the first layer to hand its rail content to a view, and `MissionRoster` and `MissionDetail` the Space Missions roster and selected mission that the launches layer publishes - the same way), the command dock, + the same way), the command dock (in progress: the style buttons, the + dock's style mini status and the ACTIVE STYLE corner render the style + VisualSettings publishes to the `gev-display` store; the buttons keep + their owner's listeners), the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 6c63406ee..b36a615b7 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -20,16 +20,17 @@ function locationSearchHandler() { test('the ACTIVE STYLE indicator is written from the style name and nothing else', () => { // A free-text location search used to write the searched CITY into the - // top-right style slot, so the corner read "ACTIVE STYLE / TOKYO". - const writes = [...ui.matchAll(/this\._styleIndicator\.textContent\s*=/g)]; + // top-right style slot, so the corner read "ACTIVE STYLE / TOKYO". The + // corner renders the style VisualSettings publishes, from one writer. + const writes = [...ui.matchAll(/\.publishStyle\(/g)]; assert.equal(writes.length, 1, 'the style indicator must have exactly one writer'); assert.match( - ui.slice(writes[0].index, writes[0].index + 160), - /this\._styleIndicator\.textContent = STYLE_STATUS_LABELS\[styleName\]\s*\?\s*t\(STYLE_STATUS_LABELS\[styleName\]\)\s*:\s*styleName\.toUpperCase\(\);/, + ui.slice(writes[0].index, writes[0].index + 200), + /\.publishStyle\(\{\s*name,\s*label: STYLE_STATUS_LABELS\[name\]\s*\?\s*t\(STYLE_STATUS_LABELS\[name\]\)\s*:\s*name\.toUpperCase\(\),/, ); const handler = locationSearchHandler(); - assert.doesNotMatch(handler, /_styleIndicator/, 'location search must not touch the style indicator'); + assert.doesNotMatch(handler, /publishStyle|_publishActiveStyle/, 'location search must not touch the style indicator'); assert.doesNotMatch(handler, /active-style-name/, 'location search must not touch the style indicator'); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c79ed3834..2a2d47804 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -213,8 +213,6 @@ export class StyleManager extends ShellFacade { _ppToggles: this._ppToggles, _sliderContainer: this._sliderContainer, _sliderPanel: this._sliderPanel, - _styleIndicator: this._styleIndicator, - _styleMiniValue: this._styleMiniValue, }, operations: { _restorePanelState: (...args) => this._restorePanelState(...args), @@ -586,7 +584,7 @@ export class StyleManager extends ShellFacade { this._initRecordingOverlay(); this._startAnimationLoop(); this._startTrafficChipTicker(); - this._updateStyleMiniStatus(); + this._publishActiveStyle(); this._updateLocationMiniStatus(); this._shareRestoration.start(); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs new file mode 100644 index 000000000..0a065558c --- /dev/null +++ b/src/ui/commandDock.test.mjs @@ -0,0 +1,81 @@ +// src/ui/commandDock.test.mjs — the command dock and the scene chrome render +// what VisualSettings publishes, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, h, nextTick } from 'vue'; +import { setLocale } from '../i18n/index.js'; +import { useDisplayStore } from './stores/display.js'; +import CommandDockView from './views/CommandDockView.vue'; +import SceneChromeView from './views/SceneChromeView.vue'; +import { VisualSettings } from './visualSettings.js'; + +function fixture(t) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp({ + render: () => [h(SceneChromeView), h(CommandDockView)], + }).use(pinia); + app.mount(host); + t.after(() => app.unmount()); + const store = useDisplayStore(pinia); + // VisualSettings publishes through its store; the method needs no more. + const settings = Object.assign(Object.create(VisualSettings.prototype), { + store, + activeStyle: 'normal', + }); + const $ = (selector) => document.querySelector(selector); + const active = () => + [...document.querySelectorAll('.style-btn.active')].map( + (button) => button.dataset.style, + ); + return { $, settings, active }; +} + +test('before VisualSettings publishes, the dock shows NORMAL', (t) => { + const { $, active } = fixture(t); + assert.deepEqual(active(), ['normal']); + assert.equal($('#style-mini-value').textContent, 'NORMAL'); + assert.equal($('#active-style-name').textContent, 'NORMAL'); +}); + +test('the active style marks its button and labels the dock and the corner', async (t) => { + const { $, settings, active } = fixture(t); + settings._publishActiveStyle('thermal'); + await nextTick(); + assert.deepEqual(active(), ['thermal']); + assert.equal($('#style-mini-value').textContent, 'FLIR'); + assert.equal($('#active-style-name').textContent, 'FLIR'); + settings.activeStyle = 'unlisted'; + settings._publishActiveStyle(); + await nextTick(); + assert.deepEqual(active(), []); + assert.equal($('#style-mini-value').textContent, 'UNLISTED'); + settings._publishActiveStyle('normal'); + await nextTick(); + assert.deepEqual(active(), ['normal']); +}); + +test('the style buttons keep classes other code sets', async (t) => { + const { $, settings } = fixture(t); + const noir = $('.style-btn[data-style="noir"]'); + noir.classList.add('voice-highlight'); + settings._publishActiveStyle('noir'); + await nextTick(); + assert.equal(noir.classList.contains('voice-highlight'), true); + assert.equal(noir.classList.contains('active'), true); +}); + +test('the dock and the corner label the style in the UI language', async (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const { $, settings } = fixture(t); + assert.equal($('#style-mini-value').textContent, 'ОБЫЧНЫЙ'); + assert.equal($('#active-style-name').textContent, 'ОБЫЧНЫЙ'); + settings._publishActiveStyle('noir'); + await nextTick(); + assert.equal($('#style-mini-value').textContent, 'НУАР'); + assert.equal($('#active-style-name').textContent, 'НУАР'); +}); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index ca053dd85..c31eeba8c 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -77,7 +77,6 @@ function styleOwner(t, initialVariant = 'cyber') { ), }, services: { governorRequestRender() {}, setDetectionStyle() {} }, - _styleIndicator: {}, _setBloomEnabled: (value) => calls.push(['bloom', value]), _setSharpenEnabled: (value) => calls.push(['sharpen', value]), _applyDetectionPreset: (value) => calls.push(['detection', value]), @@ -86,7 +85,7 @@ function styleOwner(t, initialVariant = 'cyber') { for (const name of [ 'setCelestialRingEnabled', '_startTransition', - '_updateStyleMiniStatus', + '_publishActiveStyle', '_updateSliderPanel', '_updateHudButtonState', '_syncIrBoost', diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 5336e7f13..2ac57a9ef 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -2,7 +2,6 @@ export function readShellElements(document = globalThis.document) { return { _rightPanelStack: document.getElementById('right-context-rail'), - _styleIndicator: document.getElementById('active-style-name'), _sliderPanel: document.getElementById('param-slider-panel'), _sliderContainer: document.getElementById('param-sliders'), _ppToggles: document.getElementById('pp-toggles'), @@ -132,7 +131,6 @@ export function readShellElements(document = globalThis.document) { _locationPills: document.getElementById('location-pills'), _poiRow: document.getElementById('poi-row'), _locationBarDivider: document.getElementById('location-bar-divider'), - _styleMiniValue: document.getElementById('style-mini-value'), _locationMiniCity: document.getElementById('location-mini-city'), _locationMiniPoi: document.getElementById('location-mini-poi'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 4f44d3039..89cf2644d 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -1304,8 +1304,8 @@ export class ShellFacade { * @param {string} [styleName=this.activeStyle] - Style name to display. * @returns {void} */ - _updateStyleMiniStatus(styleName = this.activeStyle) { - return this._visualSettings._updateStyleMiniStatus(...arguments); + _publishActiveStyle(styleName = this.activeStyle) { + return this._visualSettings._publishActiveStyle(...arguments); } _initOrbit(...args) { diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index cad6c5dd2..0b3ef39d5 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue'; * The Display panel as its view renders it. VisualSettings presents its * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the * draw tool its controls and the file import its list, and each publishes - * here; each keeps its listeners on the controls. + * here; each keeps its listeners on the controls. VisualSettings also + * publishes the active style, which the dock and the scene chrome show. */ export const useDisplayStore = defineStore('gev-display', () => { /** The presented panel; null until VisualSettings first renders. */ @@ -40,6 +41,14 @@ export const useDisplayStore = defineStore('gev-display', () => { geoImport.value = markRaw(next); } + /** The active visual style; null until VisualSettings first presents it. */ + const style = shallowRef(null); + + /** @param {{name: string, label: string}} next The active style and its label. */ + function publishStyle(next) { + style.value = markRaw(next); + } + return { view, publish, @@ -49,5 +58,7 @@ export const useDisplayStore = defineStore('gev-display', () => { publishDraw, geoImport, publishGeoImport, + style, + publishStyle, }; }); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index 954d31389..f60972e43 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -1,3 +1,14 @@ + +