@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.
- package/LICENSE +84 -0
- package/index.html +28 -0
- package/package.json +71 -0
- package/src/__tests__/a11y-fixes.test.ts +358 -0
- package/src/__tests__/jsx-parser.test.ts +502 -0
- package/src/__tests__/render-utils.test.ts +232 -0
- package/src/__tests__/style-utils.test.ts +404 -0
- package/src/app/index.ts +1 -0
- package/src/assets/fragments-logo.ts +4 -0
- package/src/assets/fragments_logo.png +0 -0
- package/src/components/AccessibilityPanel.tsx +1457 -0
- package/src/components/ActionCapture.tsx +172 -0
- package/src/components/ActionsPanel.tsx +332 -0
- package/src/components/AllVariantsPreview.tsx +78 -0
- package/src/components/App.tsx +604 -0
- package/src/components/BottomPanel.tsx +288 -0
- package/src/components/CodePanel.naming.test.tsx +59 -0
- package/src/components/CodePanel.tsx +118 -0
- package/src/components/CommandPalette.tsx +392 -0
- package/src/components/ComponentDocView.tsx +164 -0
- package/src/components/ComponentGraph.tsx +380 -0
- package/src/components/ComponentHeader.tsx +88 -0
- package/src/components/ContractPanel.tsx +241 -0
- package/src/components/DeviceMockup.tsx +156 -0
- package/src/components/EmptyVariantMessage.tsx +54 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/FigmaEmbed.tsx +238 -0
- package/src/components/FragmentEditor.tsx +525 -0
- package/src/components/FragmentRenderer.tsx +61 -0
- package/src/components/HeaderSearch.tsx +24 -0
- package/src/components/HealthDashboard.tsx +441 -0
- package/src/components/HmrStatusIndicator.tsx +61 -0
- package/src/components/Icons.tsx +479 -0
- package/src/components/InteractionsPanel.tsx +757 -0
- package/src/components/IsolatedPreviewFrame.tsx +390 -0
- package/src/components/IsolatedRender.tsx +113 -0
- package/src/components/KeyboardShortcutsHelp.tsx +53 -0
- package/src/components/LandingPage.tsx +420 -0
- package/src/components/Layout.tsx +27 -0
- package/src/components/LeftSidebar.tsx +472 -0
- package/src/components/LoadErrorMessage.tsx +102 -0
- package/src/components/MultiViewportPreview.tsx +527 -0
- package/src/components/NoVariantsMessage.tsx +59 -0
- package/src/components/PanelShell.tsx +161 -0
- package/src/components/PerformancePanel.tsx +304 -0
- package/src/components/PreviewArea.tsx +254 -0
- package/src/components/PreviewAside.tsx +168 -0
- package/src/components/PreviewFrameHost.tsx +304 -0
- package/src/components/PreviewToolbar.tsx +80 -0
- package/src/components/PropsEditor.tsx +506 -0
- package/src/components/PropsTable.tsx +111 -0
- package/src/components/RelationsSection.tsx +88 -0
- package/src/components/ResizablePanel.tsx +271 -0
- package/src/components/RightSidebar.tsx +102 -0
- package/src/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/components/ScreenshotButton.tsx +90 -0
- package/src/components/ShadowPreview.tsx +204 -0
- package/src/components/Sidebar.tsx +169 -0
- package/src/components/SkeletonLoader.tsx +161 -0
- package/src/components/ThemeProvider.tsx +42 -0
- package/src/components/Toast.tsx +3 -0
- package/src/components/TokenStylePanel.tsx +699 -0
- package/src/components/TopToolbar.tsx +159 -0
- package/src/components/Untitled +1 -0
- package/src/components/UsageSection.tsx +95 -0
- package/src/components/VariantMatrix.tsx +391 -0
- package/src/components/VariantRenderer.tsx +131 -0
- package/src/components/VariantTabs.tsx +40 -0
- package/src/components/ViewerHeader.tsx +69 -0
- package/src/components/ViewerStateSync.tsx +52 -0
- package/src/components/ViewportSelector.tsx +172 -0
- package/src/components/WebMCPDevTools.tsx +503 -0
- package/src/components/WebMCPIntegration.tsx +47 -0
- package/src/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/components/_future/CreatePage.tsx +835 -0
- package/src/components/viewer-utils.ts +16 -0
- package/src/composition-renderer.ts +381 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/ui.ts +166 -0
- package/src/entry.tsx +335 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useA11yCache.ts +383 -0
- package/src/hooks/useA11yService.ts +364 -0
- package/src/hooks/useActions.ts +138 -0
- package/src/hooks/useAppState.ts +147 -0
- package/src/hooks/useCompiledFragments.ts +42 -0
- package/src/hooks/useFigmaIntegration.ts +132 -0
- package/src/hooks/useHmrStatus.ts +109 -0
- package/src/hooks/useKeyboardShortcuts.ts +270 -0
- package/src/hooks/usePreviewBridge.ts +347 -0
- package/src/hooks/useScrollSpy.ts +78 -0
- package/src/hooks/useShadowStyles.ts +221 -0
- package/src/hooks/useUrlState.ts +318 -0
- package/src/hooks/useViewSettings.ts +111 -0
- package/src/intelligence/healthReport.ts +505 -0
- package/src/intelligence/styleDrift.ts +340 -0
- package/src/intelligence/usageScanner.ts +309 -0
- package/src/jsx-parser.ts +486 -0
- package/src/preview-frame-entry.tsx +25 -0
- package/src/preview-frame.html +148 -0
- package/src/render-template.html +68 -0
- package/src/render-utils.ts +311 -0
- package/src/shared/ComponentDocContent.module.scss +10 -0
- package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
- package/src/shared/ComponentDocContent.tsx +274 -0
- package/src/shared/DocsHeaderBar.tsx +129 -0
- package/src/shared/DocsPageAsideHost.tsx +89 -0
- package/src/shared/DocsPageShell.tsx +124 -0
- package/src/shared/DocsSearchCommand.tsx +99 -0
- package/src/shared/DocsSidebarNav.tsx +66 -0
- package/src/shared/PropsTable.module.scss +68 -0
- package/src/shared/PropsTable.module.scss.d.ts +2 -0
- package/src/shared/PropsTable.tsx +76 -0
- package/src/shared/VariantPreviewCard.module.scss +114 -0
- package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
- package/src/shared/VariantPreviewCard.tsx +137 -0
- package/src/shared/docs-data/index.ts +32 -0
- package/src/shared/docs-data/mcp-configs.ts +72 -0
- package/src/shared/docs-data/palettes.ts +75 -0
- package/src/shared/docs-data/setup-examples.ts +55 -0
- package/src/shared/docs-layout.scss +28 -0
- package/src/shared/docs-layout.scss.d.ts +2 -0
- package/src/shared/index.ts +34 -0
- package/src/shared/types.ts +53 -0
- package/src/style-utils.ts +414 -0
- package/src/styles/globals.css +278 -0
- package/src/types/a11y.ts +197 -0
- package/src/utils/a11y-fixes.ts +509 -0
- package/src/utils/actionExport.ts +372 -0
- package/src/utils/colorSchemes.ts +201 -0
- package/src/utils/contrast.ts +246 -0
- package/src/utils/detectRelationships.ts +256 -0
- package/src/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/webmcp/analytics.ts +165 -0
- package/src/webmcp/index.ts +3 -0
- package/src/webmcp/posthog-bridge.ts +39 -0
- package/src/webmcp/runtime-tools.ts +152 -0
- package/src/webmcp/scan-utils.ts +135 -0
- package/src/webmcp/use-tool-analytics.ts +69 -0
- package/src/webmcp/viewer-state.ts +45 -0
- 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 };
|