From 6d6df9d83b9115fdef1fa295cd9635407867f177 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:03:44 -0700 Subject: [PATCH 001/116] docs: plan the Vue migration and the panel rail --- docs/VUE-MIGRATION.md | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/VUE-MIGRATION.md diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md new file mode 100644 index 000000000..f5846c99c --- /dev/null +++ b/docs/VUE-MIGRATION.md @@ -0,0 +1,76 @@ +# Vue migration + +The application UI moves from static templates plus imperative DOM wiring to +Vue 3 components with Pinia stores. The migration keeps the experience the +same: element ids, classes, the global stylesheet, translations, keyboard +shortcuts, voice actions, share links and the phone layout all behave as +before. The one intended change is the panel rail described below. + +## Goals + +- Views are Vue single-file components; state that several views read lives in + Pinia stores rather than in DOM classes. +- The owners listed in [UI-OWNERSHIP.md](UI-OWNERSHIP.md) keep their + responsibilities. A component replaces DOM construction; it does not add a + second owner of the same state. +- Public methods used by layers, scene playback, voice and host applications + (`setPanelCollapsed`, `setContextMode`, `setStyle`, share and location + subscriptions) keep their signatures. +- Ids and classes used by browser QA and host applications stay stable until a + host can use an explicit extension point instead. + +## The panel rail + +Each desktop sidebar gets a floating icon rail that lists every panel available +on that side. Selecting an icon opens that panel; selecting it again closes it. +One panel is open per side at a time, so the rail replaces the accordion +stacking. Panels that are not available in the current mode (for example +Weather before a weather layer is active) are shown disabled or omitted as the +panel's own availability rule dictates. The rail hides with the rest of the +chrome in Cockpit, scene playback, recording and the clean view. + +Phones keep the existing bottom tab bar and sheets; the rail is desktop only. + +Voice and keyboard openings go through the same panel store, so they follow the +one-panel rule too. Share links continue to record which panels are open. + +## Component kit + +`src/ui/kit/` holds the generic components (`UiRail`, `UiPanel`, `UiButton`, +`UiChip`, `UiTooltip` and so on). Names, props and events follow Halfpixel's +`ui-vue` kit so the two can converge later, but the components are written for +this repository and styled by its existing stylesheet and custom properties. +They contain no application state. + +## Phases + +1. **Foundation.** Vue, Pinia and the Vite Vue plugin join the browser build + (`build/vite.js`, so host applications receive them too). One Vue app is + mounted beside the existing markup with a shared Pinia instance. Stores + mirror the state that owners already publish: panel disclosure, the UI + theme, exclusive surfaces (Cockpit, scene playback, recording, clean view), + share state and location. The existing `gev:*` window events feed them. + Nothing visible changes. +2. **Rail.** The panel store becomes the authority for which panel is open on + each side, enforcing one panel per side, and the rail renders from it. +3. **Panels.** Each template and its wiring become a component, keeping ids, + classes, `data-i18n` text and CSS. Order: Scenes, Weather, Recent imagery, + Street level, CCTV, Layers, Display, Context and Radio, the command dock, + the phone shell, then Cockpit. Layers that write into the right rail + directly gain a slot instead. +4. **Cleanup.** The build-time template expansion and the attribute + translation pass are retired once nothing uses them. Hosts register panels + and rail items through an explicit API instead of editing markup. + Markup-string tests are replaced by component tests. + +## Verification + +- Unit tests: stores and components run under `npm test`. Single-file + components are compiled by a test loader; component tests render with + `@vue/test-utils` and `happy-dom`. +- Visual parity: before/after screenshots of every panel at desktop and phone + widths, in the default and Cyber themes, with the rail as the only expected + difference. +- Browser QA: the selector-based `scripts/qa-*.mjs` suites keep passing. +- `npm run format:check`, `npm run check:boundaries`, `npm run i18n` and + `npm run build`. From c13c487dcffa74393e2aea1e3822ddfc82337ca7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:06:15 -0700 Subject: [PATCH 002/116] build: add Vue and Pinia to the browser build and unit tests --- build/vite.js | 3 + package-lock.json | 608 +++++++++++++++++++++++++++++++- package.json | 7 +- scripts/package-boundaries.json | 2 +- scripts/run-unit-tests.mjs | 8 +- src/testSupport/registerVue.mjs | 4 + src/testSupport/vueLoader.mjs | 39 ++ src/tooling/viteBuild.test.mjs | 7 +- 8 files changed, 658 insertions(+), 20 deletions(-) create mode 100644 src/testSupport/registerVue.mjs create mode 100644 src/testSupport/vueLoader.mjs diff --git a/build/vite.js b/build/vite.js index 960603814..a1c9c403b 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,6 +1,7 @@ import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; +import vue from '@vitejs/plugin-vue'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; @@ -156,6 +157,8 @@ export function createBrowserViteConfig({ hostCheckPlugin(), cesium(), applicationHtmlPlugin(), + // Application views are Vue single-file components. + vue(), ...plugins, embedFramingPlugin(), panelBuildPlugin(), diff --git a/package-lock.json b/package-lock.json index 3db337442..263fd793d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,14 @@ "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", - "satellite.js": "^6.0.2" + "pinia": "^2.3.1", + "satellite.js": "^6.0.2", + "vue": "^3.5.44" }, "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "@vue/test-utils": "^2.5.1", + "happy-dom": "^20.14.6", "prettier": "3.9.6", "puppeteer": "^25.16.0", "sharp": "^0.35.4", @@ -33,6 +38,52 @@ "node": ">=24.14.0 <25 || >=26 <27" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cesium/engine": { "version": "22.3.0", "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-22.3.0.tgz", @@ -1064,6 +1115,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, "node_modules/@jtarrio/signals": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@jtarrio/signals/-/signals-0.10.1.tgz", @@ -1115,6 +1172,13 @@ "node": ">=24.0.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, "node_modules/@puppeteer/browsers": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz", @@ -1556,6 +1620,16 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/pako": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", @@ -1605,6 +1679,171 @@ "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, "node_modules/@zip.js/zip.js": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz", @@ -1619,6 +1858,16 @@ "node": ">=18.0.0" } }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/ansi-regex": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", @@ -1667,18 +1916,54 @@ "pnpm": ">=10.10.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bitmap-sdf": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/browser-split": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz", "integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==", "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -1762,6 +2047,23 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1843,6 +2145,35 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1880,6 +2211,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz", @@ -1953,7 +2296,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, "license": "MIT" }, "node_modules/etag": { @@ -2056,6 +2398,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", @@ -2079,6 +2439,25 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.14.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.6.tgz", + "integrity": "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hls.js": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", @@ -2138,6 +2517,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-object": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", @@ -2157,6 +2543,35 @@ "@types/estree": "*" } }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -2216,6 +2631,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -2343,6 +2768,32 @@ "dom-walk": "^0.1.0" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2368,10 +2819,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -2392,6 +2842,22 @@ "integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==", "license": "MIT" }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/nosleep.js": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", @@ -2427,6 +2893,23 @@ "node": ">= 0.8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pbf": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", @@ -2443,7 +2926,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2459,6 +2941,28 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/polylabel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz", @@ -2475,10 +2979,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "funding": [ { "type": "opencollective", @@ -2495,9 +2998,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2528,6 +3031,13 @@ "node": ">= 0.6.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/protobufjs": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", @@ -2825,7 +3335,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2959,6 +3468,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -3115,6 +3631,60 @@ "rollup": "^2.25.0" } }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.14.tgz", + "integrity": "sha512-Pq3+OnbCiPCHk0R7KSZrx+j/XXaUn2kSQ1epxq60w8CPbByLjC1EJgV51n4WY851sTXUVzsNMuitywdkdVyKjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", @@ -3128,6 +3698,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index f0b329a90..138867968 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,14 @@ "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", - "satellite.js": "^6.0.2" + "pinia": "^2.3.1", + "satellite.js": "^6.0.2", + "vue": "^3.5.44" }, "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "@vue/test-utils": "^2.5.1", + "happy-dom": "^20.14.6", "prettier": "3.9.6", "puppeteer": "^25.16.0", "sharp": "^0.35.4", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7a5424365..9a82fa11e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -42,7 +42,7 @@ "build/embed-framing.js", "build/panel.js" ], - "external": ["vite-plugin-cesium"] + "external": ["vite-plugin-cesium", "@vitejs/plugin-vue"] }, "live-providers": { "runtime": "node", diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 3712df28e..6cdd6f1d0 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -71,8 +71,14 @@ export function allocationTestArgs(file) { return ['--expose-gc', '--test', '--test-concurrency=1', file]; } +/** Node flags every unit-test process receives. */ +export const UNIT_TEST_NODE_FLAGS = Object.freeze([ + // Single-file components compile on import. + '--import=./src/testSupport/registerVue.mjs', +]); + function runTests(args) { - const result = spawnSync(process.execPath, args, { + const result = spawnSync(process.execPath, [...UNIT_TEST_NODE_FLAGS, ...args], { cwd: process.cwd(), stdio: 'inherit', env: process.env, diff --git a/src/testSupport/registerVue.mjs b/src/testSupport/registerVue.mjs new file mode 100644 index 000000000..76725e274 --- /dev/null +++ b/src/testSupport/registerVue.mjs @@ -0,0 +1,4 @@ +import { register } from 'node:module'; + +// Lets unit tests import single-file components (`node --import`). +register('./vueLoader.mjs', import.meta.url); diff --git a/src/testSupport/vueLoader.mjs b/src/testSupport/vueLoader.mjs new file mode 100644 index 000000000..5aa340b5e --- /dev/null +++ b/src/testSupport/vueLoader.mjs @@ -0,0 +1,39 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'; + +/** + * Compile a single-file component to an ES module for Node's test runner. + * Styles are dropped: tests read the global stylesheet separately. + */ +export function compileVueModule(source, filename) { + const { descriptor, errors } = parse(source, { filename }); + if (errors.length) throw errors[0]; + const id = filename; + if (descriptor.scriptSetup) { + return compileScript(descriptor, { id, inlineTemplate: true }).content; + } + const script = descriptor.script + ? compileScript(descriptor, { id }).content.replace( + 'export default', + 'const __sfc__ =', + ) + : 'const __sfc__ = {};'; + const template = descriptor.template + ? compileTemplate({ source: descriptor.template.content, filename, id }) + .code + : ''; + return `${script}\n${template}\n__sfc__.render = typeof render === 'function' ? render : __sfc__.render;\nexport default __sfc__;\n`; +} + +/** Node module hook: load `.vue` files as compiled modules. */ +export async function load(url, context, nextLoad) { + if (!url.endsWith('.vue')) return nextLoad(url, context); + const filename = fileURLToPath(url); + const source = await readFile(filename, 'utf8'); + return { + format: 'module', + source: compileVueModule(source, filename), + shortCircuit: true, + }; +} diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index a9e8c5398..61e28eef7 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -29,7 +29,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.equal(config.plugins[0].enforce, 'pre'); assert.equal(config.plugins[1].name, 'host-check'); assert.equal(config.plugins[1].enforce, 'pre'); - assert.equal(config.plugins[4], plugin); + assert.equal(config.plugins[4].name, 'vite:vue'); + assert.equal(config.plugins[5], plugin); assert.equal(config.server.host, 'localhost'); assert.equal(config.server.port, 4173); assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); @@ -103,7 +104,7 @@ test('build helper does not discover environment values or construct local provi undefined, ); assert.deepEqual( - config.plugins.slice(4).map((plugin) => plugin.name), + config.plugins.slice(5).map((plugin) => plugin.name), ['embed-framing', 'panel-build'], ); } finally { @@ -117,7 +118,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); const standaloneProviders = config.plugins - .slice(4, -6) + .slice(5, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, From 7307c11c74a995113dcbec56fec2b18b01e817bd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:11:48 -0700 Subject: [PATCH 003/116] feat(ui): add the Vue component kit with UiRail, UiTooltip and UiIcon --- package-lock.json | 15 +++++ package.json | 1 + scripts/format-scope.json | 6 +- scripts/format.mjs | 2 +- src/materialSymbolsSubset.test.mjs | 5 +- src/testSupport/vueDom.mjs | 10 +++ src/ui/kit/UiIcon.vue | 16 +++++ src/ui/kit/UiRail.test.mjs | 73 +++++++++++++++++++++ src/ui/kit/UiRail.vue | 52 +++++++++++++++ src/ui/kit/UiTooltip.vue | 50 +++++++++++++++ src/ui/kit/rail.js | 12 ++++ src/ui/styles/kit.css | 100 +++++++++++++++++++++++++++++ style.css | 1 + 13 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 src/testSupport/vueDom.mjs create mode 100644 src/ui/kit/UiIcon.vue create mode 100644 src/ui/kit/UiRail.test.mjs create mode 100644 src/ui/kit/UiRail.vue create mode 100644 src/ui/kit/UiTooltip.vue create mode 100644 src/ui/kit/rail.js create mode 100644 src/ui/styles/kit.css diff --git a/package-lock.json b/package-lock.json index 263fd793d..6430491c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "vue": "^3.5.44" }, "devDependencies": { + "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", "happy-dom": "^20.14.6", @@ -588,6 +589,20 @@ "node": ">=18" } }, + "node_modules/@happy-dom/global-registrator": { + "version": "20.14.6", + "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.14.6.tgz", + "integrity": "sha512-Xd2FwCdm0cAZ+EG0upp69tpxBT9jtvVxaZv1lnbgWsNaZyEuPs0PojYgvCmtQ1RRdRCYCyWTeLjn3Z0aSxDchA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "happy-dom": "^20.14.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index 138867968..44316183c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "vue": "^3.5.44" }, "devDependencies": { + "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", "happy-dom": "^20.14.6", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2522f91c7..6197aa43c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1118,5 +1118,9 @@ "src/testSupport/shareViewFixtures.mjs", "src/ui/shareLinks.test.mjs", "src/ui/shareButton.test.mjs", - "src/share/shareLinkPath.test.mjs" + "src/share/shareLinkPath.test.mjs", + "src/ui/kit/UiRail.test.mjs", + "src/testSupport/vueDom.mjs", + "src/testSupport/vueLoader.mjs", + "src/testSupport/registerVue.mjs" ] diff --git a/scripts/format.mjs b/scripts/format.mjs index 25311ba29..e91cf527b 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) { const files = []; for (const name of new Set(candidates)) { if ( - !/\.[cm]?js$/.test(name) || + !/\.([cm]?js|vue)$/.test(name) || /\.test\.[cm]?js$/.test(name) || !existsSync(path.join(root, name)) ) diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs index ec9ffe2f0..e6352502a 100644 --- a/src/materialSymbolsSubset.test.mjs +++ b/src/materialSymbolsSubset.test.mjs @@ -14,6 +14,8 @@ const SPAN_TEXT = /** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */ const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs; const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g; +/** A rail or menu item's glyph: `{ icon: 'layers' }`, rendered by `UiIcon`. */ +const ICON_PROPERTY = /\bicon:\s*['"]([a-z0-9_]+)['"]/g; /** * Files that SHIP markup. Tests assert on markup, they do not render it. @@ -29,7 +31,7 @@ function sourceFiles(directory = SRC_ROOT) { if (entry.isDirectory()) files.push(...sourceFiles(absolute)); else if ( entry.isFile() && - /\.(js|mjs|html)$/.test(entry.name) && + /\.(js|mjs|html|vue)$/.test(entry.name) && !entry.name.endsWith('.test.mjs') ) { files.push(absolute); @@ -62,6 +64,7 @@ function referencedGlyphs() { if (!found.has(glyph)) found.set(glyph, relative); }; for (const match of source.matchAll(SPAN_TEXT)) add(match[1]); + for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]); for (const match of source.matchAll(TEXT_ASSIGNMENT)) { const statement = match[1].replaceAll('?.', '.'); const assigned = statement.includes('?') diff --git a/src/testSupport/vueDom.mjs b/src/testSupport/vueDom.mjs new file mode 100644 index 000000000..3fd138513 --- /dev/null +++ b/src/testSupport/vueDom.mjs @@ -0,0 +1,10 @@ +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +/** + * Give a component test a browser document; returns the teardown. + * Call it from `before`/`after`, since other suites assert there is no DOM. + */ +export function installVueDom() { + GlobalRegistrator.register({ url: 'http://localhost/' }); + return () => GlobalRegistrator.unregister(); +} diff --git a/src/ui/kit/UiIcon.vue b/src/ui/kit/UiIcon.vue new file mode 100644 index 000000000..4f34f29e4 --- /dev/null +++ b/src/ui/kit/UiIcon.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/ui/kit/UiRail.test.mjs b/src/ui/kit/UiRail.test.mjs new file mode 100644 index 000000000..6c443286a --- /dev/null +++ b/src/ui/kit/UiRail.test.mjs @@ -0,0 +1,73 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../../testSupport/vueDom.mjs'; + +let mount; +let UiRail; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ mount } = await import('@vue/test-utils')); + UiRail = (await import('./UiRail.vue')).default; +}); +after(() => uninstall()); + +const items = [ + { id: 'layers', label: 'DATA LAYERS', icon: 'layers' }, + { id: 'scenes', label: 'AUTHORING', icon: 'movie', separatorBefore: true }, + { id: 'cctv', label: 'CCTV', icon: 'videocam', disabled: true, badge: 3 }, +]; + +function mountRail(props = {}) { + return mount(UiRail, { + props: { items, side: 'left', label: 'Left panels', ...props }, + }); +} + +test('the rail is a named nav with one pressed button per item', () => { + const wrapper = mountRail({ active: 'scenes' }); + const nav = wrapper.get('nav'); + assert.equal(nav.attributes('aria-label'), 'Left panels'); + assert.ok(nav.classes().includes('ui-rail--left')); + const buttons = wrapper.findAll('button'); + assert.deepEqual( + buttons.map((button) => button.attributes('data-rail-item')), + ['layers', 'scenes', 'cctv'], + ); + assert.deepEqual( + buttons.map((button) => button.attributes('aria-pressed')), + ['false', 'true', 'false'], + ); + assert.equal(buttons[0].attributes('aria-label'), 'DATA LAYERS'); + assert.equal(buttons[0].get('.material-symbols-outlined').text(), 'layers'); + assert.ok(buttons[1].classes().includes('ui-rail__item--on')); + assert.equal(wrapper.findAll('.ui-rail__sep').length, 1); + assert.equal(buttons[2].attributes('disabled'), ''); + assert.equal(buttons[2].get('.ui-rail__badge').text(), '3'); +}); + +test('selecting an item emits its id', async () => { + const wrapper = mountRail(); + await wrapper.get('[data-rail-item="scenes"]').trigger('click'); + assert.deepEqual(wrapper.emitted('select'), [['scenes']]); +}); + +test('tips open toward the screen, and not for the open panel', async () => { + const wrapper = mountRail({ + side: 'right', + active: 'layers', + panelOpen: true, + }); + const [open, other] = wrapper.findAll('.ui-tooltip'); + await open.trigger('focusin'); + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); + await other.trigger('focusin'); + // Over an open panel the other tips wait for the pointer to rest. + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); + await new Promise((resolve) => setTimeout(resolve, 450)); + const tip = wrapper.get('[role="tooltip"]'); + assert.equal(tip.text(), 'AUTHORING'); + assert.ok(tip.classes().includes('ui-tooltip__tip--left')); + await other.trigger('focusout'); + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); +}); diff --git a/src/ui/kit/UiRail.vue b/src/ui/kit/UiRail.vue new file mode 100644 index 000000000..af740aae3 --- /dev/null +++ b/src/ui/kit/UiRail.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/ui/kit/UiTooltip.vue b/src/ui/kit/UiTooltip.vue new file mode 100644 index 000000000..4c63f2316 --- /dev/null +++ b/src/ui/kit/UiTooltip.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/ui/kit/rail.js b/src/ui/kit/rail.js new file mode 100644 index 000000000..498628770 --- /dev/null +++ b/src/ui/kit/rail.js @@ -0,0 +1,12 @@ +/** + * @typedef {object} UiRailItem + * @property {string} id Item identity, emitted on selection. + * @property {string} label Translated name, shown as the tooltip and accessible name. + * @property {string} icon Material Symbols glyph. + * @property {boolean} [disabled] Shown but not selectable. + * @property {boolean} [separatorBefore] Draw a divider above this item. + * @property {string|number} [badge] Small count or marker on the icon. + */ + +/** How long the pointer rests on an item before its tip shows over an open panel. */ +export const RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS = 400; diff --git a/src/ui/styles/kit.css b/src/ui/styles/kit.css new file mode 100644 index 000000000..ef65c1215 --- /dev/null +++ b/src/ui/styles/kit.css @@ -0,0 +1,100 @@ +/* Generic components in src/ui/kit. Names follow Halfpixel's ui-vue kit; the + surfaces use this application's glass, accent and type tokens. */ + +.ui-icon { + display: inline-block; + line-height: 1; + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 20; + user-select: none; +} + +.ui-tooltip { + position: relative; + display: inline-flex; +} +.ui-tooltip__tip { + position: absolute; + top: 50%; + z-index: 1; + padding: 4px 8px; + border: 1px solid var(--glass-border); + border-radius: 6px; + background: var(--menu-bg); + color: var(--dock-label-color); + font: var(--dock-label-font-weight) var(--dock-label-font-size) / 1.4 + var(--font-sans); + letter-spacing: var(--dock-label-letter-spacing); + white-space: nowrap; + pointer-events: none; + transform: translateY(-50%); +} +.ui-tooltip__tip--right { + left: calc(100% + 10px); +} +.ui-tooltip__tip--left { + right: calc(100% + 10px); +} + +.ui-rail { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px; + border: 1px solid var(--glass-border); + border-radius: calc(var(--btn-radius) + 4px); + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + pointer-events: auto; +} +.ui-rail__sep { + height: 1px; + margin: 3px 6px; + background: var(--glass-border); +} +.ui-rail__item { + position: relative; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid transparent; + border-radius: var(--btn-radius); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: + color var(--transition-fast), + background var(--transition-fast), + border-color var(--transition-fast); +} +.ui-rail__item:hover:not(:disabled) { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} +.ui-rail__item--on, +.ui-rail__item--on:hover:not(:disabled) { + border-color: var(--accent-dim); + background: var(--accent-dim); + color: var(--accent); +} +.ui-rail__item:disabled { + opacity: 0.35; + cursor: default; +} +.ui-rail__badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 14px; + padding: 0 3px; + border-radius: 7px; + background: var(--accent); + color: var(--bg-dark); + font: 600 9px/14px var(--font-mono); + text-align: center; +} diff --git a/style.css b/style.css index 1b55c459f..f887debb1 100644 --- a/style.css +++ b/style.css @@ -32,6 +32,7 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; +@import './src/ui/styles/kit.css'; @import './src/ui/styles/cjk.css'; @import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/cyber.css'; From 2035c767ce845d4b43cb01a750f0a7921d75663b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:28:08 -0700 Subject: [PATCH 004/116] feat(ui): keep one side panel open per desktop sidebar in every HUD layout The Cyber accordion now applies to both sidebars and every HUD layout: opening a side panel collapses its peers, the layout normalizes restored state to one owner, and collapsed panels are hidden on desktop. Phones keep showing several panels per sheet. --- src/panelStackLayout.test.mjs | 4 +- src/rightRailPolicy.js | 2 - src/rightRailPolicy.test.mjs | 52 ++----- src/ui/leftPanelRail.js | 49 ++++-- src/ui/mobileShell.js | 2 +- src/ui/panelChrome.js | 37 ++--- src/ui/panelRailGeometry.js | 16 -- src/ui/panelRails.js | 5 +- src/ui/panelRails.test.mjs | 139 +++++++++--------- src/ui/rightPanelRail.js | 33 ++--- ....test.mjs => sidePanelDisclosure.test.mjs} | 50 ++++--- src/ui/styles/layers.css | 3 + 12 files changed, 178 insertions(+), 214 deletions(-) rename src/ui/{cyberPanelDisclosure.test.mjs => sidePanelDisclosure.test.mjs} (60%) diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index a13f5c04b..ecccd21a1 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -188,7 +188,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c 'focus mode must hide every collapsed sibling, including presentation-only auto-collapses', ); assert.doesNotMatch(css, /layout-focus > \[data-panel-id\]\.collapsed:not\(\.layout-auto-collapsed\)/); - assert.match(leftRail, /const hiddenSibling =\s*shouldFocus && panel\.classList\.contains\('collapsed'\);/); + assert.match(leftRail, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); }); test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { @@ -198,7 +198,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre assert.match( ui, - /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, + /const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/, 'responsive auto-collapse must serialize the explicit expanded preference', ); assert.match( diff --git a/src/rightRailPolicy.js b/src/rightRailPolicy.js index 02d7972c4..08eab4fb0 100644 --- a/src/rightRailPolicy.js +++ b/src/rightRailPolicy.js @@ -1,5 +1,3 @@ -export { shouldHideCollapsedRightPanels } from './ui/panelRailGeometry.js'; - const GLOBAL_CONTEXT_EXPLICIT_ACTIONS = new Set([ 'contacts', 'space-missions', diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..dde4c64be 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -2,50 +2,20 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import { - shouldExpandGlobalContextPanel, - shouldHideCollapsedRightPanels, -} from './rightRailPolicy.js'; +import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js'; -test('Tactical HUD hides collapsed right-rail siblings while one panel is expanded', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'tactical', - hasExpandedPanel: true, - }), true); -}); - -test('collapsed launchers remain when Tactical has no expanded panel', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'tactical', - hasExpandedPanel: false, - }), false); -}); - -test('other HUD layouts keep collapsed right-rail launchers visible', () => { - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'minimal', - hasExpandedPanel: true, - }), false); - assert.equal(shouldHideCollapsedRightPanels({ - hudVariant: 'full', - hasExpandedPanel: true, - }), false); -}); - -test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => { - const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); +test('desktop side rails hide collapsed panels in every HUD layout, never on phones', () => { + const right = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); + const left = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); const css = readStylesheet(new URL('../style.css', import.meta.url)); - assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); - assert.match( - ui, - /!panel\.classList\.contains\('collapsed'\)\s*&&\s*\(!isMobile \|\| panel\.id !== 'pp-toggles'\)/, - ); - assert.doesNotMatch( - ui, - /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, - ); - assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); + for (const ui of [right, left]) { + assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); + assert.match(ui, /const exclusive = !isMobile;/); + assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); + assert.doesNotMatch(ui, /hud\.variant === 'cyber'/); + } assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); + assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index 607e13083..a0de82746 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -21,6 +21,7 @@ import { measurePanelNaturalHeight } from './panelMeasurement.js'; * @param {Function} [options.getComputedStyle] Optional DOM style reader override. * @param {Map} options.collapsedHeights Caller-owned measurement cache. * @param {Function} options.onAligned Notify the caller after the left rail moves. + * @param {Document} [options.documentRef] Document supplying current keyboard focus. */ export function layoutLeftPanelRail({ stack, @@ -32,6 +33,7 @@ export function layoutLeftPanelRail({ onRetry, collapsedHeights, onAligned, + documentRef = stack?.ownerDocument, getComputedStyle = (element) => windowRef.getComputedStyle(element), }) { if (!stack) return; @@ -49,20 +51,49 @@ export function layoutLeftPanelRail({ // The existing narrow-screen composition has its own full-width stack. // Keep this desktop lane engine from fighting those dedicated rules. - if (windowRef.matchMedia('(max-width: 720px)').matches) { + const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; + if (isMobile) { stack.classList.remove('layout-focus'); stack.classList.remove('layout-tail'); stack.style.removeProperty('--left-stack-safe-top'); stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-centered-height'); + stack.classList.remove('layout-exclusive'); stack.dataset.layoutMode = 'mobile'; for (const panel of panels) { + if (panel.classList.contains('accordion-collapsed')) { + panel.classList.remove('collapsed', 'accordion-collapsed'); + onCollapse(panel); + } panel.removeAttribute('aria-hidden'); panel.style.removeProperty('--left-panel-allocated-height'); } return; } + // One panel is open per desktop side and the panel rail lists the others, + // so collapsed panels are hidden. Normalize restored state without + // overwriting saved panel preferences. + const expandedBeforeAccordion = panels.filter( + (panel) => !panel.classList.contains('collapsed'), + ); + const accordionOwner = + expandedBeforeAccordion.find((panel) => panel.id === preferredPanelId) || + expandedBeforeAccordion.find((panel) => + panel.contains(documentRef?.activeElement), + ) || + expandedBeforeAccordion[0]; + for (const panel of expandedBeforeAccordion) { + if (panel === accordionOwner) continue; + panel.classList.add('collapsed', 'accordion-collapsed'); + onCollapse(panel); + } + const exclusive = !isMobile; + stack.classList.toggle('layout-exclusive', exclusive); + const laidOutPanels = panels.filter( + (panel) => !exclusive || !panel.classList.contains('collapsed'), + ); + const viewportHeight = Math.max(1, windowRef.innerHeight); const stackRect = stack.getBoundingClientRect(); const baseTop = viewportHeight * 0.26; @@ -162,7 +193,7 @@ export function layoutLeftPanelRail({ (sum, height) => sum + height, 0, ); - const siblingHeight = panels.reduce((total, panel) => { + const siblingHeight = laidOutPanels.reduce((total, panel) => { if (!panel.classList.contains('collapsed')) return total; const measured = collapsedHeights.get(panel.id); return total + (measured || panel.getBoundingClientRect().height || 0); @@ -170,12 +201,8 @@ export function layoutLeftPanelRail({ let requiredHeight = siblingHeight; const rowGap = parseFloat(getComputedStyle(stack).rowGap) || 0; - if (expandedPanels.length) { - requiredHeight += naturalExpandedHeight; - requiredHeight += rowGap * Math.max(0, panels.length - 1); - } else { - requiredHeight += rowGap * Math.max(0, panels.length - 1); - } + if (expandedPanels.length) requiredHeight += naturalExpandedHeight; + requiredHeight += rowGap * Math.max(0, laidOutPanels.length - 1); const wasFocused = stack.classList.contains('layout-focus'); const wasTail = stack.classList.contains('layout-tail'); @@ -189,7 +216,7 @@ export function layoutLeftPanelRail({ const tailRequiredHeight = naturalExpandedHeight + siblingHeight + - rowGap * Math.max(0, panels.length - 1); + rowGap * Math.max(0, laidOutPanels.length - 1); // A compact expansion should not make the whole control stack jump down // merely to center a few short rows. Preserve the normal top anchor when // the centered stack would begin below it; tall stacks can still grow @@ -296,8 +323,8 @@ export function layoutLeftPanelRail({ // CockpitView.syncSignalLayout() owns `--cockpit-utility-top` instead. for (const panel of panels) { - const hiddenSibling = shouldFocus && panel.classList.contains('collapsed'); - if (hiddenSibling) panel.setAttribute('aria-hidden', 'true'); + if (exclusive && panel.classList.contains('collapsed')) + panel.setAttribute('aria-hidden', 'true'); else panel.removeAttribute('aria-hidden'); } // The right controls share this top baseline; update them after the left diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index 2363fd9ed..07c21a46b 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -208,7 +208,7 @@ export function initMobileShell({ return ( classes.contains('collapsed') && !classes.contains('layout-auto-collapsed') && - !classes.contains('cyber-accordion-collapsed') + !classes.contains('accordion-collapsed') ); }; diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index bcf730f60..459694967 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -362,7 +362,7 @@ export class PanelChrome { // restore the user's explicit expanded preference at its own viewport. const collapsed = panelEl.classList.contains('layout-auto-collapsed') || - panelEl.classList.contains('cyber-accordion-collapsed') + panelEl.classList.contains('accordion-collapsed') ? false : panelEl.classList.contains('collapsed'); // A panel the phone shell only borrowed for a sheet shares its own state. @@ -420,19 +420,22 @@ export class PanelChrome { const nextCollapsed = Boolean(collapsed); const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed') || - panelEl.classList.contains('cyber-accordion-collapsed'); - // A user opening a Cyber rail panel owns the whole accordion, including - // peers that were only presentation-collapsed during a saved-state restore. + panelEl.classList.contains('accordion-collapsed'); + // One panel is open per desktop side: a user opening a side panel owns + // the whole accordion, including peers that were only + // presentation-collapsed during a saved-state restore. + const sideStack = [this._leftPanelStack, this._rightPanelStack].find( + (stack) => stack && panelEl.parentElement === stack, + ); if ( explicit && !restore && !nextCollapsed && - document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack && + sideStack && // Phone sheets show several rail panels at once. !document.body?.classList?.contains('mobile-shell') ) { - for (const peer of this._rightPanelStack.children) { + for (const peer of sideStack.children) { if ( peer !== panelEl && peer.matches('[data-panel-id]') && @@ -480,7 +483,6 @@ export class PanelChrome { // Reveal Radio's owner before the no-op check: the nested section may // already be expanded while its Context parent is closed. if ( - document.documentElement?.dataset.uiTheme === 'cyber' && !nextCollapsed && panelId === 'radio-panel' && document @@ -507,10 +509,7 @@ export class PanelChrome { } return; } - panelEl.classList.remove( - 'layout-auto-collapsed', - 'cyber-accordion-collapsed', - ); + panelEl.classList.remove('layout-auto-collapsed', 'accordion-collapsed'); if ( !nextCollapsed && this.cockpitView?.active && @@ -522,20 +521,6 @@ export class PanelChrome { this.cockpitView.setContextCollapsed(true); } } - if ( - !nextCollapsed && - panelId === 'radio-panel' && - document.documentElement?.dataset.uiTheme !== 'cyber' && - document - .getElementById('global-context-panel') - ?.classList.contains('collapsed') - ) { - this.setPanelCollapsed('global-context-panel', false, { - restore, - persist, - syncShare, - }); - } if (!nextCollapsed && !restore && panelId === 'location-bar') { const otherPanel = document.getElementById('control-panel'); if (otherPanel && !otherPanel.classList.contains('dock-pinned')) { diff --git a/src/ui/panelRailGeometry.js b/src/ui/panelRailGeometry.js index 8045286b0..b5a129c21 100644 --- a/src/ui/panelRailGeometry.js +++ b/src/ui/panelRailGeometry.js @@ -60,19 +60,3 @@ export function resolveHudRailLayout({ constrained: panelHeight > availableHeight, }; } - -/** - * Tactical HUD gives an expanded right-rail panel the whole control lane. - * Other HUD layouts keep collapsed launchers visible for quick switching. - * - * @param {object} input Current rail state. - * @param {string} input.hudVariant Active HUD layout variant. - * @param {boolean} input.hasExpandedPanel Whether any rail panel is expanded. - * @returns {boolean} Whether collapsed sibling launchers should be hidden. - */ -export function shouldHideCollapsedRightPanels({ - hudVariant, - hasExpandedPanel, -}) { - return hudVariant === 'tactical' && Boolean(hasExpandedPanel); -} diff --git a/src/ui/panelRails.js b/src/ui/panelRails.js index a67371cc7..e447d6302 100644 --- a/src/ui/panelRails.js +++ b/src/ui/panelRails.js @@ -1,10 +1,7 @@ export { layoutLeftPanelRail } from './leftPanelRail.js'; export { layoutRightPanelRail } from './rightPanelRail.js'; export { measurePanelNaturalHeight } from './panelMeasurement.js'; -export { - resolveHudRailLayout, - shouldHideCollapsedRightPanels, -} from './panelRailGeometry.js'; +export { resolveHudRailLayout } from './panelRailGeometry.js'; export { allocatePanelStackHeights, panelStackAutoCollapseIndices, diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 546cad87b..e7f0d07cf 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -20,61 +20,57 @@ test('Display uses an inner scroll body only in Cyber', () => { assert.equal(displayPanelScroller(null), null); }); -test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => { - for (const mobile of [false]) { - const f = fixture('right', { - mobile, - hud: { visible: true, variant: 'cyber' }, - }); +for (const side of ['left', 'right']) { + test(`${side} desktop rail keeps one expanded owner in every HUD layout`, () => { + for (const variant of ['tactical', 'minimal', 'cyber']) { + const f = fixture(side, { hud: { visible: true, variant } }); + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.preferredPanelId = f.second.id; + f.run(); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); + assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.stack.classList.contains('layout-exclusive'), true); + assert.equal(f.first.getAttribute('aria-hidden'), 'true'); + assert.equal(f.retries(), 0, 'the accordion needs no auto-collapse pass'); + f.run(); + assert.equal( + f.first.classList.contains('collapsed'), + true, + 'layout does not reopen a peer', + ); + // Phone sheets restore the presentation-only collapse. + f.options.windowRef.matchMedia = () => ({ matches: true }); + f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('accordion-collapsed'), false); + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + } + }); + + test(`${side} rail keeps no accordion on phones, where it is presented as tab sheets`, () => { + // A sheet (for example Cameras: CCTV with Street Level) shows several of the + // rail's panels at once; the accordion would collapse all but one. + const f = fixture(side, { mobile: true }); f.expand(f.first, 250); f.expand(f.second, 250); f.options.preferredPanelId = f.second.id; f.run(); - assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true); - assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - f.run(); - assert.equal( - f.first.classList.contains('collapsed'), - true, - 'layout does not reopen a peer', - ); - f.options.hud.variant = 'minimal'; - f.run(); assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal( - f.first.classList.contains('cyber-accordion-collapsed'), - false, - ); + assert.equal(f.first.classList.contains('accordion-collapsed'), false); assert.equal(f.second.classList.contains('collapsed'), false); - } -}); - -test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => { - // A sheet (for example Cameras: CCTV with Street Level) shows several of the - // rail's panels at once; the accordion would collapse all but one. - const f = fixture('right', { - mobile: true, - hud: { visible: true, variant: 'cyber' }, }); - f.expand(f.first, 250); - f.expand(f.second, 250); - f.options.preferredPanelId = f.second.id; - f.run(); - assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false); - assert.equal(f.second.classList.contains('collapsed'), false); -}); -test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => { - const f = fixture('right', { hud: { visible: true, variant: 'cyber' } }); - f.expand(f.first, 250); - f.expand(f.second, 250); - f.options.documentRef.activeElement = f.second; - f.run(); - assert.equal(f.first.classList.contains('collapsed'), true); - assert.equal(f.second.classList.contains('collapsed'), false); -}); + test(`${side} restored accordion prefers keyboard focus when no explicit owner exists`, () => { + const f = fixture(side); + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.documentRef.activeElement = f.second; + f.run(); + assert.equal(f.first.classList.contains('collapsed'), true); + assert.equal(f.second.classList.contains('collapsed'), false); + }); +} function element( id, @@ -250,16 +246,16 @@ for (const side of ['left', 'right']) { ); assert.equal(f.first.getAttribute('aria-hidden'), undefined); }); - test(`${side} constrained layout preserves the preferred panel and requests another pass`, () => { + test(`${side} constrained layout keeps the preferred panel without another pass`, () => { const f = fixture(side); f.expand(f.first, 900); f.expand(f.second, 900); f.options.preferredPanelId = 'second'; f.run(); assert.equal(f.second.classList.contains('collapsed'), false); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); assert.deepEqual(f.collapsed, ['first']); - assert.equal(f.retries(), 1); + assert.equal(f.retries(), 0); }); test(`${side} hidden HUD restores automatic collapse without altering manual collapse`, () => { const f = fixture(side, { hud: { visible: false, variant: 'tactical' } }); @@ -277,7 +273,7 @@ test('right layout uses keyboard focus when there is no preferred panel', () => f.expand(f.second, 900); f.options.documentRef.activeElement = f.second; f.run(); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), true); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); assert.equal(f.second.classList.contains('collapsed'), false); }); @@ -532,26 +528,27 @@ test('right rail enters focus only above available height and leaves below the f assert.equal(f.stack.classList.contains('layout-focus'), false); }); -test('right rail auto-collapse requests only one retry and settles within two passes', () => { +test('right rail settles a second tall panel through the accordion in one pass', () => { const f = thrashingRightRail({ weatherOpen: true }); const modes = []; for (let pass = 0; pass < 10; pass++) { f.run(); - modes.push(f.stack.classList.contains('layout-focus')); + modes.push(f.stack.dataset.layoutMode); } assert.deepEqual(f.collapsed, ['weather-panel']); - assert.equal(f.retries(), 1); - assert.deepEqual(modes.slice(1), Array(9).fill(true)); + assert.equal(f.weather.classList.contains('accordion-collapsed'), true); + assert.equal(f.retries(), 0); + assert.deepEqual(modes.slice(1), Array(9).fill(modes[0])); }); -test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => { +test('right rail reopening a peer outside the rail keeps one owner without retrying', () => { const f = thrashingRightRail({ weatherOpen: true }); f.run(); - assert.equal(f.retries(), 1); - f.weather.classList.remove('collapsed', 'layout-auto-collapsed'); + f.weather.classList.remove('collapsed', 'accordion-collapsed'); f.run(); - assert.equal(f.retries(), 1); - assert.equal(f.stack.dataset.layoutMode, 'focus'); + assert.equal(f.retries(), 0); + assert.equal(f.weather.classList.contains('collapsed'), true); + assert.equal(f.cctv.classList.contains('collapsed'), false); }); test('right rail restores presentation even when intrinsic measurement throws', () => { @@ -595,14 +592,14 @@ test('right rail measuring pass keeps an opted-in scroller position', () => { }); for (const variant of ['minimal', 'full']) { - test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => { + test(`right rail settles two tall panels without retrying in the ${variant} HUD`, () => { const f = fixture('right', { hud: { visible: true, variant } }); f.expand(f.first, 900); f.expand(f.second, 900); for (let pass = 0; pass < 10; pass++) f.run(); assert.equal(f.retries(), 0); assert.equal(f.first.classList.contains('collapsed'), false); - assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('accordion-collapsed'), true); }); } // ── Narrow-screen rail overflow pin ────────────────────────────────────────── @@ -699,10 +696,11 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity f.run(); - assert.equal(f.stack.classList.contains('layout-exclusive'), false); + // The rail lists the collapsed panels; the CSS-hidden one claims nothing. + assert.equal(f.stack.classList.contains('layout-exclusive'), true); assert.equal(f.stack.dataset.expandedCount, '0'); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - assert.equal(f.second.getAttribute('aria-hidden'), undefined); + assert.equal(weather.classList.contains('collapsed'), false); + assert.equal(weather.getAttribute('aria-hidden'), undefined); assert.equal( weather.style.getPropertyValue('--right-panel-allocated-height'), '', @@ -730,9 +728,10 @@ test('right layout still restores panels that exclusive presentation hides', () f.options.hud.visible = false; f.run(); - assert.equal(f.second.classList.contains('collapsed'), false); + // Restored from the space-based collapse, it yields to the accordion owner. assert.equal(f.second.classList.contains('layout-auto-collapsed'), false); - assert.deepEqual(f.collapsed, ['second']); + assert.equal(f.second.classList.contains('accordion-collapsed'), true); + assert.equal(f.first.classList.contains('collapsed'), false); }); test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { @@ -766,10 +765,10 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', ( const retriesBefore = f.retries(); f.run(); assert.ok( - f.collapsed.includes('recent-imagery-panel'), - 'focus mode collapses the later panel and asks for another pass', + imagery.classList.contains('accordion-collapsed'), + 'the accordion keeps the first panel and collapses the later one', ); - assert.equal(f.retries(), retriesBefore + 1); + assert.equal(f.retries(), retriesBefore); f.run(); assert.equal(f.stack.dataset.expandedCount, '1'); assert.ok( diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 5d3e5e40c..5afdce2b7 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -2,10 +2,7 @@ import { allocatePanelStackHeights, panelStackAutoCollapseIndices, } from '../panelStackLayout.js'; -import { - resolveHudRailLayout, - shouldHideCollapsedRightPanels, -} from './panelRailGeometry.js'; +import { resolveHudRailLayout } from './panelRailGeometry.js'; import { displayPanelScroller } from './displayPanelScroll.js'; @@ -89,11 +86,12 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - // Normalize restored state and theme entry without overwriting saved panel - // preferences. This marker is distinct from Tactical's space-based collapse. - // Phones present the rail as tab sheets that show several panels at once. + // One panel is open per desktop side. Normalize restored state without + // overwriting saved panel preferences; this marker is distinct from + // Tactical's space-based collapse. Phones present the rail as tab sheets + // that show several panels at once. const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; - if (hud.variant === 'cyber' && !isMobile) { + if (!isMobile) { const expanded = panels.filter( (panel) => !panel.classList.contains('collapsed'), ); @@ -103,29 +101,20 @@ export function layoutRightPanelRail({ expanded[0]; for (const panel of expanded) { if (panel === owner) continue; - panel.classList.add('collapsed', 'cyber-accordion-collapsed'); + panel.classList.add('collapsed', 'accordion-collapsed'); onCollapse(panel); } } else { for (const panel of panels) { - if (!panel.classList.contains('cyber-accordion-collapsed')) continue; - panel.classList.remove('collapsed', 'cyber-accordion-collapsed'); + if (!panel.classList.contains('accordion-collapsed')) continue; + panel.classList.remove('collapsed', 'accordion-collapsed'); onCollapse(panel); } } - const hasExpandedPanel = panels.some( - (panel) => - !panel.classList.contains('collapsed') && - (!isMobile || panel.id !== 'pp-toggles'), - ); + // The desktop panel rail lists every panel, so collapsed ones are hidden. // Phones present the rail as tab sheets (mobileShell.js), which choose what // shows; hiding collapsed siblings would empty a sheet and its a11y tree. - const exclusive = - !isMobile && - shouldHideCollapsedRightPanels({ - hudVariant: hud.variant, - hasExpandedPanel, - }); + const exclusive = !isMobile; stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { if (exclusive && panel.classList.contains('collapsed')) diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs similarity index 60% rename from src/ui/cyberPanelDisclosure.test.mjs rename to src/ui/sidePanelDisclosure.test.mjs index e2a666ba5..41b0977f7 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/sidePanelDisclosure.test.mjs @@ -19,6 +19,10 @@ function fixture(theme = 'cyber') { nodes.set(id, value); return value; }; + const left = node('left-stack'); + const layers = node('data-panel', false), scenes = node('scene-panel'); + left.children = [layers, scenes]; + for (const panel of left.children) panel.parentElement = left; const rail = node('rail'); const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel'); rail.children = [display, context, cctv]; @@ -27,6 +31,7 @@ function fixture(theme = 'cyber') { radio.parentElement = context; const saved = [], claimed = []; const owner = { + _leftPanelStack: left, _rightPanelStack: rail, _panelLayout: {}, _lifetime: { frame() {} }, @@ -36,29 +41,36 @@ function fixture(theme = 'cyber') { shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} }, }; owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner); - return { display, context, cctv, radio, owner, saved, claimed, + return { layers, scenes, display, context, cctv, radio, owner, saved, claimed, doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) }, }; } -test('explicit Cyber opening collapses peers and claims their restoration lanes', () => { - const f = fixture(), prior = globalThis.document; - globalThis.document = f.doc; - try { - f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true }); - f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); - assert.equal(f.display.classList.contains('collapsed'), false); - assert.equal(f.context.classList.contains('collapsed'), true); - assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed)); - assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel')); - f.cctv.classList.add('cyber-accordion-collapsed'); - f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true }); - assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false); - } finally { globalThis.document = prior; } +test('explicit opening collapses side peers and claims their restoration lanes in every theme', () => { + for (const theme of ['tactical', 'minimal', 'cyber']) { + const f = fixture(theme), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true }); + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + assert.equal(f.display.classList.contains('collapsed'), false); + assert.equal(f.context.classList.contains('collapsed'), true); + assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed)); + assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel')); + f.cctv.classList.add('accordion-collapsed'); + f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true }); + assert.equal(f.cctv.classList.contains('accordion-collapsed'), false); + // The left side has its own accordion; the right side is untouched. + f.owner.setPanelCollapsed('scene-panel', false, { explicit: true }); + assert.equal(f.scenes.classList.contains('collapsed'), false); + assert.equal(f.layers.classList.contains('collapsed'), true); + assert.equal(f.display.classList.contains('collapsed'), false); + } finally { globalThis.document = prior; } + } }); test('opening already-expanded Radio reveals Context and closes Display', () => { - const f = fixture(), prior = globalThis.document; + const f = fixture('tactical'), prior = globalThis.document; globalThis.document = f.doc; try { f.owner.setPanelCollapsed('radio-panel', false, { explicit: true }); @@ -71,17 +83,17 @@ test('opening already-expanded Radio reveals Context and closes Display', () => } finally { globalThis.document = prior; } }); -test('non-Cyber explicit panels and restored preferences retain their existing policy', () => { +test('restored preferences keep every saved panel open for the layout to normalize', () => { for (const theme of ['tactical', 'minimal', 'cyber']) { const f = fixture(theme), prior = globalThis.document; globalThis.document = f.doc; try { - const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true }; + const options = { restore: true, persist: false }; f.owner.setPanelCollapsed('pp-toggles', false, options); f.owner.setPanelCollapsed('cctv-panel', false, options); assert.equal(f.display.classList.contains('collapsed'), false); assert.equal(f.cctv.classList.contains('collapsed'), false); - if (theme === 'cyber') assert.deepEqual(f.saved, []); + assert.deepEqual(f.saved, []); } finally { globalThis.document = prior; } } }); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 0f8ed5522..e71cd381c 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -260,6 +260,9 @@ #right-context-rail.layout-exclusive > [data-panel-id].collapsed { display: none; } +#left-panel-stack.layout-exclusive > [data-panel-id].collapsed { + display: none; +} #right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto; From eed7657516a6668e63bd3b155c568ee6ba01dace Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:41:29 -0700 Subject: [PATCH 005/116] fix(ui): let revealed panels own their side and keep Cockpit's left strip A panel a feature opens (Street Level's viewer, a selected radio station) now becomes its side's open panel instead of being collapsed again by the next layout pass. Cockpit keeps showing its collapsed Data Layers strip, which Cyber's Cockpit layout aligns to, and Cyber's focus layout no longer re-shows collapsed left panels. Both sidebars share one accordion helper. --- scripts/package-boundaries.json | 12 ++++++--- src/rightRailPolicy.test.mjs | 9 +++++-- src/ui/leftPanelRail.js | 36 ++++++++++--------------- src/ui/panelAccordion.js | 41 +++++++++++++++++++++++++++++ src/ui/panelChrome.js | 12 ++++----- src/ui/panelRails.test.mjs | 14 ++++++++++ src/ui/rightPanelRail.js | 34 +++++++----------------- src/ui/sidePanelDisclosure.test.mjs | 17 ++++++++++++ src/ui/styles/cyber.css | 7 +++++ 9 files changed, 124 insertions(+), 58 deletions(-) create mode 100644 src/ui/panelAccordion.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9a82fa11e..29476fb6f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -426,7 +426,8 @@ "src/ui/rightPanelRail.js", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", - "src/panelStackLayout.js" + "src/panelStackLayout.js", + "src/ui/panelAccordion.js" ], "external": [] }, @@ -874,7 +875,8 @@ "src/ui/statusText.js", "src/ui/statusBadge.js", "src/scopeMaskPolicy.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/ui/panelAccordion.js" ], "external": ["cesium"] }, @@ -2810,7 +2812,8 @@ "src/ui/statusBadge.js", "src/sceneCommandRelease.js", "src/scopeMaskPolicy.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/ui/panelAccordion.js" ], "external": [ "hls.js", @@ -3603,7 +3606,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/ui/panelAccordion.js" ], "external": [] }, diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index dde4c64be..89e5b4d73 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -4,16 +4,21 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js'; -test('desktop side rails hide collapsed panels in every HUD layout, never on phones', () => { +test('desktop side rails hide collapsed panels in every HUD layout, never on phones or beside Cockpit', () => { const right = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const left = readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); const css = readStylesheet(new URL('../style.css', import.meta.url)); for (const ui of [right, left]) { assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); - assert.match(ui, /const exclusive = !isMobile;/); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); assert.doesNotMatch(ui, /hud\.variant === 'cyber'/); } + assert.match(right, /const exclusive = !isMobile;/); + // Cockpit keeps its collapsed Data Layers strip, which its layout aligns to. + assert.match( + left, + /const exclusive =\s*!isMobile && !documentRef\?\.body\?\.classList\?\.contains\('cockpit-mode'\);/, + ); assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); assert.match(css, /#left-panel-stack\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index a0de82746..d9930676a 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -5,6 +5,7 @@ import { resolvePanelStackCorridor, } from '../panelStackLayout.js'; import { measurePanelNaturalHeight } from './panelMeasurement.js'; +import { settleSideAccordion } from './panelAccordion.js'; /** * Measure and place the left panel rail for one synchronous layout pass. @@ -60,35 +61,26 @@ export function layoutLeftPanelRail({ stack.style.removeProperty('--left-stack-centered-height'); stack.classList.remove('layout-exclusive'); stack.dataset.layoutMode = 'mobile'; + settleSideAccordion({ panels, enabled: false, onCollapse }); for (const panel of panels) { - if (panel.classList.contains('accordion-collapsed')) { - panel.classList.remove('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } panel.removeAttribute('aria-hidden'); panel.style.removeProperty('--left-panel-allocated-height'); } return; } - // One panel is open per desktop side and the panel rail lists the others, - // so collapsed panels are hidden. Normalize restored state without - // overwriting saved panel preferences. - const expandedBeforeAccordion = panels.filter( - (panel) => !panel.classList.contains('collapsed'), - ); - const accordionOwner = - expandedBeforeAccordion.find((panel) => panel.id === preferredPanelId) || - expandedBeforeAccordion.find((panel) => - panel.contains(documentRef?.activeElement), - ) || - expandedBeforeAccordion[0]; - for (const panel of expandedBeforeAccordion) { - if (panel === accordionOwner) continue; - panel.classList.add('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } - const exclusive = !isMobile; + // One panel is open per desktop side, and the panel rail lists the others, + // so collapsed panels are hidden. Cockpit keeps its collapsed Data Layers + // strip, which its layout aligns to, and shows no rail. + settleSideAccordion({ + panels, + enabled: true, + preferredPanelId, + documentRef, + onCollapse, + }); + const exclusive = + !isMobile && !documentRef?.body?.classList?.contains('cockpit-mode'); stack.classList.toggle('layout-exclusive', exclusive); const laidOutPanels = panels.filter( (panel) => !exclusive || !panel.classList.contains('collapsed'), diff --git a/src/ui/panelAccordion.js b/src/ui/panelAccordion.js new file mode 100644 index 000000000..5690bf71a --- /dev/null +++ b/src/ui/panelAccordion.js @@ -0,0 +1,41 @@ +/** + * Keep one side panel open on desktop, and on phones put back the collapse + * this applied, since a sheet shows several panels at once. The owner is the + * most recently opened panel, then the one holding keyboard focus, then the + * first. `accordion-collapsed` marks the collapse as presentation only, so + * saved and shared preferences still report the panel as the user left it. + * @param {object} options + * @param {HTMLElement[]} options.panels The side's laid-out panels. + * @param {boolean} options.enabled Whether the one-panel rule applies. + * @param {string} [options.preferredPanelId] Most recently opened panel. + * @param {Document} [options.documentRef] Document supplying keyboard focus. + * @param {Function} options.onCollapse Update a panel's disclosure chrome. + */ +export function settleSideAccordion({ + panels, + enabled, + preferredPanelId, + documentRef, + onCollapse, +}) { + if (!enabled) { + for (const panel of panels) { + if (!panel.classList.contains('accordion-collapsed')) continue; + panel.classList.remove('collapsed', 'accordion-collapsed'); + onCollapse(panel); + } + return; + } + const expanded = panels.filter( + (panel) => !panel.classList.contains('collapsed'), + ); + const owner = + expanded.find((panel) => panel.id === preferredPanelId) || + expanded.find((panel) => panel.contains(documentRef?.activeElement)) || + expanded[0]; + for (const panel of expanded) { + if (panel === owner) continue; + panel.classList.add('collapsed', 'accordion-collapsed'); + onCollapse(panel); + } +} diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 459694967..8f8bd219f 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -421,14 +421,14 @@ export class PanelChrome { const wasAutoCollapsed = panelEl.classList.contains('layout-auto-collapsed') || panelEl.classList.contains('accordion-collapsed'); - // One panel is open per desktop side: a user opening a side panel owns - // the whole accordion, including peers that were only - // presentation-collapsed during a saved-state restore. + // One panel is open per desktop side: opening a side panel, whether the + // user asked or a feature revealed it (Street Level's viewer, a selected + // radio station), owns the whole accordion, including peers that were + // only presentation-collapsed during a saved-state restore. const sideStack = [this._leftPanelStack, this._rightPanelStack].find( (stack) => stack && panelEl.parentElement === stack, ); if ( - explicit && !restore && !nextCollapsed && sideStack && @@ -460,7 +460,7 @@ export class PanelChrome { : null; const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId; const priorRightOwner = this._panelLayout._rightStackPreferredPanelId; - if (explicit && !restore && !nextCollapsed && leftOwnerPanel) { + if (!restore && !nextCollapsed && leftOwnerPanel) { this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id; } else if ( explicit && @@ -470,7 +470,7 @@ export class PanelChrome { ) { this._panelLayout._leftStackPreferredPanelId = null; } - if (explicit && !restore && !nextCollapsed && rightOwnerPanel) { + if (!restore && !nextCollapsed && rightOwnerPanel) { this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id; } else if ( explicit && diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index e7f0d07cf..291eaac6c 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -777,3 +777,17 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', ( ) <= alone, ); }); + +test('left rail keeps collapsed panels shown beside Cockpit, still one open', () => { + const f = fixture('left'); + f.options.documentRef.body = { + classList: { contains: (name) => name === 'cockpit-mode' }, + }; + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.preferredPanelId = f.second.id; + f.run(); + assert.equal(f.first.classList.contains('accordion-collapsed'), true); + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); +}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 5afdce2b7..1dcd4460f 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -5,6 +5,7 @@ import { import { resolveHudRailLayout } from './panelRailGeometry.js'; import { displayPanelScroller } from './displayPanelScroll.js'; +import { settleSideAccordion } from './panelAccordion.js'; const pendingCollapseRetries = new WeakSet(); @@ -86,31 +87,16 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - // One panel is open per desktop side. Normalize restored state without - // overwriting saved panel preferences; this marker is distinct from - // Tactical's space-based collapse. Phones present the rail as tab sheets - // that show several panels at once. + // One panel is open per desktop side; this marker is distinct from + // Tactical's space-based collapse. const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; - if (!isMobile) { - const expanded = panels.filter( - (panel) => !panel.classList.contains('collapsed'), - ); - const owner = - expanded.find((panel) => panel.id === preferredPanelId) || - expanded.find((panel) => panel.contains(documentRef?.activeElement)) || - expanded[0]; - for (const panel of expanded) { - if (panel === owner) continue; - panel.classList.add('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } - } else { - for (const panel of panels) { - if (!panel.classList.contains('accordion-collapsed')) continue; - panel.classList.remove('collapsed', 'accordion-collapsed'); - onCollapse(panel); - } - } + settleSideAccordion({ + panels, + enabled: !isMobile, + preferredPanelId, + documentRef, + onCollapse, + }); // The desktop panel rail lists every panel, so collapsed ones are hidden. // Phones present the rail as tab sheets (mobileShell.js), which choose what // shows; hiding collapsed siblings would empty a sheet and its a11y tree. diff --git a/src/ui/sidePanelDisclosure.test.mjs b/src/ui/sidePanelDisclosure.test.mjs index 41b0977f7..7badbd464 100644 --- a/src/ui/sidePanelDisclosure.test.mjs +++ b/src/ui/sidePanelDisclosure.test.mjs @@ -97,3 +97,20 @@ test('restored preferences keep every saved panel open for the layout to normali } finally { globalThis.document = prior; } } }); + +test('a feature revealing a side panel makes it the side owner', () => { + const f = fixture('tactical'), prior = globalThis.document; + globalThis.document = f.doc; + try { + f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); + f.claimed.length = 0; + // Street Level's viewer or a selected radio station opens its panel + // without an explicit user disclosure. + f.owner.setPanelCollapsed('radio-panel', false); + assert.equal(f.context.classList.contains('collapsed'), false); + assert.equal(f.display.classList.contains('collapsed'), true); + assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel'); + // Only a user's choice claims restoration lanes. + assert.deepEqual(f.claimed, []); + } finally { globalThis.document = prior; } +}); diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 4dd7de5b3..b625b9f81 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1674,6 +1674,13 @@ flex: 0 0 auto; } + /* The panel rail lists the collapsed panels. */ + :root[data-ui-theme='cyber'] + #left-panel-stack.layout-focus.layout-exclusive + > [data-panel-id].collapsed { + display: none; + } + :root[data-ui-theme='cyber'] #left-panel-stack > :is(#data-panel, #scene-panel) { From 3d5d3ef014714bb58c45d755d795c81108ccace1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:41:29 -0700 Subject: [PATCH 006/116] feat(ui): add a desktop panel rail to each sidebar Each side stack hosts a Vue rail listing its panels; selecting one opens it through PanelChrome. The shell owns one Vue runtime and Pinia instance, and a panel store mirrors each side's panels from the DOM. The rail hides with its stack, in Cockpit, clean view and recording, and on phones. Panels without a catalog entry get a generic item, and Data Layers leaves the rail while the f shortcut hides it. --- CHANGELOG.md | 6 ++ docs/CURRENT-STATE.md | 31 +++++- docs/UI-OWNERSHIP.md | 1 + docs/VUE-MIGRATION.md | 19 ++-- index.html | 2 +- scripts/check-package-boundaries.mjs | 12 ++- scripts/format-scope.json | 4 +- scripts/i18n.mjs | 1 + scripts/package-boundaries.json | 25 ++++- src/i18n/locales/de.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/uk.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-Hant.json | 4 +- src/ui/PanelRailView.vue | 47 +++++++++ src/ui/applicationShell.js | 13 +++ src/ui/panelLayoutController.js | 7 +- src/ui/panelNavigation.js | 123 +++++++++++++++++++++++ src/ui/panelNavigation.test.mjs | 140 +++++++++++++++++++++++++++ src/ui/panelRailCatalog.js | 41 ++++++++ src/ui/stores/panels.js | 57 +++++++++++ src/ui/stores/panels.test.mjs | 27 ++++++ src/ui/styles/layers.css | 27 ++++++ src/ui/vueRuntime.js | 36 +++++++ 29 files changed, 627 insertions(+), 32 deletions(-) create mode 100644 src/ui/PanelRailView.vue create mode 100644 src/ui/panelNavigation.js create mode 100644 src/ui/panelNavigation.test.mjs create mode 100644 src/ui/panelRailCatalog.js create mode 100644 src/ui/stores/panels.js create mode 100644 src/ui/stores/panels.test.mjs create mode 100644 src/ui/vueRuntime.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 356289841..134032969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Each desktop sidebar has an icon rail that lists every panel on that side + and opens one with a click. One panel is open per side in every HUD layout, + so the collapsed panel headers no longer take up the sidebars. Phones keep + the bottom tab bar. The rail is the first view built with Vue and Pinia, + which now ship with the app; see docs/VUE-MIGRATION.md (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfdc06904..3c18bf83d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1545,6 +1545,28 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. +## Panel rails + +On desktop each side stack has a floating icon rail (`src/ui/panelNavigation.js`, +rendered by the Vue `PanelRailView` and the kit's `UiRail`) in the screen-edge +gutter beside it. The rail lists every panel the side currently offers, in stack +order: Data Layers and Authoring on the left; Display, CCTV, Weather and Recent +Imagery (while shown), Street Level and Context on the right. Selecting an item +opens its panel through `setPanelCollapsed(id, false, {explicit: true})`; +selecting the open panel's item closes it. The item of the open panel is +pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open. + +One panel is open per desktop side, in every HUD layout. Opening a side panel +by any route (rail, header, keyboard, voice) collapses its peers; restored or +shared state that opens several is normalized by the layout pass, which keeps +the most recently opened panel, then the one holding keyboard focus, then the +first, and marks the others `accordion-collapsed` (presentation only, so share +links still report the saved preference). Collapsed panels are hidden +(`layout-exclusive`) because the rail lists them. The rail lives inside its +stack, so it hides with it in Cockpit (where Authoring's item is also hidden), +recording and clean view. The panel store (`src/ui/stores/panels.js`) mirrors +each side's panels from the DOM; `PanelChrome` remains the disclosure owner. + ## Phone layout At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and @@ -1580,8 +1602,9 @@ sheet before a panel's own Escape handling, and the dock's hover auto-close does not apply inside a sheet. Cockpit, recording and clean view close the sheet and hide the tab bar; scene playback keeps both so its STOP control in the Authoring panel stays reachable. The -right rail neither hides collapsed siblings nor applies Cyber's one-panel -accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the +side rails neither hide collapsed siblings nor apply the desktop one-panel +accordion at these widths, and the panel rails are hidden, since the sheet +chooses what shows. The Cesium and Google attribution stays visible above the voice control, and above an open sheet. The page draws under the notch and system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets. Wider viewports are unchanged, and crossing to them closes any open sheet. @@ -5622,7 +5645,7 @@ apply to `public/` assets or built-preview serving. - GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state). - The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored. - The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded). -- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally. +- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls internally. - At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Authoring was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet. - `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay. - Collapsed mini-status indicators show active style and active location/landmark. @@ -6055,7 +6078,7 @@ After the initial broadside launch profile, the ascent chase camera stays in a r During orbit replay, the camera continues following the selected vehicle but eases its look-at target down toward the vehicle's sub-satellite globe anchor. The range expands when necessary for high-altitude missions, keeping both Earth and the tracked label visible through the full revolution. The active replay clock clamps at the final orbital sample rather than wrapping to ascent progress zero; replay completion therefore leaves the final globe/orbit framing in place and does not return to the launch site. Reconstructed mission orbits use a small downrange launch-to-insertion arc, so their estimated ground track is not artificially drawn directly over the launch pad in top-down views. The ascent remains connected to the ring at its selected insertion point. -Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; DISPLAY may remain open beside CCTV or Context, while CCTV and Context remain mutually exclusive without persisting forced collapses. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely. +Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; one right-side panel is open at a time, without persisting the collapses that rule forces. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely. Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the active thrust ring is visible; stage-recovery handoff uses a pulsing dot. ## Tooling Snapshot diff --git a/docs/UI-OWNERSHIP.md b/docs/UI-OWNERSHIP.md index 8d24215c7..4132c4779 100644 --- a/docs/UI-OWNERSHIP.md +++ b/docs/UI-OWNERSHIP.md @@ -11,6 +11,7 @@ scene playback and voice. It delegates; new behavior belongs with its owner. | `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal | | `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime | | `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration | +| `panelNavigation.js` | Desktop panel rails: each side's offered panels and the open one | | `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls | | `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners | | `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime | diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index f5846c99c..e86562699 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -45,14 +45,13 @@ They contain no application state. ## Phases 1. **Foundation.** Vue, Pinia and the Vite Vue plugin join the browser build - (`build/vite.js`, so host applications receive them too). One Vue app is - mounted beside the existing markup with a shared Pinia instance. Stores - mirror the state that owners already publish: panel disclosure, the UI - theme, exclusive surfaces (Cockpit, scene playback, recording, clean view), - share state and location. The existing `gev:*` window events feed them. - Nothing visible changes. -2. **Rail.** The panel store becomes the authority for which panel is open on - each side, enforcing one panel per side, and the rail renders from it. + (`build/vite.js`, so host applications receive them too). One Vue runtime is + created by the shell (`src/ui/vueRuntime.js`) with one Pinia instance that + every view shares; disposing the shell unmounts them. Stores are added + when a view needs one, mirroring state an owner already publishes, so + there is never a second owner of the same state. +2. **Rail.** `PanelChrome` enforces one panel per side; the panel store + mirrors each side's panels and the rail renders from it. Done. 3. **Panels.** Each template and its wiring become a component, keeping ids, classes, `data-i18n` text and CSS. Order: Scenes, Weather, Recent imagery, Street level, CCTV, Layers, Display, Context and Radio, the command dock, @@ -67,7 +66,9 @@ They contain no application state. - Unit tests: stores and components run under `npm test`. Single-file components are compiled by a test loader; component tests render with - `@vue/test-utils` and `happy-dom`. + `@vue/test-utils` and `happy-dom`. `npm test` registers the loader; to run + one file that reaches a component (including anything importing the + shell), use `node --import=./src/testSupport/registerVue.mjs --test `. - Visual parity: before/after screenshots of every panel at desktop and phone widths, in the default and Cyber themes, with the rail as the only expected difference. diff --git a/index.html b/index.html index 26cf734fb..373bbdd3a 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/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 28627e4ac..e019efe06 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -1,6 +1,7 @@ import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import vue from '@vitejs/plugin-vue'; import { build, normalizePath } from 'vite'; /** Build each browser or Node export group and reject imports outside its declared ownership. */ @@ -99,15 +100,20 @@ export async function checkPackageBoundaries(root) { ssr: node ? { noExternal: true } : undefined, logLevel: 'silent', plugins: [ + vue(), { name: 'check-package-ownership', moduleParsed(info) { - if (!allowed.has(info.id)) { + // Plugin helpers are virtual; a single-file component's style and + // template blocks belong to the component file itself. + if (info.id.startsWith('\0')) return; + const id = info.id.split('?')[0]; + if (!allowed.has(id)) { throw new Error( - `Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`, + `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); } - seen.add(info.id); + seen.add(id); }, }, ], diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6197aa43c..7e20adca7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1122,5 +1122,7 @@ "src/ui/kit/UiRail.test.mjs", "src/testSupport/vueDom.mjs", "src/testSupport/vueLoader.mjs", - "src/testSupport/registerVue.mjs" + "src/testSupport/registerVue.mjs", + "src/ui/panelNavigation.test.mjs", + "src/ui/stores/panels.test.mjs" ] diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index c035c6bb2..d19dc6067 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -433,6 +433,7 @@ function sourceFiles() { 'src/*.mjs', 'src/**/*.js', 'src/**/*.mjs', + 'src/**/*.vue', ).filter( (file) => !file.startsWith('src/i18n/') && diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 29476fb6f..e74c561e8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -876,9 +876,18 @@ "src/ui/statusBadge.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", + "src/ui/stores/panels.js", + "src/ui/panelRailCatalog.js", + "src/ui/vueRuntime.js", + "src/ui/kit/UiIcon.vue", + "src/ui/kit/rail.js", + "src/ui/kit/UiTooltip.vue", + "src/ui/kit/UiRail.vue", + "src/ui/PanelRailView.vue", + "src/ui/panelNavigation.js", "src/ui/panelAccordion.js" ], - "external": ["cesium"] + "external": ["cesium", "vue", "pinia"] }, "ui-styles": { "exports": ["./ui/styles"], @@ -2813,6 +2822,15 @@ "src/sceneCommandRelease.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", + "src/ui/stores/panels.js", + "src/ui/panelRailCatalog.js", + "src/ui/vueRuntime.js", + "src/ui/kit/UiIcon.vue", + "src/ui/kit/UiTooltip.vue", + "src/ui/kit/rail.js", + "src/ui/kit/UiRail.vue", + "src/ui/PanelRailView.vue", + "src/ui/panelNavigation.js", "src/ui/panelAccordion.js" ], "external": [ @@ -2824,7 +2842,9 @@ "pbf", "satellite.js", "mapillary-js", - "@jtarrio/webrtlsdr" + "@jtarrio/webrtlsdr", + "vue", + "pinia" ] }, "application-catalog": { @@ -3607,6 +3627,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/panelRailCatalog.js", "src/ui/panelAccordion.js" ], "external": [] diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 938fb20bc..9bf61dd27 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2943,5 +2943,7 @@ "Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden", "Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen", "Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten", - "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen" + "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen", + "Left panels": "Linke Panels", + "Right panels": "Rechte Panels" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5b893a0cb..f579e2af4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3013,5 +3013,7 @@ "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto", "Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta", "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", - "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos" + "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", + "Left panels": "Paneles izquierdos", + "Right panels": "Paneles derechos" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 895b121eb..0d63ef1da 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3013,5 +3013,7 @@ "Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté", "Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture", "Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans", - "Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources" + "Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources", + "Left panels": "Panneaux de gauche", + "Right panels": "Panneaux de droite" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ccce863d4..864f1f56a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3013,5 +3013,7 @@ "Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato", "Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione", "Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti", - "Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse" + "Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse", + "Left panels": "Pannelli a sinistra", + "Right panels": "Pannelli a destra" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index edd9c079f..5eea0a9fe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2873,5 +2873,7 @@ "Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む", "Download the timing log of the last play": "前回の再生のタイミングログをダウンロード", "Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集", - "Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード" + "Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード", + "Left panels": "左パネル", + "Right panels": "右パネル" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c3f4bcf90..69aab858a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3013,5 +3013,7 @@ "Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado", "Download the timing log of the last play": "Baixar o registro de tempos da última reprodução", "Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos", - "Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos" + "Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos", + "Left panels": "Painéis à esquerda", + "Right panels": "Painéis à direita" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index fe8338c3f..e7ef05f5b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3083,5 +3083,7 @@ "Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту", "Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене", "Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров", - "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов" + "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", + "Left panels": "Левые панели", + "Right panels": "Правые панели" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 37e55b961..f9067a2b8 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3083,5 +3083,7 @@ "Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON", "Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення", "Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів", - "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів" + "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів", + "Left panels": "Ліві панелі", + "Right panels": "Праві панелі" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8dc28b308..6dab5ce52 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2873,5 +2873,7 @@ "Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景", "Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景", "Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情", - "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包" + "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", + "Left panels": "左侧面板", + "Right panels": "右侧面板" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c82131223..cc57752c7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2873,5 +2873,7 @@ "Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景", "Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景", "Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情", - "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包" + "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", + "Left panels": "左側面板", + "Right panels": "右側面板" } diff --git a/src/ui/PanelRailView.vue b/src/ui/PanelRailView.vue new file mode 100644 index 000000000..8f43dabb2 --- /dev/null +++ b/src/ui/PanelRailView.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 140073e9e..cbbd0a7bd 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -51,6 +51,8 @@ import { t, } from '../i18n/index.js'; import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; +import { PanelNavigation } from './panelNavigation.js'; +import { createUiRuntime } from './vueRuntime.js'; /** * Central UI orchestrator for the God's Eye View application. @@ -133,6 +135,8 @@ export class StyleManager extends ShellFacade { readSharedCollapsed: (panelId) => this._mobileShell?.sharedCollapsed(panelId), }); + // Vue views share this runtime and its stores; dispose unmounts them. + this._ui = createUiRuntime(); this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], }); @@ -594,6 +598,13 @@ export class StyleManager extends ShellFacade { // Headers first: panel chrome builds its toggle labels from them. this._applyRuntimeStaticHeaderText(); this._initPanelChrome(); + this._panelNavigation = new PanelNavigation({ + ui: this._ui, + stacks: { left: this._leftPanelStack, right: this._rightPanelStack }, + setPanelCollapsed: (...args) => + this._panelChrome.setPanelCollapsed(...args), + }); + this._panelNavigation.init(); this._initLocaleSelector(); this._sizeDisplayLabelColumn(); this._initLeftPanelAdaptiveLayout(); @@ -1776,6 +1787,8 @@ export class StyleManager extends ShellFacade { this._localePicker = null; this._recording.destroy(); this._mobileShell?.destroy(); + this._panelNavigation?.destroy(); + this._ui.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index 0c2a2ad3a..9d9057fa0 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -1,6 +1,7 @@ /** Own rail measurement, layout scheduling and dock tray observation. */ import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js'; import { syncRadioPanelPlacement } from './radioPanelPlacement.js'; +import { isPanelRailMutation } from './panelRailCatalog.js'; const COCKPIT_LAYOUT_SETTLE_MS = 240; /** * Fixed UI regions that can occupy the left accordion's vertical lane. @@ -233,7 +234,8 @@ export class PanelLayoutController { } if (typeof MutationObserver !== 'undefined') { - this._rightStackMutationObserver = new MutationObserver(() => { + this._rightStackMutationObserver = new MutationObserver((records) => { + if (records.every(isPanelRailMutation)) return; this._scheduleRightPanelLayout(); }); this._rightStackMutationObserver.observe(stack, { @@ -334,7 +336,8 @@ export class PanelLayoutController { } if (typeof MutationObserver !== 'undefined') { - this._leftStackMutationObserver = new MutationObserver(() => { + this._leftStackMutationObserver = new MutationObserver((records) => { + if (records.every(isPanelRailMutation)) return; this._scheduleLeftPanelLayout(); }); this._leftStackMutationObserver.observe(stack, { diff --git a/src/ui/panelNavigation.js b/src/ui/panelNavigation.js new file mode 100644 index 000000000..5ef84c53a --- /dev/null +++ b/src/ui/panelNavigation.js @@ -0,0 +1,123 @@ +import PanelRailView from './PanelRailView.vue'; +import { PANEL_RAIL_HOST_CLASS, PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { usePanelStore } from './stores/panels.js'; + +/** + * Own the desktop panel rails: publish each side's panels to the panel store + * and open or close a panel when its rail item is selected. Disclosure itself + * stays with PanelChrome, which enforces one open panel per side. + */ +export class PanelNavigation { + /** + * @param {object} options + * @param {{pinia: object, mount: Function}} options.ui Shell Vue runtime. + * @param {{left: HTMLElement|null, right: HTMLElement|null}} options.stacks Side panel stacks. + * @param {Function} options.setPanelCollapsed PanelChrome's disclosure operation. + */ + constructor({ ui, stacks, setPanelCollapsed }) { + this._ui = ui; + this._stacks = stacks; + this._setPanelCollapsed = setPanelCollapsed; + this._store = usePanelStore(ui.pinia); + this._unmounts = []; + this._hosts = []; + this._observer = null; + this._destroyed = false; + } + + init() { + if (this._destroyed) return; + for (const side of ['left', 'right']) { + const stack = this._stacks[side]; + if (!stack) continue; + // Inside the stack, the rail shows and hides with its panels in every + // presentation mode (Cockpit, clean view, recording, phone sheets). + const host = stack.ownerDocument.createElement('div'); + host.className = `${PANEL_RAIL_HOST_CLASS} ${PANEL_RAIL_HOST_CLASS}--${side}`; + stack.prepend(host); + this._hosts.push(host); + this._unmounts.push( + this._ui.mount(PanelRailView, host, { + side, + onSelect: (panelId) => this.select(panelId), + }), + ); + } + if (typeof MutationObserver !== 'undefined') { + // Only the panels themselves matter: their order, disclosure and + // availability, not changes inside them or inside the rails. + this._observer = new MutationObserver((records) => { + if (records.some((record) => this._isPanelRecord(record))) this.sync(); + }); + for (const stack of Object.values(this._stacks)) { + if (!stack) continue; + this._observer.observe(stack, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'hidden'], + }); + } + } + this.sync(); + } + + /** Publish both sides' panels in display order. */ + sync() { + if (this._destroyed) return; + for (const side of ['left', 'right']) { + const stack = this._stacks[side]; + const panels = stack + ? [...stack.children] + .filter((panel) => panel.matches('[data-panel-id]')) + .map((panel) => { + const shownBy = PANEL_RAIL_ITEMS[panel.id]?.shownBy; + return { + id: panel.id, + title: + panel + .querySelector('.panel-title, .pp-header-label') + ?.textContent.trim() || panel.id, + open: !panel.classList.contains('collapsed'), + available: + !panel.hidden && + (!shownBy || panel.classList.contains(shownBy)), + }; + }) + : []; + this._store.setSide(side, panels); + } + } + + /** + * Toggle a panel from its rail item. + * @param {string} panelId Panel element id. + */ + select(panelId) { + if (this._destroyed) return; + this._setPanelCollapsed(panelId, this._store.isOpen(panelId), { + explicit: true, + }); + } + + _isPanelRecord(record) { + const stacks = Object.values(this._stacks); + return record.type === 'childList' + ? stacks.includes(record.target) && + [...record.addedNodes, ...record.removedNodes].some( + (node) => !this._hosts.includes(node), + ) + : stacks.includes(record.target.parentElement); + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this._observer?.disconnect(); + this._observer = null; + for (const unmount of this._unmounts) unmount(); + this._unmounts = []; + for (const host of this._hosts) host.remove(); + this._hosts = []; + } +} diff --git a/src/ui/panelNavigation.test.mjs b/src/ui/panelNavigation.test.mjs new file mode 100644 index 000000000..ec6cc8882 --- /dev/null +++ b/src/ui/panelNavigation.test.mjs @@ -0,0 +1,140 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../testSupport/vueDom.mjs'; + +let PanelNavigation; +let createUiRuntime; +let nextTick; +let uninstall; +before(async () => { + uninstall = installVueDom(); + ({ nextTick } = await import('vue')); + ({ PanelNavigation } = await import('./panelNavigation.js')); + ({ createUiRuntime } = await import('./vueRuntime.js')); +}); +after(() => uninstall()); + +function panel(id, { collapsed = true, hidden = false, title = '' } = {}) { + const element = document.createElement('div'); + element.id = id; + element.dataset.panelId = id; + element.className = collapsed + ? 'panel-collapsible collapsed' + : 'panel-collapsible'; + // Data Layers shows only while `active` (the `f` shortcut toggles it). + if (id === 'data-panel') element.classList.add('active'); + element.hidden = hidden; + if (title) element.innerHTML = `${title}`; + return element; +} + +function setup() { + document.body.innerHTML = ''; + const left = document.createElement('div'); + left.id = 'left-panel-stack'; + left.append(panel('data-panel', { collapsed: false }), panel('scene-panel')); + const right = document.createElement('div'); + right.id = 'right-context-rail'; + right.append( + panel('pp-toggles'), + panel('weather-panel', { hidden: true }), + panel('global-context-panel'), + panel('host-panel', { title: 'HOST' }), + ); + document.body.append(left, right); + const calls = []; + const ui = createUiRuntime(); + const navigation = new PanelNavigation({ + ui, + stacks: { left, right }, + setPanelCollapsed: (...args) => calls.push(args), + }); + navigation.init(); + return { left, right, calls, ui, navigation }; +} + +const railItems = (stack) => + [...stack.querySelectorAll('.gev-panel-rail [data-rail-item]')].map( + (button) => [button.dataset.railItem, button.getAttribute('aria-pressed')], + ); + +test('each stack gets a rail of its offered panels, marking the open one', async () => { + const { left, right, ui, navigation } = setup(); + await nextTick(); + assert.equal( + left.firstElementChild.className, + 'gev-panel-rail gev-panel-rail--left', + ); + assert.deepEqual(railItems(left), [ + ['data-panel', 'true'], + ['scene-panel', 'false'], + ]); + // Hidden panels are not offered; a host's panel gets a generic item. + assert.deepEqual(railItems(right), [ + ['pp-toggles', 'false'], + ['global-context-panel', 'false'], + ['host-panel', 'false'], + ]); + assert.equal( + right + .querySelector('[data-rail-item="host-panel"]') + .getAttribute('aria-label'), + 'HOST', + ); + assert.equal( + left.querySelector('nav').getAttribute('aria-label'), + 'Left panels', + ); + navigation.destroy(); + ui.destroy(); +}); + +test('selecting an item toggles its panel explicitly', async () => { + const { left, calls, ui, navigation } = setup(); + await nextTick(); + left.querySelector('[data-rail-item="scene-panel"]').click(); + left.querySelector('[data-rail-item="data-panel"]').click(); + assert.deepEqual(calls, [ + ['scene-panel', false, { explicit: true }], + ['data-panel', true, { explicit: true }], + ]); + navigation.destroy(); + ui.destroy(); +}); + +test('the rails follow disclosure and availability changes', async () => { + const { left, right, ui, navigation } = setup(); + document.getElementById('data-panel').classList.add('collapsed'); + document.getElementById('scene-panel').classList.remove('collapsed'); + document.getElementById('weather-panel').hidden = false; + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + assert.deepEqual(railItems(left), [ + ['data-panel', 'false'], + ['scene-panel', 'true'], + ]); + assert.deepEqual(railItems(right), [ + ['pp-toggles', 'false'], + ['weather-panel', 'false'], + ['global-context-panel', 'false'], + ['host-panel', 'false'], + ]); + // `f` hides Data Layers outright, so the rail stops offering it. + document.getElementById('data-panel').classList.remove('active'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + assert.deepEqual(railItems(left), [['scene-panel', 'true']]); + navigation.destroy(); + ui.destroy(); +}); + +test('destroy removes the rails and stops following the stacks', async () => { + const { left, right, calls, ui, navigation } = setup(); + navigation.destroy(); + navigation.destroy(); + assert.equal(left.querySelector('.gev-panel-rail'), null); + assert.equal(right.querySelector('.gev-panel-rail'), null); + navigation.select('data-panel'); + assert.deepEqual(calls, []); + ui.destroy(); +}); diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js new file mode 100644 index 000000000..e6613d001 --- /dev/null +++ b/src/ui/panelRailCatalog.js @@ -0,0 +1,41 @@ +import { msg } from '../i18n/index.js'; + +/** Class of the element each side stack hosts its rail in. */ +export const PANEL_RAIL_HOST_CLASS = 'gev-panel-rail'; + +/** Glyph for a panel without a catalog entry, such as one a host adds. */ +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`). + */ +export const PANEL_RAIL_ITEMS = Object.freeze({ + 'data-panel': { + icon: 'layers', + label: msg('DATA LAYERS'), + shownBy: 'active', + }, + 'scene-panel': { icon: 'movie', label: msg('AUTHORING') }, + 'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, + 'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, + 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, + 'recent-imagery-panel': { + icon: 'satellite_alt', + label: msg('RECENT IMAGERY'), + }, + 'street-level-panel': { icon: 'streetview', label: msg('STREET LEVEL') }, + 'global-context-panel': { icon: 'radar', label: msg('CONTEXT') }, +}); + +/** + * Whether a mutation happened inside a panel rail, which no stack layout or + * panel state depends on. + * @param {MutationRecord} record Observed mutation. + */ +export function isPanelRailMutation(record) { + const target = + record.target.nodeType === 1 ? record.target : record.target.parentElement; + return Boolean(target?.closest?.(`.${PANEL_RAIL_HOST_CLASS}`)); +} diff --git a/src/ui/stores/panels.js b/src/ui/stores/panels.js new file mode 100644 index 000000000..2fc5d2ac3 --- /dev/null +++ b/src/ui/stores/panels.js @@ -0,0 +1,57 @@ +import { defineStore } from 'pinia'; +import { reactive } from 'vue'; + +/** + * @typedef {object} RailPanel + * @property {string} id Panel element id. + * @property {string} title The panel's translated title. + * @property {boolean} open Whether the panel is expanded. + * @property {boolean} available Whether the panel is offered (not `hidden`). + */ + +const samePanels = (a, b) => + a.length === b.length && + a.every( + (panel, index) => + panel.id === b[index].id && + panel.title === b[index].title && + panel.open === b[index].open && + panel.available === b[index].available, + ); + +/** + * The desktop side panels as the rails present them. Panel disclosure stays + * with PanelChrome; this store publishes each side's panels in order. + */ +export const usePanelStore = defineStore('gev-panels', () => { + /** @type {{left: RailPanel[], right: RailPanel[]}} */ + const sides = reactive({ left: [], right: [] }); + + /** + * Replace one side's panels, keeping the array when nothing changed. + * @param {'left'|'right'} side Sidebar. + * @param {RailPanel[]} panels Panels in display order. + */ + function setSide(side, panels) { + if (!samePanels(sides[side], panels)) sides[side] = panels; + } + + /** + * @param {'left'|'right'} side Sidebar. + * @returns {string|null} The open panel's id. + */ + function openPanelId(side) { + return ( + sides[side].find((panel) => panel.open && panel.available)?.id ?? null + ); + } + + /** @param {string} id Panel id. */ + function isOpen(id) { + return [...sides.left, ...sides.right].some( + (panel) => panel.id === id && panel.open, + ); + } + + return { sides, setSide, openPanelId, isOpen }; +}); diff --git a/src/ui/stores/panels.test.mjs b/src/ui/stores/panels.test.mjs new file mode 100644 index 000000000..87ec34094 --- /dev/null +++ b/src/ui/stores/panels.test.mjs @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { usePanelStore } from './panels.js'; + +test('the panel store reports each side’s open panel and keeps unchanged sides', () => { + const store = usePanelStore(createPinia()); + const left = [ + { id: 'data-panel', open: false, available: true }, + { id: 'scene-panel', open: true, available: true }, + ]; + store.setSide('left', left); + store.setSide('right', [ + { id: 'weather-panel', open: true, available: false }, + ]); + assert.equal(store.openPanelId('left'), 'scene-panel'); + // An open panel that is not offered is not the side's open panel. + assert.equal(store.openPanelId('right'), null); + assert.equal(store.isOpen('scene-panel'), true); + assert.equal(store.isOpen('data-panel'), false); + const published = store.sides.left; + store.setSide( + 'left', + left.map((panel) => ({ ...panel })), + ); + assert.equal(store.sides.left, published); +}); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index e71cd381c..13694b20d 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -263,6 +263,33 @@ #left-panel-stack.layout-exclusive > [data-panel-id].collapsed { display: none; } + +/* Panel rails (src/ui/panelNavigation.js). Each rail lives inside its stack, + so it shows and hides with the stack, and floats in the screen-edge gutter. */ +.gev-panel-rail { + position: absolute; + top: 0; + z-index: 1; + pointer-events: auto; +} +.gev-panel-rail--left { + right: calc(100% + 6px); +} +.gev-panel-rail--right { + left: calc(100% + 6px); +} +body.mobile-shell #left-panel-stack > .gev-panel-rail, +body.mobile-shell #right-context-rail > .gev-panel-rail, +body.cockpit-mode .gev-panel-rail { + display: none; +} +/* Clean view and recording hide the panels one by one, not their stacks. */ +body.ui-clean-view .gev-panel-rail, +body.recording-mode .gev-panel-rail { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} #right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto; diff --git a/src/ui/vueRuntime.js b/src/ui/vueRuntime.js new file mode 100644 index 000000000..1a6ea0091 --- /dev/null +++ b/src/ui/vueRuntime.js @@ -0,0 +1,36 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; + +/** + * Create the application's Vue runtime: one Pinia instance shared by every + * view the shell mounts, and the mounts themselves for teardown. + * @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}} + */ +export function createUiRuntime() { + const pinia = createPinia(); + const apps = new Set(); + return { + pinia, + /** + * Mount a root component into a host element. + * @param {object} component Root component. + * @param {Element} host Element the view renders into. + * @param {object} [props] Root props. + * @returns {Function} Unmount this view. + */ + mount(component, host, props = {}) { + const app = createApp(component, props); + app.use(pinia); + app.mount(host); + apps.add(app); + return () => { + if (!apps.delete(app)) return; + app.unmount(); + }; + }, + destroy() { + for (const app of apps) app.unmount(); + apps.clear(); + }, + }; +} From b7e7058a5c757abb052f5eae064dbcc6c6e0ab53 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 16:41:29 -0700 Subject: [PATCH 007/116] test: compile option-API components and report template errors in the Vue test loader --- src/testSupport/vueLoader.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/testSupport/vueLoader.mjs b/src/testSupport/vueLoader.mjs index 5aa340b5e..3a3bb3cd9 100644 --- a/src/testSupport/vueLoader.mjs +++ b/src/testSupport/vueLoader.mjs @@ -14,16 +14,19 @@ export function compileVueModule(source, filename) { return compileScript(descriptor, { id, inlineTemplate: true }).content; } const script = descriptor.script - ? compileScript(descriptor, { id }).content.replace( - 'export default', - 'const __sfc__ =', - ) + ? compileScript(descriptor, { id, genDefaultAs: '__sfc__' }).content : 'const __sfc__ = {};'; - const template = descriptor.template - ? compileTemplate({ source: descriptor.template.content, filename, id }) - .code - : ''; - return `${script}\n${template}\n__sfc__.render = typeof render === 'function' ? render : __sfc__.render;\nexport default __sfc__;\n`; + let template = ''; + if (descriptor.template) { + const compiled = compileTemplate({ + source: descriptor.template.content, + filename, + id, + }); + if (compiled.errors.length) throw compiled.errors[0]; + template = compiled.code; + } + return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } /** Node module hook: load `.vue` files as compiled modules. */ From 9d249451438f30f020a3ce2e3725eb9a8d70fd28 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 17:23:45 -0700 Subject: [PATCH 008/116] feat(ui): render application templates as Vue views mounted before wiring Templates listed in build/applicationViews.js expand to mount hosts, and mountApplicationViews() renders them before translation and wiring. Markup tests read the page through a server-side render helper, the i18n scanner reads view templates, and Vue preserves whitespace so views keep the exact DOM. No template is converted yet. --- build/application-html.js | 12 +++++++++ build/applicationViews.js | 7 +++++ build/vite.js | 3 ++- build/vueOptions.js | 11 ++++++++ docs/VUE-MIGRATION.md | 12 ++++++++- scripts/check-package-boundaries.mjs | 3 ++- scripts/i18n.mjs | 28 ++++++++++++++----- scripts/package-boundaries.json | 12 ++++++--- src/app/application.js | 3 +++ src/cockpitMarkup.test.mjs | 4 +-- src/contextTabKeyboard.test.mjs | 4 +-- src/data/detectionPolicy.test.mjs | 4 +-- src/data/layerState.test.mjs | 4 +-- src/firstRunExperience.test.mjs | 10 +++---- src/hudA11y.test.mjs | 4 +-- src/loadingFeedback.test.mjs | 6 ++--- src/mapSourceFocus.test.mjs | 4 +-- src/mapStackChips.test.mjs | 6 ++--- src/overlays/worldOverlay.test.mjs | 6 ++--- src/panelStackLayout.test.mjs | 10 +++---- src/radioMarkup.test.mjs | 4 +-- src/reasonableDefaults.test.mjs | 4 +-- src/standalone/application.js | 3 +++ src/streetLevelMarkup.test.mjs | 4 +-- src/testSupport/applicationHtml.mjs | 40 ++++++++++++++++++++++++++++ src/testSupport/vueLoader.mjs | 8 +++++- src/tooling/applicationHtml.test.mjs | 29 ++++++++++++++++---- src/ui/applicationShell.js | 4 +-- src/ui/localSdrMarkup.test.mjs | 4 +-- src/ui/styles/kit.css | 6 +++++ src/ui/views/index.js | 27 +++++++++++++++++++ src/ui/vueRuntime.js | 20 +++++++++++--- 32 files changed, 243 insertions(+), 63 deletions(-) create mode 100644 build/applicationViews.js create mode 100644 build/vueOptions.js create mode 100644 src/testSupport/applicationHtml.mjs create mode 100644 src/ui/views/index.js diff --git a/build/application-html.js b/build/application-html.js index b14210a56..27cec5fba 100644 --- a/build/application-html.js +++ b/build/application-html.js @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { APPLICATION_VIEW_NAMES } from './applicationViews.js'; export const APPLICATION_TEMPLATES = Object.freeze([ 'scene-chrome', @@ -12,6 +13,16 @@ export const APPLICATION_TEMPLATES = Object.freeze([ 'hud-loading', ]); const allowed = new Set(APPLICATION_TEMPLATES); +const views = new Set(APPLICATION_VIEW_NAMES); + +/** + * The element a Vue view renders into. `display: contents` keeps the view's + * own elements laid out as children of the page. + * @param {string} name Application template name. + */ +export function applicationViewHost(name) { + return `
\n`; +} /** Expand only known component templates; markers cannot name filesystem paths. */ export function expandApplicationHtml(html) { @@ -20,6 +31,7 @@ export function expandApplicationHtml(html) { (_, name) => { if (!allowed.has(name)) throw new Error(`Unknown application template: ${name}`); + if (views.has(name)) return applicationViewHost(name); return readFileSync( new URL(`../src/ui/templates/${name}.html`, import.meta.url), 'utf8', diff --git a/build/applicationViews.js b/build/applicationViews.js new file mode 100644 index 000000000..c277d8b34 --- /dev/null +++ b/build/applicationViews.js @@ -0,0 +1,7 @@ +/** + * Application templates that are now Vue views (src/ui/views). Their markers + * expand to a mount host instead of static markup, and + * `mountApplicationViews()` renders them before the page is wired. The + * loading screen stays static so it paints before any script runs. + */ +export const APPLICATION_VIEW_NAMES = Object.freeze([]); diff --git a/build/vite.js b/build/vite.js index a1c9c403b..3dacdfdaa 100644 --- a/build/vite.js +++ b/build/vite.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import vue from '@vitejs/plugin-vue'; +import { VUE_PLUGIN_OPTIONS } from './vueOptions.js'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; @@ -158,7 +159,7 @@ export function createBrowserViteConfig({ cesium(), applicationHtmlPlugin(), // Application views are Vue single-file components. - vue(), + vue(VUE_PLUGIN_OPTIONS), ...plugins, embedFramingPlugin(), panelBuildPlugin(), diff --git a/build/vueOptions.js b/build/vueOptions.js new file mode 100644 index 000000000..c1c5c1b72 --- /dev/null +++ b/build/vueOptions.js @@ -0,0 +1,11 @@ +/** + * Template compiler options shared by the browser build, the boundary check + * and the unit-test loader. Whitespace is preserved so a view renders exactly + * the DOM its static markup did: inline layouts depend on text between nodes. + */ +export const VUE_COMPILER_OPTIONS = Object.freeze({ whitespace: 'preserve' }); + +/** `@vitejs/plugin-vue` options for application builds. */ +export const VUE_PLUGIN_OPTIONS = Object.freeze({ + template: { compilerOptions: VUE_COMPILER_OPTIONS }, +}); diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index e86562699..84892e3e9 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -53,7 +53,17 @@ They contain no application state. 2. **Rail.** `PanelChrome` enforces one panel per side; the panel store mirrors each side's panels and the rail renders from it. Done. 3. **Panels.** Each template and its wiring become a component, keeping ids, - classes, `data-i18n` text and CSS. Order: Scenes, Weather, Recent imagery, + classes, `data-i18n` text and CSS. A converted template is listed in + `build/applicationViews.js`; its `gev:template` marker then expands to a + `.gev-view` host (`display: contents`), and `mountApplicationViews()` + (`src/ui/views/index.js`) renders the view into it before the page is + translated and wired. `createApplication()` calls it too, so host pages + need no change. Views belong to the page and are never unmounted. The + loading screen stays static so it paints before any script runs. Markup + tests read `renderApplicationHtml()` from `src/testSupport`, which renders + the views on the server. Vue preserves template whitespace so a view's DOM + matches its template exactly. First the markup moves, unchanged; + then each view takes over its wiring. Order: Scenes, Weather, Recent imagery, Street level, CCTV, Layers, Display, Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index e019efe06..700dc9a1a 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -2,6 +2,7 @@ import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import vue from '@vitejs/plugin-vue'; +import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js'; import { build, normalizePath } from 'vite'; /** Build each browser or Node export group and reject imports outside its declared ownership. */ @@ -100,7 +101,7 @@ export async function checkPackageBoundaries(root) { ssr: node ? { noExternal: true } : undefined, logLevel: 'silent', plugins: [ - vue(), + vue(VUE_PLUGIN_OPTIONS), { name: 'check-package-ownership', moduleParsed(info) { diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index d19dc6067..f88b6549d 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -119,10 +119,10 @@ export function extractMessages() { } } - const markup = trackedFiles('index.html', 'src/ui/templates/*.html'); + const markup = markupFiles(); const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs; for (const file of markup) { - const html = readFileSync(path.join(ROOT, file), 'utf8'); + const html = readMarkup(file); for (const [, tag, attrs, text] of html.matchAll(TAG)) { const where = `${file} <${tag}>`; if (/\sdata-i18n(\s|=|$)/.test(attrs)) { @@ -404,10 +404,8 @@ export function unmarkedScript(file, original) { */ export function findUnmarked() { const found = []; - for (const file of trackedFiles('index.html', 'src/ui/templates/*.html')) - found.push( - ...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')), - ); + for (const file of markupFiles()) + found.push(...unmarkedMarkup(file, readMarkup(file))); for (const file of sourceFiles().filter( (name) => !NOT_UI.some((pattern) => pattern.test(name)), )) @@ -423,6 +421,24 @@ export function findUnmarked() { }); } +/** Static markup opted in with data-i18n*: the page, its templates and views. */ +function markupFiles() { + return trackedFiles( + 'index.html', + 'src/ui/templates/*.html', + 'src/ui/views/*.vue', + ); +} + +/** A file's markup; for a single-file component, its template block. */ +function readMarkup(file) { + const source = readFileSync(path.join(ROOT, file), 'utf8'); + if (!file.endsWith('.vue')) return source; + const start = source.indexOf(''); + return start === -1 ? '' : source.slice(start + '