a11y-loop 0.1.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.
Files changed (36) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +409 -0
  3. package/THIRD-PARTY-NOTICES.md +32 -0
  4. package/package.json +51 -0
  5. package/skill/a11y-loop/SKILL.md +332 -0
  6. package/skill/a11y-loop/evals/evals.json +168 -0
  7. package/skill/a11y-loop/evals/trigger-evals.json +20 -0
  8. package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
  9. package/skill/a11y-loop/references/apg-patterns.md +264 -0
  10. package/skill/a11y-loop/references/manual-testing.md +224 -0
  11. package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
  12. package/src/cli.js +207 -0
  13. package/src/commands/audit.js +125 -0
  14. package/src/commands/contrast.js +141 -0
  15. package/src/commands/diff.js +65 -0
  16. package/src/lib/axe-runner.js +400 -0
  17. package/src/lib/browser-utils.js +221 -0
  18. package/src/lib/checks/dialog.js +341 -0
  19. package/src/lib/checks/div-button.js +87 -0
  20. package/src/lib/checks/focus-visible.js +296 -0
  21. package/src/lib/checks/keyboard.js +235 -0
  22. package/src/lib/checks/link-text.js +83 -0
  23. package/src/lib/checks/reduced-motion.js +139 -0
  24. package/src/lib/checks/reflow.js +101 -0
  25. package/src/lib/checks/target-size.js +128 -0
  26. package/src/lib/contrast-math.js +189 -0
  27. package/src/lib/diff.js +118 -0
  28. package/src/lib/finding.js +164 -0
  29. package/src/lib/fingerprint.js +0 -0
  30. package/src/lib/format/checklist.js +281 -0
  31. package/src/lib/format/human.js +175 -0
  32. package/src/lib/format/json.js +139 -0
  33. package/src/lib/format/sarif.js +111 -0
  34. package/src/lib/serve.js +189 -0
  35. package/src/lib/suggest-color.js +169 -0
  36. package/src/lib/wcag-map.js +271 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `a11y-loop audit` — run the browser audit and emit the report.
3
+ *
4
+ * Exit codes: 0 no violations · 1 violations found · 2 tool error.
5
+ */
6
+
7
+ import { writeFile, readFile } from 'node:fs/promises';
8
+ import { resolve, isAbsolute } from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ import { runAudit, ToolError, DEFAULT_VIEWPORT } from '../lib/axe-runner.js';
12
+ import { axeCoreVersion } from '../lib/wcag-map.js';
13
+ import { buildReport, serializeReport } from '../lib/format/json.js';
14
+ import { formatHuman } from '../lib/format/human.js';
15
+ import { serializeSarif } from '../lib/format/sarif.js';
16
+ import { buildChecklist } from '../lib/format/checklist.js';
17
+
18
+ export const TOOL_NAME = 'a11y-loop';
19
+
20
+ /** Read our own version rather than hardcoding it in two places. */
21
+ async function toolVersion() {
22
+ try {
23
+ const pkgUrl = new URL('../../package.json', import.meta.url);
24
+ return JSON.parse(await readFile(pkgUrl, 'utf8')).version;
25
+ } catch {
26
+ return '0.0.0';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load an --interact module: `export const states = { name: async (page) => {} }`.
32
+ * @param {string} path
33
+ */
34
+ export async function loadStates(path) {
35
+ const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path);
36
+ let module;
37
+ try {
38
+ module = await import(pathToFileURL(absolute).href);
39
+ } catch (error) {
40
+ throw new ToolError(`Could not load --interact module ${absolute}: ${error.message}`, {
41
+ hint:
42
+ 'The module must be ESM (.mjs, or .js in a "type":"module" package) and export:\n\n' +
43
+ ' export const states = {\n' +
44
+ ' "modal-open": async (page) => { await page.click("#open-dialog"); },\n' +
45
+ ' };',
46
+ });
47
+ }
48
+ const states = module.states ?? module.default?.states ?? module.default;
49
+ if (!states || typeof states !== 'object') {
50
+ throw new ToolError(`--interact module ${absolute} does not export "states".`, {
51
+ hint: 'Expected: export const states = { "state-name": async (page) => { … } };',
52
+ });
53
+ }
54
+ for (const [name, fn] of Object.entries(states)) {
55
+ if (typeof fn !== 'function') {
56
+ throw new ToolError(`--interact state "${name}" is not a function.`);
57
+ }
58
+ }
59
+ return states;
60
+ }
61
+
62
+ /**
63
+ * @param {object} input
64
+ * @param {{type:'url'|'file'|'html', value:string}} input.target
65
+ * @param {object} input.flags parsed CLI flags
66
+ * @param {{write:(s:string)=>void, writeError:(s:string)=>void}} io
67
+ * @returns {Promise<number>} process exit code
68
+ */
69
+ export async function runAuditCommand({ target, flags }, io) {
70
+ const states = flags.interact ? await loadStates(flags.interact) : {};
71
+
72
+ const result = await runAudit({
73
+ target,
74
+ options: {
75
+ headed: Boolean(flags.headed),
76
+ bestPractice: flags.bestPractice !== false,
77
+ states,
78
+ },
79
+ });
80
+
81
+ const manualChecklist = buildChecklist({
82
+ facts: result.facts,
83
+ incompleteRuleIds: result.incompleteRuleIds,
84
+ statesRun: result.statesRun,
85
+ });
86
+
87
+ const report = buildReport({
88
+ tool: {
89
+ name: TOOL_NAME,
90
+ version: await toolVersion(),
91
+ axeCoreVersion: axeCoreVersion(),
92
+ browser: 'Chromium',
93
+ browserVersion: result.browserVersion,
94
+ userAgent: result.userAgent,
95
+ viewport: DEFAULT_VIEWPORT,
96
+ timestamp: new Date().toISOString(),
97
+ passesRun: result.passesRun,
98
+ statesRun: result.statesRun,
99
+ },
100
+ target: { ...target, servedAt: result.url },
101
+ findings: result.findings,
102
+ manualChecklist,
103
+ facts: result.facts,
104
+ });
105
+
106
+ const json = serializeReport(report);
107
+
108
+ if (flags.out) await writeFile(resolve(flags.out), json, 'utf8');
109
+ if (flags.sarif) await writeFile(resolve(flags.sarif), serializeSarif(report), 'utf8');
110
+
111
+ if (flags.json) {
112
+ io.write(json);
113
+ } else {
114
+ io.write(formatHuman(report, { quiet: Boolean(flags.quiet) }));
115
+ }
116
+
117
+ if (flags.out && !flags.json && !flags.quiet) {
118
+ io.writeError(`JSON report written to ${resolve(flags.out)}\n`);
119
+ }
120
+ if (flags.sarif && !flags.json && !flags.quiet) {
121
+ io.writeError(`SARIF written to ${resolve(flags.sarif)}\n`);
122
+ }
123
+
124
+ return report.summary.violations > 0 ? 1 : 0;
125
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `a11y-loop contrast <fg> <bg>` — WCAG 2.x contrast check, with fixes.
3
+ *
4
+ * Exit codes: 0 the pair meets the requested threshold · 1 it does not ·
5
+ * 2 the colours could not be parsed.
6
+ */
7
+
8
+ import { checkContrast, ColorParseError, THRESHOLDS } from '../lib/contrast-math.js';
9
+ import { suggestColors } from '../lib/suggest-color.js';
10
+ import { ToolError } from '../lib/axe-runner.js';
11
+
12
+ /** Build the machine-readable result, shared by --json and the human output. */
13
+ export function contrastResult(fg, bg, { large = false, ui = false, fix = false } = {}) {
14
+ const check = checkContrast(fg, bg, { large, ui });
15
+ const required = check.thresholds.AA;
16
+
17
+ const result = {
18
+ foreground: { input: fg, hex: check.fg.hex, alpha: check.fg.alpha },
19
+ background: { input: bg, hex: check.bg.hex, alpha: check.bg.alpha },
20
+ ratio: check.ratioTruncated,
21
+ ratioDisplay: check.ratioDisplay,
22
+ context: check.context,
23
+ wcag: {
24
+ sc: check.sc.AA,
25
+ name: ui ? 'Non-text Contrast' : 'Contrast (Minimum)',
26
+ level: 'AA',
27
+ required,
28
+ },
29
+ thresholds: {
30
+ normalText: THRESHOLDS.normal,
31
+ largeText: THRESHOLDS.large,
32
+ nonText: THRESHOLDS.nonText,
33
+ },
34
+ passes: check.passes,
35
+ };
36
+
37
+ if (check.fg.alpha < 1 || check.bg.alpha < 1) {
38
+ result.composited = { foreground: check.fg.composited, background: check.bg.composited };
39
+ }
40
+
41
+ if (fix) {
42
+ result.fix = suggestColors(check.fg.composited, check.bg.composited, { target: required });
43
+ }
44
+
45
+ return result;
46
+ }
47
+
48
+ function contextLabel(result) {
49
+ if (result.context === 'non-text') return 'non-text / UI component';
50
+ return result.context === 'large' ? 'large-scale text (≥24px, or ≥18.5px bold)' : 'normal text';
51
+ }
52
+
53
+ export function formatContrastHuman(result) {
54
+ const lines = [];
55
+ const verdictFor = (level, threshold) => {
56
+ const passes = result.passes[level];
57
+ return `${passes ? 'PASS' : 'FAIL'} ${level} (${threshold}:1)`;
58
+ };
59
+
60
+ lines.push(`${result.foreground.hex} on ${result.background.hex} — ${result.ratioDisplay}:1`);
61
+ if (result.composited) {
62
+ lines.push(
63
+ ` composited for alpha: ${result.composited.foreground} on ${result.composited.background}`,
64
+ );
65
+ }
66
+ lines.push(` measured as: ${contextLabel(result)}`);
67
+
68
+ const levels = Object.keys(result.passes);
69
+ const thresholdFor = (level) =>
70
+ result.context === 'non-text'
71
+ ? result.thresholds.nonText[level]
72
+ : result.context === 'large'
73
+ ? result.thresholds.largeText[level]
74
+ : result.thresholds.normalText[level];
75
+ lines.push(` ${levels.map((level) => verdictFor(level, thresholdFor(level))).join(' · ')}`);
76
+ lines.push(
77
+ ` SC ${result.wcag.sc} ${result.wcag.name} (Level ${result.wcag.level}) requires ${result.wcag.required}:1`,
78
+ );
79
+
80
+ if (result.fix) {
81
+ lines.push('');
82
+ if (result.fix.suggestions.length === 0) {
83
+ lines.push(
84
+ result.fix.original.passes
85
+ ? ' Already meets the threshold — nothing to fix.'
86
+ : ` No candidate reached ${result.fix.target}:1 by adjusting lightness alone. ` +
87
+ 'Change hue or chroma, or pick a different pair.',
88
+ );
89
+ } else {
90
+ lines.push(` Candidates that reach ${result.fix.target}:1 (hue and chroma preserved):`);
91
+ for (const s of result.fix.suggestions) {
92
+ const what = s.role === 'background' ? 'background' : 'text';
93
+ const from = s.role === 'background' ? result.fix.original.bg : result.fix.original.fg;
94
+ lines.push(
95
+ ` ${what} ${s.direction}: ${from} → ${s.hex} ` +
96
+ `(${result.fix.original.ratioDisplay}:1 → ${s.newRatioDisplay}:1)` +
97
+ (s.note ? ` — ${s.note}` : ''),
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ lines.push('');
104
+ lines.push('Contrast is measured with the WCAG 2.x algorithm; the ratio is truncated to two');
105
+ lines.push('decimals, matching axe-core and the WebAIM Contrast Checker.');
106
+ lines.push('');
107
+ return lines.join('\n');
108
+ }
109
+
110
+ /**
111
+ * @param {{fg:string, bg:string, flags:object}} input
112
+ * @param {{write:Function, writeError:Function}} io
113
+ * @returns {Promise<number>}
114
+ */
115
+ export async function runContrastCommand({ fg, bg, flags }, io) {
116
+ let result;
117
+ try {
118
+ result = contrastResult(fg, bg, {
119
+ large: Boolean(flags.large),
120
+ ui: Boolean(flags.ui),
121
+ fix: Boolean(flags.fix),
122
+ });
123
+ } catch (error) {
124
+ if (error instanceof ColorParseError) {
125
+ throw new ToolError(error.message, {
126
+ hint:
127
+ 'Accepted formats: #rgb, #rrggbb, #rrggbbaa, rgb()/rgba(), hsl()/hsla(), oklch(), ' +
128
+ 'and CSS named colours.',
129
+ });
130
+ }
131
+ throw error;
132
+ }
133
+
134
+ if (flags.json) {
135
+ io.write(`${JSON.stringify(result, null, 2)}\n`);
136
+ } else {
137
+ io.write(formatContrastHuman(result));
138
+ }
139
+
140
+ return result.passes.AA ? 0 : 1;
141
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `a11y-loop diff --before a.json --after b.json`
3
+ *
4
+ * Exit codes: 0 no new violations · 1 regression (NEW is non-empty) ·
5
+ * 2 a report could not be read.
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
10
+
11
+ import { diffReports, formatDiffHuman } from '../lib/diff.js';
12
+ import { ToolError } from '../lib/axe-runner.js';
13
+
14
+ async function readReport(path, label) {
15
+ const absolute = resolve(path);
16
+ let text;
17
+ try {
18
+ text = await readFile(absolute, 'utf8');
19
+ } catch (error) {
20
+ throw new ToolError(`Could not read --${label} report ${absolute}: ${error.message}`, {
21
+ hint: 'Produce reports with: a11y-loop audit <target> --out report.json',
22
+ });
23
+ }
24
+ let report;
25
+ try {
26
+ report = JSON.parse(text);
27
+ } catch (error) {
28
+ throw new ToolError(`--${label} report ${absolute} is not valid JSON: ${error.message}`);
29
+ }
30
+ if (!report?.findings?.violations) {
31
+ throw new ToolError(
32
+ `--${label} report ${absolute} is not an a11y-loop report (no findings.violations array).`,
33
+ { hint: 'Produce reports with: a11y-loop audit <target> --out report.json' },
34
+ );
35
+ }
36
+ return report;
37
+ }
38
+
39
+ /**
40
+ * @param {{flags:object}} input
41
+ * @param {{write:Function, writeError:Function}} io
42
+ * @returns {Promise<number>}
43
+ */
44
+ export async function runDiffCommand({ flags }, io) {
45
+ if (!flags.before || !flags.after) {
46
+ throw new ToolError('diff requires both --before <a.json> and --after <b.json>.');
47
+ }
48
+
49
+ const before = await readReport(flags.before, 'before');
50
+ const after = await readReport(flags.after, 'after');
51
+ const diff = diffReports(before, after);
52
+
53
+ if (flags.json) {
54
+ io.write(`${JSON.stringify(diff, null, 2)}\n`);
55
+ } else {
56
+ io.write(
57
+ formatDiffHuman(diff, {
58
+ beforePath: resolve(flags.before),
59
+ afterPath: resolve(flags.after),
60
+ }),
61
+ );
62
+ }
63
+
64
+ return diff.summary.regression ? 1 : 0;
65
+ }