diff --git a/docs/plugins/a11y.md b/docs/plugins/a11y.md
new file mode 100644
index 00000000..94ae850a
--- /dev/null
+++ b/docs/plugins/a11y.md
@@ -0,0 +1,262 @@
+---
+title: Accessibility Plugin
+id: a11y-plugin
+---
+
+The TanStack Devtools Accessibility (A11y) Plugin provides real-time accessibility auditing for your web applications, powered by [axe-core](https://github.com/dequelabs/axe-core). It helps you identify and fix accessibility issues during development.
+
+## Features
+
+- **Full Page Scanning** - Audit your entire page for accessibility violations
+- **Component-Level Scanning** - Scope audits to specific components using React hooks
+- **Live Monitoring** - Automatically re-scan when the DOM changes
+- **Visual Overlays** - Highlight problematic elements with severity-based colors
+- **Click-to-Navigate** - Click on an issue to automatically scroll to and highlight the element
+- **Dark Mode Support** - Automatically adapts to the devtools theme
+- **Devtools-Aware** - Automatically excludes devtools panels from scanning
+- **Configurable Rule Sets** - Support for WCAG 2.0/2.1/2.2 (A/AA/AAA), Section 508, and best practices
+- **Export Reports** - Download results as JSON or CSV
+- **Persistent Settings** - Configuration saved to localStorage
+
+## Installation
+
+```bash
+npm install @tanstack/devtools-a11y
+# or
+pnpm add @tanstack/devtools-a11y
+# or
+yarn add @tanstack/devtools-a11y
+```
+
+## Quick Start (React)
+
+```tsx
+import { createRoot } from 'react-dom/client'
+import { TanStackDevtools } from '@tanstack/react-devtools'
+import { a11yDevtoolsPlugin } from '@tanstack/devtools-a11y/react'
+
+createRoot(document.getElementById('root')!).render(
+ <>
+
+
+ >,
+)
+```
+
+## Quick Start (Solid)
+
+```tsx
+import { render } from 'solid-js/web'
+import { TanStackDevtools } from '@tanstack/solid-devtools'
+import { a11yDevtoolsPlugin } from '@tanstack/devtools-a11y/solid'
+
+render(
+ () => (
+ <>
+
+
+ >
+ ),
+ document.getElementById('root')!,
+)
+```
+
+## Quick Start (Vue)
+
+```ts
+import { createA11yDevtoolsVuePlugin } from '@tanstack/devtools-a11y/vue'
+
+const plugins = [createA11yDevtoolsVuePlugin()]
+```
+
+## Click-to-Navigate
+
+When you click on an issue in the panel, the plugin will:
+
+1. **Scroll** the problematic element into view (centered in the viewport)
+2. **Highlight** the element with a pulsing overlay matching its severity color
+3. **Show a tooltip** with the rule ID and impact level
+
+This makes it easy to locate and inspect issues directly on the page.
+
+## Panel Configuration
+
+Initial configuration can be provided via the vanilla plugin API:
+
+```ts
+import { createA11yPlugin } from '@tanstack/devtools-a11y'
+
+const plugin = createA11yPlugin({
+ ruleSet: 'wcag21aa',
+ threshold: 'moderate',
+ runOnMount: true,
+ liveMonitoring: true,
+ liveMonitoringDelay: 1000,
+ showOverlays: true,
+})
+```
+
+Common `options` fields:
+
+- `threshold`: minimum impact level to show
+- `ruleSet`: rule preset (`'wcag2a' | 'wcag2aa' | 'wcag21aa' | 'wcag22aa' | 'section508' | 'best-practice' | 'all'`)
+- `disabledRules`: rule IDs to ignore
+- `showOverlays`: highlight issues in the page
+- `runOnMount`: auto-scan when panel mounts
+- `persistSettings`: store config in localStorage
+- `liveMonitoring`: watch DOM mutations and re-scan automatically
+- `liveMonitoringDelay`: debounce delay (ms)
+
+If you don't need to provide initial configuration, you can use the framework plugin helpers
+directly (the settings UI persists changes to localStorage by default).
+
+## Severity Levels
+
+Issues are categorized by impact level with corresponding overlay colors:
+
+| Impact | Color | Description |
+|--------|-------|-------------|
+| Critical | Red | Must be fixed - prevents users from accessing content |
+| Serious | Orange | Should be fixed - significantly impacts user experience |
+| Moderate | Yellow | Consider fixing - affects some users |
+| Minor | Blue | Optional improvement - minor impact |
+
+## Framework Support
+
+The panel UI is implemented in Solid and wrapped for React, Solid, Preact, and Vue
+using `@tanstack/devtools-utils`.
+
+## Vanilla JavaScript API
+
+For non-React applications, `createA11yPlugin()` exposes a small programmatic API in addition to the TanStack Devtools `render()` integration:
+
+```ts
+import { createA11yPlugin } from '@tanstack/devtools-a11y'
+
+const plugin = createA11yPlugin({
+ ruleSet: 'wcag21aa',
+ liveMonitoring: true,
+ showOverlays: true,
+})
+
+// Run a scan
+const result = await plugin.scan?.()
+
+// Subscribe to scan results (manual or live)
+const unsubscribe = plugin.onScan?.((next) => {
+ console.log('Issues found:', next.issues.length)
+})
+
+// Control live monitoring
+plugin.startLiveMonitoring?.()
+plugin.stopLiveMonitoring?.()
+
+// Clean up
+unsubscribe?.()
+plugin.destroy?.()
+```
+
+## Export Formats
+
+### JSON Export
+
+```ts
+import { exportToJSON } from '@tanstack/devtools-a11y'
+
+const jsonString = exportToJSON(auditResult)
+```
+
+### CSV Export
+
+```ts
+import { exportToCSV } from '@tanstack/devtools-a11y'
+
+const csvString = exportToCSV(auditResult)
+```
+
+## Supported Standards
+
+The plugin supports the following accessibility standards:
+
+- **WCAG 2.0** Level A, AA, AAA
+- **WCAG 2.1** Level A, AA, AAA
+- **WCAG 2.2** Level AA
+- **Section 508**
+- **Best Practices** (non-standard recommendations)
+
+## Types
+
+```ts
+interface A11yIssue {
+ id: string
+ ruleId: string
+ impact: 'critical' | 'serious' | 'moderate' | 'minor'
+ description: string
+ help: string
+ helpUrl: string
+ selector: string
+ html: string
+ failureSummary: string
+ tags: string[]
+}
+
+interface A11yAuditResult {
+ timestamp: number
+ url: string
+ standard: string
+ issues: A11yIssue[]
+ passes: number
+ violations: number
+ incomplete: number
+ scanDuration: number
+}
+
+interface A11yConfig {
+ standard: string
+ liveMonitoring: boolean
+ showOverlays: boolean
+ scopedMode: boolean
+ scopeSelector?: string
+ excludeSelectors: string[]
+ includeRules: string[]
+ excludeRules: string[]
+}
+```
+
+## Performance Considerations
+
+- **Live monitoring** uses a debounced MutationObserver to avoid excessive re-scanning
+- **Scoped scanning** is recommended for large pages to reduce scan time
+- **Exclude selectors** can skip third-party widgets or known-good sections
+- The first scan may be slower as axe-core initializes
+
+## Troubleshooting
+
+### Issues not appearing
+
+1. Check that the element is visible in the viewport
+2. Ensure the element is not excluded by `excludeSelectors`
+3. Verify the selected standard includes the relevant rule
+
+### Overlays not showing
+
+1. Confirm overlays are enabled in the panel settings
+2. Check for CSS conflicts with `z-index` or `pointer-events`
+3. Ensure the container element exists in the DOM
+
+### Performance issues
+
+1. Disable live monitoring for large/complex pages
+2. Use scoped scanning to limit the audit area
+3. Increase the debounce delay for live monitoring
+
+## Example
+
+See the full working example at:
+`examples/react/a11y-devtools/`
+
+Run it with:
+```bash
+cd examples/react/a11y-devtools
+pnpm dev
+```
diff --git a/examples/react/a11y-devtools/index.html b/examples/react/a11y-devtools/index.html
new file mode 100644
index 00000000..589c473e
--- /dev/null
+++ b/examples/react/a11y-devtools/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+ A11y Devtools - TanStack Devtools
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/examples/react/a11y-devtools/package.json b/examples/react/a11y-devtools/package.json
new file mode 100644
index 00000000..1009bfdf
--- /dev/null
+++ b/examples/react/a11y-devtools/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@tanstack/devtools-a11y-example",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port=3002",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test:types": "tsc"
+ },
+ "dependencies": {
+ "@tanstack/devtools-a11y": "workspace:*",
+ "@tanstack/react-devtools": "workspace:*",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.4",
+ "vite": "^7.1.7"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/examples/react/a11y-devtools/pnpm-lock.yaml b/examples/react/a11y-devtools/pnpm-lock.yaml
new file mode 100644
index 00000000..3c27259c
--- /dev/null
+++ b/examples/react/a11y-devtools/pnpm-lock.yaml
@@ -0,0 +1,1313 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@tanstack/react-devtools':
+ specifier: ^0.9.2
+ version: 0.9.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)
+ react:
+ specifier: ^19.2.0
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.0
+ version: 19.2.8
+ '@types/react-dom':
+ specifier: ^19.2.0
+ version: 19.2.3(@types/react@19.2.8)
+ '@vitejs/plugin-react':
+ specifier: ^5.0.4
+ version: 5.1.2(vite@7.3.1)
+ vite:
+ specifier: ^7.1.7
+ version: 7.3.1
+
+packages:
+
+ '@babel/code-frame@7.28.6':
+ resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.28.6':
+ resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.28.6':
+ resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.28.6':
+ resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.28.6':
+ resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.6':
+ resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1':
+ resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1':
+ resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.28.6':
+ resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.28.6':
+ resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.27.2':
+ resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.2':
+ resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.2':
+ resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.2':
+ resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.2':
+ resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.2':
+ resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.2':
+ resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.2':
+ resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.2':
+ resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.2':
+ resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.2':
+ resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.2':
+ resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.2':
+ resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.2':
+ resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.2':
+ resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.2':
+ resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.2':
+ resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.2':
+ resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.2':
+ resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.2':
+ resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.2':
+ resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.2':
+ resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@rolldown/pluginutils@1.0.0-beta.53':
+ resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
+
+ '@rollup/rollup-android-arm-eabi@4.55.1':
+ resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.55.1':
+ resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.55.1':
+ resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.55.1':
+ resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.55.1':
+ resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.55.1':
+ resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.55.1':
+ resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.55.1':
+ resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.55.1':
+ resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.55.1':
+ resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.55.1':
+ resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.55.1':
+ resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.55.1':
+ resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.55.1':
+ resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.55.1':
+ resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.55.1':
+ resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.55.1':
+ resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.55.1':
+ resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.55.1':
+ resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.55.1':
+ resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.55.1':
+ resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.55.1':
+ resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.55.1':
+ resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.55.1':
+ resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.55.1':
+ resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@solid-primitives/event-listener@2.4.3':
+ resolution: {integrity: sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/keyboard@1.3.3':
+ resolution: {integrity: sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/resize-observer@2.1.3':
+ resolution: {integrity: sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/rootless@1.5.2':
+ resolution: {integrity: sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/static-store@0.1.2':
+ resolution: {integrity: sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/utils@6.3.2':
+ resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@tanstack/devtools-client@0.0.5':
+ resolution: {integrity: sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA==}
+ engines: {node: '>=18'}
+
+ '@tanstack/devtools-event-bus@0.4.0':
+ resolution: {integrity: sha512-1t+/csFuDzi+miDxAOh6Xv7VDE80gJEItkTcAZLjV5MRulbO/W8ocjHLI2Do/p2r2/FBU0eKCRTpdqvXaYoHpQ==}
+ engines: {node: '>=18'}
+
+ '@tanstack/devtools-event-client@0.4.0':
+ resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==}
+ engines: {node: '>=18'}
+
+ '@tanstack/devtools-ui@0.4.4':
+ resolution: {integrity: sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ solid-js: '>=1.9.7'
+
+ '@tanstack/devtools@0.10.3':
+ resolution: {integrity: sha512-M2HnKtaNf3Z8JDTNDq+X7/1gwOqSwTnCyC0GR+TYiRZM9mkY9GpvTqp6p6bx3DT8onu2URJiVxgHD9WK2e3MNQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ solid-js: '>=1.9.7'
+
+ '@tanstack/react-devtools@0.9.2':
+ resolution: {integrity: sha512-JNXvBO3jgq16GzTVm7p65n5zHNfMhnqF6Bm7CawjoqZrjEakxbM6Yvy63aKSIpbrdf+Wun2Xn8P0qD+vp56e1g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ '@types/react-dom': '>=16.8'
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.8':
+ resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
+
+ '@vitejs/plugin-react@5.1.2':
+ resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ baseline-browser-mapping@2.9.14:
+ resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
+ hasBin: true
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ caniuse-lite@1.0.30001764:
+ resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ electron-to-chromium@1.5.267:
+ resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+
+ esbuild@0.27.2:
+ resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ goober@2.1.18:
+ resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==}
+ peerDependencies:
+ csstype: ^3.0.10
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ react-dom@19.2.3:
+ resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
+ peerDependencies:
+ react: ^19.2.3
+
+ react-refresh@0.18.0:
+ resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
+ engines: {node: '>=0.10.0'}
+
+ react@19.2.3:
+ resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
+ engines: {node: '>=0.10.0'}
+
+ rollup@4.55.1:
+ resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ seroval-plugins@1.3.3:
+ resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
+ seroval@1.3.2:
+ resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==}
+ engines: {node: '>=10'}
+
+ solid-js@1.9.10:
+ resolution: {integrity: sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ vite@7.3.1:
+ resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ ws@8.19.0:
+ resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+snapshots:
+
+ '@babel/code-frame@7.28.6':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.28.6': {}
+
+ '@babel/core@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/generator': 7.28.6
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
+ '@babel/helpers': 7.28.6
+ '@babel/parser': 7.28.6
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.28.6
+ '@babel/types': 7.28.6
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.28.6':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.28.6
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.28.6
+ '@babel/types': 7.28.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.28.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.28.6': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.28.6':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
+
+ '@babel/parser@7.28.6':
+ dependencies:
+ '@babel/types': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+
+ '@babel/traverse@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/generator': 7.28.6
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.28.6
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.28.6':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@esbuild/aix-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm@0.27.2':
+ optional: true
+
+ '@esbuild/android-x64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.2':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@rolldown/pluginutils@1.0.0-beta.53': {}
+
+ '@rollup/rollup-android-arm-eabi@4.55.1':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.55.1':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.55.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.55.1':
+ optional: true
+
+ '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)':
+ dependencies:
+ '@solid-primitives/utils': 6.3.2(solid-js@1.9.10)
+ solid-js: 1.9.10
+
+ '@solid-primitives/keyboard@1.3.3(solid-js@1.9.10)':
+ dependencies:
+ '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10)
+ '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10)
+ '@solid-primitives/utils': 6.3.2(solid-js@1.9.10)
+ solid-js: 1.9.10
+
+ '@solid-primitives/resize-observer@2.1.3(solid-js@1.9.10)':
+ dependencies:
+ '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10)
+ '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10)
+ '@solid-primitives/static-store': 0.1.2(solid-js@1.9.10)
+ '@solid-primitives/utils': 6.3.2(solid-js@1.9.10)
+ solid-js: 1.9.10
+
+ '@solid-primitives/rootless@1.5.2(solid-js@1.9.10)':
+ dependencies:
+ '@solid-primitives/utils': 6.3.2(solid-js@1.9.10)
+ solid-js: 1.9.10
+
+ '@solid-primitives/static-store@0.1.2(solid-js@1.9.10)':
+ dependencies:
+ '@solid-primitives/utils': 6.3.2(solid-js@1.9.10)
+ solid-js: 1.9.10
+
+ '@solid-primitives/utils@6.3.2(solid-js@1.9.10)':
+ dependencies:
+ solid-js: 1.9.10
+
+ '@tanstack/devtools-client@0.0.5':
+ dependencies:
+ '@tanstack/devtools-event-client': 0.4.0
+
+ '@tanstack/devtools-event-bus@0.4.0':
+ dependencies:
+ ws: 8.19.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@tanstack/devtools-event-client@0.4.0': {}
+
+ '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.10)':
+ dependencies:
+ clsx: 2.1.1
+ goober: 2.1.18(csstype@3.2.3)
+ solid-js: 1.9.10
+ transitivePeerDependencies:
+ - csstype
+
+ '@tanstack/devtools@0.10.3(csstype@3.2.3)(solid-js@1.9.10)':
+ dependencies:
+ '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10)
+ '@solid-primitives/keyboard': 1.3.3(solid-js@1.9.10)
+ '@solid-primitives/resize-observer': 2.1.3(solid-js@1.9.10)
+ '@tanstack/devtools-client': 0.0.5
+ '@tanstack/devtools-event-bus': 0.4.0
+ '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10)
+ clsx: 2.1.1
+ goober: 2.1.18(csstype@3.2.3)
+ solid-js: 1.9.10
+ transitivePeerDependencies:
+ - bufferutil
+ - csstype
+ - utf-8-validate
+
+ '@tanstack/react-devtools@0.9.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)':
+ dependencies:
+ '@tanstack/devtools': 0.10.3(csstype@3.2.3)(solid-js@1.9.10)
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ transitivePeerDependencies:
+ - bufferutil
+ - csstype
+ - solid-js
+ - utf-8-validate
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.28.6
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.28.6
+
+ '@types/estree@1.0.8': {}
+
+ '@types/react-dom@19.2.3(@types/react@19.2.8)':
+ dependencies:
+ '@types/react': 19.2.8
+
+ '@types/react@19.2.8':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react@5.1.2(vite@7.3.1)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6)
+ '@rolldown/pluginutils': 1.0.0-beta.53
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: 7.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ baseline-browser-mapping@2.9.14: {}
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.9.14
+ caniuse-lite: 1.0.30001764
+ electron-to-chromium: 1.5.267
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+ caniuse-lite@1.0.30001764: {}
+
+ clsx@2.1.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ electron-to-chromium@1.5.267: {}
+
+ esbuild@0.27.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.2
+ '@esbuild/android-arm': 0.27.2
+ '@esbuild/android-arm64': 0.27.2
+ '@esbuild/android-x64': 0.27.2
+ '@esbuild/darwin-arm64': 0.27.2
+ '@esbuild/darwin-x64': 0.27.2
+ '@esbuild/freebsd-arm64': 0.27.2
+ '@esbuild/freebsd-x64': 0.27.2
+ '@esbuild/linux-arm': 0.27.2
+ '@esbuild/linux-arm64': 0.27.2
+ '@esbuild/linux-ia32': 0.27.2
+ '@esbuild/linux-loong64': 0.27.2
+ '@esbuild/linux-mips64el': 0.27.2
+ '@esbuild/linux-ppc64': 0.27.2
+ '@esbuild/linux-riscv64': 0.27.2
+ '@esbuild/linux-s390x': 0.27.2
+ '@esbuild/linux-x64': 0.27.2
+ '@esbuild/netbsd-arm64': 0.27.2
+ '@esbuild/netbsd-x64': 0.27.2
+ '@esbuild/openbsd-arm64': 0.27.2
+ '@esbuild/openbsd-x64': 0.27.2
+ '@esbuild/openharmony-arm64': 0.27.2
+ '@esbuild/sunos-x64': 0.27.2
+ '@esbuild/win32-arm64': 0.27.2
+ '@esbuild/win32-ia32': 0.27.2
+ '@esbuild/win32-x64': 0.27.2
+
+ escalade@3.2.0: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ goober@2.1.18(csstype@3.2.3):
+ dependencies:
+ csstype: 3.2.3
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.11: {}
+
+ node-releases@2.0.27: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ react-dom@19.2.3(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ scheduler: 0.27.0
+
+ react-refresh@0.18.0: {}
+
+ react@19.2.3: {}
+
+ rollup@4.55.1:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.55.1
+ '@rollup/rollup-android-arm64': 4.55.1
+ '@rollup/rollup-darwin-arm64': 4.55.1
+ '@rollup/rollup-darwin-x64': 4.55.1
+ '@rollup/rollup-freebsd-arm64': 4.55.1
+ '@rollup/rollup-freebsd-x64': 4.55.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.55.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.55.1
+ '@rollup/rollup-linux-arm64-gnu': 4.55.1
+ '@rollup/rollup-linux-arm64-musl': 4.55.1
+ '@rollup/rollup-linux-loong64-gnu': 4.55.1
+ '@rollup/rollup-linux-loong64-musl': 4.55.1
+ '@rollup/rollup-linux-ppc64-gnu': 4.55.1
+ '@rollup/rollup-linux-ppc64-musl': 4.55.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.55.1
+ '@rollup/rollup-linux-riscv64-musl': 4.55.1
+ '@rollup/rollup-linux-s390x-gnu': 4.55.1
+ '@rollup/rollup-linux-x64-gnu': 4.55.1
+ '@rollup/rollup-linux-x64-musl': 4.55.1
+ '@rollup/rollup-openbsd-x64': 4.55.1
+ '@rollup/rollup-openharmony-arm64': 4.55.1
+ '@rollup/rollup-win32-arm64-msvc': 4.55.1
+ '@rollup/rollup-win32-ia32-msvc': 4.55.1
+ '@rollup/rollup-win32-x64-gnu': 4.55.1
+ '@rollup/rollup-win32-x64-msvc': 4.55.1
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ seroval-plugins@1.3.3(seroval@1.3.2):
+ dependencies:
+ seroval: 1.3.2
+
+ seroval@1.3.2: {}
+
+ solid-js@1.9.10:
+ dependencies:
+ csstype: 3.2.3
+ seroval: 1.3.2
+ seroval-plugins: 1.3.3(seroval@1.3.2)
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ vite@7.3.1:
+ dependencies:
+ esbuild: 0.27.2
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.55.1
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ ws@8.19.0: {}
+
+ yallist@3.1.1: {}
diff --git a/examples/react/a11y-devtools/src/App.tsx b/examples/react/a11y-devtools/src/App.tsx
new file mode 100644
index 00000000..6fc05340
--- /dev/null
+++ b/examples/react/a11y-devtools/src/App.tsx
@@ -0,0 +1,167 @@
+import { useState } from 'react'
+
+/**
+ * Example app with intentional accessibility issues for testing the A11y devtools plugin
+ */
+export default function App() {
+ const [showModal, setShowModal] = useState(false)
+
+ return (
+
+
A11y Devtools Demo
+
+ This page contains intentional accessibility issues to demonstrate the
+ A11y devtools plugin. Open the devtools panel and click "Run Audit" to
+ see the issues.
+
+
+
+ Accessibility Issues Demo
+
+ {/* Issue: Image without alt text */}
+
+
1. Image without alt text
+
+
+
+ {/* Issue: Button without accessible name */}
+
+
2. Button without accessible name
+
+ ×
+
+
+
+ {/* Issue: Form input without label */}
+
+
3. Form input without label
+
+
+
+ {/* Issue: Low color contrast */}
+
+
4. Low color contrast
+
+ This text has poor color contrast and may be hard to read.
+
+
+
+ {/* Issue: Link without discernible text */}
+
+
5. Link without discernible text
+
+
+
+
+
+ {/* Issue: Missing main landmark */}
+
+
6. Click handler on non-interactive element
+
setShowModal(true)}
+ style={{
+ padding: '12px 24px',
+ backgroundColor: '#0ea5e9',
+ color: 'white',
+ borderRadius: '4px',
+ display: 'inline-block',
+ cursor: 'pointer',
+ }}
+ >
+ Click me (not a button!)
+
+
+
+ {/* Issue: Empty heading */}
+
+
7. Empty heading
+
+
+
+ {/* Issue: Missing form labels */}
+
+
8. Select without label
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+
+ Accessible Content (for comparison)
+
+
+
Proper image with alt text
+
+
+
+
+
Proper button with label
+
+ ×
+
+
+
+
+
Proper input with label
+
+ Your Name
+
+
+
+
+
+ {showModal && (
+
+
Modal Dialog
+
This is a modal that was triggered by a non-button element.
+
setShowModal(false)}>Close
+
+ )}
+
+ )
+}
diff --git a/examples/react/a11y-devtools/src/index.tsx b/examples/react/a11y-devtools/src/index.tsx
new file mode 100644
index 00000000..e27b930c
--- /dev/null
+++ b/examples/react/a11y-devtools/src/index.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { TanStackDevtools } from '@tanstack/react-devtools'
+import { createA11yDevtoolsReactPlugin } from '@tanstack/devtools-a11y/react'
+
+import App from './App'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/examples/react/a11y-devtools/tsconfig.json b/examples/react/a11y-devtools/tsconfig.json
new file mode 100644
index 00000000..df83593a
--- /dev/null
+++ b/examples/react/a11y-devtools/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@tanstack/devtools-a11y/*": ["../../../packages/devtools-a11y/src/*"]
+ },
+ "target": "ESNext",
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/react/a11y-devtools/vite.config.ts b/examples/react/a11y-devtools/vite.config.ts
new file mode 100644
index 00000000..ae745180
--- /dev/null
+++ b/examples/react/a11y-devtools/vite.config.ts
@@ -0,0 +1,6 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/packages/devtools-a11y/package.json b/packages/devtools-a11y/package.json
new file mode 100644
index 00000000..b670b06b
--- /dev/null
+++ b/packages/devtools-a11y/package.json
@@ -0,0 +1,110 @@
+{
+ "name": "@tanstack/devtools-a11y",
+ "version": "0.1.0",
+ "description": "Accessibility auditing plugin for TanStack Devtools powered by axe-core",
+ "author": "TanStack",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/devtools.git",
+ "directory": "packages/devtools-a11y"
+ },
+ "homepage": "https://tanstack.com/devtools",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "keywords": [
+ "devtools",
+ "accessibility",
+ "a11y",
+ "wcag",
+ "axe-core",
+ "audit"
+ ],
+ "type": "module",
+ "types": "dist/esm/index.d.ts",
+ "main": "dist/cjs/index.cjs",
+ "module": "dist/esm/index.js",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/index.js"
+ },
+ "require": {
+ "types": "./dist/cjs/index.d.cts",
+ "default": "./dist/cjs/index.cjs"
+ }
+ },
+ "./react": {
+ "import": {
+ "types": "./dist/esm/react/index.d.ts",
+ "default": "./dist/esm/react/index.js"
+ },
+ "require": {
+ "types": "./dist/cjs/react/index.d.cts",
+ "default": "./dist/cjs/react/index.cjs"
+ }
+ },
+ "./preact": {
+ "import": {
+ "types": "./dist/esm/preact/index.d.ts",
+ "default": "./dist/esm/preact/index.js"
+ },
+ "require": {
+ "types": "./dist/cjs/preact/index.d.cts",
+ "default": "./dist/cjs/preact/index.cjs"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "engines": {
+ "node": ">=18"
+ },
+ "files": [
+ "dist/",
+ "src"
+ ],
+ "scripts": {
+ "clean": "premove ./build ./dist",
+ "lint:fix": "eslint ./src --fix",
+ "test:eslint": "eslint ./src",
+ "test:lib": "vitest",
+ "test:lib:dev": "pnpm test:lib --watch",
+ "test:types": "tsc",
+ "test:build": "publint --strict",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@tanstack/devtools-event-client": "workspace:*",
+ "@tanstack/devtools-utils": "workspace:^",
+ "axe-core": "^4.10.0",
+ "goober": "^2.1.16"
+ },
+ "devDependencies": {
+ "vite-plugin-solid": "^2.11.8"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17.0.0",
+ "preact": ">=10.0.0",
+ "react": ">=17.0.0",
+ "solid-js": ">=1.9.7",
+ "vue": ">=3.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "preact": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+}
diff --git a/packages/devtools-a11y/src/config.ts b/packages/devtools-a11y/src/config.ts
new file mode 100644
index 00000000..f9b85196
--- /dev/null
+++ b/packages/devtools-a11y/src/config.ts
@@ -0,0 +1,87 @@
+import type { A11yPluginOptions } from './types'
+
+const STORAGE_KEY = 'tanstack-devtools-a11y-config'
+
+/**
+ * Default plugin configuration
+ */
+export const DEFAULT_CONFIG: Required = {
+ threshold: 'serious',
+ runOnMount: false,
+ ruleSet: 'wcag21aa',
+ showOverlays: true,
+ persistSettings: true,
+ disabledRules: [],
+}
+
+/**
+ * Load configuration from localStorage
+ */
+export function loadConfig(): Required {
+ if (typeof localStorage === 'undefined') {
+ return DEFAULT_CONFIG
+ }
+
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored) {
+ const parsed = JSON.parse(stored) as Partial
+ return { ...DEFAULT_CONFIG, ...parsed }
+ }
+ } catch (error) {
+ console.warn(
+ '[A11y Config] Failed to load config from localStorage:',
+ error,
+ )
+ }
+
+ return DEFAULT_CONFIG
+}
+
+/**
+ * Save configuration to localStorage
+ */
+export function saveConfig(config: Partial): void {
+ if (typeof localStorage === 'undefined') {
+ return
+ }
+
+ try {
+ const current = loadConfig()
+ const updated = { ...current, ...config }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
+ } catch (error) {
+ console.warn('[A11y Config] Failed to save config to localStorage:', error)
+ }
+}
+
+/**
+ * Clear saved configuration
+ */
+export function clearConfig(): void {
+ if (typeof localStorage === 'undefined') {
+ return
+ }
+
+ try {
+ localStorage.removeItem(STORAGE_KEY)
+ } catch (error) {
+ console.warn(
+ '[A11y Config] Failed to clear config from localStorage:',
+ error,
+ )
+ }
+}
+
+/**
+ * Merge user options with defaults
+ */
+export function mergeConfig(
+ options: A11yPluginOptions = {},
+): Required {
+ if (options.persistSettings !== false) {
+ const saved = loadConfig()
+ return { ...saved, ...options }
+ }
+ return { ...DEFAULT_CONFIG, ...options }
+}
diff --git a/packages/devtools-a11y/src/core/A11yDevtoolsCore.tsx b/packages/devtools-a11y/src/core/A11yDevtoolsCore.tsx
new file mode 100644
index 00000000..a5a0ac4d
--- /dev/null
+++ b/packages/devtools-a11y/src/core/A11yDevtoolsCore.tsx
@@ -0,0 +1,64 @@
+/** @jsxImportSource solid-js */
+
+import { A11yDevtoolsPanel } from '../ui/A11yDevtoolsPanel'
+import type { A11yPluginOptions } from '../types'
+
+export class A11yDevtoolsCore {
+ #dispose: (() => void) | null = null
+ #isMounted = false
+ #isMounting = false
+ #mountCb: (() => void) | null = null
+ #options: A11yPluginOptions
+
+ constructor(options: A11yPluginOptions = {}) {
+ this.#options = options
+ }
+
+ async mount(el: HTMLElement, theme: 'light' | 'dark' | 'system' = 'light') {
+ this.#isMounting = true
+ const { render } = await import('solid-js/web')
+
+ if (this.#isMounted) {
+ throw new Error('A11yDevtoolsCore is already mounted')
+ }
+
+ const resolvedTheme: 'light' | 'dark' =
+ theme === 'system'
+ ? typeof window !== 'undefined' &&
+ window.matchMedia?.('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ : theme
+
+ this.#dispose = render(
+ () => ,
+ el,
+ )
+ this.#isMounted = true
+ this.#isMounting = false
+
+ if (this.#mountCb) {
+ this.#mountCb()
+ this.#mountCb = null
+ }
+ }
+
+ unmount() {
+ if (!this.#isMounted && !this.#isMounting) {
+ throw new Error('A11yDevtoolsCore is not mounted')
+ }
+
+ if (this.#isMounting) {
+ this.#mountCb = () => {
+ this.#dispose?.()
+ this.#isMounted = false
+ this.#dispose = null
+ }
+ return
+ }
+
+ this.#dispose?.()
+ this.#dispose = null
+ this.#isMounted = false
+ }
+}
diff --git a/packages/devtools-a11y/src/core/create-core-class.ts b/packages/devtools-a11y/src/core/create-core-class.ts
new file mode 100644
index 00000000..50d90f03
--- /dev/null
+++ b/packages/devtools-a11y/src/core/create-core-class.ts
@@ -0,0 +1,14 @@
+import { A11yDevtoolsCore } from './A11yDevtoolsCore'
+import type { A11yPluginOptions } from '../types'
+
+/**
+ * devtools-utils panel helpers require a no-arg constructor.
+ * We bind initial options via a class factory.
+ */
+export function createA11yDevtoolsCoreClass(options: A11yPluginOptions = {}) {
+ return class A11yDevtoolsCoreWithOptions extends A11yDevtoolsCore {
+ constructor() {
+ super(options)
+ }
+ }
+}
diff --git a/packages/devtools-a11y/src/event-client.ts b/packages/devtools-a11y/src/event-client.ts
new file mode 100644
index 00000000..e46df152
--- /dev/null
+++ b/packages/devtools-a11y/src/event-client.ts
@@ -0,0 +1,22 @@
+import { EventClient } from '@tanstack/devtools-event-client'
+import { A11Y_PLUGIN_ID } from './types'
+import type { A11yEventMap } from './types'
+
+/**
+ * Event client for the A11y devtools plugin.
+ * Handles communication between the devtools panel and the page.
+ */
+class A11yEventClient extends EventClient {
+ constructor() {
+ super({
+ pluginId: A11Y_PLUGIN_ID,
+ debug: process.env.NODE_ENV === 'development',
+ })
+ }
+}
+
+/**
+ * Singleton instance of the A11y event client.
+ * Use this to emit and listen for a11y-related events.
+ */
+export const a11yEventClient = new A11yEventClient()
diff --git a/packages/devtools-a11y/src/export/index.ts b/packages/devtools-a11y/src/export/index.ts
new file mode 100644
index 00000000..ef2d5658
--- /dev/null
+++ b/packages/devtools-a11y/src/export/index.ts
@@ -0,0 +1,180 @@
+import type { A11yAuditResult, ExportOptions } from '../types'
+
+/**
+ * Export audit results to JSON format
+ */
+export function exportToJson(
+ result: A11yAuditResult,
+ _options: Partial = {},
+): string {
+ const exportData = {
+ meta: {
+ exportedAt: new Date().toISOString(),
+ url: result.url,
+ auditTimestamp: result.timestamp,
+ duration: result.duration,
+ context: result.context,
+ },
+ summary: result.summary,
+ issues: result.issues.map((issue) => ({
+ id: issue.id,
+ ruleId: issue.ruleId,
+ impact: issue.impact,
+ message: issue.message,
+ help: issue.help,
+ helpUrl: issue.helpUrl,
+ wcagTags: issue.wcagTags,
+ nodes: issue.nodes.map((node) => ({
+ selector: node.selector,
+ html: node.html,
+ failureSummary: node.failureSummary,
+ })),
+ })),
+ }
+
+ return JSON.stringify(exportData, null, 2)
+}
+
+/**
+ * Export audit results to CSV format
+ */
+export function exportToCsv(
+ result: A11yAuditResult,
+ _options: Partial = {},
+): string {
+ const headers = [
+ 'Rule ID',
+ 'Impact',
+ 'Message',
+ 'Help URL',
+ 'WCAG Tags',
+ 'Selector',
+ 'HTML',
+ ]
+
+ const rows: Array> = []
+
+ for (const issue of result.issues) {
+ for (const node of issue.nodes) {
+ rows.push([
+ issue.ruleId,
+ issue.impact,
+ issue.message.replace(/"/g, '""'),
+ issue.helpUrl,
+ issue.wcagTags.join('; '),
+ node.selector,
+ node.html.replace(/"/g, '""'),
+ ])
+ }
+ }
+
+ return [
+ headers.map((h) => `"${h}"`).join(','),
+ ...rows.map((row) => row.map((cell) => `"${cell}"`).join(',')),
+ ].join('\n')
+}
+
+/**
+ * Download a file with the given content
+ */
+function downloadFile(
+ content: string,
+ filename: string,
+ mimeType: string,
+): void {
+ const blob = new Blob([content], { type: mimeType })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+}
+
+/**
+ * Export audit results and trigger download
+ */
+export function exportAuditResults(
+ result: A11yAuditResult,
+ options: ExportOptions,
+): void {
+ const { format, filename } = options
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
+ const defaultFilename = `a11y-audit-${timestamp}`
+
+ if (format === 'json') {
+ const content = exportToJson(result, options)
+ downloadFile(
+ content,
+ `${filename || defaultFilename}.json`,
+ 'application/json',
+ )
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ } else if (format === 'csv') {
+ const content = exportToCsv(result, options)
+ downloadFile(content, `${filename || defaultFilename}.csv`, 'text/csv')
+ }
+}
+
+/**
+ * Generate a summary report as a formatted string
+ */
+export function generateSummaryReport(result: A11yAuditResult): string {
+ const { summary } = result
+
+ const lines = [
+ '='.repeat(50),
+ 'ACCESSIBILITY AUDIT REPORT',
+ '='.repeat(50),
+ '',
+ `URL: ${result.url}`,
+ `Date: ${new Date(result.timestamp).toLocaleString()}`,
+ `Duration: ${result.duration.toFixed(2)}ms`,
+ '',
+ '-'.repeat(50),
+ 'SUMMARY',
+ '-'.repeat(50),
+ '',
+ `Total Issues: ${summary.total}`,
+ ` - Critical: ${summary.critical}`,
+ ` - Serious: ${summary.serious}`,
+ ` - Moderate: ${summary.moderate}`,
+ ` - Minor: ${summary.minor}`,
+ '',
+ `Passing Rules: ${summary.passes}`,
+ `Incomplete Checks: ${summary.incomplete}`,
+ '',
+ ]
+
+ if (result.issues.length > 0) {
+ lines.push('-'.repeat(50))
+ lines.push('ISSUES')
+ lines.push('-'.repeat(50))
+ lines.push('')
+
+ const issuesByImpact = {
+ critical: result.issues.filter((i) => i.impact === 'critical'),
+ serious: result.issues.filter((i) => i.impact === 'serious'),
+ moderate: result.issues.filter((i) => i.impact === 'moderate'),
+ minor: result.issues.filter((i) => i.impact === 'minor'),
+ }
+
+ for (const [impact, issues] of Object.entries(issuesByImpact)) {
+ if (issues.length > 0) {
+ lines.push(`[${impact.toUpperCase()}]`)
+ for (const issue of issues) {
+ lines.push(` - ${issue.ruleId}: ${issue.message}`)
+ lines.push(` Selector: ${issue.nodes[0]?.selector}`)
+ lines.push(` Learn more: ${issue.helpUrl}`)
+ lines.push('')
+ }
+ }
+ }
+ }
+
+ lines.push('='.repeat(50))
+
+ return lines.join('\n')
+}
diff --git a/packages/devtools-a11y/src/index.ts b/packages/devtools-a11y/src/index.ts
new file mode 100644
index 00000000..09ad5260
--- /dev/null
+++ b/packages/devtools-a11y/src/index.ts
@@ -0,0 +1,62 @@
+// Core plugin
+export { createA11yPlugin } from './plugin'
+export type { A11yDevtoolsPlugin } from './plugin'
+
+// Event client
+export { a11yEventClient } from './event-client'
+
+// Scanner
+export {
+ runAudit,
+ groupIssuesByImpact,
+ filterByThreshold,
+ meetsThreshold,
+ diffAuditResults,
+ getAvailableRules,
+} from './scanner'
+
+// Overlay
+export {
+ highlightElement,
+ highlightAllIssues,
+ clearHighlights,
+ initOverlayAdapter,
+ overlayAdapter,
+} from './overlay'
+
+// Export utilities
+export {
+ exportToJson,
+ exportToCsv,
+ exportAuditResults,
+ generateSummaryReport,
+} from './export'
+
+// Config
+export {
+ loadConfig,
+ saveConfig,
+ clearConfig,
+ mergeConfig,
+ DEFAULT_CONFIG,
+} from './config'
+
+// Types
+export type {
+ SeverityThreshold,
+ WCAGLevel,
+ RuleSetPreset,
+ A11yNode,
+ A11yIssue,
+ GroupedIssues,
+ A11ySummary,
+ A11yAuditResult,
+ A11yAuditOptions,
+ A11yPluginOptions,
+ A11yPluginState,
+ A11yEventMap,
+ ExportFormat,
+ ExportOptions,
+} from './types'
+
+export { A11Y_PLUGIN_ID } from './types'
diff --git a/packages/devtools-a11y/src/overlay/highlight.ts b/packages/devtools-a11y/src/overlay/highlight.ts
new file mode 100644
index 00000000..7216e9b7
--- /dev/null
+++ b/packages/devtools-a11y/src/overlay/highlight.ts
@@ -0,0 +1,497 @@
+import { a11yEventClient } from '../event-client'
+import type { A11yIssue, SeverityThreshold } from '../types'
+
+const HIGHLIGHT_CLASS = 'tsd-a11y-highlight'
+const HIGHLIGHT_STYLE_ID = 'tsd-a11y-highlight-styles'
+const TOOLTIP_CLASS = 'tsd-a11y-tooltip'
+
+// Track active tooltips and their target elements for scroll updates
+const activeTooltips = new Map()
+let scrollHandler: (() => void) | null = null
+
+// Tooltip height (padding + font size + some buffer)
+const TOOLTIP_HEIGHT = 28
+
+/**
+ * Severity levels mapped to numeric values for comparison (higher = more severe)
+ */
+const SEVERITY_ORDER: Record = {
+ critical: 4,
+ serious: 3,
+ moderate: 2,
+ minor: 1,
+}
+
+/**
+ * Selectors for devtools elements that should never be highlighted
+ */
+const DEVTOOLS_SELECTORS = [
+ '[data-testid="tanstack_devtools"]',
+ '[data-devtools]',
+ '[data-devtools-panel]',
+ '[data-a11y-overlay]',
+]
+
+/**
+ * Check if an element is inside the devtools panel
+ */
+function isInsideDevtools(element: Element): boolean {
+ for (const selector of DEVTOOLS_SELECTORS) {
+ if (element.closest(selector)) {
+ return true
+ }
+ }
+ return false
+}
+
+/**
+ * Color scheme for different severity levels
+ */
+const SEVERITY_COLORS: Record<
+ SeverityThreshold,
+ { border: string; bg: string; text: string }
+> = {
+ critical: {
+ border: '#dc2626',
+ bg: 'rgba(220, 38, 38, 0.15)',
+ text: '#dc2626',
+ },
+ serious: {
+ border: '#ea580c',
+ bg: 'rgba(234, 88, 12, 0.15)',
+ text: '#ea580c',
+ },
+ moderate: {
+ border: '#ca8a04',
+ bg: 'rgba(202, 138, 4, 0.15)',
+ text: '#ca8a04',
+ },
+ minor: { border: '#2563eb', bg: 'rgba(37, 99, 235, 0.15)', text: '#2563eb' },
+}
+
+/**
+ * Inject overlay styles into the document
+ */
+function injectStyles(): void {
+ if (document.getElementById(HIGHLIGHT_STYLE_ID)) {
+ return
+ }
+
+ const style = document.createElement('style')
+ style.id = HIGHLIGHT_STYLE_ID
+ // Highlights use outline which doesn't affect layout
+ // Tooltips use fixed positioning to avoid layout shifts
+ style.textContent = `
+ .${HIGHLIGHT_CLASS}--critical {
+ outline: 3px solid ${SEVERITY_COLORS.critical.border} !important;
+ outline-offset: 2px !important;
+ background-color: ${SEVERITY_COLORS.critical.bg} !important;
+ }
+
+ .${HIGHLIGHT_CLASS}--serious {
+ outline: 3px solid ${SEVERITY_COLORS.serious.border} !important;
+ outline-offset: 2px !important;
+ background-color: ${SEVERITY_COLORS.serious.bg} !important;
+ }
+
+ .${HIGHLIGHT_CLASS}--moderate {
+ outline: 2px solid ${SEVERITY_COLORS.moderate.border} !important;
+ outline-offset: 2px !important;
+ background-color: ${SEVERITY_COLORS.moderate.bg} !important;
+ }
+
+ .${HIGHLIGHT_CLASS}--minor {
+ outline: 2px dashed ${SEVERITY_COLORS.minor.border} !important;
+ outline-offset: 2px !important;
+ background-color: ${SEVERITY_COLORS.minor.bg} !important;
+ }
+
+ .${TOOLTIP_CLASS} {
+ position: fixed;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ font-family: system-ui, -apple-system, sans-serif;
+ white-space: nowrap;
+ z-index: 99990;
+ pointer-events: none;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ max-width: 90vw;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .${TOOLTIP_CLASS}--critical {
+ background: ${SEVERITY_COLORS.critical.border};
+ color: white;
+ }
+
+ .${TOOLTIP_CLASS}--serious {
+ background: ${SEVERITY_COLORS.serious.border};
+ color: white;
+ }
+
+ .${TOOLTIP_CLASS}--moderate {
+ background: ${SEVERITY_COLORS.moderate.border};
+ color: white;
+ }
+
+ .${TOOLTIP_CLASS}--minor {
+ background: ${SEVERITY_COLORS.minor.border};
+ color: white;
+ }
+ `
+ document.head.appendChild(style)
+}
+
+/**
+ * Calculate optimal tooltip position, ensuring it's always visible in viewport
+ */
+function calculateTooltipPosition(
+ targetElement: Element,
+ tooltip: HTMLElement,
+): { top: number; left: number; flipped: boolean } {
+ const rect = targetElement.getBoundingClientRect()
+ const tooltipHeight = TOOLTIP_HEIGHT
+ const gap = 4 // Small gap between tooltip and element
+ const viewportPadding = 8 // Minimum distance from viewport edge
+
+ // Default: position above the element
+ let top = rect.top - tooltipHeight - gap
+ let flipped = false
+
+ // If tooltip would be above viewport, we need to find a visible position
+ if (top < viewportPadding) {
+ // Try positioning below the element's top edge (inside the element but visible)
+ const belowTop = rect.top + gap + viewportPadding
+
+ // If the element's bottom is within the viewport, position below the element
+ if (rect.bottom + gap + tooltipHeight < window.innerHeight) {
+ top = rect.bottom + gap
+ flipped = true
+ }
+ // Otherwise, position at the top of the viewport (for large elements like )
+ else if (belowTop + tooltipHeight < window.innerHeight) {
+ top = belowTop
+ flipped = true
+ }
+ // Fallback: just keep it at the top of the viewport
+ else {
+ top = viewportPadding
+ flipped = true
+ }
+ }
+
+ // Also handle horizontal overflow - keep tooltip within viewport
+ let left = rect.left
+ const tooltipWidth = tooltip.offsetWidth || 150 // Estimate if not yet rendered
+ if (left + tooltipWidth > window.innerWidth) {
+ left = Math.max(
+ viewportPadding,
+ window.innerWidth - tooltipWidth - viewportPadding,
+ )
+ }
+ if (left < viewportPadding) {
+ left = viewportPadding
+ }
+
+ return { top, left, flipped }
+}
+
+/**
+ * Update all tooltip positions based on their target elements
+ */
+function updateTooltipPositions(): void {
+ activeTooltips.forEach((targetElement, tooltip) => {
+ const { top, left } = calculateTooltipPosition(targetElement, tooltip)
+ tooltip.style.top = `${top}px`
+ tooltip.style.left = `${left}px`
+ })
+}
+
+/**
+ * Start listening for scroll events to update tooltip positions
+ */
+function startScrollListener(): void {
+ if (scrollHandler) return
+
+ scrollHandler = () => {
+ requestAnimationFrame(updateTooltipPositions)
+ }
+
+ window.addEventListener('scroll', scrollHandler, true) // capture phase to catch all scrolls
+}
+
+/**
+ * Stop listening for scroll events
+ */
+function stopScrollListener(): void {
+ if (scrollHandler) {
+ window.removeEventListener('scroll', scrollHandler, true)
+ scrollHandler = null
+ }
+}
+
+/**
+ * Issue info for tooltip display
+ */
+interface TooltipIssue {
+ ruleId: string
+ impact: SeverityThreshold
+}
+
+/**
+ * Create a tooltip element for issues and position it above the target element
+ */
+function createTooltip(
+ issues: Array,
+ targetElement: Element,
+): HTMLElement | null {
+ if (issues.length === 0) {
+ return null
+ }
+
+ // Sort issues by severity (most severe first)
+ const sortedIssues = [...issues].sort(
+ (a, b) => SEVERITY_ORDER[b.impact] - SEVERITY_ORDER[a.impact],
+ )
+
+ const firstIssue = sortedIssues[0]
+ if (!firstIssue) {
+ return null
+ }
+
+ const mostSevere = firstIssue.impact
+ const tooltip = document.createElement('div')
+ tooltip.className = `${TOOLTIP_CLASS} ${TOOLTIP_CLASS}--${mostSevere}`
+
+ // Build tooltip content showing all issues
+ if (sortedIssues.length === 1) {
+ tooltip.textContent = `${mostSevere.toUpperCase()}: ${firstIssue.ruleId}`
+ } else {
+ // Multiple issues - show count and list
+ const issueList = sortedIssues
+ .map(
+ (issue) => `${issue.impact.charAt(0).toUpperCase()}: ${issue.ruleId}`,
+ )
+ .join(' | ')
+ tooltip.textContent = `${sortedIssues.length} issues: ${issueList}`
+ }
+
+ // Mark as overlay element so it's excluded from a11y scans
+ tooltip.setAttribute('data-a11y-overlay', 'true')
+
+ // Track this tooltip for scroll updates (need to add before positioning)
+ activeTooltips.set(tooltip, targetElement)
+
+ // Start scroll listener if not already running
+ if (activeTooltips.size === 1) {
+ startScrollListener()
+ }
+
+ // Position the tooltip - will flip below element if above viewport
+ const { top, left } = calculateTooltipPosition(targetElement, tooltip)
+ tooltip.style.top = `${top}px`
+ tooltip.style.left = `${left}px`
+
+ return tooltip
+}
+
+/**
+ * Highlight a single element with the specified severity
+ */
+export function highlightElement(
+ selector: string,
+ impact: SeverityThreshold = 'serious',
+ options: { showTooltip?: boolean; ruleId?: string } = {},
+): void {
+ const { showTooltip = true, ruleId } = options
+
+ try {
+ injectStyles()
+
+ const elements = document.querySelectorAll(selector)
+ if (elements.length === 0) {
+ console.warn(`[A11y Overlay] No elements found for selector: ${selector}`)
+ return
+ }
+
+ let highlightedCount = 0
+ elements.forEach((el) => {
+ // Skip elements inside devtools
+ if (isInsideDevtools(el)) {
+ return
+ }
+
+ el.classList.add(HIGHLIGHT_CLASS, `${HIGHLIGHT_CLASS}--${impact}`)
+
+ // Add tooltip to first highlighted element only
+ if (showTooltip && highlightedCount === 0 && ruleId) {
+ const tooltip = createTooltip([{ ruleId, impact }], el)
+ if (tooltip) {
+ document.body.appendChild(tooltip)
+ }
+ }
+
+ highlightedCount++
+ })
+
+ if (highlightedCount > 0) {
+ console.log(
+ `[A11y Overlay] Highlighted ${highlightedCount} element(s) with selector: ${selector}`,
+ )
+ }
+ } catch (error) {
+ console.error('[A11y Overlay] Error highlighting element:', error)
+ }
+}
+
+/**
+ * Highlight all elements with issues.
+ * Shows all issues per element in the tooltip, using the most severe for highlighting.
+ */
+export function highlightAllIssues(issues: Array): void {
+ injectStyles()
+ clearHighlights()
+
+ // Track ALL issues for each selector
+ // Map: selector -> Array<{ ruleId, impact }>
+ const selectorIssues = new Map>()
+
+ // Collect all issues per selector
+ for (const issue of issues) {
+ for (const node of issue.nodes) {
+ const selector = node.selector
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const impact = issue.impact ?? 'minor'
+
+ const existing = selectorIssues.get(selector) || []
+ // Avoid duplicate rule IDs for the same selector
+ if (!existing.some((e) => e.ruleId === issue.ruleId)) {
+ existing.push({ ruleId: issue.ruleId, impact })
+ selectorIssues.set(selector, existing)
+ }
+ }
+ }
+
+ // Highlight each selector with its most severe issue, but show all in tooltip
+ for (const [selector, issueList] of selectorIssues) {
+ // Skip empty lists (shouldn't happen, but guards against undefined)
+ if (issueList.length === 0) {
+ continue
+ }
+
+ // Find most severe impact for highlighting
+ const mostSevereImpact = issueList.reduce((max, issue) =>
+ SEVERITY_ORDER[issue.impact] > SEVERITY_ORDER[max.impact] ? issue : max,
+ ).impact
+
+ try {
+ const elements = document.querySelectorAll(selector)
+ if (elements.length === 0) {
+ continue
+ }
+
+ let highlightedCount = 0
+ elements.forEach((el) => {
+ // Skip elements inside devtools
+ if (isInsideDevtools(el)) {
+ return
+ }
+
+ el.classList.add(
+ HIGHLIGHT_CLASS,
+ `${HIGHLIGHT_CLASS}--${mostSevereImpact}`,
+ )
+
+ // Add tooltip to first highlighted element only, showing ALL issues
+ if (highlightedCount === 0) {
+ const tooltip = createTooltip(issueList, el)
+ if (tooltip) {
+ document.body.appendChild(tooltip)
+ }
+ }
+
+ highlightedCount++
+ })
+ } catch (error) {
+ console.error('[A11y Overlay] Error highlighting element:', error)
+ }
+ }
+}
+
+/**
+ * Clear all highlights from the page
+ */
+export function clearHighlights(): void {
+ // Remove highlight classes
+ const highlighted = document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)
+ highlighted.forEach((el) => {
+ el.classList.remove(
+ HIGHLIGHT_CLASS,
+ `${HIGHLIGHT_CLASS}--critical`,
+ `${HIGHLIGHT_CLASS}--serious`,
+ `${HIGHLIGHT_CLASS}--moderate`,
+ `${HIGHLIGHT_CLASS}--minor`,
+ )
+ })
+
+ // Remove tooltips and clear tracking
+ const tooltips = document.querySelectorAll(`.${TOOLTIP_CLASS}`)
+ tooltips.forEach((el) => el.remove())
+ activeTooltips.clear()
+ stopScrollListener()
+}
+
+/**
+ * Remove styles from the document
+ */
+function removeStyles(): void {
+ const style = document.getElementById(HIGHLIGHT_STYLE_ID)
+ if (style) {
+ style.remove()
+ }
+}
+
+/**
+ * Initialize the overlay adapter - sets up event listeners
+ */
+export function initOverlayAdapter(): () => void {
+ injectStyles()
+
+ const cleanupHighlight = a11yEventClient.on('highlight', (event) => {
+ const { selector, impact } = event.payload
+ clearHighlights()
+ highlightElement(selector, impact)
+ })
+
+ const cleanupClear = a11yEventClient.on('clear-highlights', () => {
+ clearHighlights()
+ })
+
+ const cleanupHighlightAll = a11yEventClient.on('highlight-all', (event) => {
+ highlightAllIssues(event.payload.issues)
+ })
+
+ console.log('[A11y Overlay] Adapter initialized')
+
+ return () => {
+ cleanupHighlight()
+ cleanupClear()
+ cleanupHighlightAll()
+ clearHighlights()
+ removeStyles()
+ console.log('[A11y Overlay] Adapter destroyed')
+ }
+}
+
+/**
+ * Overlay adapter object for direct usage
+ */
+export const overlayAdapter = {
+ highlight: highlightElement,
+ highlightAll: highlightAllIssues,
+ clear: clearHighlights,
+ init: initOverlayAdapter,
+}
diff --git a/packages/devtools-a11y/src/overlay/index.ts b/packages/devtools-a11y/src/overlay/index.ts
new file mode 100644
index 00000000..6dff5d23
--- /dev/null
+++ b/packages/devtools-a11y/src/overlay/index.ts
@@ -0,0 +1,7 @@
+export {
+ highlightElement,
+ highlightAllIssues,
+ clearHighlights,
+ initOverlayAdapter,
+ overlayAdapter,
+} from './highlight'
diff --git a/packages/devtools-a11y/src/plugin.ts b/packages/devtools-a11y/src/plugin.ts
new file mode 100644
index 00000000..6b6336c4
--- /dev/null
+++ b/packages/devtools-a11y/src/plugin.ts
@@ -0,0 +1,48 @@
+import { a11yEventClient } from './event-client'
+import { A11yDevtoolsCore } from './core/A11yDevtoolsCore'
+import { getA11yRuntime } from './runtime'
+import type { A11yAuditResult, A11yPluginOptions } from './types'
+
+/**
+ * Plugin interface compatible with TanStack Devtools
+ */
+export interface A11yDevtoolsPlugin {
+ id: string
+ name: string
+ render: (el: HTMLDivElement, theme: 'light' | 'dark') => void
+ destroy?: () => void
+
+ // Optional programmatic API (non-React)
+ scan?: () => Promise
+ onScan?: (cb: (result: A11yAuditResult) => void) => () => void
+}
+
+/**
+ * Create the A11y devtools plugin.
+ *
+ * This mounts the Solid-based panel UI and exposes a small programmatic API.
+ */
+export function createA11yPlugin(
+ opts: A11yPluginOptions = {},
+): A11yDevtoolsPlugin {
+ const runtime = getA11yRuntime(opts)
+ const core = new A11yDevtoolsCore(opts)
+
+ return {
+ id: 'devtools-a11y',
+ name: 'Accessibility',
+ render: (el, theme) => {
+ void core.mount(el, theme).catch((err) => {
+ console.error('[A11y Plugin] Failed to mount panel:', err)
+ })
+ },
+ destroy: () => {
+ core.unmount()
+ runtime.destroy()
+ },
+ scan: () => runtime.scan(),
+ onScan: (cb) => {
+ return a11yEventClient.on('results', (event) => cb(event.payload))
+ },
+ }
+}
diff --git a/packages/devtools-a11y/src/preact/adapters.ts b/packages/devtools-a11y/src/preact/adapters.ts
new file mode 100644
index 00000000..470e20f5
--- /dev/null
+++ b/packages/devtools-a11y/src/preact/adapters.ts
@@ -0,0 +1,32 @@
+import {
+ createPreactPanel,
+ createPreactPlugin,
+} from '@tanstack/devtools-utils/preact'
+import { createA11yDevtoolsCoreClass } from '../core/create-core-class'
+import type { A11yPluginOptions } from '../types'
+
+export function createA11yDevtoolsPreactPanel(options: A11yPluginOptions = {}) {
+ const CoreClass = createA11yDevtoolsCoreClass(options)
+ return createPreactPanel(CoreClass)
+}
+
+const [A11yDevtoolsPanel, A11yDevtoolsPanelNoOp] =
+ createA11yDevtoolsPreactPanel()
+
+export { A11yDevtoolsPanel, A11yDevtoolsPanelNoOp }
+
+export function createA11yDevtoolsPreactPlugin(
+ options: A11yPluginOptions = {},
+) {
+ const [Panel] = createA11yDevtoolsPreactPanel(options)
+ return createPreactPlugin({
+ Component: Panel,
+ name: 'Accessibility',
+ id: 'devtools-a11y',
+ })
+}
+
+const [a11yDevtoolsPlugin, a11yDevtoolsNoOpPlugin] =
+ createA11yDevtoolsPreactPlugin()
+
+export { a11yDevtoolsPlugin, a11yDevtoolsNoOpPlugin }
diff --git a/packages/devtools-a11y/src/preact/index.ts b/packages/devtools-a11y/src/preact/index.ts
new file mode 100644
index 00000000..5ad5a3fb
--- /dev/null
+++ b/packages/devtools-a11y/src/preact/index.ts
@@ -0,0 +1,10 @@
+export type { A11yPluginOptions } from '../types'
+
+export {
+ A11yDevtoolsPanel,
+ A11yDevtoolsPanelNoOp,
+ a11yDevtoolsPlugin,
+ a11yDevtoolsNoOpPlugin,
+ createA11yDevtoolsPreactPanel,
+ createA11yDevtoolsPreactPlugin,
+} from './adapters'
diff --git a/packages/devtools-a11y/src/react/adapters.ts b/packages/devtools-a11y/src/react/adapters.ts
new file mode 100644
index 00000000..f759fe0f
--- /dev/null
+++ b/packages/devtools-a11y/src/react/adapters.ts
@@ -0,0 +1,30 @@
+import {
+ createReactPanel,
+ createReactPlugin,
+} from '@tanstack/devtools-utils/react'
+import { createA11yDevtoolsCoreClass } from '../core/create-core-class'
+import type { A11yPluginOptions } from '../types'
+
+export function createA11yDevtoolsReactPanel(options: A11yPluginOptions = {}) {
+ const CoreClass = createA11yDevtoolsCoreClass(options)
+ return createReactPanel(CoreClass)
+}
+
+const [A11yDevtoolsPanel, A11yDevtoolsPanelNoOp] =
+ createA11yDevtoolsReactPanel()
+
+export { A11yDevtoolsPanel, A11yDevtoolsPanelNoOp }
+
+export function createA11yDevtoolsReactPlugin(options: A11yPluginOptions = {}) {
+ const [Panel] = createA11yDevtoolsReactPanel(options)
+ return createReactPlugin({
+ Component: Panel,
+ name: 'Accessibility',
+ id: 'devtools-a11y',
+ })
+}
+
+const [a11yDevtoolsPlugin, a11yDevtoolsNoOpPlugin] =
+ createA11yDevtoolsReactPlugin()
+
+export { a11yDevtoolsPlugin, a11yDevtoolsNoOpPlugin }
diff --git a/packages/devtools-a11y/src/react/index.ts b/packages/devtools-a11y/src/react/index.ts
new file mode 100644
index 00000000..1013ffc0
--- /dev/null
+++ b/packages/devtools-a11y/src/react/index.ts
@@ -0,0 +1,10 @@
+export type { A11yPluginOptions } from '../types'
+
+export {
+ A11yDevtoolsPanel,
+ A11yDevtoolsPanelNoOp,
+ a11yDevtoolsPlugin,
+ a11yDevtoolsNoOpPlugin,
+ createA11yDevtoolsReactPanel,
+ createA11yDevtoolsReactPlugin,
+} from './adapters'
diff --git a/packages/devtools-a11y/src/runtime.ts b/packages/devtools-a11y/src/runtime.ts
new file mode 100644
index 00000000..22b03ae5
--- /dev/null
+++ b/packages/devtools-a11y/src/runtime.ts
@@ -0,0 +1,155 @@
+import { a11yEventClient } from './event-client'
+import { mergeConfig, saveConfig } from './config'
+import { filterByThreshold, runAudit } from './scanner'
+import {
+ clearHighlights,
+ highlightAllIssues,
+ initOverlayAdapter,
+} from './overlay'
+import type { A11yAuditResult, A11yPluginOptions } from './types'
+
+interface A11yRuntime {
+ getConfig: () => Required
+ getResults: () => A11yAuditResult | null
+ scan: () => Promise
+ setConfig: (
+ patch: Partial,
+ opts?: { persist?: boolean; emit?: boolean },
+ ) => void
+ destroy: () => void
+}
+
+let runtimeSingleton: A11yRuntime | null = null
+
+function hasDom(): boolean {
+ return typeof window !== 'undefined' && typeof document !== 'undefined'
+}
+
+function createRuntime(initOpts: A11yPluginOptions = {}): A11yRuntime {
+ let config = mergeConfig(initOpts)
+ let results: A11yAuditResult | null = null
+
+ let overlayCleanup: (() => void) | null = null
+
+ const ensureOverlayAdapter = () => {
+ if (!hasDom()) return
+ if (overlayCleanup) return
+ overlayCleanup = initOverlayAdapter()
+ }
+
+ const updateHighlightsFromResults = (next: A11yAuditResult | null) => {
+ if (!hasDom()) return
+
+ if (!config.showOverlays) {
+ clearHighlights()
+ return
+ }
+
+ if (!next) {
+ clearHighlights()
+ return
+ }
+
+ const issuesAboveThreshold = filterByThreshold(
+ next.issues,
+ config.threshold,
+ ).filter((issue) => !config.disabledRules.includes(issue.ruleId))
+
+ clearHighlights()
+ if (issuesAboveThreshold.length > 0) {
+ highlightAllIssues(issuesAboveThreshold)
+ }
+ }
+
+ const setResults = (next: A11yAuditResult) => {
+ results = next
+ a11yEventClient.emit('results', next)
+ updateHighlightsFromResults(next)
+ }
+
+ const setConfig: A11yRuntime['setConfig'] = (
+ patch,
+ { persist = true, emit = true } = {},
+ ) => {
+ // Only persist the delta to avoid stomping on external changes.
+ if (persist && config.persistSettings !== false) {
+ saveConfig(patch)
+ }
+
+ // Update local config and normalize fields.
+ config = {
+ ...config,
+ ...patch,
+ disabledRules: patch.disabledRules ?? config.disabledRules,
+ }
+
+ if (emit) {
+ a11yEventClient.emit('config-change', patch)
+ }
+
+ // Update overlays immediately when relevant config changes.
+ if (
+ patch.showOverlays !== undefined ||
+ patch.threshold !== undefined ||
+ patch.disabledRules !== undefined
+ ) {
+ updateHighlightsFromResults(results)
+ }
+ }
+
+ const scan = async () => {
+ if (!hasDom()) {
+ throw new Error('A11y runtime requires a DOM to scan')
+ }
+
+ ensureOverlayAdapter()
+ a11yEventClient.emit('scan-start', { context: 'document' })
+
+ try {
+ const next = await runAudit({
+ threshold: config.threshold,
+ ruleSet: config.ruleSet,
+ disabledRules: config.disabledRules,
+ })
+ setResults(next)
+ a11yEventClient.emit('scan-complete', {
+ duration: next.duration,
+ issueCount: next.issues.length,
+ })
+ return next
+ } catch (error) {
+ a11yEventClient.emit('scan-error', {
+ error: error instanceof Error ? error.message : String(error),
+ })
+ throw error
+ }
+ }
+
+ return {
+ getConfig: () => config,
+ getResults: () => results,
+ scan,
+ setConfig,
+ destroy: () => {
+ if (overlayCleanup) {
+ overlayCleanup()
+ overlayCleanup = null
+ }
+ clearHighlights()
+ results = null
+ },
+ }
+}
+
+/**
+ * Singleton runtime shared between the plugin entry and any framework wrappers.
+ */
+export function getA11yRuntime(initOpts: A11yPluginOptions = {}): A11yRuntime {
+ if (!runtimeSingleton) {
+ runtimeSingleton = createRuntime(initOpts)
+ } else if (Object.keys(initOpts).length > 0) {
+ // Merge in any explicit init overrides (without re-persisting).
+ runtimeSingleton.setConfig(initOpts, { persist: false, emit: false })
+ }
+ return runtimeSingleton
+}
diff --git a/packages/devtools-a11y/src/scanner/audit.ts b/packages/devtools-a11y/src/scanner/audit.ts
new file mode 100644
index 00000000..1140b203
--- /dev/null
+++ b/packages/devtools-a11y/src/scanner/audit.ts
@@ -0,0 +1,395 @@
+import axe from 'axe-core'
+import {
+ getCustomRules as getCustomRulesInternal,
+ runCustomRules,
+} from './custom-rules'
+import type { AxeResults, RuleObject, RunOptions } from 'axe-core'
+import type {
+ A11yAuditOptions,
+ A11yAuditResult,
+ A11yIssue,
+ A11yNode,
+ A11ySummary,
+ CustomRulesConfig,
+ GroupedIssues,
+ RuleSetPreset,
+ SeverityThreshold,
+} from '../types'
+
+/**
+ * Severity levels mapped to numeric values for comparison
+ */
+const IMPACT_SEVERITY: Record = {
+ critical: 4,
+ serious: 3,
+ moderate: 2,
+ minor: 1,
+}
+
+/**
+ * Rule set configurations for different presets
+ */
+const RULE_SET_CONFIGS: Record> = {
+ wcag2a: {
+ runOnly: {
+ type: 'tag',
+ values: ['wcag2a'],
+ },
+ },
+ wcag2aa: {
+ runOnly: {
+ type: 'tag',
+ values: ['wcag2a', 'wcag2aa'],
+ },
+ },
+ wcag21aa: {
+ runOnly: {
+ type: 'tag',
+ values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
+ },
+ },
+ wcag22aa: {
+ runOnly: {
+ type: 'tag',
+ values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],
+ },
+ },
+ section508: {
+ runOnly: {
+ type: 'tag',
+ values: ['section508'],
+ },
+ },
+ 'best-practice': {
+ runOnly: {
+ type: 'tag',
+ values: ['best-practice'],
+ },
+ },
+ all: {
+ // Run all rules
+ },
+}
+
+/**
+ * Check if an impact level meets or exceeds the threshold
+ */
+export function meetsThreshold(
+ impact: SeverityThreshold | null | undefined,
+ threshold: SeverityThreshold,
+): boolean {
+ if (!impact) return false
+ return IMPACT_SEVERITY[impact] >= IMPACT_SEVERITY[threshold]
+}
+
+/**
+ * Convert axe-core results to our issue format
+ */
+function convertToIssues(
+ results: AxeResults,
+ threshold: SeverityThreshold,
+): Array {
+ const issues: Array = []
+
+ for (const violation of results.violations) {
+ const impact = violation.impact as SeverityThreshold | undefined
+
+ for (let i = 0; i < violation.nodes.length; i++) {
+ const node = violation.nodes[i]!
+ const selector = node.target.join(', ')
+
+ const a11yNode: A11yNode = {
+ selector,
+ html: node.html,
+ xpath: node.xpath?.join(' > '),
+ failureSummary: node.failureSummary,
+ }
+
+ issues.push({
+ id: `${violation.id}-${i}-${Date.now()}`,
+ ruleId: violation.id,
+ impact: impact || 'minor',
+ message: node.failureSummary || violation.description,
+ help: violation.help,
+ helpUrl: violation.helpUrl,
+ wcagTags: violation.tags.filter(
+ (tag) => tag.startsWith('wcag') || tag.startsWith('section508'),
+ ),
+ nodes: [a11yNode],
+ meetsThreshold: meetsThreshold(impact, threshold),
+ timestamp: Date.now(),
+ })
+ }
+ }
+
+ return issues
+}
+
+/**
+ * Create summary statistics from issues array
+ * Used when combining axe-core results with custom rule results
+ */
+function createSummary(
+ axeResults: AxeResults,
+ issues: Array,
+): A11ySummary {
+ const summary: A11ySummary = {
+ total: issues.length,
+ critical: 0,
+ serious: 0,
+ moderate: 0,
+ minor: 0,
+ passes: axeResults.passes.length,
+ incomplete: axeResults.incomplete.length,
+ }
+
+ for (const issue of issues) {
+ const impact = issue.impact
+ if (impact === 'critical') summary.critical++
+ else if (impact === 'serious') summary.serious++
+ else if (impact === 'moderate') summary.moderate++
+ else {
+ summary.minor++
+ }
+ }
+
+ return summary
+}
+
+/**
+ * Group issues by their impact level
+ */
+export function groupIssuesByImpact(issues: Array): GroupedIssues {
+ const grouped: GroupedIssues = {
+ critical: [],
+ serious: [],
+ moderate: [],
+ minor: [],
+ }
+
+ for (const issue of issues) {
+ const impact = issue.impact
+ if (impact === 'critical') grouped.critical.push(issue)
+ else if (impact === 'serious') grouped.serious.push(issue)
+ else if (impact === 'moderate') grouped.moderate.push(issue)
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ else if (impact === 'minor') grouped.minor.push(issue)
+ }
+
+ return grouped
+}
+
+/**
+ * Filter issues by severity threshold
+ */
+export function filterByThreshold(
+ issues: Array,
+ threshold: SeverityThreshold,
+): Array {
+ return issues.filter((issue) => meetsThreshold(issue.impact, threshold))
+}
+
+/**
+ * Get the context description for logging
+ */
+function getContextDescription(context: Document | Element | string): string {
+ if (typeof context === 'string') {
+ return context
+ }
+ if (context instanceof Document) {
+ return 'document'
+ }
+ if (context instanceof Element) {
+ return context.tagName.toLowerCase() + (context.id ? `#${context.id}` : '')
+ }
+ return 'unknown'
+}
+
+/**
+ * Default selectors to exclude from auditing (devtools panels, overlays, etc.)
+ */
+const DEFAULT_EXCLUDE_SELECTORS = [
+ // TanStack Devtools root container
+ '[data-testid="tanstack_devtools"]',
+ // A11y overlay elements
+ '[data-a11y-overlay]',
+ // Common devtools patterns
+ '[data-devtools]',
+ '[data-devtools-panel]',
+]
+
+/**
+ * Run an accessibility audit using axe-core
+ */
+export async function runAudit(
+ options: A11yAuditOptions = {},
+): Promise {
+ const {
+ threshold = 'serious',
+ context = document,
+ ruleSet = 'wcag21aa',
+ enabledRules,
+ disabledRules,
+ exclude = [],
+ customRules = {},
+ } = options
+
+ // Merge user exclusions with default devtools exclusions
+ const allExclusions = [...DEFAULT_EXCLUDE_SELECTORS, ...exclude]
+
+ const startTime = performance.now()
+ const contextDescription = getContextDescription(context)
+
+ try {
+ // Build axe-core options
+ const axeOptions: RunOptions = {
+ resultTypes: ['violations', 'passes', 'incomplete'],
+ ...RULE_SET_CONFIGS[ruleSet],
+ }
+
+ // Handle specific rule configurations
+ if (enabledRules && enabledRules.length > 0) {
+ axeOptions.runOnly = {
+ type: 'rule',
+ values: enabledRules,
+ }
+ }
+
+ // Build rules configuration for disabled rules
+ if (disabledRules && disabledRules.length > 0) {
+ const rules: RuleObject = {}
+ for (const ruleId of disabledRules) {
+ rules[ruleId] = { enabled: false }
+ }
+ axeOptions.rules = rules
+ }
+
+ // Determine the context to audit
+ let auditContext: axe.ElementContext = context as axe.ElementContext
+
+ // Add exclusions if specified (always include devtools exclusions)
+ if (allExclusions.length > 0) {
+ auditContext = {
+ include: [auditContext as Element],
+ exclude: allExclusions.map((sel) => [sel]),
+ } as axe.ElementContext
+ }
+
+ // Run the axe-core audit
+ const results = await axe.run(auditContext, axeOptions)
+
+ // Convert axe-core results to our format
+ const axeIssues = convertToIssues(results, threshold)
+
+ // Run custom rules (if not all disabled)
+ const customRulesConfig: CustomRulesConfig = {
+ clickHandlerOnNonInteractive:
+ customRules.clickHandlerOnNonInteractive !== false &&
+ !disabledRules?.includes('click-handler-on-non-interactive'),
+ mouseOnlyEventHandlers:
+ customRules.mouseOnlyEventHandlers !== false &&
+ !disabledRules?.includes('mouse-only-event-handlers'),
+ staticElementInteraction:
+ customRules.staticElementInteraction !== false &&
+ !disabledRules?.includes('static-element-interaction'),
+ }
+
+ const contextElement =
+ typeof context === 'string'
+ ? document.querySelector(context) || document
+ : context
+
+ const customIssues = runCustomRules(contextElement, customRulesConfig)
+
+ // Merge all issues
+ const allIssues = [...axeIssues, ...customIssues]
+
+ const duration = performance.now() - startTime
+
+ // Create summary from combined issues
+ const summary = createSummary(results, allIssues)
+
+ return {
+ issues: allIssues,
+ summary,
+ timestamp: Date.now(),
+ url: typeof window !== 'undefined' ? window.location.href : '',
+ context: contextDescription,
+ duration,
+ }
+ } catch (error) {
+ const duration = performance.now() - startTime
+ console.error('[A11y Audit] Error running axe-core:', error)
+
+ return {
+ issues: [],
+ summary: {
+ total: 0,
+ critical: 0,
+ serious: 0,
+ moderate: 0,
+ minor: 0,
+ passes: 0,
+ incomplete: 0,
+ },
+ timestamp: Date.now(),
+ url: typeof window !== 'undefined' ? window.location.href : '',
+ context: contextDescription,
+ duration,
+ }
+ }
+}
+
+/**
+ * Compare two audit results and find new/resolved issues
+ */
+export function diffAuditResults(
+ previous: A11yAuditResult | null,
+ current: A11yAuditResult,
+): { newIssues: Array; resolvedIssues: Array } {
+ if (!previous) {
+ return {
+ newIssues: current.issues,
+ resolvedIssues: [],
+ }
+ }
+
+ // Create sets of issue identifiers for comparison
+ const previousIds = new Set(
+ previous.issues.map((i) => `${i.ruleId}:${i.nodes[0]?.selector}`),
+ )
+ const currentIds = new Set(
+ current.issues.map((i) => `${i.ruleId}:${i.nodes[0]?.selector}`),
+ )
+
+ const newIssues = current.issues.filter(
+ (i) => !previousIds.has(`${i.ruleId}:${i.nodes[0]?.selector}`),
+ )
+
+ const resolvedIssues = previous.issues.filter(
+ (i) => !currentIds.has(`${i.ruleId}:${i.nodes[0]?.selector}`),
+ )
+
+ return { newIssues, resolvedIssues }
+}
+
+/**
+ * Get a list of all available axe-core rules plus custom rules
+ */
+export function getAvailableRules(): Array<{
+ id: string
+ description: string
+ tags: Array
+}> {
+ // Get axe-core rules
+ const axeRules = axe.getRules().map((rule) => ({
+ id: rule.ruleId,
+ description: rule.description,
+ tags: rule.tags,
+ }))
+
+ // Get custom rules
+ const customRules = getCustomRulesInternal()
+
+ return [...axeRules, ...customRules]
+}
diff --git a/packages/devtools-a11y/src/scanner/custom-rules/index.ts b/packages/devtools-a11y/src/scanner/custom-rules/index.ts
new file mode 100644
index 00000000..dc565ee7
--- /dev/null
+++ b/packages/devtools-a11y/src/scanner/custom-rules/index.ts
@@ -0,0 +1,631 @@
+/**
+ * Custom accessibility rules for issues not covered by axe-core
+ *
+ * These rules detect common accessibility anti-patterns like:
+ * - Click handlers on non-interactive elements
+ * - Mouse-only event handlers without keyboard equivalents
+ * - Static elements with interactive handlers
+ */
+
+import type { A11yIssue, CustomRulesConfig } from '../../types'
+
+/**
+ * Interactive HTML elements that can receive focus and have implicit roles
+ */
+const INTERACTIVE_ELEMENTS = new Set([
+ 'a',
+ 'button',
+ 'input',
+ 'select',
+ 'textarea',
+ 'details',
+ 'summary',
+ 'audio',
+ 'video',
+])
+
+/**
+ * Elements that are interactive when they have an href attribute
+ */
+const INTERACTIVE_WITH_HREF = new Set(['a', 'area'])
+
+/**
+ * Interactive ARIA roles
+ */
+const INTERACTIVE_ROLES = new Set([
+ 'button',
+ 'checkbox',
+ 'combobox',
+ 'gridcell',
+ 'link',
+ 'listbox',
+ 'menu',
+ 'menubar',
+ 'menuitem',
+ 'menuitemcheckbox',
+ 'menuitemradio',
+ 'option',
+ 'progressbar',
+ 'radio',
+ 'scrollbar',
+ 'searchbox',
+ 'slider',
+ 'spinbutton',
+ 'switch',
+ 'tab',
+ 'tabpanel',
+ 'textbox',
+ 'tree',
+ 'treeitem',
+])
+
+/**
+ * Mouse-only events that should have keyboard equivalents
+ */
+const MOUSE_ONLY_EVENTS = [
+ 'onclick',
+ 'ondblclick',
+ 'onmousedown',
+ 'onmouseup',
+ 'onmouseover',
+ 'onmouseout',
+ 'onmouseenter',
+ 'onmouseleave',
+]
+
+/**
+ * Keyboard events that would make an element accessible
+ */
+const KEYBOARD_EVENTS = ['onkeydown', 'onkeyup', 'onkeypress']
+
+/**
+ * Selectors for devtools elements to exclude
+ */
+const DEVTOOLS_SELECTORS = [
+ '[data-testid="tanstack_devtools"]',
+ '[data-devtools]',
+ '[data-devtools-panel]',
+ '[data-a11y-overlay]',
+]
+
+/**
+ * Common root container element IDs used by frameworks.
+ * React attaches event delegation to these elements, which would
+ * cause false positives for click handler detection.
+ */
+const ROOT_CONTAINER_IDS = new Set([
+ 'root',
+ 'app',
+ '__next', // Next.js
+ '__nuxt', // Nuxt
+ '__gatsby', // Gatsby
+ 'app-root', // Angular
+ 'svelte', // SvelteKit
+ 'q-app', // Qwik
+])
+
+/**
+ * Check if an element is a root container (framework app mount point).
+ * These elements often have React internals attached for event delegation
+ * but don't actually have user-defined click handlers.
+ */
+function isRootContainer(element: Element): boolean {
+ // Check by ID
+ if (element.id && ROOT_CONTAINER_IDS.has(element.id)) {
+ return true
+ }
+
+ // Check if direct child of body (common for app containers)
+ if (element.parentElement === document.body) {
+ // Only consider it a root if it has no meaningful content attributes
+ // that would indicate it's an interactive element
+ const tagName = element.tagName.toLowerCase()
+ if (tagName === 'div' || tagName === 'main' || tagName === 'section') {
+ // Check if this looks like an app container (wraps most of the page)
+ // by checking if it has React fiber but no explicit onClick in props
+ const keys = Object.keys(element)
+ for (const key of keys) {
+ if (key.startsWith('__reactProps$')) {
+ const props = (element as unknown as Record)[key]
+ if (props && typeof props === 'object') {
+ const propsObj = props as Record
+ // If it has children but no onClick, it's likely a container
+ if ('children' in propsObj && !('onClick' in propsObj)) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return false
+}
+
+/**
+ * Check if an element is inside devtools
+ */
+function isInsideDevtools(element: Element): boolean {
+ for (const selector of DEVTOOLS_SELECTORS) {
+ if (element.closest(selector)) {
+ return true
+ }
+ }
+ return false
+}
+
+/**
+ * Check if element is interactive by nature
+ */
+function isInteractiveElement(element: Element): boolean {
+ const tagName = element.tagName.toLowerCase()
+
+ // Check if it's an inherently interactive element
+ if (INTERACTIVE_ELEMENTS.has(tagName)) {
+ // Disabled elements are not interactive
+ return !element.hasAttribute('disabled')
+ }
+
+ // Check if it's an element that becomes interactive with href
+ return INTERACTIVE_WITH_HREF.has(tagName) && element.hasAttribute('href')
+}
+
+/**
+ * Check if element has an interactive ARIA role
+ */
+function hasInteractiveRole(element: Element): boolean {
+ const role = element.getAttribute('role')
+ return role !== null && INTERACTIVE_ROLES.has(role)
+}
+
+/**
+ * Check if element is focusable (has tabindex)
+ */
+function isFocusable(element: Element): boolean {
+ const tabindex = element.getAttribute('tabindex')
+ if (tabindex === null) {
+ return false
+ }
+ const tabindexValue = parseInt(tabindex, 10)
+ return !isNaN(tabindexValue) && tabindexValue >= 0
+}
+
+/**
+ * Check if element has click event handlers (via attribute or property)
+ */
+function hasClickHandler(element: Element): boolean {
+ // Check for onclick attribute
+ if (element.hasAttribute('onclick')) {
+ return true
+ }
+
+ // Check for event listener via property (common in React/frameworks)
+ // Note: We can't detect addEventListener calls, but we can check common patterns
+ const htmlElement = element as HTMLElement
+
+ // Check if onclick property is set
+ if (typeof htmlElement.onclick === 'function') {
+ return true
+ }
+
+ // Check for React synthetic events (data attributes often indicate handlers)
+ // React 17+ uses __reactFiber$ and __reactProps$ prefixed properties
+ const keys = Object.keys(element)
+ for (const key of keys) {
+ if (
+ key.startsWith('__reactProps$') ||
+ key.startsWith('__reactFiber$') ||
+ key.startsWith('__reactEventHandlers$')
+ ) {
+ // Element has React internals, likely has event handlers
+ // We can't easily inspect these, so we'll check for common patterns
+ const props = (element as unknown as Record)[key]
+ if (props && typeof props === 'object') {
+ const propsObj = props as Record
+ if (
+ typeof propsObj.onClick === 'function' ||
+ typeof propsObj.onMouseDown === 'function' ||
+ typeof propsObj.onMouseUp === 'function'
+ ) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
+
+/**
+ * Check if element has keyboard event handlers
+ */
+function hasKeyboardHandler(element: Element): boolean {
+ // Check for keyboard event attributes
+ for (const event of KEYBOARD_EVENTS) {
+ if (element.hasAttribute(event)) {
+ return true
+ }
+ }
+
+ const htmlElement = element as HTMLElement
+ if (
+ typeof htmlElement.onkeydown === 'function' ||
+ typeof htmlElement.onkeyup === 'function' ||
+ typeof htmlElement.onkeypress === 'function'
+ ) {
+ return true
+ }
+
+ // Check React props for keyboard handlers
+ const keys = Object.keys(element)
+ for (const key of keys) {
+ if (key.startsWith('__reactProps$')) {
+ const props = (element as unknown as Record)[key]
+ if (props && typeof props === 'object') {
+ const propsObj = props as Record
+ if (
+ typeof propsObj.onKeyDown === 'function' ||
+ typeof propsObj.onKeyUp === 'function' ||
+ typeof propsObj.onKeyPress === 'function'
+ ) {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
+
+/**
+ * Class prefixes to exclude from selectors (devtools overlay classes)
+ */
+const EXCLUDED_CLASS_PREFIXES = ['tsd-a11y-']
+
+/**
+ * Filter out devtools-injected classes from class list
+ */
+function filterClasses(classList: DOMTokenList): Array {
+ return Array.from(classList).filter(
+ (cls) => !EXCLUDED_CLASS_PREFIXES.some((prefix) => cls.startsWith(prefix)),
+ )
+}
+
+/**
+ * Get a unique selector for an element
+ */
+function getSelector(element: Element): string {
+ // Try to build a unique selector
+ if (element.id) {
+ return `#${element.id}`
+ }
+
+ const tagName = element.tagName.toLowerCase()
+ // Filter out devtools overlay classes (tsd-a11y-highlight, etc.)
+ const classes = filterClasses(element.classList).join('.')
+ const classSelector = classes ? `.${classes}` : ''
+
+ // Build path from parent
+ const parent = element.parentElement
+ if (parent && parent !== document.body) {
+ const parentSelector = getSelector(parent)
+ const siblings = Array.from(parent.children).filter(
+ (el) => el.tagName === element.tagName,
+ )
+ if (siblings.length > 1) {
+ const index = siblings.indexOf(element) + 1
+ return `${parentSelector} > ${tagName}${classSelector}:nth-of-type(${index})`
+ }
+ return `${parentSelector} > ${tagName}${classSelector}`
+ }
+
+ return `${tagName}${classSelector}`
+}
+
+/**
+ * Custom rule: Click handler on non-interactive element
+ *
+ * This rule detects elements that have click handlers but are not:
+ * - Interactive HTML elements (button, a, input, etc.)
+ * - Elements with interactive ARIA roles
+ * - Elements with tabindex for keyboard access
+ */
+function checkClickHandlerOnNonInteractive(
+ context: Document | Element = document,
+): Array {
+ const issues: Array = []
+ const timestamp = Date.now()
+
+ // Query all elements and check for click handlers
+ const allElements = context.querySelectorAll('*')
+
+ for (const element of allElements) {
+ // Skip devtools elements
+ if (isInsideDevtools(element)) {
+ continue
+ }
+
+ // Skip root container elements (e.g., #root, #app)
+ // These often have React event delegation attached but no actual click handlers
+ if (isRootContainer(element)) {
+ continue
+ }
+
+ // Skip if element is interactive
+ if (isInteractiveElement(element) || hasInteractiveRole(element)) {
+ continue
+ }
+
+ // Check if element has click handler
+ if (!hasClickHandler(element)) {
+ continue
+ }
+
+ // Element has click handler but is not interactive
+ // Check if it at least has keyboard access
+ const hasFocus = isFocusable(element)
+ const hasKeyboard = hasKeyboardHandler(element)
+
+ if (!hasFocus && !hasKeyboard) {
+ // Critical: No keyboard access at all
+ const selector = getSelector(element)
+ issues.push({
+ id: `click-handler-no-keyboard-${timestamp}-${issues.length}`,
+ ruleId: 'click-handler-on-non-interactive',
+ impact: 'serious',
+ message:
+ 'Element has a click handler but is not keyboard accessible. Add tabindex="0" and keyboard event handlers, or use an interactive element like .',
+ help: 'Interactive elements must be keyboard accessible',
+ helpUrl:
+ 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard-accessible',
+ wcagTags: ['wcag211', 'wcag21a'],
+ nodes: [
+ {
+ selector,
+ html: element.outerHTML.slice(0, 200),
+ },
+ ],
+ meetsThreshold: true,
+ timestamp,
+ })
+ } else if (hasFocus && !hasKeyboard) {
+ // Moderate: Has tabindex but no keyboard handler
+ const selector = getSelector(element)
+ issues.push({
+ id: `click-handler-no-keyboard-handler-${timestamp}-${issues.length}`,
+ ruleId: 'click-handler-on-non-interactive',
+ impact: 'moderate',
+ message:
+ 'Element has a click handler and tabindex but no keyboard event handler. Add onKeyDown/onKeyPress to handle Enter/Space keys.',
+ help: 'Interactive elements should respond to keyboard events',
+ helpUrl:
+ 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard-accessible',
+ wcagTags: ['wcag211', 'wcag21a'],
+ nodes: [
+ {
+ selector,
+ html: element.outerHTML.slice(0, 200),
+ },
+ ],
+ meetsThreshold: true,
+ timestamp,
+ })
+ }
+ }
+
+ return issues
+}
+
+/**
+ * Custom rule: Mouse-only event handlers
+ *
+ * Detects elements that have mouse event handlers (onmouseover, onmousedown, etc.)
+ * without corresponding keyboard event handlers.
+ */
+function checkMouseOnlyEvents(
+ context: Document | Element = document,
+): Array {
+ const issues: Array = []
+ const timestamp = Date.now()
+
+ // Build selector for elements with mouse events
+ const mouseEventSelectors = MOUSE_ONLY_EVENTS.map(
+ (event) => `[${event}]`,
+ ).join(', ')
+
+ const elements = context.querySelectorAll(mouseEventSelectors)
+
+ for (const element of elements) {
+ // Skip devtools elements
+ if (isInsideDevtools(element)) {
+ continue
+ }
+
+ // Skip interactive elements (they handle keyboard by default)
+ if (isInteractiveElement(element)) {
+ continue
+ }
+
+ // Check if element has keyboard handlers
+ if (hasKeyboardHandler(element) || isFocusable(element)) {
+ continue
+ }
+
+ const mouseEvents: Array = []
+ for (const event of MOUSE_ONLY_EVENTS) {
+ if (element.hasAttribute(event)) {
+ mouseEvents.push(event)
+ }
+ }
+
+ const selector = getSelector(element)
+ issues.push({
+ id: `mouse-only-events-${timestamp}-${issues.length}`,
+ ruleId: 'mouse-only-event-handlers',
+ impact: 'serious',
+ message: `Element has mouse-only event handlers (${mouseEvents.join(', ')}) without keyboard equivalents. Ensure functionality is available via keyboard.`,
+ help: 'All functionality must be operable through keyboard',
+ helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard',
+ wcagTags: ['wcag211', 'wcag21a'],
+ nodes: [
+ {
+ selector,
+ html: element.outerHTML.slice(0, 200),
+ },
+ ],
+ meetsThreshold: true,
+ timestamp,
+ })
+ }
+
+ return issues
+}
+
+/**
+ * Custom rule: Static element with interactive semantics
+ *
+ * Detects elements like or
that have role="button" but lack
+ * proper keyboard handling (tabindex and key events).
+ */
+function checkStaticElementInteraction(
+ context: Document | Element = document,
+): Array {
+ const issues: Array = []
+ const timestamp = Date.now()
+
+ // Query elements with interactive roles
+ const roleSelectors = Array.from(INTERACTIVE_ROLES)
+ .map((role) => `[role="${role}"]`)
+ .join(', ')
+
+ const elements = context.querySelectorAll(roleSelectors)
+
+ for (const element of elements) {
+ // Skip devtools elements
+ if (isInsideDevtools(element)) {
+ continue
+ }
+
+ // Skip inherently interactive elements
+ if (isInteractiveElement(element)) {
+ continue
+ }
+
+ const role = element.getAttribute('role')
+ const hasFocus = isFocusable(element)
+ const hasKeyboard = hasKeyboardHandler(element)
+
+ // Check for missing tabindex
+ if (!hasFocus) {
+ const selector = getSelector(element)
+ issues.push({
+ id: `static-element-no-tabindex-${timestamp}-${issues.length}`,
+ ruleId: 'static-element-interaction',
+ impact: 'serious',
+ message: `Element with role="${role}" is not focusable. Add tabindex="0" to make it keyboard accessible.`,
+ help: 'Elements with interactive roles must be focusable',
+ helpUrl:
+ 'https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA4#description',
+ wcagTags: ['wcag211', 'wcag21a', 'wcag412'],
+ nodes: [
+ {
+ selector,
+ html: element.outerHTML.slice(0, 200),
+ },
+ ],
+ meetsThreshold: true,
+ timestamp,
+ })
+ }
+
+ // Check for missing keyboard handlers (for button-like roles)
+ const requiresKeyboardActivation = ['button', 'link', 'menuitem', 'option']
+ if (
+ role &&
+ requiresKeyboardActivation.includes(role) &&
+ !hasKeyboard &&
+ hasClickHandler(element)
+ ) {
+ const selector = getSelector(element)
+ issues.push({
+ id: `static-element-no-keyboard-${timestamp}-${issues.length}`,
+ ruleId: 'static-element-interaction',
+ impact: 'moderate',
+ message: `Element with role="${role}" has click handler but no keyboard handler. Add onKeyDown to handle Enter/Space.`,
+ help: 'Elements with button-like roles should respond to Enter and Space keys',
+ helpUrl:
+ 'https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA4#description',
+ wcagTags: ['wcag211', 'wcag21a'],
+ nodes: [
+ {
+ selector,
+ html: element.outerHTML.slice(0, 200),
+ },
+ ],
+ meetsThreshold: true,
+ timestamp,
+ })
+ }
+ }
+
+ return issues
+}
+
+/**
+ * Run all enabled custom rules
+ */
+export function runCustomRules(
+ context: Document | Element = document,
+ config: CustomRulesConfig = {},
+): Array {
+ const {
+ clickHandlerOnNonInteractive = true,
+ mouseOnlyEventHandlers = true,
+ staticElementInteraction = true,
+ } = config
+
+ const issues: Array = []
+
+ if (clickHandlerOnNonInteractive) {
+ issues.push(...checkClickHandlerOnNonInteractive(context))
+ }
+
+ if (mouseOnlyEventHandlers) {
+ issues.push(...checkMouseOnlyEvents(context))
+ }
+
+ if (staticElementInteraction) {
+ issues.push(...checkStaticElementInteraction(context))
+ }
+
+ return issues
+}
+
+/**
+ * Get list of custom rule metadata (for UI display)
+ */
+export function getCustomRules(): Array<{
+ id: string
+ description: string
+ tags: Array
+}> {
+ return [
+ {
+ id: 'click-handler-on-non-interactive',
+ description:
+ 'Ensures click handlers are only on keyboard-accessible elements',
+ tags: ['custom', 'cat.keyboard', 'wcag21a', 'wcag211'],
+ },
+ {
+ id: 'mouse-only-event-handlers',
+ description: 'Ensures mouse event handlers have keyboard equivalents',
+ tags: ['custom', 'cat.keyboard', 'wcag21a', 'wcag211'],
+ },
+ {
+ id: 'static-element-interaction',
+ description:
+ 'Ensures elements with interactive roles are properly keyboard accessible',
+ tags: ['custom', 'cat.keyboard', 'cat.aria', 'wcag21a', 'wcag211'],
+ },
+ ]
+}
diff --git a/packages/devtools-a11y/src/scanner/index.ts b/packages/devtools-a11y/src/scanner/index.ts
new file mode 100644
index 00000000..be596060
--- /dev/null
+++ b/packages/devtools-a11y/src/scanner/index.ts
@@ -0,0 +1,8 @@
+export {
+ runAudit,
+ groupIssuesByImpact,
+ filterByThreshold,
+ meetsThreshold,
+ diffAuditResults,
+ getAvailableRules,
+} from './audit'
diff --git a/packages/devtools-a11y/src/types.ts b/packages/devtools-a11y/src/types.ts
new file mode 100644
index 00000000..7c31dd13
--- /dev/null
+++ b/packages/devtools-a11y/src/types.ts
@@ -0,0 +1,254 @@
+/**
+ * Severity threshold for filtering issues
+ */
+export type SeverityThreshold = 'critical' | 'serious' | 'moderate' | 'minor'
+
+/**
+ * WCAG conformance levels
+ */
+export type WCAGLevel =
+ | 'wcag2a'
+ | 'wcag2aa'
+ | 'wcag2aaa'
+ | 'wcag21a'
+ | 'wcag21aa'
+ | 'wcag21aaa'
+ | 'wcag22aa'
+
+/**
+ * Rule set presets
+ */
+export type RuleSetPreset =
+ | 'wcag2a'
+ | 'wcag2aa'
+ | 'wcag21aa'
+ | 'wcag22aa'
+ | 'section508'
+ | 'best-practice'
+ | 'all'
+
+/**
+ * Rule categories (axe-core tags)
+ */
+export type RuleCategory =
+ | 'all'
+ | 'cat.aria'
+ | 'cat.color'
+ | 'cat.forms'
+ | 'cat.keyboard'
+ | 'cat.language'
+ | 'cat.name-role-value'
+ | 'cat.parsing'
+ | 'cat.semantics'
+ | 'cat.sensory-and-visual-cues'
+ | 'cat.structure'
+ | 'cat.tables'
+ | 'cat.text-alternatives'
+ | 'cat.time-and-media'
+
+/**
+ * Rule metadata for settings UI
+ */
+export interface RuleInfo {
+ id: string
+ description: string
+ tags: Array
+}
+
+/**
+ * Represents a single node affected by an accessibility issue
+ */
+export interface A11yNode {
+ /** CSS selector for the element */
+ selector: string
+ /** HTML snippet of the element */
+ html: string
+ /** XPath to the element (optional) */
+ xpath?: string
+ /** Failure summary for this specific node */
+ failureSummary?: string
+}
+
+/**
+ * Represents a single accessibility issue
+ */
+export interface A11yIssue {
+ /** Unique identifier for this issue instance */
+ id: string
+ /** The axe-core rule ID */
+ ruleId: string
+ /** Impact severity level */
+ impact: SeverityThreshold
+ /** Human-readable description of the issue */
+ message: string
+ /** Detailed help text */
+ help: string
+ /** URL to learn more about this issue */
+ helpUrl: string
+ /** WCAG tags associated with this rule */
+ wcagTags: Array
+ /** DOM nodes affected by this issue */
+ nodes: Array
+ /** Whether this issue meets the current severity threshold */
+ meetsThreshold: boolean
+ /** Timestamp when this issue was detected */
+ timestamp: number
+}
+
+/**
+ * Grouped issues by impact level
+ */
+export interface GroupedIssues {
+ critical: Array
+ serious: Array
+ moderate: Array
+ minor: Array
+}
+
+/**
+ * Summary statistics for an audit
+ */
+export interface A11ySummary {
+ total: number
+ critical: number
+ serious: number
+ moderate: number
+ minor: number
+ passes: number
+ incomplete: number
+}
+
+/**
+ * Result of an accessibility audit
+ */
+export interface A11yAuditResult {
+ /** All issues found */
+ issues: Array
+ /** Summary statistics */
+ summary: A11ySummary
+ /** Timestamp when the audit was run */
+ timestamp: number
+ /** URL of the page audited */
+ url: string
+ /** Description of the context (document, selector, or element) */
+ context: string
+ /** Time taken to run the audit in ms */
+ duration: number
+}
+
+/**
+ * Configuration for custom rules
+ */
+export interface CustomRulesConfig {
+ /** Enable click-handler-on-non-interactive rule (default: true) */
+ clickHandlerOnNonInteractive?: boolean
+ /** Enable mouse-only-event-handlers rule (default: true) */
+ mouseOnlyEventHandlers?: boolean
+ /** Enable static-element-interaction rule (default: true) */
+ staticElementInteraction?: boolean
+}
+
+/**
+ * Options for running an audit
+ */
+export interface A11yAuditOptions {
+ /** Minimum severity to report (default: 'serious') */
+ threshold?: SeverityThreshold
+ /** DOM context to audit (default: document) */
+ context?: Document | Element | string
+ /** Rule set preset to use (default: 'wcag21aa') */
+ ruleSet?: RuleSetPreset
+ /** Specific rules to enable (overrides ruleSet) */
+ enabledRules?: Array
+ /** Specific rules to disable */
+ disabledRules?: Array
+ /** Selectors to exclude from auditing */
+ exclude?: Array
+ /** Configuration for custom rules (default: all enabled) */
+ customRules?: CustomRulesConfig
+}
+
+/**
+ * Options for the A11y plugin
+ */
+export interface A11yPluginOptions {
+ /** Minimum severity threshold (default: 'serious') */
+ threshold?: SeverityThreshold
+ /** Run audit automatically on mount (default: false) */
+ runOnMount?: boolean
+ /** Rule set preset (default: 'wcag21aa') */
+ ruleSet?: RuleSetPreset
+ /** Show visual overlays on page (default: true) */
+ showOverlays?: boolean
+ /** Persist settings to localStorage (default: true) */
+ persistSettings?: boolean
+ /** Rules to disable (by rule ID) */
+ disabledRules?: Array
+}
+
+/**
+ * State of the A11y plugin
+ */
+export interface A11yPluginState {
+ /** Whether an audit is currently running */
+ isScanning: boolean
+ /** Latest audit results */
+ results: A11yAuditResult | null
+ /** Previous audit results (for diff detection) */
+ previousResults: A11yAuditResult | null
+ /** Current configuration */
+ config: Required
+ /** Currently selected issue ID */
+ selectedIssueId: string | null
+ /** Whether overlays are visible */
+ overlaysVisible: boolean
+ /** Error message if any */
+ error: string | null
+}
+
+/**
+ * Plugin ID constant
+ */
+export const A11Y_PLUGIN_ID = 'a11y' as const
+
+/**
+ * Event payloads for the event client.
+ * Keys must follow the pattern `{pluginId}:{eventSuffix}`
+ */
+export interface A11yEventMap {
+ /** Emitted when audit results are available */
+ 'a11y:results': A11yAuditResult
+ /** Emitted when an audit starts */
+ 'a11y:scan-start': { context: string }
+ /** Emitted when an audit completes */
+ 'a11y:scan-complete': { duration: number; issueCount: number }
+ /** Emitted when an audit fails */
+ 'a11y:scan-error': { error: string }
+ /** Request to highlight an element */
+ 'a11y:highlight': { selector: string; impact: SeverityThreshold }
+ /** Request to clear all highlights */
+ 'a11y:clear-highlights': Record
+ /** Request to highlight all issues */
+ 'a11y:highlight-all': { issues: Array }
+ /** Configuration changed */
+ 'a11y:config-change': Partial
+}
+
+/**
+ * Export format options
+ */
+export type ExportFormat = 'json' | 'csv'
+
+/**
+ * Export options
+ */
+export interface ExportOptions {
+ /** Export format */
+ format: ExportFormat
+ /** Include passing rules in export */
+ includePasses?: boolean
+ /** Include incomplete rules in export */
+ includeIncomplete?: boolean
+ /** Custom filename (without extension) */
+ filename?: string
+}
diff --git a/packages/devtools-a11y/src/ui/A11yDevtoolsPanel.tsx b/packages/devtools-a11y/src/ui/A11yDevtoolsPanel.tsx
new file mode 100644
index 00000000..cbd194bd
--- /dev/null
+++ b/packages/devtools-a11y/src/ui/A11yDevtoolsPanel.tsx
@@ -0,0 +1,461 @@
+/** @jsxImportSource solid-js */
+
+import {
+ Match,
+ Show,
+ Switch,
+ createEffect,
+ createMemo,
+ createSignal,
+ onCleanup,
+ onMount,
+} from 'solid-js'
+import { createStore, produce } from 'solid-js/store'
+import { a11yEventClient } from '../event-client'
+import { getAvailableRules, groupIssuesByImpact } from '../scanner'
+import { clearHighlights, highlightElement } from '../overlay'
+import { getA11yRuntime } from '../runtime'
+import {
+ RULE_SET_LABELS,
+ SEVERITY_LABELS,
+ createA11yPanelStyles,
+} from './styles'
+import { A11yIssueList } from './A11yIssueList'
+import { A11ySettingsOverlay } from './A11ySettingsOverlay'
+import { filterIssuesAboveThreshold } from './panelUtils'
+import type {
+ A11yAuditResult,
+ A11yPluginOptions,
+ RuleCategory,
+ RuleInfo,
+ SeverityThreshold,
+} from '../types'
+
+interface A11yDevtoolsPanelProps {
+ options?: A11yPluginOptions
+ /** Theme passed from TanStack Devtools */
+ theme?: 'light' | 'dark'
+}
+
+function scrollToElement(selector: string): boolean {
+ try {
+ const element = document.querySelector(selector)
+ if (element) {
+ element.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ inline: 'nearest',
+ })
+ return true
+ }
+ } catch (error) {
+ console.warn('[A11y Panel] Could not scroll to element:', selector, error)
+ }
+ return false
+}
+
+export function A11yDevtoolsPanel(props: A11yDevtoolsPanelProps) {
+ const theme = () => props.theme ?? 'light'
+ const styles = createMemo(() => createA11yPanelStyles(theme()))
+
+ const runtime = getA11yRuntime(props.options ?? {})
+
+ const [config, setConfig] = createSignal(runtime.getConfig())
+ const [results, setResults] = createSignal(
+ runtime.getResults(),
+ )
+
+ const [uiState, setUiState] = createStore({
+ isScanning: false,
+ selectedIssueId: null as string | null,
+ selectedSeverity: 'all' as 'all' | SeverityThreshold,
+ showSettings: false,
+ toast: null as null | {
+ message: string
+ color: string
+ timestamp: number
+ },
+ availableRules: [] as Array,
+ ruleSearchQuery: '',
+ selectedCategory: 'all' as RuleCategory,
+ })
+
+ let toastTimeoutId: number | null = null
+
+ // Keep runtime config in sync (including changes from programmatic API)
+ onMount(() => {
+ const cleanupResults = a11yEventClient.on('results', (event) => {
+ setResults(event.payload)
+ })
+
+ const cleanupConfig = a11yEventClient.on('config-change', (event) => {
+ const patch = event.payload
+ setConfig((prev) => ({
+ ...prev,
+ ...patch,
+ disabledRules: patch.disabledRules ?? prev.disabledRules,
+ }))
+ })
+
+ onCleanup(() => {
+ cleanupResults()
+ cleanupConfig()
+ })
+ })
+
+ // Auto-dismiss toast
+ createEffect(() => {
+ const t = uiState.toast
+ if (!t) return
+
+ if (toastTimeoutId != null) {
+ window.clearTimeout(toastTimeoutId)
+ }
+
+ toastTimeoutId = window.setTimeout(() => {
+ setUiState('toast', null)
+ toastTimeoutId = null
+ }, 3000)
+
+ onCleanup(() => {
+ if (toastTimeoutId != null) {
+ window.clearTimeout(toastTimeoutId)
+ toastTimeoutId = null
+ }
+ })
+ })
+
+ // Run on mount if configured
+ onMount(() => {
+ if (config().runOnMount) {
+ void actions.scan()
+ }
+ })
+
+ onCleanup(() => {
+ if (toastTimeoutId != null) {
+ window.clearTimeout(toastTimeoutId)
+ toastTimeoutId = null
+ }
+ })
+
+ const updateConfig = (patch: Partial) => {
+ setConfig((prev) => ({
+ ...prev,
+ ...patch,
+ disabledRules: patch.disabledRules ?? prev.disabledRules,
+ }))
+ runtime.setConfig(patch)
+ }
+
+ const actions = {
+ scan: async () => {
+ setUiState('isScanning', true)
+ try {
+ await runtime.scan()
+ } catch (error) {
+ console.error('[A11y Panel] Scan failed:', error)
+ } finally {
+ setUiState('isScanning', false)
+ }
+ },
+ openSettings: () => {
+ setUiState(
+ produce((state) => {
+ state.availableRules = getAvailableRules()
+ state.showSettings = true
+ }),
+ )
+ },
+ closeSettings: () => {
+ setUiState(
+ produce((state) => {
+ state.showSettings = false
+ state.ruleSearchQuery = ''
+ }),
+ )
+ },
+ toggleRule: (ruleId: string) => {
+ const isDisabled = config().disabledRules.includes(ruleId)
+ const nextDisabledRules = isDisabled
+ ? config().disabledRules.filter((id) => id !== ruleId)
+ : [...config().disabledRules, ruleId]
+ updateConfig({ disabledRules: nextDisabledRules })
+ },
+ disableRule: (ruleId: string) => {
+ if (config().disabledRules.includes(ruleId)) return
+ updateConfig({ disabledRules: [...config().disabledRules, ruleId] })
+ },
+ enableAllRules: () => {
+ updateConfig({ disabledRules: [] })
+ },
+ disableAllRules: () => {
+ const allRuleIds = uiState.availableRules.map((rule) => rule.id)
+ updateConfig({ disabledRules: allRuleIds })
+ },
+ }
+
+ const handleExport = (format: 'json' | 'csv') => {
+ // Keep export logic in runtime via event -> overlay? export is still a direct helper.
+ // We keep this import local to avoid pulling export code into the runtime module.
+ const r = results()
+ if (!r) return
+ void import('../export').then((m) => m.exportAuditResults(r, { format }))
+ }
+
+ const handleIssueClick = (issueId: string) => {
+ const r = results()
+
+ if (uiState.selectedIssueId === issueId) {
+ setUiState('selectedIssueId', null)
+ clearHighlights()
+
+ if (config().showOverlays && r) {
+ const issuesAboveThreshold = filteredIssues()
+ if (issuesAboveThreshold.length > 0) {
+ a11yEventClient.emit('highlight-all', {
+ issues: issuesAboveThreshold,
+ })
+ }
+ }
+ return
+ }
+
+ setUiState('selectedIssueId', issueId)
+ clearHighlights()
+
+ const issue = r?.issues.find((i) => i.id === issueId)
+ if (!issue || issue.nodes.length === 0) return
+
+ let scrolled = false
+ for (const node of issue.nodes) {
+ const selector = node.selector
+ if (!selector) continue
+
+ try {
+ const el = document.querySelector(selector)
+ if (el) {
+ if (!scrolled) {
+ scrollToElement(selector)
+ scrolled = true
+ }
+
+ highlightElement(selector, issue.impact, {
+ showTooltip: true,
+ ruleId: issue.ruleId,
+ })
+ }
+ } catch (error) {
+ console.warn('[A11y Panel] Invalid selector:', selector, error)
+ }
+ }
+
+ // No need to emit the highlight event here since we're applying highlights directly.
+ }
+
+ const filteredIssues = createMemo(() => {
+ const r = results()
+ if (!r) return []
+
+ return filterIssuesAboveThreshold(
+ r.issues,
+ config().threshold,
+ config().disabledRules,
+ )
+ })
+
+ const grouped = createMemo(() => groupIssuesByImpact(filteredIssues()))
+
+ const visibleIssues = createMemo(() => {
+ const issues = filteredIssues()
+ const severity = uiState.selectedSeverity
+ if (severity === 'all') return issues
+ return issues.filter((issue) => issue.impact === severity)
+ })
+
+ const visibleGrouped = createMemo(() => groupIssuesByImpact(visibleIssues()))
+
+ createEffect(() => {
+ const r = results()
+ if (!r || !config().showOverlays) {
+ a11yEventClient.emit('clear-highlights', {})
+ return
+ }
+
+ if (uiState.selectedIssueId) {
+ return
+ }
+
+ const severity = uiState.selectedSeverity
+ const issuesAboveThreshold = filteredIssues()
+
+ const issues =
+ severity === 'all'
+ ? issuesAboveThreshold
+ : issuesAboveThreshold.filter((issue) => issue.impact === severity)
+
+ if (issues.length === 0) {
+ a11yEventClient.emit('clear-highlights', {})
+ return
+ }
+
+ a11yEventClient.emit('highlight-all', { issues })
+ })
+
+ const filteredRules = createMemo(() => {
+ const cat = uiState.selectedCategory
+ const query = uiState.ruleSearchQuery.toLowerCase()
+ return uiState.availableRules.filter((rule) => {
+ if (cat !== 'all' && !rule.tags.includes(cat)) {
+ return false
+ }
+
+ if (!query) return true
+ return (
+ rule.id.toLowerCase().includes(query) ||
+ rule.description.toLowerCase().includes(query)
+ )
+ })
+ })
+
+ return (
+
+
+ {(t) => (
+
+
+ {t().message}
+
+ )}
+
+
+
+
+
+
+ {SEVERITY_LABELS[config().threshold]}+ |{' '}
+ {RULE_SET_LABELS[config().ruleSet]}
+ 0}>
+ ` | ${config().disabledRules.length} rule(s) disabled`
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
No audit results yet
+
+ Click "Run Audit" to scan for accessibility issues
+
+
+
+
+
+
+
+ No accessibility issues found!
+
+
+ Scanned in {results()!.duration.toFixed(0)}ms
+
+
+
+
+ 0}>
+
+ setUiState('selectedSeverity', severity)
+ }
+ onIssueClick={handleIssueClick}
+ onDisableRule={actions.disableRule}
+ />
+
+
+
+
+
+ updateConfig({ threshold })}
+ onRuleSetChange={(ruleSet) => updateConfig({ ruleSet })}
+ onSelectCategory={(category) =>
+ setUiState('selectedCategory', category)
+ }
+ onSearchQueryChange={(value) => setUiState('ruleSearchQuery', value)}
+ onToggleRule={actions.toggleRule}
+ onEnableAllRules={actions.enableAllRules}
+ onDisableAllRules={actions.disableAllRules}
+ />
+
+
+ )
+}
diff --git a/packages/devtools-a11y/src/ui/A11yIssueCard.tsx b/packages/devtools-a11y/src/ui/A11yIssueCard.tsx
new file mode 100644
index 00000000..a15c5193
--- /dev/null
+++ b/packages/devtools-a11y/src/ui/A11yIssueCard.tsx
@@ -0,0 +1,70 @@
+/** @jsxImportSource solid-js */
+
+import { For, Show } from 'solid-js'
+import type { createA11yPanelStyles } from './styles'
+import type { A11yIssue, SeverityThreshold } from '../types'
+
+type PanelStyles = ReturnType
+
+interface A11yIssueCardProps {
+ styles: PanelStyles
+ issue: A11yIssue
+ impact: SeverityThreshold
+ selected: boolean
+ onSelect: () => void
+ onDisableRule: (ruleId: string) => void
+}
+
+export function A11yIssueCard(props: A11yIssueCardProps) {
+ const selector = () => props.issue.nodes[0]?.selector || 'unknown'
+
+ return (
+
+
+
+
+
+ {props.issue.ruleId}
+
+
{props.issue.message}
+
{selector()}
+
+
+
+
+
+
0}>
+
+
+ {(tag) => {tag} }
+
+
+
+
+ )
+}
diff --git a/packages/devtools-a11y/src/ui/A11yIssueList.tsx b/packages/devtools-a11y/src/ui/A11yIssueList.tsx
new file mode 100644
index 00000000..75a336a9
--- /dev/null
+++ b/packages/devtools-a11y/src/ui/A11yIssueList.tsx
@@ -0,0 +1,89 @@
+/** @jsxImportSource solid-js */
+
+import { For, Show } from 'solid-js'
+import { IMPACTS } from './panelUtils'
+import { SEVERITY_LABELS } from './styles'
+import type { createA11yPanelStyles } from './styles'
+import { A11yIssueCard } from './A11yIssueCard'
+import type { GroupedIssues, SeverityThreshold } from '../types'
+
+type PanelStyles = ReturnType
+
+interface A11yIssueListProps {
+ styles: PanelStyles
+ grouped: GroupedIssues
+ visibleGrouped: GroupedIssues
+ selectedSeverity: 'all' | SeverityThreshold
+ selectedIssueId: string | null
+ onSelectSeverity: (severity: 'all' | SeverityThreshold) => void
+ onIssueClick: (issueId: string) => void
+ onDisableRule: (ruleId: string) => void
+}
+
+export function A11yIssueList(props: A11yIssueListProps) {
+ return (
+
+
+
+ {(impact) => {
+ const count = () => props.grouped[impact].length
+ const active = () => props.selectedSeverity === impact
+ return (
+
+ props.onSelectSeverity(
+ props.selectedSeverity === impact ? 'all' : impact,
+ )
+ }
+ >
+ {count()}
+
+ {SEVERITY_LABELS[impact]}
+
+
+ )
+ }}
+
+
+
+
+ {(impact) => {
+ const issues = () => props.visibleGrouped[impact]
+ const shouldRender = () => {
+ if (props.selectedSeverity !== 'all') {
+ return props.selectedSeverity === impact
+ }
+ return issues().length > 0
+ }
+
+ return (
+
+
+
+ {SEVERITY_LABELS[impact]} ({issues().length})
+
+
+
+ {(issue) => (
+ props.onIssueClick(issue.id)}
+ onDisableRule={props.onDisableRule}
+ />
+ )}
+
+
+
+ )
+ }}
+
+
+ )
+}
diff --git a/packages/devtools-a11y/src/ui/A11ySettingsOverlay.tsx b/packages/devtools-a11y/src/ui/A11ySettingsOverlay.tsx
new file mode 100644
index 00000000..2b70a252
--- /dev/null
+++ b/packages/devtools-a11y/src/ui/A11ySettingsOverlay.tsx
@@ -0,0 +1,219 @@
+/** @jsxImportSource solid-js */
+
+import { For, Show, createMemo } from 'solid-js'
+import { CATEGORIES, CATEGORY_LABELS } from './styles'
+import type { createA11yPanelStyles } from './styles'
+import type {
+ A11yPluginOptions,
+ RuleCategory,
+ RuleInfo,
+ RuleSetPreset,
+ SeverityThreshold,
+} from '../types'
+
+type PanelStyles = ReturnType
+
+interface A11ySettingsOverlayProps {
+ styles: PanelStyles
+ config: Required
+ availableRules: Array
+ filteredRules: Array
+ ruleSearchQuery: string
+ selectedCategory: RuleCategory
+ onClose: () => void
+ onThresholdChange: (threshold: SeverityThreshold) => void
+ onRuleSetChange: (ruleSet: RuleSetPreset) => void
+ onSelectCategory: (category: RuleCategory) => void
+ onSearchQueryChange: (value: string) => void
+ onToggleRule: (ruleId: string) => void
+ onEnableAllRules: () => void
+ onDisableAllRules: () => void
+}
+
+export function A11ySettingsOverlay(props: A11ySettingsOverlayProps) {
+ const disabledRulesSet = createMemo(() => new Set(props.config.disabledRules))
+
+ return (
+
+
+
+
+
+
General
+
+
+
+
+ Severity Threshold
+
+
+ Only show issues at or above this level
+
+
+
+ props.onThresholdChange(
+ event.currentTarget.value as SeverityThreshold,
+ )
+ }
+ >
+ Critical
+ Serious
+ Moderate
+ Minor
+
+
+
+
+
+
Rule Set
+
+ WCAG conformance level or standard
+
+
+
+ props.onRuleSetChange(
+ event.currentTarget.value as RuleSetPreset,
+ )
+ }
+ >
+ WCAG 2.0 A
+ WCAG 2.0 AA
+ WCAG 2.1 AA
+ WCAG 2.2 AA
+ Section 508
+ Best Practice
+ All Rules
+
+
+
+
+
+
+
+
+
+ props.onSelectCategory(
+ event.currentTarget.value as RuleCategory,
+ )
+ }
+ >
+
+ {(cat) => {CATEGORY_LABELS[cat]} }
+
+
+
+
+ props.onSearchQueryChange(event.currentTarget.value)
+ }
+ />
+
+
+
+
+ {(rule, idx) => {
+ const isDisabled = () => disabledRulesSet().has(rule.id)
+ const isBestPracticeOnly = () =>
+ rule.tags.includes('best-practice') &&
+ !rule.tags.some(
+ (tag) =>
+ tag.startsWith('wcag') || tag.startsWith('section508'),
+ )
+ const categoryTag = () =>
+ rule.tags.find((tag) => tag.startsWith('cat.'))
+ const hasBorder = () => idx() < props.filteredRules.length - 1
+
+ return (
+
+ props.onToggleRule(rule.id)}
+ />
+
+
+
+ {rule.id}
+
+
+
+ BP
+
+
+
+
+ {rule.description}
+
+
+ {(tag) => (
+
+
+ {CATEGORY_LABELS[tag() as RuleCategory] ||
+ tag().replace('cat.', '')}
+
+
+ )}
+
+
+
+ )
+ }}
+
+
+
+
+
+ )
+}
diff --git a/packages/devtools-a11y/src/ui/panelUtils.ts b/packages/devtools-a11y/src/ui/panelUtils.ts
new file mode 100644
index 00000000..ff6da6e5
--- /dev/null
+++ b/packages/devtools-a11y/src/ui/panelUtils.ts
@@ -0,0 +1,21 @@
+import type { A11yAuditResult, SeverityThreshold } from '../types'
+
+export const IMPACTS = ['critical', 'serious', 'moderate', 'minor'] as const
+
+export const SEVERITY_ORDER: Record = {
+ critical: 4,
+ serious: 3,
+ moderate: 2,
+ minor: 1,
+}
+
+export const filterIssuesAboveThreshold = (
+ issues: A11yAuditResult['issues'],
+ threshold: SeverityThreshold,
+ disabledRules: Array,
+) =>
+ issues
+ .filter((issue) => !disabledRules.includes(issue.ruleId))
+ .filter(
+ (issue) => SEVERITY_ORDER[issue.impact] >= SEVERITY_ORDER[threshold],
+ )
diff --git a/packages/devtools-a11y/src/ui/styles.ts b/packages/devtools-a11y/src/ui/styles.ts
new file mode 100644
index 00000000..a6ac2803
--- /dev/null
+++ b/packages/devtools-a11y/src/ui/styles.ts
@@ -0,0 +1,584 @@
+import * as goober from 'goober'
+import type { RuleCategory, RuleSetPreset, SeverityThreshold } from '../types'
+
+const SEVERITY_COLORS: Record = {
+ critical: '#dc2626',
+ serious: '#ea580c',
+ moderate: '#ca8a04',
+ minor: '#2563eb',
+}
+
+export const SEVERITY_LABELS: Record = {
+ critical: 'Critical',
+ serious: 'Serious',
+ moderate: 'Moderate',
+ minor: 'Minor',
+}
+
+export const RULE_SET_LABELS: Record = {
+ wcag2a: 'WCAG 2.0 A',
+ wcag2aa: 'WCAG 2.0 AA',
+ wcag21aa: 'WCAG 2.1 AA',
+ wcag22aa: 'WCAG 2.2 AA',
+ section508: 'Section 508',
+ 'best-practice': 'Best Practice',
+ all: 'All Rules',
+}
+
+export const CATEGORY_LABELS: Record = {
+ all: 'All Categories',
+ 'cat.aria': 'ARIA',
+ 'cat.color': 'Color & Contrast',
+ 'cat.forms': 'Forms',
+ 'cat.keyboard': 'Keyboard',
+ 'cat.language': 'Language',
+ 'cat.name-role-value': 'Names & Roles',
+ 'cat.parsing': 'Parsing',
+ 'cat.semantics': 'Semantics',
+ 'cat.sensory-and-visual-cues': 'Sensory Cues',
+ 'cat.structure': 'Structure',
+ 'cat.tables': 'Tables',
+ 'cat.text-alternatives': 'Text Alternatives',
+ 'cat.time-and-media': 'Time & Media',
+}
+
+export const CATEGORIES: Array = [
+ 'all',
+ 'cat.aria',
+ 'cat.color',
+ 'cat.forms',
+ 'cat.keyboard',
+ 'cat.language',
+ 'cat.name-role-value',
+ 'cat.parsing',
+ 'cat.semantics',
+ 'cat.sensory-and-visual-cues',
+ 'cat.structure',
+ 'cat.tables',
+ 'cat.text-alternatives',
+ 'cat.time-and-media',
+]
+
+const css = goober.css
+
+export function createA11yPanelStyles(theme: 'light' | 'dark') {
+ const t = (light: string, dark: string) => (theme === 'light' ? light : dark)
+
+ const bg = t('#ffffff', '#1a1a2e')
+ const fg = t('#1e293b', '#e2e8f0')
+ const border = t('#e2e8f0', '#374151')
+ const secondaryBg = t('#f8fafc', '#0f172a')
+ const muted = t('#64748b', '#94a3b8')
+ const muted2 = t('#94a3b8', '#64748b')
+
+ return {
+ colors: { bg, fg, border, secondaryBg, muted, muted2, theme },
+
+ root: css`
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ color: ${fg};
+ background: ${bg};
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+ `,
+
+ toast: css`
+ position: absolute;
+ position-anchor: --a11y-toast-anchor;
+ position-area: top center;
+ padding: 8px 12px;
+ border-radius: 999px;
+ background: ${t('#ffffff', '#0b1220')};
+ border: 1px solid ${border};
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12);
+ color: ${fg};
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ z-index: 20;
+ `,
+ toastDot: (color: string) => css`
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: ${color};
+ flex-shrink: 0;
+ `,
+
+ header: css`
+ padding: 16px;
+ border-bottom: 1px solid ${border};
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-shrink: 0;
+ anchor-name: --a11y-toast-anchor;
+ `,
+ headerTitleRow: css`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+ `,
+ headerTitle: css`
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ `,
+ headerSub: css`
+ font-size: 12px;
+ color: ${muted};
+ white-space: nowrap;
+ `,
+ headerActions: css`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ `,
+ primaryButton: css`
+ padding: 8px 16px;
+ background: #0ea5e9;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+ font-size: 13px;
+ opacity: 1;
+ `,
+ primaryButtonDisabled: css`
+ cursor: not-allowed;
+ opacity: 0.7;
+ `,
+ button: css`
+ padding: 8px 12px;
+ background: ${secondaryBg};
+ color: ${fg};
+ border: 1px solid ${border};
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ `,
+ buttonRow: css`
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ `,
+ toggleOverlay: css`
+ padding: 8px 12px;
+ background: ${secondaryBg};
+ color: ${fg};
+ border: 1px solid ${border};
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ `,
+ toggleOverlayOn: css`
+ background: #10b981;
+ color: #fff;
+ border-color: #10b981;
+ `,
+
+ statusBar: css`
+ padding: 8px 16px;
+ background: ${secondaryBg};
+ border-bottom: 1px solid ${border};
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-shrink: 0;
+ font-size: 11px;
+ color: ${muted};
+ `,
+ statusSpacer: css`
+ flex: 1;
+ `,
+ pill: (active: boolean) => css`
+ padding: 4px 10px;
+ background: ${active ? '#10b981' : 'transparent'};
+ color: ${active ? '#fff' : '#0ea5e9'};
+ border: 1px solid ${active ? '#10b981' : border};
+ border-radius: 999px;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ `,
+ smallLinkButton: css`
+ padding: 4px 10px;
+ background: transparent;
+ color: #0ea5e9;
+ border: 1px solid ${border};
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 500;
+ `,
+
+ content: css`
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ `,
+ emptyState: css`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ text-align: center;
+ color: ${muted};
+ `,
+ emptyPrimary: css`
+ font-size: 14px;
+ margin: 0 0 8px 0;
+ `,
+ emptySecondary: css`
+ font-size: 12px;
+ margin: 0;
+ `,
+ successState: css`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ text-align: center;
+ `,
+ successTitle: css`
+ font-size: 16px;
+ color: #10b981;
+ font-weight: 600;
+ margin: 0;
+ `,
+ successSub: css`
+ font-size: 12px;
+ color: ${muted};
+ margin-top: 8px;
+ margin-bottom: 0;
+ `,
+
+ summaryGrid: css`
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+ margin-bottom: 20px;
+
+ @media (max-width: 520px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ `,
+ summaryButton: css`
+ padding: 12px;
+ background: ${secondaryBg};
+ color: ${fg};
+ border-radius: 8px;
+ border: 1px solid ${border};
+ text-align: left;
+ cursor: pointer;
+ box-shadow: none;
+ `,
+ summaryButtonActive: (impact: SeverityThreshold) => css`
+ box-shadow: 0 0 0 2px ${SEVERITY_COLORS[impact]};
+ `,
+ summaryCount: (impact: SeverityThreshold) => css`
+ font-size: 24px;
+ font-weight: 700;
+ color: ${SEVERITY_COLORS[impact]};
+ `,
+ summaryLabel: css`
+ font-size: 11px;
+ color: ${muted};
+ text-transform: uppercase;
+ `,
+
+ section: css`
+ margin-bottom: 16px;
+ `,
+ sectionTitle: (impact: SeverityThreshold) => css`
+ color: ${SEVERITY_COLORS[impact]};
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0 0 8px 0;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ `,
+
+ issueCard: css`
+ padding: 12px;
+ margin-bottom: 8px;
+ background: ${secondaryBg};
+ border: 1px solid ${border};
+ border-radius: 6px;
+ cursor: pointer;
+ `,
+ issueCardSelected: css`
+ background: ${t('#e0f2fe', '#1e3a5f')};
+ border-color: #0ea5e9;
+ `,
+ issueRow: css`
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ `,
+ issueMain: css`
+ flex: 1;
+ min-width: 0;
+ `,
+ issueTitleRow: css`
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 4px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ `,
+ dot: (impact: SeverityThreshold) => css`
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: ${SEVERITY_COLORS[impact]};
+ flex-shrink: 0;
+ `,
+ issueMessage: css`
+ font-size: 12px;
+ color: ${t('#475569', '#cbd5e1')};
+ margin: 0 0 8px 0;
+ line-height: 1.4;
+ `,
+ selector: css`
+ font-size: 10px;
+ color: ${muted2};
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ 'Liberation Mono', 'Courier New', monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ `,
+ issueAside: css`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+ `,
+ helpLink: css`
+ font-size: 11px;
+ color: #0ea5e9;
+ text-decoration: none;
+ `,
+ disableRule: css`
+ font-size: 10px;
+ color: ${muted};
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ text-decoration: underline;
+ `,
+ tags: css`
+ display: flex;
+ gap: 4px;
+ margin-top: 8px;
+ flex-wrap: wrap;
+ `,
+ tag: css`
+ font-size: 10px;
+ padding: 2px 6px;
+ background: ${t('#e2e8f0', '#374151')};
+ border-radius: 4px;
+ color: ${muted};
+ `,
+
+ settingsOverlay: css`
+ position: absolute;
+ inset: 0;
+ background: ${bg};
+ display: flex;
+ flex-direction: column;
+ z-index: 10;
+ `,
+ settingsHeader: css`
+ padding: 12px 16px;
+ border-bottom: 1px solid ${border};
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-shrink: 0;
+ `,
+ settingsTitle: css`
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ `,
+ doneButton: css`
+ padding: 6px 12px;
+ background: #0ea5e9;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ `,
+ settingsContent: css`
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ `,
+ settingsSection: css`
+ margin-bottom: 24px;
+ `,
+ settingsSectionLabel: css`
+ margin: 0 0 12px 0;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: ${muted};
+ `,
+ settingsRow: css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0;
+ border-bottom: 1px solid ${border};
+ gap: 12px;
+ `,
+ settingsRowTitle: css`
+ font-size: 13px;
+ font-weight: 500;
+ `,
+ settingsRowDesc: css`
+ font-size: 11px;
+ color: ${muted};
+ margin-top: 2px;
+ `,
+ select: css`
+ padding: 6px 10px;
+ border: 1px solid ${border};
+ border-radius: 4px;
+ background: ${bg};
+ color: ${fg};
+ font-size: 12px;
+ `,
+ rulesHeaderRow: css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ gap: 12px;
+ flex-wrap: wrap;
+ `,
+ rulesHeaderActions: css`
+ display: flex;
+ gap: 6px;
+ `,
+ smallAction: (variant: 'success' | 'danger') => css`
+ padding: 4px 8px;
+ background: ${variant === 'success' ? '#10b981' : '#ef4444'};
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 10px;
+ font-weight: 500;
+ `,
+ filtersRow: css`
+ display: flex;
+ gap: 8px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+ `,
+ search: css`
+ flex: 1;
+ min-width: 180px;
+ padding: 8px 10px;
+ border: 1px solid ${border};
+ border-radius: 4px;
+ background: ${bg};
+ color: ${fg};
+ font-size: 12px;
+ box-sizing: border-box;
+ `,
+ rulesList: css`
+ border: 1px solid ${border};
+ border-radius: 6px;
+ max-height: 300px;
+ overflow-y: auto;
+ `,
+ ruleRow: css`
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 10px;
+ cursor: pointer;
+ opacity: 1;
+ background: transparent;
+ `,
+ ruleRowDisabled: css`
+ opacity: 0.6;
+ background: ${secondaryBg};
+ `,
+ ruleRowBorder: css`
+ border-bottom: 1px solid ${border};
+ `,
+ ruleCheckbox: css`
+ margin-top: 2px;
+ flex-shrink: 0;
+ `,
+ ruleInfo: css`
+ flex: 1;
+ min-width: 0;
+ `,
+ ruleTop: css`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 2px;
+ `,
+ ruleId: css`
+ font-weight: 500;
+ font-size: 12px;
+ text-decoration: none;
+ `,
+ ruleIdDisabled: css`
+ text-decoration: line-through;
+ `,
+ bpBadge: css`
+ font-size: 9px;
+ padding: 1px 4px;
+ background: #f59e0b;
+ color: #fff;
+ border-radius: 3px;
+ font-weight: 500;
+ `,
+ ruleDesc: css`
+ font-size: 11px;
+ color: ${muted};
+ line-height: 1.3;
+ `,
+ catTagRow: css`
+ display: flex;
+ gap: 4px;
+ margin-top: 4px;
+ `,
+ catTag: css`
+ font-size: 9px;
+ padding: 1px 4px;
+ background: ${t('#e2e8f0', '#374151')};
+ border-radius: 3px;
+ color: ${muted};
+ `,
+ }
+}
diff --git a/packages/devtools-a11y/tests/config.test.ts b/packages/devtools-a11y/tests/config.test.ts
new file mode 100644
index 00000000..adfbc8e8
--- /dev/null
+++ b/packages/devtools-a11y/tests/config.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ DEFAULT_CONFIG,
+ clearConfig,
+ loadConfig,
+ mergeConfig,
+ saveConfig,
+} from '../src'
+
+describe('config', () => {
+ // Mock localStorage
+ const localStorageMock = (() => {
+ let store: Record = {}
+ return {
+ getItem: vi.fn((key: string) => store[key] || null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key]
+ }),
+ clear: vi.fn(() => {
+ store = {}
+ }),
+ }
+ })()
+
+ beforeEach(() => {
+ vi.stubGlobal('localStorage', localStorageMock)
+ localStorageMock.clear()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('DEFAULT_CONFIG', () => {
+ it('should have expected default values', () => {
+ expect(DEFAULT_CONFIG.threshold).toBe('serious')
+ expect(DEFAULT_CONFIG.runOnMount).toBe(false)
+ expect(DEFAULT_CONFIG.ruleSet).toBe('wcag21aa')
+ expect(DEFAULT_CONFIG.showOverlays).toBe(true)
+ expect(DEFAULT_CONFIG.persistSettings).toBe(true)
+ })
+ })
+
+ describe('loadConfig', () => {
+ it('should return DEFAULT_CONFIG when localStorage is empty', () => {
+ const config = loadConfig()
+ expect(config).toEqual(DEFAULT_CONFIG)
+ })
+
+ it('should merge stored config with defaults', () => {
+ localStorageMock.setItem(
+ 'tanstack-devtools-a11y-config',
+ JSON.stringify({ ruleSet: 'wcag22aa', showOverlays: false }),
+ )
+
+ const config = loadConfig()
+ expect(config.ruleSet).toBe('wcag22aa')
+ expect(config.showOverlays).toBe(false)
+ expect(config.threshold).toBe('serious') // default preserved
+ })
+
+ it('should return DEFAULT_CONFIG when stored JSON is invalid', () => {
+ localStorageMock.setItem(
+ 'tanstack-devtools-a11y-config',
+ 'invalid json{{{',
+ )
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ const config = loadConfig()
+ expect(config).toEqual(DEFAULT_CONFIG)
+ expect(warnSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe('saveConfig', () => {
+ it('should save config to localStorage', () => {
+ saveConfig({ ruleSet: 'section508' })
+
+ expect(localStorageMock.setItem).toHaveBeenCalled()
+ const stored = JSON.parse(
+ localStorageMock.getItem('tanstack-devtools-a11y-config') || '{}',
+ )
+ expect(stored.ruleSet).toBe('section508')
+ })
+
+ it('should merge with existing config', () => {
+ localStorageMock.setItem(
+ 'tanstack-devtools-a11y-config',
+ JSON.stringify({ showOverlays: false }),
+ )
+
+ saveConfig({ ruleSet: 'wcag22aa' })
+
+ const stored = JSON.parse(
+ localStorageMock.getItem('tanstack-devtools-a11y-config') || '{}',
+ )
+ expect(stored.ruleSet).toBe('wcag22aa')
+ expect(stored.showOverlays).toBe(false)
+ })
+ })
+
+ describe('clearConfig', () => {
+ it('should remove config from localStorage', () => {
+ localStorageMock.setItem(
+ 'tanstack-devtools-a11y-config',
+ JSON.stringify({ ruleSet: 'wcag22aa' }),
+ )
+
+ clearConfig()
+
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith(
+ 'tanstack-devtools-a11y-config',
+ )
+ })
+ })
+
+ describe('mergeConfig', () => {
+ it('should merge user options with saved config when persistSettings is true', () => {
+ localStorageMock.setItem(
+ 'tanstack-devtools-a11y-config',
+ JSON.stringify({ showOverlays: false }),
+ )
+
+ const config = mergeConfig({ ruleSet: 'wcag22aa' })
+ expect(config.ruleSet).toBe('wcag22aa')
+ expect(config.showOverlays).toBe(false)
+ })
+
+ it('should ignore saved config when persistSettings is false', () => {
+ localStorageMock.setItem(
+ 'tanstack-devtools-a11y-config',
+ JSON.stringify({ showOverlays: false }),
+ )
+
+ const config = mergeConfig({ persistSettings: false })
+ expect(config.showOverlays).toBe(true) // default, not saved
+ })
+
+ it('should return defaults when no options provided', () => {
+ const config = mergeConfig()
+ expect(config).toEqual(DEFAULT_CONFIG)
+ })
+ })
+})
diff --git a/packages/devtools-a11y/tests/export.test.ts b/packages/devtools-a11y/tests/export.test.ts
new file mode 100644
index 00000000..f1b5a20b
--- /dev/null
+++ b/packages/devtools-a11y/tests/export.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it } from 'vitest'
+import { exportToCsv, exportToJson, generateSummaryReport } from '../src'
+import type { A11yAuditResult } from '../src'
+
+const createMockResult = (): A11yAuditResult => ({
+ issues: [
+ {
+ id: 'issue-1',
+ ruleId: 'image-alt',
+ impact: 'critical',
+ message: 'Images must have alternate text',
+ help: 'Images must have alternate text',
+ helpUrl: 'https://dequeuniversity.com/rules/axe/4.0/image-alt',
+ wcagTags: ['wcag2a', 'wcag111'],
+ nodes: [
+ {
+ selector: 'img.logo',
+ html: ' ',
+ failureSummary: 'Fix this issue by adding an alt attribute',
+ },
+ ],
+ meetsThreshold: true,
+ timestamp: 1704067200000,
+ },
+ {
+ id: 'issue-2',
+ ruleId: 'button-name',
+ impact: 'serious',
+ message: 'Buttons must have discernible text',
+ help: 'Buttons must have discernible text',
+ helpUrl: 'https://dequeuniversity.com/rules/axe/4.0/button-name',
+ wcagTags: ['wcag2a', 'wcag412'],
+ nodes: [
+ {
+ selector: 'button.submit',
+ html: ' ',
+ failureSummary: 'Add text or aria-label to the button',
+ },
+ {
+ selector: 'button.cancel',
+ html: ' ',
+ failureSummary: 'Add text or aria-label to the button',
+ },
+ ],
+ meetsThreshold: true,
+ timestamp: 1704067200000,
+ },
+ ],
+ summary: {
+ total: 2,
+ critical: 1,
+ serious: 1,
+ moderate: 0,
+ minor: 0,
+ passes: 50,
+ incomplete: 3,
+ },
+ timestamp: 1704067200000,
+ url: 'http://localhost:3000/',
+ context: 'document',
+ duration: 123.45,
+})
+
+describe('export', () => {
+ describe('exportToJson', () => {
+ it('should export audit result to JSON format', () => {
+ const result = createMockResult()
+ const json = exportToJson(result)
+
+ const parsed = JSON.parse(json)
+ expect(parsed.meta).toBeDefined()
+ expect(parsed.meta.url).toBe('http://localhost:3000/')
+ expect(parsed.meta.context).toBe('document')
+ expect(parsed.summary).toBeDefined()
+ expect(parsed.summary.total).toBe(2)
+ expect(parsed.issues).toHaveLength(2)
+ })
+
+ it('should include all issue details', () => {
+ const result = createMockResult()
+ const json = exportToJson(result)
+
+ const parsed = JSON.parse(json)
+ const firstIssue = parsed.issues[0]
+
+ expect(firstIssue.ruleId).toBe('image-alt')
+ expect(firstIssue.impact).toBe('critical')
+ expect(firstIssue.helpUrl).toContain('dequeuniversity.com')
+ expect(firstIssue.nodes).toHaveLength(1)
+ })
+
+ it('should include node details', () => {
+ const result = createMockResult()
+ const json = exportToJson(result)
+
+ const parsed = JSON.parse(json)
+ const node = parsed.issues[0].nodes[0]
+
+ expect(node.selector).toBe('img.logo')
+ expect(node.html).toContain(' {
+ it('should export audit result to CSV format', () => {
+ const result = createMockResult()
+ const csv = exportToCsv(result)
+
+ expect(csv).toContain('Rule ID')
+ expect(csv).toContain('Impact')
+ expect(csv).toContain('Message')
+ expect(csv).toContain('Help URL')
+ expect(csv).toContain('WCAG Tags')
+ expect(csv).toContain('Selector')
+ expect(csv).toContain('HTML')
+ })
+
+ it('should include one row per affected node', () => {
+ const result = createMockResult()
+ const csv = exportToCsv(result)
+
+ const lines = csv.split('\n')
+ // Header + 3 nodes (1 from issue 1 + 2 from issue 2)
+ expect(lines).toHaveLength(4)
+ })
+
+ it('should escape quotes in content', () => {
+ const result = createMockResult()
+ const firstIssue = result.issues[0]
+ if (firstIssue) {
+ firstIssue.message = 'Message with "quotes" inside'
+ }
+ const csv = exportToCsv(result)
+
+ expect(csv).toContain('""quotes""')
+ })
+
+ it('should join WCAG tags with semicolons', () => {
+ const result = createMockResult()
+ const csv = exportToCsv(result)
+
+ expect(csv).toContain('wcag2a; wcag111')
+ })
+ })
+
+ describe('generateSummaryReport', () => {
+ it('should generate a human-readable summary', () => {
+ const result = createMockResult()
+ const report = generateSummaryReport(result)
+
+ expect(report).toContain('ACCESSIBILITY AUDIT REPORT')
+ expect(report).toContain('URL: http://localhost:3000/')
+ expect(report).toContain('Total Issues: 2')
+ expect(report).toContain('Critical: 1')
+ expect(report).toContain('Serious: 1')
+ expect(report).toContain('Passing Rules: 50')
+ })
+
+ it('should group issues by impact', () => {
+ const result = createMockResult()
+ const report = generateSummaryReport(result)
+
+ expect(report).toContain('[CRITICAL]')
+ expect(report).toContain('[SERIOUS]')
+ expect(report).toContain('image-alt')
+ expect(report).toContain('button-name')
+ })
+
+ it('should include selector and help URL', () => {
+ const result = createMockResult()
+ const report = generateSummaryReport(result)
+
+ expect(report).toContain('Selector: img.logo')
+ expect(report).toContain('Learn more: https://dequeuniversity.com')
+ })
+
+ it('should handle result with no issues', () => {
+ const result: A11yAuditResult = {
+ issues: [],
+ summary: {
+ total: 0,
+ critical: 0,
+ serious: 0,
+ moderate: 0,
+ minor: 0,
+ passes: 50,
+ incomplete: 0,
+ },
+ timestamp: 1704067200000,
+ url: 'http://localhost:3000/',
+ context: 'document',
+ duration: 50,
+ }
+
+ const report = generateSummaryReport(result)
+
+ expect(report).toContain('Total Issues: 0')
+ expect(report).not.toContain('[CRITICAL]')
+ expect(report).not.toContain('[SERIOUS]')
+ })
+ })
+})
diff --git a/packages/devtools-a11y/tsconfig.json b/packages/devtools-a11y/tsconfig.json
new file mode 100644
index 00000000..966bd728
--- /dev/null
+++ b/packages/devtools-a11y/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js"
+ },
+ "include": ["src", "eslint.config.js", "vite.config.ts", "tests"]
+}
diff --git a/packages/devtools-a11y/vite.config.ts b/packages/devtools-a11y/vite.config.ts
new file mode 100644
index 00000000..e893145b
--- /dev/null
+++ b/packages/devtools-a11y/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, mergeConfig } from 'vitest/config'
+import { tanstackViteConfig } from '@tanstack/vite-config'
+import solid from 'vite-plugin-solid'
+import packageJson from './package.json'
+import type { Plugin } from 'vite'
+
+const config = defineConfig({
+ plugins: [solid() as any satisfies Plugin],
+ test: {
+ name: packageJson.name,
+ dir: './tests',
+ watch: false,
+ environment: 'jsdom',
+ globals: true,
+ },
+})
+
+export default mergeConfig(
+ config,
+ tanstackViteConfig({
+ entry: ['./src/index.ts', './src/react/index.ts', './src/preact/index.ts'],
+ srcDir: './src',
+ cjs: true,
+ }),
+)
diff --git a/packages/devtools/src/tabs/plugin-registry.ts b/packages/devtools/src/tabs/plugin-registry.ts
index ab656be3..0b0afa39 100644
--- a/packages/devtools/src/tabs/plugin-registry.ts
+++ b/packages/devtools/src/tabs/plugin-registry.ts
@@ -208,6 +208,24 @@ const PLUGIN_REGISTRY: Record = {
tags: ['TanStack'],
},
+ // TanStack A11y Devtools
+ '@tanstack/devtools-a11y': {
+ packageName: '@tanstack/devtools-a11y',
+ title: 'Accessibility Devtools',
+ description:
+ 'Audit accessibility issues in real-time with axe-core. Supports WCAG 2.1/2.2, live monitoring, and visual overlays.',
+ pluginImport: {
+ importName: 'createA11yPlugin',
+ type: 'function',
+ },
+ pluginId: 'devtools-a11y',
+ docsUrl: 'https://tanstack.com/devtools/latest/docs/plugins/a11y',
+ author: 'TanStack',
+ framework: 'react',
+ isNew: true,
+ tags: ['TanStack', 'a11y'],
+ },
+
// ==========================================
// THIRD-PARTY PLUGINS - Examples
// ==========================================
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d13b7ee2..da02d712 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -142,6 +142,34 @@ importers:
specifier: ^7.1.7
version: 7.2.6(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)
+ examples/react/a11y-devtools:
+ dependencies:
+ '@tanstack/devtools-a11y':
+ specifier: workspace:*
+ version: link:../../../packages/devtools-a11y
+ '@tanstack/react-devtools':
+ specifier: workspace:*
+ version: link:../../../packages/react-devtools
+ react:
+ specifier: ^19.2.0
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.0
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: ^19.2.0
+ version: 19.2.3(@types/react@19.2.7)
+ '@vitejs/plugin-react':
+ specifier: ^5.0.4
+ version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1))
+ vite:
+ specifier: ^7.1.7
+ version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)
+
examples/react/basic:
dependencies:
'@tanstack/devtools-client':
@@ -586,6 +614,40 @@ importers:
specifier: ^2.11.8
version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1))
+ packages/devtools-a11y:
+ dependencies:
+ '@tanstack/devtools-event-client':
+ specifier: workspace:*
+ version: link:../event-bus-client
+ '@tanstack/devtools-utils':
+ specifier: workspace:^
+ version: link:../devtools-utils
+ '@types/react':
+ specifier: '>=17.0.0'
+ version: 19.2.7
+ axe-core:
+ specifier: ^4.10.0
+ version: 4.11.1
+ goober:
+ specifier: ^2.1.16
+ version: 2.1.18(csstype@3.2.3)
+ preact:
+ specifier: '>=10.0.0'
+ version: 10.28.0
+ react:
+ specifier: '>=17.0.0'
+ version: 19.2.3
+ solid-js:
+ specifier: '>=1.9.7'
+ version: 1.9.10
+ vue:
+ specifier: '>=3.2.0'
+ version: 3.5.25(typescript@5.9.3)
+ devDependencies:
+ vite-plugin-solid:
+ specifier: ^2.11.8
+ version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1))
+
packages/devtools-client:
dependencies:
'@tanstack/devtools-event-client':
@@ -3944,6 +4006,10 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
+ axe-core@4.11.1:
+ resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
+ engines: {node: '>=4'}
+
axios@1.13.1:
resolution: {integrity: sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==}
@@ -4373,6 +4439,7 @@ packages:
dax-sh@0.43.2:
resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==}
+ deprecated: This package has moved to simply be 'dax' instead of 'dax-sh'
dayjs@1.11.18:
resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
@@ -8216,6 +8283,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -11932,6 +12000,8 @@ snapshots:
aws-ssl-profiles@1.1.2:
optional: true
+ axe-core@4.11.1: {}
+
axios@1.13.1(debug@4.4.3):
dependencies:
follow-redirects: 1.15.9(debug@4.4.3)