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.