Modal¶
Open a system-themed modal overlay on the user's frontend from the backend. Lumiverse renders the chrome — backdrop, header with title and close button, animations, Escape key handling — and displays structured content items in the body. The call blocks until the user closes the modal.
No permission is required. This is a free-tier utility like Text Editor and Toast Notifications.
For full DOM control over the modal body, use the frontend ctx.ui.showModal() API instead — see UI Placement.
Usage¶
const result = await spindle.modal.open({
title: 'Recent Nudges',
items: [
{ type: 'text', content: 'Here are the last few nudges sent to the user.' },
{ type: 'divider' },
{ type: 'card', items: [
{ type: 'text', content: 'Hey, I noticed you haven\'t been around lately...' },
{ type: 'key_value', label: 'Sent', value: '2 hours ago' },
]},
{ type: 'card', items: [
{ type: 'text', content: 'I\'ve been thinking about what you said earlier.' },
{ type: 'key_value', label: 'Sent', value: 'Yesterday' },
]},
],
})
if (result.dismissedBy === 'user') {
spindle.log.info('User closed the modal')
}
The returned Promise resolves when the modal is dismissed. It never rejects.
Minimal Call¶
const result = await spindle.modal.open({
title: 'Status',
items: [
{ type: 'text', content: 'All systems operational.' },
],
})
Options¶
| Field | Type | Default | Description |
|---|---|---|---|
title |
string |
required | Title displayed in the modal header |
items |
SpindleModalItemDTO[] |
required | Structured body content items rendered by the host (see below) |
width |
number |
420 |
Width in pixels. Clamped to viewport. |
maxHeight |
number |
520 |
Maximum height in pixels. Clamped to viewport. |
persistent |
boolean |
false |
When true, clicking the backdrop does not dismiss the modal. The user must use the close button. |
userId |
string |
— | Target user ID. Only needed for operator-scoped extensions. User-scoped extensions can omit this. |
Result¶
| Field | Type | Description |
|---|---|---|
dismissedBy |
'user' \| 'extension' \| 'cleanup' |
How the modal was dismissed. user = close button or backdrop click. extension = programmatic dismissal. cleanup = extension was disabled/unloaded. |
Content Items¶
The items array accepts a mix of content item types. The host renders them sequentially into the modal body using the system theme.
text¶
A block of text. Supports multiline content via newlines.
{ type: 'text', content: 'Hello, world!' }
{ type: 'text', content: 'Muted helper text', muted: true }
| Field | Type | Default | Description |
|---|---|---|---|
content |
string |
required | Text to display |
muted |
boolean |
false |
Render in the muted/dim text color |
heading¶
A section heading within the modal body.
{ type: 'heading', content: 'Configuration' }
| Field | Type | Description |
|---|---|---|
content |
string |
Heading text |
divider¶
A horizontal separator line. Takes no additional fields.
{ type: 'divider' }
key_value¶
A label-value pair displayed in a horizontal row — useful for metadata, timestamps, and stats.
{ type: 'key_value', label: 'Status', value: 'Active' }
{ type: 'key_value', label: 'Last Sent', value: '3 hours ago' }
| Field | Type | Description |
|---|---|---|
label |
string |
Left-aligned label text |
value |
string |
Right-aligned value text |
card¶
A themed container that groups child items. Cards render with a subtle background, border, and border radius matching the system theme. Useful for visually separating repeating entries like history items or list results.
{
type: 'card',
items: [
{ type: 'text', content: 'Hey, where did you go?' },
{ type: 'key_value', label: 'Sent', value: '2 hours ago' },
],
}
| Field | Type | Description |
|---|---|---|
items |
SpindleModalItemDTO[] |
Child content items rendered inside the card |
Cards can be nested, but keep nesting shallow (1 level) for readability.
Stack Limit¶
Extensions may have at most 2 stacked modals open simultaneously — for example, one modal opened via spindle.modal.open() and one text editor opened via spindle.textEditor.open(). Attempting to open a third modal rejects with an error.
This limit is enforced per extension. Different extensions can each have their own modals open independently.
Behavior¶
- The modal opens as an overlay in the Lumiverse frontend, centered on screen with a dimmed backdrop.
- The modal is rendered by the Lumiverse host — it automatically inherits the user's theme, font scale, accent color, and glass mode settings.
- The user can dismiss the modal by clicking the close button, clicking the backdrop (unless
persistentistrue), or pressing Escape. - The call blocks until the modal is dismissed. Only one
spindle.modal.open()call can be pending per extension at a time. - On mobile, the modal adapts to smaller viewports and is touch-friendly.
Example: Character Nudge History¶
spindle.onFrontendMessage(async (payload, userId) => {
if (payload.type === 'show_history') {
const history = await loadNudgeHistory(payload.characterId, userId)
const character = await spindle.characters.get(payload.characterId, userId)
const items: import('lumiverse-spindle-types').SpindleModalItemDTO[] = []
if (history.length === 0) {
items.push({ type: 'text', content: 'No nudges have been sent yet.', muted: true })
} else {
for (const entry of history.reverse()) {
items.push({
type: 'card',
items: [
{ type: 'text', content: entry.text },
{ type: 'key_value', label: 'Sent', value: formatTime(entry.timestamp) },
],
})
}
}
await spindle.modal.open({
title: `${character?.name ?? 'Character'} — Nudge History`,
items,
userId,
})
}
})
Backend modal vs. frontend modal
spindle.modal.open()(backend) — use when the backend already has the data and you want a quick structured display without writing any frontend code. The host renders the content items for you.ctx.ui.showModal()(frontend) — use when you need full DOM control over the modal body: custom layouts, interactive elements, live updates, event handlers.
Confirmation¶
Show a system-themed confirmation dialog and wait for the user's response. The host renders a modal with a message, a variant-colored confirm button, and a cancel button.
const { confirmed } = await spindle.modal.confirm({
title: 'Clear History',
message: 'This will delete all nudge history for this character. This action cannot be undone.',
variant: 'danger',
confirmLabel: 'Delete',
})
if (confirmed) {
await clearNudgeHistory(characterId, userId)
spindle.toast.success('History cleared')
}
The returned Promise resolves when the user clicks confirm, clicks cancel, or dismisses the modal. It never rejects. Counts toward the 2 stacked modals limit per extension.
Options¶
| Field | Type | Default | Description |
|---|---|---|---|
title |
string |
required | Title displayed in the modal header |
message |
string |
required | Body text explaining what the user is confirming |
variant |
'info' \| 'warning' \| 'danger' \| 'success' |
'info' |
Visual style for the confirm button (see variants below) |
confirmLabel |
string |
"Confirm" |
Label for the primary action button |
cancelLabel |
string |
"Cancel" |
Label for the secondary dismiss button |
userId |
string |
— | Target user ID. Only needed for operator-scoped extensions. |
Result¶
| Field | Type | Description |
|---|---|---|
confirmed |
boolean |
true if the user clicked confirm, false if they cancelled or dismissed |
Variants¶
The variant option controls the confirm button's accent color to signal intent:
| Variant | Button Color | Use For |
|---|---|---|
'info' |
Neutral / blue (default) | General confirmations, informational prompts |
'warning' |
Yellow / amber | Actions with side effects the user should be aware of |
'danger' |
Red | Destructive or irreversible actions (delete, clear, reset) |
'success' |
Green | Positive confirmations (enable, activate, approve) |
Example: Guarding a Destructive Backend Action¶
spindle.onFrontendMessage(async (payload, userId) => {
if (payload.type === 'clear_history') {
const { confirmed } = await spindle.modal.confirm({
title: 'Clear Nudge History',
message: `This will permanently delete all ${payload.count} nudge entries for this character.`,
variant: 'danger',
confirmLabel: 'Clear All',
userId,
})
if (confirmed) {
await spindle.userStorage.setJson(
`nudge-history/${payload.characterId}.json`,
[],
{ userId },
)
spindle.toast.success('Nudge history cleared')
spindle.sendToFrontend({ type: 'history_cleared', characterId: payload.characterId }, userId)
}
}
})
For frontend-initiated confirmations, see Confirmation Modal in the Frontend API docs.
Input Prompt¶
Present a text input modal to the user and wait for their response. The host renders a themed dialog with a title, optional message, a text input (single-line or multiline), and submit/cancel buttons. The call blocks until the user submits or cancels.
const { value, cancelled } = await spindle.prompt.input({
title: 'Rename Preset',
placeholder: 'Enter a name...',
defaultValue: currentName,
})
if (!cancelled && value) {
await renamePreset(value)
spindle.toast.success(`Renamed to "${value}"`)
}
The returned Promise resolves when the user submits or cancels. It never rejects. Counts toward the 2 stacked modals limit per extension.
Options¶
| Field | Type | Default | Description |
|---|---|---|---|
title |
string |
required | Title displayed in the modal header |
message |
string |
— | Description shown below the title |
placeholder |
string |
— | Placeholder text for the input field |
defaultValue |
string |
"" |
Pre-filled value |
submitLabel |
string |
"Submit" |
Label for the primary action button |
cancelLabel |
string |
"Cancel" |
Label for the dismiss button |
multiline |
boolean |
false |
Render a multi-line textarea instead of a single-line input |
userId |
string |
— | Target user ID. Only needed for operator-scoped extensions. |
Result¶
| Field | Type | Description |
|---|---|---|
value |
string \| null |
The submitted text, or null if the user cancelled |
cancelled |
boolean |
true if the user cancelled or dismissed the prompt |
Behavior¶
- The input auto-focuses when the modal opens.
- Single-line (
multiline: false): pressing Enter submits. Ctrl/Cmd+Enter also submits. - Multi-line (
multiline: true): Enter inserts a newline. Ctrl/Cmd+Enter submits. - The submit button is disabled until the input has non-whitespace content.
- The modal can be dismissed by clicking cancel, clicking the backdrop, or pressing Escape — all of these resolve with
cancelled: true.
Example: Collecting Feedback Before an Action¶
spindle.commands.register([{
id: 'send-feedback',
label: 'Send Feedback to Character',
scope: 'chat',
}])
spindle.commands.onInvoked(async (commandId, context) => {
if (commandId !== 'send-feedback') return
const { value, cancelled } = await spindle.prompt.input({
title: 'Character Feedback',
message: 'This will be injected as an OOC instruction in the next generation.',
placeholder: 'e.g. Be more descriptive, focus on the environment...',
multiline: true,
submitLabel: 'Send',
})
if (cancelled || !value) return
// Store feedback for the next generation cycle
await spindle.variables.local.set(context.chatId!, 'pending_feedback', value)
spindle.toast.info('Feedback queued for next generation')
})
Example: Single-Line Rename¶
const { value } = await spindle.prompt.input({
title: 'Rename Profile',
defaultValue: profile.name,
placeholder: 'Profile name',
submitLabel: 'Rename',
})
if (value) {
profile.name = value
await saveProfile(profile)
}