import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react"; import { COLOR_THEMES, type ThemeMode, type ColorTheme } from "@fusion/core"; import { fetchGlobalSettings, updateGlobalSettings } from "../api"; const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode"; const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme"; const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct"; const DEFAULT_FONT_SCALE_PCT = 100; const MIN_FONT_SCALE_PCT = 85; const MAX_FONT_SCALE_PCT = 125; const VALID_COLOR_THEMES = [...COLOR_THEMES] satisfies ColorTheme[]; const THEME_DATA_ID = "theme-data"; const THEME_DATA_FILENAME = "theme-data.css"; /** * Get the resolved URL for theme-data.css. * * This function handles both HTTP/HTTPS origins and Electron file:// contexts. * Using document.baseURI ensures the stylesheet path resolves correctly regardless * of whether the app is served over HTTP or loaded from a file:// URL. * * For file:// URLs, the path is derived relative to the HTML file's directory. * For HTTP/HTTPS URLs, the path is derived relative to the HTML file's directory * (same as file://) to ensure correct resolution in nested deployments. */ function getThemeDataUrl(): string { // Get base URL from document.baseURI (most reliable across contexts) // Falls back to document.location.href if baseURI is unavailable const base = document.baseURI || (typeof document.location !== "undefined" ? document.location.href : ""); if (!base) { // Fallback to absolute path if no base available return `/${THEME_DATA_FILENAME}`; } // Derive path relative to HTML file directory // Handle two cases: // 1. Base ends with "/" (directory path): replace trailing "/" with "/filename" // 2. Base ends with filename: replace filename with "/filename" if (base.endsWith("/")) { // Directory path: replace trailing "/" with "/theme-data.css" return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`; } else { // Filename path: replace last segment with "/theme-data.css" return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`); } } // Check if we're in a browser environment const isBrowser = typeof window !== "undefined"; // Use useLayoutEffect on client, useEffect on server (no-op) const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect; interface UseThemeReturn { themeMode: ThemeMode; colorTheme: ColorTheme; dashboardFontScalePct: number; setThemeMode: (mode: ThemeMode) => void; setColorTheme: (theme: ColorTheme) => void; setDashboardFontScalePct: (scalePct: number) => void; isSystemDark: boolean; } function isValidThemeMode(value: unknown): value is ThemeMode { return value === "dark" || value === "light" || value === "system"; } function readCachedThemeMode(): ThemeMode { if (!isBrowser) return "dark"; try { const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY); if (isValidThemeMode(saved)) { return saved; } } catch { // localStorage not available, use default } return "dark"; } function readCachedColorTheme(): ColorTheme { if (!isBrowser) return "default"; try { const saved = localStorage.getItem(COLOR_THEME_STORAGE_KEY); if (saved && VALID_COLOR_THEMES.includes(saved as ColorTheme)) { return saved as ColorTheme; } } catch { // localStorage not available, use default } return "default"; } function writeCachedThemeMode(mode: ThemeMode): void { if (!isBrowser) return; try { localStorage.setItem(THEME_MODE_STORAGE_KEY, mode); } catch { // localStorage not available, skip cache write } } function writeCachedColorTheme(theme: ColorTheme): void { if (!isBrowser) return; try { localStorage.setItem(COLOR_THEME_STORAGE_KEY, theme); } catch { // localStorage not available, skip cache write } } function normalizeFontScalePct(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_FONT_SCALE_PCT; } return Math.min(MAX_FONT_SCALE_PCT, Math.max(MIN_FONT_SCALE_PCT, Math.round(value))); } function readCachedDashboardFontScalePct(): number { if (!isBrowser) return DEFAULT_FONT_SCALE_PCT; try { const saved = Number(localStorage.getItem(FONT_SCALE_STORAGE_KEY)); return normalizeFontScalePct(saved); } catch { return DEFAULT_FONT_SCALE_PCT; } } function writeCachedDashboardFontScalePct(scalePct: number): void { if (!isBrowser) return; try { localStorage.setItem(FONT_SCALE_STORAGE_KEY, String(normalizeFontScalePct(scalePct))); } catch { // localStorage not available, skip cache write } } /** * Get the effective theme mode (resolves "system" to actual dark/light value) */ function getEffectiveThemeMode(mode: ThemeMode, systemIsDark: boolean): "dark" | "light" { if (mode === "system") { return systemIsDark ? "dark" : "light"; } return mode; } /** * Apply theme attributes to document.documentElement * Call this immediately to prevent flash of wrong theme */ function applyThemeAttributes( themeMode: ThemeMode, colorTheme: ColorTheme, dashboardFontScalePct: number, systemIsDark: boolean, ): void { if (!isBrowser) return; const effectiveMode = getEffectiveThemeMode(themeMode, systemIsDark); document.documentElement.setAttribute("data-theme", effectiveMode); document.documentElement.setAttribute("data-color-theme", colorTheme); document.documentElement.style.fontSize = `${normalizeFontScalePct(dashboardFontScalePct)}%`; } /** * Load theme-data.css for non-default themes. * Safely handles existing links by checking href and updating if stale. * After href reconciliation, existing links are moved to the end of
* to ensure color-theme CSS rules take precedence over base token * redefinitions in subsequent stylesheets (CSS cascade correctness). * * This is critical for dark mode: if #theme-data is injected early by * pre-hydration scripts but styles.css loads later and redefines base * tokens, those redefinitions win the cascade unless theme-data is * repositioned to come after them. */ function loadThemeDataStylesheet(): void { if (!isBrowser) return; const expectedHref = getThemeDataUrl(); const existingLink = document.getElementById(THEME_DATA_ID) as HTMLLinkElement | null; if (existingLink) { // Link exists - update href if it differs from expected (handles baseURI changes) if (existingLink.href !== expectedHref) { existingLink.href = expectedHref; } // Move existing link to end of for CSS cascade correctness. // This ensures color-theme rules (which use [data-color-theme="..."] selectors) // are evaluated AFTER any subsequent stylesheets that might redefine base tokens. // Without this, dark color themes can appear broken because base token // redefinitions win the cascade over color-theme rules. if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) { document.head.appendChild(existingLink); } return; } // No existing link - create one const link = document.createElement("link"); link.rel = "stylesheet"; link.href = expectedHref; link.id = THEME_DATA_ID; document.head.appendChild(link); } /** * Unload theme-data.css when returning to default theme. */ function unloadThemeDataStylesheet(): void { if (!isBrowser) return; const existing = document.getElementById(THEME_DATA_ID); if (existing) { existing.remove(); } } /** * Custom hook for theme management. * * Source of truth: backend global settings (`~/.fusion/settings.json`). * * Behavior: * - Initializes from localStorage cache to avoid pre-hydration theme flash * - Hydrates from backend global settings on mount and reconciles cache * - Writes through on updates (state + localStorage cache + async backend update) */ export function useTheme(): UseThemeReturn { // Initialize from localStorage cache or defaults to avoid flash before hydration. const [themeMode, setThemeModeState] = useState