UI Components
Stina provides a declarative component system for building UIs in extensions. Instead of writing HTML and CSS, you define your UI as JSON structures in the manifest or return them from actions. The component system is used in three places:
- Tool settings (
contributes.toolSettingswithkind: "component") — settings pages shown in the extension’s configuration area. - Panels (
contributes.panels) — side panel views visible during conversations. - Modals — dialog overlays triggered from actions.
Component Structure
Section titled “Component Structure”Every component is a JSON object with a component field that identifies the type. Additional fields depend on the component type. All components accept an optional style object for inline CSS (restricted to safe properties).
{ "component": "ComponentName", "style": { "padding": "1rem" }}Components that contain other components use children, content, body, or footer depending on the component type.
Where Components Are Used
Section titled “Where Components Are Used”Tool Settings (Component View)
Section titled “Tool Settings (Component View)”Use kind: "component" in a tool settings view definition. The data object defines data sources whose keys become scope variables. The content object is the root component to render.
{ "contributes": { "toolSettings": [ { "id": "my-settings", "title": "My Settings", "view": { "kind": "component", "data": { "settings": { "action": "getSettings", "refreshOn": ["settings.changed"] } }, "content": { "component": "VerticalStack", "gap": 1, "children": [ { "component": "TextInput", "label": "API Key", "value": "$settings.apiKey", "onChangeAction": { "action": "updateSetting", "params": { "key": "apiKey", "value": "$value" } } } ] } } } ] }}Panels
Section titled “Panels”Panels appear in the right sidebar. They use the same kind: "component" structure.
{ "contributes": { "panels": [ { "id": "my-panel", "title": "My Panel", "icon": "check-list", "view": { "kind": "component", "data": { "items": { "action": "getItems", "refreshOn": ["item.changed"] } }, "content": { "component": "VerticalStack", "gap": 1, "children": [] } } } ] }}Panels require the panels.register permission. Data source actions require the actions.register permission.
Available Components
Section titled “Available Components”Layout Components
Section titled “Layout Components”VerticalStack
Section titled “VerticalStack”Stacks children vertically. The gap property controls spacing between items (in rem units as a number).
{ "component": "VerticalStack", "gap": 1, "children": [ { "component": "Label", "text": "First item" }, { "component": "Label", "text": "Second item" } ]}HorizontalStack
Section titled “HorizontalStack”Stacks children horizontally. Commonly used with align-items in the style to control vertical alignment.
{ "component": "HorizontalStack", "gap": 0.5, "style": { "align-items": "center", "justify-content": "space-between" }, "children": [ { "component": "Label", "text": "Left side" }, { "component": "Pill", "text": "Active", "variant": "success" } ]}Grid layout with a fixed number of columns.
{ "component": "Grid", "columns": 2, "gap": 1, "children": [ { "component": "Label", "text": "Cell 1" }, { "component": "Label", "text": "Cell 2" }, { "component": "Label", "text": "Cell 3" }, { "component": "Label", "text": "Cell 4" } ]}Stacked card list where items share borders and the first/last items have rounded corners. Each child (or each data item when using iteration) becomes a separate list item.
Static children:
{ "component": "List", "children": [ { "component": "Label", "text": "Item one" }, { "component": "Label", "text": "Item two" }, { "component": "Label", "text": "Item three" } ]}With iteration:
{ "component": "List", "children": { "each": "$contacts", "as": "contact", "items": [ { "component": "HorizontalStack", "gap": 0.5, "style": { "align-items": "center", "justify-content": "space-between" }, "children": [ { "component": "Label", "text": "$contact.name" }, { "component": "Pill", "text": "$contact.role", "variant": "primary" } ] } ] }}Display Components
Section titled “Display Components”Short text display. The text property supports data binding with $ references.
{ "component": "Label", "text": "Account Name" }{ "component": "Label", "text": "$account.email", "style": { "font-size": "0.85em", "opacity": "0.7" } }Paragraph
Section titled “Paragraph”Longer text blocks.
{ "component": "Paragraph", "text": "This extension monitors your inbox for new messages." }Header
Section titled “Header”Section header with a heading level (1—6). Supports an optional description and icon.
{ "component": "Header", "level": 2, "title": "Settings", "description": "Configure your extension preferences.", "icon": "settings-01"}The description can also be an array of strings, which renders as multiple lines.
Markdown
Section titled “Markdown”Render markdown content. Note the property is called content, not text.
{ "component": "Markdown", "content": "**Bold** and *italic* text with [links](https://example.com)." }TextPreview
Section titled “TextPreview”Compact markdown preview that truncates after a set number of lines (default 5) and shows a toggle to expand/collapse. Useful for displaying longer text in a compact space, such as descriptions or notes in a list.
{ "component": "TextPreview", "content": "A longer markdown text that may span **multiple lines**.\n\nIt will be truncated after the default 5 lines with a chevron toggle to expand." }Custom line limit:
{ "component": "TextPreview", "content": "$item.description", "maxLines": 3 }Divider
Section titled “Divider”Horizontal line separator.
{ "component": "Divider" }Display an icon by name. Icons come from the Huge Icons set.
{ "component": "Icon", "name": "mail-01", "title": "Email" }Note: The property is name, not icon.
Small badge or tag. Supports predefined color variants: default, primary, success, warning, danger, accent.
{ "component": "Pill", "text": "Connected", "variant": "success" }{ "component": "Pill", "text": "$account.providerLabel", "variant": "$account.statusVariant", "icon": "mail-01" }Input Components
Section titled “Input Components”All input components require an onChangeAction that is called when the value changes. The new value is available as $value in action parameters.
TextInput
Section titled “TextInput”Text field with a label.
{ "component": "TextInput", "label": "Display Name", "placeholder": "Work Email", "value": "$editState.form.name", "onChangeAction": { "action": "updateFormField", "params": { "field": "name", "value": "$value" } }}DateTimeInput
Section titled “DateTimeInput”Date/time picker.
{ "component": "DateTimeInput", "label": "Due Date", "value": "$todo.dueAt", "onChangeAction": { "action": "setDueDate" }}Select
Section titled “Select”Dropdown with static options. The currently selected value is set via selectedValue (not value).
{ "component": "Select", "label": "Provider", "options": [ { "label": "iCloud", "value": "icloud" }, { "label": "Gmail", "value": "gmail" }, { "label": "Outlook", "value": "outlook" }, { "label": "Generic IMAP", "value": "imap" } ], "selectedValue": "$editState.form.provider", "onChangeAction": { "action": "updateFormField", "params": { "field": "provider", "value": "$value" } }}Toggle
Section titled “Toggle”On/off toggle switch. Uses checked (not value) for the current state.
{ "component": "Toggle", "label": "All day", "description": "Mark this as an all-day event", "checked": "$editModal.todo.allDay", "disabled": false, "onChangeAction": { "action": "updateEditField", "params": { "field": "allDay", "value": "$value" } }}Checkbox
Section titled “Checkbox”Checkbox with a label. Uses checked for the current state. When strikethrough is true (the default), the label gets a strikethrough style when checked.
{ "component": "Checkbox", "label": "Buy groceries", "checked": "$todo.completed", "strikethrough": true, "onChangeAction": { "action": "toggleTodo", "params": { "id": "$todo.id" } }}IconPicker
Section titled “IconPicker”Lets the user choose an icon from the available icon set.
{ "component": "IconPicker", "label": "Icon", "value": "$editModal.todo.icon", "onChangeAction": { "action": "updateEditField", "params": { "field": "icon", "value": "$value" } }}Interactive Components
Section titled “Interactive Components”Button
Section titled “Button”Clickable button. Uses onClickAction (not onChangeAction).
{ "component": "Button", "text": "Add Account", "onClickAction": "showAddForm"}Actions can be a simple string (just the action name) or a full action call object with parameters:
{ "component": "Button", "text": "Save", "onClickAction": { "action": "saveTodo", "params": { "todoId": "$editModal.todo.id" } }}IconButton
Section titled “IconButton”Icon-only button with a tooltip. Supports type for color variants: normal, primary, danger, accent.
{ "component": "IconButton", "icon": "pencil-edit-01", "tooltip": "Edit todo", "onClickAction": { "action": "openEditTodo", "params": { "todoId": "$todo.id" } }}{ "component": "IconButton", "icon": "delete-02", "tooltip": "Delete account", "type": "danger", "onClickAction": { "action": "deleteAccount", "params": { "id": "$account.id" } }}A titled container with an optional icon, description, and action buttons in the header. Content is rendered via the content property (a single component, not children).
{ "component": "Panel", "title": "$group.title", "icon": "folder-01", "description": "Project tasks", "actions": [ { "icon": "add-01", "tooltip": "Add item", "action": { "action": "addItem", "params": { "groupId": "$group.id" } } } ], "content": { "component": "VerticalStack", "gap": 0.5, "children": [] }}Collapsible
Section titled “Collapsible”Expandable/collapsible section with a title header. Content is rendered via the content property.
{ "component": "Collapsible", "title": "Advanced Settings", "description": "Additional configuration options", "icon": "settings-02", "defaultExpanded": false, "content": { "component": "VerticalStack", "gap": 1, "children": [ { "component": "Toggle", "label": "Debug mode", "checked": "$settings.debug", "onChangeAction": "toggleDebug" } ] }}A styled container with an optional collapsible header. Two visual variants: border (default) draws a border around the content, solid uses a solid background. The title can be a plain string or extension components. Content is rendered via children.
{ "component": "Frame", "title": "Connection Details", "variant": "border", "children": [ { "component": "Label", "text": "$connection.host" }, { "component": "Label", "text": "$connection.port" } ]}Collapsible frame with a solid background:
{ "component": "Frame", "title": "Advanced Options", "variant": "solid", "collapsible": true, "defaultExpanded": false, "children": [ { "component": "Toggle", "label": "Verbose logging", "checked": "$settings.verbose", "onChangeAction": "toggleVerbose" } ]}Dialog overlay. Controlled by the open property (bound to a data variable). The body and footer properties each take a single component.
{ "component": "Modal", "title": "Edit Todo", "open": "$editModal.modalOpen", "maxWidth": "500px", "onCloseAction": "closeEditModal", "body": { "component": "VerticalStack", "gap": 1, "children": [ { "component": "TextInput", "label": "Title", "value": "$editModal.todo.title", "onChangeAction": { "action": "updateEditField", "params": { "field": "title", "value": "$value" } } }, { "component": "Select", "label": "Status", "options": [ { "label": "Not started", "value": "not_started" }, { "label": "In progress", "value": "in_progress" }, { "label": "Completed", "value": "completed" } ], "selectedValue": "$editModal.todo.status", "onChangeAction": { "action": "updateEditField", "params": { "field": "status", "value": "$value" } } } ] }, "footer": { "component": "HorizontalStack", "gap": 0.5, "style": { "justify-content": "flex-end" }, "children": [ { "component": "Button", "text": "Cancel", "onClickAction": "closeEditModal" }, { "component": "Button", "text": "Save", "onClickAction": { "action": "saveTodo", "params": { "todoId": "$editModal.todo.id" } } } ] }}Control Flow
Section titled “Control Flow”ConditionalGroup
Section titled “ConditionalGroup”Renders its children only when the condition expression evaluates to true. The condition supports:
- Comparison operators:
==,!= - Logical operators:
&&(and),||(or) - Values:
$references,'strings', numbers,true,false,null
{ "component": "ConditionalGroup", "condition": "$editState.form.provider == 'imap'", "children": [ { "component": "TextInput", "label": "IMAP Server", "placeholder": "imap.example.com", "value": "$editState.form.imapHost", "onChangeAction": { "action": "updateFormField", "params": { "field": "imapHost", "value": "$value" } } } ]}You can combine conditions with ||:
{ "component": "ConditionalGroup", "condition": "$form.provider == 'gmail' || $form.provider == 'outlook'", "children": [ { "component": "Button", "text": "Connect with OAuth", "onClickAction": "startOAuth" } ]}ConditionalGroup does not have an else branch. To handle the opposite case, add a second ConditionalGroup with a negated condition using !=.
Data Binding
Section titled “Data Binding”Use $variableName syntax to reference data from data sources. The variable names correspond to the keys in the data object of your view definition.
{ "data": { "settings": { "action": "getSettings" }, "accounts": { "action": "getAccounts" } }}With the above data sources, you can reference $settings.apiKey, $accounts, etc. Nested paths work too: $editModal.todo.title.
Inside input onChangeAction handlers, the special variable $value contains the new value entered by the user.
Iteration
Section titled “Iteration”Use each/as on the children property to render a list of components from data. The each field points to an array in the data, as names the loop variable, and items contains the components to render for each element.
{ "component": "VerticalStack", "gap": 0.5, "children": { "each": "$accounts", "as": "account", "items": [ { "component": "HorizontalStack", "gap": 0.5, "style": { "align-items": "center" }, "children": [ { "component": "Label", "text": "$account.name" }, { "component": "Pill", "text": "$account.providerLabel", "variant": "$account.statusVariant" } ] } ] }}Iterators can be nested. For example, a list of groups where each group has a list of items:
{ "component": "VerticalStack", "gap": 1, "children": { "each": "$groups", "as": "group", "items": [ { "component": "Panel", "title": "$group.title", "content": { "component": "VerticalStack", "gap": 0.5, "children": { "each": "$group.items", "as": "item", "items": [ { "component": "Label", "text": "$item.name" } ] } } } ] }}Actions
Section titled “Actions”Components trigger extension actions via onClickAction (buttons) and onChangeAction (inputs). Actions can be specified in two ways:
Simple string — just the action name, no parameters:
{ "onClickAction": "showAddForm" }Action call object — action name with parameters:
{ "onClickAction": { "action": "deleteAccount", "params": { "id": "$account.id" } }}Parameters can mix static values and $-prefixed data references. The action name must match a registered action in your extension code:
context.actions?.register('deleteAccount', async (params) => { const id = params.id as string await context.storage?.collections.get('accounts').delete(id) await context.events?.emit('account.changed')})Data Sources and Refresh
Section titled “Data Sources and Refresh”Data sources define how a view fetches its data. Each data source calls a registered action and makes the result available as a scope variable.
{ "data": { "settings": { "action": "getSettings", "params": { "includeDefaults": true }, "refreshOn": ["settings.changed"] } }}The refreshOn array lists event names that trigger an automatic reload of the data source. When any of those events are emitted, Stina re-calls the action and re-renders the UI with the new data.
Emit events from your extension code:
await context.events?.emit('settings.changed')This requires the events.emit permission.
Allowed Styles
Section titled “Allowed Styles”Only whitelisted CSS properties are permitted in the style object. This restriction exists for security — properties like position, z-index, and pointer-events are blocked to prevent UI spoofing. Values containing url(), expression(), or javascript: are also blocked.
Colors: color, background-color, background, border-color
Borders: border, border-width, border-style, border-radius, border-top, border-right, border-bottom, border-left, border-top-left-radius, border-top-right-radius, border-bottom-left-radius, border-bottom-right-radius
Spacing: padding, padding-top, padding-right, padding-bottom, padding-left, margin, margin-top, margin-right, margin-bottom, margin-left, gap, row-gap, column-gap
Typography: font-size, font-weight, font-style, text-align, text-decoration, line-height, letter-spacing, white-space, word-break, overflow-wrap
Layout: width, height, min-width, min-height, max-width, max-height, flex, flex-grow, flex-shrink, flex-basis, flex-wrap, align-self, justify-self, align-items, justify-content
Visual: opacity, visibility, overflow, overflow-x, overflow-y, box-shadow, outline, cursor, border-collapse, border-spacing
Style values can also use $-prefixed data references:
{ "style": { "background-color": "$item.color" } }Complete Example
Section titled “Complete Example”This example shows a tool settings view for managing notification accounts — a simplified version of the pattern used by the Mail Reader extension. It combines data sources, iteration, conditional rendering, modals, and multiple input types.
manifest.json (relevant section)
Section titled “manifest.json (relevant section)”{ "permissions": [ "tools.register", "actions.register", "events.emit", "storage.collections" ], "contributes": { "toolSettings": [ { "id": "accounts", "title": "Notification Accounts", "description": "Configure accounts to monitor.", "view": { "kind": "component", "data": { "accounts": { "action": "getAccounts", "refreshOn": ["account.changed"] }, "editState": { "action": "getEditState", "refreshOn": ["edit.changed"] } }, "content": { "component": "VerticalStack", "gap": 1, "children": [ { "component": "VerticalStack", "gap": 0.5, "children": { "each": "$accounts", "as": "account", "items": [ { "component": "HorizontalStack", "gap": 0.5, "style": { "align-items": "center", "justify-content": "space-between" }, "children": [ { "component": "VerticalStack", "gap": 0.25, "children": [ { "component": "Label", "text": "$account.name" }, { "component": "Label", "text": "$account.email", "style": { "font-size": "0.85em", "opacity": "0.7" } } ] }, { "component": "HorizontalStack", "gap": 0.25, "children": [ { "component": "Pill", "text": "$account.providerLabel", "variant": "$account.statusVariant" }, { "component": "IconButton", "icon": "edit-02", "tooltip": "Edit account", "onClickAction": { "action": "editAccount", "params": { "id": "$account.id" } } }, { "component": "IconButton", "icon": "delete-02", "tooltip": "Delete account", "type": "danger", "onClickAction": { "action": "deleteAccount", "params": { "id": "$account.id" } } } ] } ] } ] } }, { "component": "Button", "text": "Add Account", "onClickAction": "showAddForm" }, { "component": "Modal", "title": "$editState.modalTitle", "open": "$editState.showModal", "onCloseAction": "closeModal", "body": { "component": "VerticalStack", "gap": 1, "children": [ { "component": "TextInput", "label": "Display Name", "placeholder": "Work Account", "value": "$editState.form.name", "onChangeAction": { "action": "updateFormField", "params": { "field": "name", "value": "$value" } } }, { "component": "TextInput", "label": "Email Address", "placeholder": "you@example.com", "value": "$editState.form.email", "onChangeAction": { "action": "updateFormField", "params": { "field": "email", "value": "$value" } } }, { "component": "Select", "label": "Provider", "options": [ { "label": "IMAP", "value": "imap" }, { "label": "Gmail", "value": "gmail" }, { "label": "Outlook", "value": "outlook" } ], "selectedValue": "$editState.form.provider", "onChangeAction": { "action": "updateFormField", "params": { "field": "provider", "value": "$value" } } }, { "component": "ConditionalGroup", "condition": "$editState.form.provider == 'imap'", "children": [ { "component": "TextInput", "label": "IMAP Server", "placeholder": "imap.example.com", "value": "$editState.form.imapHost", "onChangeAction": { "action": "updateFormField", "params": { "field": "imapHost", "value": "$value" } } }, { "component": "TextInput", "label": "Password", "placeholder": "password", "value": "$editState.form.password", "onChangeAction": { "action": "updateFormField", "params": { "field": "password", "value": "$value" } } } ] }, { "component": "Toggle", "label": "Enable notifications", "description": "Receive alerts for new messages", "checked": "$editState.form.notificationsEnabled", "onChangeAction": { "action": "updateFormField", "params": { "field": "notificationsEnabled", "value": "$value" } } } ] }, "footer": { "component": "HorizontalStack", "gap": 0.5, "style": { "justify-content": "flex-end" }, "children": [ { "component": "Button", "text": "Cancel", "onClickAction": "closeModal" }, { "component": "Button", "text": "Save", "onClickAction": "saveAccount" } ] } } ] } } } ] }}Extension code (src/index.ts)
Section titled “Extension code (src/index.ts)”The extension code registers the actions referenced in the manifest:
import type { ExtensionModule, ExtensionContext } from '@stina/extension-api'
const extension: ExtensionModule = { activate(context: ExtensionContext) { const accounts = context.storage?.collections.get('accounts')
// Data source actions context.actions?.register('getAccounts', async () => { const result = await accounts?.find({}) return (result ?? []).map((a: any) => ({ id: a.id, name: a.name, email: a.email, providerLabel: a.provider.toUpperCase(), statusVariant: a.enabled ? 'success' : 'default', })) })
// Edit state is kept in memory (not persisted) let editState = { showModal: false, modalTitle: 'Add Account', form: {} as any }
context.actions?.register('getEditState', async () => editState)
context.actions?.register('showAddForm', async () => { editState = { showModal: true, modalTitle: 'Add Account', form: { name: '', email: '', provider: 'imap', notificationsEnabled: true }, } await context.events?.emit('edit.changed') })
context.actions?.register('closeModal', async () => { editState = { ...editState, showModal: false } await context.events?.emit('edit.changed') })
context.actions?.register('updateFormField', async (params) => { editState.form[params.field as string] = params.value await context.events?.emit('edit.changed') })
context.actions?.register('saveAccount', async () => { await accounts?.upsert(editState.form) editState.showModal = false await context.events?.emit('edit.changed') await context.events?.emit('account.changed') })
context.actions?.register('editAccount', async (params) => { const account = await accounts?.get(params.id as string) if (account) { editState = { showModal: true, modalTitle: 'Edit Account', form: { ...account } } await context.events?.emit('edit.changed') } })
context.actions?.register('deleteAccount', async (params) => { await accounts?.delete(params.id as string) await context.events?.emit('account.changed') }) },}
export default extensionComponent Reference
Section titled “Component Reference”Quick reference table of all components and their key properties.
| Component | Key Properties | Event |
|---|---|---|
VerticalStack | gap, children | — |
HorizontalStack | gap, children | — |
Grid | columns, gap, children | — |
List | children | — |
Label | text | — |
Paragraph | text | — |
Header | level, title, description, icon | — |
Markdown | content | — |
TextPreview | content, maxLines | — |
Divider | — | — |
Icon | name, title | — |
Pill | text, variant, icon | — |
TextInput | label, placeholder, value | onChangeAction |
DateTimeInput | label, value | onChangeAction |
Select | label, options, selectedValue | onChangeAction |
Toggle | label, description, checked, disabled | onChangeAction |
Checkbox | label, checked, disabled, strikethrough | onChangeAction |
IconPicker | label, value | onChangeAction |
Button | text | onClickAction |
IconButton | icon, tooltip, type, active, disabled | onClickAction |
Panel | title, description, icon, actions, content | — |
Collapsible | title, description, icon, defaultExpanded, content | — |
Frame | title, variant, collapsible, defaultExpanded, children | — |
Modal | title, open, maxWidth, body, footer | onCloseAction |
ConditionalGroup | condition, children | — |