@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 +156 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/lib/halo-detector.d.ts +40 -0
- package/dist/lib/halo-detector.d.ts.map +1 -0
- package/dist/lib/halo-detector.js +238 -0
- package/dist/lib/pixel-analysis.d.ts +51 -0
- package/dist/lib/pixel-analysis.d.ts.map +1 -0
- package/dist/lib/pixel-analysis.js +121 -0
- package/dist/lib/screenshot.d.ts +44 -0
- package/dist/lib/screenshot.d.ts.map +1 -0
- package/dist/lib/screenshot.js +181 -0
- package/dist/lib/types.d.ts +79 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +7 -0
- package/dist/lib/visual-analyzer.d.ts +42 -0
- package/dist/lib/visual-analyzer.d.ts.map +1 -0
- package/dist/lib/visual-analyzer.js +154 -0
- package/package.json +51 -0
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)
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|