Adds 18 preset themes (16 dual-mode + 2 light-only) selectable from
a new /settings route. Persists per-user via the existing key-value
settings table — no schema refactor. Default on first load is
obsidian dark.
Storage: two new seeded keys (theme_id, theme_mode) inserted
idempotently from schema.sql. PATCH /api/settings tightens validation
with a discriminated branch — theme_id must be one of the 18
whitelisted ids, theme_mode ∈ {dark,light,system}, anything else
rejects 400. Other keys pass through the loose record schema.
CSS layer: 18 files in apps/web/src/styles/themes/, each declaring
.theme-<id> (light) and .theme-<id>.dark (dark) — except ivory and
chalk which are light-only. Anchor-to-token mapping per spec §3.
--destructive stays red across all themes. --radius unchanged at
0.625rem (spec parenthetical was about "not per-theme", not a
specific value swap).
Frontend: lib/theme.ts owns THEMES, applyTheme(), setTheme(), and
useTheme() — module-singleton with optimistic PATCH + revert on
failure (mirrors useChatStatus / useSidebar pattern). Settings.tsx
renders a 3-col (md) / 2-col (mobile) grid of shadcn Card swatches
with a Dark/Light/System radio group on top. App.tsx mounts
useTheme() at AppShell top and wires the /settings route.
index.html ships a pre-React FOUC script that reads localStorage
'boocode.theme' and stamps the className on <html> before any
paint. Stripped two pre-existing dark-mode lock-ins (AppShell's
hardcoded 'dark' className and body's neutral-950/100 tailwind
utilities) that would have fought theme tokens.
Light-only + dark request → falls back to obsidian dark in three
places: lib/theme.ts effectiveThemeId(), the FOUC script, and the
picker's "Light only" badge. No inline message; matches spec §8
decision 1.
shadcn primitives card and radio-group installed via shadcn CLI
(no hand-rolling). card.tsx and radio-group.tsx are the only ui/
additions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { BrowserRouter, Routes, Route, useParams } from 'react-router-dom';
|
|
import { api } from '@/api/client';
|
|
import { ProjectSidebar } from '@/components/ProjectSidebar';
|
|
import { RightRail } from '@/components/RightRail';
|
|
import { Home } from '@/pages/Home';
|
|
import { Project } from '@/pages/Project';
|
|
import { Session } from '@/pages/Session';
|
|
import { Settings } from '@/pages/Settings';
|
|
import { Toaster } from '@/components/ui/sonner';
|
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
|
import { useTheme } from '@/lib/theme';
|
|
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
|
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
|
import { useViewport } from '@/hooks/useViewport';
|
|
|
|
function SessionRightRail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
if (!id) return null;
|
|
return <RightRailForSession sessionId={id} />;
|
|
}
|
|
|
|
function RightRailForSession({ sessionId }: { sessionId: string }) {
|
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
api.sessions
|
|
.get(sessionId)
|
|
.then((s) => setProjectId(s.project_id))
|
|
.catch((err) => console.warn('RightRail: failed to fetch session', err));
|
|
}, [sessionId]);
|
|
if (!projectId) return null;
|
|
// v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as
|
|
// a right-side drawer toggled by the header's FolderTree button (via
|
|
// useRightRailDrawer). On desktop, it renders inline as before with its
|
|
// own internal open/close state.
|
|
return <RightRail projectId={projectId} />;
|
|
}
|
|
|
|
function MobileBackdrop() {
|
|
const { open, setOpen } = useSidebarDrawer();
|
|
const { isMobile } = useViewport();
|
|
if (!isMobile || !open) return null;
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
|
onClick={() => setOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function MobileRightRailBackdrop() {
|
|
const { open, setOpen } = useRightRailDrawer();
|
|
const { isMobile } = useViewport();
|
|
if (!isMobile || !open) return null;
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
|
onClick={() => setOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AppShell() {
|
|
// themes-v1: useTheme() owns the matchMedia subscription for system mode
|
|
// and reconciles cache with /api/settings on mount. Mounted first so the
|
|
// theme class on <html> is correct before any child renders.
|
|
useTheme();
|
|
useUserEvents();
|
|
return (
|
|
<div className="h-screen flex bg-background text-foreground">
|
|
<ProjectSidebar />
|
|
<MobileBackdrop />
|
|
<main className="flex-1 flex flex-col min-w-0">
|
|
<Routes>
|
|
<Route path="/" element={<Home />} />
|
|
<Route path="/project/:id" element={<Project />} />
|
|
<Route path="/session/:id" element={<Session />} />
|
|
<Route path="/settings" element={<Settings />} />
|
|
</Routes>
|
|
</main>
|
|
<MobileRightRailBackdrop />
|
|
<Routes>
|
|
<Route path="/session/:id" element={<SessionRightRail />} />
|
|
</Routes>
|
|
<Toaster position="bottom-right" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<SidebarDrawerProvider>
|
|
<RightRailDrawerProvider>
|
|
<AppShell />
|
|
</RightRailDrawerProvider>
|
|
</SidebarDrawerProvider>
|
|
</BrowserRouter>
|
|
);
|
|
}
|