Shared Components¶
Lumiverse's first-party React components — model pickers, form atoms, searchable selects, pagination, collapsible sections — are exposed to extensions via ctx.components.*. The host renders the real component into a DOM node you control. You never need to depend on React, ship the component CSS, or replicate the look in plain HTML.
Mounted components automatically inherit the active Lumiverse theme (accent color, glass mode, dark/light, density), so they visually match the rest of the host UI without any styling work on your part.
import type { SpindleFrontendContext } from 'lumiverse-spindle-types'
export function setup(ctx: SpindleFrontendContext) {
const tab = ctx.ui.registerDrawerTab({ id: 'demo', title: 'Demo' })
const wrap = document.createElement('div')
wrap.style.cssText = 'display:flex;flex-direction:column;gap:12px;padding:12px'
tab.root.appendChild(wrap)
const labelEl = document.createElement('div')
wrap.appendChild(labelEl)
const modelSlot = document.createElement('div')
wrap.appendChild(modelSlot)
const enabledSlot = document.createElement('div')
wrap.appendChild(enabledSlot)
// Mount a model picker bound to the user's active LLM connection
const model = ctx.components.mountModelCombobox(modelSlot, {
value: '',
connection: { kind: 'llm' },
appearance: 'standard',
onChange: (m) => { labelEl.textContent = `Selected: ${m}` },
})
// Mount a switch
const toggle = ctx.components.mountSwitch(enabledSlot, {
checked: true,
onChange: (on) => ctx.sendToBackend({ type: 'set_enabled', value: on }),
})
return () => {
model.destroy()
toggle.destroy()
tab.destroy()
}
}
How mounting works¶
Every mountX(target, options) call:
- Resolves the target — pass either an
HTMLElementor a CSS selector string scoped to your extension's DOM. - Renders the real component into that element.
- Returns a handle with
update(),getValue(),destroy(), and component-specific helpers.
The handle is the only thing you need to keep alive. Mounted components are auto-destroyed when your extension is disabled or unloaded — but you should still call destroy() yourself when you reuse the target for something else, to avoid leaking memory.
Shared handle shape¶
Every handle implements SpindleMountedComponent<TOptions>:
| Method / Property | Returns | Description |
|---|---|---|
componentId |
string |
Host-assigned ID, unique per extension. Useful in logs. |
element |
HTMLElement |
The target element the component was mounted into. |
update(patch) |
void |
Merge a partial of the original options into the live component. Pass only the fields you want to change. |
destroy() |
void |
Unmount the React tree and release host resources. The target element is left in place. |
Form components add getValue() so you can read the current value at any time:
const handle = ctx.components.mountTextInput(target, { value: 'hello' })
console.log(handle.getValue()) // → 'hello'
handle.update({ value: 'world' }) // programmatic value change
handle.update({ disabled: true }) // any partial option works
Controlled vs. auto-controlled¶
Components are auto-controlled by the host. You supply an initial value and an onChange callback — the host owns internal state across user interactions. To force the value from your code, call handle.update({ value }). To read the current value, call handle.getValue().
You do not need to mirror state into your own variables and call update() on every change — that's already happening inside the host.
Component catalog¶
| Helper | What it mounts |
|---|---|
mountTextInput |
Single-line text field |
mountTextArea |
Multi-line text editor |
mountNumericInput |
Validated number input with optional empty state |
mountNumberStepper |
Number input with +/− buttons |
mountRangeSlider |
Touch-friendly slider with optional label, hint, and formatted value display |
mountCheckbox |
Standard checkbox with optional label and hint |
mountSwitch |
Toggle switch |
mountSelect |
Searchable single-select dropdown |
mountMultiSelect |
Searchable multi-select dropdown |
mountModelCombobox |
LLM/image/TTS model picker, connection-aware |
mountFolderDropdown |
Folder picker with inline "create folder" affordance |
mountBadge |
Inline status/label badge |
mountSpinner |
Loading spinner |
mountCollapsibleSection |
Titled, expandable container — see body slot |
mountPagination |
Page navigation with per-page selector |
mountCloseButton |
Themed X button |
Text inputs¶
const note = ctx.components.mountTextArea(target, {
value: '',
rows: 6,
placeholder: 'Notes…',
onChange: (text) => ctx.sendToBackend({ type: 'save_note', text }),
})
// Save on a hotkey from elsewhere in your extension
function flush() {
ctx.sendToBackend({ type: 'save_note', text: note.getValue() })
}
// Programmatically focus
note.focus()
SpindleTextInputOptions / SpindleTextAreaOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
value |
string |
"" |
Initial value |
onChange |
(v: string) => void |
— | Fired on every user change |
placeholder |
string |
— | Placeholder text |
autoFocus |
boolean |
false |
Focus on mount (text input only) |
rows |
number |
4 |
Visible rows (text area only) |
disabled |
boolean |
false |
Disable user interaction |
className |
string |
— | Additional CSS class |
ariaLabel |
string |
— | Accessible label |
Both handles expose getValue(), focus(), and blur() in addition to the shared methods.
Numeric inputs¶
const temperature = ctx.components.mountNumberStepper(target, {
value: 0.7,
min: 0,
max: 2,
step: 0.1,
onChange: (v) => ctx.sendToBackend({ type: 'set_temp', value: v }),
})
SpindleNumericInputOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
value |
number \| null |
null |
Initial value. null means empty. |
onChange |
(v: number \| null) => void |
— | Fired on every user change |
allowEmpty |
boolean |
false |
Allow null as a valid value |
integer |
boolean |
false |
Restrict to integers |
min / max |
number |
— | Bounds |
step |
number |
— | Native step size |
placeholder |
string |
— | Placeholder text |
disabled |
boolean |
false |
Disable user interaction |
SpindleNumberStepperOptions accepts the same fields except integer, and uses step: 1 by default.
Range slider¶
A touch-first slider with horizontal drag. Vertical scroll passes through the slider on mobile (the host does JS direction detection and only claims horizontal gestures), and mid-drag interruptions don't kill the gesture — the slider commits the last live value if the touch is cancelled after movement. The built-in header tracks the live drag value in real time; onCommit only fires once when the gesture ends.
const opacity = ctx.components.mountRangeSlider(target, {
label: 'Opacity',
min: 0,
max: 100,
step: 5,
integer: true,
value: 35,
format: { suffix: '%' },
onCommit: (v) => ctx.sendToBackend({ type: 'set_opacity', value: v / 100 }),
})
// Programmatically jump to a value
opacity.update({ value: 80 })
// Read the latest committed value
const current = opacity.getValue()
Omit label to render the bare track without a header — useful when you want to lay out your own label/value display alongside other content. In that case, use onDragValue to mirror the live value into your own UI:
const valueLabel = document.createElement('span')
container.appendChild(valueLabel)
ctx.components.mountRangeSlider(track, {
min: 0, max: 1, step: 0.05, value: 0.5,
onDragValue: (v) => { valueLabel.textContent = v === null ? '0.50' : v.toFixed(2) },
onCommit: (v) => { valueLabel.textContent = v.toFixed(2); ctx.sendToBackend({ type: 'set_strength', value: v }) },
})
SpindleRangeSliderOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
min |
number |
— | Required. Inclusive lower bound. |
max |
number |
— | Required. Inclusive upper bound. |
value |
number |
min |
Initial committed value. |
step |
number |
1 |
Snap increment. |
integer |
boolean |
false |
Round to integers regardless of step formatting. |
onCommit |
(v: number) => void |
— | Fired once when a drag ends or the user taps the track. Not fired during the drag. |
onDragValue |
(v: number \| null) => void |
— | Fired with the live value during a drag, and with null if the gesture ends without committing. |
label |
string |
— | If set, renders a header above the track with the label and live value. |
hint |
string |
— | Helper text under the header. Ignored if label is omitted. |
format |
SpindleRangeSliderFormat |
— | Declarative formatting for the value in the header (see below). Ignored if label is omitted. |
disabled |
boolean |
false |
Dim the track and ignore input. |
className |
string |
— | Additional CSS class merged onto the track area. |
SpindleRangeSliderFormat¶
| Field | Type | Description |
|---|---|---|
decimals |
number |
Number of decimal places to show. Defaults to whatever step implies (or 0 for integer sliders). |
prefix |
string |
Prepended before the value, e.g. "$". |
suffix |
string |
Appended after the value, e.g. "%" or "ms". |
SpindleRangeSliderHandle¶
In addition to the shared handle methods:
| Method | Returns | Description |
|---|---|---|
getValue() |
number |
The current committed value. |
Boolean inputs¶
const showAdvanced = ctx.components.mountCheckbox(target, {
checked: false,
label: 'Show advanced controls',
hint: 'Reveal sampler and routing options',
onChange: (on) => detailsRow.style.display = on ? 'block' : 'none',
})
SpindleCheckboxOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
checked |
boolean |
false |
Initial state |
onChange |
(checked: boolean) => void |
— | Fired on every toggle |
label |
string |
— | Label rendered next to the checkbox |
hint |
string |
— | Helper text rendered under the label |
disabled |
boolean |
false |
Disable user interaction |
SpindleSwitchOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
checked |
boolean |
false |
Initial state |
onChange |
(checked: boolean) => void |
— | Fired on every toggle |
size |
'sm' \| 'md' |
'md' |
Visual size |
disabled |
boolean |
false |
Disable user interaction |
ariaLabel |
string |
— | Accessible label |
Selects¶
mountSelect (single) and mountMultiSelect share the same option surface except for value/onChange types. Both support search, grouped options, themed dropdowns, leading cells (icons / avatars / swatches), and portal rendering.
const picker = ctx.components.mountSelect(target, {
value: '',
placeholder: 'Pick a tone…',
searchPlaceholder: 'Search tones…',
onChange: (v) => ctx.sendToBackend({ type: 'set_tone', value: v }),
options: [
{ value: 'casual', label: 'Casual', group: 'Conversational' },
{ value: 'witty', label: 'Witty', group: 'Conversational' },
{ value: 'formal', label: 'Formal', group: 'Professional' },
{ value: 'dry', label: 'Dry', group: 'Professional' },
],
})
SpindleSelectOption¶
| Field | Type | Default | Description |
|---|---|---|---|
value |
string |
required | Stable value emitted to onChange |
label |
string |
required | Display label |
sublabel |
string |
— | Secondary text rendered beneath the label |
group |
string |
— | Group key. Options sharing a group cluster under a shared header. |
leading |
SpindleSelectOptionLeading |
— | Leading-cell content (avatar / icon / swatch / initial). See below. |
disabled |
boolean |
false |
Render as disabled |
SpindleSelectOptionsBase (shared by single & multi)¶
| Option | Type | Default | Description |
|---|---|---|---|
options |
SpindleSelectOption[] |
[] |
Available choices |
placeholder |
string |
— | Placeholder text when no value is selected |
searchPlaceholder |
string |
— | Placeholder for the search input |
searchThreshold |
number |
8 |
Minimum option count before the search input is shown |
emptyMessage |
string |
— | Message when no options were supplied |
noResultsMessage |
string |
— | Message when the search query has no matches |
triggerLabel |
string |
— | Force a trigger label (e.g. "+ Add"), ignoring current selection |
triggerIcon |
SpindleSelectOptionLeading |
— | Custom icon shown on the trigger |
triggerClassName |
string |
— | Additional CSS class on the trigger button |
ariaLabel |
string |
— | Accessible label for the trigger |
portal |
boolean |
true |
Render the dropdown into document.body so it escapes overflow:hidden ancestors |
align |
'left' \| 'right' |
'left' |
Dropdown horizontal alignment relative to the trigger |
maxHeight |
number |
— | Maximum dropdown height in CSS pixels |
minWidth |
number |
— | Minimum dropdown width in CSS pixels |
disabled |
boolean |
false |
Disable interaction |
className |
string |
— | Additional CSS class on the wrapper |
Single-select also accepts:
| Option | Type | Default | Description |
|---|---|---|---|
clearable |
boolean |
false |
Show a pinned "None" option at the top of the dropdown that emits onChange("") |
clearLabel |
string |
"None" |
Label for the clear option |
Both handles expose getValue(), open(), and close() in addition to the shared methods.
Leading cells: avatars, icons, swatches, initials¶
SpindleSelectOptionLeading is a declarative union for the small slot rendered before each option label (and on the trigger when an option is selected). Use it for persona avatars, provider brand marks, category swatches, or letter bubbles. Extensions describe what they want — no React, no <img> tags, no styling required.
The same pattern used by Lumiverse's own persona picker. If the image is missing or fails to load, the host falls back to the supplied initial bubble.
leading: {
type: 'image',
src: persona.avatarUrl,
rounded: true,
fallback: {
text: persona.name.charAt(0).toUpperCase(),
background: 'var(--lumiverse-fill-subtle)',
},
}
Best for brand marks or chevron-like glyphs. The SVG is host-sanitized before insertion.
leading: {
type: 'icon-svg',
svg: '<svg viewBox="0 0 16 16">...</svg>',
color: 'var(--lumiverse-accent)',
}
Same as image, but without the avatar-style rounded fallback semantics.
leading: { type: 'icon-url', url: '/icons/openai.png' }
Small filled circle for category tags and palette pickers.
leading: { type: 'swatch', color: '#7e57c2' }
Plain text bubble — works well with single characters or short labels.
leading: {
type: 'initial',
text: 'A',
background: '#3a3a3a',
color: '#fff',
}
Full example: persona picker¶
ctx.components.mountSelect(target, {
value: currentPersonaId,
triggerLabel: currentPersonaId ? undefined : '+ Choose persona',
searchPlaceholder: 'Search personas…',
noResultsMessage: 'No personas match your search.',
clearable: true,
clearLabel: 'Unassign',
options: personas.map((p) => ({
value: p.id,
label: p.name,
sublabel: p.title,
leading: p.avatarUrl
? {
type: 'image',
src: p.avatarUrl,
fallback: { text: p.name.charAt(0).toUpperCase() },
}
: { type: 'initial', text: p.name.charAt(0).toUpperCase() },
})),
onChange: (id) => ctx.sendToBackend({ type: 'set_persona', personaId: id || null }),
})
Model combobox¶
mountModelCombobox is the same picker Lumiverse uses for selecting LLM, image-gen, and TTS models. It has two operating modes.
Connection-bound mode (recommended)¶
Supply a connection reference and the host wires up everything for you — the model list, the refresh affordance, the loading spinner, and live updates when the user switches their active connection.
ctx.components.mountModelCombobox(target, {
value: '',
// Follow the user's currently-active LLM connection
connection: { kind: 'llm' },
appearance: 'standard',
onChange: (model) => ctx.sendToBackend({ type: 'set_model', model }),
})
To pin to a specific connection instead of the active one:
connection: { kind: 'image', id: profileId }
Supported kind values:
| Kind | Source | Active connection follows |
|---|---|---|
'llm' |
LLM connection profiles | activeProfileId |
'image' |
Image-gen connection profiles | activeImageGenConnectionId |
'tts' |
TTS connection profiles + their voice catalogs | voiceSettings.ttsConnectionId |
'embedding' |
Not yet supported in connection-bound mode — use manual mode. | — |
The host fetches via Lumiverse's authenticated API on your behalf, so you don't need cors_proxy or any backend wiring for the model list. Call handle.refresh() to force a re-fetch (e.g. after the user adds a new model in your extension's settings).
Manual mode¶
Supply models, loading, and onRefresh yourself when you maintain the catalog. Useful for OpenRouter-style listings, custom proxies, or filtered subsets.
let models: string[] = []
let loading = false
const picker = ctx.components.mountModelCombobox(target, {
value: '',
models,
loading,
onRefresh: () => {
loading = true
picker.update({ loading: true })
fetchModelsFromBackend().then((next) => {
loading = false
picker.update({ models: next, loading: false })
})
},
onChange: (m) => ctx.sendToBackend({ type: 'set_model', model: m }),
})
If both connection and models are supplied, connection wins and the manual fields are ignored.
SpindleModelComboboxOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
value |
string |
"" |
Currently entered model ID |
onChange |
(v: string) => void |
— | Fired on every change |
connection |
{ kind, id? } |
— | Bind to a host-managed connection. See above. |
models |
string[] |
[] |
Manual mode: explicit model list |
modelLabels |
Record<string, string> |
{} |
Manual mode: ID → human label |
loading |
boolean |
false |
Manual mode: show the spinner in the refresh affordance |
onRefresh |
() => void |
— | Manual mode: invoked when the user clicks refresh |
autoRefreshOnFocus |
boolean |
false |
Auto-refresh once the first time the input gains focus |
refreshKey |
string |
— | Opaque key — when it changes, re-arms autoRefreshOnFocus |
appearance |
'compact' \| 'standard' \| 'editor' |
'compact' |
Visual density to match the surrounding form |
placeholder |
string |
"gpt-4o" |
Placeholder text |
emptyMessage |
string |
— | Message when the list is empty |
loadingMessage |
string |
"Loading models..." |
Message shown in the dropdown while loading |
browseHint |
string |
— | Optional hint shown beneath the input |
disabled |
boolean |
false |
Disable interaction |
The handle exposes refresh() in addition to getValue() and the shared methods.
Folder dropdown¶
ctx.components.mountFolderDropdown(target, {
folders: ['Personal', 'Work', 'Drafts'],
value: 'Personal',
placeholder: 'No folder',
onChange: (folder) => ctx.sendToBackend({ type: 'move_to', folder }),
onCreateFolder: (name) => ctx.sendToBackend({ type: 'create_folder', name }),
})
SpindleFolderDropdownOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
folders |
string[] |
[] |
Available folder names |
value |
string |
"" |
Currently selected folder |
onChange |
(folder: string) => void |
— | Fired when the user picks a folder |
onCreateFolder |
(name: string) => void |
— | Fired when the user creates a new folder inline |
placeholder |
string |
— | Placeholder shown when no folder is selected |
disabled |
boolean |
false |
Disable interaction |
Collapsible sections (with host-managed chrome)¶
Unlike other components, mountCollapsibleSection gives you back a body element — the host renders the header/chevron chrome, and you fully own the body contents. Append your own elements to handle.body exactly as you would to a tab.root or panel.root.
const section = ctx.components.mountCollapsibleSection(target, {
title: 'Advanced',
iconSvg: '<svg>...</svg>',
badge: '3',
defaultExpanded: false,
onToggle: (open) => console.log('section is now', open ? 'open' : 'closed'),
})
// Build whatever you want into the body
section.body.innerHTML = '<p>Body content owned by the extension.</p>'
// Imperative controls
section.expand()
section.collapse()
section.toggle()
console.log(section.isExpanded())
SpindleCollapsibleSectionOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
title |
string |
required | Header text |
iconSvg |
string |
— | Inline SVG icon shown next to the title |
iconUrl |
string |
— | Icon image URL. Mutually exclusive with iconSvg. |
badge |
string \| number |
— | Optional badge text rendered next to the title |
defaultExpanded |
boolean |
true |
Initial expanded state |
onToggle |
(expanded: boolean) => void |
— | Fired whenever the user toggles the section |
SpindleCollapsibleSectionHandle¶
Adds to the shared shape:
| Method / Property | Returns | Description |
|---|---|---|
body |
HTMLElement |
Container the extension owns. Append your own child elements here. |
isExpanded() |
boolean |
Current expanded state |
expand() |
void |
Open the section |
collapse() |
void |
Close the section |
toggle() |
void |
Flip the section |
Display utilities¶
const status = ctx.components.mountBadge(target, { text: 'Beta', color: 'warning', size: 'pill' })
status.update({ color: 'success', text: 'Ready' })
const spinner = ctx.components.mountSpinner(target, { size: 16 })
spinner.destroy()
const closeBtn = ctx.components.mountCloseButton(target, {
onClick: () => panel.collapse(),
size: 'sm',
variant: 'subtle',
})
SpindleBadgeOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
text |
string |
"" |
Badge text |
color |
'neutral' \| 'primary' \| 'success' \| 'warning' \| 'danger' \| 'info' |
'neutral' |
Accent color |
size |
'sm' \| 'md' \| 'pill' |
'md' |
Visual size |
SpindleSpinnerOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
size |
number |
16 |
Diameter in CSS pixels |
fast |
boolean |
false |
Use the faster rotation variant |
SpindleCloseButtonOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
onClick |
() => void |
— | Click handler |
size |
'sm' \| 'md' |
'md' |
Visual size |
variant |
'subtle' \| 'solid' |
'subtle' |
Visual variant |
position |
'static' \| 'absolute' |
'static' |
Positioning behavior |
iconSize |
number |
— | Icon size override in CSS pixels |
Pagination¶
let page = 1
const pager = ctx.components.mountPagination(target, {
currentPage: page,
totalPages: 12,
totalItems: 234,
perPage: 20,
perPageOptions: [10, 20, 50],
onPageChange: (p) => {
page = p
pager.update({ currentPage: p })
ctx.sendToBackend({ type: 'list_page', page: p })
},
onPerPageChange: (n) => ctx.sendToBackend({ type: 'set_page_size', size: n }),
})
SpindlePaginationOptions¶
| Option | Type | Default | Description |
|---|---|---|---|
currentPage |
number |
required | Current page index (1-based) |
totalPages |
number |
required | Total page count |
onPageChange |
(p: number) => void |
required | Fired when the user clicks a page |
perPage |
number |
— | Current per-page selection (omit to hide the selector) |
perPageOptions |
number[] |
— | Page-size choices |
onPerPageChange |
(n: number) => void |
— | Fired when the user changes per-page |
totalItems |
number |
— | Total item count for the "Showing X–Y of N" summary |
Pagination is fully controlled — call pager.update({ currentPage: next }) after each navigation to keep the UI in sync.
Lifecycle & cleanup¶
| Event | What the host does |
|---|---|
You call handle.destroy() |
The React tree is unmounted. The target element remains in place. |
You replace the target's contents (e.g. el.innerHTML = '') |
Don't. Destroy the handle first. Replacing the DOM under React's feet leaks memory. |
| Your extension is disabled / unloaded | All mounted components are destroyed automatically as part of cleanup, alongside drawer tabs, dock panels, and other placements. |
| The extension reloads (dev/manifest change) | New mounts are created against fresh targets. Stale handles from the previous load are unmounted by the host. |
Pairing with placement APIs
Shared components are designed to live inside extension-owned containers — drawer tab roots, dock panel roots, app mount roots, modal bodies, float widget roots. Build your container with ctx.dom.createElement(), append it to the placement root, then mount a shared component into it.
Theming & styling¶
You generally do not need to style mounted components. They consume the same CSS variables as Lumiverse's own UI:
- Accent colors via
--lumiverse-accentand friends - Surface/border/text colors via
--lumiverse-fill-subtle,--lumiverse-border,--lumiverse-text,--lumiverse-text-dim - Radius via
--lumiverse-radius
If you need to tweak spacing or alignment around a mounted component, do it on the wrapper element you own (the target you passed to mount*). Avoid trying to override component internals with !important rules — those internals are not part of the public contract and can change.
Capacity limits¶
Mounting shared components has no per-extension cap — they are scoped to whatever container you already own (a drawer tab, a dock panel, a modal body, an app mount), and those placements already enforce their own capacity limits.