| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import indexHtml from "../index.html?raw"; |
| 4 | import { |
| 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 | |
| 21 | const 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. |
| 24 | const DEFAULT_DARK_CHROME = getDefaultThemeColors("dark").chrome; |
| 25 | |
| 26 | const 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 | |
| 32 | type 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 | |
| 41 | function 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. */ |
| 105 | function 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 | |
| 137 | const 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 | }; |
| 144 | const CHARCOAL_DARK_ONLY = { |
| 145 | id: "charcoal", |
| 146 | label: "Charcoal", |
| 147 | appearance: "dark", |
| 148 | colors: { canvas: "#1c1210", text: "#ffe8d9", accent: "#ff7a45" }, |
| 149 | }; |
| 150 | |
| 151 | describe("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 | }); |