test(e2e): sanction palette tokens per variant, and sweep populated providers

The gate sanctioned the flat union of every badge and button variant, so any
variant's hue passed on any element — precisely the wrong-variant reuse it was
built for. Match each element's palette tokens against a single variant's set,
by equality rather than containment: a lone borrowed token is a subset of the
variant it came from, and containment waved it through (verified — an outline
badge carrying the blue variant's `text-blue-800` passed until the switch).

The route sweep also reaches /settings/providers only with the empty seed, so
the provider cards were never palette-scanned, unlike in the a11y and visual
gates. Add the same dedicated populated sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-24 18:40:24 +07:00
co-authored by Claude Opus 4.8
parent a51e36fdc7
commit 585df2a953
+59 -17
View File
@@ -7,6 +7,7 @@ import { buttonVariants } from '@/components/ui/button';
import { routes } from '@/lib/routes';
import { expect, test } from '../../fixtures/test.ts';
import { populatedSettingsProvidersCassette } from '../../mocks/cassettes/settings-providers.ts';
import { ROUTE_MANIFEST } from '../../routes.ts';
const BADGE_VARIANTS = Object.keys({
@@ -25,20 +26,30 @@ const BADGE_VARIANTS = Object.keys({
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})?)$/;
/**
* Per-variant, not one union: a flat union sanctions every variant's hue on every badge and button,
* so the wrong-variant reuse this gate exists for reads as legal.
*/
const paletteTokensOf = (classes: string) => classes.split(/\s+/).filter((token) => PALETTE_UTILITY.test(token));
const SANCTIONED_VARIANTS = [
...BADGE_VARIANTS.map((variant) => ({
slot: 'badge',
tokens: paletteTokensOf(badgeVariants({ variant })),
variant,
})),
...BUTTON_VARIANTS.map((variant) => ({
slot: 'button',
tokens: paletteTokensOf(buttonVariants({ variant })),
variant,
})),
];
/**
* Off-palette colours already on the page, keyed by scan surface. A route's default view is keyed by
* its path; a tab panel by `${path} [${tab}]`. Exact strings: they waive one node, not a rule.
@@ -52,20 +63,51 @@ const ACCEPTED: Record<string, string[]> = {
const scanOffenders = (page: Page) =>
page.evaluate(
({ pattern, sanctioned }) => {
({ pattern, variants }) => {
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}`),
);
return [...document.querySelectorAll('[data-slot="badge"],[data-slot="button"]')].flatMap((element) => {
const slot = element.getAttribute('data-slot') ?? '';
const used = [...element.classList].filter((token) => palette.test(token));
if (!used.length) {
return [];
}
// Equality, not containment: a single token borrowed from another variant is a
// subset of that variant's set, so containment would wave through exactly the
// wrong-variant reuse this gate exists to catch.
const covered = variants.some(
(candidate) =>
candidate.slot === slot &&
candidate.tokens.length === used.length &&
used.every((token) => candidate.tokens.includes(token)),
);
return covered ? [] : used.map((token) => `${slot}: ${token}`);
});
},
{ pattern: PALETTE_UTILITY.source, sanctioned: [...SANCTIONED] },
{ pattern: PALETTE_UTILITY.source, variants: SANCTIONED_VARIANTS },
);
test.describe('palette compliance', { tag: '@cross' }, () => {
// The manifest sweeps this route with the empty seed, so the provider cards — the surface the
// shipped badge defect actually lived on — render nowhere in the loop below. The a11y and visual
// gates each carry the same dedicated populated sweep.
test.describe('settings providers (populated)', () => {
test.use({ cassette: populatedSettingsProvidersCassette() });
test('provider cards carry no off-palette colour', async ({ page }) => {
await page.goto(routes.settings.providers);
await expect(page.getByText('My Custom Endpoint')).toBeVisible();
const offenders = await scanOffenders(page);
const key = `${routes.settings.providers} (populated)`;
expect([...new Set(offenders)], `off-palette colours on ${key}`).toEqual(ACCEPTED[key] ?? []);
});
});
for (const entry of ROUTE_MANIFEST) {
test.describe(entry.path, () => {
test.use({ cassette: entry.cassette() });