~james/core

core/web/src/lib/theme.ts -rw-r--r-- 3.0 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
type Mode = "light" | "dark";

interface Swatch {
	background: string;
	text: string;
	accent: string;
}

const PRESETS = {
	dark: {
		label: "Default Dark",
		mode: "dark",
		swatch: { background: "#141414", text: "#fafafa", accent: "#6a63f0" }
	},
	light: {
		label: "Default Light",
		mode: "light",
		swatch: { background: "#ffffff", text: "#0a0a0a", accent: "#5750f1" }
	},
	"classic-light": {
		label: "Classic Light",
		mode: "light",
		swatch: { background: "#ffffff", text: "#111827", accent: "#5750f1" }
	},
	"classic-dark": {
		label: "Classic Dark",
		mode: "dark",
		swatch: { background: "#1f2937", text: "#f3f4f6", accent: "#6a63f0" }
	},
	ash: {
		label: "Ash",
		mode: "dark",
		swatch: { background: "#313338", text: "#eceef0", accent: "#5865f2" }
	},
	snow: {
		label: "Snow",
		mode: "light",
		swatch: { background: "#ffffff", text: "#282a30", accent: "#5e6ad2" }
	}
} as const satisfies Record<string, { label: string; mode: Mode; swatch: Swatch }>;

export type Preset = keyof typeof PRESETS;
export type Theme = "auto" | Preset;

export const PRESET_NAMES = Object.keys(PRESETS) as Preset[];
export const THEMES: Theme[] = ["auto", ...PRESET_NAMES];

const COOKIE = "theme";
const YEAR = 60 * 60 * 24 * 365;

export const themeLabel = (theme: Theme): string =>
	theme === "auto" ? "System" : PRESETS[theme].label;

export const themeSwatch = (theme: Theme, systemDark: boolean): Swatch =>
	PRESETS[theme === "auto" ? (systemDark ? "dark" : "light") : theme].swatch;

export const parseTheme = (raw: string | null | undefined): Theme =>
	raw && Object.hasOwn(PRESETS, raw) ? (raw as Preset) : "auto";

// the server reads the raw header so the hook works on any request shape
export const themeFromCookies = (header: string | null): Theme => {
	for (const part of header?.split(";") ?? []) {
		const [name, value] = part.trim().split("=");
		if (name === COOKIE) return parseTheme(value);
	}
	return "auto";
};

const themeMode = (theme: Theme): Mode | undefined =>
	theme === "auto" ? undefined : PRESETS[theme].mode;

const themePalette = (theme: Theme): Preset | undefined =>
	theme === "auto" || theme === themeMode(theme) ? undefined : theme;

// auto has no data-theme, so the dark variant uses prefers-color-scheme
export const themeAttribute = (theme: Theme): string => {
	const mode = themeMode(theme);
	const palette = themePalette(theme);
	return (mode ? ` data-theme="${mode}"` : "") + (palette ? ` data-palette="${palette}"` : "");
};

export const readTheme = (): Theme => themeFromCookies(document.cookie);

export const applyTheme = (theme: Theme): void => {
	const root = document.documentElement;
	const mode = themeMode(theme);
	const palette = themePalette(theme);
	if (mode) root.dataset.theme = mode;
	else root.removeAttribute("data-theme");
	if (palette) root.dataset.palette = palette;
	else root.removeAttribute("data-palette");
	document.cookie =
		theme === "auto"
			? `${COOKIE}=; path=/; max-age=0; samesite=lax`
			: `${COOKIE}=${theme}; path=/; max-age=${YEAR}; samesite=lax`;
};