From d33be41b05c032c5929ad11e69716288876f3172 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 21 Aug 2026 12:03:56 -0700 Subject: [PATCH 1/9] feat(plugin-sdk): add reactive client appearance hook --- apps/app/src/components/AppToaster.tsx | 6 +- apps/app/src/lib/plugin-appearance.test.tsx | 75 +++++++++++++++ apps/app/src/lib/plugin-appearance.ts | 21 ++++ apps/app/src/lib/plugin-sdk-app-impl.tsx | 2 + docs/api_to_audit.md | 35 +++++++ packages/plugin-sdk/README.md | 11 ++- packages/plugin-sdk/src/app-contract.ts | 27 ++++++ packages/plugin-sdk/src/app.ts | 1 + .../testing/__tests__/app-harness.test.tsx | 48 ++++++++++ packages/plugin-sdk/src/testing/app.tsx | 95 +++++++++++++++++++ 10 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/lib/plugin-appearance.test.tsx create mode 100644 apps/app/src/lib/plugin-appearance.ts diff --git a/apps/app/src/components/AppToaster.tsx b/apps/app/src/components/AppToaster.tsx index 9e2c84f1b0..341f774c43 100644 --- a/apps/app/src/components/AppToaster.tsx +++ b/apps/app/src/components/AppToaster.tsx @@ -1,7 +1,7 @@ import { Toaster, type ToasterProps } from "sonner"; -import { usePreferredTheme } from "@/hooks/useTheme"; +import { experimental_useAppearance } from "@/lib/plugin-appearance"; export function AppToaster(props: ToasterProps) { - const theme = usePreferredTheme(); - return ; + const { colorMode } = experimental_useAppearance(); + return ; } diff --git a/apps/app/src/lib/plugin-appearance.test.tsx b/apps/app/src/lib/plugin-appearance.test.tsx new file mode 100644 index 0000000000..9781aa7f19 --- /dev/null +++ b/apps/app/src/lib/plugin-appearance.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { experimental_useAppearance } from "./plugin-appearance"; + +function installColorMode(initial: "light" | "dark"): { + setColorMode(mode: "light" | "dark"): void; +} { + let mode = initial; + const listeners = new Set(); + const mediaQuery: MediaQueryList = { + get matches() { + return mode === "dark"; + }, + media: "(prefers-color-scheme: dark)", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener( + _type: string, + listener: EventListenerOrEventListenerObject, + ) { + listeners.add(listener); + }, + removeEventListener( + _type: string, + listener: EventListenerOrEventListenerObject, + ) { + listeners.delete(listener); + }, + dispatchEvent(event) { + for (const listener of listeners) { + if (typeof listener === "function") listener.call(mediaQuery, event); + else listener.handleEvent(event); + } + return true; + }, + }; + window.matchMedia = vi.fn(() => mediaQuery); + return { + setColorMode(next) { + mode = next; + mediaQuery.dispatchEvent(new Event("change")); + }, + }; +} + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("experimental_useAppearance", () => { + it("maps preference and system changes to semantic client appearance", () => { + const system = installColorMode("dark"); + const { result } = renderHook(() => experimental_useAppearance()); + + expect(result.current.colorModePreference).toBe("system"); + expect(result.current.colorMode).toBe("dark"); + + act(() => result.current.setColorModePreference("light")); + expect(result.current.colorModePreference).toBe("light"); + expect(result.current.colorMode).toBe("light"); + + act(() => result.current.setColorModePreference("system")); + expect(result.current.colorModePreference).toBe("system"); + expect(result.current.colorMode).toBe("dark"); + + act(() => system.setColorMode("light")); + expect(result.current.colorModePreference).toBe("system"); + expect(result.current.colorMode).toBe("light"); + }); +}); diff --git a/apps/app/src/lib/plugin-appearance.ts b/apps/app/src/lib/plugin-appearance.ts new file mode 100644 index 0000000000..0abbba6a2d --- /dev/null +++ b/apps/app/src/lib/plugin-appearance.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import type { ExperimentalPluginAppearance } from "@get-bb/plugin-sdk"; +import { + setPreferredTheme, + usePreferredTheme, + useThemePreference, +} from "@/hooks/useTheme"; + +/** Host implementation of the plugin SDK's client appearance contract. */ +export function experimental_useAppearance(): ExperimentalPluginAppearance { + const colorMode = usePreferredTheme(); + const colorModePreference = useThemePreference(); + return useMemo( + () => ({ + colorMode, + colorModePreference, + setColorModePreference: setPreferredTheme, + }), + [colorMode, colorModePreference], + ); +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 09ee4ec6a3..445a19fd8d 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -16,6 +16,7 @@ import type { import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; import { useThreadTimelineNavigation } from "@/components/thread/timeline/ThreadTimelineNavigationContext"; import { definePluginApp } from "./plugin-app-definition"; +import { experimental_useAppearance } from "./plugin-appearance"; import { useBbContext, useBbNavigate, @@ -64,6 +65,7 @@ export const pluginSdkAppImplementation = { useRealtimeConnectionState, useRpc, useSettings, + experimental_useAppearance, // The host-owned components in the SDK (plugin design: deliberate // exception to §5.5) — stable product capabilities, not a UI kit. ThreadChat: PluginThreadChat, diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index a951160490..031f445bbe 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -5,6 +5,41 @@ entry here (see [AGENTS.md](../AGENTS.md), "Plugin API"). Dropping the prefix is the deliberate stabilization step: audit the entry, rename project-wide, and delete the entry in the same change. +## Client appearance (`experimental_useAppearance`) + +**What it does.** Gives plugin React components the current client's resolved +`"light" | "dark"` color mode, its selected `"light" | "dark" | "system"` +preference, and a setter for that client-local preference. The evidence is +[Monaco's private root-class observer](https://github.com/andrewkchan/bb-plugin-monaco/blob/f165b11328efbed70f904bb0e65d432d014c5950/app.tsx#L34-L47), +which must choose a non-CSS editor theme, and [Theme Toggle's private +local-storage/class manipulation](https://github.com/xMinor-1/bb-plugins/blob/3a6ef78555814fe63891eac72a145ca9c114e9a6/plugins/theme-toggle/app.tsx#L23-L74). +[AppToaster](../apps/app/src/components/AppToaster.tsx) is the first in-repo +consumer; the plugin SDK harness includes a representative appearance-control +fixture for external authors. + +The other Appearance-tagged releases did not justify widening the contract: +[Ayu](https://github.com/vburojevic/bb-plugin-ayu/blob/8881e00888854462fc8a7c68de386fef8229f8aa/package.json) +and [Tokyo Night](https://github.com/krehel/bb-plugin-tokyo-night/blob/a5234d1a72e1fa58f3826cb239acd485701f76fe/package.json) +are declarative `bb.themes` palettes, while [Fonts](https://github.com/gtramontina/bb-plugin-fonts/blob/d48637a2052b0336b8ac12476101a816c8b421de/client-runtime.ts#L104-L111) +reapplies typography CSS configuration. Those needs remain covered by theme +CSS variables/`bb.themes`, not this JavaScript mode API. + +The hook deliberately omits palette ids, palette CSS, resolved code-theme +files, favicon selection, and CSS tokens. Plugin styles already receive live +semantic CSS variables; theme-only plugins use `bb.themes`; server-owned +palette reads/writes use `bb.sdk.theme`; and BB's host-owned code renderers own +their code-theme assets. + +**Audit before stabilizing.** Confirm the `colorMode` / +`colorModePreference` names remain unambiguous beside BB's separate palette +concept; verify `system` reactivity across browser, desktop, remote, and +multi-window clients; confirm preference writes remain client-local and obey +the same persistence/cross-window behavior as Settings; measure adoption by a +second non-editor external plugin; and re-check that no stable JavaScript +consumer needs a palette-change revision before adding one. Keep the hook on +the existing shared app runtime so it adds no appearance subsystem or lazy +chunk to plugin bundles. + ## `experimental_buildBridgeToolCallContent` **What it does.** Converts a decoded bb tool-call response into the ordered diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 34a06d1218..d1ca991f7e 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -9,6 +9,15 @@ The authoritative contracts are the exported declarations in [`src/app-contract.ts`](src/app-contract.ts). Keep author-facing guidance in the built-in `bb-plugin-authoring` skill synchronized with those declarations. +## Client appearance + +`experimental_useAppearance()` exposes the current client's resolved +`colorMode`, its `colorModePreference`, and `setColorModePreference(...)`. +Use it when JavaScript must choose light/dark behavior that CSS cannot express, +such as a canvas or third-party editor theme. Do not use it to restyle ordinary +plugin UI: plugin CSS already inherits BB's live semantic variables, and +server-owned palette selection remains on `bb.sdk.theme`. + ## Composer customization Composer UI extensions register through `app.composer.customize(...)`. A @@ -155,7 +164,7 @@ await scripts.lifecycle.dispose(); `loadPluginApp` installs the runtime before a thunk import and validates all registrations. `mountPluginContentScripts` mirrors the host's ordered mount, rollback, independent per-window signal, and exact-once disposal. `renderSlot` supplies -RPC, realtime, settings, navigation, context, and scoped composer behavior, +RPC, realtime, appearance, settings, navigation, context, and scoped composer behavior, then returns Testing Library queries plus the same behavior/inspection/lifecycle split. Use a setup-file `installTestPluginRuntime()` only when a static app import is unavoidable. diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 4e27df3a30..1685750105 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1772,6 +1772,28 @@ export interface BbContext { threadId: string | null; } +/** + * The current client's semantic light/dark appearance. + * + * Palette CSS is deliberately absent: plugin styles already inherit BB's live + * CSS variables, while server-owned palette selection remains available from + * `bb.sdk.theme`. This contract is only for JavaScript consumers that must + * choose behavior which CSS cannot express (for example a canvas/editor theme) + * or offer the same client-local mode preference as BB. + * + * @experimental Audit before relying on this as a stable contract. + */ +export interface ExperimentalPluginAppearance { + /** The mode currently applied after resolving a `system` preference. */ + colorMode: "light" | "dark"; + /** This client's selected mode preference. */ + colorModePreference: "light" | "dark" | "system"; + /** Update this client's mode preference. */ + setColorModePreference( + preference: ExperimentalPluginAppearance["colorModePreference"], + ): void; +} + export interface BbNavigate { toThread(threadId: string): void; toProject(projectId: string): void; @@ -1846,6 +1868,11 @@ export interface PluginSdkApp { */ useRealtimeConnectionState(): PluginRealtimeConnectionState; useSettings(): PluginSettingsState; + /** + * Read or update this client's semantic light/dark appearance. + * Experimental: see docs/api_to_audit.md. + */ + experimental_useAppearance(): ExperimentalPluginAppearance; useBbContext(): BbContext; useBbNavigate(): BbNavigate; /** Select one of this plugin's eligible fixed tabs on the current surface. */ diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index d54e17f5e5..5e8b4e8091 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -63,6 +63,7 @@ export const useRpc = runtime.useRpc; export const useRealtime = runtime.useRealtime; export const useRealtimeConnectionState = runtime.useRealtimeConnectionState; export const useSettings = runtime.useSettings; +export const experimental_useAppearance = runtime.experimental_useAppearance; export const useBbContext = runtime.useBbContext; export const useBbNavigate = runtime.useBbNavigate; export const experimental_useAppPanel = runtime.experimental_useAppPanel; diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index e96822e13d..104b377ffa 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -28,6 +28,7 @@ const { experimental_ProviderModelPicker: ProviderModelPicker, experimental_PermissionModePicker: PermissionModePicker, experimental_useAppPanel, + experimental_useAppearance, experimental_useFixedTabTarget, ThreadChat, useBbNavigate, @@ -236,6 +237,23 @@ function RealtimeConnectionProbe() { return
Realtime: {state}
; } +function AppearanceProbe() { + const appearance = experimental_useAppearance(); + return ( +
+ + Appearance: {appearance.colorMode}/{appearance.colorModePreference} + + +
+ ); +} + function UrlNavigationProbe() { const navigate = useBbNavigate(); return ( @@ -1430,6 +1448,36 @@ describe("renderSlot", () => { await slot.findByText("Realtime: reconnecting"); }); + it("models semantic appearance preference writes and reactive host changes", async () => { + const defaultSlot = renderSlot({ component: AppearanceProbe }, {}); + expect(defaultSlot.getByText("Appearance: light/system")).toBeTruthy(); + defaultSlot.unmount(); + + const slot = renderSlot( + { component: AppearanceProbe }, + {}, + { + experimental_appearance: { + colorMode: "dark", + colorModePreference: "system", + }, + }, + ); + expect(slot.getByText("Appearance: dark/system")).toBeTruthy(); + + fireEvent.click(slot.getByRole("button", { name: "Use light" })); + expect(slot.getByText("Appearance: light/light")).toBeTruthy(); + expect(slot.inspection.experimental_appearancePreferenceCalls).toEqual([ + "light", + ]); + + await slot.behavior.experimental_setAppearance({ + colorMode: "dark", + colorModePreference: "system", + }); + expect(slot.getByText("Appearance: dark/system")).toBeTruthy(); + }); + it("refreshes rendered RPC data after a realtime event", async () => { let listing = ["a.md"]; const slot = renderSlot( diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index ef80eef3df..3b1778d2a0 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -61,6 +61,7 @@ import { type ExperimentalAppPanel, type ExperimentalFixedTabTargetState, type ExperimentalOpenFixedTabOptions, + type ExperimentalPluginAppearance, type ExperimentalPluginFixedTabReference, type NewThreadComposerProps, type ExperimentalPermissionModePickerProps, @@ -168,12 +169,27 @@ interface TestComposerStore { subscribe(listener: () => void): () => void; } +type ExperimentalAppearanceSnapshot = Pick< + ExperimentalPluginAppearance, + "colorMode" | "colorModePreference" +>; + +interface TestAppearanceStore { + getSnapshot(): ExperimentalAppearanceSnapshot; + setColorModePreference( + preference: ExperimentalPluginAppearance["colorModePreference"], + ): void; + setSnapshot(snapshot: ExperimentalAppearanceSnapshot): void; + subscribe(listener: () => void): () => void; +} + interface SlotEnv { rpcClient: PluginRpcClient; rpcCalls: RpcCall[]; realtimeHandlers: Map void>>; realtimeConnection: TestRealtimeConnectionStore; settingsState: PluginSettingsState; + appearance: TestAppearanceStore; bbContext: BbContext; navigate: BbNavigate; navigateCalls: NavigateCall[]; @@ -736,6 +752,21 @@ const testPluginSdkApp = { useSettings(): PluginSettingsState { return useSlotEnv("useSettings").settingsState; }, + experimental_useAppearance(): ExperimentalPluginAppearance { + const appearance = useSlotEnv("experimental_useAppearance").appearance; + const snapshot = useSyncExternalStore( + appearance.subscribe, + appearance.getSnapshot, + appearance.getSnapshot, + ); + return useMemo( + () => ({ + ...snapshot, + setColorModePreference: appearance.setColorModePreference, + }), + [appearance, snapshot], + ); + }, useBbContext(): BbContext { return useSlotEnv("useBbContext").bbContext; }, @@ -1093,6 +1124,11 @@ export interface RenderSlotOptions< context?: { projectId?: string | null; threadId?: string | null }; /** Initial `useRealtimeConnectionState()` value; defaults to `connected`. */ realtimeConnectionState?: PluginRealtimeConnectionState; + /** + * Initial `experimental_useAppearance()` state. Omitted → a system + * preference resolved to light. + */ + experimental_appearance?: ExperimentalAppearanceSnapshot; /** Initial state for this render's isolated composer scope and view. */ composer?: { text?: string; @@ -1145,6 +1181,10 @@ export interface RenderedSlotBehaviorDrivers { setRealtimeConnectionState( state: PluginRealtimeConnectionState, ): Promise; + /** Drive a client appearance change, including an OS change under system. */ + experimental_setAppearance( + appearance: ExperimentalAppearanceSnapshot, + ): Promise; /** Replace composer text as a host-originated edit, wrapped in act. */ setComposerText(text: string): Promise; /** Replace the scope snapshots returned by composer hooks, wrapped in act. */ @@ -1161,6 +1201,10 @@ export interface RenderedSlotInspectionState { readonly experimental_fixedTabOpenCalls: ExperimentalFixedTabOpenCall[]; /** Every `experimental_useSidebarThreadActions()` call, in order. */ readonly sidebarActionCalls: SidebarActionCall[]; + /** Every client mode preference requested through the appearance hook. */ + readonly experimental_appearancePreferenceCalls: Array< + ExperimentalPluginAppearance["colorModePreference"] + >; /** Everything written through `useComposer()`. */ readonly composer: ComposerLog; } @@ -1279,6 +1323,47 @@ export function renderSlot< }, }; + let appearanceSnapshot: ExperimentalAppearanceSnapshot = + options.experimental_appearance ?? { + colorMode: "light", + colorModePreference: "system", + }; + let systemColorMode = appearanceSnapshot.colorMode; + const appearanceListeners = new Set<() => void>(); + const appearancePreferenceCalls: Array< + ExperimentalPluginAppearance["colorModePreference"] + > = []; + const publishAppearance = (next: ExperimentalAppearanceSnapshot): void => { + if ( + next.colorMode === appearanceSnapshot.colorMode && + next.colorModePreference === appearanceSnapshot.colorModePreference + ) { + return; + } + appearanceSnapshot = next; + for (const listener of appearanceListeners) listener(); + }; + const appearance: TestAppearanceStore = { + getSnapshot: () => appearanceSnapshot, + subscribe(listener) { + appearanceListeners.add(listener); + return () => appearanceListeners.delete(listener); + }, + setColorModePreference(preference) { + appearancePreferenceCalls.push(preference); + publishAppearance({ + colorMode: preference === "system" ? systemColorMode : preference, + colorModePreference: preference, + }); + }, + setSnapshot(snapshot) { + if (snapshot.colorModePreference === "system") { + systemColorMode = snapshot.colorMode; + } + publishAppearance(snapshot); + }, + }; + const navigateCalls: NavigateCall[] = []; const experimental_fixedTabOpenCalls: ExperimentalFixedTabOpenCall[] = []; let fixedTabTargetSnapshot = @@ -1540,6 +1625,7 @@ export function renderSlot< realtimeHandlers, realtimeConnection, settingsState: { values: options.settings, isLoading: false }, + appearance, bbContext: { projectId, threadId }, navigate, navigateCalls, @@ -1595,6 +1681,11 @@ export function renderSlot< ): Promise => { await act(async () => realtimeConnection.setState(state)); }; + const setAppearance = async ( + snapshot: ExperimentalAppearanceSnapshot, + ): Promise => { + await act(async () => appearance.setSnapshot(snapshot)); + }; const setComposerText = async (text: string): Promise => { await act(async () => commitComposerText(text)); }; @@ -1618,15 +1709,18 @@ export function renderSlot< rpcCalls, emitRealtime, setRealtimeConnectionState, + experimental_setAppearance: setAppearance, setComposerText, setComposerScope, navigateCalls, experimental_fixedTabOpenCalls, sidebarActionCalls, + experimental_appearancePreferenceCalls: appearancePreferenceCalls, composer: composerLog, behavior: { emitRealtime, setRealtimeConnectionState, + experimental_setAppearance: setAppearance, setComposerText, setComposerScope, }, @@ -1635,6 +1729,7 @@ export function renderSlot< navigateCalls, experimental_fixedTabOpenCalls, sidebarActionCalls, + experimental_appearancePreferenceCalls: appearancePreferenceCalls, composer: composerLog, }, lifecycle: { rerender: rerenderSlot, unmount: unmountSlot }, From 49246fc3cae951e5ac4c26f7bcd4a18b83b10e78 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 21 Aug 2026 12:04:00 -0700 Subject: [PATCH 2/9] docs(plugin-sdk): teach the appearance authoring contract --- .../bb-plugin-authoring/SKILL.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index d7d2fc5042..399136632e 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -30,7 +30,7 @@ The manifest is `package.json`: "name": "bb-plugin-hello", "version": "0.1.0", "type": "module", - "engines": { "bb": ">=0.9", "bbPluginSdk": ">=0.4.3" }, + "engines": { "bb": ">=0.9", "bbPluginSdk": ">=0.4.13" }, "bb": { "name": "Hello", "description": "A friendly example plugin.", @@ -109,7 +109,7 @@ The manifest is `package.json`: different branded artwork and provide a dark variant when needed. - `engines.bb` — optional semver range checked against the bb app version. - `engines.bbPluginSdk` — optional semver range for the plugin SDK surface - (currently `0.4.3`; the scaffold writes `">=0.4.3"`). bb reads it as a floor, + (currently `0.4.13`; the scaffold writes `">=0.4.13"`). bb reads it as a floor, not a ceiling: a later SDK in the same major still loads the plugin, so a caret range keeps working after the SDK moves forward. Absent means a legacy manifest. Managed (`git:`/`npm:`) installs **refuse** a plugin that needs a @@ -1292,6 +1292,7 @@ import { useRealtime, useRealtimeConnectionState, useSettings, + experimental_useAppearance, useBbContext, useBbNavigate, experimental_FileLink as FileLink, @@ -2150,6 +2151,13 @@ Hooks: `"reconnecting"` for the same shared socket used by `useRealtime`. Reconcile durable server state on subsequent transitions to `connected` (not the first connection) because plugin signals are ephemeral and are not replayed. +- `experimental_useAppearance()` → `{ colorMode, colorModePreference, +setColorModePreference }`. `colorMode` is the applied `"light" | "dark"` + result after resolving a `"system"` preference. Use it for non-CSS consumers + such as a canvas or third-party editor theme; ordinary plugin UI already + inherits BB's live semantic CSS variables. The preference setter is + client-local. Palette selection remains on `bb.sdk.theme`; the hook does not + expose palette ids, raw CSS, code-theme files, or CSS tokens. - `useSettings()` → `{ values, isLoading }` — effective non-secret values (secret settings are excluded; read them server-side only). - `useBbContext()` → `{ projectId, threadId }` from the current route. @@ -2417,6 +2425,10 @@ const slot = renderSlot( listNotes: () => ({ root: "/notes", notes: [], error: null }), }, // method → handler, calls logged settings: { greeting: "hi" }, // useSettings() values + experimental_appearance: { + colorMode: "dark", + colorModePreference: "system", + }, context: { projectId: "p1", threadId: null }, // useBbContext() realtimeConnectionState: "reconnecting", // useRealtimeConnectionState() openUrl: (url) => url.startsWith("https://"), @@ -2424,6 +2436,10 @@ const slot = renderSlot( ); await slot.findByText("…"); // Testing Library queries await slot.behavior.setRealtimeConnectionState("connected"); +await slot.behavior.experimental_setAppearance({ + colorMode: "light", + colorModePreference: "system", +}); await slot.behavior.setComposerScope( { kind: "queued-message", threadId: "t1", queuedMessageId: "q1" }, "queued draft", From 0de5c9c1b961097fbeba1c9b96cf500569f1e145 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 21 Aug 2026 12:16:00 -0700 Subject: [PATCH 3/9] fix(app): use a lint-recognized appearance hook name --- apps/app/src/components/AppToaster.tsx | 4 ++-- apps/app/src/lib/plugin-appearance.test.tsx | 4 ++-- apps/app/src/lib/plugin-appearance.ts | 2 +- apps/app/src/lib/plugin-sdk-app-impl.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/AppToaster.tsx b/apps/app/src/components/AppToaster.tsx index 341f774c43..ced5dc714f 100644 --- a/apps/app/src/components/AppToaster.tsx +++ b/apps/app/src/components/AppToaster.tsx @@ -1,7 +1,7 @@ import { Toaster, type ToasterProps } from "sonner"; -import { experimental_useAppearance } from "@/lib/plugin-appearance"; +import { usePluginAppearance } from "@/lib/plugin-appearance"; export function AppToaster(props: ToasterProps) { - const { colorMode } = experimental_useAppearance(); + const { colorMode } = usePluginAppearance(); return ; } diff --git a/apps/app/src/lib/plugin-appearance.test.tsx b/apps/app/src/lib/plugin-appearance.test.tsx index 9781aa7f19..a89eff4a4e 100644 --- a/apps/app/src/lib/plugin-appearance.test.tsx +++ b/apps/app/src/lib/plugin-appearance.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { experimental_useAppearance } from "./plugin-appearance"; +import { usePluginAppearance } from "./plugin-appearance"; function installColorMode(initial: "light" | "dark"): { setColorMode(mode: "light" | "dark"): void; @@ -55,7 +55,7 @@ afterEach(() => { describe("experimental_useAppearance", () => { it("maps preference and system changes to semantic client appearance", () => { const system = installColorMode("dark"); - const { result } = renderHook(() => experimental_useAppearance()); + const { result } = renderHook(() => usePluginAppearance()); expect(result.current.colorModePreference).toBe("system"); expect(result.current.colorMode).toBe("dark"); diff --git a/apps/app/src/lib/plugin-appearance.ts b/apps/app/src/lib/plugin-appearance.ts index 0abbba6a2d..f0724239de 100644 --- a/apps/app/src/lib/plugin-appearance.ts +++ b/apps/app/src/lib/plugin-appearance.ts @@ -7,7 +7,7 @@ import { } from "@/hooks/useTheme"; /** Host implementation of the plugin SDK's client appearance contract. */ -export function experimental_useAppearance(): ExperimentalPluginAppearance { +export function usePluginAppearance(): ExperimentalPluginAppearance { const colorMode = usePreferredTheme(); const colorModePreference = useThemePreference(); return useMemo( diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 445a19fd8d..5d0e0b2353 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -16,7 +16,7 @@ import type { import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; import { useThreadTimelineNavigation } from "@/components/thread/timeline/ThreadTimelineNavigationContext"; import { definePluginApp } from "./plugin-app-definition"; -import { experimental_useAppearance } from "./plugin-appearance"; +import { usePluginAppearance } from "./plugin-appearance"; import { useBbContext, useBbNavigate, @@ -65,7 +65,7 @@ export const pluginSdkAppImplementation = { useRealtimeConnectionState, useRpc, useSettings, - experimental_useAppearance, + experimental_useAppearance: usePluginAppearance, // The host-owned components in the SDK (plugin design: deliberate // exception to §5.5) — stable product capabilities, not a UI kit. ThreadChat: PluginThreadChat, From fa09267f85795ce12148e370647961df0f221765 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 21 Aug 2026 15:13:39 -0700 Subject: [PATCH 4/9] refactor(app): keep toaster on internal theme state --- apps/app/src/components/AppToaster.tsx | 6 +++--- docs/api_to_audit.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/AppToaster.tsx b/apps/app/src/components/AppToaster.tsx index ced5dc714f..9e2c84f1b0 100644 --- a/apps/app/src/components/AppToaster.tsx +++ b/apps/app/src/components/AppToaster.tsx @@ -1,7 +1,7 @@ import { Toaster, type ToasterProps } from "sonner"; -import { usePluginAppearance } from "@/lib/plugin-appearance"; +import { usePreferredTheme } from "@/hooks/useTheme"; export function AppToaster(props: ToasterProps) { - const { colorMode } = usePluginAppearance(); - return ; + const theme = usePreferredTheme(); + return ; } diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 031f445bbe..d32306e98b 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -13,9 +13,9 @@ preference, and a setter for that client-local preference. The evidence is [Monaco's private root-class observer](https://github.com/andrewkchan/bb-plugin-monaco/blob/f165b11328efbed70f904bb0e65d432d014c5950/app.tsx#L34-L47), which must choose a non-CSS editor theme, and [Theme Toggle's private local-storage/class manipulation](https://github.com/xMinor-1/bb-plugins/blob/3a6ef78555814fe63891eac72a145ca9c114e9a6/plugins/theme-toggle/app.tsx#L23-L74). -[AppToaster](../apps/app/src/components/AppToaster.tsx) is the first in-repo -consumer; the plugin SDK harness includes a representative appearance-control -fixture for external authors. +The host behavior test covers the existing client appearance store, while the +plugin SDK harness includes a representative appearance-control fixture for +external authors. The other Appearance-tagged releases did not justify widening the contract: [Ayu](https://github.com/vburojevic/bb-plugin-ayu/blob/8881e00888854462fc8a7c68de386fef8229f8aa/package.json) From e9e9459310aca57d4d7c22c1d4f1ee54836f7c9c Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 21 Aug 2026 15:17:08 -0700 Subject: [PATCH 5/9] feat(plugin-api-tester): exercise appearance API --- docs/api_to_audit.md | 7 +-- plugins/plugin-api-tester/app.test.tsx | 56 ++++++++++++++++++++-- plugins/plugin-api-tester/app.tsx | 66 +++++++++++++++++++++++++- plugins/plugin-api-tester/package.json | 2 +- 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index d32306e98b..e3bc8bbe15 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -13,9 +13,10 @@ preference, and a setter for that client-local preference. The evidence is [Monaco's private root-class observer](https://github.com/andrewkchan/bb-plugin-monaco/blob/f165b11328efbed70f904bb0e65d432d014c5950/app.tsx#L34-L47), which must choose a non-CSS editor theme, and [Theme Toggle's private local-storage/class manipulation](https://github.com/xMinor-1/bb-plugins/blob/3a6ef78555814fe63891eac72a145ca9c114e9a6/plugins/theme-toggle/app.tsx#L23-L74). -The host behavior test covers the existing client appearance store, while the -plugin SDK harness includes a representative appearance-control fixture for -external authors. +The host behavior test covers the existing client appearance store, and the +[Plugin API Tester](../plugins/plugin-api-tester/app.tsx) is the first in-repo +plugin consumer: its panel renders and updates both values through the same +SDK harness external authors use. The other Appearance-tagged releases did not justify widening the contract: [Ayu](https://github.com/vburojevic/bb-plugin-ayu/blob/8881e00888854462fc8a7c68de386fef8229f8aa/package.json) diff --git a/plugins/plugin-api-tester/app.test.tsx b/plugins/plugin-api-tester/app.test.tsx index a46db37ee1..7163fdd63b 100644 --- a/plugins/plugin-api-tester/app.test.tsx +++ b/plugins/plugin-api-tester/app.test.tsx @@ -1,14 +1,14 @@ // @vitest-environment jsdom -import { cleanup } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; const app = await loadPluginApp(() => import("./app")); afterEach(cleanup); -describe("Plugin API Tester panel", () => { - it("registers and renders the placeholder panel", async () => { +describe("Plugin API Tester app", () => { + it("renders reactive appearance state and writes client preferences", async () => { expect(app.navPanels).toHaveLength(1); expect(app.navPanels[0]).toMatchObject({ id: "plugin-api-tester", @@ -17,7 +17,53 @@ describe("Plugin API Tester panel", () => { path: "plugin-api-tester", }); - const slot = renderSlot(app.navPanels[0]!, { subPath: "" }); + const slot = renderSlot( + app.navPanels[0]!, + { subPath: "" }, + { + experimental_appearance: { + colorMode: "dark", + colorModePreference: "system", + }, + }, + ); expect(await slot.findByText("Plugin API Tester is active")).toBeTruthy(); + expect(slot.getByLabelText("Resolved color mode").textContent).toBe("dark"); + expect(slot.getByLabelText("Color mode preference").textContent).toBe( + "system", + ); + + fireEvent.click(slot.getByRole("button", { name: "Light" })); + expect(slot.inspection.experimental_appearancePreferenceCalls).toEqual([ + "light", + ]); + expect(slot.getByLabelText("Resolved color mode").textContent).toBe( + "light", + ); + expect(slot.getByLabelText("Color mode preference").textContent).toBe( + "light", + ); + + await slot.behavior.experimental_setAppearance({ + colorMode: "dark", + colorModePreference: "system", + }); + expect(slot.getByLabelText("Resolved color mode").textContent).toBe("dark"); + expect(slot.getByLabelText("Color mode preference").textContent).toBe( + "system", + ); + }); + + it("registers a footer shortcut to the plugin detail page", async () => { + expect(app.sidebarFooterActions).toHaveLength(1); + expect(app.sidebarFooterActions[0]).toMatchObject({ + id: "open-plugin-api-tester", + title: "Plugin API Tester", + icon: "Beaker", + }); + + const openSettings = vi.fn(); + await app.sidebarFooterActions[0]!.run({ openSettings }); + expect(openSettings).toHaveBeenCalledOnce(); }); }); diff --git a/plugins/plugin-api-tester/app.tsx b/plugins/plugin-api-tester/app.tsx index 5fdbbeefc3..e2134090bb 100644 --- a/plugins/plugin-api-tester/app.tsx +++ b/plugins/plugin-api-tester/app.tsx @@ -1,6 +1,17 @@ -import { definePluginApp } from "@get-bb/plugin-sdk/app"; +import { + definePluginApp, + experimental_useAppearance, +} from "@get-bb/plugin-sdk/app"; + +const COLOR_MODE_PREFERENCES = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "system", label: "System" }, +] as const; function PluginApiTesterPanel() { + const appearance = experimental_useAppearance(); + return (
@@ -13,6 +24,51 @@ function PluginApiTesterPanel() { disabled by default in production.

+
+
+

Appearance

+

+ Live values from the experimental plugin appearance contract. +

+
+
+
Resolved color mode
+
+ + {appearance.colorMode} + +
+
Preference
+
+ + {appearance.colorModePreference} + +
+
+
+ {COLOR_MODE_PREFERENCES.map(({ value, label }) => ( + + ))} +
+
); @@ -26,4 +82,12 @@ export default definePluginApp((app) => { path: "plugin-api-tester", component: PluginApiTesterPanel, }); + app.slots.sidebarFooterAction({ + id: "open-plugin-api-tester", + title: "Plugin API Tester", + icon: "Beaker", + run({ openSettings }) { + openSettings(); + }, + }); }); diff --git a/plugins/plugin-api-tester/package.json b/plugins/plugin-api-tester/package.json index 93c464b000..d66afede51 100644 --- a/plugins/plugin-api-tester/package.json +++ b/plugins/plugin-api-tester/package.json @@ -6,7 +6,7 @@ "description": "Exercise and inspect bb plugin API surfaces during development.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.10" + "bbPluginSdk": ">=0.4.13" }, "bb": { "name": "Plugin API Tester", From 25e5fbe3f8c38e631515a429bee0f346b66cd078 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 21 Aug 2026 15:40:43 -0700 Subject: [PATCH 6/9] feat(plugin-sdk): support appearance actions and scripts --- .../PluginSidebarFooterActions.test.tsx | 39 +++++++ .../plugin/PluginSidebarFooterActions.tsx | 10 +- apps/app/src/hooks/useTheme.ts | 16 ++- apps/app/src/lib/plugin-appearance.test.tsx | 12 ++ apps/app/src/lib/plugin-appearance.ts | 29 ++++- .../src/lib/plugin-frontend-reload.test.ts | 42 ++++++- apps/app/src/lib/plugin-frontend.ts | 11 ++ .../bb-plugin-authoring/SKILL.md | 52 ++++++--- docs/api_to_audit.md | 32 ++++-- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/README.md | 9 ++ packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/app-contract.ts | 16 +++ .../testing/__tests__/app-harness.test.tsx | 81 ++++++++++++++ packages/plugin-sdk/src/testing/app.tsx | 104 ++++++++++++++++++ plugins/plugin-api-tester/app.test.tsx | 34 ++++-- plugins/plugin-api-tester/app.tsx | 10 +- plugins/plugin-api-tester/package.json | 2 +- 18 files changed, 453 insertions(+), 50 deletions(-) diff --git a/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx b/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx index 3869ba8b5a..38b7c65875 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx @@ -13,6 +13,7 @@ import { resetPluginLogoStoreForTest, setPluginLogoUrls, } from "@/lib/plugin-logos"; +import { setPreferredTheme } from "@/hooks/useTheme"; import { PluginSidebarFooterActions } from "./PluginSidebarFooterActions"; function registrationSet( @@ -55,6 +56,7 @@ afterEach(() => { cleanup(); resetPluginSlotStoreForTest(); resetPluginLogoStoreForTest(); + setPreferredTheme("system"); vi.restoreAllMocks(); }); @@ -143,4 +145,41 @@ describe("PluginSidebarFooterActions", () => { "/settings/plugins/remote", ); }); + + it("passes the latest semantic appearance at activation time", () => { + setPreferredTheme("dark"); + const activations: Array<{ colorMode: string; preference: string }> = []; + setPluginSlotRegistrations( + "appearance", + registrationSet({ + sidebarFooterActions: [ + { + id: "toggle", + title: "Toggle color mode", + icon: "Palette", + run({ experimental_appearance: appearance }) { + activations.push({ + colorMode: appearance.colorMode, + preference: appearance.colorModePreference, + }); + appearance.setColorModePreference( + appearance.colorMode === "dark" ? "light" : "dark", + ); + }, + }, + ], + }), + ); + + renderWithProviders(); + const toggle = screen.getByRole("button", { name: "Toggle color mode" }); + fireEvent.click(toggle); + fireEvent.click(toggle); + + expect(activations).toEqual([ + { colorMode: "dark", preference: "dark" }, + { colorMode: "light", preference: "light" }, + ]); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); }); diff --git a/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx b/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx index cee215bffb..9a9ca57eac 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx @@ -3,6 +3,7 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar.js"; import { PluginIcon } from "@/components/plugin/PluginIcon"; +import { usePluginAppearance } from "@/lib/plugin-appearance"; import { usePluginSlots, type PluginSidebarFooterActionSlot, @@ -39,6 +40,7 @@ function PluginSidebarFooterActionList({ onNavigate?: () => void; }) { const navigate = useNavigate(); + const appearance = usePluginAppearance(); return ( <> {actions.map((action) => ( @@ -59,6 +61,7 @@ function PluginSidebarFooterActionList({ onNavigate?.(); runSidebarFooterAction({ action, + appearance, navigate, }); }} @@ -74,9 +77,11 @@ function PluginSidebarFooterActionList({ function runSidebarFooterAction({ action, + appearance, navigate, }: { action: PluginSidebarFooterActionSlot; + appearance: ReturnType; navigate: ReturnType; }): void { const openSettings = () => { @@ -92,7 +97,10 @@ function runSidebarFooterAction({ ); }; try { - const result = action.run({ openSettings }); + const result = action.run({ + openSettings, + experimental_appearance: appearance, + }); if (result instanceof Promise) result.catch(warn); } catch (error) { warn(error); diff --git a/apps/app/src/hooks/useTheme.ts b/apps/app/src/hooks/useTheme.ts index 09e2a62093..253f5dfd98 100644 --- a/apps/app/src/hooks/useTheme.ts +++ b/apps/app/src/hooks/useTheme.ts @@ -15,11 +15,10 @@ export type ThemePreference = Theme | "system"; type ThemeListener = () => void; -const themePreferenceStorage = - createLocalStorageEnumStorage( - (value): value is ThemePreference => - value === "light" || value === "dark" || value === "system", - ); +const themePreferenceStorage = createLocalStorageEnumStorage( + (value): value is ThemePreference => + value === "light" || value === "dark" || value === "system", +); const themePreferenceAtom = atomWithStorage( THEME_STORAGE_KEY, "system", @@ -27,7 +26,7 @@ const themePreferenceAtom = atomWithStorage( { getOnInit: true }, ); -function getThemePreference(): ThemePreference { +export function getThemePreference(): ThemePreference { return getDefaultStore().get(themePreferenceAtom); } @@ -67,7 +66,7 @@ function getSystemTheme(): Theme { return getMediaQuerySnapshot(DARK_COLOR_SCHEME_QUERY) ? "dark" : "light"; } -function getPreferredTheme(): Theme { +export function getPreferredTheme(): Theme { const themePreference = getThemePreference(); return themePreference === "system" ? getSystemTheme() : themePreference; } @@ -89,8 +88,7 @@ function emitTheme() { const nextTheme = getPreferredTheme(); applyThemeClass(nextTheme); - const themePreferenceChanged = - nextThemePreference !== currentThemePreference; + const themePreferenceChanged = nextThemePreference !== currentThemePreference; const themeChanged = nextTheme !== currentTheme; currentThemePreference = nextThemePreference; diff --git a/apps/app/src/lib/plugin-appearance.test.tsx b/apps/app/src/lib/plugin-appearance.test.tsx index a89eff4a4e..6b559d7501 100644 --- a/apps/app/src/lib/plugin-appearance.test.tsx +++ b/apps/app/src/lib/plugin-appearance.test.tsx @@ -71,5 +71,17 @@ describe("experimental_useAppearance", () => { act(() => system.setColorMode("light")); expect(result.current.colorModePreference).toBe("system"); expect(result.current.colorMode).toBe("light"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + act(() => + (result.current.setColorModePreference as (preference: string) => void)( + "sepia", + ), + ); + expect(result.current.colorModePreference).toBe("system"); + expect(result.current.colorMode).toBe("light"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('expected "light", "dark", or "system"'), + ); }); }); diff --git a/apps/app/src/lib/plugin-appearance.ts b/apps/app/src/lib/plugin-appearance.ts index f0724239de..ae91ad1d61 100644 --- a/apps/app/src/lib/plugin-appearance.ts +++ b/apps/app/src/lib/plugin-appearance.ts @@ -1,11 +1,38 @@ import { useMemo } from "react"; import type { ExperimentalPluginAppearance } from "@get-bb/plugin-sdk"; import { + getPreferredTheme, + getThemePreference, setPreferredTheme, usePreferredTheme, useThemePreference, } from "@/hooks/useTheme"; +function setPluginColorModePreference( + preference: ExperimentalPluginAppearance["colorModePreference"], +): void { + if ( + preference !== "light" && + preference !== "dark" && + preference !== "system" + ) { + console.warn( + `plugin appearance: expected "light", "dark", or "system"; received ${String(preference)}`, + ); + return; + } + setPreferredTheme(preference); +} + +/** Current semantic appearance for plugin callbacks outside React. */ +export function getPluginAppearance(): ExperimentalPluginAppearance { + return { + colorMode: getPreferredTheme(), + colorModePreference: getThemePreference(), + setColorModePreference: setPluginColorModePreference, + }; +} + /** Host implementation of the plugin SDK's client appearance contract. */ export function usePluginAppearance(): ExperimentalPluginAppearance { const colorMode = usePreferredTheme(); @@ -14,7 +41,7 @@ export function usePluginAppearance(): ExperimentalPluginAppearance { () => ({ colorMode, colorModePreference, - setColorModePreference: setPreferredTheme, + setColorModePreference: setPluginColorModePreference, }), [colorMode, colorModePreference], ); diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts index 50ad80459f..177c29cc92 100644 --- a/apps/app/src/lib/plugin-frontend-reload.test.ts +++ b/apps/app/src/lib/plugin-frontend-reload.test.ts @@ -1,6 +1,9 @@ // @vitest-environment jsdom -import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk"; +import type { + ExperimentalPluginAppearance, + PluginComposerThreadRowStatus, +} from "@get-bb/plugin-sdk"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; @@ -36,6 +39,7 @@ import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; import { PLUGIN_PANEL_ROUTE_PATH } from "./route-paths"; import { applyAppThemeCss } from "./themes"; import { PluginPanelView } from "@/views/PluginPanelView"; +import { setPreferredTheme } from "@/hooks/useTheme"; function candidate( pluginId: string, @@ -81,6 +85,7 @@ afterEach(() => { resetPluginSlotStoreForTest(); resetPluginCssForTest(); uninstallForeignDomMutationGuardForTest(); + setPreferredTheme("system"); }); function MountedHomepageSections() { @@ -501,6 +506,41 @@ describe("reconcilePluginFrontends", () => { expect(getPluginThreadRowStatus("thr_source")).toBeNull(); }); + it("gives content scripts current semantic appearance and rejects stale writes", async () => { + setPreferredTheme("dark"); + const state = createPluginFrontendReconcileState(); + const deps = makeDeps([candidate("theme-toggle", "v1")]); + let getAppearance: (() => ExperimentalPluginAppearance) | undefined; + deps.importModule.mockResolvedValue( + contentScriptModule((app) => { + app.contentScripts.register({ + id: "appearance-menu", + mount({ experimental_getAppearance }) { + getAppearance = experimental_getAppearance; + }, + }); + }), + ); + + await reconcilePluginFrontends(state, deps); + expect(getAppearance?.()).toMatchObject({ + colorMode: "dark", + colorModePreference: "dark", + }); + + getAppearance?.().setColorModePreference("light"); + expect(getAppearance?.()).toMatchObject({ + colorMode: "light", + colorModePreference: "light", + }); + expect(document.documentElement.classList.contains("dark")).toBe(false); + + deps.fetchCandidates.mockResolvedValue([]); + await reconcilePluginFrontends(state, deps); + getAppearance?.().setColorModePreference("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + it("rolls back a status from a partially mounted generation and rejects its retained setter", async () => { const state = createPluginFrontendReconcileState(); const deps = makeDeps([candidate("prompt-shaper", "v1")]); diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts index a89736238e..3c20e92b85 100644 --- a/apps/app/src/lib/plugin-frontend.ts +++ b/apps/app/src/lib/plugin-frontend.ts @@ -49,6 +49,7 @@ import { } from "./plugin-app-definition"; import { setPluginLogoUrls, type PluginLogoUrls } from "./plugin-logos"; import { createGatedPierreDiffsReact } from "./plugin-pierre-diffs-react"; +import { getPluginAppearance } from "./plugin-appearance"; import { getPluginPanelRoutePluginId } from "./route-paths"; import { pluginSdkAppImplementation } from "./plugin-sdk-app-impl"; import { @@ -573,6 +574,16 @@ async function mountWithTimeout( pluginId, generation, signal: controller.signal, + experimental_getAppearance: () => { + const appearance = getPluginAppearance(); + return { + ...appearance, + setColorModePreference(preference) { + if (controller.signal.aborted) return; + appearance.setColorModePreference(preference); + }, + }; + }, experimental_setThreadRowStatus: ( threadId: unknown, status: unknown, diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 399136632e..d2e1ffa0d6 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -30,7 +30,7 @@ The manifest is `package.json`: "name": "bb-plugin-hello", "version": "0.1.0", "type": "module", - "engines": { "bb": ">=0.9", "bbPluginSdk": ">=0.4.13" }, + "engines": { "bb": ">=0.9", "bbPluginSdk": ">=0.4.14" }, "bb": { "name": "Hello", "description": "A friendly example plugin.", @@ -109,7 +109,7 @@ The manifest is `package.json`: different branded artwork and provide a dark variant when needed. - `engines.bb` — optional semver range checked against the bb app version. - `engines.bbPluginSdk` — optional semver range for the plugin SDK surface - (currently `0.4.13`; the scaffold writes `">=0.4.13"`). bb reads it as a floor, + (currently `0.4.14`; the scaffold writes `">=0.4.14"`). bb reads it as a floor, not a ceiling: a later SDK in the same major still loads the plugin, so a caret range keeps working after the SDK moves forward. Absent means a legacy manifest. Managed (`git:`/`npm:`) installs **refuse** a plugin that needs a @@ -1307,7 +1307,8 @@ import { Dialog, DialogContent } from "@/components/ui/dialog"; export default definePluginApp((app) => { app.contentScripts.register({ id: "editor-enhancement", - mount({ pluginId, generation, signal }) { + mount({ pluginId, generation, signal, experimental_getAppearance }) { + const appearance = experimental_getAppearance(); const onKeyDown = (event: KeyboardEvent) => { // Ordinary trusted, same-origin DOM behavior. }; @@ -1393,10 +1394,13 @@ export default definePluginApp((app) => { component: CredentialForm, }); app.slots.sidebarFooterAction({ - id: "remote", - title: "Remote access", - icon: "Smartphone", - run: ({ openSettings }) => openSettings(), + id: "toggle-color-mode", + title: "Toggle color mode", + icon: "Palette", + run: ({ experimental_appearance: appearance }) => + appearance.setColorModePreference( + appearance.colorMode === "dark" ? "light" : "dark", + ), }); app.slots.messageDirective({ id: "inline-vis", component: InlineVis }); app.slots.experimental_threadList({ @@ -1572,9 +1576,14 @@ compatible ESM bundle. The host mounts scripts in registration order after the bundle loads and `definePluginApp` setup validates. `mount` receives -`{ pluginId, generation, signal, experimental_setThreadRowStatus? }`: +`{ pluginId, generation, signal, experimental_getAppearance, +experimental_setThreadRowStatus? }`: `generation` is a monotonic per-window mount attempt number, and `signal` -aborts before cleanup starts. The optional experimental setter targets an +aborts before cleanup starts. `experimental_getAppearance()` returns the same +semantic value as the React hook at call time; use it for non-React behavior +that needs to read or set client mode, and call it at the point of use instead +of retaining a snapshot. Writes from a disposed generation are ignored. The +optional experimental thread-row setter targets an explicit thread row with `{ icon, label, tone? }` or clears it with `null`. Use `tone: "running"` for the host's animated running treatment. The host scopes statuses to the calling plugin and automatically clears them when that @@ -1766,8 +1775,11 @@ target? })`. Inside the fixed-tab component, (next to Settings / bug report). No plugin component — the host paints the chrome so icons stay consistent. Registration: `{ id, title, icon, run }`. Activating it calls - `run({ openSettings })` — use `openSettings()` to open this plugin's - detail page in Tools, or do anything else (rpc, toast). Errors from `run` + `run({ openSettings, experimental_appearance })` — use `openSettings()` to + open this plugin's detail page in Tools. The experimental appearance value + is the same semantic contract as `experimental_useAppearance()`, captured at + activation time so a non-React action can read the resolved mode and update + the client-local preference. Errors from `run` (sync or async) are contained and logged, never breaking the sidebar. `title` is the tooltip + accessible label; `icon` is a BB icon-name hint (unknown names fall back to a generic bolt). @@ -2156,8 +2168,11 @@ setColorModePreference }`. `colorMode` is the applied `"light" | "dark"` result after resolving a `"system"` preference. Use it for non-CSS consumers such as a canvas or third-party editor theme; ordinary plugin UI already inherits BB's live semantic CSS variables. The preference setter is - client-local. Palette selection remains on `bb.sdk.theme`; the hook does not - expose palette ids, raw CSS, code-theme files, or CSS tokens. + client-local. Outside React, a `sidebarFooterAction` gets the same value as + `experimental_appearance`, and a content script calls + `experimental_getAppearance()` at the point of use. Palette selection + remains on `bb.sdk.theme`; none of these adapters expose palette ids, raw + CSS, code-theme files, or CSS tokens. - `useSettings()` → `{ values, isLoading }` — effective non-secret values (secret settings are excluded; read them server-side only). - `useBbContext()` → `{ projectId, threadId }` from the current route. @@ -2403,6 +2418,7 @@ Frontend (`app.tsx`) — `@get-bb/plugin-sdk/testing/app` (vitest + jsdom): ```tsx // @vitest-environment jsdom import { + experimental_runSidebarFooterAction, loadPluginApp, mountPluginContentScripts, renderSlot, @@ -2416,6 +2432,16 @@ const contentScripts = await mountPluginContentScripts(app, { pluginId: "my-plugin", generation: 1, }); +const footerResult = await experimental_runSidebarFooterAction( + app.sidebarFooterActions[0]!, + { + experimental_appearance: { + colorMode: "dark", + colorModePreference: "system", + }, + }, +); +footerResult.experimental_appearancePreferenceCalls; const slot = renderSlot( app.navPanels[0]!, diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index e3bc8bbe15..8d30362051 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -5,7 +5,7 @@ entry here (see [AGENTS.md](../AGENTS.md), "Plugin API"). Dropping the prefix is the deliberate stabilization step: audit the entry, rename project-wide, and delete the entry in the same change. -## Client appearance (`experimental_useAppearance`) +## Client appearance (`experimental_useAppearance`, `experimental_appearance`, `experimental_getAppearance`) **What it does.** Gives plugin React components the current client's resolved `"light" | "dark"` color mode, its selected `"light" | "dark" | "system"` @@ -16,14 +16,24 @@ local-storage/class manipulation](https://github.com/xMinor-1/bb-plugins/blob/3a The host behavior test covers the existing client appearance store, and the [Plugin API Tester](../plugins/plugin-api-tester/app.tsx) is the first in-repo plugin consumer: its panel renders and updates both values through the same -SDK harness external authors use. - -The other Appearance-tagged releases did not justify widening the contract: -[Ayu](https://github.com/vburojevic/bb-plugin-ayu/blob/8881e00888854462fc8a7c68de386fef8229f8aa/package.json) -and [Tokyo Night](https://github.com/krehel/bb-plugin-tokyo-night/blob/a5234d1a72e1fa58f3826cb239acd485701f76fe/package.json) -are declarative `bb.themes` palettes, while [Fonts](https://github.com/gtramontina/bb-plugin-fonts/blob/d48637a2052b0336b8ac12476101a816c8b421de/client-runtime.ts#L104-L111) -reapplies typography CSS configuration. Those needs remain covered by theme -CSS variables/`bb.themes`, not this JavaScript mode API. +SDK harness external authors use. Its host-rendered sidebar footer action gets +the same contract as `run({ experimental_appearance })`, since an action +callback cannot call a React hook, and toggles directly between the resolved +light/dark modes without opening the plugin panel. Content scripts receive +`experimental_getAppearance()` so they can read a fresh snapshot at the point +of use; this is the shape Theme Toggle's non-React menu needs to replace its +private client storage and root-class writes. + +The [live BB Community catalog](https://getbb.app/marketplace/v1/marketplace.json) +was checked against each latest compatible release: + +| Plugin | Released implementation | Appearance API outcome | +| ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [Theme Toggle 0.2.1](https://github.com/xMinor-1/bb-plugins/blob/3a6ef78555814fe63891eac72a145ca9c114e9a6/plugins/theme-toggle/app.tsx#L23-L74) | Reads/writes private `bb.theme` storage, dispatches a synthetic storage event, calls `matchMedia`, and toggles the root `dark` class from a content-script menu. | Direct beneficiary: replace that block with `experimental_getAppearance()`; palette reads/writes stay on `bb.sdk.theme`. | +| [Monaco 0.1.0](https://github.com/andrewkchan/bb-plugin-monaco/blob/f165b11328efbed70f904bb0e65d432d014c5950/app.tsx#L34-L47) | Observes the root `dark` class to choose `vs` / `vs-dark`. | Direct beneficiary: map reactive `colorMode` from `experimental_useAppearance()`. | +| [Ayu 0.2.2](https://github.com/vburojevic/bb-plugin-ayu/blob/8881e00888854462fc8a7c68de386fef8229f8aa/package.json#L21-L41) | Contributes declarative palettes and uses `bb.sdk.theme` in its palette explorer. | No migration: the existing palette contracts are the correct surface. | +| [Tokyo Night 0.1.0](https://github.com/krehel/bb-plugin-tokyo-night/blob/a5234d1a72e1fa58f3826cb239acd485701f76fe/package.json) | Contributes declarative light/dark palette CSS. | No migration: CSS already reacts to the client mode. | +| [Fonts 0.1.0](https://github.com/gtramontina/bb-plugin-fonts/blob/d48637a2052b0336b8ac12476101a816c8b421de/client-runtime.ts#L104-L111) | Recomputes typography overrides after mode or palette changes. | Not covered: it needs a stable general appearance-change notification, not mode values or CSS tokens. Keep that prerequisite separate. | The hook deliberately omits palette ids, palette CSS, resolved code-theme files, favicon selection, and CSS tokens. Plugin styles already receive live @@ -39,7 +49,9 @@ the same persistence/cross-window behavior as Settings; measure adoption by a second non-editor external plugin; and re-check that no stable JavaScript consumer needs a palette-change revision before adding one. Keep the hook on the existing shared app runtime so it adds no appearance subsystem or lazy -chunk to plugin bundles. +chunk to plugin bundles. Confirm the footer snapshot remains current at click +time, content-script reads remain current and generation-scoped, and all three +adapters continue sharing one semantic contract. ## `experimental_buildBridgeToolCallContent` diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index f519c51b1d..7cdc4520d6 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -16,7 +16,7 @@ // PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish // 0.x releases and is intentionally vacuous for them until a future 1.0. // Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger. -export const PLUGIN_SDK_VERSION = "0.4.13"; +export const PLUGIN_SDK_VERSION = "0.4.14"; /** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */ export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index d1ca991f7e..0300d25afa 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -18,6 +18,15 @@ such as a canvas or third-party editor theme. Do not use it to restyle ordinary plugin UI: plugin CSS already inherits BB's live semantic variables, and server-owned palette selection remains on `bb.sdk.theme`. +Host-rendered `sidebarFooterAction` callbacks receive the same semantic value +as `experimental_appearance` because callbacks cannot call React hooks. The +snapshot is current when the action runs, so an icon can, for example, switch +the resolved mode to the opposite explicit preference. + +Content scripts call `experimental_getAppearance()` from their mount context +when non-React behavior needs a fresh snapshot. It returns the same semantic +value and setter; it does not expose palette state or CSS tokens. + ## Composer customization Composer UI extensions register through `app.composer.customize(...)`. A diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index a48a582e7a..e7a8f0b5f6 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.13", + "version": "0.4.14", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 1685750105..398d241a29 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -585,6 +585,14 @@ export interface PluginSidebarFooterActionContext { * and `settingsSection` slots render. */ openSettings(): void; + /** + * The current client's semantic appearance at activation time. Use this for + * direct action behavior that cannot call React hooks, such as toggling the + * client-local color-mode preference from the footer icon. + * + * @experimental Audit before relying on this as a stable contract. + */ + experimental_appearance: ExperimentalPluginAppearance; } /** @@ -1140,6 +1148,14 @@ export interface PluginContentScriptContext { readonly generation: number; /** Aborted before cleanup begins on replacement, deactivation, or teardown. */ readonly signal: AbortSignal; + /** + * Read the current client's semantic appearance when non-React content + * script behavior runs. Call it at the point of use rather than retaining a + * snapshot; writes from a disposed script generation are ignored. + * + * @experimental Audit before relying on this as a stable contract. + */ + readonly experimental_getAppearance: () => ExperimentalPluginAppearance; /** * Persistently decorate any thread row for this plugin generation. * diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 104b377ffa..2be43b190c 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -12,6 +12,7 @@ import type { } from "../../app-contract.js"; import { installTestPluginRuntime, + experimental_runSidebarFooterAction, loadPluginApp, mountPluginContentScripts, renderSlot, @@ -244,6 +245,9 @@ function AppearanceProbe() { Appearance: {appearance.colorMode}/{appearance.colorModePreference} + + Monaco theme: {appearance.colorMode === "dark" ? "vs-dark" : "vs"} + ))} @@ -83,15 +109,4 @@ export default definePluginApp((app) => { path: "plugin-api-tester", component: PluginApiTesterPanel, }); - app.slots.sidebarFooterAction({ - id: "toggle-color-mode", - title: "Toggle color mode", - icon: "Beaker", - run() { - const appearance = experimental_appearance.getSnapshot(); - appearance.setColorModePreference( - appearance.colorMode === "dark" ? "light" : "dark", - ); - }, - }); });