Files
waggle-os/tests/e2e/light-mode-polish.spec.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

141 lines
6.1 KiB
TypeScript

/**
* H-04 (P40) + H-05 (P41) · Light-mode BootScreen + header polish.
*
* Phase A/B commit 8782cab already moved the Waggle logo to a
* theme-aware asset swap and ensured BootScreen + StatusBar use
* semantic tokens (text-foreground / bg-background / text-primary).
* This spec is the behavioural regression:
*
* H-04 — BootScreen in light mode: mounts, progress track is
* readable (non-zero contrast against background), and the
* light-variant PNG logo is served (not the dark JPEG).
* H-05 — "Waggle AI" title renders with the light-mode foreground
* colour (non-zero contrast against the light background).
*
* We rely on getComputedStyle assertions instead of pixel snapshots
* so the test stays stable across font-rendering / browser-build
* differences.
*
* Run: npx playwright test tests/e2e/light-mode-polish.spec.ts --reporter=list
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
const BOOT_SCREEN = '[data-testid="boot-screen"]';
async function seedLightModeFreshBoot(page: Page) {
// Install the theme flag + clear the boot gate BEFORE React mounts so
// the first render is already in light mode and the BootScreen shows.
await page.addInitScript(() => {
try {
window.localStorage.setItem('waggle-theme', 'light');
window.localStorage.removeItem('waggle-booted');
} catch {
// localStorage unavailable — test will still cover the visual side
}
});
}
/**
* Compute relative luminance for an rgb(...) colour string (sRGB per
* WCAG 2.1). Used to assert text is distinguishable from background
* without baking exact hex values into the test.
*/
function relativeLuminance(rgb: string): number {
const match = rgb.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
if (!match) return NaN;
const [r, g, b] = [match[1], match[2], match[3]].map(v => parseInt(v, 10) / 255);
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrast(a: string, b: string): number {
const la = relativeLuminance(a);
const lb = relativeLuminance(b);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
test.describe('H-04 · BootScreen in light mode', () => {
test('mounts with light theme attribute and light-variant logo', async ({ page }) => {
await seedLightModeFreshBoot(page);
await page.goto(`${BASE}/`);
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_500 });
// Theme attribute landed before mount.
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
expect(theme).toBe('light');
// Logo asset: light variant is the .png export (transparent, black
// WAGGLE text), dark variant is the .jpeg. useIsLightTheme flips the
// src. We check the rendered <img> inside BootScreen.
const logoSrc = await page.locator(`${BOOT_SCREEN} img[alt="Waggle AI"]`).getAttribute('src');
expect(logoSrc).toBeTruthy();
expect(logoSrc).toMatch(/\.(png|webp)(\?|$)/i);
});
test('progress fill stands out against the track in light mode', async ({ page }) => {
await seedLightModeFreshBoot(page);
await page.goto(`${BASE}/`);
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_500 });
// What matters for the "animation stays visible" claim is that the
// PROGRESS FILL (bg-primary) is distinguishable from the TRACK
// (bg-muted) — that's what the user perceives as progress. The
// track/background contrast is deliberately low because `muted`
// is, by design, near-background.
const sample = await page.evaluate(() => {
const root = document.querySelector('[data-testid="boot-screen"]') as HTMLElement | null;
if (!root) return null;
const track = root.querySelector('.bg-muted') as HTMLElement | null;
const fill = track?.querySelector('.bg-primary') as HTMLElement | null;
if (!track || !fill) return null;
return {
bg: window.getComputedStyle(root).backgroundColor,
trackBg: window.getComputedStyle(track).backgroundColor,
fillBg: window.getComputedStyle(fill).backgroundColor,
};
});
expect(sample, 'progress bar markup must be present').not.toBeNull();
// The progress bar is decorative, not an essential UI component for
// WCAG contrast purposes, and the brand primary is honey (#e5a000)
// which does not reach 3:1 against any near-white background. The
// regression we actually care about is "fill is distinguishable from
// track at all" — if muted and primary rendered the same in light
// mode (e.g. both fell back to white), the bar would vanish. Anything
// above 1.3:1 proves that didn't happen.
expect(contrast(sample!.fillBg, sample!.trackBg)).toBeGreaterThan(1.3);
// And fill is not the same as the root background — otherwise the
// filled portion bleeds into the surrounding area.
expect(sample!.fillBg).not.toBe(sample!.bg);
});
});
test.describe('H-05 · Header + title text in light mode', () => {
test('Waggle AI title contrasts with the light background', async ({ page }) => {
await seedLightModeFreshBoot(page);
await page.goto(`${BASE}/`);
await expect(page.locator(BOOT_SCREEN)).toBeVisible({ timeout: 1_500 });
const sample = await page.evaluate(() => {
const root = document.querySelector('[data-testid="boot-screen"]') as HTMLElement | null;
if (!root) return null;
const heading = root.querySelector('h1') as HTMLElement | null;
if (!heading) return null;
return {
bg: window.getComputedStyle(root).backgroundColor,
fg: window.getComputedStyle(heading).color,
text: heading.textContent?.trim() ?? '',
};
});
expect(sample).not.toBeNull();
expect(sample!.text).toBe('Waggle AI');
// WCAG AA for normal text: 4.5:1. BootScreen uses a display font
// (large), where the AA threshold drops to 3.0:1 — we keep 4.0 as
// a defensive minimum.
expect(contrast(sample!.bg, sample!.fg)).toBeGreaterThanOrEqual(4.0);
});
});