@fragments-sdk/cli 0.2.2

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 (259) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/bin.d.ts +1 -0
  4. package/dist/bin.js +4783 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/chunk-4FDQSGKX.js +786 -0
  7. package/dist/chunk-4FDQSGKX.js.map +1 -0
  8. package/dist/chunk-7H2MMGYG.js +369 -0
  9. package/dist/chunk-7H2MMGYG.js.map +1 -0
  10. package/dist/chunk-BSCG3IP7.js +619 -0
  11. package/dist/chunk-BSCG3IP7.js.map +1 -0
  12. package/dist/chunk-LY2CFFPY.js +898 -0
  13. package/dist/chunk-LY2CFFPY.js.map +1 -0
  14. package/dist/chunk-MUZ6CM66.js +6636 -0
  15. package/dist/chunk-MUZ6CM66.js.map +1 -0
  16. package/dist/chunk-OAENNG3G.js +1489 -0
  17. package/dist/chunk-OAENNG3G.js.map +1 -0
  18. package/dist/chunk-XHNKNI6J.js +235 -0
  19. package/dist/chunk-XHNKNI6J.js.map +1 -0
  20. package/dist/core-DWKLGY4N.js +68 -0
  21. package/dist/core-DWKLGY4N.js.map +1 -0
  22. package/dist/generate-4LQNJ7SX.js +249 -0
  23. package/dist/generate-4LQNJ7SX.js.map +1 -0
  24. package/dist/index.d.ts +775 -0
  25. package/dist/index.js +41 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/init-EMVI47QG.js +416 -0
  28. package/dist/init-EMVI47QG.js.map +1 -0
  29. package/dist/mcp-bin.d.ts +1 -0
  30. package/dist/mcp-bin.js +1117 -0
  31. package/dist/mcp-bin.js.map +1 -0
  32. package/dist/scan-4YPRF7FV.js +12 -0
  33. package/dist/scan-4YPRF7FV.js.map +1 -0
  34. package/dist/service-QSZMZJBJ.js +208 -0
  35. package/dist/service-QSZMZJBJ.js.map +1 -0
  36. package/dist/static-viewer-MIPGZ4Z7.js +12 -0
  37. package/dist/static-viewer-MIPGZ4Z7.js.map +1 -0
  38. package/dist/test-SQ5ZHXWU.js +1067 -0
  39. package/dist/test-SQ5ZHXWU.js.map +1 -0
  40. package/dist/tokens-HSGMYK64.js +173 -0
  41. package/dist/tokens-HSGMYK64.js.map +1 -0
  42. package/dist/viewer-YRF4SQE4.js +11101 -0
  43. package/dist/viewer-YRF4SQE4.js.map +1 -0
  44. package/package.json +107 -0
  45. package/src/ai.ts +266 -0
  46. package/src/analyze.ts +265 -0
  47. package/src/bin.ts +916 -0
  48. package/src/build.ts +248 -0
  49. package/src/commands/a11y.ts +302 -0
  50. package/src/commands/add.ts +313 -0
  51. package/src/commands/audit.ts +195 -0
  52. package/src/commands/baseline.ts +221 -0
  53. package/src/commands/build.ts +144 -0
  54. package/src/commands/compare.ts +337 -0
  55. package/src/commands/context.ts +107 -0
  56. package/src/commands/dev.ts +107 -0
  57. package/src/commands/enhance.ts +858 -0
  58. package/src/commands/generate.ts +391 -0
  59. package/src/commands/init.ts +531 -0
  60. package/src/commands/link/figma.ts +645 -0
  61. package/src/commands/link/index.ts +10 -0
  62. package/src/commands/link/storybook.ts +267 -0
  63. package/src/commands/list.ts +49 -0
  64. package/src/commands/metrics.ts +114 -0
  65. package/src/commands/reset.ts +242 -0
  66. package/src/commands/scan.ts +537 -0
  67. package/src/commands/storygen.ts +207 -0
  68. package/src/commands/tokens.ts +251 -0
  69. package/src/commands/validate.ts +93 -0
  70. package/src/commands/verify.ts +215 -0
  71. package/src/core/composition.test.ts +262 -0
  72. package/src/core/composition.ts +255 -0
  73. package/src/core/config.ts +84 -0
  74. package/src/core/constants.ts +111 -0
  75. package/src/core/context.ts +380 -0
  76. package/src/core/defineSegment.ts +137 -0
  77. package/src/core/discovery.ts +337 -0
  78. package/src/core/figma.ts +263 -0
  79. package/src/core/fragment-types.ts +214 -0
  80. package/src/core/generators/context.ts +389 -0
  81. package/src/core/generators/index.ts +23 -0
  82. package/src/core/generators/registry.ts +364 -0
  83. package/src/core/generators/typescript-extractor.ts +374 -0
  84. package/src/core/importAnalyzer.ts +217 -0
  85. package/src/core/index.ts +149 -0
  86. package/src/core/loader.ts +155 -0
  87. package/src/core/node.ts +63 -0
  88. package/src/core/parser.ts +551 -0
  89. package/src/core/previewLoader.ts +172 -0
  90. package/src/core/schema/fragment.schema.json +189 -0
  91. package/src/core/schema/registry.schema.json +137 -0
  92. package/src/core/schema.ts +182 -0
  93. package/src/core/storyAdapter.test.ts +571 -0
  94. package/src/core/storyAdapter.ts +761 -0
  95. package/src/core/token-types.ts +287 -0
  96. package/src/core/types.ts +754 -0
  97. package/src/diff.ts +323 -0
  98. package/src/index.ts +43 -0
  99. package/src/mcp/__tests__/projectFields.test.ts +130 -0
  100. package/src/mcp/bin.ts +36 -0
  101. package/src/mcp/index.ts +8 -0
  102. package/src/mcp/server.ts +1310 -0
  103. package/src/mcp/utils.ts +54 -0
  104. package/src/mcp-bin.ts +36 -0
  105. package/src/migrate/__tests__/argTypes/argTypes.test.ts +189 -0
  106. package/src/migrate/__tests__/args/args.test.ts +452 -0
  107. package/src/migrate/__tests__/meta/meta.test.ts +198 -0
  108. package/src/migrate/__tests__/stories/stories.test.ts +278 -0
  109. package/src/migrate/__tests__/utils/utils.test.ts +371 -0
  110. package/src/migrate/__tests__/values/values.test.ts +303 -0
  111. package/src/migrate/bin.ts +108 -0
  112. package/src/migrate/converter.ts +658 -0
  113. package/src/migrate/detect.ts +196 -0
  114. package/src/migrate/index.ts +45 -0
  115. package/src/migrate/migrate.ts +163 -0
  116. package/src/migrate/parser.ts +1136 -0
  117. package/src/migrate/report.ts +624 -0
  118. package/src/migrate/types.ts +169 -0
  119. package/src/screenshot.ts +249 -0
  120. package/src/service/__tests__/ast-utils.test.ts +426 -0
  121. package/src/service/__tests__/enhance-scanner.test.ts +200 -0
  122. package/src/service/__tests__/figma/figma.test.ts +652 -0
  123. package/src/service/__tests__/metrics-store.test.ts +409 -0
  124. package/src/service/__tests__/patch-generator.test.ts +186 -0
  125. package/src/service/__tests__/props-extractor.test.ts +365 -0
  126. package/src/service/__tests__/token-registry.test.ts +267 -0
  127. package/src/service/analytics.ts +659 -0
  128. package/src/service/ast-utils.ts +444 -0
  129. package/src/service/browser-pool.ts +339 -0
  130. package/src/service/capture.ts +267 -0
  131. package/src/service/diff.ts +279 -0
  132. package/src/service/enhance/aggregator.ts +489 -0
  133. package/src/service/enhance/cache.ts +275 -0
  134. package/src/service/enhance/codebase-scanner.ts +357 -0
  135. package/src/service/enhance/context-generator.ts +529 -0
  136. package/src/service/enhance/doc-extractor.ts +523 -0
  137. package/src/service/enhance/index.ts +131 -0
  138. package/src/service/enhance/props-extractor.ts +665 -0
  139. package/src/service/enhance/scanner.ts +445 -0
  140. package/src/service/enhance/storybook-parser.ts +552 -0
  141. package/src/service/enhance/types.ts +346 -0
  142. package/src/service/enhance/variant-renderer.ts +479 -0
  143. package/src/service/figma.ts +1008 -0
  144. package/src/service/index.ts +249 -0
  145. package/src/service/metrics-store.ts +333 -0
  146. package/src/service/patch-generator.ts +349 -0
  147. package/src/service/report.ts +854 -0
  148. package/src/service/storage.ts +401 -0
  149. package/src/service/token-fixes.ts +281 -0
  150. package/src/service/token-parser.ts +504 -0
  151. package/src/service/token-registry.ts +721 -0
  152. package/src/service/utils.ts +172 -0
  153. package/src/setup.ts +241 -0
  154. package/src/shared/command-wrapper.ts +81 -0
  155. package/src/shared/dev-server-client.ts +199 -0
  156. package/src/shared/index.ts +8 -0
  157. package/src/shared/segment-loader.ts +59 -0
  158. package/src/shared/types.ts +147 -0
  159. package/src/static-viewer.ts +715 -0
  160. package/src/test/discovery.ts +172 -0
  161. package/src/test/index.ts +281 -0
  162. package/src/test/reporters/console.ts +194 -0
  163. package/src/test/reporters/json.ts +190 -0
  164. package/src/test/reporters/junit.ts +186 -0
  165. package/src/test/runner.ts +598 -0
  166. package/src/test/types.ts +245 -0
  167. package/src/test/watch.ts +200 -0
  168. package/src/validators.ts +152 -0
  169. package/src/viewer/__tests__/jsx-parser.test.ts +502 -0
  170. package/src/viewer/__tests__/render-utils.test.ts +232 -0
  171. package/src/viewer/__tests__/style-utils.test.ts +404 -0
  172. package/src/viewer/bin.ts +86 -0
  173. package/src/viewer/cli/health.ts +256 -0
  174. package/src/viewer/cli/index.ts +33 -0
  175. package/src/viewer/cli/scan.ts +124 -0
  176. package/src/viewer/cli/utils.ts +174 -0
  177. package/src/viewer/components/AccessibilityPanel.tsx +1404 -0
  178. package/src/viewer/components/ActionCapture.tsx +172 -0
  179. package/src/viewer/components/ActionsPanel.tsx +371 -0
  180. package/src/viewer/components/App.tsx +638 -0
  181. package/src/viewer/components/BottomPanel.tsx +224 -0
  182. package/src/viewer/components/CodePanel.tsx +589 -0
  183. package/src/viewer/components/CommandPalette.tsx +336 -0
  184. package/src/viewer/components/ComponentGraph.tsx +394 -0
  185. package/src/viewer/components/ComponentHeader.tsx +85 -0
  186. package/src/viewer/components/ContractPanel.tsx +234 -0
  187. package/src/viewer/components/ErrorBoundary.tsx +85 -0
  188. package/src/viewer/components/FigmaEmbed.tsx +231 -0
  189. package/src/viewer/components/FragmentEditor.tsx +485 -0
  190. package/src/viewer/components/HealthDashboard.tsx +452 -0
  191. package/src/viewer/components/HmrStatusIndicator.tsx +71 -0
  192. package/src/viewer/components/Icons.tsx +417 -0
  193. package/src/viewer/components/InteractionsPanel.tsx +720 -0
  194. package/src/viewer/components/IsolatedPreviewFrame.tsx +321 -0
  195. package/src/viewer/components/IsolatedRender.tsx +111 -0
  196. package/src/viewer/components/KeyboardShortcutsHelp.tsx +89 -0
  197. package/src/viewer/components/LandingPage.tsx +441 -0
  198. package/src/viewer/components/Layout.tsx +22 -0
  199. package/src/viewer/components/LeftSidebar.tsx +391 -0
  200. package/src/viewer/components/MultiViewportPreview.tsx +429 -0
  201. package/src/viewer/components/PreviewArea.tsx +404 -0
  202. package/src/viewer/components/PreviewFrameHost.tsx +310 -0
  203. package/src/viewer/components/PreviewPane.tsx +150 -0
  204. package/src/viewer/components/PreviewToolbar.tsx +176 -0
  205. package/src/viewer/components/PropsEditor.tsx +512 -0
  206. package/src/viewer/components/PropsTable.tsx +98 -0
  207. package/src/viewer/components/RelationsSection.tsx +57 -0
  208. package/src/viewer/components/ResizablePanel.tsx +328 -0
  209. package/src/viewer/components/RightSidebar.tsx +118 -0
  210. package/src/viewer/components/ScreenshotButton.tsx +90 -0
  211. package/src/viewer/components/Sidebar.tsx +169 -0
  212. package/src/viewer/components/SkeletonLoader.tsx +156 -0
  213. package/src/viewer/components/StoryRenderer.tsx +128 -0
  214. package/src/viewer/components/ThemeProvider.tsx +96 -0
  215. package/src/viewer/components/Toast.tsx +67 -0
  216. package/src/viewer/components/TokenStylePanel.tsx +708 -0
  217. package/src/viewer/components/UsageSection.tsx +95 -0
  218. package/src/viewer/components/VariantMatrix.tsx +350 -0
  219. package/src/viewer/components/VariantRenderer.tsx +131 -0
  220. package/src/viewer/components/VariantTabs.tsx +84 -0
  221. package/src/viewer/components/ViewportSelector.tsx +165 -0
  222. package/src/viewer/components/_future/CreatePage.tsx +836 -0
  223. package/src/viewer/composition-renderer.ts +381 -0
  224. package/src/viewer/constants/index.ts +1 -0
  225. package/src/viewer/constants/ui.ts +185 -0
  226. package/src/viewer/entry.tsx +299 -0
  227. package/src/viewer/hooks/index.ts +2 -0
  228. package/src/viewer/hooks/useA11yCache.ts +383 -0
  229. package/src/viewer/hooks/useA11yService.ts +498 -0
  230. package/src/viewer/hooks/useActions.ts +138 -0
  231. package/src/viewer/hooks/useAppState.ts +124 -0
  232. package/src/viewer/hooks/useFigmaIntegration.ts +132 -0
  233. package/src/viewer/hooks/useHmrStatus.ts +109 -0
  234. package/src/viewer/hooks/useKeyboardShortcuts.ts +222 -0
  235. package/src/viewer/hooks/usePreviewBridge.ts +347 -0
  236. package/src/viewer/hooks/useScrollSpy.ts +78 -0
  237. package/src/viewer/hooks/useUrlState.ts +330 -0
  238. package/src/viewer/hooks/useViewSettings.ts +125 -0
  239. package/src/viewer/index.html +28 -0
  240. package/src/viewer/index.ts +14 -0
  241. package/src/viewer/intelligence/healthReport.ts +505 -0
  242. package/src/viewer/intelligence/styleDrift.ts +340 -0
  243. package/src/viewer/intelligence/usageScanner.ts +309 -0
  244. package/src/viewer/jsx-parser.ts +485 -0
  245. package/src/viewer/postcss.config.js +6 -0
  246. package/src/viewer/preview-frame-entry.tsx +25 -0
  247. package/src/viewer/preview-frame.html +109 -0
  248. package/src/viewer/render-template.html +68 -0
  249. package/src/viewer/render-utils.ts +170 -0
  250. package/src/viewer/server.ts +276 -0
  251. package/src/viewer/style-utils.ts +414 -0
  252. package/src/viewer/styles/globals.css +355 -0
  253. package/src/viewer/tailwind.config.js +37 -0
  254. package/src/viewer/types/a11y.ts +197 -0
  255. package/src/viewer/utils/a11y-fixes.ts +471 -0
  256. package/src/viewer/utils/actionExport.ts +372 -0
  257. package/src/viewer/utils/colorSchemes.ts +201 -0
  258. package/src/viewer/utils/detectRelationships.ts +256 -0
  259. package/src/viewer/vite-plugin.ts +2143 -0
@@ -0,0 +1,1404 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Accessibility Panel - Enhanced a11y testing
4
+ *
5
+ * Runs axe-core accessibility checks and displays:
6
+ * - Violations with severity hierarchy and actionable guidance
7
+ * - Static fix suggestions with before/after code
8
+ * - WCAG references and "Why it matters" explanations
9
+ * - Real-time re-scanning on file changes
10
+ */
11
+
12
+ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
13
+ import type { Result, NodeResult, ImpactValue, RunOptions } from "axe-core";
14
+ import clsx from "clsx";
15
+ import { BRAND } from "../../core/index.js";
16
+ import {
17
+ updateComponentA11yResult,
18
+ getComponentA11yResult,
19
+ } from "../hooks/useA11yCache.js";
20
+ import { useA11yService } from "../hooks/useA11yService.js";
21
+ import {
22
+ getStaticFix,
23
+ generateElementFix,
24
+ getImpactColorClass,
25
+ extractWcagTags,
26
+ } from "../utils/a11y-fixes.js";
27
+ import type { SerializedViolation, SerializedNode } from "../types/a11y.js";
28
+
29
+ // Storage key for AI config
30
+ const AI_CONFIG_STORAGE_KEY = `${BRAND.storagePrefix}ai-config`;
31
+ import {
32
+ CheckIcon,
33
+ XIcon,
34
+ LoadingIcon,
35
+ ChevronDownIcon,
36
+ ChevronRightIcon,
37
+ WandIcon,
38
+ AccessibilityIcon,
39
+ } from "./Icons.js";
40
+
41
+ // Define our own interface for axe results to avoid import issues
42
+ interface AxeResults {
43
+ violations: Result[];
44
+ passes: Result[];
45
+ incomplete: Result[];
46
+ inapplicable: Result[];
47
+ timestamp: string;
48
+ testEngine: { name: string; version: string };
49
+ testRunner: { name: string };
50
+ testEnvironment: { userAgent: string; windowWidth: number; windowHeight: number };
51
+ url: string;
52
+ toolOptions: RunOptions;
53
+ }
54
+
55
+ // AI provider configuration
56
+ interface AIProviderConfig {
57
+ provider: "anthropic" | "openai";
58
+ apiKey: string;
59
+ }
60
+
61
+ interface AccessibilityPanelProps {
62
+ /** Target element to scan (selector or ref data attribute) */
63
+ targetSelector?: string;
64
+ /** Callback when scan completes */
65
+ onScanComplete?: (results: AxeResults) => void;
66
+ /** Whether to auto-run scan on mount/changes */
67
+ autoScan?: boolean;
68
+ /** Preview key for re-scanning when component changes */
69
+ previewKey?: number;
70
+ /** Cache key for storing scan results (e.g., componentName-variantName) */
71
+ cacheKey?: string;
72
+ /** Component name for shared cache (updates global a11y cache) */
73
+ componentName?: string;
74
+ /** Variant name for shared cache */
75
+ variantName?: string;
76
+ }
77
+
78
+ type TabType = "violations" | "passes" | "incomplete";
79
+
80
+ const IMPACT_ORDER: Record<string, number> = {
81
+ critical: 0,
82
+ serious: 1,
83
+ moderate: 2,
84
+ minor: 3,
85
+ };
86
+
87
+ // Cache the axe-core module to avoid repeated dynamic imports
88
+ let axeModule: typeof import("axe-core") | null = null;
89
+
90
+ // Cache scan results to avoid re-scanning the same component
91
+ const scanResultsCache = new Map<string, { timestamp: number; results: AxeResults }>();
92
+ const SCAN_CACHE_TTL = 30000; // 30 seconds cache for scan results
93
+
94
+ export function AccessibilityPanel({
95
+ targetSelector = '[data-preview-container="true"]',
96
+ onScanComplete,
97
+ autoScan = true,
98
+ previewKey = 0,
99
+ cacheKey,
100
+ componentName,
101
+ variantName,
102
+ }: AccessibilityPanelProps) {
103
+ const [results, setResults] = useState<AxeResults | null>(() => {
104
+ // Check cache on initial render
105
+ if (cacheKey) {
106
+ const cached = scanResultsCache.get(cacheKey);
107
+ if (cached && (Date.now() - cached.timestamp) < SCAN_CACHE_TTL) {
108
+ return cached.results;
109
+ }
110
+ }
111
+ return null;
112
+ });
113
+ const [isScanning, setIsScanning] = useState(false);
114
+ const [error, setError] = useState<string | null>(null);
115
+ const [activeTab, setActiveTab] = useState<TabType>("violations");
116
+ const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set());
117
+ const [highlightedElement, setHighlightedElement] = useState<string | null>(null);
118
+ const [aiConfig, setAIConfig] = useState<AIProviderConfig | null>(null);
119
+ const [showAISetup, setShowAISetup] = useState(false);
120
+ const [generatingFix, setGeneratingFix] = useState<string | null>(null);
121
+ const [aiFixes, setAIFixes] = useState<Record<string, string>>({});
122
+ const [copiedFix, setCopiedFix] = useState<string | null>(null);
123
+
124
+ // Ref to track if a scan is in progress (prevents concurrent axe.run calls)
125
+ const scanInProgressRef = useRef(false);
126
+
127
+ // Use the a11y service for HMR integration
128
+ const { isScanning: serviceScanning, scanStatus } = useA11yService({
129
+ componentName,
130
+ targetSelector,
131
+ variant: variantName,
132
+ autoScan: false, // We handle scanning manually for more control
133
+ });
134
+
135
+ // Load saved AI config from localStorage
136
+ useEffect(() => {
137
+ const saved = localStorage.getItem(AI_CONFIG_STORAGE_KEY);
138
+ if (saved) {
139
+ try {
140
+ setAIConfig(JSON.parse(saved));
141
+ } catch (e) {
142
+ // Ignore invalid config
143
+ }
144
+ }
145
+ }, []);
146
+
147
+ // Cleanup on unmount - reset scan in progress flag
148
+ useEffect(() => {
149
+ return () => {
150
+ scanInProgressRef.current = false;
151
+ };
152
+ }, []);
153
+
154
+ // Convert axe-core Result to SerializedViolation
155
+ const serializeViolation = useCallback((result: Result): SerializedViolation => ({
156
+ id: result.id,
157
+ impact: result.impact || null,
158
+ description: result.description,
159
+ help: result.help,
160
+ helpUrl: result.helpUrl,
161
+ tags: result.tags,
162
+ nodes: result.nodes.map(node => ({
163
+ html: node.html,
164
+ target: node.target as string[],
165
+ failureSummary: node.failureSummary,
166
+ any: node.any?.map(check => ({
167
+ id: check.id,
168
+ data: check.data,
169
+ relatedNodes: check.relatedNodes?.map(rn => ({
170
+ html: rn.html,
171
+ target: rn.target as string[],
172
+ })),
173
+ impact: check.impact,
174
+ message: check.message,
175
+ })),
176
+ all: node.all?.map(check => ({
177
+ id: check.id,
178
+ data: check.data,
179
+ relatedNodes: check.relatedNodes?.map(rn => ({
180
+ html: rn.html,
181
+ target: rn.target as string[],
182
+ })),
183
+ impact: check.impact,
184
+ message: check.message,
185
+ })),
186
+ none: node.none?.map(check => ({
187
+ id: check.id,
188
+ data: check.data,
189
+ relatedNodes: check.relatedNodes?.map(rn => ({
190
+ html: rn.html,
191
+ target: rn.target as string[],
192
+ })),
193
+ impact: check.impact,
194
+ message: check.message,
195
+ })),
196
+ })),
197
+ }), []);
198
+
199
+ // Run accessibility scan
200
+ const runScan = useCallback(async () => {
201
+ // Prevent concurrent scans - axe-core throws if run() is called while already running
202
+ if (scanInProgressRef.current) {
203
+ return;
204
+ }
205
+
206
+ scanInProgressRef.current = true;
207
+ setIsScanning(true);
208
+ setError(null);
209
+
210
+ try {
211
+ // Use cached module or dynamically import axe-core
212
+ if (!axeModule) {
213
+ axeModule = await import("axe-core");
214
+ }
215
+ // Handle both ESM default export and CommonJS module
216
+ const axe = (axeModule as { default?: typeof import("axe-core") }).default || axeModule;
217
+
218
+ // First, try to find target in the main document
219
+ let target: Element | null = document.querySelector(targetSelector);
220
+ let runInIframe = false;
221
+ let iframeDocument: Document | null = null;
222
+
223
+ // If not found, look inside the preview iframe
224
+ if (!target) {
225
+ const iframe = document.querySelector('iframe[title^="Preview:"]') as HTMLIFrameElement;
226
+ if (iframe?.contentDocument) {
227
+ iframeDocument = iframe.contentDocument;
228
+ // Try to find #preview-root inside the iframe
229
+ target = iframe.contentDocument.querySelector('#preview-root');
230
+
231
+ // If still no target, try the body
232
+ if (!target) {
233
+ target = iframe.contentDocument.body;
234
+ }
235
+
236
+ if (target) {
237
+ runInIframe = true;
238
+ }
239
+ }
240
+ }
241
+
242
+ if (!target) {
243
+ setError(`Target element not found: ${targetSelector}`);
244
+ setIsScanning(false);
245
+ scanInProgressRef.current = false;
246
+ return;
247
+ }
248
+
249
+ // If running inside iframe, we need to inject and run axe-core there
250
+ let axeResults: AxeResults;
251
+
252
+ if (runInIframe && iframeDocument) {
253
+ // Inject axe-core into the iframe if not already present
254
+ const iframeWindow = (iframeDocument.defaultView || iframeDocument.parentWindow) as Window & { axe?: typeof import("axe-core") };
255
+
256
+ if (!iframeWindow.axe) {
257
+ // Get axe-core source and inject it
258
+ const axeSource = axe.source;
259
+ const script = iframeDocument.createElement('script');
260
+ script.textContent = axeSource;
261
+ iframeDocument.head.appendChild(script);
262
+ }
263
+
264
+ // Wait a tick for the script to execute
265
+ await new Promise(resolve => setTimeout(resolve, 10));
266
+
267
+ // Now run axe in the iframe context
268
+ const iframeAxe = iframeWindow.axe;
269
+ if (!iframeAxe) {
270
+ setError('Failed to inject axe-core into iframe');
271
+ setIsScanning(false);
272
+ scanInProgressRef.current = false;
273
+ return;
274
+ }
275
+
276
+ // Configure and run axe in the iframe
277
+ iframeAxe.configure({
278
+ rules: [
279
+ { id: "color-contrast", enabled: true },
280
+ { id: "image-alt", enabled: true },
281
+ { id: "button-name", enabled: true },
282
+ { id: "link-name", enabled: true },
283
+ { id: "label", enabled: true },
284
+ { id: "aria-valid-attr", enabled: true },
285
+ { id: "aria-valid-attr-value", enabled: true },
286
+ ],
287
+ });
288
+
289
+ axeResults = await iframeAxe.run(target as HTMLElement, {
290
+ resultTypes: ["violations", "passes", "incomplete", "inapplicable"],
291
+ });
292
+ } else {
293
+ // Running in main document
294
+ // Configure axe-core
295
+ axe.configure({
296
+ rules: [
297
+ { id: "color-contrast", enabled: true },
298
+ { id: "image-alt", enabled: true },
299
+ { id: "button-name", enabled: true },
300
+ { id: "link-name", enabled: true },
301
+ { id: "label", enabled: true },
302
+ { id: "aria-valid-attr", enabled: true },
303
+ { id: "aria-valid-attr-value", enabled: true },
304
+ ],
305
+ });
306
+
307
+ // Run the scan
308
+ axeResults = await axe.run(target as HTMLElement, {
309
+ resultTypes: ["violations", "passes", "incomplete", "inapplicable"],
310
+ });
311
+ }
312
+
313
+ setResults(axeResults);
314
+ onScanComplete?.(axeResults);
315
+
316
+ // Cache the results
317
+ if (cacheKey) {
318
+ scanResultsCache.set(cacheKey, { timestamp: Date.now(), results: axeResults });
319
+ }
320
+
321
+ // Update shared a11y cache with full violations
322
+ if (componentName) {
323
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
324
+ for (const violation of axeResults.violations) {
325
+ const impact = violation.impact || 'minor';
326
+ if (impact in counts) {
327
+ counts[impact as keyof typeof counts]++;
328
+ }
329
+ }
330
+
331
+ updateComponentA11yResult(componentName, {
332
+ violations: axeResults.violations.map(serializeViolation),
333
+ passes: axeResults.passes.length,
334
+ incomplete: axeResults.incomplete.length,
335
+ counts,
336
+ variant: variantName,
337
+ });
338
+ }
339
+
340
+ // Switch to violations tab if there are any
341
+ if (axeResults.violations.length > 0) {
342
+ setActiveTab("violations");
343
+ }
344
+ } catch (err) {
345
+ setError(err instanceof Error ? err.message : "Accessibility scan failed");
346
+ } finally {
347
+ scanInProgressRef.current = false;
348
+ setIsScanning(false);
349
+ }
350
+ }, [targetSelector, onScanComplete, cacheKey, componentName, variantName, serializeViolation]);
351
+
352
+ // Auto-scan on mount and when previewKey changes (skip if we have cached results)
353
+ useEffect(() => {
354
+ if (autoScan && !results) {
355
+ const timer = setTimeout(runScan, 200);
356
+ return () => clearTimeout(timer);
357
+ }
358
+ }, [autoScan, runScan, previewKey]);
359
+
360
+ // Re-scan when variant changes (cacheKey includes variant)
361
+ useEffect(() => {
362
+ if (!cacheKey || !autoScan) return;
363
+
364
+ // Check if we have cached results for this variant
365
+ const cached = scanResultsCache.get(cacheKey);
366
+ if (cached && (Date.now() - cached.timestamp) < SCAN_CACHE_TTL) {
367
+ // Use cached results
368
+ setResults(cached.results);
369
+ return;
370
+ }
371
+
372
+ // No cache for this variant - clear results and trigger re-scan
373
+ setResults(null);
374
+ setError(null);
375
+ const timer = setTimeout(runScan, 300);
376
+ return () => clearTimeout(timer);
377
+ }, [cacheKey, autoScan, runScan]);
378
+
379
+ // Listen for HMR invalidation and re-scan
380
+ useEffect(() => {
381
+ if (!componentName) return;
382
+
383
+ const handleInvalidation = (event: CustomEvent<{ components: string[] }>) => {
384
+ if (event.detail.components.includes(componentName)) {
385
+ // Debounce the re-scan
386
+ const timer = setTimeout(runScan, 500);
387
+ return () => clearTimeout(timer);
388
+ }
389
+ };
390
+
391
+ window.addEventListener('a11y-cache-invalidated', handleInvalidation as EventListener);
392
+ return () => {
393
+ window.removeEventListener('a11y-cache-invalidated', handleInvalidation as EventListener);
394
+ };
395
+ }, [componentName, runScan]);
396
+
397
+ // Highlight element in the preview
398
+ const highlightElement = useCallback((selector: string | null) => {
399
+ // Helper to clear highlights in a document
400
+ const clearHighlights = (doc: Document) => {
401
+ doc.querySelectorAll("[data-a11y-highlight]").forEach((el) => {
402
+ el.removeAttribute("data-a11y-highlight");
403
+ (el as HTMLElement).style.outline = "";
404
+ (el as HTMLElement).style.outlineOffset = "";
405
+ });
406
+ };
407
+
408
+ // Helper to apply highlights in a document
409
+ const applyHighlights = (doc: Document, sel: string) => {
410
+ try {
411
+ const elements = doc.querySelectorAll(sel);
412
+ elements.forEach((el) => {
413
+ el.setAttribute("data-a11y-highlight", "true");
414
+ (el as HTMLElement).style.outline = "3px solid #ef4444";
415
+ (el as HTMLElement).style.outlineOffset = "2px";
416
+ });
417
+ return elements.length > 0;
418
+ } catch {
419
+ return false;
420
+ }
421
+ };
422
+
423
+ // Clear highlights in main document
424
+ clearHighlights(document);
425
+
426
+ // Also clear highlights in iframe if it exists
427
+ const iframe = document.querySelector('iframe[title^="Preview:"]') as HTMLIFrameElement;
428
+ if (iframe?.contentDocument) {
429
+ clearHighlights(iframe.contentDocument);
430
+ }
431
+
432
+ if (!selector) {
433
+ setHighlightedElement(null);
434
+ return;
435
+ }
436
+
437
+ // Try to highlight in main document first
438
+ let found = applyHighlights(document, selector);
439
+
440
+ // If not found in main document, try inside the iframe
441
+ if (!found && iframe?.contentDocument) {
442
+ found = applyHighlights(iframe.contentDocument, selector);
443
+ }
444
+
445
+ if (found) {
446
+ setHighlightedElement(selector);
447
+ }
448
+ }, []);
449
+
450
+ // Toggle rule expansion
451
+ const toggleRule = useCallback((ruleId: string) => {
452
+ setExpandedRules((prev) => {
453
+ const next = new Set(prev);
454
+ if (next.has(ruleId)) {
455
+ next.delete(ruleId);
456
+ } else {
457
+ next.add(ruleId);
458
+ }
459
+ return next;
460
+ });
461
+ }, []);
462
+
463
+ // Copy fix to clipboard
464
+ const copyFix = useCallback(async (fixCode: string, fixKey: string) => {
465
+ try {
466
+ await navigator.clipboard.writeText(fixCode);
467
+ setCopiedFix(fixKey);
468
+ setTimeout(() => setCopiedFix(null), 2000);
469
+ } catch (e) {
470
+ console.error("Failed to copy:", e);
471
+ }
472
+ }, []);
473
+
474
+ // Generate AI fix suggestion
475
+ const generateAIFix = useCallback(
476
+ async (violation: Result, node: NodeResult) => {
477
+ if (!aiConfig) {
478
+ setShowAISetup(true);
479
+ return;
480
+ }
481
+
482
+ const fixKey = `${violation.id}-${node.html}`;
483
+ setGeneratingFix(fixKey);
484
+
485
+ try {
486
+ const prompt = buildFixPrompt(violation, node);
487
+
488
+ if (aiConfig.provider === "anthropic") {
489
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
490
+ method: "POST",
491
+ headers: {
492
+ "Content-Type": "application/json",
493
+ "x-api-key": aiConfig.apiKey,
494
+ "anthropic-version": "2023-06-01",
495
+ "anthropic-dangerous-direct-browser-access": "true",
496
+ },
497
+ body: JSON.stringify({
498
+ model: "claude-sonnet-4-20250514",
499
+ max_tokens: 1024,
500
+ messages: [{ role: "user", content: prompt }],
501
+ }),
502
+ });
503
+
504
+ if (!response.ok) {
505
+ throw new Error("Failed to generate fix");
506
+ }
507
+
508
+ const data = await response.json();
509
+ const fix = data.content[0].text;
510
+ setAIFixes((prev) => ({ ...prev, [fixKey]: fix }));
511
+ } else if (aiConfig.provider === "openai") {
512
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
513
+ method: "POST",
514
+ headers: {
515
+ "Content-Type": "application/json",
516
+ Authorization: `Bearer ${aiConfig.apiKey}`,
517
+ },
518
+ body: JSON.stringify({
519
+ model: "gpt-4o",
520
+ messages: [{ role: "user", content: prompt }],
521
+ max_tokens: 1024,
522
+ }),
523
+ });
524
+
525
+ if (!response.ok) {
526
+ throw new Error("Failed to generate fix");
527
+ }
528
+
529
+ const data = await response.json();
530
+ const fix = data.choices[0].message.content;
531
+ setAIFixes((prev) => ({ ...prev, [fixKey]: fix }));
532
+ }
533
+ } catch (err) {
534
+ setAIFixes((prev) => ({
535
+ ...prev,
536
+ [fixKey]: `Error: ${err instanceof Error ? err.message : "Failed to generate fix"}`,
537
+ }));
538
+ } finally {
539
+ setGeneratingFix(null);
540
+ }
541
+ },
542
+ [aiConfig]
543
+ );
544
+
545
+ // Save AI config
546
+ const saveAIConfig = useCallback((config: AIProviderConfig) => {
547
+ setAIConfig(config);
548
+ localStorage.setItem(AI_CONFIG_STORAGE_KEY, JSON.stringify(config));
549
+ setShowAISetup(false);
550
+ }, []);
551
+
552
+ // Sort violations by impact
553
+ const sortedViolations = useMemo(() => {
554
+ if (!results?.violations) return [];
555
+ return [...results.violations].sort(
556
+ (a, b) =>
557
+ IMPACT_ORDER[a.impact || "minor"] - IMPACT_ORDER[b.impact || "minor"]
558
+ );
559
+ }, [results]);
560
+
561
+ // Count totals
562
+ const counts = useMemo(
563
+ () => ({
564
+ violations: results?.violations.length || 0,
565
+ passes: results?.passes.length || 0,
566
+ incomplete: results?.incomplete.length || 0,
567
+ }),
568
+ [results]
569
+ );
570
+
571
+ // Check if currently re-scanning due to HMR
572
+ const isReScanning = isScanning && results !== null;
573
+
574
+ // Render loading state
575
+ if (isScanning && !results) {
576
+ return (
577
+ <div className="flex flex-col items-center justify-center p-8 text-tertiary">
578
+ <LoadingIcon className="w-6 h-6 animate-spin mb-2" />
579
+ <span className="text-xs">Running accessibility checks...</span>
580
+ </div>
581
+ );
582
+ }
583
+
584
+ // Render error state
585
+ if (error) {
586
+ return (
587
+ <div className="flex flex-col items-center justify-center p-8">
588
+ <XIcon className="w-6 h-6 text-red-500 mb-2" />
589
+ <span className="text-xs text-red-600 mb-2">{error}</span>
590
+ <button
591
+ onClick={runScan}
592
+ className="text-xs text-[--color-accent] hover:underline"
593
+ >
594
+ Retry Scan
595
+ </button>
596
+ </div>
597
+ );
598
+ }
599
+
600
+ // Render no results state
601
+ if (!results) {
602
+ return (
603
+ <div className="flex flex-col items-center justify-center p-8 text-tertiary">
604
+ <AccessibilityIcon className="w-8 h-8 mb-2 opacity-50" />
605
+ <span className="text-xs mb-2">No accessibility scan results</span>
606
+ <button
607
+ onClick={runScan}
608
+ className="text-xs px-3 py-1 rounded bg-[--color-accent] text-white hover:opacity-90"
609
+ >
610
+ Run Scan
611
+ </button>
612
+ </div>
613
+ );
614
+ }
615
+
616
+ return (
617
+ <div className="flex flex-col h-full overflow-hidden">
618
+ {/* Tabs */}
619
+ <div className="flex items-center gap-1 px-4 py-2 border-b border-[--border] bg-[--bg-secondary] flex-shrink-0">
620
+ <TabButton
621
+ active={activeTab === "violations"}
622
+ onClick={() => setActiveTab("violations")}
623
+ count={counts.violations}
624
+ label="Violations"
625
+ variant="error"
626
+ />
627
+ <TabButton
628
+ active={activeTab === "passes"}
629
+ onClick={() => setActiveTab("passes")}
630
+ count={counts.passes}
631
+ label="Passes"
632
+ variant="success"
633
+ />
634
+ <TabButton
635
+ active={activeTab === "incomplete"}
636
+ onClick={() => setActiveTab("incomplete")}
637
+ count={counts.incomplete}
638
+ label="Incomplete"
639
+ variant="warning"
640
+ />
641
+
642
+ <div className="flex-1" />
643
+
644
+ {/* Re-scanning indicator */}
645
+ {isReScanning && (
646
+ <span className="flex items-center gap-1 px-2 py-1 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 rounded animate-pulse">
647
+ <LoadingIcon className="w-3 h-3 animate-spin" />
648
+ Re-scanning...
649
+ </span>
650
+ )}
651
+
652
+ {/* Rescan button */}
653
+ <button
654
+ onClick={runScan}
655
+ disabled={isScanning}
656
+ className="flex items-center gap-1 px-2 py-1 text-xs text-tertiary hover:text-primary hover:bg-[--bg-hover] rounded disabled:opacity-50"
657
+ title="Re-run accessibility scan"
658
+ >
659
+ <LoadingIcon
660
+ className={clsx("w-3 h-3", isScanning && "animate-spin")}
661
+ />
662
+ {isScanning ? "Scanning..." : "Rescan"}
663
+ </button>
664
+
665
+ {/* AI Setup button */}
666
+ <button
667
+ onClick={() => setShowAISetup(true)}
668
+ className={clsx(
669
+ "flex items-center gap-1 px-2 py-1 text-xs rounded",
670
+ aiConfig
671
+ ? "text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950/30"
672
+ : "text-tertiary hover:text-primary hover:bg-[--bg-hover]"
673
+ )}
674
+ title={aiConfig ? "AI fixes enabled" : "Configure AI for fix suggestions"}
675
+ >
676
+ <WandIcon className="w-3 h-3" />
677
+ {aiConfig ? "AI Ready" : "Setup AI"}
678
+ </button>
679
+ </div>
680
+
681
+ {/* Content */}
682
+ <div className="flex-1 overflow-y-auto p-4">
683
+ {activeTab === "violations" && (
684
+ <>
685
+ {counts.violations === 0 ? (
686
+ <SuccessMessage message="No accessibility violations found." />
687
+ ) : (
688
+ <EnhancedRuleList
689
+ rules={sortedViolations}
690
+ expandedRules={expandedRules}
691
+ onToggleRule={toggleRule}
692
+ onHighlight={highlightElement}
693
+ highlightedElement={highlightedElement}
694
+ onGenerateFix={generateAIFix}
695
+ generatingFix={generatingFix}
696
+ aiFixes={aiFixes}
697
+ aiEnabled={!!aiConfig}
698
+ onCopyFix={copyFix}
699
+ copiedFix={copiedFix}
700
+ type="violation"
701
+ />
702
+ )}
703
+ </>
704
+ )}
705
+
706
+ {activeTab === "passes" && (
707
+ <>
708
+ {counts.passes === 0 ? (
709
+ <EmptyMessage message="No passing checks to display." />
710
+ ) : (
711
+ <RuleList
712
+ rules={results.passes}
713
+ expandedRules={expandedRules}
714
+ onToggleRule={toggleRule}
715
+ onHighlight={highlightElement}
716
+ highlightedElement={highlightedElement}
717
+ type="pass"
718
+ />
719
+ )}
720
+ </>
721
+ )}
722
+
723
+ {activeTab === "incomplete" && (
724
+ <>
725
+ {counts.incomplete === 0 ? (
726
+ <EmptyMessage message="No incomplete checks requiring manual review." />
727
+ ) : (
728
+ <RuleList
729
+ rules={results.incomplete}
730
+ expandedRules={expandedRules}
731
+ onToggleRule={toggleRule}
732
+ onHighlight={highlightElement}
733
+ highlightedElement={highlightedElement}
734
+ type="incomplete"
735
+ />
736
+ )}
737
+ </>
738
+ )}
739
+ </div>
740
+
741
+ {/* AI Setup Modal */}
742
+ {showAISetup && (
743
+ <AISetupModal
744
+ onSave={saveAIConfig}
745
+ onClose={() => setShowAISetup(false)}
746
+ currentConfig={aiConfig}
747
+ />
748
+ )}
749
+ </div>
750
+ );
751
+ }
752
+
753
+ // ----- Sub-components -----
754
+
755
+ interface TabButtonProps {
756
+ active: boolean;
757
+ onClick: () => void;
758
+ count: number;
759
+ label: string;
760
+ variant: "error" | "success" | "warning";
761
+ }
762
+
763
+ function TabButton({ active, onClick, count, label, variant }: TabButtonProps) {
764
+ const variantStyles = {
765
+ error: "text-red-600 dark:text-red-400",
766
+ success: "text-green-600 dark:text-green-400",
767
+ warning: "text-amber-600 dark:text-amber-400",
768
+ };
769
+
770
+ return (
771
+ <button
772
+ onClick={onClick}
773
+ className={clsx(
774
+ "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors",
775
+ active
776
+ ? "text-primary bg-[--bg-hover]"
777
+ : "text-tertiary hover:text-secondary"
778
+ )}
779
+ >
780
+ <span className={variantStyles[variant]}>{count}</span>
781
+ <span>{label}</span>
782
+ </button>
783
+ );
784
+ }
785
+
786
+ interface EnhancedRuleListProps {
787
+ rules: Result[];
788
+ expandedRules: Set<string>;
789
+ onToggleRule: (id: string) => void;
790
+ onHighlight: (selector: string | null) => void;
791
+ highlightedElement: string | null;
792
+ onGenerateFix?: (violation: Result, node: NodeResult) => void;
793
+ generatingFix?: string | null;
794
+ aiFixes?: Record<string, string>;
795
+ aiEnabled?: boolean;
796
+ onCopyFix: (fixCode: string, fixKey: string) => void;
797
+ copiedFix: string | null;
798
+ type: "violation" | "pass" | "incomplete";
799
+ }
800
+
801
+ function EnhancedRuleList({
802
+ rules,
803
+ expandedRules,
804
+ onToggleRule,
805
+ onHighlight,
806
+ highlightedElement,
807
+ onGenerateFix,
808
+ generatingFix,
809
+ aiFixes,
810
+ aiEnabled,
811
+ onCopyFix,
812
+ copiedFix,
813
+ }: EnhancedRuleListProps) {
814
+ return (
815
+ <div className="space-y-3">
816
+ {rules.map((rule) => {
817
+ const isExpanded = expandedRules.has(rule.id);
818
+ const impactColors = getImpactColorClass(rule.impact || null);
819
+ const staticFix = getStaticFix(rule.id);
820
+ const wcagTags = extractWcagTags(rule.tags);
821
+
822
+ return (
823
+ <div
824
+ key={rule.id}
825
+ className={clsx(
826
+ "rounded-lg border border-[--border] overflow-hidden border-l-4",
827
+ impactColors.borderLeft
828
+ )}
829
+ >
830
+ {/* Rule Header */}
831
+ <button
832
+ onClick={() => onToggleRule(rule.id)}
833
+ className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-[--bg-hover] transition-colors"
834
+ >
835
+ {isExpanded ? (
836
+ <ChevronDownIcon className="w-4 h-4 text-tertiary flex-shrink-0" />
837
+ ) : (
838
+ <ChevronRightIcon className="w-4 h-4 text-tertiary flex-shrink-0" />
839
+ )}
840
+
841
+ {/* Impact badge */}
842
+ {rule.impact && (
843
+ <span
844
+ className={clsx(
845
+ "text-[10px] font-semibold px-1.5 py-0.5 rounded uppercase tracking-wide",
846
+ impactColors.bg,
847
+ impactColors.text
848
+ )}
849
+ >
850
+ {rule.impact}
851
+ </span>
852
+ )}
853
+
854
+ {/* Rule ID */}
855
+ <span className="text-xs font-mono text-tertiary">
856
+ {rule.id}
857
+ </span>
858
+
859
+ {/* Rule description */}
860
+ <span className="text-xs text-primary flex-1 truncate">
861
+ {rule.description}
862
+ </span>
863
+
864
+ {/* Element count */}
865
+ <span className="text-[10px] text-tertiary flex-shrink-0 bg-[--bg-hover] px-1.5 py-0.5 rounded">
866
+ {rule.nodes.length} element{rule.nodes.length !== 1 ? "s" : ""}
867
+ </span>
868
+ </button>
869
+
870
+ {/* Expanded content */}
871
+ {isExpanded && (
872
+ <div className="border-t border-[--border] bg-[--bg-secondary]">
873
+ {/* Why it matters + WCAG reference */}
874
+ {staticFix && (
875
+ <div className="px-3 py-3 border-b border-[--border-subtle] space-y-2">
876
+ <div>
877
+ <p className="text-[10px] font-semibold text-tertiary uppercase tracking-wide mb-1">
878
+ Why it matters
879
+ </p>
880
+ <p className="text-xs text-secondary leading-relaxed">
881
+ {staticFix.whyItMatters}
882
+ </p>
883
+ </div>
884
+
885
+ {/* WCAG criterion */}
886
+ {staticFix.wcagCriterion && (
887
+ <div className="flex items-center gap-2">
888
+ <span className={clsx(
889
+ "text-[10px] px-1.5 py-0.5 rounded font-medium",
890
+ staticFix.wcagCriterion.level === 'A' && "bg-green-100 dark:bg-green-950/50 text-green-700 dark:text-green-300",
891
+ staticFix.wcagCriterion.level === 'AA' && "bg-blue-100 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300",
892
+ staticFix.wcagCriterion.level === 'AAA' && "bg-purple-100 dark:bg-purple-950/50 text-purple-700 dark:text-purple-300",
893
+ )}>
894
+ WCAG {staticFix.wcagCriterion.id} Level {staticFix.wcagCriterion.level}
895
+ </span>
896
+ <span className="text-xs text-secondary">
897
+ {staticFix.wcagCriterion.name}
898
+ </span>
899
+ <a
900
+ href={staticFix.wcagCriterion.url}
901
+ target="_blank"
902
+ rel="noopener noreferrer"
903
+ className="text-xs text-[--color-accent] hover:underline ml-auto"
904
+ >
905
+ Learn more
906
+ </a>
907
+ </div>
908
+ )}
909
+
910
+ {/* Before/After examples */}
911
+ {(staticFix.badExample || staticFix.goodExample) && (
912
+ <div className="grid grid-cols-2 gap-2 mt-2">
913
+ {staticFix.badExample && (
914
+ <div>
915
+ <p className="text-[10px] font-medium text-red-600 dark:text-red-400 mb-1">
916
+ Before
917
+ </p>
918
+ <pre className="text-[10px] font-mono p-2 rounded bg-red-50 dark:bg-red-950/30 text-red-800 dark:text-red-200 overflow-x-auto whitespace-pre-wrap">
919
+ {staticFix.badExample}
920
+ </pre>
921
+ </div>
922
+ )}
923
+ {staticFix.goodExample && (
924
+ <div>
925
+ <p className="text-[10px] font-medium text-green-600 dark:text-green-400 mb-1">
926
+ After
927
+ </p>
928
+ <pre className="text-[10px] font-mono p-2 rounded bg-green-50 dark:bg-green-950/30 text-green-800 dark:text-green-200 overflow-x-auto whitespace-pre-wrap">
929
+ {staticFix.goodExample}
930
+ </pre>
931
+ </div>
932
+ )}
933
+ </div>
934
+ )}
935
+ </div>
936
+ )}
937
+
938
+ {/* Help info (fallback if no static fix) */}
939
+ {!staticFix && (
940
+ <div className="px-3 py-2 border-b border-[--border-subtle]">
941
+ <p className="text-xs text-secondary mb-1">{rule.help}</p>
942
+ {rule.helpUrl && (
943
+ <a
944
+ href={rule.helpUrl}
945
+ target="_blank"
946
+ rel="noopener noreferrer"
947
+ className="text-xs text-[--color-accent] hover:underline"
948
+ >
949
+ Learn more about {rule.id}
950
+ </a>
951
+ )}
952
+ </div>
953
+ )}
954
+
955
+ {/* Affected elements */}
956
+ <div className="divide-y divide-[--border-subtle]">
957
+ {rule.nodes.map((node, i) => {
958
+ const fixKey = `${rule.id}-${node.html}`;
959
+ const isHighlighted =
960
+ highlightedElement === node.target.join(", ");
961
+ const elementFix = generateElementFix(rule.id, node as unknown as SerializedNode);
962
+
963
+ return (
964
+ <div
965
+ key={i}
966
+ className={clsx(
967
+ "px-3 py-3",
968
+ isHighlighted && "bg-red-50 dark:bg-red-950/30"
969
+ )}
970
+ >
971
+ {/* Element selector + actions */}
972
+ <div className="flex items-center gap-2 mb-2">
973
+ <button
974
+ onClick={() =>
975
+ onHighlight(
976
+ isHighlighted ? null : node.target.join(", ")
977
+ )
978
+ }
979
+ className={clsx(
980
+ "text-[10px] font-mono px-2 py-1 rounded truncate max-w-[200px] transition-colors",
981
+ isHighlighted
982
+ ? "bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200"
983
+ : "bg-[--bg-hover] text-secondary hover:text-primary hover:bg-[--bg-tertiary]"
984
+ )}
985
+ title={node.target.join(" > ")}
986
+ >
987
+ {isHighlighted ? "Hide" : "Highlight"}: {node.target[node.target.length - 1]}
988
+ </button>
989
+
990
+ {aiEnabled && onGenerateFix && (
991
+ <button
992
+ onClick={() => onGenerateFix(rule, node)}
993
+ disabled={generatingFix === fixKey}
994
+ className={clsx(
995
+ "flex items-center gap-1 px-2 py-1 text-[10px] rounded transition-colors",
996
+ generatingFix === fixKey
997
+ ? "bg-purple-100 dark:bg-purple-950/50 text-purple-600 dark:text-purple-400"
998
+ : "bg-[--bg-hover] text-tertiary hover:text-primary"
999
+ )}
1000
+ title="Generate AI fix suggestion"
1001
+ >
1002
+ <WandIcon
1003
+ className={clsx(
1004
+ "w-3 h-3",
1005
+ generatingFix === fixKey && "animate-pulse"
1006
+ )}
1007
+ />
1008
+ {generatingFix === fixKey ? "Generating..." : "AI Fix"}
1009
+ </button>
1010
+ )}
1011
+ </div>
1012
+
1013
+ {/* HTML snippet */}
1014
+ <pre className="text-[10px] font-mono text-tertiary bg-[--bg-primary] rounded p-2 overflow-x-auto whitespace-pre-wrap border border-[--border-subtle]">
1015
+ {node.html}
1016
+ </pre>
1017
+
1018
+ {/* Failure summary */}
1019
+ {node.failureSummary && (
1020
+ <p className="text-xs text-secondary mt-2 leading-relaxed">
1021
+ {node.failureSummary}
1022
+ </p>
1023
+ )}
1024
+
1025
+ {/* Static element fix */}
1026
+ {elementFix && (
1027
+ <div className="mt-2 p-2 rounded bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
1028
+ <div className="flex items-center justify-between mb-1">
1029
+ <span className="text-[10px] font-medium text-blue-700 dark:text-blue-300">
1030
+ Suggested Fix
1031
+ </span>
1032
+ <button
1033
+ onClick={() => onCopyFix(elementFix.fixedHtml, fixKey)}
1034
+ className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
1035
+ >
1036
+ {copiedFix === fixKey ? "Copied!" : "Copy"}
1037
+ </button>
1038
+ </div>
1039
+ <p className="text-[10px] text-blue-800 dark:text-blue-200 mb-2">
1040
+ {elementFix.explanation}
1041
+ </p>
1042
+ <pre className="text-[10px] font-mono text-blue-800 dark:text-blue-200 bg-blue-100 dark:bg-blue-900/50 rounded p-2 overflow-x-auto whitespace-pre-wrap">
1043
+ {elementFix.fixedHtml}
1044
+ </pre>
1045
+ </div>
1046
+ )}
1047
+
1048
+ {/* AI fix suggestion */}
1049
+ {aiFixes?.[fixKey] && (
1050
+ <div className="mt-2 p-2 rounded bg-purple-50 dark:bg-purple-950/30 border border-purple-200 dark:border-purple-800">
1051
+ <div className="flex items-center gap-1 mb-1">
1052
+ <WandIcon className="w-3 h-3 text-purple-600 dark:text-purple-400" />
1053
+ <span className="text-[10px] font-medium text-purple-700 dark:text-purple-300">
1054
+ AI Fix Suggestion
1055
+ </span>
1056
+ </div>
1057
+ <pre className="text-[10px] font-mono text-purple-800 dark:text-purple-200 whitespace-pre-wrap">
1058
+ {aiFixes[fixKey]}
1059
+ </pre>
1060
+ </div>
1061
+ )}
1062
+ </div>
1063
+ );
1064
+ })}
1065
+ </div>
1066
+
1067
+ {/* Tags */}
1068
+ {rule.tags && rule.tags.length > 0 && (
1069
+ <div className="px-3 py-2 border-t border-[--border-subtle]">
1070
+ <div className="flex flex-wrap gap-1">
1071
+ {rule.tags.slice(0, 8).map((tag) => (
1072
+ <span
1073
+ key={tag}
1074
+ className="text-[10px] px-1.5 py-0.5 rounded bg-[--bg-hover] text-tertiary"
1075
+ >
1076
+ {tag}
1077
+ </span>
1078
+ ))}
1079
+ {rule.tags.length > 8 && (
1080
+ <span className="text-[10px] text-tertiary">
1081
+ +{rule.tags.length - 8} more
1082
+ </span>
1083
+ )}
1084
+ </div>
1085
+ </div>
1086
+ )}
1087
+ </div>
1088
+ )}
1089
+ </div>
1090
+ );
1091
+ })}
1092
+ </div>
1093
+ );
1094
+ }
1095
+
1096
+ interface RuleListProps {
1097
+ rules: Result[];
1098
+ expandedRules: Set<string>;
1099
+ onToggleRule: (id: string) => void;
1100
+ onHighlight: (selector: string | null) => void;
1101
+ highlightedElement: string | null;
1102
+ type: "violation" | "pass" | "incomplete";
1103
+ }
1104
+
1105
+ function RuleList({
1106
+ rules,
1107
+ expandedRules,
1108
+ onToggleRule,
1109
+ onHighlight,
1110
+ highlightedElement,
1111
+ type,
1112
+ }: RuleListProps) {
1113
+ return (
1114
+ <div className="space-y-2">
1115
+ {rules.map((rule) => {
1116
+ const isExpanded = expandedRules.has(rule.id);
1117
+
1118
+ return (
1119
+ <div
1120
+ key={rule.id}
1121
+ className="rounded-lg border border-[--border] overflow-hidden"
1122
+ >
1123
+ {/* Rule Header */}
1124
+ <button
1125
+ onClick={() => onToggleRule(rule.id)}
1126
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[--bg-hover] transition-colors"
1127
+ >
1128
+ {isExpanded ? (
1129
+ <ChevronDownIcon className="w-4 h-4 text-tertiary flex-shrink-0" />
1130
+ ) : (
1131
+ <ChevronRightIcon className="w-4 h-4 text-tertiary flex-shrink-0" />
1132
+ )}
1133
+
1134
+ {type === "pass" && (
1135
+ <CheckIcon className="w-4 h-4 text-green-600 flex-shrink-0" />
1136
+ )}
1137
+
1138
+ {type === "incomplete" && (
1139
+ <span className="text-[10px] font-medium px-1.5 py-0.5 rounded uppercase bg-amber-100 dark:bg-amber-950/50 text-amber-700 dark:text-amber-300">
1140
+ Review
1141
+ </span>
1142
+ )}
1143
+
1144
+ <span className="text-xs text-primary flex-1 truncate">
1145
+ {rule.description}
1146
+ </span>
1147
+
1148
+ <span className="text-[10px] text-tertiary flex-shrink-0">
1149
+ {rule.nodes.length} element{rule.nodes.length !== 1 ? "s" : ""}
1150
+ </span>
1151
+ </button>
1152
+
1153
+ {/* Expanded content */}
1154
+ {isExpanded && (
1155
+ <div className="border-t border-[--border] bg-[--bg-secondary]">
1156
+ <div className="px-3 py-2 border-b border-[--border-subtle]">
1157
+ <p className="text-xs text-secondary mb-1">{rule.help}</p>
1158
+ {rule.helpUrl && (
1159
+ <a
1160
+ href={rule.helpUrl}
1161
+ target="_blank"
1162
+ rel="noopener noreferrer"
1163
+ className="text-xs text-[--color-accent] hover:underline"
1164
+ >
1165
+ Learn more about {rule.id}
1166
+ </a>
1167
+ )}
1168
+ </div>
1169
+
1170
+ <div className="divide-y divide-[--border-subtle]">
1171
+ {rule.nodes.map((node, i) => {
1172
+ const isHighlighted =
1173
+ highlightedElement === node.target.join(", ");
1174
+
1175
+ return (
1176
+ <div
1177
+ key={i}
1178
+ className={clsx(
1179
+ "px-3 py-2",
1180
+ isHighlighted && "bg-green-50 dark:bg-green-950/30"
1181
+ )}
1182
+ >
1183
+ <button
1184
+ onClick={() =>
1185
+ onHighlight(
1186
+ isHighlighted ? null : node.target.join(", ")
1187
+ )
1188
+ }
1189
+ className={clsx(
1190
+ "text-[10px] font-mono px-1.5 py-0.5 rounded truncate max-w-[200px] mb-1",
1191
+ isHighlighted
1192
+ ? "bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200"
1193
+ : "bg-[--bg-hover] text-secondary hover:text-primary"
1194
+ )}
1195
+ title={node.target.join(" > ")}
1196
+ >
1197
+ {node.target[node.target.length - 1]}
1198
+ </button>
1199
+
1200
+ <pre className="text-[10px] font-mono text-tertiary bg-[--bg-primary] rounded p-2 overflow-x-auto whitespace-pre-wrap">
1201
+ {node.html}
1202
+ </pre>
1203
+ </div>
1204
+ );
1205
+ })}
1206
+ </div>
1207
+ </div>
1208
+ )}
1209
+ </div>
1210
+ );
1211
+ })}
1212
+ </div>
1213
+ );
1214
+ }
1215
+
1216
+ function SuccessMessage({ message }: { message: string }) {
1217
+ return (
1218
+ <div className="flex flex-col items-center justify-center py-8 text-center">
1219
+ <div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-950/50 flex items-center justify-center mb-3">
1220
+ <CheckIcon className="w-6 h-6 text-green-600 dark:text-green-400" />
1221
+ </div>
1222
+ <p className="text-sm font-medium text-green-700 dark:text-green-300">
1223
+ {message}
1224
+ </p>
1225
+ <p className="text-xs text-tertiary mt-1">
1226
+ This component passed all automated accessibility checks.
1227
+ </p>
1228
+ </div>
1229
+ );
1230
+ }
1231
+
1232
+ function EmptyMessage({ message }: { message: string }) {
1233
+ return (
1234
+ <div className="flex flex-col items-center justify-center py-8 text-center">
1235
+ <AccessibilityIcon className="w-8 h-8 text-tertiary mb-2 opacity-50" />
1236
+ <p className="text-xs text-tertiary">{message}</p>
1237
+ </div>
1238
+ );
1239
+ }
1240
+
1241
+ interface AISetupModalProps {
1242
+ onSave: (config: AIProviderConfig) => void;
1243
+ onClose: () => void;
1244
+ currentConfig: AIProviderConfig | null;
1245
+ }
1246
+
1247
+ function AISetupModal({ onSave, onClose, currentConfig }: AISetupModalProps) {
1248
+ const [provider, setProvider] = useState<"anthropic" | "openai">(
1249
+ currentConfig?.provider || "anthropic"
1250
+ );
1251
+ const [apiKey, setApiKey] = useState(currentConfig?.apiKey || "");
1252
+
1253
+ const handleSubmit = (e: React.FormEvent) => {
1254
+ e.preventDefault();
1255
+ if (apiKey.trim()) {
1256
+ onSave({ provider, apiKey: apiKey.trim() });
1257
+ }
1258
+ };
1259
+
1260
+ return (
1261
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
1262
+ <div className="bg-[--bg-primary] rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden">
1263
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[--border]">
1264
+ <h3 className="text-sm font-medium text-primary">
1265
+ Configure AI Fix Suggestions
1266
+ </h3>
1267
+ <button
1268
+ onClick={onClose}
1269
+ className="p-1 text-tertiary hover:text-primary hover:bg-[--bg-hover] rounded"
1270
+ >
1271
+ <XIcon className="w-4 h-4" />
1272
+ </button>
1273
+ </div>
1274
+
1275
+ <form onSubmit={handleSubmit} className="p-4 space-y-4">
1276
+ <p className="text-xs text-secondary">
1277
+ Enter your API key to enable AI-powered fix suggestions for
1278
+ accessibility violations. Your key is stored locally and never sent
1279
+ to our servers.
1280
+ </p>
1281
+
1282
+ <div>
1283
+ <label className="block text-xs font-medium text-primary mb-1">
1284
+ Provider
1285
+ </label>
1286
+ <div className="flex gap-2">
1287
+ <button
1288
+ type="button"
1289
+ onClick={() => setProvider("anthropic")}
1290
+ className={clsx(
1291
+ "flex-1 px-3 py-2 text-xs rounded border transition-colors",
1292
+ provider === "anthropic"
1293
+ ? "bg-[--color-accent] text-white border-[--color-accent]"
1294
+ : "bg-[--bg-secondary] text-secondary border-[--border] hover:border-[--color-accent]"
1295
+ )}
1296
+ >
1297
+ Anthropic (Claude)
1298
+ </button>
1299
+ <button
1300
+ type="button"
1301
+ onClick={() => setProvider("openai")}
1302
+ className={clsx(
1303
+ "flex-1 px-3 py-2 text-xs rounded border transition-colors",
1304
+ provider === "openai"
1305
+ ? "bg-[--color-accent] text-white border-[--color-accent]"
1306
+ : "bg-[--bg-secondary] text-secondary border-[--border] hover:border-[--color-accent]"
1307
+ )}
1308
+ >
1309
+ OpenAI (GPT-4)
1310
+ </button>
1311
+ </div>
1312
+ </div>
1313
+
1314
+ <div>
1315
+ <label className="block text-xs font-medium text-primary mb-1">
1316
+ API Key
1317
+ </label>
1318
+ <input
1319
+ type="password"
1320
+ value={apiKey}
1321
+ onChange={(e) => setApiKey(e.target.value)}
1322
+ placeholder={
1323
+ provider === "anthropic"
1324
+ ? "sk-ant-api03-..."
1325
+ : "sk-..."
1326
+ }
1327
+ className="w-full px-3 py-2 text-xs rounded border border-[--border] bg-[--bg-secondary] text-primary placeholder:text-tertiary focus:outline-none focus:border-[--color-accent]"
1328
+ />
1329
+ <p className="text-[10px] text-tertiary mt-1">
1330
+ Get your API key from{" "}
1331
+ {provider === "anthropic" ? (
1332
+ <a
1333
+ href="https://console.anthropic.com/settings/keys"
1334
+ target="_blank"
1335
+ rel="noopener noreferrer"
1336
+ className="text-[--color-accent] hover:underline"
1337
+ >
1338
+ console.anthropic.com
1339
+ </a>
1340
+ ) : (
1341
+ <a
1342
+ href="https://platform.openai.com/api-keys"
1343
+ target="_blank"
1344
+ rel="noopener noreferrer"
1345
+ className="text-[--color-accent] hover:underline"
1346
+ >
1347
+ platform.openai.com
1348
+ </a>
1349
+ )}
1350
+ </p>
1351
+ </div>
1352
+
1353
+ <div className="flex justify-end gap-2 pt-2">
1354
+ <button
1355
+ type="button"
1356
+ onClick={onClose}
1357
+ className="px-3 py-1.5 text-xs rounded border border-[--border] text-secondary hover:bg-[--bg-hover]"
1358
+ >
1359
+ Cancel
1360
+ </button>
1361
+ <button
1362
+ type="submit"
1363
+ disabled={!apiKey.trim()}
1364
+ className="px-3 py-1.5 text-xs rounded bg-[--color-accent] text-white hover:opacity-90 disabled:opacity-50"
1365
+ >
1366
+ Save Configuration
1367
+ </button>
1368
+ </div>
1369
+ </form>
1370
+ </div>
1371
+ </div>
1372
+ );
1373
+ }
1374
+
1375
+ // ----- Helper Functions -----
1376
+
1377
+ function buildFixPrompt(violation: Result, node: NodeResult): string {
1378
+ return `You are an accessibility expert. Analyze this accessibility violation and provide a concise fix.
1379
+
1380
+ **Violation:** ${violation.id}
1381
+ **Description:** ${violation.description}
1382
+ **Impact:** ${violation.impact}
1383
+ **Help:** ${violation.help}
1384
+
1385
+ **Affected HTML:**
1386
+ \`\`\`html
1387
+ ${node.html}
1388
+ \`\`\`
1389
+
1390
+ **Element Path:** ${node.target.join(" > ")}
1391
+
1392
+ **Failure Summary:**
1393
+ ${node.failureSummary || "N/A"}
1394
+
1395
+ Provide a brief, actionable fix for this specific accessibility issue. Include:
1396
+ 1. What's wrong (1 sentence)
1397
+ 2. The fixed HTML code
1398
+ 3. Why this fix works (1 sentence)
1399
+
1400
+ Keep your response concise and focused on the practical fix.`;
1401
+ }
1402
+
1403
+ // Export icons used by App.tsx
1404
+ export { AccessibilityIcon };