diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 57923cf063..3b8f9113ab 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -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"; @@ -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"; @@ -81,7 +82,6 @@ export function CommandPaletteProvider({ const open = useCommandPaletteOpen(); const query = useCommandPaletteQuery(); const [recents, setRecents] = useState([]); - const { cycleMode } = useTheme(); const closeCanvas = useCloseCanvas(); const recentsStore = useMemo( @@ -373,7 +373,7 @@ export function CommandPaletteProvider({ path, navigate, tenantId: selectedTenantId, - cycleTheme: cycleMode, + cycleTheme, closeCanvas, }); } else if (id.startsWith("route:")) { @@ -409,7 +409,6 @@ export function CommandPaletteProvider({ navigate, path, selectedTenantId, - cycleMode, closeCanvas, pushRecent, workbenchItems, diff --git a/apps/web/src/login-routing.test.tsx b/apps/web/src/login-routing.test.tsx index 8da64811fe..9b17b5a94e 100644 --- a/apps/web/src/login-routing.test.tsx +++ b/apps/web/src/login-routing.test.tsx @@ -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"; @@ -63,16 +62,14 @@ function TestRoot({ initialSession }: { readonly initialSession: SessionState }) }; }, [handleSignedIn]); return ( - - setSession({ kind: "signed-out" })} - onRetry={() => undefined} - /> - + setSession({ kind: "signed-out" })} + onRetry={() => undefined} + /> ); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 88601c491d..5c777d337a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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"; diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index 4a603069ee..a8598d68dc 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -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"; @@ -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"); @@ -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 ( - + <> - + ); } diff --git a/apps/web/src/settings/account-section.tsx b/apps/web/src/settings/account-section.tsx index 325404f7ac..ce56bf5205 100644 --- a/apps/web/src/settings/account-section.tsx +++ b/apps/web/src/settings/account-section.tsx @@ -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"; @@ -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. */ @@ -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 ( diff --git a/apps/web/src/settings/strings.ts b/apps/web/src/settings/strings.ts index f5f644e70f..d6bcc2f470 100644 --- a/apps/web/src/settings/strings.ts +++ b/apps/web/src/settings/strings.ts @@ -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.", diff --git a/apps/web/src/shell/context-menu/shell-context-menu.tsx b/apps/web/src/shell/context-menu/shell-context-menu.tsx index 09d0d19a22..93b44a3a2e 100644 --- a/apps/web/src/shell/context-menu/shell-context-menu.tsx +++ b/apps/web/src/shell/context-menu/shell-context-menu.tsx @@ -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, @@ -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"; @@ -24,7 +24,6 @@ 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(); @@ -32,7 +31,7 @@ export function ShellContextMenu({ onSignOut }: { readonly onSignOut: () => void tenantId: selectedTenantId, navigate, openProfile, - cycleTheme: cycleMode, + cycleTheme, signOut: onSignOut, onRoutineRan: (tenantId) => invalidateRoutineQueries(queryClient, tenantId), }; @@ -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 }); diff --git a/apps/web/src/test-query-provider.tsx b/apps/web/src/test-query-provider.tsx index f55c2dddd8..3506c54a0e 100644 --- a/apps/web/src/test-query-provider.tsx +++ b/apps/web/src/test-query-provider.tsx @@ -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"; @@ -24,9 +22,5 @@ export function TestQueryProvider({ readonly children: ReactNode; readonly client?: QueryClient; }) { - return ( - - {children} - - ); + return {children}; } diff --git a/apps/web/src/theme-store.ts b/apps/web/src/theme-store.ts new file mode 100644 index 0000000000..63663f84b1 --- /dev/null +++ b/apps/web/src/theme-store.ts @@ -0,0 +1,78 @@ +// The theme lives outside React: `data-theme` on 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)); diff --git a/apps/web/src/theme-tokens.css b/apps/web/src/theme-tokens.css new file mode 100644 index 0000000000..e2ac458d88 --- /dev/null +++ b/apps/web/src/theme-tokens.css @@ -0,0 +1,179 @@ +/* Theme tokens. `:root` is Light; `` swaps the + semantic roles. Components read semantic names, never a hex. Imported last + in main.tsx so the react-ui var mapping below wins the cascade. */ + +:root { + /* Brand palette. */ + --cream: #f7ead5; + --charcoal: #2b2627; + --charcoal-soft: #5c5555; + --orange: #e98428; + --orange-hover: #d4771f; + --orange-tint: #f2b277; + --blue: #607c9a; + --blue-tint: #c5d2de; + --green: #7b9974; + --green-tint: #c1d1be; + --red: #9e4a45; /* Canyon Red: destructive only */ + --red-hover: #8a3f3b; + --red-tint: #e5c6c1; + + /* Semantic roles: Light (default). A theme is one block of these. */ + --canvas: #f3f3f2; + --surface: #ffffff; + --surface-sunk: #f7f7f6; + --ink: var(--charcoal); + --ink-2: #57534f; + --ink-3: #75706c; + --hover: rgb(0 0 0 / 4.5%); + --press: rgb(0 0 0 / 8%); + --line: rgb(0 0 0 / 8%); + --line-strong: rgb(0 0 0 / 16%); + --action: var(--orange); + --action-hover: var(--orange-hover); + --action-ink: var(--charcoal); + --attention-wash: color-mix(in srgb, var(--orange) 11%, #fff); + --info: var(--blue); + --info-wash: color-mix(in srgb, var(--blue-tint) 40%, #fff); + --success: var(--green); + --success-wash: color-mix(in srgb, var(--green-tint) 40%, #fff); + --primary: var(--charcoal); + --primary-hover: #3d3738; + --primary-ink: #fff; + --danger: var(--red); + --danger-hover: var(--red-hover); + --danger-ink: #fff; + --danger-wash: color-mix(in srgb, var(--red-tint) 40%, #fff); + --chart-ok: #3b6fa8; + --chart-fail: #a4453d; + + /* Worker hues: brand tints, charcoal marks. */ + --hue-orange-bg: var(--orange-tint); + --hue-blue-bg: var(--blue-tint); + --hue-green-bg: var(--green-tint); + --hue-ink: var(--charcoal); + + /* Elevation: ring-as-border. */ + --raised: 0 0 0 1px rgb(0 0 0 / 6%), 0 1px 2px -1px rgb(0 0 0 / 6%), 0 2px 4px 0 rgb(0 0 0 / 4%); + --raised-hover: + 0 0 0 1px rgb(0 0 0 / 8%), 0 1px 2px -1px rgb(0 0 0 / 8%), 0 2px 4px 0 rgb(0 0 0 / 6%); + --floating: 0 4px 12px -2px rgb(0 0 0 / 12%), 0 0 0 1px rgb(0 0 0 / 6%); + --overlay: 0 16px 48px -8px rgb(0 0 0 / 16%), 0 0 0 1px rgb(0 0 0 / 6%); + + --r-sm: 4px; + --r-md: 8px; + --r-lg: 12px; + --r-xl: 16px; + --r-full: 9999px; + + --font-body: "Red Hat Display", "Open Sans", Roboto, Arial, sans-serif; + --font-mono: "Space Mono", "Fira Code", Menlo, Consolas, monospace; + + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); + + --ink-soft: var(--ink-2); + --ink-faint: var(--ink-3); + color-scheme: light; +} + +[data-theme="dark"] { + --canvas: #0e0e0e; + --surface: #181818; + --surface-sunk: #141414; + --ink: #f2f2f2; + --ink-2: #bdbdbd; + --ink-3: #8f8f8f; + --hover: rgb(255 255 255 / 5%); + --press: rgb(255 255 255 / 9%); + --line: rgb(255 255 255 / 8%); + --line-strong: rgb(255 255 255 / 16%); + --action: var(--orange); + --action-hover: #f09544; + --action-ink: #111; + --attention-wash: rgb(233 132 40 / 14%); + --info: #8fa9c4; + --info-wash: rgb(96 124 154 / 22%); + --success: #9db896; + --success-wash: rgb(123 153 116 / 20%); + --primary: #f2f2f2; + --primary-hover: #fff; + --primary-ink: #111; + --danger: #d4857b; /* Canyon Red, lifted for black */ + --danger-hover: #dc948a; + --danger-ink: #111; + --danger-wash: rgb(158 74 69 / 24%); + --chart-ok: #5a8bc8; + --chart-fail: #d0665a; + --hue-orange-bg: #bf6b20; + --hue-blue-bg: #2d455c; + --hue-green-bg: #425a3d; + --hue-ink: #f7ead5; + --raised: 0 0 0 1px rgb(255 255 255 / 8%); + --raised-hover: 0 0 0 1px rgb(255 255 255 / 13%); + --floating: 0 0 0 1px rgb(255 255 255 / 10%), 0 4px 12px -2px rgb(0 0 0 / 50%); + --overlay: 0 0 0 1px rgb(255 255 255 / 10%), 0 16px 48px -8px rgb(0 0 0 / 70%); + color-scheme: dark; +} + +/* Canvas: the warm Corbits cream. */ +[data-theme="canvas"] { + --canvas: var(--cream); + --surface: #fff; + --surface-sunk: color-mix(in srgb, var(--cream) 38%, #fff); + --ink-2: var(--charcoal-soft); + --ink-3: color-mix(in srgb, var(--charcoal) 68%, #fff); + --hover: rgb(43 38 39 / 5%); + --press: rgb(43 38 39 / 9%); + --line: rgb(43 38 39 / 10%); + --line-strong: rgb(43 38 39 / 20%); + --attention-wash: color-mix(in srgb, var(--orange-tint) 32%, #fff); +} + +/* react-ui's shadcn vars follow the semantic roles, so every surface built + on them flips with the theme. Names that collide (--primary, --success) + are the semantic tokens themselves and are not remapped. */ +:root { + --radius: var(--r-md); + --background: var(--canvas); + --foreground: var(--ink); + --card: var(--surface); + --card-foreground: var(--ink); + --popover: var(--surface); + --popover-foreground: var(--ink); + --primary-foreground: var(--primary-ink); + --primary-active: var(--primary-hover); + --primary-emphasis: var(--action-hover); + --secondary: var(--surface-sunk); + --secondary-foreground: var(--ink); + --muted: var(--surface-sunk); + --muted-foreground: var(--ink-2); + --accent: var(--hover); + --accent-foreground: var(--ink); + --success-foreground: var(--action-ink); + --destructive: var(--danger); + --destructive-foreground: var(--danger-ink); + --border: var(--line); + --border-strong: var(--line-strong); + --input: var(--line-strong); + --ring: var(--action); + --ok: var(--success); + --warn: var(--action-hover); + --chart-1: var(--chart-ok); + --chart-2: var(--success); + --chart-3: var(--info); + --chart-4: var(--ink-3); + --chart-5: var(--chart-fail); +} + +body { + background: var(--canvas); + color: var(--ink); + font-family: var(--font-body); +} +code, +pre, +kbd { + font-family: var(--font-mono); +}