@a11y-lens/cli 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duchan Jo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,27 @@
1
+ a11y-lens
2
+ Copyright (c) 2026 Duchan Jo
3
+
4
+ The rule set in rules/ is an original distillation written for AI-agent
5
+ consumption. It draws on the following prior work for rule coverage and
6
+ accepted accessibility practice:
7
+
8
+ 1. eslint-plugin-jsx-a11y
9
+ https://github.com/jsx-eslint/eslint-plugin-jsx-a11y
10
+ License: MIT. Rule intents referenced for the "static baseline" sections.
11
+
12
+ 2. axe-core (Deque Systems, Inc.)
13
+ https://github.com/dequelabs/axe-core
14
+ License: MPL-2.0. Rule descriptions referenced for rule coverage mapping.
15
+ No axe-core source code is included in this package.
16
+
17
+ 3. W3C WAI-ARIA Authoring Practices Guide (APG)
18
+ https://www.w3.org/WAI/ARIA/apg/
19
+ License: W3C Document License / W3C Software and Document Notice.
20
+ Widget interaction patterns (combobox, dialog, tabs, menu, listbox)
21
+ summarized in rules/04-aria-widgets.md and rules/05-keyboard-interaction.md.
22
+
23
+ 4. WCAG 2.2 (W3C Recommendation)
24
+ https://www.w3.org/TR/WCAG22/
25
+ Success criteria referenced by ID throughout the rule set.
26
+
27
+ This package contains no source code copied from the above projects.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # a11y-lens
2
+
3
+ > **Package** [`@a11y-lens/cli`](https://www.npmjs.com/package/@a11y-lens/cli) · **CLI** `a11y-lens` · **Skill** `npx skills add jo-duchan/a11y-lens`
4
+
5
+ **AI-powered semantic accessibility linter.** Reviews what static linters can't see — using the AI coding agent you already have (Claude Code, Codex, or Cursor).
6
+
7
+ Static linters check **syntax**: *does this `img` have an `alt`?*
8
+ a11y-lens checks **semantics**: *does this `alt` actually describe the image? Is this custom dropdown's keyboard interaction complete per the WAI-ARIA combobox pattern? Does the modal return focus to its trigger?*
9
+
10
+ ```
11
+ $ git commit -m "add plan selector"
12
+ a11y-lens: reviewing 1 file(s) with claude…
13
+
14
+ src/PlanSelect.jsx
15
+ ✖ error:23 [aria-widgets] Custom dropdown is a div with onClick only — no combobox
16
+ role, no aria-expanded, no listbox/option semantics. AT users get a plain text node.
17
+ fix: use role="combobox" + aria-expanded + role="listbox"/"option", or a native <select>
18
+ ✖ error:23 [keyboard-interaction] Dropdown cannot be operated by keyboard: no ArrowDown/
19
+ ArrowUp/Enter/Escape handling per the APG combobox pattern.
20
+ fix: add onKeyDown implementing the APG combobox key set
21
+
22
+ a11y-lens: 2 error(s), 0 warning(s) (reviewed by claude)
23
+ husky - pre-commit hook exited with code 1
24
+ ```
25
+
26
+ ## How it works
27
+
28
+ 1. Collects the **staged** UI files (`.jsx`, `.tsx`, `.html`, `.vue`, `.svelte`, …) and their diffs.
29
+ 2. Sends them — together with a distilled rule set (`skills/a11y-lens/references/*.md`, drawn from WAI-ARIA APG, WCAG 2.2, eslint-plugin-jsx-a11y and axe-core coverage) — to a headless agent CLI: `claude -p`, `codex exec`, or `cursor-agent -p`, whichever is installed.
30
+ 3. Parses the structured findings and gates the commit on `error` severity. Warnings report but never block (unless `--strict`).
31
+
32
+ **Infrastructure never blocks a commit.** No agent CLI, no network, agent crash → a11y-lens warns and exits 0. Only real accessibility findings gate.
33
+
34
+ ## Install
35
+
36
+ a11y-lens has two layers — install either or both:
37
+
38
+ **Write time (agent skill).** Teaches your coding agent the rules so UI code is accessible *before* the hook ever runs. [The skills CLI](https://skills.sh) installs it for Claude Code, Codex, Cursor, and 60+ other agents:
39
+
40
+ ```bash
41
+ npx skills add jo-duchan/a11y-lens
42
+ ```
43
+
44
+ **Commit time (git hook gate):**
45
+
46
+ ```bash
47
+ npm install -D @a11y-lens/cli # or pnpm add -D / yarn add -D
48
+ npx a11y-lens init
49
+ ```
50
+
51
+ `init` installs the pre-commit hook for you — it detects lefthook (`lefthook.yml`), husky (`.husky/`), or plain `.git/hooks`, picks your package manager's runner (`pnpm exec` / `yarn` / `bunx` / `npx`), and adds the check idempotently. It also injects a rules reference into your `AGENTS.md` (a lightweight fallback for agents without skills support). Use `--no-hook` to skip hook installation.
52
+
53
+ Example (lefthook):
54
+
55
+ ```yaml
56
+ pre-commit:
57
+ jobs:
58
+ - name: a11y-lens
59
+ run: npx a11y-lens check --staged
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ a11y-lens check --staged # what the git hook runs
66
+ a11y-lens check src/Modal.tsx # review specific files
67
+ a11y-lens check --staged --strict # warnings also fail
68
+ a11y-lens check --staged --agent codex
69
+ a11y-lens rules # list rule categories
70
+ ```
71
+
72
+ Escape hatches: `A11Y_LENS_SKIP=1 git commit …` or `git commit --no-verify`.
73
+
74
+ ## Rule set
75
+
76
+ One markdown file per category in `skills/a11y-lens/references/`, consumed by both the skill and the CLI. Each separates the **static baseline** (what eslint/axe already catch — not re-reported) from the **semantic checks** this tool exists for.
77
+
78
+ | Category | Semantic checks (examples) |
79
+ |---|---|
80
+ | `01-landmarks-headings` | outline describes the document, not the visual design; one `h1`; labelled landmarks |
81
+ | `02-images-alt` | `alt` describes function in context; decorative silenced, informative never; icon-only controls named by action |
82
+ | `03-forms-labels` | placeholder ≠ label; errors tied via `aria-describedby`; accessible name matches visible label |
83
+ | `04-aria-widgets` | claimed APG patterns must be **complete** — half a combobox is worse than none; state in ARIA, not just CSS |
84
+ | `05-keyboard-interaction` | full APG key sets; no hover-only affordances; no keyboard traps |
85
+ | `06-focus-management` | overlays move focus in and return it; async results announced via live regions; SPA route changes handled |
86
+
87
+ Rules are plain markdown — tune them for your project by editing the files, no code changes needed.
88
+
89
+ ## Why commit-time AI review is cheap now
90
+
91
+ In AI-native workflows the entity blocked at pre-commit is usually **an agent, not a human**. A 10–30 second semantic review is a fine price when the committer can read the findings, fix them, and retry without getting annoyed.
92
+
93
+ ## Requirements
94
+
95
+ - Node ≥ 18, zero runtime dependencies
96
+ - One of: [Claude Code](https://claude.com/claude-code) (`claude`), [Codex CLI](https://github.com/openai/codex) (`codex`), [Cursor CLI](https://cursor.com/cli) (`cursor-agent`), logged in
97
+
98
+ ## License
99
+
100
+ MIT © Duchan Jo — see [NOTICE](./NOTICE) for rule-set attributions (eslint-plugin-jsx-a11y, axe-core, W3C WAI-ARIA APG, WCAG 2.2).
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { collectStagedUIFiles, collectPathArgs } from '../src/staged.mjs';
6
+ import { installHook, detectRunner } from '../src/hooks.mjs';
7
+ import { detectAgent, runAgent } from '../src/agent.mjs';
8
+ import { buildPrompt } from '../src/prompt.mjs';
9
+ import { parseFindings, printReport, exitCodeFor } from '../src/report.mjs';
10
+
11
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+
13
+ const HELP = `a11y-lens — AI-powered semantic accessibility linter
14
+
15
+ Usage:
16
+ a11y-lens check --staged review staged UI files (for git hooks)
17
+ a11y-lens check <files...> review specific files
18
+ a11y-lens init install the pre-commit hook (lefthook/husky/git hooks,
19
+ auto-detected) + inject rules reference into ./AGENTS.md
20
+ a11y-lens rules list rule categories
21
+
22
+ Options for check:
23
+ --agent <claude|codex|cursor> force a specific agent CLI (default: auto-detect)
24
+ --strict exit non-zero on warnings too (default: errors only)
25
+
26
+ Options for init:
27
+ --no-hook skip hook installation (AGENTS.md only)
28
+
29
+ Environment:
30
+ A11Y_LENS_AGENT same as --agent
31
+ A11Y_LENS_MODEL model override passed to claude (optional)
32
+ A11Y_LENS_SKIP=1 skip the check entirely (escape hatch)
33
+
34
+ Infrastructure failures (no agent CLI, no network, agent error) never block:
35
+ a11y-lens warns and exits 0. Only accessibility findings gate.`;
36
+
37
+ function parseArgs(argv) {
38
+ const args = { _: [], flags: {} };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const token = argv[i];
41
+ if (token === '--staged' || token === '--strict' || token === '--no-hook') {
42
+ args.flags[token.slice(2)] = true;
43
+ }
44
+ else if (token === '--agent') args.flags.agent = argv[++i];
45
+ else if (token === '--help' || token === '-h') args.flags.help = true;
46
+ else args._.push(token);
47
+ }
48
+ return args;
49
+ }
50
+
51
+ function softFail(message) {
52
+ console.warn(`a11y-lens: ${message} — skipping check (commits are never blocked by infrastructure).`);
53
+ process.exit(0);
54
+ }
55
+
56
+ function commandCheck(args) {
57
+ if (process.env.A11Y_LENS_SKIP === '1') softFail('A11Y_LENS_SKIP=1');
58
+
59
+ const { files, error } = args.flags.staged
60
+ ? collectStagedUIFiles()
61
+ : collectPathArgs(args._);
62
+ if (error) softFail(error);
63
+
64
+ const reviewable = files.filter((f) => !f.skipped);
65
+ for (const f of files.filter((f) => f.skipped)) {
66
+ console.warn(`a11y-lens: skipping ${f.path} (${f.skipped})`);
67
+ }
68
+ if (reviewable.length === 0) {
69
+ if (args.flags.staged) console.log('a11y-lens: no staged UI files, nothing to review.');
70
+ else console.log('a11y-lens: no reviewable files given. Try: a11y-lens check src/Component.tsx');
71
+ process.exit(0);
72
+ }
73
+
74
+ const detection = detectAgent(args.flags.agent);
75
+ if (detection.error) softFail(detection.error);
76
+
77
+ const { prompt, dropped } = buildPrompt(reviewable);
78
+ for (const path of dropped) {
79
+ console.warn(`a11y-lens: dropped ${path} (prompt size budget exceeded)`);
80
+ }
81
+
82
+ console.log(
83
+ `a11y-lens: reviewing ${reviewable.length - dropped.length} file(s) with ${detection.name}…`,
84
+ );
85
+ const result = runAgent(detection.agent, prompt);
86
+ if (!result.ok) softFail(`agent failed: ${result.error}`);
87
+
88
+ const parsed = parseFindings(result.output);
89
+ if (parsed.error) softFail(parsed.error);
90
+
91
+ printReport(parsed.findings, { agentName: detection.name });
92
+ process.exit(exitCodeFor(parsed.findings, { strict: args.flags.strict }));
93
+ }
94
+
95
+ function commandInit(args) {
96
+ // 1. Pre-commit hook (lefthook / husky / plain git hooks, auto-detected)
97
+ if (!args.flags['no-hook']) {
98
+ const result = installHook(process.cwd());
99
+ console.log(`a11y-lens: [${result.system}] ${result.message}`);
100
+ } else {
101
+ console.log(`a11y-lens: hook skipped (--no-hook). Manual command: ${detectRunner(process.cwd())} a11y-lens check --staged`);
102
+ }
103
+
104
+ // 2. Rules reference for interactive agents
105
+ const snippet = readFileSync(join(PACKAGE_ROOT, 'templates', 'agents-snippet.md'), 'utf8').trimEnd();
106
+ const target = join(process.cwd(), 'AGENTS.md');
107
+ const begin = '<!-- a11y-lens:begin -->';
108
+ const end = '<!-- a11y-lens:end -->';
109
+
110
+ if (existsSync(target)) {
111
+ const current = readFileSync(target, 'utf8');
112
+ const pattern = new RegExp(`${begin}[\\s\\S]*?${end}`);
113
+ const updated = pattern.test(current)
114
+ ? current.replace(pattern, snippet)
115
+ : `${current.trimEnd()}\n\n${snippet}\n`;
116
+ writeFileSync(target, updated);
117
+ console.log(`a11y-lens: ${pattern.test(current) ? 'updated' : 'appended'} rules section in AGENTS.md`);
118
+ } else {
119
+ writeFileSync(target, `${snippet}\n`);
120
+ console.log('a11y-lens: created AGENTS.md with rules section');
121
+ }
122
+
123
+ console.log(`
124
+ Richer write-time guidance for skills-capable agents: npx skills add jo-duchan/a11y-lens
125
+ Escape hatches: A11Y_LENS_SKIP=1 git commit … | git commit --no-verify`);
126
+ }
127
+
128
+ const args = parseArgs(process.argv.slice(2));
129
+ const command = args._[0];
130
+ args._ = args._.slice(1);
131
+
132
+ if (args.flags.help || !command) {
133
+ console.log(HELP);
134
+ process.exit(0);
135
+ }
136
+
137
+ switch (command) {
138
+ case 'check':
139
+ commandCheck(args);
140
+ break;
141
+ case 'init':
142
+ commandInit(args);
143
+ break;
144
+ case 'rules': {
145
+ const rulesDir = join(PACKAGE_ROOT, 'skills', 'a11y-lens', 'references');
146
+ for (const name of readdirSync(rulesDir).filter((n) => n.endsWith('.md')).sort()) {
147
+ console.log(`${name} → ${join(rulesDir, name)}`);
148
+ }
149
+ break;
150
+ }
151
+ default:
152
+ console.error(`a11y-lens: unknown command "${command}"\n`);
153
+ console.log(HELP);
154
+ process.exit(2);
155
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@a11y-lens/cli",
3
+ "version": "0.4.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "AI-powered semantic accessibility linter — reviews what static linters can't see. Runs on Claude Code, Codex, or Cursor at commit time.",
8
+ "type": "module",
9
+ "bin": {
10
+ "a11y-lens": "bin/a11y-lens.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "skills",
16
+ "templates",
17
+ "README.md",
18
+ "NOTICE",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/"
23
+ },
24
+ "keywords": [
25
+ "accessibility",
26
+ "a11y",
27
+ "lint",
28
+ "wai-aria",
29
+ "wcag",
30
+ "ai",
31
+ "agent",
32
+ "pre-commit",
33
+ "semantic-review"
34
+ ],
35
+ "author": "Duchan Jo <jo_duchan@icloud.com> (https://github.com/jo-duchan)",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/jo-duchan/a11y-lens.git"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ }
44
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: a11y-lens
3
+ description: Semantic accessibility rules for writing or reviewing UI code — components, forms, modals, dropdowns, images, interactive elements in JSX/TSX/HTML/Vue/Svelte. Use when implementing any user-facing markup, when reviewing UI changes, or when the user asks about accessibility, WCAG, ARIA, keyboard support, or screen readers. Covers what static linters (eslint-plugin-jsx-a11y, axe-core) structurally cannot check.
4
+ ---
5
+
6
+ # a11y-lens
7
+
8
+ You are applying the a11y-lens rule set: semantic accessibility review **above** the static-linter layer. Static linters check syntax ("does this `img` have an `alt`?"); you check meaning ("does this `alt` actually describe the image in context?", "is this custom dropdown's keyboard interaction complete per the WAI-ARIA APG combobox pattern?").
9
+
10
+ ## How to use the rules
11
+
12
+ Before writing or reviewing UI code, read the reference file for each category the code touches. Do not guess the rules from the summaries below — the reference files carry severity guidance and good/bad examples.
13
+
14
+ | When the code involves… | Read |
15
+ |---|---|
16
+ | Page/view structure, sections, headings | [references/01-landmarks-headings.md](references/01-landmarks-headings.md) |
17
+ | Images, icons, icon-only buttons, SVG | [references/02-images-alt.md](references/02-images-alt.md) |
18
+ | Inputs, forms, validation, error messages | [references/03-forms-labels.md](references/03-forms-labels.md) |
19
+ | Custom widgets: dropdowns, modals, tabs, menus, toggles | [references/04-aria-widgets.md](references/04-aria-widgets.md) |
20
+ | Click/hover handlers, shortcuts, drag, carousels | [references/05-keyboard-interaction.md](references/05-keyboard-interaction.md) |
21
+ | Overlays, route changes, async results, toasts, loading | [references/06-focus-management.md](references/06-focus-management.md) |
22
+
23
+ Core stances that apply everywhere:
24
+
25
+ 1. **Prefer native elements** (`button`, `select`, `details`, `dialog`) — they ship the complete pattern for free. A custom widget must implement the **whole** APG pattern; a partial pattern is worse than none.
26
+ 2. **Placeholder is not a label. Hover is not a keyboard path. CSS state is not ARIA state.**
27
+ 3. Severity discipline: `error` = clear violations (keyboard-dead interactive elements, unnamed icon-only controls, incomplete claimed ARIA patterns, unmanaged overlay focus, silenced informative images). Judgment calls are `warning`.
28
+
29
+ ## Commit-time gate (CLI)
30
+
31
+ This skill has a companion CLI that runs the same rules through a headless agent at commit time:
32
+
33
+ ```bash
34
+ npm install -D @a11y-lens/cli
35
+ npx a11y-lens init # AGENTS.md reference + hook setup instructions
36
+ npx a11y-lens check --staged # what the pre-commit hook runs
37
+ npx a11y-lens check src/Modal.tsx # review specific files
38
+ ```
39
+
40
+ If the project has the hook installed, self-check against the rules before finishing UI work — it is cheaper than failing the gate. Repository: https://github.com/jo-duchan/a11y-lens
@@ -0,0 +1,40 @@
1
+ ---
2
+ id: landmarks-headings
3
+ sources: [WCAG 1.3.1, WCAG 2.4.1, WCAG 2.4.6, axe-core region/heading rules]
4
+ ---
5
+
6
+ # Landmarks & heading hierarchy
7
+
8
+ ## Static baseline (already caught by eslint/axe — do not re-report unless visible in the diff)
9
+
10
+ - Page content not contained in landmarks (`header`, `nav`, `main`, `footer`, `aside`, `section[aria-label]`)
11
+ - More than one `main`, duplicated `banner`/`contentinfo`
12
+ - Empty headings
13
+
14
+ ## Semantic checks (what you review)
15
+
16
+ 1. **Heading hierarchy must describe the document outline, not the visual design.**
17
+ - Exactly one `h1` per page/view; levels must not skip downward (h1 → h3 with no h2 is an `error`).
18
+ - A heading chosen for its font size rather than its outline position is a violation — check whether the level makes sense relative to surrounding headings, not whether it "looks right".
19
+ - In SPAs, per-route views count as pages: a route component rendering only `h3`s is an `error` even if some layout file has an `h1` elsewhere — flag it as `warning` and say why (cannot see full composition).
20
+ 2. **`section` needs an accessible name to be a landmark.** A bare `section` used as a styling wrapper should be a `div`; a `section` that truly groups content needs `aria-labelledby` pointing at its heading (`warning`).
21
+ 3. **Landmark labels must be distinguishable.** Two `nav` elements require distinct `aria-label`s ("primary", "breadcrumb") — identical or missing labels on repeated landmarks is a `warning`.
22
+ 4. **Visual titles that are not headings.** A styled `div`/`p` acting as an obvious section title (short text, followed by related content, styled prominently) should be a heading (`warning`).
23
+
24
+ ## Examples
25
+
26
+ Bad — outline skips and decorative section:
27
+
28
+ ```jsx
29
+ <section> {/* no accessible name → not a landmark, just noise */}
30
+ <h4>요금 안내</h4> {/* previous heading was h1 → skipped to h4 */}
31
+ </section>
32
+ ```
33
+
34
+ Good:
35
+
36
+ ```jsx
37
+ <section aria-labelledby="pricing-title">
38
+ <h2 id="pricing-title">요금 안내</h2>
39
+ </section>
40
+ ```
@@ -0,0 +1,39 @@
1
+ ---
2
+ id: images-alt
3
+ sources: [WCAG 1.1.1, eslint-plugin-jsx-a11y alt-text/img-redundant-alt, axe-core image-alt]
4
+ ---
5
+
6
+ # Images & text alternatives
7
+
8
+ ## Static baseline
9
+
10
+ - `img` missing `alt` attribute entirely
11
+ - `alt` containing "image", "picture", "photo", "이미지", "사진" (redundant)
12
+ - `input[type=image]`, `area`, `object` without text alternative
13
+
14
+ ## Semantic checks (what you review)
15
+
16
+ 1. **Does the `alt` actually describe the image's function or content in context?**
17
+ - `alt="banner"`, `alt="icon"`, `alt="img_03"`, filename-derived alt → `error`. These pass static linters and fail humans.
18
+ - An image inside a link: alt must describe the **destination/action**, not the picture ("에이닷 전화 다운로드", not "스마트폰 그림") — mismatch is `warning`.
19
+ 2. **Decorative images must be explicitly silenced**, not given filler alt. Purely decorative → `alt=""` (and `aria-hidden="true"` for inline SVG). Filler like `alt="장식"` is `warning`.
20
+ 3. **Informative images must not be silenced.** `alt=""` on an image that plainly carries information (chart, badge with a number, screenshot referenced by the copy) is an `error`.
21
+ 4. **Icon-only interactive elements.** A button/link whose only content is an icon (SVG, icon font, emoji) needs an accessible name (`aria-label` or visually-hidden text). Check the name describes the **action** ("닫기", not "X 아이콘"). Missing → `error`; vague → `warning`.
22
+ 5. **Text baked into images** (event banners with dates/prices in pixels) — the alt or adjacent text must carry the same information; otherwise `warning`.
23
+ 6. **CSS background-images conveying meaning** have no alt channel at all — if the diff shows meaningful content moved into a background-image, flag `warning` with the visually-hidden-text remedy.
24
+
25
+ ## Examples
26
+
27
+ Bad — passes static linting, fails semantically:
28
+
29
+ ```jsx
30
+ <a href="/download"><img src="/hero-phone.png" alt="phone image" /></a>
31
+ <button><CloseIcon /></button>
32
+ ```
33
+
34
+ Good:
35
+
36
+ ```jsx
37
+ <a href="/download"><img src="/hero-phone.png" alt="에이닷 전화 앱 다운로드" /></a>
38
+ <button aria-label="닫기"><CloseIcon aria-hidden="true" /></button>
39
+ ```
@@ -0,0 +1,38 @@
1
+ ---
2
+ id: forms-labels
3
+ sources: [WCAG 1.3.1, WCAG 3.3.1, WCAG 3.3.2, WCAG 4.1.2, eslint-plugin-jsx-a11y label-has-associated-control, axe-core label/select-name]
4
+ ---
5
+
6
+ # Forms, labels & error states
7
+
8
+ ## Static baseline
9
+
10
+ - Form control without any programmatic label (`label[for]`, wrapping `label`, `aria-label`, `aria-labelledby`)
11
+ - `label` not associated with a control
12
+
13
+ ## Semantic checks (what you review)
14
+
15
+ 1. **Placeholder is not a label.** A control whose only "label" is `placeholder` is an `error` — it disappears on input and has no reliable AT exposure. Check the diff for inputs that visually rely on placeholder alone.
16
+ 2. **The accessible name must match the visible label.** If a visible text label says "휴대폰 번호" but `aria-label="phone"`, voice-control users cannot target it (WCAG 2.5.3 Label in Name) → `warning`.
17
+ 3. **Errors must be programmatically tied to their field.** Rendering an error message as a sibling `<p className="error">` with no `aria-describedby` on the input and no `aria-invalid` is `error` when the diff introduces validation UI. A live-updating error summary should use `role="alert"` or `aria-live="assertive"` — but only one, not both stacked.
18
+ 4. **Required and disabled semantics.** Visually-marked required fields (asterisk, "필수") need `required` or `aria-required="true"` (`warning`). A visually "disabled" button that is actually a styled `div` or keeps focus without `disabled`/`aria-disabled` misleads AT → `warning`.
19
+ 5. **Grouped controls need a group name.** Radio sets / related checkboxes introduced without `fieldset`+`legend` (or `role="radiogroup"` + `aria-labelledby`) → `warning`: each option is announced with no question attached.
20
+ 6. **Autocomplete on identity fields.** Login/checkout fields for name, email, tel, address should carry `autocomplete` tokens (WCAG 1.3.5) → `warning` when the diff adds such fields bare.
21
+
22
+ ## Examples
23
+
24
+ Bad:
25
+
26
+ ```jsx
27
+ <input placeholder="이메일" value={email} onChange={...} />
28
+ {error && <p className="error">이메일 형식이 아닙니다</p>}
29
+ ```
30
+
31
+ Good:
32
+
33
+ ```jsx
34
+ <label htmlFor="email">이메일</label>
35
+ <input id="email" type="email" autoComplete="email" value={email}
36
+ aria-invalid={!!error} aria-describedby={error ? "email-error" : undefined} />
37
+ {error && <p id="email-error" role="alert">이메일 형식이 아닙니다</p>}
38
+ ```
@@ -0,0 +1,51 @@
1
+ ---
2
+ id: aria-widgets
3
+ sources: [W3C WAI-ARIA APG patterns, WCAG 4.1.2, eslint-plugin-jsx-a11y role-* rules, axe-core aria-* rules]
4
+ ---
5
+
6
+ # ARIA widget patterns
7
+
8
+ > First rule of ARIA: prefer the native element (`button`, `select`, `details`, `dialog`) — it ships the whole pattern for free. Custom widgets must implement the **complete** APG pattern; a partial pattern is worse than none because it promises behavior that isn't there.
9
+
10
+ ## Static baseline
11
+
12
+ - Invalid `role` values, `aria-*` attributes not permitted for the role
13
+ - `role` requiring missing `aria-*` props (e.g. `role="checkbox"` without `aria-checked`)
14
+ - Interactive `div`/`span` with `onClick` and no role/tabindex
15
+
16
+ ## Semantic checks (what you review)
17
+
18
+ For any custom widget in the diff, identify which APG pattern it is imitating, then verify the pattern is **complete** — states, properties, and relationships all present. Missing pieces of a claimed pattern are `error`.
19
+
20
+ 1. **Combobox / select-like** (custom dropdown, autocomplete):
21
+ - Trigger: `role="combobox"`, `aria-expanded` toggling, `aria-controls` → listbox id, `aria-haspopup="listbox"` where appropriate.
22
+ - Popup: `role="listbox"`, options `role="option"` with `aria-selected`; active option tracked via `aria-activedescendant` on the combobox (or roving focus — one or the other, not both).
23
+ - A styled `div` dropdown with only `onClick` handlers and none of the above is the classic failure → `error`.
24
+ 2. **Dialog / modal**: `role="dialog"` + `aria-modal="true"`, labelled by its title. Background content must be inert or `aria-hidden` while open. (Focus behavior → rules/06.)
25
+ 3. **Tabs**: `role="tablist"` / `tab` / `tabpanel`, `aria-selected` on the active tab, `aria-controls` ↔ `aria-labelledby` linkage between tab and panel.
26
+ 4. **Menu**: `role="menu"`/`menuitem` is for **command menus**, not site navigation. Nav links wrapped in `role="menu"` is a misuse → `warning` (breaks expected keyboard model).
27
+ 5. **Switch vs checkbox vs button**: a toggle announced as what it visually is — `role="switch"` needs `aria-checked`, not `aria-pressed`; mixing the two vocabularies is `warning`.
28
+ 6. **State must live in ARIA, not only in CSS.** `className={isOpen ? 'open' : ''}` with no `aria-expanded` change means AT never hears the state change → `error` for expand/collapse triggers.
29
+ 7. **`aria-hidden` on focusable content** or on an element containing focusable children → `error` (focusable but invisible to AT).
30
+ 8. **Redundant/contradictory ARIA**: `role="button"` on `button`, `aria-label` duplicating identical visible text where unnecessary → `notice`-level `warning`; contradiction (label says one thing, visible text another) → follow rules/03 §2.
31
+
32
+ ## Examples
33
+
34
+ Bad — half a combobox:
35
+
36
+ ```jsx
37
+ <div className="select" onClick={toggle}>
38
+ {value}
39
+ {open && <ul>{options.map(o => <li onClick={() => pick(o)}>{o}</li>)}</ul>}
40
+ </div>
41
+ ```
42
+
43
+ Good (abridged):
44
+
45
+ ```jsx
46
+ <button role="combobox" aria-expanded={open} aria-controls="fruit-list"
47
+ aria-activedescendant={activeId} onKeyDown={handleKeys}>…</button>
48
+ <ul id="fruit-list" role="listbox" hidden={!open}>
49
+ <li id="opt-1" role="option" aria-selected={value === '사과'}>사과</li>
50
+ </ul>
51
+ ```
@@ -0,0 +1,43 @@
1
+ ---
2
+ id: keyboard-interaction
3
+ sources: [WCAG 2.1.1, WCAG 2.1.2, W3C WAI-ARIA APG keyboard patterns, eslint-plugin-jsx-a11y click-events-have-key-events]
4
+ ---
5
+
6
+ # Keyboard interaction
7
+
8
+ ## Static baseline
9
+
10
+ - `onClick` on non-interactive elements without `onKeyDown`/`onKeyUp`
11
+ - `tabIndex` greater than 0
12
+ - Interactive element with `tabIndex={-1}` and no programmatic focus management
13
+
14
+ ## Semantic checks (what you review)
15
+
16
+ 1. **Every pointer interaction needs a keyboard equivalent — the right one.** Adding `onKeyDown` that only handles Enter on a `div` "button" is incomplete: native buttons fire on Enter **and** Space. Check the handler actually implements the expected key set, not just any key (`error` if a claimed interactive element is keyboard-dead, `warning` if the key set is partial).
17
+ 2. **Widget key sets must match the APG pattern being imitated:**
18
+ - Combobox/listbox: `ArrowDown`/`ArrowUp` move the active option, `Enter` selects, `Escape` closes, `Home`/`End` jump, printable characters typeahead (typeahead is `warning`-level, the rest `error` if absent).
19
+ - Tabs: `ArrowLeft`/`ArrowRight` between tabs (roving tabindex), `Tab` leaves the tablist into the panel.
20
+ - Dialog: `Escape` closes; `Tab` cycles inside (focus trap — see rules/06).
21
+ - Menu: arrows navigate, `Escape` closes and returns focus to the trigger.
22
+ 3. **Hover-only affordances.** Content or controls revealed only on `:hover`/`onMouseEnter` with no focus/keyboard path (tooltips, hover menus, card action buttons) → `error`: keyboard users can never reach them. Also flag `onMouseDown`-only handlers (skips keyboard AND breaks click-drag expectations).
23
+ 4. **No keyboard traps.** Custom key handling that `preventDefault()`s Tab without providing an exit is an `error` (WCAG 2.1.2). Legitimate traps (modal dialogs) must be escapable via `Escape`.
24
+ 5. **Scroll/drag-only interactions** (carousels, sliders, drag-to-reorder) need button or keyboard alternatives → `warning`.
25
+ 6. **Global shortcuts on printable keys** without a modifier or an off-switch collide with AT and text input → `warning` (WCAG 2.1.4).
26
+
27
+ ## Examples
28
+
29
+ Bad — mouse-only reveal:
30
+
31
+ ```jsx
32
+ <div onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
33
+ 요금제 비교 {show && <button onClick={openDetail}>자세히</button>}
34
+ </div>
35
+ ```
36
+
37
+ Good:
38
+
39
+ ```jsx
40
+ <div onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}>
41
+ 요금제 비교 <button onClick={openDetail} className={visible ? '' : 'sr-until-focus'}>자세히</button>
42
+ </div>
43
+ ```
@@ -0,0 +1,46 @@
1
+ ---
2
+ id: focus-management
3
+ sources: [WCAG 2.4.3, WCAG 2.4.7, WCAG 3.2.1, W3C WAI-ARIA APG dialog/disclosure patterns]
4
+ ---
5
+
6
+ # Focus management & dynamic content
7
+
8
+ ## Static baseline
9
+
10
+ - `outline: none` / `outline: 0` without a replacement `:focus-visible` style
11
+ - `autoFocus` on elements below the fold
12
+
13
+ ## Semantic checks (what you review)
14
+
15
+ 1. **Opening an overlay must move focus into it; closing must return focus to the trigger.** A modal/drawer/popover in the diff that only toggles visibility state with no `focus()` call in either direction → `error`. Focus landing on the container is acceptable (`tabIndex={-1}` + label); focus landing nowhere (document.body) is the failure.
16
+ 2. **Focus trap completeness.** While a modal is open, `Tab` from the last focusable element must wrap to the first (and Shift+Tab the reverse). A "trap" implemented by `aria-hidden` on the background but with tab order still escaping → `error`.
17
+ 3. **Removing the focused element.** Deleting a list item, closing a tab, dismissing a toast that currently holds focus must move focus somewhere sensible (next item, list container, heading) — otherwise focus resets to `body` and keyboard users are lost → `warning`.
18
+ 4. **Route changes in SPAs.** Client-side navigation that only swaps content leaves focus and screen-reader context on the old page. New route should move focus to the new view's heading or main container, or announce via live region → `warning` when the diff adds routing.
19
+ 5. **Async results need announcement.** Content that appears after a delay (search results, form submission outcome, "저장되었습니다" toast) is silent to AT unless in an `aria-live` region (`polite` for results, `assertive`/`role="alert"` for errors) → `warning`; toast components with no live region → `error`.
20
+ 6. **Loading states.** Spinner-only loading (`<Spinner />` with no text, no `aria-busy`, no live announcement) → `warning`. Skeleton screens should be `aria-hidden` so AT doesn't read placeholder noise.
21
+ 7. **No focus stealing.** Auto-focusing an input on page load is acceptable for single-purpose pages (login); yanking focus on timers, carousel advance, or validation-while-typing → `warning` (WCAG 3.2.1 On Focus).
22
+ 8. **`scrollIntoView`/anchor jumps without focus.** Scrolling a target into view visually while focus stays behind creates divergence between sighted and keyboard experience → `warning`.
23
+
24
+ ## Examples
25
+
26
+ Bad — modal with no focus contract:
27
+
28
+ ```jsx
29
+ {isOpen && <div className="modal"><h2>요금제 변경</h2>…</div>}
30
+ ```
31
+
32
+ Good (abridged):
33
+
34
+ ```jsx
35
+ useEffect(() => {
36
+ if (isOpen) { dialogRef.current?.focus(); }
37
+ else { triggerRef.current?.focus(); }
38
+ }, [isOpen]);
39
+
40
+ {isOpen && (
41
+ <div ref={dialogRef} role="dialog" aria-modal="true"
42
+ aria-labelledby="plan-title" tabIndex={-1}>
43
+ <h2 id="plan-title">요금제 변경</h2>…
44
+ </div>
45
+ )}
46
+ ```
package/src/agent.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ const TIMEOUT_MS = 180_000;
4
+
5
+ const AGENTS = {
6
+ claude: {
7
+ bin: 'claude',
8
+ invoke(prompt) {
9
+ const args = ['-p', '--output-format', 'text'];
10
+ if (process.env.A11Y_LENS_MODEL) args.push('--model', process.env.A11Y_LENS_MODEL);
11
+ return spawnSync('claude', args, {
12
+ input: prompt,
13
+ encoding: 'utf8',
14
+ timeout: TIMEOUT_MS,
15
+ maxBuffer: 10 * 1024 * 1024,
16
+ });
17
+ },
18
+ },
19
+ codex: {
20
+ bin: 'codex',
21
+ invoke(prompt) {
22
+ return spawnSync('codex', ['exec', prompt], {
23
+ encoding: 'utf8',
24
+ timeout: TIMEOUT_MS,
25
+ maxBuffer: 10 * 1024 * 1024,
26
+ });
27
+ },
28
+ },
29
+ cursor: {
30
+ bin: 'cursor-agent',
31
+ invoke(prompt) {
32
+ return spawnSync('cursor-agent', ['-p', prompt, '--output-format', 'text'], {
33
+ encoding: 'utf8',
34
+ timeout: TIMEOUT_MS,
35
+ maxBuffer: 10 * 1024 * 1024,
36
+ });
37
+ },
38
+ },
39
+ };
40
+
41
+ const DETECTION_ORDER = ['claude', 'codex', 'cursor'];
42
+
43
+ function isInstalled(bin) {
44
+ const result = spawnSync('/bin/sh', ['-c', `command -v ${bin}`], { encoding: 'utf8' });
45
+ return result.status === 0;
46
+ }
47
+
48
+ /** Pick an agent: explicit flag > A11Y_LENS_AGENT env > first installed. */
49
+ export function detectAgent(preferred) {
50
+ const wanted = preferred || process.env.A11Y_LENS_AGENT;
51
+ if (wanted) {
52
+ const agent = AGENTS[wanted];
53
+ if (!agent) return { error: `unknown agent "${wanted}" (claude | codex | cursor)` };
54
+ if (!isInstalled(agent.bin)) return { error: `agent CLI "${agent.bin}" is not installed` };
55
+ return { name: wanted, agent };
56
+ }
57
+ for (const name of DETECTION_ORDER) {
58
+ if (isInstalled(AGENTS[name].bin)) return { name, agent: AGENTS[name] };
59
+ }
60
+ return { error: 'no agent CLI found (looked for: claude, codex, cursor-agent)' };
61
+ }
62
+
63
+ /** Run the review prompt through the agent. Returns { ok, output, error }. */
64
+ export function runAgent(agent, prompt) {
65
+ let result;
66
+ try {
67
+ result = agent.invoke(prompt);
68
+ } catch (err) {
69
+ return { ok: false, error: String(err) };
70
+ }
71
+ if (result.error) return { ok: false, error: String(result.error) };
72
+ if (result.status !== 0) {
73
+ return { ok: false, error: (result.stderr || result.stdout || 'agent exited non-zero').trim().slice(0, 500) };
74
+ }
75
+ return { ok: true, output: result.stdout ?? '' };
76
+ }
package/src/hooks.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const LEFTHOOK_FILES = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml'];
5
+
6
+ /** Pick the runner prefix from the repo's lockfile. */
7
+ export function detectRunner(cwd) {
8
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm exec';
9
+ if (existsSync(join(cwd, 'yarn.lock'))) return 'yarn';
10
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) return 'bunx';
11
+ return 'npx';
12
+ }
13
+
14
+ /**
15
+ * Install the pre-commit check into whichever hook system the repo uses.
16
+ * Returns { installed, system, message }. Never throws: callers report the message.
17
+ */
18
+ export function installHook(cwd) {
19
+ const command = `${detectRunner(cwd)} a11y-lens check --staged`;
20
+
21
+ const lefthookFile = LEFTHOOK_FILES.map((f) => join(cwd, f)).find((p) => existsSync(p));
22
+ if (lefthookFile) return installLefthook(lefthookFile, command);
23
+
24
+ if (existsSync(join(cwd, '.husky'))) return installHusky(cwd, command);
25
+
26
+ if (existsSync(join(cwd, '.git'))) return installPlainHook(cwd, command);
27
+
28
+ return {
29
+ installed: false,
30
+ system: 'none',
31
+ message: 'no git repository found — run inside a repo, or add the hook manually',
32
+ };
33
+ }
34
+
35
+ function installLefthook(file, command) {
36
+ const original = readFileSync(file, 'utf8');
37
+ if (original.includes('a11y-lens check')) {
38
+ return { installed: false, system: 'lefthook', message: `already installed in ${file}` };
39
+ }
40
+
41
+ const job = (indent) =>
42
+ `${indent}- name: a11y-lens\n${indent} run: ${command}\n`;
43
+
44
+ const lines = original.split('\n');
45
+ const preCommitIndex = lines.findIndex((l) => /^pre-commit:\s*$/.test(l));
46
+
47
+ if (preCommitIndex !== -1) {
48
+ // Find `jobs:` within the pre-commit block (stop at the next top-level key).
49
+ for (let i = preCommitIndex + 1; i < lines.length; i++) {
50
+ if (/^\S/.test(lines[i])) break; // left the block
51
+ const jobsMatch = lines[i].match(/^(\s+)jobs:\s*$/);
52
+ if (jobsMatch) {
53
+ const itemIndent = jobsMatch[1] + ' ';
54
+ lines.splice(i + 1, 0, job(itemIndent).trimEnd());
55
+ writeFileSync(file, lines.join('\n'));
56
+ return { installed: true, system: 'lefthook', message: `added a11y-lens job to ${file}` };
57
+ }
58
+ }
59
+ // pre-commit exists but no jobs: — unusual layout (e.g. `commands:`); don't guess.
60
+ return {
61
+ installed: false,
62
+ system: 'lefthook',
63
+ message: `${file} has a pre-commit section this tool doesn't recognize — add manually:\n\n pre-commit:\n jobs:\n - name: a11y-lens\n run: ${command}`,
64
+ };
65
+ }
66
+
67
+ writeFileSync(file, `${original.trimEnd()}\n\npre-commit:\n jobs:\n${job(' ')}`);
68
+ return { installed: true, system: 'lefthook', message: `added pre-commit block to ${file}` };
69
+ }
70
+
71
+ function installHusky(cwd, command) {
72
+ const file = join(cwd, '.husky', 'pre-commit');
73
+ if (existsSync(file)) {
74
+ const current = readFileSync(file, 'utf8');
75
+ if (current.includes('a11y-lens check')) {
76
+ return { installed: false, system: 'husky', message: `already installed in ${file}` };
77
+ }
78
+ writeFileSync(file, `${current.trimEnd()}\n${command}\n`);
79
+ return { installed: true, system: 'husky', message: `appended to ${file}` };
80
+ }
81
+ writeFileSync(file, `${command}\n`);
82
+ chmodSync(file, 0o755);
83
+ return { installed: true, system: 'husky', message: `created ${file}` };
84
+ }
85
+
86
+ function installPlainHook(cwd, command) {
87
+ const hooksDir = join(cwd, '.git', 'hooks');
88
+ const file = join(hooksDir, 'pre-commit');
89
+ if (existsSync(file)) {
90
+ const current = readFileSync(file, 'utf8');
91
+ if (current.includes('a11y-lens check')) {
92
+ return { installed: false, system: 'git-hooks', message: `already installed in ${file}` };
93
+ }
94
+ writeFileSync(file, `${current.trimEnd()}\n${command}\n`);
95
+ chmodSync(file, 0o755);
96
+ return { installed: true, system: 'git-hooks', message: `appended to ${file}` };
97
+ }
98
+ mkdirSync(hooksDir, { recursive: true });
99
+ writeFileSync(file, `#!/bin/sh\n${command}\n`);
100
+ chmodSync(file, 0o755);
101
+ return { installed: true, system: 'git-hooks', message: `created ${file}` };
102
+ }
package/src/prompt.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const RULES_DIR = join(
6
+ dirname(fileURLToPath(import.meta.url)),
7
+ '..', 'skills', 'a11y-lens', 'references',
8
+ );
9
+ const MAX_TOTAL_BYTES = 160_000;
10
+
11
+ export function loadRules() {
12
+ return readdirSync(RULES_DIR)
13
+ .filter((name) => name.endsWith('.md'))
14
+ .sort()
15
+ .map((name) => readFileSync(join(RULES_DIR, name), 'utf8'))
16
+ .join('\n\n---\n\n');
17
+ }
18
+
19
+ function numbered(content) {
20
+ return content
21
+ .split('\n')
22
+ .map((line, i) => `${String(i + 1).padStart(4)}: ${line}`)
23
+ .join('\n');
24
+ }
25
+
26
+ /**
27
+ * Assemble the review prompt. Files over the total budget are dropped
28
+ * (caller already reported per-file skips); we report drops via the return value.
29
+ */
30
+ export function buildPrompt(files) {
31
+ const rules = loadRules();
32
+ const included = [];
33
+ const dropped = [];
34
+ let budget = MAX_TOTAL_BYTES;
35
+
36
+ for (const file of files) {
37
+ const block = [
38
+ `### FILE: ${file.path}`,
39
+ '```',
40
+ numbered(file.content),
41
+ '```',
42
+ file.diff ? `#### Staged diff for ${file.path} (focus your review here)\n\`\`\`diff\n${file.diff}\n\`\`\`` : '',
43
+ ].join('\n');
44
+ const size = Buffer.byteLength(block, 'utf8');
45
+ if (size > budget) {
46
+ dropped.push(file.path);
47
+ continue;
48
+ }
49
+ budget -= size;
50
+ included.push(block);
51
+ }
52
+
53
+ const prompt = `You are a11y-lens, a semantic accessibility reviewer. You review UI code against the rule set below. You are the layer ABOVE static linters: do not report what eslint-plugin-jsx-a11y or axe-core would already catch unless it is listed under "Semantic checks". Focus on meaning, completeness of patterns, and context — the things attribute-presence checks cannot see.
54
+
55
+ ## Rule set
56
+
57
+ ${rules}
58
+
59
+ ## Code under review
60
+
61
+ Each file is shown with line numbers ("NNNN: code"). When a staged diff is provided, findings should concern changed or directly affected code; use the full file only for context.
62
+
63
+ ${included.join('\n\n')}
64
+
65
+ ## Output format
66
+
67
+ Respond with ONLY a JSON array — no prose, no markdown fences. Each finding:
68
+ {
69
+ "file": "path as given above",
70
+ "line": <number from the line-number prefix>,
71
+ "ruleId": "one of: landmarks-headings | images-alt | forms-labels | aria-widgets | keyboard-interaction | focus-management",
72
+ "severity": "error" | "warning",
73
+ "message": "what is wrong and why it matters, one or two sentences",
74
+ "suggestion": "the concrete fix, one sentence or a short code hint"
75
+ }
76
+
77
+ Severity discipline: "error" only for clear violations named as error in the rules (keyboard-dead interactive elements, missing accessible names on icon-only controls, incomplete claimed ARIA patterns, focus not managed on overlays, informative images silenced). Judgment calls are "warning". If the code is clean, respond with [].`;
78
+
79
+ return { prompt, dropped };
80
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,71 @@
1
+ const RESET = '\x1b[0m';
2
+ const RED = '\x1b[31m';
3
+ const YELLOW = '\x1b[33m';
4
+ const DIM = '\x1b[2m';
5
+ const BOLD = '\x1b[1m';
6
+
7
+ const VALID_SEVERITIES = new Set(['error', 'warning']);
8
+
9
+ /** Extract a findings array from agent output, tolerating fences and prose. */
10
+ export function parseFindings(text) {
11
+ const candidates = [];
12
+ const trimmed = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
13
+ candidates.push(trimmed);
14
+ const match = trimmed.match(/\[[\s\S]*\]/);
15
+ if (match) candidates.push(match[0]);
16
+
17
+ for (const candidate of candidates) {
18
+ try {
19
+ const parsed = JSON.parse(candidate);
20
+ if (!Array.isArray(parsed)) continue;
21
+ return {
22
+ findings: parsed.filter(
23
+ (f) => f && typeof f === 'object' && typeof f.message === 'string'
24
+ && VALID_SEVERITIES.has(f.severity),
25
+ ),
26
+ };
27
+ } catch {
28
+ /* try next candidate */
29
+ }
30
+ }
31
+ return { error: 'could not parse agent output as a findings array' };
32
+ }
33
+
34
+ export function printReport(findings, { agentName } = {}) {
35
+ if (findings.length === 0) {
36
+ console.log(`a11y-lens: no findings ${DIM}(reviewed by ${agentName})${RESET}`);
37
+ return;
38
+ }
39
+ const byFile = new Map();
40
+ for (const f of findings) {
41
+ const key = f.file ?? '(unknown file)';
42
+ if (!byFile.has(key)) byFile.set(key, []);
43
+ byFile.get(key).push(f);
44
+ }
45
+ for (const [file, list] of byFile) {
46
+ console.log(`\n${BOLD}${file}${RESET}`);
47
+ list.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
48
+ for (const f of list) {
49
+ const mark = f.severity === 'error' ? `${RED}✖ error${RESET}` : `${YELLOW}⚠ warning${RESET}`;
50
+ const line = f.line ? `${DIM}:${f.line}${RESET}` : '';
51
+ console.log(` ${mark}${line} [${f.ruleId ?? 'general'}] ${f.message}`);
52
+ if (f.suggestion) console.log(` ${DIM}fix: ${f.suggestion}${RESET}`);
53
+ }
54
+ }
55
+ const errors = findings.filter((f) => f.severity === 'error').length;
56
+ const warnings = findings.length - errors;
57
+ console.log(
58
+ `\na11y-lens: ${errors ? RED : ''}${errors} error(s)${RESET}, ` +
59
+ `${warnings ? YELLOW : ''}${warnings} warning(s)${RESET} ` +
60
+ `${DIM}(reviewed by ${agentName})${RESET}`,
61
+ );
62
+ }
63
+
64
+ /** Exit code policy: errors gate; warnings gate only with --strict. */
65
+ export function exitCodeFor(findings, { strict } = {}) {
66
+ const errors = findings.some((f) => f.severity === 'error');
67
+ const warnings = findings.some((f) => f.severity === 'warning');
68
+ if (errors) return 1;
69
+ if (strict && warnings) return 1;
70
+ return 0;
71
+ }
package/src/staged.mjs ADDED
@@ -0,0 +1,90 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ const UI_EXTENSIONS = new Set([
4
+ '.jsx', '.tsx', '.html', '.htm', '.vue', '.svelte', '.astro', '.mdx',
5
+ ]);
6
+ // .js/.ts are included only when their content looks like markup (JSX, template strings with tags).
7
+ const MAYBE_UI_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs']);
8
+
9
+ const MAX_FILE_BYTES = 48_000;
10
+
11
+ function git(args, options = {}) {
12
+ return execFileSync('git', args, { encoding: 'utf8', ...options });
13
+ }
14
+
15
+ function extensionOf(path) {
16
+ const dot = path.lastIndexOf('.');
17
+ return dot === -1 ? '' : path.slice(dot).toLowerCase();
18
+ }
19
+
20
+ function looksLikeUI(content) {
21
+ // JSX/HTML-ish: an opening tag followed by attributes, `/>` or `>`.
22
+ return /<([a-z][\w-]*|[A-Z]\w*)(\s[^<>]*)?\/?>/.test(content);
23
+ }
24
+
25
+ /**
26
+ * Collect staged files that plausibly contain UI markup.
27
+ * Returns [{ path, content, diff, skipped? }]
28
+ */
29
+ export function collectStagedUIFiles() {
30
+ let names;
31
+ try {
32
+ names = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR'])
33
+ .split('\n')
34
+ .filter(Boolean);
35
+ } catch {
36
+ return { files: [], error: 'not a git repository (or git unavailable)' };
37
+ }
38
+
39
+ const files = [];
40
+ for (const path of names) {
41
+ const ext = extensionOf(path);
42
+ const isUI = UI_EXTENSIONS.has(ext);
43
+ const isMaybeUI = MAYBE_UI_EXTENSIONS.has(ext);
44
+ if (!isUI && !isMaybeUI) continue;
45
+
46
+ let content;
47
+ try {
48
+ content = git(['show', `:${path}`], { maxBuffer: 10 * 1024 * 1024 });
49
+ } catch {
50
+ continue; // deleted between index and now, submodule, etc.
51
+ }
52
+ if (isMaybeUI && !looksLikeUI(content)) continue;
53
+ if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
54
+ files.push({ path, skipped: `larger than ${MAX_FILE_BYTES / 1000}KB` });
55
+ continue;
56
+ }
57
+
58
+ let diff = '';
59
+ try {
60
+ diff = git(['diff', '--cached', '--unified=3', '--', path]);
61
+ } catch {
62
+ /* diff is best-effort context */
63
+ }
64
+ files.push({ path, content, diff });
65
+ }
66
+ return { files };
67
+ }
68
+
69
+ /** Read explicit file paths from the working tree (non-staged mode). */
70
+ export function collectPathArgs(paths) {
71
+ const files = [];
72
+ for (const path of paths) {
73
+ let content;
74
+ try {
75
+ content = execFileSync('cat', [path], {
76
+ encoding: 'utf8',
77
+ maxBuffer: 10 * 1024 * 1024,
78
+ });
79
+ } catch {
80
+ files.push({ path, skipped: 'unreadable' });
81
+ continue;
82
+ }
83
+ if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
84
+ files.push({ path, skipped: `larger than ${MAX_FILE_BYTES / 1000}KB` });
85
+ continue;
86
+ }
87
+ files.push({ path, content, diff: '' });
88
+ }
89
+ return { files };
90
+ }
@@ -0,0 +1,18 @@
1
+ <!-- a11y-lens:begin -->
2
+ ## Accessibility rules (a11y-lens)
3
+
4
+ This project uses [a11y-lens](https://github.com/jo-duchan/a11y-lens) for semantic accessibility review. Staged UI changes are checked at commit time; findings with `error` severity block the commit.
5
+
6
+ When writing or modifying UI code (JSX/TSX/HTML/Vue/Svelte), apply the rule set in `node_modules/@a11y-lens/cli/skills/a11y-lens/references/` — read the relevant category before implementing:
7
+
8
+ - `01-landmarks-headings.md` — document outline, one h1, no level skips, labelled landmarks
9
+ - `02-images-alt.md` — alt text that describes function in context; icon-only controls need accessible names
10
+ - `03-forms-labels.md` — placeholder is not a label; errors tied via `aria-describedby`; name matches visible label
11
+ - `04-aria-widgets.md` — prefer native elements; custom widgets implement the complete WAI-ARIA APG pattern
12
+ - `05-keyboard-interaction.md` — full APG key sets, no hover-only affordances, no keyboard traps
13
+ - `06-focus-management.md` — overlays move and return focus; async results are announced via live regions
14
+
15
+ Tip: agents with skills support get richer guidance via `npx skills add jo-duchan/a11y-lens`.
16
+
17
+ Self-check against these categories before finishing any UI task — it is cheaper than failing the pre-commit gate.
18
+ <!-- a11y-lens:end -->