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.
Shared components are inert by default: they add no DOM or UI until an extension explicitly mounts one through ctx.components.* into an extension-owned target under a registered placement root.
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. Selectors are evaluated across the current extension's live registered placement roots, including roots temporarily detached while their tab or panel is inactive; the selector must resolve exactly one target. AnHTMLElementin a newly constructed, unowned detached subtree is accepted provisionally so you can mount a component before appending its containing card or panel. That subtree must be appended under one of your live registered roots synchronously, before the current JavaScript task completes, or the host destroys the mount. - Renders the real component into that element.
- Returns a handle with
update(),destroy(), and component-specific helpers such asgetValue()where documented.
The handle is the only thing you need to keep alive. A registered root may be temporarily detached without losing mounted component state; any body-ported component UI is hidden and made inert until the root is attached again. Removing or reparenting the target, or unregistering its placement root, destroys the mount. The host also destroys mounts 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; omitted fields remain unchanged. Component-specific option semantics apply; use the documented option shape and do not rely on unknown fields or undefined being rejected. |
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.
onChange ordering and detached snapshots¶
For form components, the host commits the next value before invoking onChange, so handle.getValue() inside the callback already returns that next value. If the callback calls handle.update({ value }), that programmatic update is applied afterward and wins. Callback return values — including false or a thenable that resolves to false — are notifications only and never roll state back. Synchronous throws and rejected thenables are logged without undoing the committed value. Values passed to callbacks and returned by getValue() are detached snapshots; mutating a mutable array (for example, a multiselect value) or a Loom editor snapshot cannot mutate host state.
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 |
mountLoomBlockEditor |
Native Loom prompt-block editor with validated, extension-owned state |
Loom block editor¶
ctx.components.mountLoomBlockEditor(target, options) mounts the native Loom block editor into an extension-owned element. It is permission-free, but the target must still follow the same ownership rules as every shared component. A registered placement root may be temporarily detached while its tab or panel is inactive. A new unowned detached subtree may be mounted provisionally only when it is synchronously appended under a live registered root; an arbitrary detached element with only an ownership attribute is not a valid target.
The public value is deliberately closed:
interface SpindleLoomBlockEditorValue {
blocks: PromptBlockDTO[]
promptVariableValues: PromptVariableValuesDTO
}
interface SpindleLoomBlockEditorOptions {
value: SpindleLoomBlockEditorValue
onChange?: (value: SpindleLoomBlockEditorValue) => void
readOnly?: boolean
compact?: boolean
}
value is required. readOnly defaults to false; compact defaults to true (matching the native preset editor's space-efficient layout in extension panels). readOnly disables user edits while still allowing a programmatic update(). Unknown own keys in the value, a block, or the options are rejected. The six host-owned sealed/provenance fields — sealed, sealedKey, sealedSource, sealedOriginPresetId, sealedOriginVersion, and sealedSha256 — are intentionally not part of the public PromptBlockDTO boundary and must not be supplied by an extension. Main keeps those fields in its trusted projection and merges accepted public edits back without exposing or replacing them.
Mounting validates and deep-clones the complete value before rendering. onChange receives a detached clone, and getValue() returns a detached clone, so mutating either snapshot cannot mutate host state. update({ value }) validates and deep-clones the replacement before committing it; an invalid value or option patch is rejected atomically rather than partially applied.
Loom value limits¶
The host applies these limits before cloning or rendering:
| Limit | Maximum |
|---|---|
| Object/array nesting depth | 16 |
| Visited values/nodes | 16,384 |
| Aggregate object properties and array entries | 65,536 |
| One string | 65,536 JavaScript string units |
| All strings combined | 4 MiB after UTF-8 encoding |
| Blocks | 512 |
| Prompt variables per block | 512 |
| Options per select or multiselect variable | 256 |
| Prompt-variable block buckets | 512 |
| Prompt-variable values per bucket | 256 |
Preflight also rejects cyclic graphs, accessors, sparse arrays, and custom object prototypes. Limits apply to both the initial value and later update({ value }) calls; exceeding one rejects the whole operation without changing the current editor value.
maxNodes counts every value visited during preflight, including primitive values, not only object and array nodes.
const editor = ctx.components.mountLoomBlockEditor(slot, {
value: { blocks: initialBlocks, promptVariableValues: {} },
compact: true,
onChange: (next) => {
console.log('Loom draft changed', next.blocks.length)
},
})
editor.update({ readOnly: true })
const snapshot = editor.getValue()
await editor.refreshMacros()
editor.destroy()
The editor's macro picker is also intentionally scoped. It displays only core-public macros and macros owned by the current extension; definitions owned by other extensions are filtered out. refreshMacros() refreshes that filtered catalog and serializes refreshes. The filtered catalog is independently bounded to 128 categories, 256 macros per category, 32 arguments per macro, 8,192 nodes, 32,768 entries, a nesting depth of 16, 65,536 units per string, and 4 MiB of UTF-8 string data. A public mount has no contextual preview adapter: it does not resolve macros against a chat, character, persona, or active connection, does not issue preview/generation requests, and does not render the Main-only contextual preview controls.
Loom editor lifecycle¶
destroy()is idempotent. It unmounts the React tree and releases component/portal resources, but leaves the extension-owned target element in the DOM.- The host also destroys Loom mounts when the extension is disabled, unloaded, updated, or reloaded; when the relevant permission or placement root is revoked; or when the owned root is removed. A stale handle never remounts or resurrects the component.
- After teardown,
update()andgetValue()throwCOMPONENT_DESTROYED, andrefreshMacros()returns a rejected promise with the same lifecycle error. A refresh that was already in flight ignores any late catalog settlement after teardown.
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. Extension bridges opt into this default; set false to keep the dropdown inline. |
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, and any owned body portals are removed. |
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. |
| A registered placement root is temporarily detached | Mounted state is retained. Any owned body portals are hidden with an author-level display: none rule and inert while the root is detached; outside-pointer, scroll, resize, and reposition effects are suspended without closing or clearing the select. Reattachment restores the prior hidden, inert, and inline style state, then resumes positioning. |
| 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.