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(''); + const end = source.lastIndexOf(''); + return start === -1 ? '' : source.slice(start + ''.length, end); +} + // Text that never reaches the page as UI: tool results and descriptions for // AI clients (MCP and the voice model). const NOT_UI = [/^src\/tools\//]; diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e74c561e8..549ce1641 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -17,8 +17,12 @@ }, "application": { "exports": ["./application"], - "modules": ["src/app/application.js"], - "external": [] + "modules": [ + "src/app/application.js", + "src/ui/vueRuntime.js", + "src/ui/views/index.js" + ], + "external": ["vue", "pinia"] }, "viewer": { "exports": ["./application/viewer"], @@ -40,7 +44,9 @@ "build/application-html.js", "build/allowedHosts.js", "build/embed-framing.js", - "build/panel.js" + "build/panel.js", + "build/applicationViews.js", + "build/vueOptions.js" ], "external": ["vite-plugin-cesium", "@vitejs/plugin-vue"] }, diff --git a/src/app/application.js b/src/app/application.js index b9c37a8d2..2430a77ee 100644 --- a/src/app/application.js +++ b/src/app/application.js @@ -1,3 +1,4 @@ +import { mountApplicationViews } from '../ui/views/index.js'; const START_ORDER = ['scene', 'controls', 'data', 'tools']; // Controls cancel restoration while the data manager and viewer still exist. const STOP_ORDER = ['tools', 'controls', 'data', 'scene']; @@ -15,6 +16,8 @@ export function createApplication({ createData, createTools, }) { + // A host's page needs its views before any phase reads the markup. + mountApplicationViews(); const factories = { scene: createScene, controls: createControls, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 075b5a0ad..8b6b8fc1c 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -2,7 +2,7 @@ import { readShellSource, shellMethod, } from './testSupport/readShellSource.mjs'; -import { expandApplicationHtml } from '../build/application-html.js'; +import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { PanelLayoutController } from './ui/panelLayoutController.js'; import { readShellElements } from './ui/shellElements.js'; @@ -46,7 +46,7 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const html = expandApplicationHtml( +const html = await renderApplicationHtml( fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'), ); const ui = readShellSource(); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 528341dd4..cf221756a 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,4 +1,4 @@ -import { expandApplicationHtml } from '../build/application-html.js'; +import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _syncContextModeButtons } from './ui/contextPresentation.js'; @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); +const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const css = readStylesheet(new URL('../style.css', import.meta.url)); test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { diff --git a/src/data/detectionPolicy.test.mjs b/src/data/detectionPolicy.test.mjs index a596be7c4..dd893b1c5 100644 --- a/src/data/detectionPolicy.test.mjs +++ b/src/data/detectionPolicy.test.mjs @@ -1,4 +1,4 @@ -import { expandApplicationHtml } from '../../build/application-html.js'; +import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -19,7 +19,7 @@ import { } from './detectionPolicy.js'; import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js'; -const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8')); +const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8')); test('side aircraft brackets stay readable without changing zero-opacity intent', () => { assert.equal(detectionBracketAlpha('AIR', 0), 0); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index d05fb9a95..ba2c9dfcc 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -1,6 +1,6 @@ import { encodeParams, decodeParams } from '../layers/streetLevel/params.js'; import { readShellSource } from '../testSupport/readShellSource.mjs'; -import { expandApplicationHtml } from '../../build/application-html.js'; +import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -734,7 +734,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an /this\.(?:flightState\.)?_models3dMode = 'proximity';/, 'ui.js: and believes the mode is proximity', ); - const html = expandApplicationHtml( + const html = await renderApplicationHtml( await readFile(new URL('../../index.html', import.meta.url), 'utf8'), ); assert.match( diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index ca0ca5c0a..e7429d475 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1,4 +1,4 @@ -import { expandApplicationHtml } from '../build/application-html.js'; +import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import test from 'node:test'; @@ -797,8 +797,8 @@ test('the decision table is written down where the next editor will read it', () // ── Markup, startup ordering, accessibility ───────────────────────────────── -test('markup, startup ordering and accessibility remain pinned', () => { - const html = expandApplicationHtml( +test('markup, startup ordering and accessibility remain pinned', async () => { + const html = await renderApplicationHtml( fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'), ); const startup = fs.readFileSync( @@ -915,8 +915,8 @@ test('markup, startup ordering and accessibility remain pinned', () => { assert.match(choicesBlock, /overflow-y: auto;/); }); -test('the card closes without a keyboard: the ESC hint is a button, kept on phones', () => { - const html = expandApplicationHtml( +test('the card closes without a keyboard: the ESC hint is a button, kept on phones', async () => { + const html = await renderApplicationHtml( fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'), ); const module = fs.readFileSync( diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index a854b5964..e406b3682 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -1,9 +1,9 @@ -import { expandApplicationHtml } from '../build/application-html.js'; +import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); +const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8'); // Focused markup guards; actual computed names are checked in Chromium. diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index ff9f63c9c..f838e3b9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,6 +1,6 @@ import { ShareRestoration } from './ui/shareRestoration.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { expandApplicationHtml } from '../build/application-html.js'; +import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -202,9 +202,9 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed 'elapsed wall time while hidden expires the notice instead of replaying it'); }); -test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { +test('universal notice lifecycle clears on dispose and uses the one top-center live region', async () => { const ui = readShellSource(); - const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); + const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 0fa66e0da..5ce67ab5b 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,5 +1,5 @@ import { shellMethod } from './testSupport/readShellSource.mjs'; -import { expandApplicationHtml } from '../build/application-html.js'; +import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs'; import { msg, t } from './i18n/index.js'; // Exercise the installed event routes and central close method, without WebGL. -const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); +const markup = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const locationMarkup = markup.slice(markup.indexOf('