@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.
Files changed (124) hide show
  1. package/dist/bin.js +996 -79
  2. package/dist/bin.js.map +1 -1
  3. package/dist/{chunk-ICAIQ57V.js → chunk-6JBGU74P.js} +5 -3
  4. package/dist/chunk-6JBGU74P.js.map +1 -0
  5. package/dist/chunk-7OPWMLOE.js +1625 -0
  6. package/dist/chunk-7OPWMLOE.js.map +1 -0
  7. package/dist/{chunk-2H2JAA3U.js → chunk-CVXKXVOY.js} +3 -3
  8. package/dist/{chunk-2H2JAA3U.js.map → chunk-CVXKXVOY.js.map} +1 -1
  9. package/dist/{chunk-IOJE35DZ.js → chunk-NWQ4CJOQ.js} +3 -3
  10. package/dist/{chunk-2DJH4F4P.js → chunk-RVRTRESS.js} +3 -3
  11. package/dist/{chunk-V7YLRR4C.js → chunk-TJ34N7C7.js} +41 -4
  12. package/dist/{chunk-V7YLRR4C.js.map → chunk-TJ34N7C7.js.map} +1 -1
  13. package/dist/{chunk-XNWDI6UT.js → chunk-XHUDJNN3.js} +5 -5
  14. package/dist/{core-DKHB7FYV.js → core-W2HYIQW6.js} +4 -4
  15. package/dist/{generate-KL24VZVD.js → generate-LMTISDIJ.js} +5 -5
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +15 -7
  18. package/dist/index.js.map +1 -1
  19. package/dist/{init-NION5S3M.js → init-7CHRKQ7P.js} +5 -5
  20. package/dist/mcp-bin.js +8 -220
  21. package/dist/mcp-bin.js.map +1 -1
  22. package/dist/scan-WY23TJCP.js +12 -0
  23. package/dist/{service-RWUMZ3EW.js → service-T2L7VLTE.js} +5 -5
  24. package/dist/static-viewer-GBR7YNF3.js +12 -0
  25. package/dist/{test-ECPEXFDN.js → test-OJRXNDO2.js} +4 -4
  26. package/dist/{tokens-ITADYVPF.js → tokens-3BWDESVM.js} +6 -6
  27. package/dist/viewer-SUFOISZM.js +1822 -0
  28. package/dist/viewer-SUFOISZM.js.map +1 -0
  29. package/package.json +6 -5
  30. package/src/bin.ts +31 -0
  31. package/src/build.ts +147 -13
  32. package/src/cli-commands.ts +18 -0
  33. package/src/commands/__tests__/a11y-scoring.test.ts +278 -0
  34. package/src/commands/a11y-report.ts +625 -0
  35. package/src/commands/a11y.ts +168 -14
  36. package/src/commands/build.ts +16 -0
  37. package/src/commands/graph.ts +274 -0
  38. package/src/core/auto-props.ts +464 -0
  39. package/src/core/composition.ts +64 -1
  40. package/src/core/graph-extractor.test.ts +542 -0
  41. package/src/core/graph-extractor.ts +601 -0
  42. package/src/core/importAnalyzer.ts +5 -0
  43. package/src/core/schema.ts +2 -0
  44. package/src/core/types.ts +3 -1
  45. package/src/index.ts +4 -0
  46. package/src/mcp/server.ts +13 -220
  47. package/src/theme/__tests__/component-contrast.test.ts +338 -0
  48. package/src/theme/__tests__/contrast-validation.test.ts +326 -0
  49. package/src/theme/contrast.test.ts +331 -0
  50. package/src/theme/contrast.ts +246 -0
  51. package/src/theme/generator.ts +213 -1
  52. package/src/theme/index.ts +16 -0
  53. package/src/theme/types.ts +51 -0
  54. package/src/viewer/__tests__/a11y-fixes.test.ts +358 -0
  55. package/src/viewer/__tests__/viewer-integration.test.ts +2 -7
  56. package/src/viewer/components/AccessibilityPanel.tsx +493 -433
  57. package/src/viewer/components/ActionCapture.tsx +1 -1
  58. package/src/viewer/components/ActionsPanel.tsx +142 -183
  59. package/src/viewer/components/App.tsx +276 -183
  60. package/src/viewer/components/BottomPanel.tsx +40 -80
  61. package/src/viewer/components/CodePanel.tsx +9 -87
  62. package/src/viewer/components/CommandPalette.tsx +117 -74
  63. package/src/viewer/components/ComponentGraph.tsx +143 -126
  64. package/src/viewer/components/ComponentHeader.tsx +46 -43
  65. package/src/viewer/components/ContractPanel.tsx +124 -117
  66. package/src/viewer/components/ErrorBoundary.tsx +47 -35
  67. package/src/viewer/components/FigmaEmbed.tsx +18 -13
  68. package/src/viewer/components/FragmentEditor.tsx +126 -63
  69. package/src/viewer/components/HealthDashboard.tsx +146 -171
  70. package/src/viewer/components/HmrStatusIndicator.tsx +31 -41
  71. package/src/viewer/components/Icons.tsx +151 -98
  72. package/src/viewer/components/InteractionsPanel.tsx +317 -264
  73. package/src/viewer/components/IsolatedPreviewFrame.tsx +52 -27
  74. package/src/viewer/components/IsolatedRender.tsx +12 -6
  75. package/src/viewer/components/KeyboardShortcutsHelp.tsx +34 -70
  76. package/src/viewer/components/LandingPage.tsx +285 -305
  77. package/src/viewer/components/Layout.tsx +12 -10
  78. package/src/viewer/components/LeftSidebar.tsx +103 -155
  79. package/src/viewer/components/MultiViewportPreview.tsx +254 -63
  80. package/src/viewer/components/PreviewArea.tsx +113 -44
  81. package/src/viewer/components/PreviewFrameHost.tsx +36 -6
  82. package/src/viewer/components/PreviewPane.tsx +2 -3
  83. package/src/viewer/components/PreviewToolbar.tsx +109 -105
  84. package/src/viewer/components/PropsEditor.tsx +154 -74
  85. package/src/viewer/components/PropsTable.tsx +95 -82
  86. package/src/viewer/components/RelationsSection.tsx +71 -40
  87. package/src/viewer/components/ResizablePanel.tsx +158 -55
  88. package/src/viewer/components/RightSidebar.tsx +46 -56
  89. package/src/viewer/components/ScreenshotButton.tsx +12 -12
  90. package/src/viewer/components/SkeletonLoader.tsx +99 -83
  91. package/src/viewer/components/StoryRenderer.tsx +4 -11
  92. package/src/viewer/components/Toast.tsx +3 -67
  93. package/src/viewer/components/TokenStylePanel.tsx +136 -118
  94. package/src/viewer/components/UsageSection.tsx +26 -26
  95. package/src/viewer/components/VariantMatrix.tsx +140 -47
  96. package/src/viewer/components/VariantTabs.tsx +24 -68
  97. package/src/viewer/components/ViewportSelector.tsx +121 -114
  98. package/src/viewer/constants/ui.ts +23 -22
  99. package/src/viewer/entry.tsx +8 -3
  100. package/src/viewer/index.ts +3 -6
  101. package/src/viewer/preview-frame.html +43 -18
  102. package/src/viewer/server.ts +7 -16
  103. package/src/viewer/styles/globals.css +46 -85
  104. package/src/viewer/utils/a11y-fixes.ts +53 -30
  105. package/dist/chunk-ICAIQ57V.js.map +0 -1
  106. package/dist/chunk-U4GQ2JTD.js +0 -832
  107. package/dist/chunk-U4GQ2JTD.js.map +0 -1
  108. package/dist/scan-ESEXV7LF.js +0 -12
  109. package/dist/static-viewer-O37MJ5B6.js +0 -12
  110. package/dist/viewer-YDGFDTK5.js +0 -11104
  111. package/dist/viewer-YDGFDTK5.js.map +0 -1
  112. package/src/viewer/postcss.config.js +0 -6
  113. package/src/viewer/tailwind.config.js +0 -37
  114. /package/dist/{chunk-IOJE35DZ.js.map → chunk-NWQ4CJOQ.js.map} +0 -0
  115. /package/dist/{chunk-2DJH4F4P.js.map → chunk-RVRTRESS.js.map} +0 -0
  116. /package/dist/{chunk-XNWDI6UT.js.map → chunk-XHUDJNN3.js.map} +0 -0
  117. /package/dist/{core-DKHB7FYV.js.map → core-W2HYIQW6.js.map} +0 -0
  118. /package/dist/{generate-KL24VZVD.js.map → generate-LMTISDIJ.js.map} +0 -0
  119. /package/dist/{init-NION5S3M.js.map → init-7CHRKQ7P.js.map} +0 -0
  120. /package/dist/{scan-ESEXV7LF.js.map → scan-WY23TJCP.js.map} +0 -0
  121. /package/dist/{service-RWUMZ3EW.js.map → service-T2L7VLTE.js.map} +0 -0
  122. /package/dist/{static-viewer-O37MJ5B6.js.map → static-viewer-GBR7YNF3.js.map} +0 -0
  123. /package/dist/{test-ECPEXFDN.js.map → test-OJRXNDO2.js.map} +0 -0
  124. /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
+ }
@@ -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);
@@ -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,
@@ -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
  /**