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 + + + +
+ + + 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

+ +
+
+ +
+

Accessible Content (for comparison)

+ +
+

Proper image with alt text

+ Placeholder image for demonstration +
+ +
+

Proper button with label

+ +
+ +
+

Proper input with label

+ + +
+
+ + {showModal && ( +
+ +

This is a modal that was triggered by a non-button element.

+ +
+ )} +
+ ) +} 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 + + +
+ + +
+ + +
+ + + +
+ + {SEVERITY_LABELS[config().threshold]}+ |{' '} + {RULE_SET_LABELS[config().ruleSet]} + 0}> + ` | ${config().disabledRules.length} rule(s) disabled` + + +
+ +
+ +
+ + +
+

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()}
+
+ +
+ event.stopPropagation()} + > + Learn more + + +
+
+ + 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 ( + + ) + }} + +
+ + + {(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 ( +
+
+

Settings

+ +
+ +
+
+

General

+ +
+
+
+ Severity Threshold +
+
+ Only show issues at or above this level +
+
+ +
+ +
+
+
Rule Set
+
+ WCAG conformance level or standard +
+
+ +
+
+ +
+
+

+ Rules ({props.availableRules.length} total,{' '} + {props.config.disabledRules.length} disabled) +

+
+ + +
+
+ +
+ + + + 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 ( + + ) + }} + +
+
+
+
+ ) +} 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)