@fragments-sdk/viewer 0.2.1

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