Define Eve surface contract for Sai VN

This commit is contained in:
Meta Vens 2026-05-31 12:59:08 +01:00
commit 12a996293a
7 changed files with 574 additions and 4 deletions

View file

@ -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`

View file

@ -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:

140
docs/surface-contract-v1.md Normal file
View file

@ -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.

View file

@ -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" }
]
}

View file

@ -22,6 +22,7 @@
<nav class="tabs">
<button id="voidbot-tab" class="active" type="button">VoidBot Live</button>
<button id="fensalir-tab" type="button">Fensalir Direct2D</button>
<button id="sai-tab" type="button">Sai VN Surface</button>
</nav>
<main id="app" class="surface"></main>

View file

@ -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;
}
}

View file

@ -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");