ally-a11y 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/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally audit-palette command - Audits a design system color palette for contrast issues
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Tailwind config files (tailwind.config.js, tailwind.config.ts)
|
|
6
|
+
* - CSS variable files (*.css)
|
|
7
|
+
* - JSON palette files (*.json)
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { resolve, extname } from 'path';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import boxen from 'boxen';
|
|
14
|
+
import { printBanner, createSpinner, printError, printInfo, printWarning, } from '../utils/ui.js';
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Color Parsing Utilities
|
|
17
|
+
// ============================================================================
|
|
18
|
+
/**
|
|
19
|
+
* Parse a hex color string to RGB values
|
|
20
|
+
*/
|
|
21
|
+
function hexToRgb(hex) {
|
|
22
|
+
// Remove # if present
|
|
23
|
+
const cleanHex = hex.replace(/^#/, '');
|
|
24
|
+
// Support 3-char and 6-char hex
|
|
25
|
+
let fullHex;
|
|
26
|
+
if (cleanHex.length === 3) {
|
|
27
|
+
fullHex = cleanHex
|
|
28
|
+
.split('')
|
|
29
|
+
.map((c) => c + c)
|
|
30
|
+
.join('');
|
|
31
|
+
}
|
|
32
|
+
else if (cleanHex.length === 6) {
|
|
33
|
+
fullHex = cleanHex;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const num = parseInt(fullHex, 16);
|
|
39
|
+
if (isNaN(num))
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
r: (num >> 16) & 255,
|
|
43
|
+
g: (num >> 8) & 255,
|
|
44
|
+
b: num & 255,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Convert RGB to hex string
|
|
49
|
+
*/
|
|
50
|
+
function rgbToHex(r, g, b) {
|
|
51
|
+
const toHex = (n) => Math.round(Math.max(0, Math.min(255, n)))
|
|
52
|
+
.toString(16)
|
|
53
|
+
.padStart(2, '0');
|
|
54
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check if a string looks like a valid hex color
|
|
58
|
+
*/
|
|
59
|
+
function isValidHexColor(value) {
|
|
60
|
+
return /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Normalize hex color to uppercase with #
|
|
64
|
+
*/
|
|
65
|
+
function normalizeHex(hex) {
|
|
66
|
+
const cleanHex = hex.replace(/^#/, '').toUpperCase();
|
|
67
|
+
if (cleanHex.length === 3) {
|
|
68
|
+
return ('#' +
|
|
69
|
+
cleanHex
|
|
70
|
+
.split('')
|
|
71
|
+
.map((c) => c + c)
|
|
72
|
+
.join(''));
|
|
73
|
+
}
|
|
74
|
+
return '#' + cleanHex;
|
|
75
|
+
}
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Contrast Calculation (WCAG 2.x)
|
|
78
|
+
// ============================================================================
|
|
79
|
+
/**
|
|
80
|
+
* Calculate relative luminance per WCAG 2.x spec
|
|
81
|
+
* https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
|
82
|
+
*/
|
|
83
|
+
function relativeLuminance(r, g, b) {
|
|
84
|
+
const sRGB = [r, g, b].map((c) => {
|
|
85
|
+
const s = c / 255;
|
|
86
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
87
|
+
});
|
|
88
|
+
return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Calculate WCAG 2.x contrast ratio between two colors
|
|
92
|
+
*/
|
|
93
|
+
function wcagContrastRatio(fg, bg) {
|
|
94
|
+
const l1 = relativeLuminance(fg.r, fg.g, fg.b);
|
|
95
|
+
const l2 = relativeLuminance(bg.r, bg.g, bg.b);
|
|
96
|
+
const lighter = Math.max(l1, l2);
|
|
97
|
+
const darker = Math.min(l1, l2);
|
|
98
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
99
|
+
}
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// APCA Contrast Calculation
|
|
102
|
+
// ============================================================================
|
|
103
|
+
/**
|
|
104
|
+
* APCA (Accessible Perceptual Contrast Algorithm) calculation
|
|
105
|
+
* Based on APCA W3 specification
|
|
106
|
+
* https://github.com/Myndex/SAPC-APCA
|
|
107
|
+
*
|
|
108
|
+
* Returns Lc value (Lightness contrast):
|
|
109
|
+
* - Positive values: light text on dark background
|
|
110
|
+
* - Negative values: dark text on light background
|
|
111
|
+
* - Magnitude indicates contrast level (higher = more contrast)
|
|
112
|
+
* - |Lc| >= 60 is recommended for body text
|
|
113
|
+
* - |Lc| >= 45 is recommended for large text
|
|
114
|
+
*/
|
|
115
|
+
function calcAPCA(fg, bg) {
|
|
116
|
+
// sRGB to Y (luminance) conversion with APCA coefficients
|
|
117
|
+
const sRGBtoY = (rgb) => {
|
|
118
|
+
// Piecewise sRGB decode
|
|
119
|
+
const mainTRC = 2.4;
|
|
120
|
+
const decode = (c) => {
|
|
121
|
+
const s = c / 255;
|
|
122
|
+
return s <= 0.04045
|
|
123
|
+
? s / 12.92
|
|
124
|
+
: Math.pow((s + 0.055) / 1.055, mainTRC);
|
|
125
|
+
};
|
|
126
|
+
// APCA uses slightly different coefficients than WCAG
|
|
127
|
+
const Rco = 0.2126729;
|
|
128
|
+
const Gco = 0.7151522;
|
|
129
|
+
const Bco = 0.0721750;
|
|
130
|
+
return Rco * decode(rgb.r) + Gco * decode(rgb.g) + Bco * decode(rgb.b);
|
|
131
|
+
};
|
|
132
|
+
// Soft clamp function
|
|
133
|
+
const softClamp = (y) => {
|
|
134
|
+
const blkThrs = 0.022;
|
|
135
|
+
const blkClmp = 1.414;
|
|
136
|
+
return y > blkThrs ? y : y + Math.pow(blkThrs - y, blkClmp);
|
|
137
|
+
};
|
|
138
|
+
// Calculate luminance values
|
|
139
|
+
let Ytxt = sRGBtoY(fg);
|
|
140
|
+
let Ybg = sRGBtoY(bg);
|
|
141
|
+
// Apply soft clamp
|
|
142
|
+
Ytxt = softClamp(Ytxt);
|
|
143
|
+
Ybg = softClamp(Ybg);
|
|
144
|
+
// APCA constants for contrast calculation
|
|
145
|
+
const normBG = 0.56;
|
|
146
|
+
const normTXT = 0.57;
|
|
147
|
+
const revTXT = 0.62;
|
|
148
|
+
const revBG = 0.65;
|
|
149
|
+
const scaleBoW = 1.14;
|
|
150
|
+
const scaleWoB = 1.14;
|
|
151
|
+
const loBoWoffset = 0.027;
|
|
152
|
+
const loWoBoffset = 0.027;
|
|
153
|
+
const loClip = 0.1;
|
|
154
|
+
const deltaYmin = 0.0005;
|
|
155
|
+
// Calculate contrast
|
|
156
|
+
let SAPC = 0;
|
|
157
|
+
let outputContrast = 0;
|
|
158
|
+
// Check for adequate difference
|
|
159
|
+
if (Math.abs(Ybg - Ytxt) < deltaYmin) {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
// Calculate polarity-dependent contrast
|
|
163
|
+
if (Ybg > Ytxt) {
|
|
164
|
+
// Dark text on light background
|
|
165
|
+
SAPC = (Math.pow(Ybg, normBG) - Math.pow(Ytxt, normTXT)) * scaleBoW;
|
|
166
|
+
outputContrast = SAPC < loClip ? 0 : SAPC - loBoWoffset;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Light text on dark background
|
|
170
|
+
SAPC = (Math.pow(Ybg, revBG) - Math.pow(Ytxt, revTXT)) * scaleWoB;
|
|
171
|
+
outputContrast = SAPC > -loClip ? 0 : SAPC + loWoBoffset;
|
|
172
|
+
}
|
|
173
|
+
// Return as Lc value (multiply by 100)
|
|
174
|
+
return Math.round(outputContrast * 100);
|
|
175
|
+
}
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// File Parsers
|
|
178
|
+
// ============================================================================
|
|
179
|
+
/**
|
|
180
|
+
* Parse Tailwind config file to extract colors
|
|
181
|
+
*/
|
|
182
|
+
async function parseTailwindConfig(filePath) {
|
|
183
|
+
const content = await readFile(filePath, 'utf-8');
|
|
184
|
+
const colors = [];
|
|
185
|
+
// Extract the colors object using regex (handles both JS and TS)
|
|
186
|
+
// This is a simplified parser - a real implementation might use esbuild/swc
|
|
187
|
+
const colorsMatch = content.match(/colors\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
|
188
|
+
if (!colorsMatch) {
|
|
189
|
+
// Try theme.extend.colors pattern
|
|
190
|
+
const extendMatch = content.match(/extend\s*:\s*\{[\s\S]*?colors\s*:\s*\{([\s\S]*?)\n\s*\}\s*\}/);
|
|
191
|
+
if (extendMatch) {
|
|
192
|
+
parseColorObject(extendMatch[1], '', colors);
|
|
193
|
+
}
|
|
194
|
+
return colors;
|
|
195
|
+
}
|
|
196
|
+
parseColorObject(colorsMatch[1], '', colors);
|
|
197
|
+
return colors;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Recursively parse a color object string
|
|
201
|
+
*/
|
|
202
|
+
function parseColorObject(content, prefix, colors) {
|
|
203
|
+
// Match simple color definitions: name: '#hex' or 'name': '#hex'
|
|
204
|
+
const simplePattern = /['"]?(\w+)['"]?\s*:\s*['"]?(#[A-Fa-f0-9]{3,6})['"]?/g;
|
|
205
|
+
let match;
|
|
206
|
+
while ((match = simplePattern.exec(content)) !== null) {
|
|
207
|
+
const name = prefix ? `${prefix}-${match[1]}` : match[1];
|
|
208
|
+
const hex = normalizeHex(match[2]);
|
|
209
|
+
const rgb = hexToRgb(hex);
|
|
210
|
+
if (rgb) {
|
|
211
|
+
colors.push({ name, hex, rgb });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Match nested objects: name: { ... }
|
|
215
|
+
const nestedPattern = /['"]?(\w+)['"]?\s*:\s*\{([^{}]+)\}/g;
|
|
216
|
+
while ((match = nestedPattern.exec(content)) !== null) {
|
|
217
|
+
const nestedPrefix = prefix ? `${prefix}-${match[1]}` : match[1];
|
|
218
|
+
parseColorObject(match[2], nestedPrefix, colors);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Parse CSS file to extract color variables
|
|
223
|
+
*/
|
|
224
|
+
async function parseCssFile(filePath) {
|
|
225
|
+
const content = await readFile(filePath, 'utf-8');
|
|
226
|
+
const colors = [];
|
|
227
|
+
// Match CSS custom properties with color values
|
|
228
|
+
// --color-name: #hex;
|
|
229
|
+
const pattern = /--([\w-]+)\s*:\s*(#[A-Fa-f0-9]{3,6})\s*;/g;
|
|
230
|
+
let match;
|
|
231
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
232
|
+
const name = match[1];
|
|
233
|
+
const hex = normalizeHex(match[2]);
|
|
234
|
+
const rgb = hexToRgb(hex);
|
|
235
|
+
if (rgb) {
|
|
236
|
+
colors.push({ name, hex, rgb });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return colors;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Parse JSON palette file to extract colors
|
|
243
|
+
*/
|
|
244
|
+
async function parseJsonFile(filePath) {
|
|
245
|
+
const content = await readFile(filePath, 'utf-8');
|
|
246
|
+
const data = JSON.parse(content);
|
|
247
|
+
const colors = [];
|
|
248
|
+
// Look for colors in common structures
|
|
249
|
+
const colorObjects = data.colors || data.palette || data.theme?.colors || data;
|
|
250
|
+
function extractColors(obj, prefix = '') {
|
|
251
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
252
|
+
if (typeof value === 'string' && isValidHexColor(value)) {
|
|
253
|
+
const name = prefix ? `${prefix}-${key}` : key;
|
|
254
|
+
const hex = normalizeHex(value);
|
|
255
|
+
const rgb = hexToRgb(hex);
|
|
256
|
+
if (rgb) {
|
|
257
|
+
colors.push({ name, hex, rgb });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else if (typeof value === 'object' && value !== null) {
|
|
261
|
+
extractColors(value, prefix ? `${prefix}-${key}` : key);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (typeof colorObjects === 'object' && colorObjects !== null) {
|
|
266
|
+
extractColors(colorObjects);
|
|
267
|
+
}
|
|
268
|
+
return colors;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Detect file type and parse colors accordingly
|
|
272
|
+
*/
|
|
273
|
+
async function parseColorFile(filePath) {
|
|
274
|
+
const ext = extname(filePath).toLowerCase();
|
|
275
|
+
if (filePath.includes('tailwind.config')) {
|
|
276
|
+
return parseTailwindConfig(filePath);
|
|
277
|
+
}
|
|
278
|
+
switch (ext) {
|
|
279
|
+
case '.css':
|
|
280
|
+
return parseCssFile(filePath);
|
|
281
|
+
case '.json':
|
|
282
|
+
return parseJsonFile(filePath);
|
|
283
|
+
case '.js':
|
|
284
|
+
case '.ts':
|
|
285
|
+
case '.mjs':
|
|
286
|
+
case '.cjs':
|
|
287
|
+
return parseTailwindConfig(filePath);
|
|
288
|
+
default:
|
|
289
|
+
throw new Error(`Unsupported file type: ${ext}. Supported: .css, .json, .js, .ts`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// Contrast Analysis
|
|
294
|
+
// ============================================================================
|
|
295
|
+
/**
|
|
296
|
+
* Test all foreground/background combinations
|
|
297
|
+
*/
|
|
298
|
+
function analyzeContrastPairs(colors, level, largeText) {
|
|
299
|
+
const results = [];
|
|
300
|
+
const failing = [];
|
|
301
|
+
const passing = [];
|
|
302
|
+
// Thresholds based on WCAG level and text size
|
|
303
|
+
const aaThreshold = largeText ? 3.0 : 4.5;
|
|
304
|
+
const aaaThreshold = largeText ? 4.5 : 7.0;
|
|
305
|
+
const requiredThreshold = level === 'aaa' ? aaaThreshold : aaThreshold;
|
|
306
|
+
// Test all pairs (excluding same color)
|
|
307
|
+
for (const bg of colors) {
|
|
308
|
+
for (const fg of colors) {
|
|
309
|
+
if (bg.hex === fg.hex)
|
|
310
|
+
continue;
|
|
311
|
+
const wcagRatio = wcagContrastRatio(fg.rgb, bg.rgb);
|
|
312
|
+
const apcaLc = calcAPCA(fg.rgb, bg.rgb);
|
|
313
|
+
const wcagAA = wcagRatio >= aaThreshold;
|
|
314
|
+
const wcagAAA = wcagRatio >= aaaThreshold;
|
|
315
|
+
const result = {
|
|
316
|
+
background: bg,
|
|
317
|
+
foreground: fg,
|
|
318
|
+
wcagRatio: Math.round(wcagRatio * 10) / 10,
|
|
319
|
+
wcagAA,
|
|
320
|
+
wcagAAA,
|
|
321
|
+
apcaLc,
|
|
322
|
+
};
|
|
323
|
+
results.push(result);
|
|
324
|
+
const passesLevel = level === 'aaa' ? wcagAAA : wcagAA;
|
|
325
|
+
if (passesLevel) {
|
|
326
|
+
passing.push(result);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
failing.push(result);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Generate fix suggestions for failing pairs
|
|
334
|
+
const suggestions = generateSuggestions(failing, colors, requiredThreshold);
|
|
335
|
+
return {
|
|
336
|
+
totalColors: colors.length,
|
|
337
|
+
totalCombinations: results.length,
|
|
338
|
+
wcagAAPassing: results.filter((r) => r.wcagAA).length,
|
|
339
|
+
wcagAAAPassing: results.filter((r) => r.wcagAAA).length,
|
|
340
|
+
failing,
|
|
341
|
+
passing,
|
|
342
|
+
suggestions,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Generate fix suggestions by finding nearest passing color
|
|
347
|
+
*/
|
|
348
|
+
function generateSuggestions(failing, allColors, threshold) {
|
|
349
|
+
const suggestions = [];
|
|
350
|
+
const processed = new Set();
|
|
351
|
+
for (const fail of failing.slice(0, 10)) {
|
|
352
|
+
// Limit suggestions
|
|
353
|
+
const key = `${fail.background.name}+${fail.foreground.name}`;
|
|
354
|
+
if (processed.has(key))
|
|
355
|
+
continue;
|
|
356
|
+
processed.add(key);
|
|
357
|
+
// Find alternative foreground colors that pass
|
|
358
|
+
let bestAlternative = null;
|
|
359
|
+
for (const candidate of allColors) {
|
|
360
|
+
if (candidate.hex === fail.foreground.hex)
|
|
361
|
+
continue;
|
|
362
|
+
if (candidate.hex === fail.background.hex)
|
|
363
|
+
continue;
|
|
364
|
+
const ratio = wcagContrastRatio(candidate.rgb, fail.background.rgb);
|
|
365
|
+
if (ratio >= threshold) {
|
|
366
|
+
// Prefer colors with similar lightness to original
|
|
367
|
+
if (!bestAlternative ||
|
|
368
|
+
Math.abs(relativeLuminance(candidate.rgb.r, candidate.rgb.g, candidate.rgb.b) -
|
|
369
|
+
relativeLuminance(fail.foreground.rgb.r, fail.foreground.rgb.g, fail.foreground.rgb.b)) <
|
|
370
|
+
Math.abs(relativeLuminance(bestAlternative.color.rgb.r, bestAlternative.color.rgb.g, bestAlternative.color.rgb.b) -
|
|
371
|
+
relativeLuminance(fail.foreground.rgb.r, fail.foreground.rgb.g, fail.foreground.rgb.b))) {
|
|
372
|
+
bestAlternative = { color: candidate, ratio };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (bestAlternative) {
|
|
377
|
+
suggestions.push({
|
|
378
|
+
background: fail.background.name,
|
|
379
|
+
foreground: fail.foreground.name,
|
|
380
|
+
suggestedForeground: bestAlternative.color.name,
|
|
381
|
+
originalRatio: fail.wcagRatio,
|
|
382
|
+
newRatio: Math.round(bestAlternative.ratio * 10) / 10,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return suggestions;
|
|
387
|
+
}
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// Output Formatting
|
|
390
|
+
// ============================================================================
|
|
391
|
+
/**
|
|
392
|
+
* Format results as default terminal output
|
|
393
|
+
*/
|
|
394
|
+
function formatDefaultOutput(result, showApca, level) {
|
|
395
|
+
// Summary box
|
|
396
|
+
const aaPercent = Math.round((result.wcagAAPassing / result.totalCombinations) * 100);
|
|
397
|
+
const aaaPercent = Math.round((result.wcagAAAPassing / result.totalCombinations) * 100);
|
|
398
|
+
const summaryContent = `
|
|
399
|
+
${chalk.bold(' Palette Audit Results')}
|
|
400
|
+
|
|
401
|
+
Combinations tested: ${result.totalCombinations}
|
|
402
|
+
WCAG AA passing: ${result.wcagAAPassing} (${aaPercent}%)
|
|
403
|
+
WCAG AAA passing: ${result.wcagAAAPassing} (${aaaPercent}%)
|
|
404
|
+
`;
|
|
405
|
+
const borderColor = aaPercent >= 80 ? 'green' : aaPercent >= 50 ? 'yellow' : 'red';
|
|
406
|
+
console.log(boxen(summaryContent.trim(), {
|
|
407
|
+
padding: 1,
|
|
408
|
+
borderStyle: 'round',
|
|
409
|
+
borderColor,
|
|
410
|
+
}));
|
|
411
|
+
// Failing combinations table
|
|
412
|
+
if (result.failing.length > 0) {
|
|
413
|
+
console.log();
|
|
414
|
+
console.log(chalk.bold(`Failing Combinations (WCAG ${level.toUpperCase()}):`));
|
|
415
|
+
console.log();
|
|
416
|
+
// Table header
|
|
417
|
+
const bgCol = 'Background'.padEnd(14);
|
|
418
|
+
const fgCol = 'Foreground'.padEnd(14);
|
|
419
|
+
const ratioCol = 'Ratio'.padEnd(7);
|
|
420
|
+
const wcagCol = 'WCAG'.padEnd(8);
|
|
421
|
+
const apcaCol = showApca ? 'APCA Lc'.padEnd(11) : '';
|
|
422
|
+
console.log(chalk.dim(`+${'-'.repeat(14)}+${'-'.repeat(14)}+${'-'.repeat(7)}+${'-'.repeat(8)}+${showApca ? '-'.repeat(11) + '+' : ''}`));
|
|
423
|
+
console.log(chalk.dim('|') +
|
|
424
|
+
chalk.bold(bgCol) +
|
|
425
|
+
chalk.dim('|') +
|
|
426
|
+
chalk.bold(fgCol) +
|
|
427
|
+
chalk.dim('|') +
|
|
428
|
+
chalk.bold(ratioCol) +
|
|
429
|
+
chalk.dim('|') +
|
|
430
|
+
chalk.bold(wcagCol) +
|
|
431
|
+
chalk.dim('|') +
|
|
432
|
+
(showApca ? chalk.bold(apcaCol) + chalk.dim('|') : ''));
|
|
433
|
+
console.log(chalk.dim(`+${'-'.repeat(14)}+${'-'.repeat(14)}+${'-'.repeat(7)}+${'-'.repeat(8)}+${showApca ? '-'.repeat(11) + '+' : ''}`));
|
|
434
|
+
// Show up to 15 failing pairs
|
|
435
|
+
for (const fail of result.failing.slice(0, 15)) {
|
|
436
|
+
const bg = fail.background.name.substring(0, 12).padEnd(14);
|
|
437
|
+
const fg = fail.foreground.name.substring(0, 12).padEnd(14);
|
|
438
|
+
const ratio = `${fail.wcagRatio}:1`.padEnd(7);
|
|
439
|
+
const wcag = chalk.red('Fail').padEnd(8 + 10); // Account for ANSI codes
|
|
440
|
+
const apca = showApca ? `Lc ${fail.apcaLc}`.padEnd(11) : '';
|
|
441
|
+
console.log(chalk.dim('|') +
|
|
442
|
+
bg +
|
|
443
|
+
chalk.dim('|') +
|
|
444
|
+
fg +
|
|
445
|
+
chalk.dim('|') +
|
|
446
|
+
ratio +
|
|
447
|
+
chalk.dim('|') +
|
|
448
|
+
wcag +
|
|
449
|
+
chalk.dim('|') +
|
|
450
|
+
(showApca ? apca + chalk.dim('|') : ''));
|
|
451
|
+
}
|
|
452
|
+
console.log(chalk.dim(`+${'-'.repeat(14)}+${'-'.repeat(14)}+${'-'.repeat(7)}+${'-'.repeat(8)}+${showApca ? '-'.repeat(11) + '+' : ''}`));
|
|
453
|
+
if (result.failing.length > 15) {
|
|
454
|
+
console.log(chalk.dim(` ... and ${result.failing.length - 15} more failing pairs`));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Suggestions
|
|
458
|
+
if (result.suggestions.length > 0) {
|
|
459
|
+
console.log();
|
|
460
|
+
console.log(chalk.bold('Suggested Fixes:'));
|
|
461
|
+
for (const sug of result.suggestions) {
|
|
462
|
+
console.log(chalk.yellow('*') +
|
|
463
|
+
` ${sug.background} + ${sug.foreground}: Use ${chalk.green(sug.suggestedForeground)} instead (${sug.newRatio}:1)`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Safe pairs for text
|
|
467
|
+
const safePairs = result.passing
|
|
468
|
+
.filter((p) => p.wcagAAA)
|
|
469
|
+
.sort((a, b) => b.wcagRatio - a.wcagRatio)
|
|
470
|
+
.slice(0, 5);
|
|
471
|
+
if (safePairs.length > 0) {
|
|
472
|
+
console.log();
|
|
473
|
+
console.log(chalk.bold('Safe Pairs for Text:'));
|
|
474
|
+
for (const pair of safePairs) {
|
|
475
|
+
const label = pair.wcagAAA ? chalk.green('AAA') : chalk.yellow('AA');
|
|
476
|
+
const apca = showApca ? chalk.dim(` (Lc ${pair.apcaLc})`) : '';
|
|
477
|
+
console.log(chalk.green('*') +
|
|
478
|
+
` ${pair.background.name} + ${pair.foreground.name}: ${pair.wcagRatio}:1 ${label}${apca}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Format results as JSON
|
|
484
|
+
*/
|
|
485
|
+
function formatJsonOutput(result) {
|
|
486
|
+
const output = {
|
|
487
|
+
summary: {
|
|
488
|
+
totalColors: result.totalColors,
|
|
489
|
+
totalCombinations: result.totalCombinations,
|
|
490
|
+
wcagAAPassing: result.wcagAAPassing,
|
|
491
|
+
wcagAAAPassing: result.wcagAAAPassing,
|
|
492
|
+
wcagAAPercent: Math.round((result.wcagAAPassing / result.totalCombinations) * 100),
|
|
493
|
+
wcagAAAPercent: Math.round((result.wcagAAAPassing / result.totalCombinations) * 100),
|
|
494
|
+
},
|
|
495
|
+
failing: result.failing.map((f) => ({
|
|
496
|
+
background: { name: f.background.name, hex: f.background.hex },
|
|
497
|
+
foreground: { name: f.foreground.name, hex: f.foreground.hex },
|
|
498
|
+
wcagRatio: f.wcagRatio,
|
|
499
|
+
wcagAA: f.wcagAA,
|
|
500
|
+
wcagAAA: f.wcagAAA,
|
|
501
|
+
apcaLc: f.apcaLc,
|
|
502
|
+
})),
|
|
503
|
+
passing: result.passing.map((p) => ({
|
|
504
|
+
background: { name: p.background.name, hex: p.background.hex },
|
|
505
|
+
foreground: { name: p.foreground.name, hex: p.foreground.hex },
|
|
506
|
+
wcagRatio: p.wcagRatio,
|
|
507
|
+
wcagAA: p.wcagAA,
|
|
508
|
+
wcagAAA: p.wcagAAA,
|
|
509
|
+
apcaLc: p.apcaLc,
|
|
510
|
+
})),
|
|
511
|
+
suggestions: result.suggestions,
|
|
512
|
+
};
|
|
513
|
+
console.log(JSON.stringify(output, null, 2));
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Format results as CSV
|
|
517
|
+
*/
|
|
518
|
+
function formatCsvOutput(result) {
|
|
519
|
+
const headers = [
|
|
520
|
+
'background_name',
|
|
521
|
+
'background_hex',
|
|
522
|
+
'foreground_name',
|
|
523
|
+
'foreground_hex',
|
|
524
|
+
'wcag_ratio',
|
|
525
|
+
'wcag_aa',
|
|
526
|
+
'wcag_aaa',
|
|
527
|
+
'apca_lc',
|
|
528
|
+
];
|
|
529
|
+
console.log(headers.join(','));
|
|
530
|
+
const allResults = [...result.failing, ...result.passing];
|
|
531
|
+
for (const r of allResults) {
|
|
532
|
+
const row = [
|
|
533
|
+
r.background.name,
|
|
534
|
+
r.background.hex,
|
|
535
|
+
r.foreground.name,
|
|
536
|
+
r.foreground.hex,
|
|
537
|
+
r.wcagRatio,
|
|
538
|
+
r.wcagAA,
|
|
539
|
+
r.wcagAAA,
|
|
540
|
+
r.apcaLc,
|
|
541
|
+
];
|
|
542
|
+
console.log(row.join(','));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// ============================================================================
|
|
546
|
+
// Main Command
|
|
547
|
+
// ============================================================================
|
|
548
|
+
export async function auditPaletteCommand(filePath, options = {}) {
|
|
549
|
+
const { format = 'default', level = 'aa', largeText = false, apca = false, } = options;
|
|
550
|
+
// Only print banner for default format
|
|
551
|
+
if (format === 'default') {
|
|
552
|
+
printBanner();
|
|
553
|
+
}
|
|
554
|
+
const absolutePath = resolve(filePath);
|
|
555
|
+
// Check file exists
|
|
556
|
+
if (!existsSync(absolutePath)) {
|
|
557
|
+
printError(`File not found: ${filePath}`);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
// Parse colors
|
|
561
|
+
let spinner = null;
|
|
562
|
+
if (format === 'default') {
|
|
563
|
+
spinner = createSpinner('Analyzing color palette...');
|
|
564
|
+
spinner.start();
|
|
565
|
+
}
|
|
566
|
+
let colors;
|
|
567
|
+
try {
|
|
568
|
+
colors = await parseColorFile(absolutePath);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (spinner)
|
|
572
|
+
spinner.fail('Failed to parse color file');
|
|
573
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
if (colors.length === 0) {
|
|
577
|
+
if (spinner)
|
|
578
|
+
spinner.fail('No colors found in file');
|
|
579
|
+
printWarning('The file does not contain any recognizable color definitions.');
|
|
580
|
+
printInfo('Supported formats:');
|
|
581
|
+
printInfo(' - Tailwind: colors: { primary: "#hex" }');
|
|
582
|
+
printInfo(' - CSS: --color-name: #hex;');
|
|
583
|
+
printInfo(' - JSON: { "colors": { "name": "#hex" } }');
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
if (format === 'default' && spinner) {
|
|
587
|
+
spinner.text = `Found ${colors.length} colors. Testing ${colors.length * (colors.length - 1)} combinations...`;
|
|
588
|
+
}
|
|
589
|
+
// Analyze contrast
|
|
590
|
+
const result = analyzeContrastPairs(colors, level, largeText);
|
|
591
|
+
if (spinner) {
|
|
592
|
+
spinner.succeed(`Found ${colors.length} colors in palette`);
|
|
593
|
+
console.log();
|
|
594
|
+
}
|
|
595
|
+
// Output results
|
|
596
|
+
switch (format) {
|
|
597
|
+
case 'json':
|
|
598
|
+
formatJsonOutput(result);
|
|
599
|
+
break;
|
|
600
|
+
case 'csv':
|
|
601
|
+
formatCsvOutput(result);
|
|
602
|
+
break;
|
|
603
|
+
default:
|
|
604
|
+
formatDefaultOutput(result, apca, level);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
// Exit with error if there are failing combinations
|
|
608
|
+
if (result.failing.length > 0 && format === 'default') {
|
|
609
|
+
console.log();
|
|
610
|
+
printWarning(`${result.failing.length} color combinations fail WCAG ${level.toUpperCase()}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
export default auditPaletteCommand;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ally auto-pr - THE KILLER FEATURE
|
|
4
|
+
* Automated accessibility PR creation: scan → fix → commit → push → PR
|
|
5
|
+
*/
|
|
6
|
+
interface AutoPROptions {
|
|
7
|
+
fix?: boolean;
|
|
8
|
+
branch?: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
draft?: boolean;
|
|
12
|
+
remote?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Main auto-pr command - THE KILLER FEATURE
|
|
16
|
+
* Scans for accessibility issues, fixes them, and creates a GitHub PR
|
|
17
|
+
*/
|
|
18
|
+
export declare function autoPRCommand(options: AutoPROptions): Promise<void>;
|
|
19
|
+
export {};
|