diff --git a/AGENTS.md b/AGENTS.md
index d9e2f87f5f..afd4fa20fd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,6 +30,7 @@
## Plugin API
- Any new public plugin API member (a `@get-bb/plugin-sdk/app` export, an `app.slots.*` method, or a `BbPluginApi` property) ships with an `experimental_` name prefix and an entry in [docs/api_to_audit.md](docs/api_to_audit.md) describing what it does and what to audit before stabilizing. Dropping the prefix is the deliberate stabilization step: audit the entry, rename project-wide, and remove it from the doc in the same change.
+- The Plugin Guide (the `plugin-api-docs` plugin, rendering `packages/plugin-api-map`) is bb's only plugin API documentation. A new surface needs a card in `packages/plugin-api-map/src/surfaces.ts` naming its SDK symbols in the same change; `packages/plugin-api-map/test/api-sync.test.ts` fails the build when the map and the SDK drift apart.
## Data Access
diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index 722e1db661..bbda645a54 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -41,7 +41,6 @@ import {
SETTINGS_SECTION_ROUTE_PATH,
SKILLS_ROUTE_PATH,
TOOLS_PLUGIN_BROWSE_ROUTE_PATH,
- TOOLS_PLUGIN_DETAIL_ROUTE_PATH,
TOOLS_PLUGINS_ROUTE_PATH,
TOOLS_REGISTRY_SKILL_DETAIL_ROUTE_PATH,
TOOLS_REGISTRY_SKILLS_ROUTE_PATH,
@@ -319,10 +318,6 @@ function AppRoutes() {
path={TOOLS_PLUGIN_BROWSE_ROUTE_PATH}
element={ }
/>
- }
- />
}
diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx
index 8934425814..94b5ba6868 100644
--- a/apps/app/src/components/plugin/PluginSlotMount.tsx
+++ b/apps/app/src/components/plugin/PluginSlotMount.tsx
@@ -1,5 +1,6 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Pill } from "@bb/shared-ui/pill";
+import { useRouteAnchorDelegate } from "@/components/ui/app-route-anchor";
import { usePluginCss } from "@/lib/plugin-css";
import {
PluginContext,
@@ -224,6 +225,7 @@ export function PluginSlotMount({
instanceId,
onCrash,
}: PluginSlotMountProps) {
+ const onRouteAnchorClick = useRouteAnchorDelegate();
usePluginCss(pluginId);
return (
@@ -242,6 +244,12 @@ export function PluginSlotMount({
data-bb-plugin-root=""
data-bb-plugin={pluginId}
className="contents"
+ // Links in plugin UI behave like links anywhere else in bb: a plain
+ // click on an app route navigates client-side (a bare anchor would
+ // full-load the app and wipe its Back history), and cmd-click opens
+ // a splittable page beside the focused pane. Bubble phase, so the
+ // plugin's own handlers run first and can preventDefault.
+ onClick={onRouteAnchorClick}
>
{children}
diff --git a/apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx b/apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx
new file mode 100644
index 0000000000..cbe1ff10f5
--- /dev/null
+++ b/apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx
@@ -0,0 +1,278 @@
+// @vitest-environment jsdom
+/**
+ * Guards the Plugin Guide UI-anatomy manifest against the real app.
+ *
+ * The Plugin Guide surface fixtures (packages/plugin-api-map) render the
+ * sidebar sections, the sidebar footer, and the message action bar in the
+ * order declared by anatomy-manifest.json. This test renders the real
+ * components and asserts the same DOM order, so reordering the app fails here
+ * until the manifest — and therefore the guide — is updated.
+ */
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { afterEach, beforeAll, describe, expect, it } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { Provider as JotaiProvider } from "jotai";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { PERSONAL_PROJECT_ID } from "@bb/domain";
+import { TooltipProvider } from "@bb/shared-ui/tooltip";
+
+import { makeProject } from "../../../.ladle/story-fixtures";
+import { AppCommandProvider } from "@/components/commands/AppCommandProvider";
+import { QuickCreateProjectProvider } from "@/hooks/useQuickCreateProject";
+import { ProjectActionsProvider } from "@/components/project/ProjectActionsProvider";
+import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider";
+import { AppSidebar } from "@/components/sidebar/AppSidebar";
+import { SidebarProvider } from "@/components/ui/sidebar";
+import { MessageActionBar } from "@/components/thread/timeline/MessageActionBar";
+import {
+ removePluginSlotRegistrations,
+ setPluginSlotRegistrations,
+} from "@/lib/plugin-slots";
+import { sidebarNavigationQueryKey } from "@/hooks/queries/query-keys";
+
+const REPO_ROOT = resolve(import.meta.dirname, "../../../../..");
+
+const manifest = JSON.parse(
+ readFileSync(
+ resolve(REPO_ROOT, "packages/plugin-api-map/src/anatomy-manifest.json"),
+ "utf8",
+ ),
+) as {
+ appSidebar: string[];
+ sidebarFooter: string[];
+ messageActionBar: string[];
+ surfaceFixtures: Record<
+ string,
+ {
+ responsiveStrategy: "scale-together";
+ sources: Array<{ path: string; anchors: string[] }>;
+ }
+ >;
+};
+
+const TEST_PLUGIN_ID = "docs-anatomy-test";
+
+beforeAll(() => {
+ // jsdom gaps the sidebar/tooltip stack expects.
+ window.matchMedia ??= ((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })) as typeof window.matchMedia;
+ window.ResizeObserver ??= class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ } as unknown as typeof ResizeObserver;
+ Element.prototype.scrollIntoView ??= () => {};
+});
+
+afterEach(() => {
+ removePluginSlotRegistrations(TEST_PLUGIN_ID);
+ cleanup();
+});
+
+/** Asserts the elements appear in the given document order. */
+function expectDocumentOrder(labeled: Array<[string, Element]>): void {
+ for (let index = 0; index < labeled.length - 1; index += 1) {
+ const [beforeName, before] = labeled[index];
+ const [afterName, after] = labeled[index + 1];
+ const position = before.compareDocumentPosition(after);
+ expect(
+ (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
+ `expected "${beforeName}" to render before "${afterName}"`,
+ ).toBe(true);
+ }
+}
+
+function registerTestPlugin() {
+ setPluginSlotRegistrations(TEST_PLUGIN_ID, {
+ homepageSections: [],
+ settingsSections: [],
+ navPanels: [
+ {
+ id: "anatomy-panel",
+ title: "Anatomy test panel",
+ icon: "Zap",
+ path: "anatomy",
+ component: () => null,
+ },
+ ],
+ threadPanelActions: [],
+ sidebarFooterActions: [
+ {
+ id: "anatomy-footer",
+ title: "Anatomy footer action",
+ icon: "Zap",
+ run: () => {},
+ },
+ ],
+ fileOpeners: [],
+ messageDirectives: [],
+ });
+}
+
+function renderAppSidebar() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ queryClient.setQueryData(sidebarNavigationQueryKey(), {
+ sections: [],
+ personalProject: {
+ ...makeProject({
+ id: PERSONAL_PROJECT_ID,
+ kind: "personal",
+ name: "Personal",
+ }),
+ defaultExecutionOptions: null,
+ threads: [],
+ },
+ projects: [],
+ });
+
+ return render(
+
+
+
+
+
+
+
+
+
+ {}}
+ isResizing={false}
+ showTopReserve
+ settingsRoutePath="/settings"
+ toolsRoutePath="/tools"
+ />
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe("docs anatomy manifest", () => {
+ it("keeps every surface fixture anchored to current product source", () => {
+ for (const [fixtureId, fixture] of Object.entries(
+ manifest.surfaceFixtures,
+ )) {
+ for (const source of fixture.sources) {
+ const sourceText = readFileSync(
+ resolve(REPO_ROOT, source.path),
+ "utf8",
+ );
+ for (const anchor of source.anchors) {
+ expect(
+ sourceText,
+ `${fixtureId}: missing ${JSON.stringify(anchor)} in ${source.path}`,
+ ).toContain(anchor);
+ }
+ }
+ }
+ });
+
+ it("matches AppSidebar's section order", () => {
+ registerTestPlugin();
+ const { container } = renderAppSidebar();
+
+ const sectionSelectors: Record = {
+ "top-reserve": '[data-testid="app-sidebar-top-reserve-row"]',
+ "primary-actions": '[data-testid="app-sidebar-primary-actions"]',
+ "plugin-nav": '[data-testid="plugin-nav-sidebar-items"]',
+ "thread-list": '[data-sidebar="content"]',
+ footer: '[data-sidebar="footer"]',
+ };
+ expect(Object.keys(sectionSelectors).sort()).toEqual(
+ [...manifest.appSidebar].sort(),
+ );
+
+ const sections = manifest.appSidebar.map((key): [string, Element] => {
+ const element = container.querySelector(sectionSelectors[key]);
+ expect(element, `missing sidebar section "${key}"`).not.toBeNull();
+ return [key, element as Element];
+ });
+ expectDocumentOrder(sections);
+ });
+
+ it("matches the sidebar footer's item order", () => {
+ registerTestPlugin();
+ const { container } = renderAppSidebar();
+ const footer = container.querySelector('[data-sidebar="footer"]');
+ expect(footer).not.toBeNull();
+
+ const footerSelectors: Record Element | null> = {
+ settings: () => footer!.querySelector('a[aria-label^="Settings"]'),
+ "plugin-footer-actions": () =>
+ footer!.querySelector('button[aria-label="Anatomy footer action"]'),
+ "bug-report": () => footer!.querySelector('[aria-label^="Report a bug"]'),
+ };
+ expect(Object.keys(footerSelectors).sort()).toEqual(
+ [...manifest.sidebarFooter].sort(),
+ );
+
+ const items = manifest.sidebarFooter.map((key): [string, Element] => {
+ const element = footerSelectors[key]();
+ expect(element, `missing footer item "${key}"`).not.toBeNull();
+ return [key, element as Element];
+ });
+ expectDocumentOrder(items);
+ });
+
+ it("matches the message action bar's order", () => {
+ render(
+
+ {}}
+ onEdit={() => {}}
+ onFork={() => {}}
+ onSendToMain={() => {}}
+ pluginActions={[
+ {
+ key: "anatomy-plugin-action",
+ pluginId: null,
+ icon: null,
+ label: "Anatomy message action",
+ onSelect: () => {},
+ },
+ ]}
+ />
+ ,
+ );
+
+ const actionLabels: Record = {
+ copy: "Copy message",
+ edit: "Edit message",
+ "add-to-chat": "Add to chat",
+ "send-to-main-thread": "Send to main thread",
+ fork: "Fork into new thread",
+ "plugin-actions": "Anatomy message action",
+ };
+ expect(Object.keys(actionLabels).sort()).toEqual(
+ [...manifest.messageActionBar].sort(),
+ );
+
+ const buttons = manifest.messageActionBar.map((key): [string, Element] => {
+ const element = screen.getByLabelText(actionLabels[key]);
+ return [key, element];
+ });
+ expectDocumentOrder(buttons);
+ });
+});
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
index f572c70aa6..2562192eb3 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
@@ -65,6 +65,7 @@ import {
type PromptVoiceConfig,
type TypeaheadConfig,
} from "./PromptBoxInternal";
+import { promptMentionClipboardContent } from "./mentions/prompt-mention-clipboard";
import type {
PromptMentionSuggestion,
ProviderCommandSuggestion,
@@ -3237,6 +3238,50 @@ describe("PromptBoxInternal prompt actions", () => {
expect(getPromptEditorElement().querySelector("blockquote")).not.toBeNull();
});
+ it("keeps multiple pasted plugin references as distinct pills", async () => {
+ const { changes, promptBoxRef } = renderPromptBox("");
+ const reference = (id: string, label: string) => {
+ const pill = promptMentionClipboardContent({
+ kind: "plugin",
+ pluginId: "plugin-api-docs",
+ icon: null,
+ itemId: `surface:${id}`,
+ label,
+ });
+ return {
+ text: `Build a plugin capability like ${pill.text.trimEnd()} using bb's Plugin Guide. `,
+ html: `Build a plugin capability like ${pill.html.trimEnd()} using bb's Plugin Guide. `,
+ };
+ };
+
+ await focusPromptEnd(promptBoxRef);
+ const actions = reference("composer-actions", "Inline actions");
+ pasteClipboard({ html: actions.html, plainText: actions.text });
+ await waitFor(() =>
+ expect(latestChange(changes)?.mentions).toHaveLength(1),
+ );
+
+ const panels = reference("thread-panel", "Thread side-panel tabs");
+ pasteClipboard({ html: panels.html, plainText: panels.text });
+
+ await waitFor(() =>
+ expect(latestChange(changes)?.mentions).toHaveLength(2),
+ );
+ expect(
+ latestChange(changes)?.mentions.map((mention) => mention.resource),
+ ).toEqual([
+ expect.objectContaining({ itemId: "surface:composer-actions" }),
+ expect.objectContaining({ itemId: "surface:thread-panel" }),
+ ]);
+ expect(
+ getPromptEditorElement().querySelectorAll(".prompt-mention-pill"),
+ ).toHaveLength(2);
+ expect(latestValue(changes)).toBe(
+ "Build a plugin capability like @Inline actions using bb's Plugin Guide. " +
+ "Build a plugin capability like @Thread side-panel tabs using bb's Plugin Guide. ",
+ );
+ });
+
it("opens the file picker from the prompt actions menu", async () => {
const onAttachFiles = vi.fn();
render(
diff --git a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts
index 3d4af2cd7b..c82bf97bce 100644
--- a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts
+++ b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts
@@ -3,6 +3,7 @@
import { describe, expect, it } from "vitest";
import {
parsePromptMentionClipboardElement,
+ promptMentionClipboardContent,
promptMentionClipboardDataAttributes,
serializedTextForPromptMentionResource,
} from "./prompt-mention-clipboard";
@@ -40,6 +41,28 @@ describe("serializedTextForPromptMentionResource", () => {
});
});
+describe("promptMentionClipboardContent", () => {
+ it("serializes a plugin reference as pasteable structured HTML", () => {
+ const resource = {
+ kind: "plugin" as const,
+ pluginId: "plugin-api-docs",
+ icon: null,
+ itemId: "surface:composer-actions",
+ label: "Inline actions",
+ };
+ const content = promptMentionClipboardContent(resource);
+ const document = new DOMParser().parseFromString(content.html, "text/html");
+ const element = document.querySelector("[data-prompt-mention]");
+
+ expect(content.text).toBe("@Inline actions ");
+ expect(element).not.toBeNull();
+ expect(parsePromptMentionClipboardElement({ element: element! })).toEqual({
+ resource,
+ serializedText: "@Inline actions",
+ });
+ });
+});
+
describe("parsePromptMentionClipboardElement", () => {
it("preserves the typed trigger for a plugin mention copied from a pill", () => {
const element = document.createElement("span");
diff --git a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts
index 416ceb2b7d..601302bd4f 100644
--- a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts
+++ b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts
@@ -51,6 +51,28 @@ export function promptMentionClipboardDataAttributes(
};
}
+/**
+ * The exact rich clipboard representation the composer already emits for an
+ * inline pill. A trailing space keeps sequentially pasted references
+ * independently editable without becoming one run of text.
+ */
+export function promptMentionClipboardContent(
+ resource: PromptMentionResource,
+): { text: string; html: string } {
+ const serializedText = serializedTextForPromptMentionResource(resource);
+ const element = document.createElement("span");
+ for (const [name, value] of Object.entries(
+ promptMentionClipboardDataAttributes({ resource, serializedText }),
+ )) {
+ element.setAttribute(name, value);
+ }
+ element.textContent = serializedText;
+ return {
+ text: `${serializedText} `,
+ html: `${element.outerHTML} `,
+ };
+}
+
export function serializedTextForPromptMentionResource(
resource: PromptMentionResource,
): string {
diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
index 8ab975247d..5db79904a4 100644
--- a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
+++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
@@ -6,8 +6,10 @@ import {
getPluginPanelRoutePath,
getRootComposeRoutePath,
getThreadRoutePath,
+ getPluginDetailRoutePath,
} from "@/lib/route-paths";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit";
import {
countPanes,
findPaneByContent,
@@ -32,6 +34,9 @@ const MAIN_CONTENT_SELECTOR = "main";
function routeForContent(content: PaneContent): string {
if (content.kind === "thread") return getThreadRoutePath(content);
if (content.kind === "new-thread") return getRootComposeRoutePath();
+ if (content.kind === "plugin-detail") {
+ return getPluginDetailRoutePath({ pluginId: content.pluginId });
+ }
return getPluginPanelRoutePath({
pluginId: content.pluginId,
path: content.panelPath,
@@ -54,21 +59,13 @@ export function usePaneContentSplitDrag({
const isCompact = useIsCompactViewport();
const openInSplit = useCallback(() => {
- const route = routeForContent(content);
- const layout = store.get(splitLayoutAtom);
- if (!enabled || isCompact || layout === null) {
- navigate(route);
- return;
- }
- const existing = findPaneByContent(layout.root, content);
- const next =
- existing !== null
- ? setFocus(layout, existing.paneId)
- : countPanes(layout.root) >= MAX_PANES
- ? replacePaneContent(layout, layout.focusedPaneId, content)
- : splitPane(layout, layout.focusedPaneId, "right", content);
- if (next !== layout) store.set(splitLayoutAtom, next);
- navigate(route, existing !== null ? { replace: true } : undefined);
+ openPaneContentInSplit({
+ store,
+ navigate,
+ content,
+ route: routeForContent(content),
+ enabled: enabled && !isCompact,
+ });
}, [content, enabled, isCompact, navigate, store]);
const onPointerDown = useCallback(
diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx
index 72658a943d..80ee870998 100644
--- a/apps/app/src/components/ui/app-route-anchor.tsx
+++ b/apps/app/src/components/ui/app-route-anchor.tsx
@@ -12,8 +12,12 @@ import {
type ReactNode,
} from "react";
import { useNavigate, type NavigateOptions } from "react-router-dom";
+import { useStore } from "jotai";
+import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { isRoutePath, resolveRouteHref } from "@/lib/route-paths";
import { getDesktopBrowserApi } from "@/lib/bb-desktop";
+import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit";
+import { paneContentForPathname } from "@/views/thread-detail/splitThreadNavigation";
interface RouteNavigationProviderProps {
children: ReactNode;
@@ -35,7 +39,17 @@ interface RouteNavigateOptions {
/** Navigate to an absolute app route (`/projects/...`); see {@link useRouteNavigate}. */
type RouteNavigate = (path: string, options?: RouteNavigateOptions) => void;
-const RouteNavigationContext = createContext(null);
+interface RouteNavigation {
+ navigate: RouteNavigate;
+ /**
+ * Opens a route beside the focused pane, the way cmd-click on a sidebar
+ * row does. Returns false — and does nothing — when the route is not pane
+ * content or splits are off, so the caller can fall back to the browser.
+ */
+ openInSplit: (path: string) => boolean;
+}
+
+const RouteNavigationContext = createContext(null);
// Separate from RouteNavigationContext on purpose: the pending bit flips on
// every navigation, and folding it into the navigate context would re-render
@@ -69,7 +83,9 @@ export function useIsRouteNavigationPending(): boolean {
* the click, not silently.
*/
export function useRouteNavigate(): RouteNavigate {
- return useContext(RouteNavigationContext) ?? navigateWithoutProvider;
+ return (
+ useContext(RouteNavigationContext)?.navigate ?? navigateWithoutProvider
+ );
}
function navigateWithoutProvider(path: string): void {
@@ -104,6 +120,8 @@ export function RouteNavigationProvider({
children,
}: RouteNavigationProviderProps) {
const navigate = useNavigate();
+ const store = useStore();
+ const isCompact = useIsCompactViewport();
// The live `navigate` changes per pathname; the context value must not, or
// every consumer would re-render per navigation (the thing this exists to
// avoid). Layout effect: the ref is current before any child effect or
@@ -130,6 +148,21 @@ export function RouteNavigationProvider({
},
[startNavigationTransition],
);
+ const openInSplit = useCallback(
+ (path) => {
+ const content = paneContentForPathname(path.split(/[?#]/)[0] ?? path);
+ if (content === null) return false;
+ openPaneContentInSplit({
+ store,
+ navigate: navigateRoute,
+ content,
+ route: path,
+ enabled: !isCompact,
+ });
+ return true;
+ },
+ [isCompact, navigateRoute, store],
+ );
useEffect(() => {
const browserApi = getDesktopBrowserApi();
if (browserApi === null) {
@@ -143,8 +176,12 @@ export function RouteNavigationProvider({
});
}, [navigateRoute]);
+ const value = useMemo(
+ () => ({ navigate: navigateRoute, openInSplit }),
+ [navigateRoute, openInSplit],
+ );
return (
-
+
{children}
@@ -152,6 +189,55 @@ export function RouteNavigationProvider({
);
}
+/**
+ * A click handler for a container whose descendants may include anchors to
+ * app routes — plugin-rendered UI, chiefly. Plain clicks on such anchors
+ * navigate client-side, so the app's Back button keeps working; cmd/ctrl
+ * clicks open the route beside the focused pane when it can live in one.
+ * Links to a plugin's own page (its Extensions detail) open beside on any
+ * click: that page is a companion to whatever you are reading, and the
+ * Extensions list will open it the same way. Every other click, and every
+ * anchor to anywhere else, is left to the browser. Outside a
+ * RouteNavigationProvider it does nothing.
+ */
+export function useRouteAnchorDelegate(): (
+ event: ReactMouseEvent,
+) => void {
+ const navigation = useContext(RouteNavigationContext);
+ return useCallback(
+ (event) => {
+ if (navigation === null || event.defaultPrevented) return;
+ const anchor =
+ event.target instanceof Element
+ ? event.target.closest("a[href]")
+ : null;
+ if (anchor === null || !event.currentTarget.contains(anchor)) return;
+ const target = anchor.getAttribute("target");
+ if (target !== null && target !== "" && target !== "_self") return;
+ if (event.button !== 0 || event.altKey || event.shiftKey) return;
+ const origin = currentOrigin();
+ if (origin === null) return;
+ const route = resolveRouteHref({
+ currentOrigin: origin,
+ href: anchor.getAttribute("href") ?? "",
+ });
+ if (route === null) return;
+ const opensBeside =
+ event.metaKey ||
+ event.ctrlKey ||
+ paneContentForPathname(route.path.split(/[?#]/)[0] ?? route.path)
+ ?.kind === "plugin-detail";
+ if (opensBeside) {
+ if (navigation.openInSplit(route.path)) event.preventDefault();
+ return;
+ }
+ event.preventDefault();
+ navigation.navigate(route.path);
+ },
+ [navigation],
+ );
+}
+
export function RouteAnchor({
href,
onClick,
@@ -159,7 +245,7 @@ export function RouteAnchor({
target,
...anchorProps
}: RouteAnchorProps) {
- const navigateRoute = useContext(RouteNavigationContext);
+ const navigation = useContext(RouteNavigationContext);
const route = useMemo(() => {
const origin = currentOrigin();
return origin === null || href === undefined
@@ -171,16 +257,16 @@ export function RouteAnchor({
onClick?.(event);
if (
route === null ||
- navigateRoute === null ||
+ navigation === null ||
!shouldHandleRouteAnchorClick({ event })
) {
return;
}
event.preventDefault();
- navigateRoute(route.path);
+ navigation.navigate(route.path);
},
- [navigateRoute, onClick, route],
+ [navigation, onClick, route],
);
return (
diff --git a/apps/app/src/lib/clipboard.test.ts b/apps/app/src/lib/clipboard.test.ts
index 8778b9a542..e786759e02 100644
--- a/apps/app/src/lib/clipboard.test.ts
+++ b/apps/app/src/lib/clipboard.test.ts
@@ -11,10 +11,7 @@ vi.mock("@/components/ui/app-toast", () => ({
appToast: toastMocks,
}));
-import {
- copyTextToClipboard,
- copyToClipboardWithToast,
-} from "./clipboard";
+import { copyTextToClipboard, copyToClipboardWithToast } from "./clipboard";
function installClipboard(writeText: (text: string) => Promise): void {
Object.defineProperty(navigator, "clipboard", {
@@ -30,9 +27,7 @@ function removeClipboard(): void {
});
}
-function installEditingCommand(
- implementation: (command: string) => boolean,
-) {
+function installEditingCommand(implementation: (command: string) => boolean) {
const execCommand = vi.fn(implementation);
Object.defineProperty(document, "execCommand", {
configurable: true,
diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts
index 3279748275..417f15d744 100644
--- a/apps/app/src/lib/plugin-frontend-reload.test.ts
+++ b/apps/app/src/lib/plugin-frontend-reload.test.ts
@@ -174,6 +174,38 @@ describe("reconcilePluginFrontends", () => {
);
});
+ it("waits for the stylesheet before publishing registrations", async () => {
+ // Registrations are what mount a plugin's components. Publishing them
+ // while the stylesheet is still in flight paints one unstyled frame —
+ // the plugin's UI renders at its natural, oversized layout and then
+ // snaps down when the sheet lands.
+ const state = createPluginFrontendReconcileState();
+ const deps = makeDeps([candidate("hello", "aaa")]);
+ const cssGate: { release: (() => void) | null } = { release: null };
+ vi.mocked(deps.applyCss).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ cssGate.release = resolve;
+ }),
+ );
+
+ const done = reconcilePluginFrontends(state, deps);
+ // Let every await before the CSS gate settle.
+ for (let tick = 0; tick < 20; tick++) await Promise.resolve();
+ expect(deps.applyCss).toHaveBeenCalledWith(
+ "hello",
+ "/api/v1/plugins/hello/assets/app.css?h=aaa",
+ );
+ expect(deps.setRegistrations).not.toHaveBeenCalled();
+
+ cssGate.release?.();
+ await done;
+ expect(deps.setRegistrations).toHaveBeenCalledWith(
+ "hello",
+ expect.anything(),
+ );
+ });
+
it("reloading twice leaves exactly one homepage section registered (design §9 exit criterion)", async () => {
resetPluginSlotStoreForTest();
const state = createPluginFrontendReconcileState();
diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts
index 0cbadd3c32..c950fe11ba 100644
--- a/apps/app/src/lib/plugin-frontend.ts
+++ b/apps/app/src/lib/plugin-frontend.ts
@@ -434,8 +434,12 @@ export function createPluginFrontendReconcileState(): PluginFrontendReconcileSta
export interface PluginFrontendReconcileDeps {
fetchCandidates: () => Promise;
importModule: (url: string) => Promise;
- /** Synchronously publish (string) or remove (null) the generation's CSS URL. */
- applyCss: (pluginId: string, url: string | null) => void;
+ /**
+ * Publish (string) or remove (null) the generation's CSS URL. Awaited before
+ * content scripts or registrations go live so injected implementations can
+ * preserve the same ordering as the synchronous production CSS manager.
+ */
+ applyCss: (pluginId: string, url: string | null) => void | Promise;
/** Retain the published CSS through one non-React consumer's lifetime. */
retainCss: (pluginId: string) => () => void;
resetCrashedSlots: (pluginId: string) => void;
@@ -859,7 +863,7 @@ async function reconcileCandidates(
// Publish the URL before either non-React scripts mount or slot-store
// notifications can render plugin code. Inactive plugins only preload;
// an already-mounted generation starts a safe side-by-side replacement.
- deps.applyCss(pluginId, candidate.bundle.cssUrl);
+ await deps.applyCss(pluginId, candidate.bundle.cssUrl);
const cssRelease =
collected.contentScripts.length > 0 ? deps.retainCss(pluginId) : null;
const disposeFailures = await deactivateCommittedGeneration(
diff --git a/apps/app/src/lib/split-layout/openPaneContentInSplit.ts b/apps/app/src/lib/split-layout/openPaneContentInSplit.ts
new file mode 100644
index 0000000000..d7385cc148
--- /dev/null
+++ b/apps/app/src/lib/split-layout/openPaneContentInSplit.ts
@@ -0,0 +1,81 @@
+import { splitLayoutAtom } from "./atoms";
+import {
+ countPanes,
+ findPaneByContent,
+ MAX_PANES,
+ replacePaneContent,
+ setFocus,
+ splitPane,
+ type PaneContent,
+ type SplitLayout,
+} from "./index";
+
+interface SplitLayoutStore {
+ get(atom: typeof splitLayoutAtom): SplitLayout | null;
+ set(atom: typeof splitLayoutAtom, value: SplitLayout): void;
+}
+
+export interface OpenPaneContentInSplitArgs {
+ store: SplitLayoutStore;
+ /** react-router's navigate, or anything with the same first two arguments. */
+ navigate: (
+ route: string,
+ options?: { replace?: boolean },
+ ) => void | Promise;
+ content: PaneContent;
+ /** The URL this content owns, so the focused pane and the URL agree. */
+ route: string;
+ /** Splits are off on compact viewports and outside the split workspace. */
+ enabled: boolean;
+}
+
+/**
+ * Open non-thread page content beside the focused pane: focus it if a pane
+ * already holds it, replace at the pane cap, otherwise split right. Falls
+ * back to plain navigation where there is no split to grow.
+ *
+ * Shared by the sidebar's cmd-click/drag entry point and by cmd-click on an
+ * app-route link inside plugin UI (useRouteAnchorDelegate), so both place
+ * the same page the same way.
+ */
+export function openPaneContentInSplit({
+ store,
+ navigate,
+ content,
+ route,
+ enabled,
+}: OpenPaneContentInSplitArgs): void {
+ const layout = store.get(splitLayoutAtom);
+ if (!enabled || layout === null) {
+ void navigate(route);
+ return;
+ }
+ const existing = findPaneByContent(layout.root, content);
+ const next =
+ existing !== null
+ ? setFocus(layout, existing.paneId)
+ : countPanes(layout.root) >= MAX_PANES
+ ? replacePaneContent(layout, layout.focusedPaneId, content)
+ : splitPane(layout, layout.focusedPaneId, "right", content);
+ if (next !== layout) store.set(splitLayoutAtom, next);
+ void navigate(route, existing !== null ? { replace: true } : undefined);
+}
+
+/**
+ * Whether the workspace is already holding a plugin's detail page in a pane.
+ *
+ * The detail page is full-window like the rest of Extensions by default; it
+ * only renders as a pane when something deliberately put it there (cmd-click
+ * on a link to it from plugin UI). Deciding from the
+ * layout rather than the URL is what keeps ordinary navigation to the page
+ * from evicting whatever the focused pane was showing.
+ */
+export function holdsPluginDetailPane(
+ layout: SplitLayout | null,
+ pluginId: string,
+): boolean {
+ if (layout === null) return false;
+ return (
+ findPaneByContent(layout.root, { kind: "plugin-detail", pluginId }) !== null
+ );
+}
diff --git a/apps/app/src/lib/split-layout/ops.ts b/apps/app/src/lib/split-layout/ops.ts
index d0492ffac7..4008701f83 100644
--- a/apps/app/src/lib/split-layout/ops.ts
+++ b/apps/app/src/lib/split-layout/ops.ts
@@ -80,6 +80,12 @@ export function findPaneByContent(
candidate.threadId === content.threadId
);
}
+ if (content.kind === "plugin-detail") {
+ return (
+ candidate.kind === "plugin-detail" &&
+ candidate.pluginId === content.pluginId
+ );
+ }
return (
candidate.kind === "plugin-panel" &&
candidate.pluginId === content.pluginId &&
diff --git a/apps/app/src/lib/split-layout/persistence.ts b/apps/app/src/lib/split-layout/persistence.ts
index 841c23f9c8..8a03f700bf 100644
--- a/apps/app/src/lib/split-layout/persistence.ts
+++ b/apps/app/src/lib/split-layout/persistence.ts
@@ -22,6 +22,12 @@ const paneContentSchema = z.discriminatedUnion("kind", [
subPath: z.string(),
})
.strict(),
+ z
+ .object({
+ kind: z.literal("plugin-detail"),
+ pluginId: z.string().min(1),
+ })
+ .strict(),
]);
const paneNodeSchema: z.ZodType = z
diff --git a/apps/app/src/lib/split-layout/types.ts b/apps/app/src/lib/split-layout/types.ts
index 36923f5f14..f65745f6ec 100644
--- a/apps/app/src/lib/split-layout/types.ts
+++ b/apps/app/src/lib/split-layout/types.ts
@@ -12,6 +12,11 @@ export type PaneContent =
pluginId: string;
panelPath: string;
subPath: string;
+ }
+ /** An installed plugin's Extensions detail page. */
+ | {
+ kind: "plugin-detail";
+ pluginId: string;
};
export interface PaneNode {
diff --git a/apps/app/src/views/SplitWorkspaceRoute.test.tsx b/apps/app/src/views/SplitWorkspaceRoute.test.tsx
index 58ef700e56..a9b31301a5 100644
--- a/apps/app/src/views/SplitWorkspaceRoute.test.tsx
+++ b/apps/app/src/views/SplitWorkspaceRoute.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { useEffect } from "react";
+import { Suspense, useEffect } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -25,6 +25,12 @@ vi.mock("./RootComposeView", () => ({
LegacyProjectComposeRedirect: () => legacy redirect
,
}));
+vi.mock("./ToolsView", () => ({
+ ToolsView: ({ pluginId }: { pluginId?: string }) => (
+ {pluginId ?? "overview"}
+ ),
+}));
+
function NavigationControls() {
const navigate = useNavigate();
return (
@@ -65,4 +71,29 @@ describe("SplitWorkspaceRoute", () => {
expect(screen.getByTestId("route-content").textContent).toBe("thread");
expect(workspaceLifecycle).toEqual({ mounts: 1, unmounts: 0 });
});
+
+ // The app mounts this route under `path="*"`, so `useParams` can never
+ // carry `:pluginId` — the id must be derived from the URL here and passed
+ // down explicitly. Regression: the full-window detail URL rendered the
+ // plugins overview because ToolsView read an absent route param.
+ it("passes the plugin id from the full-window detail URL to ToolsView", async () => {
+ render(
+
+
+
+
+
+ }
+ />
+
+ ,
+ );
+
+ expect((await screen.findByTestId("tools-view")).textContent).toBe(
+ "github",
+ );
+ });
});
diff --git a/apps/app/src/views/SplitWorkspaceRoute.tsx b/apps/app/src/views/SplitWorkspaceRoute.tsx
index d08e40b9db..d6c955a170 100644
--- a/apps/app/src/views/SplitWorkspaceRoute.tsx
+++ b/apps/app/src/views/SplitWorkspaceRoute.tsx
@@ -1,5 +1,8 @@
-import { useMemo } from "react";
+import { lazy, useMemo } from "react";
import { matchPath, Navigate, useLocation } from "react-router-dom";
+import { useAtomValue } from "jotai";
+import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import { holdsPluginDetailPane } from "@/lib/split-layout/openPaneContentInSplit";
// Route views render icons outside the shell's core set. Importing the
// extended registry here ships it as a static dependency of this route chunk,
// so those icons never flash blank waiting for an on-demand load.
@@ -8,6 +11,7 @@ import {
APP_ROOT_ROUTE_PATH,
LEGACY_PROJECT_COMPOSE_ROUTE_PATH,
PLUGIN_PANEL_ROUTE_PATH,
+ TOOLS_PLUGIN_DETAIL_ROUTE_PATH,
} from "@/lib/route-paths";
import type { PaneContent } from "@/lib/split-layout";
import { useRouteState } from "@/hooks/useRouteState";
@@ -16,6 +20,12 @@ import { SplitThreadArea } from "./thread-detail/SplitThreadArea";
const ROOT_COMPOSE_CONTENT = { kind: "new-thread" } as const;
+// The Extensions detail page, for the full-window case below. Lazy, like the
+// other Extensions routes in App.tsx, so it stays out of the workspace chunk.
+const ToolsView = lazy(() =>
+ import("./ToolsView").then((m) => ({ default: m.ToolsView })),
+);
+
/**
* Stable route owner for every page that can live in the split workspace.
*
@@ -27,6 +37,10 @@ export default function SplitWorkspaceRoute() {
const location = useLocation();
const { projectId, threadId, isThreadView } = useRouteState();
const pluginMatch = matchPath(PLUGIN_PANEL_ROUTE_PATH, location.pathname);
+ const pluginDetailMatch = matchPath(
+ TOOLS_PLUGIN_DETAIL_ROUTE_PATH,
+ location.pathname,
+ );
const legacyProjectMatch = matchPath(
LEGACY_PROJECT_COMPOSE_ROUTE_PATH,
location.pathname,
@@ -34,6 +48,7 @@ export default function SplitWorkspaceRoute() {
const pluginId = pluginMatch?.params.pluginId;
const panelPath = pluginMatch?.params.panelPath;
const pluginSubPath = pluginMatch?.params["*"] ?? "";
+ const detailPluginId = pluginDetailMatch?.params.pluginId;
const routeContent = useMemo(() => {
if (location.pathname === APP_ROOT_ROUTE_PATH) {
@@ -42,6 +57,9 @@ export default function SplitWorkspaceRoute() {
if (isThreadView && projectId && threadId) {
return { kind: "thread", projectId, threadId };
}
+ if (detailPluginId) {
+ return { kind: "plugin-detail", pluginId: detailPluginId };
+ }
if (pluginId && panelPath) {
return {
kind: "plugin-panel",
@@ -52,6 +70,7 @@ export default function SplitWorkspaceRoute() {
}
return null;
}, [
+ detailPluginId,
isThreadView,
location.pathname,
panelPath,
@@ -61,6 +80,8 @@ export default function SplitWorkspaceRoute() {
threadId,
]);
+ const layout = useAtomValue(splitLayoutAtom);
+
const legacyProjectId = legacyProjectMatch?.params.projectId;
if (legacyProjectId) {
return ;
@@ -68,5 +89,18 @@ export default function SplitWorkspaceRoute() {
if (routeContent === null) {
return ;
}
+ // A plugin's detail page is full-window, like the rest of Extensions,
+ // unless the workspace already holds it in a split pane — which only
+ // happens when something deliberately opened it there (cmd-click on a link
+ // to it from plugin UI). The decision is made here rather than with a
+ // separate : this element must own every URL a pane can have, or
+ // focusing a different pane (which rewrites the URL) would swap Route
+ // elements and remount the whole workspace, threads included.
+ if (
+ routeContent.kind === "plugin-detail" &&
+ !holdsPluginDetailPane(layout, routeContent.pluginId)
+ ) {
+ return ;
+ }
return ;
}
diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx
index 74d820b329..51e297845e 100644
--- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx
+++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx
@@ -537,7 +537,10 @@ describe("BB Official plugin detail routing", () => {
render(
- } />
+ }
+ />
,
{ wrapper: QueryClientWrapper },
@@ -602,7 +605,10 @@ describe("plugin removal confirmation", () => {
render(
- } />
+ }
+ />
,
{ wrapper: QueryClientWrapper },
diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx
index 65936e8af6..46325ddda4 100644
--- a/apps/app/src/views/ToolsView.tsx
+++ b/apps/app/src/views/ToolsView.tsx
@@ -5,7 +5,7 @@ import {
useState,
type ReactNode,
} from "react";
-import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { useLocation, useNavigate } from "react-router-dom";
// Route views render icons outside the shell's core set. Importing the
// extended registry here ships it as a static dependency of this route chunk,
// so those icons never flash blank waiting for an on-demand load.
@@ -397,11 +397,32 @@ function PluginDetailToolView({ pluginId }: { pluginId: string }) {
);
}
-export function ToolsView() {
+/**
+ * The Extensions detail page, rendered from an explicit plugin id.
+ *
+ * A split pane cannot read the id from `useParams`: only the focused pane
+ * owns the URL, so an unfocused plugin-detail pane would otherwise show
+ * whatever plugin the focused pane happens to name.
+ */
+export function PluginDetailPaneView({ pluginId }: { pluginId: string }) {
+ return (
+
+ );
+}
+
+/**
+ * `pluginId` must come from the caller: every mount of this view sits under
+ * a `path="*"` route (or a paramless one), so `useParams` never carries the
+ * plugin-detail id — `SplitWorkspaceRoute` derives it from the URL itself.
+ */
+export function ToolsView({ pluginId }: { pluginId?: string } = {}) {
const location = useLocation();
- const { pluginId } = useParams<{
- pluginId?: string;
- }>();
const activeSection = resolveToolsSection(location.pathname);
return (
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx
index e6e122ca8d..70dd441b58 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx
@@ -128,6 +128,20 @@ const LazyPluginPanelRightPanelHost = lazy(() =>
),
);
+const LazyPluginDetailPaneView = lazy(() =>
+ import("@/views/ToolsView").then(({ PluginDetailPaneView }) => ({
+ default: PluginDetailPaneView,
+ })),
+);
+
+function PluginDetailPaneView({ pluginId }: { pluginId: string }) {
+ return (
+
+
+
+ );
+}
+
function PluginPagePanelHost({
children,
...props
@@ -1051,6 +1065,9 @@ function StandalonePaneContent({
if (content.kind === "new-thread") {
return ;
}
+ if (content.kind === "plugin-detail") {
+ return ;
+ }
const panelEntry = navPanelChrome.find(
(candidate) =>
candidate.chrome.pluginId === content.pluginId &&
@@ -1140,7 +1157,9 @@ function NonThreadPaneContent({
isFocused ? resourceRouteLabel : null,
)
: null;
- const label = panelChrome?.title ?? "New thread";
+ const label =
+ panelChrome?.title ??
+ (content.kind === "plugin-detail" ? "Extension" : "New thread");
const handlePointerDown = (event: ReactPointerEvent) => {
if (
event.target instanceof Element &&
@@ -1245,7 +1264,9 @@ function NonThreadPaneContent({
CONTEXT_INACTIVE_TEXT_CLASS,
)}
>
- New thread
+ {content.kind === "plugin-detail"
+ ? "Extension"
+ : "New thread"}
)}
@@ -1264,6 +1285,8 @@ function NonThreadPaneContent({
>
{content.kind === "new-thread" ? (
+ ) : content.kind === "plugin-detail" ? (
+
) : (
=0.39",
+ "bbPluginSdk": ">=0.4.8"
+ },
+ "bb": {
+ "name": "Sdk Upgrade Fixture",
+ "description": "A BB plugin.",
+ "branding": {
+ "icon": "Zap"
+ },
+ "server": "./server.ts"
+ },
+ "dependencies": {
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@get-bb/plugin-sdk": "0.4.8",
+ "@types/better-sqlite3": "^7.6.12",
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "better-sqlite3": "^12.0.0",
+ "hono": "^4.11.9",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/apps/server/test/fixtures/plugins/bb-plugin-sdk-0.4.8-scaffold/server.ts b/apps/server/test/fixtures/plugins/bb-plugin-sdk-0.4.8-scaffold/server.ts
new file mode 100644
index 0000000000..24b3924588
--- /dev/null
+++ b/apps/server/test/fixtures/plugins/bb-plugin-sdk-0.4.8-scaffold/server.ts
@@ -0,0 +1,5 @@
+import type { BbPluginApi } from "@get-bb/plugin-sdk";
+
+export default function plugin(bb: BbPluginApi) {
+ bb.log.info("0.4.8 scaffold upgrade fixture loaded");
+}
diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts
index 31ea73a871..93089d1f6a 100644
--- a/apps/server/test/services/plugins/builtin-plugins.test.ts
+++ b/apps/server/test/services/plugins/builtin-plugins.test.ts
@@ -232,6 +232,7 @@ describe("builtin plugin reconciliation", () => {
["monaco-editor", "Code"],
["pdf-preview", "FileText"],
["provider-acp", "./icons/acp.svg"],
+ ["plugin-api-docs", "./icons/ai-generative.svg"],
["provider-claude-code", "./icons/claude-code.svg"],
["provider-codex", "./icons/codex.svg"],
["provider-pi", "./icons/pi.svg"],
diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts
index f8783e9440..3d80bb37c2 100644
--- a/apps/server/test/services/plugins/official-plugins.test.ts
+++ b/apps/server/test/services/plugins/official-plugins.test.ts
@@ -102,6 +102,7 @@ describe("official plugin registry invariants", () => {
memory: "Context & knowledge",
"monaco-editor": "Interface",
"pdf-preview": "Interface",
+ "plugin-api-docs": "Developer tools",
"provider-acp": "Agent interaction",
"provider-claude-code": "Agent interaction",
"provider-codex": "Agent interaction",
diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts
index 282309c131..14e9d6ffd6 100644
--- a/apps/server/test/services/plugins/plugin-service.test.ts
+++ b/apps/server/test/services/plugins/plugin-service.test.ts
@@ -1,6 +1,8 @@
import {
+ cp,
mkdtemp,
mkdir,
+ readFile,
rename,
rm,
symlink,
@@ -9,6 +11,7 @@ import {
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import semver from "semver";
import {
createConnection,
getInstalledPlugin,
@@ -16,7 +19,7 @@ import {
upsertInstalledPlugin,
type DbConnection,
} from "@bb/db";
-import type { SystemChangeKind } from "@bb/domain";
+import { PLUGIN_SDK_VERSION, type SystemChangeKind } from "@bb/domain";
import type { Logger } from "@bb/logger";
import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js";
import {
@@ -648,6 +651,77 @@ describe("plugin service", () => {
await after.stop();
});
+ it("keeps a persisted 0.4.8 scaffold plugin running after an SDK upgrade", async () => {
+ // This package.json is frozen from `bb plugin new sdk-upgrade-fixture`
+ // shipped by bb 0.39.0 with @get-bb/plugin-sdk 0.4.8. Copy it into a
+ // user-owned path, then persist the registration before starting the
+ // current host so this exercises a real upgrade rather than a fresh
+ // current-version install.
+ const fixtureDir = new URL(
+ "../../fixtures/plugins/bb-plugin-sdk-0.4.8-scaffold/",
+ import.meta.url,
+ );
+ const rootDir = join(workDir, "bb-plugin-sdk-upgrade-fixture");
+ await cp(fixtureDir, rootDir, { recursive: true });
+ const manifest = JSON.parse(
+ await readFile(join(rootDir, "package.json"), "utf8"),
+ ) as {
+ engines: { bbPluginSdk: string };
+ devDependencies: Record;
+ };
+ expect(manifest.engines.bbPluginSdk).toBe(">=0.4.8");
+ expect(manifest.devDependencies["@get-bb/plugin-sdk"]).toBe("0.4.8");
+ // The current SDK must be newer than the frozen fixture — the point is
+ // the upgrade, not any particular release, so don't pin the exact
+ // version here.
+ expect(semver.gt(PLUGIN_SDK_VERSION, "0.4.8")).toBe(true);
+
+ upsertInstalledPlugin(db, {
+ id: "sdk-upgrade-fixture",
+ source: `path:${rootDir}`,
+ provenance: { kind: "direct" },
+ sourceIntent: { kind: "path", canonicalPath: rootDir },
+ exactResolution: { kind: "path" },
+ updateState: {
+ lastCheckAt: null,
+ availableCompatibleVersion: null,
+ newestIncompatibleVersion: null,
+ statusDetail: null,
+ },
+ activeArtifactId: null,
+ rootDir,
+ version: "0.1.0",
+ enabled: true,
+ });
+
+ const upgraded = createPluginService({
+ aiServices: createAiServiceRegistry(),
+ telemetry: createNoopTelemetryService(),
+ db,
+ hub: {
+ getDaemonSessionIdForHost: () => null,
+ notifyPluginSignal: () => 0,
+ notifySystem: () => {},
+ },
+ logger,
+ dataDir: join(workDir, "data"),
+ appVersion: "0.39.0",
+ loadTimeoutMs: 2000,
+ bundledPlugins: [],
+ });
+ await upgraded.start();
+ try {
+ const entry = upgraded
+ .list()
+ .find((plugin) => plugin.id === "sdk-upgrade-fixture");
+ expect(entry?.status).toBe("running");
+ expect(entry?.statusDetail).toBeNull();
+ expect(upgraded.getApi("sdk-upgrade-fixture")).toBeDefined();
+ } finally {
+ await upgraded.stop();
+ }
+ });
+
it("skips the engines gate on 0.0.0 dev builds instead of marking everything incompatible", async () => {
const devService = createPluginService({
aiServices: createAiServiceRegistry(),
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index 5c494a387b..6dd4bc6d29 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -272,6 +272,7 @@ ever needs two configured differently. For `acpLaunchSpecSchema`: the shape
is stored in the ACP plugin's `customAgents` setting and in registrations'
bridge options, so a change is a migration of stored agents — decide what a
plugin is owed when the spec grows a field.
+
## `PluginProviderDeclaration.experimental_nativeSkillRoots`
**Kept experimental (2026-08-22).** every first-party provider declares it now (stabilization S5 moved the daemon's per-provider scan table here), but no third-party agent has validated the relative-path / 32-root rule or the per-root options, and the split between a global declaration and the per-workspace resolver (`experimental_resolvesNativeRoots`) is one release old.
@@ -882,7 +883,13 @@ Before stabilization, audit:
statuses;
- persistence expectations across full app reloads and multiple windows;
- validation, accessibility labels, reduced motion, and cleanup on plugin
- reload/disable/removal.
+ reload/disable/removal;
+- the name of `PluginComposerThreadRowStatus.tone`. The field is a state the
+ plugin reports (`default | running | success | error`), not a tone: the host
+ maps it to both a color and an animation (`running` pulses in the success
+ color; `success` and `error` are static; omitted is muted). `state` is the
+ candidate rename. Nothing under `plugins/*` sets a status today, so the
+ rename is free until the prefix drops.
## `bb.providers.register` (`experimental_bridgeOptions`, `experimental_visibility`, and the `experimental_providerBridge` artifact export)
diff --git a/packages/bb-app/scripts/smoke-tarball.mjs b/packages/bb-app/scripts/smoke-tarball.mjs
index 5b2745eac8..4a7ba383ae 100644
--- a/packages/bb-app/scripts/smoke-tarball.mjs
+++ b/packages/bb-app/scripts/smoke-tarball.mjs
@@ -34,6 +34,7 @@ const EXPECTED_RUNNING_BUILTIN_PLUGINS = [
"inline-vis",
"keep-awake",
"pdf-preview",
+ "plugin-api-docs",
"provider-retry",
"secrets",
];
diff --git a/packages/plugin-api-map/package.json b/packages/plugin-api-map/package.json
new file mode 100644
index 0000000000..e6ff9e9163
--- /dev/null
+++ b/packages/plugin-api-map/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@bb/plugin-api-map",
+ "version": "0.0.1",
+ "type": "module",
+ "private": true,
+ "description": "The bb plugin-surface map: annotated surface fixtures plus the cards that explain each pluggable surface.",
+ "exports": {
+ ".": {
+ "source": "./src/index.ts",
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./agent-reference": {
+ "source": "./src/agent-reference.ts",
+ "types": "./src/agent-reference.ts",
+ "default": "./src/agent-reference.ts"
+ }
+ },
+ "types": "./src/index.ts",
+ "scripts": {
+ "clean": "rimraf tsconfig.tsbuildinfo",
+ "scaffold:surface-entry": "node scripts/scaffold-surface-entry.mjs",
+ "update:sdk-inventory": "node scripts/sdk-api-inventory.mjs --write",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run --config vitest.config.ts"
+ },
+ "dependencies": {
+ "@hugeicons/core-free-icons": "^4.1.3",
+ "@hugeicons/react": "^1.1.6",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^3.4.0"
+ },
+ "devDependencies": {
+ "@bb/tsconfig": "workspace:*",
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
+ "typescript-7": "npm:typescript@^7.0.2",
+ "vitest": "^4.1.1"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ },
+ "bb": {
+ "pluginTailwindContent": [
+ "src/**/*"
+ ]
+ }
+}
diff --git a/packages/plugin-api-map/scripts/scaffold-surface-entry.d.mts b/packages/plugin-api-map/scripts/scaffold-surface-entry.d.mts
new file mode 100644
index 0000000000..04e277b8a0
--- /dev/null
+++ b/packages/plugin-api-map/scripts/scaffold-surface-entry.d.mts
@@ -0,0 +1,52 @@
+export type FixtureFidelity = "none" | "anchor" | "state" | "flow";
+export type FixtureResponsiveStrategy = "scale-together" | "reflow";
+
+export interface SurfaceEntryScaffoldInput {
+ id: string | null;
+ title: string | null;
+ groupId: string | null;
+ sourcePaths: string[];
+ apiSymbols: string[];
+ spatialOwner: boolean;
+ transient: boolean;
+ outcome: boolean;
+ replacement: boolean;
+}
+
+export interface SurfaceEntryScaffold {
+ schemaVersion: 1;
+ surface: {
+ id: string;
+ title: string;
+ summary: string;
+ bullets: string[];
+ apiSymbols: string[];
+ };
+ fixture: null | {
+ groupId: string;
+ fidelity: Exclude;
+ responsiveStrategy: "scale-together";
+ requiredStates: string[];
+ sources: Array<{ path: string; anchors: string[] }>;
+ fixtureClassAnchors: string[];
+ };
+}
+
+export const FIXTURE_FIDELITY_LEVELS: readonly FixtureFidelity[];
+export const FIXTURE_RESPONSIVE_STRATEGIES: readonly FixtureResponsiveStrategy[];
+export function classifyFixtureFidelity(
+ input: Pick<
+ SurfaceEntryScaffoldInput,
+ "spatialOwner" | "transient" | "outcome" | "replacement"
+ >,
+): FixtureFidelity;
+export function fixtureResponsiveStrategy(
+ input: Pick,
+): FixtureResponsiveStrategy;
+export function parseScaffoldArgs(argv: string[]): SurfaceEntryScaffoldInput;
+export function buildSurfaceEntryScaffold(
+ input: SurfaceEntryScaffoldInput,
+): SurfaceEntryScaffold;
+export function renderSurfaceEntryScaffold(
+ input: SurfaceEntryScaffoldInput,
+): string;
diff --git a/packages/plugin-api-map/scripts/scaffold-surface-entry.mjs b/packages/plugin-api-map/scripts/scaffold-surface-entry.mjs
new file mode 100644
index 0000000000..7c12ef8579
--- /dev/null
+++ b/packages/plugin-api-map/scripts/scaffold-surface-entry.mjs
@@ -0,0 +1,191 @@
+import { fileURLToPath } from "node:url";
+
+export const FIXTURE_FIDELITY_LEVELS = ["none", "anchor", "state", "flow"];
+export const FIXTURE_RESPONSIVE_STRATEGIES = ["scale-together", "reflow"];
+
+const REQUIRED_STATES = {
+ none: [],
+ anchor: ["anchor"],
+ state: ["anchor", "triggered"],
+ flow: ["anchor", "triggered", "outcome"],
+};
+
+/**
+ * Picks the minimum honest Guide fixture from observable surface behavior.
+ * The result is deterministic: the highest applicable behavior wins.
+ */
+export function classifyFixtureFidelity({
+ spatialOwner,
+ transient,
+ outcome,
+ replacement,
+}) {
+ if (!spatialOwner) return "none";
+ if (outcome || replacement) return "flow";
+ if (transient) return "state";
+ return "anchor";
+}
+
+/** Responsive behavior follows spatial ownership, never author preference. */
+export function fixtureResponsiveStrategy({ spatialOwner }) {
+ return spatialOwner ? "scale-together" : "reflow";
+}
+
+function uniqueSorted(values) {
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
+}
+
+function requireValue(argv, index, flag) {
+ const value = argv[index + 1];
+ if (value === undefined || value.startsWith("--")) {
+ throw new Error(`${flag} requires a value`);
+ }
+ return value;
+}
+
+export function parseScaffoldArgs(argv) {
+ const input = {
+ id: null,
+ title: null,
+ groupId: null,
+ sourcePaths: [],
+ apiSymbols: [],
+ spatialOwner: true,
+ transient: false,
+ outcome: false,
+ replacement: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const flag = argv[index];
+ switch (flag) {
+ case "--id":
+ input.id = requireValue(argv, index, flag);
+ index += 1;
+ break;
+ case "--title":
+ input.title = requireValue(argv, index, flag);
+ index += 1;
+ break;
+ case "--group":
+ input.groupId = requireValue(argv, index, flag);
+ index += 1;
+ break;
+ case "--source":
+ input.sourcePaths.push(requireValue(argv, index, flag));
+ index += 1;
+ break;
+ case "--api-symbol":
+ input.apiSymbols.push(requireValue(argv, index, flag));
+ index += 1;
+ break;
+ case "--no-spatial-owner":
+ case "--headless":
+ input.spatialOwner = false;
+ break;
+ case "--transient":
+ input.transient = true;
+ break;
+ case "--outcome":
+ input.outcome = true;
+ break;
+ case "--replacement":
+ input.replacement = true;
+ break;
+ default:
+ throw new Error(`unknown argument: ${flag}`);
+ }
+ }
+
+ return input;
+}
+
+function validateInput(input) {
+ if (!input.id || !/^[a-z][a-z0-9-]*$/.test(input.id)) {
+ throw new Error("--id must be lowercase kebab-case");
+ }
+ if (!input.title?.trim()) throw new Error("--title is required");
+ if (!input.groupId || !/^[a-z][a-z0-9-]*$/.test(input.groupId)) {
+ throw new Error("--group must be lowercase kebab-case");
+ }
+ if (input.apiSymbols.length === 0) {
+ throw new Error("at least one --api-symbol is required");
+ }
+ if (input.spatialOwner && input.sourcePaths.length === 0) {
+ throw new Error("spatial surfaces require at least one --source");
+ }
+ if (
+ !input.spatialOwner &&
+ (input.transient || input.outcome || input.replacement)
+ ) {
+ throw new Error(
+ "--no-spatial-owner cannot be combined with spatial behavior flags",
+ );
+ }
+}
+
+export function buildSurfaceEntryScaffold(input) {
+ validateInput(input);
+ const fidelity = classifyFixtureFidelity(input);
+ const sourcePaths = uniqueSorted(
+ input.sourcePaths.map((path) => path.replaceAll("\\", "/")),
+ );
+
+ return {
+ schemaVersion: 1,
+ surface: {
+ id: input.id,
+ title: input.title.trim(),
+ summary: `TODO: Describe where ${input.title.trim()} appears in bb. With this, a plugin can:`,
+ bullets: [
+ "TODO: Describe the first user-visible capability",
+ "TODO: Describe the second user-visible capability",
+ ],
+ apiSymbols: uniqueSorted(input.apiSymbols),
+ },
+ fixture:
+ fidelity === "none"
+ ? null
+ : {
+ groupId: input.groupId,
+ fidelity,
+ responsiveStrategy: fixtureResponsiveStrategy(input),
+ requiredStates: REQUIRED_STATES[fidelity],
+ sources: sourcePaths.map((path) => ({
+ path,
+ anchors: ["TODO: Add a stable source anchor"],
+ })),
+ fixtureClassAnchors: ["TODO: Add a product token class"],
+ },
+ };
+}
+
+export function renderSurfaceEntryScaffold(input) {
+ return `${JSON.stringify(buildSurfaceEntryScaffold(input), null, 2)}\n`;
+}
+
+function usage() {
+ return `Usage:
+ pnpm exec turbo run scaffold:surface-entry --filter=@bb/plugin-api-map -- \\
+ --id --title --group \\
+ --source --api-symbol \\
+ [--transient] [--outcome] [--replacement] [--no-spatial-owner]
+`;
+}
+
+function main() {
+ try {
+ process.stdout.write(
+ renderSurfaceEntryScaffold(parseScaffoldArgs(process.argv.slice(2))),
+ );
+ } catch (error) {
+ process.stderr.write(
+ `${error instanceof Error ? error.message : String(error)}\n\n${usage()}`,
+ );
+ process.exitCode = 1;
+ }
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ main();
+}
diff --git a/packages/plugin-api-map/scripts/sdk-api-inventory.d.mts b/packages/plugin-api-map/scripts/sdk-api-inventory.d.mts
new file mode 100644
index 0000000000..ad0fbb3994
--- /dev/null
+++ b/packages/plugin-api-map/scripts/sdk-api-inventory.d.mts
@@ -0,0 +1,8 @@
+export interface SdkPublicApiInventory {
+ schemaVersion: 1;
+ entries: Record;
+}
+
+export const INVENTORY_PATH: string;
+export function createSdkPublicApiInventory(): SdkPublicApiInventory;
+export function readSdkPublicApiInventory(): SdkPublicApiInventory;
diff --git a/packages/plugin-api-map/scripts/sdk-api-inventory.mjs b/packages/plugin-api-map/scripts/sdk-api-inventory.mjs
new file mode 100644
index 0000000000..98dd10fc51
--- /dev/null
+++ b/packages/plugin-api-map/scripts/sdk-api-inventory.mjs
@@ -0,0 +1,87 @@
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import ts from "typescript";
+
+const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const SDK_ROOT = resolve(PACKAGE_ROOT, "../plugin-sdk");
+export const INVENTORY_PATH = join(PACKAGE_ROOT, "sdk-public-api.json");
+
+function canonicalDeclaration(source, fileName) {
+ const sourceFile = ts.createSourceFile(
+ fileName,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+ const printer = ts.createPrinter({
+ newLine: ts.NewLineKind.LineFeed,
+ removeComments: true,
+ });
+ return sourceFile.statements
+ .map((statement) =>
+ printer.printNode(ts.EmitHint.Unspecified, statement, sourceFile),
+ )
+ .join("\n");
+}
+
+function publicTypeEntries() {
+ const manifest = JSON.parse(
+ readFileSync(join(SDK_ROOT, "package.json"), "utf8"),
+ );
+ return Object.entries(manifest.exports)
+ .filter(([subpath]) => !subpath.startsWith("./internal/"))
+ .map(([subpath, target]) => {
+ const types = typeof target === "string" ? target : target.types;
+ if (typeof types !== "string") {
+ throw new Error(`Public SDK export ${subpath} has no types target`);
+ }
+ const path = resolve(SDK_ROOT, types);
+ if (!existsSync(path)) {
+ throw new Error(
+ `Missing built declaration for ${subpath}: ${relative(SDK_ROOT, path)}. Run the @get-bb/plugin-sdk build:types task first.`,
+ );
+ }
+ return [subpath, { path, types: relative(SDK_ROOT, path) }];
+ })
+ .sort(([left], [right]) => left.localeCompare(right));
+}
+
+export function createSdkPublicApiInventory() {
+ return {
+ schemaVersion: 1,
+ entries: Object.fromEntries(
+ publicTypeEntries().map(([subpath, entry]) => {
+ const canonical = canonicalDeclaration(
+ readFileSync(entry.path, "utf8"),
+ entry.path,
+ );
+ return [
+ subpath,
+ {
+ types: entry.types,
+ sha256: createHash("sha256").update(canonical).digest("hex"),
+ },
+ ];
+ }),
+ ),
+ };
+}
+
+export function readSdkPublicApiInventory() {
+ return JSON.parse(readFileSync(INVENTORY_PATH, "utf8"));
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ if (!process.argv.includes("--write")) {
+ throw new Error("Pass --write to update the Plugin Guide SDK inventory");
+ }
+ writeFileSync(
+ INVENTORY_PATH,
+ `${JSON.stringify(createSdkPublicApiInventory(), null, 2)}\n`,
+ );
+ console.log(`Updated ${relative(process.cwd(), INVENTORY_PATH)}`);
+}
diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json
new file mode 100644
index 0000000000..41ba7d3497
--- /dev/null
+++ b/packages/plugin-api-map/sdk-public-api.json
@@ -0,0 +1,45 @@
+{
+ "schemaVersion": 1,
+ "entries": {
+ ".": {
+ "types": "bundled-types/bb-plugin-sdk.d.ts",
+ "sha256": "840e5e196f66d9b0ba57097c9eb23e75ed638d8483d5fcb6f17a9d9dddfe516a"
+ },
+ "./ai-services": {
+ "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts",
+ "sha256": "97b4294f28f476c4c2fbe5db621686ae66ddee616f2ba6bed2fc29e7cb122317"
+ },
+ "./app": {
+ "types": "bundled-types/bb-plugin-sdk-app.d.ts",
+ "sha256": "2b9516df9eacc9b8e6f0b9695c9a1117bcc135e62e0541434dc53817939c5c85"
+ },
+ "./host": {
+ "types": "bundled-types/bb-plugin-sdk-host.d.ts",
+ "sha256": "4b07e41aecd8f0668324c8016633237037bf4b325f00d12c8dbb5e1bf568385f"
+ },
+ "./provider-bridge": {
+ "types": "bundled-types/bb-plugin-sdk-provider-bridge.d.ts",
+ "sha256": "1ef4800cd3125f662d6dde23ede7c58713ada026124e47af7cf07f03e2b0e1ff"
+ },
+ "./provider-bridge/acp": {
+ "types": "bundled-types/bb-plugin-sdk-provider-bridge-acp.d.ts",
+ "sha256": "b0f99e3f95243581363f764f34036c1ca4ef65f12f940ac53c8b897789960aaa"
+ },
+ "./provider-bridge/testing": {
+ "types": "bundled-types/bb-plugin-sdk-provider-bridge-testing.d.ts",
+ "sha256": "1b722f069b8da3519c840bbfa964a29452227a7017095116f0f52186290abf7a"
+ },
+ "./testing": {
+ "types": "bundled-types/bb-plugin-sdk-testing.d.ts",
+ "sha256": "693ff804ff2a80f4ae1041f623ca01b58e48ff833a82b952c989c6589afbbbf7"
+ },
+ "./testing/app": {
+ "types": "bundled-types/bb-plugin-sdk-testing-app.d.ts",
+ "sha256": "c9de1acc9d5d24da0dccab82577975d58bee9a99c48ed5c2063a1301d723eac4"
+ },
+ "./testing/host": {
+ "types": "bundled-types/bb-plugin-sdk-testing-host.d.ts",
+ "sha256": "4a03019a60ef5950c9df363e330995f928b5f73a2c6b9ccf93c033717c27a297"
+ }
+ }
+}
diff --git a/packages/plugin-api-map/src/agent-reference.ts b/packages/plugin-api-map/src/agent-reference.ts
new file mode 100644
index 0000000000..495abc60c1
--- /dev/null
+++ b/packages/plugin-api-map/src/agent-reference.ts
@@ -0,0 +1,175 @@
+import { SURFACES_BY_ID, type PluginSurface } from "./surfaces";
+
+/** Stable plugin identity carried by every pasted Guide reference. */
+export const PLUGIN_GUIDE_PLUGIN_ID = "plugin-api-docs";
+
+/** Stable mention-provider identity owned by the Plugin Guide plugin. */
+export const PLUGIN_GUIDE_SURFACE_PROVIDER_ID = "surface";
+
+/** The app-side input for one structured Plugin Guide surface reference. */
+export interface PluginSurfaceAgentMention {
+ provider: typeof PLUGIN_GUIDE_SURFACE_PROVIDER_ID;
+ id: string;
+ label: string;
+}
+
+export function pluginSurfaceAgentMention(
+ surface: PluginSurface,
+): PluginSurfaceAgentMention {
+ return createPluginSurfaceAgentReference(surface).identity;
+}
+
+export interface PluginSurfaceAgentClipboardContent {
+ /** Plain fallback for pasting outside bb. */
+ text: string;
+ /** bb's existing structured mention markup, consumed by composer paste. */
+ html: string;
+}
+
+export interface PluginSurfaceAgentResource {
+ kind: "plugin";
+ pluginId: typeof PLUGIN_GUIDE_PLUGIN_ID;
+ icon: null;
+ itemId: string;
+ label: string;
+}
+
+/** One canonical object owns every representation of a Guide reference. */
+export interface PluginSurfaceAgentReference {
+ identity: PluginSurfaceAgentMention;
+ resource: PluginSurfaceAgentResource;
+ clipboard: PluginSurfaceAgentClipboardContent;
+ context: string;
+}
+
+const AGENT_REFERENCE_PREFIX = "Build a plugin that uses ";
+const AGENT_REFERENCE_SUFFIX = ". ";
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll('"', """)
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+/**
+ * Derive identity, clipboard bytes, and send-time context from one canonical
+ * surface record. No transient UI state, timestamps, prose bullets, or random
+ * ids enter the result, so equal surface data produces equal output.
+ */
+export function createPluginSurfaceAgentReference(
+ surface: PluginSurface,
+): PluginSurfaceAgentReference {
+ const identity: PluginSurfaceAgentMention = {
+ provider: PLUGIN_GUIDE_SURFACE_PROVIDER_ID,
+ id: surface.id,
+ label: surface.title,
+ };
+ const serializedText = `@${identity.label}`;
+ const resource: PluginSurfaceAgentResource = {
+ kind: "plugin",
+ pluginId: PLUGIN_GUIDE_PLUGIN_ID,
+ icon: null,
+ itemId: `${identity.provider}:${identity.id}`,
+ label: identity.label,
+ };
+ const clipboard = {
+ text: `${AGENT_REFERENCE_PREFIX}${serializedText}${AGENT_REFERENCE_SUFFIX}`,
+ html: `${escapeHtml(AGENT_REFERENCE_PREFIX)}${escapeHtml(serializedText)} ${escapeHtml(AGENT_REFERENCE_SUFFIX)}`,
+ };
+ const context = [
+ `Plugin Guide surface: ${surface.title} (${surface.id}).`,
+ `Relevant @get-bb/plugin-sdk symbols: ${surface.apiSymbols.join(", ")}.`,
+ "Use the bb-plugin-authoring skill and the authoritative @get-bb/plugin-sdk declarations to build a similar plugin capability.",
+ ].join("\n");
+ return { identity, resource, clipboard, context };
+}
+
+/**
+ * Serialize one Guide surface with bb's existing composer-pill clipboard
+ * contract. This is private first-party integration, not a Plugin SDK API.
+ */
+export function pluginSurfaceAgentClipboardContent(
+ surface: PluginSurface,
+): PluginSurfaceAgentClipboardContent {
+ return createPluginSurfaceAgentReference(surface).clipboard;
+}
+
+function copyWithEditingCommand(
+ content: PluginSurfaceAgentClipboardContent,
+): boolean {
+ if (
+ typeof document === "undefined" ||
+ document.body === null ||
+ typeof document.execCommand !== "function"
+ ) {
+ return false;
+ }
+ const textarea = document.createElement("textarea");
+ textarea.value = content.text;
+ textarea.readOnly = true;
+ textarea.setAttribute("aria-hidden", "true");
+ Object.assign(textarea.style, {
+ height: "1px",
+ opacity: "0",
+ pointerEvents: "none",
+ position: "fixed",
+ width: "1px",
+ });
+ document.body.append(textarea);
+ let richClipboardHandled = false;
+ const onCopy = (event: ClipboardEvent) => {
+ if (event.clipboardData === null) return;
+ event.clipboardData.setData("text/plain", content.text);
+ event.clipboardData.setData("text/html", content.html);
+ event.preventDefault();
+ richClipboardHandled = true;
+ };
+ document.addEventListener("copy", onCopy, { once: true });
+ try {
+ textarea.select();
+ return document.execCommand("copy") && richClipboardHandled;
+ } catch {
+ return false;
+ } finally {
+ document.removeEventListener("copy", onCopy);
+ textarea.remove();
+ }
+}
+
+/** Copy one surface as a real, composable bb composer pill. */
+export async function copyPluginSurfaceAgentReference(
+ surface: PluginSurface,
+): Promise {
+ const content = pluginSurfaceAgentClipboardContent(surface);
+ if (
+ typeof navigator !== "undefined" &&
+ typeof navigator.clipboard?.write === "function" &&
+ typeof ClipboardItem !== "undefined"
+ ) {
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ "text/plain": new Blob([content.text], { type: "text/plain" }),
+ "text/html": new Blob([content.html], { type: "text/html" }),
+ }),
+ ]);
+ return true;
+ } catch {
+ // The synchronous copy-event path also works on insecure LAN origins.
+ }
+ }
+ return copyWithEditingCommand(content);
+}
+
+/**
+ * Resolve a stable surface id into only the pointers an agent needs. The
+ * installed authoring skill owns workflow guidance; references stay compact
+ * and composable instead of embedding a tutorial per pill.
+ */
+export function pluginSurfaceAgentContext(surfaceId: string): string | null {
+ const surface = SURFACES_BY_ID.get(surfaceId);
+ if (!surface) return null;
+ return createPluginSurfaceAgentReference(surface).context;
+}
diff --git a/packages/plugin-api-map/src/anatomy-manifest.json b/packages/plugin-api-map/src/anatomy-manifest.json
new file mode 100644
index 0000000000..5cb78b673e
--- /dev/null
+++ b/packages/plugin-api-map/src/anatomy-manifest.json
@@ -0,0 +1,78 @@
+{
+ "$comment": "Shared UI-anatomy contract between the Plugin Guide surface fixtures (wireframes.tsx) and the real app. Ordered regions are rendered by both sides. surfaceFixtures records authoritative source anchors plus the minimum deterministic fidelity and states for interaction-heavy fixtures.",
+ "appSidebar": [
+ "top-reserve",
+ "primary-actions",
+ "plugin-nav",
+ "thread-list",
+ "footer"
+ ],
+ "sidebarFooter": ["settings", "plugin-footer-actions", "bug-report"],
+ "messageActionBar": [
+ "copy",
+ "edit",
+ "add-to-chat",
+ "send-to-main-thread",
+ "fork",
+ "plugin-actions"
+ ],
+ "surfaceFixtures": {
+ "command-palette-actions": {
+ "groupId": "command-palette",
+ "fidelity": "flow",
+ "responsiveStrategy": "scale-together",
+ "requiredStates": ["anchor", "triggered", "outcome"],
+ "labels": {
+ "anchor": ["Quick palette", "⇧⌘P"],
+ "triggered": ["Search commands", "Plugins", "Run release checklist"],
+ "outcome": ["Release checklist"]
+ },
+ "fixtureClassAnchors": [
+ "top-[12%]",
+ "max-w-xl",
+ "bg-black/40",
+ "bg-background",
+ "shadow-sm",
+ "h-11",
+ "rounded",
+ "px-2",
+ "py-1.5",
+ "bg-state-hover",
+ "text-foreground"
+ ],
+ "sources": [
+ {
+ "path": "apps/app/src/components/commands/CommandPalette.tsx",
+ "anchors": [
+ "top-[12%] max-w-xl translate-y-0 gap-0 p-0",
+ "aria-label={PALETTE_PLACEHOLDER}",
+ "role=\"listbox\"",
+ "aria-selected={isActive}",
+ "LAUNCHER_ACTION_ROW_BASE_CLASS",
+ "bg-state-hover text-foreground"
+ ]
+ },
+ {
+ "path": "apps/app/src/lib/command-palette/palette-plugin-actions.ts",
+ "anchors": ["group: \"Plugins\"", "run: () =>"]
+ },
+ {
+ "path": "apps/app/src/components/commands/CommandPalette.test.tsx",
+ "anchors": [
+ "lists a plugin's commandPaletteAction and runs it",
+ "expect(testState.calls).toEqual([\"plugin-ran\"])",
+ "expect(screen.queryByRole(\"combobox\")).toBeNull()"
+ ]
+ },
+ {
+ "path": "packages/shared-ui/src/components/ui/dialog.tsx",
+ "anchors": [
+ "fixed inset-0 z-50 bg-black/40",
+ "bg-background p-6 shadow-sm",
+ "sm:rounded-lg"
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/packages/plugin-api-map/src/annotation.tsx b/packages/plugin-api-map/src/annotation.tsx
new file mode 100644
index 0000000000..6a40ef080f
--- /dev/null
+++ b/packages/plugin-api-map/src/annotation.tsx
@@ -0,0 +1,153 @@
+import { Fragment, type ReactNode } from "react";
+
+import { cn } from "./cn";
+
+/**
+ * The numbered annotation chip. Shared by the surface-fixture markers and anything
+ * that lists surfaces, so the two can never drift apart: same size, same
+ * fill, same idle/selected tokens.
+ *
+ * Idle chips sit on a light ink/canvas mix — a step darker than --muted, so
+ * they hold their own against the mockups' own grey bones — with full
+ * foreground digits at semibold weight for legibility at this size. Mixed from the two anchors, so a
+ * re-anchored palette (Nord, Dracula) tints them instead of stranding grey.
+ * The selected chip switches to the timeline file accent — the same color bb
+ * uses for file names in thread timelines — so "selected" borrows an accent
+ * the product already owns instead of inventing one.
+ */
+export function annotationChipClass(active: boolean, className?: string) {
+ return cn(
+ // size-5 around a text-xs digit at semibold weight: the circle stays a
+ // marker beside the mockups' own chrome rather than a badge competing
+ // with it, and the heavier digit carries the smaller ring.
+ "flex size-5 shrink-0 items-center justify-center rounded-full font-mono text-xs font-semibold leading-none transition-colors",
+ active
+ ? "bg-file-accent text-background"
+ : "bg-[color-mix(in_oklch,var(--ink)_18%,var(--canvas))] text-foreground",
+ className,
+ );
+}
+
+/**
+ * Chip placement is a declared variant, never a per-instance offset. These
+ * four cover every in-target fixture site (exterior and lane chips measure
+ * their anchor instead — see MeasuredBadge); the rendered QA sweep asserts
+ * the result overlaps nothing, so a crowded site changes variant rather than
+ * gaining a bespoke coordinate.
+ */
+export type AnnotationChipPlacement =
+ | "corner"
+ | "corner-inset"
+ | "side"
+ | "outside-above";
+
+export const CHIP_PLACEMENT_CLASS: Record = {
+ /** Outside the target's top-right corner — the default. */
+ corner: "-right-2 -top-2",
+ /** Inside the corner, for targets that hug a clipping frame edge. */
+ "corner-inset": "right-2 -top-2",
+ /** Riding the target's right edge, vertically centered. */
+ side: "-right-2 top-1/2 -translate-y-1/2",
+ /** Floating above the target, horizontally centered — for inline-text
+ * targets where a corner chip would land on the neighboring words. */
+ "outside-above": "left-1/2 -top-6 -translate-x-1/2",
+};
+
+export function ExperimentalBadge() {
+ return (
+
+ experimental
+
+ );
+}
+
+/** How a card should present a reference to another surface. */
+export interface SurfaceReference {
+ /** Marker number on its own slide, or null for a slide with no diagram. */
+ number: number | null;
+ /** The slide's title, when it is not the slide being read; null when it is. */
+ otherPage: string | null;
+ /** Pans to that surface and opens its card. */
+ onOpen: () => void;
+}
+
+/**
+ * Splits authored copy into plain text, `backtick` code,
+ * `[label](surface-id)` cross-references, and the `{experimental}` chip that
+ * marks a named API as not yet stable.
+ *
+ * The copy is authored prose, not markdown — these three are the only markup
+ * it uses, so a tiny hand-rolled split beats pulling in a parser.
+ */
+const COPY_TOKEN = /(`[^`]+`)|(\[[^\]]+\]\([a-z0-9-]+\))|(\{experimental\})/g;
+
+/**
+ * Renders authored surface copy.
+ *
+ * With `resolve`, a `[label](surface-id)` reference becomes the label plus a
+ * superscript pointing at where that surface lives: its marker number when it
+ * is on the slide being read, or its number and page name when it is not, so
+ * a reader can follow the reference without hunting for it. Without `resolve`
+ * — or for an id the resolver does not know — the label renders as plain
+ * prose, which is what any non-map consumer wants.
+ */
+export function renderSurfaceCopy(
+ text: string,
+ resolve?: (id: string) => SurfaceReference | null,
+): ReactNode {
+ const parts = text.split(COPY_TOKEN).filter((part) => part !== undefined);
+ if (parts.length < 2) {
+ return text;
+ }
+ return parts.map((part, index) => {
+ if (part.startsWith("`") && part.endsWith("`")) {
+ return (
+
+ {part.slice(1, -1)}
+
+ );
+ }
+ if (part === "{experimental}") {
+ return (
+
+ {" "}
+
+
+ );
+ }
+ const reference = /^\[([^\]]+)\]\(([a-z0-9-]+)\)$/.exec(part);
+ if (!reference) {
+ return {part} ;
+ }
+ const [, label, id] = reference;
+ const target = resolve?.(id) ?? null;
+ if (!target) {
+ return {label} ;
+ }
+ // A link and nothing else: the words carry it, no marker or page name in
+ // the running text. The page it lands on is still announced, and shown
+ // on hover when it is not the page being read.
+ return (
+
+ {label}
+
+ );
+ });
+}
diff --git a/packages/plugin-api-map/src/cn.ts b/packages/plugin-api-map/src/cn.ts
new file mode 100644
index 0000000000..44d7c51475
--- /dev/null
+++ b/packages/plugin-api-map/src/cn.ts
@@ -0,0 +1,11 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/**
+ * Local copy of the app's class merger. This package renders bb theme classes
+ * but must stay importable from a plugin bundle, which cannot reach into an
+ * app's `@/lib` alias.
+ */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/plugin-api-map/src/index.ts b/packages/plugin-api-map/src/index.ts
new file mode 100644
index 0000000000..18d18a8930
--- /dev/null
+++ b/packages/plugin-api-map/src/index.ts
@@ -0,0 +1,64 @@
+export { cn } from "./cn";
+export {
+ copyPluginSurfaceAgentReference,
+ createPluginSurfaceAgentReference,
+ PLUGIN_GUIDE_PLUGIN_ID,
+ PLUGIN_GUIDE_SURFACE_PROVIDER_ID,
+ pluginSurfaceAgentClipboardContent,
+ pluginSurfaceAgentContext,
+ pluginSurfaceAgentMention,
+ type PluginSurfaceAgentClipboardContent,
+ type PluginSurfaceAgentMention,
+ type PluginSurfaceAgentReference,
+ type PluginSurfaceAgentResource,
+} from "./agent-reference";
+export {
+ annotationChipClass,
+ ExperimentalBadge,
+ renderSurfaceCopy,
+ type SurfaceReference,
+} from "./annotation";
+export { firstPartyPluginId, pluginIcon } from "./plugin-icons";
+export { SurfaceCard, useSurfaceCard } from "./surface-card";
+export {
+ annotationNeighbors,
+ panCarets,
+ ProductMap,
+ SURFACE_NUMBERS,
+} from "./product-map";
+export {
+ scrollUsedBy,
+ UsedByList,
+ usedByScrollState,
+ usedByScrollStep,
+ type UsedByScrollMetrics,
+ type UsedByScrollState,
+ type UsedByScrollTarget,
+} from "./used-by";
+export {
+ fixtureResponsiveStrategy,
+ GROUP_BY_SURFACE_ID,
+ SURFACE_GROUPS,
+ SURFACES_BY_ID,
+ type FixtureResponsiveStrategy,
+ type PluginSurface,
+ type SurfaceGroup,
+} from "./surfaces";
+export {
+ AppShellWireframe,
+ CommandPaletteWireframe,
+ ComposeScreenWireframe,
+ ExtensionsPluginPageWireframe,
+ SettingsWireframe,
+ SurfaceMapContext,
+ useSurfaceMap,
+ ANATOMY_RENDERER_KEYS,
+ APP_SHELL_MARKS,
+ COMMAND_PALETTE_MARKS,
+ COMPOSER_MARKS,
+ COMPOSE_MARKS,
+ EXTENSIONS_MARKS,
+ SETTINGS_MARKS,
+ type SurfaceMapState,
+} from "./wireframes";
+export { default as ANATOMY_MANIFEST } from "./anatomy-manifest.json";
diff --git a/packages/plugin-api-map/src/plugin-icons.ts b/packages/plugin-api-map/src/plugin-icons.ts
new file mode 100644
index 0000000000..e6fca44ec5
--- /dev/null
+++ b/packages/plugin-api-map/src/plugin-icons.ts
@@ -0,0 +1,111 @@
+/**
+ * The map's icons: the shipped bb plugins named in each surface's "Used by"
+ * list, and the capability glyph each pixel-less surface is drawn with.
+ *
+ * Both come from the plugin's own package.json — `bb.branding.icon` resolved
+ * through the same hugeicons set the app's icon registry uses, and the plugin
+ * id that `/extensions/plugins/` routes to. Provider plugins brand with
+ * bundled SVG files the docs cannot import, so they share one provider glyph.
+ */
+import {
+ ArrowDataTransferHorizontalIcon,
+ ArrowReloadHorizontalIcon,
+ BrainIcon,
+ BrowserIcon,
+ CheckListIcon,
+ Clock01Icon,
+ Coffee01Icon,
+ ComputerIcon,
+ DatabaseIcon,
+ Edit04Icon,
+ File01Icon,
+ GithubIcon,
+ Layers01Icon,
+ LockIcon,
+ MessageAdd02Icon,
+ MessageQuestionIcon,
+ SmartPhone01Icon,
+ SourceCodeIcon,
+ SparklesIcon,
+ TerminalIcon,
+ TestTubeIcon,
+ WorkflowCircle03Icon,
+ Activity03Icon,
+} from "@hugeicons/core-free-icons";
+import type { IconSvgElement } from "@hugeicons/react";
+
+interface FirstPartyPlugin {
+ /** Installed plugin id; the last segment of its page URL. */
+ id: string;
+ icon: IconSvgElement;
+}
+
+/** Keyed by the display name surfaces.ts lists in `firstParty`. */
+const FIRST_PARTY_PLUGINS: Record = {
+ "Ask User Question": { id: "ask-user-question", icon: MessageQuestionIcon },
+ Automations: { id: "automations", icon: Clock01Icon },
+ "Custom instructions": { id: "custom-instructions", icon: Edit04Icon },
+ // Installed as `simple-notes` (its manifest source is `builtin:docs`), so
+ // the id and the builtin slug differ; the page URL uses the id.
+ Docs: { id: "simple-notes", icon: File01Icon },
+ GitHub: { id: "github", icon: GithubIcon },
+ "Inline visualizations": { id: "inline-vis", icon: BrowserIcon },
+ "Keep Awake": { id: "keep-awake", icon: Coffee01Icon },
+ Memory: { id: "memory", icon: BrainIcon },
+ "Provider retry": { id: "provider-retry", icon: ArrowReloadHorizontalIcon },
+ "Remote access": { id: "connect", icon: SmartPhone01Icon },
+ Secrets: { id: "secrets", icon: LockIcon },
+ "Side chat": { id: "side-chat", icon: MessageAdd02Icon },
+ Tasks: { id: "tasks", icon: CheckListIcon },
+ Workflows: { id: "workflows", icon: WorkflowCircle03Icon },
+ "ACP providers": { id: "provider-acp", icon: SparklesIcon },
+ "Claude Code provider": { id: "provider-claude-code", icon: SparklesIcon },
+ "Codex provider": { id: "provider-codex", icon: SparklesIcon },
+ "Pi provider": { id: "provider-pi", icon: SparklesIcon },
+};
+
+export function pluginIcon(displayName: string): IconSvgElement | null {
+ return FIRST_PARTY_PLUGINS[displayName]?.icon ?? null;
+}
+
+/**
+ * The installed-plugin id bb knows this plugin by, or null when the name is
+ * not one of the shipped plugins.
+ *
+ * Deliberately NOT turned into a URL here. A plugin only has a page when the
+ * running bb actually knows it (installed, or present in that host's
+ * catalog), so whether to link is a question only the host can answer; see
+ * `pluginPageHref` on ProductMap. Matching is by id rather than display name
+ * because a plugin's display name is not its id: bb's own Docs plugin is
+ * installed as `simple-notes`, and two catalog entries can share a name.
+ */
+export function firstPartyPluginId(displayName: string): string | null {
+ return FIRST_PARTY_PLUGINS[displayName]?.id ?? null;
+}
+
+/**
+ * The capability glyph for a pixel-less surface, or null for one a fixture
+ * draws (those are identified by their numbered marker instead).
+ *
+ * One definition, two readers: the capability card on the "Plugin backend"
+ * slide and the detail card that card opens.
+ */
+const SURFACE_ICONS: Record = {
+ cli: TerminalIcon,
+ "agent-tools": SparklesIcon,
+ background: Clock01Icon,
+ // Two opposing arrows, not the "{api}" glyph: that one is dense text
+ // in a box and unreadable at card size on a large monitor.
+ wire: ArrowDataTransferHorizontalIcon,
+ storage: DatabaseIcon,
+ // An activity line, not a bolt: the bolt is the app's skills glyph.
+ "thread-events": Activity03Icon,
+ "host-workers": ComputerIcon,
+ "bb-sdk": SourceCodeIcon,
+ "host-components": Layers01Icon,
+ testing: TestTubeIcon,
+};
+
+export function surfaceIcon(surfaceId: string): IconSvgElement | null {
+ return SURFACE_ICONS[surfaceId] ?? null;
+}
diff --git a/packages/plugin-api-map/src/product-map.tsx b/packages/plugin-api-map/src/product-map.tsx
new file mode 100644
index 0000000000..546571d7ed
--- /dev/null
+++ b/packages/plugin-api-map/src/product-map.tsx
@@ -0,0 +1,810 @@
+/**
+ * The whole product map: one annotated surface fixture at a time, panned
+ * through with the arrows, with a click on any numbered annotation opening its
+ * card in the nearest gutter (or directly below the diagram when no gutter
+ * fits).
+ *
+ * Slides are the surface groups, in order, so the data file decides both what
+ * a slide contains and what number each marker gets. The last group has no
+ * pixels to point at, so it renders as a conventional docs capability grid:
+ * named sections of icon + title + description cards.
+ *
+ * Rendered identically by the docs site and by the bb plugin. Composer
+ * illustrations are deterministic, product-shaped fixtures: installed plugin
+ * customizations cannot leak into the Guide or move its annotations.
+ */
+import {
+ Fragment,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type ReactNode,
+} from "react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
+
+import { cn } from "./cn";
+import { SurfaceCard, useSurfaceCard } from "./surface-card";
+import { surfaceIcon } from "./plugin-icons";
+import {
+ fixtureResponsiveStrategy,
+ GROUP_BY_SURFACE_ID,
+ SURFACE_GROUPS,
+ SURFACES_BY_ID,
+ type PluginSurface,
+ type SurfaceGroup,
+} from "./surfaces";
+import { ExperimentalBadge, renderSurfaceCopy } from "./annotation";
+import {
+ AppShellWireframe,
+ CommandPaletteWireframe,
+ ComposeScreenWireframe,
+ ExtensionsPluginPageWireframe,
+ RealComposerAnnotated,
+ SettingsWireframe,
+ SurfaceMapContext,
+ useSurfaceMap,
+} from "./wireframes";
+
+/**
+ * Marker numbers restart per slide, matching each fixture's own markers.
+ * "Plugin backend" is absent on purpose: it has no fixture, so a number there
+ * would point at nothing.
+ */
+export const SURFACE_NUMBERS: ReadonlyMap = new Map(
+ SURFACE_GROUPS.filter((group) => group.id !== "headless").flatMap((group) =>
+ group.surfaces.map((surface, index) => [surface.id, index + 1] as const),
+ ),
+);
+
+/**
+ * The adjacent cards in one page's authored annotation order. The surface
+ * array is also what assigns marker numbers, so navigation and the diagram
+ * can never disagree about what "next" means.
+ */
+export function annotationNeighbors(
+ surfaces: readonly PluginSurface[],
+ currentId: string,
+): { previous: PluginSurface | null; next: PluginSurface | null } {
+ const currentIndex = surfaces.findIndex(
+ (surface) => surface.id === currentId,
+ );
+ if (currentIndex === -1) {
+ return { previous: null, next: null };
+ }
+ return {
+ previous: surfaces[currentIndex - 1] ?? null,
+ next: surfaces[currentIndex + 1] ?? null,
+ };
+}
+
+/**
+ * One capability row in the platform grid: icon, title, one-line tagline.
+ * The prose lives in the detail card a click opens, so the grid stays
+ * scannable. Same anchor as a fixture marker, same measurement path.
+ */
+function PlatformCard({ surface }: { surface: PluginSurface }) {
+ const { activeId, setActiveId, expandedId, onSelect } = useSurfaceMap();
+ const selected = activeId === surface.id || expandedId === surface.id;
+ const icon = surfaceIcon(surface.id);
+ return (
+ {
+ event.preventDefault();
+ onSelect(surface.id);
+ }
+ : undefined
+ }
+ onMouseEnter={() => setActiveId(surface.id)}
+ onMouseLeave={() => setActiveId(null)}
+ className={cn(
+ "flex h-full items-center gap-3 rounded-lg border px-4 py-4 transition-colors",
+ selected
+ ? "border-border bg-surface-selected"
+ : // Resting fill one step below hover: a faint opaque lift off the
+ // canvas, so idle cards read as cards, and the hover tint still
+ // lands a clear step darker.
+ "border-border-hairline bg-surface-raised-solid hover:border-border hover:bg-state-hover",
+ )}
+ >
+ {icon ? (
+
+ ) : null}
+
+
+
+ {surface.title}
+
+ {surface.experimental ? : null}
+
+
+ {renderSurfaceCopy(surface.tagline ?? surface.summary)}
+
+
+
+ );
+}
+
+/**
+ * The pixel-less slide: small section eyebrows chunking a two-column grid
+ * of uniform one-line rows, so the ten capabilities scan in one pass.
+ */
+function PlatformSlide({ group }: { group: SurfaceGroup }) {
+ return (
+
+ {(group.sections ?? []).map((section) => {
+ const surfaces = section.surfaceIds
+ .map((id) => SURFACES_BY_ID.get(id))
+ .filter((surface): surface is PluginSurface => Boolean(surface));
+ return (
+
+
+ {section.title}
+
+
+ {surfaces.map((surface) => (
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Fallback for environments where the pan's CSS transition never fires a
+ * `transitionend` (reduced motion, detached layout, jsdom). The normal path
+ * opens a followed reference's card on the transition's own end event, so
+ * this is a ceiling, not the pacing.
+ */
+const PAN_FALLBACK_MS = 600;
+
+/**
+ * Fixtures render proportionally larger on roomy displays, up to this
+ * legibility ceiling — past it, transform-scaled product chrome starts to
+ * read as a zoomed screenshot rather than a diagram.
+ */
+export const MAX_FIXTURE_SCALE = 1.3;
+
+/**
+ * A spatial fixture is authored at product scale, then derives its render
+ * scale from both viewport axes: it shrinks when its complete anatomy cannot
+ * fit, and grows toward {@link MAX_FIXTURE_SCALE} when the panel has room.
+ * The returned value is pure so the boundary is testable without a layout
+ * engine.
+ */
+export function spatialFixtureScale(
+ availableWidth: number,
+ authoredWidth: number,
+ availableHeight?: number,
+ authoredHeight?: number,
+): number {
+ if (availableWidth <= 0 || authoredWidth <= 0) return 1;
+ const heightScale =
+ availableHeight !== undefined &&
+ authoredHeight !== undefined &&
+ availableHeight > 0 &&
+ authoredHeight > 0
+ ? availableHeight / authoredHeight
+ : Number.POSITIVE_INFINITY;
+ return Math.min(
+ MAX_FIXTURE_SCALE,
+ availableWidth / authoredWidth,
+ heightScale,
+ );
+}
+
+const useBrowserLayoutEffect =
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
+
+/**
+ * One owner for every spatial fixture's responsive geometry. Available width
+ * comes from the frame; available height comes from the consumer's declared
+ * scroll viewport (`data-guide-stage-viewport`), measured strictly upstream —
+ * scrollport bottom minus the frame's own top — so nothing the scale itself
+ * resizes feeds back into it.
+ */
+function SpatialFixture({ children }: { children: ReactNode }) {
+ const frameRef = useRef(null);
+ const fixtureRef = useRef(null);
+ const [geometry, setGeometry] = useState({
+ scale: 1,
+ height: null as number | null,
+ width: null as number | null,
+ offsetX: 0,
+ });
+
+ useBrowserLayoutEffect(() => {
+ const frame = frameRef.current;
+ const fixture = fixtureRef.current;
+ if (!frame || !fixture) return;
+ const viewport = frame.closest("[data-guide-stage-viewport]");
+
+ const measure = () => {
+ const authoredWidth = fixture.scrollWidth;
+ const authoredHeight = fixture.scrollHeight;
+ // Scroll-invariant: the frame's offset within the scroll content, not
+ // its live client position, so scrolling never re-scales the fixture.
+ // An open in-flow card is part of the page's height budget — without
+ // subtracting its footprint the fixture fills everything and pins
+ // every card's visible band to the fold, which reads as a fixed-height
+ // card and scrolls the page chrome away.
+ const flowCard = frame
+ .closest("section")
+ ?.querySelector("[data-guide-card-flow]");
+ const cardFootprint = flowCard
+ ? flowCard.getBoundingClientRect().height +
+ parseFloat(getComputedStyle(flowCard).marginTop || "0")
+ : 0;
+ const availableHeight = viewport
+ ? viewport.clientHeight -
+ (frame.getBoundingClientRect().top -
+ viewport.getBoundingClientRect().top +
+ viewport.scrollTop) -
+ cardFootprint -
+ 8
+ : undefined;
+ const scale = spatialFixtureScale(
+ frame.clientWidth,
+ authoredWidth,
+ availableHeight,
+ authoredHeight,
+ );
+ const scaled = Math.abs(scale - 1) >= 0.0001;
+ // The reserve is unconditional: an upscaled fixture needs its extra
+ // room in flow just as a shrunken one returns its spare room.
+ const height = scaled ? authoredHeight * scale : null;
+ const width = scaled ? authoredWidth : null;
+ const offsetX = scaled
+ ? Math.max(0, (frame.clientWidth - authoredWidth * scale) / 2)
+ : 0;
+ setGeometry((current) =>
+ Math.abs(current.scale - scale) < 0.0001 &&
+ current.height === height &&
+ current.width === width &&
+ Math.abs(current.offsetX - offsetX) < 0.5
+ ? current
+ : { scale, height, width, offsetX },
+ );
+ };
+
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(frame);
+ observer.observe(fixture);
+ if (viewport) observer.observe(viewport);
+ // The in-flow card mounts and unmounts with the open annotation; watch
+ // the section so its appearance re-budgets the height, and its own size
+ // while present.
+ const section = frame.closest("section");
+ let observedCard: Element | null = null;
+ const watchCard = () => {
+ const card = section?.querySelector("[data-guide-card-flow]") ?? null;
+ if (card === observedCard) return;
+ if (observedCard) observer.unobserve(observedCard);
+ observedCard = card;
+ if (card) observer.observe(card);
+ measure();
+ };
+ watchCard();
+ const cardObserver = section
+ ? new MutationObserver(watchCard)
+ : null;
+ cardObserver?.observe(section as Node, { childList: true });
+ return () => {
+ observer.disconnect();
+ cardObserver?.disconnect();
+ };
+ }, []);
+
+ const scaled = geometry.height !== null;
+ return (
+
+ );
+}
+
+function SlideContent({ group }: { group: SurfaceGroup }) {
+ switch (group.id) {
+ case "app-shell":
+ return ;
+ case "command-palette":
+ return ;
+ case "composer":
+ return ;
+ case "home":
+ return ;
+ case "settings":
+ return ;
+ case "extensions":
+ return ;
+ case "headless":
+ return ;
+ }
+}
+
+function Slide({ group }: { group: SurfaceGroup }) {
+ if (fixtureResponsiveStrategy(group) === "reflow") {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+/**
+ * A slide title with the bare word "bb" set the way the wordmark reads:
+ * bold italic. Text rather than the SVG mark on purpose — at heading size on
+ * a 1x display an 11px vector path rasterises to a blob, while the font
+ * rasteriser hints glyphs at any size. The title stays a plain string
+ * everywhere else — nav labels, aria, tests — so only the rendered heading
+ * changes.
+ */
+function SlideTitle({ title }: { title: string }) {
+ const parts = title.split(/\bbb\b/);
+ if (parts.length === 1) {
+ return <>{title}>;
+ }
+ return (
+ <>
+ {parts.map((part, index) => (
+
+ {index > 0 ? bb : null}
+ {part}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * Which pan caret is enabled at `index`. Both carets always render so the
+ * row's geometry never changes; an end of the range just disables its caret.
+ *
+ * Pure so the ends are testable without a layout engine.
+ */
+export function panCarets(
+ index: number,
+ slideCount: number,
+): { previous: boolean; next: boolean } {
+ return { previous: index > 0, next: index < slideCount - 1 };
+}
+
+function PanButton({
+ direction,
+ disabled,
+ onClick,
+}: {
+ direction: "previous" | "next";
+ disabled: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Keeps the stage exactly as tall as the slide on show, so a short fixture
+ * does not leave the tallest one's empty space below it.
+ */
+function useStageHeight(
+ index: number,
+ slideRefs: React.RefObject>,
+): number | null {
+ const [height, setHeight] = useState(null);
+ useEffect(() => {
+ const slide = slideRefs.current[index];
+ if (!slide) {
+ return;
+ }
+ const measure = () => setHeight(slide.getBoundingClientRect().height);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(slide);
+ return () => observer.disconnect();
+ }, [index, slideRefs]);
+ return height;
+}
+
+export function ProductMap({
+ header,
+ pluginPageHref,
+ initialSlideId,
+ onSlideChange,
+ onCopyForAgent,
+ tone = "primary",
+}: {
+ /** Page copy above the diagrams; omitted inside compact plugin panels. */
+ header?: ReactNode;
+ /**
+ * Resolves a shipped plugin's page in the running bb, or null when this
+ * host has no page for it. Only the in-app copy can answer that, so the
+ * docs website omits it and the "Used by" names render as plain text.
+ */
+ pluginPageHref?: (displayName: string) => string | null;
+ /**
+ * The slide to open on, by surface-group id. The bb plugin feeds the nav
+ * panel's subPath back in here, so leaving the page and coming back (the
+ * app's Back button, a shared link) lands on the slide you left.
+ */
+ initialSlideId?: string;
+ /** Fires when the reader pans; the bb plugin mirrors it into the URL. */
+ onSlideChange?: (slideId: string) => void;
+ /** Copies a surface as a structured bb composer reference. */
+ onCopyForAgent?: (surface: PluginSurface) => Promise;
+ /**
+ * "supporting" steps the per-slide heading and blurb down a level, for
+ * pages where the map explains the docs rather than leading them. Behavior,
+ * markers, and card content are identical either way.
+ */
+ tone?: "primary" | "supporting";
+}) {
+ const slides = SURFACE_GROUPS;
+ const containerRef = useRef(null);
+ const slideRefs = useRef>([]);
+ const pageListRef = useRef(null);
+ const pageButtonRefs = useRef>([]);
+ const card = useSurfaceCard();
+ const [hoverId, setHoverId] = useState(null);
+ const [pendingOpenId, setPendingOpenId] = useState(null);
+ const [index, setIndex] = useState(() =>
+ Math.max(
+ 0,
+ slides.findIndex((slide) => slide.id === initialSlideId),
+ ),
+ );
+ const stageHeight = useStageHeight(index, slideRefs);
+
+ // The page selector is the sole horizontal scroller. Keep the active page
+ // visible without asking scrollIntoView to move the document vertically.
+ useEffect(() => {
+ const list = pageListRef.current;
+ const button = pageButtonRefs.current[index];
+ if (!list || !button) return;
+ const listRect = list.getBoundingClientRect();
+ const buttonRect = button.getBoundingClientRect();
+ const leftDelta = buttonRect.left - listRect.left;
+ const rightDelta = buttonRect.right - listRect.right;
+ if (leftDelta < 0) list.scrollLeft += leftDelta;
+ else if (rightDelta > 0) list.scrollLeft += rightDelta;
+ }, [index]);
+
+ const openSurface = card.openId ? SURFACES_BY_ID.get(card.openId) : undefined;
+ const carets = panCarets(index, slides.length);
+
+ // Panning away from a card's marker would strand the card, so it closes.
+ // A pan also supersedes any reference-follow still waiting on a previous
+ // pan, so a stale open can never land on the wrong slide.
+ const show = (next: number) => {
+ if (next < 0 || next >= slides.length) {
+ return;
+ }
+ card.close();
+ setHoverId(null);
+ setPendingOpenId(null);
+ setIndex(next);
+ onSlideChange?.(slides[next].id);
+ };
+
+ /**
+ * Follows a card's cross-reference: pan to the slide that draws the named
+ * surface, then open its card. The open waits for the pan to land because
+ * the card measures its marker's live geometry to place itself, and an
+ * off-stage marker measures where it is parked, not where it will be. The
+ * landing signal is the track's own `transitionend` (see the stage below);
+ * the effect provides only a ceiling for environments without one.
+ */
+ const goToSurface = (id: string) => {
+ const group = GROUP_BY_SURFACE_ID.get(id);
+ if (!group) return;
+ const target = slides.findIndex((slide) => slide.id === group.id);
+ if (target === -1) return;
+ if (target === index) {
+ card.open(id);
+ return;
+ }
+ show(target);
+ setPendingOpenId(id);
+ };
+
+ useEffect(() => {
+ if (pendingOpenId === null) return;
+ const timer = window.setTimeout(() => {
+ setPendingOpenId(null);
+ card.open(pendingOpenId);
+ }, PAN_FALLBACK_MS);
+ return () => window.clearTimeout(timer);
+ // `card.open` is rebuilt each render by design: it reads live geometry.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pendingOpenId]);
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "ArrowRight") {
+ event.preventDefault();
+ show(index + 1);
+ } else if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ show(index - 1);
+ }
+ };
+
+ const mapState = useMemo(
+ () => ({
+ activeId: hoverId,
+ setActiveId: setHoverId,
+ // The open card is the selection, so its marker stays lit.
+ expandedId: card.openId,
+ numberOf: (id: string) => SURFACE_NUMBERS.get(id) ?? null,
+ onSelect: card.open,
+ pluginPageHref,
+ currentGroupId: slides[index].id,
+ onGoToSurface: goToSurface,
+ }),
+ // `card.open` is rebuilt each render by design: it reads live geometry.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [hoverId, card.openId, pluginPageHref, index],
+ );
+
+ const cardNode = openSurface ? (
+
+ ) : null;
+ // Click-away, scoped to the plugin's own UI. A pointer-down anywhere in the
+ // guide that is not on the open card or on a marker dismisses the card.
+ // Beyond the plugin's root — the pane beside it, the sidebar, bb's chrome —
+ // the card is left alone, so reading it while working in a split does not
+ // lose it. The root is the host's `[data-bb-plugin]` scoping element; with
+ // no host (tests, a bare render) the map's own container stands in.
+ useEffect(() => {
+ if (card.openId === null) return;
+ const container = containerRef.current;
+ if (container === null) return;
+ const scope =
+ container.closest("[data-bb-plugin]") ?? container;
+ const onPointerDown = (event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ if (target.closest('[role="dialog"]')) return;
+ // A marker replaces the card with its own; let its click do that.
+ if (target.closest('a[href^="#surface-"]')) return;
+ card.close();
+ };
+ scope.addEventListener("pointerdown", onPointerDown);
+ return () => scope.removeEventListener("pointerdown", onPointerDown);
+ // `card.close` is a setState call behind a fresh closure each render;
+ // re-subscribing per open/close is all that is needed.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [card.openId]);
+
+ return (
+
+
+ {/* The one content-width token: the column, the stage, and the card
+ all fill it, so their edges align at every viewport. It grows on
+ wide displays — that headroom is what lets fixtures render above
+ authored size instead of floating in margin. The per-slide blurb
+ keeps its own narrower reading measure, a line-length constraint
+ rather than a layout width. */}
+
+ {header}
+
+
+ {/* The page description and, under a hairline, the navigation —
+ fixed above the stage so panning swaps only the diagram. */}
+
+ {tone === "supporting" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {slides[index].blurb}
+
+
+ {/* Carets hug the label strip: the group shrink-wraps and centers
+ as one unit, so the carets sit flush against the labels and
+ only reach the row's edges when the list genuinely overflows
+ (max-w-full pins the group; the scroller keeps sole
+ horizontal-scroll ownership). */}
+
+
show(index - 1)}
+ />
+
+
+ {slides.map((entry, slideIndex) => (
+
+ {
+ pageButtonRefs.current[slideIndex] = element;
+ }}
+ type="button"
+ onClick={() => show(slideIndex)}
+ aria-current={slideIndex === index ? "true" : undefined}
+ className={cn(
+ "cursor-pointer whitespace-nowrap rounded-md px-2.5 py-1 text-xs transition-colors",
+ slideIndex === index
+ ? "bg-surface-selected text-foreground"
+ : "text-subtle-foreground hover:bg-state-hover hover:text-foreground",
+ )}
+ >
+ {entry.title}
+
+
+ ))}
+
+
+ show(index + 1)}
+ />
+
+
+
{
+ if (
+ event.target !== event.currentTarget ||
+ event.propertyName !== "transform" ||
+ pendingOpenId === null
+ ) {
+ return;
+ }
+ setPendingOpenId(null);
+ card.open(pendingOpenId);
+ }}
+ >
+ {slides.map((entry, slideIndex) => (
+
{
+ slideRefs.current[slideIndex] = element;
+ }}
+ // Off-stage slides stay out of the tab order and out of
+ // the accessibility tree until they are panned to.
+ inert={slideIndex !== index}
+ // A taller off-stage slide would show below the stage now
+ // that the stage no longer clips downward, so it keeps to
+ // the stage's height itself. The slide on stage is never
+ // capped: that is what lets the composer's typeahead out.
+ style={
+ slideIndex === index || stageHeight === null
+ ? undefined
+ : { maxHeight: stageHeight, overflow: "hidden" }
+ }
+ // Markers sit slightly outside their region; the padding
+ // keeps them inside the stage's clip. No shared min-height:
+ // the stage measures the slide on stage and animates
+ // between them, so a card opening below sits under the
+ // diagram rather than under the tallest slide's reserved
+ // canvas.
+ // `min-w-0` belongs on the carousel item, which is the
+ // available-width owner. Without it, a spatial child's
+ // authored min-width expands this flex item before
+ // SpatialFixture measures the frame, making the measured
+ // "available" width equal the authored width and
+ // incorrectly producing scale=1 in split panes.
+ // The detail gap has one owner (the clamp below), so slides
+ // contribute no second block-end gap.
+ className="min-w-0 w-full shrink-0 self-start px-1 pt-2"
+ >
+
+
+ ))}
+
+
+
+ {/* The detail card, when no gutter can hold it: in flow under the
+ diagram, never covering it. The gap is the one owner of the
+ stage-to-card rhythm and derives from the panel's height —
+ the consumer declares the container (see the plugin's
+ scrollport); with none declared it keeps the 8px floor. */}
+ {cardNode ? (
+
+ {cardNode}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/packages/plugin-api-map/src/surface-card.tsx b/packages/plugin-api-map/src/surface-card.tsx
new file mode 100644
index 0000000000..c7db1acc9d
--- /dev/null
+++ b/packages/plugin-api-map/src/surface-card.tsx
@@ -0,0 +1,296 @@
+/**
+ * The product map's annotation card: click a numbered marker and its details
+ * open in flow directly below the diagram. Always below, at every width, so
+ * the card never covers the region it describes and never moves under the
+ * reader between one marker and the next.
+ */
+import { useCallback, useContext, useEffect, useRef, useState } from "react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowLeft01Icon,
+ ArrowRight01Icon,
+ Cancel01Icon,
+ Copy01Icon,
+ Tick02Icon,
+} from "@hugeicons/core-free-icons";
+
+import { GROUP_BY_SURFACE_ID, type PluginSurface } from "./surfaces";
+import {
+ annotationChipClass,
+ ExperimentalBadge,
+ renderSurfaceCopy,
+ type SurfaceReference,
+} from "./annotation";
+import { pluginIcon, surfaceIcon } from "./plugin-icons";
+import { UsedByList } from "./used-by";
+import { SurfaceMapContext } from "./wireframes";
+
+export function SurfaceCard({
+ surface,
+ number,
+ onDismiss,
+ onCopyForAgent,
+ navigation,
+}: {
+ surface: PluginSurface;
+ /** Marker number, so the card reads as the same annotation. */
+ number: number | null;
+ onDismiss: () => void;
+ onCopyForAgent?: (surface: PluginSurface) => Promise;
+ /** Adjacent annotations on this page; omitted for standalone cards. */
+ navigation?: {
+ previous: PluginSurface | null;
+ next: PluginSurface | null;
+ onOpen: (surfaceId: string) => void;
+ };
+}) {
+ const cardRef = useRef(null);
+ // Null outside a map (the reference sidebar renders cards standalone), and
+ // without a resolver the names render as plain text rather than as links
+ // that would dead-end on "Plugin not found".
+ const surfaceMap = useContext(SurfaceMapContext);
+ const pluginPageHref = surfaceMap?.pluginPageHref;
+ const icon = surfaceIcon(surface.id);
+ const { currentGroupId, onGoToSurface, numberOf } = surfaceMap ?? {};
+ const [copyState, setCopyState] = useState<
+ "idle" | "copying" | "copied" | "failed"
+ >("idle");
+ const copyResetTimer = useRef(null);
+
+ useEffect(() => {
+ setCopyState("idle");
+ if (copyResetTimer.current !== null) {
+ window.clearTimeout(copyResetTimer.current);
+ copyResetTimer.current = null;
+ }
+ }, [surface.id]);
+
+ useEffect(
+ () => () => {
+ if (copyResetTimer.current !== null) {
+ window.clearTimeout(copyResetTimer.current);
+ }
+ },
+ [],
+ );
+
+ const copyForAgent = useCallback(async () => {
+ if (!onCopyForAgent || copyState === "copying") return;
+ setCopyState("copying");
+ const copied = await onCopyForAgent(surface);
+ setCopyState(copied ? "copied" : "failed");
+ copyResetTimer.current = window.setTimeout(() => {
+ setCopyState("idle");
+ copyResetTimer.current = null;
+ }, 2_000);
+ }, [copyState, onCopyForAgent, surface]);
+ // Cross-references only resolve inside the map: the number and the "which
+ // page" answer both come from the carousel. Elsewhere the label is prose.
+ const resolveReference = useCallback(
+ (id: string): SurfaceReference | null => {
+ const group = GROUP_BY_SURFACE_ID.get(id);
+ if (!group || !onGoToSurface) return null;
+ return {
+ number: numberOf?.(id) ?? null,
+ otherPage: group.id === currentGroupId ? null : group.title,
+ onOpen: () => onGoToSurface(id),
+ };
+ },
+ [currentGroupId, numberOf, onGoToSurface],
+ );
+
+ // Dismissal: the close button, Escape, or a click elsewhere within the
+ // guide's own UI (ProductMap owns that listener, scoped to the plugin
+ // root). Losing focus to the rest of bb — another pane, the sidebar — does
+ // not close it, so a card can be read while working beside it. Selecting
+ // another marker replaces the card, and panning to another slide closes
+ // it, because its marker leaves the screen.
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onDismiss();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [onDismiss]);
+
+ // The card sits below the diagram, so on a short screen it can open past
+ // the fold; bring it into view whenever the open surface changes.
+ useEffect(() => {
+ cardRef.current?.scrollIntoView({ block: "nearest" });
+ }, [surface.id]);
+
+ return (
+
+
+ {/* A numbered surface is identified by its marker; a pixel-less one
+ has no marker, so it carries the same capability glyph its card on
+ the "Plugin backend" slide was clicked from. */}
+ {number === null ? (
+ icon ? (
+
+ ) : null
+ ) : (
+
+ {number}
+
+ )}
+
+
+
+ {surface.title}
+
+ {surface.experimental ? : null}
+
+
+
+ {navigation ? (
+
+ {(
+ [
+ ["previous", navigation.previous, ArrowLeft01Icon],
+ ["next", navigation.next, ArrowRight01Icon],
+ ] as const
+ ).map(([direction, target, arrowIcon]) => {
+ const directionLabel =
+ direction === "previous" ? "Previous" : "Next";
+ const label = target
+ ? `${directionLabel} annotation: ${target.title}`
+ : `No ${direction} annotation`;
+ return (
+ {
+ if (target) navigation.onOpen(target.id);
+ }}
+ disabled={!target}
+ aria-label={label}
+ title={label}
+ className="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
+ >
+
+
+ );
+ })}
+
+ ) : null}
+
+
+
+
+
+
+
+ {renderSurfaceCopy(surface.summary, resolveReference)}
+
+
+ {surface.bullets.map((bullet) => (
+ {renderSurfaceCopy(bullet, resolveReference)}
+ ))}
+
+
+ {(surface.firstParty && surface.firstParty.length > 0) ||
+ onCopyForAgent ? (
+ // A footnote, not a second subject: the label recedes to an eyebrow
+ // above the border so the surface copy stays the card's content.
+
+ {/* Inline lead-in, not a stacked heading: the label shares the
+ first baseline with the list, which keeps to one line and
+ drifts when it outgrows the row. */}
+ {/* A subtle pill: the recessed tint alone, no border and no extra
+ weight, so the label sits under the names it introduces. */}
+ {surface.firstParty && surface.firstParty.length > 0 ? (
+ <>
+
+ Used by
+
+
{
+ const icon = pluginIcon(plugin);
+ const href = pluginPageHref?.(plugin) ?? null;
+ const body = (
+ <>
+ {icon ? (
+
+ ) : null}
+ {plugin}
+ >
+ );
+ return href ? (
+
+ {body}
+
+ ) : (
+
+ {body}
+
+ );
+ }}
+ />
+ >
+ ) : null}
+ {onCopyForAgent ? (
+ void copyForAgent()}
+ disabled={copyState === "copying"}
+ className="ml-auto inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground disabled:cursor-wait disabled:opacity-60"
+ >
+
+
+ {copyState === "copying"
+ ? "Copying…"
+ : copyState === "copied"
+ ? "Copied"
+ : copyState === "failed"
+ ? "Copy failed"
+ : "Copy for agent"}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+/** Tracks which surface's card is open. */
+export function useSurfaceCard() {
+ const [openId, setOpenId] = useState(null);
+ return {
+ openId,
+ open: (id: string) => setOpenId(id),
+ close: () => setOpenId(null),
+ };
+}
diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts
new file mode 100644
index 0000000000..f789ec63cb
--- /dev/null
+++ b/packages/plugin-api-map/src/surfaces.ts
@@ -0,0 +1,790 @@
+/**
+ * The product-shape inventory behind the Plugin Guide: every surface a plugin
+ * can plug into, in plain language. This file is the whole of bb's plugin API
+ * documentation, so it has to stay current with the SDK — each surface names
+ * the SDK symbols it documents and api-sync.test.ts fails the build when the
+ * two drift apart.
+ *
+ * Each surface reads as plain product capability: what becomes possible in
+ * that part of bb once a plugin owns it.
+ *
+ * `firstParty` lists the shipped bb plugins that use each surface today,
+ * taken from a registration-call inventory of plugins/* in the bb repo.
+ */
+
+export interface PluginSurface {
+ id: string;
+ title: string;
+ /** Lead sentence, ending in the phrase the bullets hang off. */
+ summary: string;
+ /** What a plugin can do on this surface, one capability per line. */
+ bullets: string[];
+ /**
+ * One scannable line for capability grids; the prose stays in the detail
+ * card. Only the pixel-less surfaces need one today.
+ */
+ tagline?: string;
+ /**
+ * The SDK symbols this surface documents. Not shown in the UI: it is the
+ * tie between a card and the API it describes, and api-sync.test.ts fails
+ * when one of them is renamed or removed, or when a new registration slot
+ * ships with no card naming its type.
+ */
+ apiSymbols: string[];
+ /** First-party bb plugins that ship on this surface today (display names). */
+ firstParty?: string[];
+ experimental?: boolean;
+}
+
+export interface SurfaceGroup {
+ id:
+ | "app-shell"
+ | "command-palette"
+ | "composer"
+ | "home"
+ | "settings"
+ | "extensions"
+ | "headless";
+ title: string;
+ blurb: string;
+ /** Product anatomy stays spatial; non-spatial documentation can reflow. */
+ fixtureKind: "spatial" | "capability-grid";
+ surfaces: PluginSurface[];
+ /**
+ * Named clusters for a group that lists its surfaces instead of drawing
+ * them. Every surface id in the group appears in exactly one section
+ * (surfaces.test.ts enforces it).
+ */
+ sections?: readonly {
+ title: string;
+ surfaceIds: readonly string[];
+ }[];
+}
+
+export type FixtureResponsiveStrategy = "scale-together" | "reflow";
+
+/**
+ * Responsive behavior follows fixture meaning, never an author's per-page
+ * preference. Product anatomy scales as one composition; the one non-spatial
+ * capability grid uses ordinary document reflow.
+ */
+export function fixtureResponsiveStrategy(
+ group: Pick,
+): FixtureResponsiveStrategy {
+ return group.fixtureKind === "spatial" ? "scale-together" : "reflow";
+}
+
+export const SURFACE_GROUPS: SurfaceGroup[] = [
+ {
+ id: "app-shell",
+ title: "The bb app window",
+ fixtureKind: "spatial",
+ blurb:
+ "The main bb window, containing the sidebar, the conversation, and the side panel. A plugin can add rows, controls, panel tabs, and message content to the numbered regions.",
+ surfaces: [
+ {
+ id: "nav-panel",
+ title: "Full-page panels",
+ summary:
+ "Adds a row to bb's sidebar that opens a page your plugin renders where threads normally appear. With this, a plugin can:",
+ bullets: [
+ "Render any React you write across that whole area",
+ "Get its own URL, so the page can be linked to and bb's back and forward buttons work",
+ "Register tabs in the panel to the right of its page, beside bb's own Browser and Terminal tabs",
+ ],
+ apiSymbols: ["PluginNavPanelRegistration"],
+ firstParty: ["Automations", "Docs", "GitHub", "Tasks"],
+ },
+ {
+ id: "thread-list",
+ title: "The thread list",
+ summary:
+ "Replaces the list of threads in bb's sidebar with a component your plugin renders. With this, a plugin can:",
+ bullets: [
+ "Render every row, and decide the grouping, the ordering, and what each row shows",
+ "Read the same live thread data and run statuses bb's own list reads",
+ "Replace only the list. The New thread button, the search field, the plugin rows, and the sidebar footer stay bb's",
+ ],
+ apiSymbols: [
+ "PluginThreadListRegistration",
+ "PluginSidebarThreadsState",
+ ],
+ experimental: true,
+ },
+ {
+ id: "thread-row-status",
+ title: "Thread row status",
+ summary:
+ "A small status bb can draw on a thread's row in the sidebar. With this, a plugin can:",
+ bullets: [
+ "Give the status an icon and a label",
+ "Mark a thread as running while it works on it, and bb shimmers the icon",
+ "Mark it succeeded or failed when the work ends, and bb settles the icon",
+ "Set it only from an [app-wide script](content-scripts). A status needs an owner that outlives any single screen, and those scripts are the only plugin code that does",
+ "Rely on bb to clear it when the script unmounts",
+ ],
+ apiSymbols: [
+ "PluginComposerThreadRowStatus",
+ "PluginContentScriptContext",
+ ],
+ experimental: true,
+ },
+ {
+ id: "sidebar-footer",
+ title: "Sidebar footer buttons",
+ summary:
+ "Adds an icon button to the row at the bottom of bb's sidebar, beside the Settings button. With this, a plugin can:",
+ bullets: [
+ "Supply the button's icon and its hover tooltip",
+ "Run a callback when the button is clicked",
+ "Stay reachable wherever bb's sidebar is showing",
+ ],
+ apiSymbols: ["PluginSidebarFooterActionRegistration"],
+ firstParty: ["Remote access"],
+ },
+ {
+ id: "thread-header",
+ title: "Thread header controls",
+ summary:
+ "Adds a control to the header bar at the top of an open thread. With this, a plugin can:",
+ bullets: [
+ "Render a React component rather than a plain button, so it can show live state",
+ "Receive the id of the thread currently on screen",
+ "Render in the same row as bb's own header controls",
+ ],
+ apiSymbols: ["PluginThreadHeaderActionRegistration"],
+ experimental: true,
+ },
+ {
+ id: "message-directives",
+ title: "Rich message embeds",
+ summary:
+ "Renders your component inside an agent's reply, in place of a marker the agent writes into its message. With this, a plugin can:",
+ bullets: [
+ "Claim a directive name; an agent writes `::name` in a message to invoke it",
+ "Replace that marker with a live component, inline in the conversation",
+ "Open a file from the workspace when someone interacts with the embed",
+ ],
+ apiSymbols: ["PluginMessageDirectiveRegistration"],
+ firstParty: ["Docs", "Inline visualizations", "Tasks", "Workflows"],
+ },
+ {
+ id: "message-actions",
+ title: "Message actions",
+ summary:
+ "Adds an action to individual messages in a thread. With this, a plugin can:",
+ bullets: [
+ "Appear in the row that shows under messages on hover, or in the toolbar that appears when text in an agent's message is selected",
+ "Receive the message, plus the selected text when the action was run from a selection",
+ "Open one of the plugin's own [side-panel tabs](thread-panel) with what it received",
+ ],
+ apiSymbols: ["PluginMessageActionRegistration"],
+ firstParty: ["Side chat"],
+ },
+ {
+ id: "pending-interaction",
+ title: "In-thread forms",
+ summary:
+ "Pauses an agent mid-turn to ask the person a question, and hands their answer back to the agent. With this, a plugin can:",
+ bullets: [
+ "Replace the prompt box with a form while the agent waits for an answer",
+ "Receive the submitted answer, or a cancellation and its reason",
+ "Supply the component that draws the form",
+ ],
+ apiSymbols: ["PluginUi", "PluginPendingInteractionRegistration"],
+ firstParty: ["Ask User Question", "Secrets"],
+ },
+ {
+ id: "code-renderers",
+ title: "Code & diff renderers",
+ summary:
+ "Replaces bb's source-code or diff renderer everywhere that kind of content appears. With this, a plugin can:",
+ bullets: [
+ "Register the source-code and diff replacements independently",
+ "Apply each replacement across bb's file previews, timeline and environment diffs, and plugin pages",
+ "Hand any individual render back to bb's built-in renderer, and fall back to it automatically if the plugin is unavailable or crashes",
+ ],
+ apiSymbols: [
+ "PluginSourceCodeRendererRegistration",
+ "PluginSourceCodeRendererProps",
+ "PluginDiffRendererRegistration",
+ "PluginDiffRendererProps",
+ ],
+ experimental: true,
+ },
+ {
+ id: "thread-panel",
+ title: "Thread side-panel tabs",
+ summary:
+ "Adds a tab to the side panel that opens to the right of a thread. With this, a plugin can:",
+ bullets: [
+ "Render the tab's contents and receive the id of the thread it was opened from",
+ "Open the tab from a [message action](message-actions), from the + button in the side panel, or from its own code",
+ ],
+ apiSymbols: ["PluginThreadPanelActionRegistration"],
+ firstParty: ["Docs", "GitHub", "Side chat", "Tasks", "Workflows"],
+ },
+ {
+ id: "file-opener",
+ title: "File viewers & editors",
+ summary:
+ "Registers a viewer for the file types you name, so bb opens those files there instead of its built-in preview. With this, a plugin can:",
+ bullets: [
+ "Declare the file extensions it handles, for example `.csv` or `.excalidraw`",
+ "Render its own viewer or editor whenever a file of that type is opened in bb",
+ "Receive the file's path, then read it however the plugin already reads files",
+ ],
+ apiSymbols: ["PluginFileOpenerRegistration"],
+ firstParty: ["Docs"],
+ },
+ {
+ id: "timeline-renderers",
+ title: "Timeline entry content",
+ summary:
+ "Renders the expanded content of plugin-owned timeline entries while bb keeps each entry's header and controls. With this, a plugin can:",
+ bullets: [
+ "Draw the expanded content beneath timeline entries created by the plugin's own provider",
+ "Receive the entry data and plugin payload, plus bb's default content as `Original`",
+ "Fall back to bb's default content automatically when the plugin is unavailable or crashes",
+ ],
+ apiSymbols: [
+ "PluginTimelineRendererRegistration",
+ "PluginTimelineRendererProps",
+ ],
+ experimental: true,
+ },
+ {
+ id: "content-scripts",
+ title: "App-wide scripts",
+ summary:
+ "Runs your code inside the bb window itself, without rendering a UI of its own. With this, a plugin can:",
+ bullets: [
+ "Mount once per bb window and unmount when the window reloads",
+ "Add behavior that is not tied to one screen, such as a keyboard shortcut",
+ "Set a [thread row status](thread-row-status) on any thread, for as long as the script is mounted",
+ "Add plugin-owned elements to app pages without taking ownership of bb's built-in layout",
+ "Return a cleanup function. bb calls it once on unmount, and clears any row statuses the script set",
+ ],
+ apiSymbols: [
+ "PluginContentScriptRegistration",
+ "PluginContentScriptContext",
+ ],
+ },
+ ],
+ },
+ {
+ id: "command-palette",
+ title: "Command palette",
+ fixtureKind: "spatial",
+ blurb:
+ "bb's searchable command menu. A plugin can add actions that match, rank, and run alongside bb's own commands.",
+ surfaces: [
+ {
+ id: "command-palette-actions",
+ title: "Command palette actions",
+ summary:
+ "Adds a row under Plugins in bb's quick command palette. With this, a plugin can:",
+ bullets: [
+ "Supply the row's label and run behavior; bb owns matching, ordering, and recency",
+ "Read the current thread and project, and hide the row when it is unavailable",
+ "Open one of the plugin's own thread side-panel tabs when a thread is on screen",
+ ],
+ apiSymbols: [
+ "PluginCommandPaletteActionRegistration",
+ "PluginCommandPaletteActionContext",
+ ],
+ },
+ ],
+ },
+ {
+ id: "composer",
+ title: "The composer",
+ fixtureKind: "spatial",
+ blurb:
+ "The prompt box used to start a thread and to reply inside one. A plugin can add banners, menu entries, and action buttons to it, answer mention searches, highlight the draft prompt, and supply the agent that runs the message.",
+ surfaces: [
+ {
+ id: "composer-banners",
+ title: "Banners",
+ summary:
+ "Renders a banner above the prompt box. With this, a plugin can:",
+ bullets: [
+ "Render its own component in the strip directly above the draft prompt",
+ "Name which prompt boxes it appears in: the new-thread screen, the follow-up composer in a thread, or a queued message being edited. Omit the list to appear in all of them",
+ "Show something the person should read before sending, such as a warning or a status",
+ ],
+ apiSymbols: ["ComposerCustomization", "PluginComposerScope"],
+ firstParty: ["Provider retry", "Workflows"],
+ },
+ {
+ id: "mention-provider",
+ title: "Mentions",
+ summary:
+ "Adds results to the menu that opens when someone types a trigger character in the prompt box. On a trigger bb does not use itself, your plugin opens that menu and owns it. With this, a plugin can:",
+ bullets: [
+ "Answer each keystroke after the trigger with a list of items to show",
+ "Claim one or more of the trigger characters @, #, $, !, and ~. Omit them to answer the default @",
+ "Turn a picked item into a chip in the draft prompt, and send its content to the agent along with the message",
+ ],
+ apiSymbols: [
+ "PluginMentionProviderRegistration",
+ "PluginMentionSearchContext",
+ "PluginMentionItem",
+ ],
+ firstParty: ["Docs", "GitHub", "Tasks"],
+ },
+ {
+ id: "composer-rich-text",
+ title: "Draft prompt highlighting",
+ summary:
+ "Styles text ranges as the person types a prompt, without changing the text. With this, a plugin can:",
+ bullets: [
+ "Match ranges in the draft prompt, such as a ticket number or the word TODO",
+ "Change only how those ranges look; the text the agent receives is untouched",
+ "Re-run its matcher on every keystroke",
+ "Observe the draft prompt and its @-mentions as they change, read-only",
+ ],
+ apiSymbols: ["ComposerRichTextSpec", "ComposerStructuredDraft"],
+ },
+ {
+ id: "composer-state",
+ title: "Draft prompt state & locking",
+ summary:
+ "Reads the draft prompt, and can block typing while the plugin works. With this, a plugin can:",
+ bullets: [
+ "Read the draft prompt's text, whether it is empty, and how many files are attached",
+ "Read the prompt box's layout and whether the thread is already running a turn",
+ "Lock the input and release it again, so the draft prompt cannot change mid-operation",
+ "Mark the thread row as running while the input is locked, with a [thread row status](thread-row-status)",
+ ],
+ apiSymbols: ["ComposerView", "PluginComposerApi"],
+ },
+ {
+ id: "composer-plus-menu",
+ title: "The + menu",
+ summary:
+ "Adds rows to the menu that opens from the + button beside the prompt box. With this, a plugin can:",
+ bullets: [
+ "Supply each row's icon, label, and disabled state; bb renders the row itself",
+ "Run a callback when someone picks the row",
+ "Read and rewrite the draft prompt from that callback",
+ ],
+ apiSymbols: ["ComposerPlusMenuItem"],
+ },
+ {
+ id: "provider-picker",
+ title: "Agent providers",
+ summary:
+ "Adds an agent to bb's model picker and runs the threads started with it. With this, a plugin can:",
+ bullets: [
+ "Appear in the model picker beside bb's built-in providers",
+ "Declare what the provider supports, then serve its model list at runtime",
+ "Supply a small icon that appears next to its name",
+ "Receive every message in a thread started with it, through a bridge process the plugin ships",
+ ],
+ apiSymbols: [
+ "PluginProviderDeclaration",
+ "PluginProviderIconRegistration",
+ ],
+ firstParty: [
+ "ACP providers",
+ "Claude Code provider",
+ "Codex provider",
+ "Pi provider",
+ ],
+ experimental: true,
+ },
+ {
+ id: "composer-actions",
+ title: "Inline actions",
+ summary:
+ "Adds a button to the row of controls inside the prompt box, beside the voice and send buttons. With this, a plugin can:",
+ bullets: [
+ "Read and rewrite the draft prompt, for example rephrasing it or inserting a template",
+ "Insert an @-mention into the draft so its provider can resolve fresh context when the message is sent",
+ "Lock the input while it works, and tint the whole draft while it does",
+ "Render in the same row as bb's own prompt-box buttons. If you have more than 3 plugins enabled, bb keeps the 3 most-used plugins inline and moves the rest into an overflow menu",
+ ],
+ apiSymbols: ["PluginComposerApi"],
+ },
+ ],
+ },
+ {
+ id: "home",
+ title: "Home page",
+ fixtureKind: "spatial",
+ blurb:
+ "The screen bb opens on, holding the new-thread composer and a side panel. A plugin can add a section below the composer, and an action in that panel that opens its own tab.",
+ surfaces: [
+ {
+ id: "homepage-section",
+ title: "Home-screen sections",
+ summary:
+ "Adds a full-width section to the page bb opens on, below the prompt box. With this, a plugin can:",
+ bullets: [
+ "Render its own component across the width of the content area",
+ "Render before any thread exists, which suits shortcuts and pinned work",
+ "Render after bb's own content, in the order plugins registered",
+ ],
+ apiSymbols: ["PluginHomepageSectionRegistration"],
+ },
+ {
+ id: "new-thread-panel",
+ title: "New-thread side panel",
+ summary:
+ "Adds a plugin tab to the side panel on the new-thread screen. With this, a plugin can:",
+ bullets: [
+ "Render before a thread exists, so it receives no thread id",
+ "Host setup the person does while writing the first prompt",
+ "Receive the project selected in the prompt box",
+ ],
+ apiSymbols: ["PluginNewThreadPanelActionRegistration"],
+ experimental: true,
+ },
+ ],
+ },
+ {
+ id: "settings",
+ title: "Plugin settings page",
+ fixtureKind: "spatial",
+ blurb:
+ "The settings page bb creates for every installed plugin. A plugin can declare fields for bb to render and add its own section below them.",
+ surfaces: [
+ {
+ id: "declarative-settings",
+ title: "Settings fields",
+ summary:
+ "Declares the settings your plugin needs as plain data; bb renders the form for them on the plugin's settings page and stores the values. With this, a plugin can:",
+ bullets: [
+ "Declare each field's type (text, toggle, choice, or project) with a label and an optional default",
+ "Get the form, its validation, and saving without writing any UI",
+ "Mark a text field secret: bb stores it in a protected file on the server and never sends it to the browser",
+ "Read saved values from its server code, or the non-secret ones from its own UI with `useSettings()`",
+ ],
+ apiSymbols: [
+ "PluginSettings",
+ "PluginSettingDescriptor",
+ "PluginSettingsState",
+ ],
+ firstParty: ["GitHub", "Provider retry", "Workflows"],
+ },
+ {
+ id: "settings-section",
+ title: "Custom settings section",
+ summary:
+ "Renders your own React component on the plugin's settings page, below the [fields bb generated](declarative-settings). Use it for anything that is not a value in a form. With this, a plugin can:",
+ bullets: [
+ "Render whatever UI it needs, such as a connect-account button, a test-connection result, or a preview",
+ "Run in the browser, so it stores nothing itself. It calls the plugin's own backend to do that",
+ "Supply a heading and a one-line description for bb to render above it",
+ ],
+ apiSymbols: ["PluginSettingsSectionRegistration"],
+ firstParty: [
+ "Custom instructions",
+ "Keep Awake",
+ "Memory",
+ "Remote access",
+ ],
+ },
+ ],
+ },
+ {
+ id: "extensions",
+ title: "Plugin page in Extensions",
+ fixtureKind: "spatial",
+ blurb:
+ "The page bb shows for an installed plugin under Extensions: what it is, what it registers, and whether it is healthy. A plugin can report that it needs configuring, and bb says so at the top of this page.",
+ surfaces: [
+ {
+ id: "plugin-status",
+ title: "Configuration status",
+ summary:
+ "Reports that the plugin cannot run until someone configures it, so bb can say so instead of the plugin failing silently. With this, a plugin can:",
+ bullets: [
+ "Set a needs-configuration state with a message naming what is missing",
+ "Show a warning banner with that message on the plugin's page in Extensions",
+ ],
+ apiSymbols: ["PluginStatusApi"],
+ firstParty: ["GitHub", "Workflows"],
+ },
+ ],
+ },
+ {
+ id: "headless",
+ title: "Plugin backend",
+ fixtureKind: "capability-grid",
+ // The grid below names all ten capabilities with their own taglines, so
+ // the blurb does not list them again.
+ blurb: "The parts of the plugin API with no interface of their own.",
+ sections: [
+ {
+ title: "Commands & agent capabilities",
+ surfaceIds: ["cli", "agent-tools"],
+ },
+ {
+ title: "Running & reacting",
+ surfaceIds: ["background", "wire", "thread-events", "host-workers"],
+ },
+ {
+ title: "Data & platform",
+ surfaceIds: ["storage", "bb-sdk", "host-components"],
+ },
+ {
+ title: "Confidence",
+ surfaceIds: ["testing"],
+ },
+ ],
+ surfaces: [
+ {
+ id: "cli",
+ tagline: "Your own `bb ` command",
+ title: "bb CLI commands",
+ summary:
+ "Registers a top-level `bb ` command, available in the terminal and to agents. With this, a plugin can:",
+ bullets: [
+ "Be invoked the same way by a person at a terminal and by an agent mid-task",
+ "Receive the thread and project it was invoked from, when bb knows them",
+ "Make the plugin usable from scripts and automations, not only from the UI",
+ ],
+ apiSymbols: ["PluginCli"],
+ firstParty: [
+ "Automations",
+ "Custom instructions",
+ "Docs",
+ "GitHub",
+ "Keep Awake",
+ "Memory",
+ "Provider retry",
+ "Remote access",
+ "Secrets",
+ "Tasks",
+ "Workflows",
+ ],
+ },
+ {
+ id: "agent-tools",
+ tagline: "Native tools, skills, and instructions in every session",
+ title: "Agent tools & skills",
+ summary:
+ "Adds tools, skills, and instructions to the agent sessions bb runs. With this, a plugin can:",
+ bullets: [
+ "Register tools an agent calls the same way it calls bb's built-in tools",
+ "Decide per thread which of its tools and skills are available",
+ "Append instructions to a session's system prompt as that session starts",
+ ],
+ apiSymbols: ["PluginAgents"],
+ firstParty: [
+ "Ask User Question",
+ "Custom instructions",
+ "Memory",
+ "Remote access",
+ "Workflows",
+ ],
+ },
+ {
+ id: "background",
+ tagline: "Supervised services and cron schedules",
+ title: "Background work",
+ summary:
+ "Runs code on the bb server when no window is open. With this, a plugin can:",
+ bullets: [
+ "Register long-running services that bb starts, supervises, and restarts after a failure",
+ "Register jobs that run on a cron schedule",
+ "Be told to shut down cleanly before it reloads or is disabled",
+ ],
+ apiSymbols: ["PluginBackground"],
+ firstParty: [
+ "Automations",
+ "Docs",
+ "GitHub",
+ "Keep Awake",
+ "Provider retry",
+ "Remote access",
+ "Side chat",
+ "Tasks",
+ "Workflows",
+ ],
+ },
+ {
+ id: "wire",
+ tagline: "Typed RPC, webhook routes, realtime push",
+ title: "HTTP, RPC & realtime",
+ summary:
+ "Connects the plugin's own UI, its server code, and outside services. With this, a plugin can:",
+ bullets: [
+ "Call its server from its UI over RPC, with arguments and results checked against a schema",
+ "Serve HTTP routes other systems can call, webhooks included",
+ "Push messages to every open bb window, so the UI does not have to poll",
+ ],
+ apiSymbols: ["PluginRpc", "PluginHttp", "PluginRealtime"],
+ firstParty: [
+ "Automations",
+ "Custom instructions",
+ "Docs",
+ "GitHub",
+ "Inline visualizations",
+ "Keep Awake",
+ "Memory",
+ "Provider retry",
+ "Remote access",
+ "Side chat",
+ "Tasks",
+ "Workflows",
+ ],
+ },
+ {
+ id: "thread-events",
+ tagline: "React when threads start, finish, or fail",
+ title: "Thread lifecycle events",
+ summary:
+ "Runs server code when a thread changes state. With this, a plugin can:",
+ bullets: [
+ "Subscribe to threads being created, going active or idle, failing, being archived, or being deleted",
+ "Receive a typed payload describing the thread and the transition",
+ "Respond by sending a notification, retrying, or writing to its own storage",
+ ],
+ apiSymbols: ["PluginEvents", "PluginThreadEventPayloads"],
+ firstParty: ["Automations", "Provider retry", "Tasks", "Workflows"],
+ },
+ {
+ id: "host-workers",
+ tagline: "Run code on enrolled machines",
+ title: "Host workers",
+ summary:
+ "Runs the plugin's code on an enrolled machine, not only on the bb server. With this, a plugin can:",
+ bullets: [
+ "Ship a Node entry point bb starts on demand on the machine it calls",
+ "Call that worker from its server code over typed RPC",
+ "Do work that has to happen on the machine itself, such as watching files or holding a wake lock",
+ ],
+ apiSymbols: ["PluginHosts"],
+ firstParty: ["Keep Awake", "Remote access"],
+ experimental: true,
+ },
+ {
+ id: "storage",
+ tagline: "Namespaced KV plus your own SQLite",
+ title: "Storage",
+ summary:
+ "Stores the plugin's data on the bb server. With this, a plugin can:",
+ bullets: [
+ "Get a key-value store for small values such as flags and cursors",
+ "Get its own SQLite database, with migrations, for larger or relational data",
+ "Read and write only its own namespace; other plugins cannot see it",
+ ],
+ apiSymbols: ["PluginStorage"],
+ firstParty: [
+ "Automations",
+ "Custom instructions",
+ "Docs",
+ "GitHub",
+ "Keep Awake",
+ "Memory",
+ "Remote access",
+ "Side chat",
+ "Tasks",
+ "Workflows",
+ ],
+ },
+ {
+ id: "bb-sdk",
+ tagline: "Create threads and projects from plugin code",
+ title: "The bb SDK",
+ summary:
+ "Calls bb's own API from the plugin's server code. With this, a plugin can:",
+ bullets: [
+ "Create threads, send messages to them, and manage projects",
+ "Reach the same operations the [bb CLI](cli) and the bb UI use",
+ "Have the threads it creates attributed back to the plugin",
+ ],
+ apiSymbols: ["BbPluginApi"],
+ firstParty: [
+ "Automations",
+ "Docs",
+ "GitHub",
+ "Inline visualizations",
+ "Keep Awake",
+ "Provider retry",
+ "Secrets",
+ "Side chat",
+ "Tasks",
+ "Workflows",
+ ],
+ },
+ {
+ id: "host-components",
+ tagline: "Embed bb's chat and prompt box",
+ title: "Host components",
+ summary:
+ "Renders bb's own conversation and prompt-box components inside the plugin's pages. With this, a plugin can:",
+ bullets: [
+ "Embed the thread view and the new-thread prompt box as components",
+ "Render message text with the same Markdown renderer bb uses",
+ "Inherit bb's styling, so embedded UI matches the rest of the app",
+ ],
+ apiSymbols: [
+ "ThreadChat",
+ "Markdown",
+ "experimental_NewThreadComposer",
+ ],
+ firstParty: ["Side chat"],
+ },
+ {
+ id: "testing",
+ tagline: "Unit-test every surface without a running bb",
+ title: "Testing harnesses",
+ summary:
+ "Tests the plugin without a running bb. With this, a plugin can:",
+ bullets: [
+ "Run its server code against an in-process fake of the bb server",
+ "Render its UI slots under vitest and jsdom",
+ "Drive its host worker with no host daemon running",
+ ],
+ apiSymbols: [
+ "createFakePluginHost",
+ "renderSlot",
+ "createFakeSdk",
+ "experimental_createHostEntryHarness",
+ ],
+ firstParty: [
+ "Ask User Question",
+ "Automations",
+ "Custom instructions",
+ "Docs",
+ "GitHub",
+ "Inline visualizations",
+ "Keep Awake",
+ "Memory",
+ "Provider retry",
+ "Remote access",
+ "Secrets",
+ "Side chat",
+ "Tasks",
+ "Workflows",
+ ],
+ },
+ ],
+ },
+];
+
+/**
+ * Which slide each surface is drawn on, so a card that names another surface
+ * can say where to find it — the same page's marker number, or the other
+ * page by name.
+ */
+export const GROUP_BY_SURFACE_ID: ReadonlyMap<
+ string,
+ { id: SurfaceGroup["id"]; title: string }
+> = new Map(
+ SURFACE_GROUPS.flatMap((group) =>
+ group.surfaces.map(
+ (surface) => [surface.id, { id: group.id, title: group.title }] as const,
+ ),
+ ),
+);
+
+export const SURFACES_BY_ID: ReadonlyMap = new Map(
+ SURFACE_GROUPS.flatMap((group) =>
+ group.surfaces.map((surface) => [surface.id, surface] as const),
+ ),
+);
diff --git a/packages/plugin-api-map/src/used-by.tsx b/packages/plugin-api-map/src/used-by.tsx
new file mode 100644
index 0000000000..0253f2c9be
--- /dev/null
+++ b/packages/plugin-api-map/src/used-by.tsx
@@ -0,0 +1,235 @@
+/**
+ * The "Used by" row: the shipped plugins that ship on a surface.
+ *
+ * A short list renders as one plain row, exactly as it always has. A list too
+ * long for the row keeps its single line and scrolls sideways, driven by the
+ * reader: carets page through it, and the trackpad, wheel, drag, and arrow
+ * keys all work because the row is an ordinary scroll container.
+ *
+ * Nothing moves on its own, so there is no motion to suppress under
+ * `prefers-reduced-motion`; that setting only decides whether a caret click
+ * animates or jumps.
+ */
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
+
+import { cn } from "./cn";
+
+/** Sub-pixel slack: a 0.5px remainder is not something left to scroll to. */
+const SCROLL_EPSILON_PX = 1;
+/** Kept on screen across a paged scroll, so the eye has an anchor. */
+const SCROLL_OVERLAP_PX = 32;
+/** Floor for the step, so a very narrow row still advances usefully. */
+const MIN_SCROLL_STEP_PX = 80;
+
+export interface UsedByScrollState {
+ canScrollLeft: boolean;
+ canScrollRight: boolean;
+}
+
+/** Geometry of a scroll container, as much of it as these helpers need. */
+export interface UsedByScrollMetrics {
+ scrollLeft: number;
+ scrollWidth: number;
+ clientWidth: number;
+}
+
+/**
+ * Which carets to offer. Both false means everything fits and the row shows
+ * no scroll affordance at all.
+ *
+ * Pure so the extents are testable without a layout engine.
+ */
+export function usedByScrollState({
+ scrollLeft,
+ scrollWidth,
+ clientWidth,
+}: UsedByScrollMetrics): UsedByScrollState {
+ const maxScroll = scrollWidth - clientWidth;
+ if (maxScroll <= SCROLL_EPSILON_PX) {
+ return { canScrollLeft: false, canScrollRight: false };
+ }
+ return {
+ canScrollLeft: scrollLeft > SCROLL_EPSILON_PX,
+ canScrollRight: scrollLeft < maxScroll - SCROLL_EPSILON_PX,
+ };
+}
+
+/** Roughly one visible width, less an overlap so nothing jumps past unread. */
+export function usedByScrollStep(clientWidth: number): number {
+ return Math.max(clientWidth - SCROLL_OVERLAP_PX, MIN_SCROLL_STEP_PX);
+}
+
+/** The minimum a caret needs from its viewport, so tests can stand one in. */
+export interface UsedByScrollTarget {
+ clientWidth: number;
+ scrollBy(options: { left: number; behavior: ScrollBehavior }): void;
+}
+
+/** One caret press: a page in `direction`, instant when motion is reduced. */
+export function scrollUsedBy(
+ viewport: UsedByScrollTarget,
+ direction: -1 | 1,
+ { reducedMotion }: { reducedMotion: boolean },
+): void {
+ viewport.scrollBy({
+ left: direction * usedByScrollStep(viewport.clientWidth),
+ behavior: reducedMotion ? "auto" : "smooth",
+ });
+}
+
+function useReducedMotion(): boolean {
+ const [reduced, setReduced] = useState(false);
+ useEffect(() => {
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
+ if (!query) {
+ return;
+ }
+ const sync = () => setReduced(query.matches);
+ sync();
+ query.addEventListener("change", sync);
+ return () => query.removeEventListener("change", sync);
+ }, []);
+ return reduced;
+}
+
+function Caret({
+ direction,
+ shown,
+ onClick,
+}: {
+ direction: "left" | "right";
+ shown: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+export function UsedByList({
+ items,
+ renderItem,
+}: {
+ items: readonly string[];
+ renderItem: (item: string) => ReactNode;
+}) {
+ const viewportRef = useRef(null);
+ const [scroll, setScroll] = useState({
+ canScrollLeft: false,
+ canScrollRight: false,
+ });
+ const reducedMotion = useReducedMotion();
+
+ const sync = useCallback(() => {
+ const viewport = viewportRef.current;
+ if (viewport) {
+ setScroll(usedByScrollState(viewport));
+ }
+ }, []);
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport) {
+ return;
+ }
+ sync();
+ // The viewport resizes with the card; the row inside it resizes with the
+ // items. Either changes what is left to scroll to.
+ const observer = new ResizeObserver(sync);
+ observer.observe(viewport);
+ const row = viewport.firstElementChild;
+ if (row) {
+ observer.observe(row);
+ }
+ viewport.addEventListener("scroll", sync, { passive: true });
+ return () => {
+ observer.disconnect();
+ viewport.removeEventListener("scroll", sync);
+ };
+ }, [items, sync]);
+
+ const page = (direction: -1 | 1) => {
+ const viewport = viewportRef.current;
+ if (viewport) {
+ scrollUsedBy(viewport, direction, { reducedMotion });
+ }
+ };
+
+ const scrollable = scroll.canScrollLeft || scroll.canScrollRight;
+
+ return (
+
+ {scrollable ? (
+
page(-1)}
+ />
+ ) : null}
+ {
+ if (
+ scrollable &&
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")
+ ) {
+ event.stopPropagation();
+ }
+ }}
+ className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring [&::-webkit-scrollbar]:hidden"
+ >
+
+ {items.map((item) => (
+
+ {renderItem(item)}
+
+ ))}
+
+
+ {scrollable ? (
+ page(1)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/packages/plugin-api-map/src/wireframes.tsx b/packages/plugin-api-map/src/wireframes.tsx
new file mode 100644
index 0000000000..1f6424aa01
--- /dev/null
+++ b/packages/plugin-api-map/src/wireframes.tsx
@@ -0,0 +1,2106 @@
+/**
+ * Deterministic fixtures of the real bb UI with every pluggable surface marked.
+ * Layout and ordering mirror the real components in apps/app (audited against
+ * AppSidebar, ThreadDetailHeader, ConversationMessageContent, MessageActionBar,
+ * FollowUpPromptBox/PromptBoxInternal, ThreadSecondaryPanel, RootComposeView,
+ * and PluginSettings); plugin contributions render highlighted, in the exact
+ * spot the host inserts them.
+ *
+ * The regions covered by anatomy-manifest.json (sidebar sections, sidebar
+ * footer, message action bar) render FROM the manifest, and a test in
+ * apps/app renders the real components and asserts the same DOM order, so an
+ * app-side reorder fails tests until the manifest, and these fixtures,
+ * update.
+ *
+ * Marks are anchors that expand the matching sidebar row and sync hover state
+ * through SurfaceMapContext. The exported *_MARKS arrays are the contract with
+ * surfaces.ts: surfaces.test.ts asserts every surface in a visual group is
+ * marked exactly once.
+ */
+import {
+ createContext,
+ Fragment,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+import {
+ ArrowLeft01Icon,
+ ArrowMoveDownLeftIcon,
+ ArrowUp01Icon,
+ ArrowRight01Icon,
+ Bug01Icon,
+ Copy01Icon,
+ File01Icon,
+ Folder01Icon,
+ GitBranchIcon,
+ InformationCircleIcon,
+ MessageAdd01Icon,
+ Mic01Icon,
+ MoreHorizontalIcon,
+ PencilEdit01Icon,
+ PlusSignIcon,
+ ElectricPlugsIcon,
+ Search01Icon,
+ Settings02Icon,
+ SparklesIcon,
+ PlusMinusSquare01Icon,
+ SidebarLeftIcon,
+ SidebarRightIcon,
+ ToolboxIcon,
+ TerminalIcon,
+} from "@hugeicons/core-free-icons";
+
+import { cn } from "./cn";
+import {
+ annotationChipClass,
+ CHIP_PLACEMENT_CLASS,
+ type AnnotationChipPlacement,
+} from "./annotation";
+import anatomy from "./anatomy-manifest.json";
+
+export interface SurfaceMapState {
+ activeId: string | null;
+ setActiveId: (id: string | null) => void;
+ /**
+ * The surface whose sidebar row is open. Markers use it alongside
+ * `activeId` so a marker and its row are never in different states.
+ */
+ expandedId?: string | null;
+ /**
+ * When set, only this surface's marker stays lit; every other region of the
+ * fixture recedes. Lets one diagram serve as a per-surface illustration
+ * instead of shipping a cropped image per surface.
+ */
+ spotlightId?: string | null;
+ numberOf: (id: string) => number | null;
+ /**
+ * Resolves a shipped plugin's page URL, or null when this host has no page
+ * for it. Supplied by the bb plugin, which can ask the running host; the
+ * docs website has no host and so supplies nothing.
+ */
+ pluginPageHref?: (displayName: string) => string | null;
+ /**
+ * When provided, clicking a marker calls this instead of following the
+ * `#surface-` anchor — the sidebar-nav layout uses it to expand the
+ * matching nav row in place.
+ */
+ onSelect?: (id: string) => void;
+ /**
+ * The slide currently on stage, so a card naming another surface can tell
+ * whether that surface is on this page or another one.
+ */
+ currentGroupId?: string;
+ /**
+ * Pans to the slide holding a surface and opens its card. Absent outside
+ * the carousel, where there is nothing to pan.
+ */
+ onGoToSurface?: (id: string) => void;
+}
+
+export const SurfaceMapContext = createContext(null);
+
+export function useSurfaceMap(): SurfaceMapState {
+ const state = useContext(SurfaceMapContext);
+ if (!state) {
+ throw new Error("useSurfaceMap must be used inside a SurfaceMapContext");
+ }
+ return state;
+}
+
+export const APP_SHELL_MARKS = [
+ "nav-panel",
+ "thread-list",
+ "thread-row-status",
+ "sidebar-footer",
+ "thread-header",
+ "message-directives",
+ "message-actions",
+ "pending-interaction",
+ "code-renderers",
+ "thread-panel",
+ "file-opener",
+ "timeline-renderers",
+ "content-scripts",
+] as const;
+
+export const COMMAND_PALETTE_MARKS = ["command-palette-actions"] as const;
+
+export const COMPOSER_MARKS = [
+ "composer-banners",
+ "mention-provider",
+ "composer-rich-text",
+ "composer-state",
+ "composer-plus-menu",
+ "provider-picker",
+ "composer-actions",
+] as const;
+
+export const COMPOSE_MARKS = ["homepage-section", "new-thread-panel"] as const;
+
+export const EXTENSIONS_MARKS = ["plugin-status"] as const;
+
+export const SETTINGS_MARKS = [
+ "declarative-settings",
+ "settings-section",
+] as const;
+
+/* ── primitives ─────────────────────────────────────────────────────── */
+
+/**
+ * The shared engagement triple every annotation reads. `active` drives the
+ * chip fill (an open card's marker stays lit); `outlined` is exclusive so two
+ * outlines are never on screen to overlap; `dimmed` recedes everything but a
+ * spotlighted surface.
+ */
+function useEngagement(id: string) {
+ const { activeId, expandedId, spotlightId } = useSurfaceMap();
+ return {
+ active: activeId === id || expandedId === id || spotlightId === id,
+ outlined:
+ activeId !== null
+ ? activeId === id
+ : expandedId === id || spotlightId === id,
+ dimmed: Boolean(spotlightId) && spotlightId !== id,
+ };
+}
+
+/**
+ * The engaged ring, applied to the target element itself. The ring's
+ * geometry is the target's geometry by construction — never a separately
+ * positioned box — so it wraps content, follows the target's own radius, and
+ * survives any fixture edit.
+ */
+function engagedRingClass(outlined: boolean) {
+ return outlined
+ ? "bg-surface-selected/30 ring-1 ring-inset ring-surface-selected-border"
+ : undefined;
+}
+
+function Mark({
+ id,
+ label,
+ className,
+ chip = "corner",
+ showChip = true,
+ onActivate,
+ children,
+}: {
+ id: string;
+ label: string;
+ className?: string;
+ /** Where the numbered chip sits relative to this target — a declared
+ * variant, never a per-instance offset. */
+ chip?: AnnotationChipPlacement;
+ /** Whether this region renders its own numbered chip. */
+ showChip?: boolean;
+ /** Runs the fixture interaction represented by this marker. */
+ onActivate?: () => void;
+ children?: ReactNode;
+}) {
+ const { setActiveId, numberOf, onSelect } = useSurfaceMap();
+ const { active, outlined, dimmed } = useEngagement(id);
+ return (
+ {
+ onActivate?.();
+ if (!onSelect) return;
+ event.preventDefault();
+ // A marker inside another marked region (the provider glyph in the
+ // picker, the painted range in the draft) must open its own card, not
+ // the enclosing one's.
+ event.stopPropagation();
+ onSelect(id);
+ }}
+ onMouseEnter={() => setActiveId(id)}
+ onMouseLeave={() => setActiveId(null)}
+ onFocus={() => setActiveId(id)}
+ onBlur={() => setActiveId(null)}
+ className={cn(
+ // ring-inset keeps the outline inside this region's own bounds, so
+ // it cannot bleed into a neighbor that shares an edge.
+ "relative rounded-md ring-1 ring-inset transition-all",
+ outlined
+ ? "bg-surface-selected ring-surface-selected-border"
+ : "ring-transparent hover:bg-state-hover",
+ dimmed && "opacity-25",
+ className,
+ )}
+ >
+ {/* Markers ship in the prominent ink fill so they read as the page's
+ interactive layer; the selected one switches to the timeline file
+ accent. The ring punches the chip out of the mockup's grey bones. */}
+ {showChip ? (
+
+ {numberOf(id)}
+
+ ) : null}
+ {children}
+
+ );
+}
+
+/** The palette row remains the real product action, separate from the badge. */
+function CommandPaletteActionMark({ onRun }: { onRun: () => void }) {
+ const id = "command-palette-actions";
+ const { setActiveId } = useSurfaceMap();
+ const { outlined, dimmed } = useEngagement(id);
+
+ return (
+ setActiveId(id)}
+ onMouseLeave={() => setActiveId(null)}
+ onFocus={() => setActiveId(id)}
+ onBlur={() => setActiveId(null)}
+ data-guide-fixture="command-palette-action"
+ className={cn(
+ "flex w-full cursor-pointer items-center gap-1.5 rounded bg-state-hover px-2 py-1.5 text-left text-foreground ring-1 ring-inset transition-all",
+ outlined ? "ring-surface-selected-border" : "ring-transparent",
+ dimmed && "opacity-25",
+ )}
+ >
+ Run release checklist
+ Plugins
+
+ );
+}
+
+/**
+ * An annotation whose boundary is the fixture element it describes.
+ *
+ * Unlike MeasuredBadge, this component measures nothing. Its interactive
+ * layer fills the content wrapper, so the outline and marker move with that
+ * content. The overlay is a sibling of `children`, so fixture content does
+ * not have to become part of the interactive anchor.
+ */
+function RegionMark({
+ id,
+ label,
+ className,
+ chip = "corner",
+ showChip = true,
+ children,
+}: {
+ id: string;
+ label: string;
+ className?: string;
+ /** Where the numbered chip sits relative to this target — a declared
+ * variant, never a per-instance offset. */
+ chip?: AnnotationChipPlacement;
+ /** Whether this region renders its own numbered chip. */
+ showChip?: boolean;
+ children: ReactNode;
+}) {
+ const { setActiveId, numberOf, onSelect } = useSurfaceMap();
+ const { active, outlined, dimmed } = useEngagement(id);
+
+ return (
+
+ );
+}
+
+const useBrowserLayoutEffect =
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
+
+/** Chip diameter (annotationChipClass `size-5`) plus its breathing gap. */
+const CHIP_SIZE = 20;
+const CHIP_GAP = 8;
+
+/**
+ * A numbered chip whose position is measured from its anchor element instead
+ * of authored. For annotations whose chip cannot live inside the target's
+ * own subtree — exterior gutters, the tab lane, dialog margins — the chip
+ * derives its place from the anchor's rendered box within the positioning
+ * parent, so any fixture change moves the chip with it. Positions are
+ * computed in layout coordinates (client deltas divided by the wrapper's
+ * scale), which are invariant under the scale-together transform.
+ *
+ * Placement is measured at runtime, so static markup tests can assert this
+ * chip exists but not where it sits — the rendered QA sweep is the placement
+ * gate for these badges.
+ */
+function MeasuredBadge({
+ id,
+ label,
+ anchor,
+ at,
+ align = "center",
+ onActivate,
+}: {
+ id: string;
+ label: string;
+ /** Selector for the anchor element, resolved inside the positioning parent. */
+ anchor: string;
+ /**
+ * start/end: the exterior gutter columns beside the positioning parent,
+ * vertically tracking the anchor; above: floating over the anchor; lane:
+ * the reserved band above the window frame, horizontally tracking it.
+ */
+ at: "start" | "end" | "above" | "lane";
+ /** Vertical alignment against the anchor for start/end placements. */
+ align?: "center" | "end";
+ /** Runs the fixture interaction represented by this badge. */
+ onActivate?: () => void;
+}) {
+ const { setActiveId, numberOf, onSelect } = useSurfaceMap();
+ const { active } = useEngagement(id);
+ const ref = useRef(null);
+ const [position, setPosition] = useState<{
+ left: number;
+ top: number;
+ } | null>(null);
+
+ useBrowserLayoutEffect(() => {
+ const element = ref.current;
+ const container = element?.offsetParent;
+ if (!element || !(container instanceof HTMLElement)) return;
+ // Anchors can live outside the positioning parent (a sibling frame), so
+ // resolve within the slide; ids repeat across slides, so never wider.
+ const scope = container.closest("[data-map-section]") ?? container;
+ const target = scope.querySelector(anchor);
+ if (!target) return;
+
+ const measure = () => {
+ const scaleWrapper = container.closest(
+ "[data-guide-responsive-strategy]",
+ );
+ const scale = Number(scaleWrapper?.dataset.guideScale ?? "1") || 1;
+ const containerRect = container.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ const local = {
+ left: (targetRect.left - containerRect.left) / scale,
+ top: (targetRect.top - containerRect.top) / scale,
+ width: targetRect.width / scale,
+ height: targetRect.height / scale,
+ };
+ const centerY = local.top + local.height / 2 - CHIP_SIZE / 2;
+ const anchoredY =
+ align === "end" ? local.top + local.height - CHIP_SIZE : centerY;
+ // The exterior columns and the lane derive from the window frame's own
+ // box: chips sit just outside the frame edge (inside the slide gutter,
+ // so nothing clips them) and the lane centers in the band the gutter
+ // reserves above the frame. Without a frame, the container stands in.
+ const frame = container.querySelector("[data-guide-frame]");
+ const frameRect = frame?.getBoundingClientRect() ?? containerRect;
+ const frameLocal = {
+ left: (frameRect.left - containerRect.left) / scale,
+ right: (frameRect.right - containerRect.left) / scale,
+ top: (frameRect.top - containerRect.top) / scale,
+ };
+ const next =
+ at === "start"
+ ? { left: frameLocal.left - CHIP_SIZE - CHIP_GAP, top: anchoredY }
+ : at === "end"
+ ? { left: frameLocal.right + CHIP_GAP, top: anchoredY }
+ : at === "above"
+ ? {
+ left: local.left + local.width / 2 - CHIP_SIZE / 2,
+ top: local.top - CHIP_SIZE - 4,
+ }
+ : {
+ left: local.left + local.width / 2 - CHIP_SIZE / 2,
+ top: Math.max(0, (frameLocal.top - CHIP_SIZE) / 2),
+ };
+ // A container inside a clipping window frame (the palette dialog)
+ // cannot hang chips past that frame's edge — they would clip to
+ // nothing. Clamp into the frame's interior instead; the chip then
+ // rides the container's edge when the frame leaves no margin.
+ const clippingFrame = container.closest(
+ "[data-guide-frame]",
+ );
+ if (clippingFrame) {
+ const clipRect = clippingFrame.getBoundingClientRect();
+ const clipLocal = {
+ left: (clipRect.left - containerRect.left) / scale,
+ right: (clipRect.right - containerRect.left) / scale,
+ };
+ next.left = Math.min(
+ Math.max(next.left, clipLocal.left + 4),
+ clipLocal.right - CHIP_SIZE - 4,
+ );
+ }
+ setPosition((current) =>
+ current &&
+ Math.abs(current.left - next.left) < 0.5 &&
+ Math.abs(current.top - next.top) < 0.5
+ ? current
+ : next,
+ );
+ };
+
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(container);
+ observer.observe(target);
+ const scaleWrapper = container.closest(
+ "[data-guide-responsive-strategy]",
+ );
+ if (scaleWrapper) observer.observe(scaleWrapper);
+ return () => observer.disconnect();
+ }, [anchor, at, align]);
+
+ return (
+ {
+ onActivate?.();
+ if (!onSelect) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onSelect(id);
+ }}
+ onMouseEnter={() => setActiveId(id)}
+ onMouseLeave={() => setActiveId(null)}
+ onFocus={() => setActiveId(id)}
+ onBlur={() => setActiveId(null)}
+ className="pointer-events-auto absolute z-50"
+ style={position ?? undefined}
+ >
+
+ {numberOf(id)}
+
+
+ );
+}
+
+function MiniIcon({
+ icon,
+ className,
+}: {
+ icon: IconSvgElement;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+/** A plugin-contributed control: electric-plug glyph, drawn in the ink color. */
+function PluginGlyph({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function WindowFrame({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function TrafficLights() {
+ return (
+
+
+
+
+
+ );
+}
+
+/* ── the main app window ────────────────────────────────────────────── */
+
+const SIDEBAR_THREADS: readonly { title: string; glyph?: "spin" | "dot" }[] = [
+ { title: "Fix flaky checkout tests", glyph: "spin" },
+ { title: "Refactor settings page" },
+ { title: "Ship dark mode", glyph: "dot" },
+];
+
+/**
+ * Sidebar footer icons, in anatomy-manifest order: Settings, then plugin
+ * footer actions, then Report a bug (mirrors AppSidebar's SidebarFooter).
+ */
+const FOOTER_ITEM_RENDERERS: Record ReactNode> = {
+ settings: () => ,
+ "plugin-footer-actions": () => (
+
+
+
+ ),
+ "bug-report": () => ,
+};
+
+/**
+ * Sidebar sections, in anatomy-manifest order (mirrors AppSidebar.tsx:
+ * top-reserve chrome, the New-thread/search block, plugin nav rows, the
+ * scrolling thread list, the footer).
+ */
+const SIDEBAR_SECTION_RENDERERS: Record ReactNode> = {
+ "top-reserve": () => (
+
+
+
+
+ ),
+ "primary-actions": () => (
+
+
+
+ New thread
+
+
+
+ ),
+ "plugin-nav": () => (
+
+
+
+ Extensions
+
+ {/* The active row uses the sidebar's own accent, exactly like the
+ real nav row (PluginNavSidebarItems). */}
+
+
+ Your panel
+
+
+ ),
+ "thread-list": () => (
+
+
+ Pinned
+
+ {SIDEBAR_THREADS.map((thread) => (
+
+ {thread.title}
+ {thread.glyph === "spin" ? (
+ // A running status on the row: the glyph a plugin's thread row
+ // status replaces. Its own marker, inside the thread list's.
+
+
+
+ ) : thread.glyph === "dot" ? (
+
+ ) : null}
+
+ ))}
+
+ Projects
+
+ {["acme-app", "dotfiles"].map((project) => (
+
+ {project}
+
+
+ ))}
+
+ ),
+ footer: () => (
+
+ ),
+};
+
+/**
+ * Message action bar icons, in anatomy-manifest order: the five host actions,
+ * then plugin actions (mirrors MessageActionBar.tsx).
+ */
+const MESSAGE_ACTION_RENDERERS: Record ReactNode> = {
+ copy: () => ,
+ edit: () => ,
+ "add-to-chat": () => ,
+ "send-to-main-thread": () => (
+
+ ),
+ fork: () => ,
+ "plugin-actions": () => ,
+};
+
+/** Registry coverage, checked against the manifest by surfaces.test.ts. */
+export const ANATOMY_RENDERER_KEYS = {
+ appSidebar: Object.keys(SIDEBAR_SECTION_RENDERERS),
+ sidebarFooter: Object.keys(FOOTER_ITEM_RENDERERS),
+ messageActionBar: Object.keys(MESSAGE_ACTION_RENDERERS),
+};
+
+export type AppShellRightPanelTab =
+ | "thread-panel"
+ | "file-opener"
+ | "code-renderers";
+
+/**
+ * The three right-panel tab chips ride the lane the gutter reserves above the
+ * window frame, each measured from its own tab element — the lane layer no
+ * longer duplicates the panel's geometry.
+ */
+function RightPanelTabLaneBadges({
+ onTabSelect,
+}: {
+ onTabSelect: (tab: AppShellRightPanelTab) => void;
+}) {
+ return (
+ <>
+ onTabSelect("code-renderers")}
+ />
+ onTabSelect("thread-panel")}
+ />
+ onTabSelect("file-opener")}
+ />
+ >
+ );
+}
+
+/**
+ * A whole-window command-palette flow. The fixture starts with the palette
+ * open over a real thread-shaped backdrop; running the plugin row closes it
+ * and opens the plugin's thread-panel tab, just as the registered action can.
+ */
+export function CommandPaletteWireframe() {
+ const [paletteOpen, setPaletteOpen] = useState(true);
+ const [releasePanelOpen, setReleasePanelOpen] = useState(false);
+
+ const openPalette = () => setPaletteOpen(true);
+ const runReleaseChecklist = () => {
+ setPaletteOpen(false);
+ setReleasePanelOpen(true);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Ship release candidate
+
+
+
+
+
+ Quick palette
+
+ ⇧⌘P
+
+
+
+
+
+
+
+ Prepare this branch for the release candidate.
+
+
+
+
+ The release build is ready for final checks. I verified the
+ focused tests and collected the latest UI evidence.
+
+
+
+
+ release/2026-08-25
+
+
+
+
+
+
+
+
+ Ask for a follow-up…
+
+
+
+ {releasePanelOpen ? (
+
+
+
+
+
+
+
+ Release checklist
+
+
+
+
+
+ Release checklist
+
+
+ Final checks for this thread and branch.
+
+
+ {[
+ ["Tests and typecheck", "Passed"],
+ ["UI evidence", "Ready"],
+ ["Mergeability", "Clean"],
+ ].map(([label, status]) => (
+
+
+
+ {label}
+
+
+ {status}
+
+
+ ))}
+
+
+ ) : null}
+
+
+ {paletteOpen ? (
+
+
+
+
+
+
+
+
+ Open release notes
+
+ Navigation
+
+
+
+
+ Copy thread link
+
+ Thread
+
+
+
+ {/* The numbered chip rides the row it annotates, measured from
+ the row's own box so palette content changes move it. */}
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+export function AppShellWireframe() {
+ const { expandedId } = useSurfaceMap();
+ const [rightPanelTab, setRightPanelTab] =
+ useState("thread-panel");
+
+ // Card arrows can select these annotations without clicking their tab
+ // markers. Keep the fixture body synchronized with whichever card is open
+ // so every sequential step still demonstrates the surface it describes.
+ useEffect(() => {
+ if (
+ expandedId === "thread-panel" ||
+ expandedId === "file-opener" ||
+ expandedId === "code-renderers"
+ ) {
+ setRightPanelTab(expandedId);
+ }
+ }, [expandedId]);
+
+ return (
+ // The padding is the annotation gutter: edge-hugging markers anchor to
+ // this box and sit outside the frame, so they ring the diagram instead
+ // of crowding its chrome.
+ // Unlike the simpler slides, this dense three-column anatomy stays at a
+ // readable minimum size. ProductMap supplies the single scroll owner so
+ // the exterior gutter and all badges move with the frame.
+
+ {/* The first two surfaces belong to the sidebar as a whole. Their chips
+ ride the exterior gutter column, each measured from the region it
+ annotates, while the in-frame regions remain independently
+ clickable. */}
+
+
+ {/* Content scripts have no slot of their own — they run across the
+ whole window, so the badge and the engaged tint annotate the frame
+ itself. */}
+
+
+
+
+ );
+}
+
+function AppShellWireframeBody({
+ rightPanelTab,
+ onRightPanelTabSelect,
+}: {
+ rightPanelTab: AppShellRightPanelTab;
+ onRightPanelTabSelect: (tab: AppShellRightPanelTab) => void;
+}) {
+ const { expandedId } = useSurfaceMap();
+ const [assistantMessageHovered, setAssistantMessageHovered] = useState(false);
+ const contentScripts = useEngagement("content-scripts");
+ const messageActionsSelected = expandedId === "message-actions";
+ const messageActionRowVisible =
+ assistantMessageHovered || messageActionsSelected;
+
+ return (
+
+ {/* Content scripts run across the whole window, so their target — and
+ the engaged tint — is the frame itself, never a separately authored
+ region. */}
+
+ {/* AppLayout owns this trigger as a pinned overlay. AppSidebar's top
+ reserve deliberately contains only history navigation. */}
+
+
+
+ {/* Keep product chrome and the pending form at their real density. At
+ bb's 1028px desktop viewport the 500px floor leaves every card above
+ the fold; each extra viewport pixel then restores one pixel of blank
+ canvas until the original 650px minimum is reached. Real content may
+ still grow past that minimum rather than being clipped. */}
+
+ {/* ── sidebar, sections in anatomy-manifest order ── */}
+
+ {anatomy.appSidebar.map((key) => (
+ {SIDEBAR_SECTION_RENDERERS[key]?.()}
+ ))}
+
+
+ {/* ── thread view ── */}
+
+ {/* header: title left; plugin action leads the right action row */}
+
+
+ Fix flaky checkout tests
+
+
+
+
+
+
+ {/* timeline */}
+
+ {/* user message: right-aligned bubble */}
+
+
+ Fix the flaky checkout tests
+
+
+
+ {/* plugin-owned row: bb retains the header while the plugin
+ renderer supplies the expanded body beneath it */}
+
+
+
+ Re-ran checkout suite
+ Completed
+
+
+
+
+
+
+
+
+
+ {/* assistant message: plain prose + directive + action bar */}
+
setAssistantMessageHovered(true)}
+ onMouseLeave={() => setAssistantMessageHovered(false)}
+ onFocusCapture={() => setAssistantMessageHovered(true)}
+ onBlurCapture={() => setAssistantMessageHovered(false)}
+ className="w-[88%] space-y-2"
+ >
+
+ The retries cluster in two suites. Failure rate by suite:
+
+
+
+
+
+
+
+
+
+
+
+ ::your-directive
+
+
+
+ {messageActionsSelected ? (
+
+
+
+ Add to chat
+
+
+
+
+ Your action
+
+
+ ) : null}
+
+ Fixed by isolating the{" "}
+
+ Stripe mock
+ {" "}
+ per test.
+
+
+ {/* Reserve the real action row's height so hover never shifts
+ the message or the entries below it. */}
+
+
+
+ {anatomy.messageActionBar.map((key) => (
+
+ {MESSAGE_ACTION_RENDERERS[key]?.()}
+
+ ))}
+
+
+
+
+
+
+ {/* pending interaction: replaces the prompt box, not the timeline */}
+
+
+
+
+ Pick a release channel
+
+
+
+
+ Cancel
+
+
+ Submit
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * The three annotated right-panel capabilities are tabs in the product. Their
+ * numbered controls live in AppShellWireframe's exterior top layer; the host
+ * row below stays at the product's real 48px height. Each marker selects its
+ * tab before opening the corresponding Guide card, so the diagram and the
+ * explanation always describe the same visible body.
+ */
+export function AppShellRightPanel({
+ activeTab,
+ onTabSelect,
+}: {
+ activeTab: AppShellRightPanelTab;
+ onTabSelect: (tab: AppShellRightPanelTab) => void;
+}) {
+ const tabClass = (tab: AppShellRightPanelTab) =>
+ cn(
+ "flex h-7 shrink-0 items-center rounded-md",
+ activeTab === tab && "bg-state-hover",
+ );
+
+ return (
+ // Plain bg-sidebar, like the real ThreadSecondaryPanel — the real panel
+ // is not the app's `.fixed.bg-sidebar` element, so it does not receive
+ // the themed sidebar overlay.
+
+
+
+
+
+
+ onTabSelect("code-renderers")}
+ >
+
+
+ Diff
+
+
+
+
+ onTabSelect("thread-panel")}
+ >
+
+
+ Your tab
+
+
+ onTabSelect("file-opener")}
+ >
+
+
+ retry-notes.md
+
+
+
+
+
+
+
+
+ {activeTab === "thread-panel" ? (
+
+
+
+ Your plugin owns this tab and receives the thread it was opened
+ from.
+
+
+
+
+ ) : activeTab === "file-opener" ? (
+
+
+
+ docs
+ /
+ retry-notes.md
+
+
+
+
+ Checkout retry notes
+
+
+
+ Custom viewer
+
+
+
+ Flakes cluster around shared test state. Reset each mock between
+ cases before rerunning the suite.
+
+
+ Next: isolate the Stripe mock per test.
+
+
+
+ ) : (
+
+
+
+
tests/checkout.test.ts
+
+
+ Custom diff
+
+
+
+
+ @@ -18,7 +18,8 @@ describe("checkout")
+
+
+ 18
+ 18
+ beforeEach(() => {
+
+
+ 19
+
+ − sharedMock.reset()
+
+
+
+ 19
+ + stripeMock.reset()
+
+
+
+ 20
+ + inventoryMock.reset()
+
+
+ 20
+ 21
+ })
+
+
+
+ )}
+
+
+ );
+}
+
+/* ── close-up composer anatomy ─────────────────────────────────────── */
+
+/**
+ * The close-up composer is seated in the thread chrome it actually lives in:
+ * window bar, a short exchange above, and the reply box at the bottom. The
+ * fixture owns the exact geometry so installed plugin customizations cannot
+ * move, rewrite, or add controls inside the Guide illustration.
+ *
+ * Composer controls sit against clipping and transient surfaces, so their
+ * chips live in one Guide-owned sibling layer outside the WindowFrame — each
+ * measured from the target it annotates, so a fixture change moves its chip
+ * with it. The composer's two menus stay collapsed; each expands only while
+ * its own annotation is engaged, anchored to the element the real menu flips
+ * against. The + menu opens upward, as the real one does when the composer
+ * sits at the bottom of the window.
+ */
+export function RealComposerAnnotated() {
+ const banners = useEngagement("composer-banners");
+ const mention = useEngagement("mention-provider");
+ return (
+ // The same gutter geometry as the other window slides, so the nav above
+ // and the card below sit the same distance from every frame.
+
+ {/* ProductMap keeps the full-width anatomy and its markers inside the
+ same scale-together wrapper at every panel width. */}
+
+
+
+
+
+
+
+
+
+
+ {/* thread chrome: header and a short exchange, unannotated */}
+ {/* Full-scale chrome (text-sm rows, 44px header, 16px icons): the
+ real composer renders at product size below, so the drawn
+ thread around it holds the same scale instead of miniature. */}
+
+
+ Ship the release notes
+
+
+
+
+
+
+
+ Draft the release notes
+
+
+
+ Drafted. Two rough edges left in checkout — reply with what to
+ fold in.
+
+
+
+ {/* the reply box, pinned to the bottom like the real one, at the
+ real product's footprint: the actual composer spans ~two thirds
+ of the thread column, not edge to edge. */}
+
+ {/* banner: a plugin banner renders in this slot, above the box.
+ The mention menu anchors to it — the real MentionMenu flips
+ above the composer, seating itself over this slot — so its
+ clearance derives from the banner's own box. */}
+
+ {mention.outlined ? (
+
+
+ Your plugin
+
+
+
+ release-notes
+
+
+
+ roadmap
+
+
+ ) : null}
+
+
Your banner
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Product-shaped prompt-box chrome, drawn in flow: the draft line and the
+ * control row are the box's own content, so every target's ring and chip
+ * derive from elements the layout itself positions — no hand-synced
+ * coordinate pair between an overlay and a reservation.
+ */
+function StaticEmbeddedComposer() {
+ const draft = useEngagement("composer-state");
+ const plus = useEngagement("composer-plus-menu");
+ const picker = useEngagement("provider-picker");
+ const actions = useEngagement("composer-actions");
+ return (
+
+
+ {/* + menu: opens upward while engaged, the direction the real menu
+ takes at the window's bottom, anchored to the box the real menu
+ flips against. The bottom margin is the outside-above chip lane
+ (CHIP_SIZE + CHIP_GAP), so the open menu clears the draft line's
+ chips by construction. */}
+ {plus.outlined ? (
+
+
+
+ Attach files
+
+
+
+ Skills
+
+
+
+ Your action
+
+
+ ) : null}
+
+ {/* The drawn draft: the editor's first line, in flow. A real mention
+ pill and a plugin-painted range ride inside it, each its own
+ annotation whose boundary follows the rendered text. */}
+
+
+ Summarize{" "}
+
+
+ @release-notes
+
+
+ {" "}
+ and fix the{" "}
+
+
+ TODO
+
+
+ {" "}
+ in checkout.
+
+
+
+ {/* bottom controls, at the real product's sizes and order */}
+
+
+
+
+
+
+ Fable 5High
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ acme-app · worktree
+
+ Full Access
+
+
+ );
+}
+
+/* ── the new-thread screen (RootComposeView order) ──────────────────── */
+
+export function ComposeScreenWireframe({
+ composer,
+}: {
+ /** The host's real composer, when available; replaces the mock one. */
+ composer?: ReactNode;
+} = {}) {
+ return (
+ // Padded for the same annotation gutter as the app-window diagram.
+
+ );
+}
+
+function ComposeScreenWireframeBody({ composer }: { composer?: ReactNode }) {
+ return (
+
+
+
+
+ {/* Proportions mirror RootComposeView: a centered reading column
+ (max-w-[760px] in the real app) inside a much wider main area,
+ content top-aligned, empty canvas below. */}
+
+
+
+ {/* the composer, no greeting above it (RootComposeView order):
+ the real one when the host lends it, the mock otherwise.
+ Inert either way — this is a diagram, and a live menu opening
+ here would cover the marked section below it. Width-capped to
+ the real home page's ratio: the product's composer spans about
+ two thirds of the content area, not the whole column. */}
+ {composer ?
{composer}
:
}
+
+ {/* plugin homepage sections render last, below everything */}
+
+
+
+ Your section
+
+
+ {["Release 1.4", "Bug triage", "Design QA"].map((card) => (
+
+ {card}
+
+
+
+ ))}
+
+
+
+
+
+ {/* right panel: no Info/Diff pins here; the new-tab launcher */}
+
+
+ Actions
+
+
+
+ Open browser
+
+
+
+ Start terminal
+
+ {/* The row hugs the frame's right edge, so its chip rides the
+ exterior gutter column, measured from the row itself. */}
+
+
+ Your action
+
+
+
+
+ );
+}
+
+/* ── the plugin settings page (PluginSettings.tsx order) ────────────── */
+
+export function SettingsWireframe() {
+ return (
+
+ {/* Page chrome: the settings area's own title bar (SettingsView). */}
+
+
+ Settings
+
+
+
+ {/* Header: icon, name, one-line description (PluginSettings.tsx). */}
+
+
+
+
+
+
+ Hello
+
+
+ A friendly example plugin.
+
+
+
+
+ {/* One "Configuration" heading covers both settings surfaces on the
+ real page: the recessed panel holds the form bb generates from the
+ plugin's declared fields, and any settingsSection components render
+ beneath it. The markers distinguish them. */}
+
+
Configuration
+
+
+
+
+ API key
+
+ secret
+
+
+
+ Stored server-side; never sent to the browser.
+
+
+
+ ••••••••
+
+
+
+
+
+ Case-sensitive search
+
+
+ Match capitalisation when looking things up.
+
+
+
+
+
+
+
+
+ Save settings
+
+
+
+
+ {/* settingsSection slots render under the generated form. */}
+
+
+
+ Your section
+
+
+
+ Connected as @acme-bot
+
+ Test connection
+
+
+
+
+
+
+
+ {/* The page's closing section, verbatim from PluginSettings.tsx. */}
+
+ Plugin details
+
+ Release, capabilities, and health live on
+
+ its plugin page
+
+
+
+
+
+
+ );
+}
+
+/* ── the plugin's page in Extensions (ToolsView + PluginDetail) ───────── */
+
+/**
+ * The Extensions detail page for one installed plugin. The one pluggable
+ * thing on it is the health banner: a plugin that reports needs-configuration
+ * gets a warning bar at the top of the pane (PluginBannerBar, rendered by
+ * PluginDetailBanners outside the scroll page), above the header and the
+ * section stack bb builds from the manifest and registrations.
+ */
+export function ExtensionsPluginPageWireframe() {
+ return (
+
+
+
+ Extensions
+
+
+ {/* Banner: full pane width, recessed, with a rule under it; the
+ icon/title/detail row lines up with the page gutter below. */}
+
+
+
+
+ Needs configuration
+
+
+ Set an API key in Settings. Reloads when you save.
+
+
+
+ Reload
+
+
+
+
+ {/* Header: icon, name, publisher badge; the enable toggle and menu
+ at the right (PluginDetail header). */}
+
+
+
Hello
+
+ BB Official
+
+
+
+
+
+
+
+
+ ~/.bb/plugins/hello
+
+
+
+ About
+
+ A friendly example plugin.
+
+
+
+
+ Configuration
+
+ Configure it on
+
+ its Settings page
+
+
+
+
+
+
+ Capabilities
+
+ {[
+ ["Settings", "API key, Case-sensitive search"],
+ ["bb hello", "Say hello from the terminal"],
+ ].map(([name, what]) => (
+
+ {name}
+ {what}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** The stand-in composer for surfaces with no bb behind them (the docs site). */
+function MockHomeComposer() {
+ return (
+ <>
+
+
+ Ask anything. @ to mention files, folders, or sections
+
+
+
+
+
+
+
+
+ Fable 5 · High
+
+
+
+
+
+
+
+
+
+
+
+ acme-app
+ · worktree
+
+ Full Access
+
+ >
+ );
+}
diff --git a/packages/plugin-api-map/test/agent-reference.test.ts b/packages/plugin-api-map/test/agent-reference.test.ts
new file mode 100644
index 0000000000..afc745c63c
--- /dev/null
+++ b/packages/plugin-api-map/test/agent-reference.test.ts
@@ -0,0 +1,156 @@
+/** @vitest-environment jsdom */
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ copyPluginSurfaceAgentReference,
+ createPluginSurfaceAgentReference,
+ PLUGIN_GUIDE_PLUGIN_ID,
+ pluginSurfaceAgentClipboardContent,
+ pluginSurfaceAgentContext,
+ pluginSurfaceAgentMention,
+ SURFACES_BY_ID,
+} from "../src/index";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("Plugin Guide agent references", () => {
+ it("derives the complete reference from canonical surface data", () => {
+ const surface = SURFACES_BY_ID.get("code-renderers");
+ if (!surface) throw new Error("code-renderers surface missing");
+
+ const reference = createPluginSurfaceAgentReference(surface);
+ expect(reference).toEqual(createPluginSurfaceAgentReference(surface));
+ expect(reference.identity).toEqual({
+ provider: "surface",
+ id: "code-renderers",
+ label: "Code & diff renderers",
+ });
+ expect(reference.resource).toEqual({
+ kind: "plugin",
+ pluginId: PLUGIN_GUIDE_PLUGIN_ID,
+ icon: null,
+ itemId: "surface:code-renderers",
+ label: "Code & diff renderers",
+ });
+ expect(reference.context.split("\n")).toHaveLength(3);
+ expect(reference.clipboard.text).toBe(
+ "Build a plugin that uses @Code & diff renderers. ",
+ );
+ });
+
+ it("uses the stable surface id and concise card label", () => {
+ const surface = SURFACES_BY_ID.get("composer-actions");
+ if (!surface) throw new Error("composer-actions surface missing");
+
+ expect(pluginSurfaceAgentMention(surface)).toEqual({
+ provider: "surface",
+ id: "composer-actions",
+ label: "Inline actions",
+ });
+ });
+
+ it("resolves only surface identity, SDK symbols, and the authoring guide", () => {
+ const context = pluginSurfaceAgentContext("composer-actions");
+ expect(context).toContain("Inline actions (composer-actions)");
+ expect(context).toContain("PluginComposerApi");
+ expect(context).toContain("bb-plugin-authoring skill");
+ expect(context?.split("\n")).toHaveLength(3);
+ expect(pluginSurfaceAgentContext("missing-surface")).toBeNull();
+ });
+
+ it("serializes one surface as bb's existing structured composer pill", () => {
+ const surface = SURFACES_BY_ID.get("composer-actions");
+ if (!surface) throw new Error("composer-actions surface missing");
+
+ const content = pluginSurfaceAgentClipboardContent(surface);
+ const document = new DOMParser().parseFromString(content.html, "text/html");
+ const pill = document.querySelector("[data-prompt-mention='true']");
+
+ expect(content.text).toBe("Build a plugin that uses @Inline actions. ");
+ expect(document.body.textContent).toBe(content.text);
+ expect(pill?.textContent).toBe("@Inline actions");
+ expect(pill?.getAttribute("data-prompt-mention-serialized-text")).toBe(
+ "@Inline actions",
+ );
+ expect(
+ JSON.parse(pill?.getAttribute("data-prompt-mention-resource") ?? ""),
+ ).toEqual({
+ kind: "plugin",
+ pluginId: PLUGIN_GUIDE_PLUGIN_ID,
+ icon: null,
+ itemId: "surface:composer-actions",
+ label: "Inline actions",
+ });
+ });
+
+ it("keeps multiple copied surfaces distinct and composable", () => {
+ const actions = SURFACES_BY_ID.get("composer-actions");
+ const panels = SURFACES_BY_ID.get("thread-panel");
+ if (!actions || !panels) throw new Error("reference surfaces missing");
+
+ const document = new DOMParser().parseFromString(
+ [actions, panels]
+ .map((surface) => pluginSurfaceAgentClipboardContent(surface).html)
+ .join(""),
+ "text/html",
+ );
+ const resources = [...document.querySelectorAll("[data-prompt-mention]")]
+ .map((pill) => pill.getAttribute("data-prompt-mention-resource"))
+ .map((value) => JSON.parse(value ?? ""));
+
+ expect(resources.map((resource) => resource.itemId)).toEqual([
+ "surface:composer-actions",
+ "surface:thread-panel",
+ ]);
+ expect(document.body.textContent).toBe(
+ "Build a plugin that uses @Inline actions. " +
+ "Build a plugin that uses @Thread side-panel tabs. ",
+ );
+ });
+
+ it("gives every surface a byte-stable, globally distinct pill identity", () => {
+ const itemIds = [...SURFACES_BY_ID.values()].map((surface) => {
+ const first = createPluginSurfaceAgentReference(surface);
+ const second = createPluginSurfaceAgentReference(surface);
+ expect(first).toEqual(second);
+ expect(first.clipboard.html).not.toContain(surface.summary);
+ for (const bullet of surface.bullets) {
+ expect(first.clipboard.html).not.toContain(bullet);
+ }
+ return first.resource.itemId;
+ });
+
+ expect(new Set(itemIds).size).toBe(itemIds.length);
+ });
+
+ it("writes both rich and plain clipboard representations", async () => {
+ const surface = SURFACES_BY_ID.get("composer-actions");
+ if (!surface) throw new Error("composer-actions surface missing");
+ const clipboardWrite = vi.fn().mockResolvedValue(undefined);
+ const items: Array> = [];
+ class TestClipboardItem {
+ constructor(item: Record) {
+ items.push(item);
+ }
+ }
+ vi.stubGlobal("ClipboardItem", TestClipboardItem);
+ vi.stubGlobal("navigator", { clipboard: { write: clipboardWrite } });
+
+ await expect(copyPluginSurfaceAgentReference(surface)).resolves.toBe(true);
+ expect(clipboardWrite).toHaveBeenCalledOnce();
+ expect(Object.keys(items[0] ?? {}).sort()).toEqual([
+ "text/html",
+ "text/plain",
+ ]);
+ await expect(items[0]?.["text/plain"]?.text()).resolves.toBe(
+ "Build a plugin that uses @Inline actions. ",
+ );
+ await expect(items[0]?.["text/html"]?.text()).resolves.toContain(
+ 'Build a plugin that uses match[1]),
+);
+
+const SURFACES = SURFACE_GROUPS.flatMap((group) => group.surfaces);
+
+describe("public SDK inventory", () => {
+ it("matches every non-internal published declaration subpath", () => {
+ // This is intentionally an exact declaration-shape gate, not only an
+ // export-name list: adding a BbPluginApi property or an interface method
+ // must fail CI even when its enclosing exported type already has a card.
+ expect(createSdkPublicApiInventory()).toEqual(readSdkPublicApiInventory());
+ });
+});
+
+describe("surface-to-SDK links", () => {
+ it("names only symbols the SDK still exports", () => {
+ const missing: string[] = [];
+ for (const surface of SURFACES) {
+ expect(surface.apiSymbols.length, surface.id).toBeGreaterThan(0);
+ for (const symbol of surface.apiSymbols) {
+ if (!EXPORTED.has(symbol)) {
+ missing.push(`${surface.id}: "${symbol}"`);
+ }
+ }
+ }
+ // A rename that lands here means the card still describes the old API.
+ expect(missing).toEqual([]);
+ });
+});
+
+/**
+ * The registration type each `app.slots.*` / `app.composer.*` /
+ * `app.contentScripts.*` method takes, read straight out of the interface
+ * bodies so a slot added to the SDK shows up here with no edit.
+ */
+function registrationTypes(interfaceName: string): Map {
+ const body = APP_CONTRACT.match(
+ new RegExp(`export interface ${interfaceName} \\{([\\s\\S]*?)\\n\\}`),
+ )?.[1];
+ if (!body) throw new Error(`${interfaceName} not found in app-contract.ts`);
+ const found = new Map();
+ // Method signatures, on one line or wrapped across several.
+ for (const match of body.matchAll(
+ /^ {2}([A-Za-z_][A-Za-z0-9_]*)\(\s*(?:registration:\s*)?([A-Za-z_][A-Za-z0-9_]*)/gm,
+ )) {
+ found.set(match[1], match[2]);
+ }
+ return found;
+}
+
+describe("registration slot coverage", () => {
+ it("documents every slot the SDK ships", () => {
+ const slots = [
+ ...registrationTypes("PluginAppSlots"),
+ ...registrationTypes("PluginAppComposer"),
+ ...registrationTypes("PluginAppContentScripts"),
+ ];
+ // Sanity-check the parse itself: a regex that silently matched nothing
+ // would make this test pass for the wrong reason forever.
+ expect(slots.length).toBeGreaterThanOrEqual(15);
+
+ const documented = new Set(SURFACES.flatMap((s) => s.apiSymbols));
+ const uncovered = slots
+ .filter(([, type]) => !documented.has(type))
+ .map(([method, type]) => `app.${method}() takes ${type}`);
+ // A new slot with no card is a surface plugin authors cannot discover.
+ expect(uncovered).toEqual([]);
+ });
+});
diff --git a/packages/plugin-api-map/test/command-palette-interaction.test.ts b/packages/plugin-api-map/test/command-palette-interaction.test.ts
new file mode 100644
index 0000000000..a23f4d31cc
--- /dev/null
+++ b/packages/plugin-api-map/test/command-palette-interaction.test.ts
@@ -0,0 +1,123 @@
+/** @vitest-environment jsdom */
+import { act, createElement, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vitest";
+
+import { SURFACE_NUMBERS } from "../src/product-map";
+import anatomy from "../src/anatomy-manifest.json";
+import { CommandPaletteWireframe, SurfaceMapContext } from "../src/wireframes";
+
+function InteractiveCommandPalette() {
+ const [activeId, setActiveId] = useState(null);
+ const [expandedId, setExpandedId] = useState(null);
+
+ return createElement(
+ SurfaceMapContext.Provider,
+ {
+ value: {
+ activeId,
+ setActiveId,
+ expandedId,
+ numberOf: (id: string) => SURFACE_NUMBERS.get(id) ?? null,
+ onSelect: setExpandedId,
+ },
+ },
+ createElement(CommandPaletteWireframe),
+ );
+}
+
+describe("command palette guide interaction", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
+ configurable: true,
+ value: true,
+ });
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ act(() => root.render(createElement(InteractiveCommandPalette)));
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it("closes the palette and opens the release-checklist tab, then reopens from the shortcut", () => {
+ const contract = anatomy.surfaceFixtures["command-palette-actions"];
+ const action = container.querySelector(
+ '[data-guide-fixture="command-palette-action"]',
+ );
+ const badge = container.querySelector(
+ '[data-guide-badge="command-palette-actions"]',
+ );
+ const listbox = container.querySelector('[role="listbox"]');
+
+ expect(action?.getAttribute("aria-selected")).toBe("true");
+ expect(
+ container.querySelectorAll(
+ '[data-guide-region="command-palette-actions"]',
+ ),
+ ).toHaveLength(1);
+ expect(
+ container.querySelector('[data-guide-fixture="command-palette-overlay"]'),
+ ).not.toBeNull();
+ // The measured badge rides the dialog's margin, anchored to the row it
+ // annotates, and stays outside the clipping listbox.
+ expect(badge?.parentElement).toBe(
+ container.querySelector('[data-guide-fixture="command-palette-dialog"]'),
+ );
+ expect(badge?.getAttribute("data-guide-badge-placement")).toBe("start");
+ expect(listbox?.contains(badge ?? null)).toBe(false);
+
+ act(() => badge?.click());
+
+ expect(
+ container.querySelector('[data-guide-fixture="command-palette-overlay"]'),
+ ).not.toBeNull();
+
+ act(() => action?.click());
+
+ expect(
+ container.querySelector('[data-guide-fixture="command-palette-overlay"]'),
+ ).toBeNull();
+ expect(
+ container.querySelector('[data-guide-fixture="release-checklist-panel"]'),
+ ).not.toBeNull();
+ for (const label of contract.labels.outcome) {
+ expect(container.textContent).toContain(label);
+ }
+ expect(
+ container
+ .querySelector('[data-guide-fixture="release-checklist-tab"]')
+ ?.getAttribute("aria-selected"),
+ ).toBe("true");
+
+ const shortcut = container.querySelector(
+ '[data-guide-fixture="command-palette-shortcut"]',
+ );
+ act(() => shortcut?.click());
+
+ expect(
+ container.querySelector('[data-guide-fixture="command-palette-overlay"]'),
+ ).not.toBeNull();
+ });
+});
diff --git a/packages/plugin-api-map/test/maintenance-skill.test.ts b/packages/plugin-api-map/test/maintenance-skill.test.ts
new file mode 100644
index 0000000000..9aaa5bd042
--- /dev/null
+++ b/packages/plugin-api-map/test/maintenance-skill.test.ts
@@ -0,0 +1,129 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const PLUGIN_ROOT = join(
+ import.meta.dirname,
+ "../../../plugins/plugin-api-docs",
+);
+
+describe("Plugin Guide maintenance skill", () => {
+ it("ships from the plugin manifest with the API-sync workflow", () => {
+ const manifest = JSON.parse(
+ readFileSync(join(PLUGIN_ROOT, "package.json"), "utf8"),
+ );
+ const skill = readFileSync(
+ join(PLUGIN_ROOT, "skills/plugin-guide-maintenance/SKILL.md"),
+ "utf8",
+ );
+
+ expect(manifest.bb.skills).toContain("skills");
+ expect(skill).toContain("name: plugin-guide-maintenance");
+ expect(skill).toContain("packages/plugin-api-map/src/surfaces.ts");
+ expect(skill).toContain("scaffold:surface-entry");
+ expect(skill).toContain("surface fixture");
+ expect(skill).toContain("anatomy-manifest.json");
+ expect(skill).toContain("`none`");
+ expect(skill).toContain("`anchor`");
+ expect(skill).toContain("`state`");
+ expect(skill).toContain("`flow`");
+ expect(skill).toContain("### Annotation quality contract");
+ expect(skill).toContain("clipping ancestor");
+ expect(skill).toContain("must not change the host surface's geometry");
+ expect(skill).toMatch(/outside the clipping\s+subtree/);
+ expect(skill).toContain("row alignment");
+ expect(skill).toContain("actual entry point");
+ expect(skill).toContain("transient menu");
+ expect(skill).toContain("sequential by annotation number");
+ expect(skill).toContain("separate interaction targets");
+ expect(skill).toContain("bounding rectangles");
+ expect(skill).toContain("docs/api_to_audit.md");
+ expect(skill).toContain("update:sdk-inventory");
+ expect(skill).toContain("Copy for agent");
+ expect(skill).toContain("scripts/bb-dev-app current");
+ expect(skill).not.toContain("launch the exact bb desktop dev build");
+ });
+
+ it("codifies the fixture conventions established across every Guide page", () => {
+ const skill = readFileSync(
+ join(PLUGIN_ROOT, "skills/plugin-guide-maintenance/SKILL.md"),
+ "utf8",
+ );
+ const normalized = skill.replace(/\s+/g, " ");
+
+ expect(normalized).toContain("### Surface-fixture composition contract");
+ expect(normalized).toContain("canonical product object");
+ expect(normalized).toContain("file path, file icon, and filename");
+ expect(normalized).toContain("hunk header, line numbers");
+ expect(normalized).toContain(
+ "Loaded content and its loading skeleton must use aligned height and vertical spacing",
+ );
+ expect(normalized).toContain("scales as one annotated composition");
+ expect(normalized).toContain("installed plugin customizations");
+ expect(normalized).toContain("visible host scope");
+ expect(normalized).toContain("host-owned wrapper or header");
+ expect(normalized).toContain("Derive fidelity; do not choose it");
+ expect(normalized).toContain("no meaningful spatial owner");
+ // The geometry rules are derivation contracts, not authored constants.
+ expect(normalized).toContain(
+ "min(MAX_FIXTURE_SCALE, availW / authoredW, availH / authoredH)",
+ );
+ expect(normalized).toContain(
+ "page selector is the sole narrow-width horizontal scroll owner",
+ );
+ expect(normalized).toContain("carets hug the label strip");
+ expect(normalized).toContain(
+ "Off-stage carousel pages must not contribute inline overflow",
+ );
+ expect(skill).toContain("980px-tall plugin content region");
+ expect(normalized).toContain("no `100dvh` arithmetic");
+ expect(normalized).toContain(
+ "blank canvas bounds are minimums, not fixed heights",
+ );
+ expect(normalized).toContain(
+ "The non-spatial capability grid is the only reflowing fixture",
+ );
+ expect(normalized).toContain("clamp(8px, 3cqh, 28px)");
+ expect(normalized).toContain(
+ "Page tabs are one horizontally scrolling, non-wrapping row",
+ );
+
+ expect(normalized).toContain("### Annotation placement decision table");
+ expect(normalized).toContain("Never nest interactive annotation anchors");
+ expect(normalized).toContain("one active annotation outline");
+ expect(normalized).toContain("elementFromPoint");
+
+ expect(normalized).toContain("### Interaction and state contract");
+ expect(normalized).toContain("Reserve its footprint");
+ expect(normalized).toContain("source placement direction");
+ expect(normalized).toContain("visually distinct selection");
+
+ expect(normalized).toContain("### Page, card, and reference contract");
+ expect(normalized).toContain("different owning host surface");
+ expect(normalized).toContain("normal flow below the fixture");
+ expect(normalized).toContain("both page-panning arrows");
+ expect(normalized).toContain(". With this, a plugin can:");
+ expect(normalized).toContain("multiple references distinct and composable");
+ expect(normalized).toContain("provider is exactly `surface`");
+ expect(normalized).toContain("item id is exactly `surface:`");
+ expect(normalized).toContain("byte-identical clipboard and context output");
+ expect(normalized).toContain(
+ "fixed host tabs before scrollable content tabs",
+ );
+ expect(normalized).toContain(
+ "one Guide-owned gap between a fixture and its card",
+ );
+ expect(normalized).toContain(
+ "title names the visible product object or outcome",
+ );
+ expect(normalized).toContain("Build a plugin that uses");
+ });
+
+ it("keeps desktop footer spacing from manufacturing page overflow", () => {
+ const app = readFileSync(join(PLUGIN_ROOT, "app.tsx"), "utf8");
+
+ expect(app).toMatch(/pb-6[^"]*lg:pb-0/);
+ expect(app).toMatch(/pt-5[^"]*lg:pt-4/);
+ });
+});
diff --git a/packages/plugin-api-map/test/message-actions-interaction.test.ts b/packages/plugin-api-map/test/message-actions-interaction.test.ts
new file mode 100644
index 0000000000..fa40a1ed53
--- /dev/null
+++ b/packages/plugin-api-map/test/message-actions-interaction.test.ts
@@ -0,0 +1,101 @@
+/** @vitest-environment jsdom */
+import { act, createElement, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vitest";
+
+import { SURFACE_NUMBERS } from "../src/product-map";
+import { AppShellWireframe, SurfaceMapContext } from "../src/wireframes";
+
+function InteractiveAppShell() {
+ const [activeId, setActiveId] = useState(null);
+ const [expandedId, setExpandedId] = useState(null);
+
+ return createElement(
+ SurfaceMapContext.Provider,
+ {
+ value: {
+ activeId,
+ setActiveId,
+ expandedId,
+ numberOf: (id: string) => SURFACE_NUMBERS.get(id) ?? null,
+ onSelect: setExpandedId,
+ },
+ },
+ createElement(AppShellWireframe),
+ );
+}
+
+describe("message action guide interaction", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
+ configurable: true,
+ value: true,
+ });
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ act(() => root.render(createElement(InteractiveAppShell)));
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it("reveals the action row on message hover, then shows the selected-text toolbar on activation", () => {
+ const message = container.querySelector(
+ '[data-guide-fixture="assistant-message"]',
+ );
+ const actionRegion = container.querySelector(
+ '[data-guide-region="message-actions"]',
+ );
+ const actionRow = container.querySelector(
+ '[data-guide-fixture="message-action-hover-row"]',
+ );
+
+ expect(message).not.toBeNull();
+ expect(actionRow?.className).toContain("opacity-0");
+ expect(
+ container.querySelector(
+ '[data-guide-fixture="message-action-selection-toolbar"]',
+ ),
+ ).toBeNull();
+
+ act(() => {
+ message?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ });
+
+ expect(actionRow?.className).toContain("opacity-100");
+
+ act(() => actionRegion?.click());
+
+ expect(
+ container.querySelector(
+ '[data-guide-fixture="message-action-selection-toolbar"]',
+ ),
+ ).not.toBeNull();
+ expect(
+ container.querySelector(
+ '[data-guide-fixture="message-action-selected-text"]',
+ )?.className,
+ ).toContain("bg-file-accent/25");
+ });
+});
diff --git a/packages/plugin-api-map/test/product-map.test.ts b/packages/plugin-api-map/test/product-map.test.ts
new file mode 100644
index 0000000000..c559f38719
--- /dev/null
+++ b/packages/plugin-api-map/test/product-map.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+
+import { annotationNeighbors, panCarets, SURFACE_GROUPS } from "../src/index";
+
+const LAST = SURFACE_GROUPS.length - 1;
+
+describe("panCarets", () => {
+ it("disables the caret that has nowhere to go", () => {
+ // Both carets always render; the end of the range just disables its
+ // caret, so the row's geometry never changes.
+ expect(panCarets(0, SURFACE_GROUPS.length)).toEqual({
+ previous: false,
+ next: true,
+ });
+ expect(panCarets(LAST, SURFACE_GROUPS.length)).toEqual({
+ previous: true,
+ next: false,
+ });
+ });
+
+ it("enables both carets everywhere in between", () => {
+ for (let index = 1; index < LAST; index++) {
+ expect(panCarets(index, SURFACE_GROUPS.length), `slide ${index}`).toEqual(
+ {
+ previous: true,
+ next: true,
+ },
+ );
+ }
+ });
+
+ it("disables both carets when there is a single slide", () => {
+ expect(panCarets(0, 1)).toEqual({ previous: false, next: false });
+ });
+});
+
+describe("annotationNeighbors", () => {
+ const surfaces = SURFACE_GROUPS[0]!.surfaces;
+
+ it("moves through annotations in their authored numeric order", () => {
+ expect(annotationNeighbors(surfaces, surfaces[1]!.id)).toEqual({
+ previous: surfaces[0],
+ next: surfaces[2],
+ });
+ });
+
+ it("keeps the missing direction disabled at each endpoint", () => {
+ expect(annotationNeighbors(surfaces, surfaces[0]!.id)).toEqual({
+ previous: null,
+ next: surfaces[1],
+ });
+ expect(annotationNeighbors(surfaces, surfaces.at(-1)!.id)).toEqual({
+ previous: surfaces.at(-2),
+ next: null,
+ });
+ });
+});
diff --git a/packages/plugin-api-map/test/scaffold-surface-entry.test.ts b/packages/plugin-api-map/test/scaffold-surface-entry.test.ts
new file mode 100644
index 0000000000..fa825b8534
--- /dev/null
+++ b/packages/plugin-api-map/test/scaffold-surface-entry.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildSurfaceEntryScaffold,
+ classifyFixtureFidelity,
+ fixtureResponsiveStrategy,
+ parseScaffoldArgs,
+ renderSurfaceEntryScaffold,
+} from "../scripts/scaffold-surface-entry.mjs";
+import anatomy from "../src/anatomy-manifest.json";
+
+describe("surface-entry scaffold", () => {
+ it.each([
+ [
+ {
+ spatialOwner: false,
+ transient: false,
+ outcome: false,
+ replacement: false,
+ },
+ "none",
+ ],
+ [
+ {
+ spatialOwner: true,
+ transient: false,
+ outcome: false,
+ replacement: false,
+ },
+ "anchor",
+ ],
+ [
+ {
+ spatialOwner: true,
+ transient: true,
+ outcome: false,
+ replacement: false,
+ },
+ "state",
+ ],
+ [
+ {
+ spatialOwner: true,
+ transient: false,
+ outcome: true,
+ replacement: false,
+ },
+ "flow",
+ ],
+ [
+ {
+ spatialOwner: true,
+ transient: false,
+ outcome: false,
+ replacement: true,
+ },
+ "flow",
+ ],
+ ] as const)("classifies %j as %s", (traits, expected) => {
+ expect(classifyFixtureFidelity(traits)).toBe(expected);
+ });
+
+ it("derives fidelity from spatial ownership and observable behavior", () => {
+ expect(
+ classifyFixtureFidelity({
+ spatialOwner: false,
+ transient: false,
+ outcome: false,
+ replacement: false,
+ }),
+ ).toBe("none");
+ expect(
+ classifyFixtureFidelity({
+ spatialOwner: true,
+ transient: false,
+ outcome: false,
+ replacement: false,
+ }),
+ ).toBe("anchor");
+ });
+
+ it("uses one responsive rule for every generated spatial fixture", () => {
+ expect(fixtureResponsiveStrategy({ spatialOwner: true })).toBe(
+ "scale-together",
+ );
+ expect(fixtureResponsiveStrategy({ spatialOwner: false })).toBe("reflow");
+ });
+
+ it("generates the representative command-palette flow deterministically", () => {
+ const first = parseScaffoldArgs([
+ "--id",
+ "command-palette-actions",
+ "--title",
+ "Command palette actions",
+ "--group",
+ "command-palette",
+ "--source",
+ "apps/app/src/lib/command-palette/palette-plugin-actions.ts",
+ "--source",
+ "apps/app/src/components/commands/CommandPalette.test.tsx",
+ "--source",
+ "apps/app/src/components/commands/CommandPalette.tsx",
+ "--api-symbol",
+ "PluginCommandPaletteActionRegistration",
+ "--api-symbol",
+ "PluginCommandPaletteActionContext",
+ "--transient",
+ "--outcome",
+ ]);
+ const reordered = parseScaffoldArgs([
+ "--outcome",
+ "--transient",
+ "--api-symbol",
+ "PluginCommandPaletteActionContext",
+ "--source",
+ "apps/app/src/components/commands/CommandPalette.tsx",
+ "--source",
+ "apps/app/src/components/commands/CommandPalette.test.tsx",
+ "--group",
+ "command-palette",
+ "--title",
+ "Command palette actions",
+ "--id",
+ "command-palette-actions",
+ "--api-symbol",
+ "PluginCommandPaletteActionRegistration",
+ "--source",
+ "apps/app/src/lib/command-palette/palette-plugin-actions.ts",
+ "--api-symbol",
+ "PluginCommandPaletteActionRegistration",
+ "--source",
+ "apps/app/src/lib/command-palette/palette-plugin-actions.ts",
+ ]);
+
+ expect(renderSurfaceEntryScaffold(first)).toBe(
+ renderSurfaceEntryScaffold(reordered),
+ );
+ expect(buildSurfaceEntryScaffold(first)).toMatchObject({
+ surface: {
+ id: "command-palette-actions",
+ apiSymbols: [
+ "PluginCommandPaletteActionContext",
+ "PluginCommandPaletteActionRegistration",
+ ],
+ },
+ fixture: {
+ groupId: "command-palette",
+ fidelity: "flow",
+ responsiveStrategy: "scale-together",
+ requiredStates: ["anchor", "triggered", "outcome"],
+ sources: [
+ {
+ path: "apps/app/src/components/commands/CommandPalette.test.tsx",
+ anchors: ["TODO: Add a stable source anchor"],
+ },
+ {
+ path: "apps/app/src/components/commands/CommandPalette.tsx",
+ anchors: ["TODO: Add a stable source anchor"],
+ },
+ {
+ path: "apps/app/src/lib/command-palette/palette-plugin-actions.ts",
+ anchors: ["TODO: Add a stable source anchor"],
+ },
+ ],
+ fixtureClassAnchors: ["TODO: Add a product token class"],
+ },
+ });
+ expect(buildSurfaceEntryScaffold(first).fixture).toMatchObject({
+ fidelity: anatomy.surfaceFixtures["command-palette-actions"].fidelity,
+ sources: anatomy.surfaceFixtures["command-palette-actions"].sources
+ .filter((source) => source.path.startsWith("apps/app/"))
+ .map((source) => ({
+ path: source.path,
+ anchors: ["TODO: Add a stable source anchor"],
+ }))
+ .sort((left, right) => left.path.localeCompare(right.path)),
+ });
+ });
+
+ it("rejects a visual entry without an authoritative source", () => {
+ expect(() =>
+ buildSurfaceEntryScaffold({
+ id: "missing-source",
+ title: "Missing source",
+ groupId: "app-shell",
+ sourcePaths: [],
+ apiSymbols: ["PluginMissingSource"],
+ spatialOwner: true,
+ transient: false,
+ outcome: false,
+ replacement: false,
+ }),
+ ).toThrow("spatial surfaces require at least one --source");
+ });
+});
diff --git a/packages/plugin-api-map/test/surface-card.test.ts b/packages/plugin-api-map/test/surface-card.test.ts
new file mode 100644
index 0000000000..f95c4ba548
--- /dev/null
+++ b/packages/plugin-api-map/test/surface-card.test.ts
@@ -0,0 +1,64 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import { SurfaceCard, SURFACE_GROUPS } from "../src/index";
+
+const surfaces = SURFACE_GROUPS[0]!.surfaces;
+
+describe("SurfaceCard annotation navigation", () => {
+ it("renders compact previous and next annotation actions", () => {
+ const markup = renderToStaticMarkup(
+ createElement(SurfaceCard, {
+ surface: surfaces[1]!,
+ number: 2,
+ onDismiss: () => undefined,
+ navigation: {
+ previous: surfaces[0]!,
+ next: surfaces[2]!,
+ onOpen: () => undefined,
+ },
+ }),
+ );
+
+ expect(markup).toContain(
+ `aria-label="Previous annotation: ${surfaces[0]!.title}"`,
+ );
+ expect(markup).toContain(
+ `aria-label="Next annotation: ${surfaces[2]!.title}"`,
+ );
+ });
+
+ it("keeps the unavailable endpoint visible and disabled", () => {
+ const markup = renderToStaticMarkup(
+ createElement(SurfaceCard, {
+ surface: surfaces[0]!,
+ number: 1,
+ onDismiss: () => undefined,
+ navigation: {
+ previous: null,
+ next: surfaces[1]!,
+ onOpen: () => undefined,
+ },
+ }),
+ );
+
+ expect(markup).toMatch(
+ /]*disabled=""[^>]*aria-label="No previous annotation"/,
+ );
+ });
+
+ it("renders the compact copy action when the host supplies copy behavior", () => {
+ const markup = renderToStaticMarkup(
+ createElement(SurfaceCard, {
+ surface: surfaces[0]!,
+ number: 1,
+ onDismiss: () => undefined,
+ onCopyForAgent: async () => true,
+ }),
+ );
+
+ expect(markup).toContain("Copy for agent");
+ expect(markup).not.toContain("bb-plugin-authoring skill");
+ });
+});
diff --git a/packages/plugin-api-map/test/surfaces.test.ts b/packages/plugin-api-map/test/surfaces.test.ts
new file mode 100644
index 0000000000..f12be0badc
--- /dev/null
+++ b/packages/plugin-api-map/test/surfaces.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from "vitest";
+
+import { ANATOMY_MANIFEST as anatomy } from "../src/index";
+import {
+ fixtureResponsiveStrategy,
+ SURFACE_GROUPS,
+ SURFACE_NUMBERS,
+ SURFACES_BY_ID,
+} from "../src/index";
+import {
+ ANATOMY_RENDERER_KEYS,
+ APP_SHELL_MARKS,
+ COMMAND_PALETTE_MARKS,
+ COMPOSE_MARKS,
+ COMPOSER_MARKS,
+ EXTENSIONS_MARKS,
+ SETTINGS_MARKS,
+} from "../src/index";
+
+const groupById = new Map(SURFACE_GROUPS.map((group) => [group.id, group]));
+
+function surfaceIds(groupId: string): string[] {
+ return (groupById.get(groupId as never)?.surfaces ?? []).map(
+ (surface) => surface.id,
+ );
+}
+
+describe("product-map surfaces", () => {
+ it("keeps app-window annotations in their stable sequential order", () => {
+ const ordered = [
+ "nav-panel",
+ "thread-list",
+ "thread-row-status",
+ "sidebar-footer",
+ "thread-header",
+ "message-directives",
+ "message-actions",
+ "pending-interaction",
+ "code-renderers",
+ "thread-panel",
+ "file-opener",
+ "timeline-renderers",
+ "content-scripts",
+ ];
+ expect(surfaceIds("app-shell")).toEqual(ordered);
+ expect([...APP_SHELL_MARKS]).toEqual(ordered);
+ });
+
+ it("gives command palette actions their own numbered page", () => {
+ expect(surfaceIds("command-palette")).toEqual(["command-palette-actions"]);
+ expect([...COMMAND_PALETTE_MARKS]).toEqual(["command-palette-actions"]);
+ });
+
+ it("has globally unique surface ids", () => {
+ const all = SURFACE_GROUPS.flatMap((group) =>
+ group.surfaces.map((surface) => surface.id),
+ );
+ expect(new Set(all).size).toBe(all.length);
+ expect(SURFACES_BY_ID.size).toBe(all.length);
+ });
+
+ it("marks every visual-group surface on its fixture exactly once", () => {
+ // One surface fixture per carousel slide, so each group's surfaces must all
+ // be marked on that group's own fixture.
+ expect([...APP_SHELL_MARKS].sort()).toEqual(surfaceIds("app-shell").sort());
+ expect([...COMMAND_PALETTE_MARKS].sort()).toEqual(
+ surfaceIds("command-palette").sort(),
+ );
+ expect([...COMPOSER_MARKS].sort()).toEqual(surfaceIds("composer").sort());
+ expect([...COMPOSE_MARKS].sort()).toEqual(surfaceIds("home").sort());
+ expect([...SETTINGS_MARKS].sort()).toEqual(surfaceIds("settings").sort());
+ expect([...EXTENSIONS_MARKS].sort()).toEqual(
+ surfaceIds("extensions").sort(),
+ );
+ });
+
+ it("numbers the surfaces a fixture draws, and only those", () => {
+ // A numbered surface with no marker would print a number the diagram
+ // never shows; an unnumbered marked surface renders an empty chip.
+ for (const group of SURFACE_GROUPS) {
+ const numbers = group.surfaces.map((surface) =>
+ SURFACE_NUMBERS.get(surface.id),
+ );
+ if (group.id === "headless") {
+ expect(numbers.every((number) => number === undefined)).toBe(true);
+ continue;
+ }
+ expect(numbers).toEqual(group.surfaces.map((_, index) => index + 1));
+ }
+ });
+
+ it("derives one responsive strategy from each group's fixture kind", () => {
+ for (const group of SURFACE_GROUPS) {
+ expect(fixtureResponsiveStrategy(group), group.id).toBe(
+ group.fixtureKind === "spatial" ? "scale-together" : "reflow",
+ );
+ }
+ expect(
+ SURFACE_GROUPS.filter(
+ (group) => fixtureResponsiveStrategy(group) === "scale-together",
+ ).map((group) => group.id),
+ ).toEqual([
+ "app-shell",
+ "command-palette",
+ "composer",
+ "home",
+ "settings",
+ "extensions",
+ ]);
+ });
+
+ it("renders every anatomy-manifest region and nothing else", () => {
+ // The fixtures draw these regions by mapping over the manifest, so a
+ // manifest key without a renderer would silently drop UI, and a stale
+ // renderer key would be dead code hiding a manifest drift.
+ for (const area of [
+ "appSidebar",
+ "sidebarFooter",
+ "messageActionBar",
+ ] as const) {
+ expect([...ANATOMY_RENDERER_KEYS[area]].sort()).toEqual(
+ [...anatomy[area]].sort(),
+ );
+ }
+ });
+
+ it("ties every deterministic fixture contract to its visual surface group", () => {
+ for (const [surfaceId, contract] of Object.entries(
+ anatomy.surfaceFixtures,
+ )) {
+ const group = groupById.get(contract.groupId as never);
+ expect(
+ group,
+ `${surfaceId}: unknown group ${contract.groupId}`,
+ ).toBeDefined();
+ expect(
+ group?.surfaces.some((surface) => surface.id === surfaceId),
+ `${surfaceId}: missing from ${contract.groupId}`,
+ ).toBe(true);
+ expect(["anchor", "state", "flow"]).toContain(contract.fidelity);
+ expect(contract.responsiveStrategy).toBe("scale-together");
+ expect(contract.sources.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("clusters every headless surface into exactly one named section", () => {
+ // The pixel-less slide renders FROM these sections, so a surface missing
+ // from them would silently vanish from the map.
+ const headless = groupById.get("headless" as never);
+ const sectioned = (headless?.sections ?? []).flatMap(
+ (section) => section.surfaceIds,
+ );
+ expect([...sectioned].sort()).toEqual(surfaceIds("headless").sort());
+ expect(new Set(sectioned).size).toBe(sectioned.length);
+ // The flat surface array drives card Previous/Next, and the sections
+ // drive the rendered grid — order equality keeps navigation from jumping
+ // between sections and back.
+ expect(surfaceIds("headless")).toEqual(sectioned);
+ });
+
+ it("keeps the headless group off the surface fixtures", () => {
+ const marked = new Set([
+ ...APP_SHELL_MARKS,
+ ...COMMAND_PALETTE_MARKS,
+ ...COMPOSER_MARKS,
+ ...COMPOSE_MARKS,
+ ...SETTINGS_MARKS,
+ ...EXTENSIONS_MARKS,
+ ]);
+ for (const id of surfaceIds("headless")) {
+ expect(marked.has(id)).toBe(false);
+ }
+ });
+});
+
+describe("surface cross-references", () => {
+ it("points every [label](id) at a real surface", () => {
+ // An id that no longer exists renders as plain prose — the reference just
+ // quietly disappears rather than failing, so nothing else would catch it.
+ const dangling: string[] = [];
+ for (const group of SURFACE_GROUPS) {
+ for (const surface of group.surfaces) {
+ for (const copy of [surface.summary, ...surface.bullets]) {
+ for (const [, id] of copy.matchAll(/\[[^\]]+\]\(([a-z0-9-]+)\)/g)) {
+ if (!SURFACES_BY_ID.has(id)) {
+ dangling.push(`${surface.id}: "${id}"`);
+ }
+ if (id === surface.id) {
+ dangling.push(`${surface.id}: references itself`);
+ }
+ }
+ }
+ }
+ }
+ expect(dangling).toEqual([]);
+ });
+});
+
+describe("surface card copy", () => {
+ it("follows the lead-then-bullets template", () => {
+ // Every card reads the same way: one lead sentence that the bullets hang
+ // off, then the capabilities. A lead that stops mid-thought (or bullets
+ // that have nothing to hang off) reads as a broken card.
+ for (const group of SURFACE_GROUPS) {
+ for (const surface of group.surfaces) {
+ expect(surface.summary, surface.id).toMatch(
+ /\. With this, a plugin can:$/,
+ );
+ expect(surface.bullets.length, surface.id).toBeGreaterThanOrEqual(2);
+ for (const bullet of surface.bullets) {
+ expect(bullet.trim().length, surface.id).toBeGreaterThan(0);
+ // The lead-in already says "can"; a bullet that repeats it reads
+ // "a plugin can: Can register…". Bullets are bare verb phrases.
+ expect(bullet, `${surface.id}: "${bullet}"`).not.toMatch(/^Can\b/);
+ }
+ }
+ }
+ });
+});
diff --git a/packages/plugin-api-map/test/used-by.test.ts b/packages/plugin-api-map/test/used-by.test.ts
new file mode 100644
index 0000000000..a1cb9e03e9
--- /dev/null
+++ b/packages/plugin-api-map/test/used-by.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ scrollUsedBy,
+ usedByScrollState,
+ usedByScrollStep,
+} from "../src/index";
+
+/** A row wide enough for its items: the common case, and it shows no carets. */
+const FITS = { scrollLeft: 0, scrollWidth: 180, clientWidth: 240 };
+/** Fourteen plugin names in a card-width row, scrolled to the start. */
+const AT_START = { scrollLeft: 0, scrollWidth: 900, clientWidth: 240 };
+
+describe("usedByScrollState", () => {
+ it("offers no carets when the items fit", () => {
+ expect(usedByScrollState(FITS)).toEqual({
+ canScrollLeft: false,
+ canScrollRight: false,
+ });
+ });
+
+ it("offers no carets for a row that is exactly full, or over by a rounding error", () => {
+ // Sub-pixel layout maths must not put a caret on a row that reads as
+ // fitting, because pressing it would move nothing.
+ expect(
+ usedByScrollState({ scrollLeft: 0, scrollWidth: 240, clientWidth: 240 }),
+ ).toEqual({ canScrollLeft: false, canScrollRight: false });
+ expect(
+ usedByScrollState({
+ scrollLeft: 0,
+ scrollWidth: 240.5,
+ clientWidth: 240,
+ }),
+ ).toEqual({ canScrollLeft: false, canScrollRight: false });
+ });
+
+ it("offers only the right caret at the start", () => {
+ expect(usedByScrollState(AT_START)).toEqual({
+ canScrollLeft: false,
+ canScrollRight: true,
+ });
+ });
+
+ it("offers both carets in the middle", () => {
+ expect(usedByScrollState({ ...AT_START, scrollLeft: 300 })).toEqual({
+ canScrollLeft: true,
+ canScrollRight: true,
+ });
+ });
+
+ it("offers only the left caret at the end", () => {
+ // scrollWidth - clientWidth = 660: the far extent.
+ expect(usedByScrollState({ ...AT_START, scrollLeft: 660 })).toEqual({
+ canScrollLeft: true,
+ canScrollRight: false,
+ });
+ // Browsers can report a fractional scrollLeft just shy of the extent.
+ expect(usedByScrollState({ ...AT_START, scrollLeft: 659.4 })).toEqual({
+ canScrollLeft: true,
+ canScrollRight: false,
+ });
+ });
+});
+
+describe("usedByScrollStep", () => {
+ it("pages by roughly one visible width, keeping an overlap", () => {
+ expect(usedByScrollStep(240)).toBe(208);
+ expect(usedByScrollStep(600)).toBe(568);
+ });
+
+ it("still advances usefully in a very narrow row", () => {
+ // Without a floor, a 40px row would page by 8px, or backwards.
+ expect(usedByScrollStep(40)).toBe(80);
+ });
+});
+
+describe("scrollUsedBy", () => {
+ it("scrolls the viewport one page in the pressed direction", () => {
+ const scrollBy = vi.fn();
+ scrollUsedBy({ clientWidth: 240, scrollBy }, 1, { reducedMotion: false });
+ expect(scrollBy).toHaveBeenCalledWith({ left: 208, behavior: "smooth" });
+
+ scrollUsedBy({ clientWidth: 240, scrollBy }, -1, { reducedMotion: false });
+ expect(scrollBy).toHaveBeenLastCalledWith({
+ left: -208,
+ behavior: "smooth",
+ });
+ });
+
+ it("jumps instead of animating when motion is reduced", () => {
+ const scrollBy = vi.fn();
+ scrollUsedBy({ clientWidth: 240, scrollBy }, 1, { reducedMotion: true });
+ expect(scrollBy).toHaveBeenCalledWith({ left: 208, behavior: "auto" });
+ });
+});
diff --git a/packages/plugin-api-map/test/wireframes.test.ts b/packages/plugin-api-map/test/wireframes.test.ts
new file mode 100644
index 0000000000..f46f82e3b0
--- /dev/null
+++ b/packages/plugin-api-map/test/wireframes.test.ts
@@ -0,0 +1,382 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { createElement, type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ MAX_FIXTURE_SCALE,
+ ProductMap,
+ spatialFixtureScale,
+ SURFACE_NUMBERS,
+} from "../src/product-map";
+import { SURFACES_BY_ID } from "../src/surfaces";
+import anatomy from "../src/anatomy-manifest.json";
+import {
+ AppShellRightPanel,
+ AppShellWireframe,
+ CommandPaletteWireframe,
+ RealComposerAnnotated,
+ SurfaceMapContext,
+ type SurfaceMapState,
+} from "../src/wireframes";
+
+const mapState: SurfaceMapState = {
+ activeId: null,
+ setActiveId: vi.fn(),
+ expandedId: null,
+ spotlightId: null,
+ numberOf: (id) => SURFACE_NUMBERS.get(id) ?? null,
+};
+
+function renderWireframe(
+ node: ReactNode,
+ state: SurfaceMapState = mapState,
+): string {
+ return renderToStaticMarkup(
+ createElement(SurfaceMapContext.Provider, { value: state }, node),
+ );
+}
+
+describe("guide fixture boundaries", () => {
+ it("scales every spatial fixture together and reflows only the capability grid", () => {
+ const markup = renderToStaticMarkup(createElement(ProductMap));
+
+ expect(
+ markup.match(/data-guide-responsive-strategy="scale-together"/g),
+ ).toHaveLength(6);
+ expect(
+ markup.match(/data-guide-responsive-strategy="reflow"/g),
+ ).toHaveLength(1);
+ // Two-axis, capped: width or height pressure shrinks; headroom grows the
+ // fixture toward the legibility ceiling, never past it.
+ expect(spatialFixtureScale(360, 720)).toBe(0.5);
+ expect(spatialFixtureScale(720, 720)).toBe(1);
+ expect(spatialFixtureScale(1280, 720)).toBe(MAX_FIXTURE_SCALE);
+ expect(spatialFixtureScale(1280, 720, 700, 700)).toBe(1);
+ expect(spatialFixtureScale(1280, 720, 350, 700)).toBe(0.5);
+ expect(spatialFixtureScale(1280, 720, 7000, 700)).toBe(MAX_FIXTURE_SCALE);
+ expect(spatialFixtureScale(360, 720, 7000, 700)).toBe(0.5);
+ });
+
+ it("scrolls only the one-line page list and clips off-stage fixture overflow", () => {
+ const markup = renderToStaticMarkup(createElement(ProductMap));
+
+ expect(markup).toContain(
+ "overflow-x-clip transition-[height] duration-300 ease-out",
+ );
+ // The caret+label group shrink-wraps and centers as one unit, so the
+ // carets hug the strip; the scroller stays the sole horizontal owner.
+ expect(markup).toContain("mx-auto flex w-fit max-w-full items-center");
+ expect(markup).toContain("data-guide-page-list-scroll");
+ expect(markup).toContain("min-w-0 overflow-x-auto");
+ expect(markup).toContain("w-max flex-nowrap");
+ expect(markup).toContain("min-w-0 w-full shrink-0 self-start px-1 pt-2");
+ expect(markup).not.toContain("flex flex-wrap items-center justify-center");
+ expect(markup).not.toContain("min-w-full flex-nowrap");
+ });
+
+ it("does not reserve the full header gap when the compact plugin page omits its header", () => {
+ const compactMarkup = renderToStaticMarkup(createElement(ProductMap));
+ const headedMarkup = renderToStaticMarkup(
+ createElement(ProductMap, {
+ header: createElement("h1", null, "Plugin surfaces"),
+ }),
+ );
+
+ expect(compactMarkup).toContain('class="mt-2"');
+ expect(headedMarkup).toContain('class="mt-8"');
+ });
+
+ it("never nests one annotation link inside another", () => {
+ const markup = renderWireframe(createElement(AppShellWireframe));
+ let anchorDepth = 0;
+
+ for (const tag of markup.matchAll(/]*)?>|<\/a>/g)) {
+ if (tag[0].startsWith("")) {
+ anchorDepth -= 1;
+ } else {
+ expect(anchorDepth).toBe(0);
+ anchorDepth += 1;
+ }
+ }
+
+ expect(anchorDepth).toBe(0);
+ });
+
+ it("places the right-panel annotations on their respective tabs", () => {
+ const markup = renderWireframe(createElement(AppShellWireframe));
+
+ for (const id of ["thread-panel", "file-opener", "code-renderers"]) {
+ expect(markup).toMatch(
+ new RegExp(`data-guide-region="${id}"[\\s\\S]*?data-guide-tab="${id}"`),
+ );
+ }
+ });
+
+ it("keeps the real 48px tab row and places its badges in an exterior top layer", () => {
+ const panelMarkup = renderWireframe(
+ createElement(AppShellRightPanel, {
+ activeTab: "thread-panel",
+ onTabSelect: vi.fn(),
+ }),
+ );
+ const tabStrip = panelMarkup.slice(
+ 0,
+ panelMarkup.indexOf("data-guide-tab-body="),
+ );
+ const appMarkup = renderWireframe(createElement(AppShellWireframe));
+
+ expect(tabStrip).toContain("h-12 items-center");
+ expect(tabStrip).not.toContain("h-16");
+ expect(tabStrip).not.toContain("items-end");
+ expect(tabStrip).not.toContain("pb-2");
+ expect(tabStrip).not.toContain("data-guide-badge=");
+ // The tab chips ride the lane the gutter reserves above the frame, each
+ // measured from its own tab — no layer duplicating panel geometry.
+ for (const id of ["thread-panel", "file-opener", "code-renderers"]) {
+ expect(appMarkup).toMatch(
+ new RegExp(
+ `data-guide-badge="${id}"[\\s\\S]*?data-guide-badge-placement="lane"`,
+ ),
+ );
+ }
+ expect(appMarkup).not.toContain(
+ 'data-guide-annotation-layer="right-panel-tabs"',
+ );
+ });
+
+ it("mirrors bb's fixed Info/Diff tabs before plugin-owned content tabs", () => {
+ const appSource = readFileSync(
+ join(
+ import.meta.dirname,
+ "../../../apps/app/src/views/thread-detail/ThreadDetailView.tsx",
+ ),
+ "utf8",
+ );
+ const markup = renderWireframe(
+ createElement(AppShellRightPanel, {
+ activeTab: "thread-panel",
+ onTabSelect: vi.fn(),
+ }),
+ );
+ const tabStrip = markup.slice(0, markup.indexOf("data-guide-tab-body="));
+
+ expect(appSource.indexOf("createThreadInfoFixedPanelTab()")).toBeLessThan(
+ appSource.indexOf("createGitDiffFixedPanelTab()"),
+ );
+ expect(tabStrip).toMatch(
+ /data-guide-fixture="right-panel-fixed-tabs"[\s\S]*data-guide-tab="info"[\s\S]*data-guide-tab="code-renderers"/,
+ );
+ expect(tabStrip).toMatch(
+ /data-guide-fixture="right-panel-content-tabs"[\s\S]*data-guide-tab="thread-panel"[\s\S]*data-guide-tab="file-opener"/,
+ );
+ expect(tabStrip.indexOf('data-guide-tab="code-renderers"')).toBeLessThan(
+ tabStrip.indexOf('data-guide-tab="thread-panel"'),
+ );
+ });
+
+ it("places the sidebar and frame badges in the measured exterior gutter", () => {
+ const markup = renderWireframe(createElement(AppShellWireframe));
+
+ // Exterior chips are measured from the regions they annotate — the
+ // markup carries the placement declaration; the rendered QA sweep is the
+ // geometry gate.
+ expect(markup).toMatch(
+ /data-guide-badge="nav-panel"[\s\S]*?data-guide-badge-placement="start"/,
+ );
+ expect(markup).toMatch(
+ /data-guide-badge="thread-list"[\s\S]*?data-guide-badge-placement="start"/,
+ );
+ expect(markup).toMatch(
+ /data-guide-badge="content-scripts"[\s\S]*?data-guide-badge-placement="end"/,
+ );
+ expect(markup).not.toContain("overflow-x-auto");
+ expect(markup).toContain("relative min-w-[1260px] px-10 pb-0 pt-[26px]");
+ // The engaged tint for content scripts is the frame's own box.
+ expect(markup).toMatch(
+ /data-guide-target="content-scripts"[^>]*class="[^"]*absolute inset-0/,
+ );
+ });
+
+ it("keeps the sidebar trigger in app-owned overlay chrome", () => {
+ const markup = renderWireframe(createElement(AppShellWireframe));
+ const reserveStart = markup.indexOf(
+ 'data-guide-fixture="sidebar-top-reserve"',
+ );
+ const reserveEnd = markup.indexOf(
+ 'data-guide-fixture="sidebar-primary-actions"',
+ );
+
+ expect(markup).toContain('data-guide-fixture="sidebar-trigger-overlay"');
+ expect(reserveStart).toBeGreaterThan(-1);
+ expect(reserveEnd).toBeGreaterThan(reserveStart);
+ expect(markup.slice(reserveStart, reserveEnd)).not.toContain(
+ 'data-guide-fixture="sidebar-trigger-overlay"',
+ );
+ });
+
+ it("grows the app window within capped viewport-fit bounds while retaining loose timeline spacing", () => {
+ const markup = renderWireframe(createElement(AppShellWireframe));
+ const timeline = markup.slice(
+ markup.indexOf('data-guide-fixture="app-window-timeline"'),
+ markup.indexOf("Fix the flaky checkout tests"),
+ );
+
+ // One width owner: the gutter wrapper's floor; the frame fills it minus
+ // the gutter, so a second inner floor would just be a copy to drift.
+ expect(markup).not.toContain("min-w-[1180px]");
+ // One authored height per fixture — no frozen app-chrome arithmetic. The
+ // two-axis fixture scale is what fits short panels.
+ expect(markup).not.toContain("100dvh");
+ expect(markup).toContain("flex min-h-[650px] items-stretch");
+ expect(markup).toContain("flex w-[300px] shrink-0 flex-col");
+ expect(timeline).toContain("min-h-[510px] flex-1 space-y-7");
+ expect(timeline).toContain("px-5 py-6");
+ });
+
+ it.each([
+ ["thread-panel", "thread-panel", "Release checklist"],
+ ["file-opener", "file-viewer", "Checkout retry notes"],
+ ["code-renderers", "diff-renderer", "checkout.test.ts"],
+ ] as const)(
+ "renders the %s tab's matching body",
+ (activeTab, fixture, copy) => {
+ const markup = renderWireframe(
+ createElement(AppShellRightPanel, {
+ activeTab,
+ onTabSelect: vi.fn(),
+ }),
+ );
+
+ expect(markup).toContain(`data-guide-tab-body="${activeTab}"`);
+ expect(markup).toContain(`data-guide-fixture="${fixture}"`);
+ expect(markup).toContain(copy);
+ },
+ );
+
+ it("renders the command-palette action on a dedicated realistic page", () => {
+ const markup = renderWireframe(createElement(CommandPaletteWireframe));
+ const contract = anatomy.surfaceFixtures["command-palette-actions"];
+
+ expect(contract.fidelity).toBe("flow");
+ expect(contract.requiredStates).toEqual(["anchor", "triggered", "outcome"]);
+ for (const state of ["anchor", "triggered"] as const) {
+ for (const label of contract.labels[state]) {
+ expect(markup).toContain(label);
+ }
+ }
+ for (const label of contract.labels.outcome) {
+ expect(markup).not.toContain(label);
+ }
+ for (const classAnchor of contract.fixtureClassAnchors) {
+ expect(markup, `missing fixture class ${classAnchor}`).toContain(
+ classAnchor,
+ );
+ }
+ expect(markup).toContain('data-guide-fixture="command-palette-thread"');
+ expect(markup).toContain('data-guide-fixture="command-palette-overlay"');
+ expect(markup).toContain('data-guide-fixture="command-palette-dialog"');
+ expect(markup).toContain('data-guide-fixture="command-palette-shortcut"');
+ expect(markup).toContain('data-guide-region="command-palette-actions"');
+ expect(markup).toContain('data-guide-fixture="command-palette-action"');
+ expect(markup).toMatch(
+ /data-guide-badge="command-palette-actions"[\s\S]*?data-guide-badge-placement="start"/,
+ );
+ expect(markup).toContain(
+ "max-h-[min(24rem,50dvh)] overflow-y-auto p-1 text-sm",
+ );
+ expect(markup).not.toContain(
+ "grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-x-3",
+ );
+ expect(markup).not.toContain("p-1 pl-3 text-sm");
+ expect(markup).toContain('role="option" aria-selected="true"');
+ expect(markup).toContain("Run release checklist");
+ expect(markup).toContain("Plugins");
+ expect(markup).toContain("⇧⌘P");
+ expect(markup).not.toContain(
+ 'data-guide-fixture="release-checklist-panel"',
+ );
+ });
+
+ it("attaches the mention annotation to the rendered mention pill", () => {
+ const markup = renderWireframe(createElement(RealComposerAnnotated));
+
+ expect(markup).toMatch(
+ /data-guide-region="mention-provider"[\s\S]*@release-notes/,
+ );
+ });
+
+ it("annotates the single fixture-owned composer action without duplicating it", () => {
+ const markup = renderWireframe(createElement(RealComposerAnnotated));
+
+ expect(
+ markup.match(/data-guide-fixture="plugin-composer-action"/g),
+ ).toHaveLength(1);
+ expect(markup).toContain('data-guide-target="composer-actions"');
+ expect(markup).toContain('data-guide-badge="composer-actions"');
+ expect(markup).toContain('data-guide-icon="CornerDownLeft"');
+ });
+
+ it("keeps composer badges in a Guide-owned layer separate from host controls", () => {
+ const markup = renderWireframe(createElement(RealComposerAnnotated));
+
+ expect(markup).toContain('data-guide-annotation-layer="composer-controls"');
+ for (const id of [
+ "composer-banners",
+ "composer-state",
+ "composer-plus-menu",
+ "provider-picker",
+ "composer-actions",
+ ]) {
+ expect(markup).toContain(`data-guide-badge="${id}"`);
+ expect(markup).toContain(`data-guide-target="${id}"`);
+ }
+ expect(markup).not.toMatch(
+ /data-guide-target="composer-actions"[^>]*>[\s\S]*data-guide-badge="composer-actions"/,
+ );
+ });
+
+ it("keeps the open mention typeahead separate from its target and annotation layer", () => {
+ const markup = renderWireframe(createElement(RealComposerAnnotated), {
+ ...mapState,
+ activeId: "mention-provider",
+ });
+
+ expect(markup).toContain('data-guide-transient-for="mention-provider"');
+ // The menu anchors to the banner it seats above, so its clearance
+ // derives from the banner's own box rather than an authored offset.
+ expect(markup).toMatch(
+ /data-guide-target="composer-banners"[^>]*>[\s\S]*?data-guide-transient-for="mention-provider"/,
+ );
+ expect(markup).toContain("bottom-full z-20 mb-1");
+ const transientStart = markup.indexOf(
+ 'data-guide-transient-for="mention-provider"',
+ );
+ const transientMarkup = markup.slice(
+ transientStart,
+ markup.indexOf("", transientStart),
+ );
+ expect(transientMarkup).not.toContain(
+ 'data-guide-badge="mention-provider"',
+ );
+ });
+
+ it("keeps the message selection toolbar closed before activation", () => {
+ const markup = renderWireframe(createElement(AppShellWireframe));
+
+ expect(markup).toContain('data-guide-fixture="assistant-message"');
+ expect(markup).not.toContain(
+ 'data-guide-fixture="message-action-selection-toolbar"',
+ );
+ });
+});
+
+describe("guide taxonomy", () => {
+ it("names the renderer surface for both code and diffs", () => {
+ expect(SURFACES_BY_ID.get("code-renderers")?.title).toBe(
+ "Code & diff renderers",
+ );
+ });
+});
diff --git a/packages/plugin-api-map/tsconfig.json b/packages/plugin-api-map/tsconfig.json
new file mode 100644
index 0000000000..c67e8af5d3
--- /dev/null
+++ b/packages/plugin-api-map/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": ["@bb/tsconfig/base.json"],
+ "compilerOptions": {
+ "rootDir": ".",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "resolveJsonModule": true,
+ "types": ["node", "vitest/globals"],
+ "composite": false,
+ "declaration": false,
+ "declarationMap": false,
+ "noEmit": true
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/plugin-api-map/vitest.config.ts b/packages/plugin-api-map/vitest.config.ts
new file mode 100644
index 0000000000..c104c88e1f
--- /dev/null
+++ b/packages/plugin-api-map/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineWorkspaceTestConfig } from "../../vitest.shared.js";
+
+export default defineWorkspaceTestConfig({
+ test: {
+ silent: "passed-only",
+ name: "@bb/plugin-api-map",
+ include: ["test/**/*.test.ts"],
+ exclude: ["dist/**", "node_modules/**"],
+ },
+});
diff --git a/packages/plugin-build/src/svg-asset.test.ts b/packages/plugin-build/src/svg-asset.test.ts
index f70379d8ab..03005bbdf3 100644
--- a/packages/plugin-build/src/svg-asset.test.ts
+++ b/packages/plugin-build/src/svg-asset.test.ts
@@ -17,11 +17,13 @@ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
/**
* Every SVG a first-party or example plugin ships under `icons/`: the
* provider plugins' `bb.branding.icon` files and path-shaped provider icons,
- * plus the echo example's declared icon. Pinned so the list cannot silently
- * go empty; the discovery test checks it against the tree.
+ * plus declared icons from other first-party and example plugins. Pinned so
+ * the list cannot silently go empty; the discovery test checks it against the
+ * tree.
*/
const FIRST_PARTY_BRANDING_SVGS = [
"examples/plugins/echo-provider/icons/receipt.svg",
+ "plugins/plugin-api-docs/icons/ai-generative.svg",
"plugins/provider-acp/icons/acp.svg",
"plugins/provider-acp/icons/cursor.svg",
"plugins/provider-acp/icons/grok.svg",
diff --git a/packages/plugin-registry/r/icon-extended.json b/packages/plugin-registry/r/icon-extended.json
index 992b777846..d645c2c256 100644
--- a/packages/plugin-registry/r/icon-extended.json
+++ b/packages/plugin-registry/r/icon-extended.json
@@ -14,7 +14,7 @@
"files": [
{
"path": "registry/components/ui/icon-extended.tsx",
- "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\n// Extended glyph registry: every named icon the shell does not need before\n// first paint. `./icon` keeps only the core map on the boot path; this module\n// publishes the rest into the registry when it evaluates. Route chunks that\n// render extended icons import it statically (so their icons never flash), and\n// `Icon` loads it on demand for anything else.\n\n// The free hugeicons set ships no artist-palette glyph (its `Palette` export\n// is a pen nib), so this inlines the stroke-rounded palette artwork in the\n// same element format the set uses.\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n NewTab: DashedLine02Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n",
+ "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiBrowserIcon,\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\n// Extended glyph registry: every named icon the shell does not need before\n// first paint. `./icon` keeps only the core map on the boot path; this module\n// publishes the rest into the registry when it evaluates. Route chunks that\n// render extended icons import it statically (so their icons never flash), and\n// `Icon` loads it on demand for anything else.\n\n// The free hugeicons set ships no artist-palette glyph (its `Palette` export\n// is a pen nib), so this inlines the stroke-rounded palette artwork in the\n// same element format the set uses.\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n NewTab: DashedLine02Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n",
"type": "registry:ui",
"target": "components/ui/icon-extended.tsx"
}
diff --git a/packages/plugin-registry/r/icon-registry.json b/packages/plugin-registry/r/icon-registry.json
index ab7caad2f4..16cfb56230 100644
--- a/packages/plugin-registry/r/icon-registry.json
+++ b/packages/plugin-registry/r/icon-registry.json
@@ -10,7 +10,7 @@
"files": [
{
"path": "registry/components/ui/icon-registry.ts",
- "content": "import type { IconSvgElement } from \"@hugeicons/react\";\n\n/**\n * Names of the glyphs that live in the lazily loaded extended registry\n * (`./icon-extended`). Only this list of strings is on the boot path; the\n * artwork itself loads with the first route that renders one of these icons\n * or, as a fallback, on first request from `Icon`.\n *\n * `./icon-extended` must map every name here and nothing else; the compiler\n * enforces that through `Record`.\n */\nexport const EXTENDED_ICON_NAMES = [\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DateTime\",\n \"Github\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"NewTab\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\n/**\n * Publishes the extended glyph map. Called by `./icon-extended` when it\n * evaluates, so any chunk that statically imports that module makes every\n * extended icon render synchronously; `Icon` instances that were showing a\n * placeholder re-render through {@link subscribeExtendedIcons}.\n */\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\n/** The extended glyph map, or null until `./icon-extended` has evaluated. */\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\n/** `useSyncExternalStore`-shaped subscription to {@link getExtendedIcons}. */\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n",
+ "content": "import type { IconSvgElement } from \"@hugeicons/react\";\n\n/**\n * Names of the glyphs that live in the lazily loaded extended registry\n * (`./icon-extended`). Only this list of strings is on the boot path; the\n * artwork itself loads with the first route that renders one of these icons\n * or, as a fallback, on first request from `Icon`.\n *\n * `./icon-extended` must map every name here and nothing else; the compiler\n * enforces that through `Record`.\n */\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DateTime\",\n \"Github\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"NewTab\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\n/**\n * Publishes the extended glyph map. Called by `./icon-extended` when it\n * evaluates, so any chunk that statically imports that module makes every\n * extended icon render synchronously; `Icon` instances that were showing a\n * placeholder re-render through {@link subscribeExtendedIcons}.\n */\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\n/** The extended glyph map, or null until `./icon-extended` has evaluated. */\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\n/** `useSyncExternalStore`-shaped subscription to {@link getExtendedIcons}. */\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n",
"type": "registry:ui",
"target": "components/ui/icon-registry.ts"
}
diff --git a/packages/shared-ui/src/components/ui/icon-extended.tsx b/packages/shared-ui/src/components/ui/icon-extended.tsx
index e99bca936a..8f1c980774 100644
--- a/packages/shared-ui/src/components/ui/icon-extended.tsx
+++ b/packages/shared-ui/src/components/ui/icon-extended.tsx
@@ -1,5 +1,6 @@
import type { IconSvgElement } from "@hugeicons/react";
import {
+ AiBrowserIcon,
AiContentGenerator01Icon,
ArrowDown02Icon,
ArrowDownDoubleIcon,
@@ -188,6 +189,7 @@ const PaletteStrokeRoundedIcon: IconSvgElement = [
];
export const EXTENDED_ICON_MAP: ExtendedIconMap = {
+ AiBrowser: AiBrowserIcon,
AiContentGenerator01: AiContentGenerator01Icon,
AlignLeft: Menu02Icon,
AppWindow: BrowserIcon,
diff --git a/packages/shared-ui/src/components/ui/icon-registry.ts b/packages/shared-ui/src/components/ui/icon-registry.ts
index 926e286572..ef2e3338f7 100644
--- a/packages/shared-ui/src/components/ui/icon-registry.ts
+++ b/packages/shared-ui/src/components/ui/icon-registry.ts
@@ -10,6 +10,7 @@ import type { IconSvgElement } from "@hugeicons/react";
* enforces that through `Record`.
*/
export const EXTENDED_ICON_NAMES = [
+ "AiBrowser",
"AiContentGenerator01",
"AlignLeft",
"AppWindow",
diff --git a/plugins/plugin-api-docs/app.tsx b/plugins/plugin-api-docs/app.tsx
new file mode 100644
index 0000000000..203daf97eb
--- /dev/null
+++ b/plugins/plugin-api-docs/app.tsx
@@ -0,0 +1,118 @@
+// bb-plugin-plugin-api-docs frontend.
+//
+// The plugin API docs, inside bb. It renders the same product map the docs
+// site does, one annotated surface fixture at a time, from the shared
+// @bb/plugin-api-map package, so the two can never disagree about what bb can
+// be extended with. Composer illustrations are deterministic fixtures, so
+// globally installed plugins cannot rewrite the Guide's example UI.
+//
+// One surface, which the map itself documents: `navPanel`, the map as its own
+// full-window page in the sidebar. The fixtures want the whole window, so
+// there is deliberately no thread-panel tab.
+import {
+ copyPluginSurfaceAgentReference,
+ firstPartyPluginId,
+ ProductMap,
+} from "@bb/plugin-api-map";
+import { useCallback, useEffect, useState } from "react";
+import { definePluginApp, useBbNavigate } from "@get-bb/plugin-sdk/app";
+
+/**
+ * The plugin ids this bb can actually open a page for: the ones installed on
+ * this machine, plus the ones its catalog lists. A built-in that is neither
+ * (an uninstalled provider, say) has no page, and linking to it would land on
+ * "Plugin not found" — so those names stay plain text.
+ */
+function useResolvablePluginIds(): ReadonlySet | null {
+ const [ids, setIds] = useState | null>(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ const read = async (url: string, pick: (row: never) => string) => {
+ try {
+ const response = await fetch(url, { signal: controller.signal });
+ if (!response.ok) return [];
+ const body = (await response.json()) as unknown;
+ const rows = Array.isArray(body)
+ ? body
+ : ((body as { plugins?: unknown[]; results?: unknown[] }).plugins ??
+ (body as { results?: unknown[] }).results ??
+ []);
+ return rows.map((row) => pick(row as never)).filter(Boolean);
+ } catch {
+ return [];
+ }
+ };
+ void Promise.all([
+ read("/api/v1/plugins", (row: { id?: string }) => row.id ?? ""),
+ read(
+ "/api/v1/plugin-catalog/search?q=",
+ (row: { pluginId?: string }) => row.pluginId ?? "",
+ ),
+ ]).then(([installed, catalog]) => {
+ if (!controller.signal.aborted) {
+ setIds(new Set([...installed, ...catalog]));
+ }
+ });
+ return () => controller.abort();
+ }, []);
+ return ids;
+}
+
+function PluginApiMapPage({ subPath }: { subPath: string }) {
+ const resolvable = useResolvablePluginIds();
+ const bbNavigate = useBbNavigate();
+ const pluginPageHref = useCallback(
+ (displayName: string) => {
+ const id = firstPartyPluginId(displayName);
+ if (!id || !resolvable?.has(id)) return null;
+ return `/extensions/plugins/${id}`;
+ },
+ [resolvable],
+ );
+ // The slide lives in the panel's subPath (replace, not push), so history
+ // entries record which screen the reader was on: coming Back from a plugin
+ // detail page reopens the guide on that slide instead of the first one.
+ const onSlideChange = useCallback(
+ (slideId: string) => {
+ bbNavigate.toPluginPanel("plugin-api", {
+ subPath: slideId,
+ replace: true,
+ });
+ },
+ [bbNavigate],
+ );
+ return (
+ // The page owns its scrolling: the host's nav-panel region is a clipped
+ // flex column, so without this the part of the page below the fold (the
+ // detail card, on a short window) is cut off rather than scrollable.
+ // Full width, no reading-column cap: the fixture and its in-flow card use
+ // the available panel width before either needs to scroll.
+ //
+ // data-guide-stage-viewport + the container declaration are ProductMap's
+ // height contract: fixtures derive their scale from this scrollport's
+ // height, and the stage-to-card gap grows with it (3cqh within its
+ // clamp). Without a declared container the map keeps its 8px gap floor
+ // and width-only scaling.
+
+ );
+}
+
+export default definePluginApp((app) => {
+ app.slots.navPanel({
+ id: "plugin-api",
+ title: "Plugin Guide",
+ icon: "Puzzle",
+ path: "plugin-api",
+ component: PluginApiMapPage,
+ });
+});
diff --git a/plugins/plugin-api-docs/icons/ai-generative.svg b/plugins/plugin-api-docs/icons/ai-generative.svg
new file mode 100644
index 0000000000..e01edd694e
--- /dev/null
+++ b/plugins/plugin-api-docs/icons/ai-generative.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/plugins/plugin-api-docs/package.json b/plugins/plugin-api-docs/package.json
new file mode 100644
index 0000000000..9b38d77935
--- /dev/null
+++ b/plugins/plugin-api-docs/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "bb-plugin-plugin-api-docs",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Browse the bb plugin API as an annotated map of the product.",
+ "engines": {
+ "bb": ">=0.0",
+ "bbPluginSdk": ">=0.4.3"
+ },
+ "bb": {
+ "name": "Plugin Guide",
+ "description": "Browse the bb plugin API as an annotated map of the product.",
+ "branding": {
+ "icon": "./icons/ai-generative.svg"
+ },
+ "server": "./server.ts",
+ "app": "./app.tsx",
+ "skills": [
+ "skills"
+ ]
+ },
+ "keywords": [
+ "bb-plugin"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@bb/plugin-api-map": "workspace:*"
+ },
+ "devDependencies": {
+ "@get-bb/plugin-sdk": "workspace:*",
+ "@types/react": "^19.0.0",
+ "react": "^19.0.0",
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
+ "typescript-7": "npm:typescript@^7.0.2"
+ }
+}
diff --git a/plugins/plugin-api-docs/server.ts b/plugins/plugin-api-docs/server.ts
new file mode 100644
index 0000000000..4291444395
--- /dev/null
+++ b/plugins/plugin-api-docs/server.ts
@@ -0,0 +1,26 @@
+// bb-plugin-plugin-api-docs backend.
+//
+// Surface references copied from the frontend resolve here at message-send
+// time. The provider deliberately returns no search rows: references originate
+// on annotation cards and should not add a second Plugin Guide UI to typeahead.
+import type { BbPluginApi } from "@get-bb/plugin-sdk";
+import {
+ PLUGIN_GUIDE_SURFACE_PROVIDER_ID,
+ pluginSurfaceAgentContext,
+} from "@bb/plugin-api-map/agent-reference";
+
+export default function plugin(bb: BbPluginApi) {
+ bb.ui.registerMentionProvider({
+ id: PLUGIN_GUIDE_SURFACE_PROVIDER_ID,
+ label: "Plugin Guide",
+ search: () => [],
+ resolve(surfaceId) {
+ const context = pluginSurfaceAgentContext(surfaceId);
+ if (context === null) {
+ throw new Error(`Unknown Plugin Guide surface: ${surfaceId}`);
+ }
+ return { context };
+ },
+ });
+ bb.log.debug("plugin API docs loaded");
+}
diff --git a/plugins/plugin-api-docs/skills/plugin-guide-maintenance/SKILL.md b/plugins/plugin-api-docs/skills/plugin-guide-maintenance/SKILL.md
new file mode 100644
index 0000000000..e8f31a862a
--- /dev/null
+++ b/plugins/plugin-api-docs/skills/plugin-guide-maintenance/SKILL.md
@@ -0,0 +1,408 @@
+---
+name: plugin-guide-maintenance
+description: Keep the Plugin Guide synchronized with public @get-bb/plugin-sdk APIs and the live bb surfaces that host them. Use whenever adding, changing, stabilizing, renaming, or removing a public Plugin SDK export, BbPluginApi member, app slot, composer API, provider bridge API, host API, or testing API; when adding or auditing a Guide surface/card/fixture; when correcting fixture fidelity, annotation placement, interaction realism, or responsive layout; or when the Plugin Guide API inventory or UI-anatomy tests fail.
+---
+
+# Maintain the Plugin Guide
+
+The Plugin Guide is bb's only public Plugin SDK documentation. Update it in
+the same change as every public API delta and every source-UI change that makes
+an existing Guide representation inaccurate. Do not refresh only the SDK
+inventory or redraw from memory.
+
+## Terminology and ownership
+
+Call the Guide representation a **surface fixture**: a deterministic,
+interactive documentation state derived from the real bb surface. “Skeleton”
+means a loading placeholder in bb, and “wireframe” implies a provisional
+design. `packages/plugin-api-map/src/wireframes.tsx` keeps its legacy filename
+and component names, but new copy, comments, tests, and reports use “surface
+fixture.”
+
+The real app owns structure, placement, states, labels, roles, ordering, and
+product styling. The Guide owns realistic example content, annotation badges,
+highlight rings, and replay controls. Guide-owned layers may clarify the
+surface but cannot move, restyle, or invent host behavior.
+
+## 1. Establish the authoritative source
+
+For an SDK delta, build the portable declarations and inspect the changed
+source contracts:
+
+```sh
+pnpm exec turbo run build:types --filter=@get-bb/plugin-sdk
+git diff -- packages/plugin-sdk/package.json packages/plugin-sdk/src
+```
+
+For every visual surface, trace all three owners before authoring the fixture:
+
+1. the app component that paints the host surface;
+2. the slot/collector or adapter that inserts the plugin contribution;
+3. the closest focused app test or story that establishes its real states.
+
+Record repo-relative source paths and stable source anchors in
+`packages/plugin-api-map/src/anatomy-manifest.json`. Prefer accessible labels,
+roles, data attributes, shared class constants, and state names as anchors;
+avoid line numbers and generated bundles. The app-side
+`docs-anatomy-manifest.test.tsx` makes stale anchors fail instead of silently
+leaving the Guide behind.
+
+For a new public member, also enforce the repository contract:
+
+- name it with `experimental_` (or `Experimental` for a type);
+- add its audit entry to `docs/api_to_audit.md`;
+- preserve compatibility with released SDK consumers unless the user has
+ explicitly approved the exact break and migration.
+
+## 2. Generate the deterministic entry scaffold
+
+Run the scaffold command with observable behavior, not a preferred visual
+style. Repeat `--source` and `--api-symbol` as needed:
+
+```sh
+pnpm exec turbo run scaffold:surface-entry --filter=@bb/plugin-api-map -- \
+ --id command-palette-actions \
+ --title "Command palette actions" \
+ --group command-palette \
+ --source apps/app/src/components/commands/CommandPalette.tsx \
+ --source apps/app/src/lib/command-palette/palette-plugin-actions.ts \
+ --source apps/app/src/components/commands/CommandPalette.test.tsx \
+ --api-symbol PluginCommandPaletteActionRegistration \
+ --api-symbol PluginCommandPaletteActionContext \
+ --transient \
+ --outcome
+```
+
+The command is read-only and prints a stable JSON scaffold. Argument order,
+duplicate source paths, and duplicate symbols cannot change the result. Use
+the scaffold to start the card and fixture contract; replace every `TODO`
+with observed product behavior before considering the entry complete.
+
+### Deterministic fidelity level
+
+Derive fidelity; do not choose it. The generator computes the minimum honest
+level from spatial ownership and observable behavior. The highest applicable
+rule wins:
+
+| Level | Deterministic rule | Fixture obligation |
+| -------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| `none` | The capability has no meaningful spatial owner (`--no-spatial-owner`). | No pixels or marker; document it in the Plugin backend group. |
+| `anchor` | It is visible in a stable default state and has no visible outcome. | Show the exact insertion point, owning host chrome, and nearest adjacent controls. |
+| `state` | It exists only after hover, focus, selection, a menu/dialog, loading, empty, error, or another transient state (`--transient`). | Include `anchor`, then show the real trigger and one reachable canonical state. |
+| `flow` | Activation changes host state/navigation (`--outcome`) or replaces host-rendered content (`--replacement`). | Include `state`, then make activation reach the visible plugin outcome; show fallback/original ownership for replacements. |
+
+This is a floor, not a score. Do not lower fidelity to save space. If several
+surfaces share one page, the page must support each surface's required state;
+use focused nested states rather than an impossible composite where mutually
+exclusive menus or selections appear together.
+
+Every visual level also requires:
+
+- exact user-facing labels, accessible roles, item order, selected/pressed
+ semantics, and product token classes from the source;
+- realistic safe content at the source surface's normal density;
+- responsive geometry that preserves the source's ownership boundaries;
+- working visible controls whose state matches their outcome;
+- Guide annotations in a separate top layer that does not obscure targets.
+
+### Surface-fixture composition contract
+
+Start with the closest real product surface, then preserve its ownership,
+scale, density, and object identity. A fixture teaches where a plugin enters
+bb; it should be recognizable without the annotation card explaining the
+scene.
+
+Use this page audit as the minimum credible anatomy:
+
+| Guide page or fixture | Required representation |
+| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| The bb app window | Keep the sidebar, thread, timeline, composer boundary, and side panel at readable product proportions. Loaded content and its loading skeleton must use aligned height and vertical spacing. |
+| Command palette or another whole-window overlay | Give a different owning host surface its own dedicated page. Show the trigger or shortcut, dimmed host context, realistic neighboring results, the selected plugin result, and its reachable outcome. |
+| Composer | Seat it in the thread or home chrome that owns it. Show menus in their source placement direction and show hover, selection, or locking only in the state that triggers it. |
+| File viewer or editor | Preserve the canonical product object: use a real file path, file icon, and filename in the tab, then render the plugin's custom viewer or editor in its body. |
+| Code or diff renderer | Put the entry point on the real Diff tab and show a credible filename, hunk header, line numbers, context, additions, and removals rather than generic placeholder bars. |
+| Home, Settings, or Extensions | Preserve the source page chrome, section order, and the exact position where bb inserts the plugin-owned content. |
+| Plugin backend | Render a reflowing capability grid with no numbered marker. Do not invent pixels for a headless API. |
+
+“Renders no UI of its own” is not sufficient to choose `none`. When a
+capability has a visible host scope—an app-wide script runs across the bb
+window, for example—use `anchor` and annotate the owning host boundary without
+inventing a plugin control. Reserve `none` for capabilities with no meaningful
+spatial owner to teach.
+
+Draw partial ownership precisely. When bb keeps a host-owned wrapper or header
+and the plugin supplies only the body beneath it, retain the host chrome and
+annotate only the plugin-owned region. For a true replacement, show the
+replaced boundary and preserve any fallback or original ownership required by
+the API.
+
+Derive responsive behavior from fixture ownership; do not choose it per page.
+Every spatial fixture scales as one annotated composition. ProductMap measures
+the fixture's authored `scrollWidth`/`scrollHeight` and the consumer's
+declared scroll viewport (`data-guide-stage-viewport`), and applies exactly
+`min(MAX_FIXTURE_SCALE, availW / authoredW, availH / authoredH)` to the host
+anatomy, exterior chips, engaged rings, and menus together — shrinking under
+pressure and growing toward the legibility cap when the panel has room. The
+non-spatial capability grid is the only reflowing fixture. Never scale the
+annotation card or let an individual fixture choose a different responsive
+mode.
+
+The scaled fixture reserves exactly `authoredHeight * scale` in normal flow,
+whether shrunken or grown. It must have no horizontal scrollbar, clipped
+content, or page-level inline overflow at any width; require
+`scrollWidth <= clientWidth + 1` on its outer frame after settling.
+Off-stage carousel pages must not contribute inline overflow to the Guide
+page. Clip the carousel's inline axis while leaving its block axis available
+to real menus that escape downward.
+
+The page selector is the sole narrow-width horizontal scroll owner. Its
+carets hug the label strip: the caret+labels group shrink-wraps and centers
+as one unit, and the carets only reach the row's edges when the labels
+genuinely overflow. Horizontally reveal the active label after arrow, click,
+or linked navigation.
+
+The carousel item owns the available width and must be shrinkable before a
+spatial fixture measures itself. Put `min-width: 0` on that item; never let an
+authored fixture minimum inflate the measurement frame and turn a narrow pane
+into a false `scale=1` result.
+
+Each fixture declares one authored geometry — a single minimum height and, for
+non-fluid fixtures, one width floor with one owner — and never encodes the app
+chrome around it (no `100dvh` arithmetic). Vertical fit at every panel size
+is the scale formula's job: the desktop app-window fixture and every open
+annotation card stay reachable in the 980px-tall plugin content region left by
+a 2048 by 1080 bb window, with shorter viewports scrolling vertically and
+taller ones growing the fixture toward the cap. Preserve product-control
+density; blank canvas bounds are minimums, not fixed heights, and real content
+may grow beyond them without being clipped.
+
+Never reflow a spatial fixture into anatomy bb does not have, scale any part of
+it independently, or add blank canvas to match the tallest page.
+The active carousel stage follows the active fixture's height; off-stage pages
+are inert and clipped without clipping a live menu that legitimately escapes
+the active fixture.
+
+Fixtures use deterministic, safe example content. Isolate them from installed
+plugin customizations so a reader's local plugins cannot add controls, rewrite
+copy, or move annotations. If the Guide embeds a real host component, make it
+inert when interacting with it would open unrelated host UI over the lesson.
+
+### Annotation placement decision table
+
+Annotations are Guide-owned reading controls placed beside the host UI. Treat
+their placement and behavior as a deterministic contract rather than a final
+pixel nudge:
+
+| Target shape | Placement rule |
+| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| Stable internal region | Put the badge beside an outside corner of the target, clear of its icon, label, selection, and hit area. |
+| Target on a fixture's outer edge | Put the badge in an exterior Guide-owned gutter inside the same scale-together wrapper, not inside the host region. |
+| Tab or action on a clipped top edge | Put the badge above the actual tab or action in a top-layer sibling. Do not make the product row taller or add host padding to create room. |
+| Target inside a scroll container, menu, palette, or popover | Put the badge outside the clipping subtree, then align it back to the target from the Guide layer. `z-index` alone cannot escape clipping. |
+| Target nested inside another annotated region | Keep the target region and its badge as sibling overlays. Never nest interactive annotation anchors. |
+
+### Annotation quality contract
+
+- Attach each badge to the actual entry point a plugin author would use. A tab
+ surface is annotated on its real tab, a message action on its real action,
+ and a composer contribution on its real control or content. Selecting the
+ annotation reveals that entry point's corresponding state or tab body.
+- Annotation space is Guide-owned and must not change the host surface's geometry,
+ including its width, padding, row alignment, or item density. A chip's
+ position is never an authored coordinate: an in-target chip declares one of
+ the shared placement variants (`corner`, `corner-inset`, `side`,
+ `outside-above`), and a chip that cannot live inside its target's clipping
+ subtree is a measured badge (`start`/`end` gutter columns, `above`, or the
+ `lane` above the frame) that derives its place from the element it
+ annotates. The badge must stay fully visible inside the fixture and
+ viewport, clear of every clipping ancestor. A high `z-index` prevents
+ occlusion only after the badge escapes clipping; it cannot repair geometry
+ that leaves the badge inside a scroll container or puts half of it beyond a
+ clipped boundary.
+- Keep the badge clear of the entry point's icon, label, selection, and hit
+ target. Keep every transient menu, palette, popover, or toolbar clear of both
+ its annotation and annotated target, and anchor a transient to the element
+ the real product flips it against — never to an authored offset. If a
+ placement collides, change the declared variant; do not add a bespoke
+ coordinate.
+- Keep annotations on each page sequential by annotation number. Previous and
+ next controls use that page order, including first and last disabled states,
+ so readers never have to hunt across the fixture.
+- Derive that sequence from the host's visual scan order: owning regions from
+ left to right, then controls within a region from top to bottom. In a panel
+ tab row, preserve fixed host tabs before scrollable content tabs. Put a
+ whole-window boundary last in an exterior end or bottom gutter; do not place
+ its final number above an earlier interior target.
+- Keep the badge/card and demonstrated action as separate interaction targets.
+ Clicking a badge opens or pans the Guide card without running the product
+ action; clicking the host action changes only the reachable fixture state.
+- When annotated regions overlap or contain one another, show one active
+ annotation outline at a time. The selected badge may stay lit, but hovering
+ a nested target must not leave the parent ring active underneath it.
+- Show only states that a user can actually reach. Hover, selection, menu, tab,
+ and outcome states may replace one another; do not display mutually
+ exclusive states as a convenient composite.
+
+Run the committed relationship sweep after any fixture, annotation, or layout
+change: `scripts/verify-guide-chrome.mjs` (in this skill's directory) drives
+Chrome for Testing against the running dev app across the four viewport
+classes, discovers every rendered annotation, reconciles it against the
+declared inventory, and asserts the relationships above — badge bounds and
+hit-tests, engaged rings on targets, transient clearances, caret adjacency,
+scale bounds with zero page overflow, the gap clamp, and the wide-viewport
+fill gate. Extend the sweep when a rule is added; never replace a sweep
+assertion with an exact authored pixel.
+
+Verify these rules at every required viewport with rendered geometry, not
+class names alone. Record the badge, target-content, transient-surface,
+fixture, and viewport bounding rectangles. The badge rectangle must be fully
+contained, the badge and target-content rectangles must not intersect, and a
+visible transient surface must intersect neither. Use at least 4 CSS pixels of
+clearance between adjacent rectangles in the fixture's authored CSS coordinate
+space (divide rendered distances by the uniform fixture scale), then inspect
+the screenshot because a
+border, shadow, or rounded edge can still visually cut through a technically
+non-intersecting box. Hit-test the badge center with `elementFromPoint`; it must
+resolve to the badge or one of its descendants, proving the badge is actually
+in the top visual layer rather than merely having an unclipped rectangle.
+
+### Interaction and state contract
+
+- Make every visible control behaviorally honest: clicking it changes the
+ fixture to the state its pressed, selected, or active styling promises.
+- A transient contribution starts from its real trigger. Reveal a message
+ action row on message hover, a selection toolbar only after activation, and
+ a menu only while its owning annotation or trigger is engaged.
+- Reserve its footprint when a hover-only row appears so messages, timeline
+ entries, and controls below it do not reflow under the pointer.
+- Place menus and typeaheads in the source placement direction and keep them
+ clear of both badges and targets. Move the Guide annotation layer before
+ changing the host menu's dimensions, alignment, or padding.
+- Use a visually distinct selection for source state such as selected message
+ text. It must remain distinguishable from the annotation highlight and from
+ the menu's selected row.
+- When an annotation represents a tab, selecting it also selects that tab and
+ changes the tab body. Card Previous/Next navigation must produce the same
+ synchronized fixture state as clicking the badge directly.
+- For a `flow` fixture, demonstrate the complete causal chain: entry point,
+ trigger, selected or open state, action, and visible plugin outcome. Keep
+ mutually exclusive states separate instead of composing a convenient fake.
+
+### Page, card, and reference contract
+
+- Give a surface a dedicated page when it belongs to a different owning host
+ surface, overlay, or user job. Do not hide a command palette, file tab, or
+ side-panel capability inside a nearby but inaccurate fixture.
+- Keep page tabs and annotations in authored numeric order. Page tabs are one
+ horizontally scrolling, non-wrapping row; fixtures never inherit that
+ overflow. Render both page-panning arrows outside the scroller so navigation
+ geometry stays stable; disable the missing direction at the first and last
+ pages rather than removing its control.
+- Open the annotation card in normal flow below the fixture so it never covers
+ the entry point. Panning pages closes the old card; following a cross-page
+ reference lands on the destination before opening its card.
+- Use one Guide-owned gap between a fixture and its card: the card wrapper
+ owns `clamp(8px, 3cqh, 28px)`, derived from the consumer's declared
+ container and floored at 8 CSS pixels without one. The active carousel slide
+ and the fixture itself add no block-end spacing, so stacked padding cannot
+ manufacture page overflow.
+- Keep Previous and Next annotation controls compact in the card header. Both
+ remain visible, navigate the current page's numeric order, and expose a
+ disabled endpoint rather than wrapping to another page.
+- For every new or touched card, the title names the visible product object or outcome,
+ not the SDK mechanism that implements it. The lead starts with an active verb,
+ names the visible location or result, and stands alone before the capability
+ list. Do not explain a current surface through an absent or hypothetical
+ object (for example, “a thread that does not exist yet”), and do not use
+ `renderer`, `registration`, `slot`, `normalized`, or `declarative` unless that
+ word is visible in bb's own UI.
+- Author every card as one complete lead ending in
+ `. With this, a plugin can:`, followed by at least two bare verb-phrase
+ bullets. Keep tutorials in the authoring skill or SDK guidance, not cards.
+- Derive **Copy for agent** from the canonical `PluginSurface` record through
+ `createPluginSurfaceAgentReference`; do not author any clipboard or context
+ field separately. The provider is exactly `surface`, the reference id is
+ exactly `surface.id`, the label is exactly `surface.title`, the plugin id is
+ exactly `plugin-api-docs`, and the item id is exactly
+ `surface:`. The framing is exactly `Build a plugin that uses `
+ before the pill and `. ` after it. The pill's send-time context already
+ points at the Plugin Guide and authoring skill, so visible clipboard prose
+ never repeats that implementation pointer.
+- Resolve exactly three context lines: surface title plus id; the surface's
+ `apiSymbols`; then a pointer to the `bb-plugin-authoring` skill and the
+ authoritative `@get-bb/plugin-sdk` declarations. Do not include card
+ summaries, bullets, tutorials, timestamps, random ids, or current fixture
+ state. Equal canonical surface data must produce byte-identical clipboard
+ and context output.
+- Keep each pasted reference as its own mention node and preserve paste order.
+ Distinct surface ids produce distinct item ids; never merge or deduplicate
+ several pills into one prose blob. This keeps multiple references distinct
+ and composable in one agent request.
+
+## 3. Complete the Plugin Guide entry
+
+Edit `packages/plugin-api-map/src/surfaces.ts`.
+
+- Put the API on the existing card that represents where or why authors use
+ it. Create a card only for a genuinely new product surface.
+- Keep the generated `id` stable. Surface ids persist in “Copy for agent”
+ references; never rename or reuse one casually.
+- Add exact exported names to `apiSymbols`.
+- Replace scaffold copy with one concise capability lead and observable
+ capability bullets. Link related surfaces instead of repeating tutorials.
+- Add `firstParty` only for maintained in-repo plugins that exercise the API.
+
+For a visual entry, implement the fixture in the legacy
+`packages/plugin-api-map/src/wireframes.tsx` module at the generated fidelity
+level. Add its marker to the matching `*_MARKS` array and its source/fidelity
+contract to `anatomy-manifest.json`. Render repeated ordered anatomy from that
+manifest. For a backend-only entry, place it in the Plugin backend group and
+do not invent a fixture.
+
+Add focused coverage for:
+
+- surface/card/marker coverage and stable ordering;
+- every state required by the fidelity level;
+- the real source anchors and the fixture's matching structure, naming, and
+ style anchors;
+- the actual trigger and outcome for `state` and `flow` fixtures;
+- the annotation quality contract, including target ownership, stable page
+ order, separate badge/action behavior, and browser bounding-rectangle gates.
+
+## 4. Refresh the exhaustive SDK inventory
+
+Only after the Guide represents the contract delta, update the canonical
+comment-free declaration hashes:
+
+```sh
+pnpm exec turbo run update:sdk-inventory --filter=@bb/plugin-api-map
+```
+
+Review `packages/plugin-api-map/sdk-public-api.json`. A new package export must
+appear as a new key; a changed hash must correspond to the documented contract
+delta. Do not hand-edit hashes. Skip this step when no public SDK declaration
+changed.
+
+## 5. Verify the complete workflow
+
+```sh
+pnpm exec turbo run test typecheck \
+ --filter=@get-bb/plugin-sdk \
+ --filter=@bb/plugin-api-map \
+ --filter=@bb/app \
+ --filter=bb-plugin-plugin-api-docs
+bb plugin build plugins/plugin-api-docs
+```
+
+For user-visible fixture/card changes, launch the exact branch web app with
+`scripts/bb-dev-app current`. Verify the affected page from first paint through
+every required state, the annotation card, **Copy for agent**, and composer
+paste. Exercise Chrome for Testing and real Safari, then stop QA-only launchers.
+Use the desktop dev app only when the source behavior depends on Electron or
+native window chrome. Preserve every annotation-placement correction made in
+the task as a named visual checkpoint and reread the complete page screenshot;
+a close crop can hide the clipping or overlap the check is meant to catch.
+
+The CI packages shard runs `@bb/plugin-api-map#test`; the app shard runs the
+source-anchor/anatomy test. Together they fail when the SDK inventory, live
+surface contract, generated scaffold rules, or Guide fixture drifts.
diff --git a/plugins/plugin-api-docs/skills/plugin-guide-maintenance/scripts/verify-guide-chrome.mjs b/plugins/plugin-api-docs/skills/plugin-guide-maintenance/scripts/verify-guide-chrome.mjs
new file mode 100644
index 0000000000..74ff27180c
--- /dev/null
+++ b/plugins/plugin-api-docs/skills/plugin-guide-maintenance/scripts/verify-guide-chrome.mjs
@@ -0,0 +1,365 @@
+/**
+ * Rendered relationship sweep for the Plugin Guide, driven through Chrome for
+ * Testing over CDP against a running bb dev app.
+ *
+ * This is the placement gate for everything the static tests cannot see:
+ * measured badges, engaged rings, transient clearances, scale, and the
+ * stage-to-card rhythm. It asserts relationships — contained-in, adjacent-to,
+ * identical-box, non-overlapping, bounded — never exact authored pixels, and
+ * it discovers annotations from the rendered DOM, then reconciles them
+ * against the declared inventory so a missing annotation cannot pass
+ * silently.
+ *
+ * Usage:
+ * QA_ORIGIN=http://localhost: QA_OUTPUT_DIR=/tmp/guide-qa \
+ * node verify-guide-chrome.mjs
+ */
+import { spawn } from "node:child_process";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const outputDir = process.env.QA_OUTPUT_DIR;
+const candidate = process.env.QA_CANDIDATE ?? "working-tree";
+const origin = process.env.QA_ORIGIN ?? "http://localhost:38886";
+if (!outputDir) throw new Error("QA_OUTPUT_DIR is required");
+await mkdir(outputDir, { recursive: true });
+
+const PAGES = [
+ ["app-shell", "The bb app window"],
+ ["command-palette", "Command palette"],
+ ["composer", "The composer"],
+ ["home", "Home page"],
+ ["settings", "Plugin settings page"],
+ ["extensions", "Plugin page in Extensions"],
+ ["headless", "Plugin backend"],
+];
+const VIEWPORTS = [
+ { tag: "mobile", width: 390, height: 844 },
+ { tag: "narrow", width: 768, height: 900 },
+ { tag: "desktop", width: 1440, height: 900 },
+ { tag: "wide", width: 2030, height: 1100 },
+];
+
+const chrome = join(
+ process.env.HOME,
+ ".cache/chrome-for-testing/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+);
+const profile = await mkdtemp(join(outputDir, "guide-sweep-cft."));
+const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+const browser = spawn(
+ chrome,
+ [
+ "--headless=new",
+ "--use-mock-keychain",
+ "--remote-debugging-port=0",
+ `--user-data-dir=${profile}`,
+ "--no-first-run",
+ "--disable-gpu",
+ "about:blank",
+ ],
+ { stdio: "ignore" },
+);
+
+try {
+ let port = null;
+ for (let attempt = 0; attempt < 120; attempt += 1) {
+ try {
+ port = (await readFile(join(profile, "DevToolsActivePort"), "utf8"))
+ .split("\n")[0]
+ .trim();
+ if (port) break;
+ } catch {}
+ if (browser.exitCode !== null) throw new Error("Chrome exited early");
+ await delay(100);
+ }
+ const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then(
+ (response) => response.json(),
+ );
+ const socket = new WebSocket(
+ targets.find((entry) => entry.type === "page").webSocketDebuggerUrl,
+ );
+ await new Promise((resolve, reject) => {
+ socket.onopen = resolve;
+ socket.onerror = reject;
+ });
+ let sequence = 0;
+ const pending = new Map();
+ socket.onmessage = (event) => {
+ const message = JSON.parse(event.data);
+ if (message.id !== undefined && pending.has(message.id)) {
+ const entry = pending.get(message.id);
+ pending.delete(message.id);
+ if (message.error) entry.reject(new Error(message.error.message));
+ else entry.resolve(message.result);
+ }
+ };
+ const send = (method, params = {}) =>
+ new Promise((resolve, reject) => {
+ sequence += 1;
+ pending.set(sequence, { resolve, reject });
+ socket.send(JSON.stringify({ id: sequence, method, params }));
+ });
+ const evaluate = async (expression) => {
+ const result = await send("Runtime.evaluate", {
+ expression,
+ returnByValue: true,
+ awaitPromise: true,
+ });
+ if (result.exceptionDetails) {
+ throw new Error(JSON.stringify(result.exceptionDetails));
+ }
+ return result.result.value;
+ };
+ const waitFor = async (expression, label) => {
+ for (let attempt = 0; attempt < 200; attempt += 1) {
+ try {
+ if (await evaluate(expression)) return;
+ } catch {}
+ await delay(100);
+ }
+ throw new Error(`Timed out waiting for ${label}`);
+ };
+ const capture = async (name) => {
+ const path = `${outputDir}/${name}-${candidate}.png`;
+ const result = await send("Page.captureScreenshot", {
+ format: "png",
+ fromSurface: true,
+ });
+ await writeFile(path, Buffer.from(result.data, "base64"));
+ return path;
+ };
+ const click = async (selector) => {
+ const clicked = await evaluate(`(() => {
+ const element = document.querySelector(${JSON.stringify(selector)});
+ if (!element) return false;
+ element.click();
+ return true;
+ })()`);
+ if (!clicked) throw new Error(`Missing ${selector}`);
+ await delay(400);
+ };
+ const showPage = async (label, groupId) => {
+ const clicked = await evaluate(`(() => {
+ const button = [...document.querySelectorAll("button")].find(
+ (entry) => entry.textContent?.trim() === ${JSON.stringify(label)},
+ );
+ button?.click();
+ return !!button;
+ })()`);
+ if (!clicked) throw new Error(`Missing page ${label}`);
+ await waitFor(
+ `!!document.querySelector('[data-map-section=${groupId}]:not([inert])')`,
+ label,
+ );
+ await delay(450);
+ };
+
+ await send("Page.enable");
+ await send("Runtime.enable");
+
+ const failures = [];
+ const record = { candidate, viewports: [] };
+
+ for (const viewport of VIEWPORTS) {
+ await send("Emulation.setDeviceMetricsOverride", {
+ width: viewport.width,
+ height: viewport.height,
+ deviceScaleFactor: 1,
+ mobile: viewport.width < 700,
+ });
+ await send("Page.navigate", {
+ url: `${origin}/plugins/plugin-api-docs/plugin-api/app-shell`,
+ });
+ await waitFor(
+ 'document.readyState === "complete" && !!document.querySelector("[data-map-section=app-shell] [data-guide-responsive-strategy]")',
+ "Plugin Guide",
+ );
+ await delay(700);
+
+ const pages = [];
+ for (const [groupId, label] of PAGES) {
+ await showPage(label, groupId);
+ const page = await evaluate(`(() => {
+ const slide = document.querySelector('[data-map-section="${groupId}"]');
+ const rect = (element) => {
+ const value = element?.getBoundingClientRect();
+ return value
+ ? { left: value.left, top: value.top, right: value.right, bottom: value.bottom, width: value.width, height: value.height }
+ : null;
+ };
+ const frame = slide.querySelector("[data-guide-responsive-strategy]");
+ const scale = frame ? Number(frame.dataset.guideScale ?? "1") : 1;
+ const scroller = document.querySelector("[data-guide-page-list-scroll]");
+ const list = scroller?.firstElementChild;
+ const carets = [...document.querySelectorAll('button[aria-label$="surface"]')].map((entry) => rect(entry));
+ const badges = [...slide.querySelectorAll("[data-guide-badge]")]
+ .filter((entry) => entry.getBoundingClientRect().width > 0)
+ .map((entry) => {
+ const box = entry.getBoundingClientRect();
+ const hit = document.elementFromPoint(
+ (box.left + box.right) / 2,
+ (box.top + box.bottom) / 2,
+ );
+ return {
+ id: entry.dataset.guideBadge,
+ rect: rect(entry),
+ hit: entry === hit || entry.contains(hit),
+ };
+ });
+ return {
+ scale,
+ overflow: {
+ document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ scroller: scroller ? scroller.scrollWidth - scroller.clientWidth : 0,
+ scrollerIsOnlyOwner: !!scroller && getComputedStyle(scroller).overflowX === "auto",
+ },
+ caretAdjacency:
+ scroller && list && scroller.scrollWidth <= scroller.clientWidth + 1
+ ? Math.max(
+ rect(scroller).left - carets[0].right,
+ carets[1].left - rect(scroller).right,
+ )
+ : null,
+ slide: rect(slide),
+ badges,
+ };
+ })()`);
+
+ if (!(page.scale > 0 && page.scale <= 1.3001)) {
+ failures.push(`${viewport.tag}/${groupId}: scale ${page.scale} out of bounds`);
+ }
+ if (page.overflow.document > 1) {
+ failures.push(`${viewport.tag}/${groupId}: page overflows horizontally by ${page.overflow.document}px`);
+ }
+ if (!page.overflow.scrollerIsOnlyOwner) {
+ failures.push(`${viewport.tag}/${groupId}: page list is not the horizontal scroll owner`);
+ }
+ if (page.caretAdjacency !== null && page.caretAdjacency > 8.5) {
+ failures.push(`${viewport.tag}/${groupId}: caret ${page.caretAdjacency.toFixed(1)}px from label strip`);
+ }
+ for (const badge of page.badges) {
+ if (!badge.hit) {
+ failures.push(`${viewport.tag}/${groupId}: badge ${badge.id} is not hit-testable`);
+ }
+ if (
+ badge.rect.left < page.slide.left - 1 ||
+ badge.rect.right > page.slide.right + 1
+ ) {
+ failures.push(`${viewport.tag}/${groupId}: badge ${badge.id} escapes the slide bounds`);
+ }
+ }
+ pages.push({ groupId, ...page });
+ }
+
+ // Inventory reconciliation on the desktop pass: every declared annotation
+ // must have rendered somewhere, or coverage silently shrank.
+ if (viewport.tag === "desktop") {
+ const rendered = new Set(
+ pages.flatMap((page) => page.badges.map((badge) => badge.id)),
+ );
+ const declared = await evaluate(`(() => {
+ return [...document.querySelectorAll("[data-map-section] [data-guide-region], [data-map-section] [data-guide-badge]")]
+ .map((entry) => entry.dataset.guideRegion ?? entry.dataset.guideBadge);
+ })()`);
+ for (const id of new Set(declared)) {
+ if (!rendered.has(id)) {
+ failures.push(`desktop: declared surface ${id} rendered no badge`);
+ }
+ }
+ }
+
+ // Engaged-ring and gap relationships, exercised on the composer page.
+ await showPage("The composer", "composer");
+ for (const id of ["provider-picker", "composer-plus-menu", "composer-actions", "composer-state"]) {
+ await click(`[data-guide-badge="${id}"]`);
+ const engaged = await evaluate(`(() => {
+ const target = document.querySelector('[data-map-section="composer"] [data-guide-target="${id}"]');
+ const card = document.querySelector('[role="dialog"]');
+ const slide = document.querySelector('[data-map-section="composer"]');
+ const frame = slide.querySelector("[data-guide-responsive-strategy]");
+ const scale = Number(frame?.dataset.guideScale ?? "1") || 1;
+ const ringed = !!target && target.className.includes("ring-surface-selected-border");
+ // The gap contract applies to the in-flow card under the stage; a
+ // compact viewport may float the card as an overlay instead.
+ const inFlowCard = card?.closest('section[aria-roledescription="carousel"]')
+ ? card
+ : null;
+ const gap = inFlowCard && frame
+ ? (inFlowCard.getBoundingClientRect().top - slide.getBoundingClientRect().bottom)
+ : null;
+ const transient = document.querySelector('[data-guide-transient-for="${id}"]');
+ const badges = [...slide.querySelectorAll("[data-guide-badge]")]
+ .filter((entry) => entry.getBoundingClientRect().width > 0)
+ .map((entry) => ({ id: entry.dataset.guideBadge, box: entry.getBoundingClientRect().toJSON() }));
+ return {
+ ringed,
+ gap,
+ scale,
+ cardInViewport: inFlowCard
+ ? inFlowCard.getBoundingClientRect().bottom <= innerHeight + 1
+ : null,
+ scrollTop: document.querySelector("[data-guide-stage-viewport]")?.scrollTop ?? 0,
+ transient: transient ? transient.getBoundingClientRect().toJSON() : null,
+ badges,
+ };
+ })()`);
+ if (!engaged.ringed) {
+ failures.push(`${viewport.tag}/composer: engaged ${id} target carries no ring`);
+ }
+ if (engaged.gap !== null && (engaged.gap < 7 || engaged.gap > 29)) {
+ failures.push(`${viewport.tag}/composer: stage-to-card gap ${engaged.gap.toFixed(1)}px outside clamp`);
+ }
+ // The open card is part of the height budget: it must fit above the
+ // fold without scrolling the page chrome away (desktop classes; a
+ // compact viewport may float or scroll legitimately).
+ if (viewport.width >= 1000) {
+ if (engaged.cardInViewport === false) {
+ failures.push(`${viewport.tag}/composer: open ${id} card extends past the fold`);
+ }
+ if (engaged.scrollTop > 1) {
+ failures.push(`${viewport.tag}/composer: opening ${id} scrolled the page chrome away (${engaged.scrollTop}px)`);
+ }
+ }
+ if (engaged.transient) {
+ for (const badge of engaged.badges) {
+ const clear =
+ badge.box.right < engaged.transient.left - 4 * engaged.scale ||
+ badge.box.left > engaged.transient.right + 4 * engaged.scale ||
+ badge.box.bottom < engaged.transient.top - 4 * engaged.scale ||
+ badge.box.top > engaged.transient.bottom + 4 * engaged.scale;
+ if (!clear) {
+ failures.push(`${viewport.tag}/composer: transient for ${id} within 4px of badge ${badge.id}`);
+ }
+ }
+ }
+ await click('[role="dialog"] button[aria-label="Close"]');
+ }
+ record.viewports.push({ ...viewport, pages });
+ }
+
+ // Anti-squish gate: at the wide viewport, the app-shell fixture fills its
+ // content column instead of floating in margin.
+ const wide = record.viewports.find((entry) => entry.tag === "wide");
+ const wideShell = wide.pages.find((page) => page.groupId === "app-shell");
+ const fill = wideShell.slide.width > 0 ? wideShell.slide.width / wide.width : 0;
+ if (fill < 0.6) {
+ failures.push(`wide: app-shell slide fills only ${(fill * 100).toFixed(0)}% of the viewport`);
+ }
+
+ const screenshot = await capture("guide-sweep-final");
+ record.result = failures.length === 0 ? "PASS" : "FAIL";
+ record.failures = failures;
+ record.screenshot = screenshot;
+ await writeFile(
+ `${outputDir}/guide-sweep-${candidate}.json`,
+ JSON.stringify(record, null, 2),
+ );
+ console.log(JSON.stringify({ result: record.result, failures }, null, 2));
+ if (failures.length > 0) process.exitCode = 1;
+ socket.close();
+} finally {
+ if (browser.exitCode === null) browser.kill("SIGKILL");
+ await delay(200);
+ if (browser.exitCode === null) browser.kill("SIGKILL");
+ await rm(profile, { recursive: true, force: true });
+}
diff --git a/plugins/plugin-api-docs/tsconfig.json b/plugins/plugin-api-docs/tsconfig.json
new file mode 100644
index 0000000000..540492cf57
--- /dev/null
+++ b/plugins/plugin-api-docs/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM"],
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["app.tsx", "server.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e2625dbe64..2a55e52358 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1911,6 +1911,49 @@ importers:
specifier: npm:typescript@^7.0.2
version: typescript@7.0.2
+ packages/plugin-api-map:
+ dependencies:
+ '@hugeicons/core-free-icons':
+ specifier: ^4.1.3
+ version: 4.1.3
+ '@hugeicons/react':
+ specifier: ^1.1.6
+ version: 1.1.6(react@19.2.4)
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ tailwind-merge:
+ specifier: ^3.4.0
+ version: 3.4.0
+ devDependencies:
+ '@bb/tsconfig':
+ specifier: workspace:*
+ version: link:../tsconfig
+ '@types/node':
+ specifier: ^22.0.0
+ version: 22.19.10
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.2.13
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.2.13)
+ react:
+ specifier: ^19.0.0
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.2.4(react@19.2.4)
+ typescript:
+ specifier: npm:@typescript/typescript6@^6.0.2
+ version: '@typescript/typescript6@6.0.2'
+ typescript-7:
+ specifier: npm:typescript@^7.0.2
+ version: typescript@7.0.2
+ vitest:
+ specifier: ^4.1.1
+ version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+
packages/plugin-build:
dependencies:
'@bb/domain':
@@ -3318,6 +3361,28 @@ importers:
specifier: ^4.1.1
version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))
+ plugins/plugin-api-docs:
+ dependencies:
+ '@bb/plugin-api-map':
+ specifier: workspace:*
+ version: link:../../packages/plugin-api-map
+ devDependencies:
+ '@get-bb/plugin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/plugin-sdk
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.2.13
+ react:
+ specifier: ^19.0.0
+ version: 19.2.4
+ typescript:
+ specifier: npm:@typescript/typescript6@^6.0.2
+ version: '@typescript/typescript6@6.0.2'
+ typescript-7:
+ specifier: npm:typescript@^7.0.2
+ version: typescript@7.0.2
+
plugins/plugin-api-tester:
devDependencies:
'@get-bb/plugin-sdk':
diff --git a/turbo.json b/turbo.json
index f677d8be98..d229f61d88 100644
--- a/turbo.json
+++ b/turbo.json
@@ -20,6 +20,46 @@
// bb:bundle-stats Vite plugin writes beside dist for the boot-payload
// budget check. Without it a cache hit would leave CI checking a stale
// file, or none at all.
+ // The docs' UI-anatomy manifest is the contract this test checks the real
+ // app components against, so a manifest edit must re-run it.
+ "@bb/app#test": {
+ "dependsOn": [
+ "//#ensure-native-modules",
+ "topo"
+ ],
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ "$TURBO_ROOT$/packages/plugin-api-map/src/anatomy-manifest.json",
+ "$TURBO_ROOT$/vitest.shared.ts"
+ ]
+ },
+ // The Plugin Guide's API inventory is checked against the SDK's committed
+ // declarations, so those files are inputs to this test.
+ "@bb/plugin-api-map#test": {
+ "dependsOn": [
+ "//#ensure-native-modules",
+ "@get-bb/plugin-sdk#build:types",
+ "topo"
+ ],
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ "$TURBO_ROOT$/packages/plugin-sdk/bundled-types/**",
+ "$TURBO_ROOT$/packages/plugin-sdk/package.json",
+ // wireframes.test.ts asserts fixture fidelity against the real app
+ // source, and maintenance-skill.test.ts reads the shipped skill, so
+ // both are inputs — otherwise edits to them cache-hit a stale pass.
+ "$TURBO_ROOT$/apps/app/src/views/thread-detail/ThreadDetailView.tsx",
+ "$TURBO_ROOT$/plugins/plugin-api-docs/**",
+ "$TURBO_ROOT$/vitest.shared.ts"
+ ]
+ },
+ "@bb/plugin-api-map#update:sdk-inventory": {
+ "dependsOn": ["@get-bb/plugin-sdk#build:types"],
+ "cache": false
+ },
+ "@bb/plugin-api-map#scaffold:surface-entry": {
+ "cache": false
+ },
"@bb/app#build": {
"dependsOn": ["topo"],
"inputs": [
@@ -667,6 +707,9 @@
"bb-plugin-composer-customization#typecheck": {
"dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"]
},
+ "bb-plugin-plugin-api-docs#typecheck": {
+ "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"]
+ },
"bb-plugin-echo-provider#typecheck": {
"dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"]
},