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
- 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]
- 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]
- Landmarks & structure — exactly one
<main>;<header>/<footer>present; multiple<nav>s get distinctaria-labels; exactly one<h1>per page; heading levels never skip (h2→h4 is a defect). [MEASURED] - Alt text & names — every image has
alt: a real description when the image argues something,alt=""when decorative; icon-only buttons/links havearia-label; no alt starting "image of"/"photo of" and no filename alt. [MEASURED] - Reduced motion — with
prefers-reduced-motion: reduceemulated: 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 itssr-onlynarrative readable as a complete claim. [OBSERVED] - 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]
- Forms — every input has an associated
<label>(for/id); errors are text tied viaaria-describedbyand announced (role="alert"oraria-live="polite"); invalid state exposed viaaria-invalid; identifying fields carryautocomplete. [MEASURED] - 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] - 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 onprodUrl, the footer link row, and the conformance sentence quoted from the page — it answers none of (a)–(c).] - axe sweep (supplement, not substitute) — zero violations over
wcag2a+wcag2aa+wcag22aa;source: nullis 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:
## 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:
## 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-hiddenwith nofocus-visible:replacement (grep it — automatic defect)- Fixing a contrast fail with a hard-coded hex on one element instead of the token ramp (
ultraweb:colorowns it) — the same pair fails on the next surface alt="image";aria-labelparroting visible text onto everythingtabindexgreater than 0 (greptabindex="[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-clampinside a hardh-*) that truncate under the text-spacing override instead of growing —min-h-*+ flex is the fix (ultraweb:cards owns it) - A clean
/barrierefreiheitclaim ("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.