forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 2 | * Colour theme of the app: light (the default) or dark. The choice is kept | |
| 3 | * apart from the chat and workspace state because it is a per-browser | |
| 4 | * preference, remembered in localStorage and applied to the document as a | |
| 5 | * `data-theme` attribute that the stylesheet keys its palettes on. | |
| 6 | * | |
| 7 | * @example | |
| 8 | * toggleTheme(); // light → dark, dark → light | |
| 9 | * useTheme((t) => t.theme); // inside a component | |
| 10 | * document.documentElement.dataset.theme; // "dark" | |
| 11 | */ | |
| 12 | ||
| 13 | import { create } from "zustand"; | |
| 14 | ||
| 15 | export type Theme = "light" | "dark"; | |
| 16 | ||
| 17 | /** Theme used until the user picks one. */ | |
| 18 | export const DEFAULT_THEME: Theme = "light"; | |
| 19 | /** localStorage key under which the chosen theme is remembered. */ | |
| 20 | export const THEME_KEY = "ori.theme"; | |
| 21 | ||
| 22 | export interface ThemeState { | |
| 23 | /** The active theme. */ | |
| 24 | theme: Theme; | |
| 25 | } | |
| 26 | ||
| 27 | /** isTheme narrows an arbitrary value (e.g. read from storage) to a Theme. */ | |
| 28 | export function isTheme(value: unknown): value is Theme { | |
| 29 | return value === "light" || value === "dark"; | |
| 30 | } | |
| 31 | ||
| 32 | /** React hook over the theme store. */ | |
| 33 | export const useTheme = create<ThemeState>(() => ({ theme: loadTheme() })); | |
| 34 | ||
| 35 | /** | |
| 36 | * setTheme activates a theme, reflects it on the document and remembers it. | |
| 37 | * | |
| 38 | * @example | |
| 39 | * setTheme("dark"); | |
| 40 | * useTheme.getState().theme; // "dark" | |
| 41 | */ | |
| 42 | export function setTheme(theme: Theme): void { | |
| 43 | useTheme.setState({ theme }); | |
| 44 | applyTheme(theme); | |
| 45 | saveTheme(theme); | |
| 46 | } | |
| 47 | ||
| 48 | /** toggleTheme switches between light and dark. */ | |
| 49 | export function toggleTheme(): void { | |
| 50 | setTheme(useTheme.getState().theme === "dark" ? "light" : "dark"); | |
| 51 | } | |
| 52 | ||
| 53 | /** resetTheme restores the default theme and forgets the stored one; meant for tests. */ | |
| 54 | export function resetTheme(): void { | |
| 55 | setTheme(DEFAULT_THEME); | |
| 56 | } | |
| 57 | ||
| 58 | /** applyTheme stamps the theme on <html> so the CSS palettes switch. */ | |
| 59 | function applyTheme(theme: Theme): void { | |
| 60 | document.documentElement.dataset.theme = theme; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * loadTheme reads the remembered theme. Storage can be missing or throw | |
| 65 | * (private browsing, blocked site data), and the stored value may be stale, | |
| 66 | * so any failure or unknown value yields the default. | |
| 67 | */ | |
| 68 | function loadTheme(): Theme { | |
| 69 | try { | |
| 70 | const stored = globalThis.localStorage?.getItem(THEME_KEY); | |
| 71 | return isTheme(stored) ? stored : DEFAULT_THEME; | |
| 72 | } catch { | |
| 73 | return DEFAULT_THEME; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | function saveTheme(theme: Theme): void { | |
| 78 | try { | |
| 79 | globalThis.localStorage?.setItem(THEME_KEY, theme); | |
| 80 | } catch { | |
| 81 | // Storage unavailable: the theme still applies for this page. | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | // Stamp the document as soon as the module loads, before React renders, so | |
| 86 | // the first paint already uses the remembered theme. | |
| 87 | applyTheme(useTheme.getState().theme); |