summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/theme/context.tsx
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-28 05:12:32 -0600
committerAdam <[email protected]>2025-12-28 05:12:36 -0600
commit4a9ff9412e8daedc36319bd2ee8ca62d5aa52be7 (patch)
treead035366cdebc87d4eea75475201af3d11bd64ed /packages/ui/src/theme/context.tsx
parentd6db6ff198c4513ca5511ae49b03d4277bc21718 (diff)
downloadopencode-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.tsx280
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
+}