{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-theme",
  "type": "registry:lib",
  "title": "useTheme hook",
  "description": "@interlace/ui — reads and writes the theme (`data-theme`) and colour scheme (`.dark`) axes, persists to localStorage, and follows the OS when no preference is stored.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "foundation",
    "util"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme-script.json",
    "https://ds.interlace.tools/r/theme-tokens.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/lib/use-theme.ts",
      "target": "hooks/use-theme.ts",
      "type": "registry:lib",
      "content": "'use client';\n\nimport { useCallback, useEffect, useState } from 'react';\n\n// @interlace/use-theme v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/use-theme\n// What changed since: https://ds.interlace.tools/c/use-theme#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — useTheme.\n *\n * The runtime half of the two-axis theme contract. It owns the two pieces of\n * state a user can change (which THEME, which SCHEME), persists them, and\n * projects them onto `<html>` in exactly the shape `styles/index.css`\n * expects:\n *\n *   theme   → `data-theme=\"<name>\"`   (absent = the default, which IS `:root`)\n *   scheme  → `class=\"dark\"`          (shadcn / next-themes canon)\n *\n * ─── No dependency, but not no coordination ──────────────────────\n *\n * next-themes is ~3kB to do this, and its API surface (`forcedTheme`,\n * `enableColorScheme`, `nonce`, `themes[]`, a provider, a context) exists to\n * serve apps whose theme list is dynamic. Ours is a compile-time constant\n * with a machine-checked contract behind it, so the provider has nothing to\n * provide: the `<html>` element is the shared state, and a Context would be a\n * SECOND source of truth that can disagree with the DOM after the bootstrap\n * script runs — the exact bug class the script exists to avoid.\n *\n * What the DOM cannot do on its own is tell React that it changed. Two\n * instances of this hook in ONE document — a switcher in the nav and, say, a\n * themed preview frame in the page — each own their own `useState`, so the\n * one that did not handle the click keeps rendering the previous theme\n * forever: `storage` events fire in OTHER documents only, so nothing wakes\n * it. Found exactly that way (Phase 8.4: ds.interlace.tools repainted into\n * Harbor while every embedded preview stayed Interlace-orange), and it is\n * the sort of bug that reads as \"theming doesn't work\" rather than as a\n * missing subscription.\n *\n * The fix is a module-level subscriber list, not a provider: writes are\n * broadcast to every live instance in the document. It is deliberately the\n * VALUE that is broadcast rather than a \"re-read storage\" ping, because\n * storage can refuse the write (Safari private mode) and a re-read would\n * then roll the user's click back to the previous theme — turning a\n * degraded-but-working page into a control that visibly does nothing.\n *\n * ─── Hydration ───────────────────────────────────────────────────\n *\n * The hook starts at the defaults on both server and client, then syncs from\n * `localStorage` in an effect. That is deliberate: reading storage in a lazy\n * `useState` initialiser would produce different markup on server and client\n * and React would throw the tree away. The PAGE does not flash while this\n * happens — `THEME_SCRIPT` already painted the right colours before first\n * paint — only the switcher's own checkmark settles a frame late, which is\n * why `mounted` is returned. Render the control disabled/neutral until then\n * if the wrong tick would mislead.\n *\n * ─── Preference vs resolved scheme ───────────────────────────────\n *\n * `schemePreference` is what the user chose (`'light' | 'dark' | 'system'`).\n * `scheme` is what that resolves to right now. They are different values and\n * conflating them is why so many switchers stop following the OS after the\n * user has touched them once: storing the RESOLVED value turns \"follow my\n * system\" into \"dark, forever, because it was dark when I clicked\".\n */\n\nimport { SCHEME_STORAGE_KEY, THEME_STORAGE_KEY } from '@/lib/theme-script';\nimport {\n  DEFAULT_THEME,\n  isScheme,\n  isThemeName,\n  THEMES,\n  type Scheme,\n  type ThemeName,\n} from '@/lib/theme-tokens';\n\n/** What the user chose. `'system'` means \"follow `prefers-color-scheme`\". */\nexport type SchemePreference = Scheme | 'system';\n\nconst DARK_QUERY = '(prefers-color-scheme: dark)';\n\n/**\n * `localStorage` access that cannot take the page down.\n *\n * Safari in private browsing throws on `localStorage` — not on write, on\n * ACCESS — and a theme hook is not worth a blank page. Same reasoning as the\n * `try` in THEME_SCRIPT.\n */\nfunction readStorage(key: string): string | null {\n  try {\n    return window.localStorage.getItem(key);\n  } catch {\n    return null;\n  }\n}\n\nfunction writeStorage(key: string, value: string | null): void {\n  try {\n    if (value === null) window.localStorage.removeItem(key);\n    else window.localStorage.setItem(key, value);\n  } catch {\n    /* Storage unavailable (private mode, quota, blocked). The in-memory\n     * state still applies for this page; it just will not survive a\n     * reload — which is strictly better than throwing out of a click. */\n  }\n}\n\n/**\n * Every live `useTheme()` in THIS document, by axis.\n *\n * Module scope, so it is per-bundle rather than per-tree — which is what\n * makes it work without a provider anywhere. Two separate sets rather than\n * one state object: the axes are independent, and broadcasting a pair would\n * make a theme change re-render every scheme-only consumer for nothing.\n */\nconst themeSubscribers = new Set<(theme: ThemeName) => void>();\nconst schemeSubscribers = new Set<(preference: SchemePreference) => void>();\n\n/** The OS preference right now. `'light'` where the query is unsupported. */\nexport function systemScheme(): Scheme {\n  if (typeof window === 'undefined' || !window.matchMedia) return 'light';\n  return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light';\n}\n\n/**\n * The persisted theme, or `null` when the user has not chosen one.\n *\n * An unregistered value reads as `null` rather than being written through:\n * `localStorage` outlives any theme we ever remove, and `data-theme=\"ember\"`\n * pointing at a selector no stylesheet defines renders the default theme\n * with the switcher insisting a different one is active.\n */\nexport function readStoredTheme(): ThemeName | null {\n  const stored = readStorage(THEME_STORAGE_KEY);\n  return isThemeName(stored) ? stored : null;\n}\n\n/** The persisted scheme preference. Absent or corrupt reads as `'system'`. */\nexport function readStoredScheme(): SchemePreference {\n  const stored = readStorage(SCHEME_STORAGE_KEY);\n  return isScheme(stored) ? stored : 'system';\n}\n\n/**\n * Project a (theme, scheme) pair onto `<html>`.\n *\n * The default theme is written as the ABSENCE of `data-theme`, because\n * `:root` already is that theme — writing `data-theme=\"interlace\"` would add\n * a redundant selector that has to be kept in sync with a stylesheet rule\n * that does not exist.\n *\n * `style.colorScheme` is not decoration: it is what makes the browser paint\n * form controls, scrollbars and the pre-CSS canvas in the matching scheme.\n * Without it a dark page keeps a white scrollbar and white select popups.\n */\nexport function applyTheme(theme: ThemeName, scheme: Scheme): void {\n  if (typeof document === 'undefined') return;\n  const root = document.documentElement;\n  if (theme === DEFAULT_THEME) root.removeAttribute('data-theme');\n  else root.setAttribute('data-theme', theme);\n  root.classList.toggle('dark', scheme === 'dark');\n  root.style.colorScheme = scheme;\n}\n\nexport interface UseThemeResult {\n  /** The active theme name. */\n  theme: ThemeName;\n  /** Choose a theme. Persists, and repaints `<html>`. */\n  setTheme: (theme: ThemeName) => void;\n  /** The resolved colour scheme — what is on screen right now. */\n  scheme: Scheme;\n  /** What the user chose; `'system'` follows `prefers-color-scheme`. */\n  schemePreference: SchemePreference;\n  /** Choose a scheme preference. `'system'` clears the stored override. */\n  setScheme: (preference: SchemePreference) => void;\n  /** The theme registry, for rendering a picker. */\n  themes: typeof THEMES;\n  /** `false` until the first client effect has read storage. */\n  mounted: boolean;\n}\n\nexport function useTheme(): UseThemeResult {\n  const [theme, setThemeState] = useState<ThemeName>(DEFAULT_THEME);\n  const [schemePreference, setSchemePreferenceState] =\n    useState<SchemePreference>('system');\n  const [resolvedSystem, setResolvedSystem] = useState<Scheme>('light');\n  const [mounted, setMounted] = useState(false);\n\n  // Sync from storage + OS on mount. Runs once; everything after this is\n  // driven by user action, another tab, or the OS.\n  useEffect(() => {\n    setThemeState(readStoredTheme() ?? DEFAULT_THEME);\n    setSchemePreferenceState(readStoredScheme());\n    setResolvedSystem(systemScheme());\n    setMounted(true);\n  }, []);\n\n  // Follow the OS while the page is open — a user who flips their system\n  // theme at sunset expects the tab they left open to follow.\n  useEffect(() => {\n    if (!window.matchMedia) return;\n    const query = window.matchMedia(DARK_QUERY);\n    const onChange = (event: MediaQueryListEvent) =>\n      setResolvedSystem(event.matches ? 'dark' : 'light');\n    query.addEventListener('change', onChange);\n    return () => query.removeEventListener('change', onChange);\n  }, []);\n\n  // Follow the other instances in THIS document. `setThemeState` /\n  // `setSchemePreferenceState` are stable, so the subscription is registered\n  // once and the set never churns.\n  useEffect(() => {\n    themeSubscribers.add(setThemeState);\n    schemeSubscribers.add(setSchemePreferenceState);\n    return () => {\n      themeSubscribers.delete(setThemeState);\n      schemeSubscribers.delete(setSchemePreferenceState);\n    };\n  }, []);\n\n  // Follow other tabs. Without this, a user with two tabs open switches the\n  // theme in one and the other keeps rendering the old one until reload,\n  // while `localStorage` — the thing both of them believe — already moved.\n  useEffect(() => {\n    const onStorage = (event: StorageEvent) => {\n      if (event.key === THEME_STORAGE_KEY) {\n        setThemeState(readStoredTheme() ?? DEFAULT_THEME);\n      } else if (event.key === SCHEME_STORAGE_KEY) {\n        setSchemePreferenceState(readStoredScheme());\n      }\n    };\n    window.addEventListener('storage', onStorage);\n    return () => window.removeEventListener('storage', onStorage);\n  }, []);\n\n  const scheme: Scheme =\n    schemePreference === 'system' ? resolvedSystem : schemePreference;\n\n  // Project onto the DOM — but only once storage has been read. Applying the\n  // defaults first would strip the class THEME_SCRIPT just set and flash the\n  // page light for a frame, which is the exact failure the script prevents.\n  useEffect(() => {\n    if (!mounted) return;\n    applyTheme(theme, scheme);\n  }, [mounted, theme, scheme]);\n\n  // Both writers broadcast rather than calling their own setter: the\n  // instance that handled the click is already subscribed, so one path\n  // updates all of them — including this one — and there is no ordering\n  // question about who sees the new value first.\n  const setTheme = useCallback((next: ThemeName) => {\n    writeStorage(THEME_STORAGE_KEY, next);\n    for (const notify of themeSubscribers) notify(next);\n  }, []);\n\n  const setScheme = useCallback((next: SchemePreference) => {\n    // `'system'` REMOVES the key rather than storing the string: absence is\n    // how the bootstrap script knows to consult the OS, and it keeps one\n    // representation of \"no preference\" instead of two.\n    writeStorage(SCHEME_STORAGE_KEY, next === 'system' ? null : next);\n    for (const notify of schemeSubscribers) notify(next);\n  }, []);\n\n  return {\n    theme,\n    setTheme,\n    scheme,\n    schemePreference,\n    setScheme,\n    themes: THEMES,\n    mounted,\n  };\n}\n"
    }
  ],
  "meta": {
    "tier": "util",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/use-theme\n\nInstalled to `hooks/use-theme.ts`.\n\n```tsx\nimport { /* … */ } from '@/hooks/use-theme';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/use-theme"
}
