context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. // @refresh reload
  2. import { createEffect, onMount } from "solid-js"
  3. import { createStore } from "solid-js/store"
  4. import { makeEventListener } from "@solid-primitives/event-listener"
  5. import { createSimpleContext } from "../context/helper"
  6. import oc2ThemeJson from "./themes/oc-2.json"
  7. import { resolveThemeVariant, themeToCss } from "./resolve"
  8. import { resolveThemeVariantV2, themeV2ToCss } from "./v2/resolve"
  9. import type { DesktopTheme } from "./types"
  10. export type ColorScheme = "light" | "dark" | "system"
  11. const STORAGE_KEYS = {
  12. THEME_ID: "opencode-theme-id",
  13. COLOR_SCHEME: "opencode-color-scheme",
  14. THEME_CSS_LIGHT: "opencode-theme-css-light",
  15. THEME_CSS_DARK: "opencode-theme-css-dark",
  16. } as const
  17. const THEME_STYLE_ID = "oc-theme"
  18. let files: Record<string, () => Promise<{ default: DesktopTheme }>> | undefined
  19. let ids: string[] | undefined
  20. let known: Set<string> | undefined
  21. function getFiles() {
  22. if (files) return files
  23. files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json")
  24. return files
  25. }
  26. function themeIDs() {
  27. if (ids) return ids
  28. ids = Object.keys(getFiles())
  29. .map((path) => path.slice("./themes/".length, -".json".length))
  30. .sort()
  31. return ids
  32. }
  33. function knownThemes() {
  34. if (known) return known
  35. known = new Set(themeIDs())
  36. return known
  37. }
  38. const names: Record<string, string> = {
  39. "oc-2": "OC-2",
  40. amoled: "AMOLED",
  41. aura: "Aura",
  42. ayu: "Ayu",
  43. carbonfox: "Carbonfox",
  44. catppuccin: "Catppuccin",
  45. "catppuccin-frappe": "Catppuccin Frappe",
  46. "catppuccin-macchiato": "Catppuccin Macchiato",
  47. cobalt2: "Cobalt2",
  48. cursor: "Cursor",
  49. dracula: "Dracula",
  50. everforest: "Everforest",
  51. flexoki: "Flexoki",
  52. github: "GitHub",
  53. gruvbox: "Gruvbox",
  54. kanagawa: "Kanagawa",
  55. "lucent-orng": "Lucent Orng",
  56. material: "Material",
  57. matrix: "Matrix",
  58. mercury: "Mercury",
  59. monokai: "Monokai",
  60. nightowl: "Night Owl",
  61. nord: "Nord",
  62. "one-dark": "One Dark",
  63. onedarkpro: "One Dark Pro",
  64. opencode: "OpenCode",
  65. orng: "Orng",
  66. "osaka-jade": "Osaka Jade",
  67. palenight: "Palenight",
  68. rosepine: "Rose Pine",
  69. shadesofpurple: "Shades of Purple",
  70. solarized: "Solarized",
  71. synthwave84: "Synthwave '84",
  72. tokyonight: "Tokyonight",
  73. vercel: "Vercel",
  74. vesper: "Vesper",
  75. zenburn: "Zenburn",
  76. }
  77. const oc2Theme = oc2ThemeJson as DesktopTheme
  78. function normalize(id: string | null | undefined) {
  79. return id === "oc-1" ? "oc-2" : id
  80. }
  81. function read(key: string) {
  82. if (typeof localStorage !== "object") return null
  83. try {
  84. return localStorage.getItem(key)
  85. } catch {
  86. return null
  87. }
  88. }
  89. function write(key: string, value: string) {
  90. if (typeof localStorage !== "object") return
  91. try {
  92. localStorage.setItem(key, value)
  93. } catch {}
  94. }
  95. function drop(key: string) {
  96. if (typeof localStorage !== "object") return
  97. try {
  98. localStorage.removeItem(key)
  99. } catch {}
  100. }
  101. function clear() {
  102. drop(STORAGE_KEYS.THEME_CSS_LIGHT)
  103. drop(STORAGE_KEYS.THEME_CSS_DARK)
  104. }
  105. function ensureThemeStyleElement(): HTMLStyleElement {
  106. const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
  107. if (existing) return existing
  108. const element = document.createElement("style")
  109. element.id = THEME_STYLE_ID
  110. document.head.appendChild(element)
  111. return element
  112. }
  113. function getSystemMode(): "light" | "dark" {
  114. if (typeof window !== "object") return "light"
  115. return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
  116. }
  117. function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark") {
  118. const isDark = mode === "dark"
  119. const variant = isDark ? theme.dark : theme.light
  120. const tokens = resolveThemeVariant(variant, isDark)
  121. const css = themeToCss(tokens)
  122. const v2 = themeV2ToCss(resolveThemeVariantV2(variant, isDark))
  123. if (themeId !== "oc-2") {
  124. write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, `${css}\n ${v2}`)
  125. }
  126. const fullCss = `:root {
  127. color-scheme: ${mode};
  128. --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"};
  129. ${css}
  130. ${v2}
  131. }`
  132. document.getElementById("oc-theme-preload")?.remove()
  133. ensureThemeStyleElement().textContent = fullCss
  134. document.documentElement.dataset.theme = themeId
  135. document.documentElement.dataset.colorScheme = mode
  136. // Update theme-color meta tag to match light/dark mode
  137. const meta = document.querySelector('meta[name="theme-color"]')
  138. if (meta) meta.setAttribute("content", isDark ? "#131010" : "#F8F7F7")
  139. }
  140. function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
  141. if (themeId === "oc-2") return
  142. for (const mode of ["light", "dark"] as const) {
  143. const isDark = mode === "dark"
  144. const variant = isDark ? theme.dark : theme.light
  145. const tokens = resolveThemeVariant(variant, isDark)
  146. const css = themeToCss(tokens)
  147. const v2 = themeV2ToCss(resolveThemeVariantV2(variant, isDark))
  148. write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, `${css}\n ${v2}`)
  149. }
  150. }
  151. export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
  152. name: "Theme",
  153. init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
  154. const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2"
  155. const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
  156. const mode = colorScheme === "system" ? getSystemMode() : colorScheme
  157. const [store, setStore] = createStore({
  158. themes: {
  159. "oc-2": oc2Theme,
  160. } as Record<string, DesktopTheme>,
  161. themeId,
  162. colorScheme,
  163. mode,
  164. previewThemeId: null as string | null,
  165. previewScheme: null as ColorScheme | null,
  166. })
  167. const loads = new Map<string, Promise<DesktopTheme | undefined>>()
  168. const load = (id: string) => {
  169. const next = normalize(id)
  170. if (!next) return Promise.resolve(undefined)
  171. const hit = store.themes[next]
  172. if (hit) return Promise.resolve(hit)
  173. const pending = loads.get(next)
  174. if (pending) return pending
  175. const file = getFiles()[`./themes/${next}.json`]
  176. if (!file) return Promise.resolve(undefined)
  177. const task = file()
  178. .then((mod) => {
  179. const theme = mod.default
  180. setStore("themes", next, theme)
  181. return theme
  182. })
  183. .finally(() => {
  184. loads.delete(next)
  185. })
  186. loads.set(next, task)
  187. return task
  188. }
  189. const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
  190. applyThemeCss(theme, themeId, mode)
  191. props.onThemeApplied?.(theme, mode)
  192. }
  193. const ids = () => {
  194. const extra = Object.keys(store.themes)
  195. .filter((id) => !knownThemes().has(id))
  196. .sort()
  197. const all = themeIDs()
  198. if (extra.length === 0) return all
  199. return [...all, ...extra]
  200. }
  201. const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes)
  202. const onStorage = (e: StorageEvent) => {
  203. if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) {
  204. const next = normalize(e.newValue)
  205. if (!next) return
  206. if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
  207. setStore("themeId", next)
  208. if (next === "oc-2") {
  209. clear()
  210. return
  211. }
  212. void load(next).then((theme) => {
  213. if (!theme || store.themeId !== next) return
  214. cacheThemeVariants(theme, next)
  215. })
  216. }
  217. if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
  218. setStore("colorScheme", e.newValue as ColorScheme)
  219. setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark"))
  220. }
  221. }
  222. onMount(() => {
  223. makeEventListener(window, "storage", onStorage)
  224. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
  225. const onMedia = () => {
  226. if (store.colorScheme !== "system") return
  227. setStore("mode", getSystemMode())
  228. }
  229. makeEventListener(mediaQuery, "change", onMedia)
  230. const rawTheme = read(STORAGE_KEYS.THEME_ID)
  231. const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2"
  232. const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
  233. if (rawTheme && rawTheme !== savedTheme) {
  234. write(STORAGE_KEYS.THEME_ID, savedTheme)
  235. clear()
  236. }
  237. if (savedTheme !== store.themeId) setStore("themeId", savedTheme)
  238. if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme)
  239. setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme)
  240. void load(savedTheme).then((theme) => {
  241. if (!theme || store.themeId !== savedTheme) return
  242. cacheThemeVariants(theme, savedTheme)
  243. })
  244. })
  245. createEffect(() => {
  246. const theme = store.themes[store.themeId]
  247. if (!theme) return
  248. applyTheme(theme, store.themeId, store.mode)
  249. })
  250. const setTheme = (id: string) => {
  251. const next = normalize(id)
  252. if (!next) {
  253. console.warn(`Theme "${id}" not found`)
  254. return
  255. }
  256. if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) {
  257. console.warn(`Theme "${id}" not found`)
  258. return
  259. }
  260. setStore("themeId", next)
  261. if (next === "oc-2") {
  262. write(STORAGE_KEYS.THEME_ID, next)
  263. clear()
  264. return
  265. }
  266. void load(next).then((theme) => {
  267. if (!theme || store.themeId !== next) return
  268. cacheThemeVariants(theme, next)
  269. write(STORAGE_KEYS.THEME_ID, next)
  270. })
  271. }
  272. const setColorScheme = (scheme: ColorScheme) => {
  273. setStore("colorScheme", scheme)
  274. write(STORAGE_KEYS.COLOR_SCHEME, scheme)
  275. setStore("mode", scheme === "system" ? getSystemMode() : scheme)
  276. }
  277. return {
  278. themeId: () => store.themeId,
  279. colorScheme: () => store.colorScheme,
  280. mode: () => store.mode,
  281. ids,
  282. name: (id: string) => store.themes[id]?.name ?? names[id] ?? id,
  283. loadThemes,
  284. themes: () => store.themes,
  285. setTheme,
  286. setColorScheme,
  287. registerTheme: (theme: DesktopTheme) => setStore("themes", theme.id, theme),
  288. previewTheme: (id: string) => {
  289. const next = normalize(id)
  290. if (!next) return
  291. if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
  292. setStore("previewThemeId", next)
  293. void load(next).then((theme) => {
  294. if (!theme || store.previewThemeId !== next) return
  295. const mode = store.previewScheme
  296. ? store.previewScheme === "system"
  297. ? getSystemMode()
  298. : store.previewScheme
  299. : store.mode
  300. applyTheme(theme, next, mode)
  301. })
  302. },
  303. previewColorScheme: (scheme: ColorScheme) => {
  304. setStore("previewScheme", scheme)
  305. const mode = scheme === "system" ? getSystemMode() : scheme
  306. const id = store.previewThemeId ?? store.themeId
  307. void load(id).then((theme) => {
  308. if (!theme) return
  309. if ((store.previewThemeId ?? store.themeId) !== id) return
  310. if (store.previewScheme !== scheme) return
  311. applyTheme(theme, id, mode)
  312. })
  313. },
  314. commitPreview: () => {
  315. if (store.previewThemeId) {
  316. setTheme(store.previewThemeId)
  317. }
  318. if (store.previewScheme) {
  319. setColorScheme(store.previewScheme)
  320. }
  321. setStore("previewThemeId", null)
  322. setStore("previewScheme", null)
  323. },
  324. cancelPreview: () => {
  325. setStore("previewThemeId", null)
  326. setStore("previewScheme", null)
  327. void load(store.themeId).then((theme) => {
  328. if (!theme) return
  329. applyTheme(theme, store.themeId, store.mode)
  330. })
  331. },
  332. }
  333. },
  334. })