refactor(build): place the views in the page instead of expanding templates

index.html now lists each view's host and the loading screen itself, so
the gev:template expansion plugin, its view list, the loading-screen
template and the ./build/html export are gone. createApplication takes
omitViews for views an application leaves out; their hosts are removed,
and a view an earlier call rendered is unmounted.
This commit is contained in:
Sameh Khamis committed 2026-10-31 17:52:47 -07:00
1 parent f279200af5
commit 5bf797ef71
18 files changed
+176 -174

No files matched your search

+6
View File
@@ -14,6 +14,12 @@
picker, and Node tests that import application modules register the
component loader with `--import=<core>/build/register-vue` (samehkhamis).
- Pages place each view with a `<div class="gev-view" data-gev-view="NAME">`
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.
-52
View File
@@ -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 `<div class="gev-view" data-gev-view="${name}"></div>\n`;
}
/** Expand only known component templates; markers cannot name filesystem paths. */
export function expandApplicationHtml(html) {
return html.replace(
/^[ \t]*<!-- gev:template ([^\s]+) -->\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,
},
};
}
-16
View File
@@ -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',
]);
-2
View File
@@ -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,
+7 -5
View File
@@ -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,
`<div class="gev-view" data-gev-view="NAME"></div>`, 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=<core package>/build/register-vue`.
+8 -7
View File
@@ -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.
+18 -16
View File
@@ -29,23 +29,25 @@
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<!-- gev:template scene-chrome -->
<div class="gev-view" data-gev-view="scene-chrome"></div>
<div class="gev-view" data-gev-view="cockpit"></div>
<div class="gev-view" data-gev-view="display-controls"></div>
<div class="gev-view" data-gev-view="command-dock"></div>
<div class="gev-view" data-gev-view="layer-panels"></div>
<div class="gev-view" data-gev-view="context"></div>
<div class="gev-view" data-gev-view="welcome"></div>
<div class="gev-view" data-gev-view="provider-settings"></div>
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- gev:template cockpit -->
<!-- gev:template display-controls -->
<!-- gev:template command-dock -->
<!-- gev:template layer-panels -->
<!-- gev:template context -->
<!-- gev:template welcome -->
<!-- gev:template provider-settings -->
<!-- gev:template hud-loading -->
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
-3
View File
@@ -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"
},
-1
View File
@@ -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",
+3 -8
View File
@@ -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');
}
/**
+1 -3
View File
@@ -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"
+5 -2
View File
@@ -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,
+5 -6
View File
@@ -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 class="gev-view" data-gev-view="([^"]+)"><\/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<string>}
*/
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}`);
+14 -28
View File
@@ -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('<!-- gev:template hud-loading -->\n');
assert.match(html, /id="loading-screen"/);
assert.doesNotMatch(html, /id="cesiumContainer"/);
assert.throws(() => expandApplicationHtml('<!-- gev:template ../../.env -->'), /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(`<!-- gev:template ${name} -->\n`),
`<div class="gev-view" data-gev-view="${name}"></div>\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\.\.\.</);
assert.doesNotMatch(source, /gev:template/);
});
+4 -4
View File
@@ -29,8 +29,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
assert.equal(config.plugins[0].enforce, 'pre');
assert.equal(config.plugins[1].name, 'host-check');
assert.equal(config.plugins[1].enforce, 'pre');
assert.equal(config.plugins[4].name, 'vite:vue');
assert.equal(config.plugins[5], plugin);
assert.equal(config.plugins[3].name, 'vite:vue');
assert.equal(config.plugins[4], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
@@ -104,7 +104,7 @@ test('build helper does not discover environment values or construct local provi
undefined,
);
assert.deepEqual(
config.plugins.slice(5).map((plugin) => 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,
-11
View File
@@ -1,11 +0,0 @@
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
</div>
</div>
+28 -10
View File
@@ -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,
* `<div class="gev-view" data-gev-view="NAME"></div>` (`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);
}
}
@@ -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']);
});