@a11y-lens/cli 0.5.0 → 0.6.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/README.md CHANGED
@@ -43,6 +43,31 @@ a11y-lens is an AI reviewer, not a deterministic linter. The same files reviewed
43
43
 
44
44
  This is deliberate: only clear `error`-severity violations gate and warnings never block, precisely because AI output varies run to run. (This note belongs here, in the tool's own README — not in the `AGENTS.md` rules block that `init` injects into a consuming project, which is reserved for the accessibility rules themselves.)
45
45
 
46
+ ## Levels: what gets checked, and what gets shown
47
+
48
+ Not every project needs every check. Each check in the rule set is tagged by who it helps:
49
+
50
+ - **`core`**: checks that help everyone. They cover accessible names on icon-only controls, labels that are not placeholders, names that match the visible label, autocomplete on identity fields, full keyboard operation, and focus that is moved, returned and never lost.
51
+ - **`full`**: the core checks plus the ones that are specific to screen readers. They cover headings and landmarks, alt text, complete ARIA patterns, errors tied to their fields, and live-region announcements for async results and loading.
52
+
53
+ A `full` review is a superset of a `core` one, so code written to `full` passes `core`.
54
+
55
+ Set the level for the whole team in the repository:
56
+
57
+ ```json
58
+ // a11y-lens.config.json at the repository root (or the "a11y-lens" field of the root package.json)
59
+ { "level": "core", "report": "errors" }
60
+ ```
61
+
62
+ | Setting | Values | Default |
63
+ |---|---|---|
64
+ | `level` | `core`: only `[core]` checks are sent to the agent. `full`: all checks. | `full` |
65
+ | `report` | `errors`: print errors, and say how many warnings were hidden. `all`: print everything. | `all` |
66
+
67
+ The defaults run the same checks and print the same output as earlier versions, so upgrading changes nothing a project would notice until it opts in. The rule text itself now carries the tags. `A11Y_LENS_LEVEL` and `A11Y_LENS_REPORT` override the file for one run. `--strict` always prints warnings, because it makes them fail the commit. An unknown value is warned about and replaced by the default.
68
+
69
+ These levels are not WCAG's A/AA/AAA. They sort checks by who benefits, not by conformance level.
70
+
46
71
  ## Install
47
72
 
48
73
  a11y-lens has two layers — install either or both:
@@ -89,6 +114,8 @@ Escape hatches: `A11Y_LENS_SKIP=1 git commit …` or `git commit --no-verify`.
89
114
  | `A11Y_LENS_AGENT` | same as `--agent` |
90
115
  | `A11Y_LENS_MODEL` | model passed to `claude` |
91
116
  | `A11Y_LENS_TIMEOUT_MS` | agent timeout in milliseconds (default `180000`) |
117
+ | `A11Y_LENS_LEVEL` | `core` or `full` for this run, over the project's setting |
118
+ | `A11Y_LENS_REPORT` | `errors` or `all` for this run, over the project's setting |
92
119
  | `A11Y_LENS_SKIP=1` | skip the check entirely |
93
120
 
94
121
  ## Skipped checks
package/bin/a11y-lens.mjs CHANGED
@@ -8,6 +8,7 @@ import { detectAgent, runAgent } from '../src/agent.mjs';
8
8
  import { buildPrompt } from '../src/prompt.mjs';
9
9
  import { parseFindings, printReport, exitCodeFor } from '../src/report.mjs';
10
10
  import { recordPending, listPending, clearReviewed, clearEntry, contentOf, pendingDir } from '../src/pending.mjs';
11
+ import { loadConfig } from '../src/config.mjs';
11
12
 
12
13
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
13
14
 
@@ -33,6 +34,16 @@ Environment:
33
34
  A11Y_LENS_MODEL model override passed to claude (optional)
34
35
  A11Y_LENS_SKIP=1 skip the check entirely (escape hatch)
35
36
  A11Y_LENS_TIMEOUT_MS agent timeout in ms (default 180000)
37
+ A11Y_LENS_LEVEL=core|full override the project's level (see below)
38
+ A11Y_LENS_REPORT=errors|all override the project's report setting
39
+
40
+ Project settings, in a11y-lens.config.json at the repository root (or the
41
+ "a11y-lens" field of package.json):
42
+ { "level": "core", "report": "errors" }
43
+ level core = checks that help everyone (names, keyboard, focus);
44
+ full = those plus screen-reader-specific checks (default)
45
+ report errors = print errors only, and count the hidden warnings;
46
+ all = print everything (default). --strict always prints all.
36
47
 
37
48
  Infrastructure failures (no agent CLI, no network, agent error) never block:
38
49
  a11y-lens warns and exits 0. Only accessibility findings gate. When a staged
@@ -100,6 +111,12 @@ function warnPending() {
100
111
  }
101
112
  }
102
113
 
114
+ /** Settings for this run; `--strict` gates on warnings, so it always shows them. */
115
+ function runSettings(args) {
116
+ const config = loadConfig();
117
+ return { level: config.level, report: args.flags.strict ? 'all' : config.report };
118
+ }
119
+
103
120
  function commandCheck(args) {
104
121
  if (args.flags.pending) return commandCheckPending(args);
105
122
  warnPending();
@@ -122,7 +139,8 @@ function commandCheck(args) {
122
139
  const detection = detectAgent(args.flags.agent);
123
140
  if (detection.error) softFail(detection.error);
124
141
 
125
- const { prompt, dropped } = buildPrompt(reviewable);
142
+ const settings = runSettings(args);
143
+ const { prompt, dropped } = buildPrompt(reviewable, { level: settings.level });
126
144
  for (const path of dropped) {
127
145
  console.warn(`a11y-lens: dropped ${path} (prompt size budget exceeded)`);
128
146
  }
@@ -149,7 +167,7 @@ function commandCheck(args) {
149
167
  // typecheck aborted in parallel — is answered.
150
168
  if (staged) clearReviewed(included.map((f) => f.path));
151
169
 
152
- printReport(parsed.findings, { agentName: detection.name });
170
+ printReport(parsed.findings, { agentName: detection.name, report: settings.report });
153
171
  process.exit(exitCodeFor(parsed.findings, { strict: args.flags.strict }));
154
172
  }
155
173
 
@@ -170,6 +188,7 @@ function commandCheckPending(args) {
170
188
 
171
189
  const detection = detectAgent(args.flags.agent);
172
190
  if (detection.error) softFail(`${detection.error}; ${entries.length} file(s) remain pending`);
191
+ const settings = runSettings(args);
173
192
 
174
193
  const batches = new Map();
175
194
  for (const item of [...entries].sort((a, b) => a.entry.at.localeCompare(b.entry.at))) {
@@ -193,7 +212,7 @@ function commandCheckPending(args) {
193
212
  }
194
213
  if (files.length === 0) continue;
195
214
 
196
- const { prompt, dropped } = buildPrompt(files);
215
+ const { prompt, dropped } = buildPrompt(files, { level: settings.level });
197
216
  const included = files.filter((f) => !dropped.includes(f.path));
198
217
  if (included.length === 0) {
199
218
  console.warn(`a11y-lens: every file skipped at ${batch[0].entry.at} exceeds the prompt budget — they stay pending.`);
@@ -221,7 +240,7 @@ function commandCheckPending(args) {
221
240
  }
222
241
  }
223
242
 
224
- printReport(findings, { agentName: detection.name });
243
+ printReport(findings, { agentName: detection.name, report: settings.report });
225
244
  if (remaining) console.warn(`a11y-lens: ${remaining} file(s) are still pending — run ${RECHECK} again.`);
226
245
  process.exit(exitCodeFor(findings, { strict: args.flags.strict }));
227
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11y-lens/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,11 +20,13 @@ Before writing or reviewing UI code, read the reference file for each category t
20
20
  | Click/hover handlers, shortcuts, drag, carousels | [references/05-keyboard-interaction.md](references/05-keyboard-interaction.md) |
21
21
  | Overlays, route changes, async results, toasts, loading | [references/06-focus-management.md](references/06-focus-management.md) |
22
22
 
23
- Core stances that apply everywhere:
23
+ **Levels.** Every check is tagged `[core]` or `[full]`. `[core]` checks help everyone — accessible names, keyboard operation, focus that is never lost. `[full]` checks are screen-reader specific — headings and landmarks, alt text, ARIA patterns, live-region announcements. If the project sets `"level": "core"` (in `a11y-lens.config.json`, or the `"a11y-lens"` field of `package.json`), apply only the `[core]` checks and `[core]` examples; otherwise apply all of them.
24
+
25
+ Stances at every level (items that belong only to `full` are marked):
24
26
 
25
27
  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
+ 2. **Placeholder is not a label. Hover is not a keyboard path.** At `full`, also: **CSS state is not ARIA state.**
29
+ 3. Severity discipline: `error` = clear violations (keyboard-dead interactive elements, unnamed icon-only controls, unmanaged overlay focus; at `full`, also incomplete claimed ARIA patterns and silenced informative images). Judgment calls are `warning`.
28
30
 
29
31
  ## Commit-time gate (CLI)
30
32
 
@@ -13,16 +13,18 @@ sources: [WCAG 1.3.1, WCAG 2.4.1, WCAG 2.4.6, axe-core region/heading rules]
13
13
 
14
14
  ## Semantic checks (what you review)
15
15
 
16
- 1. **Heading hierarchy must describe the document outline, not the visual design.**
16
+ 1. `[full]` **Heading hierarchy must describe the document outline, not the visual design.**
17
17
  - Exactly one `h1` per page/view; levels must not skip downward (h1 → h3 with no h2 is an `error`).
18
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
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`).
20
+ 2. `[full]` **`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. `[full]` **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. `[full]` **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
23
 
24
24
  ## Examples
25
25
 
26
+ ### `[full]` Outline and landmark
27
+
26
28
  Bad — outline skips and decorative section:
27
29
 
28
30
  ```jsx
@@ -13,27 +13,41 @@ sources: [WCAG 1.1.1, eslint-plugin-jsx-a11y alt-text/img-redundant-alt, axe-cor
13
13
 
14
14
  ## Semantic checks (what you review)
15
15
 
16
- 1. **Does the `alt` actually describe the image's function or content in context?**
16
+ 1. `[full]` **Does the `alt` actually describe the image's function or content in context?**
17
17
  - `alt="banner"`, `alt="icon"`, `alt="img_03"`, filename-derived alt → `error`. These pass static linters and fail humans.
18
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.
19
+ 2. `[full]` **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. `[full]` **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. `[core]` **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. `[full]` **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. `[full]` **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
24
 
25
25
  ## Examples
26
26
 
27
+ ### `[core]` Icon-only button
28
+
29
+ Bad — no name:
30
+
31
+ ```jsx
32
+ <button><CloseIcon /></button>
33
+ ```
34
+
35
+ Good:
36
+
37
+ ```jsx
38
+ <button aria-label="닫기"><CloseIcon aria-hidden="true" /></button>
39
+ ```
40
+
41
+ ### `[full]` Image inside a link
42
+
27
43
  Bad — passes static linting, fails semantically:
28
44
 
29
45
  ```jsx
30
46
  <a href="/download"><img src="/hero-phone.png" alt="phone image" /></a>
31
- <button><CloseIcon /></button>
32
47
  ```
33
48
 
34
49
  Good:
35
50
 
36
51
  ```jsx
37
52
  <a href="/download"><img src="/hero-phone.png" alt="에이닷 전화 앱 다운로드" /></a>
38
- <button aria-label="닫기"><CloseIcon aria-hidden="true" /></button>
39
53
  ```
@@ -12,27 +12,43 @@ sources: [WCAG 1.3.1, WCAG 3.3.1, WCAG 3.3.2, WCAG 4.1.2, eslint-plugin-jsx-a11y
12
12
 
13
13
  ## Semantic checks (what you review)
14
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.
15
+ 1. `[core]` **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. `[core]` **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. `[full]` **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. `[full]` **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. `[full]` **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. `[core]` **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
21
 
22
22
  ## Examples
23
23
 
24
+ ### `[core]` Label and autocomplete
25
+
24
26
  Bad:
25
27
 
26
28
  ```jsx
27
29
  <input placeholder="이메일" value={email} onChange={...} />
28
- {error && <p className="error">이메일 형식이 아닙니다</p>}
29
30
  ```
30
31
 
31
32
  Good:
32
33
 
33
34
  ```jsx
34
35
  <label htmlFor="email">이메일</label>
35
- <input id="email" type="email" autoComplete="email" value={email}
36
+ <input id="email" type="email" autoComplete="email" value={email} onChange={...} />
37
+ ```
38
+
39
+ ### `[full]` Error tied to its field
40
+
41
+ Bad:
42
+
43
+ ```jsx
44
+ <input id="email" type="email" value={email} onChange={...} />
45
+ {error && <p className="error">이메일 형식이 아닙니다</p>}
46
+ ```
47
+
48
+ Good:
49
+
50
+ ```jsx
51
+ <input id="email" type="email" value={email} onChange={...}
36
52
  aria-invalid={!!error} aria-describedby={error ? "email-error" : undefined} />
37
53
  {error && <p id="email-error" role="alert">이메일 형식이 아닙니다</p>}
38
54
  ```
@@ -17,20 +17,22 @@ sources: [W3C WAI-ARIA APG patterns, WCAG 4.1.2, eslint-plugin-jsx-a11y role-* r
17
17
 
18
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
19
 
20
- 1. **Combobox / select-like** (custom dropdown, autocomplete):
20
+ 1. `[full]` **Combobox / select-like** (custom dropdown, autocomplete):
21
21
  - Trigger: `role="combobox"`, `aria-expanded` toggling, `aria-controls` → listbox id, `aria-haspopup="listbox"` where appropriate.
22
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
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.
24
+ 2. `[full]` **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. `[full]` **Tabs**: `role="tablist"` / `tab` / `tabpanel`, `aria-selected` on the active tab, `aria-controls` ↔ `aria-labelledby` linkage between tab and panel.
26
+ 4. `[full]` **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. `[full]` **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. `[full]` **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. `[full]` **`aria-hidden` on focusable content** or on an element containing focusable children → `error` (focusable but invisible to AT).
30
+ 8. `[full]` **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
31
 
32
32
  ## Examples
33
33
 
34
+ ### `[full]` Combobox
35
+
34
36
  Bad — half a combobox:
35
37
 
36
38
  ```jsx
@@ -13,19 +13,21 @@ sources: [WCAG 2.1.1, WCAG 2.1.2, W3C WAI-ARIA APG keyboard patterns, eslint-plu
13
13
 
14
14
  ## Semantic checks (what you review)
15
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:**
16
+ 1. `[core]` **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. `[core]` **Widget key sets must match the APG pattern being imitated:**
18
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
19
  - Tabs: `ArrowLeft`/`ArrowRight` between tabs (roving tabindex), `Tab` leaves the tablist into the panel.
20
20
  - Dialog: `Escape` closes; `Tab` cycles inside (focus trap — see rules/06).
21
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).
22
+ 3. `[core]` **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. `[core]` **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. `[core]` **Scroll/drag-only interactions** (carousels, sliders, drag-to-reorder) need button or keyboard alternatives → `warning`.
25
+ 6. `[core]` **Global shortcuts on printable keys** without a modifier or an off-switch collide with AT and text input → `warning` (WCAG 2.1.4).
26
26
 
27
27
  ## Examples
28
28
 
29
+ ### `[core]` Hover-only reveal
30
+
29
31
  Bad — mouse-only reveal:
30
32
 
31
33
  ```jsx
@@ -12,18 +12,20 @@ sources: [WCAG 2.4.3, WCAG 2.4.7, WCAG 3.2.1, W3C WAI-ARIA APG dialog/disclosure
12
12
 
13
13
  ## Semantic checks (what you review)
14
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`.
15
+ 1. `[core]` **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. `[core]` **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. `[core]` **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. `[full]` **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. `[full]` **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. `[full]` **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. `[core]` **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. `[core]` **`scrollIntoView`/anchor jumps without focus.** Scrolling a target into view visually while focus stays behind creates divergence between sighted and keyboard experience → `warning`.
23
23
 
24
24
  ## Examples
25
25
 
26
- Bad modal with no focus contract:
26
+ ### `[core]` Modal focus contract
27
+
28
+ Bad — focus goes nowhere on open, and nowhere on close:
27
29
 
28
30
  ```jsx
29
31
  {isOpen && <div className="modal"><h2>요금제 변경</h2>…</div>}
@@ -38,9 +40,19 @@ useEffect(() => {
38
40
  }, [isOpen]);
39
41
 
40
42
  {isOpen && (
41
- <div ref={dialogRef} role="dialog" aria-modal="true"
42
- aria-labelledby="plan-title" tabIndex={-1}>
43
- <h2 id="plan-title">요금제 변경</h2>…
43
+ <div ref={dialogRef} tabIndex={-1} aria-label="요금제 변경">
44
+ <h2>요금제 변경</h2>…
44
45
  </div>
45
46
  )}
46
47
  ```
48
+
49
+ ### `[full]` Modal semantics
50
+
51
+ The same modal, announced as one: `role="dialog"`, `aria-modal="true"` and a label (rules/04 §2).
52
+
53
+ ```jsx
54
+ <div ref={dialogRef} role="dialog" aria-modal="true"
55
+ aria-labelledby="plan-title" tabIndex={-1}>
56
+ <h2 id="plan-title">요금제 변경</h2>…
57
+ </div>
58
+ ```
package/src/config.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // Project settings: which checks run (`level`) and which findings are shown (`report`).
2
+ //
3
+ // Read from the repository, because what a team checks is a team decision: `a11y-lens.config.json`
4
+ // at the git top level, else the `"a11y-lens"` field of its `package.json`. Environment variables
5
+ // override either, for one person trying something out. A bad value is warned about and replaced by
6
+ // the default — never an error, since a commit is never blocked by infrastructure.
7
+ import { execFileSync } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ export const LEVELS = ['core', 'full'];
12
+ export const REPORTS = ['errors', 'all'];
13
+ // The defaults are what every version before 0.6.0 did, so upgrading changes nothing by itself.
14
+ export const DEFAULTS = { level: 'full', report: 'all' };
15
+
16
+ function repoRoot(cwd) {
17
+ try {
18
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
19
+ } catch {
20
+ return cwd;
21
+ }
22
+ }
23
+
24
+ /** `{ value }`, `{ missing: true }`, or `{ error }` — a file that is there but unreadable is worth a warning. */
25
+ function readJson(path) {
26
+ let text;
27
+ try {
28
+ text = readFileSync(path, 'utf8');
29
+ } catch {
30
+ return { missing: true };
31
+ }
32
+ try {
33
+ return { value: JSON.parse(text) };
34
+ } catch (err) {
35
+ return { error: String(err?.message ?? err) };
36
+ }
37
+ }
38
+
39
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
40
+
41
+ export function loadConfig({ cwd = process.cwd(), env = process.env, warn = (m) => console.warn(m) } = {}) {
42
+ const root = repoRoot(cwd);
43
+ let settings = {};
44
+ let source = 'defaults';
45
+
46
+ const file = join(root, 'a11y-lens.config.json');
47
+ const fromFile = readJson(file);
48
+ if (fromFile.error) {
49
+ warn(`a11y-lens: ignoring ${file} (${fromFile.error})`);
50
+ } else if (!fromFile.missing) {
51
+ if (isPlainObject(fromFile.value)) {
52
+ settings = fromFile.value;
53
+ source = 'a11y-lens.config.json';
54
+ } else {
55
+ warn(`a11y-lens: ignoring ${file} (expected an object)`);
56
+ }
57
+ } else {
58
+ const pkg = readJson(join(root, 'package.json'));
59
+ const field = pkg.value?.['a11y-lens'];
60
+ if (field !== undefined) {
61
+ if (isPlainObject(field)) {
62
+ settings = field;
63
+ source = 'package.json';
64
+ } else {
65
+ warn('a11y-lens: ignoring the "a11y-lens" field in package.json (expected an object)');
66
+ }
67
+ }
68
+ }
69
+
70
+ const pick = (key, allowed, envName) => {
71
+ for (const [value, from] of [[env[envName], envName], [settings[key], source]]) {
72
+ if (value === undefined || value === '') continue;
73
+ if (allowed.includes(value)) return value;
74
+ warn(`a11y-lens: ignoring ${key} "${value}" from ${from} (expected ${allowed.join(' | ')})`);
75
+ }
76
+ return DEFAULTS[key];
77
+ };
78
+
79
+ return {
80
+ level: pick('level', LEVELS, 'A11Y_LENS_LEVEL'),
81
+ report: pick('report', REPORTS, 'A11Y_LENS_REPORT'),
82
+ };
83
+ }
package/src/prompt.mjs CHANGED
@@ -8,12 +8,51 @@ const RULES_DIR = join(
8
8
  );
9
9
  const MAX_TOTAL_BYTES = 160_000;
10
10
 
11
- export function loadRules() {
12
- return readdirSync(RULES_DIR)
11
+ // Named per level so the prompt never cites a check the level left out — an agent told that
12
+ // "incomplete claimed ARIA patterns" are errors reviews ARIA patterns whether or not they are listed.
13
+ const SEVERITY_EXAMPLES = {
14
+ core: 'keyboard-dead interactive elements, missing accessible names on icon-only controls, focus not managed on overlays',
15
+ full: 'keyboard-dead interactive elements, missing accessible names on icon-only controls, incomplete claimed ARIA patterns, focus not managed on overlays, informative images silenced',
16
+ };
17
+
18
+ const LEVEL_TAG = /^`\[(core|full)\]`/;
19
+
20
+ /**
21
+ * One rule file cut down to a level. `full` is the file as written. `core` keeps the numbered
22
+ * semantic checks and the example subsections tagged `[core]`, and drops a file with none — so what
23
+ * the agent is shown is only what it is asked to check. Numbers are kept, because other files refer
24
+ * to checks by number ("rules/03 §2").
25
+ */
26
+ export function filterRule(text, level) {
27
+ if (level === 'full') return text;
28
+ const sections = text.split(/(?=^## )/m);
29
+ let kept = 0;
30
+ const out = sections.map((section) => {
31
+ if (section.startsWith('## Semantic checks')) {
32
+ const [intro, ...items] = section.split(/(?=^\d+\. )/m);
33
+ const chosen = items.filter((item) => item.replace(/^\d+\. /, '').match(LEVEL_TAG)?.[1] === level);
34
+ kept += chosen.length;
35
+ return intro + chosen.join('');
36
+ }
37
+ if (section.startsWith('## Examples')) {
38
+ const [intro, ...subsections] = section.split(/(?=^### )/m);
39
+ const chosen = subsections.filter((sub) => sub.replace(/^### /, '').match(LEVEL_TAG)?.[1] === level);
40
+ return chosen.length ? intro + chosen.join('') : '';
41
+ }
42
+ return section;
43
+ });
44
+ return kept === 0 ? null : out.join('');
45
+ }
46
+
47
+ /** The rule files at `level`, joined, and the ids of the categories that survived. */
48
+ export function loadRules(level = 'full') {
49
+ const files = readdirSync(RULES_DIR)
13
50
  .filter((name) => name.endsWith('.md'))
14
51
  .sort()
15
- .map((name) => readFileSync(join(RULES_DIR, name), 'utf8'))
16
- .join('\n\n---\n\n');
52
+ .map((name) => filterRule(readFileSync(join(RULES_DIR, name), 'utf8'), level))
53
+ .filter((text) => text !== null);
54
+ const ids = files.map((text) => text.match(/^id: (\S+)$/m)?.[1]).filter(Boolean);
55
+ return { text: files.join('\n\n---\n\n'), ids };
17
56
  }
18
57
 
19
58
  function numbered(content) {
@@ -27,8 +66,8 @@ function numbered(content) {
27
66
  * Assemble the review prompt. Files over the total budget are dropped
28
67
  * (caller already reported per-file skips); we report drops via the return value.
29
68
  */
30
- export function buildPrompt(files) {
31
- const rules = loadRules();
69
+ export function buildPrompt(files, { level = 'full' } = {}) {
70
+ const { text: rules, ids } = loadRules(level);
32
71
  const included = [];
33
72
  const dropped = [];
34
73
  let budget = MAX_TOTAL_BYTES;
@@ -68,13 +107,13 @@ Respond with ONLY a JSON array — no prose, no markdown fences. Each finding:
68
107
  {
69
108
  "file": "path as given above",
70
109
  "line": <number from the line-number prefix>,
71
- "ruleId": "one of: landmarks-headings | images-alt | forms-labels | aria-widgets | keyboard-interaction | focus-management",
110
+ "ruleId": "one of: ${ids.join(' | ')}",
72
111
  "severity": "error" | "warning",
73
112
  "message": "what is wrong and why it matters, one or two sentences",
74
113
  "suggestion": "the concrete fix, one sentence or a short code hint"
75
114
  }
76
115
 
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 [].`;
116
+ Severity discipline: "error" only for clear violations named as error in the rules (${SEVERITY_EXAMPLES[level]}). Judgment calls are "warning". ${level === 'core' ? 'Check only the rules above; this project has chosen not to review the others, so do not report them. ' : ''}If the code is clean, respond with [].`;
78
117
 
79
118
  return { prompt, dropped };
80
119
  }
package/src/report.mjs CHANGED
@@ -31,9 +31,20 @@ export function parseFindings(text) {
31
31
  return { error: 'could not parse agent output as a findings array' };
32
32
  }
33
33
 
34
- export function printReport(findings, { agentName } = {}) {
34
+ /**
35
+ * `report: 'errors'` shows errors only and says how many warnings it held back — the count, so a
36
+ * clean-looking run is not mistaken for a run that found nothing.
37
+ */
38
+ export function printReport(allFindings, { agentName, report = 'all' } = {}) {
39
+ const findings = report === 'errors' ? allFindings.filter((f) => f.severity === 'error') : allFindings;
40
+ const hidden = allFindings.length - findings.length;
41
+ // On the last line, beside the totals it qualifies: printed above the report, the count was
42
+ // followed by a footer saying "0 warning(s)" — a clean-looking run again.
43
+ const hiddenNote = hidden ? `, ${YELLOW}${hidden} warning(s) hidden${RESET} ${DIM}(report: errors)${RESET}` : '';
35
44
  if (findings.length === 0) {
36
- console.log(`a11y-lens: no findings ${DIM}(reviewed by ${agentName})${RESET}`);
45
+ console.log(hidden
46
+ ? `a11y-lens: no errors${hiddenNote} ${DIM}(reviewed by ${agentName})${RESET}`
47
+ : `a11y-lens: no findings ${DIM}(reviewed by ${agentName})${RESET}`);
37
48
  return;
38
49
  }
39
50
  const byFile = new Map();
@@ -56,7 +67,7 @@ export function printReport(findings, { agentName } = {}) {
56
67
  const warnings = findings.length - errors;
57
68
  console.log(
58
69
  `\na11y-lens: ${errors ? RED : ''}${errors} error(s)${RESET}, ` +
59
- `${warnings ? YELLOW : ''}${warnings} warning(s)${RESET} ` +
70
+ (hidden ? `${hiddenNote.slice(2)} ` : `${warnings ? YELLOW : ''}${warnings} warning(s)${RESET} `) +
60
71
  `${DIM}(reviewed by ${agentName})${RESET}`,
61
72
  );
62
73
  }
@@ -12,6 +12,8 @@ When writing or modifying UI code (JSX/TSX/HTML/Vue/Svelte), apply the rule set
12
12
  - `05-keyboard-interaction.md` — full APG key sets, no hover-only affordances, no keyboard traps
13
13
  - `06-focus-management.md` — overlays move and return focus; async results are announced via live regions
14
14
 
15
+ Each check is tagged `[core]` or `[full]`. If this project's `a11y-lens.config.json` (or the `"a11y-lens"` field of `package.json`) sets `"level": "core"`, apply only the `[core]` checks — the commit-time review checks nothing else.
16
+
15
17
  Tip: agents with skills support get richer guidance via `npx skills add jo-duchan/a11y-lens`.
16
18
 
17
19
  Self-check against these categories before finishing any UI task — it is cheaper than failing the pre-commit gate.