diff --git a/frontend/e2e/specs/cross/palette.spec.ts b/frontend/e2e/specs/cross/palette.spec.ts index c4ed6574..98a6b7f9 100644 --- a/frontend/e2e/specs/cross/palette.spec.ts +++ b/frontend/e2e/specs/cross/palette.spec.ts @@ -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 = { // 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] ?? []); + }); + } }); } });