@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,326 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { validateContrast, generateTokenFiles } from '../generator.js';
3
+ import type { ThemeConfig } from '../types.js';
4
+ import { DEFAULT_PRESET } from '../presets.js';
5
+
6
+ describe('validateContrast', () => {
7
+ describe('result shape', () => {
8
+ it('returns an object with pairs, warnings, errors, and passed', () => {
9
+ const result = validateContrast({ name: 'empty' });
10
+ expect(result).toHaveProperty('pairs');
11
+ expect(result).toHaveProperty('warnings');
12
+ expect(result).toHaveProperty('errors');
13
+ expect(result).toHaveProperty('passed');
14
+ });
15
+
16
+ it('returns arrays for pairs, warnings, and errors', () => {
17
+ const result = validateContrast({ name: 'empty' });
18
+ expect(Array.isArray(result.pairs)).toBe(true);
19
+ expect(Array.isArray(result.warnings)).toBe(true);
20
+ expect(Array.isArray(result.errors)).toBe(true);
21
+ });
22
+
23
+ it('returns boolean for passed', () => {
24
+ const result = validateContrast({ name: 'empty' });
25
+ expect(typeof result.passed).toBe('boolean');
26
+ });
27
+ });
28
+
29
+ describe('default preset — light mode', () => {
30
+ const result = validateContrast(DEFAULT_PRESET);
31
+ const lightPairs = result.pairs.filter(p => p.mode === 'light');
32
+
33
+ it('produces 13 light mode pairs (3 text x 4 surface + 1 inverse-on-accent)', () => {
34
+ expect(lightPairs.length).toBe(13);
35
+ });
36
+
37
+ it('includes text.primary on all 4 surfaces', () => {
38
+ const primaryPairs = lightPairs.filter(p => p.textToken === 'text.primary');
39
+ expect(primaryPairs.length).toBe(4);
40
+ });
41
+
42
+ it('includes text.secondary on all 4 surfaces', () => {
43
+ const secondaryPairs = lightPairs.filter(p => p.textToken === 'text.secondary');
44
+ expect(secondaryPairs.length).toBe(4);
45
+ });
46
+
47
+ it('includes text.tertiary on all 4 surfaces', () => {
48
+ const tertiaryPairs = lightPairs.filter(p => p.textToken === 'text.tertiary');
49
+ expect(tertiaryPairs.length).toBe(4);
50
+ });
51
+
52
+ it('includes inverse text on accent', () => {
53
+ const inversePair = lightPairs.find(
54
+ p => p.textToken === 'text.inverse' && p.surfaceToken === 'colors.accent'
55
+ );
56
+ expect(inversePair).toBeDefined();
57
+ expect(inversePair!.meetsAA).toBe(false); // ~3.95:1 < 4.5:1
58
+ });
59
+
60
+ it('reports passed as false due to inverse-on-accent failure', () => {
61
+ expect(result.passed).toBe(false);
62
+ });
63
+
64
+ it('errors have severity "error"', () => {
65
+ for (const err of result.errors) {
66
+ expect(err.severity).toBe('error');
67
+ }
68
+ });
69
+ });
70
+
71
+ describe('default preset — dark mode', () => {
72
+ const result = validateContrast(DEFAULT_PRESET);
73
+ const darkPairs = result.pairs.filter(p => p.mode === 'dark');
74
+
75
+ it('produces 12 dark mode pairs (3 text x 4 surface)', () => {
76
+ expect(darkPairs.length).toBe(12);
77
+ });
78
+
79
+ it('dark token names use dark.text.* prefix', () => {
80
+ for (const pair of darkPairs) {
81
+ expect(pair.textToken).toMatch(/^dark\.text\./);
82
+ }
83
+ });
84
+
85
+ it('dark surface tokens use dark.surfaces.* prefix', () => {
86
+ for (const pair of darkPairs) {
87
+ expect(pair.surfaceToken).toMatch(/^dark\.surfaces\./);
88
+ }
89
+ });
90
+ });
91
+
92
+ describe('partial config', () => {
93
+ it('no text tokens → 0 pairs', () => {
94
+ const config: ThemeConfig = {
95
+ name: 'no-text',
96
+ surfaces: { bgPrimary: '#ffffff' },
97
+ };
98
+ const result = validateContrast(config);
99
+ expect(result.pairs.length).toBe(0);
100
+ expect(result.passed).toBe(true);
101
+ });
102
+
103
+ it('no surfaces → 0 matrix pairs (only inverse-on-accent if present)', () => {
104
+ const config: ThemeConfig = {
105
+ name: 'no-surfaces',
106
+ text: { primary: '#000000' },
107
+ };
108
+ const result = validateContrast(config);
109
+ expect(result.pairs.length).toBe(0);
110
+ });
111
+
112
+ it('no dark mode → light-only pairs', () => {
113
+ const config: ThemeConfig = {
114
+ name: 'light-only',
115
+ text: { primary: '#000000', secondary: '#666666' },
116
+ surfaces: { bgPrimary: '#ffffff' },
117
+ };
118
+ const result = validateContrast(config);
119
+ const darkPairs = result.pairs.filter(p => p.mode === 'dark');
120
+ expect(darkPairs.length).toBe(0);
121
+ expect(result.pairs.length).toBe(2); // 2 text x 1 surface
122
+ });
123
+
124
+ it('high contrast theme passes all checks', () => {
125
+ const config: ThemeConfig = {
126
+ name: 'high-contrast',
127
+ text: { primary: '#000000', secondary: '#333333', tertiary: '#555555', inverse: '#ffffff' },
128
+ surfaces: { bgPrimary: '#ffffff', bgSecondary: '#f5f5f5', bgTertiary: '#eeeeee', bgElevated: '#ffffff' },
129
+ colors: { accent: '#000000' }, // white on black = 21:1
130
+ };
131
+ const result = validateContrast(config);
132
+ expect(result.passed).toBe(true);
133
+ expect(result.errors.length).toBe(0);
134
+ });
135
+
136
+ it('text + surfaces but no colors → no inverse-on-accent pair', () => {
137
+ const config: ThemeConfig = {
138
+ name: 'no-colors',
139
+ text: { primary: '#000000', inverse: '#ffffff' },
140
+ surfaces: { bgPrimary: '#ffffff' },
141
+ };
142
+ const result = validateContrast(config);
143
+ const inversePair = result.pairs.find(p => p.textToken === 'text.inverse');
144
+ expect(inversePair).toBeUndefined();
145
+ });
146
+ });
147
+
148
+ describe('unparseable colors', () => {
149
+ it('CSS variable text/surface are silently skipped (0 pairs)', () => {
150
+ const config: ThemeConfig = {
151
+ name: 'css-vars',
152
+ text: { primary: 'var(--text-color)' },
153
+ surfaces: { bgPrimary: 'var(--bg-color)' },
154
+ };
155
+ const result = validateContrast(config);
156
+ expect(result.pairs.length).toBe(0);
157
+ expect(result.errors.length).toBe(0);
158
+ });
159
+
160
+ it('does not throw on unparseable colors', () => {
161
+ const config: ThemeConfig = {
162
+ name: 'mixed',
163
+ text: { primary: 'invalid-color', secondary: '#333333' },
164
+ surfaces: { bgPrimary: '#ffffff', bgSecondary: 'not-a-color' },
165
+ };
166
+ expect(() => validateContrast(config)).not.toThrow();
167
+ const result = validateContrast(config);
168
+ // Only #333333 on #ffffff should produce a pair
169
+ expect(result.pairs.length).toBe(1);
170
+ });
171
+ });
172
+
173
+ describe('preset integration', () => {
174
+ it('neutral preset (partial — colors only) yields inverse-on-accent pair', () => {
175
+ const config: ThemeConfig = {
176
+ name: 'Neutral',
177
+ colors: { accent: '#71717a' },
178
+ text: { inverse: '#ffffff' },
179
+ };
180
+ const result = validateContrast(config);
181
+ const inversePair = result.pairs.find(p => p.textToken === 'text.inverse');
182
+ expect(inversePair).toBeDefined();
183
+ });
184
+
185
+ it('merged config works correctly', () => {
186
+ const config: ThemeConfig = {
187
+ ...DEFAULT_PRESET,
188
+ name: 'merged',
189
+ colors: { ...DEFAULT_PRESET.colors, accent: '#1e40af' }, // darker blue
190
+ };
191
+ const result = validateContrast(config);
192
+ const inversePair = result.pairs.find(
193
+ p => p.textToken === 'text.inverse' && p.surfaceToken === 'colors.accent'
194
+ );
195
+ expect(inversePair).toBeDefined();
196
+ expect(inversePair!.ratio).toBeGreaterThan(4.5); // white on dark blue passes
197
+ });
198
+
199
+ it('ratios are rounded to 2 decimal places', () => {
200
+ const result = validateContrast(DEFAULT_PRESET);
201
+ for (const pair of result.pairs) {
202
+ const decimals = pair.ratio.toString().split('.')[1];
203
+ if (decimals) {
204
+ expect(decimals.length).toBeLessThanOrEqual(2);
205
+ }
206
+ }
207
+ });
208
+
209
+ it('slate preset with surfaces produces correct number of pairs', () => {
210
+ const config: ThemeConfig = {
211
+ name: 'Slate',
212
+ colors: { accent: '#64748b' },
213
+ text: { primary: '#0f172a', inverse: '#ffffff' },
214
+ surfaces: { bgPrimary: '#ffffff', bgSecondary: '#f1f5f9' },
215
+ };
216
+ const result = validateContrast(config);
217
+ // 1 text x 2 surfaces + 1 inverse-on-accent = 3
218
+ expect(result.pairs.length).toBe(3);
219
+ });
220
+ });
221
+
222
+ describe('custom DS themes (BYODS)', () => {
223
+ it('corporate brand (navy/gold) validates correctly', () => {
224
+ const config: ThemeConfig = {
225
+ name: 'CorporateBrand',
226
+ text: { primary: '#1a1a2e', secondary: '#4a4a6a', tertiary: '#8a8aaa' },
227
+ surfaces: { bgPrimary: '#f0f0f5', bgSecondary: '#e0e0ea' },
228
+ colors: { accent: '#c9a62c' },
229
+ };
230
+ const result = validateContrast(config);
231
+ expect(result.pairs.length).toBeGreaterThan(0);
232
+ // Dark text on light surfaces should mostly pass
233
+ const primaryPairs = result.pairs.filter(p => p.textToken === 'text.primary');
234
+ for (const pair of primaryPairs) {
235
+ expect(pair.meetsAA).toBe(true);
236
+ }
237
+ });
238
+
239
+ it('high-contrast accessible theme passes AA + AAA', () => {
240
+ const config: ThemeConfig = {
241
+ name: 'HighContrastDS',
242
+ text: { primary: '#000000', secondary: '#1a1a1a', tertiary: '#333333', inverse: '#ffffff' },
243
+ surfaces: { bgPrimary: '#ffffff', bgSecondary: '#fafafa', bgTertiary: '#f5f5f5', bgElevated: '#ffffff' },
244
+ colors: { accent: '#000000' },
245
+ };
246
+ const result = validateContrast(config);
247
+ expect(result.passed).toBe(true);
248
+ expect(result.errors.length).toBe(0);
249
+ // All pairs should meet AAA
250
+ for (const pair of result.pairs) {
251
+ expect(pair.meetsAAA).toBe(true);
252
+ }
253
+ });
254
+
255
+ it('dark-first theme validates dark mode matrix', () => {
256
+ const config: ThemeConfig = {
257
+ name: 'DarkFirstDS',
258
+ text: { primary: '#e0e0e0' },
259
+ surfaces: { bgPrimary: '#121212' },
260
+ dark: {
261
+ text: { primary: '#f5f5f5', secondary: '#b0b0b0' },
262
+ surfaces: { bgPrimary: '#0a0a0a', bgSecondary: '#1a1a1a' },
263
+ },
264
+ };
265
+ const result = validateContrast(config);
266
+ const darkPairs = result.pairs.filter(p => p.mode === 'dark');
267
+ expect(darkPairs.length).toBe(4); // 2 text x 2 surfaces
268
+ });
269
+
270
+ it('low-contrast brand theme fails with correct errors', () => {
271
+ const config: ThemeConfig = {
272
+ name: 'LowContrastBrand',
273
+ text: { primary: '#aaaaaa' }, // light gray on white = poor contrast
274
+ surfaces: { bgPrimary: '#ffffff' },
275
+ };
276
+ const result = validateContrast(config);
277
+ expect(result.passed).toBe(false);
278
+ expect(result.errors.length).toBeGreaterThan(0);
279
+ expect(result.errors[0].severity).toBe('error');
280
+ });
281
+
282
+ it('custom theme with HSL colors parses and validates', () => {
283
+ const config: ThemeConfig = {
284
+ name: 'HSLTheme',
285
+ text: { primary: 'hsl(0, 0%, 10%)', secondary: 'hsl(0, 0%, 40%)' },
286
+ surfaces: { bgPrimary: 'hsl(0, 0%, 100%)', bgSecondary: 'hsl(0, 0%, 95%)' },
287
+ };
288
+ const result = validateContrast(config);
289
+ expect(result.pairs.length).toBe(4); // 2 text x 2 surfaces
290
+ // Dark text on white should pass
291
+ const primaryOnWhite = result.pairs.find(
292
+ p => p.textToken === 'text.primary' && p.surfaceToken === 'surfaces.bgPrimary'
293
+ );
294
+ expect(primaryOnWhite?.meetsAA).toBe(true);
295
+ });
296
+ });
297
+
298
+ describe('generateTokenFiles integration', () => {
299
+ it('result includes contrastValidation', async () => {
300
+ const tmpDir = '/tmp/contrast-test-' + Date.now();
301
+ const result = await generateTokenFiles(DEFAULT_PRESET, {
302
+ format: 'css',
303
+ outputDir: tmpDir,
304
+ });
305
+ expect(result.success).toBe(true);
306
+ expect(result.contrastValidation).toBeDefined();
307
+ expect(result.contrastValidation!.pairs.length).toBeGreaterThan(0);
308
+ });
309
+
310
+ it('console.warn is called when there are contrast errors', async () => {
311
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
312
+ const tmpDir = '/tmp/contrast-warn-test-' + Date.now();
313
+ await generateTokenFiles(DEFAULT_PRESET, {
314
+ format: 'css',
315
+ outputDir: tmpDir,
316
+ });
317
+ // Default preset has the inverse-on-accent error
318
+ expect(warnSpy).toHaveBeenCalled();
319
+ const errorCalls = warnSpy.mock.calls.filter(
320
+ call => typeof call[0] === 'string' && call[0].includes('[contrast]')
321
+ );
322
+ expect(errorCalls.length).toBeGreaterThan(0);
323
+ warnSpy.mockRestore();
324
+ });
325
+ });
326
+ });
@@ -0,0 +1,331 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ parseColor,
4
+ relativeLuminance,
5
+ contrastRatio,
6
+ meetsAA,
7
+ meetsAAA,
8
+ rgbToHex,
9
+ suggestFix,
10
+ } from './contrast.js';
11
+ import { deriveSemanticText } from '@fragments/seed-derivation';
12
+
13
+ describe('parseColor', () => {
14
+ it('parses 6-digit hex', () => {
15
+ expect(parseColor('#ffffff')).toEqual({ r: 255, g: 255, b: 255 });
16
+ expect(parseColor('#000000')).toEqual({ r: 0, g: 0, b: 0 });
17
+ expect(parseColor('#6366f1')).toEqual({ r: 99, g: 102, b: 241 });
18
+ });
19
+
20
+ it('parses 3-digit hex', () => {
21
+ expect(parseColor('#fff')).toEqual({ r: 255, g: 255, b: 255 });
22
+ expect(parseColor('#000')).toEqual({ r: 0, g: 0, b: 0 });
23
+ expect(parseColor('#f00')).toEqual({ r: 255, g: 0, b: 0 });
24
+ });
25
+
26
+ it('parses 8-digit hex (ignores alpha)', () => {
27
+ expect(parseColor('#ff0000ff')).toEqual({ r: 255, g: 0, b: 0 });
28
+ });
29
+
30
+ it('parses rgb()', () => {
31
+ expect(parseColor('rgb(255, 255, 255)')).toEqual({ r: 255, g: 255, b: 255 });
32
+ expect(parseColor('rgb(99, 102, 241)')).toEqual({ r: 99, g: 102, b: 241 });
33
+ });
34
+
35
+ it('parses rgba()', () => {
36
+ expect(parseColor('rgba(239, 68, 68, 0.1)')).toEqual({ r: 239, g: 68, b: 68 });
37
+ });
38
+
39
+ it('parses hsl()', () => {
40
+ const red = parseColor('hsl(0, 100%, 50%)');
41
+ expect(red.r).toBe(255);
42
+ expect(red.g).toBe(0);
43
+ expect(red.b).toBe(0);
44
+
45
+ const white = parseColor('hsl(0, 0%, 100%)');
46
+ expect(white).toEqual({ r: 255, g: 255, b: 255 });
47
+ });
48
+
49
+ it('parses hsla()', () => {
50
+ const blue = parseColor('hsla(240, 100%, 50%, 0.5)');
51
+ expect(blue.r).toBe(0);
52
+ expect(blue.g).toBe(0);
53
+ expect(blue.b).toBe(255);
54
+ });
55
+
56
+ it('is case-insensitive', () => {
57
+ expect(parseColor('#FFFFFF')).toEqual({ r: 255, g: 255, b: 255 });
58
+ expect(parseColor('RGB(0, 0, 0)')).toEqual({ r: 0, g: 0, b: 0 });
59
+ });
60
+
61
+ it('trims whitespace', () => {
62
+ expect(parseColor(' #fff ')).toEqual({ r: 255, g: 255, b: 255 });
63
+ });
64
+
65
+ it('throws on invalid input', () => {
66
+ expect(() => parseColor('not-a-color')).toThrow('Cannot parse color');
67
+ expect(() => parseColor('')).toThrow('Cannot parse color');
68
+ });
69
+ });
70
+
71
+ describe('relativeLuminance', () => {
72
+ it('returns 1 for white', () => {
73
+ expect(relativeLuminance(255, 255, 255)).toBeCloseTo(1.0, 4);
74
+ });
75
+
76
+ it('returns 0 for black', () => {
77
+ expect(relativeLuminance(0, 0, 0)).toBeCloseTo(0.0, 4);
78
+ });
79
+ });
80
+
81
+ describe('contrastRatio', () => {
82
+ it('black on white = 21:1', () => {
83
+ const ratio = contrastRatio({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 });
84
+ expect(ratio).toBeCloseTo(21.0, 0);
85
+ });
86
+
87
+ it('white on white = 1:1', () => {
88
+ const ratio = contrastRatio({ r: 255, g: 255, b: 255 }, { r: 255, g: 255, b: 255 });
89
+ expect(ratio).toBeCloseTo(1.0, 4);
90
+ });
91
+
92
+ it('#767676 on #ffffff ~ 4.54:1 (AA boundary)', () => {
93
+ const fg = parseColor('#767676');
94
+ const bg = parseColor('#ffffff');
95
+ const ratio = contrastRatio(fg, bg);
96
+ expect(ratio).toBeGreaterThanOrEqual(4.5);
97
+ expect(ratio).toBeLessThan(4.6);
98
+ });
99
+
100
+ it('is symmetric (order of fg/bg does not change ratio)', () => {
101
+ const fg = parseColor('#333333');
102
+ const bg = parseColor('#ffffff');
103
+ const ratio1 = contrastRatio(fg, bg);
104
+ const ratio2 = contrastRatio(bg, fg);
105
+ expect(ratio1).toBeCloseTo(ratio2, 4);
106
+ });
107
+ });
108
+
109
+ describe('meetsAA', () => {
110
+ it('4.5:1 passes for normal text', () => {
111
+ expect(meetsAA(4.5)).toBe(true);
112
+ });
113
+
114
+ it('4.49:1 fails for normal text', () => {
115
+ expect(meetsAA(4.49)).toBe(false);
116
+ });
117
+
118
+ it('3:1 passes for large text', () => {
119
+ expect(meetsAA(3.0, true)).toBe(true);
120
+ });
121
+
122
+ it('2.9:1 fails for large text', () => {
123
+ expect(meetsAA(2.9, true)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('meetsAAA', () => {
128
+ it('7:1 passes for normal text', () => {
129
+ expect(meetsAAA(7.0)).toBe(true);
130
+ });
131
+
132
+ it('6.9:1 fails for normal text', () => {
133
+ expect(meetsAAA(6.9)).toBe(false);
134
+ });
135
+
136
+ it('4.5:1 passes for large text', () => {
137
+ expect(meetsAAA(4.5, true)).toBe(true);
138
+ });
139
+
140
+ it('4.49:1 fails for large text', () => {
141
+ expect(meetsAAA(4.49, true)).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe('rgbToHex', () => {
146
+ it('converts white', () => {
147
+ expect(rgbToHex(255, 255, 255)).toBe('#ffffff');
148
+ });
149
+
150
+ it('converts black', () => {
151
+ expect(rgbToHex(0, 0, 0)).toBe('#000000');
152
+ });
153
+
154
+ it('clamps out-of-range values', () => {
155
+ expect(rgbToHex(300, -10, 128)).toBe('#ff0080');
156
+ });
157
+ });
158
+
159
+ describe('suggestFix', () => {
160
+ it('suggests a color that meets the target ratio', () => {
161
+ const fg = parseColor('#888888');
162
+ const bg = parseColor('#ffffff');
163
+ const fixed = suggestFix(fg, bg, 4.5);
164
+ const fixedRgb = parseColor(fixed);
165
+ const ratio = contrastRatio(fixedRgb, bg);
166
+ // Allow small rounding tolerance from integer RGB clamping
167
+ expect(ratio).toBeGreaterThanOrEqual(4.4);
168
+ });
169
+
170
+ it('handles dark bg with light fg', () => {
171
+ const fg = parseColor('#999999');
172
+ const bg = parseColor('#1a1a1a');
173
+ const fixed = suggestFix(fg, bg, 4.5);
174
+ const fixedRgb = parseColor(fixed);
175
+ const ratio = contrastRatio(fixedRgb, bg);
176
+ expect(ratio).toBeGreaterThanOrEqual(4.4);
177
+ });
178
+
179
+ it('returns a valid hex color', () => {
180
+ const fg = parseColor('#666666');
181
+ const bg = parseColor('#ffffff');
182
+ const fixed = suggestFix(fg, bg, 7.0);
183
+ expect(fixed).toMatch(/^#[0-9a-f]{6}$/);
184
+ });
185
+ });
186
+
187
+ describe('default preset color pairs', () => {
188
+ const passingPairs = [
189
+ { name: 'primary text on primary bg', fg: '#0f172a', bg: '#ffffff' },
190
+ { name: 'secondary text on primary bg', fg: '#64748b', bg: '#ffffff' },
191
+ { name: 'primary text on secondary bg', fg: '#0f172a', bg: '#f8fafc' },
192
+ ];
193
+
194
+ for (const pair of passingPairs) {
195
+ it(`${pair.name} meets AA`, () => {
196
+ const fg = parseColor(pair.fg);
197
+ const bg = parseColor(pair.bg);
198
+ const ratio = contrastRatio(fg, bg);
199
+ expect(meetsAA(ratio)).toBe(true);
200
+ });
201
+ }
202
+
203
+ it('inverse text on accent (#6366f1) fails AA for normal text (known)', () => {
204
+ // White on indigo-500 is ~3.95:1, below 4.5:1 threshold for normal text.
205
+ // Passes for large text (3:1). This is a real accessibility finding.
206
+ const fg = parseColor('#ffffff');
207
+ const bg = parseColor('#6366f1');
208
+ const ratio = contrastRatio(fg, bg);
209
+ expect(meetsAA(ratio)).toBe(false);
210
+ expect(meetsAA(ratio, true)).toBe(true); // passes for large text
211
+ });
212
+ });
213
+
214
+ describe('suggestFix edge cases', () => {
215
+ it('pure black fg → grayscale result', () => {
216
+ const fg = parseColor('#000000');
217
+ const bg = parseColor('#ffffff');
218
+ const fixed = suggestFix(fg, bg, 4.5);
219
+ expect(fixed).toMatch(/^#[0-9a-f]{6}$/);
220
+ const fixedRgb = parseColor(fixed);
221
+ // Grayscale: all channels should be equal or very close
222
+ expect(Math.abs(fixedRgb.r - fixedRgb.g)).toBeLessThanOrEqual(1);
223
+ expect(Math.abs(fixedRgb.g - fixedRgb.b)).toBeLessThanOrEqual(1);
224
+ });
225
+
226
+ it('black on dark bg → lighter result', () => {
227
+ const fg = parseColor('#000000');
228
+ const bg = parseColor('#1a1a1a');
229
+ const fixed = suggestFix(fg, bg, 4.5);
230
+ const fixedRgb = parseColor(fixed);
231
+ // Must be lighter than the dark bg to achieve contrast
232
+ const avgFixed = (fixedRgb.r + fixedRgb.g + fixedRgb.b) / 3;
233
+ expect(avgFixed).toBeGreaterThan(26); // > #1a1a1a average
234
+ });
235
+
236
+ it('impossible 21:1 target → returns valid hex (best effort)', () => {
237
+ const fg = parseColor('#888888');
238
+ const bg = parseColor('#888888');
239
+ const fixed = suggestFix(fg, bg, 21);
240
+ expect(fixed).toMatch(/^#[0-9a-f]{6}$/);
241
+ });
242
+ });
243
+
244
+ describe('parseColor HSL edge cases', () => {
245
+ it('h=360 is equivalent to h=0', () => {
246
+ const h360 = parseColor('hsl(360, 100%, 50%)');
247
+ const h0 = parseColor('hsl(0, 100%, 50%)');
248
+ expect(h360.r).toBe(h0.r);
249
+ expect(h360.g).toBe(h0.g);
250
+ expect(h360.b).toBe(h0.b);
251
+ });
252
+
253
+ it('s=0% → grayscale', () => {
254
+ const gray = parseColor('hsl(180, 0%, 50%)');
255
+ expect(gray.r).toBe(gray.g);
256
+ expect(gray.g).toBe(gray.b);
257
+ expect(gray.r).toBe(128);
258
+ });
259
+
260
+ it('l=0% → black', () => {
261
+ const black = parseColor('hsl(120, 100%, 0%)');
262
+ expect(black).toEqual({ r: 0, g: 0, b: 0 });
263
+ });
264
+
265
+ it('l=100% → white', () => {
266
+ const white = parseColor('hsl(240, 100%, 100%)');
267
+ expect(white).toEqual({ r: 255, g: 255, b: 255 });
268
+ });
269
+ });
270
+
271
+ describe('deriveSemanticText (via seed-derivation)', () => {
272
+ const semanticColors = {
273
+ danger: '#ef4444',
274
+ success: '#22c55e',
275
+ warning: '#f59e0b',
276
+ info: '#3b82f6',
277
+ };
278
+
279
+ for (const [name, color] of Object.entries(semanticColors)) {
280
+ it(`light mode: ${name} text meets 6:1 on white (headroom for composited bg)`, () => {
281
+ const text = deriveSemanticText(color, false);
282
+ const fg = parseColor(text);
283
+ const bg = parseColor('#ffffff');
284
+ const ratio = contrastRatio(fg, bg);
285
+ expect(ratio).toBeGreaterThanOrEqual(6.0);
286
+ });
287
+
288
+ it(`dark mode: ${name} text meets 6:1 on near-black (headroom for composited bg)`, () => {
289
+ const text = deriveSemanticText(color, true);
290
+ const fg = parseColor(text);
291
+ const bg = parseColor('#0a0a0a');
292
+ const ratio = contrastRatio(fg, bg);
293
+ expect(ratio).toBeGreaterThanOrEqual(6.0);
294
+ });
295
+ }
296
+
297
+ it('returns a valid hex color', () => {
298
+ const text = deriveSemanticText('#ef4444', false);
299
+ expect(text).toMatch(/^#[0-9a-f]{6}$/);
300
+ });
301
+
302
+ it('already-compliant color is returned with minimal change', () => {
303
+ // Pure black on white is 21:1 — should not darken further
304
+ const text = deriveSemanticText('#000000', false);
305
+ expect(text).toBe('#000000');
306
+ });
307
+ });
308
+
309
+ describe('contrastRatio edge cases', () => {
310
+ it('very similar colors → ratio close to 1.0', () => {
311
+ const fg = parseColor('#fefefe');
312
+ const bg = parseColor('#ffffff');
313
+ const ratio = contrastRatio(fg, bg);
314
+ expect(ratio).toBeGreaterThanOrEqual(1.0);
315
+ expect(ratio).toBeLessThan(1.05);
316
+ });
317
+
318
+ it('identical colors → exactly 1', () => {
319
+ const color = parseColor('#abcdef');
320
+ const ratio = contrastRatio(color, color);
321
+ expect(ratio).toBe(1);
322
+ });
323
+
324
+ it('near-white on white → slightly above 1', () => {
325
+ const fg = parseColor('#f0f0f0');
326
+ const bg = parseColor('#ffffff');
327
+ const ratio = contrastRatio(fg, bg);
328
+ expect(ratio).toBeGreaterThan(1);
329
+ expect(ratio).toBeLessThan(1.2);
330
+ });
331
+ });