diff --git a/.gitignore b/.gitignore index c398cc9..c16fcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ next-env.d.ts # Recovered asset binaries (local backup for manual re-migration) assets-backup/ +reports/ diff --git a/docs/accessibility.md b/docs/accessibility.md index 05a3d42..68d308f 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -4,35 +4,115 @@ Flixstack targets **WCAG 2.1 Level AA** compliance in both light and dark mode. ## Audit Tools +Two checks ship with the repo and both exit non-zero on failure, so either can gate CI. + ```bash -# Run the automated axe-core audit -npm run a11y -# Output: reports/a11y-report.json +# 1. axe-core across every route, in both themes +npm run dev # in another terminal (or `npm run build && npm start`) +npm run a11y # -> reports/a11y-report.json +npm run a11y -- --url=http://localhost:3001 + +# 2. WCAG contrast guard over the design tokens +npm run check-contrast +npm run check-contrast -- --json ``` -Supplemental manual testing with: -- **macOS VoiceOver** (Cmd + F5) -- **NVDA** on Windows -- **axe DevTools** browser extension -- **Lighthouse** accessibility audit +[`scripts/a11y.mjs`](../scripts/a11y.mjs) drives headless Chrome over the DevTools Protocol +— no Selenium or Playwright dependency, and `axe-core` itself has none. Set `CHROME_PATH` +if your browser is somewhere unusual. + +[`scripts/check-contrast.mjs`](../scripts/check-contrast.mjs) parses the real token values +out of [globals.css](../src/app/globals.css) and checks all 76 shipped pairs. It exists +because this document once asserted the light accent was 5.0:1 on white when it was +actually **3.30:1** — a real WCAG 1.4.3 failure that sat unnoticed precisely because the +number was written by hand. Reverting that token makes the guard fail with 5 errors, so the +check is known to work rather than merely known to pass. + +**Current status:** axe-core 4.13.0, 22 runs (11 routes x 2 themes), **0 violations**. +Contrast guard: **76/76 pass**. + +Manual testing still recommended for what automation cannot judge: +- **macOS VoiceOver** (Cmd + F5) / **NVDA** on Windows — announcement quality and order +- **Windows High Contrast** — `forced-colors` fallbacks +- **Lighthouse** — performance interactions with the texture layer --- ## Color Contrast -All green accent values are pre-validated: +> **Corrected in the cyber redesign (PR 1).** The previous version of this table claimed +> `#16A34A` on `#FFFFFF` was 5.0:1. Recomputed, it is **3.30:1** — a genuine WCAG 1.4.3 +> failure that affected every light-mode accent link, accent badge, and the entire +> white-on-green primary button. The light accent is now `#0E7038` (6.19:1). Two other +> figures in the old table were also inaccurate (`#4ADE80` on `#0D0D0D` was understated as +> 9.4:1 vs 11.15:1 actual; `#4A4A4A` on `#FFFFFF` was overstated as 9.7:1 vs 8.86:1). -| Token | Light value | Dark value | Ratio | Standard | +Text pairs must clear **4.5:1** (1.4.3). Control boundaries and focus indicators must clear +**3:1** (1.4.11). Every value below is verified on **all three** surfaces of its theme — +base, surface, and elevated — because a token that passes on white can still fail on the +elevated fill. + +### Dark theme — base `#05070A` / surface `#0B0F14` / elevated `#121822` + +| Token | Value | base | surface | elevated | |---|---|---|---|---| -| Accent on white | `#16A34A` on `#FFFFFF` | — | 5.0:1 | AA ✓ | -| Accent on dark | — | `#4ADE80` on `#0D0D0D` | 9.4:1 | AAA ✓ | -| Body text light | `#111111` on `#F8F9FA` | — | 17.8:1 | AAA ✓ | -| Body text dark | — | `#F2F2F2` on `#0D0D0D` | 18.1:1 | AAA ✓ | -| Secondary text light | `#4A4A4A` on `#FFFFFF` | — | 9.7:1 | AAA ✓ | -| Focus ring | Matches accent | Matches accent | ≥ 3:1 | AA ✓ | +| `--color-text-primary` | `#E6F1F5` | 17.54 | 16.72 | 15.49 | +| `--color-text-secondary` | `#94A9B8` | 8.28 | 7.89 | 7.31 | +| `--color-accent` | `#3DFF9E` | 15.36 | 14.64 | 13.56 | +| `--color-info` | `#22D3EE` | 11.16 | 10.63 | 9.85 | +| `--color-signal` | `#FF4FD8` | 7.07 | 6.74 | 6.24 | +| `--color-premium` | `#FBBF24` | 12.08 | 11.51 | 10.67 | +| `--color-error` | `#FF6B6B` | 7.27 | 6.93 | 6.42 | +| `--color-border-control` (≥3:1) | `#56708A` | 3.92 | 3.73 | 3.46 | + +`--color-accent-foreground` `#04120A` on the accent fill: **14.59:1**. + +### Light theme — base `#EDF1F2` / surface `#FFFFFF` / elevated `#E1E7E9` + +| Token | Value | surface | base | elevated | +|---|---|---|---|---| +| `--color-text-primary` | `#0A1014` | 19.14 | 16.83 | 15.32 | +| `--color-text-secondary` | `#47535C` | 7.90 | 6.94 | 6.32 | +| `--color-accent` | `#0E7038` | 6.19 | 5.44 | 4.95 | +| `--color-info` | `#0B5F76` | 7.21 | 6.34 | 5.77 | +| `--color-signal` | `#86198F` | 8.24 | 7.24 | 6.59 | +| `--color-premium` | `#9A4508` | 6.50 | 5.72 | 5.21 | +| `--color-error` | `#B91C1C` | 6.47 | 5.69 | 5.18 | +| `--color-border-control` (≥3:1) | `#6E7D86` | 4.25 | 3.74 | 3.40 | + +`--color-accent-foreground` `#FFFFFF` on the accent fill: **6.19:1**. + +`--color-text-disabled` is the one token held only to 3:1 (3.42 dark / 3.55 light on +elevated). **It is for decorative `aria-hidden` icons and genuinely disabled controls only +— never for text a sighted user is expected to read.** WCAG 1.4.3 exempts inactive +components, not quiet ones. Using it for card genre names produced 231 axe violations at +3.41:1 (finding 8 below); real-but-secondary content takes `--color-text-secondary`. + +### Two kinds of border + +`--color-border` is **decorative** (dividers, card edges) and is exempt from 1.4.11. +`--color-border-control` is for anything whose boundary identifies a control — inputs, +buttons, toggles — and clears 3:1 on every surface. Do not use the decorative token on a +control: the search input previously did, at roughly 1.3:1. Color is never the sole means of conveying information (paired with icons/labels). +### Focus indicators on notched elements + +`clip-path` clips an element's `outline`, and `overflow: hidden` on an ancestor clips a +descendant's outline. Elements carrying the `.notch` / `.notch-sm` HUD geometry therefore +take their focus indicator as an **inset `box-shadow`**, which is clipped with the shape and +stays visible. + +That ring is deliberately **two-tone** — an inner band in `--color-bg-base` and an outer +band in `--color-focus-ring`. A single-tone ring is invisible on the primary button, whose +fill *is* the accent colour. With two bands, whichever way the fill goes, one always +contrasts: the ground band carries it on accent/error fills, the accent band carries it on +surface, elevated, and transparent fills. + +Buttons use `transition-colors`, not `transition-all`, so the focus ring appears +immediately instead of fading in over 150ms. + --- ## Keyboard Navigation @@ -41,11 +121,25 @@ Color is never the sole means of conveying information (paired with icons/labels - **Skip to main content** link is the first focusable element on every page - **Rails** support `ArrowLeft`/`ArrowRight` for horizontal scrolling - **Hero carousel** pause/prev/next are keyboard accessible -- **Mobile nav** can be opened/closed with Enter/Space and dismissed with Escape -- **Modals / panels** (e.g., CS Inspector) trap focus correctly and return focus on close +- **Mobile nav** can be opened/closed with Enter/Space +- **Season accordions** use native `
`/`` - **Dropdowns and selects** use native ` setSort(e.target.value as SortOption)} - className="bg-elevated border border-border text-(--color-text-primary) rounded-lg px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-(--color-focus-ring)" + className="border border-border-control bg-elevated px-3 py-1.5 font-mono text-xs uppercase tracking-wider text-text-primary" aria-label="Sort titles by" > @@ -179,9 +175,12 @@ export function BrowseClient({ titles, genres, heading }: BrowseClientProps) { ))} ) : ( -
-