1.0 archive. This section describes the previous 1.0 API. The current model is the 2.0 design.
4. App tier — @get-bb/plugin-sdk/app
The browser entry. It runs only through the import map (app.mjs leaves @get-bb/plugin-sdk/app bare; packages/plugin-build/src/externals.ts). It re-exports React types only; React itself comes from the map (07 §2.1, I8). Everything here is a line in packages/plugin-sdk/api.lock.json (29 values, 85 types at e1efe72a3; LayoutRoot, useCommands, ClientCommands, DragSession, LayoutRootProps arrived with that delta, §0.8). Slot names are §5; this section is the mechanism. Source of truth: packages/plugin-sdk/src/app/{index,define-plugin-app,slots,hooks}.ts over packages/kernel-ui/src/**; where spec 07 and the built kernel differ, the "As built" column wins (04-kernel-as-built D16–D18).
4.1 definePluginApp and setup(app, config)
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
definePluginApp |
(def: { setup(app: PluginAppApi, config: ActivationConfig): void | Promise<void> }) => PluginApp |
The default export of app.tsx. Returns kernel-ui's branded product (Symbol.for("bb.plugin-app")); the loader refuses a default export without the brand (appTier: "error", "default export is not a definePluginApp() product"). |
07 §2.3 step 3, 01 §2.4 | define-plugin-app.ts: wraps kernel-ui's definePluginApp; setup(api, config?) on the product, config ?? {}. |
PluginApp |
{ [brand]: true; setup(api: CollectorApi, config?: ActivationConfig) } |
What the loader imports and calls. | 07 §2.3 | config is {} in the product; only createAppHarness passes row config (SDK README deviation 12; optional in the signature because the wrapper fills {}). |
PluginAppApi |
{ slots: SlotsApi; routes; panes; tabs; commands; preferences } |
The typed face setup receives. routes/panes/tabs/commands are kernel-ui's CollectorApi members verbatim; slots and preferences are SDK-narrowed. |
07 §2.3 step 4 | No slots.declare on the SDK face (kernel CollectorApi has it; catalog-only sugar). commands.handle returns void at setup time. |
setup contract |
runs twice | (1) bb plugin build runs it in the headless collector (no DOM) to write dist/app.contributions.json; (2) the browser runs it at load against the same collector and compares output with the file (contributions_mismatch fails the load). setup only records; nothing touches a store until commit. At e1efe72a3 the build also runs every slots.inject thunk against a recorder, so the children its registrations declare land in declares; a thunk that throws there records nothing and the kernel reports the failure when it realizes it. |
07 §2.3, §5.9 | contributions.ts createCollector; plugin-frontends.ts prepareFrontend (checkClaim: true). The type admits void | Promise<void>; the browser (plugin-frontends.ts await app.setup(api)) and the build hook (cli/src/_pending/plugin-dev-hooks.ts await app.setup(api)) await a Promise, and only createAppHarness refuses one (invalid_contract, D20). Write setup synchronous so one module passes all three. |
Rules: every register/inject needs literal name, kind, scope (the collector throws otherwise). A registration into a name no live occupant declares fails the plugin's load (SlotAuthorityError, "use slots.inject"). Commit is atomic per plugin: any failure restores the previous generation in routes, commands, kinds, slots and preferences (kernel-ui README "Failed reload rollback").
4.2 app.slots.register
register<Name, O extends RegisterOptions<Name>>(options: O & { name: Name }, component: SlotComponentInput<SlotComponentProps<Name, O>>): void. kind/scope are narrowed by the merged SlotMap entry for Name; an unmerged name accepts the open unions and the component's props take kind/scope from the literal options (SDK README deviation 9).
| Field | Type | Default (filled once by the collector) | Meaning |
|---|---|---|---|
name |
Name extends string |
required, literal | The slot. Must be declared (kernel root, the two kit facade slots, a live occupant's children, or — at e1efe72a3 — a live pane kind's children, §4.5) or the load fails (07 §5.1; slot-tree.ts validate). Before that commit a registration into a pane-kind child (thread.timeline) failed with slot … is not declared. |
kind |
SlotKindOf<Name> = "single" | "list" | "keyed" | "chain" |
required | Must equal the declaration's kind (07 §5.1). |
scope |
SlotScopeOf<Name> = "root" | "thread" | "pane" |
required | Must equal the declaration's scope (07 §5.3). |
key |
string |
null |
keyed only, required there ("command", "claude-code:task", "*" as the conventional fallback key; snake_case core kinds, R27). |
priority |
number |
0 |
Arbitration for single/keyed/chain; higher wins (§4.4). |
order |
number |
0 |
list ordering, ascending; ties by sorted plugin id. |
select |
(owner: SlotPropsOf<Name>) => unknown |
null |
chain only, required there: pure self-nomination; null/undefined = pass. The non-null value arrives as selected. |
when |
(owner: SlotPropsOf<Name>) => boolean |
null |
list only: visibility predicate the outlet evaluates with the owner props. |
children |
Record<string, SlotDeclarationInput> |
{} |
Declares and exclusively authorizes child slots (07 §5.5). Each: { kind, scope, props: ZodType, boot?: "critical" | "deferred" (deferred), overlay?: boolean (false), fallback?: "owner" | "none" (owner) }. A root-scoped registration may not declare a thread-scoped child (SlotScopeError at load); pane-scoped children of root occupants are allowed and checked at render (D16). A pane kind declares children the same way (PaneKindInput.children, §4.5); at e1efe72a3 those are live declarations while the kind's plugin wins the kind (slot-tree.ts kindReg, create-ui-kernel.ts kindLive). |
store |
StoreDefinition<unknown> from defineStore({ init, persist?, actions }) |
null |
Per-instance store; the component receives store: StoreFace<T> (§4.3). persist: { key, scope: "client" | "tab" | "thread" } routes through preferences; the key must be one this plugin defined (07 §5.7.1), else the store is memory-only. |
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
SlotsApi.inject |
(name: string, thunk: (slots: Pick<SlotsApi, "register">) => void) => void |
Late contributor: the thunk runs when name's declaration exists (now or on a later commit), its registrations are dropped when the declaration vanishes, and it re-runs on redeclaration. The sanctioned form for a plugin without requires on the declarer. At e1efe72a3 the thunk also runs at bb plugin build against a recorder (contributions.ts toAppContributionsJson), so the children of its registrations appear in declares; write it as plain registration code that needs nothing from the live tree. |
07 §5.6 | slot-tree.ts runInjects (≤ 8 passes per commit). A throwing thunk drops its partial registrations, marks the injecting plugin degraded (inject into <slot> failed) with one toast, and is not retried until that plugin reloads. |
SlotComponentInput<P> |
SlotComponent<P> | { regular: SlotComponent<P>; compact: SlotComponent<P> } |
One component, or one per layout mode; the kernel picks by useLayoutMode() and remounts on change. |
07 §8 | SlotMount.tsx pickComponent; the mount key includes the mode. |
SlotComponent<P> |
ComponentType<P> |
A React component. | 07 §5.1 | — |
SlotMap / SlotEntry |
interface SlotMap {}; SlotEntry<P> = { kind: SlotKind; scope: SlotScope; props: P } |
Declaration-merge hook: a declarer's contracts module adds "<slot>": { kind; scope; props } and every consumer gets typed register and props with no value import. Also exported from /contracts. |
07 §5.9 | slots.ts; fixture src/fixture/contracts.ts merges notes.panel/notes.badge. |
SlotKindOf<N> / SlotScopeOf<N> / SlotPropsOf<N> |
conditional types | The merged entry's kind/scope/props, or the open union / Record<string, unknown> for an unmerged name. |
07 §5.9 | slots.ts. |
SlotDeclarationInput / StoreDefinition / defineStore |
see table above; defineStore<T, A>({ init: () => T; persist?; actions: A }) => StoreDefinition<T, A> |
defineStore fills persist: null. Actions are (draft: T, ...args) => void and run on a one-level structural copy, so every action yields a new reference. |
07 §5.7.1 | slots/store.ts. |
// contracts.ts (published): declare module "@get-bb/plugin-sdk/app" {
// interface SlotMap { "notes.panel": { kind: "list"; scope: "root"; props: { heading: string } } } }
app.slots.register(
{ name: "root", kind: "single", scope: "root", priority: -1000,
children: { "notes.panel": { kind: "list", scope: "root", props: z.object({ heading: z.string() }) } } },
({ renderSlot, Original }) => <main>{renderSlot("notes.panel", { heading: "Notes" })}</main>, // renderSlot typed to "notes.panel"
);
app.slots.register({ name: "notes.panel", kind: "list", scope: "root", order: 10 }, (p: SlotComponentProps<"notes.panel">) => <b>{p.heading}</b>);The root registration at priority: -1000 is a fallback for a composition or test without ui-shell: in the product ui-shell (priority 0) wins root, this occupant never mounts, and the plugin's panel reaches the screen as a pane kind plus a route (§4.5, "How a page appears").
4.3 Slot component props
SlotComponentProps<Name, O> = SlotPropsOf<Name> & ScopeProps<scope> & { renderSlot: RenderSlot<children of O> } & ({ store: StoreFace<T> } when O.store) & ({ Original: SlotComponent } unless kind is list) & ({ selected: unknown } when kind is chain). Never ctx, the container, or a query client (07 §5.7, A5); services arrive through hooks (§4.9).
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
| owner props | SlotPropsOf<Name> |
Whatever the owner passed to renderSlot(name, props); JSON data and callbacks. Validated by the declaration's zod schema in the catalog, typed by SlotMap. |
07 §5.7 | The runtime spreads props as given (SlotMount.tsx Occupant); no render-time zod parse. |
ScopeProps<S> |
thread → { threadId: string }; pane → { paneId: string; threadId: string | null }; root → {} |
Scope keys the kernel injects from the ambient ScopeContext (pane subtree sets paneId and the pane kind's threadId(state)). |
07 §5.3 | Occupant: threadId for every non-root scope, paneId for pane. |
renderSlot |
RenderSlot<Names> = (name: Names, props: Record<string, unknown>, opts?: RenderSlotOptions) => ReactNode |
The owner-side call, bound to this registration and narrowed to its children. Any other name is a type error and a runtime SlotAuthorityError. The bound function may be handed to an exported component and mounted by another plugin (foreign mount): the instance key then gains |mount:<useId> so stores and latches stay separate. |
07 §5.5 | bindRenderSlot; SlotOutlet computes foreignMount from PluginContext. |
RenderSlotOptions |
{ fallback?: ReactNode; keys?: readonly string[]; only?: readonly string[]; thread?: string } |
fallback: what renders when nothing does (single: no candidate; keyed/chain: no match; list: ignored). keys: the keyed ladder, first key with an occupant wins. only: restrict a list to these plugin ids. thread: explicit thread scope key for a thread-scoped child at a non-occupant mount. |
07 §5.2, §5.5 | SlotMount.tsx. A pane-scoped slot outside a pane subtree or a thread-scoped slot with no thread key is SlotScopeError: thrown in dev, fallback in production. |
Original |
SlotComponent (absent for list) |
The next candidate in arbitration order, ending at the owner fallback (fallback: "none" ends in nothing). Bound once per mount; identity changes only when the chain behind this occupant changes, so <Original {...props} /> keeps the original's state across re-renders. |
07 §5.7 | Occupant useMemo on the chain's ids. Pane and tab kinds get the same prop (PaneComponentProps, TabComponentProps). |
selected |
unknown (chain only) |
The non-null value this registration's select(owner) returned. With overlay: true the owner fallback stays mounted in a <div hidden> beside the winner; at e1efe72a3 that wrapper has one shape whether or not an occupant is selected (hidden={occupant !== null}), so React never remounts the fallback across a takeover and its DOM state (a composer's editor) survives. |
07 §5.2 | SlotOutlet chain branch. |
store |
StoreFace<T, A> = { use<S>(selector: (state: T) => S): S; get(): T; actions: BoundActions<A> } |
One instance per (registration, instance key), created on first render, discarded with the plugin's generation. use is useSyncExternalStore; actions.x(...args) calls the draft action and notifies. persist loads/saves through the preferences facade (thread scope takes the thread id from the scope key). |
07 §5.7.1 | slots/store.ts, SlotTree.store, create-ui-kernel.ts persistence. |
Mount: every occupant instance renders under PluginContext {pluginId, generation} → the kit's PortalScopeProvider → release registry → CrashBoundary → Suspense (fallback = the owner fallback) → <div data-bb-plugin-root data-bb-plugin="<id>" class="contents">. React key = instance key ${name}|${key ?? ""}|${pluginId}|${generation}|${scopeKey}|${registrationId} (+ |<mode>), so a new generation or a mode change remounts. The trailing registration id (added at e1efe72a3) keeps two list registrations of one plugin into one slot apart — each has its own store and crash latch — while the latch's one-toast tuple stays (slot, key, pluginId, generation) (07 §5.8; slot-tree.ts instanceKey, SlotMount.tsx).
4.4 Arbitration and pins
One rule, owned by kernel-core (02 §3.4 order(candidates, pin)): replaces → user pin → priority desc → sorted plugin id asc. kernel-ui adds only the crash-latch filter in front of it and never ranks on its own (12 U1). It applies to single, each key of keyed, the evaluation order of chain, and every pane/tab kind (LayoutStore.paneKinds_/tabKinds_). list slots skip arbitration: all occupants, order asc then plugin id, when(owner) and only filtered.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
| pins | profile preference kernel/pins: Record<target, pluginId>; targets slot:<name>, slot:<name>:<key>, pane:<kind>, tab:<kind> (and service:<id> for the container) |
The user's override at one point; a pinned plugin that is absent or latched is skipped, never an error. Written by bb composition pin <target> <pluginId> / --clear, listed by bb composition pins; Settings › Appearance writes the same map. |
07 §5.4, 02 §3.4 | Defined by kernel-ui at boot (defineKernelPreferences); a kernel/preferences.changed for it re-arbitrates every outlet. Keyed lookup tries slot:<name>:<key> then slot:<name>. |
| crash latch | CrashLatch (Set of instance keys) |
An occupant that throws latches its instance for the current generation, runs its useOwnedEffect releases, toasts once per (slot, key, pluginId, generation) ("<id> crashed in <slot>"), and the next candidate renders; none left → owner fallback. A reload of the plugin clears its latches. |
07 §5.4, §5.8 | crash-latch.ts, SlotMount.tsx onCrash; pane/tab kinds latch per instance (kindInstanceKey) and fall through the same chain. |
| commit order | sorted plugin id per wave | A route, command, or child-declaration collision always fails the later id, never the later bundle to arrive (hello-slot < ui-shell). |
07 §2.2 (150 ms batching) | plugin-frontends.ts runWave (D16; batching subsumed). |
SlotDeclarationConflict |
thrown at commit | Two live occupants declaring the same child name: the later plugin fails to load. | 07 §5.5 | slot-tree.ts checkDeclarationConflicts. |
4.5 Routes, panes, tabs
URL = the focused pane (Q21). The kernel has no routes of its own; / belongs to ui-shell (D16). A pane is { kind, state }; a route maps a path to a pane kind; a dock holds a flat tab list (no splits inside a dock).
How a page appears. KernelRoot renders NoRoutePage when no route matches the URL and otherwise the root slot, renderSlot("root", {}, { fallback: <LoadingPage/> }) (boot/KernelRoot.tsx); root is the kernel's a priori single slot (create-ui-kernel.ts), and arbitration (§4.4) picks one occupant. In the product that occupant is ui-shell's ShellRoot (priority 0, §5.1): it renders shell.main, whose occupant LayoutHost mounts kernel-ui's LayoutRoot, and LayoutRoot mounts every pane of the layout tree through SlotMount with the pane kind's winning component (PaneHost → usePaneMount → kindChainHead, layout/LayoutRoot.tsx; an unknown kind renders MissingKindPane). So a plugin page in the product is a pane kind plus a route (hello-slot: kind hello, path /hello), and the shell draws it. At e1efe72a3 LayoutRoot is exported from @get-bb/plugin-sdk/app (table below), so a plugin's own root occupant can host the pane tree by rendering <LayoutRoot /> inside what it returns; before that commit it was not exported and a plugin root rendered only what it returned. A root registration at priority: -1000 mounts only when nothing outranks it — createAppHarness without shell, or a composition without ui-shell — and then the routed pane appears only if that occupant mounts LayoutRoot; hello-slot, written before the export, renders the same panel from both places instead (HelloPanel is both the root occupant and the hello pane component). createAppHarness({ shell: { panes: true } }) mounts LayoutRoot under the test shell's root (§9.3). ui-shell/pages (§5.14) with the page pane kind (§5.6, route /plugins/:pluginId/:pageId/*) is the shell's registry for a page with shell chrome; the slice has no registrants.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
app.routes.register |
<S>(route: RouteContribution<S>) => void; RouteContribution<S> = { id: "<pluginId>/<name>"; path: string; pane: { kind: string; toState(params, url: URL): S; toPath(state: S): string }; title(state: S): string } |
Path patterns take :name segments and a trailing * (captured as params["*"]); static segments beat params beat *. Same path by two plugins → RouteCollision for the later id; no route priority (a fork uses replaces). Unmatched URL → NoRoutePage. title sets document.title. |
07 §3.1 | router/router.ts. A malformed percent-escape is a non-match, not a throw. |
app.panes.register |
<S>(kind: PaneKindInput<S>) => void; PaneKindInput<S> = { kind; schema: ZodType<S>; Component: SlotComponentInput<PaneProps<S>>; title(s): string; threadId(s): string | null; dock: { default(s): DockState; allowedTabKinds: string[] | null } | null; priority?: number (0); icon?: IconName | null (null); equals?(a, b): boolean (deepEqual); intents?: ZodType<JsonValue> | null (null); children?: Record<string, SlotDeclarationInput> } |
Registers a pane kind. Several plugins may register one kind; the kernel arbitrates with the pane:<kind> pin and the winner gets Original (PaneComponentProps<S> = PaneProps<S> & { Original: ComponentType<PaneProps<S>> }). threadId(state) sets the thread scope key for the pane subtree and selects the kernel-ui/dock.tabs row for persist: "thread" tabs. children declares the pane component's child slots (how ui-thread-page declares thread.*). kind is a non-empty string; the kernel applies no grammar and no namespace (contributions.ts copies it as given; core kinds are bare — thread, compose, page — and hello-slot uses hello). Two plugins on one kind are candidates of one chain, never a collision; only a route path collides (RouteCollision). |
07 §4.2 | contributions.ts fills the defaults; LayoutRoot.tsx usePaneMount/useKindChain (per-instance crash fallback, stable Original). An unregistered kind renders MissingKindPane naming the owner from the catalog. At e1efe72a3 the kind's children are live slot-tree declarations while this plugin wins the kind (kindLive), so another plugin's register into one of them passes the load-time check, and app.contributions.json records them under owner: "pane:<kind>". A new pane's dock opens only when dock.default(state).open is true and the client preference kernel-ui/dockOpenDefault is true (default false at e1efe72a3, true before): the preference closes a default-open dock and never opens a default-closed one (layout-store.ts buildPane). |
app.tabs.register |
<S>(kind: TabKindInput<S>) => void; TabKindInput<S> = { kind; schema; Component: SlotComponentInput<TabProps<S>>; label(s): string; icon: IconName | ((s) => IconName); persist: "thread" | "tab" | ((s) => "thread" | "tab"); priority? (0); singleton? (false); launcher?: { label; order; create(): S | Promise<S> } | null (null); closable? (true) } |
Registers a dock tab kind. persist decides the store per tab from its state ("thread" → the thread's kernel-ui/dock.tabs annotation with CAS; "tab" → the tab-scoped tree); the catalog records a function as "dynamic". icon is required (the strip always shows a glyph). |
07 §4.2, §4.3 | layout-store.ts (persistOf, persistThreadTabs, one CAS retry); each tab mounts one TabOutlet per tabId for the tab's life. |
PaneProps<S> |
{ paneId; state: S; isFocused; isMaximized; isOnlyPane; index; hash: string; dockHost: "inline" | "workspace"; setState(next: S): void; consumeIntent(): JsonValue | null; dock: DockHandle | null; renderSlot: RenderSlot } |
What a pane component receives. setState replaces content in place (URL replace when focused); consumeIntent is one-shot (second call null); dock is DockHandle { toggle; open; close; setMode; addTab(tab, { activate }): string; removeTab; activate; reorder; setWidth } bound to this pane; renderSlot is narrowed to the kind's children. |
07 §4.6, §4.4 | LayoutRoot.tsx. Invalid setState is refused with a toast, never thrown. PaneProps exist even when no kind is live, so dock tabs can usePane(). |
usePane |
() => PaneProps |
For descendants of a pane (header actions, dock tabs inline or in the workspace column); throws outside a pane subtree. | 07 §4.6 | LayoutRoot.tsx PaneContext. |
TabProps<S> |
{ tabId; paneId; state: S; isActive; setState(next: S): void; close(): void } |
What a tab component receives, plus Original. |
07 §4.2 | LayoutRoot.tsx TabOutlet. |
useNavigation |
() => Navigation; Navigation = { open(target: PaneTarget, opts?: Partial<OpenOptions>): string; openPath(path: string, opts?): string; current(): { path; paneId; navId }; subscribe(listener: (change: { entry; cause: "push" | "replace" | "pop" }) => void): () => void } |
The SDK facade fills OpenOptions = { where: OpenWhere; replace: boolean; intent: JsonValue | null } with { where: "focused", replace: false, intent: null }. PaneTarget = { kind: string; state: unknown }; OpenWhere = "focused" | "new-split" | { paneId }. Returns the pane id now showing the target (an equals match focuses the existing pane and delivers the intent there). |
07 §3.2 | hooks.ts navigationFacade; router.ts NavigationService. A state the kind's schema refuses toasts and returns the focused pane id. Focusing a different pane with a different URL pushes; setState replaces. Browser back/forward reports cause: "pop" at e1efe72a3 (before, the popstate listener took the event as its initial flag and reported replace). |
useLayoutTree |
<T>(selector: (tree: LayoutTree) => T) => T |
Subscribe to the tab-scoped tree (LayoutTree = { version: 1; root: pane | split; focusedPaneId; maximizedPaneId }, ≤ 8 panes, ≤ 32 dock tabs). The frozen tree is the snapshot; the selector may return fresh values. |
07 §4.4 | KernelRoot.tsx. |
kernel-ui/layout |
useService("kernel-ui/layout") |
The layout ops face: open, split, close, focus, move, maximize, spotlight, setSizes, setState, findPaneByThread, paneRects, dockOf(paneId): DockHandle. Browser-local, no server half. |
07 §4.4, §7.9 | Built as the LayoutStore instance (dock ops via dockOf(paneId), not a dock.* namespace). |
DockOwnerProps / DropZoneOwnerProps |
{ dock: DockState; tabs: { tabId; kind; label; icon; closable; Component }[]; ops: DockHandle; host: "inline" | "workspace" } / { panes: { paneId; rect; activeSide }[]; label; hoveredPaneId } |
Owner props of ui-shell's pane.dock and layout.dropZones children, passed through LayoutRoot's renderDock/renderOverlay callbacks. |
07 §4.5 | No paneId on DockOwnerProps (read it with usePane()); the overlay is called only during a drag session. |
LayoutRoot |
(props: LayoutRootProps) => ReactNode; LayoutRootProps = { renderDock?(props: DockOwnerProps): ReactNode; renderOverlay?(props: DropZoneOwnerProps): ReactNode; dragSession?: DragSession | null } |
The layout tree as a component: a CSS-grid container over the tab-scoped LayoutTree, one PaneHost per pane (scope providers, PaneProps, crash boundary, the kind's winning component through SlotMount), dividers as setSizes calls, maximize, and the dock host placement (inline beside the pane, or the workspace column). It draws no chrome: tab strips come through renderDock, drop zones through renderOverlay, which is called only while dragSession is non-null (null and absent mean no drag). Mount it once, inside a root occupant (in the product ui-shell's shell.main does); it throws outside a UiKernel provider. |
07 §3.1, §4.5 | exported at e1efe72a3 (plugin-sdk/src/app/index.ts); layout/LayoutRoot.tsx. The grid uses minmax(0, 1fr) tracks and minHeight: 0 on pane bodies and dock hosts, so tall pane content scrolls inside its pane. |
DragSession |
{ label: string; hoveredPaneId: string | null; sides: Readonly<Record<string, SplitSide>> } |
A drag-to-split session (ui-shell/layout-drag runs it): the label the overlay shows, the pane under the pointer, and the edge under the pointer per pane id (absent = none). Pass it as LayoutRoot's dragSession; renderOverlay then receives DropZoneOwnerProps. |
07 §4.5 | exported at e1efe72a3; layout/LayoutRoot.tsx. SplitSide = "left" | "right" | "top" | "bottom" (layout/layout-tree.ts) is kernel-ui's and is not on the SDK face; name the strings. |
Kernel-registered pane commands (bindable, when: { all: ["splitActive"], none: ["modalOpen"] }): pane.focus.1…8 (Mod+N desktop, Control+N web-mac, Mod+Shift+N web-other), pane.focus.previous, pane.focus.next, pane.close (Mod+Shift+X), pane.maximize.toggle (Mod+Shift+E). thread.open and pane.act are ui-shell's (07 §4.7, R10; layout-commands.ts).
4.6 Client commands and keybindings
Ids are flat and open (thread.new, pane.focus.*); one executor plus a before-interceptor stack per id (02 §6.3 on the browser side). The bus is kernel-ui's synchronous registry; the kernel does not mirror it onto the container bus (D17). Hub {t: "command"} frames reach commands.run directly (a server tier sends them with ctx.hub.command, §3.5). An executor that throws yields an error result, never a timeout.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
app.commands.register |
(c: CommandContributionInput) => void; CommandContributionInput = { id; title: string | ((index?) => string); defaultShortcut: Shortcut | readonly ShortcutVariant[] | null; family?: { count } (null); when?: When ({all: [], none: []}); bindable? (true); desktopOnly? (false); menu?: { path: string[]; order } | null (null) } |
Declares a command for the catalog, the bindings table and menus. family expands thread.jump.* to .1…count (the handler gets index). defaultShortcut is required; null = bindable with no default; a variant list carries per-surface when/desktopOnly. |
07 §6.1 | command-bus.ts normalizeCommand. A second plugin registering the same id is CommandConflict at commit: the later plugin's app tier fails to load and rolls back (07 §6.1 says degraded; built fails the commit). |
app.commands.handle |
(id, handler: CommandHandler, opts?: { priority?: number; executor?: boolean }) => void at setup; CommandHandler = (inv: Invocation) => boolean | void; Invocation = { input: unknown; index: number | null; actor: { kind; id } | null } |
The first handle (or executor: true) is the executor; every other is a before-interceptor ordered by priority desc then registration desc. An interceptor returning true consumes; an executor returning false reports "not consumed". A second executor is CommandConflict. |
07 §6.1, 02 §6.3 | command-bus.ts handle/run. |
useCommandHandler |
(id, handler: CommandHandler, deps: readonly unknown[], opts?: { priority? }) => void |
Hook form of handle; registers on mount under the ambient plugin id and disposes on unmount / when deps change. |
07 §6.1 | KernelRoot.tsx. |
useCommands |
() => ClientCommands; ClientCommands = { run(id: string, input?: JsonValue | null): boolean; has(id: string): boolean } |
The caller side of the bus: run runs a command by id as a shortcut would (input defaults to null) and returns whether a handler consumed it; has tells whether id resolves. For a menu item, a probe, or a button that triggers another plugin's command. The returned object is not memoized; do not put it in a dependency list. |
07 §6.1 | added at e1efe72a3; hooks.ts over kernel-ui CommandBus.run/has. |
useCommandContext |
(key: ContextKey, active: boolean) => void |
Asserts a context key while mounted and active; counted, so overlapping asserters compose. Kernel keys: modalOpen, editableFocus (transient, derived from the keydown target), layoutCompact, webSurface, desktopSurface, mobileSurface, macPlatform, splitActive. ContextKey = string; When = { all: ContextKey[]; none: ContextKey[] }. |
07 §6.1 | CommandBus.assert. commands.context.define(key, description) is not on the SDK face; a plugin asserts any key string. |
useShortcut |
(id: string) => Shortcut | null; Shortcut = { key; mod; meta; control; alt; shift } |
The effective binding of id from the in-memory table: defaults from every contribution filtered by isBindingAvailable({ isDesktop, isMac }), then ui-keyboard's overrides (bindings.set; null disables). Two defaults on one chord: the earlier plugin by sorted id keeps it. Family defaults bind key: "<index>" per member. Stable snapshot between changes. |
07 §6.2 | command-bus.ts effective(); the kit's ShortcutHint takes the Shortcut object. |
useIsCommandModifierHeld |
() => boolean |
true after Meta (mac) or Control is held 700 ms; clears on keyup/blur. For shortcut hints. |
07 §6.1 | KernelRoot.tsx. |
useModalPresence |
(id: string, open: boolean) => void |
Acquires a counted modal presence while open; modalOpen derives from it, never from a DOM selector. Kit Dialog/PersistentDrawer acquire it themselves; a raw Radix dialog does not (dev build warns). |
07 §6.1 | modal-presence.ts; ModalPresence does not block window.shouldClose. |
| keydown dispatch | window keydown capture listener |
First effective binding whose chord matches (today's key normalization) and whose when holds → commands.run(id); a consumed run calls preventDefault. Redelivered events are no-ops. A keydown inside an IME composition (event.isComposing, or the legacy keyCode 229) is never a chord (e1efe72a3). |
07 §6.1 | KernelRoot.tsx + dispatchKeydown(event, ["editableFocus"]?). |
Commands, CommandInput<N>, CommandOutput<N> |
interface Commands {}; CommandInput<N> = Commands[N]["input"] | unknown |
kernel-core's declaration-merge hook for typed command names (declare module "@get-bb/plugin-sdk/contracts" { interface Commands { "thread.send": { input; output } } }). Re-exported here for app code that names commands. |
02 §6.3 | kernel-core/src/commands.ts. |
4.7 Preferences
A browser facade over 02's kernel/preferences; there is no kernel-ui/preferences service. Keys are static <pluginId>/<name> (^[a-z0-9][a-z0-9-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$, PreferencesFacade KEY_RE); per-instance values are maps under one key. Preference key rule (identical in §1.3 and §3.7): the key is the full <pluginId>/<name> string in every tier, with name in ^[a-zA-Z][a-zA-Z0-9_-]*$ (the app's regex is wider, the server tier's is not); each tier that reads the key defines it itself with the same schema and default (a server define does not make the key readable in the browser, nor the reverse — usePreference of a key this bundle did not define throws read before its define); contributes.settings[name] is optional for code — it adds the Settings form row and the required fast path, and nothing checks that its default equals the code default. hello-slot defines hello-slot/theme in both server.ts and app.tsx from one THEME_KEY and DEFAULT_THEME exported by contracts.ts.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
app.preferences.define |
<T>(key: string, schema: ZodType<T>, opts: { scope: PreferenceScope; default: T }) => PreferenceRef<T>; PreferenceScope = "profile" | "client" | "tab" | "thread"; PreferenceRef<T> = { key; scope; default: T } |
Declares a key and returns the typed ref usePreference reads. profile/thread live in core (kernel/preferences, CAS, kernel/preferences.changed); client in localStorage["bb.pref.<key>"]; tab in sessionStorage. The default is filled at define; a read before define throws. The build emits { key, scope, default, schema } into app.contributions.json (a schema or default without a JSON form is ContributionSchemaError), spec 07 §7.5 has core validate profile/thread writes against it and refuse client/tab as unsupported_scope; as built kernel-core's PreferencesService has no schema producer (services.ts), so a write is validated only by its writer (the hook setter, PreferenceHandle.set). |
07 §7.5, §5.9 | preferences.ts PreferencesFacade.define(key, schema, opts, owner): a key is owned by its plugin; a reload redefines it (held values re-validated), another owner, a scope change, or a key outside KEY_RE throws a plain Error at commit, which fails the plugin's load (rollback, §4.1). define in setup is the only place (hello-slot keeps the ref in module scope). At e1efe72a3 a bootstrap-snapshot or wire row for a key not yet defined is parked (thread rows keyed <key>@<threadId>) and applied when that key's define lands; before, the facade dropped it. |
usePreference |
<T>(ref: PreferenceRef<T>, args?: ScopeArgs) => [T, (next: T) => Promise<void>]; also <T>(key: string, args?); ScopeArgs = { threadId?: string } |
Subscribe to one cell and write it. thread scope requires { threadId }. The setter validates with the schema and resolves when the write lands. |
07 §7.5 | hooks.ts → usePreferenceOf. A profile/thread cell reads its default until the first core read lands — unless the bootstrap snapshot already carried the row, which at e1efe72a3 is parked through an app tier's late define and applied then, so the first paint shows the stored value (before, the row was dropped and the cell showed the default until its own kernel/preferences.get answered); the hook's setter always sends expectedUpdatedAt: null (last-writer-wins; CAS is facade-internal, used by the dock-tab writer). A remote value the schema rejects reads as the default. |
4.8 Data: defineQuery, defineMutation, defineRowStore, defineStore
Module-level definitions that bind to the kernel lazily from a component; every .use()/.useHandle() is a hook (D18). The kernel owns the cache, the realtime subscriptions and invalidation (A5). client in the callbacks is the BbClient (kernel-ui/src/data/client.ts): { serverUrl: string; query<T = JsonValue>(serviceId: string, method: string, input: JsonValue): Promise<T>; mutate<T = JsonValue>(serviceId, method, input: JsonValue): Promise<T> } — query = GET /api/v1/<service>/<method>?input=<json>, mutate = POST, X-BB-Client on every request, an error envelope → KernelError. T is a statement, not a parse: hello-slot parses the body with the contract's output schema. I is unconstrained; a method whose input is z.strictObject({}) takes {} (second example below).
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
defineQuery |
<I, O>(def: QueryDefinition<I, O>) => UseQuery<I, O>; QueryDefinition = { key: string; fetch(input: I, client): Promise<O>; invalidateOn: InvalidateOn<I>[]; staleMs?: number; debounceMs?: { min; max } } |
Cache entries are keyed <pluginId>/<key> + JSON(input) (the plugin id is the ambient PluginContext, "kernel" outside a mount), so two components share one entry. On mount: refetch when dirty or older than staleMs (default 0). Invalidation: the kernel subscribes to each wire target while a component uses the entry, debounces 50 ms (200 ms ceiling), defers while document.hidden, refetches every mounted entry on reconnect, and evicts an entry 5 min after its last listener leaves. |
03 §8.5, 07 §7.6 | data/define-query.ts QueryClient. |
InvalidateOn<I> |
{ name: string; key: string | null | ((input: I) => string) } | { entity: KernelEntity; id?: (input: I) => string } |
A wire target. name must be <owner>/<name> (else invalid_contract at definition). key: null matches every frame of the name, keyed or not (define-query.ts); an event declared with wire.key: null (§2.4) is matched only by key: null. The entity sugar maps to kernel/<entity>.changed with key <entity>:<id>; KernelEntity = "thread" | "project" | "environment" | "host" | "thread-head" | "interaction" | "thread-annotation". |
07 §7.6 (no entity sugar) | Built has the sugar (invalidationTargets). |
UseQuery<I, O> |
{ use(input: I): QueryState<O> & { refetch(): Promise<void> }; useHandle(): QueryHandle<I, O> } |
QueryState<O> = { status: "loading" | "success" | "error"; data: O | undefined; error: KernelError | null; updatedAt: number | null; fetching: boolean }. QueryHandle = { key; use; fetch(input): Promise<O>; invalidate(input?): void; peek(input): QueryState | null; setData(input, update): void } for imperative use and optimistic patches. useHandle() is a hook: call during render, capture for handlers. |
03 §8.5 | hooks.ts. A failed background refetch lands in error and keeps stale data (status stays success). |
defineMutation |
<I extends JsonValue, O>(def: MutationDefinition<I>) => UseMutation<I, O>; MutationDefinition = { ref: string; method: string; optimistic?(input: I): () => void; silent?: boolean } |
client.mutate(ref, method, input). optimistic runs before the call and returns the rollback that runs on error. Errors toast "<ref>.<method> failed" unless silent, then rethrow as KernelError. UseMutation = { use(): { mutate(input): Promise<O>; pending; error: KernelError | null }; useHandle(): MutationHandle }; MutationHandle = { run(input): Promise<O>; use() }. |
03 §8.5 | data/define-row-store.ts defineMutation. |
defineRowStore |
<Row, Op>(def: RowStoreDefinition<Row, Op>) => UseRowStore<Row, Op>; RowStoreDefinition = { key; page({ threadId, before: number | null, limit }, client): Promise<{ rows; seqEnd }>; resume({ threadId, after }, client): Promise<{ ops: SeqOp<Op>[]; seqEnd }>; apply(state: RowState<Row>, ops: Op[], ctx: { threadId: string }): RowState<Row>; follow: { name; key(threadId): string; ops(payload): SeqOp<Op>[] | null }; pageSize? (200) } |
The generic keyed-event store: one first page per thread, one wire follow (kernel/store.rows keyed thread:<id>), ops applied in seq order. SeqOp<Op> = { seq; op }; an op at or below the store's seqEnd is skipped; ops arriving while a page/catch-up is in flight are buffered, then gated. A stale reconnect calls resume; a failed page/catch-up sits in RowState.error and retries on the next reconnect. Row payloads apply immediately, never debounced. RowState<Row> = { rows: Row[]; seqEnd: number | null; error: KernelError | null; exhausted: boolean }. At e1efe72a3: apply receives { threadId } as its third argument, for ops that name no thread (a children cache, an empty window); a two-argument apply still type-checks. exhausted is a required member, true once a page (the first page or a loadOlder) came back with fewer than pageSize rows — the "Load earlier" gate; an apply that returns a fresh RowState literal must carry it (spread the incoming state), and a resume catch-up leaves it as it was. For kernel rows, Row from /contracts (§2.11) is the row type. |
07 §7.6, §7.10 | define-row-store.ts (shape is the built one, D18; exhausted and ctx.threadId added at e1efe72a3); idle eviction 5 min. |
UseRowStore<Row, Op> |
{ use(threadId): RowState<Row> & { loading: boolean; loadOlder(before: number): Promise<void> }; useHandle(): RowStoreHandle<Row>; definition: RowStoreDefinition } |
loadOlder takes the oldest seq the plugin holds (the kernel knows no row shape); stop offering it once exhausted is true. RowStoreHandle = { use; peek(threadId): RowState | null }. definition is exposed for tests and a server-side groupRows caller. |
03 §8.5 | hooks.ts. |
export const notesQuery = defineQuery<{ pinnedOnly: boolean }, Note[]>({
key: "list", fetch: (input, client) => client.query("notes-fixture/notes", "list", input),
invalidateOn: [{ name: "notes-fixture/note.changed", key: null }, { entity: "thread", id: () => threadId }],
});
const q = notesQuery.use({ pinnedOnly: false }); // { status, data, error, refetch, … }
const handle = notesQuery.useHandle(); // a hook too; use `handle.invalidate()` in an event handler// no input: the cache entry is `<pluginId>/total:{}`
export const totalQuery = defineQuery<Record<string, never>, Total>({
key: "total", fetch: (input, client) => client.query("word-count/counter", "total", input),
invalidateOn: [{ name: "word-count/counted", key: null }],
});
const total = totalQuery.use({});4.9 useService and errors
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
useService |
<C extends AnyContract>(contract: C) => HandleOf<C>; <T = unknown>(id: string) => T |
By contract object (imported from the plugin's own contracts module through the import map) the handle is typed: HandleOf<C> = ServiceDefHandle<C> for a defineService object (method(input, opts?), AsyncIterable for stream, HTTP pair for custom) or kernel-core's ServiceHandle for a bare contract (AnyContract = ServiceDef | ServiceContract). By id (kernel-ui/navigation, kernel-ui/toasts, kernel-ui/layout, or a uses service) the caller states the type. Suspends while the binding resolves; throws ServiceUnavailable (a KernelError, code service_unavailable) when nothing provides it; re-renders when the binding changes. |
07 §7.7 | hooks.ts + services/use-service.ts. Resolution: local kernel-ui/* objects → local container → core scope (RemoteContainer over one WebSocket at /api/v1/kernel/scope). The contract is registered with the resolver through Suspense on first use. Suspense lands in the occupant's own SlotMount (fallback = owner fallback), never the React root. A lost scope socket invalidates every handle, toasts once, reconnects with backoff, and re-injects (H1). Catch the unavailable error after your other hooks so hook order stays stable (hello-slot useGreeter). |
KernelError |
class KernelError extends Error { code: string; message; issues: Issue[] | null; data: JsonValue | null; retryable: boolean; toJSON(): KernelErrorShape; withData(extra) } |
The one error shape. Codes: invalid_input, invalid_output, invalid_facts, invalid_contract, reserved_name, unknown_method, unknown_command, unknown_event, not_found, unauthenticated, forbidden, vetoed, conflict, precondition, service_unavailable, needs_configuration, stale_handle, scope_disposed, timeout, cancelled, dispatch_depth, activation_loop, plugin_error, internal. A handle rejects with stale_handle across a reload; HTTP envelopes map back to the same codes. |
02 §8 | kernel-core/src/errors.ts; QueryState.error, MutationHandle.use().error and RowState.error carry it. |
useToasts / Toasts |
() => Toasts; Toasts = { show(input: ToastInput): string; dismiss(id): void }; ToastInput = { tone: ToastTone; title; description: string | null; action: { label; onClick } | null }; ToastTone = "message" | "success" | "warning" | "error" | "loading" |
kernel-ui/toasts: a queue the kit's sonner Toaster drains (kitToastSink; the record id is the sonner id). |
07 §7.2 | services/toasts.ts; raised before mount → queued. |
Dialogs (type only) |
{ open<P, R>(Component: DialogComponent<P, R>, props: P, opts: { size: "sm" | "md" | "lg"; dismissible: boolean }): DialogHandle<R>; confirm({ title; description; confirmLabel; destructive }): Promise<boolean> }; DialogComponent<P, R> = ComponentType<P & { close(result: R): void }>; DialogHandle<R> = { result: Promise<R | undefined>; close(): void } |
The contract of ui-shell/dialogs (useService<Dialogs>("ui-shell/dialogs")). The kernel ships the type; a composition without ui-shell has no provider. |
07 §7.1 | services/contracts.ts. |
Attention, AttentionLevel, AttentionTarget (type only) |
{ set(source: string, level: AttentionLevel, target: AttentionTarget): void; clear(source): void }; AttentionLevel = "none" | "info" | "attention" | "urgent"; AttentionTarget = { kind: "app" } | { kind: "thread"; threadId } | { kind: "pane"; paneId } | { kind: "nav"; itemId } |
The contract of ui-shell/attention. |
07 §7.3 | services/contracts.ts. |
Events, EventDecl, BbServices, ServiceDef, ServiceDefHandle |
interface Events { "<owner>/<name>": EventDecl<Payload, "emit" | "waterfall" | "parallel", Result> }; interface BbServices {} |
Declaration-merge hooks and authoring types shared with /contracts, re-exported so app code can name wire events (invalidateOn, follow) and handle types without a second import path. |
02 §5, §11 | contracts/index.ts; kernel-core/src/events.ts declares the kernel events (kernel/preferences.changed, kernel/activation.changed, kernel/catalog.changed, …). |
// hello-slot app.tsx: tolerate service_unavailable after every other hook has run, so the hook order stays stable
function useGreeter(): GreeterHandle | null {
try { return useService(greeter); } catch (error) {
if (error instanceof KernelError && error.code === "service_unavailable") return null;
throw error; // a Suspense promise or a real bug
}
}4.10 Every app export (29 values, 21 hooks at e1efe72a3)
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
definePluginApp |
(def) => PluginApp |
§4.1. | 07 §2.3 | define-plugin-app.ts |
defineQuery |
(def: QueryDefinition) => UseQuery |
§4.8. | 03 §8.5 | hooks.ts |
defineMutation |
(def: MutationDefinition) => UseMutation |
§4.8. | 03 §8.5 | hooks.ts |
defineRowStore |
(def: RowStoreDefinition) => UseRowStore |
§4.8. | 03 §8.5 | hooks.ts |
defineStore |
({ init, persist?, actions }) => StoreDefinition |
§4.2 store option. |
07 §5.7.1 | slots/store.ts |
useService |
(contract | id) => handle |
§4.9. | 07 §7.7 | hooks.ts |
usePreference |
(ref | key, args?) => [value, set] |
§4.7. | 07 §7.5 | hooks.ts |
useNavigation |
() => Navigation |
§4.5; facade defaults filled. | 07 §3.2 | hooks.ts |
useShellHost |
() => ShellHost |
The negotiated shell (§4.12); shell.has(id) gates every capability; never branch on hello.surface for a feature. |
07 §9 | hooks.ts → kernel useShell |
useToasts |
() => Toasts |
§4.9. | 07 §7.2 | KernelRoot.tsx |
useCommandContext |
(key, active) => void |
§4.6. | 07 §6.1 | KernelRoot.tsx |
useCommandHandler |
(id, handler, deps, opts?) => void |
§4.6. | 07 §6.1 | KernelRoot.tsx |
useCommands |
() => ClientCommands |
§4.6; run(id, input?) / has(id). |
07 §6.1 | hooks.ts (added at e1efe72a3) |
useShortcut |
(id) => Shortcut | null |
§4.6. | 07 §6.2 | KernelRoot.tsx |
useIsCommandModifierHeld |
() => boolean |
§4.6. | 07 §6.1 | KernelRoot.tsx |
useModalPresence |
(id, open) => void |
§4.6. | 07 §6.1 | KernelRoot.tsx |
useLayoutTree |
(selector) => T |
§4.5. | 07 §4.4 | KernelRoot.tsx |
usePane |
() => PaneProps |
§4.5. | 07 §4.6 | LayoutRoot.tsx |
LayoutRoot |
(props: LayoutRootProps) => ReactNode |
§4.5; the pane tree for a plugin-owned root. |
07 §3.1, §4.5 | LayoutRoot.tsx (exported at e1efe72a3) |
useLayoutMode |
() => LayoutMode |
"compact" | "regular": bootstrap override → shell layoutModeHint → (max-width: 767px); a LayoutModeProvider pin wins in its subtree. |
07 §8 | @bb/ui (G6; kernel installs its source) |
useIsCompact |
() => boolean |
useLayoutMode() === "compact". |
07 §8 | @bb/ui |
usePointerCoarse |
() => boolean |
(pointer: coarse); orthogonal to layout mode. |
07 §8 | @bb/ui |
usePortalScopeProps |
() => { "data-bb-portaled-overlay": ""; "data-bb-plugin"?: string } |
Spread on portaled overlay content so plugin CSS reaches it and the desktop shell routes pointer input; kit overlays do it themselves. Omits data-bb-plugin outside a plugin mount. |
07 §5.8 | @bb/ui over PortalScopeProvider (mounted by SlotMount) |
useOwnedEffect |
(release: () => void) => void |
Registers imperative state (locks, drag sessions, text effects) with the crash boundary's release registry; runs on crash and on unmount. Pass a stable function. | 07 §5.8 | SlotMount.tsx |
useCloseGuard |
(guard: () => boolean | Promise<boolean>) => void |
Answers window.shouldClose: a guard returning true consumed the close ({ close: false }). |
07 §9.2 (200 ms) | create-ui-kernel.ts: 150 ms deadline, a throwing guard counts as false. |
useAppPluginStatus |
(pluginId) => { status: PluginStatus; appTier: TierState; error: string | null; hash; generation; loaded } | null |
The in-memory app-tier table (§4.12). | 07 §2.3 step 6 | plugin-frontends.ts AppTierTable |
useCatalog |
() => Catalog | null |
The last kernel-ui/catalog.get answer { revision; slots[] { name, kind, scope, owner, occupant }; panes[]; tabs[] }; null before the first kernel/catalog.changed fetch. |
07 §5.9 | boot/catalog.ts (shape is kernel-ui's request to 03) |
CrashBoundary |
class extends Component<{ onCrash(error) }> |
The error boundary SlotMount uses; renders nothing after a crash. Exported for a plugin that wants its own inner boundary. |
07 §5.8 | SlotMount.tsx |
KernelError |
class | §4.9. | 02 §8 | @bb/kernel-core |
4.11 Components and the import map
bb plugin build marks every import-map specifier external; the browser resolves them through the map core inlines into index.html (hashed, immutable URLs). Bundles load with import("<jsUrl>?h=<hash>").
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
IMPORT_MAP_SPECIFIERS |
react, react-dom, react-dom/client, react/jsx-runtime, @get-bb/plugin-sdk/app, @bb/ui, @bb/ui/ (prefix), @radix-ui/react-dialog, -dropdown-menu, -popover, -tooltip, -context-menu, -select, -alert-dialog, -hover-card, sonner, @pierre/diffs, @pierre/diffs/react, clsx, tailwind-merge, class-variance-authority |
One React, one Radix dismissable-layer world, one sonner, pierre's worker pool, the cn() pair. vaul, react-menubar, react-navigation-menu are bundled, not mapped. |
07 §2.1 | boot/import-map.ts. The build also leaves react/jsx-dev-runtime, @get-bb/plugin-sdk/contracts (so app.tsx may import its own ./contracts.js) and @bb/design-tokens bare (plugin-build/src/externals.ts, D18); zod is bundled into every tier. |
LayoutMode |
"compact" | "regular" |
The kit's type; see useLayoutMode (§4.10). |
07 §8 | @bb/ui |
@bb/ui exports |
see table below | The kit: one copy per window, semver'd with its own major (peer: { "@bb/ui": "^1" } in the manifest), source-visible for bb plugin vendor. |
07 §11, Q18 | packages/ui/src/index.ts |
@bb/ui group |
Names on the import map |
|---|---|
| Primitives | Button, Input, Textarea, Label, Checkbox, RadioGroup/RadioGroupItem, Switch, Separator, Skeleton, Badge, Pill, Kbd, EmptyState/EmptyStatePanel, OptionDisplay, Tabs/TabsList/TabsTrigger/TabsContent, ScrollArea/ScrollBar, Tooltip*, Select*, Dialog*, Popover*, DropdownMenu*, ContextMenu*, CompactLongPressMenu, Command*, Icon (+ registerIcons, ICON_NAMES) |
| Composites | CopyButton, CopyableInlineLabel, TruncateStart, TruncatedList, ExpandableLine, OverflowFade, ScrollToBottomButton, SettingsSection/SettingsRow/SettingsRowList/SettingsBadge/SettingsWithControl, DetailCard/DetailRow/DetailRowIconLabel, TabPill, SplitButton, ImageLightbox, BbLogo, BottomAnchoredScrollBody, PageShell, RouteLoadingSkeleton, HeightTransition, AutoHeightContainer, CollapsibleHeader, ExpandablePanel |
| Drawer, toast, shortcuts | PersistentDrawer, ResponsiveDrawer, MobileTrigger, useResponsiveRoot, useDrawerRealization, stripRadixContentProps; Toaster, toast, ToastContent; ShortcutHint, formatShortcut, formatShortcutAria, presentShortcut |
| Hooks and tokens | cn, useLayoutMode, useIsCompact, useMediaQuery, usePrefersReducedMotion, usePointerCoarse, useColorScheme, useHoverPopover, usePortalScopeProps/PortalScopeProvider, LayoutModeProvider, CONTROL_HOVER_TRANSITION, LIST_HOVER_TRANSITION, COARSE_POINTER_*, CHROME_*_CLASS, activity*Class, MenuHoverProvider, useClipboardCopy, createScrollAnchorRegistry, beginLayoutAnimation |
@bb/ui/domain |
Markdown, Diff, SourceCode (facades resolved through ui-markdown/render, slot:diff.renderer, slot:source.renderer; Unavailable when unbound), ProviderIcon, ModelLabel, FileBytes, Unavailable; concrete renderers in @bb/ui/domain/impl for the providing plugins only |
IconName (@bb/ui, packages/ui/src/primitives/icon/icon.tsx) is a core name or an extended name; PluginIconName is <pluginId>/<name>. The 46 core names (CORE_ICON_MAP, on the boot path): AlertCircle, AlertTriangle, Archive, Bug, Check, ChevronDown, ChevronLeft, ChevronRight, Circle, CircleCheck, CircleQuestion, CircleX, ClosePluginPane, CloseThreadPane, Code, ComputerTerminal01, Copy, Download, Edit, Folder, FolderExport, FolderGit, FolderPlus, Info, ListTodo, Loading, MessageQuestion, MessageCirclePlus, MessageSquarePlus, MessageSquare, MoreHorizontal, PanelLeft, Search, SectionAdd, Settings, SlidersHorizontal, Spinner, Target, Terminal, Toolbox, ToolCase, Trash2, UserRoundPlus, Workflow, X, Zap. The 95 extended names (EXTENDED_ICON_NAMES, icon-registry.ts) load with the first route that needs them; ICON_NAMES lists both. <Icon name> takes IconName | PluginIconName; plugin glyphs enter the namespace through registerIcons(pluginId, map), and no kernel-ui or app code in the slice calls it for contributes.icons.named, so a <pluginId>/<name> value (hello-slot's hello-slot/wave) has no renderer today — use a core or extended name. app.panes.register icon and app.tabs.register icon take the same IconName (layout-store.ts). Props: Button = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "title"> & { variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; size?: "default" | "sm" | "lg" | "icon"; asChild?: boolean } (primitives/button.tsx); Input = React.ComponentProps<"input"> (primitives/input.tsx); both forward their ref, and every other primitive passes its HTML or Radix props through the same way (packages/ui/src/primitives/<name>.tsx is the prop reference).
CSS rules (07 §2.5, §5.8, R34; packages/plugin-build/src/scope-plugin-utilities.ts): the build rewrites the compiled utilities layer to :where([data-bb-plugin="<id>"]) .cls, :where([data-bb-plugin="<id>"]).cls (both arms; never @scope). Authored CSS (global.css/app.css) must root every selector at [data-bb-plugin-effect="<own id>"], including selectors nested in @media/@supports; anything else fails the build. Stamp data-bb-plugin-effect="<contributorId>" on DOM you paint for another plugin (ui-editor-tiptap decorations) so the contributor's sheet reaches it. Overlays portaled to body carry data-bb-plugin through usePortalScopeProps. @bb/ui/ui.css and the app's theme.css are the host sheets; a plugin never imports Tailwind itself. The plugin's own dist/app.css is served as the descriptor's cssUrl and activated at commit (§4.12); at dc07292bf it never loaded — core looked the digest up under dist/app.css while the build keys meta.digests by the path under dist/ (app.css), so cssUrl was always null — and e1efe72a3 fixes the key (core/src/app.ts appDescriptor). Only core changed: a plugin built before the fix needs no rebuild. Manifest contributes.tokens values must derive from var(--canvas|--ink|<theme color>) or a color-mix of those.
4.12 Bundle and load lifecycle
bb plugin build writes dist/app.mjs, dist/app.css, dist/app.contributions.json (and meta.json with sdkMajor: 1, uiMajor: 1, per-file digests). app.contributions.json has no optional field: declares{} (each child slot: kind, scope, JSON-Schema props, boot, overlay, fallback, owner — the declaring registration's slot name, pane:<kind> for a pane kind's children, or the slot an inject thunk's registration targets; the last two at e1efe72a3), registers[] (name, kind, scope, key, priority, order, hasStore), injects[], routes[], panes[] (kind, priority, hasDock), tabs[] (kind, priority, persist incl. "dynamic", singleton, closable), commands[] (id, defaultShortcut, desktopOnly, menu), preferences[] (key, scope, default, schema). It is the app tier's only claim; contributes.slots in the manifest is informational and cross-checked at build (D9).
| Step | What happens | Spec | As built |
|---|---|---|---|
| bootstrap | GET /api/v1/kernel-ui/bootstrap → { generation, catalogRevision, plugins: AppPluginDescriptor[], preferences (profile snapshot), layoutMode }; cached in localStorage["bb.uiBootstrap"] so the next cold start imports before the network answers. Descriptor: { id, version, generation, replaces, jsUrl, cssUrl, hash, jsBytes, sdkMajor, uiMajor, contributions, status: PluginStatus, appTier: TierState }. cssUrl is non-null when meta.digests has app.css (at e1efe72a3; before, core tested dist/app.css and it was always null, §4.11). |
07 §2 step 6, §2.3 | boot/bootstrap.ts; replaces added for order(); core/src/app.ts appDescriptor. |
| loadable | only when status ∈ running | degraded and appTier ∈ ready | stale. PluginStatus = missing | incompatible | needs-update | disabled | replaced | waiting | activating | running | degraded | needs-configuration | failed | disposing | disposed (01 §4.1, R13); TierState = absent | ready | needs-update | error | stale. |
07 §2.3 | isLoadable. |
| waves | Wave 1 = plugins owning the current route, registering into or injecting into a critical slot (root + every boot: "critical" declaration), or declaring children of one; wave 2 = the rest, started at route paint + idle or 1,500 ms after bootstrap. Order: route owner, then smallest jsBytes; 3 concurrent imports; commits in sorted id order after the wave arrives. |
07 §2.2 | planWaves, runWave, WAVE2_DEADLINE_MS. |
| one plugin | gate sdkMajor === 1 && uiMajor === 1 (else needs-update) → preload CSS → import(jsUrl) → brand check → setup(api) in the collector → claim check → atomic commit (prefs, routes, commands, kinds, then slots; rollback of all on failure) → CSS activate (new <link data-bb-plugin-css> beside the old, swap on load; on error keep the old and record appTier: "error") → table entry. One toast per (id, hash) failure. |
07 §2.3, §2.5 | prepareFrontend / commitFrontend. CSS activates at commit, not first mount. |
| reconcile | kernel/activation.changed → refetch bootstrap → reload descriptors whose hash, generation or status changed; unload those gone or no longer loadable (slots, routes, commands, kinds, preference definitions, CSS dropped; occupants unmount because the generation is in the instance key; latches cleared). Concurrent passes coalesce; pageshow.persisted runs one. kernel/catalog.changed refetches the catalog only. |
07 §2.4 | reconcile, refresh(). |
ShellHost |
{ hello: ShellHello; has(id, version?): boolean; require(id, version?): Capability; currentServer(): ServerEntry }: the negotiated shell (@bb/shell-contract), via useShellHost(). Absent capability → render the kit's unavailable state. The kernel itself uses window (chrome variables, shouldClose, setTitle), keyboard (--shell-keyboard-height) and servers. |
07 §9 | shell-contract/src/host.ts: has/require match an exact version (default 1). |
4.13 Types index
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
Actor |
{ kind: "human" | "agent" | "plugin" | "system"; id: string } |
Who invoked a command (Invocation.actor); the tab runs as human:local. |
02 §7 | kernel-core/src/actor.ts |
JsonValue / JsonObject |
JSON unions | Pane state, intents, preference values, mutation inputs. | 02 §1 | @bb/kernel-core |
SlotKind / SlotScope |
"single" | "list" | "keyed" | "chain" / "root" | "thread" | "pane" |
The open unions behind §4.2. | 07 §5.1 | slot-tree.ts |
PluginStatus / TierState |
see §4.12 | Re-exported from @bb/kernel-loader/status. |
01 §4.1 | kernel-loader/src/status.ts |
| Slot types (§4.2–4.3) | RegisterOptions, SlotsApi, SlotMap, SlotEntry, SlotKindOf, SlotScopeOf, SlotPropsOf, SlotDeclarationInput, SlotComponent, SlotComponentInput, SlotComponentProps, ScopeProps, RenderSlot, RenderSlotOptions, StoreDefinition, StoreFace |
pure aliases / interfaces | 07 §5 | slots.ts |
| Layout types (§4.5) | RouteContribution, PaneKindInput, TabKindInput, PaneProps, PaneComponentProps, TabProps, PaneTarget, OpenWhere, OpenOptions, Navigation, LayoutTree, DockOwnerProps, DropZoneOwnerProps, LayoutRootProps, DragSession (the last two at e1efe72a3) |
pure aliases / interfaces | 07 §3–§4 | router.ts, layout-*.ts, LayoutRoot.tsx |
| Command types (§4.6) | CommandContributionInput, CommandHandler, Invocation, ContextKey, When, Shortcut, ClientCommands (at e1efe72a3), Commands, CommandInput, CommandOutput |
pure aliases / interfaces | 07 §6, 02 §6.3 | command-bus.ts, kernel-core |
| Preference and data types (§4.7–4.8) | PreferenceScope, PreferenceRef, ScopeArgs, QueryDefinition, QueryHandle, QueryState, InvalidateOn, KernelEntity, UseQuery, MutationDefinition, MutationHandle, UseMutation, RowStoreDefinition, RowStoreHandle, RowState, SeqOp, UseRowStore |
pure aliases / interfaces | 07 §7.5–7.6, 03 §8.5 | preferences.ts, define-query.ts, define-row-store.ts, hooks.ts |
| Service types (§4.9) | AnyContract, HandleOf, ServiceDef, ServiceDefHandle, BbServices, Events, EventDecl, Toasts, ToastInput, ToastTone, Dialogs, DialogComponent, DialogHandle, Attention, AttentionLevel, AttentionTarget |
pure aliases / interfaces | 07 §7, 02 §5, 02 §11 | contracts/handles.ts, services/*.ts |
| Shell and app types (§4.1, §4.10–4.12) | PluginApp, PluginAppApi, ShellHost, LayoutMode |
see the cited subsection | 07 §2.3, §8, §9 | define-plugin-app.ts, @bb/shell-contract, @bb/ui |
4.14 Typed slots and the generated catalog
The SlotMap machinery (§4.2) is built; the catalog around it is design. Guide 16 is the worked form. Normative rules:
| Rule | Statement | Status |
|---|---|---|
| publication | The declarer publishes its SlotMap merge and named owner-prop types in src/contracts.ts, so they ship in dist/types/contracts.d.ts under the ./contracts export (§2.1). A merge in app.tsx or an app helper types one file and publishes nothing. |
built (convention; tsconfig.types.json includes only src/contracts.ts) |
| ownership | One merge per slot name, owned by the declarer. A consumer imports the declarer's contracts module; it hand-merges only when the declarer publishes nothing, naming only the fields it reads. Two incompatible merges in one program fail the compile — that failure is correct, not a bug to route around. | built (TypeScript semantics) |
| generation source | The generated catalog joins three sources, in precedence order: (1) runtime declarations — every installed artifact's app.contributions.json declares plus the kernel's a priori slots — authoritative for existence, kind, scope, owner; (2) the declarer's published merge, authoritative for props TypeScript; (3) the declaration's JSON-Schema props, generating structural fallback types (callbacks degrade to (...args: unknown[]) => unknown; the collector records functions as any, §4.12). |
design |
| generator | bb sdk types --slots writes bb/slots.d.ts for the plugin workspace: an @get-bb/plugin-sdk/app SlotMap augmentation covering every installed declaration, re-exporting published merges by reference and synthesizing the rest from JSON Schema. Disagreement between a published merge and the runtime declaration (kind/scope), or between two published merges, fails generation and names both sources. |
design (rides bb sdk types, §8.3) |
| distribution | Outside the workspace, types resolve from the installed artifact, never npm: bb sdk types materializes each installed plugin's dist/types/ into bb/types/<id>/ and emits a generated paths block mapping @get-bb/plugin-*/contracts (and /app, §4.16) onto it. Types and bytes share one digest-addressed artifact, so they cannot skew; rerun after install or update, the same habit as the service declarations. Publishing a types package to npm stays optional, never required. |
design (Guide 16 step 4) |
| dump | bb plugin slots [<pluginId>] [--json] prints the joined catalog row per slot: name, kind, scope, declaring plugin, owner (<slot> | pane:<kind> | tab:<kind>), boot, overlay, fallback, props schema, whether a published merge exists, current occupants. --json is the machine-readable catalog; §5 is its prose rendering. |
design |
| exclusions | Generation reads built artifacts, never source, so test-only slots (test.* in app.test.*) and app-only mirrors cannot enter the catalog. contributes.slots (a flat claim of registration/injection targets, §1.3) contributes nothing to generation. |
design |
4.15 Cross-plugin slots: the contract
The mechanism is §4.2 (children, inject) and §4.4 (arbitration); Guide 17 is the worked form. The contract between a declarer and an injector:
| Clause | Statement | Status |
|---|---|---|
| no dependency | inject never creates a dependency. An injector whose target has no live declaration is healthy: the thunk stays armed, runs when any declaration with that name appears (registration children, or a pane/tab kind's children while that kind's plugin wins it), and its registrations drop when the declaration vanishes. A plugin that is useless without the declarer states requires (§1.3) instead. |
built (slot-tree.ts runInjects) |
| absence semantics | Every declarer failure is silent for the injector: never installed / disabled → thunk armed, never run; unloaded at runtime → registrations dropped, thunk re-arms; redeclared → thunk re-runs; slot dropped in a new version → registrations dropped, thunk waits. The injector's status is unaffected in every row. The only injector-degrading event is its own thunk throwing: partial registrations dropped, degraded, one toast, no retry until the injector reloads (§4.2). |
built |
| name is the contract | A slot name binds kind, scope, props, and owner-prop behavior. A compatible change (new optional prop) keeps the name; an incompatible change (removed/retyped prop, kind or scope change) requires a new name, because the kernel re-realizes thunks on name existence only — a declaration that changes shape under a realized injector does not re-run or re-validate it, and the stale registration renders wrong props, not an error. | rule (the gap it guards is as built: runInjects checks the name only) |
| arbitration parity | Injected registrations arbitrate identically to the declarer's own (replaces → pin → priority → sorted id); the declarer has no reserved rank in its own slot. An out-arbitrated declarer's declaration is parked, not removed: its registrations stay in the map, reachable through an Original chain (resolve checks declaration, not liveDeclaration — as built). Treat a parked slot as gone; the parked-reachability edge is not contract. |
built, with one as-built edge |
| discovery | Build-time: the declarer's published merge, §5, or bb plugin slots (§4.14, design). Runtime: useCatalog() — flat {name, kind, scope, owner, occupant} claims from every installed app tier, no props, no live/parked distinction (§4.10). There is deliberately no runtime API that enumerates foreign declarations with props; props travel through types. |
built |
| worker rows | Composition isolation applies to the server tier only (§1.10); app tiers always load in the browser, so a worker-isolated declarer or injector behaves identically here. A worker exit fails the row; the next reconcile unloads its app tier and the unload row above applies. | built |
4.16 App module exports
Status: design; the import path below is not built at f97fbe617 (Appendix B, H9). Components already cross the boundary by value (first row); what does not exist is sharing by import: the builder rejects sibling imports other than @get-bb/plugin-<id>/contracts (externals.ts), and the loader imports only the descriptor's jsUrl, ignoring named exports (the ./app package entries on ui-composer and git serve tests only). Guide 18 is the worked form; the surface:
| Member | Definition (design) |
|---|---|
| by-value sharing (as built) | Three channels, none an import: (1) slot machinery — owner props and registration objects carry components (Original, markdown.extension's { name, Component } rows, composer.typeahead.source/composer.submit data-slot values, message.action occupants); (2) the @bb/ui/domain facades (Markdown, Diff, SourceCode), resolved through ui-markdown/render and the renderer slots (§4.11); (3) browser-local app services — the built UI plugins publish service objects, components included, through one window table keyed Symbol.for("bb.ui-shell.services") (each plugin carries a verbatim app-services.ts copy; consumers resolve the ids with useService, §4.9). The table is an interim mechanism: the SDK's missing piece is app.services.provide, and the copies are deleted when it lands. By-value sharing is asynchronous (value exists only after the provider commits; consumers render a fallback) and untyped beyond the service interface; the rows below add the synchronous, versioned, build-time path. |
AppServices + app.services.provide |
The typed retirement of the window table. The SDK adds interface AppServices {} as a declaration-merge registry for browser-local services, app.services.provide(id, value): void on PluginAppApi (published at commit, withdrawn with the plugin's generation), and a useService overload keyed by the registry, so useService("ui-markdown/render") returns the merged type with no caller assertion. The provider merges AppServices in its contracts module; React types enter type-only (import type { ComponentType } from "react" erases, so contracts stay tier-neutral — ui-timeline-view/contracts.ts already does this). Absence keeps the service semantics: suspend while resolving, then service_unavailable (§4.9). Every copied app-services.ts is deleted, not edited, when this lands. |
bb.appExports |
Manifest key naming the export entry (./src/app-exports.ts); requires bb.app. Built to dist/app-exports.mjs with the app tier's external rules (§4.11); named exports recorded in meta.json appExports: string[]; top-level statements other than declarations and re-exports fail the build (side-effect-free at import). |
contributes.appImports |
Record<pluginId, major> on the consumer. Plan-time check: exporter absent/disabled or appExports surface absent → consumer app tier not loadable, degraded, detail missing_app_import; major mismatch → needs-update. An unclaimed @get-bb/plugin-<id>/app import stays a build error. |
| import path | @get-bb/plugin-<id>/app, left bare by the builder when claimed; resolved in the browser through a per-exporter import-map entry core inlines into index.html: /plugins/app/<id>/app-exports.mjs?h=<digest> (digest-suffixed, immutable). Types resolve through the exporter package's ./app types condition (dist/types/app-exports.d.ts). |
| identity | The exports bundle shares the map's externals, so React, @bb/ui, zod, and the SDK resolve to the single shared runtime modules; one module evaluation per page, shared by all consumers. |
| updates | An import map cannot change after resolution starts: a new exporter's entry appears on the next page load; an exporter artifact update with a loaded claimant triggers toast + page reload (the H1 rule); with no loaded claimant, nothing reloads. Consumer updates reload per-plugin as usual. |
| CSS | An exported component mounts in the consumer's data-bb-plugin subtree, so every exported component renders a root carrying the exporter's data-bb-plugin-effect via usePluginEffectProps() (reads the defining bundle's id, not the ambient mount). The exporter's utilities compile into its own app.css with a [data-bb-plugin-effect="<id>"] arm; that sheet rides the exporter's app tier, which the plan check keeps present. |
importAppModule |
(pluginId: string) => Promise<Record<string, unknown> | null> — the soft path: resolves the map entry, null when absent. Static import = hard dependency (loader-gated); importAppModule = optional presence, injector-style (§4.15). |
| worker rows | Unchanged: exports are browser code served like app.mjs; worker isolation covers server/background entries only. Marketplace review treats appExports as app-tier code that runs inside other plugins' subtrees. |