Files
descrybe/apps/web/src/lib/theme.svelte.ts
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

113 lines
3.0 KiB
TypeScript

import { browser } from "$app/environment";
/** Canonical preference key (app-wide: marketing, dashboard, admin, auth). */
export const THEME_KEY = "descrybe-theme";
/** Pre-unification marketing key — read once, then migrate to THEME_KEY. */
export const LEGACY_MARKETING_THEME_KEY = "descrybe-marketing-theme";
/** Brief dual-store key from ui-theme experiment — migrate to THEME_KEY. */
export const LEGACY_UI_THEME_KEY = "descrybe-ui-theme";
export type Theme = "light" | "dark";
/** SSR / first paint / unset storage — always light (no OS prefers-color-scheme). */
export const THEME_DEFAULT: Theme = "light";
/** Match layout.css --background (FOUC paint before CSS loads). */
const DARK_PAINT = "hsl(252 22% 9%)";
const LIGHT_PAINT = "hsl(240 20% 98%)";
function normalize(value: string | null | undefined): Theme | null {
if (value === "light" || value === "dark") return value;
return null;
}
function readStored(): Theme {
if (!browser) return THEME_DEFAULT;
try {
const fromWindow =
normalize(window.__THEME__) ??
normalize(window.__MARKETING_THEME__) ??
normalize(window.__UI_THEME__);
if (fromWindow) return fromWindow;
const fromStorage =
normalize(localStorage.getItem(THEME_KEY)) ??
normalize(localStorage.getItem(LEGACY_MARKETING_THEME_KEY)) ??
normalize(localStorage.getItem(LEGACY_UI_THEME_KEY));
if (fromStorage) return fromStorage;
} catch {
/* ignore */
}
return THEME_DEFAULT;
}
function paintDocument(next: Theme): void {
if (!browser) return;
const root = document.documentElement;
root.classList.toggle("dark", next === "dark");
root.dataset.theme = next;
root.style.colorScheme = next;
root.style.backgroundColor = next === "dark" ? DARK_PAINT : LIGHT_PAINT;
document.body?.classList.remove("dark");
for (const id of ["marketing-shell", "auth-shell", "admin-shell", "app-shell"] as const) {
const shell = document.getElementById(id);
if (shell) {
shell.classList.toggle("dark", next === "dark");
}
}
}
function createTheme() {
let theme = $state<Theme>(readStored());
function setTheme(next: Theme) {
theme = next;
if (!browser) return;
try {
localStorage.setItem(THEME_KEY, next);
localStorage.removeItem(LEGACY_MARKETING_THEME_KEY);
localStorage.removeItem(LEGACY_UI_THEME_KEY);
} catch {
/* ignore */
}
window.__THEME__ = next;
window.__MARKETING_THEME__ = next;
window.__UI_THEME__ = next;
paintDocument(next);
}
function toggle() {
setTheme(theme === "dark" ? "light" : "dark");
}
function syncFromStorage() {
const next = readStored();
theme = next;
paintDocument(next);
}
return {
get theme() {
return theme;
},
get isDark() {
return theme === "dark";
},
setTheme,
toggle,
syncFromStorage,
paintDocument
};
}
export const theme = createTheme();
declare global {
interface Window {
__THEME__?: Theme;
/** @deprecated Prefer __THEME__; kept for FOUC / docs lockstep. */
__MARKETING_THEME__?: Theme;
/** @deprecated Prefer __THEME__; kept for ui-theme migration. */
__UI_THEME__?: Theme;
}
}