diff --git a/config/pa11yci.json b/config/pa11yci.json index 2b7a3545..89cf8755 100644 --- a/config/pa11yci.json +++ b/config/pa11yci.json @@ -1,9 +1,10 @@ { "// Note": "Pa11y requires Chromium to be installed. In Docker, update Dockerfile to include chromium package", "// Note2": "Next.js 15 streams metadata via JavaScript. The title element appears after hydration, not in initial HTML.", - "// Note3": "color-contrast is ignored here because Pa11y's axe runner hardcodes violations.concat(incomplete) — it treats 'needs review' as failure. DaisyUI .btn gradients prevent axe from resolving a flat bg color, so every button lands in incomplete (14–61 false positives per page, ratio=0). The real contrast gate is tests/e2e/color-contrast.spec.ts, which runs the same axe rule on the same pages but asserts violations only, covering both scripthammer-light and scripthammer-dark (headless Chromium's prefers-color-scheme defaults to light, so this config never tested dark).", + "// Note3": "color-contrast (and color-contrast-enhanced for AAA) is ignored here because Pa11y's axe runner hardcodes violations.concat(incomplete) — it treats 'needs review' as failure. DaisyUI .btn gradients prevent axe from resolving a flat bg color, so every button lands in incomplete (14–61 false positives per page, ratio=0). The real contrast gate is tests/e2e/color-contrast.spec.ts, which runs the same axe rule on the same pages but asserts violations only, covering both scripthammer-light and scripthammer-dark (headless Chromium's prefers-color-scheme defaults to light, so this config never tested dark).", + "// Note4": "Standard bumped to WCAG2AAA per #21 (Phase 0 closure). Contrast checks remain delegated to the E2E spec; pa11y here covers the non-contrast AAA criteria (focus visibility, link purpose, etc.).", "defaults": { - "standard": "WCAG2AA", + "standard": "WCAG2AAA", "timeout": 30000, "wait": 3000, "viewport": { @@ -12,7 +13,7 @@ }, "runners": ["axe"], "hideElements": "", - "ignore": ["color-contrast"], + "ignore": ["color-contrast", "color-contrast-enhanced"], "chromeLaunchConfig": { "args": [ "--no-sandbox", diff --git a/features/foundation/001-wcag-aa-compliance/spec.md b/features/foundation/001-wcag-aa-compliance/spec.md index 248caca4..3424b4c2 100644 --- a/features/foundation/001-wcag-aa-compliance/spec.md +++ b/features/foundation/001-wcag-aa-compliance/spec.md @@ -9,24 +9,35 @@ ## Implementation Status -**Last audited**: 2026-04-25 -**Real status**: Partial -**Tracking**: see gap-audit GitHub issues + STATUS.md +**Last audited**: 2026-05-06 (#21 Phase 0 closure) +**Real status**: Mostly shipped (AAA contrast bumped; live overlay deferred) +**Tracking**: #21 closing PR + #80 (live overlay follow-up) ### Shipped - pa11y + axe-core integration - \*.accessibility.test.tsx files across components +- ContactForm a11y suite green (3255+/3255+ pass) +- **AAA contrast scope** — `config/pa11yci.json` standard is `WCAG2AAA`; + `tests/e2e/color-contrast.spec.ts` uses the `color-contrast-enhanced` + axe rule (7:1 normal text / 4.5:1 large text) +- Contrast gate split: pa11y covers non-contrast AAA criteria + (focus visibility, link purpose, etc.); E2E spec covers contrast + (because pa11y treats axe `incomplete` as failure, which produces + 14–61 false positives per page on DaisyUI `.btn` gradients) -### Gaps +### Deferred -- WCAG AAA scope (specs call for AAA but implementation appears AA) -- 4 ContactForm a11y test failures noted in PRP-STATUS -- Real-time dev feedback dashboard incomplete +- Live a11y overlay (dev-time floating panel): tracked in #80, + deferred to v0.1.0+ per the original next-session primer ### Notes -- Accessibility infrastructure exists; AAA-level coverage incomplete. +- Spec was originally written at AAA scope; the code had drifted to AA. + #21 aligned the code to the spec rather than the inverse — the + template ships at the level the spec promises. Per memory rule + "always prefer cleaner long-term solutions over quick short-term + hacks," amending the spec down to AA was rejected. diff --git a/src/app/page.tsx b/src/app/page.tsx index 739aa84a..57bfdb45 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -174,7 +174,11 @@ export default function Home() { href={STORYBOOK_URL} target="_blank" rel="noopener noreferrer" - className="link link-hover text-base-content/70 hover:text-base-content inline-flex min-h-11 items-center gap-2 text-sm" + // Solid text-base-content for AAA contrast (7:1) on + // scripthammer-light's #ebe5dd panel. /70 was 4.98:1 — fine + // for AA but failed AAA per #21. The muted-secondary feel + // is preserved via text-sm + the smaller font, not opacity. + className="link link-hover text-base-content inline-flex min-h-11 items-center gap-2 text-sm" > or explore the component catalogue in Storybook diff --git a/tests/e2e/color-contrast.spec.ts b/tests/e2e/color-contrast.spec.ts index 801ce4f4..16a32837 100644 --- a/tests/e2e/color-contrast.spec.ts +++ b/tests/e2e/color-contrast.spec.ts @@ -6,11 +6,17 @@ import { dirname } from 'node:path'; // produces 14–61 false positives per page on DaisyUI — .btn gradients // prevent axe from resolving a flat background color, so every button // lands in the needs-review bucket. That's why config/pa11yci.json keeps -// color-contrast in its ignore list. +// color-contrast (and color-contrast-enhanced) in its ignore list. // -// This spec is the real contrast gate. It runs the same rule against the -// same pages but asserts only on `violations` — cases where axe measured -// the ratio and confirmed it's under the WCAG AA threshold. +// This spec is the real contrast gate. It runs the AAA enhanced-contrast +// rule (7:1 normal text / 4.5:1 large text) against the same pages but +// asserts only on `violations` — cases where axe measured the ratio and +// confirmed it's under the WCAG AAA threshold. +// +// Bumped from `color-contrast` (AA, 4.5:1 / 3:1) to `color-contrast-enhanced` +// (AAA, 7:1 / 4.5:1) per #21 Phase 0 closure. The features/foundation/ +// 001-wcag-aa-compliance/spec.md was originally written for AAA; the code +// had drifted to AA. Aligning code to the spec rather than the inverse. // axe-core is a transitive dep under pnpm's strict node_modules; resolve it // through jest-axe (direct dep) so the path survives lockfile bumps. @@ -54,7 +60,7 @@ const THEMES = ['scripthammer-light', 'scripthammer-dark'] as const; // Mirrors config/pa11yci.json's urls[]. const PAGES = ['/', '/themes/', '/accessibility/', '/status/'] as const; -test.describe('WCAG AA color-contrast (violations only)', () => { +test.describe('WCAG AAA color-contrast-enhanced (violations only)', () => { // Match pa11yci.json viewport. test.use({ viewport: { width: 1280, height: 1024 } }); @@ -76,7 +82,7 @@ test.describe('WCAG AA color-contrast (violations only)', () => { const results = await page.evaluate(() => // @ts-expect-error — axe is injected as a global by the line above axe.run(document, { - runOnly: { type: 'rule', values: ['color-contrast'] }, + runOnly: { type: 'rule', values: ['color-contrast-enhanced'] }, resultTypes: ['violations', 'incomplete'], }) ); @@ -106,7 +112,7 @@ test.describe('WCAG AA color-contrast (violations only)', () => { expect( details, - `color-contrast violations on ${path} [${theme}] ` + + `color-contrast-enhanced (AAA) violations on ${path} [${theme}] ` + `(${incompleteCount} incomplete/needs-review — expected, not a failure):\n` + JSON.stringify(details, null, 2) ).toHaveLength(0);