Thing/web/surface.js
2026-06-01 22:15:40 +01:00

483 lines
16 KiB
JavaScript

import { compileEveDsl } from "./eve-dsl.js";
const statusEl = document.querySelector("#status");
const app = document.querySelector("#app");
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");
const huginnTab = document.querySelector("#huginn-tab");
const dslTab = document.querySelector("#dsl-tab");
let socket;
voidBotTab.addEventListener("click", () => {
setActiveTab(voidBotTab);
openVoidBot();
});
fensalirTab.addEventListener("click", async () => {
setActiveTab(fensalirTab);
closeSocket();
const response = await fetch("./fixtures/fensalir-client-surface.json");
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");
});
huginnTab.addEventListener("click", async () => {
setActiveTab(huginnTab);
closeSocket();
const response = await fetch("./fixtures/huginn-cc-surface.eve");
renderSurface(compileEveDsl(await response.text()), "dsl");
});
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, huginnTab, dslTab]) {
button.classList.toggle("active", button === tab);
}
}
function openVoidBot() {
closeSocket();
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const host = location.hostname || "127.0.0.1";
const url = `${scheme}//${host}:8795/eve/deck`;
statusEl.textContent = `connecting ${url}`;
socket = new WebSocket(url);
const activeSocket = socket;
socket.addEventListener("open", () => {
if (socket !== activeSocket) return;
statusEl.textContent = "connected to Mimir Eve broker";
activeSocket.send(JSON.stringify({ type: "open-provider", providerId: "voidbot.swarm" }));
});
socket.addEventListener("message", event => {
if (socket !== activeSocket) return;
const state = JSON.parse(event.data);
if (state.providerId === "voidbot.swarm") {
renderSurface(state, "live");
}
});
socket.addEventListener("close", () => {
if (socket !== activeSocket) return;
statusEl.textContent = "Mimir broker disconnected";
});
socket.addEventListener("error", () => {
if (socket !== activeSocket) return;
statusEl.textContent = "Mimir broker error; is port 8795 running?";
});
}
function closeSocket() {
if (socket) {
socket.close();
socket = undefined;
}
}
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})`;
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 || [];
let rendered;
if (kind === "vn.stage") {
const stage = el("section", "cultui-vn-stage");
for (const child of children) stage.append(renderCultComponent(child));
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}")`;
view.setAttribute("aria-label", props.label || "background");
return placeComponent(view, props.placement);
}
if (kind === "graph" || kind === "embed.norn") {
return placeComponent(renderCultGraph(props), props.placement);
}
if (kind === "embed.tex") {
return placeComponent(renderCultTex(props), props.placement);
}
if (
kind === "layer.sprites" ||
kind === "layer.cards" ||
kind === "layer.embedded-surfaces" ||
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 === "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"));
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" || 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", () => {
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;
}
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));
rendered = fallback;
return placeComponent(rendered, props.placement);
}
function placeComponent(element, placement) {
if (!placement || placement.space !== "scene") return element;
element.classList.add("cultui-placed");
element.dataset.placementMode = placement.mode || "overlay";
element.dataset.placementAnchor = placement.anchor || "";
if (placement.zIndex !== undefined) element.style.zIndex = placement.zIndex;
if (placement.opacity !== undefined) element.style.opacity = placement.opacity;
const quad = placement.quad;
if (Array.isArray(quad) && quad.length >= 4) {
const xs = quad.map(point => point[0]);
const ys = quad.map(point => point[1]);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
element.style.left = `${minX * 100}%`;
element.style.top = `${minY * 100}%`;
element.style.width = `${Math.max(0.02, maxX - minX) * 100}%`;
element.style.height = `${Math.max(0.02, maxY - minY) * 100}%`;
const skewX = ((quad[1][1] - quad[0][1]) + (quad[2][1] - quad[3][1])) * 18;
const skewY = ((quad[3][0] - quad[0][0]) + (quad[2][0] - quad[1][0])) * -18;
element.style.transform = `skew(${skewX}deg, ${skewY}deg)`;
}
if (placement.chromaKey) {
element.dataset.chromaKey = placement.chromaKey.color || "enabled";
}
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";
graph.dataset.contract = props.engine?.contract || "";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 1 1");
svg.setAttribute("preserveAspectRatio", "none");
const graphState = props.graph || props;
const nodes = graphState.nodes || [];
for (const edge of graphState.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 renderCultTex(props) {
const panel = el("section", "cultui-tex");
panel.dataset.renderer = props.renderer?.web || "tex";
if (props.label) panel.append(el("div", "cultui-tex-label", props.label));
panel.append(el("div", "cultui-tex-source", props.source || props.sourceUri || ""));
return panel;
}
function renderVoidBot(state) {
const nodes = state.nodes || [];
const ctb = nodes.filter(n => n.kind === "ctb-turn");
const leaves = nodes.filter(n => n.kind === "state-leaf");
const summary = nodeById(nodes, "voidbot-summary");
const agent = nodeById(nodes, "agent-detail");
const detail = nodeById(nodes, state.selectedNodeId) || nodeById(nodes, "state-detail") || agent || summary;
app.innerHTML = "";
const root = el("section", "cockpit");
const rail = el("div", "ctb");
for (const turn of ctb) {
const card = el("button", "card");
card.type = "button";
if (turn.avatarUrl) {
const img = el("img", "avatar");
img.src = turn.avatarUrl;
img.alt = "";
card.append(img);
}
card.append(el("div", "card-title", turn.label || turn.id));
card.append(el("div", "health", turn.health || ""));
rail.append(card);
}
const left = el("section", "pane");
left.append(el("h2", "", "Controls / Selected Face"));
left.append(el("div", "detail", `${summary?.label || "VoidBot Swarm"}\n${summary?.detail || ""}`));
left.append(metricStack());
left.append(el("div", "detail", agent?.detail || "No selected Face detail."));
const middle = el("section", "pane");
middle.append(el("h2", "", "State Graph"));
const list = el("div", "list");
for (const leaf of leaves) {
const card = el("div", `card ${leaf.id === state.selectedNodeId ? "selected" : ""}`);
card.append(el("div", "card-title", leaf.label || leaf.id));
card.append(el("div", "health", leaf.health || leaf.statePath || ""));
list.append(card);
}
middle.append(list);
const right = el("section", "pane");
right.append(el("h2", "", "State Detail"));
right.append(el("div", "detail", `${detail?.label || "detail"}\n\n${detail?.detail || ""}`));
root.append(rail, left, middle, right);
app.append(root);
}
function renderGraphSurface(state) {
app.innerHTML = "";
const graph = el("section", "graph");
for (const node of state.nodes || []) {
const view = el("article", `node ${node.id === state.selectedNodeId ? "selected" : ""}`);
const width = Math.max(160, graphWidth(node.width));
const height = Math.max(92, graphHeight(node.height));
view.style.width = `${width}px`;
view.style.minHeight = `${height}px`;
view.style.left = `${((node.x ?? 0) + 1) * 50}%`;
view.style.top = `${((node.y ?? 0) + 1) * 50}%`;
view.append(el("div", "card-title", node.label || node.id));
view.append(el("div", "health", `${node.kind || "node"} ${node.health || ""}`));
if (node.detail) {
view.append(el("div", "detail", node.detail));
}
graph.append(view);
}
app.append(graph);
}
function graphWidth(value) {
return Math.round((value || 0.25) * Math.max(900, window.innerWidth - 32));
}
function graphHeight(value) {
return Math.round((value || 0.16) * Math.max(520, window.innerHeight - 150));
}
function metricStack() {
const wrapper = el("div", "metrics");
const metrics = [
["TURN", 0.96],
["MEMORY", 0.70],
["PRESSURE", 0.42],
["HEAT", 0.34],
["LOAD", 0.99],
["SPEED", 0.88],
];
for (const [label, value] of metrics) {
const metric = el("div", "metric");
metric.append(el("label", "", `${label} ${Math.round(value * 100)}%`));
const bar = el("div", "bar");
const fill = el("span");
fill.style.width = `${Math.round(value * 100)}%`;
bar.append(fill);
metric.append(bar);
wrapper.append(metric);
}
return wrapper;
}
function nodeById(nodes, id) {
return nodes.find(node => node.id === id);
}
function el(tag, className = "", text = "") {
const element = document.createElement(tag);
if (className) element.className = className;
if (text) element.textContent = text;
return element;
}