UI contributions: navbar & theme
How plugins contribute a navbar button and select a theme through the mediated BodegaAPI, with no DOM access.
The F-200 contribution surface lets a plugin add UI to the desktop app without ever touching the DOM. You declare an item through the mediated BodegaAPI; the host renders it, validates it, and routes interactions back to your plugin. Two surfaces are wired today:
- Navbar items — an icon button in the desktop topbar.
- Themes — selecting one of the host's built-in
[data-theme]palettes.
Everything below is host-authoritative: plugin input is untrusted, so the host validates the shape, caps the count, and only delivers events for items your plugin actually registered.
Navbar items
Register a navbar item with ui.registerPanel. It returns a disposer — track it and call it in
deactivate().
const dispose = api.ui.registerPanel({
id: 'theme-toggle', // unique within your plugin
title: 'Toggle theme', // rendered verbatim (the host can't translate plugin strings)
location: 'navbar', // the only location rendered today
icon: 'contrast' // optional lucide name; unknown -> generic glyph
})
The host shows the icon plus the title (as the button's accessible label and hover tooltip) in the
topbar, left-to-right by registration order. Clicks are routed back to your plugin through
ui.onNavItemClick — the navbar item itself does not navigate anywhere.
Reserved locations
location accepts 'sidebar' | 'settings' | 'navbar', but only 'navbar' is rendered today.
'sidebar' and 'settings' are accepted and recorded but not yet rendered (reserved for a
future host release). Registering them today is harmless but invisible.
Panel caps and validation
registerPanel rejects out-of-bounds input (it throws inside an in-process plugin's activate(), or
is logged and skipped for worker-isolated plugins):
| Field | Rule |
|---|---|
id | Required. Charset [a-zA-Z0-9._-], ≤64 chars. Unique within your plugin. |
title | Required, non-empty, ≤120 chars. |
icon | Optional. Charset [a-zA-Z0-9._-], ≤40 chars. |
| count | At most 32 panels per plugin. |
Supported navbar icons
icon takes a lucide icon name string. Names outside this set
fall back to a generic plugin glyph, so an unknown or forged name never breaks the navbar.
moon, sun, contrast, palette, paintbrush, sparkles, settings, cloud, wallet,
package, activity, store, puzzle, star, bell, bookmark, tag, box, wrench, zap,
plug, gift, heart, globe, calendar, clock, receipt, printer, link, download, upload
Handling clicks
ui.onNavItemClick(handler) subscribes to clicks on your navbar items. The handler receives the
itemId that was clicked. It returns a disposer.
const dispose = api.ui.onNavItemClick(async (itemId) => {
if (itemId !== 'theme-toggle') return
// react to the click — no DOM access needed
})
The host is spoof-guarded: it only delivers a click for an item that your plugin actually
registered as a navbar panel. A compromised renderer cannot route a forged (pluginId, itemId) pair
to another plugin's handler. If you register more than one navbar item, branch on itemId.
Themes
The desktop ships two authoritative palettes, applied by toggling [data-theme] on the document
root: light and dark. Plugins do not paint colors directly — they select one of these via the
API.
Reserve a slug
const dispose = api.ui.registerTheme('dark', { label: 'Dark' })
registerTheme(slug, definition) reserves a theme slug for your plugin and returns a disposer.
tokensis optional and not applied today. You may passdefinition.tokens(a CSS-variable map), and the host records it, but it is not applied to the renderer — the built-in[data-theme]palettes do the recoloring. The token map is reserved for a future host that applies plugin-supplied tokens directly. To change the look today, usesetThemeto select a built-in palette.
| Field | Rule |
|---|---|
slug | Required. Charset [a-zA-Z0-9._-], ≤64 chars. |
label | Optional human-readable name (shown if the host lists themes). |
tokens | Optional, recorded but not applied. ≤200 entries. |
tokens key | ≤128 chars. |
tokens value | string, ≤2048 chars. |
| count | At most 16 themes per plugin. |
Apply and read the active theme
const current = await api.ui.getActiveTheme() // 'light' | 'dark'
await api.ui.setTheme(current === 'dark' ? 'light' : 'dark')
ui.setTheme('light' | 'dark')tells the host to apply a palette. The host flips[data-theme]and the renderer's CSS variables recolor. Returns aPromise.ui.getActiveTheme()returns the palette the host currently has applied (defaults tolight).
Revert-to-light on uninstall
The host tracks which plugin applied the current non-light theme. When that plugin is disabled or
uninstalled, the host automatically reverts to light so an uninstall never strands the app in a
plugin's theme. You do not need to call setTheme('light') in deactivate() — and it would not
reach the renderer post-deactivate anyway. (light is the default and is not "owned" by any plugin,
so reverting only fires for a non-light theme set by the leaving plugin.)
Worked example: theme-toggle
This is the official com.puntosuite.theme-toggle plugin
(packages/plugin-theme-toggle/src/index.ts). It contributes a navbar button and a dark theme,
flips light/dark on click, persists the choice, and re-applies it on every activation — all with
zero DOM access and zero Node built-ins, so it runs under the strictest worker-isolation sandbox.
import type { BodegaAPI, ThemeSlug } from '@bodegasuite/plugin-sdk'
const NAV_ITEM_ID = 'theme-toggle'
const CONFIG_THEME_KEY = 'theme'
// The host renders titles verbatim, so the plugin localizes its own label from the host locale.
const TITLES: Record<'es' | 'en', string> = {
es: 'Cambiar tema',
en: 'Toggle theme'
}
function isThemeSlug(value: unknown): value is ThemeSlug {
return value === 'light' || value === 'dark'
}
let disposers: Array<() => void> = []
export async function activate(api: BodegaAPI): Promise<void> {
const lang = api.i18n.getLocale().toLowerCase().startsWith('es') ? 'es' : 'en'
// 1. Reserve the 'dark' slug. No tokens — rendering is host-authoritative.
disposers.push(api.ui.registerTheme('dark', { label: 'Dark' }))
// 2. The navbar toggle button. The host shows the icon + title and routes clicks back.
disposers.push(
api.ui.registerPanel({
id: NAV_ITEM_ID,
title: TITLES[lang],
location: 'navbar',
icon: 'contrast'
})
)
// 3. React to a click: flip the active theme and persist the new value.
disposers.push(
api.ui.onNavItemClick(async (itemId) => {
if (itemId !== NAV_ITEM_ID) return
const current = await api.ui.getActiveTheme()
const next: ThemeSlug = current === 'dark' ? 'light' : 'dark'
await api.ui.setTheme(next)
await api.config.set(CONFIG_THEME_KEY, next)
})
)
// 4. Re-apply the saved preference. Runs on EVERY activation (first launch AND any reload).
const saved = await api.config.get(CONFIG_THEME_KEY)
if (isThemeSlug(saved)) {
await api.ui.setTheme(saved)
}
}
export async function deactivate(): Promise<void> {
// Tear down in reverse registration order. The host reverts to light on uninstall,
// so no explicit setTheme('light') is needed here.
for (const dispose of disposers.reverse()) {
dispose()
}
disposers = []
}
What each step does:
registerTheme('dark', { label: 'Dark' })— reserves the slug. Notokensare passed because the desktop's built-in[data-theme="dark"]block does the recoloring.registerPanel({ location: 'navbar', icon: 'contrast' })— adds the topbar button. The label is picked from the host locale viai18n.getLocale(), since the host renders plugin titles verbatim.onNavItemClick— guards onitemId, reads the active theme withgetActiveTheme(), flips it withsetTheme(), then persists the choice withconfig.set().- Re-apply on activate — reads the saved value with
config.get()and re-applies it. This runs on the first launch and on any plugin reload (for example, one triggered by toggling another plugin), so a reload never drops the chosen theme. The renderer's theme provider pulls the active theme on mount, so applying it here before the window exists is safe.
In deactivate, the plugin disposes everything in reverse registration order. It does not reset
the theme — the host's revert-to-light logic handles that when the plugin is removed.
Related
- API reference — the full
BodegaAPIsurface. - Lifecycle —
activate/deactivate, disposers, and reloads. - Hooks and BodegaAPI — action hooks fired by core.
- Manifest —
id,permissions, andcapabilities. - Security — worker isolation, trust tiers, and contribution caps.