Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { artifactKindLabel, CommandPalette, useCommandShortcut, useTheme } from "@corbits/react-ui";
import { artifactKindLabel, CommandPalette, useCommandShortcut } from "@corbits/react-ui";
import type { CommandPaletteGroup } from "@corbits/react-ui";
import { listWorkbenches } from "@/chat/workbench-tenants";
import { libraryArtifactPath } from "@/library";
Expand Down Expand Up @@ -28,6 +28,7 @@ import {
import { WORKBENCH_NOT_FOUND_EVENT } from "./workbench-not-found-event";
import { recentsStoreForBench } from "./command-palette-recents";
import { NAV_ROUTES } from "./routes";
import { cycleTheme } from "./theme-store";
import { ArtifactListPageSchema, useAPIQuery } from "./api";
import { useBench } from "./bench-context";
import { useCloseCanvas } from "./shell/canvas-availability";
Expand Down Expand Up @@ -81,7 +82,6 @@ export function CommandPaletteProvider({
const open = useCommandPaletteOpen();
const query = useCommandPaletteQuery();
const [recents, setRecents] = useState<readonly RecentEntry[]>([]);
const { cycleMode } = useTheme();
const closeCanvas = useCloseCanvas();

const recentsStore = useMemo(
Expand Down Expand Up @@ -373,7 +373,7 @@ export function CommandPaletteProvider({
path,
navigate,
tenantId: selectedTenantId,
cycleTheme: cycleMode,
cycleTheme,
closeCanvas,
});
} else if (id.startsWith("route:")) {
Expand Down Expand Up @@ -409,7 +409,6 @@ export function CommandPaletteProvider({
navigate,
path,
selectedTenantId,
cycleMode,
closeCanvas,
pushRecent,
workbenchItems,
Expand Down
19 changes: 8 additions & 11 deletions apps/web/src/login-routing.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// `TestRoot` mirrors `main.tsx`'s `Root` history wiring so these tests
// drive real `pushState`/`popstate` traffic, not a bare `navigate` prop.

import { ThemeProvider } from "@corbits/react-ui";
import { afterEach, describe, expect, test } from "bun:test";
import { act, useCallback, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
Expand Down Expand Up @@ -63,16 +62,14 @@ function TestRoot({ initialSession }: { readonly initialSession: SessionState })
};
}, [handleSignedIn]);
return (
<ThemeProvider>
<App
path={path}
navigate={navigate}
session={session}
onSignedIn={handleSignedIn}
onSignOut={() => setSession({ kind: "signed-out" })}
onRetry={() => undefined}
/>
</ThemeProvider>
<App
path={path}
navigate={navigate}
session={session}
onSignedIn={handleSignedIn}
onSignOut={() => setSession({ kind: "signed-out" })}
onRetry={() => undefined}
/>
);
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import "@corbits/react-ui/styles.css";
import "./app.css";
import "./tailwind.css";
import "./theme-tokens.css";

import { StrictMode } from "react";
import { createRoot, type Root as ReactRoot } from "react-dom/client";
Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// first-login hook, and the theme shell everything else renders inside.
// Kept out of `main.tsx` so the entry module owns nothing but the mount.

import { ThemeProvider, Toaster, toast } from "@corbits/react-ui";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Toaster, toast } from "@corbits/react-ui";
import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";

import { getLogger } from "@/lib/client-log";
import { App } from "./app";
Expand All @@ -13,6 +13,7 @@ import { getPath, navigateTo, subscribeToPath } from "./router-store";
import { ONBOARDING_PATH } from "./routes";
import { fetchSession, signOut } from "./session";
import type { SessionState, SessionUser } from "./session";
import { setThemeStorageKey } from "./theme-store";

const log = getLogger("web.session");

Expand Down Expand Up @@ -74,13 +75,15 @@ export function Root() {
});
}, []);

// Per-user storage key so theme follows the account; not synced to the
// preferences store since ThemeProvider owns mode entirely internally.
// Per-user storage key so the theme follows the account.
const themeStorageKey =
session.kind === "signed-in" ? `corbits-theme:${session.user.id}` : "corbits-theme";
useLayoutEffect(() => {
setThemeStorageKey(themeStorageKey);
}, [themeStorageKey]);

return (
<ThemeProvider storageKey={themeStorageKey} defaultMode="light">
<>
<App
path={path}
navigate={navigate}
Expand All @@ -93,6 +96,6 @@ export function Root() {
onRetryProvisioning={handleRetryProvisioning}
/>
<Toaster position="bottom-right" />
</ThemeProvider>
</>
);
}
23 changes: 7 additions & 16 deletions apps/web/src/settings/account-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,7 @@
// The Agent card was removed — nothing there could change until a hub
// preference store exists to write it to.

import {
Avatar,
Badge,
Button,
SettingsPanel,
isThemeMode,
toast,
useTheme,
} from "@corbits/react-ui";
import { Avatar, Badge, Button, SettingsPanel, toast } from "@corbits/react-ui";
import { Select } from "@corbits/react-ui/ui/select";
import { ChatCircleDots, Copy, SignOut } from "@/lib/icons";
import { useQuery } from "@tanstack/react-query";
Expand All @@ -20,6 +12,7 @@ import { resolveAvatarFill } from "@/chat";
import webPackage from "../../package.json";
import { getAccount, type Account } from "./api";
import { SETTINGS_STRINGS } from "./strings";
import { isTheme, setTheme, useTheme } from "../theme-store";

/** The repo's own issue tracker — read off this package's manifest (set
* from `git remote`) rather than a hardcoded org/repo guess. */
Expand Down Expand Up @@ -154,26 +147,24 @@ export function AccountSectionView({
);
}

/** Theme row, wired to `ThemeProvider`'s three-state mode contract — the
* host mounts `ThemeProvider` once near the app root, so `setMode` here
* both applies and persists the choice with no storage code of our own. */
/** Theme row, wired to the theme store, which applies and persists the choice. */
export function AppearanceSection() {
const { mode, setMode } = useTheme();
const theme = useTheme();
return (
<SettingsPanel title={SETTINGS_STRINGS.appearanceSectionTitle}>
<label className="settings-form-field settings-form-field-inline">
<span>{SETTINGS_STRINGS.appearanceThemeLabel}</span>
<Select
className="settings-appearance-select"
value={mode}
value={theme}
onChange={(event) => {
const next = event.target.value;
if (isThemeMode(next)) setMode(next);
if (isTheme(next)) setTheme(next);
}}
>
<option value="system">{SETTINGS_STRINGS.themeFollowSystem}</option>
<option value="light">{SETTINGS_STRINGS.themeLight}</option>
<option value="dark">{SETTINGS_STRINGS.themeDark}</option>
<option value="canvas">{SETTINGS_STRINGS.themeCanvas}</option>
</Select>
</label>
</SettingsPanel>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/settings/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ export const SETTINGS_STRINGS = {

appearanceSectionTitle: "Appearance",
appearanceThemeLabel: "Theme",
themeFollowSystem: "Follow System",
themeLight: "Light",
themeDark: "Dark",
themeCanvas: "Canvas",

peopleSectionTitle: "People",
peopleSectionDescription: "Everyone with a seat on this workbench.",
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/shell/context-menu/shell-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// every right-click to a typed shell target, builds that target's real
// items, and renders them through react-ui's Menu.

import { useTheme } from "@corbits/react-ui";
import {
ContextMenuView,
resolveTarget,
Expand All @@ -15,6 +14,7 @@ import { useCallback } from "react";
import { useBench } from "../../bench-context";
import { useNavigate } from "../../navigation";
import { invalidateRoutineQueries } from "../../query-client";
import { cycleTheme } from "../../theme-store";
import { useOpenProfileInCanvas } from "../canvas-availability";
import { shellContextMenuFor } from "./items";
import type { ShellContextMenuActions } from "./items";
Expand All @@ -24,15 +24,14 @@ export function ShellContextMenu({ onSignOut }: { readonly onSignOut: () => void
const { selectedTenantId } = useBench();
const navigate = useNavigate();
const openProfile = useOpenProfileInCanvas();
const { cycleMode } = useTheme();
const queryClient = useQueryClient();
const { open, x, y, menu, triggerElement, show, hide } = useContextMenuState();

const actions: ShellContextMenuActions = {
tenantId: selectedTenantId,
navigate,
openProfile,
cycleTheme: cycleMode,
cycleTheme,
signOut: onSignOut,
onRoutineRan: (tenantId) => invalidateRoutineQueries(queryClient, tenantId),
};
Expand All @@ -48,7 +47,7 @@ export function ShellContextMenu({ onSignOut }: { readonly onSignOut: () => void
},
// `actions` is a fresh object every render; the values it closes over
// are what actually determine the menu, so those are the real deps.
[selectedTenantId, navigate, openProfile, cycleMode, onSignOut, queryClient],
[selectedTenantId, navigate, openProfile, onSignOut, queryClient],
);

useDocumentContextMenuTrigger({ resolve, onOpen: show });
Expand Down
10 changes: 2 additions & 8 deletions apps/web/src/test-query-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// retry:false + gcTime:0 keep test failures loud and cache-free; matches
// main.tsx's ThemeProvider so shell chrome renders under the same contract.
// retry:false + gcTime:0 keep test failures loud and cache-free.

import { ThemeProvider } from "@corbits/react-ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";

Expand All @@ -24,9 +22,5 @@ export function TestQueryProvider({
readonly children: ReactNode;
readonly client?: QueryClient;
}) {
return (
<ThemeProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</ThemeProvider>
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
78 changes: 78 additions & 0 deletions apps/web/src/theme-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// The theme lives outside React: `data-theme` on <html> is the one switch,
// and this store is its only writer. Light is the absence of the attribute.

import { reportError } from "@corbits/error-sink";
import { useSyncExternalStore } from "react";

export const THEMES = ["light", "dark", "canvas"] as const;
export type Theme = (typeof THEMES)[number];

export function isTheme(value: unknown): value is Theme {
return THEMES.some((theme) => theme === value);
}

const DEFAULT_KEY = "corbits-theme";

let storageKey = DEFAULT_KEY;
let current: Theme = "light";
const listeners = new Set<() => void>();

function readStored(key: string): Theme {
try {
const raw = window.localStorage.getItem(key);
return isTheme(raw) ? raw : "light";
} catch (error) {
reportError(error, { operation: "theme_read" });
return "light";
}
}

function apply(theme: Theme) {
const root = document.documentElement;
if (theme === "light") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", theme);
// react-ui's `dark:` variant keys off the class.
root.classList.toggle("dark", theme === "dark");
}

function commit(theme: Theme) {
current = theme;
apply(theme);
for (const listener of listeners) listener();
}

/** Point the store at a per-user key and adopt whatever that key holds. */
export function setThemeStorageKey(key: string) {
if (key !== storageKey) {
storageKey = key;
commit(readStored(key));
}
}

export function setTheme(theme: Theme) {
commit(theme);
try {
window.localStorage.setItem(storageKey, theme);
} catch (error) {
reportError(error, { operation: "theme_persist" });
}
}

export function cycleTheme() {
const index = THEMES.indexOf(current);
setTheme(THEMES[(index + 1) % THEMES.length] ?? "light");
}

function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function useTheme(): Theme {
return useSyncExternalStore(subscribe, () => current);
}

// First paint uses the signed-out key; the session key takes over once known.
if (typeof document !== "undefined") commit(readStored(storageKey));
Loading
Loading