@a11y-oracle/visual-engine 1.0.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 ADDED
@@ -0,0 +1,156 @@
1
+ # @a11y-oracle/visual-engine
2
+
3
+ Visual pixel analysis engine for resolving incomplete color contrast warnings from axe-core. Provides CSS halo heuristic detection, CDP-based screenshot capture, and pixel-level luminance analysis using the WCAG Safe Assessment Matrix.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @a11y-oracle/visual-engine
9
+ ```
10
+
11
+ > **Note:** Most users should use [`@a11y-oracle/axe-bridge`](../axe-bridge/README.md) instead, which wraps this engine and integrates directly with axe-core results. Install the visual-engine directly only if you need fine-grained control over individual analysis steps or are building a custom integration.
12
+
13
+ ## Usage
14
+
15
+ ### VisualContrastAnalyzer (Recommended)
16
+
17
+ The coordinator class runs the full pipeline for a single element:
18
+
19
+ ```typescript
20
+ import { VisualContrastAnalyzer } from '@a11y-oracle/visual-engine';
21
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
22
+
23
+ const analyzer = new VisualContrastAnalyzer(cdpSession);
24
+
25
+ const result = await analyzer.analyzeElement('#hero-text', 4.5);
26
+
27
+ switch (result.category) {
28
+ case 'pass': // Worst-case contrast passes threshold
29
+ case 'violation': // Best-case contrast fails threshold
30
+ case 'incomplete': // Split decision, dynamic content, or unresolvable
31
+ }
32
+ ```
33
+
34
+ ### Individual Pipeline Steps
35
+
36
+ Each stage of the pipeline is also exported for advanced use:
37
+
38
+ ```typescript
39
+ import {
40
+ getElementStyles,
41
+ captureElementBackground,
42
+ analyzeHalo,
43
+ extractPixelLuminance,
44
+ } from '@a11y-oracle/visual-engine';
45
+
46
+ // 1. Get computed styles via CDP
47
+ const styles = await getElementStyles(cdp, '#my-element');
48
+
49
+ // 2. CSS halo fast path (no screenshot needed)
50
+ const halo = analyzeHalo(styles, 4.5);
51
+ if (halo.hasValidHalo) {
52
+ // Element has a valid text stroke or shadow halo
53
+ }
54
+
55
+ // 3. Capture background with text hidden
56
+ const capture = await captureElementBackground(cdp, '#my-element');
57
+
58
+ // 4. Pixel-level luminance analysis
59
+ const pixels = extractPixelLuminance(capture.pngBuffer, capture.textColor);
60
+ ```
61
+
62
+ ## Analysis Pipeline
63
+
64
+ The `VisualContrastAnalyzer.analyzeElement()` method runs this pipeline:
65
+
66
+ 1. **Get Computed Styles** — Fetches `color`, `backgroundColor`, `textStrokeWidth`, `textStrokeColor`, `textShadow`, and `backgroundImage` via CDP `Runtime.evaluate`.
67
+
68
+ 2. **Dynamic Content Check** — Detects video/canvas ancestors, sub-1 opacity, and CSS blend modes. Dynamic content is left as `incomplete`.
69
+
70
+ 3. **CSS Halo Heuristic** (fast path) — Checks for:
71
+ - `-webkit-text-stroke` >= 1px with sufficient contrast against the background
72
+ - `text-shadow` with 4+ zero-blur directional shadows covering all quadrants
73
+
74
+ If a valid halo is found and its color passes the threshold against the background, the element passes without a screenshot.
75
+
76
+ 4. **Screenshot Capture** — Hides the element's text (sets `color: transparent`), captures a clipped screenshot via CDP `Page.captureScreenshot`, then restores the text.
77
+
78
+ 5. **Pixel Analysis** — Decodes the PNG, scans all opaque pixels for luminance extremes (lightest and darkest), and computes contrast ratios against the text color.
79
+
80
+ 6. **Safe Assessment Matrix**:
81
+ - **Pass**: Text contrast against the *lightest* AND *darkest* background pixels both meet the threshold (worst-case passes)
82
+ - **Violation**: Text contrast against the *lightest* AND *darkest* background pixels both fail the threshold (best-case fails)
83
+ - **Incomplete**: One passes and one fails (split decision) — cannot safely categorize
84
+
85
+ ## API Reference
86
+
87
+ ### VisualContrastAnalyzer
88
+
89
+ #### `constructor(cdp: CDPSessionLike)`
90
+
91
+ Create an analyzer bound to a CDP session.
92
+
93
+ #### `analyzeElement(selector, threshold?): Promise<ContrastAnalysisResult>`
94
+
95
+ Run the full pipeline on an element.
96
+
97
+ - **selector** — CSS selector targeting the element
98
+ - **threshold** — Minimum contrast ratio (default: 4.5)
99
+ - **Returns** — `ContrastAnalysisResult` with `category`, `textColor`, `halo`, `pixels`, and `reason`
100
+
101
+ ### Halo Detection
102
+
103
+ #### `analyzeHalo(styles, threshold?): HaloResult`
104
+
105
+ Check if computed styles contain a valid CSS halo.
106
+
107
+ - **styles** — `ElementComputedStyles` from `getElementStyles()`
108
+ - **threshold** — Minimum contrast ratio (default: 4.5)
109
+ - **Returns** — `HaloResult` with `hasValidHalo`, `haloContrast`, `method`, and `skipReason`
110
+
111
+ #### `parseTextShadow(css): TextShadowPart[]`
112
+
113
+ Parse a CSS `text-shadow` value into structured parts.
114
+
115
+ ### Pixel Analysis
116
+
117
+ #### `extractPixelLuminance(pngBuffer, textColor): PixelAnalysisResult | null`
118
+
119
+ Decode a PNG and compute contrast ratios against a text color.
120
+
121
+ - **pngBuffer** — Raw PNG buffer from `captureElementBackground()`
122
+ - **textColor** — `RGBColor` of the foreground text
123
+ - **Returns** — Luminance extremes and contrast ratios, or `null` if no opaque pixels
124
+
125
+ #### `decodePng(buffer): { width, height, data: Uint8Array }`
126
+
127
+ Decode a PNG buffer into raw RGBA pixel data.
128
+
129
+ ### Screenshot Capture
130
+
131
+ #### `getElementStyles(cdp, selector): Promise<ElementComputedStyles | null>`
132
+
133
+ Fetch computed styles for an element via CDP.
134
+
135
+ #### `captureElementBackground(cdp, selector): Promise<{ pngBuffer: Buffer; textColor: RGBColor | null } | null>`
136
+
137
+ Capture a clipped screenshot of an element with its text hidden.
138
+
139
+ ## Types
140
+
141
+ ```typescript
142
+ import type {
143
+ ContrastAnalysisResult, // Full analysis result
144
+ ContrastCategory, // 'pass' | 'violation' | 'incomplete'
145
+ HaloResult, // CSS halo analysis result
146
+ PixelAnalysisResult, // Luminance extremes + contrast ratios
147
+ TextShadowPart, // Parsed text-shadow entry
148
+ ElementComputedStyles, // Computed CSS properties for contrast analysis
149
+ } from '@a11y-oracle/visual-engine';
150
+ ```
151
+
152
+ ## Dependencies
153
+
154
+ - **`@a11y-oracle/focus-analyzer`** — Reuses `relativeLuminance()`, `contrastRatio()`, and `parseColor()` for WCAG-compliant color math
155
+ - **`@a11y-oracle/cdp-types`** — `CDPSessionLike` interface for framework-agnostic CDP access
156
+ - **`fast-png`** — Pure TypeScript PNG decoder/encoder (browser + Node.js compatible, no native dependencies)
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @module @a11y-oracle/visual-engine
3
+ *
4
+ * Visual pixel analysis engine for resolving incomplete color contrast
5
+ * warnings. Provides CSS halo heuristic detection, CDP-based screenshot
6
+ * capture, and pixel-level luminance analysis using the WCAG Safe
7
+ * Assessment Matrix.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export { VisualContrastAnalyzer } from './lib/visual-analyzer.js';
12
+ export { analyzeHalo, parseTextShadow } from './lib/halo-detector.js';
13
+ export { extractPixelLuminance, decodePng } from './lib/pixel-analysis.js';
14
+ export { captureElementBackground, getElementStyles } from './lib/screenshot.js';
15
+ export type { ContrastAnalysisResult, ContrastCategory, HaloResult, PixelAnalysisResult, TextShadowPart, ElementComputedStyles, } from './lib/types.js';
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,qBAAqB,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAC3E,OAAO,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACjF,YAAY,EACV,sBAAsB,EACtB,gBAAgB,EAChB,UAAU,EACV,mBAAmB,EACnB,cAAc,EACd,qBAAqB,GACtB,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module @a11y-oracle/visual-engine
3
+ *
4
+ * Visual pixel analysis engine for resolving incomplete color contrast
5
+ * warnings. Provides CSS halo heuristic detection, CDP-based screenshot
6
+ * capture, and pixel-level luminance analysis using the WCAG Safe
7
+ * Assessment Matrix.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export { VisualContrastAnalyzer } from './lib/visual-analyzer.js';
12
+ export { analyzeHalo, parseTextShadow } from './lib/halo-detector.js';
13
+ export { extractPixelLuminance, decodePng } from './lib/pixel-analysis.js';
14
+ export { captureElementBackground, getElementStyles } from './lib/screenshot.js';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @module halo-detector
3
+ *
4
+ * CSS halo heuristic for WCAG color contrast. WCAG allows a text
5
+ * stroke or shadow to serve as the contrast boundary. This module
6
+ * evaluates computed CSS styles to determine if a valid halo exists
7
+ * before falling back to the more expensive pixel analysis pipeline.
8
+ */
9
+ import type { ElementComputedStyles, HaloResult, TextShadowPart } from './types.js';
10
+ /**
11
+ * Analyze an element's computed styles for a valid CSS halo.
12
+ *
13
+ * A halo is valid when:
14
+ * - `-webkit-text-stroke-width` >= 1px with a stroke color that passes
15
+ * the contrast threshold against the background, OR
16
+ * - `text-shadow` has 0-blur, multi-directional coverage (4+ quadrants),
17
+ * and a shadow color that passes the threshold.
18
+ *
19
+ * If the background is complex (transparent, gradient, or image),
20
+ * the halo check is skipped entirely.
21
+ *
22
+ * @param styles - Computed CSS styles from the element.
23
+ * @param threshold - Minimum contrast ratio. Default: 4.5 (WCAG AA).
24
+ */
25
+ export declare function analyzeHalo(styles: ElementComputedStyles, threshold?: number): HaloResult;
26
+ /**
27
+ * Parse a CSS `text-shadow` value into structured parts.
28
+ *
29
+ * Handles the standard CSS syntax where each shadow is:
30
+ * `[color] <offset-x> <offset-y> [blur-radius]`
31
+ * or
32
+ * `<offset-x> <offset-y> [blur-radius] [color]`
33
+ *
34
+ * Multiple shadows are comma-separated.
35
+ *
36
+ * @param css - The raw `text-shadow` CSS value.
37
+ * @returns Array of parsed shadow parts. Empty if `none` or unparseable.
38
+ */
39
+ export declare function parseTextShadow(css: string): TextShadowPart[];
40
+ //# sourceMappingURL=halo-detector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"halo-detector.d.ts","sourceRoot":"","sources":["../../src/lib/halo-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,qBAAqB,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAKpF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,qBAAqB,EAC7B,SAAS,GAAE,MAA0B,GACpC,UAAU,CA2BZ;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,EAAE,CAY7D"}
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @module halo-detector
3
+ *
4
+ * CSS halo heuristic for WCAG color contrast. WCAG allows a text
5
+ * stroke or shadow to serve as the contrast boundary. This module
6
+ * evaluates computed CSS styles to determine if a valid halo exists
7
+ * before falling back to the more expensive pixel analysis pipeline.
8
+ */
9
+ import { parseColor, contrastRatio } from '@a11y-oracle/focus-analyzer';
10
+ /** Default WCAG AA contrast threshold for normal text. */
11
+ const DEFAULT_THRESHOLD = 4.5;
12
+ /**
13
+ * Analyze an element's computed styles for a valid CSS halo.
14
+ *
15
+ * A halo is valid when:
16
+ * - `-webkit-text-stroke-width` >= 1px with a stroke color that passes
17
+ * the contrast threshold against the background, OR
18
+ * - `text-shadow` has 0-blur, multi-directional coverage (4+ quadrants),
19
+ * and a shadow color that passes the threshold.
20
+ *
21
+ * If the background is complex (transparent, gradient, or image),
22
+ * the halo check is skipped entirely.
23
+ *
24
+ * @param styles - Computed CSS styles from the element.
25
+ * @param threshold - Minimum contrast ratio. Default: 4.5 (WCAG AA).
26
+ */
27
+ export function analyzeHalo(styles, threshold = DEFAULT_THRESHOLD) {
28
+ const noHalo = {
29
+ hasValidHalo: false,
30
+ haloContrast: null,
31
+ method: null,
32
+ skipReason: null,
33
+ };
34
+ // Check if background is complex (can't resolve halo via CSS alone)
35
+ if (isBackgroundComplex(styles)) {
36
+ return { ...noHalo, skipReason: 'complex-background' };
37
+ }
38
+ const bgColor = parseColor(styles.backgroundColor);
39
+ if (!bgColor) {
40
+ return { ...noHalo, skipReason: 'unparseable-background' };
41
+ }
42
+ // Check text-stroke first (simpler, more reliable)
43
+ const strokeResult = checkStroke(styles, bgColor, threshold);
44
+ if (strokeResult)
45
+ return strokeResult;
46
+ // Check text-shadow
47
+ const shadowResult = checkShadow(styles, bgColor, threshold);
48
+ if (shadowResult)
49
+ return shadowResult;
50
+ return noHalo;
51
+ }
52
+ /**
53
+ * Parse a CSS `text-shadow` value into structured parts.
54
+ *
55
+ * Handles the standard CSS syntax where each shadow is:
56
+ * `[color] <offset-x> <offset-y> [blur-radius]`
57
+ * or
58
+ * `<offset-x> <offset-y> [blur-radius] [color]`
59
+ *
60
+ * Multiple shadows are comma-separated.
61
+ *
62
+ * @param css - The raw `text-shadow` CSS value.
63
+ * @returns Array of parsed shadow parts. Empty if `none` or unparseable.
64
+ */
65
+ export function parseTextShadow(css) {
66
+ if (!css || css.trim().toLowerCase() === 'none')
67
+ return [];
68
+ const shadows = splitShadows(css);
69
+ const parts = [];
70
+ for (const shadow of shadows) {
71
+ const parsed = parseSingleShadow(shadow.trim());
72
+ if (parsed)
73
+ parts.push(parsed);
74
+ }
75
+ return parts;
76
+ }
77
+ /**
78
+ * Check if the background is too complex for CSS-only halo analysis.
79
+ */
80
+ function isBackgroundComplex(styles) {
81
+ // Background image present (gradient, url(), etc.)
82
+ if (styles.backgroundImage && styles.backgroundImage !== 'none') {
83
+ return true;
84
+ }
85
+ // Background color is transparent or semi-transparent
86
+ const bgColor = parseColor(styles.backgroundColor);
87
+ if (!bgColor || bgColor.a < 1) {
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+ /**
93
+ * Check if `-webkit-text-stroke` qualifies as a valid halo.
94
+ */
95
+ function checkStroke(styles, bgColor, threshold) {
96
+ const width = parseFloat(styles.textStrokeWidth);
97
+ if (isNaN(width) || width < 1)
98
+ return null;
99
+ const strokeColor = parseColor(styles.textStrokeColor);
100
+ if (!strokeColor || strokeColor.a < 1)
101
+ return null;
102
+ const cr = contrastRatio(strokeColor, bgColor);
103
+ if (cr >= threshold) {
104
+ return {
105
+ hasValidHalo: true,
106
+ haloContrast: cr,
107
+ method: 'stroke',
108
+ skipReason: null,
109
+ };
110
+ }
111
+ return null;
112
+ }
113
+ /**
114
+ * Check if `text-shadow` qualifies as a valid halo.
115
+ *
116
+ * Requirements:
117
+ * 1. All shadows must have blur radius of 0.
118
+ * 2. Shadows must cover at least 4 directions (all quadrants).
119
+ * 3. All shadow colors must pass the contrast threshold.
120
+ */
121
+ function checkShadow(styles, bgColor, threshold) {
122
+ const parts = parseTextShadow(styles.textShadow);
123
+ if (parts.length === 0)
124
+ return null;
125
+ // Any blur > 0 disqualifies the entire shadow as a halo
126
+ if (parts.some((p) => p.blur > 0))
127
+ return null;
128
+ // Must cover all 4 quadrants
129
+ if (!coversAllQuadrants(parts))
130
+ return null;
131
+ // All shadow colors must pass contrast
132
+ let minContrast = Infinity;
133
+ for (const part of parts) {
134
+ if (part.color.a < 1)
135
+ return null;
136
+ const cr = contrastRatio(part.color, bgColor);
137
+ minContrast = Math.min(minContrast, cr);
138
+ }
139
+ if (minContrast >= threshold) {
140
+ return {
141
+ hasValidHalo: true,
142
+ haloContrast: minContrast,
143
+ method: 'shadow',
144
+ skipReason: null,
145
+ };
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * Check if shadow parts cover all 4 directional quadrants
151
+ * (top-left, top-right, bottom-left, bottom-right).
152
+ */
153
+ function coversAllQuadrants(parts) {
154
+ let hasTopLeft = false;
155
+ let hasTopRight = false;
156
+ let hasBottomLeft = false;
157
+ let hasBottomRight = false;
158
+ for (const p of parts) {
159
+ if (p.offsetX <= 0 && p.offsetY <= 0)
160
+ hasTopLeft = true;
161
+ if (p.offsetX >= 0 && p.offsetY <= 0)
162
+ hasTopRight = true;
163
+ if (p.offsetX <= 0 && p.offsetY >= 0)
164
+ hasBottomLeft = true;
165
+ if (p.offsetX >= 0 && p.offsetY >= 0)
166
+ hasBottomRight = true;
167
+ }
168
+ return hasTopLeft && hasTopRight && hasBottomLeft && hasBottomRight;
169
+ }
170
+ /**
171
+ * Split a CSS text-shadow value by commas, respecting parentheses.
172
+ * Commas inside `rgb()` / `rgba()` are not treated as separators.
173
+ */
174
+ function splitShadows(css) {
175
+ const parts = [];
176
+ let depth = 0;
177
+ let start = 0;
178
+ for (let i = 0; i < css.length; i++) {
179
+ if (css[i] === '(')
180
+ depth++;
181
+ else if (css[i] === ')')
182
+ depth--;
183
+ else if (css[i] === ',' && depth === 0) {
184
+ parts.push(css.substring(start, i));
185
+ start = i + 1;
186
+ }
187
+ }
188
+ parts.push(css.substring(start));
189
+ return parts;
190
+ }
191
+ /**
192
+ * Parse a single text-shadow value (one of possibly many comma-separated).
193
+ *
194
+ * Format: `[color] <offset-x> <offset-y> [blur] [color]`
195
+ * The color can appear before or after the numeric values.
196
+ */
197
+ function parseSingleShadow(raw) {
198
+ if (!raw)
199
+ return null;
200
+ let remaining = raw;
201
+ let color = null;
202
+ // Extract color first — it can be rgb()/rgba() or #hex
203
+ // Try rgb()/rgba() first
204
+ const rgbRegex = /rgba?\([^)]+\)/i;
205
+ const rgbMatch = remaining.match(rgbRegex);
206
+ if (rgbMatch) {
207
+ color = parseColor(rgbMatch[0]);
208
+ remaining = remaining.replace(rgbMatch[0], '');
209
+ }
210
+ else {
211
+ // Try hex color
212
+ const hexRegex = /#[0-9a-f]{3,8}/i;
213
+ const hexMatch = remaining.match(hexRegex);
214
+ if (hexMatch) {
215
+ color = parseColor(hexMatch[0]);
216
+ remaining = remaining.replace(hexMatch[0], '');
217
+ }
218
+ }
219
+ // Extract numeric values from the remainder (px values)
220
+ const numericRegex = /-?[\d.]+(?:px)?/g;
221
+ const numbers = [];
222
+ let numericMatch;
223
+ while ((numericMatch = numericRegex.exec(remaining)) !== null) {
224
+ numbers.push(parseFloat(numericMatch[0]));
225
+ }
226
+ // Need at least 2 numbers (offsetX, offsetY)
227
+ if (numbers.length < 2)
228
+ return null;
229
+ const offsetX = numbers[0];
230
+ const offsetY = numbers[1];
231
+ const blur = numbers.length >= 3 ? numbers[2] : 0;
232
+ // Default to black if no color found
233
+ if (!color)
234
+ color = parseColor('#000000');
235
+ if (!color)
236
+ return null;
237
+ return { offsetX, offsetY, blur, color };
238
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @module pixel-analysis
3
+ *
4
+ * PNG decoding and pixel-level WCAG luminance analysis. Scans a
5
+ * background screenshot to find the lightest and darkest pixels,
6
+ * then calculates contrast ratios against the foreground text color.
7
+ */
8
+ import type { RGBColor } from '@a11y-oracle/focus-analyzer';
9
+ import type { PixelAnalysisResult } from './types.js';
10
+ /**
11
+ * Decode a PNG buffer into raw pixel data.
12
+ *
13
+ * @param buffer - A PNG-encoded buffer (e.g. from CDP screenshot).
14
+ * @returns Width, height, and RGBA pixel data (4 bytes per pixel).
15
+ */
16
+ export declare function decodePng(input: Uint8Array): {
17
+ width: number;
18
+ height: number;
19
+ data: Uint8Array;
20
+ };
21
+ /**
22
+ * Scan an RGBA pixel buffer to find the lightest and darkest pixels
23
+ * by relative luminance.
24
+ *
25
+ * Skips pixels with alpha < 255 (semi-transparent or fully transparent).
26
+ *
27
+ * @param data - RGBA pixel data (4 bytes per pixel).
28
+ * @param width - Image width in pixels.
29
+ * @param height - Image height in pixels.
30
+ * @returns The extreme luminance values and their corresponding colors,
31
+ * or null if no opaque pixels were found.
32
+ */
33
+ export declare function findLuminanceExtremes(data: Uint8Array, width: number, height: number): {
34
+ lMax: number;
35
+ lMin: number;
36
+ lMaxColor: RGBColor;
37
+ lMinColor: RGBColor;
38
+ pixelCount: number;
39
+ } | null;
40
+ /**
41
+ * Decode a PNG screenshot and analyze pixel luminance against a text color.
42
+ *
43
+ * Computes contrast ratios of the text color against the lightest and
44
+ * darkest background pixels found in the image.
45
+ *
46
+ * @param pngBuffer - PNG-encoded screenshot of the element background.
47
+ * @param textColor - The foreground text color to check contrast against.
48
+ * @returns Pixel analysis result, or null if no opaque pixels were found.
49
+ */
50
+ export declare function extractPixelLuminance(pngBuffer: Uint8Array, textColor: RGBColor): PixelAnalysisResult | null;
51
+ //# sourceMappingURL=pixel-analysis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pixel-analysis.d.ts","sourceRoot":"","sources":["../../src/lib/pixel-analysis.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,UAAU,CAAC;CAClB,CAuCA;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,UAAU,EAChB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GACb;IACD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,QAAQ,CAAC;IACpB,SAAS,EAAE,QAAQ,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,IAAI,CAqCP;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,UAAU,EACrB,SAAS,EAAE,QAAQ,GAClB,mBAAmB,GAAG,IAAI,CAc5B"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @module pixel-analysis
3
+ *
4
+ * PNG decoding and pixel-level WCAG luminance analysis. Scans a
5
+ * background screenshot to find the lightest and darkest pixels,
6
+ * then calculates contrast ratios against the foreground text color.
7
+ */
8
+ import { decode } from 'fast-png';
9
+ import { relativeLuminance, contrastRatio, } from '@a11y-oracle/focus-analyzer';
10
+ /**
11
+ * Decode a PNG buffer into raw pixel data.
12
+ *
13
+ * @param buffer - A PNG-encoded buffer (e.g. from CDP screenshot).
14
+ * @returns Width, height, and RGBA pixel data (4 bytes per pixel).
15
+ */
16
+ export function decodePng(input) {
17
+ const png = decode(input);
18
+ const channels = png.channels ?? 4;
19
+ // fast-png returns data matching the PNG's channel count.
20
+ // Normalize to RGBA (4 bytes per pixel) for consistent downstream processing.
21
+ if (channels === 4) {
22
+ return {
23
+ width: png.width,
24
+ height: png.height,
25
+ data: new Uint8Array(png.data),
26
+ };
27
+ }
28
+ if (channels === 3) {
29
+ // RGB → RGBA: inject alpha = 255 for every pixel
30
+ const totalPixels = png.width * png.height;
31
+ const rgba = new Uint8Array(totalPixels * 4);
32
+ for (let i = 0; i < totalPixels; i++) {
33
+ rgba[i * 4] = png.data[i * 3];
34
+ rgba[i * 4 + 1] = png.data[i * 3 + 1];
35
+ rgba[i * 4 + 2] = png.data[i * 3 + 2];
36
+ rgba[i * 4 + 3] = 255;
37
+ }
38
+ return { width: png.width, height: png.height, data: rgba };
39
+ }
40
+ // Grayscale (1) or Grayscale+Alpha (2) — expand to RGBA
41
+ const totalPixels = png.width * png.height;
42
+ const rgba = new Uint8Array(totalPixels * 4);
43
+ for (let i = 0; i < totalPixels; i++) {
44
+ const gray = png.data[i * channels];
45
+ const alpha = channels === 2 ? png.data[i * channels + 1] : 255;
46
+ rgba[i * 4] = gray;
47
+ rgba[i * 4 + 1] = gray;
48
+ rgba[i * 4 + 2] = gray;
49
+ rgba[i * 4 + 3] = alpha;
50
+ }
51
+ return { width: png.width, height: png.height, data: rgba };
52
+ }
53
+ /**
54
+ * Scan an RGBA pixel buffer to find the lightest and darkest pixels
55
+ * by relative luminance.
56
+ *
57
+ * Skips pixels with alpha < 255 (semi-transparent or fully transparent).
58
+ *
59
+ * @param data - RGBA pixel data (4 bytes per pixel).
60
+ * @param width - Image width in pixels.
61
+ * @param height - Image height in pixels.
62
+ * @returns The extreme luminance values and their corresponding colors,
63
+ * or null if no opaque pixels were found.
64
+ */
65
+ export function findLuminanceExtremes(data, width, height) {
66
+ let lMax = -Infinity;
67
+ let lMin = Infinity;
68
+ let lMaxColor = { r: 0, g: 0, b: 0, a: 1 };
69
+ let lMinColor = { r: 0, g: 0, b: 0, a: 1 };
70
+ let pixelCount = 0;
71
+ const totalPixels = width * height;
72
+ for (let i = 0; i < totalPixels; i++) {
73
+ const offset = i * 4;
74
+ const a = data[offset + 3];
75
+ // Skip non-opaque pixels
76
+ if (a < 255)
77
+ continue;
78
+ const r = data[offset];
79
+ const g = data[offset + 1];
80
+ const b = data[offset + 2];
81
+ const color = { r, g, b, a: 1 };
82
+ const lum = relativeLuminance(color);
83
+ pixelCount++;
84
+ if (lum > lMax) {
85
+ lMax = lum;
86
+ lMaxColor = color;
87
+ }
88
+ if (lum < lMin) {
89
+ lMin = lum;
90
+ lMinColor = color;
91
+ }
92
+ }
93
+ if (pixelCount === 0)
94
+ return null;
95
+ return { lMax, lMin, lMaxColor, lMinColor, pixelCount };
96
+ }
97
+ /**
98
+ * Decode a PNG screenshot and analyze pixel luminance against a text color.
99
+ *
100
+ * Computes contrast ratios of the text color against the lightest and
101
+ * darkest background pixels found in the image.
102
+ *
103
+ * @param pngBuffer - PNG-encoded screenshot of the element background.
104
+ * @param textColor - The foreground text color to check contrast against.
105
+ * @returns Pixel analysis result, or null if no opaque pixels were found.
106
+ */
107
+ export function extractPixelLuminance(pngBuffer, textColor) {
108
+ const { width, height, data } = decodePng(pngBuffer);
109
+ const extremes = findLuminanceExtremes(data, width, height);
110
+ if (!extremes)
111
+ return null;
112
+ return {
113
+ lMax: extremes.lMax,
114
+ lMin: extremes.lMin,
115
+ lMaxColor: extremes.lMaxColor,
116
+ lMinColor: extremes.lMinColor,
117
+ crAgainstLightest: contrastRatio(textColor, extremes.lMaxColor),
118
+ crAgainstDarkest: contrastRatio(textColor, extremes.lMinColor),
119
+ pixelCount: extremes.pixelCount,
120
+ };
121
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @module screenshot
3
+ *
4
+ * CDP-based element screenshot capture with text hiding. Extracts
5
+ * computed styles and captures bounding-box screenshots of element
6
+ * backgrounds after temporarily making text invisible.
7
+ */
8
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
9
+ import type { RGBColor } from '@a11y-oracle/focus-analyzer';
10
+ import type { ElementComputedStyles } from './types.js';
11
+ /**
12
+ * Extract computed styles from an element for halo analysis.
13
+ *
14
+ * @param cdp - CDP session.
15
+ * @param selector - CSS selector targeting the element.
16
+ * @returns Computed styles, or null if element not found.
17
+ */
18
+ export declare function getElementStyles(cdp: CDPSessionLike, selector: string): Promise<ElementComputedStyles | null>;
19
+ /**
20
+ * Check if an element contains dynamic/temporal content that
21
+ * prevents reliable pixel analysis (video, canvas, opacity < 1, blend modes).
22
+ *
23
+ * @param cdp - CDP session.
24
+ * @param selector - CSS selector targeting the element.
25
+ * @returns true if the element has dynamic content.
26
+ */
27
+ export declare function isDynamicContent(cdp: CDPSessionLike, selector: string): Promise<boolean>;
28
+ /**
29
+ * Capture a bounding-box screenshot of an element's background
30
+ * after temporarily hiding its text content.
31
+ *
32
+ * The text is hidden via CSS injection (`color: transparent`) and
33
+ * restored in a `finally` block to ensure cleanup even on errors.
34
+ *
35
+ * @param cdp - CDP session.
36
+ * @param selector - CSS selector targeting the element.
37
+ * @returns PNG buffer of the background and the parsed text color,
38
+ * or null if the element was not found.
39
+ */
40
+ export declare function captureElementBackground(cdp: CDPSessionLike, selector: string): Promise<{
41
+ pngBuffer: Uint8Array;
42
+ textColor: RGBColor | null;
43
+ } | null>;
44
+ //# sourceMappingURL=screenshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../src/lib/screenshot.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA0GxD;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAOvC;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,SAAS,EAAE,UAAU,CAAC;IAAC,SAAS,EAAE,QAAQ,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAAC,CA4CvE"}
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @module screenshot
3
+ *
4
+ * CDP-based element screenshot capture with text hiding. Extracts
5
+ * computed styles and captures bounding-box screenshots of element
6
+ * backgrounds after temporarily making text invisible.
7
+ */
8
+ import { parseColor } from '@a11y-oracle/focus-analyzer';
9
+ /**
10
+ * Decode a base64 string to Uint8Array.
11
+ * Cross-platform: works in both Node.js (≥16) and browsers.
12
+ */
13
+ function base64ToUint8Array(base64) {
14
+ const binaryString = atob(base64);
15
+ const bytes = new Uint8Array(binaryString.length);
16
+ for (let i = 0; i < binaryString.length; i++) {
17
+ bytes[i] = binaryString.charCodeAt(i);
18
+ }
19
+ return bytes;
20
+ }
21
+ /**
22
+ * JavaScript expression to extract computed styles relevant to
23
+ * visual contrast analysis from a target element.
24
+ *
25
+ * @param selector - CSS selector string (will be JSON.stringify'd).
26
+ */
27
+ function getStylesScript(selector) {
28
+ return `
29
+ (() => {
30
+ const el = document.querySelector(${JSON.stringify(selector)});
31
+ if (!el) return null;
32
+ const cs = window.getComputedStyle(el);
33
+ return {
34
+ color: cs.color || '',
35
+ backgroundColor: cs.backgroundColor || '',
36
+ textStrokeWidth: cs.webkitTextStrokeWidth || cs.getPropertyValue('-webkit-text-stroke-width') || '0px',
37
+ textStrokeColor: cs.webkitTextStrokeColor || cs.getPropertyValue('-webkit-text-stroke-color') || '',
38
+ textShadow: cs.textShadow || 'none',
39
+ backgroundImage: cs.backgroundImage || 'none',
40
+ };
41
+ })()
42
+ `;
43
+ }
44
+ /**
45
+ * JavaScript expression to get the bounding rect and foreground color
46
+ * of a target element, then hide its text content.
47
+ *
48
+ * Returns the bounding rect and text color before hiding.
49
+ */
50
+ function getCaptureInfoAndHideScript(selector) {
51
+ return `
52
+ (() => {
53
+ const el = document.querySelector(${JSON.stringify(selector)});
54
+ if (!el) return null;
55
+ const cs = window.getComputedStyle(el);
56
+ const color = cs.color || '';
57
+ const rect = el.getBoundingClientRect();
58
+ const info = {
59
+ color,
60
+ x: rect.x,
61
+ y: rect.y,
62
+ width: rect.width,
63
+ height: rect.height,
64
+ };
65
+ // Hide text content to expose background
66
+ el.style.setProperty('color', 'transparent', 'important');
67
+ el.style.setProperty('text-shadow', 'none', 'important');
68
+ el.style.setProperty('caret-color', 'transparent', 'important');
69
+ return info;
70
+ })()
71
+ `;
72
+ }
73
+ /**
74
+ * JavaScript expression to restore text visibility on a target element.
75
+ */
76
+ function restoreTextScript(selector) {
77
+ return `
78
+ (() => {
79
+ const el = document.querySelector(${JSON.stringify(selector)});
80
+ if (!el) return null;
81
+ el.style.removeProperty('color');
82
+ el.style.removeProperty('text-shadow');
83
+ el.style.removeProperty('caret-color');
84
+ return true;
85
+ })()
86
+ `;
87
+ }
88
+ /**
89
+ * JavaScript expression to detect dynamic/temporal content that
90
+ * prevents reliable pixel analysis.
91
+ */
92
+ function isDynamicContentScript(selector) {
93
+ return `
94
+ (() => {
95
+ const el = document.querySelector(${JSON.stringify(selector)});
96
+ if (!el) return false;
97
+ // Check if element is or is inside a video/canvas
98
+ if (el.tagName === 'VIDEO' || el.tagName === 'CANVAS') return true;
99
+ if (el.closest('video, canvas')) return true;
100
+ // Check for semi-transparent element or blend modes
101
+ const cs = window.getComputedStyle(el);
102
+ if (parseFloat(cs.opacity) < 1) return true;
103
+ if (cs.mixBlendMode && cs.mixBlendMode !== 'normal') return true;
104
+ return false;
105
+ })()
106
+ `;
107
+ }
108
+ /**
109
+ * Extract computed styles from an element for halo analysis.
110
+ *
111
+ * @param cdp - CDP session.
112
+ * @param selector - CSS selector targeting the element.
113
+ * @returns Computed styles, or null if element not found.
114
+ */
115
+ export async function getElementStyles(cdp, selector) {
116
+ const result = (await cdp.send('Runtime.evaluate', {
117
+ expression: getStylesScript(selector),
118
+ returnByValue: true,
119
+ }));
120
+ return result.result.value;
121
+ }
122
+ /**
123
+ * Check if an element contains dynamic/temporal content that
124
+ * prevents reliable pixel analysis (video, canvas, opacity < 1, blend modes).
125
+ *
126
+ * @param cdp - CDP session.
127
+ * @param selector - CSS selector targeting the element.
128
+ * @returns true if the element has dynamic content.
129
+ */
130
+ export async function isDynamicContent(cdp, selector) {
131
+ const result = (await cdp.send('Runtime.evaluate', {
132
+ expression: isDynamicContentScript(selector),
133
+ returnByValue: true,
134
+ }));
135
+ return result.result.value;
136
+ }
137
+ /**
138
+ * Capture a bounding-box screenshot of an element's background
139
+ * after temporarily hiding its text content.
140
+ *
141
+ * The text is hidden via CSS injection (`color: transparent`) and
142
+ * restored in a `finally` block to ensure cleanup even on errors.
143
+ *
144
+ * @param cdp - CDP session.
145
+ * @param selector - CSS selector targeting the element.
146
+ * @returns PNG buffer of the background and the parsed text color,
147
+ * or null if the element was not found.
148
+ */
149
+ export async function captureElementBackground(cdp, selector) {
150
+ // Get info and hide text in one atomic operation
151
+ const infoResult = (await cdp.send('Runtime.evaluate', {
152
+ expression: getCaptureInfoAndHideScript(selector),
153
+ returnByValue: true,
154
+ }));
155
+ const info = infoResult.result.value;
156
+ if (!info || info.width <= 0 || info.height <= 0)
157
+ return null;
158
+ try {
159
+ // Capture screenshot of the background-only region
160
+ const screenshotResult = (await cdp.send('Page.captureScreenshot', {
161
+ format: 'png',
162
+ clip: {
163
+ x: info.x,
164
+ y: info.y,
165
+ width: info.width,
166
+ height: info.height,
167
+ scale: 1,
168
+ },
169
+ }));
170
+ const pngBuffer = base64ToUint8Array(screenshotResult.data);
171
+ const textColor = parseColor(info.color);
172
+ return { pngBuffer, textColor };
173
+ }
174
+ finally {
175
+ // Always restore text visibility
176
+ await cdp.send('Runtime.evaluate', {
177
+ expression: restoreTextScript(selector),
178
+ returnByValue: true,
179
+ });
180
+ }
181
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @module types
3
+ *
4
+ * Type definitions for visual contrast analysis, including halo
5
+ * detection, pixel luminance analysis, and the Safe Assessment Matrix.
6
+ */
7
+ import type { RGBColor } from '@a11y-oracle/focus-analyzer';
8
+ /** Classification of a contrast analysis result per the Safe Assessment Matrix. */
9
+ export type ContrastCategory = 'pass' | 'violation' | 'incomplete';
10
+ /** Parsed representation of a single CSS `text-shadow` declaration. */
11
+ export interface TextShadowPart {
12
+ /** Horizontal offset in pixels. */
13
+ offsetX: number;
14
+ /** Vertical offset in pixels. */
15
+ offsetY: number;
16
+ /** Blur radius in pixels. 0 = sharp edge (valid for halo). */
17
+ blur: number;
18
+ /** Parsed shadow color. */
19
+ color: RGBColor;
20
+ }
21
+ /** Result of CSS halo heuristic analysis. */
22
+ export interface HaloResult {
23
+ /** Whether a valid halo (stroke or shadow) was detected. */
24
+ hasValidHalo: boolean;
25
+ /** Contrast ratio of the halo color against the background, or null. */
26
+ haloContrast: number | null;
27
+ /** The halo method that passed: 'stroke', 'shadow', or null. */
28
+ method: 'stroke' | 'shadow' | null;
29
+ /** Reason the halo check was skipped, if applicable. */
30
+ skipReason: string | null;
31
+ }
32
+ /** Result of pixel-level luminance analysis on a background screenshot. */
33
+ export interface PixelAnalysisResult {
34
+ /** Relative luminance of the lightest pixel (0–1). */
35
+ lMax: number;
36
+ /** Relative luminance of the darkest pixel (0–1). */
37
+ lMin: number;
38
+ /** RGB color of the lightest pixel. */
39
+ lMaxColor: RGBColor;
40
+ /** RGB color of the darkest pixel. */
41
+ lMinColor: RGBColor;
42
+ /** Contrast ratio of text color against the lightest background pixel. */
43
+ crAgainstLightest: number;
44
+ /** Contrast ratio of text color against the darkest background pixel. */
45
+ crAgainstDarkest: number;
46
+ /** Total opaque pixels analyzed. */
47
+ pixelCount: number;
48
+ }
49
+ /** Computed CSS styles extracted from an element for halo analysis. */
50
+ export interface ElementComputedStyles {
51
+ /** Foreground text `color`. */
52
+ color: string;
53
+ /** Computed `background-color`. */
54
+ backgroundColor: string;
55
+ /** `-webkit-text-stroke-width` value. */
56
+ textStrokeWidth: string;
57
+ /** `-webkit-text-stroke-color` value. */
58
+ textStrokeColor: string;
59
+ /** `text-shadow` value. */
60
+ textShadow: string;
61
+ /** `background-image` value (e.g. `'none'` or `'linear-gradient(...)'`). */
62
+ backgroundImage: string;
63
+ }
64
+ /** Full result of the visual contrast analysis for a single element. */
65
+ export interface ContrastAnalysisResult {
66
+ /** CSS selector of the analyzed element. */
67
+ selector: string;
68
+ /** The Safe Assessment Matrix category outcome. */
69
+ category: ContrastCategory;
70
+ /** Foreground text color, or null if it could not be determined. */
71
+ textColor: RGBColor | null;
72
+ /** The halo analysis result (fast path). */
73
+ halo: HaloResult;
74
+ /** The pixel analysis result (slow path), or null if halo resolved it. */
75
+ pixels: PixelAnalysisResult | null;
76
+ /** Human-readable explanation of the decision. */
77
+ reason: string;
78
+ }
79
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAE5D,mFAAmF;AACnF,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,WAAW,GAAG,YAAY,CAAC;AAEnE,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,2BAA2B;IAC3B,KAAK,EAAE,QAAQ,CAAC;CACjB;AAED,6CAA6C;AAC7C,MAAM,WAAW,UAAU;IACzB,4DAA4D;IAC5D,YAAY,EAAE,OAAO,CAAC;IACtB,wEAAwE;IACxE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,gEAAgE;IAChE,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACnC,wDAAwD;IACxD,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,2EAA2E;AAC3E,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,SAAS,EAAE,QAAQ,CAAC;IACpB,sCAAsC;IACtC,SAAS,EAAE,QAAQ,CAAC;IACpB,0EAA0E;IAC1E,iBAAiB,EAAE,MAAM,CAAC;IAC1B,yEAAyE;IACzE,gBAAgB,EAAE,MAAM,CAAC;IACzB,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,uEAAuE;AACvE,MAAM,WAAW,qBAAqB;IACpC,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,eAAe,EAAE,MAAM,CAAC;IACxB,yCAAyC;IACzC,eAAe,EAAE,MAAM,CAAC;IACxB,yCAAyC;IACzC,eAAe,EAAE,MAAM,CAAC;IACxB,2BAA2B;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,wEAAwE;AACxE,MAAM,WAAW,sBAAsB;IACrC,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,mDAAmD;IACnD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,oEAAoE;IACpE,SAAS,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC3B,4CAA4C;IAC5C,IAAI,EAAE,UAAU,CAAC;IACjB,0EAA0E;IAC1E,MAAM,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACnC,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;CAChB"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @module types
3
+ *
4
+ * Type definitions for visual contrast analysis, including halo
5
+ * detection, pixel luminance analysis, and the Safe Assessment Matrix.
6
+ */
7
+ export {};
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @module visual-analyzer
3
+ *
4
+ * Coordinator class that runs the full visual contrast analysis pipeline:
5
+ * halo heuristic → dynamic content check → pixel analysis → Safe Assessment Matrix.
6
+ */
7
+ import type { CDPSessionLike } from '@a11y-oracle/cdp-types';
8
+ import type { ContrastAnalysisResult } from './types.js';
9
+ /**
10
+ * Visual contrast analyzer that coordinates the halo-then-pixel pipeline
11
+ * for resolving incomplete color contrast warnings.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const analyzer = new VisualContrastAnalyzer(cdpSession);
16
+ * const result = await analyzer.analyzeElement('#my-text', 4.5);
17
+ * if (result.category === 'pass') {
18
+ * // Safe to filter out
19
+ * }
20
+ * ```
21
+ */
22
+ export declare class VisualContrastAnalyzer {
23
+ private cdp;
24
+ constructor(cdp: CDPSessionLike);
25
+ /**
26
+ * Analyze a single element's color contrast using the full pipeline.
27
+ *
28
+ * Pipeline order:
29
+ * 1. Get computed styles
30
+ * 2. Check for dynamic/temporal content (video, canvas, opacity)
31
+ * 3. Try CSS halo heuristic (fast path)
32
+ * 4. Capture background screenshot with text hidden
33
+ * 5. Pixel-level luminance analysis
34
+ * 6. Apply Safe Assessment Matrix
35
+ *
36
+ * @param selector - CSS selector targeting the element.
37
+ * @param threshold - Minimum contrast ratio. Default: 4.5 (WCAG AA).
38
+ * @returns The analysis result with category, metrics, and explanation.
39
+ */
40
+ analyzeElement(selector: string, threshold?: number): Promise<ContrastAnalysisResult>;
41
+ }
42
+ //# sourceMappingURL=visual-analyzer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visual-analyzer.d.ts","sourceRoot":"","sources":["../../src/lib/visual-analyzer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,sBAAsB,EAAc,MAAM,YAAY,CAAC;AAmBrE;;;;;;;;;;;;GAYG;AACH,qBAAa,sBAAsB;IACrB,OAAO,CAAC,GAAG;gBAAH,GAAG,EAAE,cAAc;IAEvC;;;;;;;;;;;;;;OAcG;IACG,cAAc,CAClB,QAAQ,EAAE,MAAM,EAChB,SAAS,GAAE,MAA0B,GACpC,OAAO,CAAC,sBAAsB,CAAC;CAgHnC"}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @module visual-analyzer
3
+ *
4
+ * Coordinator class that runs the full visual contrast analysis pipeline:
5
+ * halo heuristic → dynamic content check → pixel analysis → Safe Assessment Matrix.
6
+ */
7
+ import { analyzeHalo } from './halo-detector.js';
8
+ import { extractPixelLuminance } from './pixel-analysis.js';
9
+ import { getElementStyles, captureElementBackground, isDynamicContent, } from './screenshot.js';
10
+ /** Default WCAG AA contrast threshold for normal text. */
11
+ const DEFAULT_THRESHOLD = 4.5;
12
+ const NO_HALO = {
13
+ hasValidHalo: false,
14
+ haloContrast: null,
15
+ method: null,
16
+ skipReason: null,
17
+ };
18
+ /**
19
+ * Visual contrast analyzer that coordinates the halo-then-pixel pipeline
20
+ * for resolving incomplete color contrast warnings.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const analyzer = new VisualContrastAnalyzer(cdpSession);
25
+ * const result = await analyzer.analyzeElement('#my-text', 4.5);
26
+ * if (result.category === 'pass') {
27
+ * // Safe to filter out
28
+ * }
29
+ * ```
30
+ */
31
+ export class VisualContrastAnalyzer {
32
+ cdp;
33
+ constructor(cdp) {
34
+ this.cdp = cdp;
35
+ }
36
+ /**
37
+ * Analyze a single element's color contrast using the full pipeline.
38
+ *
39
+ * Pipeline order:
40
+ * 1. Get computed styles
41
+ * 2. Check for dynamic/temporal content (video, canvas, opacity)
42
+ * 3. Try CSS halo heuristic (fast path)
43
+ * 4. Capture background screenshot with text hidden
44
+ * 5. Pixel-level luminance analysis
45
+ * 6. Apply Safe Assessment Matrix
46
+ *
47
+ * @param selector - CSS selector targeting the element.
48
+ * @param threshold - Minimum contrast ratio. Default: 4.5 (WCAG AA).
49
+ * @returns The analysis result with category, metrics, and explanation.
50
+ */
51
+ async analyzeElement(selector, threshold = DEFAULT_THRESHOLD) {
52
+ // Step 1: Get computed styles
53
+ const styles = await getElementStyles(this.cdp, selector);
54
+ if (!styles) {
55
+ return {
56
+ selector,
57
+ category: 'incomplete',
58
+ textColor: null,
59
+ halo: NO_HALO,
60
+ pixels: null,
61
+ reason: 'Element not found',
62
+ };
63
+ }
64
+ // Step 2: Check for dynamic/temporal content
65
+ const dynamic = await isDynamicContent(this.cdp, selector);
66
+ if (dynamic) {
67
+ return {
68
+ selector,
69
+ category: 'incomplete',
70
+ textColor: null,
71
+ halo: NO_HALO,
72
+ pixels: null,
73
+ reason: 'Dynamic or temporal content detected (video, canvas, opacity, or blend mode)',
74
+ };
75
+ }
76
+ // Step 3: Try halo heuristic (CSS-only fast path)
77
+ const halo = analyzeHalo(styles, threshold);
78
+ if (halo.hasValidHalo) {
79
+ return {
80
+ selector,
81
+ category: 'pass',
82
+ textColor: null,
83
+ halo,
84
+ pixels: null,
85
+ reason: `Valid CSS halo detected via ${halo.method} (contrast: ${halo.haloContrast?.toFixed(2)})`,
86
+ };
87
+ }
88
+ // Step 4: Capture background screenshot
89
+ const capture = await captureElementBackground(this.cdp, selector);
90
+ if (!capture) {
91
+ return {
92
+ selector,
93
+ category: 'incomplete',
94
+ textColor: null,
95
+ halo,
96
+ pixels: null,
97
+ reason: 'Could not capture element background',
98
+ };
99
+ }
100
+ if (!capture.textColor) {
101
+ return {
102
+ selector,
103
+ category: 'incomplete',
104
+ textColor: null,
105
+ halo,
106
+ pixels: null,
107
+ reason: 'Could not determine foreground text color',
108
+ };
109
+ }
110
+ // Step 5: Pixel-level luminance analysis
111
+ const pixels = extractPixelLuminance(capture.pngBuffer, capture.textColor);
112
+ if (!pixels) {
113
+ return {
114
+ selector,
115
+ category: 'incomplete',
116
+ textColor: capture.textColor,
117
+ halo,
118
+ pixels: null,
119
+ reason: 'No opaque pixels found in background screenshot',
120
+ };
121
+ }
122
+ // Step 6: Safe Assessment Matrix
123
+ const passesLightest = pixels.crAgainstLightest >= threshold;
124
+ const passesDarkest = pixels.crAgainstDarkest >= threshold;
125
+ if (passesLightest && passesDarkest) {
126
+ return {
127
+ selector,
128
+ category: 'pass',
129
+ textColor: capture.textColor,
130
+ halo,
131
+ pixels,
132
+ reason: `Passes worst-case contrast (lightest: ${pixels.crAgainstLightest.toFixed(2)}, darkest: ${pixels.crAgainstDarkest.toFixed(2)})`,
133
+ };
134
+ }
135
+ if (!passesLightest && !passesDarkest) {
136
+ return {
137
+ selector,
138
+ category: 'violation',
139
+ textColor: capture.textColor,
140
+ halo,
141
+ pixels,
142
+ reason: `Fails best-case contrast (lightest: ${pixels.crAgainstLightest.toFixed(2)}, darkest: ${pixels.crAgainstDarkest.toFixed(2)})`,
143
+ };
144
+ }
145
+ return {
146
+ selector,
147
+ category: 'incomplete',
148
+ textColor: capture.textColor,
149
+ halo,
150
+ pixels,
151
+ reason: `Split decision — passes one extreme but fails the other (lightest: ${pixels.crAgainstLightest.toFixed(2)}, darkest: ${pixels.crAgainstDarkest.toFixed(2)})`,
152
+ };
153
+ }
154
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@a11y-oracle/visual-engine",
3
+ "version": "1.0.0",
4
+ "description": "Visual pixel analysis engine for color contrast resolution using CDP screenshot capture and luminance analysis",
5
+ "license": "MIT",
6
+ "author": "a11y-oracle",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/a11y-oracle/a11y-oracle.git",
10
+ "directory": "libs/visual-engine"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/a11y-oracle/a11y-oracle/issues"
14
+ },
15
+ "homepage": "https://github.com/a11y-oracle/a11y-oracle/tree/main/libs/visual-engine",
16
+ "keywords": [
17
+ "accessibility",
18
+ "a11y",
19
+ "color-contrast",
20
+ "wcag",
21
+ "visual",
22
+ "pixel-analysis",
23
+ "cdp",
24
+ "testing"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "main": "./dist/index.js",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ "./package.json": "./package.json",
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js",
38
+ "default": "./dist/index.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "!**/*.tsbuildinfo"
44
+ ],
45
+ "dependencies": {
46
+ "@a11y-oracle/cdp-types": "1.0.0",
47
+ "@a11y-oracle/focus-analyzer": "1.0.0",
48
+ "fast-png": "^8.0.0",
49
+ "tslib": "^2.3.0"
50
+ }
51
+ }