diff --git a/README.md b/README.md
index 95b2b89..b97dc94 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,8 @@ touch/motion observations as `mimir.eve_sensor_observation.v1`.
See `docs/cultmesh-streaming-ui-framework.md` for the target architecture.
See `docs/renderer-parity.md` for current browser/iOS/Android/Fensalir/Flutter
renderer parity.
+See `docs/surface-contract-v1.md` for the shared CultUI/CultMesh surface and
+command contract.
## Browser Reference
@@ -104,6 +106,8 @@ Open `http://127.0.0.1:8891/`. The page can spawn:
- `VoidBot Live`, connected to Mimir's `/eve/deck` broker.
- `Fensalir Direct2D`, a fixture surface for the planned
CultMesh-to-`AquariumUiDocument` lowering.
+- `Sai VN Surface`, a fixture for visual-novel scenes exported as
+ `gamecult.eve.surface.v1`.
## VoidBot CEF Stream
@@ -237,7 +241,8 @@ without losing the basic app deployment path.
- Create the browser reference Eve runtime and use it as the visual/behavior
test oracle for native clients.
-- Split the shared CultNet/CultMesh surface contract from the iOS app code.
+- Split the shared CultNet/CultMesh surface contract from the iOS app code and
+ keep `gamecult.eve.surface.v1` as the renderer-facing contract.
- Replace JSON/base64 sensor packets with binary framing once camera and mic
timing are proven through Mimir.
- Replace the dashboard fixture state with live `MimirPresentationControlState`
diff --git a/docs/cultmesh-streaming-ui-framework.md b/docs/cultmesh-streaming-ui-framework.md
index 6a589ea..49978c8 100644
--- a/docs/cultmesh-streaming-ui-framework.md
+++ b/docs/cultmesh-streaming-ui-framework.md
@@ -39,10 +39,18 @@ distributed UI layer is `surface.root`, a CultUI-shaped tree:
references.
- `detail`: structured text, state paths, diagnostics, and provenance.
+The current contract id is `gamecult.eve.surface.v1`; command envelopes use
+`gamecult.eve.command.v1`. See `docs/surface-contract-v1.md`.
+
The browser is the reference renderer for this model. Native renderers should
match the document semantics, then choose native controls where that gives the
device better latency, touch, media, or sensor access.
+Sai visual novels are now a first pressure test for this contract. Sai needs
+backgrounds, dialogue panels, choices, sprite layers, provider-owned cards,
+Norn graph navigation, and synchronized style controls. If a renderer can lower
+that tree, it can handle more than a dashboard pretending to be a list.
+
## Sensor Model
Eve clients publish timestamped sensor streams:
diff --git a/docs/surface-contract-v1.md b/docs/surface-contract-v1.md
new file mode 100644
index 0000000..e689109
--- /dev/null
+++ b/docs/surface-contract-v1.md
@@ -0,0 +1,140 @@
+# Eve Surface Contract v1
+
+`gamecult.eve.surface.v1` is the shared CultMesh UI document for Eve surfaces.
+It is the contract that CultUI should consume. Renderers may be web, UIKit,
+Android native views, Flutter, Windows Direct2D/DirectWrite, or another local
+client, but the semantic surface is the same retained tree.
+
+## Authority
+
+- Provider owns truth, accepted state, command effects, and style token values.
+- Eve owns the surface document contract, command envelope, renderer parity, and
+ local input/sensor publication.
+- CultMesh owns delivery, replay, provenance, and subscription state.
+- Renderers own native projection only. They do not invent provider semantics.
+
+## Surface Document
+
+Required top-level fields:
+
+- `type`: `surface-state`
+- `schema`: `gamecult.eve.surface.v1`
+- `providerId`: stable provider id.
+- `providerKind`: provider family, for example `sai.visual_novel`.
+- `title`: human label.
+- `version`: provider-owned monotonically increasing version.
+- `updatedAt`: provider timestamp.
+- `surface.root`: retained CultUI component tree.
+- `surface.styles`: synchronized style tokens and optional style controls.
+- `commands`: command templates the provider accepts.
+
+Compatibility fields:
+
+- `nodes`: old graph/dashboard projection for legacy clients.
+- `selectedNodeId`: old selection projection.
+
+These fields are display projections. New renderers should prefer
+`surface.root`.
+
+## Component Shape
+
+Every component has:
+
+```json
+{
+ "id": "stable.local.id",
+ "kind": "panel.dialogue",
+ "props": {},
+ "children": []
+}
+```
+
+Kinds are semantic, not HTML tags. Renderers lower them to native controls:
+
+- `surface`, `stack`, `grid`, `dock`, `panel`, `card`
+- `text`, `text.dialogue`, `avatar`
+- `image.background`, `image.sprite`, `media.stream`
+- `graph`, `tree`, `inspector.kv`
+- `rail.actions`, `control.button`, `control.toggle`, `control.slider`,
+ `control.segmented`, `control.color`, `control.select`
+- domain kinds such as `vn.stage` when the provider needs richer semantics.
+
+If a renderer does not know a specialized kind, it should fall back through the
+kind path: `panel.dialogue` may render as `panel`; `image.sprite` may render as
+`image`.
+
+## Commands
+
+Commands use `gamecult.eve.command.v1`:
+
+```json
+{
+ "type": "surface-command",
+ "schema": "gamecult.eve.command.v1",
+ "providerId": "gamecult.home.vn",
+ "surfaceId": "sai.visual_novel.surface",
+ "command": "story.choose",
+ "payload": { "index": 0, "targetPath": "eve" },
+ "issuedAt": "2026-05-31T00:00:00.000Z",
+ "clientId": "browser.reference"
+}
+```
+
+Standard command names:
+
+- `select`
+- `invoke`
+- `open-provider`
+- `story.continue`
+- `story.choose`
+- `story.jump`
+- `style.patch`
+- `edit.value`
+- `toggle.visibility`
+- `transform.move`
+- `transform.scale`
+- `transform.rotate`
+
+Providers may add command names, but they must advertise them in `commands`.
+
+## Synchronized Style
+
+`surface.styles.tokens` is provider-owned appearance state. Renderers project
+tokens into CSS variables, UIKit colors/fonts, Android styles, DirectWrite
+brushes, or platform equivalents.
+
+Style controls are ordinary controls whose command is usually `style.patch`.
+They synchronize appearance by sending token edits back to the provider. The
+provider accepts or rejects the patch and republishes the next surface version.
+Local renderers may preview a pending value, but provider state is the authority.
+
+Minimum common tokens:
+
+- `colorBackground`
+- `colorPanel`
+- `colorPanelAlt`
+- `colorText`
+- `colorMuted`
+- `colorAccent`
+- `colorLink`
+- `radiusPanel`
+- `fontBody`
+- `fontTitle`
+- `pixelArt`
+- `motion`
+
+## Sai VN Requirements
+
+Sai requires Eve/CultUI to carry:
+
+- visual novel stage semantics;
+- background images;
+- speaker, avatar, dialogue, and choices;
+- sprite layers with position, scale, and offsets;
+- external provider-owned cards/fragments;
+- Norn-style graph components with clickable node targets;
+- synchronized style tokens;
+- story commands for continue, choice, jump, and style patch.
+
+Those requirements are now inside the contract, not Sai-specific browser
+behavior.
diff --git a/web/fixtures/sai-vn-surface.json b/web/fixtures/sai-vn-surface.json
new file mode 100644
index 0000000..d50366d
--- /dev/null
+++ b/web/fixtures/sai-vn-surface.json
@@ -0,0 +1,137 @@
+{
+ "type": "surface-state",
+ "schema": "gamecult.eve.surface.v1",
+ "providerKind": "sai.visual_novel",
+ "providerId": "gamecult.home.vn",
+ "title": "GameCult Compound VN",
+ "version": 1,
+ "updatedAt": "2026-05-31T00:00:00.000Z",
+ "surface": {
+ "id": "sai.visual_novel.surface",
+ "styles": {
+ "themeId": "gamecult.vn",
+ "tokens": {
+ "colorBackground": "#05080d",
+ "colorPanel": "rgba(5, 8, 13, 0.92)",
+ "colorPanelAlt": "rgba(9, 14, 22, 0.82)",
+ "colorText": "#f6f1e2",
+ "colorMuted": "#b7c7d9",
+ "colorAccent": "#ff8a2a",
+ "colorLink": "#59b7ff",
+ "radiusPanel": 8,
+ "fontBody": "Ubuntu",
+ "fontTitle": "Montserrat",
+ "pixelArt": true,
+ "motion": "subtle"
+ },
+ "controls": [
+ {
+ "id": "theme.accent",
+ "kind": "color",
+ "label": "Accent",
+ "token": "colorAccent",
+ "command": "style.patch"
+ }
+ ]
+ },
+ "root": {
+ "id": "sai.root",
+ "kind": "vn.stage",
+ "props": {
+ "mode": "visual-novel",
+ "storyId": "gamecult-compound",
+ "currentPath": "hub",
+ "style": "gamecult.vn"
+ },
+ "children": [
+ {
+ "id": "sai.background",
+ "kind": "image.background",
+ "props": {
+ "src": "/static/interactive/cotsc-praxis/backgrounds/compound.png",
+ "label": "Front Gate",
+ "fit": "cover",
+ "pixelArt": true
+ }
+ },
+ {
+ "id": "sai.graph",
+ "kind": "graph",
+ "props": {
+ "label": "GameCult Ink Knot Graph",
+ "layout": "norn.2d",
+ "nodes": [
+ { "id": "hub", "label": "Hub", "target": "hub", "x": 0.2, "y": 0.2, "current": true },
+ { "id": "eve", "label": "Eve", "target": "eve", "x": 0.72, "y": 0.24 },
+ { "id": "sai", "label": "Sai", "target": "sai", "x": 0.48, "y": 0.55 }
+ ],
+ "edges": [
+ { "source": "hub", "target": "eve" },
+ { "source": "hub", "target": "sai" },
+ { "source": "sai", "target": "eve" }
+ ]
+ }
+ },
+ {
+ "id": "sai.cards",
+ "kind": "layer.cards",
+ "children": [
+ {
+ "id": "sai.card.projects",
+ "kind": "card.external",
+ "props": {
+ "key": "projects",
+ "title": "Project Atlas",
+ "selector": "#gamecult-vn-projects",
+ "mode": "provider-owned-fragment"
+ }
+ }
+ ]
+ },
+ {
+ "id": "sai.dialogue",
+ "kind": "panel.dialogue",
+ "props": {
+ "speaker": "Void",
+ "text": "Pick a door. The map will keep accusing us of being real.",
+ "scene": "Front Gate"
+ },
+ "children": [
+ {
+ "id": "sai.dialogue.text",
+ "kind": "text.dialogue",
+ "props": {
+ "speaker": "Void",
+ "text": "Pick a door. The map will keep accusing us of being real."
+ }
+ }
+ ]
+ },
+ {
+ "id": "sai.choices",
+ "kind": "rail.actions",
+ "children": [
+ {
+ "id": "sai.choice.eve",
+ "kind": "control.button",
+ "props": {
+ "label": "What is Eve trying to turn the web into?",
+ "action": {
+ "schema": "gamecult.eve.command.v1",
+ "command": "story.choose",
+ "payload": { "index": 0, "targetPath": "eve" }
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "commands": [
+ { "schema": "gamecult.eve.command.v1", "command": "story.continue", "label": "Continue" },
+ { "schema": "gamecult.eve.command.v1", "command": "story.choose", "label": "Choose" },
+ { "schema": "gamecult.eve.command.v1", "command": "story.jump", "label": "Jump to knot" },
+ { "schema": "gamecult.eve.command.v1", "command": "style.patch", "label": "Patch style" }
+ ]
+}
diff --git a/web/index.html b/web/index.html
index 303d1cb..e0e9aa3 100644
--- a/web/index.html
+++ b/web/index.html
@@ -22,6 +22,7 @@
diff --git a/web/styles.css b/web/styles.css
index 1c53119..e55ddaf 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -20,7 +20,7 @@ body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
- font-family: Inter, "Segoe UI", system-ui, sans-serif;
+ font-family: var(--font-body, Inter), "Segoe UI", system-ui, sans-serif;
}
.topbar {
@@ -224,3 +224,130 @@ p {
grid-template-columns: 1fr;
}
}
+
+.cultui-vn-stage {
+ position: relative;
+ min-height: calc(100vh - 138px);
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--bg);
+}
+
+.cultui-background {
+ position: absolute;
+ inset: 0;
+ background-size: cover;
+ background-position: center;
+ image-rendering: pixelated;
+ opacity: 0.72;
+}
+
+.cultui-graph {
+ position: absolute;
+ top: 18px;
+ left: 18px;
+ width: min(48rem, 46vw);
+ height: min(29rem, 52vh);
+ z-index: 4;
+}
+
+.cultui-node {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ border: 2px solid var(--cyan);
+ border-radius: 999px;
+ background: rgba(7, 17, 26, 0.76);
+ color: var(--text);
+ min-width: 68px;
+ min-height: 44px;
+ padding: 8px 10px;
+ font: 700 12px "Cascadia Mono", Consolas, monospace;
+}
+
+.cultui-node.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(255, 184, 79, 0.28);
+}
+
+.cultui-graph svg {
+ position: absolute;
+ inset: 0;
+ overflow: visible;
+}
+
+.cultui-graph line {
+ stroke: rgba(142, 252, 255, 0.36);
+ stroke-width: 0.006;
+ stroke-linecap: round;
+}
+
+.cultui-layer-cards {
+ position: absolute;
+ top: 18px;
+ right: 18px;
+ left: calc(min(48rem, 46vw) + 36px);
+ z-index: 3;
+ display: grid;
+ gap: 10px;
+}
+
+.cultui-dialogue {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 5;
+ min-height: 128px;
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+ border-radius: 0;
+ background: rgba(5, 8, 13, 0.92);
+}
+
+.cultui-rail-actions {
+ position: absolute;
+ right: 18px;
+ bottom: 18px;
+ z-index: 6;
+ display: grid;
+ gap: 8px;
+ width: min(28rem, 42vw);
+}
+
+.cultui-button {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: rgba(10, 36, 35, 0.94);
+ color: var(--text);
+ padding: 9px 10px;
+ text-align: left;
+ font: 700 12px "Cascadia Mono", Consolas, monospace;
+}
+
+.cultui-button:hover,
+.cultui-button:focus-visible {
+ border-color: var(--line-hot);
+ color: var(--accent);
+}
+
+@media (max-width: 900px) {
+ .cultui-graph {
+ right: 12px;
+ left: 12px;
+ width: auto;
+ height: min(18rem, 34vh);
+ }
+
+ .cultui-layer-cards {
+ top: calc(30px + min(18rem, 34vh));
+ right: 12px;
+ left: 12px;
+ }
+
+ .cultui-rail-actions {
+ left: 12px;
+ width: auto;
+ }
+}
diff --git a/web/surface.js b/web/surface.js
index 4e11989..84d8fb5 100644
--- a/web/surface.js
+++ b/web/surface.js
@@ -4,6 +4,7 @@ const surfaceId = document.querySelector("#surface-id");
const surfaceVersion = document.querySelector("#surface-version");
const voidBotTab = document.querySelector("#voidbot-tab");
const fensalirTab = document.querySelector("#fensalir-tab");
+const saiTab = document.querySelector("#sai-tab");
let socket;
@@ -19,10 +20,17 @@ fensalirTab.addEventListener("click", async () => {
renderSurface(await response.json(), "fixture");
});
+saiTab.addEventListener("click", async () => {
+ setActiveTab(saiTab);
+ closeSocket();
+ const response = await fetch("./fixtures/sai-vn-surface.json");
+ renderSurface(await response.json(), "fixture");
+});
+
openVoidBot();
function setActiveTab(tab) {
- for (const button of [voidBotTab, fensalirTab]) {
+ for (const button of [voidBotTab, fensalirTab, saiTab]) {
button.classList.toggle("active", button === tab);
}
}
@@ -61,13 +69,157 @@ function renderSurface(state, source) {
surfaceId.textContent = state.providerId || "surface unknown";
surfaceVersion.textContent = `v${state.version ?? "?"}`;
statusEl.textContent = `${state.title || "surface"} (${source})`;
- if (state.providerId === "voidbot.swarm") {
+ applySurfaceStyles(state.surface?.styles);
+ if (state.surface?.root) {
+ app.replaceChildren(renderCultComponent(state.surface.root));
+ } else if (state.providerId === "voidbot.swarm") {
renderVoidBot(state);
} else {
renderGraphSurface(state);
}
}
+function applySurfaceStyles(styles) {
+ const tokens = styles?.tokens || {};
+ const root = document.documentElement.style;
+ const map = {
+ colorBackground: "--bg",
+ colorPanel: "--panel",
+ colorPanelAlt: "--panel-2",
+ colorText: "--text",
+ colorMuted: "--quiet",
+ colorAccent: "--accent",
+ colorLink: "--cyan",
+ };
+ for (const [token, variable] of Object.entries(map)) {
+ if (tokens[token]) root.setProperty(variable, tokens[token]);
+ }
+ if (tokens.fontBody) root.setProperty("--font-body", tokens.fontBody);
+ if (tokens.fontTitle) root.setProperty("--font-title", tokens.fontTitle);
+}
+
+function renderCultComponent(node) {
+ const kind = node.kind || "panel";
+ const props = node.props || {};
+ const children = node.children || [];
+
+ if (kind === "vn.stage") {
+ const stage = el("section", "cultui-vn-stage");
+ for (const child of children) stage.append(renderCultComponent(child));
+ return stage;
+ }
+
+ if (kind === "image.background") {
+ const view = el("div", "cultui-background");
+ if (props.src) view.style.backgroundImage = `url("${props.src}")`;
+ view.setAttribute("aria-label", props.label || "background");
+ return view;
+ }
+
+ if (kind === "graph") {
+ return renderCultGraph(props);
+ }
+
+ if (kind === "layer.sprites" || kind === "layer.cards" || kind === "rail.actions") {
+ const layer = el("div", `cultui-${kind.replace(".", "-")}`);
+ for (const child of children) layer.append(renderCultComponent(child));
+ return layer;
+ }
+
+ if (kind === "image.sprite") {
+ const figure = el("figure", `cultui-sprite ${props.slot || "center"}`);
+ if (props.src) {
+ const image = el("img");
+ image.src = props.src;
+ image.alt = props.alt || props.actor || "";
+ figure.append(image);
+ }
+ return figure;
+ }
+
+ if (kind === "card.external") {
+ const card = el("article", "card cultui-card");
+ card.append(el("div", "card-title", props.title || props.key || "card"));
+ card.append(el("div", "detail", props.selector || props.htmlRef || "provider-owned fragment"));
+ return card;
+ }
+
+ if (kind === "panel.dialogue") {
+ const panel = el("section", "cultui-dialogue pane");
+ panel.append(el("h2", "", props.speaker || "Speaker"));
+ panel.append(el("div", "detail", props.text || ""));
+ for (const child of children) {
+ if (child.kind !== "text.dialogue") panel.append(renderCultComponent(child));
+ }
+ return panel;
+ }
+
+ if (kind === "avatar") {
+ const image = el("img", "avatar");
+ image.src = props.src || "";
+ image.alt = props.label || "";
+ return image;
+ }
+
+ if (kind === "text.dialogue" || kind === "text") {
+ return el("div", "detail", props.text || "");
+ }
+
+ if (kind === "control.button") {
+ const button = el("button", "cultui-button", props.label || "Action");
+ button.type = "button";
+ button.addEventListener("click", () => {
+ statusEl.textContent = `command ${props.action?.command || "invoke"} ${JSON.stringify(props.action?.payload || {})}`;
+ });
+ return button;
+ }
+
+ if (kind === "inspector.kv") {
+ const panel = el("section", "pane");
+ panel.append(el("h2", "", props.title || "Inspector"));
+ for (const item of props.items || []) {
+ panel.append(el("div", "detail", `${item.key}: ${item.value}`));
+ }
+ return panel;
+ }
+
+ const fallback = el("section", "pane");
+ fallback.append(el("h2", "", kind));
+ for (const child of children) fallback.append(renderCultComponent(child));
+ return fallback;
+}
+
+function renderCultGraph(props) {
+ const graph = el("section", "cultui-graph");
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 1 1");
+ svg.setAttribute("preserveAspectRatio", "none");
+ const nodes = props.nodes || [];
+ for (const edge of props.edges || []) {
+ const source = nodes.find(node => node.id === edge.source);
+ const target = nodes.find(node => node.id === edge.target);
+ if (!source || !target) continue;
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ line.setAttribute("x1", source.x ?? 0);
+ line.setAttribute("y1", source.y ?? 0);
+ line.setAttribute("x2", target.x ?? 0);
+ line.setAttribute("y2", target.y ?? 0);
+ svg.append(line);
+ }
+ graph.append(svg);
+ for (const node of nodes) {
+ const button = el("button", `cultui-node ${node.current ? "selected" : ""}`, node.label || node.id);
+ button.type = "button";
+ button.style.left = `${node.x * 100}%`;
+ button.style.top = `${node.y * 100}%`;
+ button.addEventListener("click", () => {
+ statusEl.textContent = `command story.jump {"targetPath":"${node.target || node.id}"}`;
+ });
+ graph.append(button);
+ }
+ return graph;
+}
+
function renderVoidBot(state) {
const nodes = state.nodes || [];
const ctb = nodes.filter(n => n.kind === "ctb-turn");