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 -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);
-48
View File
@@ -1,48 +0,0 @@
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';
/**
* 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,
templateOptions: { compilerOptions: VUE_COMPILER_OPTIONS },
}).content;
}
const script = descriptor.script
? compileScript(descriptor, { id, genDefaultAs: '__sfc__' }).content
: 'const __sfc__ = {};';
let template = '';
if (descriptor.template) {
const compiled = compileTemplate({
source: descriptor.template.content,
filename,
id,
compilerOptions: VUE_COMPILER_OPTIONS,
});
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. */
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,
};
}
+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();