From 86e428b04a1ee549596a2b607aedf3fc411680f0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 31 May 2026 16:49:21 +0100 Subject: [PATCH] Add Eve reactive DSL reference --- README.md | 5 + docs/eve-dsl-reactive-bindings.md | 93 +++++++++ docs/surface-contract-v1.md | 21 ++ scripts/start-browser-reference.ps1 | 2 +- web/eve-dsl.js | 284 ++++++++++++++++++++++++++ web/fixtures/reactive-composition.eve | 38 ++++ web/index.html | 1 + web/styles.css | 28 +++ web/surface.js | 89 +++++++- 9 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 docs/eve-dsl-reactive-bindings.md create mode 100644 web/eve-dsl.js create mode 100644 web/fixtures/reactive-composition.eve 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";