882 lines
32 KiB
JavaScript
882 lines
32 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 providerSelect = document.querySelector("#provider-select");
|
|
const providerMeta = document.querySelector("#provider-meta");
|
|
|
|
let socket;
|
|
let providers = [];
|
|
let currentProvider;
|
|
let currentSurfaceStyles = {};
|
|
|
|
const defaultStyleTokens = {
|
|
"--bg": "#020909",
|
|
"--bg-top": "#020909",
|
|
"--bg-mid": "#020909",
|
|
"--bg-bottom": "#020909",
|
|
"--shell-top": "#010707",
|
|
"--shell-bottom": "#010707",
|
|
"--panel": "#071918",
|
|
"--panel-2": "#0a2423",
|
|
"--line": "rgba(103, 240, 228, 0.28)",
|
|
"--line-deep": "rgba(103, 240, 228, 0.18)",
|
|
"--text": "#e7f1f1",
|
|
"--text-bright": "#e7f1f1",
|
|
"--quiet": "#8ba5a3",
|
|
"--accent": "#ffb84f",
|
|
"--accent-strong": "#ffb84f",
|
|
"--corner-accent": "#ffb84f",
|
|
"--cyan": "#8efcff",
|
|
"--shadow": "rgba(0, 0, 0, 0.35)",
|
|
"--pixel-grid": "rgba(142, 252, 255, 0.04)",
|
|
"--border-width": "1px",
|
|
"--border-bottom-width": "1px",
|
|
"--corner-accent-size": "0",
|
|
"--font-body": "Inter, Segoe UI, system-ui, sans-serif",
|
|
"--font-title": "Cascadia Mono, Consolas, monospace",
|
|
"--font-mono": "Cascadia Mono, Consolas, monospace",
|
|
};
|
|
|
|
const localProviders = [
|
|
{
|
|
providerId: "voidbot.swarm",
|
|
title: "VoidBot Live",
|
|
kind: "service.operator",
|
|
freshness: { state: "live" },
|
|
surfaces: [{ transport: "mimir-eve-deck", surfaceId: "voidbot.swarm" }],
|
|
},
|
|
{
|
|
providerId: "repixelizer",
|
|
title: "Repixelizer",
|
|
kind: "service.product",
|
|
advertisement: "./fixtures/repixelizer.provider-advertisement.json",
|
|
surfaces: [{ transport: "local-json", surfaceId: "repixelizer.operator.surface", url: "./fixtures/repixelizer.eve-surface.json" }],
|
|
},
|
|
{
|
|
providerId: "fensalir.direct2d",
|
|
title: "Fensalir Direct2D",
|
|
kind: "surface.renderer",
|
|
freshness: { state: "fixture" },
|
|
surfaces: [{ transport: "local-json", surfaceId: "fensalir.direct2d.fixture", url: "./fixtures/fensalir-client-surface.json" }],
|
|
},
|
|
{
|
|
providerId: "sai.visual_novel",
|
|
aliases: ["gamecult.home.vn", "sai-vn"],
|
|
title: "Sai VN Surface",
|
|
kind: "content.runtime",
|
|
freshness: { state: "fixture" },
|
|
surfaces: [{ transport: "local-json", surfaceId: "sai.visual_novel.surface", url: "./fixtures/sai-vn-surface.json" }],
|
|
},
|
|
{
|
|
providerId: "cultcache.huginn.inspector",
|
|
title: "Huginn .cc",
|
|
kind: "inspection.huginn",
|
|
freshness: { state: "fixture" },
|
|
surfaces: [{ transport: "local-eve-dsl", surfaceId: "cultcache.huginn.inspector", url: "./fixtures/huginn-cc-surface.eve" }],
|
|
},
|
|
{
|
|
providerId: "eve.reactive.dsl",
|
|
title: "Reactive DSL",
|
|
kind: "surface.fixture",
|
|
freshness: { state: "fixture" },
|
|
surfaces: [{ transport: "local-eve-dsl", surfaceId: "eve.reactive.dsl", url: "./fixtures/reactive-composition.eve" }],
|
|
},
|
|
{
|
|
providerId: "eve.cultui.inspector",
|
|
title: "CultUI Inspector",
|
|
kind: "surface.fixture",
|
|
freshness: { state: "fixture" },
|
|
surfaces: [{ transport: "local-eve-dsl", surfaceId: "eve.cultui.inspector", url: "./fixtures/cultui-slider-inspector.eve" }],
|
|
},
|
|
];
|
|
|
|
providerSelect.addEventListener("change", () => {
|
|
const provider = providers.find(candidate => candidate.providerId === providerSelect.value);
|
|
if (provider) void openProvider(provider);
|
|
});
|
|
|
|
void bootProviders();
|
|
|
|
async function bootProviders() {
|
|
providers = await Promise.all(localProviders.map(loadProviderAdvertisement));
|
|
providerSelect.replaceChildren(...providers.map(provider => {
|
|
const option = document.createElement("option");
|
|
option.value = provider.providerId;
|
|
option.textContent = provider.title || provider.providerId;
|
|
return option;
|
|
}));
|
|
const requestedProviderId = new URLSearchParams(location.search).get("provider");
|
|
const firstProduct = providers.find(provider => provider.providerId === requestedProviderId || provider.aliases?.includes(requestedProviderId))
|
|
|| providers.find(provider => provider.providerId === "repixelizer")
|
|
|| providers[0];
|
|
providerSelect.value = firstProduct.providerId;
|
|
await openProvider(firstProduct);
|
|
}
|
|
|
|
async function loadProviderAdvertisement(provider) {
|
|
if (!provider.advertisement) return provider;
|
|
const response = await fetch(provider.advertisement);
|
|
const advertisement = await response.json();
|
|
return {
|
|
...advertisement,
|
|
...provider,
|
|
surfaces: provider.surfaces || advertisement.surfaces || [],
|
|
localAdvertisement: advertisement,
|
|
};
|
|
}
|
|
|
|
async function openProvider(provider) {
|
|
closeSocket();
|
|
currentProvider = provider;
|
|
providerMeta.textContent = `${provider.kind || "provider"} | ${provider.providerId} | ${provider.freshness?.state || "unknown"}`;
|
|
surfaceId.textContent = provider.providerId;
|
|
surfaceVersion.textContent = "opening";
|
|
const surface = provider.surfaces?.[0];
|
|
if (!surface) {
|
|
statusEl.textContent = `${provider.title || provider.providerId} has no advertised surface`;
|
|
app.replaceChildren(emptyState("No advertised surface"));
|
|
return;
|
|
}
|
|
|
|
if (surface.transport === "mimir-eve-deck") {
|
|
openVoidBot(provider);
|
|
return;
|
|
}
|
|
|
|
if (surface.transport === "local-eve-dsl") {
|
|
const response = await fetch(surface.url);
|
|
renderSurface(compileEveDsl(await response.text()), "local dsl");
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(surface.url);
|
|
renderSurface(await response.json(), "local fixture");
|
|
}
|
|
|
|
function openVoidBot(provider) {
|
|
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: provider.providerId }));
|
|
});
|
|
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;
|
|
currentSurfaceStyles = state.surface?.styles || {};
|
|
surfaceId.textContent = state.providerId || "surface unknown";
|
|
surfaceVersion.textContent = `v${state.version ?? "?"}`;
|
|
statusEl.textContent = `${state.title || "surface"} (${source})`;
|
|
applySurfaceStyles(state.surface?.styles);
|
|
document.body.dataset.provider = state.providerId || currentProvider?.providerId || "";
|
|
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;
|
|
for (const [variable, value] of Object.entries(defaultStyleTokens)) {
|
|
root.setProperty(variable, value);
|
|
}
|
|
const map = {
|
|
colorBackground: "--bg",
|
|
colorBackgroundTop: "--bg-top",
|
|
colorBackgroundMid: "--bg-mid",
|
|
colorBackgroundBottom: "--bg-bottom",
|
|
colorShellSurfaceTop: "--shell-top",
|
|
colorShellSurfaceBottom: "--shell-bottom",
|
|
colorPanel: "--panel",
|
|
colorPanelAlt: "--panel-2",
|
|
colorPanelBorder: "--line",
|
|
colorPanelBorderDeep: "--line-deep",
|
|
colorText: "--text",
|
|
colorTextBright: "--text-bright",
|
|
colorMuted: "--quiet",
|
|
colorAccent: "--accent",
|
|
colorAccentStrong: "--accent-strong",
|
|
colorCornerAccent: "--corner-accent",
|
|
colorLink: "--cyan",
|
|
colorShadow: "--shadow",
|
|
colorPixelGrid: "--pixel-grid",
|
|
};
|
|
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);
|
|
if (tokens.fontMono) root.setProperty("--font-mono", tokens.fontMono);
|
|
if (tokens.borderWidthPx) root.setProperty("--border-width", `${tokens.borderWidthPx}px`);
|
|
if (tokens.borderBottomWidthPx) root.setProperty("--border-bottom-width", `${tokens.borderBottomWidthPx}px`);
|
|
if (tokens.cornerAccentPx) root.setProperty("--corner-accent-size", `${tokens.cornerAccentPx}px`);
|
|
document.body.dataset.pixelArt = tokens.pixelArt || tokens.imageRendering === "pixelated" ? "true" : "false";
|
|
document.body.dataset.scanlines = tokens.scanlineOverlay ? "true" : "false";
|
|
}
|
|
|
|
function renderCultComponent(node) {
|
|
const kind = node.kind || "panel";
|
|
const props = node.props || {};
|
|
const layout = node.layout || {};
|
|
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 === "partition") {
|
|
const partition = el("section", `cultui-partition split-${props.split || "none"}`);
|
|
applyBoxProps(partition, props);
|
|
if (props.role) partition.dataset.role = props.role;
|
|
for (const child of children) partition.append(renderCultComponent(child));
|
|
return partition;
|
|
}
|
|
|
|
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 === "surface") {
|
|
const surface = el("section", "cultui-surface-root");
|
|
if (layout.direction) surface.dataset.direction = layout.direction;
|
|
for (const child of children) surface.append(renderCultComponent(child));
|
|
return surface;
|
|
}
|
|
|
|
if (kind === "pane" || kind === "panel") {
|
|
const pane = el("section", "pane cultui-pane");
|
|
if (props.title || node.text) pane.append(el("h2", "", props.title || node.text));
|
|
for (const child of children) pane.append(renderCultComponent(child));
|
|
return pane;
|
|
}
|
|
|
|
if (kind === "card") {
|
|
const card = el("article", "card cultui-card");
|
|
if (props.title || node.text) card.append(el("div", "card-title", props.title || node.text));
|
|
for (const child of children) card.append(renderCultComponent(child));
|
|
if (props.commandId || node.commandId) {
|
|
card.dataset.commandId = props.commandId || node.commandId;
|
|
card.tabIndex = 0;
|
|
card.addEventListener("click", () => publishCommandIntent(card.dataset.commandId, props));
|
|
card.addEventListener("keydown", event => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
publishCommandIntent(card.dataset.commandId, props);
|
|
}
|
|
});
|
|
}
|
|
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" || kind === "label") {
|
|
const text = el("div", textClassName(kind, props), props.text || node.text || "");
|
|
bindText(text, props);
|
|
return text;
|
|
}
|
|
|
|
if (kind === "metric") {
|
|
return renderCultMetric(props);
|
|
}
|
|
|
|
if (kind === "image.preview") {
|
|
const figure = el("figure", "cultui-image-preview");
|
|
const frame = el("div", "cultui-image-frame");
|
|
if (props.src) {
|
|
const image = el("img");
|
|
image.src = props.src;
|
|
image.alt = props.label || "";
|
|
image.style.imageRendering = props.imageRendering || "auto";
|
|
if (props.zoom) {
|
|
image.style.transform = `scale(${props.zoom})`;
|
|
image.style.transformOrigin = props.crop || "50% 50%";
|
|
}
|
|
frame.append(image);
|
|
} else {
|
|
frame.append(el("span", "", props.label || "image"));
|
|
}
|
|
figure.append(frame);
|
|
if (props.label) figure.append(el("figcaption", "", props.label));
|
|
return figure;
|
|
}
|
|
|
|
if (kind === "canvas.preview" || kind === "canvas.editor") {
|
|
const panel = el("div", `cultui-canvas-placeholder ${kind === "canvas.editor" ? "editor" : "preview"}`);
|
|
panel.append(el("div", "viz-label", props.label || "Canvas"));
|
|
panel.append(el("div", "cultui-canvas-grid", props.state === "empty" ? "waiting for pixels" : props.state || ""));
|
|
return panel;
|
|
}
|
|
|
|
if (kind === "status.stage") {
|
|
const panel = el("section", "cultui-status-stage");
|
|
panel.append(el("span", "status-badge", props.label || "Status"));
|
|
panel.append(el("p", "status-stage", props.stage || ""));
|
|
panel.append(el("p", "status-text", props.detail || ""));
|
|
return panel;
|
|
}
|
|
|
|
if (kind === "input.file" || kind === "dropzone") {
|
|
const control = el("button", kind === "dropzone" ? "dropzone cultui-dropzone" : "cultui-button button-secondary", props.label || "Choose File");
|
|
control.type = "button";
|
|
return control;
|
|
}
|
|
|
|
if (kind === "input.number" || kind === "input.select" || kind === "control.range") {
|
|
const field = el("label", "field cultui-field");
|
|
field.append(el("span", "field-label", props.label || ""));
|
|
field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value)));
|
|
return field;
|
|
}
|
|
|
|
if (kind === "control.toggle") {
|
|
const toggle = el("label", "toggle-row cultui-toggle");
|
|
toggle.append(el("span", "field-control", props.value ? "on" : "off"));
|
|
toggle.append(el("span", "toggle-copy", props.label || "Toggle"));
|
|
return toggle;
|
|
}
|
|
|
|
if (kind === "color.swatch") {
|
|
const swatch = el("div", "paint-swatch cultui-swatch");
|
|
swatch.style.background = props.value || props.color || "currentColor";
|
|
swatch.title = props.label || "Color";
|
|
return swatch;
|
|
}
|
|
|
|
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 === "control.slider") {
|
|
return renderCultSlider(props, children);
|
|
}
|
|
|
|
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 renderCultSlider(props, children) {
|
|
const anatomy = resolveSliderAnatomy(props, children);
|
|
const box = anatomy.find(part => part.kind === "control.box")?.props || {};
|
|
const parts = anatomy.filter(part => part.kind === "control.part");
|
|
const hitArea = anatomy.find(part => part.kind === "control.hitArea")?.props || {};
|
|
const min = Number(props.min ?? 0);
|
|
const max = Number(props.max ?? 1);
|
|
const step = Number(props.step ?? 0.01);
|
|
const slider = el("div", "cultui-slider");
|
|
slider.dataset.skin = props.skin || "default";
|
|
applyControlBoxProps(slider, box);
|
|
|
|
const visual = el("div", "cultui-slider-visual");
|
|
const input = el("input", "cultui-slider-input");
|
|
input.type = "range";
|
|
input.min = String(min);
|
|
input.max = String(max);
|
|
input.step = String(step);
|
|
input.setAttribute("aria-label", props.bind || "slider");
|
|
|
|
for (const part of parts) {
|
|
const partEl = el("span", `cultui-slider-part ${part.props?.name || "part"}`);
|
|
applySliderPartProps(partEl, part.props || {});
|
|
visual.append(partEl);
|
|
}
|
|
|
|
if (!parts.some(part => part.props?.name === "track")) visual.append(el("span", "cultui-slider-part track"));
|
|
if (!parts.some(part => part.props?.name === "fill")) visual.append(el("span", "cultui-slider-part fill"));
|
|
if (!parts.some(part => part.props?.name === "thumb")) visual.append(el("span", "cultui-slider-part thumb"));
|
|
|
|
applyHitAreaProps(input, hitArea);
|
|
slider.append(visual, input);
|
|
|
|
const setVisualValue = (value) => {
|
|
const number = Number(value ?? min);
|
|
const bounded = Math.max(min, Math.min(max, Number.isFinite(number) ? number : min));
|
|
const percent = max === min ? 0 : ((bounded - min) / (max - min)) * 100;
|
|
input.value = String(bounded);
|
|
slider.style.setProperty("--cultui-slider-value", `${percent}%`);
|
|
slider.dataset.value = String(bounded);
|
|
};
|
|
|
|
if (props.bind) {
|
|
currentMesh()?.var(props.bind).subscribe(setVisualValue);
|
|
input.addEventListener("input", () => currentMesh()?.var(props.bind).set(Number(input.value)));
|
|
} else {
|
|
setVisualValue(props.value ?? min);
|
|
}
|
|
|
|
return slider;
|
|
}
|
|
|
|
function resolveSliderAnatomy(props, children) {
|
|
const skin = props.skin ? currentSurfaceStyles.controlSkins?.[props.skin] : undefined;
|
|
const skinChildren = skin?.children || [];
|
|
const anatomy = [...skinChildren, ...children];
|
|
if (anatomy.length) return anatomy;
|
|
return [
|
|
{ kind: "control.box", props: { height: 18, overflow: "visible" } },
|
|
{ kind: "control.part", props: { name: "track", anchor: "center", size: ["100%", 6], radius: 2, fill: "color.panelInset" } },
|
|
{ kind: "control.part", props: { name: "fill", anchor: ["left", "center"], size: ["value%", 6], radius: 2, fill: "color.accent" } },
|
|
{ kind: "control.part", props: { name: "thumb", anchor: ["value", "center"], size: [12, 12], bleed: 3, radius: 999, fill: "color.accent" } },
|
|
{ kind: "control.hitArea", props: { size: ["100%", 18] } },
|
|
];
|
|
}
|
|
|
|
function applyControlBoxProps(element, props) {
|
|
if (props.height !== undefined) element.style.minHeight = cssSize(props.height);
|
|
if (props.width !== undefined) element.style.width = cssSize(props.width);
|
|
if (props.overflow) element.style.overflow = props.overflow;
|
|
}
|
|
|
|
function applySliderPartProps(element, props) {
|
|
const [width, height] = Array.isArray(props.size) ? props.size : [props.size, undefined];
|
|
if (width !== undefined && width !== "value%") element.style.width = cssSize(width);
|
|
if (height !== undefined) element.style.height = cssSize(height);
|
|
if (props.radius !== undefined) element.style.borderRadius = cssSize(props.radius);
|
|
if (props.fill !== undefined) element.style.background = tokenColor(props.fill);
|
|
if (props.bleed !== undefined) element.style.setProperty("--part-bleed", cssSize(props.bleed));
|
|
if (props.shadow) element.style.boxShadow = sliderShadow(props.shadow);
|
|
const anchor = Array.isArray(props.anchor) ? props.anchor : [props.anchor];
|
|
if (anchor.includes("value")) element.dataset.anchorValue = "true";
|
|
}
|
|
|
|
function applyHitAreaProps(element, props) {
|
|
const [width, height] = Array.isArray(props.size) ? props.size : [props.size, undefined];
|
|
if (width !== undefined) element.style.width = cssSize(width);
|
|
if (height !== undefined) element.style.height = cssSize(height);
|
|
}
|
|
|
|
function applyBoxProps(element, props) {
|
|
if (props.gap !== undefined) element.style.gap = cssSize(props.gap);
|
|
if (props.padding !== undefined) element.style.padding = cssSize(props.padding);
|
|
if (props.size !== undefined) element.style.flex = flexSize(props.size);
|
|
if (props.min !== undefined) element.style.minWidth = cssSize(props.min);
|
|
if (props.max !== undefined) element.style.maxWidth = cssSize(props.max);
|
|
if (props.align !== undefined) element.style.alignItems = alignValue(props.align);
|
|
if (props.clip === "true" || props.clip === true) element.style.overflow = "hidden";
|
|
if (props.scroll === "y") element.style.overflowY = "auto";
|
|
if (props.scroll === "x") element.style.overflowX = "auto";
|
|
}
|
|
|
|
function flexSize(value) {
|
|
if (typeof value === "string" && value.endsWith("fr")) {
|
|
return `${Number(value.slice(0, -2)) || 1} 1 0`;
|
|
}
|
|
if (value === "auto" || value === "content") return "0 0 auto";
|
|
return `0 0 ${cssSize(value)}`;
|
|
}
|
|
|
|
function cssSize(value) {
|
|
if (Array.isArray(value)) return value.map(cssSize).join(" ");
|
|
if (typeof value === "number") return `${value}px`;
|
|
if (typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value)) return `${value}px`;
|
|
return String(value ?? "");
|
|
}
|
|
|
|
function alignValue(value) {
|
|
if (value === "center") return "center";
|
|
if (value === "end") return "flex-end";
|
|
return String(value);
|
|
}
|
|
|
|
function tokenColor(value) {
|
|
const map = {
|
|
"color.accent": "var(--accent)",
|
|
"color.panelInset": "rgba(0, 0, 0, 0.34)",
|
|
"color.panel": "var(--panel)",
|
|
"color.text": "var(--text)",
|
|
};
|
|
return map[value] || value;
|
|
}
|
|
|
|
function sliderShadow(value) {
|
|
const parts = Array.isArray(value) ? value : [value];
|
|
if (parts[0] !== "glow") return parts.join(" ");
|
|
const color = tokenColor(parts[1] || "color.accent");
|
|
const alpha = Number(parts[2] ?? 0.35);
|
|
const radius = cssSize(parts[4] ?? parts[3] ?? 6);
|
|
return `0 0 ${radius} color-mix(in srgb, ${color} ${Math.round(alpha * 100)}%, transparent)`;
|
|
}
|
|
|
|
function currentMesh() {
|
|
return window.__eveCurrentMesh;
|
|
}
|
|
|
|
function textClassName(kind, props) {
|
|
if (kind === "text.title" || props.role === "title") return "cultui-title";
|
|
if (kind === "label") return "cultui-label";
|
|
if (props.role === "mono") return "detail mono";
|
|
return "detail";
|
|
}
|
|
|
|
function emptyState(message) {
|
|
const pane = el("section", "pane");
|
|
pane.append(el("h2", "", "No Surface"));
|
|
pane.append(el("div", "detail", message));
|
|
return pane;
|
|
}
|
|
|
|
function publishCommandIntent(commandId, props = {}) {
|
|
const command = {
|
|
type: "surface-command",
|
|
schema: "gamecult.eve.command.v1",
|
|
providerId: currentProvider?.providerId || surfaceId.textContent,
|
|
surfaceId: currentProvider?.surfaces?.[0]?.surfaceId || surfaceId.textContent,
|
|
command: commandId || "invoke",
|
|
payload: {
|
|
transport: props.transport || null,
|
|
},
|
|
issuedAt: new Date().toISOString(),
|
|
clientId: "browser.reference",
|
|
};
|
|
statusEl.textContent = `command ${command.command}`;
|
|
console.info("Eve command intent", command);
|
|
}
|
|
|
|
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 Persona"));
|
|
left.append(el("div", "detail", `${summary?.label || "VoidBot Swarm"}\n${summary?.detail || ""}`));
|
|
left.append(metricStack());
|
|
left.append(el("div", "detail", agent?.detail || "No selected Persona 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;
|
|
}
|