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,139 @@
1
+ /**
2
+ * The JSON report — the primary machine format and the agent-facing contract.
3
+ *
4
+ * Design rules:
5
+ * - Stable keys, so a loop can rely on them across versions.
6
+ * - `violations` / `needsReview` / `bestPractice` are separate arrays.
7
+ * best-practice rules have no success criterion behind them and are NEVER
8
+ * counted as violations or allowed to affect the exit code.
9
+ * - Every finding carries a fingerprint, so `diff` can match across runs.
10
+ * - The summary states what was NOT checked as prominently as what was.
11
+ * - No conformance language, ever. See RED_LINE_PATTERNS.
12
+ */
13
+
14
+ import { bucketFindings } from '../finding.js';
15
+ import { compareSc, CONFORMANCE_SET } from '../wcag-map.js';
16
+
17
+ /** The clean-run sentence. Deliberately not "passed", "compliant" or "accessible". */
18
+ export const CLEAN_VERDICT = 'No automatically detectable failures';
19
+
20
+ /** Coverage, with denominators attached. Quoting 57% alone overstates the case. */
21
+ export const COVERAGE = Object.freeze({
22
+ statement:
23
+ 'Automated checks cover a subset of WCAG (Deque: ~57% of issues by volume; ~31% of AA ' +
24
+ 'criteria have any automated rule). This is not an audit or conformance claim.',
25
+ issueVolumePercent: 57,
26
+ issueVolumeDenominator: 'instances of individual issues found, per Deque’s 2,000-audit study',
27
+ criteriaWithAnyAutomatedRulePercent: 31,
28
+ criteriaWithAnyAutomatedRuleDenominator: `17 of the ${CONFORMANCE_SET.aaSet} WCAG 2.2 A/AA criteria`,
29
+ criteriaReliablyAutomatedPercent: 13,
30
+ criteriaReliablyAutomatedDenominator: `7 of the ${CONFORMANCE_SET.aaSet} WCAG 2.2 A/AA criteria`,
31
+ criteriaUntestableByAnyTool: 9,
32
+ criteriaRequiringHumanVerification: 13,
33
+ });
34
+
35
+ /**
36
+ * Phrases that must never appear in a11y-loop's own report language. The FTC
37
+ * fined accessiBe $1M over claims of this kind; the Overlay Fact Sheet has 800+
38
+ * signatories. A tool that overstates coverage makes agents declare victory
39
+ * early, which is the exact failure this one exists to prevent.
40
+ */
41
+ export const RED_LINE_PATTERNS = [
42
+ /\bcompliant\b/i,
43
+ /\bcompliance\s+(?:guaranteed|achieved|confirmed)\b/i,
44
+ /\bconforms?\s+to\s+WCAG\b/i,
45
+ /\bfully\s+accessible\b/i,
46
+ /\bis\s+accessible\b/i,
47
+ /\bpasse[sd]\s+WCAG\b/i,
48
+ /\bguarantee\w*\b/i,
49
+ /\bno\s+manual\s+testing\b/i,
50
+ /\breduces?\s+legal\s+risk\b/i,
51
+ /\baccessibility\s+score\b/i,
52
+ /\b100%\s+accessible\b/i,
53
+ ];
54
+
55
+ /** @returns {string[]} the red-line phrases present in a block of text. */
56
+ export function findRedLineLanguage(text) {
57
+ return RED_LINE_PATTERNS.filter((pattern) => pattern.test(String(text ?? ''))).map(
58
+ (pattern) => pattern.source,
59
+ );
60
+ }
61
+
62
+ const IMPACTS = ['critical', 'serious', 'moderate', 'minor'];
63
+
64
+ function countByImpact(findings) {
65
+ const counts = {};
66
+ for (const impact of IMPACTS) {
67
+ const n = findings.filter((f) => f.impact === impact).length;
68
+ if (n > 0) counts[impact] = n;
69
+ }
70
+ return counts;
71
+ }
72
+
73
+ /** Distinct success criteria touched by a set of findings, in criterion order. */
74
+ export function affectedCriteria(findings) {
75
+ const map = new Map();
76
+ for (const finding of findings) {
77
+ if (!finding.wcag?.sc) continue;
78
+ if (!map.has(finding.wcag.sc)) {
79
+ map.set(finding.wcag.sc, {
80
+ sc: finding.wcag.sc,
81
+ name: finding.wcag.name,
82
+ level: finding.wcag.level,
83
+ wcag22Only: finding.wcag.wcag22Only,
84
+ count: 0,
85
+ });
86
+ }
87
+ map.get(finding.wcag.sc).count += 1;
88
+ }
89
+ return [...map.values()].sort((a, b) => compareSc(a.sc, b.sc));
90
+ }
91
+
92
+ /**
93
+ * Assemble the report.
94
+ *
95
+ * @param {object} input
96
+ * @param {object} input.tool provenance: name, version, axeCoreVersion, browser, …
97
+ * @param {{type:string, value:string}} input.target
98
+ * @param {Array} input.findings deduped, flat
99
+ * @param {Array} input.manualChecklist
100
+ * @param {object} [input.facts]
101
+ */
102
+ export function buildReport({ tool, target, findings, manualChecklist = [], facts = {} }) {
103
+ const buckets = bucketFindings(findings);
104
+ const violationCount = buckets.violations.length;
105
+
106
+ const summary = {
107
+ violations: violationCount,
108
+ needsReview: buckets.needsReview.length,
109
+ bestPractice: buckets.bestPractice.length,
110
+ manualChecklist: manualChecklist.length,
111
+ byImpact: countByImpact(buckets.violations),
112
+ criteriaAffected: affectedCriteria(buckets.violations),
113
+ wcag22OnlyViolations: buckets.violations.filter((f) => f.wcag?.wcag22Only).length,
114
+ verdict:
115
+ violationCount === 0
116
+ ? CLEAN_VERDICT
117
+ : `${violationCount} automatically detectable failure${violationCount === 1 ? '' : 's'}`,
118
+ target: 'WCAG 2.2 Level AA',
119
+ coverage: COVERAGE,
120
+ };
121
+
122
+ return {
123
+ tool,
124
+ target,
125
+ summary,
126
+ findings: {
127
+ violations: buckets.violations,
128
+ needsReview: buckets.needsReview,
129
+ bestPractice: buckets.bestPractice,
130
+ },
131
+ manualChecklist,
132
+ pageInventory: facts,
133
+ };
134
+ }
135
+
136
+ /** Pretty-printed JSON with a trailing newline, for stdout or a file. */
137
+ export function serializeReport(report) {
138
+ return `${JSON.stringify(report, null, 2)}\n`;
139
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * SARIF v2.1 output, via Microsoft's axe-sarif-converter.
3
+ *
4
+ * Supported with eyes open. GitHub code scanning only DISPLAYS results whose
5
+ * location is a file path with a valid `physicalLocation.artifactLocation.uri`;
6
+ * an accessibility finding's natural location is (page URL, CSS selector), so a
7
+ * naive upload produces an empty Code Scanning view. The honest approach, taken
8
+ * here, is to emit valid SARIF for Azure DevOps and the VS Code SARIF viewer and
9
+ * to document the GitHub limitation rather than fake file paths.
10
+ *
11
+ * Plain JSON remains the primary machine format: SARIF is verbose and hostile to
12
+ * an LLM's context budget.
13
+ */
14
+
15
+ import { createRequire } from 'node:module';
16
+ import { SEVERITY } from '../finding.js';
17
+
18
+ const require = createRequire(import.meta.url);
19
+
20
+ /** Where GitHub's limitation is documented for report readers. */
21
+ export const GITHUB_LIMITATION_NOTE =
22
+ 'GitHub code scanning drops SARIF results without a file-path location. These results are ' +
23
+ 'located by URL and CSS selector, so use the Azure DevOps SARIF viewer or the VS Code SARIF ' +
24
+ 'extension, or map selectors back to source templates before uploading.';
25
+
26
+ /**
27
+ * Rebuild an axe-shaped results object from our report so the converter can
28
+ * consume it. Our own a11y-loop checks are included as additional rules — they
29
+ * are real findings and dropping them from the SARIF would misrepresent the run.
30
+ *
31
+ * @param {object} report
32
+ * @returns {object} an axe-core AxeResults-shaped object
33
+ */
34
+ export function toAxeShape(report) {
35
+ const nodeFor = (finding) => ({
36
+ html: finding.html,
37
+ target: [finding.selector],
38
+ any: [],
39
+ all: [],
40
+ none: [],
41
+ impact: finding.impact ?? null,
42
+ failureSummary: finding.message,
43
+ });
44
+
45
+ const groupByRule = (findings) => {
46
+ const groups = new Map();
47
+ for (const finding of findings) {
48
+ if (!groups.has(finding.ruleId)) {
49
+ const tags = [];
50
+ if (finding.wcag?.sc) {
51
+ tags.push(`wcag${finding.wcag.sc.replace(/\./g, '')}`);
52
+ if (finding.wcag.level) {
53
+ const version = finding.wcag.minVersion === '2.0' ? '2' : finding.wcag.minVersion.replace('.', '');
54
+ tags.push(`wcag${version}${finding.wcag.level.toLowerCase()}`);
55
+ }
56
+ }
57
+ if (finding.severity === SEVERITY.BEST_PRACTICE) tags.push('best-practice');
58
+ for (const act of finding.act ?? []) tags.push(`ACT-${act}`);
59
+
60
+ groups.set(finding.ruleId, {
61
+ id: finding.ruleId,
62
+ impact: finding.impact ?? null,
63
+ tags,
64
+ description: finding.message,
65
+ help: finding.message,
66
+ helpUrl: finding.helpUrl ?? 'https://github.com/chanmeng/a11y-loop',
67
+ nodes: [],
68
+ });
69
+ }
70
+ groups.get(finding.ruleId).nodes.push(nodeFor(finding));
71
+ }
72
+ return [...groups.values()];
73
+ };
74
+
75
+ return {
76
+ testEngine: { name: 'axe-core', version: report.tool.axeCoreVersion },
77
+ testRunner: { name: `${report.tool.name} ${report.tool.version}` },
78
+ testEnvironment: {
79
+ userAgent: report.tool.userAgent ?? `${report.tool.browser}/${report.tool.browserVersion}`,
80
+ windowWidth: report.tool.viewport.width,
81
+ windowHeight: report.tool.viewport.height,
82
+ orientationAngle: 0,
83
+ orientationType: 'landscape-primary',
84
+ },
85
+ toolOptions: { passes: report.tool.passesRun, states: report.tool.statesRun ?? [] },
86
+ timestamp: report.tool.timestamp,
87
+ url: report.target.value,
88
+ violations: groupByRule([...report.findings.violations, ...report.findings.bestPractice]),
89
+ incomplete: groupByRule(report.findings.needsReview),
90
+ passes: [],
91
+ inapplicable: [],
92
+ };
93
+ }
94
+
95
+ /**
96
+ * @param {object} report
97
+ * @returns {object} a SARIF v2.1 log
98
+ */
99
+ export function toSarif(report) {
100
+ const { convertAxeToSarif } = require('axe-sarif-converter');
101
+ const log = convertAxeToSarif(toAxeShape(report));
102
+ for (const run of log.runs ?? []) {
103
+ run.properties = { ...run.properties, a11yLoopNote: GITHUB_LIMITATION_NOTE };
104
+ }
105
+ return log;
106
+ }
107
+
108
+ /** Serialized SARIF, with a trailing newline. */
109
+ export function serializeSarif(report) {
110
+ return `${JSON.stringify(toSarif(report), null, 2)}\n`;
111
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Ephemeral static server on 127.0.0.1.
3
+ *
4
+ * `file://` is deliberately not supported: its null origin breaks axe's frame
5
+ * injection (axe-core #3002), ES modules and `fetch`, and Windows path→URL
6
+ * conversion is its own bug source. Serving over HTTP costs ~20 lines and no
7
+ * dependencies, and behaves like the real thing.
8
+ */
9
+
10
+ import { createServer } from 'node:http';
11
+ import { createReadStream } from 'node:fs';
12
+ import { readFile, stat } from 'node:fs/promises';
13
+ import { extname, resolve, join, sep } from 'node:path';
14
+
15
+ const CONTENT_TYPES = {
16
+ '.html': 'text/html; charset=utf-8',
17
+ '.htm': 'text/html; charset=utf-8',
18
+ '.css': 'text/css; charset=utf-8',
19
+ '.js': 'text/javascript; charset=utf-8',
20
+ '.mjs': 'text/javascript; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.svg': 'image/svg+xml',
23
+ '.png': 'image/png',
24
+ '.jpg': 'image/jpeg',
25
+ '.jpeg': 'image/jpeg',
26
+ '.gif': 'image/gif',
27
+ '.webp': 'image/webp',
28
+ '.avif': 'image/avif',
29
+ '.ico': 'image/x-icon',
30
+ '.woff': 'font/woff',
31
+ '.woff2': 'font/woff2',
32
+ '.ttf': 'font/ttf',
33
+ '.txt': 'text/plain; charset=utf-8',
34
+ };
35
+
36
+ const contentTypeFor = (path) => CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
37
+
38
+ /**
39
+ * Wrap an HTML fragment in a minimal valid document.
40
+ *
41
+ * `lang` and `<title>` are supplied so that auditing a fragment does not
42
+ * report the wrapper's own missing-lang and missing-title failures as if they
43
+ * were the agent's. A fragment that already looks like a full document is
44
+ * passed through untouched, so `--html` can also take a whole page.
45
+ */
46
+ export function wrapFragment(fragment) {
47
+ const text = String(fragment ?? '');
48
+ if (/<html[\s>]/i.test(text) || /^\s*<!doctype/i.test(text)) {
49
+ return /^\s*<!doctype/i.test(text) ? text : `<!doctype html>\n${text}`;
50
+ }
51
+ return [
52
+ '<!doctype html>',
53
+ '<html lang="en">',
54
+ '<head>',
55
+ '<meta charset="utf-8">',
56
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
57
+ '<title>a11y-loop fragment audit</title>',
58
+ '</head>',
59
+ '<body>',
60
+ text,
61
+ '</body>',
62
+ '</html>',
63
+ ].join('\n');
64
+ }
65
+
66
+ /**
67
+ * Serve a single in-memory HTML document.
68
+ * @param {string} html
69
+ * @returns {Promise<{url:string, close:() => Promise<void>}>}
70
+ */
71
+ export async function serveHtml(html) {
72
+ const body = Buffer.from(html, 'utf8');
73
+ const server = createServer((req, res) => {
74
+ if (req.url === '/' || req.url === '/index.html') {
75
+ res.writeHead(200, {
76
+ 'content-type': 'text/html; charset=utf-8',
77
+ 'content-length': body.length,
78
+ });
79
+ res.end(body);
80
+ return;
81
+ }
82
+ res.writeHead(404, { 'content-type': 'text/plain' });
83
+ res.end('Not found');
84
+ });
85
+ const port = await listen(server);
86
+ return {
87
+ url: `http://127.0.0.1:${port}/`,
88
+ close: () => closeServer(server),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Serve a file's own directory as the document root, so relative stylesheets,
94
+ * scripts and images resolve the way they would in a dev server.
95
+ *
96
+ * @param {string} filePath
97
+ * @returns {Promise<{url:string, close:() => Promise<void>}>}
98
+ */
99
+ export async function serveFile(filePath) {
100
+ const absolute = resolve(filePath);
101
+ const info = await stat(absolute).catch(() => null);
102
+ if (!info) throw new Error(`File not found: ${absolute}`);
103
+ if (info.isDirectory()) return serveDirectory(absolute, 'index.html');
104
+
105
+ const root = absolute.slice(0, absolute.lastIndexOf(sep));
106
+ const entry = absolute.slice(absolute.lastIndexOf(sep) + 1);
107
+ return serveDirectory(root, entry);
108
+ }
109
+
110
+ /**
111
+ * @param {string} root
112
+ * @param {string} entry file within `root` served at `/`
113
+ */
114
+ export async function serveDirectory(root, entry = 'index.html') {
115
+ const server = createServer(async (req, res) => {
116
+ try {
117
+ const requested = decodeURIComponent((req.url ?? '/').split('?')[0]);
118
+ const relative = requested === '/' ? entry : requested.replace(/^\/+/, '');
119
+ const target = resolve(join(root, relative));
120
+
121
+ // Never serve outside the document root.
122
+ if (target !== resolve(root) && !target.startsWith(resolve(root) + sep)) {
123
+ res.writeHead(403, { 'content-type': 'text/plain' });
124
+ res.end('Forbidden');
125
+ return;
126
+ }
127
+
128
+ const info = await stat(target).catch(() => null);
129
+ if (!info || info.isDirectory()) {
130
+ res.writeHead(404, { 'content-type': 'text/plain' });
131
+ res.end('Not found');
132
+ return;
133
+ }
134
+
135
+ res.writeHead(200, { 'content-type': contentTypeFor(target), 'content-length': info.size });
136
+ createReadStream(target).pipe(res);
137
+ } catch {
138
+ res.writeHead(500, { 'content-type': 'text/plain' });
139
+ res.end('Server error');
140
+ }
141
+ });
142
+ const port = await listen(server);
143
+ return {
144
+ url: `http://127.0.0.1:${port}/`,
145
+ close: () => closeServer(server),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Resolve the target of an audit into a URL plus a teardown function.
151
+ *
152
+ * @param {{type:'url'|'file'|'html', value:string}} target
153
+ * @returns {Promise<{url:string, close:() => Promise<void>}>}
154
+ */
155
+ export async function resolveTarget(target) {
156
+ if (target.type === 'url') {
157
+ return { url: target.value, close: async () => {} };
158
+ }
159
+ if (target.type === 'file') {
160
+ return serveFile(target.value);
161
+ }
162
+ if (target.type === 'html') {
163
+ return serveHtml(wrapFragment(target.value));
164
+ }
165
+ throw new Error(`Unknown target type: ${target.type}`);
166
+ }
167
+
168
+ /** Read a fragment or document from disk, for `--html @file` style use. */
169
+ export async function readHtmlFile(path) {
170
+ return readFile(resolve(path), 'utf8');
171
+ }
172
+
173
+ /** Listen on an OS-assigned port and resolve with it. */
174
+ function listen(server) {
175
+ return new Promise((resolvePort, reject) => {
176
+ server.once('error', reject);
177
+ server.listen(0, '127.0.0.1', () => {
178
+ const address = server.address();
179
+ resolvePort(typeof address === 'object' && address ? address.port : 0);
180
+ });
181
+ });
182
+ }
183
+
184
+ function closeServer(server) {
185
+ return new Promise((done) => {
186
+ server.closeAllConnections?.();
187
+ server.close(() => done());
188
+ });
189
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * "Nearest accessible color" suggester.
3
+ *
4
+ * No well-maintained library does this properly, so it is implemented here in
5
+ * ~100 lines. The search runs on OKLCh **lightness** with hue and chroma held
6
+ * fixed, which keeps suggestions recognisably part of the designer's palette —
7
+ * an HSL search desaturates and hue-shifts badly.
8
+ *
9
+ * Both a LIGHTER and a DARKER candidate are returned for the foreground, plus
10
+ * one background option. A loop that silently picks one direction will destroy
11
+ * a brand palette, so the caller (agent or human) chooses.
12
+ *
13
+ * culori is used only for color-space conversion; all contrast math is ours.
14
+ */
15
+
16
+ import { converter, clampChroma, formatHex } from 'culori';
17
+ import { parseColor, contrastRatio, formatRatio, truncateRatio } from './contrast-math.js';
18
+
19
+ const toOklch = converter('oklch');
20
+
21
+ /** Coarse scan resolution when bracketing the first lightness that passes. */
22
+ const SCAN_STEPS = 120;
23
+ /** Bisection refinement steps once a bracket is found. */
24
+ const REFINE_STEPS = 24;
25
+
26
+ /** @returns {string} hex for an OKLCh color at lightness `l`, gamut-mapped to sRGB. */
27
+ function hexAtLightness(base, l) {
28
+ const clamped = clampChroma({ ...base, l: Math.min(1, Math.max(0, l)) }, 'oklch', 'rgb');
29
+ return formatHex(clamped);
30
+ }
31
+
32
+ /**
33
+ * Walk lightness away from `base` in one direction until the contrast against
34
+ * `other` reaches `target`, then bisect back toward the original color to find
35
+ * the smallest change that still passes.
36
+ *
37
+ * Contrast is not monotonic in lightness across the whole range (it dips to 1:1
38
+ * where the two colors meet), which is why this brackets with a coarse scan
39
+ * first instead of bisecting blindly.
40
+ *
41
+ * @returns {{hex:string, l:number, ratio:number}|null} null if unreachable
42
+ */
43
+ function searchLightness(base, other, target, direction) {
44
+ const startL = base.l;
45
+ const endL = direction === 'lighter' ? 1 : 0;
46
+ const span = endL - startL;
47
+ if (Math.abs(span) < 1e-6) return null;
48
+
49
+ const ratioAt = (l) => {
50
+ const hex = hexAtLightness(base, l);
51
+ return { hex, ratio: contrastRatio(parseColor(hex), other) };
52
+ };
53
+
54
+ // Coarse scan for the first sample that meets the target.
55
+ let bracketLo = startL; // known-failing end (closest to the original)
56
+ let hit = null;
57
+ for (let i = 1; i <= SCAN_STEPS; i++) {
58
+ const l = startL + (span * i) / SCAN_STEPS;
59
+ const probe = ratioAt(l);
60
+ if (probe.ratio >= target) {
61
+ hit = { l, ...probe };
62
+ break;
63
+ }
64
+ bracketLo = l;
65
+ }
66
+ if (!hit) return null;
67
+
68
+ // Bisect between the last failing sample and the first passing one.
69
+ let lo = bracketLo;
70
+ let hi = hit.l;
71
+ let best = hit;
72
+ for (let i = 0; i < REFINE_STEPS; i++) {
73
+ const mid = (lo + hi) / 2;
74
+ const probe = ratioAt(mid);
75
+ if (probe.ratio >= target) {
76
+ best = { l: mid, ...probe };
77
+ hi = mid;
78
+ } else {
79
+ lo = mid;
80
+ }
81
+ }
82
+ return best;
83
+ }
84
+
85
+ /**
86
+ * Suggest passing alternatives for a failing foreground/background pair.
87
+ *
88
+ * @param {string} fgInput CSS color
89
+ * @param {string} bgInput CSS color
90
+ * @param {{target?:number, includeBackground?:boolean}} [opts]
91
+ * `target` is the required ratio (4.5 normal text, 3 large text / non-text).
92
+ * @returns {{
93
+ * target:number,
94
+ * original:{fg:string, bg:string, ratio:number, ratioDisplay:string, passes:boolean},
95
+ * suggestions:Array<{role:'foreground'|'background', direction:'lighter'|'darker',
96
+ * hex:string, newRatio:number, newRatioDisplay:string, note?:string}>
97
+ * }}
98
+ */
99
+ export function suggestColors(fgInput, bgInput, opts = {}) {
100
+ const { target = 4.5, includeBackground = true } = opts;
101
+ const fg = parseColor(fgInput);
102
+ const bg = parseColor(bgInput);
103
+ const original = contrastRatio(fg, bg);
104
+
105
+ const fgOk = toOklch({ mode: 'rgb', r: fg.r / 255, g: fg.g / 255, b: fg.b / 255 });
106
+ const bgOk = toOklch({ mode: 'rgb', r: bg.r / 255, g: bg.g / 255, b: bg.b / 255 });
107
+ // Hue is undefined for achromatic colors; keep it at 0 so clampChroma is happy.
108
+ if (!Number.isFinite(fgOk.h)) fgOk.h = 0;
109
+ if (!Number.isFinite(bgOk.h)) bgOk.h = 0;
110
+
111
+ const suggestions = [];
112
+
113
+ for (const direction of ['lighter', 'darker']) {
114
+ const found = searchLightness(fgOk, bg, target, direction);
115
+ if (found && found.hex !== fg.hex) {
116
+ suggestions.push({
117
+ role: 'foreground',
118
+ direction,
119
+ hex: found.hex,
120
+ newRatio: truncateRatio(found.ratio),
121
+ newRatioDisplay: formatRatio(found.ratio),
122
+ });
123
+ }
124
+ }
125
+
126
+ if (includeBackground) {
127
+ // Move the background away from the foreground: if the text is dark, the
128
+ // surface should get lighter, and vice versa.
129
+ const direction = fgOk.l <= bgOk.l ? 'lighter' : 'darker';
130
+ const found = searchLightness(bgOk, fg, target, direction);
131
+ if (found && found.hex !== bg.hex) {
132
+ suggestions.push({
133
+ role: 'background',
134
+ direction,
135
+ hex: found.hex,
136
+ newRatio: truncateRatio(found.ratio),
137
+ newRatioDisplay: formatRatio(found.ratio),
138
+ note: 'changes the surface, which affects every element drawn on it',
139
+ });
140
+ }
141
+ }
142
+
143
+ return {
144
+ target,
145
+ original: {
146
+ fg: fg.hex,
147
+ bg: bg.hex,
148
+ ratio: truncateRatio(original),
149
+ ratioDisplay: formatRatio(original),
150
+ passes: original >= target,
151
+ },
152
+ suggestions,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * One-line rendering of a suggestion set, for human output.
158
+ * e.g. `#777777 → #595959 (4.47 → 7.00) or lighten background #ffffff → …`
159
+ */
160
+ export function formatSuggestions(result) {
161
+ if (result.suggestions.length === 0) return null;
162
+ return result.suggestions
163
+ .map((s) => {
164
+ const what = s.role === 'foreground' ? 'text' : 'background';
165
+ const from = s.role === 'foreground' ? result.original.fg : result.original.bg;
166
+ return `${what} ${s.direction}: ${from} → ${s.hex} (${result.original.ratioDisplay} → ${s.newRatioDisplay})`;
167
+ })
168
+ .join('; ');
169
+ }