import { computed, ref } from "vue"; import { APP_CORNER_STYLE_STORAGE_KEY, APP_CUSTOM_UI_COLOR_DEFS, APP_CUSTOM_UI_DARK_STORAGE_KEY, APP_CUSTOM_UI_DERIVED_VAR_NAMES, APP_CUSTOM_UI_STORAGE_KEY, deriveCustomUiColors, APP_THEME_PALETTE_CLASS_NAMES, APP_THEME_PALETTE_STORAGE_KEY, APP_THEME_STORAGE_KEY, DEFAULT_APP_CUSTOM_UI_COLORS, DEFAULT_APP_CUSTOM_UI_COLORS_DARK, appCustomUiColorValue, getAppThemePaletteClass, getTauriThemeForMode, isSystemAppThemeMode, normalizeAppCustomUiColors, normalizeAppThemeMode, normalizeAppCornerStyle, normalizeAppThemePalette, resolveAppThemeAppearance, type AppCustomUiColors, type AppThemeMode, type AppThemePalette, type AppCornerStyle, } from "@/lib/app/appTheme"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; function isLinuxTauriRuntime() { return isTauriRuntime() && typeof navigator !== "undefined" && /linux/i.test(navigator.userAgent); } const savedThemeMode = safeLocalStorageGet(APP_THEME_STORAGE_KEY); const themeMode = ref(normalizeAppThemeMode(savedThemeMode)); const savedThemePalette = safeLocalStorageGet(APP_THEME_PALETTE_STORAGE_KEY); const savedThemePaletteValue = ref(normalizeAppThemePalette(savedThemePalette)); const previewedThemePalette = ref(null); // Consumers use the effective palette so editor and canvas surfaces preview in // lockstep with the document-level theme classes. const themePalette = computed(() => previewedThemePalette.value ?? savedThemePaletteValue.value); function parseStoredCustomUiColors(raw: string | null): AppCustomUiColors | null { if (!raw) return null; try { return normalizeAppCustomUiColors(JSON.parse(raw)); } catch { return null; } } const customUiColors = ref(parseStoredCustomUiColors(safeLocalStorageGet(APP_CUSTOM_UI_STORAGE_KEY)) ?? DEFAULT_APP_CUSTOM_UI_COLORS); const customUiColorsDark = ref(parseStoredCustomUiColors(safeLocalStorageGet(APP_CUSTOM_UI_DARK_STORAGE_KEY)) ?? DEFAULT_APP_CUSTOM_UI_COLORS_DARK); const activeCustomUiColors = computed(() => (isDark.value ? customUiColorsDark.value : customUiColors.value)); const savedCornerStyle = safeLocalStorageGet(APP_CORNER_STYLE_STORAGE_KEY); const cornerStyle = ref(normalizeAppCornerStyle(savedCornerStyle)); if (savedThemeMode && savedThemeMode !== themeMode.value) safeLocalStorageSet(APP_THEME_STORAGE_KEY, themeMode.value); if (savedCornerStyle && savedCornerStyle !== cornerStyle.value) safeLocalStorageSet(APP_CORNER_STYLE_STORAGE_KEY, cornerStyle.value); const systemPrefersDark = ref(readSystemPrefersDark()); const isDark = computed(() => resolveAppThemeAppearance(themeMode.value, systemPrefersDark.value) === "dark"); // Bumped at the end of every applyTheme(): theme changes are DOM mutations // (classes / inline vars), so watchers keyed on reactive sources like isDark // miss palette switches and custom-color edits. Consumers that mirror the // resolved token set (plugin iframe bridge) watch this revision instead. const themeRevision = ref(0); // Token mutations on the root that happen outside applyTheme() — e.g. the UI // and editor font settings write --font-sans / --font-mono inline — bump this // so open plugin workbench bridges re-read and re-push the resolved tokens. function bumpThemeRevision() { themeRevision.value += 1; } let pendingBumpTimer: ReturnType | undefined; // The one sanctioned way to write a design token onto the root outside // applyTheme(): writes the inline override AND bumps the theme revision. // Writing root tokens directly (documentElement.style.setProperty) skips the // bump and open plugin bridges keep stale tokens — always go through here. // `debounceMs` coalesces rapid writes (font-family preview keystrokes) into // one bump; an immediate write afterwards supersedes the pending one. function writeRootToken(cssVar: string, value: string, options?: { debounceMs?: number }) { if (typeof document === "undefined") return; document.documentElement.style.setProperty(cssVar, value); if (options?.debounceMs) { if (pendingBumpTimer) clearTimeout(pendingBumpTimer); pendingBumpTimer = setTimeout(() => { pendingBumpTimer = undefined; bumpThemeRevision(); }, options.debounceMs); return; } if (pendingBumpTimer) { clearTimeout(pendingBumpTimer); pendingBumpTimer = undefined; } bumpThemeRevision(); } let mediaQuery: MediaQueryList | null = null; let isListeningForSystemTheme = false; let cachedTauriWindow: typeof import("@tauri-apps/api/window") | null = null; function readSystemPrefersDark() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; return window.matchMedia("(prefers-color-scheme: dark)").matches; } function setupSystemThemeListener() { if (isListeningForSystemTheme || typeof window === "undefined" || typeof window.matchMedia !== "function") return; mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); systemPrefersDark.value = mediaQuery.matches; const onChange = (event: MediaQueryListEvent) => { systemPrefersDark.value = event.matches; if (isSystemAppThemeMode(themeMode.value)) applyTheme(); }; mediaQuery.addEventListener("change", onChange); isListeningForSystemTheme = true; } function applyThemePalette() { if (typeof document === "undefined") return; const doc = document.documentElement; for (const className of APP_THEME_PALETTE_CLASS_NAMES) doc.classList.remove(className); const paletteClass = getAppThemePaletteClass(themePalette.value); if (paletteClass) doc.classList.add(paletteClass); applyCustomUiColors(); } function applyTheme() { if (typeof document === "undefined") return; const doc = document.documentElement; const dark = isDark.value; doc.classList.add("disable-transitions"); doc.classList.toggle("dark", dark); applyThemePalette(); doc.dataset.cornerStyle = cornerStyle.value; doc.style.colorScheme = dark ? "dark" : "light"; // force reflow so the class toggle takes effect before re-enabling transitions doc.offsetHeight; // eslint-disable-line @typescript-eslint/no-unused-expressions themeRevision.value += 1; requestAnimationFrame(() => doc.classList.remove("disable-transitions")); if (!isTauriRuntime()) return; const tauriTheme = getTauriThemeForMode(themeMode.value); // Tauri owns the Linux GTK preference. Calling setTheme(null) here forces it // to light, so only skip the native system-theme write and keep explicit modes. if (isLinuxTauriRuntime() && tauriTheme == null) return; if (cachedTauriWindow) { cachedTauriWindow .getCurrentWindow() .setTheme(tauriTheme) .catch(() => {}); } else { import("@tauri-apps/api/window").then((mod) => { cachedTauriWindow = mod; mod .getCurrentWindow() .setTheme(tauriTheme) .catch(() => {}); }); } } function setThemeMode(mode: AppThemeMode) { themeMode.value = mode; safeLocalStorageSet(APP_THEME_STORAGE_KEY, mode); applyTheme(); } function setThemePalette(palette: AppThemePalette) { savedThemePaletteValue.value = palette; previewedThemePalette.value = null; safeLocalStorageSet(APP_THEME_PALETTE_STORAGE_KEY, palette); applyTheme(); } function previewThemePalette(palette: AppThemePalette) { if (themePalette.value === palette) return; previewedThemePalette.value = palette; // Palette previews do not change the OS/window mode, so avoid the native // Tauri write performed by applyTheme(). applyThemePalette(); } function clearThemePalettePreview() { if (previewedThemePalette.value === null) return; previewedThemePalette.value = null; applyThemePalette(); } function applyCustomUiColors() { if (typeof document === "undefined") return; const doc = document.documentElement; const clear = () => { for (const def of APP_CUSTOM_UI_COLOR_DEFS) { doc.style.removeProperty(def.varName); if (def.rgbVarName) doc.style.removeProperty(def.rgbVarName); } for (const name of APP_CUSTOM_UI_DERIVED_VAR_NAMES) doc.style.removeProperty(name); }; if (themePalette.value !== "custom") { clear(); return; } for (const def of APP_CUSTOM_UI_COLOR_DEFS) { const { color, rgbTriplet } = appCustomUiColorValue(activeCustomUiColors.value[def.key]); doc.style.setProperty(def.varName, color); if (def.rgbVarName) doc.style.setProperty(def.rgbVarName, rgbTriplet); } const derived = deriveCustomUiColors(activeCustomUiColors.value); for (const name of APP_CUSTOM_UI_DERIVED_VAR_NAMES) doc.style.setProperty(name, derived[name]); } function setCustomUiColors(colors: AppCustomUiColors) { const next = normalizeAppCustomUiColors(colors); if (isDark.value) { customUiColorsDark.value = next; safeLocalStorageSet(APP_CUSTOM_UI_DARK_STORAGE_KEY, JSON.stringify(next)); } else { customUiColors.value = next; safeLocalStorageSet(APP_CUSTOM_UI_STORAGE_KEY, JSON.stringify(next)); } applyCustomUiColors(); } function resetCustomUiColors() { setCustomUiColors({ ...(isDark.value ? DEFAULT_APP_CUSTOM_UI_COLORS_DARK : DEFAULT_APP_CUSTOM_UI_COLORS) }); } function setCornerStyle(style: AppCornerStyle) { cornerStyle.value = normalizeAppCornerStyle(style); safeLocalStorageSet(APP_CORNER_STYLE_STORAGE_KEY, cornerStyle.value); applyTheme(); } export function useTheme() { setupSystemThemeListener(); function toggleTheme() { setThemeMode(isDark.value ? "light" : "dark"); } return { isDark, themeMode, themePalette, customUiColors, customUiColorsDark, activeCustomUiColors, themeRevision, bumpThemeRevision, writeRootToken, cornerStyle, applyTheme, setThemeMode, setThemePalette, previewThemePalette, clearThemePalettePreview, setCustomUiColors, resetCustomUiColors, setCornerStyle, toggleTheme, }; }