Design Guidelines
How to build Stoa plugins that feel native, match the sidebar design system, and work well in both compact and full-panel modes.
Plugins that feel native are more useful and more trusted. These guidelines help you build output that matches the Stoa design system and works well across rendering contexts.
Use native output types
Prefer structured output types (list, kv, table, json, tree) over raw html. Native types:
- Match the sidebar design system automatically
- Respect light/dark theming
- Scale correctly across sidebar and full-panel modes
- Are accessible by default
Reserve html and editableHtml for visualizations that genuinely can't be expressed with the standard types.
Design for both modes
Every widget renders in two contexts:
| Mode | Context | Design for |
|---|---|---|
Sidebar (context.mode === 'widget') | Narrow accordion panel | Scanning. 3-5 items max. Key facts only. |
Full panel (context.mode === 'full') | Wide content area | Exploration. Tables, tabs, detail views. |
render(context) {
if (context.mode === 'full') {
// Detailed view — tables, tabs, multiple sections
return {
type: 'tabs',
tabs: [
{ label: 'Overview', content: { type: 'kv', entries: [...] } },
{ label: 'Details', content: { type: 'table', columns: [...], rows: [...] } },
]
}
}
// Sidebar — compact summary
return {
type: 'list',
items: [
{ label: 'Status', subtitle: 'All systems go', iconColor: 'green' },
]
}
}If your plugin returns the same output regardless of mode, make sure it works well in the narrow sidebar width.
Sidebar density
The sidebar is narrow. Design for it:
- Lists: 3-5 items maximum. Use
subtitlefor secondary info, not additional list items. - KV pairs: 2-4 entries. Keep keys short.
- Tables: Avoid in sidebar mode — they don't have room. Use
listorkvinstead. - Markdown: Keep it to 2-3 lines. No headings in sidebar mode.
- Tabs: Don't use in sidebar mode. Save for full-panel.
Handle empty and error states
Always handle the case where there's no data:
if (!data || data.length === 0) {
return { type: 'empty', message: 'No sprint items found' }
}For network errors:
try {
const resp = await fetch(url)
const data = await resp.json()
// ... render data
} catch (e) {
return { type: 'error', message: 'Failed to fetch data', details: e.message }
}Never return a blank or undefined output. The sidebar will show nothing, which looks broken.
Refresh intervals
Choose refresh intervals that match the data's rate of change:
| Data type | Suggested interval |
|---|---|
| Real-time (clock, timer) | 1s |
| Live transcript processing | 5s |
| API data (CI, tickets) | 5m |
| Rarely changing data | 30m or 1h |
Don't use 1s for API-backed plugins — you'll hit rate limits and waste bandwidth.
Icons and labels
- Choose an icon that communicates the plugin's purpose at a glance.
- Keep the
@nameshort — it appears in the sidebar accordion header. 2-3 words is ideal. - If using
listitems, useiconColorsparingly — green/red/orange for status, not for decoration.
Renderers
- Always handle empty files:
return { type: 'empty', message: 'Empty file' } - Handle parse errors gracefully with
{ type: 'error', message, details } - For editable renderers, make the editing model obvious — inline editing should feel like a spreadsheet, not require instructions
- The banner above the rendered file lets users toggle back to raw source, so don't worry about hiding the raw format