chaincss 2.2.0 → 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaincss",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "ChainCSS - The first CSS-in-JS library with true auto-detection mixed mode. Zero runtime by default, dynamic when you need it.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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;