import { test, expect, type Page } from "@playwright/test"; import { REQUIRED_TOKENS, RETIRED_BASE } from "../../common/components/themeConfig"; import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand"; import { chooseTheme } from "./helpers"; // The shared theme system (common/styles/tokens.css + ThemeScript + // ThemeProvider + the header's toggle) as the project's own site uses it: it // opens on the DARK base in Signal, the family's accent. A reader's base // persists across reloads and is applied before hydration (no flash of the // wrong theme). const BASE_KEY = "ytdlp-tb:base"; async function htmlState(page: Page) { return page.evaluate((key) => { const d = document.documentElement; return { base: d.getAttribute("data-base"), dark: d.classList.contains("dark"), accent: d.getAttribute("data-accent"), stored: localStorage.getItem(key), brand: getComputedStyle(d).getPropertyValue("--brand").trim(), }; }, BASE_KEY); } test("dark + Signal by default; a base chosen with the toggle persists with no FOUC", async ({ page, }) => { // The OS says light: the default is still dark (it is the site's, not the // system's). await page.emulateMedia({ colorScheme: "light" }); await page.goto("/"); await expect.poll(() => htmlState(page)).toEqual({ base: "dark", dark: true, accent: "signal", stored: null, brand: ACCENTS.signal.onDark, }); // An explicit Light base, through the header's toggle (chooseTheme retries // a click until hydration has made it live). await chooseTheme(page, { base: "light" }); await expect.poll(async () => (await htmlState(page)).stored).toBe("light"); let s = await htmlState(page); expect(s.base).toBe("light"); expect(s.dark).toBe(false); expect(s.brand).toBe(ACCENTS.signal.onLight); // Reload: the pre-paint inline script must re-apply the persisted base on // the very first commit, before React hydrates. await page.reload({ waitUntil: "commit" }); await page.waitForFunction( () => document.documentElement?.dataset.themeReady === "1", ); s = await htmlState(page); expect(s.stored).toBe("light"); expect(s.base).toBe("light"); expect(s.dark).toBe(false); // is server-rendered dark now; hydration must not put it back. await page.waitForLoadState("load"); await expect.poll(() => htmlState(page)).toMatchObject({ base: "light", dark: false, stored: "light", brand: ACCENTS.signal.onLight, }); }); test.describe("with JavaScript off", () => { test.use({ javaScriptEnabled: false }); test("the server renders the dark base, so no script is needed for it", async ({ page, }) => { await page.goto("/"); const html = page.locator("html"); await expect(html).toHaveAttribute("data-base", "dark"); await expect(html).toHaveClass(/(^|\s)dark(\s|$)/); await expect(html).toHaveAttribute("data-accent", "signal"); // The pre-paint script never ran: this is the server's markup. await expect(html).not.toHaveAttribute("data-theme-ready", /.*/); await expect(html).toHaveCSS("--background", BASE_GROUNDS.dark); await expect(page.locator("body")).toHaveCSS("background-color", "rgb(12, 10, 8)"); }); }); test("every base declares a complete palette, and the grounds differ", async ({ page, }) => { // The failure this guards is silent: a base that omits a token inherits the // LIGHT block's value (`:root` always matches), so a half-declared palette // looks merely "a bit off" rather than broken — and only on the base nobody // checked. themeTokens.test.ts checks the sheet; this checks the browser. const TOKENS = [...REQUIRED_TOKENS, "--radius"]; await page.goto("/"); const readOn = (base: "light" | "dark") => page.evaluate( ({ base, tokens }) => { const d = document.documentElement; d.setAttribute("data-base", base); d.classList.toggle("dark", base === "dark"); const cs = getComputedStyle(d); return Object.fromEntries( tokens.map((t) => [t, cs.getPropertyValue(t).trim()]), ); }, { base, tokens: TOKENS }, ); const bases = ["light", "dark"] as const; const palettes = {} as Record<(typeof bases)[number], Record>; for (const b of bases) palettes[b] = await readOn(b); for (const b of bases) { for (const t of TOKENS) { expect(palettes[b][t], `${t} must be set on the ${b} base`).not.toBe(""); } expect(palettes[b]["--background"]).toBe(BASE_GROUNDS[b]); } // …and the two must actually differ, or a base is a label on the other's // palette. expect(new Set(bases.map((b) => palettes[b]["--background"])).size).toBe(2); expect(new Set(bases.map((b) => palettes[b]["--foreground"])).size).toBe(2); expect(new Set(bases.map((b) => palettes[b]["--chart-1"])).size).toBe(2); }); // The retired third ground: a reader who chose it gets Light, before first // paint, with no other ground on the way, and the stored value becomes // "light" once. Every value `data-base` holds is recorded by a // MutationObserver installed before the page's first script. test("a stored retired base renders Light, with no other ground on the way, and is rewritten", async ({ page, }) => { await page.addInitScript( ([key, retired]) => { try { localStorage.setItem(key, retired); } catch {} const held: (string | null)[] = []; (window as unknown as { __bases: (string | null)[] }).__bases = held; new MutationObserver((records) => { for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue); }).observe(document, { subtree: true, attributes: true, attributeFilter: ["data-base"], attributeOldValue: true, }); }, [BASE_KEY, RETIRED_BASE], ); await page.goto("/", { waitUntil: "commit" }); await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); expect(await page.evaluate(() => document.documentElement.getAttribute("data-base"))).toBe("light"); await page.waitForLoadState("load"); await page.waitForTimeout(300); const held = await page.evaluate(() => [ ...(window as unknown as { __bases: (string | null)[] }).__bases, document.documentElement.getAttribute("data-base"), ]); // The server's dark (the markup, before the pre-paint script), then light. expect(held.filter((v) => v !== "dark" && v !== "light"), JSON.stringify(held)).toEqual([]); expect(held.slice(1).every((v) => v === "light"), JSON.stringify(held)).toBe(true); expect(await htmlState(page)).toMatchObject({ base: "light", dark: false, stored: "light" }); });