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>
)
} <script setup lang="ts">
import { ref } from 'vue'
import { XenolithGraph, XenolithControls, XenolithPanel, XenolithButton, vueWidget } from '@xenolithengine/graph-vue'
import { xenTheme } from '@xenolithengine/graph-render-pixi'
import { liquidGlassTheme } from '@xenolithengine/graph-theme-liquid-glass'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import AsyncSelect from './AsyncSelect.vue'
import FileDrop from './FileDrop.vue'
import CodeEditor from './CodeEditor.vue'
import Sparkline from './Sparkline.vue'
const theme = ref<'xen' | 'lg'>('xen')
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 },
]
function setup(editor: XenolithEditor): void {
editor.registerWidget('async-select', vueWidget(AsyncSelect))
editor.registerWidget('file-drop', vueWidget(FileDrop))
editor.registerWidget('code', vueWidget(CodeEditor))
editor.registerWidget('sparkline', vueWidget(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 })
}
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph
class="xeno"
:resize-to-window="false"
:theme="theme === 'xen' ? xenTheme : liquidGlassTheme"
@ready="setup"
>
<XenolithControls position="top-right" orientation="horizontal" />
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:240px;">
<div style="display:flex;gap:8px;">
<XenolithButton :active="theme === 'xen'" @click="theme = 'xen'">Xen</XenolithButton>
<XenolithButton :active="theme === 'lg'" @click="theme = 'lg'">Liquid Glass</XenolithButton>
</div>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Widgets are Vue components styled with <code>var(--xeno-*)</code> — they restyle on theme change.
</span>
</div>
</XenolithPanel>
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { ref, watch } from 'vue'
import type { WidgetProps } from '@xenolithengine/graph-vue'
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))
}
const props = defineProps<WidgetProps>()
const q = ref('')
const open = ref(false)
const loading = ref(false)
const opts = ref<string[]>([])
let timer: ReturnType<typeof setTimeout> | undefined
watch([q, open], () => {
clearTimeout(timer)
if (!open.value) return
loading.value = true
timer = setTimeout(() => {
void fakeSearch(q.value).then((rows) => { opts.value = rows; loading.value = false })
}, 250)
})
function focus(): void { open.value = true; q.value = '' }
function blur(): void { setTimeout(() => { open.value = false }, 150) }
function pick(name: string): void { props.setValue(name); open.value = false }
function onQuery(event: Event): void { q.value = (event.target as HTMLInputElement).value }
</script>
<template>
<div style="position:relative;width:100%;height:100%;">
<input
placeholder="Search fruit…"
:value="open ? q : String(value ?? '')"
style="width:100%;box-sizing:border-box;font:inherit;font-size:12px;padding:4px 6px;border-radius:6px;border:1px solid var(--xeno-border);background:var(--xeno-bg);color:var(--xeno-text);"
@focus="focus"
@blur="blur"
@input="onQuery"
/>
<div v-if="open" style="position:absolute;left:0;right:0;top:100%;z-index:5;margin-top:2px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:6px;overflow:hidden;">
<div v-if="loading" style="padding:4px 8px;font-size:12px;color:var(--xeno-muted);">Searching…</div>
<div v-else-if="opts.length === 0" style="padding:4px 8px;font-size:12px;color:var(--xeno-muted);">No matches</div>
<div
v-for="name in opts"
:key="name"
style="padding:4px 8px;font-size:12px;cursor:pointer;color:var(--xeno-text);"
@mousedown.prevent="pick(name)"
>{{ name }}</div>
</div>
</div>
</template> <script setup lang="ts">
import { computed } from 'vue'
import type { WidgetProps } from '@xenolithengine/graph-vue'
const props = defineProps<WidgetProps>()
const src = computed(() => (typeof props.value === 'string' && props.value.startsWith('data:') ? props.value : ''))
function onFile(file?: File): void {
if (!file) return
const reader = new FileReader()
reader.onload = () => props.setValue(reader.result as string)
reader.readAsDataURL(file)
}
function onPick(event: Event): void {
onFile((event.target as HTMLInputElement).files?.[0])
}
function onDrop(event: DragEvent): void {
onFile(event.dataTransfer?.files?.[0])
}
</script>
<template>
<div
style="width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px dashed var(--xeno-border);display:flex;align-items:center;justify-content:center;"
@dragover.prevent
@drop.prevent="onDrop"
>
<img v-if="src" :src="src" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />
<label v-else style="color:var(--xeno-muted);font-size:12px;cursor:pointer;">
Drop image or <span style="color:var(--xeno-accent);">browse</span>
<input type="file" accept="image/*" hidden @change="onPick" />
</label>
</div>
</template> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { json } from '@codemirror/lang-json'
import type { WidgetProps } from '@xenolithengine/graph-vue'
const props = defineProps<WidgetProps>()
const host = ref<HTMLDivElement | null>(null)
let view: EditorView | null = null
onMounted(() => {
if (!host.value) return
view = new EditorView({
parent: host.value,
state: EditorState.create({
doc: String(props.value ?? ''),
extensions: [
json(),
EditorView.updateListener.of((update) => {
if (update.docChanged) props.setValue(update.state.doc.toString())
}),
EditorView.theme({
'&': { height: '100%', background: 'transparent', fontSize: '11px' },
'.cm-content': { fontFamily: 'ui-monospace, monospace', color: 'var(--xeno-text)' },
'.cm-scroller': { overflow: 'auto' },
'&.cm-focused': { outline: 'none' },
}),
],
}),
})
})
onUnmounted(() => { view?.destroy(); view = null })
</script>
<template>
<div ref="host" style="width:100%;height:100%;overflow:hidden;"></div>
</template> <script setup lang="ts">
import { computed } from 'vue'
import type { WidgetProps } from '@xenolithengine/graph-vue'
const props = defineProps<WidgetProps>()
const points = computed(() => {
const data = Array.isArray(props.value) ? props.value as number[] : []
return data.map((v, i) => `${(i / Math.max(1, data.length - 1)) * 100},${100 - v * 100}`).join(' ')
})
function shuffle(): void {
props.setValue(Array.from({ length: 16 }, () => Math.random()))
}
</script>
<template>
<div style="position:relative;width:100%;height:100%;">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block;">
<polyline :points="points" fill="none" stroke="var(--xeno-accent)" stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
<button
type="button"
style="position:absolute;right:4px;bottom:4px;font:inherit;font-size:10px;padding:2px 6px;border-radius:6px;border:1px solid var(--xeno-border);background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;"
@mousedown.prevent
@click="shuffle"
>Shuffle</button>
</div>
</template> <script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton, XenolithControls, svelteWidget } from '@xenolithengine/graph-svelte/components'
import { xenTheme } from '@xenolithengine/graph-render-pixi'
import { liquidGlassTheme } from '@xenolithengine/graph-theme-liquid-glass'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import AsyncSelect from './AsyncSelect.svelte'
import FileDrop from './FileDrop.svelte'
import CodeEditor from './CodeEditor.svelte'
import Sparkline from './Sparkline.svelte'
const panelEditor = createXenolithEditorContext()
let theme = $state<'xen' | 'lg'>('xen')
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 },
]
const widgets = {
'async-select': svelteWidget(AsyncSelect),
'file-drop': svelteWidget(FileDrop),
'code': svelteWidget(CodeEditor),
'sparkline': svelteWidget(Sparkline),
}
function setup(editor: XenolithEditor): void {
panelEditor.set(editor)
for (const [name, controller] of Object.entries(widgets)) editor.registerWidget(name, controller)
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 })
}
</script>
<div
use:xenolith={{ resizeToWindow: false, theme: theme === 'xen' ? xenTheme : liquidGlassTheme }}
onready={(e) => setup(e.detail)}
style="position:absolute;inset:0"
></div>
<XenolithControls position="top-right" orientation="horizontal" />
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:240px;">
<div style="display:flex;gap:8px;">
<XenolithButton active={theme === 'xen'} onclick={() => { theme = 'xen' }}>Xen</XenolithButton>
<XenolithButton active={theme === 'lg'} onclick={() => { theme = 'lg' }}>Liquid Glass</XenolithButton>
</div>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Widgets are Svelte components styled with <code>var(--xeno-*)</code> — they restyle on theme change.
</span>
</div>
</XenolithPanel> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
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))
}
let { value, setValue }: WidgetProps = $props()
let q = $state('')
let open = $state(false)
let loading = $state(false)
let opts = $state<string[]>([])
let timer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const query = q
const isOpen = open
clearTimeout(timer)
if (!isOpen) return
loading = true
timer = setTimeout(() => { void fakeSearch(query).then((rows) => { opts = rows; loading = false }) }, 250)
return () => clearTimeout(timer)
})
</script>
<div style="position:relative;width:100%;height:100%;">
<input
placeholder="Search fruit…"
value={open ? q : String(value ?? '')}
style="width:100%;box-sizing:border-box;font:inherit;font-size:12px;padding:4px 6px;border-radius:6px;border:1px solid var(--xeno-border);background:var(--xeno-bg);color:var(--xeno-text);"
onfocus={() => { open = true; q = '' }}
onblur={() => setTimeout(() => { open = false }, 150)}
oninput={(e) => { q = (e.target as HTMLInputElement).value }}
/>
{#if open}
<div style="position:absolute;left:0;right:0;top:100%;z-index:5;margin-top:2px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:6px;overflow:hidden;">
{#if loading}
<div style="padding:4px 8px;font-size:12px;color:var(--xeno-muted);">Searching…</div>
{:else if opts.length === 0}
<div style="padding:4px 8px;font-size:12px;color:var(--xeno-muted);">No matches</div>
{:else}
{#each opts as name}
<div
style="padding:4px 8px;font-size:12px;cursor:pointer;color:var(--xeno-text);"
onmousedown={(e) => { e.preventDefault(); setValue(name); open = false }}
>{name}</div>
{/each}
{/if}
</div>
{/if}
</div> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value, setValue }: WidgetProps = $props()
const src = $derived(typeof value === 'string' && value.startsWith('data:') ? value : '')
function onFile(file?: File): void {
if (!file) return
const reader = new FileReader()
reader.onload = () => setValue(reader.result as string)
reader.readAsDataURL(file)
}
</script>
<div
style="width:100%;height:100%;border-radius:8px;overflow:hidden;background:rgba(0,0,0,0.25);border:1px dashed var(--xeno-border);display:flex;align-items:center;justify-content:center;"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => { e.preventDefault(); onFile(e.dataTransfer?.files?.[0]) }}
>
{#if src}
<img src={src} alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />
{:else}
<label style="color:var(--xeno-muted);font-size:12px;cursor:pointer;">
Drop image or <span style="color:var(--xeno-accent);">browse</span>
<input type="file" accept="image/*" hidden onchange={(e) => onFile((e.target as HTMLInputElement).files?.[0])} />
</label>
{/if}
</div> <script lang="ts">
import { onMount } from 'svelte'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { json } from '@codemirror/lang-json'
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value, setValue }: WidgetProps = $props()
let host: HTMLDivElement
let view: EditorView | null = null
onMount(() => {
view = new EditorView({
parent: host,
state: EditorState.create({
doc: String(value ?? ''),
extensions: [
json(),
EditorView.updateListener.of((update) => {
if (update.docChanged) setValue(update.state.doc.toString())
}),
EditorView.theme({
'&': { height: '100%', background: 'transparent', fontSize: '11px' },
'.cm-content': { fontFamily: 'ui-monospace, monospace', color: 'var(--xeno-text)' },
'.cm-scroller': { overflow: 'auto' },
'&.cm-focused': { outline: 'none' },
}),
],
}),
})
return () => { view?.destroy(); view = null }
})
</script>
<div bind:this={host} style="width:100%;height:100%;overflow:hidden;"></div> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value, setValue }: WidgetProps = $props()
const points = $derived((Array.isArray(value) ? value as number[] : [])
.map((v, i, data) => `${(i / Math.max(1, data.length - 1)) * 100},${100 - v * 100}`)
.join(' '))
function shuffle(): void {
setValue(Array.from({ length: 16 }, () => Math.random()))
}
</script>
<div style="position:relative;width:100%;height:100%;">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block;">
<polyline points={points} fill="none" stroke="var(--xeno-accent)" stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
<button
type="button"
style="position:absolute;right:4px;bottom:4px;font:inherit;font-size:10px;padding:2px 6px;border-radius:6px;border:1px solid var(--xeno-border);background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;"
onmousedown={(e) => e.preventDefault()}
onclick={shuffle}
>Shuffle</button>
</div>