test(e2e): sweep tab panels in the palette gate, not just the default view

The palette gate scanned only each route's default view, so off-palette colours
behind a tab (which Radix unmounts while inactive) were never checked — the a11y
gate iterates tabs, this one did not, and they had drifted. The flow Files tab
carries a live off-palette node (file-manager's expand-all control,
hover:text-blue-400) that went green purely because the panel was unmounted.

Adds a per-tab scan mirroring the a11y gate, with tab-scoped waivers keyed
`${path} [${tab}]`. The file-manager control is waived on the Files tab under the
same "goes with the design pass" rationale it already carries on /resources.
Proven: the Files-tab scan passes against the exact waived offender (a non-empty
`toEqual`), so the scan reaches the panel — the old default-view scan could not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-24 01:22:58 +07:00
co-authored by Claude Opus 4.8
parent 05e79902d8
commit 8a6eecd6e6
+42 -17
View File
@@ -1,7 +1,10 @@
import type { Page } from '@playwright/test';
import type { BadgeVariant } from '@/components/ui/badge';
import { badgeVariants } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { routes } from '@/lib/routes';
import { expect, test } from '../../fixtures/test.ts';
import { ROUTE_MANIFEST } from '../../routes.ts';
@@ -36,12 +39,32 @@ const SANCTIONED = new Set(
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. */
/**
* 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.
*/
const ACCEPTED: Record<string, string[]> = {
// file-manager.tsx expand-all control; changing it moves pixels, so it goes with the design pass.
// Same control renders in the flow Files tab, so it is waived there under the same rationale.
'/resources': ['button: hover:text-blue-400'],
[`${routes.flow('5')} [Files]`]: ['button: hover:text-blue-400'],
};
const scanOffenders = (page: Page) =>
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] },
);
test.describe('palette compliance', { tag: '@cross' }, () => {
for (const entry of ROUTE_MANIFEST) {
test.describe(entry.path, () => {
@@ -51,25 +74,27 @@ test.describe('palette compliance', { tag: '@cross' }, () => {
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);
const offenders = await scanOffenders(page);
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] },
expect([...new Set(offenders)], `off-palette colours on ${entry.path}`).toEqual(
ACCEPTED[entry.path] ?? [],
);
const accepted = ACCEPTED[entry.path] ?? [];
expect([...new Set(offenders)], `off-palette colours on ${entry.path}`).toEqual(accepted);
});
// Tabs mount their panels lazily (Radix unmounts inactive ones), so the default-view scan
// above never sees them — sweep each panel like the a11y gate does.
for (const tab of entry.tabs ?? []) {
test(`tab "${tab}" carries no off-palette colour`, async ({ page }) => {
await page.goto(entry.path);
await expect(entry.ready(page)).toBeVisible();
await page.getByRole('tab', { name: tab }).click();
const offenders = await scanOffenders(page);
const key = `${entry.path} [${tab}]`;
expect([...new Set(offenders)], `off-palette colours on ${key}`).toEqual(ACCEPTED[key] ?? []);
});
}
});
}
});