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);