diff options
| author | Adam <[email protected]> | 2025-12-28 05:12:32 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-28 05:12:36 -0600 |
| commit | 4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7 (patch) | |
| tree | ad035366cdebc87d4eea75475201af3d11bd64ed /packages/ui/src/theme/context.tsx | |
| parent | d6db6ff198c4513ca5511ae49b03d4277bc21718 (diff) | |
| download | opencode-4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7.tar.gz opencode-4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7.zip | |
feat(desktop): themes
Diffstat (limited to 'packages/ui/src/theme/context.tsx')
| -rw-r--r-- | packages/ui/src/theme/context.tsx | 280 |
1 files changed, 280 insertions, 0 deletions
diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx new file mode 100644 index 000000000..d8ca6d507 --- /dev/null +++ b/packages/ui/src/theme/context.tsx @@ -0,0 +1,280 @@ +/** + * Theme context for SolidJS applications. + * Provides reactive theme management with localStorage persistence and caching. + * + * Works in conjunction with the preload script to provide zero-FOUC theming: + * 1. Preload script applies cached CSS immediately from localStorage + * 2. ThemeProvider takes over, resolves theme, and updates cache + */ + +import { + createContext, + useContext, + createSignal, + onMount, + onCleanup, + createEffect, + type JSX, + type Accessor, +} from "solid-js" +import type { DesktopTheme } from "./types" +import { resolveThemeVariant, themeToCss } from "./resolve" +import { STORAGE_KEYS, getThemeCacheKey } from "./preload" +import { DEFAULT_THEMES } from "./default-themes" + +export type ColorScheme = "light" | "dark" | "system" + +interface ThemeContextValue { + /** Currently active theme ID */ + themeId: Accessor<string> + /** Current color scheme preference */ + colorScheme: Accessor<ColorScheme> + /** Resolved current mode (light or dark) */ + mode: Accessor<"light" | "dark"> + /** All available themes */ + themes: Accessor<Record<string, DesktopTheme>> + /** Set the active theme by ID */ + setTheme: (id: string) => void + /** Set color scheme preference */ + setColorScheme: (scheme: ColorScheme) => void + /** Register a custom theme */ + registerTheme: (theme: DesktopTheme) => void +} + +const ThemeContext = createContext<ThemeContextValue>() + +/** + * Static tokens that don't change between themes + */ +const STATIC_TOKENS = ` + --font-family-sans: "Inter", "Inter Fallback"; + --font-family-sans--font-feature-settings: "ss03" 1; + --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback"; + --font-family-mono--font-feature-settings: "ss01" 1; + --font-size-small: 13px; + --font-size-base: 14px; + --font-size-large: 16px; + --font-size-x-large: 20px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --line-height-large: 150%; + --line-height-x-large: 180%; + --line-height-2x-large: 200%; + --letter-spacing-normal: 0; + --letter-spacing-tight: -0.16; + --letter-spacing-tightest: -0.32; + --paragraph-spacing-base: 0; + --spacing: 0.25rem; + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.625rem; + --shadow-xs: 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-md: 0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), 0 1px 2px -1px rgba(19, 16, 16, 0.12); + --shadow-xs-border: 0 0 0 1px var(--border-base), 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-base: 0 0 0 1px var(--border-weak-base), 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); + --shadow-xs-border-select: 0 0 0 3px var(--border-weak-selected), 0 0 0 1px var(--border-selected), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); + --shadow-xs-border-focus: 0 0 0 1px var(--border-base), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak), 0 0 0 3px var(--border-selected); +` + +const THEME_STYLE_ID = "oc-theme" + +function ensureThemeStyleElement(): HTMLStyleElement { + const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null + if (existing) { + return existing + } + const element = document.createElement("style") + element.id = THEME_STYLE_ID + document.head.appendChild(element) + return element +} + +/** + * Resolve a mode from system preference + */ +function getSystemMode(): "light" | "dark" { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" +} + +/** + * Apply theme CSS to the document + */ +function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark"): void { + const isDark = mode === "dark" + const variant = isDark ? theme.dark : theme.light + const tokens = resolveThemeVariant(variant, isDark) + const css = themeToCss(tokens) + + // Cache to localStorage for preload script + const cacheKey = getThemeCacheKey(themeId, mode) + try { + localStorage.setItem(cacheKey, css) + } catch { + // localStorage might be full or disabled + } + + // Build full CSS + const fullCss = `:root { + ${STATIC_TOKENS} + color-scheme: ${mode}; + --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"}; + ${css} +}` + + // Remove preload style if it exists + const preloadStyle = document.getElementById("oc-theme-preload") + if (preloadStyle) { + preloadStyle.remove() + } + + const themeStyleElement = ensureThemeStyleElement() + themeStyleElement.textContent = fullCss + + // Update data attributes + document.documentElement.dataset.theme = themeId + document.documentElement.dataset.colorScheme = mode +} + +/** + * Cache both light and dark variants of a theme + */ +function cacheThemeVariants(theme: DesktopTheme, themeId: string): void { + for (const mode of ["light", "dark"] as const) { + const isDark = mode === "dark" + const variant = isDark ? theme.dark : theme.light + const tokens = resolveThemeVariant(variant, isDark) + const css = themeToCss(tokens) + const cacheKey = getThemeCacheKey(themeId, mode) + try { + localStorage.setItem(cacheKey, css) + } catch { + // localStorage might be full or disabled + } + } +} + +export function ThemeProvider(props: { children: JSX.Element; defaultTheme?: string }) { + const [themes, setThemes] = createSignal<Record<string, DesktopTheme>>(DEFAULT_THEMES) + const [themeId, setThemeIdSignal] = createSignal(props.defaultTheme ?? "oc-1") + const [colorScheme, setColorSchemeSignal] = createSignal<ColorScheme>("system") + const [mode, setMode] = createSignal<"light" | "dark">(getSystemMode()) + + // Listen for system color scheme changes + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handler = () => { + if (colorScheme() === "system") { + setMode(getSystemMode()) + } + } + mediaQuery.addEventListener("change", handler) + onCleanup(() => mediaQuery.removeEventListener("change", handler)) + + // Load saved preferences + const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) + const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null + + if (savedTheme && themes()[savedTheme]) { + setThemeIdSignal(savedTheme) + } + + if (savedScheme) { + setColorSchemeSignal(savedScheme) + if (savedScheme !== "system") { + setMode(savedScheme) + } + } + + // Cache current theme variants for future preloads + const currentTheme = themes()[themeId()] + if (currentTheme) { + cacheThemeVariants(currentTheme, themeId()) + } + }) + + // Apply theme when themeId or mode changes + createEffect(() => { + const id = themeId() + const m = mode() + const theme = themes()[id] + if (theme) { + applyThemeCss(theme, id, m) + } + }) + + const setTheme = (id: string) => { + const theme = themes()[id] + if (!theme) { + console.warn(`Theme "${id}" not found`) + return + } + + setThemeIdSignal(id) + localStorage.setItem(STORAGE_KEYS.THEME_ID, id) + + // Cache both variants for future preloads + cacheThemeVariants(theme, id) + } + + const setColorSchemePref = (scheme: ColorScheme) => { + setColorSchemeSignal(scheme) + localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + + if (scheme === "system") { + setMode(getSystemMode()) + } else { + setMode(scheme) + } + } + + const registerTheme = (theme: DesktopTheme) => { + setThemes((prev) => ({ + ...prev, + [theme.id]: theme, + })) + } + + return ( + <ThemeContext.Provider + value={{ + themeId, + colorScheme, + mode, + themes, + setTheme, + setColorScheme: setColorSchemePref, + registerTheme, + }} + > + {props.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 +} |
