Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
resetPluginLogoStoreForTest,
setPluginLogoUrls,
} from "@/lib/plugin-logos";
import { setPreferredTheme } from "@/hooks/useTheme";
import { PluginSidebarFooterActions } from "./PluginSidebarFooterActions";

function registrationSet(
Expand Down Expand Up @@ -55,6 +56,7 @@ afterEach(() => {
cleanup();
resetPluginSlotStoreForTest();
resetPluginLogoStoreForTest();
setPreferredTheme("system");
vi.restoreAllMocks();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ function runSidebarFooterAction({
);
};
try {
const result = action.run({ openSettings });
const result = action.run({
openSettings,
});
if (result instanceof Promise) result.catch(warn);
} catch (error) {
warn(error);
Expand Down
29 changes: 20 additions & 9 deletions apps/app/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,18 @@ export type ThemePreference = Theme | "system";

type ThemeListener = () => void;

const themePreferenceStorage =
createLocalStorageEnumStorage<ThemePreference>(
(value): value is ThemePreference =>
value === "light" || value === "dark" || value === "system",
);
const themePreferenceStorage = createLocalStorageEnumStorage<ThemePreference>(
(value): value is ThemePreference =>
value === "light" || value === "dark" || value === "system",
);
const themePreferenceAtom = atomWithStorage<ThemePreference>(
THEME_STORAGE_KEY,
"system",
themePreferenceStorage,
{ getOnInit: true },
);

function getThemePreference(): ThemePreference {
export function getThemePreference(): ThemePreference {
return getDefaultStore().get(themePreferenceAtom);
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -82,15 +81,15 @@ let currentTheme: Theme = "light";
let currentThemePreference: ThemePreference = "system";
const themeSubscribers = new Set<ThemeListener>();
const themePreferenceSubscribers = new Set<ThemeListener>();
const themeAppearanceSubscribers = new Set<ThemeListener>();
let initialized = false;

function emitTheme() {
const nextThemePreference = getThemePreference();
const nextTheme = getPreferredTheme();
applyThemeClass(nextTheme);

const themePreferenceChanged =
nextThemePreference !== currentThemePreference;
const themePreferenceChanged = nextThemePreference !== currentThemePreference;
const themeChanged = nextTheme !== currentTheme;

currentThemePreference = nextThemePreference;
Expand All @@ -102,6 +101,9 @@ function emitTheme() {
if (themeChanged) {
themeSubscribers.forEach((listener) => listener());
}
if (themePreferenceChanged || themeChanged) {
themeAppearanceSubscribers.forEach((listener) => listener());
}
}

function ensureThemeObserver() {
Expand Down Expand Up @@ -154,3 +156,12 @@ export function useThemePreference(): ThemePreference {
() => "system",
);
}

/** Subscribe once when either the selected preference or resolved mode changes. */
export function subscribeThemeAppearance(listener: ThemeListener): () => void {
ensureThemeObserver();
themeAppearanceSubscribers.add(listener);
return () => {
themeAppearanceSubscribers.delete(listener);
};
}
101 changes: 101 additions & 0 deletions apps/app/src/lib/plugin-appearance.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// @vitest-environment jsdom

import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
pluginAppearanceStore,
usePluginAppearance,
} from "./plugin-appearance";

function installColorMode(initial: "light" | "dark"): {
setColorMode(mode: "light" | "dark"): void;
} {
let mode = initial;
const listeners = new Set<EventListenerOrEventListenerObject>();
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("shares stable, reactive semantic snapshots across store and hook", () => {
const system = installColorMode("dark");
const notifications = vi.fn();
const unsubscribe = pluginAppearanceStore.subscribe(notifications);
const { result } = renderHook(() => usePluginAppearance());

expect(result.current.colorModePreference).toBe("system");
expect(result.current.colorMode).toBe("dark");
expect(pluginAppearanceStore.getSnapshot()).toBe(result.current);
expect(pluginAppearanceStore.getSnapshot()).toBe(
pluginAppearanceStore.getSnapshot(),
);

act(() => result.current.setColorModePreference("light"));
expect(result.current.colorModePreference).toBe("light");
expect(result.current.colorMode).toBe("light");
expect(notifications).toHaveBeenCalledTimes(1);

act(() => result.current.setColorModePreference("system"));
expect(result.current.colorModePreference).toBe("system");
expect(result.current.colorMode).toBe("dark");
expect(notifications).toHaveBeenCalledTimes(2);

act(() => system.setColorMode("light"));
expect(result.current.colorModePreference).toBe("system");
expect(result.current.colorMode).toBe("light");
expect(notifications).toHaveBeenCalledTimes(3);

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"'),
);
expect(notifications).toHaveBeenCalledTimes(3);
unsubscribe();
});
});
68 changes: 68 additions & 0 deletions apps/app/src/lib/plugin-appearance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useSyncExternalStore } from "react";
import type {
ExperimentalPluginAppearance,
ExperimentalPluginAppearanceStore,
} from "@get-bb/plugin-sdk";
import {
getPreferredTheme,
getThemePreference,
setPreferredTheme,
subscribeThemeAppearance,
} 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);
}

const serverAppearance: ExperimentalPluginAppearance = {
colorMode: "light",
colorModePreference: "system",
setColorModePreference: setPluginColorModePreference,
};

let cachedAppearance: ExperimentalPluginAppearance | undefined;

function getPluginAppearanceSnapshot(): ExperimentalPluginAppearance {
if (typeof window === "undefined") return serverAppearance;
const colorMode = getPreferredTheme();
const colorModePreference = getThemePreference();
if (
cachedAppearance?.colorMode === colorMode &&
cachedAppearance.colorModePreference === colorModePreference
) {
return cachedAppearance;
}
cachedAppearance = {
colorMode,
colorModePreference,
setColorModePreference: setPluginColorModePreference,
};
return cachedAppearance;
}

/** Host implementation of the app-wide plugin appearance contract. */
export const pluginAppearanceStore: ExperimentalPluginAppearanceStore = {
getSnapshot: getPluginAppearanceSnapshot,
subscribe: subscribeThemeAppearance,
};

/** React convenience wrapper over the app-wide plugin appearance store. */
export function usePluginAppearance(): ExperimentalPluginAppearance {
return useSyncExternalStore(
pluginAppearanceStore.subscribe,
pluginAppearanceStore.getSnapshot,
() => serverAppearance,
);
}
2 changes: 2 additions & 0 deletions apps/app/src/lib/plugin-frontend-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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,
Expand Down Expand Up @@ -81,6 +82,7 @@ afterEach(() => {
resetPluginSlotStoreForTest();
resetPluginCssForTest();
uninstallForeignDomMutationGuardForTest();
setPreferredTheme("system");
});

function MountedHomepageSections() {
Expand Down
6 changes: 6 additions & 0 deletions apps/app/src/lib/plugin-sdk-app-impl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import type {
import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link";
import { useThreadTimelineNavigation } from "@/components/thread/timeline/ThreadTimelineNavigationContext";
import { definePluginApp } from "./plugin-app-definition";
import {
pluginAppearanceStore,
usePluginAppearance,
} from "./plugin-appearance";
import {
useBbContext,
useBbNavigate,
Expand Down Expand Up @@ -64,6 +68,8 @@ export const pluginSdkAppImplementation = {
useRealtimeConnectionState,
useRpc,
useSettings,
experimental_appearance: pluginAppearanceStore,
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,
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/commands/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function registerThemeCommands(
if (
outputJson(opts, {
active: catalog.active,
builtInThemes,
builtInThemes: catalog.experimental_builtIn,
custom: catalog.custom,
plugins: catalog.plugins,
dir: catalog.dir,
Expand All @@ -77,7 +77,7 @@ export function registerThemeCommands(
const active = catalog.active.themeId;
console.log("");
console.log("Built-in:");
for (const entry of builtInThemes) {
for (const entry of catalog.experimental_builtIn) {
const marker = active === entry.id ? "*" : " ";
console.log(`${marker} ${entry.id.padEnd(12)} ${entry.description}`);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "@bb/db";
import {
applyAppKeybindingOverrides,
builtInThemes,
customThemeNameSchema,
isBuiltInThemeId,
resolveCodeTheme,
Expand Down Expand Up @@ -244,6 +245,7 @@ export function registerSystemRoutes(
get(routes.themes, async (context) =>
context.json({
dir: themeRoot,
experimental_builtIn: [...builtInThemes],
custom: listCustomThemeNames(themeRoot),
plugins: pluginService.listThemes(),
active: await resolveSelectedTheme(
Expand Down
Loading
Loading