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 (27 values, 82 types). 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. |
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, or a live occupant's children) or the load fails (07 §5.1; slot-tree.ts validate). |
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). |
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. |
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. |
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} (+ |<mode>), so a new generation or a mode change remounts (07 §5.8; 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. LayoutRoot is not exported from @get-bb/plugin-sdk/app, so a plugin's own root occupant cannot host panes: it renders only what it returns. 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 does not appear; render the same panel from both places, as hello-slot does (HelloPanel is both the root occupant and the hello pane component). 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. |
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. |
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. |
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. 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. |
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. |
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). |
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; 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[]): 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 }. |
07 §7.6, §7.10 | define-row-store.ts (shape is the built one, D18); 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). 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 (27 values, 20 hooks)
| 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 |
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 |
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. 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), 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 }. |
07 §2 step 6, §2.3 | boot/bootstrap.ts; replaces added for order(). |
| 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 |
pure aliases / interfaces | 07 §3–§4 | router.ts, layout-*.ts, LayoutRoot.tsx |
| Command types (§4.6) | CommandContributionInput, CommandHandler, Invocation, ContextKey, When, Shortcut, 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 |