bb.layout
bb.layout supplies the app shell, shared panel frame, modal layer, status area, and theme system.
Purpose
The plugin claims the kernel-owned bb.app.root surface. Its winner declares all bb.layout.* child surfaces.
The plugin owns shell chrome and panel dock rules. Domain plugins own the content that the shell shows.
Surfaces
| ID | Kind | Replaceable | Props contract sketch | Notes |
|---|---|---|---|---|
bb.app.root |
single |
yes | AppRootProps |
The kernel declares this surface. bb.layout supplies the default claim. |
bb.layout.sidebar |
single |
yes | LayoutSidebarProps |
The winner owns the complete navigation column. |
bb.layout.main |
single |
yes | LayoutMainProps |
The winner owns the main route region. |
bb.layout.statusbar |
list |
no | LayoutStatusItemRegistration |
All active entries appear in a stable order. |
bb.layout.panels |
list |
no | LayoutPanelRegistration |
Entries declare app-wide panels. The shell owns every dock and frame. |
bb.layout.modals |
list |
no | LayoutModalRegistration |
Entries declare modal content. The shell owns the stack, focus, and backdrop. |
bb.layout.theme |
keyed |
yes | LayoutThemeDefinition |
The key is the stable theme ID. The user selects one key. |
The active root claim declares the six bb.layout.* child surfaces. A root replacement can keep these IDs.
The first-party manifest makes the graph and service edges static:
{
"id": "bb.layout",
"claims": [
{ "surface": "bb.app.root" },
{ "surface": "bb.layout.sidebar" },
{ "surface": "bb.layout.theme", "key": "bb.layout.default" },
{ "service": "bb.layout.themes", "version": "1.0.0" }
],
"surfaces": [
{ "id": "bb.layout.sidebar", "kind": "single", "replaceable": true, "contract": "./contracts#LayoutSidebarProps", "stability": "stable" },
{ "id": "bb.layout.main", "kind": "single", "replaceable": true, "contract": "./contracts#LayoutMainProps", "stability": "stable" },
{ "id": "bb.layout.statusbar", "kind": "list", "contract": "./contracts#LayoutStatusItemRegistration", "stability": "stable" },
{ "id": "bb.layout.panels", "kind": "list", "contract": "./contracts#LayoutPanelRegistration", "stability": "stable" },
{ "id": "bb.layout.modals", "kind": "list", "contract": "./contracts#LayoutModalRegistration", "stability": "stable" },
{ "id": "bb.layout.theme", "kind": "keyed", "replaceable": true, "contract": "./contracts#LayoutThemeDefinition", "stability": "stable" }
],
"services": [
{ "id": "bb.layout.themes", "kind": "single", "replaceable": true, "defaultProvider": "bb.layout", "stability": "stable" }
],
"requires": [{ "service": "bb.storage", "range": "^1" }],
"optional": [{ "service": "bb.realtime", "range": "^1" }],
"watched": [{ "service": "bb.plugins", "range": "^1" }]
}Root, sidebar, and main
The kernel passes only stable window and route facts to the root. The Layout plugin owns all view state.
import type { ComponentType } from "react";
export interface AppRootProps {
route: LayoutRoute;
viewport: LayoutViewport;
safeArea: { top: number; right: number; bottom: number; left: number };
}
export interface LayoutRoute {
path: string;
projectId: string | null;
threadId: string | null;
}
export interface LayoutViewport {
width: number;
height: number;
compact: boolean;
desktopWindow: boolean;
}
export interface LayoutSidebarProps {
route: LayoutRoute;
viewport: LayoutViewport;
panels: readonly LayoutPanelEntry[];
statusItems: readonly LayoutStatusItemEntry[];
closeCompactSidebar(): void;
}
export interface LayoutMainProps {
route: LayoutRoute;
viewport: LayoutViewport;
panels: LayoutPanelController;
modals: LayoutModalController;
}A single winner also gets a typed Original component. The kernel remounts the winner after a winner change.
The root error boundary stays outside the plugin tree. A failed root claim returns to the default Layout claim.
The first-party root uses the child surfaces directly:
function LayoutRoot(props: AppRootProps) {
return (
<LayoutFrame>
<Surface id="bb.layout.sidebar" props={sidebarProps(props)} />
<Surface id="bb.layout.main" props={mainProps(props)} />
<SurfaceList id="bb.layout.statusbar" props={statusbarProps(props)} />
<PanelDock surface="bb.layout.panels" />
<ModalLayer surface="bb.layout.modals" />
</LayoutFrame>
);
}Status bar
The status bar is a list surface. Each entry supplies data or a small component.
export interface LayoutStatusItemRegistration {
id: string;
order?: number;
align?: "start" | "end";
title: string;
icon?: string;
component?: ComponentType<LayoutStatusItemProps>;
onActivate?(props: LayoutStatusItemProps): void | Promise<void>;
}
export interface LayoutStatusItemProps {
compact: boolean;
route: LayoutRoute;
panels: LayoutPanelController;
modals: LayoutModalController;
}
export interface LayoutStatusItemEntry {
pluginId: string;
registration: LayoutStatusItemRegistration;
}The shell renders a data-only entry as a standard icon button. The shell supplies its tooltip and focus style.
The default sidebar can show selected status entries in its footer. This placement does not change contract ownership.
Panels
bb.layout.panels stores panel declarations. A declaration does not open its panel.
export type LayoutPanelDock = "sidebar" | "right" | "bottom";
export type LayoutPanelFrame = "padded" | "flush";
export interface LayoutPanelRegistration<TState = JsonValue, TTarget = JsonValue> {
id: string;
title: string;
icon?: string;
component: ComponentType<LayoutPanelProps<TState, TTarget>>;
preferredDock: LayoutPanelDock;
allowedDocks?: readonly LayoutPanelDock[];
frame?: LayoutPanelFrame;
instances?: "single" | "multiple";
persistent?: boolean;
target?: StandardSchemaV1<TTarget>;
navigation?: {
path: string;
order?: number;
accessory?: ComponentType;
};
headerContent?: ComponentType<LayoutPanelHeaderProps>;
}
export interface LayoutPanelEntry {
id: string; // `<pluginId>/<registrationId>`
pluginId: string;
registration: LayoutPanelRegistration;
}
export interface LayoutPanelProps<TState = JsonValue, TTarget = JsonValue> {
panelId: string;
instanceId: string;
dock: LayoutPanelDock;
state: TState | null;
target: { sequence: number; value: TTarget } | null;
focused: boolean;
controller: LayoutPanelController;
}
export interface LayoutPanelHeaderProps {
panelId: string;
instanceId: string;
dock: LayoutPanelDock;
close(): void;
}
export interface LayoutPanelOpenOptions<TState = JsonValue, TTarget = JsonValue> {
instanceKey?: string;
title?: string;
state?: TState;
target?: TTarget;
dock?: LayoutPanelDock;
focus?: boolean;
}
export interface LayoutPanelController {
open<TState, TTarget>(
panelId: string,
options?: LayoutPanelOpenOptions<TState, TTarget>,
): string;
focus(instanceId: string): boolean;
close(instanceId: string): boolean;
move(instanceId: string, dock: LayoutPanelDock): boolean;
updateState<TState extends JsonValue>(instanceId: string, state: TState): void;
clearTarget(instanceId: string, sequence?: number): void;
}The shell creates the full panel ID from the claimant ID and local registration ID. This rule prevents silent collisions.
A single panel uses one instance per panel ID. An open call focuses that instance.
A multiple panel uses instanceKey as its stable instance identity. The controller creates a key when none exists.
The shell stores only JSON state for a persistent panel. It never stores a session target.
Each target gets a larger sequence value. The panel clears only the sequence that it used.
The shell checks the target schema before it opens the panel. An invalid target fails the call without a state change.
The shell can move a panel when the viewport becomes compact. The panel cannot set pixel sizes or frame chrome.
Domain surfaces can use this controller. For example, bb.thread-ui.sidePanels supplies thread panel declarations.
Modals
The modal surface follows the same declaration rule. The shell owns all modal behavior outside the component.
export interface LayoutModalRegistration<TState = JsonValue> {
id: string;
title: string;
component: ComponentType<LayoutModalProps<TState>>;
size?: "small" | "medium" | "large";
dismiss?: "button" | "button-and-backdrop" | "explicit";
}
export interface LayoutModalProps<TState = JsonValue> {
modalId: string;
instanceId: string;
state: TState | null;
close(result?: JsonValue): void;
}
export interface LayoutModalController {
open<TState extends JsonValue>(modalId: string, state?: TState): string;
close(instanceId: string, result?: JsonValue): boolean;
closeTop(result?: JsonValue): boolean;
}The shell gives focus to the top modal. It returns focus to the prior element after the modal closes.
Themes
bb.layout.theme is a keyed app capability. Its key has the form <ownerPluginId>.<localThemeId>.
export type LayoutThemeKey = `${string}.${string}`;
export type LayoutColorMode = "light" | "dark";
export interface LayoutThemeDefinition {
id: LayoutThemeKey;
name: string;
description?: string;
palette: {
light: LayoutThemePalette;
dark: LayoutThemePalette;
};
codeTheme?: {
light: LayoutCodeTheme;
dark: LayoutCodeTheme;
};
}
export interface LayoutThemePalette {
variables: Readonly<Record<`--${string}`, string>>;
colorScheme: "light" | "dark";
}
export interface LayoutCodeTheme {
name: string;
foreground: string;
background: string;
colors: Readonly<Record<string, string>>;
tokenColors: readonly LayoutCodeThemeTokenRule[];
}
export interface LayoutCodeThemeTokenRule {
scope?: string | readonly string[];
settings: {
foreground?: string;
background?: string;
fontStyle?: string;
};
}
export interface LayoutThemeClientState {
selection: LayoutThemeSelection;
mode: LayoutColorMode;
definition: LayoutThemeDefinition;
codeTheme: LayoutCodeTheme;
}The keyed winner rule applies only when two claimants use the same key. The default keys use the bb.layout.* prefix.
The browser resolves the light or dark branch. A mode change does not change the selected theme key.
The theme definition supplies data. It does not run a React wrapper around the app.
The catalog also includes old on-disk custom themes. These themes are Layout-owned data, not dynamic contract claims.
Services
| ID | Kind | Replaceable | Default provider | Edge use | Notes |
|---|---|---|---|---|---|
bb.layout.themes |
single |
yes | bb.layout |
required: bb.storage@^1; optional: bb.realtime@^1; watched: bb.plugins@^1 |
Stores the active theme selection and reads keyed theme claims. |
bb.layout.themes
The service gives every server plugin one typed theme API. The old bb.sdk.theme facade calls this service.
export interface LayoutThemes {
get(): Promise<LayoutThemeState>;
catalog(): Promise<LayoutThemeCatalog>;
// Set the complete selection.
set(selection: LayoutThemeSelection): Promise<LayoutThemeState>;
// Keep the current favicon color and change only the theme.
set(themeId: LayoutThemeKey): Promise<LayoutThemeState>;
}
export interface LayoutThemeSelection {
themeId: LayoutThemeKey;
faviconColor:
| "default"
| "red"
| "orange"
| "yellow"
| "green"
| "teal"
| "blue"
| "purple"
| "pink";
}
export interface LayoutThemeState extends LayoutThemeSelection {
definition: LayoutThemeDefinition;
}
export interface LayoutThemeCatalog {
customThemeDirectory: string;
themes: readonly {
id: LayoutThemeKey;
pluginId: string;
name: string;
description: string | null;
}[];
active: LayoutThemeState;
}The object overload changes the full selection in one call. The string overload keeps the current favicon color.
The compatibility facade converts old IDs, such as nord, to canonical keys before it calls the service.
The service stores the selection through bb.storage. It watches bb.plugins for theme claim changes.
If the selected claimant goes away, the service selects bb.layout.default. It publishes one theme-change event when bb.realtime is available.
Required consumers restart after a service winner change. Watched consumers stay active and receive the new provider state.
Exports
bb.layout publishes exact first-party code through these module paths.
| Module | Export | Purpose |
|---|---|---|
@bb/layout/components |
LayoutRoot |
Renders the first-party shell claim. |
@bb/layout/components |
Sidebar |
Renders the first-party sidebar implementation. |
@bb/layout/components |
Main |
Renders the first-party main region. |
@bb/layout/components |
PanelDock |
Renders the first-party panel frame and tabs. |
@bb/layout/components |
ModalLayer |
Renders the first-party modal stack. |
@bb/layout/components |
Statusbar |
Renders the first-party status item list. |
@bb/layout/hooks |
useLayoutTheme |
Reads LayoutThemeClientState and follows the active keyed theme. |
@bb/layout/hooks |
usePanelController |
Reads the nearest shell panel controller. |
@bb/layout/hooks |
usePanelTarget |
Reads and clears the current panel target by sequence. |
@bb/layout/contracts |
types and bbLayoutThemes |
Shares the surface types and the service token. |
An import selects this exact first-party code. A surface render follows the current contract winner.
Host roles
bb.layout declares no host role. The app and server artifacts use kernel ports only.
Example
This plugin adds a status action and a theme. The manifest records both claims before the app code runs.
{
"id": "acme.focus",
"version": "2.0.0",
"claims": [
{ "surface": "bb.layout.statusbar" },
{ "surface": "bb.layout.theme", "key": "acme.focus.dim" }
],
"artifacts": { "app": "./dist/app.js" }
}import { definePlugin } from "@get-bb/plugin/app";
import { Button } from "@bb/ui";
export default definePlugin((api) => {
api.surfaces.add("bb.layout.statusbar", {
id: "focus-toggle",
order: 40,
title: "Focus mode",
component: ({ compact }) => (
<Button size={compact ? "icon" : "sm"} onClick={() => api.commands.run("acme.focus.toggle")}>
Focus
</Button>
),
});
api.surfaces.provideKey("bb.layout.theme", "acme.focus.dim", {
id: "acme.focus.dim",
name: "Focus Dim",
description: "A low contrast theme for long work sessions.",
palette: {
light: {
colorScheme: "light",
variables: { "--background": "#f5f3ee", "--foreground": "#292722" },
},
dark: {
colorScheme: "dark",
variables: { "--background": "#171816", "--foreground": "#d7d5ce" },
},
},
});
});The runtime removes both entries when the plugin unloads. The theme service uses the default theme if this key disappears.
Covers
| Old item ID | New contract or verb | Note |
|---|---|---|
app.hooks.experimental_useAppPanel |
@bb/layout/hooks: usePanelController() |
The controller opens, focuses, closes, and moves shell panels. |
app.hooks.experimental_useFixedTabTarget |
@bb/layout/hooks: usePanelTarget() |
The hook reads the target and its sequence from LayoutPanelProps. |
app.hooks.experimental_useCodeTheme |
@bb/layout/hooks: useLayoutTheme() |
The hook returns the active mode and resolved code theme. |
app.slots.navPanel |
bb.layout.panels surface: api.surfaces.add() |
One panel declaration can add navigation metadata. |
app.slots.sidebarFooterAction |
bb.layout.statusbar surface: api.surfaces.add() |
The default sidebar can place status entries in its footer. |
app.contracts.PluginNavPanelProps |
bb.layout.panels surface: LayoutPanelProps |
Panel state replaces route remainder props. |
app.contracts.PluginNavPanelRegistration |
bb.layout.panels surface: LayoutPanelRegistration |
The new declaration separates panel content from shell frame rules. |
app.contracts.PluginNavPanel.fixedTabs |
bb.layout.panels surface: persistent panel declarations |
Each old fixed tab becomes one persistent panel declaration. |
app.contracts.PluginNavPanel.experimental_sidebarAccessory |
bb.layout.panels surface: navigation.accessory |
The sidebar winner places the bounded accessory. |
app.contracts.PluginNavPanel.headerContent |
bb.layout.panels surface: headerContent |
The panel dock winner owns the panel header. |
app.contracts.PluginFixedTabRegistration |
bb.layout.panels surface: LayoutPanelRegistration |
Use persistent: true and instances: "single". |
app.contracts.ExperimentalPluginFixedTabReference |
bb.layout.panels surface: full panel ID plus target schema |
The full ID replaces the old panelId and id pair. |
app.contracts.ExperimentalFixedTabTargetContract |
bb.layout.panels surface: target: StandardSchemaV1<T> |
The panel declaration owns target validation. |
app.contracts.ExperimentalFixedTabTargetContract.validate |
bb.layout.panels surface: target schema validation |
The shell checks the schema before open(). |
app.contracts.PluginPanelActionOpenOptions |
bb.layout.panels surface: LayoutPanelOpenOptions |
state replaces params; the frame keeps the optional title. |
app.contracts.PluginCodeThemeState |
@bb/layout/contracts: LayoutThemeClientState |
The new state includes the selected theme definition. |
app.contracts.PluginCodeThemeData |
@bb/layout/contracts: LayoutCodeTheme |
The stable code theme keeps colors and token rules. |
app.contracts.PluginNavPanelProps.subPath |
bb.layout.panels surface: LayoutPanelProps.state |
A panel can store its route state as JSON. |
app.contracts.PluginNavPanelRegistration.id |
bb.layout.panels surface: LayoutPanelRegistration.id |
The runtime adds the claimant ID to form the full panel ID. |
app.contracts.PluginNavPanelRegistration.title |
bb.layout.panels surface: LayoutPanelRegistration.title |
The shell uses the title in navigation and panel chrome. |
app.contracts.PluginNavPanelRegistration.icon |
bb.layout.panels surface: LayoutPanelRegistration.icon |
The shell uses one icon in navigation and panel chrome. |
app.contracts.PluginNavPanelRegistration.path |
bb.layout.panels surface: LayoutPanelRegistration.navigation.path |
The path remains shell-owned navigation metadata. |
app.contracts.PluginNavPanelRegistration.component |
bb.layout.panels surface: LayoutPanelRegistration.component |
The plugin still owns the panel body. |
app.contracts.PluginFixedTabRegistration.id |
bb.layout.panels surface: LayoutPanelRegistration.id |
The local ID remains stable. |
app.contracts.PluginFixedTabRegistration.panelId |
bb.layout.panels surface: full panel ID |
The claimant namespace replaces the separate old panel ID. |
app.contracts.PluginFixedTabRegistration.title |
bb.layout.panels surface: LayoutPanelRegistration.title |
The shell uses this title for its tab. |
app.contracts.PluginFixedTabRegistration.icon |
bb.layout.panels surface: LayoutPanelRegistration.icon |
The shell uses this icon for its tab. |
app.contracts.PluginFixedTabRegistration.component |
bb.layout.panels surface: LayoutPanelRegistration.component |
The plugin still owns the panel body. |
app.contracts.PluginFixedTabRegistration.layout |
bb.layout.panels surface: LayoutPanelRegistration.frame |
padded and flush keep their meanings. |
app.contracts.PluginThreadPanelActionRegistration.layout |
bb.layout.panels surface: LayoutPanelRegistration.frame |
Threads owns the body; Layout owns the frame choice. |
app.contracts.PluginNewThreadPanelActionRegistration.layout |
bb.layout.panels surface: LayoutPanelRegistration.frame |
Threads owns the launcher; Layout owns the frame choice. |
app.contracts.PluginSidebarFooterActionProps |
bb.layout.statusbar surface: LayoutStatusItemProps |
The new props include route and shell controllers. |
app.contracts.PluginCodeThemeTokenRule |
@bb/layout/contracts: LayoutCodeThemeTokenRule |
The TextMate rule shape stays stable. |
app.components.ExperimentalAppPanelSurface |
@bb/layout/contracts: LayoutPanelController |
The nearest controller replaces { kind: "current" }. |
app.components.ExperimentalOpenFixedTabOptions |
bb.layout.panels surface: LayoutPanelOpenOptions |
The panel ID and target move into one open() call. |
app.components.ExperimentalFixedTabTargetState |
bb.layout.panels surface: LayoutPanelProps.target |
The target keeps its sequence and value. |
app.components.ExperimentalAppPanel |
@bb/layout/contracts: LayoutPanelController |
The controller covers all shell panel actions. |
app.contracts.ExperimentalAppPanel.openFixedTab |
bb.layout.panels surface: LayoutPanelController.open() |
An open call focuses the current single panel. |
app.contracts.ExperimentalFixedTabTargetState.clear |
bb.layout.panels surface: LayoutPanelController.clearTarget() |
The caller can clear one exact target sequence. |
app.contracts.PluginFixedTabDeclaration |
bb.layout.panels surface: LayoutPanelRegistration |
The target schema is optional on one stable declaration. |
app.contracts.PluginSidebarFooterActionContext |
bb.layout.statusbar surface: LayoutStatusItemProps |
The shell controllers replace the special footer context. |
app.contracts.PluginSidebarFooterActionContext.openSettings |
bb.commands command: bb.settings.openCurrent |
A status action runs the Settings command through api.commands. |
server.sdk.theme |
bb.layout.themes service |
bb.sdk.theme becomes a compatibility facade. |
server.sdk.theme.get |
bb.layout.themes service: get() |
The result uses LayoutThemeState. |
server.sdk.theme.catalog |
bb.layout.themes service: catalog() |
The catalog lists built-in, custom, and plugin theme claims. |
server.sdk.theme.set |
bb.layout.themes service: set(selection) and set(themeId) |
Both old overloads stay available. |
app.contracts.PluginSidebarFooterActionRegistration |
bb.layout.statusbar surface: LayoutStatusItemRegistration |
The data-only form keeps the host-rendered action. |