1import { describe, expect, it, vi } from "vite-plus/test";
2
3import indexHtml from "../index.html?raw";
4import {
5 CUSTOM_THEMES_STORAGE_KEY,
6 getDefaultThemeColors,
7 getThemeColorsForMode,
8 invalidateCustomThemes,
9 isKnownThemePreference,
10 resolveThemeAppearance,
11 T3_CHAT_THEME,
12 EMBER_THEME,
13 GROVE_THEME,
14 IRIS_THEME,
15 OCEAN_THEME,
16 THEME_APPEARANCE_MODE_STORAGE_KEY,
17 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
18 toCanonicalThemeColor,
19} from "./themePalette";
20
21const THEME_STORAGE_KEY = "t3code:theme";
22const DEFAULT_DARK_CHROME = getDefaultThemeColors("dark").chrome;
23
24const bootScript = (() => {
25 const match = indexHtml.match(/<script>([\s\S]*?)<\/script>/);
26 if (!match?.[1]) throw new Error("Could not find the inline boot script in index.html");
27 return match[1];
28})();
29
30type BootResult = {
31 isDark: boolean;
32 themeId: string | undefined;
33 themeSelected: string | undefined;
34 backgroundColor: string;
35 bootVariables: Record<string, string>;
36 metaContent: string | null;
37};
38
39function runBootScript(options: {
40 storage?: Record<string, string>;
41 storageThrows?: boolean;
42 prefersDark: boolean;
43}): BootResult {
44 const classes = new Set<string>();
45 const bootVariables: Record<string, string> = {};
46 const meta = {
47 content: null as string | null,
48 setAttribute(_name: string, value: string) {
49 this.content = value;
50 },
51 };
52 const documentElement = {
53 dataset: {} as Record<string, string | undefined>,
54 classList: {
55 add: (name: string) => void classes.add(name),
56 remove: (name: string) => void classes.delete(name),
57 toggle: (name: string, force?: boolean) => {
58 const next = force ?? !classes.has(name);
59 if (next) classes.add(name);
60 else classes.delete(name);
61 return next;
62 },
63 },
64 style: {
65 backgroundColor: "",
66 setProperty: (name: string, value: string) => {
67 bootVariables[name] = value;
68 },
69 },
70 };
71 const fakeDocument = {
72 documentElement,
73 querySelectorAll: (selector: string) => (selector === 'meta[name="theme-color"]' ? [meta] : []),
74 };
75 const fakeWindow = {
76 localStorage: {
77 getItem: (key: string): string | null => {
78 if (options.storageThrows) throw new Error("storage blocked");
79 return options.storage?.[key] ?? null;
80 },
81 },
82 matchMedia: () => ({ matches: options.prefersDark }),
83 };
84
85 const fakeCss = {
86 supports: (property: string, value: string) =>
87 property === "color" && toCanonicalThemeColor(value) !== null,
88 };
89
90 new Function("window", "document", "CSS", bootScript)(fakeWindow, fakeDocument, fakeCss);
91
92 return {
93 isDark: classes.has("dark"),
94 themeId: documentElement.dataset.themeId,
95 themeSelected: documentElement.dataset.themeSelected,
96 backgroundColor: documentElement.style.backgroundColor,
97 bootVariables,
98 metaContent: meta.content,
99 };
100}
101
102function runtimeResolvedAppearance(
103 storage: Record<string, string>,
104 prefersDark: boolean,
105): "light" | "dark" {
106 vi.stubGlobal("window", {
107 localStorage: { getItem: (key: string) => storage[key] ?? null },
108 matchMedia: () => ({ matches: prefersDark }),
109 });
110 invalidateCustomThemes();
111 try {
112 const raw = storage[THEME_STORAGE_KEY] ?? null;
113 const theme = raw !== null && isKnownThemePreference(raw) ? raw : "system";
114 const followRaw = storage[THEME_FOLLOW_SYSTEM_STORAGE_KEY] ?? null;
115 const appearanceRaw = storage[THEME_APPEARANCE_MODE_STORAGE_KEY] ?? null;
116 const appearanceMode =
117 appearanceRaw === "light" || appearanceRaw === "dark" || appearanceRaw === "system"
118 ? appearanceRaw
119 : followRaw === "true"
120 ? "system"
121 : followRaw === "false"
122 ? null
123 : theme === "system"
124 ? "system"
125 : null;
126 const followSystem = appearanceMode === "system";
127 return resolveThemeAppearance(theme, prefersDark, followSystem, appearanceMode ?? undefined);
128 } finally {
129 vi.unstubAllGlobals();
130 invalidateCustomThemes();
131 }
132}
133
134const AURORA_DUAL = {
135 id: "aurora",
136 label: "Aurora",
137 appearance: "light",
138 colors: { canvas: "#f8fbff", text: "#10243d", accent: "#5b6cff" },
139 variants: { dark: { canvas: "#101827", text: "#eef5ff", accent: "#7c93ff" } },
140};
141const CHARCOAL_DARK_ONLY = {
142 id: "charcoal",
143 label: "Charcoal",
144 appearance: "dark",
145 colors: { canvas: "#1c1210", text: "#ffe8d9", accent: "#ff7a45" },
146};
147
148describe("index.html boot script", () => {
149 const parityCases: ReadonlyArray<{
150 name: string;
151 storage: Record<string, string>;
152 prefersDark: boolean;
153 }> = [
154 { name: "no stored preference on a dark OS", storage: {}, prefersDark: true },
155 {
156 name: "T3 Chat follows a dark OS",
157 storage: { [THEME_STORAGE_KEY]: "t3-chat", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
158 prefersDark: true,
159 },
160 {
161 name: "an explicit global dark mode applies to T3 Chat",
162 storage: {
163 [THEME_STORAGE_KEY]: "t3-chat",
164 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "dark",
165 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false",
166 },
167 prefersDark: false,
168 },
169 {
170 name: "Grove follows a dark OS",
171 storage: { [THEME_STORAGE_KEY]: "grove", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
172 prefersDark: true,
173 },
174 {
175 name: "Ocean follows a dark OS",
176 storage: { [THEME_STORAGE_KEY]: "ocean", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
177 prefersDark: true,
178 },
179 {
180 name: "Ember follows a dark OS",
181 storage: { [THEME_STORAGE_KEY]: "ember", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
182 prefersDark: true,
183 },
184 {
185 name: "Iris follows a dark OS",
186 storage: { [THEME_STORAGE_KEY]: "iris", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
187 prefersDark: true,
188 },
189 {
190 name: "a legacy t3-grove preference resolves through the alias",
191 storage: { [THEME_STORAGE_KEY]: "t3-grove", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
192 prefersDark: true,
193 },
194 {
195 name: "legacy t3-chat-dark resolves to dark T3 Chat",
196 storage: { [THEME_STORAGE_KEY]: "t3-chat-dark" },
197 prefersDark: true,
198 },
199 {
200 name: "a dual-mode custom theme follows the OS",
201 storage: {
202 [THEME_STORAGE_KEY]: "aurora",
203 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
204 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
205 },
206 prefersDark: true,
207 },
208 {
209 name: "a dark-only custom theme stays dark on a light OS",
210 storage: {
211 [THEME_STORAGE_KEY]: "charcoal",
212 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
213 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([CHARCOAL_DARK_ONLY]),
214 },
215 prefersDark: false,
216 },
217 {
218 name: "a legacy mode-suffixed preference is treated as unknown",
219 storage: {
220 [THEME_STORAGE_KEY]: "aurora:dark",
221 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
222 },
223 prefersDark: true,
224 },
225 {
226 name: "a removed custom theme falls back to system",
227 storage: { [THEME_STORAGE_KEY]: "gone-theme" },
228 prefersDark: true,
229 },
230 {
231 name: "a corrupted follow-system value falls back to inference",
232 storage: { [THEME_STORAGE_KEY]: "system", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "1" },
233 prefersDark: true,
234 },
235 {
236 name: "follow-system off keeps an explicit light preference on a dark OS",
237 storage: { [THEME_STORAGE_KEY]: "light", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false" },
238 prefersDark: true,
239 },
240 {
241 name: "a bare dark preference stays dark on a light OS",
242 storage: { [THEME_STORAGE_KEY]: "dark" },
243 prefersDark: false,
244 },
245 {
246 name: "follow-system off keeps a bare dark preference on a light OS",
247 storage: { [THEME_STORAGE_KEY]: "dark", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false" },
248 prefersDark: false,
249 },
250 ];
251
252 it.each(parityCases)("matches the runtime appearance: $name", ({ storage, prefersDark }) => {
253 const boot = runBootScript({ storage, prefersDark });
254 expect(boot.isDark).toBe(runtimeResolvedAppearance(storage, prefersDark) === "dark");
255 });
256
257 it("accepts exponent-form OKLCH before the runtime mounts", () => {
258 const colors = {
259 canvas: "oklch(9.5e-1 1e-2 2.8e2)",
260 chrome: "oklch(9.4e-1 1e-2 2.8e2)",
261 text: "oklch(2e-1 0 0 / 9e-1)",
262 accent: "oklch(6.2e-1 0.2 2.8e2)",
263 };
264 const boot = runBootScript({
265 storage: {
266 [THEME_STORAGE_KEY]: "scientific",
267 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
268 {
269 id: "scientific",
270 label: "Scientific",
271 appearance: "light",
272 colors,
273 },
274 ]),
275 },
276 prefersDark: false,
277 });
278
279 expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas);
280 expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text);
281 expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent);
282 expect(boot.backgroundColor).toBe(colors.chrome);
283 expect(boot.metaContent).toBe(colors.chrome);
284 });
285
286 it("keeps every built-in boot splash in sync with the real palettes", () => {
287 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
288 expect(theme.appearance).toBe("light");
289 for (const mode of ["light", "dark"] as const) {
290 const colors = getThemeColorsForMode(theme, mode);
291 expect(colors).not.toBeNull();
292 const boot = runBootScript({
293 storage: {
294 [THEME_STORAGE_KEY]: theme.id,
295 [THEME_APPEARANCE_MODE_STORAGE_KEY]: mode,
296 },
297 prefersDark: mode === "dark",
298 });
299 expect(boot.themeId).toBe(theme.id);
300 expect(boot.isDark).toBe(mode === "dark");
301 expect(boot.bootVariables["--boot-background"]).toBe(colors!.canvas);
302 expect(boot.bootVariables["--boot-foreground"]).toBe(colors!.text);
303 expect(boot.bootVariables["--boot-accent"]).toBe(colors!.accent);
304 expect(boot.backgroundColor).toBe(colors!.chrome);
305 expect(boot.metaContent).toBe(colors!.chrome);
306 }
307 }
308 });
309
310 it("applies the matching half of an automatic mix to the splash", () => {
311 const storage = {
312 [THEME_STORAGE_KEY]: "t3-chat",
313 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
314 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
315 };
316
317 const dark = runBootScript({ storage, prefersDark: true });
318 expect(dark.isDark).toBe(true);
319 expect(dark.themeId).toBe(GROVE_THEME.id);
320 expect(dark.bootVariables["--boot-background"]).toBe(
321 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
322 );
323
324 const light = runBootScript({ storage, prefersDark: false });
325 expect(light.isDark).toBe(false);
326 expect(light.themeId).toBe("t3-chat");
327 expect(light.bootVariables["--boot-background"]).toBe(
328 getThemeColorsForMode(T3_CHAT_THEME, "light")!.canvas,
329 );
330 });
331
332 it("paints the half's splash when the base theme no longer exists", () => {
333 const boot = runBootScript({
334 storage: {
335 [THEME_STORAGE_KEY]: "gone-theme",
336 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
337 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
338 },
339 prefersDark: true,
340 });
341 expect(boot.isDark).toBe(true);
342 expect(boot.themeId).toBe(GROVE_THEME.id);
343 expect(boot.themeSelected).toBe("true");
344 expect(boot.bootVariables["--boot-background"]).toBe(
345 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
346 );
347 });
348
349 it("uses runtime defaults for malformed custom roles", () => {
350 const boot = runBootScript({
351 storage: {
352 [THEME_STORAGE_KEY]: "partial",
353 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
354 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
355 {
356 id: "partial",
357 label: "Partial",
358 appearance: "dark",
359 colors: { canvas: "not-a-color", text: "#fffaff", accent: "nope" },
360 },
361 ]),
362 },
363 prefersDark: true,
364 });
365
366 expect(boot.themeId).toBe("partial");
367 expect(boot.bootVariables["--boot-background"]).toBe(getDefaultThemeColors("dark").canvas);
368 expect(boot.bootVariables["--boot-foreground"]).toBe("#fffaff");
369 expect(boot.bootVariables["--boot-accent"]).toBe(getDefaultThemeColors("dark").accent);
370 expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME);
371 expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME);
372 });
373
374 it("follows the OS appearance when storage is unavailable", () => {
375 const light = runBootScript({ storageThrows: true, prefersDark: false });
376 expect(light.isDark).toBe(false);
377 expect(light.themeId).toBeUndefined();
378
379 const dark = runBootScript({ storageThrows: true, prefersDark: true });
380 expect(dark.isDark).toBe(true);
381 });
382});