test(e2e): fail on a colour written outside the variant set

The contrast gate mounts probes from cva output, so a colour written as a
raw utility in a page — the shape of the badge defect that shipped — was
outside it by construction. This walks the rendered DOM of every swept
route instead: each badge and button must draw its colour from the variant
set or from a semantic token.

It immediately found one, a hard-coded blue hover on the file manager's
expand-all control, waived by its exact node string until the design pass
takes it (the colour change moves baselined pixels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-22 14:06:20 +07:00
co-authored by Claude Opus 4.8
parent dfff92bef6
commit 3d04fcfff2
+81
View File
@@ -0,0 +1,81 @@
import type { BadgeVariant } from '@/components/ui/badge';
import { badgeVariants } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { expect, test } from '../../fixtures/test.ts';
import { ROUTE_MANIFEST } from '../../routes.ts';
// Keyed off the unions so a new variant joins the sanctioned set without a manual edit.
const BADGE_VARIANTS = Object.keys({
blue: true,
default: true,
destructive: true,
green: true,
orange: true,
outline: true,
pink: true,
purple: true,
red: true,
secondary: true,
yellow: true,
} satisfies Record<BadgeVariant, true>) as BadgeVariant[];
const BUTTON_VARIANTS = ['default', 'destructive', 'ghost', 'link', 'outline', 'secondary'] as const;
const SANCTIONED = new Set(
[
...BADGE_VARIANTS.map((variant) => badgeVariants({ variant })),
...BUTTON_VARIANTS.map((variant) => buttonVariants({ variant })),
]
.join(' ')
.split(/\s+/),
);
// A hard-coded hue (`text-green-800`, `bg-[#16a34a]`) rather than a semantic token
// (`bg-primary`, `text-muted-foreground`) or a non-colour utility (`text-xs`).
const PALETTE_UTILITY =
/^(?:[a-z-]+:)*(?:bg|text|border|ring|from|via|to|fill|stroke|shadow|outline|decoration|divide|accent|caret|placeholder)-(?:\[[^\]]*\]|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:\/\d{1,3})?)$/;
/** Off-palette colours already on the page, by route. Exact strings: they waive one node, not a rule. */
const ACCEPTED: Record<string, string[]> = {
// file-manager.tsx expand-all control; changing it moves pixels, so it goes with the design pass.
'/resources': ['button: hover:text-blue-400'],
};
/**
* The contrast gate probes cva output, so a colour written straight into a page — the shape of the
* one badge defect that shipped — is outside it by construction. This walks the rendered DOM
* instead: every badge and button must draw its colour from the variant set or from a token.
*/
test.describe('palette compliance', { tag: '@cross' }, () => {
for (const entry of ROUTE_MANIFEST) {
test.describe(entry.path, () => {
test.use({ cassette: entry.cassette() });
test('rendered badges and buttons carry no off-palette colour', async ({ page }) => {
await page.goto(entry.path);
await expect(entry.ready(page)).toBeVisible();
const offenders = await page.evaluate(
({ pattern, sanctioned }) => {
const palette = new RegExp(pattern);
const allowed = new Set(sanctioned);
return [...document.querySelectorAll('[data-slot="badge"],[data-slot="button"]')].flatMap(
(element) =>
[...element.classList]
.filter((token) => palette.test(token) && !allowed.has(token))
.map((token) => `${element.getAttribute('data-slot')}: ${token}`),
);
},
{ pattern: PALETTE_UTILITY.source, sanctioned: [...SANCTIONED] },
);
const accepted = ACCEPTED[entry.path] ?? [];
expect([...new Set(offenders)], `off-palette colours on ${entry.path}`).toEqual(accepted);
});
});
}
});