Claude Skill

a11y-ops

Web accessibility end to end - WCAG 2.2 conformance, legal obligations (EAA, ADA Title II), auditing with automated + keyboard + screen-reader passes, and the failures that appear on most sites. Triggers on: accessibility, a11y, WCAG, WCAG 2.2, AA conformance, EAA, European Acces

LLM Mart · 0 points · 0 views 1 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_a11y-ops-3dfaf0b.zip · 25 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/a11y-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

a11y-ops

Accessibility stopped being a quality preference and became a legal requirement with dates attached. It is also, unhelpfully, a domain where the tooling everyone reaches for finds well under half the problems — so the work is mostly about knowing where automation stops.

Helps with

"Is our site compliant?" — a question with a real answer that depends on which jurisdiction, which standard version, and which deadline applies to that client.

An audit that needs to find real failures rather than produce a score. Vendor "94% accessible" numbers correspond to nothing in the standard.

A component library where every custom control is mouse-only, because a <div> replaced a <button> and nothing replaced what the <button> was doing.

Forms that lose conversions from screen reader and keyboard users: placeholders used as labels, errors signalled only in red, no autocomplete.

outline: none shipped across a design system, leaving keyboard users with no idea where they are.

A modal, dropdown or date picker that traps focus, or drops it, or never returns it to the trigger.

Writing an accessibility statement that is honest enough to be defensible rather than an overclaim that creates its own liability.

Wiring accessibility into CI so a fix stays fixed instead of being re-bought at the next audit.

The two things that decide everything

1. Automated tooling finds roughly 30–40% of WCAG failures. Every plan follows from this. A green axe run is a floor, not a result — the rest needs a keyboard and a human. Anyone selling automated compliance is selling the 30%.

2. Use the native element. <button>, <a href>, <input>, <select>, <details> arrive with focusability, keyboard activation, correct role and state announcement already handled. Almost every failure in the catalogue comes from replacing one with a <div> and rebuilding a fraction of what was lost. Its corollary is the first rule of ARIA: no ARIA beats bad ARIA, because role="button" claims a contract a div does not fulfil.

Which standard, and by when

Target WCAG 2.2 Level AA for essentially every project. It satisfies every regime below, and building to 2.1 to skip nine criteria just buys a migration at a worse moment.

Regime Standard Status (verified 2026-08-30)
EAA (EU) EN 301 549 v3.2.1 → WCAG 2.1 AA Applicable since 28 Jun 2025; 2026 is the first full supervision year. v4.1.1 expected 2026 moves it to WCAG 2.2. Fines ~€5k–€500k
ADA Title II (US) WCAG 2.1 AA Deadlines extended 20 Apr 2026: 26 Apr 2027 (pop ≥50k), 26 Apr 2028 (smaller)
UK PSBAR WCAG 2.2 AA Public sector; accessibility statement required

The EAA is extraterritorial — it applies to anyone offering services to consumers in the EU regardless of where the business sits. And conformance is per-page and all-or-nothing: one failed AA criterion means the page does not conform. There is no partial credit and no percentage.

Full detail, the nine criteria new in WCAG 2.2, and what a conformance claim commits you to → references/wcag-conformance.md.

Workflow — four passes, cheapest first

1. Static source scan (seconds, in CI)

scripts/scan-a11y.py src/                          # exit 10 = findings
scripts/scan-a11y.py --min-severity serious src/
scripts/scan-a11y.py --json src/ | jq '.data[] | select(.severity=="critical")'

Catches the mechanical failures in HTML/JSX/Vue/Svelte/Astro source before anything renders: missing alt, unlabelled inputs, placeholder-as-label, icon-only controls with no name, click handlers on non-interactive elements, positive tabindex, heading skips, aria-hidden on focusable elements, untitled iframes, unmuted autoplay, duplicate ids.

Exit codes: 0 clean · 2 usage · 3 path missing · 5 nothing scannable · 10 findings. It reads source, so it cannot see computed contrast or conditionally-rendered markup — it is a pre-filter, not the audit.

2. Automated DOM scan (minutes, per route)

Run axe-core against the rendered page — ideally inside the Playwright suite you already have, so authentication and navigation aren't rebuilt in a separate crawler (playwright-ops). Alternatives: pa11y-ci for URL lists, Lighthouse for quick triage.

Scan states, not just pages. Open the menu, trigger the error, expand the accordion. A modal's focus trap is invisible to a scan of the page behind it.

3. Keyboard pass (10 minutes, highest yield)

Put the mouse down. Tab the whole page: is focus order logical, is the indicator always visible and not eclipsed by a sticky header (2.4.11), is everything mouse-reachable also keyboard-reachable, can you escape every modal and does focus return to the trigger, does the skip link move focus rather than just scroll?

Nearly every custom component fails one of these, and none of them appear in an automated scan.

4. Screen reader pass (30+ minutes)

Test one combination properly: NVDA + Firefox on Windows, or VoiceOver + Safari on macOS. Listen for name, role and state on every control; a sensible heading outline; errors that are announced; alt text that says what the image means here.

Tool comparison, what to test in what order, and how to write a finding a developer can act on → references/audit-workflow.md.

The failures you will actually find

Ranked by frequency, with fixes that remove the class rather than the instance — form fields without programmatic labels, errors carried only in colour, icon-only controls with no name, keyboard-dead custom controls, outline: none, focus traps, headings chosen for size, wrong-rather-than-missing alt text, meaningless link text, ARIA that lies, targets under 24×24 (2.5.8), unstoppable motion, missing skip links and landmarks → references/common-failures.md.

Three worth knowing before you read it:

  • aria-hidden="true" on anything focusable creates a focusable element with no accessible name — a guaranteed 4.1.2 failure, still in the tab order.
  • tabindex above 0 overrides DOM order globally. 0 and -1 are the only values worth using.
  • A live region must exist in the DOM before the content arrives, or nothing is announced.

Accessibility statements

Publishing one is part of the EAA obligation, not a nicety. Start from assets/accessibility-statement.template.md.

Do not overclaim. A statement is a written representation about the product, so an undisclosed known failure is a worse problem than the failure. "Partially conformant" with a listed issue and a remediation date is a normal, defensible position; "fully conformant" without an audit is not.

What this skill doesn't cover

  • Contrast ratios and palette maths (WCAG 1.4.3, APCA) → color-ops
  • Icon and logo specifics — the two-case naming rule, target size for icon-only controls → icon-ops
  • Wiring axe into a browser test suite → playwright-ops
  • Native mobile accessibility (UIKit/Android APIs) — different platform APIs
  • Legal advice. This encodes standards, dates and obligations as published; a compliance decision with money attached needs a lawyer, not a skill.

Cross-references

When Use
Checking a palette meets 1.4.3 / 1.4.11 color-ops
Naming icon-only controls, logo alt icon-ops
Automating the DOM scan in e2e playwright-ops, testing-ops
The component library needs rebuilding around native elements refactor-ops

References

  • references/wcag-conformance.md — the standards map: WCAG 2.1 vs 2.2 with all nine new criteria and why 4.1.1 was removed; EAA dates, penalties and extraterritorial reach; the extended ADA Title II deadlines; and what a conformance claim actually commits you to (per-page, all-or-nothing, overlays don't count). Load before quoting a deadline or a target to a client.

  • references/audit-workflow.md — the four passes with what each can and cannot detect, the axe/pa11y/Lighthouse comparison, screen-reader/browser pairings, where to spend a single hour, how to report a finding usefully, and regression gating. Load when running an audit.

  • references/common-failures.md — the twelve recurring failures with before/after code and the class-level fix. Load when remediating, or when reviewing a component library.

Scripts

  • scripts/scan-a11y.py — static pre-flight for high-confidence WCAG failures in HTML/JSX/Vue/Svelte/Astro source. --min-severity, --rule to filter, --json envelope, exit 10 as the CI domain signal. Deliberately conservative: a linter that cries wolf gets muted, and a muted linter is worse than none.

Assets

  • assets/accessibility-statement.template.md — heavily commented statement template covering conformance status, known issues, assessment method, compatibility, feedback route and the enforcement procedure per jurisdiction.
Files (claude-mods)
  • assets
    • accessibility-statement.template.md 3.5 KB
      <!--
        ACCESSIBILITY STATEMENT TEMPLATE
      
        Publishing one is part of the EAA obligation, not a nicety, and the UK Public
        Sector Bodies Accessibility Regulations require one with specific content.
      
        ADAPT EVERY <BRACKETED> FIELD. A statement that overclaims is worse than none:
        it is a written representation about a product, so an unfixed known failure
        that the statement does not disclose is the problem, not the failure itself.
        Disclose honestly and date it - a partial-conformance statement with a
        remediation plan is a normal, defensible position.
      
        Publish at a stable URL (conventionally /accessibility), link it from the
        global footer, and make sure the page itself conforms.
      -->
      
      # Accessibility Statement for <ORGANISATION / SERVICE NAME>
      
      **Last reviewed: <YYYY-MM-DD>**
      
      <ORGANISATION> is committed to making <SERVICE NAME> accessible to as many
      people as possible, including people with disabilities.
      
      ## Conformance status
      
      This <website / application> aims to conform to the **Web Content Accessibility
      Guidelines (WCAG) 2.2 Level AA**.
      
      **Current status: <fully conformant | partially conformant | non-conformant>.**
      
      <"Partially conformant" means some parts do not fully conform to the standard.
      The specific known issues are listed below.>
      
      ## Known issues
      
      <List each unresolved failure honestly. Delete this section only if there are
      genuinely none — and only if that has been verified by audit, not assumed.>
      
      | Issue | Who it affects | WCAG criterion | Planned fix |
      |---|---|---|---|
      | <e.g. Some older PDF documents are not tagged and cannot be read by screen readers> | Screen reader users | 1.3.1, 1.1.1 | <Being re-issued by DATE> |
      | <e.g. The archived video library has no captions> | Deaf and hard-of-hearing users | 1.2.2 | <Captioning in progress, DATE> |
      
      ### Content outside our control
      
      <Name any third-party content — an embedded map, chat widget, payment iframe or
      advertising slot — that you do not control, and say what you have done about it.
      This does not automatically exempt you where you chose to include it.>
      
      ## How this was assessed
      
      - **Method:** <self-assessment | third-party audit by NAME>
      - **Date of assessment:** <YYYY-MM-DD>
      - **Assessed against:** WCAG 2.2 Level AA
      - **Techniques:** automated scanning (<tool>), manual keyboard testing, and
        screen reader testing with <NVDA + Firefox / VoiceOver + Safari>
      
      ## Compatibility
      
      This <website / application> is designed to work with current versions of major
      browsers and with the following assistive technologies: <NVDA, JAWS, VoiceOver,
      TalkBack>. <Note any known incompatibility, e.g. with browsers more than N
      versions old.>
      
      ## Feedback and contact
      
      We welcome reports of accessibility barriers. If you find one, or need content
      in an alternative format:
      
      - **Email:** <accessibility@example.com>
      - **Phone:** <+00 0000 000000>
      - **Postal address:** <address>
      
      **We aim to respond within <N> working days.**
      
      ## Enforcement procedure
      
      <EU / EAA:> If you are not satisfied with our response, you may contact the
      relevant national enforcement body in your member state.
      
      <UK:> If you are not happy with our response, contact the Equality Advisory and
      Support Service (EASS).
      
      <US, ADA Title II:> <Name the grievance procedure and the responsible officer.>
      
      ## Preparation of this statement
      
      This statement was prepared on <YYYY-MM-DD> and last reviewed on <YYYY-MM-DD>.
      
      <Set a review cadence and honour it. Conformance decays with every content edit,
      so a statement dated two years ago is itself evidence that nobody is checking.>
      
  • references
    • audit-workflow.md 6.4 KB
      # Running an Accessibility Audit
      
      How to actually find the failures, in the order that finds the most for the least
      effort — and an honest account of what each layer can and cannot detect.
      
      ---
      
      ## The uncomfortable number
      
      **Automated tooling detects roughly 30–40% of WCAG failures.** That figure is
      consistent across vendors and independent studies, and it is the single most
      important fact in this file. Everything about how you plan an audit follows from
      it:
      
      - A green axe run is a **floor**, not a result.
      - The majority of failures require a human judgement — is this alt text
        *meaningful*, is this focus order *logical*, does this error message *explain
        how to fix it*.
      - Anyone selling "automated compliance" is selling the 30%.
      
      So the workflow below spends automation on the cheap, mechanical failures and
      reserves human time for what only humans can decide.
      
      ## The four passes, in order
      
      ### Pass 1 — Static source scan (seconds, in CI)
      
      `scripts/scan-a11y.py src/` catches the mechanical failures before anything is
      even built. Cheapest possible feedback, and it works on components in isolation
      rather than needing a running page.
      
      ```bash
      scan-a11y.py --min-severity serious src/     # exit 10 on findings
      ```
      
      It reads *source*, so it sees less than a DOM-based tool: it cannot evaluate a
      computed contrast ratio, a dynamically-set attribute, or anything a framework
      renders conditionally. Treat it as a pre-filter that stops obvious defects
      reaching the expensive passes.
      
      ### Pass 2 — Automated DOM scan (minutes, per route)
      
      Run against the **rendered** page, which catches what source cannot: computed
      contrast, ARIA relationships that resolve at runtime, generated markup.
      
      | Tool | Shape | Use when |
      |---|---|---|
      | **axe-core** | library; the engine inside most others | The default. Embed in your e2e suite |
      | **@axe-core/playwright** | Playwright integration | You already have Playwright (see `playwright-ops`) |
      | **pa11y / pa11y-ci** | CLI + config, URL lists | Auditing a set of URLs outside a test suite |
      | **Lighthouse** | bundled in Chrome DevTools | Quick single-page triage; its a11y score is *not* a conformance measure |
      | **IBM Equal Access** | scanner + reports | When you need a report artefact for a client |
      
      Wire it into the e2e suite rather than as a separate job — a route already has a
      Playwright test that navigates and authenticates it, and duplicating that setup
      in a standalone crawler is how a11y checks end up unmaintained.
      
      **Scan states, not just pages.** The default state of a page is often its most
      accessible one. Open the menu, trigger the error, expand the accordion, and scan
      each — a modal's focus trap is invisible to a scan of the page behind it.
      
      ### Pass 3 — Keyboard (10 minutes per page, finds the most)
      
      The highest-yield manual pass, and it needs no assistive technology. Put the
      mouse down and:
      
      1. **Tab through the whole page.** Does focus order follow the visual order?
      2. **Is focus always visible?** Not just present — visible against its
         background, and not eclipsed by a sticky header or a cookie bar (2.4.11).
      3. **Can you reach everything interactive?** Anything reachable by mouse must be
         reachable by keyboard (2.1.1).
      4. **Can you get back out?** Open a modal, a date picker, a custom dropdown —
         does focus move in, stay in, and return to the trigger on close? A focus trap
         you cannot escape is a 2.1.2 failure and traps a keyboard user on the page.
      5. **Does Escape close what it should?** Does Enter/Space activate what looks
         like a button?
      6. **Is there a skip link**, and does it actually move focus (not just scroll)?
      
      Almost every custom component fails at least one of these, and none of them show
      up in an automated scan.
      
      ### Pass 4 — Screen reader (30+ minutes, finds the subtle ones)
      
      Test with **one** screen reader properly rather than four badly. Pair them
      correctly, because SR + browser combinations behave differently:
      
      | Screen reader | Pair with | Platform |
      |---|---|---|
      | **NVDA** (free) | Firefox or Chrome | Windows — the highest-usage combination |
      | **VoiceOver** (built in) | Safari | macOS / iOS |
      | **JAWS** (paid) | Chrome | Windows enterprise |
      | **TalkBack** (built in) | Chrome | Android |
      
      What to listen for:
      
      - Does each control announce a **name, a role and its state**? "Button" alone is
        a failure; "Delete item, button" is right.
      - Do headings form a sensible outline when you navigate by heading?
      - Are form errors **announced**, not just coloured red?
      - Is dynamic content announced (live regions), and is it announced *once*?
      - Does the alt text say what the image *means* here, not what it depicts?
      
      ## Where to spend limited time
      
      If you have one hour, in this order:
      
      1. **Keyboard pass on the primary conversion flow.** Highest failure density,
         highest business impact.
      2. **Automated scan across all routes** to sweep the mechanical failures.
      3. **Forms** — labels, error messages, required-field indication. Forms are where
         accessibility failures turn directly into lost revenue.
      4. **Focus management in whatever is custom** — the bespoke dropdown, modal or
         tab set. Native elements are usually fine; the hand-rolled ones are not.
      
      ## Reporting a finding usefully
      
      A finding that a developer cannot act on wastes everyone's time. Each one needs:
      
      - **Where** — page URL plus a selector or component name.
      - **What** — the criterion number *and* what a user actually experiences.
        "2.4.7 fails" is a citation; "keyboard users cannot see which control is
        focused in the nav" is a bug report.
      - **Who it affects** — screen reader, keyboard-only, low vision, cognitive.
      - **Severity** — blocker (cannot complete the task) vs serious vs minor. Not all
        Level A failures are equally harmful in context.
      - **A suggested fix**, ideally the native element that removes the problem.
      
      ## Regression: keep it fixed
      
      Fixing accessibility once and not gating it means paying for the audit again
      next year.
      
      - Put the static scan in the pre-commit or CI gate (exit 10 = findings).
      - Add axe assertions to the e2e tests for the flows that matter.
      - Treat a new component without keyboard support as an incomplete component,
        not a follow-up ticket.
      - Re-audit on a cadence — conformance decays with every CMS edit.
      
      ## Cross-reference
      
      - What standard applies and by when → [wcag-conformance.md](wcag-conformance.md)
      - The specific failures and their fixes → [common-failures.md](common-failures.md)
      - Wiring axe into Playwright → `playwright-ops`
      - Contrast maths and palette checking → `color-ops`
      
    • common-failures.md 7.9 KB
      # The Failures You Will Actually Find
      
      Ranked by how often they appear, with the fix that removes the class of problem
      rather than the instance. Most of these have the same root cause: a native
      element was replaced by a `<div>`, and everything the native element gave you for
      free had to be rebuilt and wasn't.
      
      ---
      
      ## The one rule that prevents most of this
      
      **Use the native element.** `<button>`, `<a href>`, `<input>`, `<select>`,
      `<details>` arrive with focusability, keyboard activation, correct role, state
      announcement and platform conventions already handled. A `<div role="button"
      tabindex="0">` needs all of that hand-written, and the hand-written version is
      where the failures live.
      
      The corollary — the **first rule of ARIA** — is that no ARIA is better than bad
      ARIA. `role="button"` on a div is worse than a `<button>`, because it *claims* a
      contract it does not fulfil.
      
      ## 1. Form fields without a programmatic label
      
      The most common serious failure, and the most commercially expensive because it
      sits on your conversion path.
      
      ```html
      <!-- Broken: placeholder is not a label. It vanishes on input, fails contrast
           in most designs, and is ignored or double-announced by many screen readers -->
      <input type="email" placeholder="Email address">
      
      <!-- Correct -->
      <label for="email">Email address</label>
      <input type="email" id="email" autocomplete="email">
      
      <!-- Correct when the design has no visible label (reconsider first) -->
      <input type="search" aria-label="Search products">
      ```
      
      `autocomplete` is not decoration: **1.3.5 Identify Input Purpose (AA)** requires
      it on fields collecting personal data, and it materially helps users with motor
      and cognitive impairments.
      
      ## 2. Errors that only exist in colour
      
      ```html
      <!-- Broken: red border only. Invisible to colourblind and SR users -->
      <input class="error">
      
      <!-- Correct: programmatic state, a described-by message, and text -->
      <label for="pw">Password</label>
      <input id="pw" type="password" aria-invalid="true" aria-describedby="pw-err">
      <p id="pw-err">Password must be at least 12 characters.</p>
      ```
      
      **Say how to fix it, not that it broke.** "Invalid input" fails 3.3.3 Error
      Suggestion; "Enter a date as DD/MM/YYYY" passes. And **1.4.1 Use of Colour**
      means colour can never be the only carrier of meaning — pair it with text or an
      icon shape.
      
      ## 3. Icon-only controls with no accessible name
      
      ```html
      <!-- Broken: announced as "button" -->
      <button><svg>…</svg></button>
      
      <!-- Correct: name the CONTROL, keep the icon hidden -->
      <button aria-label="Delete item">
        <svg aria-hidden="true" focusable="false">…</svg>
      </button>
      ```
      
      The counter-intuitive part: the SVG stays `aria-hidden` **even when the icon is
      the only content**. A name on the icon *and* the button produces a double
      announcement. (`icon-ops` owns this in depth.)
      
      ## 4. Custom controls that are keyboard-dead
      
      ```html
      <!-- Broken: mouse-only. Not focusable, no keyboard activation, no role -->
      <div class="btn" onclick="save()">Save</div>
      
      <!-- Correct -->
      <button type="button" onclick="save()">Save</button>
      ```
      
      If you genuinely cannot use a `<button>`, the div needs `role="button"`,
      `tabindex="0"`, **and** a key handler for both Enter and Space — plus
      `aria-pressed`/`aria-expanded` if it has state. Four things instead of zero.
      
      `type="button"` matters: a `<button>` inside a form defaults to `type="submit"`
      and will submit it.
      
      ## 5. Focus you cannot see, or cannot escape
      
      - **`outline: none` with no replacement** is the single most damaging line of CSS
        in accessibility. If you dislike the default ring, replace it:
        `:focus-visible { outline: 2px solid; outline-offset: 2px; }`.
      - `:focus-visible` rather than `:focus` gives keyboard users a ring without
        showing one on mouse click — which is the reason people remove it.
      - **Focus obscured (2.4.11, AA in 2.2):** a sticky header or cookie banner that
        covers the focused element fails, even though focus is technically visible.
        `scroll-margin-top` on focusable elements is the usual fix.
      - **Focus traps:** a modal must move focus in on open, keep Tab inside while
        open, close on Escape, and **return focus to the trigger** on close. Missing
        the last step is the most common half-implementation.
      - **Never `tabindex` above 0.** It overrides DOM order globally and the
        resulting tab sequence is unmaintainable. `0` and `-1` are the only values you
        need.
      
      ## 6. Headings used for size
      
      ```html
      <h1>Page title</h1>
      <h4>Because h4 looked right</h4>   <!-- fails 1.3.1 -->
      ```
      
      Screen reader users navigate by heading; the levels are the document outline.
      Size is a CSS decision. One `<h1>` per page, no skipped levels, and if the design
      needs small-but-important text, style an `<h2>`.
      
      ## 7. Images whose alt text is wrong rather than missing
      
      Missing alt is caught by every tool. *Wrong* alt is caught by none of them.
      
      | Case | Correct alt |
      |---|---|
      | Decorative / repeats adjacent text | `alt=""` — **empty, not omitted** |
      | Informative | What the image *conveys here*, not what it depicts |
      | Image inside a link | Where the link goes |
      | Chart or graph | The finding, with the data in an adjacent table |
      | Logo linking home | The company name — never `alt="Acme logo"` |
      | Text in an image | The text verbatim (and reconsider the image) |
      
      Alt text is contextual: the same photograph needs different alt in a news story
      and a shopping grid.
      
      ## 8. Link text that means nothing out of context
      
      Screen reader users list links to navigate. A list of nine "Read more" entries is
      useless. Make the link text describe its destination, or extend it with visually
      hidden text — never with `title`, which is unreliable and mouse-only.
      
      Also: **2.4.4** is failed by a bare URL as link text, and by two links with the
      same text going to different places.
      
      ## 9. ARIA that lies
      
      - `aria-label` on a non-interactive element (`<div>`, `<span>`) is widely ignored.
      - `role="presentation"` on something interactive removes its semantics but not its
        behaviour.
      - `aria-hidden="true"` on anything focusable creates a focusable element with no
        accessible name — a guaranteed 4.1.2 failure and one of the nastiest, because
        the element is still in the tab order.
      - Live regions (`aria-live="polite"`) must exist in the DOM **before** the content
        arrives, or nothing is announced.
      - `aria-expanded`, `aria-selected`, `aria-checked` must be **updated in JS**. A
        state attribute set once at render and never changed is worse than absent.
      
      ## 10. Touch targets under 24×24 (2.5.8, new AA)
      
      Pad the *control*, not the icon:
      
      ```css
      .icon-btn { display: inline-flex; place-items: center; min-width: 24px; min-height: 24px; padding: .5rem; }
      ```
      
      24×24 CSS px is the AA minimum. 44×44 is the long-standing usability guidance and
      is what mobile actually needs. Note the spacing exception: a small target can pass
      if it has sufficient clear space around it.
      
      ## 11. Motion that cannot be stopped
      
      ```css
      @media (prefers-reduced-motion: reduce) {
        *, *::before, *::after { animation-duration: .01ms !important;
          animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
      }
      ```
      
      Anything auto-playing, blinking or scrolling for more than five seconds needs a
      pause/stop/hide control (2.2.2). Carousels are the usual offender.
      
      ## 12. Skipped structure
      
      - **No skip link** — keyboard users tab through the entire nav on every page.
        It may be visually hidden until focused, but it must move focus, not just scroll.
      - **No landmarks** — use `<header>`, `<nav>`, `<main>`, `<footer>`. Exactly one
        `<main>`.
      - **Missing `lang`** — the wrong speech synthesiser voice makes content
        unintelligible. Mark inline language changes with `lang` too.
      - **No page `<title>`**, or the same title on every route: it is the first thing
        announced on navigation.
      
      ## Cross-reference
      
      - Which of these are legally required and by when → [wcag-conformance.md](wcag-conformance.md)
      - How to find them systematically → [audit-workflow.md](audit-workflow.md)
      - Contrast ratios → `color-ops` · Icons and logos → `icon-ops`
      
    • wcag-conformance.md 6.3 KB
      # WCAG, EN 301 549 and the Law
      
      What standard applies to you, what it actually requires, and what a conformance
      claim commits you to. Facts verified **2026-08-30** — dates and version numbers
      in this area move, so re-check before quoting one to a client.
      
      ---
      
      ## The standards, in one paragraph
      
      **WCAG** is the technical standard, published by the W3C. **EN 301 549** is the
      European harmonised standard that *incorporates* WCAG and adds non-web
      requirements. **The EAA** and **ADA Title II** are laws that point at those
      standards. So there is one body of technical criteria and several legal
      instruments that make it enforceable in different jurisdictions.
      
      | Level | What it means in practice |
      |---|---|
      | **A** | Baseline. Failing these excludes people outright |
      | **AA** | **The legal target everywhere.** Every regime below requires AA |
      | **AAA** | Not expected wholesale; W3C explicitly says AAA conformance is not required as a general policy |
      
      ## WCAG 2.2 — what changed from 2.1
      
      WCAG 2.2 (October 2023) has **87 success criteria**. It added nine and removed
      one. It is backwards-compatible: satisfying 2.2 satisfies 2.1.
      
      | New in 2.2 | Level | What it requires |
      |---|---|---|
      | 2.4.11 Focus Not Obscured (Minimum) | **AA** | A focused element must not be *entirely* hidden by other content (sticky headers, cookie bars) |
      | 2.4.12 Focus Not Obscured (Enhanced) | AAA | Not even partially hidden |
      | 2.4.13 Focus Appearance | AAA | Minimum size and contrast for the focus indicator |
      | 2.5.7 Dragging Movements | **AA** | Anything draggable needs a single-pointer alternative |
      | 2.5.8 Target Size (Minimum) | **AA** | Interactive targets at least **24×24 CSS px**, with spacing exceptions |
      | 3.2.6 Consistent Help | **A** | Help mechanisms appear in a consistent place across pages |
      | 3.3.7 Redundant Entry | **A** | Don't make people re-enter information they already gave you |
      | 3.3.8 Accessible Authentication (Minimum) | **AA** | No cognitive function test (puzzles, transcription) without an alternative |
      | 3.3.9 Accessible Authentication (Enhanced) | AAA | As above, without the object-recognition exception |
      
      **Removed: 4.1.1 Parsing.** It was obsoleted — modern browsers recover from
      duplicate ids and malformed nesting, so it no longer mapped to real user harm.
      Tools still reporting "4.1.1 failures" are out of date. (Duplicate ids remain a
      genuine problem where they break `label`/`aria-*` association, which is why this
      skill's scanner reports them under 4.1.2 instead.)
      
      The three AA additions with real design consequences are **2.5.8** (target size
      — it forces padding decisions across a whole component library), **2.4.11**
      (sticky UI must not eclipse focus) and **3.3.8** (kills "type the characters
      from this image" auth).
      
      ## The European Accessibility Act
      
      The EAA became applicable on **28 June 2025** for new products and newly
      published digital content. **2026 is the first full year national authorities
      supervise against it**, and enforcement is expected to intensify through the year
      as monitoring bodies staff up.
      
      - **Standard:** EN 301 549 **v3.2.1**, which incorporates **WCAG 2.1 Level AA**
        in full. **v4.1.1 is expected during 2026 and moves to WCAG 2.2** — so building
        to 2.2 AA now is the cheaper path, not gold-plating.
      - **Extraterritorial.** It applies to anyone offering products or services to
        consumers *in the EU*, regardless of where the business is established. "We're
        not an EU company" is not a defence.
      - **Penalties** are set per member state and range roughly **€5,000 to
        €500,000**; Germany, for example, provides for up to €100,000 per violation.
      - **Transitional:** service contracts concluded before 28 June 2025 must comply
        by **28 June 2027**.
      - **An accessibility statement is part of the obligation**, not a nicety — see
        [`assets/accessibility-statement.template.md`](../assets/accessibility-statement.template.md).
      
      ## ADA Title II (United States)
      
      **Check this one carefully — the dates moved recently and most published advice
      is stale.** On **20 April 2026 the DOJ issued an interim final rule extending the
      Title II compliance dates by one year**:
      
      | Covered entity | Deadline |
      |---|---|
      | Public entities serving a population of **50,000 or more** | **26 April 2027** |
      | Smaller entities and special district governments | **26 April 2028** |
      
      The standard is **WCAG 2.1 Level AA**. The DOJ stated it "fully anticipates
      implementing the regulation at the new deadline", which legal commentary reads as
      a signal that enforcement follows the dates rather than slipping again.
      
      Title II covers state and local government. **Title III** (private businesses as
      places of public accommodation) has no equivalent regulation specifying WCAG, but
      is litigated heavily on the same substance — so the practical target is identical.
      
      ## What a conformance claim actually says
      
      Conformance is **per-page** (or per-process for a multi-step flow), and it is
      all-or-nothing at the chosen level: **one failed Level AA criterion means the page
      does not conform to AA.** There is no partial credit, no percentage score.
      
      Consequences worth stating to a client before they ask for "a compliance badge":
      
      - **A score is not a claim.** Tool output like "94% accessible" corresponds to
        nothing in the standard. Vendors sell it; the standard does not recognise it.
      - **Third-party content still counts** where you control its inclusion — an
        embedded map, a chat widget, an ad slot.
      - **Accessibility-overlay widgets do not confer conformance**, and have
        repeatedly failed in litigation. Treat a request for one as a signal that
        someone is looking for a shortcut, and explain the remediation path instead.
      - **Conformance decays.** A page that conformed at launch does not conform after
        six months of CMS edits. The claim needs a review cadence attached.
      
      ## Choosing a target
      
      For almost every project: **WCAG 2.2 Level AA**.
      
      It satisfies the EAA today via 2.1 AA, satisfies it after EN 301 549 v4.1.1
      lands, satisfies ADA Title II, and satisfies the UK Public Sector Bodies
      Accessibility Regulations. Targeting 2.1 to save the nine extra criteria buys a
      migration later at a worse moment.
      
      ## Cross-reference
      
      - Running an actual audit → [audit-workflow.md](audit-workflow.md)
      - The failures you will actually find → [common-failures.md](common-failures.md)
      - Contrast ratios and colour maths → `color-ops`
      - Icon and logo accessibility → `icon-ops`
      
  • scripts
    • scan-a11y.py 15.6 KB
      #!/usr/bin/env python3
      """Static pre-flight for high-confidence WCAG failures in HTML/JSX/Vue source.
      
      Catches the recurring, mechanically-detectable failures that appear on most
      sites - missing alt text, unlabelled inputs, click handlers on non-interactive
      elements, positive tabindex, heading-level skips, aria-hidden over focusable
      content.
      
      THIS IS NOT AN AUDIT. Automated tooling detects a minority of WCAG failures even
      against a rendered DOM, and this runs against SOURCE, so it sees less again. It
      exists to clear the cheap findings before a human spends time on the keyboard
      and screen-reader passes that find the rest. A clean run means "nothing obvious
      in the markup", never "accessible".
      
      Usage:   scan-a11y.py [OPTIONS] <PATH>...
      Input:   files or directories; .html/.htm/.jsx/.tsx/.vue/.svelte/.astro are scanned
      Output:  stdout - one finding per line (TSV), or a JSON envelope under --json
      Stderr:  progress, warnings, errors
      Exit:    0 no findings, 2 usage, 3 path not found, 5 nothing scannable,
               10 findings (the DOMAIN SIGNAL a CI gate branches on)
      
      Examples:
        scan-a11y.py src/
        scan-a11y.py --min-severity serious src/ --json | jq '.data[]'
        scan-a11y.py index.html || echo "fix the findings above"
      """
      
      import argparse
      import json
      import os
      import re
      import sys
      
      EXIT_OK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2
      EXIT_NOT_FOUND, EXIT_PRECONDITION = 3, 5
      EXIT_FINDINGS = 10
      
      SCAN_EXT = {".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte", ".astro"}
      SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".nuxt", "vendor",
                   "__pycache__", "coverage", ".svelte-kit", ".astro", "out"}
      SEVERITY_ORDER = {"minor": 0, "moderate": 1, "serious": 2, "critical": 3}
      
      # Elements that are focusable/interactive without any extra attributes.
      INTERACTIVE = r"a|button|input|select|textarea|summary|details|label|option"
      # Void + text-bearing elements we treat as "has an accessible name" sources.
      NAME_ATTRS = ("aria-label", "aria-labelledby", "title")
      
      
      def log(msg):
          print(msg, file=sys.stderr)
      
      
      def strip_comments(text):
          """Remove HTML and JSX block comments so commented-out markup is not flagged."""
          text = re.sub(r"<!--.*?-->", lambda m: " " * len(m.group(0)), text, flags=re.S)
          text = re.sub(r"\{\s*/\*.*?\*/\s*\}", lambda m: " " * len(m.group(0)), text, flags=re.S)
          return text
      
      
      def line_of(text, index):
          return text.count("\n", 0, index) + 1
      
      
      def attrs_of(tag_text):
          """Parse an opening tag's attributes. Handles quoted, unquoted and JSX braces."""
          out = {}
          for m in re.finditer(
                  r"""([:@a-zA-Z_][-\w:.]*)\s*=\s*("([^"]*)"|'([^']*)'|\{([^}]*)\}|([^\s>]+))""",
                  tag_text):
              name = m.group(1).lower()
              value = m.group(3) or m.group(4) or m.group(5) or m.group(6) or ""
              out[name] = value.strip()
          # Valueless (boolean) attributes.
          for m in re.finditer(r"(?<![-\w:.=])([a-zA-Z_][-\w:.]*)(?=[\s/>])", tag_text):
              out.setdefault(m.group(1).lower(), "")
          return out
      
      
      def has_name(a, inner_text=""):
          """Does this element carry an accessible name from any usual source?"""
          if inner_text and re.sub(r"<[^>]+>", "", inner_text).strip():
              return True
          for k in NAME_ATTRS:
              if a.get(k, "").strip():
                  return True
          # JSX/Vue dynamic bindings - we cannot evaluate them, so treat as named
          # rather than emit a false positive.
          for k in list(a):
              if k.lstrip(":@").replace("v-bind:", "") in NAME_ATTRS and a[k].strip():
                  return True
          if a.get("aria-hidden", "") == "true":
              return True
          return False
      
      
      def iter_tags(text, names):
          """Yield (name, attrs, start_index, raw) for each opening tag in `names`."""
          pattern = re.compile(r"<(%s)(\s[^<>]*?)?/?>" % names, re.I | re.S)
          for m in pattern.finditer(text):
              yield m.group(1).lower(), attrs_of(m.group(2) or ""), m.start(), m.group(0)
      
      
      def inner_after(text, index, tag):
          """Best-effort inner content of the element opening at `index`."""
          close = re.search(r"</%s\s*>" % tag, text[index:], re.I)
          if not close:
              return ""
          open_end = text.find(">", index)
          return text[open_end + 1: index + close.start()] if open_end != -1 else ""
      
      
      # === CHECKS =================================================================
      # Each returns a list of (rule, severity, wcag, line, message).
      # Rules are deliberately conservative: a linter that cries wolf gets muted, and
      # a muted linter is worse than no linter. Anything requiring a rendered DOM or
      # a judgement call belongs in the manual passes, not here.
      
      def check_images(text, findings):
          for name, a, idx, raw in iter_tags(text, "img"):
              if "alt" not in a and not any(k.lstrip(":@").endswith("alt") for k in a):
                  findings.append(("img-missing-alt", "critical", "1.1.1", line_of(text, idx),
                                   "<img> has no alt attribute; decorative images need alt=\"\""))
      
      
      def check_lang_and_title(text, findings, is_html):
          if not is_html:
              return
          for name, a, idx, raw in iter_tags(text, "html"):
              if not a.get("lang", "").strip():
                  findings.append(("html-missing-lang", "serious", "3.1.1", line_of(text, idx),
                                   "<html> has no lang attribute; screen readers pick the wrong voice"))
          if re.search(r"<html", text, re.I) and not re.search(r"<title\s*>\s*\S", text, re.I):
              findings.append(("missing-title", "serious", "2.4.2", 1,
                               "document has no non-empty <title>"))
      
      
      def check_inputs(text, findings):
          labelled_ids = {m.group(1) for m in re.finditer(r"<label[^>]*\bfor=[\"']([^\"']+)", text, re.I)}
          for name, a, idx, raw in iter_tags(text, "input|select|textarea"):
              if a.get("type", "").lower() in ("hidden", "submit", "button", "reset", "image"):
                  continue
              el_id = a.get("id", "")
              if el_id and el_id in labelled_ids:
                  continue
              if has_name(a):
                  continue
              if a.get("placeholder", "").strip():
                  findings.append(("placeholder-as-label", "serious", "3.3.2", line_of(text, idx),
                                   "placeholder is not a label; it disappears on input and many SRs ignore it"))
              else:
                  findings.append(("input-missing-label", "critical", "3.3.2", line_of(text, idx),
                                   "<%s> has no associated label or accessible name" % name))
      
      
      def check_empty_interactive(text, findings):
          for name, a, idx, raw in iter_tags(text, "a|button"):
              if raw.rstrip().endswith("/>"):
                  continue
              inner = inner_after(text, idx, name)
              # An icon-only control is named by aria-label on the control itself.
              if has_name(a, inner):
                  continue
              if re.search(r"<(svg|img|i|span)\b", inner, re.I):
                  findings.append(("icon-only-control-unnamed", "critical", "4.1.2", line_of(text, idx),
                                   "icon-only <%s> has no accessible name; put aria-label on the control" % name))
              else:
                  findings.append(("empty-interactive", "critical", "4.1.2", line_of(text, idx),
                                   "<%s> has no text content and no accessible name" % name))
          for name, a, idx, raw in iter_tags(text, "a"):
              if "href" not in a and not any(k.lstrip(":@") == "href" for k in a) and "role" not in a:
                  findings.append(("anchor-without-href", "serious", "2.1.1", line_of(text, idx),
                                   "<a> without href is not focusable or activatable; use <button>"))
      
      
      def check_tabindex(text, findings):
          for m in re.finditer(r"tabindex\s*=\s*[\"'{]?\s*(\d+)", text, re.I):
              if int(m.group(1)) > 0:
                  findings.append(("positive-tabindex", "serious", "2.4.3", line_of(text, m.start()),
                                   "positive tabindex=%s overrides DOM order and breaks tab sequence" % m.group(1)))
      
      
      def check_click_handlers(text, findings):
          """A click handler on a non-interactive element is keyboard-inaccessible."""
          for m in re.finditer(r"<(\w[-\w]*)((?:\s[^<>]*?)?)/?>", text):
              tag = m.group(1).lower()
              if re.fullmatch(INTERACTIVE, tag):
                  continue
              a = attrs_of(m.group(2) or "")
              has_click = any(k in ("onclick", "@click", "v-on:click", "on:click") for k in a)
              if not has_click:
                  continue
              has_key = any("keydown" in k or "keypress" in k or "keyup" in k for k in a)
              if not (a.get("role") and "tabindex" in a and has_key):
                  findings.append(("click-on-non-interactive", "serious", "2.1.1", line_of(text, m.start()),
                                   "<%s> has a click handler but is not keyboard-operable; "
                                   "use <button>, or add role + tabindex + a key handler" % tag))
      
      
      def check_headings(text, findings):
          levels = [(int(m.group(1)), m.start()) for m in re.finditer(r"<h([1-6])\b", text, re.I)]
          prev = None
          for lvl, idx in levels:
              if prev is not None and lvl > prev + 1:
                  findings.append(("heading-skip", "moderate", "1.3.1", line_of(text, idx),
                                   "heading jumps h%d -> h%d; levels convey structure and must not skip" % (prev, lvl)))
              prev = lvl
      
      
      def check_aria_hidden_focusable(text, findings):
          for m in re.finditer(r"<(\w[-\w]*)((?:\s[^<>]*?)?)/?>", text):
              a = attrs_of(m.group(2) or "")
              if a.get("aria-hidden", "") != "true":
                  continue
              tag = m.group(1).lower()
              focusable = re.fullmatch(INTERACTIVE, tag) or ("tabindex" in a and not a.get("tabindex", "").startswith("-"))
              if focusable:
                  findings.append(("aria-hidden-focusable", "critical", "4.1.2", line_of(text, m.start()),
                                   "aria-hidden=\"true\" on a focusable <%s> creates a focusable "
                                   "element with no accessible name" % tag))
      
      
      def check_iframe_title(text, findings):
          for name, a, idx, raw in iter_tags(text, "iframe"):
              if not has_name(a):
                  findings.append(("iframe-missing-title", "serious", "4.1.2", line_of(text, idx),
                                   "<iframe> has no title; it is announced as an unlabelled frame"))
      
      
      def check_autoplay(text, findings):
          for name, a, idx, raw in iter_tags(text, "video|audio"):
              if "autoplay" in a and "muted" not in a:
                  findings.append(("autoplay-unmuted", "serious", "1.4.2", line_of(text, idx),
                                   "<%s autoplay> without muted; audio over 3s needs a stop control" % name))
      
      
      def check_duplicate_ids(text, findings):
          seen = {}
          for m in re.finditer(r"\bid\s*=\s*[\"']([^\"']+)[\"']", text):
              seen.setdefault(m.group(1), []).append(m.start())
          for val, spots in seen.items():
              if len(spots) > 1:
                  findings.append(("duplicate-id", "moderate", "4.1.2", line_of(text, spots[1]),
                                   "id=\"%s\" appears %d times; label/aria references resolve to the first only"
                                   % (val, len(spots))))
      
      
      CHECKS = (check_images, check_inputs, check_empty_interactive, check_tabindex,
                check_click_handlers, check_headings, check_aria_hidden_focusable,
                check_iframe_title, check_autoplay, check_duplicate_ids)
      
      
      def scan_file(path):
          try:
              with open(path, "r", encoding="utf-8", errors="replace") as fh:
                  raw = fh.read()
          except OSError as exc:
              log("[WARN] cannot read %s: %s" % (path, exc))
              return []
          text = strip_comments(raw)
          findings = []
          for fn in CHECKS:
              fn(text, findings)
          check_lang_and_title(text, findings, path.lower().endswith((".html", ".htm")))
          return [{"file": path, "rule": r, "severity": s, "wcag": w, "line": ln, "message": msg}
                  for (r, s, w, ln, msg) in findings]
      
      
      def collect(paths):
          out = []
          for p in paths:
              if os.path.isfile(p):
                  out.append(p)
              elif os.path.isdir(p):
                  for root, dirs, files in os.walk(p):
                      dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
                      for f in sorted(files):
                          if os.path.splitext(f)[1].lower() in SCAN_EXT:
                              out.append(os.path.join(root, f))
              else:
                  log("[FAIL] no such path: %s" % p)
                  return None
          return out
      
      
      def main():
          ap = argparse.ArgumentParser(
              prog="scan-a11y.py", add_help=True,
              description="Static pre-flight for high-confidence WCAG failures in markup.",
              epilog=("This is a PRE-FILTER, not an audit. Automated tools catch a minority of\n"
                      "WCAG failures against a rendered DOM, and this reads source, so it sees\n"
                      "less again. A clean run means 'nothing obvious in the markup'.\n\n"
                      "EXAMPLES:\n"
                      "  scan-a11y.py src/\n"
                      "  scan-a11y.py --min-severity serious src/\n"
                      "  scan-a11y.py --json src/ | jq '.data[] | select(.severity==\"critical\")'\n"
                      "  scan-a11y.py index.html || echo 'findings above'\n"),
              formatter_class=argparse.RawDescriptionHelpFormatter)
          ap.add_argument("paths", nargs="+", help="files or directories to scan")
          ap.add_argument("--min-severity", choices=sorted(SEVERITY_ORDER, key=lambda k: SEVERITY_ORDER[k]),
                          default="minor", help="suppress findings below this severity")
          ap.add_argument("--rule", action="append", metavar="ID",
                          help="only report this rule (repeatable)")
          ap.add_argument("--json", action="store_true", help="emit the JSON envelope")
          ap.add_argument("--quiet", action="store_true", help="suppress stderr progress")
          args = ap.parse_args()
      
          files = collect(args.paths)
          if files is None:
              if args.json:
                  print(json.dumps({"error": {"code": "NOT_FOUND", "message": "path does not exist",
                                              "details": {"paths": args.paths}}}))
              return EXIT_NOT_FOUND
          if not files:
              log("[FAIL] no scannable files found (looked for: %s)" % ", ".join(sorted(SCAN_EXT)))
              if args.json:
                  print(json.dumps({"error": {"code": "PRECONDITION", "message": "no scannable files",
                                              "details": {"extensions": sorted(SCAN_EXT)}}}))
              return EXIT_PRECONDITION
      
          floor = SEVERITY_ORDER[args.min_severity]
          findings = []
          for f in files:
              for item in scan_file(f):
                  if SEVERITY_ORDER[item["severity"]] < floor:
                      continue
                  if args.rule and item["rule"] not in args.rule:
                      continue
                  findings.append(item)
          findings.sort(key=lambda d: (-SEVERITY_ORDER[d["severity"]], d["file"], d["line"]))
      
          if args.json:
              counts = {}
              for f in findings:
                  counts[f["severity"]] = counts.get(f["severity"], 0) + 1
              print(json.dumps({"data": findings,
                                "meta": {"count": len(findings), "files_scanned": len(files),
                                         "by_severity": counts,
                                         "schema": "claude-mods.a11y-ops.scan-a11y/v1"}}))
          else:
              for f in findings:
                  print("%s\t%d\t%s\t%s\t%s\t%s"
                        % (f["file"], f["line"], f["severity"], f["wcag"], f["rule"], f["message"]))
      
          if not args.quiet:
              if findings:
                  log("[WARN] %d finding(s) across %d file(s). Automated checks are a floor, "
                      "not a ceiling - the keyboard and screen-reader passes find the rest."
                      % (len(findings), len(files)))
              else:
                  log("[PASS] no static findings in %d file(s). This is not a conformance claim."
                      % len(files))
      
          return EXIT_FINDINGS if findings else EXIT_OK
      
      
      if __name__ == "__main__":
          try:
              sys.exit(main())
          except KeyboardInterrupt:
              sys.exit(EXIT_ERROR)
      
  • tests
    • run.sh 7.9 KB
      #!/usr/bin/env bash
      # Self-test for a11y-ops — scan-a11y.py behaviour against known-bad and
      # known-good fixtures, plus resource-citation checks.
      #
      # Fully offline and self-contained: fixtures are written to a temp dir, nothing
      # is fetched. Needs python3 (or python); skips cleanly with exit 0 where absent.
      #
      # Usage:   bash tests/run.sh
      # Exit:    0 all pass (or skipped), 1 a failure
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      SCAN="$SKILL/scripts/scan-a11y.py"
      STATEMENT="$SKILL/assets/accessibility-statement.template.md"
      
      PASS=0; FAIL=0
      ok(){ PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no(){ FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      
      echo "=== a11y-ops self-test ==="
      
      echo "-- resources --"
      [ -f "$SCAN" ]      && ok "scan-a11y.py present"                  || no "scan-a11y.py missing"
      [ -f "$STATEMENT" ] && ok "accessibility-statement template present" || no "statement template missing"
      for r in references/wcag-conformance.md references/audit-workflow.md references/common-failures.md; do
        [ -f "$SKILL/$r" ] && ok "$r present" || no "$r missing"
        # An uncited resource is dead weight the router never finds (resource protocol §1).
        grep -q "$(basename "$r")" "$SKILL/SKILL.md" && ok "$r cited from SKILL.md" || no "$r not cited from SKILL.md"
      done
      grep -q 'scan-a11y.py' "$SKILL/SKILL.md" && ok "script cited from SKILL.md" || no "script not cited"
      grep -q 'accessibility-statement.template.md' "$SKILL/SKILL.md" && ok "asset cited from SKILL.md" || no "asset not cited"
      
      # Probe by EXECUTING python, not `command -v`: on Windows the python3 name
      # resolves to a Microsoft Store stub that is on PATH and exits 49.
      PY=""
      for c in python3 python py; do
        if "$c" -c 'import sys' >/dev/null 2>&1; then PY="$c"; break; fi
      done
      if [ -z "$PY" ]; then
        echo "  (python not found — skipping dynamic checks)"
        echo "=== $PASS passed, $FAIL failed ==="
        [ "$FAIL" -eq 0 ] || exit 1
        exit 0
      fi
      
      echo "-- scan-a11y --"
      "$PY" -m py_compile "$SCAN" 2>/dev/null && ok "py_compile clean" || no "py_compile failed"
      "$PY" "$SCAN" --help >/dev/null 2>&1 && ok "--help exits 0" || no "--help nonzero"
      "$PY" "$SCAN" --help 2>/dev/null | grep -q 'EXAMPLES' && ok "--help lists EXAMPLES" || no "--help has no EXAMPLES"
      # The honesty caveat is load-bearing: a caller who reads a clean run as a
      # conformance claim has been misled by the tool.
      "$PY" "$SCAN" --help 2>/dev/null | grep -qi 'not an audit\|PRE-FILTER' \
        && ok "--help states it is not an audit" || no "--help omits the not-an-audit caveat"
      
      TMP="$(mktemp -d)"
      trap 'rm -rf "$TMP"' EXIT
      
      cat > "$TMP/bad.html" <<'FIXTURE'
      <!doctype html>
      <html>
      <head></head>
      <body>
        <h1>Title</h1>
        <h3>Skipped a level</h3>
        <img src="cat.jpg">
        <input type="text" placeholder="Your name">
        <input type="email" id="em">
        <a href="/x"><svg viewBox="0 0 1 1"></svg></a>
        <button></button>
        <a>no href</a>
        <div onclick="go()">Click me</div>
        <span tabindex="3">skip order</span>
        <button aria-hidden="true">hidden but focusable</button>
        <iframe src="/embed"></iframe>
        <video autoplay src="v.mp4"></video>
        <p id="dupe">a</p><p id="dupe">b</p>
        <!-- <img src="commented.jpg"> -->
      </body>
      </html>
      FIXTURE
      
      # Every construct here is CORRECT. Any finding is a false positive, and a linter
      # that cries wolf gets muted - which is worse than not having one.
      cat > "$TMP/good.html" <<'FIXTURE'
      <!doctype html>
      <html lang="en">
      <head><title>Fine</title></head>
      <body>
        <h1>Title</h1>
        <h2>Sub</h2>
        <img src="dog.jpg" alt="A dog">
        <img src="spacer.gif" alt="">
        <label for="nm">Name</label><input id="nm" type="text">
        <input type="search" aria-label="Search products">
        <input type="hidden" name="csrf" value="x">
        <a href="/x" aria-label="Search"><svg aria-hidden="true"></svg></a>
        <button type="button">Go</button>
        <button type="button" onclick="go()">Handler on a real button</button>
        <div role="button" tabindex="0" onclick="go()" onkeydown="k(e)">Proper custom control</div>
        <span tabindex="-1">programmatic focus only</span>
        <iframe src="/embed" title="Embedded map"></iframe>
        <video autoplay muted src="v.mp4"></video>
      </body>
      </html>
      FIXTURE
      
      out="$("$PY" "$SCAN" "$TMP/bad.html" 2>/dev/null)"; rc=$?
      [ "$rc" = "10" ] && ok "findings -> exit 10 (domain signal)" || no "bad fixture -> exit $rc, expected 10"
      for rule in img-missing-alt input-missing-label placeholder-as-label icon-only-control-unnamed \
                  empty-interactive anchor-without-href click-on-non-interactive positive-tabindex \
                  aria-hidden-focusable iframe-missing-title autoplay-unmuted heading-skip \
                  duplicate-id html-missing-lang missing-title; do
        printf '%s' "$out" | grep -q "$rule" && ok "detects $rule" || no "missed $rule"
      done
      # Commented-out markup must not be scanned, or every dead example becomes a finding.
      printf '%s' "$out" | grep -q 'commented.jpg' && no "flagged commented-out markup" || ok "ignores commented-out markup"
      
      echo "-- false positives --"
      gout="$("$PY" "$SCAN" "$TMP/good.html" 2>/dev/null)"; rc=$?
      if [ "$rc" = "0" ] && [ -z "$gout" ]; then
        ok "clean fixture produces zero findings (exit 0)"
      else
        no "false positives on the clean fixture (exit $rc): $gout"
      fi
      
      echo "-- filtering and envelope --"
      sev="$("$PY" "$SCAN" --min-severity critical "$TMP/bad.html" 2>/dev/null)"
      printf '%s' "$sev" | grep -q 'heading-skip' && no "--min-severity critical leaked a moderate finding" \
                                                  || ok "--min-severity filters by level"
      printf '%s' "$sev" | grep -q 'img-missing-alt' && ok "--min-severity keeps critical findings" \
                                                     || no "--min-severity dropped a critical finding"
      one="$("$PY" "$SCAN" --rule positive-tabindex "$TMP/bad.html" 2>/dev/null)"
      [ "$(printf '%s' "$one" | grep -c . )" = "1" ] && ok "--rule narrows to a single rule" || no "--rule did not narrow output"
      
      jout="$("$PY" "$SCAN" --json "$TMP/bad.html" 2>/dev/null)"
      printf '%s' "$jout" | grep -q 'claude-mods.a11y-ops.scan-a11y/v1' && ok "JSON envelope declares the schema" \
                                                                        || no "JSON schema missing"
      printf '%s' "$jout" | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["count"]>0 and "by_severity" in d["meta"]' 2>/dev/null \
        && ok "JSON envelope carries counts and severity breakdown" || no "JSON envelope malformed"
      # stdout must stay data-only so `| jq` is safe (resource protocol §4).
      printf '%s' "$jout" | head -1 | grep -q '^{' && ok "stdout is data-only under --json" || no "stdout polluted under --json"
      
      echo "-- guard rails --"
      "$PY" "$SCAN" "$TMP/__absent__" >/dev/null 2>&1; rc=$?
      [ "$rc" = "3" ] && ok "missing path -> exit 3" || no "missing path -> exit $rc, expected 3"
      mkdir -p "$TMP/empty"
      "$PY" "$SCAN" "$TMP/empty" >/dev/null 2>&1; rc=$?
      [ "$rc" = "5" ] && ok "no scannable files -> exit 5" || no "empty dir -> exit $rc, expected 5"
      "$PY" "$SCAN" >/dev/null 2>&1; rc=$?
      [ "$rc" = "2" ] && ok "no args -> exit 2 (usage)" || no "no args -> exit $rc, expected 2"
      # node_modules and friends must be skipped or a scan of a real repo never ends.
      mkdir -p "$TMP/proj/node_modules" && cp "$TMP/bad.html" "$TMP/proj/node_modules/x.html"
      cp "$TMP/good.html" "$TMP/proj/ok.html"
      "$PY" "$SCAN" "$TMP/proj" >/dev/null 2>&1; rc=$?
      [ "$rc" = "0" ] && ok "skips node_modules when walking a tree" || no "did not skip node_modules (exit $rc)"
      
      echo "-- statement template --"
      # Overclaiming is the failure mode this template exists to prevent.
      grep -qi 'partially conformant' "$STATEMENT" && ok "template offers a partial-conformance status" \
                                                   || no "template has no partial-conformance option"
      grep -qi 'do not overclaim\|overclaims is worse' "$STATEMENT" && ok "template warns against overclaiming" \
                                                                    || no "template lacks the overclaim warning"
      
      echo "=== $PASS passed, $FAIL failed ==="
      [ "$FAIL" -eq 0 ] || exit 1
      
  • SKILL.md 10 KB
    ---
    name: a11y-ops
    description: "Web accessibility end to end - WCAG 2.2 conformance, legal obligations (EAA, ADA Title II), auditing with automated + keyboard + screen-reader passes, and the failures that appear on most sites. Triggers on: accessibility, a11y, WCAG, WCAG 2.2, AA conformance, EAA, European Accessibility Act, EN 301 549, ADA Title II, Section 508, accessibility audit, accessibility statement, VPAT, screen reader, NVDA, VoiceOver, JAWS, TalkBack, axe, axe-core, pa11y, Lighthouse accessibility, keyboard navigation, focus trap, focus visible, focus indicator, skip link, tab order, tabindex, aria, aria-label, aria-hidden, landmarks, alt text, form labels, error messages, colour contrast, target size, prefers-reduced-motion, inaccessible, disability, assistive technology, is my site accessible, accessibility compliance."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: color-ops, icon-ops, playwright-ops, testing-ops
    ---
    
    # a11y-ops
    
    Accessibility stopped being a quality preference and became a legal requirement
    with dates attached. It is also, unhelpfully, a domain where the tooling
    everyone reaches for finds well under half the problems — so the work is mostly
    about knowing where automation stops.
    
    ## Helps with
    
    "Is our site compliant?" — a question with a real answer that depends on which
    jurisdiction, which standard version, and which deadline applies to that client.
    
    An audit that needs to find real failures rather than produce a score. Vendor
    "94% accessible" numbers correspond to nothing in the standard.
    
    A component library where every custom control is mouse-only, because a `<div>`
    replaced a `<button>` and nothing replaced what the `<button>` was doing.
    
    Forms that lose conversions from screen reader and keyboard users: placeholders
    used as labels, errors signalled only in red, no `autocomplete`.
    
    `outline: none` shipped across a design system, leaving keyboard users with no
    idea where they are.
    
    A modal, dropdown or date picker that traps focus, or drops it, or never
    returns it to the trigger.
    
    Writing an accessibility statement that is honest enough to be defensible
    rather than an overclaim that creates its own liability.
    
    Wiring accessibility into CI so a fix stays fixed instead of being re-bought
    at the next audit.
    
    ## The two things that decide everything
    
    **1. Automated tooling finds roughly 30–40% of WCAG failures.** Every plan
    follows from this. A green axe run is a floor, not a result — the rest needs a
    keyboard and a human. Anyone selling automated compliance is selling the 30%.
    
    **2. Use the native element.** `<button>`, `<a href>`, `<input>`, `<select>`,
    `<details>` arrive with focusability, keyboard activation, correct role and
    state announcement already handled. Almost every failure in the catalogue comes
    from replacing one with a `<div>` and rebuilding a fraction of what was lost.
    Its corollary is the first rule of ARIA: **no ARIA beats bad ARIA**, because
    `role="button"` claims a contract a div does not fulfil.
    
    ## Which standard, and by when
    
    Target **WCAG 2.2 Level AA** for essentially every project. It satisfies every
    regime below, and building to 2.1 to skip nine criteria just buys a migration
    at a worse moment.
    
    | Regime | Standard | Status (verified 2026-08-30) |
    |---|---|---|
    | **EAA** (EU) | EN 301 549 v3.2.1 → WCAG 2.1 AA | Applicable since 28 Jun 2025; **2026 is the first full supervision year**. v4.1.1 expected 2026 moves it to **WCAG 2.2**. Fines ~€5k–€500k |
    | **ADA Title II** (US) | WCAG 2.1 AA | **Deadlines extended 20 Apr 2026**: 26 Apr **2027** (pop ≥50k), 26 Apr **2028** (smaller) |
    | **UK PSBAR** | WCAG 2.2 AA | Public sector; accessibility statement required |
    
    **The EAA is extraterritorial** — it applies to anyone offering services to
    consumers in the EU regardless of where the business sits. And conformance is
    **per-page and all-or-nothing**: one failed AA criterion means the page does not
    conform. There is no partial credit and no percentage.
    
    Full detail, the nine criteria new in WCAG 2.2, and what a conformance claim
    commits you to → [`references/wcag-conformance.md`](references/wcag-conformance.md).
    
    ## Workflow — four passes, cheapest first
    
    ### 1. Static source scan (seconds, in CI)
    
    ```bash
    scripts/scan-a11y.py src/                          # exit 10 = findings
    scripts/scan-a11y.py --min-severity serious src/
    scripts/scan-a11y.py --json src/ | jq '.data[] | select(.severity=="critical")'
    ```
    
    Catches the mechanical failures in HTML/JSX/Vue/Svelte/Astro source before
    anything renders: missing alt, unlabelled inputs, placeholder-as-label,
    icon-only controls with no name, click handlers on non-interactive elements,
    positive `tabindex`, heading skips, `aria-hidden` on focusable elements,
    untitled iframes, unmuted autoplay, duplicate ids.
    
    Exit codes: `0` clean · `2` usage · `3` path missing · `5` nothing scannable ·
    `10` findings. It reads *source*, so it cannot see computed contrast or
    conditionally-rendered markup — it is a pre-filter, not the audit.
    
    ### 2. Automated DOM scan (minutes, per route)
    
    Run **axe-core** against the rendered page — ideally inside the Playwright suite
    you already have, so authentication and navigation aren't rebuilt in a separate
    crawler (`playwright-ops`). Alternatives: `pa11y-ci` for URL lists, Lighthouse
    for quick triage.
    
    **Scan states, not just pages.** Open the menu, trigger the error, expand the
    accordion. A modal's focus trap is invisible to a scan of the page behind it.
    
    ### 3. Keyboard pass (10 minutes, highest yield)
    
    Put the mouse down. Tab the whole page: is focus order logical, is the indicator
    always *visible* and not eclipsed by a sticky header (2.4.11), is everything
    mouse-reachable also keyboard-reachable, can you escape every modal and does
    focus return to the trigger, does the skip link move focus rather than just
    scroll?
    
    Nearly every custom component fails one of these, and none of them appear in an
    automated scan.
    
    ### 4. Screen reader pass (30+ minutes)
    
    Test **one** combination properly: NVDA + Firefox on Windows, or VoiceOver +
    Safari on macOS. Listen for name, role and state on every control; a sensible
    heading outline; errors that are announced; alt text that says what the image
    *means here*.
    
    Tool comparison, what to test in what order, and how to write a finding a
    developer can act on → [`references/audit-workflow.md`](references/audit-workflow.md).
    
    ## The failures you will actually find
    
    Ranked by frequency, with fixes that remove the class rather than the instance —
    form fields without programmatic labels, errors carried only in colour,
    icon-only controls with no name, keyboard-dead custom controls, `outline: none`,
    focus traps, headings chosen for size, wrong-rather-than-missing alt text,
    meaningless link text, ARIA that lies, targets under 24×24 (2.5.8), unstoppable
    motion, missing skip links and landmarks →
    [`references/common-failures.md`](references/common-failures.md).
    
    Three worth knowing before you read it:
    
    - **`aria-hidden="true"` on anything focusable** creates a focusable element
      with no accessible name — a guaranteed 4.1.2 failure, still in the tab order.
    - **`tabindex` above 0** overrides DOM order globally. `0` and `-1` are the only
      values worth using.
    - **A live region must exist in the DOM before the content arrives**, or nothing
      is announced.
    
    ## Accessibility statements
    
    Publishing one is part of the EAA obligation, not a nicety. Start from
    [`assets/accessibility-statement.template.md`](assets/accessibility-statement.template.md).
    
    **Do not overclaim.** A statement is a written representation about the product,
    so an undisclosed known failure is a worse problem than the failure. "Partially
    conformant" with a listed issue and a remediation date is a normal, defensible
    position; "fully conformant" without an audit is not.
    
    ## What this skill doesn't cover
    
    - **Contrast ratios and palette maths** (WCAG 1.4.3, APCA) → `color-ops`
    - **Icon and logo specifics** — the two-case naming rule, target size for
      icon-only controls → `icon-ops`
    - **Wiring axe into a browser test suite** → `playwright-ops`
    - **Native mobile accessibility** (UIKit/Android APIs) — different platform APIs
    - **Legal advice.** This encodes standards, dates and obligations as published;
      a compliance decision with money attached needs a lawyer, not a skill.
    
    ## Cross-references
    
    | When | Use |
    |---|---|
    | Checking a palette meets 1.4.3 / 1.4.11 | `color-ops` |
    | Naming icon-only controls, logo `alt` | `icon-ops` |
    | Automating the DOM scan in e2e | `playwright-ops`, `testing-ops` |
    | The component library needs rebuilding around native elements | `refactor-ops` |
    
    ## References
    
    - [`references/wcag-conformance.md`](references/wcag-conformance.md) — the
      standards map: WCAG 2.1 vs 2.2 with all nine new criteria and why 4.1.1 was
      removed; EAA dates, penalties and extraterritorial reach; the extended ADA
      Title II deadlines; and what a conformance claim actually commits you to
      (per-page, all-or-nothing, overlays don't count). Load before quoting a
      deadline or a target to a client.
    
    - [`references/audit-workflow.md`](references/audit-workflow.md) — the four
      passes with what each can and cannot detect, the axe/pa11y/Lighthouse
      comparison, screen-reader/browser pairings, where to spend a single hour, how
      to report a finding usefully, and regression gating. Load when running an audit.
    
    - [`references/common-failures.md`](references/common-failures.md) — the twelve
      recurring failures with before/after code and the class-level fix. Load when
      remediating, or when reviewing a component library.
    
    ## Scripts
    
    - `scripts/scan-a11y.py` — static pre-flight for high-confidence WCAG failures
      in HTML/JSX/Vue/Svelte/Astro source. `--min-severity`, `--rule` to filter,
      `--json` envelope, exit 10 as the CI domain signal. Deliberately conservative:
      a linter that cries wolf gets muted, and a muted linter is worse than none.
    
    ## Assets
    
    - `assets/accessibility-statement.template.md` — heavily commented statement
      template covering conformance status, known issues, assessment method,
      compatibility, feedback route and the enforcement procedure per jurisdiction.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related