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