Accessibility Skill — Success Story


What I Built

The accessibility skill is a Claude Code + Cowork plugin that gives Claude expert-level WCAG 2.2 AA knowledge for React 19 / shadcn/ui / Tailwind CSS v4 codebases. Instead of pasting accessibility docs into every chat, you get a fully loaded specialist that Claude invokes automatically — or you call it directly with /accessibility.

What it covers:

  • WCAG 2.2 AA compliance (all 78 success criteria) + AAA aspirational items
  • ARIA roles, states, and properties with correct usage patterns
  • Keyboard navigation — roving tabindex, arrow key models, focus trapping
  • Screen reader support (NVDA, JAWS, VoiceOver, TalkBack, Narrator)
  • Color and non-text contrast
  • Accessible forms with autocomplete attributes (SC 1.3.5)
  • Focus management including return-focus after modal close (SC 2.4.11)
  • Touch targets and pointer interactions (SC 2.5.8)
  • axe-core and WAVE failure interpretation
  • VPAT / accessibility conformance report authoring
  • Skip links, reduced motion, and semantic HTML

File Structure

Everything shipped as a self-contained skill package:

skills/accessibility/
+-- SKILL.md                          # 321-line skill instructions
+-- skills-rules.json                 # Cowork plugin metadata + trigger description
+-- LICENSE.txt                       # MIT
+-- references/                       # 12 on-demand reference files
|   +-- aria-attributes.md            # Full ARIA attribute catalog with allowed roles
|   +-- aria-patterns.md              # Widget patterns: menu, dialog, combobox, tabs
|   +-- enterprise-audit.md           # 100-point WCAG AA audit checklist
|   +-- focus-management.md           # Return focus, FocusScope, focus-visible
|   +-- forms-a11y.md                 # Labels, autocomplete, error patterns
|   +-- keyboard-navigation.md        # Tab order, skip links, roving tabindex
|   +-- keyboard-patterns.md          # Per-widget key bindings (ARIA APG)
|   +-- mobile-touch.md               # SC 2.5.8 touch targets, pointer cancellation
|   +-- reduced-motion.md             # prefers-reduced-motion, carousel controls
|   +-- screen-readers.md             # NVDA/JAWS/VoiceOver behavior differences
|   +-- vpat-template.md              # Voluntary Product Accessibility Template
|   +-- wcag-criteria.md              # All 78 AA success criteria with techniques
+-- evals/
|   +-- trigger-evals.json            # 20-query triggering eval set
|   +-- evals.json                    # 8 functional task evals

The Journey: From 0% to 100% Trigger Recall

The harder problem was not writing the skill — it was making Claude reliably pick it up without being explicitly asked. I measured and optimized description triggering accuracy using a custom evaluation framework.

Baseline: 0% recall

Before optimization, the old description was a capability list:

"Implements WCAG 2.2 AA… Use when building interactive components…"

Claude ignored it for nearly every real-world query. A developer typing "hey, my dropdown can't navigate with arrow keys" got no skill. Score: 0/20 queries triggered correctly.


The eval set

I wrote 20 realistic developer queries — 10 that should trigger the skill and 10 that should not. Split: 12 train / 8 holdout test.

True-positive queries (should trigger):

"hey so i'm building this dropdown menu with shadcn and when you tab to it and hit enter it opens but then you can't navigate the items with arrow keys. do i need to add keyboard support myself or does radix handle it?"

"running axe-core on our login page and it's flagging SC 1.3.5 -- says our email and password fields are missing autocomplete attributes. what values should i use?"

"my image carousel auto-plays every 4 seconds. a user filed a bug saying they get motion sick. also it's not keyboard accessible at all. two problems, need to fix both"

"client is asking for a VPAT for our SaaS app. never done one before. what sections does it need and how do I fill out the wcag conformance tables?"

True-negative queries (must NOT trigger):

"what's the difference between useCallback and useMemo in react 19?"

"how do i add dark mode support to my shadcn/ui app using class strategy?"

"setting up playwright for e2e testing in my next.js app"

"want to add a tooltip to my icon buttons using shadcn Tooltip -- it should show on hover and also when the button is focused with keyboard. is this already built in?"

The Tooltip query is the hardest negative: it mentions keyboard + focus but has no accessibility problem to solve.


Root cause discovery

Running the eval harness revealed why recall was 0%: Claude prefers the real cached skill over any parallel test skill. Pointing Claude at a separate eval-XXXXX skill in the cache simply didn't work — it always picked the real one.

The fix: temporarily swap the actual SKILL.md in the plugin cache with the candidate description during each eval run, then restore it. Here is the core swap logic:

_skill_swap_lock = threading.Lock()

def run_single_query(query: str, cache_skill_path: Path, candidate_desc: str) -> bool:
    with _skill_swap_lock:
        original = cache_skill_path.read_text(encoding="utf-8")
        patched = _replace_description_in_frontmatter(original, candidate_desc)
        cache_skill_path.write_text(patched, encoding="utf-8")
    try:
        result = _run_query_subprocess(query)
        return _skill_triggered(result)
    finally:
        with _skill_swap_lock:
            cache_skill_path.write_text(original, encoding="utf-8")

The lock serializes concurrent swaps so parallel query runs do not corrupt each other's cache state.


Description optimization loop

With the eval harness working, I ran the automated description optimizer: 3 iterations × 20 queries, each iteration proposing a better description using Claude itself as the optimizer.

Iteration Train (12) Test (8) Notes
Starting description 0/12 0/8 Recall 0% — baseline
Iter 1 11/12 7/8 Arrow keys, dropdowns, Radix now trigger
Iter 2 10/12 7/8 Regressed — broader phrasing triggered Tooltip false positive
Iter 312/128/8

Perfect — loop exited early (all_passed)

Final description (applied to both SKILL.md and skills-rules.json):

Use this skill for any accessibility concern: WCAG compliance (any success criterion
including SC 1.3.5 autocomplete, SC 2.4.11 focus obscured), ARIA roles/attributes,
screen reader behavior, axe-core or WAVE audit failures, color/non-text contrast,
keyboard navigation, focus trapping/management, skip links, touch targets, VPAT
reports, or accessible form patterns. Also trigger when a user asks whether a UI
library (shadcn, Radix) handles keyboard interactions -- but only if the question is
specifically about accessibility behavior, not general usage. Do NOT trigger for
general component usage questions where keyboard behavior is incidental (e.g., "does
shadcn Tooltip show on hover and focus?" without an accessibility problem to solve).

The key insight from the optimizer: lead with concern categories (WCAG compliance, ARIA, keyboard nav…), name specific SC numbers, and include an explicit "do NOT trigger" guard rail for adjacent-but-not-accessibility questions.


Functional Evals

Beyond triggering accuracy, I wrote 8 functional task evals that verify the skill produces correct code. Each eval has a prompt, expected output description, and a list of machine-verifiable expectations.

Example eval — roving tabindex dropdown:

{
  "id": 1,
  "prompt": "I'm building a custom DropdownMenu with shadcn/ui. Keyboard users can't Tab through items -- they Tab to the trigger and the whole menu closes. Fix the keyboard navigation so Arrow keys move through items and Tab closes the menu. Use roving tabindex.",
  "expectations": [
    "Output sets tabIndex={-1} on non-active items and tabIndex={0} on the active item",
    "Output handles ArrowDown and ArrowUp to move through menu items",
    "Output uses role='menu' on the container and role='menuitem' on items",
    "Output uses aria-haspopup and aria-expanded on the trigger",
    "Output returns focus to the trigger on Escape or Tab"
  ]
}

All 8 tasks:

# Task
1 Roving tabindex dropdown (shadcn / Radix)
2 Modal focus trapping + return focus with FocusScope
3

ARIA combobox with aria-activedescendant

4 Multi-step form — WCAG 3.3.7 redundant entry + 3.3.8 auth
5 SPA route change announcements + skip links
6 Data grid keyboard model — roving tabindex in a table
7

prefers-reduced-motion + carousel pause control

8 Color contrast audit + Tailwind token fix

What Was Shipped

GitHub: github.com/michelve/accessibility

Artifact Location
Skill instructions

SKILL.md (321 lines)

12 reference filesreferences/
Cowork metadataskills-rules.json
Trigger evals (20 queries)evals/trigger-evals.json
Functional evals (8 tasks)evals/evals.json

Invocation Design

Two SKILL.md frontmatter fields control who can invoke the skill and what arguments users can pass. The accessibility skill was designed to support both invocation modes:

Field Value Meaning
user-invocabletrue

Users can type /accessibility [...] directly

disable-model-invocationabsentClaude can also auto-invoke it when relevant
argument-hintsee below Shown in the slash-command picker to guide input

Why user-invocable: true? Accessibility is an actionable workflow — a developer can reasonably type /accessibility audit my LoginForm component and expect a specific task to be performed. Unlike a background context skill (e.g. a legacy-system-context skill that just informs Claude silently), accessibility work has concrete outputs: code changes, ARIA fixes, audit checklists.

Why NOT disable-model-invocation: true? That field is reserved for skills with side effects or timing-sensitive actions — /commit, /deploy, /send-slack-message — where you do not want Claude deciding to run the skill on its own. Accessibility guidance has no side effects; Claude auto-invoking it when a user asks about keyboard nav is exactly the intended behavior.

The argument-hint guides users in the slash-command picker:

---
name: accessibility
description: 'Use this skill for any accessibility concern...'
user-invocable: true
argument-hint: 'Describe the accessibility problem, WCAG criterion (e.g. SC 1.3.5),
  component to audit, or topic (aria, keyboard nav, contrast, screen reader, VPAT)'
---

Example invocations:

/accessibility SC 2.4.11 -- my sticky header is covering focused elements
/accessibility audit my CheckoutForm for WCAG 3.3.7 redundant entry
/accessibility my axe-core run flagged 4 violations on the search page
/accessibility write a VPAT section for our data table component

Compare with a skill that should use disable-model-invocation:

# /deploy skill -- Claude must NOT decide to run this on its own
name: deploy
disable-model-invocation: true
argument-hint: 'Environment to deploy to (staging | production)'

And a skill that should use user-invocable: false:

# Background knowledge -- not an action, just context for Claude
name: legacy-system-context
user-invocable: false
description: 'Load context about our legacy COBOL billing system...'

The accessibility skill fits neither restriction: it is actionable, has no side effects, and benefits from both user-driven and model-driven invocation.


How to Use It

Install from GitHub: github.com/michelve/accessibility — clone or copy the files into your Claude Code skills folder.

Claude Code (plugin): The skill loads automatically when you ask anything accessibility-related. Or invoke directly:

/accessibility my modal traps focus but loses it when closed -- how do I restore focus?

Cowork: Install via the marketplace. The skills-rules.json drives triggering.

Test triggering yourself — none of these use the word "accessibility":

running axe-core on our login page and it's flagging SC 1.3.5
some users are keyboard-only -- they say tab skips our data table cells
my image carousel auto-plays. a user filed a bug saying they get motion sick

All three invoke the skill automatically.


Numbers at a Glance

Metric Result
Trigger recall 0% → 100% (20-query eval set, held-out 8-query test)
Trigger precision 100% (zero false positives across all iterations)
Optimization runs

3 iterations to perfect score (exit: all_passed)

Reference files 12 (loaded on demand, not dumped into every prompt)
Functional evals 8 tasks with verifiable expectations
SKILL.md length 321 lines (within the 500-line recommended limit)
Platforms Claude Code plugin + Cowork plugin