Skip to content
Navigate Open escClose

UI kit

@openchamber/sdk/ui is a set of ready-made controls that use the app’s colours, fonts, and spacing, so your extension looks like the rest of OpenChamber without copying its styles. Each control is a function: you give it an element you own and its settings, it draws itself there and returns { update, dispose }. You put the controls together into a screen and keep the data. The kit never calls your provider or OpenChamber; you still call host.request, host.attach, and the rest yourself. You can write your own HTML and CSS for anything the kit lacks, but reach for the kit first: its controls follow the user’s theme, spacing, and keyboard habits automatically, so a panel built from them feels like part of OpenChamber instead of a website inside it.

Call applyHostReady first

The controls read their colours from CSS variables that applyHostReady writes onto the page. It also sets the app’s font and text colour on the page root, so plain HTML you add yourself looks right too. Call it inside host.onReady before the first mount, and the colours follow the app’s theme when the user switches it.

import { applyHostReady } from "@openchamber/sdk/ui";
let mounted = false;
host.onReady((ctx) => {
applyHostReady(ctx, document.documentElement);
if (mounted) return;
mounted = true;
// Mount controls and register listeners once here.
});

The kit uses host-computed text colors for tinted buttons, badges, banner titles, validation messages, and links. You don’t need to calculate contrast yourself.

For custom CSS, applyHostReady provides --primary-text, --success-text, --warning-text, --error-text, and --info-text, plus matching --oc-*-text aliases. Use them for text on neutral or lightly tinted backgrounds. Keep base colors for fills and --oc-primary-fg for text on a solid --oc-primary fill. Apply every onReady snapshot to keep the colors current.

onReady can run many times while the panel is open, including when session context changes. Apply the theme on every call, but mount controls and register listeners only once. Keep draft values outside rendering functions. When switching tabs, hide existing panels or restore their values from your state rather than clearing them.

Keep selection and input state in sync

mountTabs, mountSelect, mountCheckbox, and mountSwitch report changes through onChange; they do not commit the new activeId, value, or checked themselves. Save the new value and pass it back with update. Text and search inputs show typed text immediately, but their props still need update({ value }), or a later update can restore an old value. A button’s onClick does not change its variant; for a choice of modes, prefer mountTabs.

Mount these controls once in your panel’s existing root:

import { mountSelect, mountTabs } from "@openchamber/sdk/ui";
let activeId = "convert";
const tabs = mountTabs(root, {
items: [{ id: "convert", label: "Convert" }, { id: "format", label: "Format" }],
activeId,
onChange: (next) => {
activeId = next;
tabs.update({ activeId });
// Show the matching panel without discarding its draft values.
},
});
let format = "json";
const select = mountSelect(root, {
options: [{ id: "json", label: "JSON" }, { id: "yaml", label: "YAML" }],
value: format,
onChange: (next) => {
format = next;
select.update({ value: format });
},
});

For a format-swap button, swap both values in your state and call update({ value }) on both existing select handles. Do not mount replacement controls to change their values. Call dispose() when you remove a control.

What you can mount

FunctionWhat it draws
mountButtonA button. variant is default, secondary, outline, ghost, or destructive; size is default, sm, or xs. loading shows a spinner and blocks clicks.
mountTextFieldA labelled input, or a textarea with multiline. Optional helper or error text, password masking, and mono for tokens and ids.
mountSearchFieldA search box with a magnifier and a clear button. Escape clears it.
mountSelectA dropdown. searchable adds a filter box at the top of the list. Arrow keys move, Enter picks, Escape closes.
mountCheckbox, mountSwitchA checkbox or a toggle with a label and an optional description.
mountTabsPill tabs, each with an optional count. Left and right arrows move between them.
mountBadgeA small pill. tone is neutral, primary, success, warning, error, or info.
mountListA keyboard-friendly list. Each row can have a leading key, a title, a subtitle, right-aligned meta text, and a badge.
mountEmptyA centred empty state with a title, a body line, and an optional button.
mountSpinnerA loading ring with an optional label.
mountBannerA toned notice with a title, a body, and an optional action.
mountSeparatorA thin line, optionally with a label in the middle.
mountProgressA progress bar from 0 to 100.
mountMenuA button that opens a list of actions. Items can be destructive, disabled, or a separator.
mountTextText from your provider. Markdown-style images and http(s) links become real images and links; everything else stays plain text, so untrusted content is safe to show.

Three plain helpers ship next to the controls. filterSelectOptions(options, query) is the match mountSelect uses. splitTextMedia(text) is what mountText does before drawing. moveListSelection is the keyboard step the list, select, and menu share, in case you build your own list.

import { applyHostReady, mountEmpty, mountList, mountSearchField } from "@openchamber/sdk/ui";
let mounted = false;
host.onReady((ctx) => {
applyHostReady(ctx, document.documentElement);
if (mounted) return;
mounted = true;
const root = document.querySelector("#root")!;
const searchRoot = root.appendChild(document.createElement("div"));
const listRoot = root.appendChild(document.createElement("div"));
const emptyRoot = root.appendChild(document.createElement("div"));
let query = "";
let empty: { dispose: () => void } | null = null;
const list = mountList(listRoot, {
items: [],
onSelect: (id) => {
const task = tasks.find((item) => item.id === id);
if (task) void host.attach({ providerId: "acme-hello", id, title: task.title, url: task.url });
},
});
const paint = () => {
const rows = tasks
.filter((task) => task.title.toLowerCase().includes(query.toLowerCase()))
.map((task) => ({ id: task.id, leading: task.key, title: task.title, meta: task.updated }));
list.update({ items: rows });
empty?.dispose();
empty = rows.length === 0
? mountEmpty(emptyRoot, { title: "No tasks match", body: "Try a shorter search." })
: null;
};
const search = mountSearchField(searchRoot, {
value: query,
placeholder: "Search tasks",
onChange: (next) => {
query = next;
search.update({ value: next });
paint();
},
});
paint();
});

update takes only the settings that changed. The list keeps its highlighted row across updates as long as that row is still there.

Your page runs in a sandbox and cannot open a new tab by itself. When you use mountText, pass onOpenUrl and hand the link to OpenChamber:

import { mountText } from "@openchamber/sdk/ui";
mountText(root, {
text: comment.body,
onOpenUrl: (url) => void host.openUrl(url),
});