Post-React-migration regressions: spawn submit button moved to modal footer, sidebar footer restored (help/theme/palette/status/spawn), session card stripped of redundant Tailwind, action buttons use proper leaf-act/leaf-actions CSS structure for hover-only visibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
127 lines
3.7 KiB
TypeScript
127 lines
3.7 KiB
TypeScript
// React port of ../theme.ts. Same storage keys and `data-theme`/`data-palette`
|
|
// attributes on <html>, so this can sit alongside the vanilla shell (which
|
|
// still owns theme.ts) without either one fighting the other over state.
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useLayoutEffect,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
type Theme = "light" | "dark";
|
|
const THEME_KEY = "maestro-theme";
|
|
|
|
/** Every `data-palette` value, in picker order. Graphite is the default. */
|
|
export const PALETTES = [
|
|
"graphite",
|
|
"ice-cyan",
|
|
"periwinkle",
|
|
"amber-phosphor",
|
|
"ember",
|
|
"orchid",
|
|
] as const;
|
|
export type Palette = (typeof PALETTES)[number];
|
|
const PALETTE_KEY = "maestro-palette";
|
|
|
|
function storedTheme(): Theme | null {
|
|
try {
|
|
const v = localStorage.getItem(THEME_KEY);
|
|
return v === "light" || v === "dark" ? v : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function storedPalette(): Palette | null {
|
|
try {
|
|
const v = localStorage.getItem(PALETTE_KEY);
|
|
return (PALETTES as readonly string[]).includes(v ?? "")
|
|
? (v as Palette)
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function systemPrefersLight(): boolean {
|
|
return window.matchMedia("(prefers-color-scheme: light)").matches;
|
|
}
|
|
|
|
// Dev-only `?theme=` override for visual review, same as theme.ts's — never
|
|
// persisted, just seeds the initial pin for this session.
|
|
function devThemeOverride(): Theme | null {
|
|
if (!import.meta.env.DEV) return null;
|
|
const q = new URLSearchParams(location.search).get("theme");
|
|
return q === "light" || q === "dark" ? q : null;
|
|
}
|
|
|
|
interface ThemeContextValue {
|
|
theme: Theme;
|
|
palette: Palette;
|
|
setTheme: (t: Theme) => void;
|
|
toggleTheme: () => void;
|
|
setPalette: (p: Palette) => void;
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
|
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
const [pinnedTheme, setPinnedTheme] = useState<Theme | null>(
|
|
() => devThemeOverride() ?? storedTheme(),
|
|
);
|
|
const [systemIsLight, setSystemIsLight] = useState(systemPrefersLight);
|
|
const [palette, setPaletteState] = useState<Palette>(
|
|
() => storedPalette() ?? "graphite",
|
|
);
|
|
|
|
const theme: Theme = pinnedTheme ?? (systemIsLight ? "light" : "dark");
|
|
|
|
useLayoutEffect(() => {
|
|
const mql = window.matchMedia("(prefers-color-scheme: light)");
|
|
const onChange = (e: MediaQueryListEvent) => setSystemIsLight(e.matches);
|
|
mql.addEventListener("change", onChange);
|
|
return () => mql.removeEventListener("change", onChange);
|
|
}, []);
|
|
|
|
// Pinning is one-way (no UI un-pins back to "follow system"), so this only
|
|
// ever needs to set the attribute — never clear it — same as theme.ts.
|
|
useLayoutEffect(() => {
|
|
if (pinnedTheme) document.documentElement.dataset.theme = pinnedTheme;
|
|
}, [pinnedTheme]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (palette === "graphite") delete document.documentElement.dataset.palette;
|
|
else document.documentElement.dataset.palette = palette;
|
|
}, [palette]);
|
|
|
|
const setTheme = useCallback((t: Theme) => {
|
|
try { localStorage.setItem(THEME_KEY, t); } catch { /* test env */ }
|
|
setPinnedTheme(t);
|
|
}, []);
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
setTheme(theme === "dark" ? "light" : "dark");
|
|
}, [theme, setTheme]);
|
|
|
|
const setPalette = useCallback((p: Palette) => {
|
|
try { localStorage.setItem(PALETTE_KEY, p); } catch { /* test env */ }
|
|
setPaletteState(p);
|
|
}, []);
|
|
|
|
return (
|
|
<ThemeContext.Provider
|
|
value={{ theme, palette, setTheme, toggleTheme, setPalette }}
|
|
>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
const ctx = useContext(ThemeContext);
|
|
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
|
|
return ctx;
|
|
}
|