From f8e6e8454ebe3f7760bd16795b92be89ae24dd7b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 6 Sep 2026 20:19:24 +0200 Subject: [PATCH] Add static lowering: an Eve surface rendered ahead of time to standalone HTML The live lowering binds to a provider over CultMesh and updates. That is right for an operator dashboard and structurally unavailable to everyone else, because an anonymous visitor to a public page is not a Verse participant and never will be. Rendering at publish time takes the mesh out of the delivery path, so a published surface becomes bytes any web server can hand out. It renders through renderEveSurface, the same function the browser calls, under jsdom. The alternative was a second emitter walking the same graph, which would drift from the live path and stay drifted until someone compared two pages side by side. Eve already renders this way in its own tests, so the harness is borrowed rather than invented. Controls that would publish a command are disabled, greyed, and labelled. A static page has no back-channel and a button that looks live is lying about one. They are marked rather than removed: deleting them would misrepresent the surface, when the truth is that the capability exists and is not reachable from a snapshot. No daemon. The point of static lowering is that a published surface stops depending on Hermodr running, and a lowering reachable only by starting a server would have handed that dependency straight back. jsdom is needed only here and only at build time, resolving from this repository's install or from the Eve checkout that already carries it. 29 tests pass. The suite covers the negative checks the extraction plan asked for: no operable command control survives, no live endpoint appears in the page, a title carrying markup is escaped rather than injected, and lowering the same surface twice gives identical bytes, which is what would let a later conformance check diff static output against the live renderer. Verified end to end against Erycina's real operator surface: all four panels render and its Run ingest control arrives inert. One thing found while writing the fixtures: the renderer silently drops component kinds it does not know. A fixture inventing one tests nothing, and the first version of these tests did exactly that. Co-Authored-By: Claude Opus 5 --- README.md | 27 ++++ package.json | 9 +- src/lower-surface.cjs | 97 +++++++++++++++ src/static-lowering.cjs | 223 ++++++++++++++++++++++++++++++++++ test/static-lowering.test.cjs | 145 ++++++++++++++++++++++ 5 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 src/lower-surface.cjs create mode 100644 src/static-lowering.cjs create mode 100644 test/static-lowering.test.cjs diff --git a/README.md b/README.md index 75854a7..bb52a8a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,33 @@ npm test # unit tests, no live Verse required npm run check ``` +## Static lowering + +The live lowering binds to a provider and updates, which is right for an +operator dashboard and structurally unavailable to anyone else: an anonymous +visitor to a public page is not a Verse participant and never will be. Rendering +a surface ahead of time removes the mesh from the delivery path, so the page +becomes bytes any web server can hand out — no mesh, no runtime, no daemon. + +```bash +npm run lower -- surface.json --out page.html +cat surface.json | node src/lower-surface.cjs > page.html +``` + +It renders through the same `renderEveSurface` the browser uses, under jsdom, +rather than through a second emitter walking the same graph. A separate emitter +would drift, and the drift would stay invisible until someone compared two pages +side by side. + +**Controls that would publish a command are rendered disabled and labelled.** A +static page has no back-channel; a button that looks live is lying about one. +They are marked rather than deleted, because deleting them would misrepresent +the surface — the capability exists, it just is not reachable from a snapshot. + +jsdom is needed only for this, and only at build time. It resolves from +Hermodr's own install if present, otherwise from the Eve checkout, which already +carries it for the renderer's own tests. + ## Known shape Provider state is read by polling, wrapped in lifecycle semantics that emit diff --git a/package.json b/package.json index ddcaf55..1560ef3 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,17 @@ "private": true, "description": "Eve browser lowering: resolves providers through Odin, reads their state, renders surfaces, and relays operator intent as typed CultMesh command documents.", "type": "commonjs", + "bin": { + "hermodr-lower": "src/lower-surface.cjs" + }, "scripts": { "start": "node src/hermodr-daemon.cjs", + "lower": "node src/lower-surface.cjs", "test": "node --test test/*.test.cjs", - "check": "node --check src/hermodr-daemon.cjs && node --check src/hermodr-provider-subscription.cjs && node --check src/hermodr-state-stream.cjs && node --check src/idunn-health.cjs && node --check src/args.cjs" + "check": "node --check src/hermodr-daemon.cjs && node --check src/hermodr-provider-subscription.cjs && node --check src/hermodr-state-stream.cjs && node --check src/idunn-health.cjs && node --check src/args.cjs && node --check src/static-lowering.cjs && node --check src/lower-surface.cjs" + }, + "devDependencies": { + "jsdom": "^30.0.1" }, "license": "MIT" } diff --git a/src/lower-surface.cjs b/src/lower-surface.cjs new file mode 100644 index 0000000..9c838d1 --- /dev/null +++ b/src/lower-surface.cjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +"use strict"; + +// Static lowering, from the command line. +// +// Reads a gamecult.eve.surface.v1 document and writes standalone HTML. It does +// not talk to Hermodr, Odin, or any provider: a published surface that needs a +// daemon running to be published is not static. + +const fs = require("fs"); +const path = require("path"); + +const { parseArgs } = require("./args.cjs"); +const { lowerSurfaceToHtml } = require("./static-lowering.cjs"); + +async function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.help || parsed.h) { + printUsage(); + return; + } + + const positional = process.argv.slice(2).filter((arg) => !arg.startsWith("--")); + const inputPath = stringOption(parsed.in || parsed.input) || positional[0]; + + const raw = inputPath ? fs.readFileSync(path.resolve(inputPath), "utf8") : await readStdin(); + if (!raw.trim()) { + throw new Error("No surface document given. Pass a path, or pipe one on stdin."); + } + + let surface; + try { + surface = JSON.parse(raw); + } catch (error) { + throw new Error(`Surface document is not valid JSON: ${error.message}`); + } + + const result = await lowerSurfaceToHtml(surface, { + eveRepoRoot: stringOption(parsed.eveRepoRoot || parsed["eve-repo-root"]), + title: stringOption(parsed.title), + lang: stringOption(parsed.lang), + source: stringOption(parsed.source) || (inputPath ? path.basename(inputPath) : "stdin"), + }); + + const outPath = stringOption(parsed.out || parsed.output); + if (outPath) { + const resolved = path.resolve(outPath); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, result.html); + process.stderr.write( + `Lowered "${result.title}" to ${resolved} (${result.inertCount} control${result.inertCount === 1 ? "" : "s"} marked inert)\n`, + ); + } else { + process.stdout.write(result.html); + } +} + +function stringOption(value) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +async function readStdin() { + if (process.stdin.isTTY) return ""; + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +function printUsage() { + console.log(`Usage: + node src/lower-surface.cjs [--out page.html] + cat surface.json | node src/lower-surface.cjs --out page.html + +Renders an Eve surface document to standalone HTML through the same renderer +the browser uses. Writes to stdout when --out is omitted. + + --out, --output write here instead of stdout + --eve-repo-root Eve checkout holding the browser lowering + (or HERMODR_EVE_REPO_ROOT) + --title override the page title + --lang document language, default en + --source provenance label passed to the renderer + +Controls that would publish a command are rendered disabled and labelled. A +static page has no back-channel, and a button that looks live is a lie about +one. +`); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error?.message || error}\n`); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/src/static-lowering.cjs b/src/static-lowering.cjs new file mode 100644 index 0000000..3fb98d6 --- /dev/null +++ b/src/static-lowering.cjs @@ -0,0 +1,223 @@ +"use strict"; + +// Static lowering: an Eve surface graph rendered ahead of time to standalone +// HTML. +// +// The live lowering binds to a provider over CultMesh and updates. That is +// exactly right for an operator dashboard and structurally unavailable to +// anyone else: an anonymous visitor to a public page is not a Verse participant +// and never will be. Rendering at publish time removes the mesh from the +// delivery path, so the page becomes bytes any web server can hand out. +// +// It renders through the same renderEveSurface the browser uses, under a DOM +// shim, rather than through a second emitter that walks the same graph. A +// separate emitter would drift, and the drift would be invisible until someone +// compared two pages side by side. +// +// This is a build step. It deliberately does not need the daemon: the whole +// point is that a published surface stops depending on Hermodr running, and a +// lowering you can only reach by starting a server has given that back. + +const fs = require("fs"); +const path = require("path"); +const { createRequire } = require("module"); + +const repoRoot = path.resolve(__dirname, ".."); +const projectsRoot = path.resolve(repoRoot, ".."); + +// Command controls carry these classes. Anything operable in the live surface +// must be visibly inert here; see neutraliseCommandControls. +const COMMAND_CONTROL_SELECTOR = "button, .eve-control-button, .eve-control-popup, [data-eve-command]"; + +function resolveEveRepoRoot(options = {}) { + return path.resolve( + options.eveRepoRoot || process.env.HERMODR_EVE_REPO_ROOT || path.resolve(projectsRoot, "Eve"), + ); +} + +function resolveLoweringPackage(eveRepoRoot) { + const entry = path.resolve(eveRepoRoot, "packages", "eve-browser-lowering", "dist", "index.js"); + if (!fs.existsSync(entry)) { + throw new Error( + `Eve browser lowering not found at ${entry}. Set --eve-repo-root or HERMODR_EVE_REPO_ROOT to an Eve checkout.`, + ); + } + return entry; +} + +// jsdom is a build-time dependency only. Prefer Hermodr's own install, fall +// back to Eve's, which already carries it for the renderer's own tests. +function requireJsdom(eveRepoRoot) { + const candidates = [ + path.resolve(repoRoot, "package.json"), + path.resolve(eveRepoRoot, "packages", "eve-browser-lowering", "package.json"), + path.resolve(eveRepoRoot, "package.json"), + ]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) continue; + try { + return createRequire(candidate)("jsdom"); + } catch { + // Try the next install root. + } + } + throw new Error( + "jsdom is required for static lowering and was not found. Run npm install here, or point --eve-repo-root at an Eve checkout that has it.", + ); +} + +const DOM_GLOBALS = [ + "document", + "window", + "Element", + "HTMLElement", + "HTMLButtonElement", + "HTMLInputElement", + "HTMLSelectElement", + "HTMLOptionElement", + "HTMLTextAreaElement", + "HTMLImageElement", + "Node", + "SVGElement", +]; + +function installDom(JSDOM) { + const dom = new JSDOM("
", { + url: "https://static.invalid/", + pretendToBeVisual: true, + }); + const previous = new Map(DOM_GLOBALS.map((name) => [name, globalThis[name]])); + for (const name of DOM_GLOBALS) { + if (dom.window[name] !== undefined) globalThis[name] = dom.window[name]; + } + return { + dom, + restore() { + for (const [name, value] of previous) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + dom.window.close(); + }, + }; +} + +/** + * Every control that would have published a command is disabled and labelled. + * + * A live-looking button in a static page is a lie about a back-channel that + * does not exist: the click goes nowhere and the visitor has no way to know + * that before trying. Marking them is the honest degradation, and it is what + * the extraction plan's negative check looks for. + */ +function neutraliseCommandControls(root, note) { + const controls = root.querySelectorAll(COMMAND_CONTROL_SELECTOR); + let count = 0; + for (const control of controls) { + control.setAttribute("disabled", "disabled"); + control.setAttribute("aria-disabled", "true"); + control.setAttribute("data-eve-static-inert", "true"); + control.setAttribute("title", note); + control.classList.add("eve-static-inert"); + count += 1; + } + return count; +} + +const INERT_STYLES = ` +.eve-static-inert { + opacity: 0.55; + cursor: not-allowed; + filter: grayscale(1); +} +.eve-static-inert::after { + content: " (unavailable)"; + font-size: 0.85em; + opacity: 0.8; +} +`; + +/** + * Renders a gamecult.eve.surface.v1 document to a standalone HTML string. + * + * No command sink is supplied, so nothing here can publish intent even if a + * control survived neutralisation. + */ +async function lowerSurfaceToHtml(surface, options = {}) { + if (!surface || typeof surface !== "object") { + throw new Error("Static lowering requires a surface document."); + } + + const eveRepoRoot = resolveEveRepoRoot(options); + const { JSDOM } = requireJsdom(eveRepoRoot); + const loweringEntry = resolveLoweringPackage(eveRepoRoot); + const { renderEveSurface } = await import(`file://${loweringEntry.replace(/\\/g, "/")}`); + + const installed = installDom(JSDOM); + try { + const { document } = installed.dom.window; + const host = document.getElementById("eve-surface"); + + renderEveSurface(surface, host, { + body: document.body, + clientId: "hermodr.static", + source: options.source || "static", + // No commandSink, no documentResolver, no stateBindingResolver: a static + // artifact has no back-channel and no live state to bind to. + }); + + const inertCount = neutraliseCommandControls( + host, + options.inertNote || "This page is a static snapshot. Controls are not connected.", + ); + + const title = options.title + || surface.title + || surface.surface?.title + || surface.providerId + || "Eve surface"; + + const styles = [readPackageStyles(eveRepoRoot), INERT_STYLES, options.extraCss || ""] + .filter(Boolean) + .join("\n"); + + const html = [ + "", + ``, + "", + '', + '', + `${escapeText(title)}`, + ``, + "", + ``, + host.outerHTML, + "", + "", + "", + ].join("\n"); + + return { html, inertCount, title }; + } finally { + installed.restore(); + } +} + +function readPackageStyles(eveRepoRoot) { + const stylePath = path.resolve(eveRepoRoot, "packages", "eve-browser-lowering", "styles.css"); + return fs.existsSync(stylePath) ? fs.readFileSync(stylePath, "utf8") : ""; +} + +function escapeText(value) { + return String(value).replace(/[&<>]/g, (char) => ({ "&": "&", "<": "<", ">": ">" }[char])); +} + +function escapeAttribute(value) { + return escapeText(value).replace(/"/g, """); +} + +module.exports = { + COMMAND_CONTROL_SELECTOR, + lowerSurfaceToHtml, + neutraliseCommandControls, +}; diff --git a/test/static-lowering.test.cjs b/test/static-lowering.test.cjs new file mode 100644 index 0000000..95a3663 --- /dev/null +++ b/test/static-lowering.test.cjs @@ -0,0 +1,145 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { lowerSurfaceToHtml, neutraliseCommandControls } = require("../src/static-lowering.cjs"); + +function surfaceWith(children) { + return { + providerId: "erycina", + title: "Erycina Operator", + surface: { + schema: "gamecult.eve.surface.v1", + id: "erycina-operator-surface", + title: "Erycina Operator", + root: { id: "root", kind: "dashboard", props: { title: "Erycina" }, children }, + }, + }; +} + +const panel = { + id: "readiness", + kind: "panel", + props: { title: "Readiness" }, + // "metric" is the kind Erycina real surface uses for a labelled value. + // The renderer silently drops kinds it does not know, so a fixture that + // invents one tests nothing. + children: [{ id: "manifest", kind: "metric", props: { label: "Manifest", value: "present" } }], +}; + +const commandButton = { + id: "run-ingest", + kind: "control.button", + props: { label: "Run ingest", command: "erycina.ingest.run", target: "cultmesh://erycina/commands/ingest" }, +}; + +test("a surface lowers to a standalone document", async () => { + const { html, title } = await lowerSurfaceToHtml(surfaceWith([panel])); + + assert.equal(title, "Erycina Operator"); + assert.match(html, /^/); + assert.match(html, //); + assert.match(html, /<\/html>\s*$/); + // Content the graph declared actually reaches the page. + assert.match(html, /Readiness/); + assert.match(html, /Manifest/); +}); + +test("styles are inlined, so the page needs no second request", async () => { + const { html } = await lowerSurfaceToHtml(surfaceWith([panel])); + + assert.match(html, /