WFDASH-WEBAPP/e2e/themes.spec.ts
Dave 9ce6c97772 feat(theme): the picker reads the real palette, not a hand-kept copy
The theme registry carried a hand-maintained set of hex swatches for
every palette, and they had drifted away from the CSS the app actually
paints. The picker previewed colours the product never showed.

The registry now holds only what cannot be read from CSS — id, label,
tagline, fonts, radius — and the picker, the Settings swatches and the
preview cards resolve --background, --primary and --foreground off the
theme itself. A preview can no longer disagree with the screen it
previews.

themes.css is rewritten so every palette carries a real dark surface,
and the palettes that are dark by nature stay dark whichever way the
mode switch is set. The pre-hydration script validates the stored theme
id against the registry, so a retired id falls back to WFDASH instead of
painting an unstyled page, and the old "warm" mode migrates to Light.

The legacy text-{warn,success,info}-foreground classes now resolve to
the semantic text colour and switch to the filled ink only on an exact
solid fill, so one class reads correctly on both a tinted badge and a
filled button. A solid focus outline replaces the faint ring, and the
charts and quote status badges take their colours from tokens rather
than hard-coded dark strokes.

Tests: theme-bootstrap.test.tsx covers saved-theme recovery and the
storage-blocked path; e2e/themes.spec.ts walks every palette in both
modes checking surface contrast, tinted and solid semantic pairs, focus
colour, chart tokens and the destructive hover, and asserts that the
picker preview matches the applied theme.
2026-11-05 00:40:40 +02:00

258 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});
});