Bring your own UI
Four widgets that are real framework components (async-select, file drop, CodeMirror, sparkline), themed via --xeno-*.
import { useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, XenolithControls } from '@xenolithengine/graph-react'
import { xenTheme } from '@xenolithengine/graph-render-pixi'
import { liquidGlassTheme } from '@xenolithengine/graph-theme-liquid-glass'
import { DemoStage } from '../Layout.js'
import { reactWidget } from '@xenolithengine/graph-react'
import { AsyncSelect } from '../widgets/AsyncSelect.js'
import { FileDrop } from '../widgets/FileDrop.js'
import { CodeEditor } from '../widgets/CodeEditor.js'
import { Sparkline } from '../widgets/Sparkline.js'
const seedSpark = Array.from({ length: 16 }, (_, i) => 0.5 + 0.4 * Math.sin(i / 2))
const NODES = [
{ type: 'Pick', title: 'Pick fruit', renderer: 'async-select', key: 'fruit', val: 'Mango' as unknown, h: 34, x: 0, y: 0 },
{ type: 'Image', title: 'Image', renderer: 'file-drop', key: 'img', val: '', h: 120, x: 360, y: 0 },
{ type: 'Prompt', title: 'Prompt', renderer: 'code', key: 'json', val: '{\n "seed": 42\n}', h: 140, x: 0, y: 250 },
{ type: 'Signal', title: 'Signal', renderer: 'sparkline', key: 'data', val: seedSpark, h: 96, x: 360, y: 320 },
]
/** Island: four custom widgets that are real React components (server-search select, image drop,
* CodeMirror, sparkline). They style themselves with --xeno-* CSS vars, so flipping the theme from
* the in-editor panel restyles them for free — the whole point of the plugin ecosystem. */
export function CustomWidgetsDemo() {
const [theme, setTheme] = useState<'xen' | 'lg'>('xen')
return (
<DemoStage>
<XenolithGraph
className="xeno"
resizeToWindow={false}
theme={theme === 'xen' ? xenTheme : liquidGlassTheme}
onReady={(editor) => {
editor.registerWidget('async-select', reactWidget(AsyncSelect))
editor.registerWidget('file-drop', reactWidget(FileDrop))
editor.registerWidget('code', reactWidget(CodeEditor))
editor.registerWidget('sparkline', reactWidget(Sparkline))
for (const d of NODES) {
editor.registry.register({
type: d.type,
title: d.title,
pins: [{ kind: 'data', direction: 'out', type: 'any', label: 'Out' }],
widgets: [{ id: d.key, label: d.title, type: 'custom', renderer: d.renderer, key: d.key, height: d.h }],
})
const node = editor.registry.instantiate(d.type, { x: d.x, y: d.y })
node.state[d.key] = d.val
editor.addNode(node)
}
editor.view.fitView({ padding: 56, maxZoom: 1 })
}}
>
<XenolithControls position="top-right" orientation="horizontal" />
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 240 }}>
<div style={{ display: 'flex', gap: 8 }}>
<XenolithButton active={theme === 'xen'} onClick={() => setTheme('xen')}>Xen</XenolithButton>
<XenolithButton active={theme === 'lg'} onClick={() => setTheme('lg')}>Liquid Glass</XenolithButton>
</div>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.4 }}>
Widgets are React components styled with <code>var(--xeno-*)</code> — they restyle on theme change.
</span>
</XenolithPanel>
</XenolithGraph>
</DemoStage>
)
} import { useEffect, useState } from 'react'
import type { WidgetProps } from '@xenolithengine/graph-react'
// A select whose options come from an async "server" search — debounced, with a loading state.
const FRUITS = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry', 'Date', 'Fig', 'Grape', 'Kiwi', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear', 'Plum', 'Raspberry', 'Strawberry', 'Watermelon']
function fakeSearch(q: string): Promise<string[]> {
return new Promise((res) => setTimeout(() => res(FRUITS.filter((f) => f.toLowerCase().includes(q.toLowerCase())).slice(0, 6)), 350))
}
export function AsyncSelect({ value, setValue }: WidgetProps) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [opts, setOpts] = useState<string[]>([])
useEffect(() => {
if (!open) return
setLoading(true)
const t = setTimeout(() => { void fakeSearch(q).then((r) => { setOpts(r); setLoading(false) }) }, 250)
return () => clearTimeout(t)
}, [q, open])
return (
<div className="w-async">
<input
placeholder="Search fruit…"
value={open ? q : String(value ?? '')}
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => setQ(e.target.value)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
/>
{open && (
<div className="w-async-menu">
{loading ? <div className="w-async-item muted">Searching…</div>
: opts.length ? opts.map((o) => (
<div key={o} className="w-async-item" onMouseDown={() => { setValue(o); setOpen(false) }}>{o}</div>
))
: <div className="w-async-item muted">No matches</div>}
</div>
)}
</div>
)
} import type { WidgetProps } from '@xenolithengine/graph-react'
// Drag-and-drop (or browse) an image; reads it to a data URL and previews it inline.
export function FileDrop({ value, setValue }: WidgetProps) {
const onFile = (file?: File): void => {
if (!file) return
const r = new FileReader()
r.onload = () => setValue(r.result as string)
r.readAsDataURL(file)
}
const hasImg = typeof value === 'string' && value.startsWith('data:')
return (
<div className="w-drop" onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); onFile(e.dataTransfer.files[0]) }}>
{hasImg
? <img src={value as string} className="w-drop-img" alt="" />
: <label className="w-drop-empty">Drop image or <span>browse</span>
<input type="file" accept="image/*" hidden onChange={(e) => onFile(e.target.files?.[0])} />
</label>}
</div>
)
} import CodeMirror from '@uiw/react-codemirror'
import { json } from '@codemirror/lang-json'
import type { WidgetProps } from '@xenolithengine/graph-react'
// A real CodeMirror editor mounted inside a node — proof that any DOM-heavy component works.
export function CodeEditor({ value, setValue }: WidgetProps) {
return (
<div className="w-code">
<CodeMirror
value={String(value ?? '')}
theme="dark"
height="100%"
extensions={[json()]}
basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }}
onChange={(v) => setValue(v)}
/>
</div>
)
} import type { WidgetProps } from '@xenolithengine/graph-react'
// An SVG sparkline that reads its series from the widget value and reshuffles it on click —
// strokes with var(--xeno-accent) so it tracks the active theme.
export function Sparkline({ value, setValue }: WidgetProps) {
const data = Array.isArray(value) ? (value as number[]) : []
const pts = data.map((v, i) => `${(i / Math.max(1, data.length - 1)) * 100},${100 - v * 100}`).join(' ')
return (
<div className="w-spark">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="w-spark-svg">
<polyline points={pts} fill="none" stroke="var(--xeno-accent)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
</svg>
<button onMouseDown={(e) => e.preventDefault()} onClick={() => setValue(Array.from({ length: 16 }, () => Math.random()))}>
Shuffle
</button>
</div>
)
}