/** * E2E regression guard: Appearance → theme (light / dark / system). * * This is the E2E half of the "Appearance theme not working" fix. The root * cause was a token-format mismatch: the core theme tokens in globals.css * were stored as FULL `oklch(…)` colors while tailwind.config.ts wrapped them * as `oklch(var(--token) / )`, producing the invalid nested * `oklch(oklch(…))`. The browser resolved that to TRANSPARENT, so every * `bg-*` / `text-*` / `border-*` utility painted nothing and toggling * `.dark` produced no visible change. * * The spec asserts the OBSERVABLE behavior (the actual bug), not the * implementation: * 1. switching to Dark toggles `.dark` on AND paints a non-transparent * body background that differs from the light one (the transparency * regression would make both transparent/identical). * 2. the choice persists to localStorage (`factory-theme`) and survives a * hard reload. * 3. switching back to Light reverses it. * * Uses a fresh (auto-deleted) workspace so the test is self-contained. * The Appearance settings route is `/w/:workspace/settings/appearance`. */ import type { Page } from "@playwright/test"; import { expect, test } from "../fixtures"; /** Read a theme signal straight from the page. */ interface ThemeSignal { dark: boolean; bodyBg: string; stored: string; } function readTheme(page: Page): Promise { return page.evaluate(() => ({ dark: document.documentElement.classList.contains("dark"), bodyBg: getComputedStyle(document.body).backgroundColor, stored: localStorage.getItem("factory-theme") ?? "", })); } function isTransparent(bg: string): boolean { // `rgba(0, 0, 0, 0)` / `transparent` — the exact symptom of the bug. return /rgba?\([^)]*,\s*(0|0\.\d+)\s*\)$/.test(bg) || bg === "transparent"; } async function gotoAppearance(page: Page, workspaceId: string): Promise { await page.goto(`/w/${workspaceId}/settings/appearance`); // Wait for the three theme option buttons to render. await expect( page.getByRole("button", { name: /^dark$/i }), ).toBeVisible(); } test("theme: dark toggles .dark, paints an opaque distinct bg, and persists", async ({ authPage, freshWorkspace, }) => { const ws = await freshWorkspace("E2E Theme"); await gotoAppearance(authPage, ws.id); const light = await readTheme(authPage); // Sanity: we start light (no stored preference in this fresh context). expect(light.dark).toBe(false); expect(isTransparent(light.bodyBg)).toBe(false); // 1) Switch to Dark. await authPage.getByRole("button", { name: /^dark$/i }).click(); await expect.poll(async () => (await readTheme(authPage)).dark).toBe(true); const dark = await readTheme(authPage); expect(dark.dark).toBe(true); expect(dark.stored).toBe("dark"); // The regression: body bg was transparent in BOTH themes. It must now paint, // and it must differ from the light value. expect(isTransparent(dark.bodyBg)).toBe(false); expect(dark.bodyBg).not.toBe(light.bodyBg); // 2) Persists across a hard reload (localStorage + re-apply on load). await authPage.reload(); await expect.poll(async () => (await readTheme(authPage)).dark).toBe(true); expect((await readTheme(authPage)).stored).toBe("dark"); // 3) The dark class is global — a different route sees it too (not scoped // to the settings page). await authPage.goto(`/w/${ws.id}/settings`); await expect .poll(async () => authPage.evaluate(() => document.documentElement.classList.contains("dark")), ) .toBe(true); // 4) Switch back to Light — reverses cleanly. await authPage.goto(`/w/${ws.id}/settings/appearance`); await authPage.getByRole("button", { name: /^light$/i }).click(); await expect.poll(async () => (await readTheme(authPage)).dark).toBe(false); const backToLight = await readTheme(authPage); expect(backToLight.stored).toBe("light"); expect(isTransparent(backToLight.bodyBg)).toBe(false); expect(backToLight.bodyBg).toBe(light.bodyBg); }); test("theme: system follows the OS preference via .dark sync", async ({ authPage, freshWorkspace, }) => { const ws = await freshWorkspace("E2E Theme System"); await gotoAppearance(authPage, ws.id); // No stored preference → the store defaults to "system". await authPage.evaluate(() => localStorage.removeItem("factory-theme")); await authPage.reload(); await gotoAppearance(authPage, ws.id); await authPage.getByRole("button", { name: /^system$/i }).click(); // Emulate a DARK OS: system must apply .dark. await authPage.emulateMedia({ colorScheme: "dark" }); await expect .poll(async () => authPage.evaluate(() => document.documentElement.classList.contains("dark")), ) .toBe(true); // Emulate a LIGHT OS: system must drop .dark (the media-query listener // re-applies on change). await authPage.emulateMedia({ colorScheme: "light" }); await expect .poll(async () => authPage.evaluate(() => document.documentElement.classList.contains("dark")), ) .toBe(false); });