# Gate Accessibility

> WCAG 2.2 AA quality gate for ultraweb builds — computes real contrast ratios in the browser (computed colors canvas-normalized to sRGB, so oklch tokens measure correctly), walks the full keyboard path via Playwright (tab order, focus-visible ring on every interactive element, Escape closes every overlay), audits landmarks, heading structure, and alt text, re-tests every page under emulated prefers-reduced-motion, injects the WCAG 1.4.12 text-spacing override to catch clipped or overlapping text, and for DACH-market builds scopes the BFSG/BaFG duty (jurisdiction, e-commerce service scope, microenterprise exemption) before verifying the required accessibility information. Invoke in Phase 11 (Gates) of every ultraweb build before reporting done, and whenever the user says "accessibility check", "a11y audit", "WCAG", "contrast check", "keyboard navigation", "screen reader", "focus states", "text spacing", "BFSG", "Barrierefreiheitserklärung", or "is this accessible".

- Skill: `blyatiful1/gate-accessibility` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add blyatiful1/gate-accessibility`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blyatiful1/gate-accessibility/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: blyatiful1 (https://skillmd.com/u/blyatiful1)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blyatiful1/gate-accessibility

---


# gate-accessibility — exclusion is a defect

**Stage:** Phase 11 — Gates, executed by the `gate-runner` agent (the Lead never loads this file) against the **production server of record** the Lead built and started in the Phase 11 preamble (`mkdir -p qa && rm -rf .next && npm run build > qa/build.log 2>&1`, then `PORT=3100 npm start > qa/prod.log 2>&1 &`; root SKILL.md). Fifth in the fixed sequence code → responsive → antislop → content → **accessibility** → performance. - **Reads:** design/SYSTEM.md §color + §motion, design/SITEMAP.md (route list), design/BRIEF.md (market + §Compliance facts), design/DIRECTION.md (scene scope), built components - **Writes:** design/QA.md §gate-accessibility (append), qa/gate-accessibility.log

## Standard

WCAG 2.2 AA on every route, in both themes, verified by measurement — never by reading code and assuming: contrast computed, the keyboard path driven key by key, reduced motion emulated and re-shot. The constitution calls this the floor, not a feature: a red here blocks ship regardless of how the site looks. For a DACH commercial brief the floor can also be legal — Germany's BFSG and Austria's BaFG (in force since 2025-06-28) make WCAG/EN 301 549 statutory for in-scope B2C services — so item 9 scopes the duty before anyone claims conformance, and a statement's claimed level must match what these checks measured.

## Checklist

1. **Contrast** — text ≥4.5:1; large text (≥24px, or ≥18.66px bold) ≥3:1; non-text UI (input borders, meaning-bearing icons, focus rings against their surface) ≥3:1. Light AND dark theme — dark is a first-class design with its own full pass. [MEASURED]
2. **Keyboard** — every interactive element reachable by Tab in visual order; a designed focus-visible ring on every stop (never browser-default blue, never nothing); skip link is the first stop and works; Escape closes every overlay (mobile menu, modal, lightbox) and returns focus to its trigger; modals trap focus while open. [MEASURED]
3. **Landmarks & structure** — exactly one `<main>`; `<header>`/`<footer>` present; multiple `<nav>`s get distinct `aria-label`s; exactly one `<h1>` per page; heading levels never skip (h2→h4 is a defect). [MEASURED]
4. **Alt text & names** — every image has `alt`: a real description when the image argues something, `alt=""` when decorative; icon-only buttons/links have `aria-label`; no alt starting "image of"/"photo of" and no filename alt. [MEASURED]
5. **Reduced motion** — with `prefers-reduced-motion: reduce` emulated: no transform-based entrance plays, every piece of content is still present (an animated SVG path included — it must land drawn, not parked at full dashoffset), state changes read without movement. On a build carrying a DIRECTION-commissioned persistent scene, the same emulation must produce **no canvas element in the DOM at all** on every route in its scope — not a paused canvas — with that route's poster rendered and its `sr-only` narrative readable as a complete claim. [OBSERVED]
6. **WCAG 2.2 specifics** — pointer targets ≥24×24 CSS px (2.5.8; the ≥44px mobile bar is gate-responsive's, not this gate's); a focused element is never left under the sticky header (2.4.11); any drag interaction has a click alternative (2.5.7). [MEASURED]
7. **Forms** — every input has an associated `<label>` (for/id); errors are text tied via `aria-describedby` and announced (`role="alert"` or `aria-live="polite"`); invalid state exposed via `aria-invalid`; identifying fields carry `autocomplete`. [MEASURED]
8. **Text spacing (1.4.12)** — under the standard user override (line-height 1.5, letter-spacing .12em, word-spacing .16em, paragraph 2em) nothing clips, truncates, or overlaps; every fixed-height container grows to fit its text. This stack's fluid `clamp()` type inside fixed-height cards is the exact risk. [MEASURED]
9. **Accessibility statement (DACH commercial builds)** — scope FIRST, statement second; "every DACH site needs a BFSG statement" is itself a defect. Three questions, stop at the first no: (a) **Jurisdiction** — DE → BFSG; AT → BaFG; CH-only → no statutory duty today (the BehiG's web rule binds authorities, Art. 6 is only a discrimination ban, the Teilrevision that would bind private providers is still in Parliament), so WCAG AA stays craft and no statement is emitted. (b) **Service scope** — the duty covers *Dienstleistungen im elektronischen Geschäftsverkehr*: a service taking an individual consumer request toward concluding a consumer contract (shop, booking, ticketing, banking); a site that never takes such a request falls outside § 1 Abs. 3 Nr. 5 BFSG. (c) **Microenterprise** — fewer than 10 employees AND ≤ €2M turnover or balance total → service providers are exempt (§ 3 Abs. 3 BFSG / § 6 Abs. 1 BaFG; covered *products* stay bound). All three yes → the § 14 / Anlage 3 information is owed, linked in the footer beside Impressum + Datenschutz, claiming a level that matches this gate's measured result. The decision goes into QA.md even when the answer is out of scope; guidance toward the statutes, never legal sign-off. [JUDGMENT → Lead: the runner returns `market`, BRIEF §Compliance facts, the legal routes found in SITEMAP/app and whether each resolves on `prodUrl`, the footer link row, and the conformance sentence quoted from the page — it answers none of (a)–(c).]
10. **axe sweep (supplement, not substitute)** — zero violations over `wcag2a` + `wcag2aa` + `wcag22aa`; `source: null` is UNVERIFIED, not PASS; `source: 'page'` means axe was already loaded from the previous route — acceptable, record it. [MEASURED]

## How to verify

**Runner contract.** In: `gate: gate-accessibility` · `pluginRoot` · `projectRoot` · `prodUrl` · `devUrl` · `artifacts` (BRIEF, DIRECTION, SYSTEM, SITEMAP, QA, PROGRESS paths) · `tier` · `market` · `themeStrategy` · optional `rerunOnly` (check numbers — run only those). Out: verdict `PASS` / `PASS-ON-MEASURED` / `FAIL` / `UNVERIFIED` (`PASS` only when `judgment-open` is empty); every failed check as `check# · file:line or route@theme · one line · owner skill · MECHANICAL|DESIGN`; item 9's evidence as `judgment-open`; the QA.md entry appended; the path `qa/gate-accessibility.log`. Everything runs against `prodUrl`, at 1440 and 375, both themes, every route in SITEMAP.md; `devUrl` is unused.

**Never, whole dispatch:** `npm run build`, `npm start`, `npm run dev`, `rm -rf .next`, `npm install`, or any edit to source or `design/*` beyond appending the QA.md entry. The Lead owns the build, the server, the fixes.

**Scripts.** Each measurement is a library file run through `browser_run_code_unsafe` with `filename: <plugin>/scripts/measure/<name>.mjs` — an INPUT that loads the file (`code` is ignored), never a result sink, and `browser_evaluate` cannot run the library. Where a step says to seed, set `window.__ultraweb` with one `browser_evaluate` first, once per page. Results land in the runner's own context, never the Lead's; write a list-shaped one to `<projectRoot>/qa/gate-accessibility-<script>.json` with the Write tool and grep that file, never echo it. A result carrying an `error` field is UNVERIFIED, never PASS. Fallback (Phase 0 recorded `measure-library: unavailable`): the V1.8.0 inline snippet — `git -C <plugin> show V1.8.0:skills/gate-accessibility/SKILL.md`, which works only in a git checkout; in a marketplace snapshot the affected checks go UNVERIFIED.

**1 · Contrast (item 1).** Seed `{theme:"light", themeStrategy, maxPairs:200}`, run `<plugin>/scripts/measure/contrast-matrix.mjs`: it pairs every visible text node's foreground against its effective background and asserts `failing.length === 0`; read `counts.pairs`, `counts.failing`, and each failing `{selector, text, fg, bg, ratio, required}`. Then the dark pass — re-seed `{theme:"dark", themeStrategy, maxPairs:200}` and run again, then re-seed `{}` so every later result carries the page's real theme label; the script applies the theme itself (class → `html.dark`, media → emulation) and hands the page back in the state it found, so never set the class or the emulation by hand. The script normalizes each computed color through a canvas pixel before computing luminance, and that is load-bearing: oklch tokens serialize as `oklch(…)` in computed styles (CSS Color 4), so parsing that string as an rgb triplet reads L/C/H as R/G/B and yields garbage ratios. Backgrounds composite down to `body`/`html`; a pair carrying `bgAssumed:true` sits over an image, gradient, or alpha layer where no ratio is provable — report those selectors, never pass them silently. Focus-ring contrast against its surface is not in the matrix: sample it from one focused-stop screenshot per theme (≥3:1).

**2 · Keyboard (item 2).** `<plugin>/scripts/measure/keyboard-walk.mjs` tabs from document start and asserts `skipLinkFirst`, `!trap`, and `visibleFocusRing` on every stop; return `count`, every ringless stop `{index, tag, name}`, and any `inViewport:false`. `stops[]` order must match DOM order in a `browser_snapshot` — that is the visual-order check. Grep-assist: `rg -n "focus:outline-(none|hidden)" -g "*.tsx"` — a hit with no `focus-visible:` replacement in the same class list is an automatic defect (file:line, owner `micro-interactions`). Overlays, at 375: open the mobile menu, re-run `keyboard-walk` — inside an open modal every `cycledWithin` selector must sit inside the dialog and the walk must wrap to the first dialog stop; `trap:true` (focus pinned on ONE element, three identical stops) is a defect anywhere, open modal included — then press Escape and confirm with one `browser_evaluate` one-liner that no open dialog remains and `document.activeElement` is the trigger. Repeat per modal and lightbox.

**3 · Landmarks & headings (item 3).** `<plugin>/scripts/measure/landmarks-headings.mjs` asserts `issues.length === 0` over the kinds `no-main`, `no-nav`, `no-h1`, `multiple-h1`, `skipped-level`, `empty-heading`; return each issue's kind + detail. gate-content judges whether the headings tell a story; structure only here.

**4 · Alt & names (item 4).** `<plugin>/scripts/measure/alt-and-labels.mjs` asserts `issues.length === 0` over `missing-alt`, `filename-alt`, `unlabelled-control`, `icon-button-no-name`; return each issue with its `src` or selector. The `decorative:true` images go to the log as a list, not a verdict — whether an `alt=""` is honestly decorative is a design call this gate does not make.

**5 · Reduced motion (item 5, OBSERVED).** Emulate reduce — `page.emulateMedia({ reducedMotion: "reduce" })` through `browser_run_code_unsafe`, or a Chromium launched with `--force-prefers-reduced-motion` — then reload each route, scroll to the bottom, and screenshot. Re-run `landmarks-headings` and `contrast-matrix` under the emulation: a route whose heading count or `counts.pairs` drops against its baseline has content that never revealed — the classic failure is an initial `opacity: 0` whose entrance is skipped instead of completed. Report per route what the capture is missing, plus any count delta.

Source sweeps stay inline. `rg -n "whileInView|initial=\{\{ opacity: 0" app components` — every hit must sit behind motion-language's reduced-motion policy. Sweep the second engine by import specifier, `rg -l 'from "animejs"' app components`, never by bare API name (`animate(` is also motion/react and WAAPI): every hit carries the Scope `mediaQueries.reduceMotion` branch, and that branch must land the FINAL state — completed path, assembled text — never the starting one, because a `stroke-dashoffset` left undrawn renders as blank whitespace, not as a still frame. In the same files `rg -n "accessible: false"` → zero hits: `splitText`'s visually-hidden mirror is on by default and turning it off shreds the text for screen readers.

Scene builds: sweep the renderer the same way — `rg -l 'from "three"|from "@react-three/' app components lib`, never `<Canvas` or `useFrame(` — then emulate reduce on **every route in the DIRECTION.md scope**, not just the first, and report three facts per route from one `browser_evaluate` one-liner plus the capture: canvas count (must be 0), a poster `<img>` or server-rendered composition in the DOM, the `sr-only` narrative text ("an interactive 3D scene" is not a narrative — `showpiece`'s rule binds per route here). Then re-run `keyboard-walk` there and `rg -n 'addEventListener\("wheel"' app components lib` → no `preventDefault`: a site that took the wheel away took Space, PageDown, Home, End and find-in-page with it (2.1.1).

**6 · Targets & obscuring (item 6).** Seed `{threshold: 24}`, run `<plugin>/scripts/measure/targets.mjs` at 1440: asserts `small.length === 0`, returns each `{selector, text, w, h}`. The 44px bar at 375 is gate-responsive's — do not duplicate it. Obscuring: `keyboard-walk`'s `inViewport:false` stops are the 2.4.11 candidates; confirm from that stop's screenshot that the sticky header is not covering the focused element (`scroll-padding-top` on `html` is the fix, `navigation` owns it). Drag: `rg -n "onDragStart|draggable=|useDrag|PointerSensor" app components` — every hit needs a click or keyboard route to the same outcome.

**7 · Forms (item 7).** `<plugin>/scripts/measure/forms-a11y.mjs` asserts `issues.length === 0` over `unlabelled`, `required-unmarked`, `no-error-binding`, `missing-autocomplete`; return each `{kind, selector}`. Then drive the failure path: submit each form empty, re-run, and assert on the script's own `describedByResolved` — the invalid field returns `ariaInvalid:true` with `{exists:true, visible:true}` and `role:"alert"` or `ariaLive:"polite"` on that node; a dangling or invisible target is already reported as `no-error-binding`. A message that only changes color, or exists only visually, fails here.

**8 · Text spacing (item 8).** Seed `{keep: true}`, run `<plugin>/scripts/measure/text-spacing.mjs`: it injects the WCAG 1.4.12 override as `<style id="__ultraweb-text-spacing">`, waits a frame, and asserts `clipped.length === 0 && !overflowX` — `overflowX` is `documentElement.scrollWidth > documentElement.clientWidth`, the one basis used everywhere — returning each `{selector, text, scrollWidth, clientWidth}`. That catches the horizontal blowout, but vertical clipping inside a fixed `h-*` never moves it — so with the style kept, screenshot every route at 375/768/1440 and report truncated titles, cut-off buttons, overlapping lines; then re-run without `keep` (or reload) to drop the style. This is the German BITV-Test's manual procedure.

**9 · Statement scoping (item 9 — evidence only, the Lead rules).** Skip unless `market` ∈ {DE, AT, CH}. Collect and decide nothing: the payload `market`; BRIEF.md §Compliance facts verbatim (Phase 1 answers, not the verdict); the legal routes named in SITEMAP.md and present in the app — `rg -n "barrierefreiheit|impressum|datenschutz" design/SITEMAP.md app` — and whether each resolves on `prodUrl` (`curl -s -o /dev/null -w '%{http_code}' <prodUrl>/<route>`); the footer link row; the conformance sentence quoted from the statement page. Relay item 9's three questions with it so the Lead rules without opening this gate, and note that (b) is re-derived from the FINISHED build — a brochure brief that grew a booking flow re-entered scope, whatever BRIEF.md says. Content test for an in-scope build: the § 14 / Anlage 3 *Informationen zur Barrierefreiheit* — never the public-sector BGG/BITV "Erklärung" template — names the service in general terms, how it works, how it meets the BFSGV requirements, and the market-surveillance authority: **MLBF AöR, Magdeburg** for DE (competent since 2025-09-26), **Sozialministeriumservice** for AT (§ 14 Abs. 2 BaFG + Anlage 3, written AND spoken form) — never a German body on an Austrian site. `/barrierefreiheit` is the HOUSE convention, not the statute (Anlage 3 permits the AGB or any clearly perceivable form); the Schlichtungsstelle BGG is optional § 34 goodwill, never the enforcement body. Cross-border — a non-German seat selling into Germany — is settled by no quotable official source: flag it for counsel, never assert it. The runner asserts nothing here either: it returns that conformance sentence beside the residual-fail count from steps 1–8 and 10, and the Lead rules the mismatch — *vollständig konform* over residual fails is false conformance, and the claim then has to read *teilweise konform* and list them.

**10 · axe sweep (item 10).** Seed `{axePath: "<projectRoot>/node_modules/axe-core/axe.min.js"}` and run `<plugin>/scripts/measure/axe.mjs`: it loads axe-core from that path when the file exists and from the CDN otherwise (the runner installs nothing; record `source`: `path` | `cdn` | `page` | `null`), runs the `wcag2a` + `wcag2aa` + `wcag22aa` tag set (the house floor) and asserts `violations.length === 0`; return each `{id, impact, help, nodes[0].target}`. A clean run supports a BFSG/BaFG statement but never proves statutory conformity — automated checks cover only part of the BFSGV requirements, and axe cannot judge focus order, ring design, or reduced motion: steps 1–9 stay mandatory, the claim rests on this gate's full result.

## Pass criteria

Items 1–8 and 10 green (+ 9 ruled) on every route in design/SITEMAP.md, both themes, at 375px and 1440px. Item 9 applies only to DACH-market builds and closes on the Lead's ruling: while it is open the runner's verdict is `PASS-ON-MEASURED`. A fix re-dispatches this gate with `rerunOnly` naming exactly the checks that failed — no "fixed, trust me" — and the production server serves the build of record, not the working tree, so a fix that touched source, config, tokens or dependencies repeats the Phase 11 preamble first; a token or config change widens the re-run back to the whole gate, since it can move every measurement. A rulings-only round changed no code and skips the rebuild.

## Degraded mode (no browser)

When Phase 0's preflight reported no Playwright MCP, the verdict is **UNVERIFIED**, never a fake PASS, and the return opens with "NO BROWSER — Playwright MCP not available; zero routes verified." The split stays explicit. Still runs in full, still blocks on failure: computed contrast math on every token pair in globals.css (contrast is arithmetic, not pixels), the semantic-HTML and landmark audit of the source, alt-text presence, `prefers-reduced-motion` guards in the motion code, focus-visible styles in CSS, item 9's route/link collection. Cannot run: the keyboard walk, real focus order, text-spacing overflow, axe against a rendered DOM. QA.md records both halves: `UNVERIFIED — no browser: token contrast + source audit PASS; keyboard path, focus order, axe unproven.` Accessibility claimed from source alone is a claim, and the entry must read like one.

## QA.md entry

The runner appends it with a shell heredoc (`cat >> <QA path> <<'EOF'`) — never rewrites QA.md, never Reads it for an anchor. Format unchanged:

```md
## gate-accessibility — PASS (2026-07-16)
routes: / /about /pricing · themes: light+dark · viewports: 375/1440
contrast: 0 failing pairs (214 checked, min 4.61) · keyboard: 42 stops, ring on all, Esc closes menu+modal
landmarks/alt/targets: clean · forms: labels 0 missing, empty-submit announces on contact form · reduced-motion: re-shot, no hidden content · axe: 0 violations (wcag2a+2aa+22aa, source: path)
text-spacing (1.4.12): override on, 0 clips/overlaps
BFSG scope (DE): evidence for the Lead — market DE, /barrierefreiheit 200, footer links it beside Impressum+Datenschutz, page claims 'vollständig konform' against 0 residual fails
fixed: footer link 3.9:1 → 4.7:1 (muted token bumped in color ramp) · residual: none
```

On `rerunOnly`, append a dated re-run block naming only the re-run checks:

```md
## gate-accessibility — re-run (2026-07-16): checks 1, 8
contrast: 0 failing pairs (216 checked, min 4.52) · text-spacing: 0 clips, no overflowX → both PASS
```

Full stdout/stderr of every failing or unverified command goes to `<projectRoot>/qa/gate-accessibility.log` (`mkdir -p` first, overwrite per dispatch). The return carries that path, never the contents.

## Anti-patterns

- `focus:outline-none`/`focus:outline-hidden` with no `focus-visible:` replacement (grep it — automatic defect)
- Fixing a contrast fail with a hard-coded hex on one element instead of the token ramp (`ultraweb:color` owns it) — the same pair fails on the next surface
- `alt="image"`; `aria-label` parroting visible text onto everything
- `tabindex` greater than 0 (grep `tabindex="[1-9]`) — forces tab order instead of fixing DOM order
- Passing reduced motion with a global `* { animation: none }` — color/opacity feedback must survive; state never depends on movement alone
- Fixed-height title clamps (`line-clamp` inside a hard `h-*`) that truncate under the text-spacing override instead of growing — `min-h-*` + flex is the fix (ultraweb:cards owns it)
- A clean `/barrierefreiheit` claim ("vollständig barrierefrei") while the gate still shows residual fails — evidence-before-claims governs compliance copy too
- Emitting a statement without the scoping ruling — a German BFSG page on an Austrian or Swiss build, any statement on an out-of-scope brochure site, or the Schlichtungsstelle BGG presented as the enforcement body (that is the MLBF)
- Reporting a verdict from one theme, one viewport, or the home page

## Worked example — Aldermoor Trust, community foundation grants + stories

Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.

## Composes with

Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.

