diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b279902..91de5887f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ picker, and Node tests that import application modules register the component loader with `--import=/build/register-vue` (samehkhamis). +- Pages place each view with a `
` + host and write their own loading screen; the `build/html` template + expansion and its `gev:template` markers are gone. `createApplication` + takes `omitViews` (for example `['provider-settings']`) for views an + application leaves out (samehkhamis). + - `./ui/display`'s `bindDisplayControls` takes the keyhole fade and outside opacity sliders as `fadeSlider` and `outsideOpacitySlider`, and their actions receive whole percentages; `fadeSliders` still works. diff --git a/build/application-html.js b/build/application-html.js deleted file mode 100644 index 27cec5fba..000000000 --- a/build/application-html.js +++ /dev/null @@ -1,52 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { APPLICATION_VIEW_NAMES } from './applicationViews.js'; - -export const APPLICATION_TEMPLATES = Object.freeze([ - 'scene-chrome', - 'cockpit', - 'display-controls', - 'command-dock', - 'layer-panels', - 'context', - 'welcome', - 'provider-settings', - '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) { - return html.replace( - /^[ \t]*\r?\n?/gm, - (_, 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', - ); - }, - ); -} - -/** Assemble static application markup before Vite processes scripts and assets. */ -export function applicationHtmlPlugin() { - return { - name: 'application-component-templates', - transformIndexHtml: { - order: 'pre', - handler: expandApplicationHtml, - }, - }; -} diff --git a/build/applicationViews.js b/build/applicationViews.js deleted file mode 100644 index 1ecc466b4..000000000 --- a/build/applicationViews.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 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([ - 'provider-settings', - 'scene-chrome', - 'cockpit', - 'display-controls', - 'command-dock', - 'layer-panels', - 'context', - 'welcome', -]); diff --git a/build/vite.js b/build/vite.js index 3dacdfdaa..4421b8db3 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,5 +1,4 @@ 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'; @@ -157,7 +156,6 @@ export function createBrowserViteConfig({ // Next, so no provider route answers a Host the server does not allow. hostCheckPlugin(), cesium(), - applicationHtmlPlugin(), // Application views are Vue single-file components. vue(VUE_PLUGIN_OPTIONS), ...plugins, diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index a7a8777a5..24a7111a9 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -200,11 +200,13 @@ 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: -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 +Provider Settings. A page places each Vue view in `src/ui/views` with a host, +`
`, and writes its own loading +screen (`#loading-screen` with a `.loader-status` line) as static markup so it +paints before any script runs. `createApplication()` renders the views before +anything reads the page; `omitViews` names views the application leaves out, +such as `provider-settings`, and their hosts are removed. An unknown view name +is an error. 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=/build/register-vue`. diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 910ebb857..e012afc95 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -63,13 +63,13 @@ a feature supplying only its accent. 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. A converted template is listed in - `build/applicationViews.js`; its `gev:template` marker then expands to a + classes, `data-i18n` text and CSS. The page places each view with 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 + translated and wired. `createApplication()` calls it too, removing the + hosts of any views its `omitViews` names. Views belong to the page and are + unmounted only when a later call omits them. The loading screen is static markup in the page so it + paints before any script runs. Markup tests read `renderApplicationHtml()` from `src/testSupport`, which renders the views on the server. Templates compile with Vue's `preserve` whitespace, which keeps the whitespace between elements as a single @@ -168,8 +168,9 @@ a feature supplying only its accent. Display popover's slots stay static for the Cockpit display portal). 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 +4. **Cleanup.** The build-time template expansion is retired: `index.html` + lists the view hosts and the loading screen itself. The attribute + translation pass goes with the move to vue-i18n. Hosts register panels and rail items through an explicit API instead of editing markup. Markup-string tests are replaced by component tests. diff --git a/index.html b/index.html index 373bbdd3a..9916a9f41 100644 --- a/index.html +++ b/index.html @@ -29,23 +29,25 @@ - +
+
+
+
+
+
+
+
+ +
- - - - - - - - - - - - - - - + +
+
+ +

GOD'S EYE VIEW

+

Initializing photorealistic world...

+
+
diff --git a/package.json b/package.json index 2131d6d59..a0ba4dccd 100644 --- a/package.json +++ b/package.json @@ -219,9 +219,6 @@ "./voice/actions": "./src/voice/gevActions.js", "./annotations": "./src/annotations/index.js", "./context": "./src/data/contextStore.js", - "./build/html": { - "node": "./build/application-html.js" - }, "./build/register-vue": { "node": "./build/registerVue.js" }, diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fca43cd46..4273bc39a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -650,7 +650,6 @@ "src/voice/realtimeBackend.test.mjs", "src/agent/worldPacket.test.mjs", "src/voice/layerSummary.js", - "build/application-html.js", "src/app/sources.js", "src/ui/composition.js", "src/services/application.js", diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index e5ed0c587..d8d131fff 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -8,8 +8,7 @@ // lists it. // // Messages are the English literals passed to t(), tn() and msg() in src/, -// plus the markup opted in with data-i18n* in index.html, src/ui/templates and -// the Vue views. +// plus the markup opted in with data-i18n* in index.html 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'; @@ -422,13 +421,9 @@ export function findUnmarked() { }); } -/** Static markup opted in with data-i18n*: the page, its templates and views. */ +/** Static markup opted in with data-i18n*: the page and its views. */ function markupFiles() { - return trackedFiles( - 'index.html', - 'src/ui/templates/*.html', - 'src/ui/views/*.vue', - ); + return trackedFiles('index.html', 'src/ui/views/*.vue'); } /** diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 54e15584a..edfa93735 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -98,14 +98,12 @@ }, "vite-build": { "runtime": "node", - "exports": ["./build/vite", "./build/html", "./build/register-vue"], + "exports": ["./build/vite", "./build/register-vue"], "modules": [ "build/vite.js", - "build/application-html.js", "build/allowedHosts.js", "build/embed-framing.js", "build/panel.js", - "build/applicationViews.js", "build/vueOptions.js", "build/registerVue.js", "build/vueLoader.js" diff --git a/src/app/application.js b/src/app/application.js index 2430a77ee..9770ea291 100644 --- a/src/app/application.js +++ b/src/app/application.js @@ -7,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene']; * Own application startup and teardown without importing an application entry. * Constructors receive earlier components, an AbortSignal and defer(cleanup). * Register cleanup immediately after acquiring each resource, before any await. - * @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors + * The page's views render first; `omitViews` names the ones this application + * leaves out (see mountApplicationViews). + * @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors * @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}} */ export function createApplication({ @@ -15,9 +17,10 @@ export function createApplication({ createControls, createData, createTools, + omitViews = [], }) { // A host's page needs its views before any phase reads the markup. - mountApplicationViews(); + mountApplicationViews(globalThis.document, { omit: omitViews }); const factories = { scene: createScene, controls: createControls, diff --git a/src/testSupport/applicationHtml.mjs b/src/testSupport/applicationHtml.mjs index 37e8ee64b..ca8820087 100644 --- a/src/testSupport/applicationHtml.mjs +++ b/src/testSupport/applicationHtml.mjs @@ -2,20 +2,19 @@ import { readFileSync } from 'node:fs'; import { createPinia } from 'pinia'; import { createSSRApp } from 'vue'; import { renderToString } from 'vue/server-renderer'; -import { expandApplicationHtml } from '../../build/application-html.js'; import { APPLICATION_VIEWS } from '../ui/views/index.js'; const HOST = /
<\/div>/g; /** - * Expand application markup as the browser shows it once views are mounted: - * static templates inline, and each view rendered into its host. Markup tests - * read this instead of the raw template expansion. - * @param {string} html Document with `gev:template` markers. + * Application markup as the browser shows it once views are mounted: each + * view rendered into its host. Markup tests read this instead of the page + * source. + * @param {string} html Document with `.gev-view` hosts. * @returns {Promise} */ export async function renderApplicationHtml(html) { - let output = expandApplicationHtml(html); + let output = html; for (const [host, name] of [...output.matchAll(HOST)]) { const view = APPLICATION_VIEWS[name]; if (!view) throw new Error(`Unknown application view: ${name}`); diff --git a/src/tooling/applicationHtml.test.mjs b/src/tooling/applicationHtml.test.mjs index b895552b3..ee1294401 100644 --- a/src/tooling/applicationHtml.test.mjs +++ b/src/tooling/applicationHtml.test.mjs @@ -1,41 +1,27 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { - expandApplicationHtml, - APPLICATION_TEMPLATES, -} from '../../build/application-html.js'; -import { APPLICATION_VIEW_NAMES } from '../../build/applicationViews.js'; import { APPLICATION_VIEWS } from '../ui/views/index.js'; import { readApplicationHtml } from '../testSupport/applicationHtml.mjs'; -test('the standalone document expands every component once and preserves unique element ids', async () => { - const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); +const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); + +test('the standalone document hosts every view once and keeps element ids unique', async () => { + const hosts = [...source.matchAll(/data-gev-view="([^"]+)"/g)].map((match) => match[1]); + // Order is stacking order for fixed elements that share a z-index. + assert.deepEqual(hosts, ['scene-chrome', 'cockpit', 'display-controls', 'command-dock', 'layer-panels', 'context', 'welcome', 'provider-settings']); + assert.deepEqual([...hosts].sort(), Object.keys(APPLICATION_VIEWS).sort()); const html = await readApplicationHtml(); - assert.equal([...source.matchAll(/gev:template /g)].length, APPLICATION_TEMPLATES.length); - assert.doesNotMatch(html, /gev:template/); - const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]); + const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); assert.equal(new Set(ids).size, ids.length); assert.match(html, /id="cesiumContainer"/); assert.match(html, /type="module" src="\/src\/main.js"/); }); -test('component selection includes only requested markup and refuses filesystem traversal', () => { - const html = expandApplicationHtml('\n'); - assert.match(html, /id="loading-screen"/); - assert.doesNotMatch(html, /id="cesiumContainer"/); - assert.throws(() => expandApplicationHtml(''), /Unknown application template/); -}); - -test('views expand to mount hosts, the loading screen stays static, and every view is registered', () => { - assert.deepEqual(Object.keys(APPLICATION_VIEWS).sort(), [...APPLICATION_VIEW_NAMES].sort()); - for (const name of APPLICATION_VIEW_NAMES) { - assert.ok(APPLICATION_TEMPLATES.includes(name), name); - assert.equal( - expandApplicationHtml(`\n`), - `
\n`, - ); - } - // It paints before any script runs. - assert.ok(!APPLICATION_VIEW_NAMES.includes('hud-loading')); +test('the loading screen is static markup, so it paints before any script runs', () => { + const loading = source.indexOf('id="loading-screen"'); + assert.ok(loading > 0); + assert.ok(loading < source.indexOf('type="module" src="/src/main.js"')); + assert.match(source, /class="loader-status" data-i18n>Initializing photorealistic world\.\.\. plugin.name), + config.plugins.slice(4).map((plugin) => plugin.name), ['embed-framing', 'panel-build'], ); } finally { @@ -118,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(5, -6) + .slice(4, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, diff --git a/src/ui/templates/hud-loading.html b/src/ui/templates/hud-loading.html deleted file mode 100644 index 1a68a7078..000000000 --- a/src/ui/templates/hud-loading.html +++ /dev/null @@ -1,11 +0,0 @@ - -
- - -
-
- -

GOD'S EYE VIEW

-

Initializing photorealistic world...

-
-
diff --git a/src/ui/views/index.js b/src/ui/views/index.js index be33e6c13..af2c66746 100644 --- a/src/ui/views/index.js +++ b/src/ui/views/index.js @@ -10,8 +10,9 @@ 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. + * Application views by name. A page places each one with a host element, + * `
` (`display: contents`), + * where the view's markup belongs. */ export const APPLICATION_VIEWS = Object.freeze({ 'provider-settings': ProviderSettingsView, @@ -24,21 +25,38 @@ export const APPLICATION_VIEWS = Object.freeze({ welcome: WelcomeView, }); -const mounted = new WeakSet(); +const mounted = new WeakMap(); /** * Render every application view into its host. Views belong to the page, as - * their static markup did, so they are never unmounted. Call before anything + * their static markup did, so they are unmounted only when a later call omits + * them; omit views before any control code is wired to them. Call before anything * reads or translates the page; repeated calls are harmless. * @param {Document} [doc] Page document. + * @param {{omit?: string[]}} [options] Views this page leaves out; their + * hosts are removed, unmounting a view an earlier call rendered. */ -export function mountApplicationViews(doc = globalThis.document) { +export function mountApplicationViews( + doc = globalThis.document, + { omit = [] } = {}, +) { + for (const name of omit) { + if (!Object.hasOwn(APPLICATION_VIEWS, name)) + throw new Error(`Unknown application view: ${name}`); + } for (const host of doc?.querySelectorAll?.('[data-gev-view]') ?? []) { - if (mounted.has(host)) continue; const name = host.getAttribute('data-gev-view'); - const view = APPLICATION_VIEWS[name]; - if (!view) throw new Error(`Unknown application view: ${name}`); - createApp(view).use(applicationPinia()).mount(host); - mounted.add(host); + if (omit.includes(name)) { + mounted.get(host)?.unmount(); + mounted.delete(host); + host.remove(); + continue; + } + if (mounted.has(host)) continue; + if (!Object.hasOwn(APPLICATION_VIEWS, name)) + throw new Error(`Unknown application view: ${name}`); + const app = createApp(APPLICATION_VIEWS[name]).use(applicationPinia()); + app.mount(host); + mounted.set(host, app); } } diff --git a/src/ui/views/mountApplicationViews.test.mjs b/src/ui/views/mountApplicationViews.test.mjs new file mode 100644 index 000000000..a93e4ba14 --- /dev/null +++ b/src/ui/views/mountApplicationViews.test.mjs @@ -0,0 +1,77 @@ +// src/ui/views/mountApplicationViews.test.mjs — a page's view hosts render +// their views, and an application can leave views out, over happy-dom. +import '../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplication } from '../../app/application.js'; +import { mountApplicationViews } from './index.js'; + +function page(names) { + document.body.replaceChildren(); + for (const name of names) { + const host = document.createElement('div'); + host.className = 'gev-view'; + host.setAttribute('data-gev-view', name); + document.body.append(host); + } +} + +const hostNames = () => + [...document.querySelectorAll('[data-gev-view]')].map((host) => + host.getAttribute('data-gev-view'), + ); + +test('omitted views lose their hosts and the rest render', () => { + page(['welcome', 'provider-settings']); + mountApplicationViews(document, { omit: ['provider-settings'] }); + assert.deepEqual(hostNames(), ['welcome']); + assert.equal(document.getElementById('key-setup-chip'), null); + assert.ok( + document.querySelector('[data-gev-view="welcome"]').children.length, + ); +}); + +test('a view an earlier call rendered is unmounted when a later call omits it', () => { + page(['welcome', 'provider-settings']); + mountApplicationViews(document); + assert.ok(document.getElementById('key-setup-chip')); + mountApplicationViews(document, { omit: ['provider-settings'] }); + assert.deepEqual(hostNames(), ['welcome']); + assert.equal(document.getElementById('key-setup-chip'), null); +}); + +test('unknown view names are refused, in the page or in the omit list', () => { + page([]); + for (const name of ['toString', 'constructor']) + assert.throws( + () => mountApplicationViews(document, { omit: [name] }), + /Unknown application view/, + ); + page(['toString']); + assert.throws( + () => mountApplicationViews(document), + /Unknown application view/, + ); + assert.throws( + () => mountApplicationViews(document, { omit: ['nope'] }), + /Unknown application view: nope/, + ); + page(['nope']); + assert.throws( + () => mountApplicationViews(document), + /Unknown application view: nope/, + ); +}); + +test('createApplication leaves out the views its omitViews names', () => { + page(['welcome', 'provider-settings']); + const noop = () => ({}); + createApplication({ + createScene: noop, + createControls: noop, + createData: noop, + createTools: noop, + omitViews: ['provider-settings'], + }); + assert.deepEqual(hostNames(), ['welcome']); +});