feat(ui): render every application template except the loading screen as a Vue view

The scene chrome, cockpit, Display controls, command dock, layer panels,
Context rail, welcome card and Provider Settings move verbatim into
src/ui/views and mount into their hosts before the page is translated and
wired. Each view renders the same elements, attributes and text as its
template; Vue keeps whitespace between elements as one space, and no view
markup sits under a white-space: pre rule.

Template asset URLs stay literal, view files are left out of Prettier so
the markup is not reflowed, and the Node component loader moves to build/
and is exported as build/register-vue for host test runners. Hosts that do
not initialize Core's locale pass localePicker: false instead of editing
the markup.
This commit is contained in:
Sameh Khamis committed 2026-10-30 17:47:52 -07:00
1 parent 9d24945143
commit 6802c9c8b0
46 files changed
+258 -97

No files matched your search

+3
View File
@@ -20,3 +20,6 @@ node_modules
.vite
.vite-*
*.min.js
# Application views hold the page markup verbatim; reflowing it changes the DOM
src/ui/views/*View.vue
+6
View File
@@ -8,6 +8,12 @@
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).
- The page's panels, dock, cockpit and dialogs are Vue views with the same
markup as before. Hosts that do not initialize Core's locale pass
`localePicker: false` to `createApplicationControls` to drop the language
picker, and Node tests that import application modules register the
component loader with `--import=<core>/build/register-vue` (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
+10 -1
View File
@@ -4,4 +4,13 @@
* `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([]);
export const APPLICATION_VIEW_NAMES = Object.freeze([
'provider-settings',
'scene-chrome',
'cockpit',
'display-controls',
'command-dock',
'layer-panels',
'context',
'welcome',
]);
+5
View File
@@ -0,0 +1,5 @@
import { register } from 'node:module';
// Lets Node import single-file components: `node --import=<this file>`.
// Hosts reach it as `<core package>/build/register-vue`.
register('./vueLoader.js', import.meta.url);
@@ -1,10 +1,11 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc';
import { VUE_COMPILER_OPTIONS } from '../../build/vueOptions.js';
import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js';
/**
* Compile a single-file component to an ES module for Node's test runner.
* Compile a single-file component to an ES module for Node, so unit tests (the
* application's and a host's) can import components without a bundler.
* Styles are dropped: tests read the global stylesheet separately.
*/
export function compileVueModule(source, filename) {
@@ -15,7 +16,7 @@ export function compileVueModule(source, filename) {
return compileScript(descriptor, {
id,
inlineTemplate: true,
templateOptions: { compilerOptions: VUE_COMPILER_OPTIONS },
templateOptions: VUE_TEMPLATE_OPTIONS,
}).content;
}
const script = descriptor.script
@@ -27,7 +28,7 @@ export function compileVueModule(source, filename) {
source: descriptor.template.content,
filename,
id,
compilerOptions: VUE_COMPILER_OPTIONS,
...VUE_TEMPLATE_OPTIONS,
});
if (compiled.errors.length) throw compiled.errors[0];
template = compiled.code;
+13 -4
View File
@@ -1,11 +1,20 @@
/**
* 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.
* and the unit-test loader. Whitespace between nodes is kept so a view renders
* as its static markup did: inline layouts depend on text between nodes.
*/
export const VUE_COMPILER_OPTIONS = Object.freeze({ whitespace: 'preserve' });
const VUE_COMPILER_OPTIONS = Object.freeze({ whitespace: 'preserve' });
/**
* SFC template options. Asset URLs such as `/pin.svg` stay literal, as they
* were in the static markup, instead of becoming module imports.
*/
export const VUE_TEMPLATE_OPTIONS = Object.freeze({
compilerOptions: VUE_COMPILER_OPTIONS,
transformAssetUrls: false,
});
/** `@vitejs/plugin-vue` options for application builds. */
export const VUE_PLUGIN_OPTIONS = Object.freeze({
template: { compilerOptions: VUE_COMPILER_OPTIONS },
template: VUE_TEMPLATE_OPTIONS,
});
+8 -3
View File
@@ -200,6 +200,11 @@ its pending results. Source disposal does not silently reinstate a default.
`ui/composition` supplies the default engines to the shell. Applications can
supply a HUD implementation or request policy through control services. The
shared chrome owns the welcome/loading transition; standalone composition adds
Provider Settings. `build/html` expands an allowlist of component markers from
`src/ui/templates`; unknown names cannot read arbitrary filesystem paths. The
standalone document expands to the same markup as before this extraction.
Provider Settings. `build/html` expands an allowlist of component markers:
most become hosts for the Vue views in `src/ui/views`, which
`createApplication()` renders before anything reads the page, and the loading
screen is inlined from `src/ui/templates`. Unknown names cannot read arbitrary
filesystem paths. A host that does not initialize Core's locale passes
`localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`.
+1 -1
View File
@@ -276,7 +276,7 @@ compatible through re-exports.
### Adding a right-rail readout panel
Add the shell in `src/ui/templates/context.html` inside `#right-context-rail`:
Add the shell in `src/ui/views/ContextView.vue` inside `#right-context-rail`:
a `.panel-collapsible` with a stable `id` and matching `data-panel-id`, the
existing `.panel-header`, collapse button and a dedicated body. Register it
with the existing panel chrome/layout owners; do not allocate rail space in
+3 -3
View File
@@ -866,7 +866,7 @@ The standalone FIRMS provider, source defaults and parsing behavior are unchange
CLI tools and development launchers accept `GEV_PROJECT_ROOT` to select the project environment, dependencies and output directory. Their default remains this checkout; bundled tool resources and source checks stay relative to the script installation. The setup doctor also accepts an explicit `rootDir`.
Application composition now uses shared scene/control/catalog/tool constructors and allowlisted HTML component templates. The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
Application composition now uses shared scene/control/catalog/tool constructors and the application's Vue views (see [VUE-MIGRATION.md](VUE-MIGRATION.md)). The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
Voice controls compose a supplied action runner and connection controller.
Realtime token and SDP requests live in a configurable backend, with independent
@@ -5453,7 +5453,7 @@ request viewbox and continues searching without that geographic preference.
### Manual Whiteboard Drawing (September 2026)
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is a DISPLAY-rail component template (`src/ui/templates/display-controls.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is in the DISPLAY-rail view (`src/ui/views/DisplayControlsView.vue`), rendered into its `index.html` host before the page is wired. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
@@ -5517,7 +5517,7 @@ pins and share links already supply complete boxes.
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples.
+6 -4
View File
@@ -61,9 +61,11 @@ They contain no application state.
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,
the views on the server. Vue keeps the whitespace between elements as a
single space, so a view renders as its template did; no view markup sits
under a `white-space: pre` rule. The markup has moved, unchanged: every
template except the loading screen is a view. Next 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.
@@ -78,7 +80,7 @@ They contain no application state.
components are compiled by a test loader; component tests render with
`@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 <file>`.
shell), use `node --import=./build/registerVue.js --test <file>`.
- 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.
+3
View File
@@ -222,6 +222,9 @@
"./build/html": {
"node": "./build/application-html.js"
},
"./build/register-vue": {
"node": "./build/registerVue.js"
},
"./application/catalog": "./src/app/catalog.js",
"./standalone/catalog": "./src/standalone/catalog.js",
"./application/layers": "./src/app/constructCatalog.js",
+2 -2
View File
@@ -1121,8 +1121,8 @@
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/testSupport/vueDom.mjs",
"src/testSupport/vueLoader.mjs",
"src/testSupport/registerVue.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/panels.test.mjs"
]
+2 -1
View File
@@ -8,7 +8,8 @@
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// plus the markup opted in with data-i18n* in index.html, src/ui/templates and
// the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
+14 -4
View File
@@ -20,7 +20,15 @@
"modules": [
"src/app/application.js",
"src/ui/vueRuntime.js",
"src/ui/views/index.js"
"src/ui/views/index.js",
"src/ui/views/CockpitView.vue",
"src/ui/views/CommandDockView.vue",
"src/ui/views/ContextView.vue",
"src/ui/views/DisplayControlsView.vue",
"src/ui/views/LayerPanelsView.vue",
"src/ui/views/ProviderSettingsView.vue",
"src/ui/views/SceneChromeView.vue",
"src/ui/views/WelcomeView.vue"
],
"external": ["vue", "pinia"]
},
@@ -38,7 +46,7 @@
},
"vite-build": {
"runtime": "node",
"exports": ["./build/vite", "./build/html"],
"exports": ["./build/vite", "./build/html", "./build/register-vue"],
"modules": [
"build/vite.js",
"build/application-html.js",
@@ -46,9 +54,11 @@
"build/embed-framing.js",
"build/panel.js",
"build/applicationViews.js",
"build/vueOptions.js"
"build/vueOptions.js",
"build/registerVue.js",
"build/vueLoader.js"
],
"external": ["vite-plugin-cesium", "@vitejs/plugin-vue"]
"external": ["vite-plugin-cesium", "@vitejs/plugin-vue", "vue"]
},
"live-providers": {
"runtime": "node",
+73 -35
View File
@@ -25,12 +25,15 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
const TESTS = [
'src/firstRunExperience.test.mjs',
'src/standalone/startupChrome.test.mjs',
];
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceDescriptions: path.join(
@@ -53,7 +56,8 @@ const MUTATIONS = [
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
},
{
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
defect:
'the checkbox is ignored, so nothing can ever suppress the launcher',
file: 'module',
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
to: ' ? false',
@@ -77,7 +81,8 @@ const MUTATIONS = [
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
defect:
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
file: 'module',
from: "if (params.get('welcome') === '1') return true;",
to: '// replay hatch removed',
@@ -139,7 +144,8 @@ const MUTATIONS = [
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
defect:
'cockpit drops off the exclusive list and can stack with the launcher',
file: 'module',
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
@@ -157,19 +163,22 @@ const MUTATIONS = [
to: 'dismiss();',
},
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
defect:
'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
defect:
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
file: 'module',
from: ' return Boolean(hit) && !root.contains(hit);',
to: ' return !root.contains(hit);',
},
{
defect: 'the launcher stops honouring a key another surface already claimed',
defect:
'the launcher stops honouring a key another surface already claimed',
file: 'keyboard',
from: ' || event.defaultPrevented',
to: ' /* belt removed */',
@@ -177,25 +186,29 @@ const MUTATIONS = [
{
// Anchored past the call so it lands on the Cockpit utility disclosure's
// Escape claim and nowhere else.
defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
defect:
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
file: 'radio',
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
defect:
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
to: ' * (note removed)',
},
{
defect: 'a bounded reveal timer punches the card through a recording in progress',
defect:
'a bounded reveal timer punches the card through a recording in progress',
file: 'module',
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
},
{
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
defect:
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
file: 'docs',
from: 'a surface class that never clears means no launcher for that page',
to: 'the launcher always turns up eventually for that page',
@@ -203,7 +216,8 @@ const MUTATIONS = [
// ── The checkbox may not promise what storage refused ─────────────────────
{
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
defect:
'a refused write leaves the box ticked, promising a suppression nobody stored',
file: 'module',
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
@@ -215,14 +229,16 @@ const MUTATIONS = [
to: ' store?.setItem?.(key, value);\n return true;',
},
{
defect: 'the scroll fade promises more list on a card where everything fits',
defect:
'the scroll fade promises more list on a card where everything fits',
file: 'module',
from: 'choiceList.dataset.scrollable = String(overflows);',
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
},
{
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
defect:
'the DISPLAY rail opens on first run again, stealing the impression',
file: 'ui',
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
@@ -236,13 +252,15 @@ const MUTATIONS = [
// ── Mission definitions ────────────────────────────────────────────────────
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
defect:
'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
defect:
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
file: 'module',
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
to: "layerIds: Object.freeze(['local-firms']),",
@@ -260,7 +278,7 @@ const MUTATIONS = [
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
},
{
defect: "the owner-authored first-run line is quietly rewritten",
defect: 'the owner-authored first-run line is quietly rewritten',
file: 'html',
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
@@ -302,7 +320,8 @@ const MUTATIONS = [
to: 'if (!mission) return { ok: true, choice };',
},
{
defect: 'the fires/quakes tile name stops being switchable from one constant',
defect:
'the fires/quakes tile name stops being switchable from one constant',
file: 'module',
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
@@ -310,13 +329,15 @@ const MUTATIONS = [
// ── Defaults interplay (the ruling this whole integration turns on) ────────
{
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
defect:
'mission layer enables stop persisting, unlike the clicks they stand for',
file: 'module',
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
to: 'dataManager.setEnabled(layerId, true)',
},
{
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
defect:
'a mission hand-edits detection and kills the style auto-preset contract',
file: 'module',
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
@@ -328,13 +349,15 @@ const MUTATIONS = [
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
},
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
defect:
'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
defect:
'the decision table is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
to: ' * (notes removed)',
@@ -342,7 +365,8 @@ const MUTATIONS = [
// ── Accessibility and the transplant defects ──────────────────────────────
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
defect:
'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
@@ -384,19 +408,22 @@ const MUTATIONS = [
to: 'body.never-matches-anything #first-run-launcher,',
},
{
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
defect:
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
file: 'css',
from: '#first-run-launcher[hidden] {\n display: none;\n}',
to: '/* [hidden] rule removed */',
},
{
defect: 'the card loses its viewport cap and clips on a short landscape phone',
defect:
'the card loses its viewport cap and clips on a short landscape phone',
file: 'css',
from: ' max-height: calc(100dvh - 1.5rem);',
to: ' /* cap removed */',
},
{
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
defect:
'the mission list stops scrolling, so tiles overflow the capped card',
file: 'css',
from: ' min-height: 0;\n overflow-y: auto;',
to: ' /* scroll removed */',
@@ -419,7 +446,7 @@ const MUTATIONS = [
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
},
{
defect: 'the menu order stops matching the owner\'s',
defect: "the menu order stops matching the owner's",
file: 'html',
from: '<button type="button" data-first-run-choice="environmental">',
to: '<button type="button" data-first-run-choice="zzz-environmental">',
@@ -449,7 +476,8 @@ const MUTATIONS = [
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
defect:
'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
@@ -463,7 +491,8 @@ const MUTATIONS = [
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
defect:
'the instruction mapping is dropped, so voice cannot reach the modes',
file: 'voiceInstructions',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
@@ -471,21 +500,25 @@ const MUTATIONS = [
];
const originals = new Map();
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
for (const [key, file] of Object.entries(FILES))
originals.set(key, fs.readFileSync(file, 'utf8'));
/** Write only on a real change: a no-op write still wakes every file watcher. */
const write = (file, next) => {
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
};
const restoreAll = () => {
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
for (const [key, file] of Object.entries(FILES))
write(file, originals.get(key));
};
process.on('exit', restoreAll);
let caught = 0;
const missed = [];
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
console.log(
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
);
for (const { defect, file, from, to } of MUTATIONS) {
const original = originals.get(file);
if (!original.includes(from)) {
@@ -497,10 +530,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
let red = false;
let by = '';
try {
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
execFileSync(
'node',
['--import=./build/registerVue.js', '--test', ...TESTS],
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
);
} catch (error) {
red = true;
const failed = String(error.stdout || '').split('\n')
const failed = String(error.stdout || '')
.split('\n')
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
.map((line) => line.trim().slice(2).split(' (')[0]);
by = [...new Set(failed)].slice(0, 2).join('; ');
+6 -2
View File
@@ -12,10 +12,14 @@ import { after, before, test } from 'node:test';
import puppeteer from 'puppeteer';
const root = new URL('../', import.meta.url);
const template = await readFile(
new URL('src/ui/templates/provider-settings.html', root),
const view = await readFile(
new URL('src/ui/views/ProviderSettingsView.vue', root),
'utf8',
);
const template = view.slice(
view.indexOf('<template>') + '<template>'.length,
view.lastIndexOf('</template>'),
);
const assets = new Set([
'/src/keySetup.js',
'/src/voice/cloudVoiceAuth.js',
+1 -1
View File
@@ -74,7 +74,7 @@ export function allocationTestArgs(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',
'--import=./build/registerVue.js',
]);
function runTests(args) {
+3 -3
View File
@@ -62,9 +62,9 @@ test('the engine resolves manual geometry before any name resolution', () => {
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a component template now, assembled into index.html at
// build time — the markup is not in index.html any more.
const html = read('src/ui/templates/display-controls.html');
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
// the markup is not in index.html any more.
const html = read('src/ui/views/DisplayControlsView.vue');
const ids = [
'draw-toggle',
'draw-mode-row',
+2
View File
@@ -31,6 +31,8 @@ test('importing and constructing the package is inactive without browser globals
const result = spawnSync(
process.execPath,
[
// Views are single-file components; Node needs the loader hosts use.
'--import=gods-eye-view/build/register-vue',
'--input-type=module',
'-e',
`
+4 -1
View File
@@ -7,7 +7,8 @@ import { t } from '../i18n/index.js';
/**
* Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one.
* whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that does not initialize Core's locale.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
@@ -18,6 +19,7 @@ export function createApplicationControls({
placeSearch,
defer,
initialShareCode = null,
localePicker = true,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
@@ -34,6 +36,7 @@ export function createApplicationControls({
placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
localePicker,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
+1 -1
View File
@@ -816,7 +816,7 @@ test('markup, startup ordering and accessibility remain pinned', async () => {
html,
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
);
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
assert.match(html, /<input type="checkbox" data-first-run-suppress\s*\/?>/);
assert.match(html, /<strong data-first-run-environmental-title>/);
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
// half of what it does is the defect this replaced. Only the VISIBLE <small>
+1 -1
View File
@@ -24,7 +24,7 @@ test('HUD sliders and location search have descriptive explicit names', () => {
test('the first-run checkbox keeps its native visible label', () => {
// The span may carry a data-i18n extraction attribute; the native wrapping
// label contract is what this guards.
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress\s*\/?>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
});
test('generated style sliders use the visible parameter label as their name', () => {
+2 -3
View File
@@ -20,9 +20,8 @@ const ICON_PROPERTY = /\bicon:\s*['"]([a-z0-9_]+)['"]/g;
/**
* Files that SHIP markup. Tests assert on markup, they do not render it.
*
* The panel markup lives in `src/ui/templates/*.html`, so HTML under `src` is
* read as well: a glyph named only in a template is still a glyph the font has
* to carry.
* The panel markup lives in Vue views under `src`, so markup there is read as
* well: a glyph named only in a view is still a glyph the font has to carry.
*/
function sourceFiles(directory = SRC_ROOT) {
const files = [];
+6 -1
View File
@@ -286,7 +286,12 @@ test('Radio volume and mission speed share the Sharpen slider visual language',
test('Radio is nested inside Context with its own disclosure and power controls', () => {
const contextStart = html.indexOf('id="global-context-panel"');
const radioStart = html.indexOf('id="radio-panel"');
const contextEnd = html.indexOf('\n </aside>', contextStart);
// The Context panel ends with the right rail's own closing tag, after the
// military awareness aside nested inside it.
const contextEnd = html.indexOf(
'</aside>',
html.indexOf('</aside>', html.indexOf('id="military-awareness-panel"')) + 1,
);
assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd);
assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/);
assert.match(html, /aria-label="Radio playback"/);
+1 -1
View File
@@ -92,7 +92,7 @@ test('first run opens with Scope ON and 100% feather at every default surface',
assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%</,
'index.html: and its readout agrees with the handle');
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/>/,
assert.match(indexHtml, /<input type="checkbox" id="scope-toggle"[^>]*\schecked\s*\/?>/,
'the native Scope checkbox starts checked');
// The link this session generates must describe the mask this session draws,
+5 -4
View File
@@ -20,13 +20,14 @@ export async function renderApplicationHtml(html) {
const view = APPLICATION_VIEWS[name];
if (!view) throw new Error(`Unknown application view: ${name}`);
const markup = (await renderToString(createSSRApp(view).use(createPinia())))
// Hydration markers and empty-attribute values are SSR detail; the
// DOM is the same as the template's.
// Hydration markers, empty-attribute values and escaped apostrophes
// are SSR detail; the DOM is the same as the template's.
.replace(/<!--\[-->|<!--\]-->|<!---->/g, '')
.replace(/ ([\w:.-]+)=""/g, ' $1');
.replace(/ ([\w:.-]+)=""/g, ' $1')
.replaceAll('&#39;', "'");
output = output.replace(
host,
`<div class="gev-view" data-gev-view="${name}">${markup}</div>`,
() => `<div class="gev-view" data-gev-view="${name}">${markup}</div>`,
);
}
return output;
-4
View File
@@ -1,4 +0,0 @@
import { register } from 'node:module';
// Lets unit tests import single-file components (`node --import`).
register('./vueLoader.mjs', import.meta.url);
+1 -1
View File
@@ -78,7 +78,7 @@ test('the documented outcomes hold: default, stored open, stored shut, and a sha
const { PanelPositionControls } =
await import('../ui/panelPositionControls.js');
const html = await readFile(
path.join(root, 'src/ui/templates/layer-panels.html'),
path.join(root, 'src/ui/views/LayerPanelsView.vue'),
'utf8',
);
assert.match(
+1 -1
View File
@@ -208,7 +208,7 @@ test('DETECT uses the real OFF/restore button and density input to reach DENSE',
assert.equal(await detectOn(page, pause), 'DENSE');
assert.deepEqual(modes, ['BALANCED', 'OFF', 'DENSE']);
const template = readFileSync(
new URL('../ui/templates/display-controls.html', import.meta.url),
new URL('../ui/views/DisplayControlsView.vue', import.meta.url),
'utf8',
);
assert.match(template, /id="detection-density-slider"/);
+8
View File
@@ -77,6 +77,8 @@ export class StyleManager extends ShellFacade {
/**
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
* @param {object} [options]
* @param {boolean} [options.localePicker=true] - false removes the language
* picker, for a host that does not initialize Core's locale.
*/
constructor(
viewer,
@@ -86,9 +88,11 @@ export class StyleManager extends ShellFacade {
services,
requestServices,
initialShareCode = null,
localePicker = true,
} = {},
) {
super();
this._localePickerEnabled = localePicker;
const {
IntelHUD,
ShareLinkManager,
@@ -763,6 +767,10 @@ export class StyleManager extends ShellFacade {
];
const root = globalThis.document?.getElementById?.('locale-picker');
if (!root) return;
if (this._localePickerEnabled === false) {
root.remove();
return;
}
this._localePicker = mountLocalePicker({
root,
current: getLocale(),
+1 -1
View File
@@ -82,7 +82,7 @@ test('real runner and facade use visual settings, preserve visibility and return
});
test('native sonar sliders represent every accepted integer without browser snapping', () => {
const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8');
const html = readFileSync(new URL('./views/DisplayControlsView.vue', import.meta.url), 'utf8');
for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) {
assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`));
}
+4 -4
View File
@@ -17,9 +17,9 @@ import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
for (const file of ['layer-panels.html', 'context.html']) {
for (const file of ['LayerPanelsView.vue', 'ContextView.vue']) {
const markup = readFileSync(
new URL(`./templates/${file}`, import.meta.url),
new URL(`./views/${file}`, import.meta.url),
'utf8',
);
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
@@ -647,7 +647,7 @@ test('Cyber visual defaults update the style, thermal palette and render state',
});
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
const display = read('./views/DisplayControlsView.vue');
const stylesheet = read('../../style.css');
const rightRail = read('./rightPanelRail.js');
const hud = read('../hud.js');
@@ -877,7 +877,7 @@ test('Cyber panels retain clean edges without the added footer band', () => {
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
const template = readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
new URL('./views/DisplayControlsView.vue', import.meta.url),
'utf8',
);
assert.match(template, /id="cyber-sonar-toggle"/);
+1 -1
View File
@@ -439,7 +439,7 @@ test('the application composition wires the import and owns its teardown', () =>
);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
const markup = fs.readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
new URL('./views/DisplayControlsView.vue', import.meta.url),
'utf8',
);
for (const id of [
+18
View File
@@ -136,3 +136,21 @@ test('the language already active neither asks nor reloads', () => {
view.restore();
}
});
test('a host without Core locales removes the picker instead of mounting it', () => {
const previous = globalThis.document;
let removed = 0;
const root = { remove: () => removed++ };
globalThis.document = {
getElementById: (id) => (id === 'locale-picker' ? root : null),
};
const shell = { services: {}, _localePickerEnabled: false };
try {
StyleManager.prototype._initLocaleSelector.call(shell);
assert.equal(removed, 1);
assert.equal(shell._localePicker, undefined);
} finally {
for (const unregister of shell._unsavedWorkChecks) unregister();
globalThis.document = previous;
}
});
+2 -2
View File
@@ -11,8 +11,8 @@ const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
test('every scroller the rail measuring pass lifts restores its scroll offset', () => {
const css = read('./styles/layers.css');
const templates = [
read('./templates/context.html'),
read('./templates/layer-panels.html'),
read('./views/ContextView.vue'),
read('./views/LayerPanelsView.vue'),
].join('\n');
const lifted = [
...css.matchAll(
+1 -1
View File
@@ -190,7 +190,7 @@ class FakeElement {
}
}
/** The static rail panel skeleton from templates/context.html. */
/** The static rail panel skeleton from views/ContextView.vue. */
function fakeDocument() {
const document = {
documentElement: { clientWidth: 1200 },
+1 -1
View File
@@ -988,7 +988,7 @@ test('a note under SINCE says what date it compares, only while one is given', (
assert.equal(note().hidden, true);
// The panel's own markup has it, read out with the slider.
const template = fs.readFileSync(
new URL('./templates/layer-panels.html', import.meta.url),
new URL('./views/LayerPanelsView.vue', import.meta.url),
'utf8',
);
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
@@ -1,3 +1,4 @@
<template>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label>
<!-- One optical coordinate system: every targeting mark shares this center.
@@ -326,3 +327,4 @@
<span data-i18n>EXIT COCKPIT</span>
</button>
</nav>
</template>
@@ -1,3 +1,4 @@
<template>
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<!-- Control panel -->
@@ -91,3 +92,4 @@
</div>
</div>
</div>
</template>
@@ -1,3 +1,4 @@
<template>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
@@ -214,3 +215,4 @@
</aside>
<div id="scene-runtime"></div>
</template>
@@ -1,3 +1,4 @@
<template>
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
@@ -184,3 +185,4 @@
</div>
</div>
<button id="clean-view-exit" title="Return UI controls" data-i18n-title data-i18n>EXIT CLEAN VIEW</button>
</template>
@@ -1,3 +1,4 @@
<template>
<div id="left-panel-stack">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
@@ -185,3 +186,4 @@
</div>
</div>
</template>
@@ -1,3 +1,4 @@
<template>
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from
@@ -28,3 +29,4 @@
</div>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" data-i18n>The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
</aside>
</template>
@@ -1,3 +1,4 @@
<template>
<div id="cesiumContainer"></div>
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
@@ -59,3 +60,4 @@
<div id="safe-frame-overlay">
<div id="safe-frame-box"></div>
</div>
</template>
@@ -1,3 +1,4 @@
<template>
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
until the visitor asks; shared links skip this surface entirely. It returns
every session until "Don't show this again" is ticked — see
@@ -56,3 +57,4 @@
a live region that also holds layout makes the row jump as it speaks. -->
<p class="first-run-note" data-first-run-status role="status" aria-live="polite" data-i18n>Tip: the GEV MIC button in the dock lets you talk to the map.</p>
</aside>
</template>
+18 -1
View File
@@ -1,11 +1,28 @@
import { createApp } from 'vue';
import { applicationPinia } from '../vueRuntime.js';
import WelcomeView from './WelcomeView.vue';
import ContextView from './ContextView.vue';
import LayerPanelsView from './LayerPanelsView.vue';
import CommandDockView from './CommandDockView.vue';
import DisplayControlsView from './DisplayControlsView.vue';
import CockpitView from './CockpitView.vue';
import SceneChromeView from './SceneChromeView.vue';
import ProviderSettingsView from './ProviderSettingsView.vue';
/**
* Application views by template name (build/applicationViews.js lists the
* same names). Each renders the markup its static template held.
*/
export const APPLICATION_VIEWS = Object.freeze({});
export const APPLICATION_VIEWS = Object.freeze({
'provider-settings': ProviderSettingsView,
'scene-chrome': SceneChromeView,
cockpit: CockpitView,
'display-controls': DisplayControlsView,
'command-dock': CommandDockView,
'layer-panels': LayerPanelsView,
context: ContextView,
welcome: WelcomeView,
});
const mounted = new WeakSet();