import { test, expect } from "@playwright/test"; import { THEMES } from "../src/lib/themes"; import { HAS_AUTH_FIXTURE, signIn } from "./_helpers"; import { quoteStatusClassName } from "../src/lib/quote-status"; // Theme assertions must read current CSS, independently of installed PWA caches. test.use({ serviceWorkers: "block" }); // Public login loads the same global theme CSS without depending on PB data. // Resolve colours in Chrome: jsdom does not implement OKLCH or the CSS cascade. test("all themes have readable surfaces, semantic colours and focus indicators", async ({ page }) => { await page.goto("/login"); const failures = await page.evaluate((themes) => { const failures: string[] = []; const root = document.documentElement; const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; const ctx = canvas.getContext("2d", { willReadFrequently: true })!; function rgb(colour: string) { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = colour; ctx.fillRect(0, 0, 1, 1); return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3); } function luminance(channels: number[]) { const linear = channels.map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722; } function contrast(a: number[], b: number[]) { const x = luminance(a), y = luminance(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); } for (const theme of themes) { for (const mode of ["light", "dark"]) { root.dataset.theme = theme.id; root.classList.toggle("dark", mode === "dark"); const css = getComputedStyle(root); const colour = (token: string) => rgb(css.getPropertyValue(`--${token}`).trim()); const check = (name: string, a: number[], b: number[], minimum = 4.5) => { const ratio = contrast(a, b); if (ratio < minimum) failures.push(`${theme.id}/${mode} ${name}: ${ratio.toFixed(2)} < ${minimum}`); }; const bgToken = css.getPropertyValue("--background").trim(); if (!bgToken) failures.push(`${theme.id}/${mode}: missing background`); const lightness = Number(bgToken.match(/oklch\(\s*([\d.]+)/)?.[1]); if ((mode === "dark" || theme.category === "dark") && lightness > 0.4) { failures.push(`${theme.id}/${mode}: dark mode still has a light canvas`); } for (const surface of ["background", "card", "popover", "sidebar"]) { const bg = colour(surface); check(surface, colour(surface === "background" ? "foreground" : `${surface}-foreground`), bg); check(`muted on ${surface}`, colour("muted-foreground"), bg); check(`focus on ${surface}`, colour("ring"), bg, 3); for (const semantic of ["success", "warn", "info", "destructive", "ai"]) { const fg = colour(semantic); check(`${semantic} text on ${surface}`, fg, bg); // The app uses semantic text on 10–20% tinted badges/buttons. for (const alpha of [0.1, 0.2]) { const tinted = fg.map((v, i) => v * alpha + bg[i] * (1 - alpha)); check(`${semantic}/${alpha} badge on ${surface}`, fg, tinted); } } } for (const token of ["primary", "sidebar-primary", "success", "warn", "info", "destructive", "ai"]) { check(`${token} filled label`, colour(`${token}-foreground`), colour(token)); } for (const surface of ["background", "card", "sidebar"]) { const base = colour(surface), primary = colour("primary"); const hover = primary.map((v, i) => v * 0.9 + base[i] * 0.1); check(`primary hover on ${surface}`, colour("primary-foreground"), hover); } for (let i = 1; i <= 5; i++) { if (!css.getPropertyValue(`--chart-${i}`).trim()) failures.push(`${theme.id}/${mode}: missing chart-${i}`); } } } return failures; }, THEMES.map(({ id, category }) => ({ id, category }))); expect(failures).toEqual([]); }); test("legacy status text and solid badges stay readable using the rendered utilities", async ({ page }) => { await page.goto("/login"); const failures = await page.evaluate(({ themes, statusClasses }) => { const samples = [ ...statusClasses, "bg-warn/10 text-warn-foreground", "text-warn-foreground", "bg-success/15 text-success-foreground dark:text-success", "border-success/40 text-success-foreground dark:text-success", "bg-info/10 text-info-foreground", "bg-warn text-warn-foreground", "bg-success text-success-foreground", "bg-info text-info-foreground", "bg-destructive text-destructive-foreground", "bg-warn text-warn-foreground dark:bg-warn/15 dark:text-warn", ]; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d", { willReadFrequently: true })!; const rgb = (value: string) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return Array.from(ctx.getImageData(0, 0, 1, 1).data); }; const lum = (rgb: number[]) => rgb.slice(0, 3).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); const probe = document.createElement("span"); probe.textContent = "Status"; document.body.append(probe); const failures: string[] = []; for (const theme of themes) for (const mode of ["light", "dark"]) { document.documentElement.dataset.theme = theme.id; document.documentElement.classList.toggle("dark", mode === "dark"); const surface = rgb(getComputedStyle(document.documentElement).getPropertyValue("--card")); for (const sample of samples) { probe.className = sample; const css = getComputedStyle(probe), fg = rgb(css.color), bg = rgb(css.backgroundColor); const composite = bg.slice(0, 3).map((v, i) => v * bg[3] / 255 + surface[i] * (1 - bg[3] / 255)); const paintedText = fg.slice(0, 3).map((v, i) => v * fg[3] / 255 + composite[i] * (1 - fg[3] / 255)); const a = lum(paintedText), b = lum(composite); const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); if (ratio < 4.5) failures.push(`${theme.id}/${mode} ${sample}: ${ratio.toFixed(2)}`); } // Selected customer rows use an inverse info-colour checkmark bubble. probe.className = "border-info-foreground bg-info-foreground text-info"; const inverse = getComputedStyle(probe); const ink = rgb(getComputedStyle(document.documentElement).getPropertyValue("--info-foreground")); if (rgb(inverse.backgroundColor).join() !== ink.join() || rgb(inverse.borderTopColor).join() !== ink.join()) { failures.push(`${theme.id}/${mode}: missing inverse info background or border`); } } probe.remove(); return failures; }, { themes: THEMES.map(({ id }) => ({ id })), statusClasses: ["new", "claimed", "processed", "emailed", "sending", "template", "approval_pending", "partial_approval_pending", "follow_up", "approved", "complete", "partial_approved", "rejected", "email_failed", "mail_failed", "unmatched_bounce", "problem", "cancelled"].map(quoteStatusClassName), }); expect(failures).toEqual([]); }); test("a destructive outline button gets the filled text colour on hover", async ({ page }) => { await page.goto("/login"); await page.evaluate(() => { const button = document.createElement("button"); button.id = "theme-hover-probe"; button.className = "border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"; button.style.cssText = "position:fixed;top:20px;left:20px;z-index:9999;padding:10px"; button.textContent = "Cancel"; document.body.append(button); }); const button = page.locator("#theme-hover-probe"); await button.hover(); for (const mode of ["light", "dark"]) for (const theme of THEMES) { const matches = await button.evaluate((el, { id, mode }) => { document.documentElement.dataset.theme = id; document.documentElement.classList.toggle("dark", mode === "dark"); const probe = document.createElement("span"); probe.style.cssText = "color:var(--destructive-foreground);background:var(--destructive)"; document.body.append(probe); const actual = getComputedStyle(el), expected = getComputedStyle(probe); const result = actual.color === expected.color && actual.backgroundColor === expected.backgroundColor; probe.remove(); return result; }, { id: theme.id, mode }); expect(matches).toBe(true); } }); test.describe("appearance controls", () => { test.skip(!process.env.E2E_STORAGE_STATE && !HAS_AUTH_FIXTURE, "Needs a signed-in browser fixture"); test.use({ storageState: process.env.E2E_STORAGE_STATE }); test("each preview matches the selected theme in both modes and selection survives reload", async ({ page }) => { if (!process.env.E2E_STORAGE_STATE) { await signIn(page, process.env.E2E_TEST_EMAIL!, process.env.E2E_TEST_PASSWORD!); } await page.goto("/settings"); await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 30_000 }); for (const mode of ["light", "dark"] as const) { await page.getByRole("button", { name: mode === "light" ? "Light" : "Dark", exact: true }).last().click(); await expect(page.getByRole("button", { name: mode === "light" ? "Light" : "Dark", exact: true }).last()).toHaveAttribute("aria-pressed", "true"); for (const theme of THEMES) { const card = page.getByRole("button", { name: `Use ${theme.label} theme`, exact: true }); await card.click(); await expect(card).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("html")).toHaveAttribute("data-theme", theme.id); const colours = await card.locator(".theme-preview").evaluate((preview) => { const actual = getComputedStyle(document.documentElement); const miniature = getComputedStyle(preview); return ["--background", "--primary", "--foreground"].map((token) => ({ actual: actual.getPropertyValue(token).trim(), preview: miniature.getPropertyValue(token).trim(), })); }); for (const colour of colours) expect(colour.preview).toBe(colour.actual); } await page.reload(); await expect(page.getByRole("button", { name: "Use WFDASH theme", exact: true })).toHaveAttribute("aria-pressed", "true"); await expect(page.getByRole("button", { name: mode === "light" ? "Light" : "Dark", exact: true }).last()).toHaveAttribute("aria-pressed", "true"); } }); test("dialogs follow every palette and remain usable on a phone", async ({ page }) => { test.setTimeout(60_000); if (!process.env.E2E_STORAGE_STATE) { await signIn(page, process.env.E2E_TEST_EMAIL!, process.env.E2E_TEST_PASSWORD!); } await page.goto("/settings"); await page.getByRole("button", { name: "What's New", exact: true }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); for (const mode of ["light", "dark"]) { for (const theme of THEMES) { await page.evaluate(({ id, mode }) => { document.documentElement.dataset.theme = id; document.documentElement.classList.toggle("dark", mode === "dark"); }, { id: theme.id, mode }); await expect.poll(async () => dialog.evaluate((el) => { const probe = document.createElement("div"); probe.style.cssText = "background:var(--popover);color:var(--popover-foreground)"; document.body.append(probe); const expected = getComputedStyle(probe), actual = getComputedStyle(el); // Transitions can serialize equivalent colours in OKLab rather // than OKLCH. Compare their painted sRGB channels, not notation. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true })!; const rgb = (colour: string) => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = colour; ctx.fillRect(0, 0, 1, 1); return Array.from(ctx.getImageData(0, 0, 1, 1).data); }; const same = (a: string, b: string) => { const x = rgb(a), y = rgb(b); return x.every((v, i) => Math.abs(v - y[i]) <= 1); }; const result = same(actual.backgroundColor, expected.backgroundColor) && same(actual.color, expected.color); probe.remove(); return result; }), { message: `${theme.id}/${mode} dialog colours` }).toBe(true); } } await page.setViewportSize({ width: 390, height: 844 }); const box = await dialog.boundingBox(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(390); // Enter keyboard modality before programmatic focus: a mouse-opened // dialog correctly does not show :focus-visible on button.focus() alone. await page.keyboard.press("Tab"); await dialog.getByRole("button", { name: "Close", exact: true }).focus(); await expect.poll(async () => dialog.getByRole("button", { name: "Close", exact: true }).evaluate((el) => { const css = getComputedStyle(el); return { width: css.outlineWidth, style: css.outlineStyle, offset: css.outlineOffset }; })).toEqual({ width: "2px", style: "solid", offset: "3px" }); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); }); });