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";
22// A custom theme that omits chrome falls back to the runtime default, so the
23// boot copy of that default stays derived from the real palette.
24const DEFAULT_DARK_CHROME = getDefaultThemeColors("dark").chrome;
25
26const bootScript = (() => {
27 const match = indexHtml.match(/<script>([\s\S]*?)<\/script>/);
28 if (!match?.[1]) throw new Error("Could not find the inline boot script in index.html");
29 return match[1];
30})();
31
32type BootResult = {
33 isDark: boolean;
34 themeId: string | undefined;
35 themeSelected: string | undefined;
36 backgroundColor: string;
37 bootVariables: Record<string, string>;
38 metaContent: string | null;
39};
40
41function runBootScript(options: {
42 storage?: Record<string, string>;
43 storageThrows?: boolean;
44 prefersDark: boolean;
45}): BootResult {
46 const classes = new Set<string>();
47 const bootVariables: Record<string, string> = {};
48 const meta = {
49 content: null as string | null,
50 setAttribute(_name: string, value: string) {
51 this.content = value;
52 },
53 };
54 const documentElement = {
55 dataset: {} as Record<string, string | undefined>,
56 classList: {
57 add: (name: string) => void classes.add(name),
58 remove: (name: string) => void classes.delete(name),
59 toggle: (name: string, force?: boolean) => {
60 const next = force ?? !classes.has(name);
61 if (next) classes.add(name);
62 else classes.delete(name);
63 return next;
64 },
65 },
66 style: {
67 backgroundColor: "",
68 setProperty: (name: string, value: string) => {
69 bootVariables[name] = value;
70 },
71 },
72 };
73 const fakeDocument = {
74 documentElement,
75 querySelectorAll: (selector: string) => (selector === 'meta[name="theme-color"]' ? [meta] : []),
76 };
77 const fakeWindow = {
78 localStorage: {
79 getItem: (key: string): string | null => {
80 if (options.storageThrows) throw new Error("storage blocked");
81 return options.storage?.[key] ?? null;
82 },
83 },
84 matchMedia: () => ({ matches: options.prefersDark }),
85 };
86
87 const fakeCss = {
88 supports: (property: string, value: string) =>
89 property === "color" && toCanonicalThemeColor(value) !== null,
90 };
91
92 new Function("window", "document", "CSS", bootScript)(fakeWindow, fakeDocument, fakeCss);
93
94 return {
95 isDark: classes.has("dark"),
96 themeId: documentElement.dataset.themeId,
97 themeSelected: documentElement.dataset.themeSelected,
98 backgroundColor: documentElement.style.backgroundColor,
99 bootVariables,
100 metaContent: meta.content,
101 };
102}
103
104/** Mirrors getStored + readAppearanceModePreference + resolveThemeAppearance from the runtime. */
105function runtimeResolvedAppearance(
106 storage: Record<string, string>,
107 prefersDark: boolean,
108): "light" | "dark" {
109 vi.stubGlobal("window", {
110 localStorage: { getItem: (key: string) => storage[key] ?? null },
111 matchMedia: () => ({ matches: prefersDark }),
112 });
113 invalidateCustomThemes();
114 try {
115 const raw = storage[THEME_STORAGE_KEY] ?? null;
116 const theme = raw !== null && isKnownThemePreference(raw) ? raw : "system";
117 const followRaw = storage[THEME_FOLLOW_SYSTEM_STORAGE_KEY] ?? null;
118 const appearanceRaw = storage[THEME_APPEARANCE_MODE_STORAGE_KEY] ?? null;
119 const appearanceMode =
120 appearanceRaw === "light" || appearanceRaw === "dark" || appearanceRaw === "system"
121 ? appearanceRaw
122 : followRaw === "true"
123 ? "system"
124 : followRaw === "false"
125 ? null
126 : theme === "system"
127 ? "system"
128 : null;
129 const followSystem = appearanceMode === "system";
130 return resolveThemeAppearance(theme, prefersDark, followSystem, appearanceMode ?? undefined);
131 } finally {
132 vi.unstubAllGlobals();
133 invalidateCustomThemes();
134 }
135}
136
137const AURORA_DUAL = {
138 id: "aurora",
139 label: "Aurora",
140 appearance: "light",
141 colors: { canvas: "#f8fbff", text: "#10243d", accent: "#5b6cff" },
142 variants: { dark: { canvas: "#101827", text: "#eef5ff", accent: "#7c93ff" } },
143};
144const CHARCOAL_DARK_ONLY = {
145 id: "charcoal",
146 label: "Charcoal",
147 appearance: "dark",
148 colors: { canvas: "#1c1210", text: "#ffe8d9", accent: "#ff7a45" },
149};
150
151describe("index.html boot script", () => {
152 const parityCases: ReadonlyArray<{
153 name: string;
154 storage: Record<string, string>;
155 prefersDark: boolean;
156 }> = [
157 { name: "no stored preference on a dark OS", storage: {}, prefersDark: true },
158 {
159 name: "T3 Chat follows a dark OS",
160 storage: { [THEME_STORAGE_KEY]: "t3-chat", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
161 prefersDark: true,
162 },
163 {
164 name: "an explicit global dark mode applies to T3 Chat",
165 storage: {
166 [THEME_STORAGE_KEY]: "t3-chat",
167 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "dark",
168 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false",
169 },
170 prefersDark: false,
171 },
172 {
173 name: "Grove follows a dark OS",
174 storage: { [THEME_STORAGE_KEY]: "grove", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
175 prefersDark: true,
176 },
177 {
178 name: "Ocean follows a dark OS",
179 storage: { [THEME_STORAGE_KEY]: "ocean", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
180 prefersDark: true,
181 },
182 {
183 name: "Ember follows a dark OS",
184 storage: { [THEME_STORAGE_KEY]: "ember", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
185 prefersDark: true,
186 },
187 {
188 name: "Iris follows a dark OS",
189 storage: { [THEME_STORAGE_KEY]: "iris", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
190 prefersDark: true,
191 },
192 {
193 name: "a legacy t3-grove preference resolves through the alias",
194 storage: { [THEME_STORAGE_KEY]: "t3-grove", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
195 prefersDark: true,
196 },
197 {
198 name: "legacy t3-chat-dark resolves to dark T3 Chat",
199 storage: { [THEME_STORAGE_KEY]: "t3-chat-dark" },
200 prefersDark: true,
201 },
202 {
203 name: "a dual-mode custom theme follows the OS",
204 storage: {
205 [THEME_STORAGE_KEY]: "aurora",
206 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
207 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
208 },
209 prefersDark: true,
210 },
211 {
212 name: "a dark-only custom theme stays dark on a light OS",
213 storage: {
214 [THEME_STORAGE_KEY]: "charcoal",
215 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
216 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([CHARCOAL_DARK_ONLY]),
217 },
218 prefersDark: false,
219 },
220 {
221 name: "a legacy mode-suffixed preference is treated as unknown",
222 storage: {
223 [THEME_STORAGE_KEY]: "aurora:dark",
224 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
225 },
226 prefersDark: true,
227 },
228 {
229 name: "a removed custom theme falls back to system",
230 storage: { [THEME_STORAGE_KEY]: "gone-theme" },
231 prefersDark: true,
232 },
233 {
234 name: "a corrupted follow-system value falls back to inference",
235 storage: { [THEME_STORAGE_KEY]: "system", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "1" },
236 prefersDark: true,
237 },
238 {
239 name: "follow-system off keeps an explicit light preference on a dark OS",
240 storage: { [THEME_STORAGE_KEY]: "light", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false" },
241 prefersDark: true,
242 },
243 {
244 name: "a bare dark preference stays dark on a light OS",
245 storage: { [THEME_STORAGE_KEY]: "dark" },
246 prefersDark: false,
247 },
248 {
249 name: "follow-system off keeps a bare dark preference on a light OS",
250 storage: { [THEME_STORAGE_KEY]: "dark", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "false" },
251 prefersDark: false,
252 },
253 ];
254
255 it.each(parityCases)("matches the runtime appearance: $name", ({ storage, prefersDark }) => {
256 const boot = runBootScript({ storage, prefersDark });
257 expect(boot.isDark).toBe(runtimeResolvedAppearance(storage, prefersDark) === "dark");
258 });
259
260 it("marks built-in and custom themes on the document element", () => {
261 const chat = runBootScript({
262 storage: { [THEME_STORAGE_KEY]: "t3-chat", [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true" },
263 prefersDark: true,
264 });
265 expect(chat.themeId).toBe("t3-chat");
266 expect(chat.themeSelected).toBe("true");
267 expect(chat.isDark).toBe(true);
268
269 const aurora = runBootScript({
270 storage: {
271 [THEME_STORAGE_KEY]: "aurora",
272 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
273 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([AURORA_DUAL]),
274 },
275 prefersDark: true,
276 });
277 expect(aurora.themeId).toBe("aurora");
278 expect(aurora.isDark).toBe(true);
279 expect(aurora.backgroundColor).toBe(DEFAULT_DARK_CHROME);
280 expect(aurora.bootVariables["--boot-background"]).toBe(AURORA_DUAL.variants.dark.canvas);
281 expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME);
282 });
283
284 it("accepts exponent-form OKLCH before the runtime mounts", () => {
285 const colors = {
286 canvas: "oklch(9.5e-1 1e-2 2.8e2)",
287 chrome: "oklch(9.4e-1 1e-2 2.8e2)",
288 text: "oklch(2e-1 0 0 / 9e-1)",
289 accent: "oklch(6.2e-1 0.2 2.8e2)",
290 };
291 const boot = runBootScript({
292 storage: {
293 [THEME_STORAGE_KEY]: "scientific",
294 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
295 {
296 id: "scientific",
297 label: "Scientific",
298 appearance: "light",
299 colors,
300 },
301 ]),
302 },
303 prefersDark: false,
304 });
305
306 expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas);
307 expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text);
308 expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent);
309 expect(boot.backgroundColor).toBe(colors.chrome);
310 expect(boot.metaContent).toBe(colors.chrome);
311 });
312
313 it("accepts legacy CSS color formats before the runtime mounts", () => {
314 const colors = {
315 canvas: "rgb(248 251 255)",
316 chrome: "hsl(210 100% 99%)",
317 text: "rebeccapurple",
318 accent: "color(display-p3 0.36 0.42 1)",
319 };
320 const boot = runBootScript({
321 storage: {
322 [THEME_STORAGE_KEY]: "legacy-css",
323 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
324 {
325 id: "legacy-css",
326 label: "Legacy CSS",
327 appearance: "light",
328 colors,
329 },
330 ]),
331 },
332 prefersDark: false,
333 });
334
335 expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas);
336 expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text);
337 expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent);
338 expect(boot.backgroundColor).toBe(colors.chrome);
339 });
340
341 // Asserting against the real palette definitions (not literals) turns the
342 // boot script's hand-maintained copy into a CI-enforced contract: any
343 // palette change breaks this test until the copy in index.html is updated.
344 it("keeps every built-in boot splash in sync with the real palettes", () => {
345 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
346 // The boot script resolves every built-in from a light base appearance.
347 expect(theme.appearance).toBe("light");
348 for (const mode of ["light", "dark"] as const) {
349 const colors = getThemeColorsForMode(theme, mode);
350 expect(colors).not.toBeNull();
351 const boot = runBootScript({
352 storage: {
353 [THEME_STORAGE_KEY]: theme.id,
354 [THEME_APPEARANCE_MODE_STORAGE_KEY]: mode,
355 },
356 prefersDark: mode === "dark",
357 });
358 expect(boot.themeId).toBe(theme.id);
359 expect(boot.isDark).toBe(mode === "dark");
360 expect(boot.bootVariables["--boot-background"]).toBe(colors!.canvas);
361 expect(boot.bootVariables["--boot-foreground"]).toBe(colors!.text);
362 expect(boot.bootVariables["--boot-accent"]).toBe(colors!.accent);
363 expect(boot.backgroundColor).toBe(colors!.chrome);
364 expect(boot.metaContent).toBe(colors!.chrome);
365 }
366 }
367 });
368
369 it("applies the matching half of an automatic mix to the splash", () => {
370 const storage = {
371 [THEME_STORAGE_KEY]: "t3-chat",
372 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
373 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
374 };
375
376 const dark = runBootScript({ storage, prefersDark: true });
377 expect(dark.isDark).toBe(true);
378 expect(dark.themeId).toBe(GROVE_THEME.id);
379 expect(dark.bootVariables["--boot-background"]).toBe(
380 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
381 );
382
383 const light = runBootScript({ storage, prefersDark: false });
384 expect(light.isDark).toBe(false);
385 expect(light.themeId).toBe("t3-chat");
386 expect(light.bootVariables["--boot-background"]).toBe(
387 getThemeColorsForMode(T3_CHAT_THEME, "light")!.canvas,
388 );
389 });
390
391 it("lets a dark half go dark when the light-only base cannot", () => {
392 const boot = runBootScript({
393 storage: {
394 [THEME_STORAGE_KEY]: "paper",
395 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
396 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
397 {
398 id: "paper",
399 label: "Paper",
400 appearance: "light",
401 colors: { canvas: "#f8fbff", text: "#10243d", accent: "#5b6cff" },
402 },
403 ]),
404 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
405 },
406 prefersDark: true,
407 });
408 expect(boot.isDark).toBe(true);
409 expect(boot.themeId).toBe(GROVE_THEME.id);
410 });
411
412 it("paints the half's splash when the base theme no longer exists", () => {
413 const boot = runBootScript({
414 storage: {
415 [THEME_STORAGE_KEY]: "gone-theme",
416 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
417 "t3code:theme-halves:v1": JSON.stringify({ dark: GROVE_THEME.id }),
418 },
419 prefersDark: true,
420 });
421 expect(boot.isDark).toBe(true);
422 expect(boot.themeId).toBe(GROVE_THEME.id);
423 expect(boot.themeSelected).toBe("true");
424 expect(boot.bootVariables["--boot-background"]).toBe(
425 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
426 );
427 });
428
429 it("resolves a legacy-prefixed mix half onto the renamed theme", () => {
430 const boot = runBootScript({
431 storage: {
432 [THEME_STORAGE_KEY]: "t3-chat",
433 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
434 "t3code:theme-halves:v1": JSON.stringify({ dark: "t3-grove" }),
435 },
436 prefersDark: true,
437 });
438 expect(boot.isDark).toBe(true);
439 expect(boot.themeId).toBe(GROVE_THEME.id);
440 expect(boot.bootVariables["--boot-background"]).toBe(
441 getThemeColorsForMode(GROVE_THEME, "dark")!.canvas,
442 );
443 });
444
445 it("ignores a mix half that names an unknown theme", () => {
446 const boot = runBootScript({
447 storage: {
448 [THEME_STORAGE_KEY]: "t3-chat",
449 [THEME_APPEARANCE_MODE_STORAGE_KEY]: "system",
450 "t3code:theme-halves:v1": JSON.stringify({ dark: "gone-theme" }),
451 },
452 prefersDark: true,
453 });
454 expect(boot.themeId).toBe("t3-chat");
455 expect(boot.isDark).toBe(true);
456 });
457
458 it("uses runtime defaults for malformed custom roles", () => {
459 const boot = runBootScript({
460 storage: {
461 [THEME_STORAGE_KEY]: "partial",
462 [THEME_FOLLOW_SYSTEM_STORAGE_KEY]: "true",
463 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
464 {
465 id: "partial",
466 label: "Partial",
467 appearance: "dark",
468 colors: { canvas: "not-a-color", text: "#fffaff", accent: "nope" },
469 },
470 ]),
471 },
472 prefersDark: true,
473 });
474
475 expect(boot.themeId).toBe("partial");
476 expect(boot.bootVariables["--boot-background"]).toBe(getDefaultThemeColors("dark").canvas);
477 expect(boot.bootVariables["--boot-foreground"]).toBe("#fffaff");
478 expect(boot.bootVariables["--boot-accent"]).toBe(getDefaultThemeColors("dark").accent);
479 expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME);
480 expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME);
481 });
482
483 it("ignores malformed custom theme entries before applying a splash", () => {
484 const boot = runBootScript({
485 storage: {
486 [THEME_STORAGE_KEY]: "broken",
487 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
488 { id: "broken", label: "Broken", appearance: "light", colors: "bad" },
489 ]),
490 },
491 prefersDark: false,
492 });
493
494 expect(boot.themeId).toBeUndefined();
495 expect(boot.themeSelected).toBeUndefined();
496 expect(boot.backgroundColor).toBe("#ffffff");
497 expect(boot.metaContent).toBe("#ffffff");
498 });
499
500 it("leaves unknown preferences unthemed so the runtime default applies", () => {
501 const boot = runBootScript({
502 storage: { [THEME_STORAGE_KEY]: "gone-theme" },
503 prefersDark: true,
504 });
505 expect(boot.themeId).toBeUndefined();
506 expect(boot.themeSelected).toBeUndefined();
507 expect(boot.isDark).toBe(true);
508 });
509
510 it("follows the OS appearance when storage is unavailable", () => {
511 const light = runBootScript({ storageThrows: true, prefersDark: false });
512 expect(light.isDark).toBe(false);
513 expect(light.themeId).toBeUndefined();
514
515 const dark = runBootScript({ storageThrows: true, prefersDark: true });
516 expect(dark.isDark).toBe(true);
517 });
518});