Add Eve reactive DSL reference

This commit is contained in:
Meta Vens 2026-05-31 16:49:21 +01:00
commit 86e428b04a
9 changed files with 556 additions and 5 deletions

View file

@ -93,6 +93,8 @@ 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.
See `docs/eve-dsl-reactive-bindings.md` for the first Eve DSL and reactive
CultMesh binding contract.
## Browser Reference
@ -110,6 +112,9 @@ Open `http://127.0.0.1:8891/`. The page can spawn:
- `Sai VN Surface`, a fixture for visual-novel scenes exported as
`gamecult.eve.surface.v1`, including embedded Norn and TeX surfaces placed
diegetically inside the scene.
- `Reactive DSL`, a browser-reference fixture that compiles a small `.eve`
composition language into the same surface contract and binds UI controls to
live CultMesh-shaped vars, collections, and derived fields.
## VoidBot CEF Stream

View file

@ -0,0 +1,93 @@
# Eve DSL And Reactive CultMesh Bindings
Eve surfaces need a small declarative language for visual composition. The DSL
is not a command language for agents and not an application state owner. It is a
portable way to describe a surface that Eve can lower into the shared
`gamecult.eve.surface.v1` retained tree.
## Authority
- Provider owns accepted truth, commands, side effects, and permissions.
- CultMesh owns state identity, versioning, provenance, subscriptions, event
replay, and conflict semantics.
- Eve owns visual composition, renderer parity, local input capture, and
platform lowering.
- Renderers own native projection only. They subscribe, render, and send
actions back through the advertised command boundary.
Eve makes state feel local. CultMesh decides what is true.
## Reactive Surface Model
The ergonomic layer has three reactive shapes:
- `var`: one live value with subscribers.
- `collection`: an ordered event-shaped set with append/update streams.
- `derive`: a computed field that updates from vars or collections.
The browser reference fixture proves the first cut with `web/eve-dsl.js` and
`web/fixtures/reactive-composition.eve`:
```eve
var face.name "Eve"
collection swarm.events
item swarm.events "surface compiled from Eve DSL"
derive swarm.eventCount count swarm.events
card pulse "Reactive Pulse"
metric "Event Count" bind swarm.eventCount
button "Append Event" append swarm.events "operator touched the stream at {{now}}"
end
```
This is deliberately small. The current compiler lowers DSL cards, text,
metrics, lists, and buttons into the ordinary Eve surface component tree. The
browser renderer binds those components to a lightweight CultMesh-shaped store
so updates are pushed into the DOM through subscriptions rather than manual
rerender calls.
## DSL Contract
The DSL should stay declarative:
- describe components, bindings, and actions;
- avoid hidden imperative renderer behavior;
- avoid provider-specific business logic;
- avoid posting, dispatch, or transport side effects.
The DSL can name an action, but the provider/CultMesh boundary accepts or
rejects that action. A local renderer may preview pending UI, but provider state
must remain the authority.
## Planned Visual Primitives
The first browser slice implements:
- `surface`
- `version`
- `var`
- `collection`
- `item`
- `derive count`
- `derive latest`
- `card`
- `title`
- `text`
- `metric`
- `list`
- `button append`
- `button set`
Next primitives should map onto the existing surface contract:
- `image`, `crop`, and `composite` for Eve-owned visual composition;
- `graph` / `embed.norn` for Norn-backed graph surfaces;
- `chart` for SVG-backed data views;
- `formula` / `embed.tex` for TeX cards;
- `layout`, `stack`, `grid`, `dock`, and `placement` for portable scenes;
- `bind` on every primitive that can expose a CultMesh var, collection, or
derived stream.
Do not turn this into a universal scripting language. If a feature needs
authority, persistence, or side effects, it belongs behind a provider command or
CultMesh action, not inside the visual DSL.

View file

@ -36,6 +36,27 @@ Compatibility fields:
These fields are display projections. New renderers should prefer
`surface.root`.
## Eve DSL
The Eve DSL is a source language for authoring `gamecult.eve.surface.v1`
documents. It is not a second runtime authority. A DSL compiler may lower cards,
text, metrics, lists, graphs, charts, formulas, and composites into
`surface.root`, while CultMesh still owns live state identity and providers still
own accepted commands.
Browser reference support starts in `web/eve-dsl.js` and the fixture
`web/fixtures/reactive-composition.eve`. The first binding primitives are:
- `var`: one live value surfaced as a reactive field.
- `collection`: event-shaped ordered values surfaced as lists or streams.
- `derive`: computed fields such as counts and latest events.
- `bind`: component props subscribe to a var, collection, or derived field.
The compiled document remains the contract renderers consume. Native clients do
not need to parse every authoring dialect immediately, but they must honor the
surface tree and binding semantics once the provider/CultMesh layer publishes
them.
## Component Shape
Every component has:

View file

@ -25,7 +25,7 @@ const fs = require('fs');
const path = require('path');
const root = process.argv[2];
const port = Number(process.argv[3]);
const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8'};
const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.eve':'text/plain; charset=utf-8'};
http.createServer((req, res) => {
const urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
const clean = urlPath === '/' ? '/index.html' : urlPath;

284
web/eve-dsl.js Normal file
View file

@ -0,0 +1,284 @@
export function compileEveDsl(source) {
const mesh = createCultMeshStore();
const root = {
id: "root",
kind: "grid",
props: { columns: "repeat(auto-fit, minmax(260px, 1fr))" },
children: [],
};
const state = {
providerId: "eve.dsl.fixture",
providerKind: "eve.composition",
title: "Eve DSL Surface",
version: 1,
surface: {
root,
styles: {
tokens: {
colorBackground: "#04070b",
colorPanel: "#0b1720",
colorPanelAlt: "#0e2230",
colorAccent: "#ffcc66",
colorText: "#eef7ff",
colorMuted: "#8ba7b8",
colorLink: "#8efcff",
},
},
},
commands: [],
mesh,
};
let currentComponent;
for (const rawLine of source.split(/\r?\n/)) {
const line = stripComment(rawLine).trim();
if (!line) continue;
const tokens = tokenize(line);
const [head, ...rest] = tokens;
if (head === "surface") {
state.providerId = rest[0] || state.providerId;
state.title = rest[1] || state.title;
continue;
}
if (head === "version") {
state.version = Number(rest[0]) || state.version;
continue;
}
if (head === "var") {
mesh.var(rest[0]).set(parseDslValue(rest.slice(1).join(" ")));
continue;
}
if (head === "collection") {
mesh.collection(rest[0]);
continue;
}
if (head === "item") {
mesh.collection(rest[0]).append(parseDslValue(rest.slice(1).join(" ")));
continue;
}
if (head === "derive") {
const [target, operator, sourcePath] = rest;
mesh.derive(target, operator, sourcePath);
continue;
}
if (head === "card") {
currentComponent = {
id: rest[0] || `card-${root.children.length + 1}`,
kind: "card",
props: { title: rest[1] || rest[0] || "Card" },
children: [],
};
root.children.push(currentComponent);
continue;
}
if (head === "end") {
currentComponent = undefined;
continue;
}
if (!currentComponent) continue;
if (head === "title") {
currentComponent.children.push({
id: `${currentComponent.id}.title.${currentComponent.children.length}`,
kind: "text.title",
props: parseTextProps(rest),
children: [],
});
continue;
}
if (head === "text") {
currentComponent.children.push({
id: `${currentComponent.id}.text.${currentComponent.children.length}`,
kind: "text",
props: parseTextProps(rest),
children: [],
});
continue;
}
if (head === "metric") {
currentComponent.children.push({
id: `${currentComponent.id}.metric.${currentComponent.children.length}`,
kind: "metric",
props: {
label: rest[0] || "Metric",
bind: readKeyword(rest, "bind"),
format: rest.includes("percent") ? "percent" : "value",
},
children: [],
});
continue;
}
if (head === "list") {
currentComponent.children.push({
id: `${currentComponent.id}.list.${currentComponent.children.length}`,
kind: "list",
props: {
title: rest[0] || "List",
bind: readKeyword(rest, "bind"),
},
children: [],
});
continue;
}
if (head === "button") {
currentComponent.children.push({
id: `${currentComponent.id}.button.${currentComponent.children.length}`,
kind: "control.button",
props: {
label: rest[0] || "Action",
action: parseButtonAction(rest),
},
children: [],
});
}
}
return state;
}
export function createCultMeshStore() {
const records = new Map();
const ensure = (path, initialValue) => {
if (!records.has(path)) {
records.set(path, {
value: initialValue,
subscribers: new Set(),
});
}
return records.get(path);
};
const notify = (path) => {
const record = ensure(path, undefined);
for (const subscriber of record.subscribers) subscriber(record.value);
};
return {
var(path) {
return {
get: () => ensure(path, undefined).value,
set: (value) => {
ensure(path, undefined).value = value;
notify(path);
},
subscribe: (subscriber) => {
const record = ensure(path, undefined);
record.subscribers.add(subscriber);
subscriber(record.value);
return () => record.subscribers.delete(subscriber);
},
};
},
collection(path) {
const record = ensure(path, []);
if (!Array.isArray(record.value)) record.value = [];
return {
get: () => record.value.slice(),
append: (value) => {
record.value = [...record.value, value];
notify(path);
},
subscribe: (subscriber) => {
record.subscribers.add(subscriber);
subscriber(record.value.slice());
return () => record.subscribers.delete(subscriber);
},
};
},
derive(target, operator, sourcePath) {
const source = this.var(sourcePath);
const targetVar = this.var(target);
source.subscribe((value) => {
if (operator === "count") {
targetVar.set(Array.isArray(value) ? value.length : 0);
} else if (operator === "latest") {
targetVar.set(Array.isArray(value) ? value[value.length - 1] : value);
}
});
},
applyAction(action) {
if (!action) return;
if (action.type === "append") {
this.collection(action.target).append(renderTemplate(action.value));
} else if (action.type === "set") {
this.var(action.target).set(renderTemplate(action.value));
}
},
};
}
function parseTextProps(tokens) {
const bind = readKeyword(tokens, "bind");
return {
text: bind ? "" : tokens.join(" "),
bind,
prefix: readKeyword(tokens, "prefix") || "",
suffix: readKeyword(tokens, "suffix") || "",
};
}
function parseButtonAction(tokens) {
const appendIndex = tokens.indexOf("append");
if (appendIndex >= 0) {
return {
type: "append",
target: tokens[appendIndex + 1],
value: tokens.slice(appendIndex + 2).join(" "),
};
}
const setIndex = tokens.indexOf("set");
if (setIndex >= 0) {
return {
type: "set",
target: tokens[setIndex + 1],
value: tokens.slice(setIndex + 2).join(" "),
};
}
return undefined;
}
function readKeyword(tokens, keyword) {
const index = tokens.indexOf(keyword);
return index >= 0 ? tokens[index + 1] : undefined;
}
function parseDslValue(value) {
const trimmed = value.trim();
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (trimmed === "true") return true;
if (trimmed === "false") return false;
return trimmed;
}
function renderTemplate(value) {
return value.replace(/\{\{now\}\}/g, new Date().toLocaleTimeString());
}
function stripComment(line) {
const index = line.indexOf("#");
return index >= 0 ? line.slice(0, index) : line;
}
function tokenize(line) {
const tokens = [];
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
let match;
while ((match = pattern.exec(line))) {
tokens.push(match[1] ?? match[2] ?? match[3]);
}
return tokens;
}

View file

@ -0,0 +1,38 @@
# Eve DSL browser-reference fixture.
# This compiles to gamecult.eve.surface.v1 and binds UI controls to CultMesh-shaped vars.
surface gamecult.eve.reactive-demo "Reactive Eve DSL"
version 1
var face.name "Eve"
var face.body "browser reference renderer"
var swarm.heat 0.42
collection swarm.events
item swarm.events "surface compiled from Eve DSL"
item swarm.events "CultMesh vars surfaced as live UI fields"
item swarm.events "collections behave like event streams"
derive swarm.eventCount count swarm.events
derive swarm.latestEvent latest swarm.events
card identity "Identity Binding"
title bind face.name prefix "Renderer: "
text bind face.body prefix "Body: "
text "Eve owns rendering; providers and CultMesh own truth."
end
card pulse "Reactive Pulse"
metric "Swarm Heat" bind swarm.heat percent
metric "Event Count" bind swarm.eventCount
text bind swarm.latestEvent prefix "Latest: "
button "Append Event" append swarm.events "operator touched the stream at {{now}}"
end
card doctrine "Binding Contract"
text "Vars are single live values with subscribers."
text "Collections are event-shaped surfaces."
text "Derived fields update from their sources."
text "Actions mutate through the mesh boundary, not the DOM."
list "Event Stream" bind swarm.events
end

View file

@ -23,6 +23,7 @@
<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>
<button id="dsl-tab" type="button">Reactive DSL</button>
</nav>
<main id="app" class="surface"></main>

View file

@ -234,6 +234,34 @@ p {
background: var(--bg);
}
.cultui-grid {
min-height: calc(100vh - 138px);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
align-content: start;
}
.cultui-title {
color: var(--accent);
font: 700 16px "Cascadia Mono", Consolas, monospace;
line-height: 1.35;
}
.cultui-reactive-metric {
margin-top: 8px;
}
.cultui-reactive-list {
display: grid;
gap: 8px;
}
.cultui-stream-item {
border-left: 2px solid var(--line-hot);
padding-left: 8px;
}
.cultui-background {
position: absolute;
inset: 0;

View file

@ -1,3 +1,5 @@
import { compileEveDsl } from "./eve-dsl.js";
const statusEl = document.querySelector("#status");
const app = document.querySelector("#app");
const surfaceId = document.querySelector("#surface-id");
@ -5,6 +7,7 @@ const surfaceVersion = document.querySelector("#surface-version");
const voidBotTab = document.querySelector("#voidbot-tab");
const fensalirTab = document.querySelector("#fensalir-tab");
const saiTab = document.querySelector("#sai-tab");
const dslTab = document.querySelector("#dsl-tab");
let socket;
@ -27,10 +30,17 @@ saiTab.addEventListener("click", async () => {
renderSurface(await response.json(), "fixture");
});
dslTab.addEventListener("click", async () => {
setActiveTab(dslTab);
closeSocket();
const response = await fetch("./fixtures/reactive-composition.eve");
renderSurface(compileEveDsl(await response.text()), "dsl");
});
openVoidBot();
function setActiveTab(tab) {
for (const button of [voidBotTab, fensalirTab, saiTab]) {
for (const button of [voidBotTab, fensalirTab, saiTab, dslTab]) {
button.classList.toggle("active", button === tab);
}
}
@ -68,6 +78,7 @@ function closeSocket() {
}
function renderSurface(state, source) {
window.__eveCurrentMesh = state.mesh;
surfaceId.textContent = state.providerId || "surface unknown";
surfaceVersion.textContent = `v${state.version ?? "?"}`;
statusEl.textContent = `${state.title || "surface"} (${source})`;
@ -112,6 +123,13 @@ function renderCultComponent(node) {
return stage;
}
if (kind === "grid") {
const grid = el("section", "cultui-grid");
if (props.columns) grid.style.gridTemplateColumns = props.columns;
for (const child of children) grid.append(renderCultComponent(child));
return grid;
}
if (kind === "image.background") {
const view = el("div", "cultui-background");
if (props.src) view.style.backgroundImage = `url("${props.src}")`;
@ -156,6 +174,13 @@ function renderCultComponent(node) {
return card;
}
if (kind === "card") {
const card = el("article", "card cultui-card");
if (props.title) card.append(el("div", "card-title", props.title));
for (const child of children) card.append(renderCultComponent(child));
return card;
}
if (kind === "panel.dialogue") {
const panel = el("section", "cultui-dialogue pane");
panel.append(el("h2", "", props.speaker || "Speaker"));
@ -173,15 +198,28 @@ function renderCultComponent(node) {
return image;
}
if (kind === "text.dialogue" || kind === "text") {
return el("div", "detail", props.text || "");
if (kind === "text.dialogue" || kind === "text" || kind === "text.title") {
const text = el("div", kind === "text.title" ? "cultui-title" : "detail", props.text || "");
bindText(text, props);
return text;
}
if (kind === "metric") {
return renderCultMetric(props);
}
if (kind === "list") {
return renderCultList(props);
}
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 || {})}`;
currentMesh()?.applyAction(props.action);
statusEl.textContent = props.action
? `command ${props.action.type} ${props.action.target || ""}`
: `command ${props.action?.command || "invoke"} ${JSON.stringify(props.action?.payload || {})}`;
});
return button;
}
@ -233,6 +271,49 @@ function placeComponent(element, placement) {
return element;
}
function bindText(element, props) {
if (!props.bind) return;
currentMesh()?.var(props.bind).subscribe(value => {
element.textContent = `${props.prefix || ""}${value ?? ""}${props.suffix || ""}`;
});
}
function renderCultMetric(props) {
const metric = el("div", "metric cultui-reactive-metric");
const label = el("label", "", props.label || "Metric");
const valueEl = el("span", "metric-value", "");
label.append(valueEl);
const bar = el("div", "bar");
const fill = el("span");
bar.append(fill);
metric.append(label, bar);
if (props.bind) {
currentMesh()?.var(props.bind).subscribe(value => {
const number = Number(value) || 0;
valueEl.textContent = props.format === "percent" ? `${Math.round(number * 100)}%` : String(value ?? "");
fill.style.width = `${Math.max(0, Math.min(100, Math.round(number * 100)))}%`;
});
}
return metric;
}
function renderCultList(props) {
const panel = el("section", "cultui-reactive-list");
panel.append(el("div", "card-title", props.title || "List"));
const list = el("div", "list");
panel.append(list);
if (props.bind) {
currentMesh()?.collection(props.bind).subscribe(items => {
list.replaceChildren(...items.map(item => el("div", "detail cultui-stream-item", String(item))));
});
}
return panel;
}
function currentMesh() {
return window.__eveCurrentMesh;
}
function renderCultGraph(props) {
const graph = el("section", "cultui-graph");
graph.dataset.engine = props.engine?.id || "graph";