diff --git a/README.md b/README.md
index 2a2b0f2..cc2e735 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/eve-dsl-reactive-bindings.md b/docs/eve-dsl-reactive-bindings.md
new file mode 100644
index 0000000..2a5b180
--- /dev/null
+++ b/docs/eve-dsl-reactive-bindings.md
@@ -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.
diff --git a/docs/surface-contract-v1.md b/docs/surface-contract-v1.md
index 5127218..b20e355 100644
--- a/docs/surface-contract-v1.md
+++ b/docs/surface-contract-v1.md
@@ -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:
diff --git a/scripts/start-browser-reference.ps1 b/scripts/start-browser-reference.ps1
index e2d941e..3514e54 100644
--- a/scripts/start-browser-reference.ps1
+++ b/scripts/start-browser-reference.ps1
@@ -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;
diff --git a/web/eve-dsl.js b/web/eve-dsl.js
new file mode 100644
index 0000000..c127e05
--- /dev/null
+++ b/web/eve-dsl.js
@@ -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;
+}
diff --git a/web/fixtures/reactive-composition.eve b/web/fixtures/reactive-composition.eve
new file mode 100644
index 0000000..f8a58bd
--- /dev/null
+++ b/web/fixtures/reactive-composition.eve
@@ -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
diff --git a/web/index.html b/web/index.html
index e0e9aa3..b1490c7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -23,6 +23,7 @@
+
diff --git a/web/styles.css b/web/styles.css
index 6bf34f9..37eac32 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -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;
diff --git a/web/surface.js b/web/surface.js
index 5b6e61c..f3f57a3 100644
--- a/web/surface.js
+++ b/web/surface.js
@@ -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";