# XenolithGraph — full reference (LLM-friendly dump) > Open-source embeddable node-graph editor for the web. WebGL (PIXI v8), opinionated design (Xen + Liquid Glass), typed pins, in-node widgets, framework adapters, and an MCP server for AI-driven graph construction. This file concatenates every public guide for one-shot ingestion. Canonical site: https://xenolithengine.github.io/xenolith-graph/ Live examples: https://xenolithengine.github.io/xenolith-graph/examples/ MCP server: https://github.com/XenolithEngine/xenolith-graph/tree/main/packages/mcp-server --- # MCP tool catalogue The `@xenolithengine/graph-mcp-server` package exposes these tools to any MCP client (Claude Desktop, Cursor, etc). Mutations go through the editor command bus, so undo/redo and events fire normally. ## list_node_types List every node type registered in the editor with its pins (direction/label/type). ALWAYS call this before add_node and connect_pins so you can use the real type names and pin labels — otherwise pins will not match. ## get_graph Return the current graph as xenolith.v1 JSON (nodes, edges, comments). Read-only snapshot. ## add_node Insert a node of the given type. Coordinates are OPTIONAL: if omitted the editor drops it just to the right of the existing graph (or at the origin if empty). Prefer adding all nodes without coordinates, then calling auto_layout once to tidy the whole picture — the LLM has no idea about node sizes/spacing, so manual coords almost always overlap. ## connect_pins Connect an output pin (from) to a compatible input pin (to). Pin types must match (float→float, object→object). The `pin` field accepts the pin LABEL ("Output", "In") as returned by list_node_types, OR a numeric index ("0", "1"), OR the literal "in"/"out" for simple single-pin nodes — pick whatever is easiest. Never invent uuids. On error the response lists the available pins so you can retry. ## fit_view Frame the whole graph (or a specific node subset) in the viewport with padding. No return value. ## set_widget_value Set the value of a widget on a node (slider/number/toggle/combo/text/color/custom). Value type must match the widget type (number for slider/number, boolean for toggle, string for combo/text/color, arbitrary JSON for custom). Undoable. ## remove_node Delete a node by id. Incident edges are removed automatically. Undoable. ## disconnect_edge Remove an edge by id (as returned by connect_pins or get_graph). ## create_macro Wrap a set of nodes into a collapsed Macro (group). External edges touching the selection become proxy pins on the macro, so the macro behaves like a single node from the outside. Returns the macro id. ## expand_macro Open a collapsed macro inline — members become visible again. Camera animates to fit the group automatically. ## collapse_macro Re-collapse an expanded macro back into a single node with proxy pins. ## auto_layout Tidy the entire graph: re-position every node using a layered left-to-right layout based on edge topology (sources on the left, sinks on the right). Call this AFTER adding nodes/edges so the result looks like a hand-arranged graph instead of overlapping boxes. Safe to call multiple times. ## set_category_palette Recolour every node by category. Pass a map of category id → CSS colour. Categories come from node schemas — the values returned by list_node_types under `category` (e.g. "logic", "data", "macro", "utility", or whatever the host registered). Each entry can be (a) a plain "#RRGGBB" / rgba(...) / CSS-named colour for a solid pill, or (b) an object `{start, end}` for a two-stop gradient header. Pass an empty object to reset to the theme defaults. ## set_theme Override a subset of theme tokens (colours, gradients, geometry) at runtime. Pass a partial token object — anything you do not specify keeps its current value. The shape mirrors the editor's theme tokens; the most common subset is `color.accent`, `color.surface.*`, `color.text.*`, `geometry.node.radius`. Use this to make AI-driven recolours visible without uploading a whole new theme. ## register_node_schema Define a brand-new node type the user can then instantiate via add_node. The schema must include `type` (unique identifier), `title`, optional `category`, and a `pins` array (each pin has `kind`, `direction`, `type`, optional `label`). Optional `widgets` array describes inline controls — for `combo` widgets you MUST pass a `values` array; `slider`/`number` accept `min`/`max`/`step`. If list_node_types already shows a type with the name you want, prefer add_node on the existing type rather than registering a variant with a different name. ## select_nodes Set the editor selection to exactly the listed node ids. Use to draw the user's attention to nodes the AI just changed, or to scope a follow-up operation visually. ## clear_selection Deselect every node. ## dive_into_template Open a template-instance node to edit its inner subgraph. Pass the instance node id (as listed by get_graph). The breadcrumb in the editor updates so the user can see where they are. ## dive_out Pop out of the current subgraph back to its parent. Optional `toDepth` jumps multiple levels at once (0 = back to Root). ## find_nodes Search the current graph for nodes matching a predicate. Returns ids + types + labels. Combine fields with AND. Use this instead of pulling the whole graph when you only need a subset. ## describe_node Return a detailed snapshot of one node: type, title, category, position, all pins (with live connections), and all widgets (with current values). Use before reasoning about a node — get_graph returns a thinner shape. ## screenshot Render the entire graph (NOT just the viewport — every node, padded) to a base64-encoded PNG/JPEG. Use to verify what the user is seeing after a series of edits. The response is large — only call when you need to "see" the result. ## node_screenshot Render a single node's current view (with its widget values, status ring, the lot) to a base64-encoded image. Much smaller than full `screenshot`. Use when you want to inspect or show ONE node — e.g. "did my set_widget_value land?". ## list_recipes Return the named subgraph templates the editor knows about. Each entry has `id`, `title`, `description`, `category`, and `requires` (the schema types the recipe assumes exist). Use this BEFORE instantiate_recipe so you know which recipes are available and what types you may need to register_node_schema first. ## instantiate_recipe Drop a named recipe into the current graph in one call. Creates every node + every edge, returns the map of recipe-local id → real node id (so you can describe_node / connect_pins to additional nodes after). If the recipe needs schemas that aren't registered, the call fails with a list of missing types — register them with register_node_schema and retry. Nodes are stacked at the origin; call auto_layout afterwards to tidy. Full JSON Schemas: https://xenolithengine.github.io/xenolith-graph/api/mcp-tools.json --- # Examples gallery Every example below is a live, interactive demo with a React island + full source. Visit the URL for the running app. ## Showcases - **MCP live (AI builds the graph)** — Start @xenolithengine/graph-mcp-server locally, click Connect, then ask Claude Desktop / Cursor to build a graph. The AI calls list_node_types → add_node → connect_pins → auto_layout. "I describe it, the editor builds it." https://xenolithengine.github.io/xenolith-graph/examples/mcp-live/ - **Visual stepping debugger** — Run a graph one node at a time. Yellow ring = paused; green = executed; red = breakpoint. The inspector shows live inputs/outputs and per-node timing. Debug your AI workflow on the canvas — like Chrome DevTools, but for nodes. https://xenolithengine.github.io/xenolith-graph/examples/step-debugger/ - **Time-travel scrubber** — Rewind a graph run. Drag the timeline through every step and watch the highlights replay — green = done, yellow = the step under inspection. The inspector shows that step’s inputs and outputs. https://xenolithengine.github.io/xenolith-graph/examples/time-travel/ - **Graph diff (PR review)** — Two versions of a graph side by side, with structural diff highlights — green = added, red = removed, yellow = modified. Drop-in PR-review for node graphs. https://xenolithengine.github.io/xenolith-graph/examples/graph-diff/ - **Per-node cost heatmap** — A RAG pipeline with per-node latency badges — cool blue → hot red. Press Pulse to see metrics breathe. Drop-in observability overlay; no OSS competitor ships it. https://xenolithengine.github.io/xenolith-graph/examples/heatmap/ - **LLM workflow builder** — Input → Prompt → Model → Output. Press Run; the chain walks in topological order, the active node glows, the completion streams into the Output. A prettier LangFlow, on Xenolith. https://xenolithengine.github.io/xenolith-graph/examples/llm-builder/ - **Audio synth (Web Audio)** — A real synth built on the graph — Oscillator → Filter → Gain → Output. Knobs are node widgets; Play wires live AudioNodes and lights the active chain. It makes sound. https://xenolithengine.github.io/xenolith-graph/examples/audio-synth/ - **Save & restore** — The whole graph is JSON. Download / upload a .json file, and autosave to localStorage on every edit (driven by useGraphJSON). Reload the page — it comes back. https://xenolithengine.github.io/xenolith-graph/examples/save-restore/ - **Image pipeline (WebGL)** — A real image-filter pipeline — Source → Exposure → Saturation → Hue → Blur → Vignette → Result. Each node is a live GLSL fragment pass. Drag a slider and the result re-renders; drop your own image; download the PNG. https://xenolithengine.github.io/xenolith-graph/examples/image-pipeline/ - **Diagram edges** — Edges as a diagramming primitive — text nodes wired with directional arrowhead markers, edge labels (pass / fail / retry), and an animated flowing dash on the main path. Toggle the flow. https://xenolithengine.github.io/xenolith-graph/examples/diagram/ - **Stress test (1000s of nodes)** — Generate hundreds → thousands of WebGL nodes wired into a chain. Live FPS + node-count overlay top-right; the panel counter is driven by the reactive `useNodes()` hook. Zoom floor dropped to 5% so 10k+ nodes fit on one screen. https://xenolithengine.github.io/xenolith-graph/examples/stress-test/ ## Nodes - **Mount an editor** — The honest minimum: register a node type, add one, frame it. Xen is the default theme — no setup. https://xenolithengine.github.io/xenolith-graph/examples/mount/ - **Load a graph** — Load a real saved xenolith.v1 graph and reframe it. Built-in controls + a reload panel. https://xenolithengine.github.io/xenolith-graph/examples/load/ ## Widgets - **Built-in widgets** — Every built-in widget — slider, number, toggle, combo, color, text — on one node, in WebGL. https://xenolithengine.github.io/xenolith-graph/examples/builtin-widgets/ - **Custom canvas widget** — The simplest custom widget: a click/drag level bar, drawn in WebGL — no DOM. The value flows back to your app via the standard widget callback. https://xenolithengine.github.io/xenolith-graph/examples/canvas-widget/ - **Bring your own UI** — Four widgets that are real framework components (async-select, file drop, CodeMirror, sparkline), themed via --xeno-*. https://xenolithengine.github.io/xenolith-graph/examples/custom-widgets/ - **Conditional widgets** — Declarative `displayOptions.show(state)` — n8n-style. One HTTP Request node hides `body` until the method needs one, and `token` until auth is `bearer`. Pure schema: no `setNodeWidgets` plumbing in the host. The node re-layouts and edges stay attached as widgets appear and disappear. https://xenolithengine.github.io/xenolith-graph/examples/conditional-widgets/ - **Per-node canvas drawing** — Sparkline + ColorPreview nodes — each paints its own body via a CanvasWidgetController (the equivalent of LiteGraph onDrawForeground). The sparkline rolls a live plot of the upstream slider; the swatch fills from `node.state.tint`. Anything you can draw on a `` can be a node body. https://xenolithengine.github.io/xenolith-graph/examples/preview-nodes/ ## Interaction - **Events → your state** — Typed event callbacks wired to app state: a live log, selection inspector, widget values. https://xenolithengine.github.io/xenolith-graph/examples/events/ - **Mobile / tablet (touch demo)** — Touch-first demo for testing pinch, two-finger pan, long-press context menu, and the drawer-mode palette on narrow viewports. Includes a fullscreen toggle. Open this URL on an iOS device or in the iOS Simulator — see docs/TESTING-ON-IOS-SIMULATOR.md. https://xenolithengine.github.io/xenolith-graph/examples/mobile-touch/ - **Properties sidebar** — A "fat" node with 8 widgets opts into the docked properties panel via the per-widget `showInSidebar: true` flag. Edit live — the same widget renders inline AND in the panel; no separate sidebar component to author. Themed via --xeno-*. Open programmatically: `editor.openSidebar(nodeId)`. https://xenolithengine.github.io/xenolith-graph/examples/properties-sidebar/ - **Two-way data binding** — Both binding levels in one: `useSelection()` edits the selected node’s widgets; `useGraphJSON()` binds the whole graph ⇄ JSON. No manual event wiring. https://xenolithengine.github.io/xenolith-graph/examples/two-way/ - **Type conversions** — Typed pins of different types refuse to connect — unless you register a conversion. NumberSource (out: number) won’t wire into TextSink (in: text) until `types.registerConversion("number", "text", String)` is called. Toggle the cast live; the existing edge drops when it disappears. https://xenolithengine.github.io/xenolith-graph/examples/type-conversions/ - **Subgraph breadcrumb** — Nested template instances (Pipeline → Stage → primitives). Dive in by double-click OR programmatically; the breadcrumb in the top-left tracks the path (Root › Pipeline › Stage) and pops any segment. Auto-themed via --xeno-*. Opt-out with `editor.setBreadcrumbVisible(false)`. https://xenolithengine.github.io/xenolith-graph/examples/breadcrumb-dive/ - **Palette sidebar (drag to spawn)** — 16 schemas across 5 categories (data / math / transform / logic / io) listed in a docked palette on the left. Drag any tile onto the canvas — the editor inserts the node at the drop point via its built-in `node:drop` handler. Configure with `editor.setPaletteSidebar({ side, filter })`. https://xenolithengine.github.io/xenolith-graph/examples/palette-sidebar/ - **Connection validation** — Typed Blueprint pins refuse mismatched wires automatically (a string won’t plug into a number). A custom guard adds cycle prevention on top. Every attempt is logged live. https://xenolithengine.github.io/xenolith-graph/examples/connection-validation/ - **Export to image** — Export the whole graph — not just the viewport — to a Blob at any scale. Download PNG, retina 2×, or JPG straight from a panel. https://xenolithengine.github.io/xenolith-graph/examples/export-image/ ## Styling - **Edge path styles** — Per-edge `pathStyle`: bezier (default Xen S-curve), smoothstep (rounded orthogonal), step (90° elbows), linear (straight). Set on construction or live via `editor.setEdgeOptions(id, { pathStyle })`. Same wire colour / animated dash / arrowhead contract regardless of shape. https://xenolithengine.github.io/xenolith-graph/examples/edge-paths/ - **Theming** — Theme is a reactive prop — flip Xen ⇄ Liquid Glass at runtime; panels/widgets restyle via --xeno-*. https://xenolithengine.github.io/xenolith-graph/examples/theming/ ## Viewport - **Viewport & minimap** — Built-in controls (zoom/fit/reset/undo/redo/save/lock), toggleable minimap, live useNodes/useViewport readout. https://xenolithengine.github.io/xenolith-graph/examples/viewport/ ## Layout - **Nested auto-layout (ELK)** — Three levels of nested macros — Encoder/Decoder containing Attention/FFN containing leaf ops. ELK respects the hierarchy (children stay inside their parent frame); dagre ignores parent and pancakes everything. Toggle to see the difference. https://xenolithengine.github.io/xenolith-graph/examples/nested-layout/ - **Auto-layout (dagre)** — A messy 14-node DAG snaps into a clean layered layout. Toggle LR/TB; Cmd+Z restores the mess in a single undo step. https://xenolithengine.github.io/xenolith-graph/examples/auto-layout/ --- # Install > Install XenolithGraph and mount your first editor. ## Install ```sh npm install @xenolithengine/graph-editor pixi.js ``` That's all. `@xenolithengine/graph-editor` lists `core`, `render-pixi`, and `theme-xen` as transitive dependencies — npm pulls them automatically. `pixi.js` stays a peer dependency so you pin it yourself and keep control of the WebGL renderer version. ## Mount ```ts import { XenolithEditor } from '@xenolithengine/graph-editor' const editor = await XenolithEditor.init('#app') ``` That single call: 1. Loads bundled Inter (Regular / SemiBold / Bold) from inside the theme package — no Google Fonts. 2. Creates a PIXI v8 `Application`, attaches a canvas to your target element. 3. Wires pan, zoom, drag, selection, marquee, and node interactions. 4. Returns an `editor` instance you can populate. There is no second setup step. If you `npm install`, you can `init`. ## Add nodes and edges ```ts import { createNodeId, createPinId } from '@xenolithengine/graph-core' const a = { id: createNodeId(), type: 'Source', position: { x: 100, y: 100 }, size: { x: 150, y: 70 }, state: {}, pins: [ { id: createPinId(), kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'Output' }, ], } editor.addNode(a, { category: 'logic', title: 'Source' }) ``` `editor.connect(fromNode, fromPinIndex, toNode, toPinIndex, opts?)` draws an edge between two pins. The optional `opts` is `RenderEdgeOptions` — `{ sourceType?, label?, markerEnd?, animated?, pathStyle?, noMidpoint? }`. The wire colour follows the source pin's type — an invariant of the Xen theme. ## Interaction | Gesture | Behaviour | |------------------------------|----------------------------------------------| | Wheel | Focal zoom to the cursor | | Right- or middle-button drag | Pan | | Left-click on a node | Select (replaces selection) | | Shift + left-click | Toggle in multi-selection | | Left-click on empty canvas | Clear selection | | Left-drag on empty canvas | Marquee selection (nodes inside the box) | | Left-drag on a node | Move it (and the whole selection if any), snapped to grid | | Hold Alt while dragging | Disable snap-to-grid | | Click on a header chevron | Collapse / expand the node to / from pill mode | --- # Editor options > All initialisation options for XenolithEditor. `XenolithEditor.init(target, options)` accepts an optional `options` object. Every field has a sensible default — empty options give you the canonical Xen editor. ## All options ```ts interface XenolithEditorOptions { /** A full XenolithTheme (Xen, Liquid Glass, …) or a partial token override deep-merged into Xen. * Swap at runtime via `editor.setTheme(...)`. */ theme?: XenolithTheme | DeepPartial /** Canvas background. Defaults to the active theme's canvas color. */ background?: string /** Auto-resize the canvas with the window. Defaults to `true`. */ resizeToWindow?: boolean /** Preferred renderer. Defaults to `'webgl'`. */ renderer?: 'webgl' | 'webgpu' /** Initial viewport state `{x, y, zoom}`. Defaults to identity. */ viewport?: ViewportState /** Min/max zoom bounds. Defaults to `[0.05, 16]`. */ zoomBounds?: readonly [min: number, max: number] /** Disable pan/zoom and node click interaction. Defaults to `false`. */ disableInteraction?: boolean /** Hide the dot grid behind the world. Defaults to `false`. */ disableGrid?: boolean /** Snap cell size in world pixels when dragging. Hold Alt to disable. Defaults to `8`. */ snap?: number /** Show the overview minimap. `true` uses the default (bottom-right) placement; pass an object * for an explicit corner/edge anchor. Toggle later via `editor.setMinimapVisible(bool)`. */ minimap?: boolean | { position?: MinimapPosition } /** Show the built-in viewport controls (zoom / fit / reset / undo·redo / save / lock). `true` * uses defaults; pass an object for position/orientation/buttons. Toggle later via * `editor.setControls(...)`. */ controls?: boolean | ControlsOptions /** Custom connection guard, on top of the built-in type check. Return `false` to reject a wire. * Receives the normalised out → in endpoints. Update at runtime via `editor.setIsValidConnection`. */ isValidConnection?: (connection: ConnectionRequest) => boolean } ``` ## Examples ### Read-only embed for documentation ```ts const editor = await XenolithEditor.init('#docs-embed', { disableInteraction: true, disableGrid: true, viewport: { x: 0, y: 0, zoom: 0.75 }, }) ``` ### Wider snap, larger zoom range ```ts const editor = await XenolithEditor.init('#app', { snap: 16, zoomBounds: [0.1, 4], }) ``` --- # Editor API & interactions > Build graphs, load and save, fit the view, insert nodes and reroutes, and show a busy overlay. Everything below is a method on the `editor` returned by `XenolithEditor.init(...)`. ## Building a graph ```ts // Add a node you constructed yourself. editor.addNode(node, { category: 'logic', title: 'Source', collapsed: false }) // Connect an output pin to an input pin (indices into each node's `pins`). editor.connect(source, 0, sink, 1, { sourceType: 'float' }) ``` Most apps don't hand-build nodes, though — they load a document. ## Load & save (the `xenolith.v1` format) The editor speaks one canonical JSON format. `loadJSON` replaces the whole graph, selection, and viewport; `toJSON` round-trips it back out. ```ts editor.loadJSON(graphDoc) // replace the scene from a xenolith.v1 document const doc = editor.toJSON() // serialise nodes + edges + render opts + viewport ``` A document is `{ version: 'xenolith.v1', nodes: [...], edges: [...], viewport? }`. Pins, sizes, collapsed state, category/title render options, and per-edge `sourceType` all serialise. This is the recommended way to seed a graph — author it as data, register the matching schemas, then load it: ```ts for (const schema of schemas) editor.registry.register(schema) // so Tab can spawn them editor.loadJSON(graphDoc) editor.fitView() ``` ## Framing the view `fitView` computes the bounding box of every node and frames it in the canvas — the reliable way to reveal a freshly loaded graph, however large. ```ts editor.fitView() // 64px padding, never zooms past 1× editor.fitView({ padding: 80, maxZoom: 1 }) // more margin editor.resetView() // back to identity ``` | Option | Default | Meaning | |-----------|----------------------|--------------------------------------------| | `padding` | `64` | Screen-space margin kept clear on each side | | `maxZoom` | `1` | Never zoom **in** past this (keeps small graphs from filling the screen) | | `minZoom` | the editor's zoom floor | Never zoom **out** past this | ## Inserting nodes & reroutes ```ts // Spawn a registered schema at a world position (undoable, selects it). editor.insertNode('Transform', { x: 320, y: 200 }) editor.insertNode('Transform', midpoint, { center: true }) // centre on the point // Split an edge with an inline reroute knot at a world point. editor.insertRerouteOnEdge(edgeId, worldPos) // Disconnect an edge — preventable through the `edge:disconnecting` event (no cleanup of dangling // inline reroutes; use `deleteEdge` for that destructive variant). editor.disconnectEdge(edgeId) // Hard-delete one edge; dangling inline reroutes are removed too. editor.deleteEdge(edgeId) ``` ### Two kinds of reroute - **Inline knot** (`$reroute`) — created by splitting an edge. A non-pullable dot that carries the wire through and takes the wire's type colour. It can't exist on its own: deleting its last connection removes it. - **Reroute node** (`Reroute`) — a movable rectangular relay you *can* pull fresh wires from. It's a built-in schema, so it always appears in the insert palette. ## Interactions out of the box | Gesture | Action | |---------|--------| | `Tab` / double-click empty canvas | Open the insert palette (fuzzy search; built-in nodes rank first) | | Right-click an edge's midpoint dot | Context menu: **Add Node** (filtered to compatible types) · **Add Reroute** · **Delete** | | Drag from a pin | Live ghost edge with type-compatibility validation | | `Alt`+drag a connected pin | Tear the edge off and re-wire it | | Drag node / marquee | Move (8px snap, `Alt` to disable) / box-select | | `Delete` · `⌘Z` / `⌘⇧Z` · `⌘C`/`⌘V` · `⌘D` · `⌘A` | Delete · undo/redo · copy/paste · duplicate · select-all | ## Busy overlay Heavy first renders (large graphs) can be wrapped in a themeable blur-and-spinner overlay. It paints first, runs your work behind the blur, then fades out — so big graphs reveal smoothly instead of freezing then popping in. It's styled by the active theme's `paletteStyle`. ```ts await editor.withOverlay('Rendering…', () => { editor.loadJSON(bigGraph) editor.fitView() }) // Or drive it manually: editor.showOverlay('Loading…') editor.hideOverlay() ``` ## Widgets Nodes can carry in-node controls. A widget is **declarative data** on the node (or its schema); its value lives in `node.state[key]`, so it serialises with the graph and every change is undoable. ```ts const node = { id: 'n1', type: 'Sampler', position: { x: 0, y: 0 }, state: {}, pins: [], widgets: [ { id: 'steps', type: 'number', label: 'Steps', key: 'steps', min: 1, max: 150, step: 1 }, { id: 'cfg', type: 'slider', label: 'CFG', key: 'cfg', min: 0, max: 20, step: 0.1 }, { id: 'sampler',type: 'combo', label: 'Sampler', key: 'sampler', values: ['euler', 'dpm++', 'ddim'] }, { id: 'prompt', type: 'text', label: 'Prompt', key: 'prompt', multiline: true }, { id: 'hires', type: 'toggle', label: 'Hi-res', key: 'hires' }, { id: 'tint', type: 'color', label: 'Tint', key: 'tint' }, { id: 'run', type: 'button', label: 'Run', action: 'run' }, ], } ``` Built-in types: **number** (drag-scrub + click-to-type), **slider**, **combo** (dropdown), **text** (`multiline`), **toggle**, **color** (picker popover), **button** (fires an action). ```ts editor.getWidgetValue(nodeId, 'steps') editor.setWidgetValue(nodeId, 'steps', 30) // clamped, undoable editor.on('widget:action', ({ nodeId, action }) => { /* button pressed */ }) ``` Every widget is themeable globally via `color.widget` / `geometry.widget` tokens, and per-instance via a `style` override: ```ts { id: 'gain', type: 'slider', label: 'Gain', key: 'gain', min: 0, max: 1, style: { fill: '#FF3366', radius: 10, borderFocused: '#FF3366' } } ``` ### Custom widgets Register a controller, then reference it from a `custom` widget's `renderer`: ```ts { id: 'curve', type: 'custom', renderer: 'curve', key: 'curve', height: 120 } ``` Two controller kinds: ```ts // Canvas-draw (fast — painted to a WebGL texture, no DOM): editor.registerWidget('curve', { draw(ctx, { value, width, height }) { /* 2D-canvas drawing */ }, onPointer(phase, x, y, { value, width, height }) { return newValue }, }) // DOM-mounted (arbitrary HTML — the contract React / Vue / Svelte adapters wrap): editor.registerWidget('chart', { mount(el, { value, setValue }) { /* render a component into el */; return () => { /* cleanup */ } }, update({ value }) { /* re-render on external change */ }, }) ``` Canvas-draw is the perf-friendly default; the editor keeps DOM-mounted widgets glued to their node's on-screen rect through pan/zoom/drag. ### ComfyUI import `importComfyWorkflow` maps each node's positional `widgets_values` to typed widgets by value (number→number, boolean→toggle, string→text). Names/combos/ranges require the server's `object_info` and are a later upgrade. ## Plugins `editor.use(plugin)` installs a plugin. A plugin is `{ name, install(ctx) }`; `install` runs once and may return a disposer that fires on `editor.destroy()`. Plugins are how the editor stays extensible without baking features in — node packs, runtimes, type systems, custom widgets, and validators all enter through this surface. ```ts editor.use({ name: 'my-plugin', install(ctx) { ctx.registry.register({ type: 'Custom', title: 'Custom', pins: [/* ... */] }) ctx.types.register({ id: 'vec3', color: '#a0f', shape: 'diamond', compatibleWith: ['vec2'] }) ctx.icons.register('star', '...') ctx.setIsValidConnection((from, to) => from.node.type !== 'Frozen') ctx.registerWidget('myKnob', { draw(/* ... */) {}, onPointer(/* ... */) {} }) const off = ctx.on('node:added', e => console.log('added', e.id)) return () => off() }, }) ``` `PluginContext` exposes: | Field | Purpose | |-------|---------| | `registry` | Node schema registry (`ctx.registry.register(schema)`) | | `types` | `TypeRegistry` for custom pin types (id/color/shape/compatibleWith) | | `icons` | Glyph registry — built-in Feather set + `register(name, svg)` | | `commandBus` | Batch mutations as one transaction (use sparingly) | | `graph` | Read-only view of the graph | | `app` | The PIXI `Application` — escape hatch for custom rendering only; touching the scene graph here is unsupported | | `registerWidget` | Register canvas-draw or DOM-mount custom widget controllers | | `setIsValidConnection` | Install a global connection validator (runs after built-in type-compat) | | `on` | Full event-bus access (see [Events](#events)) | Runtime delegation (for execution plugins — none ship in the editor itself): | Method | Purpose | |--------|---------| | `onTick(cb)` | Subscribe to per-frame ticks | | `startLoop()` / `stopLoop()` / `step()` | Drive the tick loop or step one frame | | `setWidgetValue(nodeId, key, value, { ephemeral: true })` | Non-undoable widget write for sims | | `setNodePins(nodeId, pins)` | Variadic pins (Sequence / MakeArray) | | `setEdgeAnimated(edgeId, on)` | Toggle the marching-ants pulse | | `expandTemplateInstance(nodeId)` | Read-only flatten of a template instance | | `graphSnapshot({ expandMacros, flattenReroutes })` | Snapshot for execution traversal | ## Custom types `TypeRegistry` defines pin types — colour, shape, and which other types they auto-cast from. Type drives wire colour, pin fill, and connection validity. ```ts editor.types.register({ id: 'float', color: '#36f', shape: 'circle', compatibleWith: ['int'] }) editor.types.register({ id: 'exec', color: '#fff', shape: 'arrow' }) editor.types.register({ id: 'struct:Vec3', color: '#a0f', shape: 'diamond' }) ``` Pin shapes: `circle` (data), `arrow` (exec), `diamond` (struct/object). `compatibleWith` enables auto-cast between related types (e.g. `int` → `float`). ## Custom validation `setIsValidConnection` installs a predicate that runs **after** built-in type-compat. Return `false` to refuse the connection. ```ts editor.setIsValidConnection((from, to) => { return !(from.node.type === 'A' && to.node.type === 'B') }) ``` ## Header glyphs A glyph is a small SVG icon rendered on the node header beside the title. The built-in Feather set is preloaded; register more by name. ```ts editor.icons.register('zap', '') editor.registry.register({ type: 'Trigger', title: 'Trigger', glyph: { icon: 'zap', side: 'left' }, pins: [{ kind: 'exec', direction: 'out' }], }) // Per-node override (without mutating the schema): editor.setNodeGlyph(nodeId, { icon: 'star', side: 'right' }) editor.setNodeGlyph(nodeId, null) // clear override ``` A per-node glyph override round-trips through `xenolith.v1`. ## Events `editor.on(event, handler)` returns an `Unsubscribe`. Events are bridged off the command bus, so they fire on undo/redo too — a UI bound to them stays in sync without extra wiring around mutation calls. ```ts const off = editor.on('node:added', ({ node }) => {}) editor.on('node:removed', ({ nodeId }) => {}) editor.on('node:moved', ({ nodeId, position }) => {}) editor.on('edge:connected', ({ edge }) => {}) editor.on('edge:disconnected', ({ edgeId }) => {}) editor.on('selection:changed', ({ nodeIds }) => {}) editor.on('widget:changed', ({ nodeId, widgetId, value }) => {}) editor.on('viewport:changed', ({ x, y, zoom }) => {}) editor.on('dive:changed', ({ depth, definitionId }) => {}) // template dive in/out off() ``` Pre-mutation events (`node:removing`, `edge:connecting`, `edge:disconnecting`, `node:clicking`) accept `payload.cancel()` to veto. ## Animated edges Toggles a flowing marching-ants pulse along an edge. Round-trips through `xenolith.v1`. ```ts editor.setEdgeAnimated(edgeId, true) editor.setEdgeAnimated(edgeId, false) ``` Use sparingly — animated edges break render-on-demand for as long as they're on-screen. ## Variadic pins `setNodePins` replaces a node's pin list. Use this for nodes whose pin count depends on state — Sequence (exec out 1..N), MakeArray (data in 1..N), and similar. Plugins typically call this from a `widget:changed` handler. ```ts editor.setNodePins(nodeId, [ { kind: 'exec', direction: 'in', label: 'in' }, { kind: 'exec', direction: 'out', label: 'Then 1' }, { kind: 'exec', direction: 'out', label: 'Then 2' }, ]) ``` Edges connected to removed pins are pruned automatically. ## Comments A Comment is a labelled rectangle behind nodes — a spatial group, not data containment. Move it, resize it from the corner, double-click the header to rename. ```ts const id = editor.addComment({ position: { x: 100, y: 100 }, size: { x: 400, y: 300 }, text: 'IO', color: '#36c', }) editor.setCommentText(id, 'Inputs') editor.setCommentColor(id, '#f93') editor.removeComment(id) ``` Inline rename: double-click the comment header. Shortcuts: `Tab` → **Comment**, or double-click an empty area and pick **Comment** from the palette. ## Macros (Groups) A Macro packs N selected nodes into one collapsible wrapper with proxy pins on its border. Inline — there's no shared definition, no propagation. ```ts const macroId = editor.createMacroFromSelection() const macroId = editor.createMacroFromSelection([id1, id2, id3], 'Backup') editor.collapseMacro(macroId) editor.expandMacro(macroId) editor.ungroupMacro(macroId) // dissolve; members go back to the root graph ``` Shortcut: `Cmd+G` groups the current selection. ## Live templates A Template is a sub-graph with a stored definition. You can spawn many instances of one definition; editing the definition (via *dive-in*) propagates to every instance. Boundary nodes `$templateInput` / `$templateOutput` inside the definition form the pin interface — each holds one pin, rename it to set the pin label. ```ts const instanceId = editor.createTemplateFromSelection() const instanceId = editor.createTemplateFromSelection([id1, id2], 'My Template') editor.diveInto(instanceId) // enter the definition (breadcrumb tracks the path) editor.diveOut() // back to the parent graph editor.renameTemplate(defId, 'New name') // Drop the shared link, get an editable Macro: editor.unpackTemplateInstance(instanceId) // Conversion both ways: editor.convertTemplateInstanceToMacro(instanceId) editor.convertMacroToTemplate(macroId) // nested macros carry into the def ``` The palette lists every definition as an insertable node. A definition can't insert itself or any ancestor on the dive stack (recursion guard). Shortcuts: `Cmd+Shift+G` makes a template from the selection; double-click an instance dives in. ## Virtualization & LOD The editor virtualizes nodes outside the viewport and downgrades distant nodes to lower-fidelity representations automatically. Defaults are tuned for ~30k+ nodes — the threshold is read from the active theme (`virtualizeThreshold`, default 300) so a custom theme can override it without touching editor code. ```ts // In your theme module: export const myTheme: XenolithTheme = { ...xenTheme, virtualizeThreshold: 100, // virtualize earlier on a render-heavy theme } ``` LOD switches as zoom decreases: full → sprite-baked → flat-batch. No public toggle — the editor manages it per-frame against the active theme's threshold. ## Headless runtime Execution is delegated to plugins — the editor itself ships **no execution**. A runtime (e.g. `@xenolithengine/graph-plugin-runtime`, a Blueprint VM, in progress) installs as a plugin and uses the [runtime delegation API](#plugins) above. ```ts import { runtime } from '@xenolithengine/graph-plugin-runtime' // future package editor.use(runtime({ /* options */ })) editor.startLoop() // begins ticking — the runtime decides what executes editor.step() // single frame ``` --- # In-node widgets > Declarative number/slider/combo/text/toggle/color/button controls, custom canvas + DOM controllers, conditional visibility, properties sidebar. Widgets are the controls that live inside a node — number inputs, sliders, combos, toggles, color swatches, and your own canvas or DOM controllers. They're declared on the schema; values live on `node.state`; the editor handles layout, hit-testing, and serialization. ## Built-in widgets ```ts editor.registry.register({ type: 'Material', title: 'Material', pins: [ { kind: 'data', direction: 'in', type: 'float', label: 'intensity' }, ], widgets: [ { id: 'intensity', type: 'slider', key: 'intensity', label: '', min: 0, max: 1, step: 0.01 }, { id: 'mode', type: 'combo', key: 'mode', label: 'mode', values: ['Add', 'Multiply', 'Screen'] }, { id: 'tint', type: 'color', key: 'tint', label: 'tint' }, { id: 'enabled', type: 'toggle', key: 'enabled', label: 'enabled' }, { id: 'notes', type: 'text', key: 'notes', label: 'notes', multiline: true }, { id: 'reset', type: 'button', label: '+ reset', action: 'reset' }, ], }) ``` Widget types: `number`, `slider`, `combo`, `text` (single or `multiline`), `toggle`, `color`, `button`, `custom`. Every value-holding widget needs a `key` — that's the field on `node.state` where its value lives, and the implicit pin-binding key. ### Pin binding When a widget's `key` matches a data IN-pin's `label` (or `id`), the widget renders **inline on that pin's row** and disappears when the pin gets a wire (UE-Blueprint default-value behaviour). Override the implicit binding with `pinKey`: ```ts { id: 'value', type: 'number', key: 'value', pinKey: 'amount' } ``` ### Display widgets (always visible) Set `visibility: 'always'` to make the widget render even when its pin is wired — useful for read-only displays. When connected, the widget reads the live upstream value: ```ts { id: 'out', type: 'number', key: 'out', label: 'value', visibility: 'always' } ``` ### Reading and writing values ```ts editor.getWidgetValue(nodeId, widgetId) editor.setWidgetValue(nodeId, widgetId, value) editor.setWidgetValue(nodeId, widgetId, value, { ephemeral: true }) // skip command bus (no undo entry) ``` The second parameter is the widget's `id` field (the stable identifier you set in the schema). It often matches `key` (the path into `node.state`), but they're conceptually distinct — `id` is for addressing the widget; `key` is for state lookup. ## Conditional visibility (`displayOptions.show`) Toggle widgets on and off from another widget's value — no imperative `setNodeWidgets` plumbing on the host side. The callback runs against `node.state` after every `setWidgetValue`; returning `false` hides the widget AND its pin row (only if disconnected — wired pins stay so the edge doesn't dangle). ```ts widgets: [ { id: 'method', type: 'combo', key: 'method', label: '', values: ['GET', 'POST', 'PUT'] }, { id: 'body', type: 'text', key: 'body', label: '', multiline: true, displayOptions: { show: (state) => state.method !== 'GET' } }, { id: 'auth', type: 'combo', key: 'auth', label: '', values: ['none', 'basic', 'bearer'] }, { id: 'token', type: 'text', key: 'token', label: '', displayOptions: { show: (state) => state.auth === 'bearer' } }, ] ``` Fail-open: a `show` callback that throws is treated as visible — a schema bug must not blank the node. ## Free-floating widgets (no pin) By default, a non-`custom` widget without a matching pin is dropped silently (it's probably a typo). To intentionally render an orphan widget — config fields with no external input (HTTP body, secret tokens, schema editors) — set `freeFloating: true`. It renders as a full-width body-band row under the pins: ```ts { id: 'apiKey', type: 'text', key: 'apiKey', label: '', placeholder: 'sk-…', freeFloating: true } ``` `custom` widgets are free-floating automatically when their `key` doesn't match any pin. ## Properties sidebar Flag any widget with `showInSidebar: true` to make it appear in the editor's docked properties panel as well. The SAME widget instance renders inline AND in the panel — no separate component: ```ts widgets: [ { id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true }, { id: 'intensity', type: 'slider', key: 'intensity', label: '', min: 0, max: 1, showInSidebar: true }, ] ``` Open / close from your host UI: ```ts editor.openSidebar(nodeId) editor.closeSidebar() editor.isSidebarOpen() editor.refreshSidebar() // force a redraw after structural mutation ``` The sidebar is fully themable via `--xeno-*` CSS variables; it lives in `editor.overlayRoot`. ## Custom widgets Two flavours: `canvas-draw` controllers (you paint into a 2D context, we render that to a sprite) and `dom-mount` controllers (you own a DOM element layered over the widget rect — great for React, Vue, Svelte islands). ### Canvas-draw ```ts editor.registerWidget('sparkline', { draw(ctx, { value, node, width, height, accent, text }) { const points = (value as number[]) ?? [] ctx.strokeStyle = accent ctx.beginPath() points.forEach((y, i) => { const x = (i / (points.length - 1 || 1)) * width const cy = height - y * height if (i === 0) ctx.moveTo(x, cy); else ctx.lineTo(x, cy) }) ctx.stroke() }, }) // Reference it on the schema: widgets: [{ id: 'spark', type: 'custom', renderer: 'sparkline', key: 'samples', height: 80 }] ``` ### DOM-mount (React / Vue / Svelte) ```ts editor.registerWidget('reactPicker', { isDom: true, mount(host, { value, onCommit, accent }) { const root = createRoot(host) root.render() return () => root.unmount() }, }) ``` The host element is positioned over the widget rect and stays in sync as the node moves. `onCommit(newValue)` writes through `setWidgetValue` (with undo). ## Live values on display widgets `visibility: 'always'` widgets read the live value from their wired upstream pin. Built-in widgets do this automatically; custom widgets receive it via the `value` arg every redraw. No subscription wiring on your side. --- # Macros, templates, subgraphs > Group nodes inline, extract reusable subgraphs, dive in and out, navigate with a breadcrumb. Two complementary ways to organise big graphs: **macros** (inline collapse — the members live on the same canvas behind a pill) and **templates** (reusable subgraph definitions — instances reference a shared interior you can dive into). ## Macros — inline collapse A macro wraps a selection in a single collapsible node. Members stay on the current canvas; collapsing hides their box and routes their boundary edges through synthesised proxy pins on the macro. ```ts const macroId = editor.createMacroFromSelection(undefined, 'My group') // wraps current selection editor.expandMacro(macroId) // unhide members editor.collapseMacro(macroId) // hide again — pin proxies stay editor.toggleMacro(macroId) editor.ungroupMacro(macroId) // dissolve the macro back into raw members ``` The macro auto-derives proxy pins from boundary-crossing edges plus disconnected widget-bound IN-pins (so they remain reachable for wiring). Expand → drag a wire onto a member pin → re-collapse: the macro grows a new proxy pin to match. ## Templates — reusable definitions A template definition lives once in `editor.definitions`; instance nodes (`$templateInstance`) reference it by id. Editing the shared definition propagates to every instance. There are no per-instance values — instance pins mirror the definition's `$templateInput` / `$templateOutput` boundary nodes. ```ts // Extract the current selection into a template + leave an instance behind. const instanceId = editor.createTemplateFromSelection(undefined, 'Pipeline') // Rename the underlying definition (affects every instance). const defId = (editor.graph.getNode(instanceId)!.state.definitionId as string) editor.renameTemplate(defId, 'Pipeline v2') // Inline an instance back into the graph (one-off). Definition + other instances untouched. editor.unpackTemplateInstance(instanceId) // Convert an instance into an editable Macro group (drops the link to the shared def). editor.convertTemplateInstanceToMacro(instanceId) // Convert an editable Macro into a Template (one definition, many instances). const newInstance = editor.convertMacroToTemplate(macroId) ``` ## Diving in / out Double-click a `$templateInstance` (or call `editor.diveInto(nodeId)`) to swap the canvas for the definition's interior. `editor.diveOut()` pops one level; `editor.diveOut(0)` returns to the root. ```ts editor.diveInto(instanceId) editor.diveDepth // 0 at the root editor.diveOut() // up one editor.diveOut(0) // straight to root ``` Nested instances dive recursively — the breadcrumb shows the path. ## Breadcrumb A DOM panel that auto-appears in the top-left when `diveDepth > 0`, listing every segment (`Root › Pipeline › Stage`). Each segment is clickable to pop back to that level. ```ts editor.setBreadcrumbVisible(false) // opt out ``` Theme-aware via `--xeno-*` vars. Lives in `editor.overlayRoot`. ## Events ```ts editor.on('dive:changed', ({ depth, definitionId }) => { console.log('now at depth', depth, definitionId ?? '(root)') }) ``` ## A round-trip example ```ts // Group + extract + share + reuse: editor.setSelection(['n1', 'n2', 'n3']) const inst1 = editor.createTemplateFromSelection(undefined, 'Mixer') // Spawn a second instance of the same definition somewhere else: const def = editor.graph.getNode(inst1)!.state.definitionId as string const inst2 = editor.addNode({ id: createNodeId(), type: '$templateInstance', position: { x: 600, y: 0 }, state: { definitionId: def, pinBoundary: {} }, pins: [], }) // Edit the shared definition by diving in: both instances now reflect the change: editor.diveInto(inst1) // ...mutate... editor.diveOut() ``` ## Why macros vs templates? | Use a **macro** when… | Use a **template** when… | |---|---| | You just want to tuck members away into a single tidy pill | You'll use this subgraph in more than one place | | You'll edit the members in place (expand → tweak → collapse) | You want a single source of truth — edit once, all instances follow | | You don't need to dive | You want navigable subgraphs with a breadcrumb | Both are first-class — convert either direction at any time. --- # Themes > Swap between Xen and Liquid Glass, override tokens, or build your own theme with a custom shader. A `XenolithTheme` bundles three things: ```ts interface XenolithTheme { id: string tokens: XenTokens // design tokens paletteStyle?: PaletteStyle // DOM styling for palette / menus / overlay needsBackdrop?: boolean // opt into per-frame backdrop RT renderNode?: (node, opts, ctx) => NodeView // custom node rendering renderReroute?: (node, opts, ctx) => NodeView // custom inline reroute-knot rendering renderRerouteNode?: (node, opts, ctx) => NodeView // custom pullable Reroute-node rendering drawEdge?: (g, from, to, opts) => Graphics // custom wire rendering createGrid?: () => Container // custom canvas backdrop } ``` Each hook is optional. When omitted, the editor falls back to Xen's built-in renderer for that element — so a "theme" can be anything from a 5-line palette tweak to a fully custom Mesh + GLSL material. `renderReroute` styles the inline `$reroute` knot (a wire passing through a dot); `renderRerouteNode` styles the pullable rectangular `Reroute` node. Liquid Glass overrides both with glass discs/boxes tinted by the wire's pin type. `paletteStyle` drives the DOM chrome — the insert palette, the edge context menu, **and** the "Rendering…" busy overlay — so they all restyle with the active theme. ## Built-in themes Two themes ship in the box. ### Xen — default ```ts import { XenolithEditor } from '@xenolithengine/graph-editor' const editor = await XenolithEditor.init('#app') // Xen is the default — no `theme` option needed. ``` Original dark/gold design system: `#0F110E` node bodies, gold accents, category-tinted headers, typed-pin colour-coded wires, animated collapse to pill. Lives in `@xenolithengine/graph-theme-xen`. ### Liquid Glass — frosted glass inspired by new Apple design ```ts import { XenolithEditor } from '@xenolithengine/graph-editor' import { liquidGlassTheme } from '@xenolithengine/graph-theme-liquid-glass' const editor = await XenolithEditor.init('#app', { theme: liquidGlassTheme }) ``` Custom PIXI v8 Mesh + GLSL material. Each frame the editor renders the world (minus the nodes layer) into a backdrop `RenderTexture`; every glass body samples that texture through its shader with edge-localised refraction, gaussian blur, and a vertical tint. Sits over a radial-gradient navy canvas with a soft dot grid. The backdrop pass is **opt-in**: Liquid Glass sets `needsBackdrop: true`, Xen does not — so swapping back to Xen turns the extra render pass off entirely. ## Runtime theme switching ```ts import { xenTheme } from '@xenolithengine/graph-render-pixi' import { liquidGlassTheme } from '@xenolithengine/graph-theme-liquid-glass' editor.setTheme(liquidGlassTheme) // every node re-rendered through the new theme editor.setTheme(xenTheme) // selection, hover, collapsed state, positions preserved ``` `setTheme` accepts either a full `XenolithTheme` **or** a `DeepPartial` token override (which gets deep-merged into the active theme's tokens). ## Token overrides If you just want to tweak Xen's palette, pass an override in `init`: ```ts const editor = await XenolithEditor.init('#app', { theme: { category: { logic: { accent: '#3FB8FF' }, data: { accent: '#FF66C4' }, macro: { accent: '#FFB347' }, utility: { accent: '#A5F3C5' }, }, pinType: { float: { color: '#3FB8FF', edgeColor: '#3FB8FF' }, object: { color: '#FF66C4', edgeColor: '#FF66C4' }, string: { color: '#FFB347', edgeColor: '#FFB347' }, }, }, }) ``` Every other token (geometry, typography, effects, surfaces) stays at the Xen defaults. ## Category colours in the graph data (data-first) Token overrides change a category colour **in code**, which can't travel inside a saved graph. To colour your own categories (`agent`, `warehouse`, …) — or a single node — straight from the `xenolith.v1` data, use the optional `categories` palette and per-node `render.color`. Both round-trip through `editor.toJSON()` / `loadJSON()`. ```ts editor.loadJSON({ version: 'xenolith.v1', // graph-owned palette: a solid accent, or an explicit header gradient categories: { agent: { color: '#7C5CFF' }, warehouse: { gradient: { start: '#1FB6A8', end: '#0E5C55' } }, }, nodes: [ { id: 'a1', type: 'Planner', position: { x: 0, y: 0 }, pins: [], render: { category: 'agent', title: 'Planner' } }, // per-node colour beats the category { id: 'a2', type: 'Boss', position: { x: 240, y: 0 }, pins: [], render: { category: 'agent', title: 'Boss', color: '#FF3366' } }, ], edges: [], }) ``` Resolution order for a node's header colour: **`render.color` → `categories[category]` → theme `category` token → `utility` fallback**. Both fields are optional — graphs without them render exactly as before, using the theme's built-in categories. Types `CategoryColorSpec` / `GraphCategoryPalette` are exported from `@xenolithengine/graph-render-pixi`. Use **token overrides** to restyle the whole visual language; use the **`categories` palette** to colour domain categories that live in your data. ## The full token shape The full type lives in `@xenolithengine/graph-theme-xen` and is `XenTokens`. Top-level groups: | Group | Purpose | |----------------|--------------------------------------------------------------------------| | `color.brand` | Brand / hover / accent yellows and their alphas. | | `color.surface`| Canvas, node body, panel, divider, header-end gradient stop. | | `color.text` | Primary, secondary, muted, disabled text colours. | | `pinType` | One entry per pin type: `color`, `edgeColor`, `shape`, `edgeWidth`. | | `category` | Header accent + Figma gradient per category (`logic`, `data`, `macro`, `utility`). | | `pill` | Pill-form accent gradient CSS strings (`green`, `orange`, ...). | | `state` | `hover`, `selected`, `active`, `disabled` — border + glow tokens. | | `geometry` | Node, pin, header, edge, comment, **reroute** (`radius`, `ringWidth`) and edge `midpointRadius` dimensions. | | `typography` | Inter family + heading / label / comment styles. | | `effect` | Header inner shadow, backdrop blur, rim highlight, drop shadow. | | `background` | Canvas colour and dot-grid configuration. | ## Building your own theme For a palette-only theme, an object literal is enough: ```ts import type { XenolithTheme } from '@xenolithengine/graph-render-pixi' import { xenTokens } from '@xenolithengine/graph-theme-xen' import { mergeTheme } from '@xenolithengine/graph-theme-xen' export const pastelTheme: XenolithTheme = { id: 'pastel', tokens: mergeTheme(xenTokens, { color: { surface: { canvas: '#FAF7F2', node: '#FFFFFF' }, text: { primary: '#1A1A1A', secondary: '#666666' }, }, background: { color: '#FAF7F2', grid: { color: 'rgba(0,0,0,0.06)' } }, category: { logic: { accent: '#7FB8A1' }, data: { accent: '#7FA8C7' }, macro: { accent: '#C77F9F' }, utility: { accent: '#444444' }, }, }), } ``` For a custom material (your own shader, your own backdrop), implement `renderNode` and optionally `createGrid`. See `@xenolithengine/graph-theme-liquid-glass`'s source for a complete worked example — PIXI Mesh + GLSL with SDF refraction and backdrop sampling. ## Fonts Themes **declare** what font families they need; the editor loads them. A theme exposes a `fonts: FontSpec[]` field — `FontSpec` is `{ family: string; weights?: number[]; styles?: ('normal' | 'italic')[] }`. ```ts import type { XenolithTheme } from '@xenolithengine/graph-render-pixi' export const myTheme: XenolithTheme = { id: 'mine', tokens: { /* ... */ }, fonts: [{ family: 'Inter', weights: [400, 600, 700] }], // ... } ``` By default, the editor fetches the listed families from the **Google Fonts CDN** (`fonts.gstatic.com`). Zero shipped binaries, zero bundler config — works in any host out of the box. ### Self-hosting (airgap, strict CSP, privacy) Pass per-`(family, weight, style?)` URLs to override the CDN. Keys follow the pattern `|` or `||