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
+ * Does `prefers-reduced-motion: reduce` actually do anything?
3
+ *
4
+ * Declaring a media query is not the same as honouring it. This samples
5
+ * computed animation state while the browser reports the reduce preference, and
6
+ * reports animations that are still running.
7
+ *
8
+ * Criterion mapping, chosen to stay honest about levels:
9
+ * - An animation that loops forever, or runs for more than 5 seconds, and
10
+ * starts on its own is SC 2.2.2 Pause, Stop, Hide (Level A) → violation.
11
+ * - A short, finite animation is SC 2.3.3 Animation from Interactions
12
+ * (Level AAA) → needs review, because AAA is not part of an AA target.
13
+ */
14
+
15
+ import { makeFinding, SEVERITY } from '../finding.js';
16
+
17
+ /** SC 2.2.2's threshold for "more than five seconds". */
18
+ export const LONG_ANIMATION_SECONDS = 5;
19
+
20
+ /** `"2s"` → 2, `"500ms"` → 0.5, `"0s"` → 0. Handles comma-separated lists. */
21
+ export function parseSeconds(value) {
22
+ if (typeof value !== 'string') return 0;
23
+ const durations = value.split(',').map((part) => {
24
+ const text = part.trim();
25
+ const number = Number.parseFloat(text);
26
+ if (!Number.isFinite(number)) return 0;
27
+ return /ms$/.test(text) ? number / 1000 : number;
28
+ });
29
+ return Math.max(0, ...durations);
30
+ }
31
+
32
+ /** `"infinite"` or a finite count. */
33
+ export function isInfinite(iterationCount) {
34
+ return String(iterationCount ?? '').split(',').some((part) => part.trim() === 'infinite');
35
+ }
36
+
37
+ /**
38
+ * @param {Array<{selector:string, html:string, animationName:string,
39
+ * animationDuration:string, animationPlayState:string, animationIterationCount:string,
40
+ * transitionDuration:string}>} samples
41
+ */
42
+ export function findRunningAnimations(samples = []) {
43
+ const running = [];
44
+ for (const sample of samples) {
45
+ const names = (sample.animationName ?? 'none')
46
+ .split(',')
47
+ .map((n) => n.trim())
48
+ .filter((n) => n && n !== 'none');
49
+ if (names.length === 0) continue;
50
+
51
+ const duration = parseSeconds(sample.animationDuration);
52
+ if (duration === 0) continue;
53
+ const playState = String(sample.animationPlayState ?? 'running');
54
+ if (playState.split(',').every((s) => s.trim() === 'paused')) continue;
55
+
56
+ const infinite = isInfinite(sample.animationIterationCount);
57
+ running.push({
58
+ selector: sample.selector,
59
+ html: sample.html,
60
+ names,
61
+ duration,
62
+ infinite,
63
+ continuous: infinite || duration > LONG_ANIMATION_SECONDS,
64
+ });
65
+ }
66
+ return running;
67
+ }
68
+
69
+ /**
70
+ * @param {Array} samples
71
+ * @param {{passes:string[], state:string|null}} ctx
72
+ */
73
+ export function reducedMotionFindings(samples, ctx) {
74
+ const { passes = [], state = null } = ctx ?? {};
75
+ return findRunningAnimations(samples).map((animation) => {
76
+ const lifetime = animation.infinite
77
+ ? 'loops forever'
78
+ : `runs for ${animation.duration}s`;
79
+ return makeFinding({
80
+ ruleId: 'reduced-motion-ignored',
81
+ source: 'a11y-loop',
82
+ severity: animation.continuous ? SEVERITY.VIOLATION : SEVERITY.NEEDS_REVIEW,
83
+ impact: animation.continuous ? 'serious' : 'moderate',
84
+ sc: animation.continuous ? '2.2.2' : '2.3.3',
85
+ selector: animation.selector,
86
+ html: animation.html,
87
+ message:
88
+ `Animation "${animation.names.join(', ')}" is still running while the browser reports ` +
89
+ `prefers-reduced-motion: reduce, and it ${lifetime}. Wrap the animation in ` +
90
+ '@media (prefers-reduced-motion: no-preference), or disable it under reduce.' +
91
+ (animation.continuous
92
+ ? ''
93
+ : ' Short, finite animations are Level AAA, so this is flagged for review rather ' +
94
+ 'than as an AA failure.'),
95
+ passes,
96
+ state,
97
+ data: {
98
+ animationNames: animation.names,
99
+ durationSeconds: animation.duration,
100
+ infinite: animation.infinite,
101
+ },
102
+ });
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Sample computed animation state. Only meaningful on the reduced-motion pass.
108
+ *
109
+ * Uses isRendered rather than isVisible deliberately: SC 2.2.2 is about
110
+ * motion a SIGHTED user can see, which has nothing to do with the
111
+ * accessibility tree. A moving element inside an aria-hidden container (a
112
+ * decorative marquee/ticker, say) is still a real vestibular hazard and still
113
+ * needs to stop under prefers-reduced-motion, even though it is correctly
114
+ * invisible to a screen reader.
115
+ *
116
+ * @param {import('playwright').Page} page
117
+ */
118
+ export async function surveyAnimations(page) {
119
+ return page.evaluate(() => {
120
+ const helpers = window.__a11yLoop;
121
+ const samples = [];
122
+ for (const el of document.querySelectorAll('*')) {
123
+ if (!helpers.isRendered(el)) continue;
124
+ const style = getComputedStyle(el);
125
+ if (!style.animationName || style.animationName === 'none') continue;
126
+ samples.push({
127
+ selector: helpers.cssPath(el),
128
+ html: helpers.shortHtml(el),
129
+ animationName: style.animationName,
130
+ animationDuration: style.animationDuration,
131
+ animationPlayState: style.animationPlayState,
132
+ animationIterationCount: style.animationIterationCount,
133
+ transitionDuration: style.transitionDuration,
134
+ });
135
+ if (samples.length >= 30) break;
136
+ }
137
+ return samples;
138
+ });
139
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Reflow (SC 1.4.10, Level AA) at 320×256.
3
+ *
4
+ * Playwright has no browser zoom API, but it does not need one: SC 1.4.10 says
5
+ * "320 CSS pixels is equivalent to a starting viewport width of 1280 CSS pixels
6
+ * wide at 400% zoom". Setting the viewport to 320×256 IS the 400% zoom test.
7
+ *
8
+ * The criterion excepts content that genuinely needs two-dimensional layout
9
+ * (data tables, maps, diagrams), which is why this reports named overflowing
10
+ * elements for review rather than failing the page outright.
11
+ */
12
+
13
+ import { makeFinding, SEVERITY } from '../finding.js';
14
+
15
+ export const REFLOW_VIEWPORT = { width: 320, height: 256 };
16
+
17
+ /** Elements whose overflow SC 1.4.10 explicitly tolerates. */
18
+ const TWO_DIMENSIONAL_TAGS = new Set(['TABLE', 'SVG', 'CANVAS', 'IFRAME', 'VIDEO', 'IMG', 'PRE']);
19
+
20
+ /**
21
+ * @param {{horizontalScroll:boolean, scrollWidth:number, clientWidth:number, culprits:Array}} result
22
+ * @param {{passes:string[], state:string|null}} ctx
23
+ */
24
+ export function reflowFindings(result, ctx) {
25
+ const { passes = [], state = null } = ctx ?? {};
26
+ if (!result?.horizontalScroll) return [];
27
+
28
+ const culprits = result.culprits ?? [];
29
+ const excepted = culprits.filter((c) => TWO_DIMENSIONAL_TAGS.has(c.tag));
30
+ const plain = culprits.filter((c) => !TWO_DIMENSIONAL_TAGS.has(c.tag));
31
+ const named = (plain.length ? plain : culprits).slice(0, 5);
32
+
33
+ const overflowBy = result.scrollWidth - result.clientWidth;
34
+ const list = named.length
35
+ ? named
36
+ .map((c) => `${c.selector ?? c.tag.toLowerCase()} (extends to ${c.right}px)`)
37
+ .join(', ')
38
+ : 'no single element could be identified';
39
+
40
+ const exceptionNote = excepted.length
41
+ ? ` ${excepted.length} of the overflowing element(s) are ` +
42
+ `${[...new Set(excepted.map((c) => c.tag.toLowerCase()))].join('/')}, which SC 1.4.10 ` +
43
+ 'excepts when the content genuinely requires two-dimensional layout.'
44
+ : '';
45
+
46
+ return [
47
+ makeFinding({
48
+ ruleId: 'reflow-horizontal-scroll',
49
+ source: 'a11y-loop',
50
+ severity: plain.length ? SEVERITY.VIOLATION : SEVERITY.NEEDS_REVIEW,
51
+ impact: 'serious',
52
+ sc: '1.4.10',
53
+ selector: named[0]?.selector ?? 'html',
54
+ html: named[0]?.html ?? '',
55
+ message:
56
+ `At a ${REFLOW_VIEWPORT.width}×${REFLOW_VIEWPORT.height} viewport (equivalent to 400% ` +
57
+ `zoom at 1280px) the page scrolls horizontally by ${overflowBy}px. Overflowing: ` +
58
+ `${list}.${exceptionNote}`,
59
+ passes,
60
+ state,
61
+ data: {
62
+ viewport: REFLOW_VIEWPORT,
63
+ scrollWidth: result.scrollWidth,
64
+ clientWidth: result.clientWidth,
65
+ culprits: culprits.slice(0, 20),
66
+ },
67
+ }),
68
+ ];
69
+ }
70
+
71
+ /**
72
+ * Detect horizontal overflow and name the elements responsible.
73
+ * Assumes the viewport has already been set to 320×256.
74
+ *
75
+ * @param {import('playwright').Page} page
76
+ */
77
+ export async function surveyReflow(page) {
78
+ return page.evaluate(() => {
79
+ const helpers = window.__a11yLoop;
80
+ const doc = document.documentElement;
81
+ const clientWidth = doc.clientWidth;
82
+ const horizontalScroll = doc.scrollWidth > clientWidth + 1;
83
+ const culprits = [];
84
+ if (horizontalScroll) {
85
+ for (const el of document.body.querySelectorAll('*')) {
86
+ const rect = el.getBoundingClientRect();
87
+ if (rect.right > clientWidth + 1 && rect.width > 0) {
88
+ culprits.push({
89
+ tag: el.tagName,
90
+ selector: helpers.cssPath(el),
91
+ html: helpers.shortHtml(el, 120),
92
+ right: Math.round(rect.right),
93
+ width: Math.round(rect.width),
94
+ });
95
+ }
96
+ if (culprits.length >= 20) break;
97
+ }
98
+ }
99
+ return { horizontalScroll, scrollWidth: doc.scrollWidth, clientWidth, culprits };
100
+ });
101
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Target size (SC 2.5.8, Level AA, new in WCAG 2.2).
3
+ *
4
+ * Our own geometric check rather than axe's `target-size`, which is disabled by
5
+ * default and has a documented false-positive trail. We measure the rect and
6
+ * apply the Spacing exception explicitly, so the report explains itself:
7
+ * "18×18 CSS px, and the 24 px spacing circle overlaps <other element>".
8
+ *
9
+ * Everything here is needs-review, never a hard failure. Of the criterion's
10
+ * five exceptions, only Spacing is measurable: Equivalent, Inline, User agent
11
+ * control and Essential all require knowing the author's intent.
12
+ */
13
+
14
+ import { makeFinding, SEVERITY } from '../finding.js';
15
+
16
+ export const MIN_TARGET_PX = 24;
17
+
18
+ const centerOf = (rect) => ({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
19
+
20
+ /**
21
+ * Does a 24 px-diameter circle centred on this target stay clear of every other
22
+ * target's circle? Two circles of radius 12 clear each other when their centres
23
+ * are at least 24 px apart.
24
+ */
25
+ export function meetsSpacingException(target, allTargets) {
26
+ const c = centerOf(target.rect);
27
+ for (const other of allTargets) {
28
+ if (other === target || other.selector === target.selector) continue;
29
+ const o = centerOf(other.rect);
30
+ const distance = Math.hypot(c.x - o.x, c.y - o.y);
31
+ if (distance < MIN_TARGET_PX) return { met: false, conflictsWith: other.selector, distance };
32
+ }
33
+ return { met: true };
34
+ }
35
+
36
+ /**
37
+ * @param {Array<{selector:string, html:string, rect:{x:number,y:number,width:number,height:number}, inline?:boolean}>} targets
38
+ * @returns {Array<{selector:string, html:string, width:number, height:number, spacing:object, inline:boolean}>}
39
+ */
40
+ export function evaluateTargets(targets = []) {
41
+ const undersized = targets.filter(
42
+ (t) => t.rect.width < MIN_TARGET_PX || t.rect.height < MIN_TARGET_PX,
43
+ );
44
+ return undersized.map((t) => ({
45
+ selector: t.selector,
46
+ html: t.html,
47
+ width: Math.round(t.rect.width * 10) / 10,
48
+ height: Math.round(t.rect.height * 10) / 10,
49
+ inline: Boolean(t.inline),
50
+ spacing: meetsSpacingException(t, targets),
51
+ }));
52
+ }
53
+
54
+ /**
55
+ * @param {Array} targets
56
+ * @param {{passes:string[], state:string|null}} ctx
57
+ */
58
+ export function targetSizeFindings(targets, ctx) {
59
+ const { passes = [], state = null } = ctx ?? {};
60
+ return evaluateTargets(targets).map((t) => {
61
+ const size = `${t.width}×${t.height} CSS px`;
62
+ const detail = t.spacing.met
63
+ ? 'No other target sits within 24 px, so the Spacing exception may apply.'
64
+ : `The 24 px spacing circle overlaps ${t.spacing.conflictsWith} ` +
65
+ `(centres ${Math.round(t.spacing.distance)} px apart), so the Spacing exception does not apply.`;
66
+ const inlineNote = t.inline
67
+ ? ' This target is inside a sentence, so the Inline exception may also apply.'
68
+ : '';
69
+
70
+ return makeFinding({
71
+ ruleId: 'target-size-min',
72
+ source: 'a11y-loop',
73
+ severity: SEVERITY.NEEDS_REVIEW,
74
+ impact: 'moderate',
75
+ sc: '2.5.8',
76
+ selector: t.selector,
77
+ html: t.html,
78
+ message:
79
+ `Target is ${size}, smaller than the 24×24 minimum. ${detail}${inlineNote} ` +
80
+ 'The Equivalent, User agent control and Essential exceptions cannot be judged ' +
81
+ 'automatically — confirm one applies, or enlarge the target.',
82
+ passes,
83
+ state,
84
+ data: {
85
+ width: t.width,
86
+ height: t.height,
87
+ minimum: MIN_TARGET_PX,
88
+ spacingExceptionMet: t.spacing.met,
89
+ conflictsWith: t.spacing.conflictsWith ?? null,
90
+ },
91
+ });
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Measure every interactive element's rect.
97
+ * @param {import('playwright').Page} page
98
+ */
99
+ export async function surveyTargets(page) {
100
+ return page.evaluate(() => {
101
+ const helpers = window.__a11yLoop;
102
+ return helpers.interactiveElements().map((el) => {
103
+ const rect = el.getBoundingClientRect();
104
+ // SC 2.5.8's Inline exception covers targets in a sentence or block of
105
+ // text, so compare against the nearest text-bearing block rather than the
106
+ // immediate parent — a link wrapped in a <span> would otherwise look
107
+ // standalone.
108
+ const block = el.closest('p, li, td, th, dd, blockquote, figcaption, h1, h2, h3, h4, h5, h6');
109
+ const blockText = (block?.textContent ?? '').trim();
110
+ const ownText = (el.textContent ?? '').trim();
111
+ const inline =
112
+ getComputedStyle(el).display === 'inline' &&
113
+ Boolean(block) &&
114
+ blockText.length > ownText.length + 10;
115
+ return {
116
+ selector: helpers.cssPath(el),
117
+ html: helpers.shortHtml(el),
118
+ inline,
119
+ rect: {
120
+ x: rect.x,
121
+ y: rect.y,
122
+ width: rect.width,
123
+ height: rect.height,
124
+ },
125
+ };
126
+ });
127
+ });
128
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * WCAG 2.x contrast math — own implementation.
3
+ *
4
+ * Reference (WCAG 2.2, Understanding SC 1.4.3):
5
+ * c_srgb = c_8bit / 255
6
+ * c = c_srgb <= 0.04045 ? c_srgb / 12.92 : ((c_srgb + 0.055) / 1.055) ** 2.4
7
+ * L = 0.2126 * R + 0.7152 * G + 0.0722 * B
8
+ * ratio = (L_lighter + 0.05) / (L_darker + 0.05)
9
+ *
10
+ * Deliberate choices, for reproducibility against the WebAIM Contrast Checker
11
+ * (the de facto reference implementation practitioners compare against):
12
+ * - Channels are quantised to 8 bits on parse, because that is what a browser
13
+ * actually renders and what WebAIM operates on.
14
+ * - Alpha is composited in NON-LINEAR sRGB (the same thing browsers and
15
+ * axe-core do) before linearisation.
16
+ * - The displayed ratio is TRUNCATED to 2 decimals, never rounded up. This is
17
+ * what axe-core does (`Math.floor(contrast * 100) / 100` in
18
+ * color-contrast-evaluate) and it is why the canonical figure for red on
19
+ * white is 3.99:1 and not 4.00:1. The pass/fail verdict uses the full
20
+ * precision ratio, so a pair can never reach a threshold by rounding.
21
+ */
22
+
23
+ import { parse, converter } from 'culori';
24
+
25
+ const toRgb = converter('rgb');
26
+
27
+ export class ColorParseError extends Error {
28
+ constructor(input) {
29
+ super(`Could not parse color: ${JSON.stringify(input)}`);
30
+ this.name = 'ColorParseError';
31
+ this.input = input;
32
+ }
33
+ }
34
+
35
+ /** WCAG threshold table. `nonText` is SC 1.4.11 (UI components / graphical objects). */
36
+ export const THRESHOLDS = {
37
+ normal: { AA: 4.5, AAA: 7 },
38
+ large: { AA: 3, AAA: 4.5 },
39
+ nonText: { AA: 3 },
40
+ };
41
+
42
+ /** SC that governs each context. */
43
+ export const CONTEXT_SC = {
44
+ normal: { AA: '1.4.3', AAA: '1.4.6' },
45
+ large: { AA: '1.4.3', AAA: '1.4.6' },
46
+ 'non-text': { AA: '1.4.11' },
47
+ };
48
+
49
+ const to8bit = (v) => Math.round(Math.min(1, Math.max(0, v)) * 255);
50
+
51
+ /**
52
+ * Parse any CSS color string into 8-bit sRGB channels plus alpha.
53
+ * @param {string} input
54
+ * @returns {{r:number,g:number,b:number,alpha:number,hex:string}}
55
+ */
56
+ export function parseColor(input) {
57
+ if (typeof input !== 'string' || input.trim() === '') throw new ColorParseError(input);
58
+ let raw = input.trim();
59
+ // Bare hex digits are a very common agent/user input ("777777").
60
+ if (/^[0-9a-f]{3}$|^[0-9a-f]{4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i.test(raw)) raw = `#${raw}`;
61
+ const parsed = parse(raw);
62
+ if (!parsed) throw new ColorParseError(input);
63
+ const rgb = toRgb(parsed);
64
+ if (!rgb) throw new ColorParseError(input);
65
+ const r = to8bit(rgb.r);
66
+ const g = to8bit(rgb.g);
67
+ const b = to8bit(rgb.b);
68
+ const alpha = rgb.alpha === undefined ? 1 : Math.min(1, Math.max(0, rgb.alpha));
69
+ return { r, g, b, alpha, hex: toHex({ r, g, b }) };
70
+ }
71
+
72
+ /** @returns {string} lowercase `#rrggbb` */
73
+ export function toHex({ r, g, b }) {
74
+ const h = (v) => Math.round(v).toString(16).padStart(2, '0');
75
+ return `#${h(r)}${h(g)}${h(b)}`;
76
+ }
77
+
78
+ /**
79
+ * Composite `fg` over `bg` in non-linear sRGB (what browsers do).
80
+ * @returns {{r:number,g:number,b:number,alpha:number,hex:string}}
81
+ */
82
+ export function composite(fg, bg) {
83
+ const a = fg.alpha ?? 1;
84
+ if (a >= 1) return { ...fg, alpha: 1 };
85
+ const mix = (f, b) => Math.round(f * a + b * (1 - a));
86
+ const out = { r: mix(fg.r, bg.r), g: mix(fg.g, bg.g), b: mix(fg.b, bg.b), alpha: 1 };
87
+ return { ...out, hex: toHex(out) };
88
+ }
89
+
90
+ /** Relative luminance of an opaque 8-bit sRGB color. */
91
+ export function relativeLuminance({ r, g, b }) {
92
+ const lin = (v8) => {
93
+ const c = v8 / 255;
94
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
95
+ };
96
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
97
+ }
98
+
99
+ /** Contrast ratio between two opaque colors. Order-independent. */
100
+ export function contrastRatio(a, b) {
101
+ const la = relativeLuminance(a);
102
+ const lb = relativeLuminance(b);
103
+ const lighter = Math.max(la, lb);
104
+ const darker = Math.min(la, lb);
105
+ return (lighter + 0.05) / (darker + 0.05);
106
+ }
107
+
108
+ /**
109
+ * Truncate to 2 decimals for display — never for the verdict, and never
110
+ * upward. Matches axe-core's `Math.floor(contrast * 100) / 100`.
111
+ */
112
+ export function truncateRatio(ratio) {
113
+ return Math.floor(ratio * 100) / 100;
114
+ }
115
+
116
+ /** `4.47828` → `"4.47"`, `21` → `"21"`. */
117
+ export function formatRatio(ratio) {
118
+ const r = truncateRatio(ratio);
119
+ return Number.isInteger(r) ? String(r) : r.toFixed(2);
120
+ }
121
+
122
+ /**
123
+ * WCAG "large scale" text: 18pt regular / 14pt bold, i.e. >=24px / >=18.5px bold
124
+ * (1pt = 4/3 px). This boundary is the most common source of disagreement
125
+ * between contrast tools; 18.5 rather than 18.66 matches axe-core.
126
+ */
127
+ export function isLargeText(fontSizePx, bold = false) {
128
+ const px = Number(fontSizePx);
129
+ if (!Number.isFinite(px)) return false;
130
+ return bold ? px >= 18.5 : px >= 24;
131
+ }
132
+
133
+ /** Is a CSS font-weight value bold for the purposes of SC 1.4.3? */
134
+ export function isBoldWeight(weight) {
135
+ if (weight === 'bold' || weight === 'bolder') return true;
136
+ const n = Number(weight);
137
+ return Number.isFinite(n) && n >= 700;
138
+ }
139
+
140
+ /**
141
+ * Full contrast verdict for a foreground/background pair.
142
+ *
143
+ * @param {string|object} fgInput CSS color string or parsed color
144
+ * @param {string|object} bgInput
145
+ * @param {{large?:boolean, ui?:boolean, pageBackground?:string}} [opts]
146
+ */
147
+ export function checkContrast(fgInput, bgInput, opts = {}) {
148
+ const { large = false, ui = false, pageBackground = '#ffffff' } = opts;
149
+ const fgRaw = typeof fgInput === 'string' ? parseColor(fgInput) : fgInput;
150
+ const bgRaw = typeof bgInput === 'string' ? parseColor(bgInput) : bgInput;
151
+ const page = typeof pageBackground === 'string' ? parseColor(pageBackground) : pageBackground;
152
+
153
+ // A translucent background composites over the page behind it first.
154
+ const bg = composite(bgRaw, page);
155
+ const fg = composite(fgRaw, bg);
156
+
157
+ const context = ui ? 'non-text' : large ? 'large' : 'normal';
158
+ const thresholds = ui ? THRESHOLDS.nonText : large ? THRESHOLDS.large : THRESHOLDS.normal;
159
+ const ratio = contrastRatio(fg, bg);
160
+
161
+ const passes = {};
162
+ for (const [level, min] of Object.entries(thresholds)) passes[level] = ratio >= min;
163
+
164
+ return {
165
+ fg: {
166
+ input: typeof fgInput === 'string' ? fgInput : fgRaw.hex,
167
+ hex: fgRaw.hex,
168
+ alpha: fgRaw.alpha ?? 1,
169
+ composited: fg.hex,
170
+ rgb: [fg.r, fg.g, fg.b],
171
+ },
172
+ bg: {
173
+ input: typeof bgInput === 'string' ? bgInput : bgRaw.hex,
174
+ hex: bgRaw.hex,
175
+ alpha: bgRaw.alpha ?? 1,
176
+ composited: bg.hex,
177
+ rgb: [bg.r, bg.g, bg.b],
178
+ },
179
+ context,
180
+ ratio,
181
+ ratioDisplay: formatRatio(ratio),
182
+ ratioTruncated: truncateRatio(ratio),
183
+ thresholds,
184
+ passes,
185
+ sc: CONTEXT_SC[context],
186
+ /** The threshold that has to be met for the requested context at Level AA. */
187
+ required: thresholds.AA,
188
+ };
189
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Cross-iteration diff — the convergence and oscillation detector.
3
+ *
4
+ * Matching is by fingerprint, which is why fingerprints exist. Without it a loop
5
+ * cannot tell "fixed" from "moved", cannot detect a regression, and cannot see
6
+ * the classic oscillation: fix A, break B, fix B, break A, forever.
7
+ *
8
+ * NEW violations are a regression and fail the command, even when the total
9
+ * count went down. A run that fixes six things and breaks one has broken one.
10
+ */
11
+
12
+ const byFingerprint = (findings = []) => new Map(findings.map((f) => [f.fingerprint, f]));
13
+
14
+ function diffBucket(before = [], after = []) {
15
+ const beforeMap = byFingerprint(before);
16
+ const afterMap = byFingerprint(after);
17
+ return {
18
+ fixed: [...beforeMap.values()].filter((f) => !afterMap.has(f.fingerprint)),
19
+ new: [...afterMap.values()].filter((f) => !beforeMap.has(f.fingerprint)),
20
+ remaining: [...afterMap.values()].filter((f) => beforeMap.has(f.fingerprint)),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * @param {object} before an a11y-loop JSON report
26
+ * @param {object} after
27
+ * @returns {{
28
+ * fixed:Array, new:Array, remaining:Array,
29
+ * needsReview:{fixed:Array,new:Array,remaining:Array},
30
+ * summary:{fixed:number,new:number,remaining:number,regression:boolean,converged:boolean,verdict:string}
31
+ * }}
32
+ */
33
+ export function diffReports(before, after) {
34
+ const violations = diffBucket(before?.findings?.violations, after?.findings?.violations);
35
+ const needsReview = diffBucket(before?.findings?.needsReview, after?.findings?.needsReview);
36
+
37
+ const regression = violations.new.length > 0;
38
+ const converged = !regression && violations.remaining.length === 0;
39
+
40
+ let verdict;
41
+ if (regression) {
42
+ verdict =
43
+ `REGRESSION: ${violations.new.length} new violation${violations.new.length === 1 ? '' : 's'} ` +
44
+ `introduced (${violations.fixed.length} fixed, ${violations.remaining.length} still present)`;
45
+ } else if (converged) {
46
+ verdict =
47
+ violations.fixed.length > 0
48
+ ? `Converged: all ${violations.fixed.length} violation${violations.fixed.length === 1 ? '' : 's'} fixed, none introduced`
49
+ : 'No violations in either report';
50
+ } else {
51
+ verdict =
52
+ `Progress: ${violations.fixed.length} fixed, ${violations.remaining.length} still present, ` +
53
+ 'none introduced';
54
+ }
55
+
56
+ return {
57
+ ...violations,
58
+ needsReview,
59
+ summary: {
60
+ fixed: violations.fixed.length,
61
+ new: violations.new.length,
62
+ remaining: violations.remaining.length,
63
+ needsReviewFixed: needsReview.fixed.length,
64
+ needsReviewNew: needsReview.new.length,
65
+ regression,
66
+ converged,
67
+ verdict,
68
+ },
69
+ };
70
+ }
71
+
72
+ const label = (finding) => {
73
+ const sc = finding.wcag?.sc ? `SC ${finding.wcag.sc}` : '—';
74
+ return { sc, rule: finding.ruleId, selector: finding.selector || '(document)' };
75
+ };
76
+
77
+ /** Compact fixed-width table plus the one-line verdict. */
78
+ export function formatDiffHuman(diff, { beforePath, afterPath } = {}) {
79
+ const lines = [];
80
+ lines.push(`a11y-loop diff · before: ${beforePath ?? 'before'} · after: ${afterPath ?? 'after'}`);
81
+
82
+ const rows = [
83
+ ...diff.fixed.map((f) => ({ status: 'FIXED', ...label(f) })),
84
+ ...diff.new.map((f) => ({ status: 'NEW', ...label(f) })),
85
+ ...diff.remaining.map((f) => ({ status: 'REMAINING', ...label(f) })),
86
+ ];
87
+
88
+ if (rows.length === 0) {
89
+ lines.push('', 'No violations in either report.');
90
+ } else {
91
+ const width = (key, min) => Math.max(min, ...rows.map((r) => r[key].length));
92
+ const statusWidth = width('status', 9);
93
+ const scWidth = width('sc', 8);
94
+ const ruleWidth = Math.min(28, width('rule', 4));
95
+ lines.push('');
96
+ for (const row of rows) {
97
+ lines.push(
98
+ [
99
+ row.status.padEnd(statusWidth),
100
+ row.sc.padEnd(scWidth),
101
+ row.rule.slice(0, ruleWidth).padEnd(ruleWidth),
102
+ row.selector,
103
+ ].join(' '),
104
+ );
105
+ }
106
+ }
107
+
108
+ if (diff.summary.needsReviewNew > 0) {
109
+ lines.push(
110
+ '',
111
+ `${diff.summary.needsReviewNew} new needs-review finding(s) — not blocking, but new.`,
112
+ );
113
+ }
114
+
115
+ lines.push('', diff.summary.verdict);
116
+ lines.push('');
117
+ return lines.join('\n');
118
+ }