Files
emaui/apps/e2e/visual/theme.spec.ts
fitse-yotor b63e07305a feat(theme): add semantic tokens, focus ring, and reduced-motion support
Adds the layer feature code has been missing: tokens that name a role — page
surface, subtle border, danger — instead of a colour. Every one resolves to a
Mantine variable rather than a literal, so they follow the colour scheme for
free and cannot drift from the theme. A parallel palette of raw hexes would
have recreated exactly the problem this exists to fix.

Also closes three accessibility gaps that had no implementation anywhere in the
codebase: no :focus-visible rule, no screen-reader-only utility, and no global
prefers-reduced-motion handling.

The focus work found a real bug, and nearly introduced a worse one. Mantine
already rings its own controls, so the first attempt deferred to it with
`outline: none` on .mantine-focus-auto. That suppressed Mantine's ring without
replacing it, leaving portal buttons with no focus indicator at all — and it
only showed up in one app, because which rule won depended on stylesheet order.
The rules use identical values, so overlapping them is invisible and safe;
opting out is not. The focus test now asserts computed outline width and style,
not just pixels, since a screenshot alone would not have caught this.

`status-tone.ts` establishes the six-tone vocabulary that the 48 scattered
status→colour maps will eventually collapse onto. Nothing consumes it yet.

No visual change: the 8 existing baselines pass unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:11:20 +03:00

101 lines
3.6 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
import { VISUAL } from '../visual.config';
/**
* Theme baselines.
*
* These exist so a change to the shared theme can be reviewed as a diff rather
* than trusted. The gallery route renders every primitive the theme controls,
* so one screenshot per app per scheme per width covers the whole surface.
*
* Update baselines deliberately, never reflexively:
* npx playwright test -c apps/e2e/visual.config.ts --update-snapshots
* A diff you did not intend is the entire point of the suite.
*/
const WIDTHS = [
{ name: 'desktop', width: 1440, height: 1200 },
{ name: 'tablet', width: 768, height: 1200 },
] as const;
const SCHEMES = ['light', 'dark'] as const;
const APPS = [
{ name: 'backoffice', url: VISUAL.backofficeUrl },
{ name: 'portal', url: VISUAL.portalUrl },
] as const;
/**
* Set the scheme the way the app itself does — the pre-paint script in
* index.html reads this key. Setting it before navigation means the very first
* paint is already correct, so no screenshot catches a flash of the wrong one.
*/
async function gotoGallery(page: Page, baseUrl: string, scheme: string) {
await page.addInitScript((value) => {
window.localStorage.setItem('mantine-color-scheme-value', value);
}, scheme);
await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' });
// The gallery is static, but web fonts are not: screenshotting before they
// settle bakes a fallback-font baseline that every later run then fails
// against.
await page.evaluate(() => document.fonts.ready);
await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible();
}
for (const app of APPS) {
for (const scheme of SCHEMES) {
for (const size of WIDTHS) {
test(`${app.name} gallery — ${scheme}${size.name}`, async ({ page }) => {
await page.setViewportSize({ width: size.width, height: size.height });
await gotoGallery(page, app.url, scheme);
await expect(page).toHaveScreenshot(
`${app.name}-gallery-${scheme}-${size.name}.png`,
{ fullPage: true },
);
});
}
}
/**
* The focus ring, captured while actually focused.
*
* The full-page shots above can't show this: nothing is focused in them, so
* a regression that removed the ring entirely would leave them all green.
* Keyboard focus specifically, because `:focus-visible` deliberately does
* not match a mouse click.
*/
test(`${app.name} — focus ring is visible`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoGallery(page, app.url, 'light');
const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last();
await section.scrollIntoViewIfNeeded();
const button = page.getByRole('button', { name: 'Button', exact: true });
await button.focus();
await expect(button).toBeFocused();
// Assert the ring in computed styles as well as pixels. A screenshot alone
// would still pass if the outline came from somewhere unintended, and a
// token that failed to resolve leaves an empty string rather than an error.
const ring = await button.evaluate((el) => {
const s = getComputedStyle(el);
return {
width: s.outlineWidth,
style: s.outlineStyle,
token: getComputedStyle(document.documentElement)
.getPropertyValue('--ema-focus-ring')
.trim(),
};
});
expect(ring.style).toBe('solid');
expect(ring.width).toBe('2px');
expect(ring.token).not.toBe('');
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`);
});
}