Define Eve surface contract for Sai VN
This commit is contained in:
parent
7b1818b58c
commit
12a996293a
7 changed files with 574 additions and 4 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
140
docs/surface-contract-v1.md
Normal 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.
|
||||
137
web/fixtures/sai-vn-surface.json
Normal file
137
web/fixtures/sai-vn-surface.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
129
web/styles.css
129
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
156
web/surface.js
156
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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue