@fragments-sdk/cli 0.5.2 → 0.7.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/dist/bin.js +996 -79
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-ICAIQ57V.js → chunk-6JBGU74P.js} +5 -3
- package/dist/chunk-6JBGU74P.js.map +1 -0
- package/dist/chunk-7OPWMLOE.js +1625 -0
- package/dist/chunk-7OPWMLOE.js.map +1 -0
- package/dist/{chunk-2H2JAA3U.js → chunk-CVXKXVOY.js} +3 -3
- package/dist/{chunk-2H2JAA3U.js.map → chunk-CVXKXVOY.js.map} +1 -1
- package/dist/{chunk-IOJE35DZ.js → chunk-NWQ4CJOQ.js} +3 -3
- package/dist/{chunk-2DJH4F4P.js → chunk-RVRTRESS.js} +3 -3
- package/dist/{chunk-V7YLRR4C.js → chunk-TJ34N7C7.js} +41 -4
- package/dist/{chunk-V7YLRR4C.js.map → chunk-TJ34N7C7.js.map} +1 -1
- package/dist/{chunk-XNWDI6UT.js → chunk-XHUDJNN3.js} +5 -5
- package/dist/{core-DKHB7FYV.js → core-W2HYIQW6.js} +4 -4
- package/dist/{generate-KL24VZVD.js → generate-LMTISDIJ.js} +5 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -7
- package/dist/index.js.map +1 -1
- package/dist/{init-NION5S3M.js → init-7CHRKQ7P.js} +5 -5
- package/dist/mcp-bin.js +8 -220
- package/dist/mcp-bin.js.map +1 -1
- package/dist/scan-WY23TJCP.js +12 -0
- package/dist/{service-RWUMZ3EW.js → service-T2L7VLTE.js} +5 -5
- package/dist/static-viewer-GBR7YNF3.js +12 -0
- package/dist/{test-ECPEXFDN.js → test-OJRXNDO2.js} +4 -4
- package/dist/{tokens-ITADYVPF.js → tokens-3BWDESVM.js} +6 -6
- package/dist/viewer-SUFOISZM.js +1822 -0
- package/dist/viewer-SUFOISZM.js.map +1 -0
- package/package.json +6 -5
- package/src/bin.ts +31 -0
- package/src/build.ts +147 -13
- package/src/cli-commands.ts +18 -0
- package/src/commands/__tests__/a11y-scoring.test.ts +278 -0
- package/src/commands/a11y-report.ts +625 -0
- package/src/commands/a11y.ts +168 -14
- package/src/commands/build.ts +16 -0
- package/src/commands/graph.ts +274 -0
- package/src/core/auto-props.ts +464 -0
- package/src/core/composition.ts +64 -1
- package/src/core/graph-extractor.test.ts +542 -0
- package/src/core/graph-extractor.ts +601 -0
- package/src/core/importAnalyzer.ts +5 -0
- package/src/core/schema.ts +2 -0
- package/src/core/types.ts +3 -1
- package/src/index.ts +4 -0
- package/src/mcp/server.ts +13 -220
- package/src/theme/__tests__/component-contrast.test.ts +338 -0
- package/src/theme/__tests__/contrast-validation.test.ts +326 -0
- package/src/theme/contrast.test.ts +331 -0
- package/src/theme/contrast.ts +246 -0
- package/src/theme/generator.ts +213 -1
- package/src/theme/index.ts +16 -0
- package/src/theme/types.ts +51 -0
- package/src/viewer/__tests__/a11y-fixes.test.ts +358 -0
- package/src/viewer/__tests__/viewer-integration.test.ts +2 -7
- package/src/viewer/components/AccessibilityPanel.tsx +493 -433
- package/src/viewer/components/ActionCapture.tsx +1 -1
- package/src/viewer/components/ActionsPanel.tsx +142 -183
- package/src/viewer/components/App.tsx +276 -183
- package/src/viewer/components/BottomPanel.tsx +40 -80
- package/src/viewer/components/CodePanel.tsx +9 -87
- package/src/viewer/components/CommandPalette.tsx +117 -74
- package/src/viewer/components/ComponentGraph.tsx +143 -126
- package/src/viewer/components/ComponentHeader.tsx +46 -43
- package/src/viewer/components/ContractPanel.tsx +124 -117
- package/src/viewer/components/ErrorBoundary.tsx +47 -35
- package/src/viewer/components/FigmaEmbed.tsx +18 -13
- package/src/viewer/components/FragmentEditor.tsx +126 -63
- package/src/viewer/components/HealthDashboard.tsx +146 -171
- package/src/viewer/components/HmrStatusIndicator.tsx +31 -41
- package/src/viewer/components/Icons.tsx +151 -98
- package/src/viewer/components/InteractionsPanel.tsx +317 -264
- package/src/viewer/components/IsolatedPreviewFrame.tsx +52 -27
- package/src/viewer/components/IsolatedRender.tsx +12 -6
- package/src/viewer/components/KeyboardShortcutsHelp.tsx +34 -70
- package/src/viewer/components/LandingPage.tsx +285 -305
- package/src/viewer/components/Layout.tsx +12 -10
- package/src/viewer/components/LeftSidebar.tsx +103 -155
- package/src/viewer/components/MultiViewportPreview.tsx +254 -63
- package/src/viewer/components/PreviewArea.tsx +113 -44
- package/src/viewer/components/PreviewFrameHost.tsx +36 -6
- package/src/viewer/components/PreviewPane.tsx +2 -3
- package/src/viewer/components/PreviewToolbar.tsx +109 -105
- package/src/viewer/components/PropsEditor.tsx +154 -74
- package/src/viewer/components/PropsTable.tsx +95 -82
- package/src/viewer/components/RelationsSection.tsx +71 -40
- package/src/viewer/components/ResizablePanel.tsx +158 -55
- package/src/viewer/components/RightSidebar.tsx +46 -56
- package/src/viewer/components/ScreenshotButton.tsx +12 -12
- package/src/viewer/components/SkeletonLoader.tsx +99 -83
- package/src/viewer/components/StoryRenderer.tsx +4 -11
- package/src/viewer/components/Toast.tsx +3 -67
- package/src/viewer/components/TokenStylePanel.tsx +136 -118
- package/src/viewer/components/UsageSection.tsx +26 -26
- package/src/viewer/components/VariantMatrix.tsx +140 -47
- package/src/viewer/components/VariantTabs.tsx +24 -68
- package/src/viewer/components/ViewportSelector.tsx +121 -114
- package/src/viewer/constants/ui.ts +23 -22
- package/src/viewer/entry.tsx +8 -3
- package/src/viewer/index.ts +3 -6
- package/src/viewer/preview-frame.html +43 -18
- package/src/viewer/server.ts +7 -16
- package/src/viewer/styles/globals.css +46 -85
- package/src/viewer/utils/a11y-fixes.ts +53 -30
- package/dist/chunk-ICAIQ57V.js.map +0 -1
- package/dist/chunk-U4GQ2JTD.js +0 -832
- package/dist/chunk-U4GQ2JTD.js.map +0 -1
- package/dist/scan-ESEXV7LF.js +0 -12
- package/dist/static-viewer-O37MJ5B6.js +0 -12
- package/dist/viewer-YDGFDTK5.js +0 -11104
- package/dist/viewer-YDGFDTK5.js.map +0 -1
- package/src/viewer/postcss.config.js +0 -6
- package/src/viewer/tailwind.config.js +0 -37
- /package/dist/{chunk-IOJE35DZ.js.map → chunk-NWQ4CJOQ.js.map} +0 -0
- /package/dist/{chunk-2DJH4F4P.js.map → chunk-RVRTRESS.js.map} +0 -0
- /package/dist/{chunk-XNWDI6UT.js.map → chunk-XHUDJNN3.js.map} +0 -0
- /package/dist/{core-DKHB7FYV.js.map → core-W2HYIQW6.js.map} +0 -0
- /package/dist/{generate-KL24VZVD.js.map → generate-LMTISDIJ.js.map} +0 -0
- /package/dist/{init-NION5S3M.js.map → init-7CHRKQ7P.js.map} +0 -0
- /package/dist/{scan-ESEXV7LF.js.map → scan-WY23TJCP.js.map} +0 -0
- /package/dist/{service-RWUMZ3EW.js.map → service-T2L7VLTE.js.map} +0 -0
- /package/dist/{static-viewer-O37MJ5B6.js.map → static-viewer-GBR7YNF3.js.map} +0 -0
- /package/dist/{test-ECPEXFDN.js.map → test-OJRXNDO2.js.map} +0 -0
- /package/dist/{tokens-ITADYVPF.js.map → tokens-3BWDESVM.js.map} +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color Contrast Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript WCAG 2.1 color contrast math — zero dependencies.
|
|
5
|
+
* Handles hex (#rgb, #rrggbb), rgb(), rgba(), hsl(), hsla().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RGB {
|
|
9
|
+
r: number;
|
|
10
|
+
g: number;
|
|
11
|
+
b: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a CSS color string into an RGB object.
|
|
16
|
+
* Supports: #rgb, #rrggbb, rgb(), rgba(), hsl(), hsla().
|
|
17
|
+
*/
|
|
18
|
+
export function parseColor(css: string): RGB {
|
|
19
|
+
const trimmed = css.trim().toLowerCase();
|
|
20
|
+
|
|
21
|
+
// Hex: #rgb or #rrggbb
|
|
22
|
+
const hexMatch = trimmed.match(/^#([0-9a-f]{3,8})$/);
|
|
23
|
+
if (hexMatch) {
|
|
24
|
+
const hex = hexMatch[1];
|
|
25
|
+
if (hex.length === 3) {
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
28
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
29
|
+
b: parseInt(hex[2] + hex[2], 16),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
33
|
+
return {
|
|
34
|
+
r: parseInt(hex.substring(0, 2), 16),
|
|
35
|
+
g: parseInt(hex.substring(2, 4), 16),
|
|
36
|
+
b: parseInt(hex.substring(4, 6), 16),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// rgb() / rgba()
|
|
42
|
+
const rgbMatch = trimmed.match(
|
|
43
|
+
/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
|
|
44
|
+
);
|
|
45
|
+
if (rgbMatch) {
|
|
46
|
+
return {
|
|
47
|
+
r: parseInt(rgbMatch[1], 10),
|
|
48
|
+
g: parseInt(rgbMatch[2], 10),
|
|
49
|
+
b: parseInt(rgbMatch[3], 10),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// hsl() / hsla()
|
|
54
|
+
const hslMatch = trimmed.match(
|
|
55
|
+
/^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/
|
|
56
|
+
);
|
|
57
|
+
if (hslMatch) {
|
|
58
|
+
return hslToRgb(
|
|
59
|
+
parseFloat(hslMatch[1]),
|
|
60
|
+
parseFloat(hslMatch[2]),
|
|
61
|
+
parseFloat(hslMatch[3])
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Cannot parse color: "${css}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert HSL values to RGB.
|
|
70
|
+
*/
|
|
71
|
+
function hslToRgb(h: number, s: number, l: number): RGB {
|
|
72
|
+
const sNorm = s / 100;
|
|
73
|
+
const lNorm = l / 100;
|
|
74
|
+
|
|
75
|
+
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
|
76
|
+
const hPrime = (((h % 360) + 360) % 360) / 60;
|
|
77
|
+
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
|
78
|
+
const m = lNorm - c / 2;
|
|
79
|
+
|
|
80
|
+
let r1 = 0, g1 = 0, b1 = 0;
|
|
81
|
+
if (hPrime < 1) { r1 = c; g1 = x; }
|
|
82
|
+
else if (hPrime < 2) { r1 = x; g1 = c; }
|
|
83
|
+
else if (hPrime < 3) { g1 = c; b1 = x; }
|
|
84
|
+
else if (hPrime < 4) { g1 = x; b1 = c; }
|
|
85
|
+
else if (hPrime < 5) { r1 = x; b1 = c; }
|
|
86
|
+
else { r1 = c; b1 = x; }
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
r: Math.round((r1 + m) * 255),
|
|
90
|
+
g: Math.round((g1 + m) * 255),
|
|
91
|
+
b: Math.round((b1 + m) * 255),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Linearize an sRGB channel value (0-255) for luminance calculation.
|
|
97
|
+
*/
|
|
98
|
+
function linearize(channel: number): number {
|
|
99
|
+
const srgb = channel / 255;
|
|
100
|
+
return srgb <= 0.04045
|
|
101
|
+
? srgb / 12.92
|
|
102
|
+
: Math.pow((srgb + 0.055) / 1.055, 2.4);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Calculate relative luminance per WCAG 2.1.
|
|
107
|
+
* @see https://www.w3.org/WAI/WCAG21/Techniques/general/G17#procedure
|
|
108
|
+
*/
|
|
109
|
+
export function relativeLuminance(r: number, g: number, b: number): number {
|
|
110
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Calculate the contrast ratio between two colors.
|
|
115
|
+
* Returns a value >= 1 (lighter / darker).
|
|
116
|
+
*/
|
|
117
|
+
export function contrastRatio(fg: RGB, bg: RGB): number {
|
|
118
|
+
const l1 = relativeLuminance(fg.r, fg.g, fg.b);
|
|
119
|
+
const l2 = relativeLuminance(bg.r, bg.g, bg.b);
|
|
120
|
+
const lighter = Math.max(l1, l2);
|
|
121
|
+
const darker = Math.min(l1, l2);
|
|
122
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check whether a contrast ratio meets WCAG 2.1 AA.
|
|
127
|
+
* Normal text: 4.5:1. Large text (18pt / 14pt bold): 3:1.
|
|
128
|
+
*/
|
|
129
|
+
export function meetsAA(ratio: number, isLargeText = false): boolean {
|
|
130
|
+
return ratio >= (isLargeText ? 3.0 : 4.5);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check whether a contrast ratio meets WCAG 2.1 AAA.
|
|
135
|
+
* Normal text: 7:1. Large text: 4.5:1.
|
|
136
|
+
*/
|
|
137
|
+
export function meetsAAA(ratio: number, isLargeText = false): boolean {
|
|
138
|
+
return ratio >= (isLargeText ? 4.5 : 7.0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Convert an RGB object back to a hex string.
|
|
143
|
+
*/
|
|
144
|
+
export function rgbToHex(r: number, g: number, b: number): string {
|
|
145
|
+
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
146
|
+
const hex = (v: number) => clamp(v).toString(16).padStart(2, '0');
|
|
147
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Suggest a fixed foreground color that meets the target contrast ratio
|
|
152
|
+
* against the given background.
|
|
153
|
+
*
|
|
154
|
+
* Uses binary search: hold bg fixed, adjust fg luminance until the target
|
|
155
|
+
* ratio is met. Returns the adjusted foreground hex color.
|
|
156
|
+
*/
|
|
157
|
+
export function suggestFix(
|
|
158
|
+
fg: RGB,
|
|
159
|
+
bg: RGB,
|
|
160
|
+
targetRatio: number
|
|
161
|
+
): string {
|
|
162
|
+
const bgLum = relativeLuminance(bg.r, bg.g, bg.b);
|
|
163
|
+
const fgLum = relativeLuminance(fg.r, fg.g, fg.b);
|
|
164
|
+
|
|
165
|
+
// Determine whether we need to go darker or lighter
|
|
166
|
+
const fgIsLighter = fgLum >= bgLum;
|
|
167
|
+
|
|
168
|
+
// Target luminance for the foreground to meet the required ratio
|
|
169
|
+
let targetLum: number;
|
|
170
|
+
if (fgIsLighter) {
|
|
171
|
+
// fg is lighter: ratio = (fgLum + 0.05) / (bgLum + 0.05)
|
|
172
|
+
// Try making fg lighter first
|
|
173
|
+
targetLum = targetRatio * (bgLum + 0.05) - 0.05;
|
|
174
|
+
if (targetLum > 1) {
|
|
175
|
+
// Can't go lighter enough — go darker instead
|
|
176
|
+
targetLum = (bgLum + 0.05) / targetRatio - 0.05;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// fg is darker: ratio = (bgLum + 0.05) / (fgLum + 0.05)
|
|
180
|
+
// Try making fg darker first
|
|
181
|
+
targetLum = (bgLum + 0.05) / targetRatio - 0.05;
|
|
182
|
+
if (targetLum < 0) {
|
|
183
|
+
// Can't go darker enough — go lighter instead
|
|
184
|
+
targetLum = targetRatio * (bgLum + 0.05) - 0.05;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
targetLum = Math.max(0, Math.min(1, targetLum));
|
|
189
|
+
|
|
190
|
+
// Scale the fg color channels proportionally to reach target luminance
|
|
191
|
+
return adjustToLuminance(fg, targetLum);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Adjust an RGB color to reach the target relative luminance.
|
|
196
|
+
* Scales channels proportionally, clamping at boundaries.
|
|
197
|
+
*/
|
|
198
|
+
function adjustToLuminance(color: RGB, targetLum: number): string {
|
|
199
|
+
const currentLum = relativeLuminance(color.r, color.g, color.b);
|
|
200
|
+
|
|
201
|
+
if (currentLum === 0) {
|
|
202
|
+
// Pure black — can only go to grayscale
|
|
203
|
+
// Solve: 0.2126*L + 0.7152*L + 0.0722*L = targetLum where L = linearize(v)
|
|
204
|
+
// => L = targetLum, v = delinearize(targetLum)
|
|
205
|
+
const v = delinearize(targetLum);
|
|
206
|
+
const channel = Math.round(v * 255);
|
|
207
|
+
return rgbToHex(channel, channel, channel);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Binary search for the right scale factor
|
|
211
|
+
let lo = 0;
|
|
212
|
+
let hi = 10;
|
|
213
|
+
let bestHex = rgbToHex(color.r, color.g, color.b);
|
|
214
|
+
let bestDiff = Infinity;
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < 40; i++) {
|
|
217
|
+
const mid = (lo + hi) / 2;
|
|
218
|
+
const r = Math.max(0, Math.min(255, Math.round(color.r * mid)));
|
|
219
|
+
const g = Math.max(0, Math.min(255, Math.round(color.g * mid)));
|
|
220
|
+
const b = Math.max(0, Math.min(255, Math.round(color.b * mid)));
|
|
221
|
+
const lum = relativeLuminance(r, g, b);
|
|
222
|
+
const diff = Math.abs(lum - targetLum);
|
|
223
|
+
|
|
224
|
+
if (diff < bestDiff) {
|
|
225
|
+
bestDiff = diff;
|
|
226
|
+
bestHex = rgbToHex(r, g, b);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (lum < targetLum) {
|
|
230
|
+
lo = mid;
|
|
231
|
+
} else {
|
|
232
|
+
hi = mid;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return bestHex;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reverse sRGB linearization: linear -> sRGB channel (0-1 range).
|
|
241
|
+
*/
|
|
242
|
+
function delinearize(linear: number): number {
|
|
243
|
+
return linear <= 0.0031308
|
|
244
|
+
? linear * 12.92
|
|
245
|
+
: 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
|
|
246
|
+
}
|
package/src/theme/generator.ts
CHANGED
|
@@ -10,7 +10,16 @@ import type {
|
|
|
10
10
|
ThemeConfig,
|
|
11
11
|
TokenGeneratorOptions,
|
|
12
12
|
TokenGeneratorResult,
|
|
13
|
+
ContrastPair,
|
|
14
|
+
ContrastWarning,
|
|
15
|
+
ContrastValidationResult,
|
|
13
16
|
} from "./types.js";
|
|
17
|
+
import {
|
|
18
|
+
parseColor,
|
|
19
|
+
contrastRatio,
|
|
20
|
+
meetsAA as checkAA,
|
|
21
|
+
meetsAAA as checkAAA,
|
|
22
|
+
} from "./contrast.js";
|
|
14
23
|
|
|
15
24
|
const DEFAULT_FILE_PREFIX = "_theme-tokens";
|
|
16
25
|
|
|
@@ -31,6 +40,10 @@ const TOKEN_MAPPINGS = {
|
|
|
31
40
|
successBg: "fui-color-success-bg",
|
|
32
41
|
warningBg: "fui-color-warning-bg",
|
|
33
42
|
infoBg: "fui-color-info-bg",
|
|
43
|
+
dangerText: "fui-color-danger-text",
|
|
44
|
+
successText: "fui-color-success-text",
|
|
45
|
+
warningText: "fui-color-warning-text",
|
|
46
|
+
infoText: "fui-color-info-text",
|
|
34
47
|
},
|
|
35
48
|
surfaces: {
|
|
36
49
|
bgPrimary: "fui-bg-primary",
|
|
@@ -100,6 +113,10 @@ const DARK_TOKEN_MAPPINGS = {
|
|
|
100
113
|
successBg: "fui-dark-color-success-bg",
|
|
101
114
|
warningBg: "fui-dark-color-warning-bg",
|
|
102
115
|
infoBg: "fui-dark-color-info-bg",
|
|
116
|
+
dangerText: "fui-dark-color-danger-text",
|
|
117
|
+
successText: "fui-dark-color-success-text",
|
|
118
|
+
warningText: "fui-dark-color-warning-text",
|
|
119
|
+
infoText: "fui-dark-color-info-text",
|
|
103
120
|
backdrop: "fui-dark-backdrop",
|
|
104
121
|
} as const;
|
|
105
122
|
|
|
@@ -162,7 +179,7 @@ function generateDarkTokens(
|
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
// Handle direct dark mode properties
|
|
165
|
-
const directProps = ["dangerBg", "successBg", "warningBg", "infoBg", "backdrop"] as const;
|
|
182
|
+
const directProps = ["dangerBg", "successBg", "warningBg", "infoBg", "dangerText", "successText", "warningText", "infoText", "backdrop"] as const;
|
|
166
183
|
for (const prop of directProps) {
|
|
167
184
|
const value = config.dark[prop];
|
|
168
185
|
if (value !== undefined) {
|
|
@@ -180,6 +197,186 @@ function generateDarkTokens(
|
|
|
180
197
|
return tokens;
|
|
181
198
|
}
|
|
182
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Validate contrast across all text/surface token pairs in a theme.
|
|
202
|
+
*
|
|
203
|
+
* Returns `ContrastValidationResult` with every evaluated pair plus
|
|
204
|
+
* errors (fails AA) and warnings (passes AA, fails AAA).
|
|
205
|
+
*/
|
|
206
|
+
export function validateContrast(config: ThemeConfig): ContrastValidationResult {
|
|
207
|
+
const pairs: ContrastPair[] = [];
|
|
208
|
+
const warnings: ContrastWarning[] = [];
|
|
209
|
+
const errors: ContrastWarning[] = [];
|
|
210
|
+
|
|
211
|
+
const textTokens = [
|
|
212
|
+
{ key: 'primary', token: 'text.primary' },
|
|
213
|
+
{ key: 'secondary', token: 'text.secondary' },
|
|
214
|
+
{ key: 'tertiary', token: 'text.tertiary' },
|
|
215
|
+
] as const;
|
|
216
|
+
|
|
217
|
+
const surfaceTokens = [
|
|
218
|
+
{ key: 'bgPrimary', token: 'surfaces.bgPrimary' },
|
|
219
|
+
{ key: 'bgSecondary', token: 'surfaces.bgSecondary' },
|
|
220
|
+
{ key: 'bgTertiary', token: 'surfaces.bgTertiary' },
|
|
221
|
+
{ key: 'bgElevated', token: 'surfaces.bgElevated' },
|
|
222
|
+
] as const;
|
|
223
|
+
|
|
224
|
+
// -- Light mode matrix --
|
|
225
|
+
for (const text of textTokens) {
|
|
226
|
+
const textColor = config.text?.[text.key];
|
|
227
|
+
if (!textColor) continue;
|
|
228
|
+
|
|
229
|
+
for (const surface of surfaceTokens) {
|
|
230
|
+
const surfaceColor = config.surfaces?.[surface.key];
|
|
231
|
+
if (!surfaceColor) continue;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const fg = parseColor(textColor);
|
|
235
|
+
const bg = parseColor(surfaceColor);
|
|
236
|
+
const ratio = contrastRatio(fg, bg);
|
|
237
|
+
const aa = checkAA(ratio);
|
|
238
|
+
const aaa = checkAAA(ratio);
|
|
239
|
+
|
|
240
|
+
const pair: ContrastPair = {
|
|
241
|
+
textToken: text.token,
|
|
242
|
+
surfaceToken: surface.token,
|
|
243
|
+
textColor,
|
|
244
|
+
surfaceColor,
|
|
245
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
246
|
+
meetsAA: aa,
|
|
247
|
+
meetsAAA: aaa,
|
|
248
|
+
mode: 'light',
|
|
249
|
+
};
|
|
250
|
+
pairs.push(pair);
|
|
251
|
+
|
|
252
|
+
if (!aa) {
|
|
253
|
+
errors.push({
|
|
254
|
+
message: `${text.token} (${textColor}) on ${surface.token} (${surfaceColor}) — ${pair.ratio}:1 — fails AA (need 4.5:1)`,
|
|
255
|
+
pair,
|
|
256
|
+
severity: 'error',
|
|
257
|
+
});
|
|
258
|
+
} else if (!aaa) {
|
|
259
|
+
warnings.push({
|
|
260
|
+
message: `${text.token} (${textColor}) on ${surface.token} (${surfaceColor}) — ${pair.ratio}:1 — passes AA but fails AAA (need 7:1)`,
|
|
261
|
+
pair,
|
|
262
|
+
severity: 'warning',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Skip unparseable colors (e.g. rgba with alpha)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// inverse text on accent
|
|
272
|
+
if (config.text?.inverse && config.colors?.accent) {
|
|
273
|
+
try {
|
|
274
|
+
const fg = parseColor(config.text.inverse);
|
|
275
|
+
const bg = parseColor(config.colors.accent);
|
|
276
|
+
const ratio = contrastRatio(fg, bg);
|
|
277
|
+
const aa = checkAA(ratio);
|
|
278
|
+
const aaa = checkAAA(ratio);
|
|
279
|
+
|
|
280
|
+
const pair: ContrastPair = {
|
|
281
|
+
textToken: 'text.inverse',
|
|
282
|
+
surfaceToken: 'colors.accent',
|
|
283
|
+
textColor: config.text.inverse,
|
|
284
|
+
surfaceColor: config.colors.accent,
|
|
285
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
286
|
+
meetsAA: aa,
|
|
287
|
+
meetsAAA: aaa,
|
|
288
|
+
mode: 'light',
|
|
289
|
+
};
|
|
290
|
+
pairs.push(pair);
|
|
291
|
+
|
|
292
|
+
if (!aa) {
|
|
293
|
+
errors.push({
|
|
294
|
+
message: `text.inverse (${config.text.inverse}) on colors.accent (${config.colors.accent}) — ${pair.ratio}:1 — fails AA`,
|
|
295
|
+
pair,
|
|
296
|
+
severity: 'error',
|
|
297
|
+
});
|
|
298
|
+
} else if (!aaa) {
|
|
299
|
+
warnings.push({
|
|
300
|
+
message: `text.inverse (${config.text.inverse}) on colors.accent (${config.colors.accent}) — ${pair.ratio}:1 — passes AA but fails AAA`,
|
|
301
|
+
pair,
|
|
302
|
+
severity: 'warning',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
// skip
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// -- Dark mode matrix --
|
|
311
|
+
if (config.dark) {
|
|
312
|
+
const darkTextTokens = [
|
|
313
|
+
{ key: 'primary' as const, token: 'dark.text.primary' },
|
|
314
|
+
{ key: 'secondary' as const, token: 'dark.text.secondary' },
|
|
315
|
+
{ key: 'tertiary' as const, token: 'dark.text.tertiary' },
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const darkSurfaceTokens = [
|
|
319
|
+
{ key: 'bgPrimary' as const, token: 'dark.surfaces.bgPrimary' },
|
|
320
|
+
{ key: 'bgSecondary' as const, token: 'dark.surfaces.bgSecondary' },
|
|
321
|
+
{ key: 'bgTertiary' as const, token: 'dark.surfaces.bgTertiary' },
|
|
322
|
+
{ key: 'bgElevated' as const, token: 'dark.surfaces.bgElevated' },
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
for (const text of darkTextTokens) {
|
|
326
|
+
const textColor = config.dark.text?.[text.key] ?? config.text?.[text.key];
|
|
327
|
+
if (!textColor) continue;
|
|
328
|
+
|
|
329
|
+
for (const surface of darkSurfaceTokens) {
|
|
330
|
+
const surfaceColor = config.dark.surfaces?.[surface.key] ?? config.surfaces?.[surface.key];
|
|
331
|
+
if (!surfaceColor) continue;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const fg = parseColor(textColor);
|
|
335
|
+
const bg = parseColor(surfaceColor);
|
|
336
|
+
const ratio = contrastRatio(fg, bg);
|
|
337
|
+
const aa = checkAA(ratio);
|
|
338
|
+
const aaa = checkAAA(ratio);
|
|
339
|
+
|
|
340
|
+
const pair: ContrastPair = {
|
|
341
|
+
textToken: text.token,
|
|
342
|
+
surfaceToken: surface.token,
|
|
343
|
+
textColor,
|
|
344
|
+
surfaceColor,
|
|
345
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
346
|
+
meetsAA: aa,
|
|
347
|
+
meetsAAA: aaa,
|
|
348
|
+
mode: 'dark',
|
|
349
|
+
};
|
|
350
|
+
pairs.push(pair);
|
|
351
|
+
|
|
352
|
+
if (!aa) {
|
|
353
|
+
errors.push({
|
|
354
|
+
message: `${text.token} (${textColor}) on ${surface.token} (${surfaceColor}) — ${pair.ratio}:1 — fails AA`,
|
|
355
|
+
pair,
|
|
356
|
+
severity: 'error',
|
|
357
|
+
});
|
|
358
|
+
} else if (!aaa) {
|
|
359
|
+
warnings.push({
|
|
360
|
+
message: `${text.token} (${textColor}) on ${surface.token} (${surfaceColor}) — ${pair.ratio}:1 — passes AA but fails AAA`,
|
|
361
|
+
pair,
|
|
362
|
+
severity: 'warning',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// skip
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
pairs,
|
|
374
|
+
warnings,
|
|
375
|
+
errors,
|
|
376
|
+
passed: errors.length === 0,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
183
380
|
/**
|
|
184
381
|
* Generate SCSS tokens from a theme configuration
|
|
185
382
|
*
|
|
@@ -329,6 +526,21 @@ export async function generateTokenFiles(
|
|
|
329
526
|
|
|
330
527
|
const result: TokenGeneratorResult = { success: true };
|
|
331
528
|
|
|
529
|
+
// Run contrast validation (non-blocking)
|
|
530
|
+
const contrastResult = validateContrast(config);
|
|
531
|
+
result.contrastValidation = contrastResult;
|
|
532
|
+
|
|
533
|
+
if (contrastResult.errors.length > 0) {
|
|
534
|
+
for (const err of contrastResult.errors) {
|
|
535
|
+
console.warn(`[contrast] ERROR: ${err.message}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (contrastResult.warnings.length > 0) {
|
|
539
|
+
for (const warn of contrastResult.warnings) {
|
|
540
|
+
console.warn(`[contrast] WARNING: ${warn.message}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
332
544
|
// Generate SCSS if requested
|
|
333
545
|
if (format === "scss" || format === "both") {
|
|
334
546
|
const scssContent = generateScssTokens(config);
|
package/src/theme/index.ts
CHANGED
|
@@ -19,6 +19,9 @@ export type {
|
|
|
19
19
|
TokenGeneratorOptions,
|
|
20
20
|
TokenGeneratorResult,
|
|
21
21
|
PresetName,
|
|
22
|
+
ContrastPair,
|
|
23
|
+
ContrastWarning,
|
|
24
|
+
ContrastValidationResult,
|
|
22
25
|
} from "./types.js";
|
|
23
26
|
|
|
24
27
|
// Schema validation
|
|
@@ -49,8 +52,21 @@ export {
|
|
|
49
52
|
generateScssTokens,
|
|
50
53
|
generateCssTokens,
|
|
51
54
|
generateTokenFiles,
|
|
55
|
+
validateContrast,
|
|
52
56
|
} from "./generator.js";
|
|
53
57
|
|
|
58
|
+
// Contrast utilities
|
|
59
|
+
export {
|
|
60
|
+
parseColor,
|
|
61
|
+
relativeLuminance,
|
|
62
|
+
contrastRatio,
|
|
63
|
+
meetsAA,
|
|
64
|
+
meetsAAA,
|
|
65
|
+
rgbToHex,
|
|
66
|
+
suggestFix,
|
|
67
|
+
} from "./contrast.js";
|
|
68
|
+
export type { RGB } from "./contrast.js";
|
|
69
|
+
|
|
54
70
|
// Presets
|
|
55
71
|
export {
|
|
56
72
|
DEFAULT_PRESET,
|
package/src/theme/types.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface ThemeColors {
|
|
|
32
32
|
warningBg?: string;
|
|
33
33
|
/** Info background - $fui-color-info-bg */
|
|
34
34
|
infoBg?: string;
|
|
35
|
+
/** Danger text (WCAG AA safe) - $fui-color-danger-text */
|
|
36
|
+
dangerText?: string;
|
|
37
|
+
/** Success text (WCAG AA safe) - $fui-color-success-text */
|
|
38
|
+
successText?: string;
|
|
39
|
+
/** Warning text (WCAG AA safe) - $fui-color-warning-text */
|
|
40
|
+
warningText?: string;
|
|
41
|
+
/** Info text (WCAG AA safe) - $fui-color-info-text */
|
|
42
|
+
infoText?: string;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -138,6 +146,14 @@ export interface ThemeDarkMode {
|
|
|
138
146
|
infoBg?: string;
|
|
139
147
|
/** Backdrop/overlay color for dark mode - $fui-dark-backdrop */
|
|
140
148
|
backdrop?: string;
|
|
149
|
+
/** Danger text for dark mode (WCAG AA safe) - $fui-dark-color-danger-text */
|
|
150
|
+
dangerText?: string;
|
|
151
|
+
/** Success text for dark mode (WCAG AA safe) - $fui-dark-color-success-text */
|
|
152
|
+
successText?: string;
|
|
153
|
+
/** Warning text for dark mode (WCAG AA safe) - $fui-dark-color-warning-text */
|
|
154
|
+
warningText?: string;
|
|
155
|
+
/** Info text for dark mode (WCAG AA safe) - $fui-dark-color-info-text */
|
|
156
|
+
infoText?: string;
|
|
141
157
|
}
|
|
142
158
|
|
|
143
159
|
/**
|
|
@@ -202,6 +218,41 @@ export interface TokenGeneratorResult {
|
|
|
202
218
|
cssPath?: string;
|
|
203
219
|
/** Error message if generation failed */
|
|
204
220
|
error?: string;
|
|
221
|
+
/** Contrast validation results (if theme has color tokens) */
|
|
222
|
+
contrastValidation?: ContrastValidationResult;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A text/surface color pair evaluated for contrast compliance
|
|
227
|
+
*/
|
|
228
|
+
export interface ContrastPair {
|
|
229
|
+
textToken: string;
|
|
230
|
+
surfaceToken: string;
|
|
231
|
+
textColor: string;
|
|
232
|
+
surfaceColor: string;
|
|
233
|
+
ratio: number;
|
|
234
|
+
meetsAA: boolean;
|
|
235
|
+
meetsAAA: boolean;
|
|
236
|
+
mode: "light" | "dark";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A contrast validation warning or error
|
|
241
|
+
*/
|
|
242
|
+
export interface ContrastWarning {
|
|
243
|
+
message: string;
|
|
244
|
+
pair: ContrastPair;
|
|
245
|
+
severity: "error" | "warning";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Result of validating contrast across all theme token pairs
|
|
250
|
+
*/
|
|
251
|
+
export interface ContrastValidationResult {
|
|
252
|
+
pairs: ContrastPair[];
|
|
253
|
+
warnings: ContrastWarning[];
|
|
254
|
+
errors: ContrastWarning[];
|
|
255
|
+
passed: boolean;
|
|
205
256
|
}
|
|
206
257
|
|
|
207
258
|
/**
|