chaincss 2.2.0 → 2.3.1
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 +240 -372
- package/dist/compiler/accessibility-engine.d.ts +57 -0
- package/dist/compiler/constraint-solver.d.ts +85 -0
- package/dist/compiler/intent-api.d.ts +73 -0
- package/dist/compiler/layout-intelligence.d.ts +71 -0
- package/dist/compiler/pass-manager.d.ts +157 -0
- package/dist/compiler/pattern-learner.d.ts +112 -0
- package/dist/compiler/responsive-inference.d.ts +63 -0
- package/dist/compiler/semantic-tokens.d.ts +57 -0
- package/dist/compiler/source-optimizer.d.ts +109 -0
- package/dist/compiler/style-ir.d.ts +183 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +3475 -0
- package/package.json +1 -1
- package/src/compiler/accessibility-engine.ts +502 -0
- package/src/compiler/constraint-solver.ts +407 -0
- package/src/compiler/intent-api.ts +505 -0
- package/src/compiler/layout-intelligence.ts +697 -0
- package/src/compiler/pass-manager.ts +657 -0
- package/src/compiler/pattern-learner.ts +398 -0
- package/src/compiler/responsive-inference.ts +415 -0
- package/src/compiler/semantic-tokens.ts +468 -0
- package/src/compiler/source-optimizer.ts +541 -0
- package/src/compiler/style-ir.ts +495 -0
- package/src/index.ts +175 -0
- package/ROADMAP.md +0 -31
package/package.json
CHANGED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// src/compiler/accessibility-engine.ts
|
|
2
|
+
/**
|
|
3
|
+
* Accessibility Intelligence Engine
|
|
4
|
+
*
|
|
5
|
+
* Build-time WCAG 2.2 compliance checking. Auto-fixes where possible.
|
|
6
|
+
* Runs only in build-time and hybrid-static modes. Zero runtime cost.
|
|
7
|
+
*
|
|
8
|
+
* Detectors:
|
|
9
|
+
* - Contrast ratio (extends design-orchestrator)
|
|
10
|
+
* - Minimum font size (12px)
|
|
11
|
+
* - Touch target size (44×44px)
|
|
12
|
+
* - Missing focus indicators
|
|
13
|
+
* - prefers-reduced-motion
|
|
14
|
+
* - Hover-only interactions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { StyleIR, IRRule, IRDeclaration, IRPass } from './style-ir.js';
|
|
18
|
+
import { contrastRatio } from './design-orchestrator.js';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Types
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export interface AccessibilityIssue {
|
|
25
|
+
ruleId: string;
|
|
26
|
+
selector: string;
|
|
27
|
+
category: 'contrast' | 'font-size' | 'touch-target' | 'focus' | 'motion' | 'hover-only' | 'color-only';
|
|
28
|
+
severity: 'error' | 'warning';
|
|
29
|
+
wcagCriterion: string;
|
|
30
|
+
message: string;
|
|
31
|
+
suggestion: string;
|
|
32
|
+
autoFixable: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AccessibilityReport {
|
|
36
|
+
issues: AccessibilityIssue[];
|
|
37
|
+
errorCount: number;
|
|
38
|
+
warningCount: number;
|
|
39
|
+
passedCount: number;
|
|
40
|
+
summary: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Constants
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
const WCAG = {
|
|
48
|
+
MIN_CONTRAST_AA: 4.5,
|
|
49
|
+
MIN_CONTRAST_AA_LARGE: 3.0,
|
|
50
|
+
MIN_CONTRAST_AAA: 7.0,
|
|
51
|
+
MIN_FONT_SIZE: 12, // px
|
|
52
|
+
MIN_TOUCH_TARGET: 44, // px
|
|
53
|
+
CRITERIA: {
|
|
54
|
+
contrast: '1.4.3 Contrast (Minimum) — AA',
|
|
55
|
+
fontSize: '1.4.4 Resize Text — AA',
|
|
56
|
+
touchTarget: '2.5.8 Target Size — AA',
|
|
57
|
+
focus: '2.4.7 Focus Visible — AA',
|
|
58
|
+
motion: '2.3.3 Animation from Interactions — AAA',
|
|
59
|
+
hoverOnly: '1.4.13 Content on Hover or Focus — AA',
|
|
60
|
+
colorOnly: '1.4.1 Use of Color — A',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Detectors
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detect insufficient contrast between color and background.
|
|
70
|
+
*/
|
|
71
|
+
function detectContrast(rule: IRRule): AccessibilityIssue[] {
|
|
72
|
+
const issues: AccessibilityIssue[] = [];
|
|
73
|
+
|
|
74
|
+
const color = rule.declarations.find(d =>
|
|
75
|
+
d.property === 'color' && typeof d.value === 'string'
|
|
76
|
+
);
|
|
77
|
+
const bg = rule.declarations.find(d =>
|
|
78
|
+
(d.property === 'backgroundColor' || d.property === 'background') &&
|
|
79
|
+
typeof d.value === 'string'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (color && bg) {
|
|
83
|
+
const ratio = contrastRatio(String(color.value), String(bg.value));
|
|
84
|
+
|
|
85
|
+
if (ratio > 0 && ratio < WCAG.MIN_CONTRAST_AA) {
|
|
86
|
+
issues.push({
|
|
87
|
+
ruleId: rule.id,
|
|
88
|
+
selector: rule.selector,
|
|
89
|
+
category: 'contrast',
|
|
90
|
+
severity: 'error',
|
|
91
|
+
wcagCriterion: WCAG.CRITERIA.contrast,
|
|
92
|
+
message: 'Contrast ratio ' + ratio.toFixed(1) + ':1 fails WCAG AA (needs ' + WCAG.MIN_CONTRAST_AA + ':1)',
|
|
93
|
+
suggestion: 'Darken text or lighten background. Current: ' + color.value + ' on ' + bg.value,
|
|
94
|
+
autoFixable: false, // Can't auto-fix without knowing design intent
|
|
95
|
+
});
|
|
96
|
+
} else if (ratio > 0 && ratio < WCAG.MIN_CONTRAST_AAA) {
|
|
97
|
+
issues.push({
|
|
98
|
+
ruleId: rule.id,
|
|
99
|
+
selector: rule.selector,
|
|
100
|
+
category: 'contrast',
|
|
101
|
+
severity: 'warning',
|
|
102
|
+
wcagCriterion: WCAG.CRITERIA.contrast,
|
|
103
|
+
message: 'Contrast ratio ' + ratio.toFixed(1) + ':1 passes AA but fails AAA (' + WCAG.MIN_CONTRAST_AAA + ':1)',
|
|
104
|
+
suggestion: 'Consider increasing contrast for better readability.',
|
|
105
|
+
autoFixable: false,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return issues;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detect font sizes below WCAG minimum.
|
|
115
|
+
*/
|
|
116
|
+
function detectMinimumFontSize(rule: IRRule): AccessibilityIssue[] {
|
|
117
|
+
const issues: AccessibilityIssue[] = [];
|
|
118
|
+
|
|
119
|
+
for (const decl of rule.declarations) {
|
|
120
|
+
if ((decl.property === 'fontSize' || decl.property === 'font-size') &&
|
|
121
|
+
typeof decl.value === 'string') {
|
|
122
|
+
const pxMatch = decl.value.match(/^(\d+(\.\d+)?)px$/);
|
|
123
|
+
if (pxMatch) {
|
|
124
|
+
const px = parseFloat(pxMatch[1]);
|
|
125
|
+
if (px < WCAG.MIN_FONT_SIZE) {
|
|
126
|
+
issues.push({
|
|
127
|
+
ruleId: rule.id,
|
|
128
|
+
selector: rule.selector,
|
|
129
|
+
category: 'font-size',
|
|
130
|
+
severity: 'warning',
|
|
131
|
+
wcagCriterion: WCAG.CRITERIA.fontSize,
|
|
132
|
+
message: 'font-size: ' + decl.value + ' is below WCAG minimum of ' + WCAG.MIN_FONT_SIZE + 'px',
|
|
133
|
+
suggestion: 'font-size: max(' + WCAG.MIN_FONT_SIZE + 'px, ' + decl.value + ')',
|
|
134
|
+
autoFixable: true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return issues;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Detect touch targets smaller than 44×44px.
|
|
146
|
+
*/
|
|
147
|
+
function detectTouchTargets(rule: IRRule): AccessibilityIssue[] {
|
|
148
|
+
const issues: AccessibilityIssue[] = [];
|
|
149
|
+
|
|
150
|
+
// Only check interactive elements (those with cursor: pointer or buttons)
|
|
151
|
+
const isInteractive = rule.declarations.some(d =>
|
|
152
|
+
d.property === 'cursor' && d.value === 'pointer'
|
|
153
|
+
);
|
|
154
|
+
const isButton = rule.selector.includes('btn') || rule.selector.includes('button');
|
|
155
|
+
const isLink = rule.selector.includes('link') || rule.selector.includes('a');
|
|
156
|
+
|
|
157
|
+
if (!isInteractive && !isButton && !isLink) return issues;
|
|
158
|
+
|
|
159
|
+
const width = rule.declarations.find(d =>
|
|
160
|
+
(d.property === 'width' || d.property === 'min-width') && typeof d.value === 'string'
|
|
161
|
+
);
|
|
162
|
+
const height = rule.declarations.find(d =>
|
|
163
|
+
(d.property === 'height' || d.property === 'min-height') && typeof d.value === 'string'
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const hasWidthIssue = width && extractPx(String(width.value)) < WCAG.MIN_TOUCH_TARGET;
|
|
167
|
+
const hasHeightIssue = height && extractPx(String(height.value)) < WCAG.MIN_TOUCH_TARGET;
|
|
168
|
+
|
|
169
|
+
if (hasWidthIssue || hasHeightIssue) {
|
|
170
|
+
issues.push({
|
|
171
|
+
ruleId: rule.id,
|
|
172
|
+
selector: rule.selector,
|
|
173
|
+
category: 'touch-target',
|
|
174
|
+
severity: 'warning',
|
|
175
|
+
wcagCriterion: WCAG.CRITERIA.touchTarget,
|
|
176
|
+
message: 'Interactive element "' + rule.selector + '" may be too small for touch (needs ≥ ' + WCAG.MIN_TOUCH_TARGET + '×' + WCAG.MIN_TOUCH_TARGET + 'px)',
|
|
177
|
+
suggestion: 'Add min-width: ' + WCAG.MIN_TOUCH_TARGET + 'px; min-height: ' + WCAG.MIN_TOUCH_TARGET + 'px;',
|
|
178
|
+
autoFixable: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return issues;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Detect missing focus indicators on interactive elements.
|
|
187
|
+
*/
|
|
188
|
+
function detectMissingFocus(rule: IRRule): AccessibilityIssue[] {
|
|
189
|
+
const issues: AccessibilityIssue[] = [];
|
|
190
|
+
|
|
191
|
+
const isInteractive = rule.declarations.some(d =>
|
|
192
|
+
d.property === 'cursor' && d.value === 'pointer'
|
|
193
|
+
);
|
|
194
|
+
if (!isInteractive) return issues;
|
|
195
|
+
|
|
196
|
+
// Check if outline is explicitly set to none
|
|
197
|
+
const outline = rule.declarations.find(d =>
|
|
198
|
+
d.property === 'outline'
|
|
199
|
+
);
|
|
200
|
+
const hasFocusStyle = rule.pseudoClasses.some(pc =>
|
|
201
|
+
(pc.name === 'focus' || pc.name === 'focus-visible') && pc.declarations.length > 0
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (outline && String(outline.value) === 'none' && !hasFocusStyle) {
|
|
205
|
+
issues.push({
|
|
206
|
+
ruleId: rule.id,
|
|
207
|
+
selector: rule.selector,
|
|
208
|
+
category: 'focus',
|
|
209
|
+
severity: 'error',
|
|
210
|
+
wcagCriterion: WCAG.CRITERIA.focus,
|
|
211
|
+
message: '"' + rule.selector + '" has outline: none with no :focus-visible fallback',
|
|
212
|
+
suggestion: 'Add .focusVisible(c => c.outline("2px solid #3b82f6").outlineOffset("2px"))',
|
|
213
|
+
autoFixable: false,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// If no outline and no focus styles at all
|
|
218
|
+
if (!outline && !hasFocusStyle && isInteractive) {
|
|
219
|
+
issues.push({
|
|
220
|
+
ruleId: rule.id,
|
|
221
|
+
selector: rule.selector,
|
|
222
|
+
category: 'focus',
|
|
223
|
+
severity: 'warning',
|
|
224
|
+
wcagCriterion: WCAG.CRITERIA.focus,
|
|
225
|
+
message: 'Interactive element "' + rule.selector + '" has no visible focus indicator',
|
|
226
|
+
suggestion: 'Add :focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }',
|
|
227
|
+
autoFixable: true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return issues;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Detect animations without prefers-reduced-motion respect.
|
|
236
|
+
*/
|
|
237
|
+
function detectMotionRespect(rule: IRRule): AccessibilityIssue[] {
|
|
238
|
+
const issues: AccessibilityIssue[] = [];
|
|
239
|
+
|
|
240
|
+
const hasAnimation = rule.declarations.some(d =>
|
|
241
|
+
(d.property === 'animation' || d.property === 'animation-name') &&
|
|
242
|
+
typeof d.value === 'string' && String(d.value) !== 'none'
|
|
243
|
+
);
|
|
244
|
+
const hasTransition = rule.declarations.some(d =>
|
|
245
|
+
d.property === 'transition' && typeof d.value === 'string'
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Check if motion is wrapped in a prefers-reduced-motion media query
|
|
249
|
+
const hasReducedMotionWrapper = rule.atRules.some(at =>
|
|
250
|
+
at.type === 'media' && at.query &&
|
|
251
|
+
at.query.includes('prefers-reduced-motion')
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
if ((hasAnimation || hasTransition) && !hasReducedMotionWrapper) {
|
|
255
|
+
issues.push({
|
|
256
|
+
ruleId: rule.id,
|
|
257
|
+
selector: rule.selector,
|
|
258
|
+
category: 'motion',
|
|
259
|
+
severity: 'warning',
|
|
260
|
+
wcagCriterion: WCAG.CRITERIA.motion,
|
|
261
|
+
message: 'Animations/transitions on "' + rule.selector + '" should respect prefers-reduced-motion',
|
|
262
|
+
suggestion: 'Wrap in @media (prefers-reduced-motion: no-preference) { ... }',
|
|
263
|
+
autoFixable: true,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return issues;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Detect hover-only interactions without focus fallback.
|
|
272
|
+
*/
|
|
273
|
+
function detectHoverOnly(rule: IRRule): AccessibilityIssue[] {
|
|
274
|
+
const issues: AccessibilityIssue[] = [];
|
|
275
|
+
|
|
276
|
+
const hasHover = rule.pseudoClasses.some(pc => pc.name === 'hover');
|
|
277
|
+
const hasFocusVisible = rule.pseudoClasses.some(pc =>
|
|
278
|
+
pc.name === 'focus' || pc.name === 'focus-visible'
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (hasHover && !hasFocusVisible) {
|
|
282
|
+
issues.push({
|
|
283
|
+
ruleId: rule.id,
|
|
284
|
+
selector: rule.selector,
|
|
285
|
+
category: 'hover-only',
|
|
286
|
+
severity: 'warning',
|
|
287
|
+
wcagCriterion: WCAG.CRITERIA.hoverOnly,
|
|
288
|
+
message: '"' + rule.selector + '" has hover styles but no :focus-visible fallback. Keyboard users cannot access this interaction.',
|
|
289
|
+
suggestion: 'Add the same styles to :focus-visible for keyboard accessibility.',
|
|
290
|
+
autoFixable: true,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return issues;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// Utilities
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
function extractPx(value: string): number {
|
|
302
|
+
const match = value.match(/^(\d+(\.\d+)?)px$/);
|
|
303
|
+
return match ? parseFloat(match[1]) : Infinity;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ============================================================================
|
|
307
|
+
// Full Audit
|
|
308
|
+
// ============================================================================
|
|
309
|
+
|
|
310
|
+
function auditRule(rule: IRRule): AccessibilityIssue[] {
|
|
311
|
+
if (rule.isDead) return [];
|
|
312
|
+
|
|
313
|
+
return [
|
|
314
|
+
...detectContrast(rule),
|
|
315
|
+
...detectMinimumFontSize(rule),
|
|
316
|
+
...detectTouchTargets(rule),
|
|
317
|
+
...detectMissingFocus(rule),
|
|
318
|
+
...detectMotionRespect(rule),
|
|
319
|
+
...detectHoverOnly(rule),
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function generateAccessibilityReport(rules: IRRule[]): AccessibilityReport {
|
|
324
|
+
const allIssues: AccessibilityIssue[] = [];
|
|
325
|
+
|
|
326
|
+
for (const rule of rules) {
|
|
327
|
+
allIssues.push(...auditRule(rule));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const errors = allIssues.filter(i => i.severity === 'error');
|
|
331
|
+
const warnings = allIssues.filter(i => i.severity === 'warning');
|
|
332
|
+
|
|
333
|
+
let summary: string;
|
|
334
|
+
if (allIssues.length === 0) {
|
|
335
|
+
summary = '✅ All accessibility checks passed.';
|
|
336
|
+
} else if (errors.length > 0) {
|
|
337
|
+
summary = '❌ ' + errors.length + ' errors, ' + warnings.length + ' warnings — fix errors before shipping.';
|
|
338
|
+
} else {
|
|
339
|
+
summary = '⚠️ ' + warnings.length + ' warnings — recommended fixes available.';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
issues: allIssues,
|
|
344
|
+
errorCount: errors.length,
|
|
345
|
+
warningCount: warnings.length,
|
|
346
|
+
passedCount: rules.filter(r => !r.isDead).length - allIssues.length,
|
|
347
|
+
summary,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ============================================================================
|
|
352
|
+
// Auto-Fix
|
|
353
|
+
// ============================================================================
|
|
354
|
+
|
|
355
|
+
function autoFixFontSize(decl: IRDeclaration): string {
|
|
356
|
+
return 'max(' + WCAG.MIN_FONT_SIZE + 'px, ' + decl.value + ')';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function autoFixTouchTarget(): Record<string, string> {
|
|
360
|
+
return {
|
|
361
|
+
'min-width': WCAG.MIN_TOUCH_TARGET + 'px',
|
|
362
|
+
'min-height': WCAG.MIN_TOUCH_TARGET + 'px',
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ============================================================================
|
|
367
|
+
// IR Pass
|
|
368
|
+
// ============================================================================
|
|
369
|
+
|
|
370
|
+
export const accessibilityPass: IRPass = (ir: StyleIR): StyleIR => {
|
|
371
|
+
for (const rule of ir.rules) {
|
|
372
|
+
const issues = auditRule(rule);
|
|
373
|
+
|
|
374
|
+
for (const issue of issues) {
|
|
375
|
+
ir.diagnostics.push({
|
|
376
|
+
id: 'a11y-' + issue.category + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
|
|
377
|
+
nodeId: issue.ruleId,
|
|
378
|
+
severity: issue.severity,
|
|
379
|
+
message: '[' + issue.wcagCriterion + '] ' + issue.message,
|
|
380
|
+
suggestion: issue.suggestion,
|
|
381
|
+
pass: 'accessibility',
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Auto-fix where possible
|
|
385
|
+
if (issue.autoFixable) {
|
|
386
|
+
if (issue.category === 'font-size') {
|
|
387
|
+
const decl = rule.declarations.find(d =>
|
|
388
|
+
d.property === 'fontSize' || d.property === 'font-size'
|
|
389
|
+
);
|
|
390
|
+
if (decl) {
|
|
391
|
+
decl.value = autoFixFontSize(decl);
|
|
392
|
+
decl.history.push({
|
|
393
|
+
pass: 'accessibility',
|
|
394
|
+
action: 'auto-fix-min-font',
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
reason: issue.message,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (issue.category === 'touch-target') {
|
|
402
|
+
const fixes = autoFixTouchTarget();
|
|
403
|
+
for (const [prop, value] of Object.entries(fixes)) {
|
|
404
|
+
rule.declarations.push({
|
|
405
|
+
id: 'a11y-fix-' + Date.now(),
|
|
406
|
+
property: prop,
|
|
407
|
+
value,
|
|
408
|
+
history: [{
|
|
409
|
+
pass: 'accessibility',
|
|
410
|
+
action: 'auto-fix-touch-target',
|
|
411
|
+
timestamp: Date.now(),
|
|
412
|
+
reason: issue.message,
|
|
413
|
+
}],
|
|
414
|
+
meta: { a11y: true },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (issue.category === 'focus' && issue.autoFixable) {
|
|
420
|
+
rule.pseudoClasses.push({
|
|
421
|
+
id: 'a11y-focus-' + Date.now(),
|
|
422
|
+
name: 'focus-visible',
|
|
423
|
+
declarations: [{
|
|
424
|
+
id: 'a11y-focus-outline',
|
|
425
|
+
property: 'outline',
|
|
426
|
+
value: '2px solid #3b82f6',
|
|
427
|
+
history: [{
|
|
428
|
+
pass: 'accessibility',
|
|
429
|
+
action: 'auto-fix-focus',
|
|
430
|
+
timestamp: Date.now(),
|
|
431
|
+
reason: issue.message,
|
|
432
|
+
}],
|
|
433
|
+
meta: {},
|
|
434
|
+
}, {
|
|
435
|
+
id: 'a11y-focus-offset',
|
|
436
|
+
property: 'outlineOffset',
|
|
437
|
+
value: '2px',
|
|
438
|
+
history: [],
|
|
439
|
+
meta: {},
|
|
440
|
+
}],
|
|
441
|
+
source: rule.source,
|
|
442
|
+
history: [],
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (issue.category === 'motion' && issue.autoFixable) {
|
|
447
|
+
// Move existing animation/transition declarations into a reduced-motion query
|
|
448
|
+
const motionDecls = rule.declarations.filter(d =>
|
|
449
|
+
d.property === 'animation' || d.property === 'transition'
|
|
450
|
+
);
|
|
451
|
+
if (motionDecls.length > 0) {
|
|
452
|
+
rule.atRules.push({
|
|
453
|
+
id: 'a11y-motion-' + Date.now(),
|
|
454
|
+
type: 'media',
|
|
455
|
+
query: '(prefers-reduced-motion: no-preference)',
|
|
456
|
+
declarations: motionDecls.map(d => ({ ...d, id: d.id + '-motion' })),
|
|
457
|
+
nestedRules: [],
|
|
458
|
+
source: rule.source,
|
|
459
|
+
history: [{
|
|
460
|
+
pass: 'accessibility',
|
|
461
|
+
action: 'auto-fix-motion',
|
|
462
|
+
timestamp: Date.now(),
|
|
463
|
+
reason: 'Wrapped in prefers-reduced-motion query',
|
|
464
|
+
}],
|
|
465
|
+
});
|
|
466
|
+
// Remove from main declarations
|
|
467
|
+
rule.declarations = rule.declarations.filter(d =>
|
|
468
|
+
!motionDecls.includes(d)
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Store report
|
|
477
|
+
ir.meta = ir.meta || {};
|
|
478
|
+
(ir.meta as any).accessibilityReport = generateAccessibilityReport(ir.rules);
|
|
479
|
+
|
|
480
|
+
return ir;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// ============================================================================
|
|
484
|
+
// Standalone API
|
|
485
|
+
// ============================================================================
|
|
486
|
+
|
|
487
|
+
export function auditAccessibility(rules: IRRule[]): AccessibilityReport {
|
|
488
|
+
return generateAccessibilityReport(rules);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function checkRule(rule: IRRule): AccessibilityIssue[] {
|
|
492
|
+
return auditRule(rule);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export const accessibilityEngine = {
|
|
496
|
+
audit: auditAccessibility,
|
|
497
|
+
checkRule,
|
|
498
|
+
pass: accessibilityPass,
|
|
499
|
+
wcag: WCAG,
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
export default accessibilityEngine;
|