Isolate Eve browser lowering per host

This commit is contained in:
Meta Vens 2026-08-17 20:11:36 +02:00
commit 7f51dfd6ac
6 changed files with 1078 additions and 84 deletions

View file

@ -122,18 +122,27 @@ const defaultStyleTokens = {
"--font-title": "\"Montserrat\", \"Zen Kaku Gothic New\", \"M PLUS 1\", \"Ubuntu Sans\", system-ui, sans-serif",
"--font-mono": "\"Ubuntu Sans Mono\", \"M PLUS 1 Code\", \"Cascadia Mono\", Consolas, monospace",
};
let activeFontStylesheet;
let currentSurfaceStyles = normalizeSurfaceStyles(undefined);
let currentSurfaceDocument;
let currentOptions = {};
const loadedFontStylesheets = new Map();
const activeSurfaceBindings = new WeakMap();
const eveBrowserRenderContext = Symbol("eve.browser.render-context");
function scopeEveBrowserOptions(surface, options) {
return {
...options,
[eveBrowserRenderContext]: {
surface,
styles: normalizeSurfaceStyles(surface.surface?.styles),
elements: new WeakMap(),
},
};
}
function renderContext(options) {
return options[eveBrowserRenderContext];
}
export function renderEveSurface(surface, host, options = {}) {
activeSurfaceBindings.get(host)?.dispose();
currentSurfaceDocument = surface;
currentOptions = options;
currentSurfaceStyles = normalizeSurfaceStyles(surface.surface?.styles);
globalThis.__eveCurrentMesh = surface.mesh;
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? document.body);
const scopedOptions = scopeEveBrowserOptions(surface, options);
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host);
host.dataset.provider = surface.providerId || options.provider?.providerId || "";
if (options.body) {
options.body.dataset.provider = surface.providerId || options.provider?.providerId || "";
}
@ -141,9 +150,9 @@ export function renderEveSurface(surface, host, options = {}) {
options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`;
}
if (surface.surface?.root) {
host.replaceChildren(renderEveComponent(surface.surface.root, options));
host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions));
if (options.stateBindingResolver) {
const controller = new EveSurfaceBindingController(surface, host, options);
const controller = new EveSurfaceBindingController(surface, host, scopedOptions);
activeSurfaceBindings.set(host, controller);
void controller.start();
}
@ -173,6 +182,8 @@ class EveSurfaceBindingController {
options;
disposed = false;
unsubscribers = [];
pendingComponents = new Set();
projectionQueued = false;
constructor(surface, host, options) {
this.surface = surface;
this.host = host;
@ -183,6 +194,7 @@ class EveSurfaceBindingController {
const root = this.surface.surface?.root;
if (!resolver || !root)
return;
const hydratedComponents = new Set();
for (const component of walkEveComponents(root)) {
for (const binding of component.stateBindings || []) {
if (this.disposed)
@ -194,32 +206,102 @@ class EveSurfaceBindingController {
if (this.disposed)
return;
applyEveStateBindingValue(component, binding, value);
this.renderCanonicalProjection();
this.queueComponentProjection(component);
};
apply(await handle.latest());
applyEveStateBindingValue(component, binding, await handle.latest());
hydratedComponents.add(component);
if (this.disposed)
return;
this.unsubscribers.push(handle.watch(apply));
}
}
for (const component of hydratedComponents) {
if (this.disposed)
return;
this.renderComponentProjection(component);
}
}
dispose() {
this.disposed = true;
this.pendingComponents.clear();
for (const unsubscribe of this.unsubscribers.splice(0))
unsubscribe();
}
renderCanonicalProjection() {
const root = this.surface.surface?.root;
if (root)
this.host.replaceChildren(renderEveComponent(root, this.options));
queueComponentProjection(component) {
this.pendingComponents.add(component);
if (this.projectionQueued)
return;
this.projectionQueued = true;
queueMicrotask(() => {
this.projectionQueued = false;
if (this.disposed)
return;
for (const pending of this.pendingComponents)
this.renderComponentProjection(pending);
this.pendingComponents.clear();
});
}
renderComponentProjection(component) {
const current = renderContext(this.options)?.elements.get(component);
if (!current?.isConnected)
return;
replaceElementPreservingInteraction(current, renderEveComponent(component, this.options), this.host);
}
}
function replaceElementPreservingInteraction(current, replacement, host) {
const active = current.ownerDocument.activeElement;
const activeElement = active instanceof HTMLElement && current.contains(active) ? active : undefined;
const activePath = activeElement ? elementPath(current, activeElement) : undefined;
const selection = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement
? { start: activeElement.selectionStart, end: activeElement.selectionEnd, direction: activeElement.selectionDirection }
: undefined;
const hostScroll = { left: host.scrollLeft, top: host.scrollTop };
current.replaceWith(replacement);
host.scrollLeft = hostScroll.left;
host.scrollTop = hostScroll.top;
if (!activePath)
return;
const nextActive = elementAtPath(replacement, activePath);
nextActive?.focus({ preventScroll: true });
if (selection && (nextActive instanceof HTMLInputElement || nextActive instanceof HTMLTextAreaElement)) {
nextActive.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
}
}
function elementPath(root, target) {
if (root === target)
return [];
const path = [];
let current = target;
while (current && current !== root) {
const parent = current.parentElement;
if (!parent)
return undefined;
path.unshift(Array.prototype.indexOf.call(parent.children, current));
current = parent;
}
return current === root ? path : undefined;
}
function elementAtPath(root, path) {
let current = root;
for (const index of path) {
const child = current.children.item(index);
if (!(child instanceof HTMLElement))
return undefined;
current = child;
}
return current instanceof HTMLElement ? current : undefined;
}
function* walkEveComponents(root) {
yield root;
for (const child of root.children || [])
yield* walkEveComponents(child);
}
export function renderEveComponent(node, options = currentOptions) {
export function renderEveComponent(node, options = {}) {
const element = renderEveComponentProjection(node, options);
renderContext(options)?.elements.set(node, element);
return element;
}
function renderEveComponentProjection(node, options) {
const kind = node.kind || "panel";
const props = objectProps(node.props);
const layout = objectProps(node.layout);
@ -300,8 +382,8 @@ export function renderEveComponent(node, options = currentOptions) {
if (adapter?.renderComponent) {
return adapter.renderComponent(node, props, layout, style, options, {
applyGeneratedLayout,
providerId: currentSurfaceDocument?.providerId || options.provider?.providerId,
resolveAssetUrl,
providerId: renderContext(options)?.surface.providerId || options.provider?.providerId,
resolveAssetUrl: uri => resolveAssetUrl(uri, options),
});
}
if (kind === "world.scene3d" || kind === "world.scene2d") {
@ -309,7 +391,7 @@ export function renderEveComponent(node, options = currentOptions) {
}
if (kind === "image.background") {
const view = el("div", "cultui-background");
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src)
view.style.backgroundImage = `url("${src}")`;
view.setAttribute("aria-label", stringProp(props.label, "background"));
@ -318,7 +400,7 @@ export function renderEveComponent(node, options = currentOptions) {
if (kind === "image.sprite") {
const figure = el("figure", `cultui-sprite ${stringProp(props.slot, "center")}`);
assignId(figure, node);
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) {
const image = el("img");
image.src = src;
@ -330,7 +412,7 @@ export function renderEveComponent(node, options = currentOptions) {
if (kind === "avatar") {
const card = el("figure", "cultui-avatar-card");
assignId(card, node);
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) {
const image = el("img");
image.src = src;
@ -529,7 +611,7 @@ export function renderEveComponent(node, options = currentOptions) {
const figure = el("figure", "cultui-image-preview");
assignId(figure, node);
const frame = el("div", "cultui-image-frame");
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) {
const image = el("img");
image.src = src;
@ -848,7 +930,7 @@ function renderWorldScene(node, props, layout, style, options) {
function emitWorldCommand(command, action, node, options) {
void options.commandSink?.(createWorldActionIntent(command, action, options), node);
}
export function createWorldActionIntent(command, action, options = currentOptions) {
export function createWorldActionIntent(command, action, options = {}) {
return createEveCommandIntent(command, { action }, options);
}
function worldDirectionForKey(key) {
@ -1060,7 +1142,7 @@ function renderInventoryItem(node, props, layout, style, options) {
function hasInventoryDropCommand(target) {
return Object.entries(target).some(([key, value]) => (key === "dropCommand" || key.startsWith("dropCommand.")) && typeof value === "string" && value.length > 0);
}
export function createInventoryDropIntent(source, target, destinationX, destinationY, options = currentOptions) {
export function createInventoryDropIntent(source, target, destinationX, destinationY, options = {}) {
const sourceKind = firstString(source.sourceKind, source.source, "");
const command = firstString(target[`dropCommand.${sourceKind}`], target.dropCommand, "");
if (!sourceKind || !command)
@ -1395,10 +1477,11 @@ export function applyEveSurfaceStyles(styles, body = document.body) {
body.dataset.pixelArt = normalized.tokens.pixelArt || normalized.tokens.imageRendering === "pixelated" ? "true" : "false";
body.dataset.scanlines = normalized.tokens.scanlineOverlay ? "true" : "false";
}
export function createEveCommandIntent(commandId, props = {}, options = currentOptions) {
export function createEveCommandIntent(commandId, props = {}, options = {}) {
const action = objectProps(props.action);
const providerId = options.provider?.providerId || currentSurfaceDocument?.providerId || "surface unknown";
const surfaceId = options.activeSurfaceId || currentSurfaceDocument?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId;
const surface = renderContext(options)?.surface;
const providerId = options.provider?.providerId || surface?.providerId || "surface unknown";
const surfaceId = options.activeSurfaceId || surface?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId;
const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId);
const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target);
const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema);
@ -1456,7 +1539,7 @@ function wireCommand(element, node, commandId, props, options) {
});
}
function renderSlider(props, children, options) {
const anatomy = resolveSliderAnatomy(props, children);
const anatomy = resolveSliderAnatomy(props, children, options);
const box = objectProps(anatomy.find(part => part.kind === "control.box")?.props);
const parts = anatomy.filter(part => part.kind === "control.part");
const min = Number(props.min ?? 0);
@ -1507,9 +1590,11 @@ function renderSlider(props, children, options) {
});
return slider;
}
function resolveSliderAnatomy(props, children) {
function resolveSliderAnatomy(props, children, options) {
const skinName = typeof props.skin === "string" ? props.skin : "";
const skinChildren = skinName ? currentSurfaceStyles.controlSkins[skinName]?.children || [] : [];
const skinChildren = skinName
? renderContext(options)?.styles.controlSkins[skinName]?.children || []
: [];
const anatomy = [...skinChildren, ...children];
if (anatomy.length)
return anatomy;
@ -1540,27 +1625,26 @@ function normalizeSurfaceStyles(styles) {
controlSkins: objectProps(styles?.controlSkins),
};
}
function resolveAssetUrl(uri) {
function resolveAssetUrl(uri, options) {
if (!uri)
return "";
const resolver = currentOptions.assetUrlResolver;
const surface = renderContext(options)?.surface;
const resolver = options.assetUrlResolver;
if (resolver)
return resolver(uri, currentSurfaceDocument);
return resolver(uri, surface);
if (/^(https?:|data:|blob:)/i.test(uri))
return uri;
if (currentOptions.assetBaseUrl && uri.startsWith("/")) {
return `${currentOptions.assetBaseUrl.replace(/\/+$/, "")}${uri}`;
if (options.assetBaseUrl && uri.startsWith("/")) {
return `${options.assetBaseUrl.replace(/\/+$/, "")}${uri}`;
}
return uri;
}
function loadFontStylesheet(href) {
const normalized = typeof href === "string" ? href.trim() : "";
if (activeFontStylesheet?.dataset.href === normalized)
return;
activeFontStylesheet?.remove();
activeFontStylesheet = undefined;
if (!normalized)
return;
if (loadedFontStylesheets.has(normalized))
return;
const preconnect = document.createElement("link");
preconnect.rel = "preconnect";
preconnect.href = "https://fonts.gstatic.com";
@ -1571,7 +1655,7 @@ function loadFontStylesheet(href) {
link.href = normalized;
link.dataset.href = normalized;
document.head.append(link);
activeFontStylesheet = link;
loadedFontStylesheets.set(normalized, link);
}
function textClassName(kind, props, node) {
const classes = [];

View file

@ -0,0 +1,58 @@
# Browser Lowering Host Authority
Owner:
- one rendered host owns its surface document, lowering options, style tokens,
component-to-element index, and state-binding subscriptions;
- the provider owns the surface graph and command meaning;
- the browser lowerer owns only DOM projection and interaction forwarding.
Inputs:
- one `EveSurfaceDocument`;
- one host element;
- host-scoped lowering options, plugin adapters, asset resolver, and binding
resolver;
- canonical state-binding values.
Outputs:
- DOM beneath that host;
- typed command intents carrying that host's provider and surface identity;
- host-local style variables and provider diagnostics.
Derived state:
- DOM nodes, focus restoration data, normalized styles, and component indexes
are projections of the owning surface;
- loaded font stylesheets are a document resource cache, not active-surface
state.
Forbidden writers:
- another host may not replace the active surface, options, styles, assets, or
command identity used by this host;
- binding updates may not rebuild an unrelated host or mutate provider state;
- module globals may not decide which surface a command or asset belongs to.
Shared paths:
- initial render, embedded render, plugin render, binding updates, command
creation, and asset resolution all receive the same host-scoped context;
- initial binding hydration and live binding watches use the same component
projection replacement primitive.
Cut line:
- delete module-global current surface/options/styles;
- remove the global current-mesh side channel;
- replace whole-host binding rerenders with indexed component-subtree updates;
- preserve focus, selection, and host scroll across a bound component update.
Verification:
- two hosts in one document retain distinct provider commands, asset routes,
and control skins after either host renders or updates;
- a bound leaf update does not replace either host root;
- focused input selection and host scroll survive a binding update;
- disposing or rerendering one host cannot unsubscribe the other.

View file

@ -8,9 +8,512 @@
"name": "@gamecult/eve-browser-lowering",
"version": "0.1.0",
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",
"typescript": "^5.6.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.1.10",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz",
"integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz",
"integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@types/jsdom": {
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz",
"integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/tough-cookie": "*",
"parse5": "^8.0.0",
"undici-types": "^8.9.0"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/node/node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.10"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -24,6 +527,88 @@
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.0.tgz",
"integrity": "sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}

View file

@ -9,10 +9,12 @@
"styles.css"
],
"scripts": {
"build": "C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json",
"test": "C:\\Progra~1\\nodejs\\node.exe --test test/*.test.mjs"
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
"test": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test test/*.test.mjs"
},
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",
"typescript": "^5.6.3"
}
}

View file

@ -297,10 +297,7 @@ const defaultStyleTokens: Record<string, string> = {
"--font-mono": "\"Ubuntu Sans Mono\", \"M PLUS 1 Code\", \"Cascadia Mono\", Consolas, monospace",
};
let activeFontStylesheet: HTMLLinkElement | undefined;
let currentSurfaceStyles: NormalizedStyles = normalizeSurfaceStyles(undefined);
let currentSurfaceDocument: EveSurfaceDocument | undefined;
let currentOptions: EveBrowserLoweringOptions = {};
const loadedFontStylesheets = new Map<string, HTMLLinkElement>();
const activeSurfaceBindings = new WeakMap<HTMLElement, EveSurfaceBindingController>();
interface NormalizedStyles {
@ -309,17 +306,44 @@ interface NormalizedStyles {
controlSkins: Record<string, { children?: EveSurfaceComponent[] }>;
}
interface EveBrowserRenderContext {
readonly surface: EveSurfaceDocument;
readonly styles: NormalizedStyles;
readonly elements: WeakMap<EveSurfaceComponent, HTMLElement>;
}
const eveBrowserRenderContext = Symbol("eve.browser.render-context");
type EveBrowserScopedOptions = EveBrowserLoweringOptions & {
[eveBrowserRenderContext]?: EveBrowserRenderContext;
};
function scopeEveBrowserOptions(
surface: EveSurfaceDocument,
options: EveBrowserLoweringOptions,
): EveBrowserScopedOptions {
return {
...options,
[eveBrowserRenderContext]: {
surface,
styles: normalizeSurfaceStyles(surface.surface?.styles),
elements: new WeakMap(),
},
};
}
function renderContext(options: EveBrowserLoweringOptions): EveBrowserRenderContext | undefined {
return (options as EveBrowserScopedOptions)[eveBrowserRenderContext];
}
export function renderEveSurface(
surface: EveSurfaceDocument,
host: HTMLElement,
options: EveBrowserLoweringOptions = {},
): HTMLElement {
activeSurfaceBindings.get(host)?.dispose();
currentSurfaceDocument = surface;
currentOptions = options;
currentSurfaceStyles = normalizeSurfaceStyles(surface.surface?.styles);
(globalThis as { __eveCurrentMesh?: unknown }).__eveCurrentMesh = surface.mesh;
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? document.body);
const scopedOptions = scopeEveBrowserOptions(surface, options);
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host);
host.dataset.provider = surface.providerId || options.provider?.providerId || "";
if (options.body) {
options.body.dataset.provider = surface.providerId || options.provider?.providerId || "";
}
@ -327,9 +351,9 @@ export function renderEveSurface(
options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`;
}
if (surface.surface?.root) {
host.replaceChildren(renderEveComponent(surface.surface.root, options));
host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions));
if (options.stateBindingResolver) {
const controller = new EveSurfaceBindingController(surface, host, options);
const controller = new EveSurfaceBindingController(surface, host, scopedOptions);
activeSurfaceBindings.set(host, controller);
void controller.start();
}
@ -359,6 +383,8 @@ export function applyEveStateBindingValue(
class EveSurfaceBindingController {
private disposed = false;
private readonly unsubscribers: Array<() => void> = [];
private readonly pendingComponents = new Set<EveSurfaceComponent>();
private projectionQueued = false;
constructor(
private readonly surface: EveSurfaceDocument,
@ -370,6 +396,7 @@ class EveSurfaceBindingController {
const resolver = this.options.stateBindingResolver;
const root = this.surface.surface?.root;
if (!resolver || !root) return;
const hydratedComponents = new Set<EveSurfaceComponent>();
for (const component of walkEveComponents(root)) {
for (const binding of component.stateBindings || []) {
if (this.disposed) return;
@ -378,24 +405,93 @@ class EveSurfaceBindingController {
const apply = (value: unknown) => {
if (this.disposed) return;
applyEveStateBindingValue(component, binding, value);
this.renderCanonicalProjection();
this.queueComponentProjection(component);
};
apply(await handle.latest());
applyEveStateBindingValue(component, binding, await handle.latest());
hydratedComponents.add(component);
if (this.disposed) return;
this.unsubscribers.push(handle.watch(apply));
}
}
for (const component of hydratedComponents) {
if (this.disposed) return;
this.renderComponentProjection(component);
}
}
dispose(): void {
this.disposed = true;
this.pendingComponents.clear();
for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe();
}
private renderCanonicalProjection(): void {
const root = this.surface.surface?.root;
if (root) this.host.replaceChildren(renderEveComponent(root, this.options));
private queueComponentProjection(component: EveSurfaceComponent): void {
this.pendingComponents.add(component);
if (this.projectionQueued) return;
this.projectionQueued = true;
queueMicrotask(() => {
this.projectionQueued = false;
if (this.disposed) return;
for (const pending of this.pendingComponents) this.renderComponentProjection(pending);
this.pendingComponents.clear();
});
}
private renderComponentProjection(component: EveSurfaceComponent): void {
const current = renderContext(this.options)?.elements.get(component);
if (!current?.isConnected) return;
replaceElementPreservingInteraction(
current,
renderEveComponent(component, this.options),
this.host,
);
}
}
function replaceElementPreservingInteraction(
current: HTMLElement,
replacement: HTMLElement,
host: HTMLElement,
): void {
const active = current.ownerDocument.activeElement;
const activeElement = active instanceof HTMLElement && current.contains(active) ? active : undefined;
const activePath = activeElement ? elementPath(current, activeElement) : undefined;
const selection = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement
? { start: activeElement.selectionStart, end: activeElement.selectionEnd, direction: activeElement.selectionDirection }
: undefined;
const hostScroll = { left: host.scrollLeft, top: host.scrollTop };
current.replaceWith(replacement);
host.scrollLeft = hostScroll.left;
host.scrollTop = hostScroll.top;
if (!activePath) return;
const nextActive = elementAtPath(replacement, activePath);
nextActive?.focus({ preventScroll: true });
if (selection && (nextActive instanceof HTMLInputElement || nextActive instanceof HTMLTextAreaElement)) {
nextActive.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
}
}
function elementPath(root: HTMLElement, target: HTMLElement): number[] | undefined {
if (root === target) return [];
const path: number[] = [];
let current: HTMLElement | null = target;
while (current && current !== root) {
const parent: HTMLElement | null = current.parentElement;
if (!parent) return undefined;
path.unshift(Array.prototype.indexOf.call(parent.children, current));
current = parent;
}
return current === root ? path : undefined;
}
function elementAtPath(root: HTMLElement, path: readonly number[]): HTMLElement | undefined {
let current: Element = root;
for (const index of path) {
const child = current.children.item(index);
if (!(child instanceof HTMLElement)) return undefined;
current = child;
}
return current instanceof HTMLElement ? current : undefined;
}
function* walkEveComponents(root: EveSurfaceComponent): Generator<EveSurfaceComponent> {
@ -405,7 +501,16 @@ function* walkEveComponents(root: EveSurfaceComponent): Generator<EveSurfaceComp
export function renderEveComponent(
node: EveSurfaceComponent,
options: EveBrowserLoweringOptions = currentOptions,
options: EveBrowserLoweringOptions = {},
): HTMLElement {
const element = renderEveComponentProjection(node, options);
renderContext(options)?.elements.set(node, element);
return element;
}
function renderEveComponentProjection(
node: EveSurfaceComponent,
options: EveBrowserLoweringOptions,
): HTMLElement {
const kind = node.kind || "panel";
const props = objectProps(node.props);
@ -485,8 +590,8 @@ export function renderEveComponent(
if (adapter?.renderComponent) {
return adapter.renderComponent(node, props, layout, style, options, {
applyGeneratedLayout,
providerId: currentSurfaceDocument?.providerId || options.provider?.providerId,
resolveAssetUrl,
providerId: renderContext(options)?.surface.providerId || options.provider?.providerId,
resolveAssetUrl: uri => resolveAssetUrl(uri, options),
});
}
@ -496,7 +601,7 @@ export function renderEveComponent(
if (kind === "image.background") {
const view = el("div", "cultui-background");
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) view.style.backgroundImage = `url("${src}")`;
view.setAttribute("aria-label", stringProp(props.label, "background"));
return view;
@ -505,7 +610,7 @@ export function renderEveComponent(
if (kind === "image.sprite") {
const figure = el("figure", `cultui-sprite ${stringProp(props.slot, "center")}`);
assignId(figure, node);
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) {
const image = el("img") as HTMLImageElement;
image.src = src;
@ -518,7 +623,7 @@ export function renderEveComponent(
if (kind === "avatar") {
const card = el("figure", "cultui-avatar-card");
assignId(card, node);
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) {
const image = el("img") as HTMLImageElement;
image.src = src;
@ -707,7 +812,7 @@ export function renderEveComponent(
const figure = el("figure", "cultui-image-preview");
assignId(figure, node);
const frame = el("div", "cultui-image-frame");
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""));
const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, ""), options);
if (src) {
const image = el("img") as HTMLImageElement;
image.src = src;
@ -1027,7 +1132,7 @@ function emitWorldCommand(
export function createWorldActionIntent(
command: string,
action: Record<string, unknown>,
options: EveBrowserLoweringOptions = currentOptions,
options: EveBrowserLoweringOptions = {},
): EveCommandIntent {
return createEveCommandIntent(command, { action }, options);
}
@ -1261,7 +1366,7 @@ export function createInventoryDropIntent(
target: Record<string, unknown>,
destinationX: number,
destinationY: number,
options: EveBrowserLoweringOptions = currentOptions,
options: EveBrowserLoweringOptions = {},
): EveCommandIntent | undefined {
const sourceKind = firstString(source.sourceKind, source.source, "");
const command = firstString(target[`dropCommand.${sourceKind}`], target.dropCommand, "");
@ -1628,11 +1733,12 @@ export function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body
export function createEveCommandIntent(
commandId: string,
props: Record<string, unknown> = {},
options: EveBrowserLoweringOptions = currentOptions,
options: EveBrowserLoweringOptions = {},
): EveCommandIntent {
const action = objectProps(props.action);
const providerId = options.provider?.providerId || currentSurfaceDocument?.providerId || "surface unknown";
const surfaceId = options.activeSurfaceId || currentSurfaceDocument?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId;
const surface = renderContext(options)?.surface;
const providerId = options.provider?.providerId || surface?.providerId || "surface unknown";
const surfaceId = options.activeSurfaceId || surface?.surface?.id || options.provider?.surfaces?.[0]?.surfaceId || providerId;
const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId);
const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target);
const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema);
@ -1715,7 +1821,7 @@ function renderSlider(
children: EveSurfaceComponent[],
options: EveBrowserLoweringOptions,
): HTMLElement {
const anatomy = resolveSliderAnatomy(props, children);
const anatomy = resolveSliderAnatomy(props, children, options);
const box = objectProps(anatomy.find(part => part.kind === "control.box")?.props);
const parts = anatomy.filter(part => part.kind === "control.part");
const min = Number(props.min ?? 0);
@ -1764,9 +1870,15 @@ function renderSlider(
return slider;
}
function resolveSliderAnatomy(props: Record<string, unknown>, children: EveSurfaceComponent[]): EveSurfaceComponent[] {
function resolveSliderAnatomy(
props: Record<string, unknown>,
children: EveSurfaceComponent[],
options: EveBrowserLoweringOptions,
): EveSurfaceComponent[] {
const skinName = typeof props.skin === "string" ? props.skin : "";
const skinChildren = skinName ? currentSurfaceStyles.controlSkins[skinName]?.children || [] : [];
const skinChildren = skinName
? renderContext(options)?.styles.controlSkins[skinName]?.children || []
: [];
const anatomy = [...skinChildren, ...children];
if (anatomy.length) return anatomy;
return [
@ -1795,23 +1907,22 @@ function normalizeSurfaceStyles(styles: EveSurfaceStyles | undefined): Normalize
};
}
function resolveAssetUrl(uri: string): string {
function resolveAssetUrl(uri: string, options: EveBrowserLoweringOptions): string {
if (!uri) return "";
const resolver = currentOptions.assetUrlResolver;
if (resolver) return resolver(uri, currentSurfaceDocument);
const surface = renderContext(options)?.surface;
const resolver = options.assetUrlResolver;
if (resolver) return resolver(uri, surface);
if (/^(https?:|data:|blob:)/i.test(uri)) return uri;
if (currentOptions.assetBaseUrl && uri.startsWith("/")) {
return `${currentOptions.assetBaseUrl.replace(/\/+$/, "")}${uri}`;
if (options.assetBaseUrl && uri.startsWith("/")) {
return `${options.assetBaseUrl.replace(/\/+$/, "")}${uri}`;
}
return uri;
}
function loadFontStylesheet(href: unknown): void {
const normalized = typeof href === "string" ? href.trim() : "";
if (activeFontStylesheet?.dataset.href === normalized) return;
activeFontStylesheet?.remove();
activeFontStylesheet = undefined;
if (!normalized) return;
if (loadedFontStylesheets.has(normalized)) return;
const preconnect = document.createElement("link");
preconnect.rel = "preconnect";
preconnect.href = "https://fonts.gstatic.com";
@ -1822,7 +1933,7 @@ function loadFontStylesheet(href: unknown): void {
link.href = normalized;
link.dataset.href = normalized;
document.head.append(link);
activeFontStylesheet = link;
loadedFontStylesheets.set(normalized, link);
}
function textClassName(kind: string, props: Record<string, unknown>, node: EveSurfaceComponent): string {

View file

@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { renderEveSurface } from "../dist/index.js";
function installDom() {
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='a'></div><div id='b'></div></body></html>", {
url: "https://eve.test/",
pretendToBeVisual: true,
});
const names = [
"document",
"window",
"Element",
"HTMLElement",
"HTMLButtonElement",
"HTMLInputElement",
"HTMLTextAreaElement",
"HTMLImageElement",
];
const previous = new Map(names.map(name => [name, globalThis[name]]));
for (const name of names) globalThis[name] = dom.window[name];
return () => {
for (const [name, value] of previous) {
if (value === undefined) delete globalThis[name];
else globalThis[name] = value;
}
dom.window.close();
};
}
function testInputAdapter(onRender = () => {}) {
return {
pluginId: "test.input",
componentKinds: ["test.input"],
renderComponent(_node, props) {
onRender();
const input = document.createElement("input");
input.type = "text";
input.value = String(props.value ?? "");
return input;
},
};
}
function surface(providerId, imagePath, skinPart, inputComponent) {
return {
providerId,
surface: {
id: `${providerId}.hangar`,
styles: {
tokens: { colorAccent: providerId === "alpha" ? "#a00" : "#00a" },
controlSkins: {
custom: {
children: [{ kind: "control.part", props: { name: skinPart } }],
},
},
},
root: {
id: `${providerId}.root`,
kind: "column",
children: [
{ kind: "image.preview", props: { src: imagePath, label: providerId } },
{ kind: "control.slider", props: { skin: "custom", value: 0.5 } },
inputComponent,
{ kind: "control.button", props: { label: `Launch ${providerId}`, command: `${providerId}.launch` } },
],
},
},
};
}
test("browser lowering isolates two hosts and patches only the bound component", async () => {
const restoreDom = installDom();
try {
let applyAlphaBinding;
let alphaInputRenders = 0;
const alphaCommands = [];
const betaCommands = [];
const alphaInput = {
id: "alpha.input",
kind: "test.input",
props: { value: "initial" },
stateBindings: [{
targetProp: "value",
pointerId: "alpha.input.value",
sourceId: "alpha.state",
schemaId: "alpha.state.v1",
routeKind: "cultmesh",
}],
};
const alphaSurface = surface("alpha", "/ship.png", "track-alpha", alphaInput);
const betaSurface = surface("beta", "/ship.png", "track-beta", {
id: "beta.input",
kind: "test.input",
props: { value: "beta" },
});
const alphaHost = document.querySelector("#a");
const betaHost = document.querySelector("#b");
renderEveSurface(alphaSurface, alphaHost, {
assetUrlResolver: uri => `https://alpha.assets${uri}`,
commandSink: intent => alphaCommands.push(intent),
pluginAdapters: [testInputAdapter(() => { alphaInputRenders++; })],
stateBindingResolver: async () => ({
latest: async () => "hydrated",
watch: callback => {
applyAlphaBinding = callback;
return () => { applyAlphaBinding = undefined; };
},
}),
});
renderEveSurface(betaSurface, betaHost, {
assetUrlResolver: uri => `https://beta.assets${uri}`,
commandSink: intent => betaCommands.push(intent),
pluginAdapters: [testInputAdapter()],
});
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(alphaHost.querySelector("img").src, "https://alpha.assets/ship.png");
assert.equal(betaHost.querySelector("img").src, "https://beta.assets/ship.png");
assert.ok(alphaHost.querySelector(".track-alpha"));
assert.ok(betaHost.querySelector(".track-beta"));
alphaHost.querySelector("button").click();
betaHost.querySelector("button").click();
assert.equal(alphaCommands[0].providerId, "alpha");
assert.equal(alphaCommands[0].surfaceId, "alpha.hangar");
assert.equal(betaCommands[0].providerId, "beta");
assert.equal(betaCommands[0].surfaceId, "beta.hangar");
const alphaRoot = alphaHost.firstElementChild;
const betaRoot = betaHost.firstElementChild;
const focusedInput = alphaHost.querySelector("input[type='text']");
focusedInput.focus();
focusedInput.setSelectionRange(1, 4);
alphaHost.scrollTop = 37;
const rendersBeforeUpdates = alphaInputRenders;
for (let index = 0; index < 1000; index++) applyAlphaBinding(`updated-${index}`);
await new Promise(resolve => setTimeout(resolve, 0));
const updatedInput = alphaHost.querySelector("input[type='text']");
assert.equal(alphaHost.firstElementChild, alphaRoot);
assert.equal(betaHost.firstElementChild, betaRoot);
assert.equal(updatedInput.value, "updated-999");
assert.equal(alphaInputRenders, rendersBeforeUpdates + 1);
assert.equal(document.activeElement, updatedInput);
assert.equal(updatedInput.selectionStart, 1);
assert.equal(updatedInput.selectionEnd, 4);
assert.equal(alphaHost.scrollTop, 37);
} finally {
restoreDom();
}
});