"use client" /** * useAppTheme — Manages theme dimensions beyond light/dark: * • Contrast : "system" | "normal" | "high" | "windows" (JSON-driven palette) * • Text size : "compact" | "default" | "large" — root rem scale (industry pattern: * iOS “Larger Text”, Android font scale, Gmail/Slack density). Default keeps * 16px root; compact/large adjust modestly with xs clamped to 11px in CSS. * * **Product brand chrome** (`theme-one` / `theme-prism` / `theme-custom`) is owned by * the app’s `ProductProvider` — not this hook. Do not re-apply theme-* classes here; * each hook instance runs its own mount effect and would clobber the active product. * * Persists contrast + text size to localStorage; applies data-* on ``. * Use alongside `useTheme()` from `@exxatdesignux/ui/hooks/use-color-scheme` for light / dark / system. */ import { useCallback, useEffect, useState } from "react" import { applyWindowsContrastTheme, clearWindowsContrastTheme, } from "../theme/apply-windows-contrast-theme" export type Brand = "one" | "prism" const CONTRAST_PREFS = ["system", "normal", "high", "windows"] as const /** What the user explicitly picks (or "system" to follow OS). */ export type ContrastPreference = (typeof CONTRAST_PREFS)[number] /** The resolved mode actually applied to the DOM. */ export type ContrastMode = "normal" | "high" | "windows" /** UI text scale at the document root (rem-based UI tracks together). */ export type TextSizePreference = "compact" | "default" | "large" /** @deprecated Legacy key — product theme is persisted via the app store now. */ const LEGACY_BRAND_KEY = "exxat-brand" const CONTRAST_KEY = "exxat-contrast" const TEXT_SIZE_KEY = "exxat-text-size" const MQ = "(prefers-contrast: more)" function getOsContrast(): ContrastMode { if (typeof window === "undefined") return "normal" return window.matchMedia(MQ).matches ? "high" : "normal" } function resolveContrast(pref: ContrastPreference): ContrastMode { if (pref === "system") return getOsContrast() return pref } function normalizeContrastPref(raw: string | null): ContrastPreference { if (raw && (CONTRAST_PREFS as readonly string[]).includes(raw)) { return raw as ContrastPreference } return "system" } function applyContrast(mode: ContrastMode) { if (mode === "windows") { document.documentElement.setAttribute("data-contrast", "windows") applyWindowsContrastTheme() return } clearWindowsContrastTheme() document.documentElement.setAttribute( "data-contrast", mode === "high" ? "high" : "off", ) } function applyTextSize(pref: TextSizePreference) { const html = document.documentElement if (pref === "default") { html.removeAttribute("data-text-size") } else { html.setAttribute("data-text-size", pref) } } export function useAppTheme() { const [contrastPref, setContrastPrefState] = useState("system") const [resolvedContrast, setResolvedContrast] = useState("normal") const [textSizePref, setTextSizePrefState] = useState("default") const [mounted, setMounted] = useState(false) /* Hydrate from localStorage on first client render */ useEffect(() => { setMounted(true) // Drop stale product-brand key — ProductProvider owns theme-* classes. try { localStorage.removeItem(LEGACY_BRAND_KEY) } catch { /* ignore quota / private mode */ } const storedPref = normalizeContrastPref(localStorage.getItem(CONTRAST_KEY)) const storedText = (localStorage.getItem(TEXT_SIZE_KEY) as TextSizePreference | null) ?? "default" setContrastPrefState(storedPref) setTextSizePrefState(storedText) const resolved = resolveContrast(storedPref) setResolvedContrast(resolved) applyContrast(resolved) applyTextSize(storedText) }, []) /* Listen for OS contrast changes when preference is "system" */ useEffect(() => { if (contrastPref !== "system") return const mql = window.matchMedia(MQ) function onChange() { const resolved = getOsContrast() setResolvedContrast(resolved) applyContrast(resolved) } mql.addEventListener("change", onChange) return () => mql.removeEventListener("change", onChange) }, [contrastPref]) /* Re-apply Windows JSON palette when light/dark class toggles. */ useEffect(() => { if (resolvedContrast !== "windows") return applyWindowsContrastTheme() const html = document.documentElement const obs = new MutationObserver(() => { applyWindowsContrastTheme() }) obs.observe(html, { attributes: true, attributeFilter: ["class"] }) return () => obs.disconnect() }, [resolvedContrast]) const setContrast = useCallback((pref: ContrastPreference) => { setContrastPrefState(pref) localStorage.setItem(CONTRAST_KEY, pref) const resolved = resolveContrast(pref) setResolvedContrast(resolved) applyContrast(resolved) }, []) const setTextSize = useCallback((pref: TextSizePreference) => { setTextSizePrefState(pref) localStorage.setItem(TEXT_SIZE_KEY, pref) applyTextSize(pref) }, []) return { /** The user's preference: "system" | "normal" | "high" | "windows" */ contrastPref, /** The resolved contrast mode actually applied to the DOM. */ contrast: resolvedContrast, /** Set the contrast preference. */ setContrast, /** Text scale: "compact" | "default" | "large" */ textSizePref, setTextSize, mounted, } } /** * Applies the persisted contrast + text-size preferences for as long as the app * is mounted. Every other caller of `useAppTheme()` lives inside a menu or a * settings card, so without this at the shell root the preferences are only * applied once the user opens the control that sets them — a reload drops a * high-contrast user back to the normal palette. * * Mount once, next to the color-scheme provider. Renders nothing. */ export function AppThemeSync() { useAppTheme() return null }