From 0fb9660e8f29ba0fcc78d79fbadbbf0d12badf08 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 5 Jun 2026 18:22:59 +0100 Subject: [PATCH] Build CultUI slider anatomy in web reference --- web/eve-dsl.js | 459 ++++++++++++++++++----- web/eve-dsl.test.mjs | 42 +++ web/fixtures/cultui-slider-inspector.eve | 28 ++ web/fixtures/huginn-cc-surface.eve | 19 +- web/fixtures/reactive-composition.eve | 27 +- web/styles.css | 103 +++++ web/surface.js | 166 +++++++- 7 files changed, 716 insertions(+), 128 deletions(-) create mode 100644 web/eve-dsl.test.mjs create mode 100644 web/fixtures/cultui-slider-inspector.eve diff --git a/web/eve-dsl.js b/web/eve-dsl.js index c127e05..1336168 100644 --- a/web/eve-dsl.js +++ b/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", +]); diff --git a/web/eve-dsl.test.mjs b/web/eve-dsl.test.mjs new file mode 100644 index 0000000..2fc64fa --- /dev/null +++ b/web/eve-dsl.test.mjs @@ -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); +}); diff --git a/web/fixtures/cultui-slider-inspector.eve b/web/fixtures/cultui-slider-inspector.eve new file mode 100644 index 0000000..3a79e8e --- /dev/null +++ b/web/fixtures/cultui-slider-inspector.eve @@ -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 diff --git a/web/fixtures/huginn-cc-surface.eve b/web/fixtures/huginn-cc-surface.eve index 6deaeab..def069b 100644 --- a/web/fixtures/huginn-cc-surface.eve +++ b/web/fixtures/huginn-cc-surface.eve @@ -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 diff --git a/web/fixtures/reactive-composition.eve b/web/fixtures/reactive-composition.eve index f8a58bd..8bcbffc 100644 --- a/web/fixtures/reactive-composition.eve +++ b/web/fixtures/reactive-composition.eve @@ -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 diff --git a/web/styles.css b/web/styles.css index 49d250b..13c3385 100644 --- a/web/styles.css +++ b/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; } diff --git a/web/surface.js b/web/surface.js index c31a7a0..2b27bf7 100644 --- a/web/surface.js +++ b/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"; }