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:

ModeContextDesign for
Sidebar (context.mode === 'widget')Narrow accordion panelScanning. 3-5 items max. Key facts only.
Full panel (context.mode === 'full')Wide content areaExploration. 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.

The sidebar is narrow. Design for it:

  • Lists: 3-5 items maximum. Use subtitle for 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 list or kv instead.
  • 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 typeSuggested interval
Real-time (clock, timer)1s
Live transcript processing5s
API data (CI, tickets)5m
Rarely changing data30m 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 @name short — it appears in the sidebar accordion header. 2-3 words is ideal.
  • If using list items, use iconColor sparingly — 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