Add Eve reactive DSL reference
This commit is contained in:
parent
66aaf9445b
commit
86e428b04a
9 changed files with 556 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
93
docs/eve-dsl-reactive-bindings.md
Normal file
93
docs/eve-dsl-reactive-bindings.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
284
web/eve-dsl.js
Normal 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;
|
||||
}
|
||||
38
web/fixtures/reactive-composition.eve
Normal file
38
web/fixtures/reactive-composition.eve
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue