context.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. /**
  2. * Theme context for SolidJS applications.
  3. * Provides reactive theme management with localStorage persistence and caching.
  4. *
  5. * Works in conjunction with the preload script to provide zero-FOUC theming:
  6. * 1. Preload script applies cached CSS immediately from localStorage
  7. * 2. ThemeProvider takes over, resolves theme, and updates cache
  8. */
  9. import {
  10. createContext,
  11. useContext,
  12. createSignal,
  13. onMount,
  14. onCleanup,
  15. createEffect,
  16. type JSX,
  17. type Accessor,
  18. } from "solid-js"
  19. import type { DesktopTheme } from "./types"
  20. import { resolveThemeVariant, themeToCss } from "./resolve"
  21. import { STORAGE_KEYS, getThemeCacheKey } from "./preload"
  22. import { DEFAULT_THEMES } from "./default-themes"
  23. export type ColorScheme = "light" | "dark" | "system"
  24. interface ThemeContextValue {
  25. /** Currently active theme ID */
  26. themeId: Accessor<string>
  27. /** Current color scheme preference */
  28. colorScheme: Accessor<ColorScheme>
  29. /** Resolved current mode (light or dark) */
  30. mode: Accessor<"light" | "dark">
  31. /** All available themes */
  32. themes: Accessor<Record<string, DesktopTheme>>
  33. /** Set the active theme by ID */
  34. setTheme: (id: string) => void
  35. /** Set color scheme preference */
  36. setColorScheme: (scheme: ColorScheme) => void
  37. /** Register a custom theme */
  38. registerTheme: (theme: DesktopTheme) => void
  39. }
  40. const ThemeContext = createContext<ThemeContextValue>()
  41. /**
  42. * Static tokens that don't change between themes
  43. */
  44. const STATIC_TOKENS = `
  45. --font-family-sans: "Inter", "Inter Fallback";
  46. --font-family-sans--font-feature-settings: "ss03" 1;
  47. --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
  48. --font-family-mono--font-feature-settings: "ss01" 1;
  49. --font-size-small: 13px;
  50. --font-size-base: 14px;
  51. --font-size-large: 16px;
  52. --font-size-x-large: 20px;
  53. --font-weight-regular: 400;
  54. --font-weight-medium: 500;
  55. --line-height-large: 150%;
  56. --line-height-x-large: 180%;
  57. --line-height-2x-large: 200%;
  58. --letter-spacing-normal: 0;
  59. --letter-spacing-tight: -0.16;
  60. --letter-spacing-tightest: -0.32;
  61. --paragraph-spacing-base: 0;
  62. --spacing: 0.25rem;
  63. --breakpoint-sm: 40rem;
  64. --breakpoint-md: 48rem;
  65. --breakpoint-lg: 64rem;
  66. --breakpoint-xl: 80rem;
  67. --breakpoint-2xl: 96rem;
  68. --container-3xs: 16rem;
  69. --container-2xs: 18rem;
  70. --container-xs: 20rem;
  71. --container-sm: 24rem;
  72. --container-md: 28rem;
  73. --container-lg: 32rem;
  74. --container-xl: 36rem;
  75. --container-2xl: 42rem;
  76. --container-3xl: 48rem;
  77. --container-4xl: 56rem;
  78. --container-5xl: 64rem;
  79. --container-6xl: 72rem;
  80. --container-7xl: 80rem;
  81. --radius-xs: 0.125rem;
  82. --radius-sm: 0.25rem;
  83. --radius-md: 0.375rem;
  84. --radius-lg: 0.5rem;
  85. --radius-xl: 0.625rem;
  86. --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);
  87. --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);
  88. --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);
  89. --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);
  90. --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);
  91. --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);
  92. `
  93. const THEME_STYLE_ID = "oc-theme"
  94. function ensureThemeStyleElement(): HTMLStyleElement {
  95. const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
  96. if (existing) {
  97. return existing
  98. }
  99. const element = document.createElement("style")
  100. element.id = THEME_STYLE_ID
  101. document.head.appendChild(element)
  102. return element
  103. }
  104. /**
  105. * Resolve a mode from system preference
  106. */
  107. function getSystemMode(): "light" | "dark" {
  108. return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
  109. }
  110. /**
  111. * Apply theme CSS to the document
  112. */
  113. function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark"): void {
  114. const isDark = mode === "dark"
  115. const variant = isDark ? theme.dark : theme.light
  116. const tokens = resolveThemeVariant(variant, isDark)
  117. const css = themeToCss(tokens)
  118. // Cache to localStorage for preload script
  119. const cacheKey = getThemeCacheKey(themeId, mode)
  120. try {
  121. localStorage.setItem(cacheKey, css)
  122. } catch {
  123. // localStorage might be full or disabled
  124. }
  125. // Build full CSS
  126. const fullCss = `:root {
  127. ${STATIC_TOKENS}
  128. color-scheme: ${mode};
  129. --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"};
  130. ${css}
  131. }`
  132. // Remove preload style if it exists
  133. const preloadStyle = document.getElementById("oc-theme-preload")
  134. if (preloadStyle) {
  135. preloadStyle.remove()
  136. }
  137. const themeStyleElement = ensureThemeStyleElement()
  138. themeStyleElement.textContent = fullCss
  139. // Update data attributes
  140. document.documentElement.dataset.theme = themeId
  141. document.documentElement.dataset.colorScheme = mode
  142. }
  143. /**
  144. * Cache both light and dark variants of a theme
  145. */
  146. function cacheThemeVariants(theme: DesktopTheme, themeId: string): void {
  147. for (const mode of ["light", "dark"] as const) {
  148. const isDark = mode === "dark"
  149. const variant = isDark ? theme.dark : theme.light
  150. const tokens = resolveThemeVariant(variant, isDark)
  151. const css = themeToCss(tokens)
  152. const cacheKey = getThemeCacheKey(themeId, mode)
  153. try {
  154. localStorage.setItem(cacheKey, css)
  155. } catch {
  156. // localStorage might be full or disabled
  157. }
  158. }
  159. }
  160. export function ThemeProvider(props: { children: JSX.Element; defaultTheme?: string }) {
  161. const [themes, setThemes] = createSignal<Record<string, DesktopTheme>>(DEFAULT_THEMES)
  162. const [themeId, setThemeIdSignal] = createSignal(props.defaultTheme ?? "oc-1")
  163. const [colorScheme, setColorSchemeSignal] = createSignal<ColorScheme>("system")
  164. const [mode, setMode] = createSignal<"light" | "dark">(getSystemMode())
  165. // Listen for system color scheme changes
  166. onMount(() => {
  167. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
  168. const handler = () => {
  169. if (colorScheme() === "system") {
  170. setMode(getSystemMode())
  171. }
  172. }
  173. mediaQuery.addEventListener("change", handler)
  174. onCleanup(() => mediaQuery.removeEventListener("change", handler))
  175. // Load saved preferences
  176. const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID)
  177. const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null
  178. if (savedTheme && themes()[savedTheme]) {
  179. setThemeIdSignal(savedTheme)
  180. }
  181. if (savedScheme) {
  182. setColorSchemeSignal(savedScheme)
  183. if (savedScheme !== "system") {
  184. setMode(savedScheme)
  185. }
  186. }
  187. // Cache current theme variants for future preloads
  188. const currentTheme = themes()[themeId()]
  189. if (currentTheme) {
  190. cacheThemeVariants(currentTheme, themeId())
  191. }
  192. })
  193. // Apply theme when themeId or mode changes
  194. createEffect(() => {
  195. const id = themeId()
  196. const m = mode()
  197. const theme = themes()[id]
  198. if (theme) {
  199. applyThemeCss(theme, id, m)
  200. }
  201. })
  202. const setTheme = (id: string) => {
  203. const theme = themes()[id]
  204. if (!theme) {
  205. console.warn(`Theme "${id}" not found`)
  206. return
  207. }
  208. setThemeIdSignal(id)
  209. localStorage.setItem(STORAGE_KEYS.THEME_ID, id)
  210. // Cache both variants for future preloads
  211. cacheThemeVariants(theme, id)
  212. }
  213. const setColorSchemePref = (scheme: ColorScheme) => {
  214. setColorSchemeSignal(scheme)
  215. localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme)
  216. if (scheme === "system") {
  217. setMode(getSystemMode())
  218. } else {
  219. setMode(scheme)
  220. }
  221. }
  222. const registerTheme = (theme: DesktopTheme) => {
  223. setThemes((prev) => ({
  224. ...prev,
  225. [theme.id]: theme,
  226. }))
  227. }
  228. return (
  229. <ThemeContext.Provider
  230. value={{
  231. themeId,
  232. colorScheme,
  233. mode,
  234. themes,
  235. setTheme,
  236. setColorScheme: setColorSchemePref,
  237. registerTheme,
  238. }}
  239. >
  240. {props.children}
  241. </ThemeContext.Provider>
  242. )
  243. }
  244. export function useTheme(): ThemeContextValue {
  245. const ctx = useContext(ThemeContext)
  246. if (!ctx) {
  247. throw new Error("useTheme must be used within a ThemeProvider")
  248. }
  249. return ctx
  250. }