Build CultUI slider anatomy in web reference
This commit is contained in:
parent
1846a1c2be
commit
0fb9660e8f
7 changed files with 714 additions and 126 deletions
459
web/eve-dsl.js
459
web/eve-dsl.js
|
|
@ -2,8 +2,8 @@ export function compileEveDsl(source) {
|
|||
const mesh = createCultMeshStore();
|
||||
const root = {
|
||||
id: "root",
|
||||
kind: "grid",
|
||||
props: { columns: "repeat(auto-fit, minmax(260px, 1fr))" },
|
||||
kind: "surface",
|
||||
props: {},
|
||||
children: [],
|
||||
};
|
||||
const state = {
|
||||
|
|
@ -23,127 +23,59 @@ export function compileEveDsl(source) {
|
|||
colorMuted: "#8ba7b8",
|
||||
colorLink: "#8efcff",
|
||||
},
|
||||
controlSkins: {},
|
||||
},
|
||||
},
|
||||
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;
|
||||
const stack = [{ indent: -1, node: root }];
|
||||
let activeSkin;
|
||||
|
||||
if (head === "surface") {
|
||||
state.providerId = rest[0] || state.providerId;
|
||||
state.title = rest[1] || state.title;
|
||||
for (const entry of readDslLines(source)) {
|
||||
while (entry.indent <= stack[stack.length - 1].indent) stack.pop();
|
||||
|
||||
const parent = stack[stack.length - 1].node;
|
||||
const [head, ...rest] = entry.tokens;
|
||||
|
||||
if (applyStateCommand(state, mesh, head, rest)) {
|
||||
activeSkin = undefined;
|
||||
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" },
|
||||
if (head === "skin") {
|
||||
activeSkin = {
|
||||
target: readKeyword(rest, "for") || rest[2] || "control",
|
||||
children: [],
|
||||
};
|
||||
root.children.push(currentComponent);
|
||||
state.surface.styles.controlSkins[rest[0]] = activeSkin;
|
||||
stack.push({ indent: entry.indent, node: activeSkin });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (head === "end") {
|
||||
currentComponent = undefined;
|
||||
if (isPropertyLine(head, parent)) {
|
||||
applyProperty(parent.props || (parent.props = {}), head, rest);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentComponent) continue;
|
||||
const node = createComponent(entry, state);
|
||||
if (!node) continue;
|
||||
|
||||
if (head === "title") {
|
||||
currentComponent.children.push({
|
||||
id: `${currentComponent.id}.title.${currentComponent.children.length}`,
|
||||
kind: "text.title",
|
||||
props: parseTextProps(rest),
|
||||
children: [],
|
||||
});
|
||||
if (head === "fieldRow") {
|
||||
parent.children.push(node.row);
|
||||
stack.push({ indent: entry.indent, node: node.field });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (head === "text") {
|
||||
currentComponent.children.push({
|
||||
id: `${currentComponent.id}.text.${currentComponent.children.length}`,
|
||||
kind: "text",
|
||||
props: parseTextProps(rest),
|
||||
children: [],
|
||||
});
|
||||
if (activeSkin && parent === activeSkin) {
|
||||
activeSkin.children.push(node);
|
||||
stack.push({ indent: entry.indent, node });
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
parent.children.push(node);
|
||||
stack.push({ indent: entry.indent, node });
|
||||
}
|
||||
|
||||
return state;
|
||||
|
|
@ -215,12 +147,248 @@ export function createCultMeshStore() {
|
|||
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));
|
||||
this.var(action.target).set(parseDslValue(renderTemplate(action.value)));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readDslLines(source) {
|
||||
const lines = [];
|
||||
let indentMode;
|
||||
let spaceUnit;
|
||||
|
||||
for (const rawLine of source.split(/\r?\n/)) {
|
||||
const withoutComment = stripComment(rawLine);
|
||||
if (!withoutComment.trim()) continue;
|
||||
|
||||
const leading = withoutComment.match(/^[\t ]*/)?.[0] || "";
|
||||
if (leading.includes("\t") && leading.includes(" ")) {
|
||||
throw new Error("CultUI indentation may use tabs or spaces, not both on one line.");
|
||||
}
|
||||
|
||||
if (leading) {
|
||||
const mode = leading.includes("\t") ? "tabs" : "spaces";
|
||||
indentMode ||= mode;
|
||||
if (indentMode !== mode) throw new Error("CultUI indentation must be consistent within a file.");
|
||||
if (mode === "spaces") spaceUnit ||= leading.length;
|
||||
}
|
||||
|
||||
const indent = leading.includes("\t")
|
||||
? leading.length
|
||||
: leading.length ? leading.length / (spaceUnit || leading.length) : 0;
|
||||
const line = withoutComment.trim();
|
||||
lines.push({ indent, tokens: tokenize(line), line });
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function applyStateCommand(state, mesh, head, rest) {
|
||||
if (head === "surface") {
|
||||
state.providerId = rest[0] || state.providerId;
|
||||
state.surface.root.id = rest[0] || state.surface.root.id;
|
||||
state.title = rest[1] || state.title;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (head === "version") {
|
||||
state.version = Number(rest[0]) || state.version;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (head === "var") {
|
||||
mesh.var(rest[0]).set(parseDslValue(rest.slice(1).join(" ")));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (head === "collection") {
|
||||
mesh.collection(rest[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (head === "item") {
|
||||
mesh.collection(rest[0]).append(parseDslValue(rest.slice(1).join(" ")));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (head === "derive") {
|
||||
const [target, operator, sourcePath] = rest;
|
||||
mesh.derive(target, operator, sourcePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function createComponent(entry, state) {
|
||||
const [head, ...rest] = entry.tokens;
|
||||
|
||||
if (head === "fieldRow") {
|
||||
const label = rest[0] || "Field";
|
||||
const id = slug(label);
|
||||
const field = {
|
||||
id: `${id}.field`,
|
||||
kind: "partition",
|
||||
props: { size: "1fr", role: "inspector.field" },
|
||||
children: [],
|
||||
};
|
||||
return {
|
||||
field,
|
||||
row: {
|
||||
id: `${id}.row`,
|
||||
kind: "partition",
|
||||
props: { split: "x", gap: 8, padding: 4, align: "center", role: "inspector.row" },
|
||||
children: [
|
||||
{
|
||||
id: `${id}.label`,
|
||||
kind: "partition",
|
||||
props: { size: "12rem", role: "inspector.label" },
|
||||
children: [{ id: `${id}.label.text`, kind: "label", props: { text: label }, children: [] }],
|
||||
},
|
||||
field,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "partition") {
|
||||
return {
|
||||
id: rest[0] || `partition.${entry.line.length}`,
|
||||
kind: "partition",
|
||||
props: parsePairs(rest.slice(1)),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "pane") {
|
||||
return {
|
||||
id: rest[0] || `pane.${entry.line.length}`,
|
||||
kind: "pane",
|
||||
props: parsePairs(rest.slice(1), { title: rest[0] }),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "card") {
|
||||
return {
|
||||
id: rest[0] || `card.${entry.line.length}`,
|
||||
kind: "card",
|
||||
props: { title: rest[1] || rest[0] || "Card", ...parsePairs(rest.slice(2)) },
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "title") {
|
||||
return {
|
||||
id: scopedId("title", entry),
|
||||
kind: "text.title",
|
||||
props: parseTextProps(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "text") {
|
||||
return {
|
||||
id: scopedId("text", entry),
|
||||
kind: "text",
|
||||
props: parseTextProps(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "label") {
|
||||
return {
|
||||
id: scopedId("label", entry),
|
||||
kind: "label",
|
||||
props: parseTextProps(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "metric") {
|
||||
return {
|
||||
id: scopedId("metric", entry),
|
||||
kind: "metric",
|
||||
props: {
|
||||
label: rest[0] || "Metric",
|
||||
bind: readKeyword(rest, "bind"),
|
||||
format: rest.includes("percent") ? "percent" : "value",
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "list") {
|
||||
return {
|
||||
id: scopedId("list", entry),
|
||||
kind: "list",
|
||||
props: {
|
||||
title: rest[0] || "List",
|
||||
bind: readKeyword(rest, "bind"),
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "button") {
|
||||
return {
|
||||
id: scopedId("button", entry),
|
||||
kind: "control.button",
|
||||
props: {
|
||||
label: rest[0] || "Action",
|
||||
action: parseButtonAction(rest),
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "slider") {
|
||||
return {
|
||||
id: scopedId("slider", entry),
|
||||
kind: "control.slider",
|
||||
props: parsePairs(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "box") {
|
||||
return {
|
||||
id: scopedId("box", entry),
|
||||
kind: "control.box",
|
||||
props: parsePairs(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "part") {
|
||||
return {
|
||||
id: scopedId(`part.${rest[0] || "unnamed"}`, entry),
|
||||
kind: "control.part",
|
||||
props: { name: rest[0] || "part", ...parsePairs(rest.slice(1)) },
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "hitArea") {
|
||||
return {
|
||||
id: scopedId("hitArea", entry),
|
||||
kind: "control.hitArea",
|
||||
props: parsePairs(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (head === "skin") return undefined;
|
||||
|
||||
return {
|
||||
id: scopedId(head, entry),
|
||||
kind: head,
|
||||
props: parsePairs(rest),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseTextProps(tokens) {
|
||||
const bind = readKeyword(tokens, "bind");
|
||||
return {
|
||||
|
|
@ -251,12 +419,63 @@ function parseButtonAction(tokens) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function parsePairs(tokens, initial = {}) {
|
||||
const props = { ...initial };
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const key = tokens[index];
|
||||
if (key === "bind") {
|
||||
props.bind = tokens[index + 1];
|
||||
index += 1;
|
||||
} else if (key === "split") {
|
||||
props.split = tokens[index + 1];
|
||||
index += 1;
|
||||
} else if (key === "skin") {
|
||||
props.skin = tokens[index + 1];
|
||||
index += 1;
|
||||
} else if (PROPERTY_NAMES.has(key)) {
|
||||
const value = readPropertyValue(tokens, index + 1);
|
||||
props[key] = value.value;
|
||||
index = value.nextIndex - 1;
|
||||
} else if (index + 1 < tokens.length && !PROPERTY_NAMES.has(tokens[index + 1])) {
|
||||
props[key] = parseDslValue(tokens[index + 1]);
|
||||
index += 1;
|
||||
} else {
|
||||
props[key] = true;
|
||||
}
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
function isPropertyLine(head, parent) {
|
||||
return Boolean(parent?.props && PROPERTY_NAMES.has(head));
|
||||
}
|
||||
|
||||
function applyProperty(props, head, rest) {
|
||||
props[head] = normalizePropertyValues(rest.map(parseDslValue));
|
||||
}
|
||||
|
||||
function readPropertyValue(tokens, start) {
|
||||
let nextIndex = start;
|
||||
while (nextIndex < tokens.length && !PROPERTY_NAMES.has(tokens[nextIndex])) {
|
||||
nextIndex += 1;
|
||||
}
|
||||
return {
|
||||
value: normalizePropertyValues(tokens.slice(start, nextIndex).map(parseDslValue)),
|
||||
nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePropertyValues(values) {
|
||||
return values.length <= 1 ? values[0] : values;
|
||||
}
|
||||
|
||||
function readKeyword(tokens, keyword) {
|
||||
const index = tokens.indexOf(keyword);
|
||||
return index >= 0 ? tokens[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function parseDslValue(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if (trimmed === "true") return true;
|
||||
|
|
@ -269,8 +488,15 @@ function renderTemplate(value) {
|
|||
}
|
||||
|
||||
function stripComment(line) {
|
||||
const index = line.indexOf("#");
|
||||
return index >= 0 ? line.slice(0, index) : line;
|
||||
let quote;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if ((char === "\"" || char === "'") && line[index - 1] !== "\\") {
|
||||
quote = quote === char ? undefined : quote || char;
|
||||
}
|
||||
if (char === "#" && !quote) return line.slice(0, index);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function tokenize(line) {
|
||||
|
|
@ -282,3 +508,34 @@ function tokenize(line) {
|
|||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function slug(value) {
|
||||
return String(value || "field")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ".")
|
||||
.replace(/^\.+|\.+$/g, "") || "field";
|
||||
}
|
||||
|
||||
function scopedId(prefix, entry) {
|
||||
return `${prefix}.${slug(entry.line).slice(0, 48)}`;
|
||||
}
|
||||
|
||||
const PROPERTY_NAMES = new Set([
|
||||
"align",
|
||||
"anchor",
|
||||
"bleed",
|
||||
"clip",
|
||||
"fill",
|
||||
"gap",
|
||||
"height",
|
||||
"max",
|
||||
"min",
|
||||
"overflow",
|
||||
"padding",
|
||||
"radius",
|
||||
"scroll",
|
||||
"shadow",
|
||||
"size",
|
||||
"step",
|
||||
"width",
|
||||
]);
|
||||
|
|
|
|||
42
web/eve-dsl.test.mjs
Normal file
42
web/eve-dsl.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { compileEveDsl } from "./eve-dsl.js";
|
||||
|
||||
test("compiles indentation-authored cards without end blocks", () => {
|
||||
const state = compileEveDsl(readFileSync(new URL("./fixtures/reactive-composition.eve", import.meta.url), "utf8"));
|
||||
|
||||
assert.equal(state.providerId, "gamecult.eve.reactive-demo");
|
||||
assert.equal(state.surface.root.kind, "surface");
|
||||
assert.equal(state.surface.root.children.length, 3);
|
||||
assert.equal(state.surface.root.children[0].children.length, 3);
|
||||
});
|
||||
|
||||
test("lowers fieldRow sugar into explicit label and field partitions", () => {
|
||||
const state = compileEveDsl(readFileSync(new URL("./fixtures/cultui-slider-inspector.eve", import.meta.url), "utf8"));
|
||||
const pane = state.surface.root.children[0].children[0];
|
||||
const fieldList = pane.children[0];
|
||||
const row = fieldList.children[0];
|
||||
|
||||
assert.equal(row.kind, "partition");
|
||||
assert.equal(row.props.role, "inspector.row");
|
||||
assert.equal(row.children[0].props.role, "inspector.label");
|
||||
assert.equal(row.children[1].props.role, "inspector.field");
|
||||
assert.equal(row.children[1].children[0].kind, "control.slider");
|
||||
});
|
||||
|
||||
test("preserves slider skin anatomy as retained control parts", () => {
|
||||
const state = compileEveDsl(readFileSync(new URL("./fixtures/cultui-slider-inspector.eve", import.meta.url), "utf8"));
|
||||
const skin = state.surface.styles.controlSkins["inspector.orangeSlider"];
|
||||
|
||||
assert.equal(skin.target, "slider");
|
||||
assert.deepEqual(skin.children.map(child => child.kind), [
|
||||
"control.box",
|
||||
"control.part",
|
||||
"control.part",
|
||||
"control.part",
|
||||
"control.hitArea",
|
||||
]);
|
||||
assert.equal(skin.children[3].props.name, "thumb");
|
||||
assert.equal(skin.children[3].props.bleed, 3);
|
||||
});
|
||||
28
web/fixtures/cultui-slider-inspector.eve
Normal file
28
web/fixtures/cultui-slider-inspector.eve
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# CultUI partition and element-anatomy fixture.
|
||||
# The slider face is specified here; the browser only lowers it.
|
||||
|
||||
surface eve.cultui.inspector "CultUI Inspector"
|
||||
version 1
|
||||
|
||||
var tester.fov 38
|
||||
var tester.throttleDecay 5
|
||||
var tester.flowSpeed 2
|
||||
|
||||
skin inspector.orangeSlider for slider
|
||||
box height 18 overflow visible
|
||||
part track anchor center size 100% 6 radius 2 fill color.panelInset
|
||||
part fill anchor left center size value% 6 radius 2 fill color.accent
|
||||
part thumb anchor value center size 12 12 bleed 3 radius 999 fill color.accent shadow glow color.accent 0.45 radius 7
|
||||
hitArea size 100% 22
|
||||
|
||||
partition inspector split y gap 6 padding 10
|
||||
pane field-tester title "Aetheric Field Tester" padding 10
|
||||
partition field-list split y gap 6
|
||||
fieldRow "FOV"
|
||||
slider bind tester.fov min 0 max 120 step 1 skin inspector.orangeSlider
|
||||
|
||||
fieldRow "Throttle Decay"
|
||||
slider bind tester.throttleDecay min 0 max 20 step 0.5 skin inspector.orangeSlider
|
||||
|
||||
fieldRow "Flow Speed"
|
||||
slider bind tester.flowSpeed min 0 max 10 step 0.25 skin inspector.orangeSlider
|
||||
|
|
@ -13,18 +13,15 @@ item huginn.records.entries "cursor:moderation | voidbot.moderation_cursor | 202
|
|||
item huginn.records.entries "affect:face | voidbot.face_affect | 2026-05-31T22:13:44.000Z | 768 bytes | decoded"
|
||||
|
||||
card source "Source Witness"
|
||||
text "file: E:\\Projects\\CultCacheTS\\.voidbot\\state\\huginn.cc"
|
||||
text "format: cultcache.store.v1"
|
||||
text "bytes: 3456"
|
||||
text ".cc bytes are authority; Huginn only emits Eve DSL."
|
||||
end
|
||||
text "file: E:\\Projects\\CultCacheTS\\.voidbot\\state\\huginn.cc"
|
||||
text "format: cultcache.store.v1"
|
||||
text "bytes: 3456"
|
||||
text ".cc bytes are authority; Huginn only emits Eve DSL."
|
||||
|
||||
card catalog "Schema Catalog"
|
||||
text "schemas: 3"
|
||||
list "Catalog Entries" bind huginn.catalog.entries
|
||||
end
|
||||
text "schemas: 3"
|
||||
list "Catalog Entries" bind huginn.catalog.entries
|
||||
|
||||
card records "Persisted Records"
|
||||
text "records: 3"
|
||||
list "Record Entries" bind huginn.records.entries
|
||||
end
|
||||
text "records: 3"
|
||||
list "Record Entries" bind huginn.records.entries
|
||||
|
|
|
|||
|
|
@ -17,22 +17,19 @@ 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
|
||||
title bind face.name prefix "Renderer: "
|
||||
text bind face.body prefix "Body: "
|
||||
text "Eve owns rendering; providers and CultMesh own truth."
|
||||
|
||||
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
|
||||
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}}"
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
103
web/styles.css
103
web/styles.css
|
|
@ -289,6 +289,109 @@ p {
|
|||
border-bottom-width: var(--border-bottom-width);
|
||||
}
|
||||
|
||||
.cultui-partition {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cultui-partition.split-x {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.cultui-partition.split-y,
|
||||
.cultui-partition.split-none {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cultui-partition.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.cultui-partition[data-role="inspector.row"] {
|
||||
min-height: 34px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, rgba(32, 31, 43, 0.96), rgba(19, 20, 29, 0.96));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.cultui-partition[data-role="inspector.label"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cultui-label {
|
||||
color: var(--accent);
|
||||
font: 700 13px var(--font-mono);
|
||||
line-height: 1.15;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cultui-slider {
|
||||
--cultui-slider-value: 0%;
|
||||
--part-bleed: 0px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 18px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cultui-slider-visual {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cultui-slider-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cultui-slider-part {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
display: block;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.cultui-slider-part.track {
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.cultui-slider-part.fill {
|
||||
left: 0;
|
||||
width: var(--cultui-slider-value);
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.cultui-slider-part.thumb {
|
||||
left: var(--cultui-slider-value);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 7px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.cultui-slider-part[data-anchor-value="true"] {
|
||||
left: var(--cultui-slider-value);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
body[data-provider="repixelizer"] .surface {
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
|||
166
web/surface.js
166
web/surface.js
|
|
@ -10,6 +10,7 @@ const providerMeta = document.querySelector("#provider-meta");
|
|||
let socket;
|
||||
let providers = [];
|
||||
let currentProvider;
|
||||
let currentSurfaceStyles = {};
|
||||
|
||||
const defaultStyleTokens = {
|
||||
"--bg": "#020909",
|
||||
|
|
@ -82,6 +83,13 @@ const localProviders = [
|
|||
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", () => {
|
||||
|
|
@ -182,6 +190,7 @@ function closeSocket() {
|
|||
|
||||
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})`;
|
||||
|
|
@ -255,6 +264,14 @@ function renderCultComponent(node) {
|
|||
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}")`;
|
||||
|
|
@ -347,7 +364,7 @@ function renderCultComponent(node) {
|
|||
return image;
|
||||
}
|
||||
|
||||
if (kind === "text.dialogue" || kind === "text" || kind === "text.title") {
|
||||
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;
|
||||
|
|
@ -373,6 +390,10 @@ function renderCultComponent(node) {
|
|||
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"));
|
||||
|
|
@ -459,12 +480,155 @@ function renderCultList(props) {
|
|||
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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue