diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md
new file mode 100644
index 00000000..83983259
--- /dev/null
+++ b/examples/a11y-messages-playground/README.md
@@ -0,0 +1,86 @@
+# A11y × Messages Playground
+
+A focused hub playground for testing **[`@devframes/plugin-a11y`](../../plugins/a11y)**
+alongside **[`@devframes/plugin-messages`](../../plugins/messages)** — and, above
+all, the **message → dock navigation** they share.
+
+Where [`minimal-vite-devframe-hub`](../minimal-vite-devframe-hub) mounts every
+built-in plugin against the hub's own (mostly accessible) UI, this example ships a
+deliberately **inaccessible, multi-route app under test** so the a11y scanner
+always has real violations to find, track per route, and link back to from the
+messages feed.
+
+## Run it
+
+```sh
+pnpm install
+pnpm --filter a11y-messages-playground dev
+```
+
+The `dev` script builds the workspace first (the a11y agent bundle and both
+plugin SPAs must exist), then starts Vite bound to `0.0.0.0`. Open the printed
+URL.
+
+## What you'll see
+
+The window is split in two:
+
+- **Left — App under test.** A tiny client-side-routed app whose every route is
+ broken on purpose:
+ - `/` — a missing `alt`, an icon-only button, an empty link, a skipped heading
+ level
+ - `/images` — a gallery of `alt`-less images
+ - `/forms` — inputs and a select with no labels
+ - `/contrast` — low-contrast text and a custom control with no accessible name
+- **Right — Devtools.** The **A11y Inspector** and **Messages** docks.
+
+## What to try
+
+1. **Live scanning** — open the **A11y Inspector**. It scans the left pane on
+ load; the Dashboard shows the totals and severity breakdown.
+2. **Route tracking** — click the route tabs in the app under test (`Home`,
+ `Images`, `Forms`, `Contrast`). Each navigation is a `history.pushState`,
+ which the agent patches — the Violations tab accrues one group per route.
+3. **Select + highlight** — hover a violation to ring the element in the page;
+ tick a violation's checkbox to highlight all its elements with numbered
+ badges, then hit **Generate fix prompts** in the nav for a paste-ready AI
+ prompt covering everything you selected.
+4. **Message → dock navigation** *(the headline)* — open the **Messages** dock.
+ Each scan mirrors a summary entry plus one entry per violated rule, and every
+ entry carries a navigation action. Select an entry and click **View in a11y
+ inspector** (or **Open a11y dashboard**): the hub switches the focused dock to
+ the A11y Inspector, deep-linked to that rule + route, and pins the offending
+ elements.
+
+## How it's wired
+
+`src/a11y-messages-playground.ts` is the whole host — a ~120-line Vite plugin
+that runs `@devframes/hub` in the dev server, mounts the two plugins as docks,
+and attaches the a11y agent as the a11y dock's `clientScript`:
+
+```ts
+a11yMessagesPlayground({
+ devframes: [a11yDevframe, messagesDevframe],
+ clientScripts: {
+ [a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` },
+ },
+})
+```
+
+`src/client/main.ts` boots the hub's client runtime with
+`createDevframeClientHost()`, which imports that agent into the host page (so it
+scans the app under test) and renders the dock rail from `devframe:docks` shared
+state. The navigation itself rides existing hub primitives: the messages panel
+calls `hub:docks:activate`, the hub broadcasts it, and the client host switches
+the focused dock — the same path a manual dock click takes.
+
+## Files
+
+| File | Role |
+|---|---|
+| `src/a11y-messages-playground.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS |
+| `vite.config.ts` | Mounts a11y + messages; attaches the a11y agent as its dock's `clientScript` |
+| `src/client/main.ts` | Boots the client host, renders the dock rail + iframe stage |
+| `src/client/app-under-test.ts` | The intentionally-broken, multi-route app the agent scans |
+| `src/client/icons.ts` | Offline Phosphor icons for the dock rail |
+| `index.html` | The two-pane shell (app under test · devtools) |
diff --git a/examples/a11y-messages-playground/index.html b/examples/a11y-messages-playground/index.html
new file mode 100644
index 00000000..a977dcae
--- /dev/null
+++ b/examples/a11y-messages-playground/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ A11y × Messages Playground
+
+
+
+
+
+
+
+
+ A11y × Messages Playground
+
+ Connecting…
+
+
+
+
+
+
+
diff --git a/examples/a11y-messages-playground/package.json b/examples/a11y-messages-playground/package.json
new file mode 100644
index 00000000..2fdba2d9
--- /dev/null
+++ b/examples/a11y-messages-playground/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "a11y-messages-playground",
+ "type": "module",
+ "version": "0.7.12",
+ "private": true,
+ "description": "A focused hub playground that pairs @devframes/plugin-a11y with @devframes/plugin-messages over an intentionally-broken, multi-route app under test — for exercising a11y scanning, route tracking, and message→dock navigation.",
+ "homepage": "https://github.com/devframes/devframe/tree/main/examples/a11y-messages-playground",
+ "scripts": {
+ "dev": "pnpm -C ../.. run build && vite --host",
+ "build": "vite build",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@antfu/design": "catalog:frontend",
+ "@devframes/hub": "workspace:*",
+ "@devframes/plugin-a11y": "workspace:*",
+ "@devframes/plugin-messages": "workspace:*",
+ "devframe": "workspace:*"
+ },
+ "devDependencies": {
+ "@iconify-json/ph": "catalog:frontend",
+ "get-port-please": "catalog:deps",
+ "pathe": "catalog:deps",
+ "unocss": "catalog:frontend",
+ "vite": "catalog:build"
+ }
+}
diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts
new file mode 100644
index 00000000..842e4ee3
--- /dev/null
+++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts
@@ -0,0 +1,123 @@
+import type { DevframeHubContext } from '@devframes/hub/node'
+import type { ClientScriptEntry } from '@devframes/hub/types'
+import type { DevframeDefinition, DevframeHost } from 'devframe/types'
+import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
+import { homedir } from 'node:os'
+import { createHubContext, mountDevframe } from '@devframes/hub/node'
+import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
+import { startHttpAndWs } from 'devframe/node'
+import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
+import { getPort } from 'get-port-please'
+import { join } from 'pathe'
+
+export interface A11yMessagesPlaygroundOptions {
+ /** Mount path for the hub's connection-meta endpoint. Default: `/__hub/`. */
+ base?: string
+ /** Preferred port for the side-car RPC/WS server. Default: a free port near 9878. */
+ port?: number
+ /** Devframes to mount as docks (here: a11y + messages). */
+ devframes?: DevframeDefinition[]
+ /**
+ * Per-dock client scripts, keyed by devframe id. Attached to the mounted
+ * iframe dock so the hub client runtime imports them into the host page —
+ * this is how the a11y inspector's in-page agent gets into the page it scans.
+ */
+ clientScripts?: Record
+}
+
+/**
+ * A tiny Vite plugin that runs `@devframes/hub` inside the Vite dev server —
+ * the same shape as `examples/minimal-vite-devframe-hub`, trimmed to the two
+ * plugins this playground pairs (a11y + messages). It creates a hub context,
+ * implements the framework-neutral `DevframeHost` surface, mounts each devframe
+ * as a dock (attaching the a11y agent as its client script), and exposes the
+ * side-car WS endpoint at ` __connection.json`.
+ */
+export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin {
+ const base = normalizeBase(options.base ?? '/__hub/')
+ let viteConfig: ResolvedConfig | undefined
+ let started: { close: () => Promise } | undefined
+
+ return {
+ name: 'a11y-messages-playground',
+ apply: 'serve',
+
+ configResolved(config) {
+ viteConfig = config
+ },
+
+ async configureServer(server: ViteDevServer) {
+ // Vite re-invokes `configureServer` on restart — tear the old server down
+ // so we don't leak the WS port.
+ await started?.close().catch(() => {})
+ started = undefined
+
+ const cwd = viteConfig!.root
+ const port = options.port ?? await getPort({ port: 9878, portRange: [9878, 9978] })
+
+ const serveConnectionMeta = (metaBase: string): void => {
+ const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}`
+ server.middlewares.use(metaPath, (_req, res) => {
+ res.setHeader('Content-Type', 'application/json')
+ res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
+ })
+ }
+
+ const host: DevframeHost = {
+ mountStatic(base, distDir) {
+ server.middlewares.use(base, serveStaticNodeMiddleware(distDir))
+ },
+ mountConnectionMeta(base) {
+ serveConnectionMeta(base)
+ },
+ resolveOrigin() {
+ const resolved = server.resolvedUrls?.local?.[0]
+ return resolved ? new URL(resolved).origin : 'http://localhost:5173'
+ },
+ getStorageDir(scope) {
+ if (scope === 'workspace')
+ return join(cwd, '.devframe')
+ if (scope === 'project')
+ return join(cwd, 'node_modules/.a11y-messages-playground')
+ return join(homedir(), '.a11y-messages-playground')
+ },
+ }
+
+ const context: DevframeHubContext = await createHubContext({
+ cwd,
+ workspaceRoot: cwd,
+ mode: 'dev',
+ host,
+ })
+
+ // Mount each devframe as a dock, attaching its client script when one is
+ // configured (the a11y agent). `mountDevframe` runs the def's `setup(ctx)`,
+ // so `setupA11y` / `setupMessages` register their RPCs automatically.
+ for (const def of options.devframes ?? []) {
+ const clientScript = options.clientScripts?.[def.id]
+ await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
+ }
+
+ started = await startHttpAndWs({ context, port, auth: false })
+
+ // Tell the hub UI (served at `base`) where to find the WS endpoint.
+ serveConnectionMeta(base)
+
+ server.httpServer?.once('close', () => {
+ void started?.close().catch(() => {})
+ })
+ },
+
+ async closeBundle() {
+ await started?.close().catch(() => {})
+ started = undefined
+ },
+ }
+}
+
+function normalizeBase(base: string): string {
+ let out = base.startsWith('/') ? base : `/${base}`
+ if (!out.endsWith('/'))
+ out = `${out}/`
+ return out
+}
diff --git a/examples/a11y-messages-playground/src/client/app-under-test.ts b/examples/a11y-messages-playground/src/client/app-under-test.ts
new file mode 100644
index 00000000..86f2395b
--- /dev/null
+++ b/examples/a11y-messages-playground/src/client/app-under-test.ts
@@ -0,0 +1,130 @@
+// The "app under test": an intentionally-inaccessible, client-side-routed mini
+// app the a11y agent scans. Each route seeds a different family of axe
+// violations, and navigating between them (History API pushState — which the
+// a11y agent patches) exercises the inspector's per-route tracking.
+//
+// Keep the chrome (the route nav) accessible so the deliberate violations stay
+// concentrated in each route's , where they're easy to reason about.
+
+interface Route {
+ path: string
+ label: string
+ /** What the route is designed to make axe flag — shown as a hint. */
+ hint: string
+ render: () => string
+}
+
+const ROUTES: Route[] = [
+ {
+ path: '/',
+ label: 'Home',
+ hint: 'image-alt · button-name · link-name · heading-order',
+ render: () => `
+ Welcome to the broken shop
+ Featured (heading level skips from 1 to 4)
+
+ An icon-only button with no accessible name:
+ 🔔
+ A link with no discernible text:
+
+ `,
+ },
+ {
+ path: '/images',
+ label: 'Images',
+ hint: 'image-alt (several)',
+ render: () => `
+ Gallery
+
+ ${['#c0653a', '#3a8fc0', '#4aa06a', '#b04a9a']
+ .map(c => `
`)
+ .join('')}
+
+ None of the images above carry an alt attribute.
+ `,
+ },
+ {
+ path: '/forms',
+ label: 'Forms',
+ hint: 'label · select-name · placeholder-only',
+ render: () => `
+ Checkout
+
+ The inputs and the select have no associated labels; the checkbox's
+ text is not programmatically linked.
+ `,
+ },
+ {
+ path: '/contrast',
+ label: 'Contrast',
+ hint: 'color-contrast · region (best-practice)',
+ render: () => `
+ Low-contrast heading
+
+ This paragraph sets light grey text on a white background — well below
+ the WCAG AA contrast ratio for body text.
+
+
+ Yellow-on-white call to action
+
+
+ The grey square is a custom control with a role but no accessible name.
+ `,
+ },
+]
+
+/** A tiny inline SVG banner used as decorative (alt-less) imagery. */
+function banner(color: string): string {
+ return encodeURIComponent(
+ ` `,
+ )
+}
+
+function currentRoute(): Route {
+ return ROUTES.find(r => r.path === location.pathname) ?? ROUTES[0]!
+}
+
+/**
+ * Mount the app-under-test into `container`. Renders a route nav plus the
+ * current route's (deliberately broken) content, and navigates with
+ * `history.pushState` so the a11y agent re-scans and buckets per route.
+ */
+export function mountAppUnderTest(container: HTMLElement): void {
+ function render(): void {
+ const active = currentRoute()
+ container.innerHTML = `
+
+ ${ROUTES.map(r =>
+ `${r.label} `,
+ ).join('')}
+
+ Route ${active.path} · seeds: ${active.hint}
+ ${active.render()}
+ `
+ }
+
+ container.addEventListener('click', (event) => {
+ const btn = (event.target as HTMLElement).closest('button[data-route]')
+ if (!btn)
+ return
+ const path = btn.dataset.route!
+ if (path === location.pathname)
+ return
+ history.pushState({}, '', path)
+ render()
+ })
+
+ window.addEventListener('popstate', render)
+ render()
+}
diff --git a/examples/a11y-messages-playground/src/client/env.d.ts b/examples/a11y-messages-playground/src/client/env.d.ts
new file mode 100644
index 00000000..01b43943
--- /dev/null
+++ b/examples/a11y-messages-playground/src/client/env.d.ts
@@ -0,0 +1,3 @@
+///
+
+declare module 'virtual:uno.css'
diff --git a/examples/a11y-messages-playground/src/client/icons.ts b/examples/a11y-messages-playground/src/client/icons.ts
new file mode 100644
index 00000000..b7579fc2
--- /dev/null
+++ b/examples/a11y-messages-playground/src/client/icons.ts
@@ -0,0 +1,13 @@
+// @unocss-include
+// Map dock `icon` strings (ph:*) to statically-extractable UnoCSS classes, so
+// the offline Phosphor set renders without a runtime icon library. UnoCSS only
+// emits classes it can see in source, hence the literal map.
+const ICONS: Record = {
+ 'ph:person-simple-circle-duotone': 'i-ph-person-simple-circle-duotone',
+ 'ph:notification-duotone': 'i-ph-notification-duotone',
+}
+
+/** Class chain for a dock icon, or `''` when unmapped (caller falls back). */
+export function iconClass(name: string | undefined): string {
+ return (name && ICONS[name]) ?? ''
+}
diff --git a/examples/a11y-messages-playground/src/client/main.ts b/examples/a11y-messages-playground/src/client/main.ts
new file mode 100644
index 00000000..d24164c0
--- /dev/null
+++ b/examples/a11y-messages-playground/src/client/main.ts
@@ -0,0 +1,128 @@
+import type { DevframeDockEntry } from '@devframes/hub/types'
+import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
+import { mountAppUnderTest } from './app-under-test'
+import { iconClass } from './icons'
+import 'virtual:uno.css'
+import '@antfu/design/styles.css'
+
+const HUB_BASE = '/__hub/'
+
+const connEl = document.querySelector('#conn')!
+const docksEl = document.querySelector('#docks')!
+const stageEl = document.querySelector('#dock-stage')!
+const appEl = document.querySelector('#app-under-test')!
+
+function setStatus(text: string, kind?: 'ready' | 'error') {
+ const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400'
+ connEl.innerHTML = ` ${text}`
+}
+
+function dockIcon(entry: DevframeDockEntry): string {
+ const cls = iconClass(typeof entry.icon === 'string' ? entry.icon : undefined)
+ if (cls)
+ return ` `
+ const initial = (entry.title?.[0] ?? '?').toUpperCase()
+ return `${initial} `
+}
+
+function isIframeDock(d: DevframeDockEntry): d is DevframeDockEntry & { type: 'iframe', url: string } {
+ return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string'
+}
+
+async function main() {
+ setStatus('Connecting…')
+
+ // The app under test renders immediately — it's plain page content the a11y
+ // agent (imported below as the a11y dock's client script) will scan.
+ mountAppUnderTest(appEl)
+
+ const rpc = await connectDevframe({ baseURL: HUB_BASE })
+ setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready')
+
+ // Boot the hub client runtime: it publishes the shared client context and
+ // imports each dock's client script into this page — here, the a11y agent,
+ // which then scans this document live and mirrors findings into the messages
+ // feed. The rail below reads the same `devframe:docks` shared state.
+ const host = await createDevframeClientHost({ rpc })
+ const docksCtx = host.context.docks
+
+ const docks = await rpc.sharedState.get('devframe:docks', { initialValue: [] })
+
+ // Keep-alive iframe pool: one iframe per dock, toggled by visibility so the
+ // a11y panel keeps its BroadcastChannel connection while you switch docks.
+ const iframePool = new Map()
+
+ function ensureIframe(entry: DevframeDockEntry & { url: string }): HTMLIFrameElement {
+ let el = iframePool.get(entry.id)
+ if (!el) {
+ el = document.createElement('iframe')
+ el.title = entry.title
+ el.className = 'absolute inset-0 block h-full w-full border-0 bg-base'
+ el.hidden = true
+ el.src = entry.url
+ stageEl.appendChild(el)
+ iframePool.set(entry.id, el)
+ const state = docksCtx.getStateById(entry.id)
+ if (state) {
+ state.domElements.iframe = el
+ state.events.emit('dom:iframe:mounted', el)
+ }
+ }
+ return el
+ }
+
+ function showSelection(list: DevframeDockEntry[]): void {
+ const entry = docksCtx.selectedId ? list.find(d => d.id === docksCtx.selectedId) ?? null : null
+ const active = entry && isIframeDock(entry) ? ensureIframe(entry) : null
+ for (const el of iframePool.values()) el.hidden = el !== active
+ }
+
+ const wired = new Set()
+ function render(): void {
+ const list = docksCtx.entries.filter(isIframeDock)
+
+ if (!docksCtx.selectedId && list.length > 0)
+ void docksCtx.switchEntry(list[0]!.id)
+
+ for (const entry of list) {
+ if (wired.has(entry.id))
+ continue
+ const state = docksCtx.getStateById(entry.id)
+ if (!state)
+ continue
+ wired.add(entry.id)
+ state.events.on('entry:activated', render)
+ }
+
+ if (!list.length) {
+ docksEl.innerHTML = 'Waiting for docks… '
+ showSelection(list)
+ return
+ }
+
+ docksEl.innerHTML = list.map(d =>
+ `${dockIcon(d)}${d.title} `,
+ ).join('')
+
+ showSelection(list)
+ }
+
+ docksEl.addEventListener('click', (event) => {
+ const target = (event.target as HTMLElement).closest('button[data-dock-id]')
+ if (!target)
+ return
+ const id = target.dataset.dockId
+ if (!id || id === docksCtx.selectedId)
+ return
+ void docksCtx.switchEntry(id)
+ })
+
+ docks.on('updated', render)
+ render()
+}
+
+main().catch((err) => {
+ setStatus(`Failed: ${(err as Error).message}`, 'error')
+ console.error(err)
+})
diff --git a/examples/a11y-messages-playground/tsconfig.json b/examples/a11y-messages-playground/tsconfig.json
new file mode 100644
index 00000000..46dbffd9
--- /dev/null
+++ b/examples/a11y-messages-playground/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "esModuleInterop": true,
+ "isolatedDeclarations": false
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/examples/a11y-messages-playground/uno.config.ts b/examples/a11y-messages-playground/uno.config.ts
new file mode 100644
index 00000000..668ea1be
--- /dev/null
+++ b/examples/a11y-messages-playground/uno.config.ts
@@ -0,0 +1,13 @@
+import { mergeConfigs } from 'unocss'
+import { designConfig } from '../../design/uno.config'
+
+// Compose the repo-shared design preset. `content.pipeline.include` opts the
+// vanilla `.ts`/`.html` sources into extraction, since the dock rail and the
+// app-under-test class strings live in plain TS/HTML rather than a framework
+// file UnoCSS scans by default.
+export default mergeConfigs([
+ designConfig,
+ {
+ content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } },
+ },
+])
diff --git a/examples/a11y-messages-playground/vite.config.ts b/examples/a11y-messages-playground/vite.config.ts
new file mode 100644
index 00000000..50791a28
--- /dev/null
+++ b/examples/a11y-messages-playground/vite.config.ts
@@ -0,0 +1,24 @@
+import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y'
+import messagesDevframe from '@devframes/plugin-messages'
+import UnoCSS from 'unocss/vite'
+import { defineConfig } from 'vite'
+import { alias } from '../../alias'
+import { a11yMessagesPlayground } from './src/a11y-messages-playground'
+
+export default defineConfig({
+ resolve: { alias },
+ server: { allowedHosts: true, strictPort: false },
+ optimizeDeps: { exclude: ['@antfu/design'] },
+ plugins: [
+ UnoCSS(),
+ a11yMessagesPlayground({
+ devframes: [a11yDevframe, messagesDevframe],
+ // Attach the a11y agent as the a11y dock's client script — served over
+ // Vite's `/@fs/` so it shares this page's origin (the BroadcastChannel the
+ // agent and panel talk over rides that origin).
+ clientScripts: {
+ [a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` },
+ },
+ }),
+ ],
+})
diff --git a/packages/hub/src/types/messages.ts b/packages/hub/src/types/messages.ts
index 32e63caf..d5e63b71 100644
--- a/packages/hub/src/types/messages.ts
+++ b/packages/hub/src/types/messages.ts
@@ -21,6 +21,27 @@ export interface DevframeMessageFilePosition {
column?: number
}
+/**
+ * A labeled control a message can carry. Rendered by the messages panel; when
+ * clicked it drives the described intent. Discriminated by `kind` so further
+ * action kinds can be added without reshaping the field.
+ *
+ * `'activate'` requests the viewer switch its focused dock to `activate.dockId`
+ * (deep-linking via the opaque, serializable `activate.params` bag the target
+ * dock interprets), via the hub's `hub:docks:activate` RPC.
+ */
+export interface DevframeMessageActivateAction {
+ /** Stable id for the action within its entry. */
+ id: string
+ /** Button label shown in the messages panel. */
+ label: string
+ kind: 'activate'
+ /** The dock to focus, plus an optional deep-link params bag. */
+ activate: { dockId: string, params?: Record }
+}
+
+export type DevframeMessageAction = DevframeMessageActivateAction
+
export interface DevframeMessageEntry {
/**
* Unique identifier for this message entry (auto-generated if not provided)
@@ -66,6 +87,11 @@ export interface DevframeMessageEntry {
* Optional tags/labels for filtering
*/
labels?: string[]
+ /**
+ * Optional labeled actions (e.g. "navigate to a dock") the panel renders as
+ * clickable controls in the entry's detail view.
+ */
+ actions?: DevframeMessageAction[]
/**
* Time in ms to auto-dismiss the toast notification (client-side)
*/
diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md
index aae2f179..a2d0c473 100644
--- a/plugins/a11y/README.md
+++ b/plugins/a11y/README.md
@@ -5,9 +5,28 @@
> it stabilizes.
An accessibility inspector built on [devframe](../../packages/devframe). It runs
-[axe-core](https://github.com/dequelabs/axe-core) against a host application,
-lists the WCAG A/AA violations in a [Solid](https://www.solidjs.com/) panel, and
-**highlights the offending element in the page when you hover a warning**.
+[axe-core](https://github.com/dequelabs/axe-core) against a host application and
+surfaces the violations in a [Solid](https://www.solidjs.com/) panel:
+
+- **Route-aware tracking** — buckets violations by `location.pathname` and tracks
+ them as you navigate the app (History-API patched, framework-neutral), persisted
+ in `sessionStorage` so history survives reloads within a tab session.
+- **Dashboard + grouped violations** — a Dashboard tab (totals, severity
+ breakdown, per-route inventory, scan controls) and a Violations tab listing
+ every tracked route, grouped, with the active route marked.
+- **Select + highlight** — hover previews the offending element; ticking a
+ violation's checkbox selects it, giving the card a distinct state and drawing
+ globally-numbered badges over all its elements in the page. ``/``
+ targets get a corner notice instead of a viewport-filling ring.
+- **Generate fix prompts** — a nav button gathers the selected violations (rule
+ metadata, WCAG tags, docs, and each element's selector/markup/failure summary,
+ grouped by route) into one paste-ready AI prompt in a dialog.
+- **Constant scanning** — a DOM `MutationObserver` plus debounced
+ mouse/keyboard/touch rescans, toggleable from the Dashboard.
+- **WCAG 2.0–2.2 + best-practice** — the broadened axe tag set, with best-practice
+ rules tagged and filterable.
+- **Console logging** — newly-appeared violations are logged (deduped) to the
+ browser console.
The scan + highlight loop works the same whether the plugin runs as a live dev
server or as a baked static build.
@@ -18,17 +37,20 @@ Three pieces, two of them browser-side:
| Piece | Runs in | Role |
|-------|---------|------|
-| **Agent** (`src/inject`) | the host app's page | runs axe-core, broadcasts the report, draws the highlight ring |
-| **Panel** (`src/spa`) | the devtools iframe | Solid SPA: lists violations, fires highlight/clear on hover |
-| **Node** (`src/index.ts`, `src/node`, `src/rpc`) | the devframe backend | `get-config` RPC (impact taxonomy) — live in dev, baked in a static build |
+| **Agent** (`src/inject`) | the host app's page | runs axe-core, tracks routes, broadcasts the aggregate state, draws the preview + pinned rings |
+| **Panel** (`src/spa`) | the devtools iframe | Solid SPA: Dashboard + grouped violations, fires preview/pin/rescan |
+| **Node** (`src/index.ts`, `src/node`, `src/rpc`) | the devframe backend | `get-config` RPC (impact taxonomy + runtime config) — live in dev, baked in a static build |
The agent and panel talk over a same-origin
[`BroadcastChannel`](src/shared/protocol.ts), not the devframe RPC backend. That
is what keeps the live loop working in **both modes**: neither half needs a
server to reach the other, only a shared browser origin (host page + panel
-iframe). devframe RPC carries the data model on top — `get-config` is a `static`
-function, so it resolves over WebSocket in dev and from the baked dump in a
-static build.
+iframe). The agent owns the authoritative route → report map and broadcasts the
+whole aggregate on every change, so the panel stays a pure render of it. devframe
+RPC carries the data model on top — `get-config` is a `static` function, so it
+resolves over WebSocket in dev and from the baked dump in a static build; the
+panel forwards its runtime-config slice to the agent over the channel, keeping the
+agent itself free of any RPC dependency.
devframe deliberately provides no access to the host application's DOM, so the
agent is the author-provided bridge into the page being checked. In a hub, the
@@ -37,14 +59,34 @@ dock's `clientScript` (resolved to an importable URL — `/@fs/…` under Vite,
statically-served path) and the hub's client runtime (`createDevframeClientHost`
from `@devframes/hub/client`) imports it into the host page and calls its
default export with the client-script context. Booted that way, the agent also
-mirrors each scan into the hub's **messages feed** — a summary entry driven
-through the loading → idle lifecycle plus one entry per violated rule, carrying
-the impact-mapped level, WCAG tags as labels, and the first offending element's
-selector and bounding box (rendered by `@devframes/plugin-messages` when the
-hub mounts it). Both minimal hub examples do exactly this. Outside a hub, one
+mirrors the active route's scan into the hub's **messages feed** — a summary entry
+driven through the loading → idle lifecycle plus one entry per violated rule,
+carrying the impact-mapped level, WCAG tags as labels, and the first offending
+element's selector and bounding box (rendered by `@devframes/plugin-messages` when
+the hub mounts it). Each entry also carries a **navigation action**: clicking it
+in the messages panel activates the a11y dock (`hub:docks:activate`) deep-linked
+to the rule + route (or the Dashboard, for the summary). Both minimal hub examples
+do exactly this. Outside a hub, one
`` — or let a
@@ -12,9 +13,21 @@
* export receives the hub's client-script context and additionally mirrors
* each scan into the hub's messages feed.
*/
-import type { A11yMessage, ScanReport } from '../shared/protocol.ts'
+import type {
+ A11yMessage,
+ A11yState,
+ AgentConfig,
+ PinTarget,
+ ScanReport,
+} from '../shared/protocol.ts'
import type { A11yAgentContext } from './messages.ts'
-import { A11Y_CHANNEL } from '../shared/protocol.ts'
+import type { PinInfo } from './overlay.ts'
+import {
+ A11Y_CHANNEL,
+ A11Y_DEFAULT_DOCK_ID,
+ A11Y_NODE_ATTR,
+ A11Y_STORAGE_KEY,
+} from '../shared/protocol.ts'
import { createMessagesReporter } from './messages.ts'
import { createOverlay } from './overlay.ts'
import { resolveElement, scan } from './scanner.ts'
@@ -31,10 +44,13 @@ function start(context?: A11yAgentContext) {
const overlay = createOverlay()
document.documentElement.appendChild(overlay.root)
- // Booted as a hub dock client script — mirror every scan into the hub's
- // messages feed. Standalone boots have no context and skip the mirror.
+ // Booted as a hub dock client script — mirror the active route's scan into
+ // the hub's messages feed. Standalone boots have no context and skip it.
+ const config: AgentConfig = { logIssues: true, autoScan: true }
+
const reporter = context?.messages
? createMessagesReporter(context.messages, {
+ dockId: () => config.activateDockId ?? A11Y_DEFAULT_DOCK_ID,
resolveBoundingBox: (target) => {
const rect = resolveElement(target)?.getBoundingClientRect()
if (!rect)
@@ -49,13 +65,48 @@ function start(context?: A11yAgentContext) {
})
: undefined
- let lastReport: ScanReport | null = null
+ // Authoritative route → report map, persisted so history survives reloads /
+ // MPA navigations within the tab session.
+ const routes = new Map(loadRoutes())
+ /** Rule ids logged to the console per route, to dedupe under auto-scan. */
+ const loggedRules = new Map>()
+ let activeRoute = location.pathname
+ let engine = routes.get(activeRoute)?.engine ?? 'unknown'
+
let scanning = false
let rescanQueued = false
let debounceTimer = 0
const post = (message: A11yMessage) => channel.postMessage(message)
+ function loadRoutes(): [string, ScanReport][] {
+ try {
+ const raw = sessionStorage.getItem(A11Y_STORAGE_KEY)
+ if (!raw)
+ return []
+ const parsed = JSON.parse(raw) as ScanReport[]
+ return parsed.map(report => [report.route, report])
+ }
+ catch {
+ return []
+ }
+ }
+ function saveRoutes() {
+ try {
+ sessionStorage.setItem(A11Y_STORAGE_KEY, JSON.stringify([...routes.values()]))
+ }
+ catch {
+ // Storage full/unavailable — history is best-effort, carry on.
+ }
+ }
+
+ function buildState(): A11yState {
+ return { engine, activeRoute, routes: [...routes.values()] }
+ }
+ function broadcastState() {
+ post({ type: 'a11y:state', state: buildState() })
+ }
+
const observer = new MutationObserver((records) => {
// Ignore our own overlay mutations; everything else may have changed the
// accessibility tree, so debounce a fresh scan.
@@ -78,21 +129,74 @@ function start(context?: A11yAgentContext) {
debounceTimer = window.setTimeout(runScan, 600)
}
+ // Interaction-driven rescans, layered on top of the DOM observer. Bound only
+ // while auto-scan is enabled.
+ const INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'] as const
+ const onInteraction = () => scheduleScan()
+ let interactionsBound = false
+ function bindInteractions() {
+ if (interactionsBound || !config.autoScan)
+ return
+ interactionsBound = true
+ for (const type of INTERACTION_EVENTS)
+ addEventListener(type, onInteraction, { passive: true, capture: true })
+ }
+ function unbindInteractions() {
+ if (!interactionsBound)
+ return
+ interactionsBound = false
+ for (const type of INTERACTION_EVENTS)
+ removeEventListener(type, onInteraction, { capture: true } as EventListenerOptions)
+ }
+
+ function logNewIssues(report: ScanReport) {
+ if (!config.logIssues)
+ return
+ const previous = loggedRules.get(report.route) ?? new Set()
+ const current = new Set(report.violations.map(v => v.ruleId))
+ const fresh = report.violations.filter(v => !previous.has(v.ruleId))
+ loggedRules.set(report.route, current)
+ if (fresh.length === 0)
+ return
+ // eslint-disable-next-line no-console
+ console.groupCollapsed(
+ `%c a11y %c ${fresh.length} new issue${fresh.length === 1 ? '' : 's'} on ${report.route}`,
+ 'background:#6fb07d;color:#0b0e13;border-radius:3px;padding:1px 4px;font-weight:700',
+ 'color:inherit',
+ )
+ for (const v of fresh) {
+ // eslint-disable-next-line no-console
+ console.log(
+ `%c${v.impact}%c ${v.ruleId} — ${v.help} (${v.nodes.length})\n${v.helpUrl}`,
+ 'font-weight:700',
+ 'font-weight:400',
+ )
+ }
+ // eslint-disable-next-line no-console
+ console.groupEnd()
+ }
+
async function runScan() {
if (scanning) {
rescanQueued = true
return
}
scanning = true
- post({ type: 'a11y:scanning' })
+ activeRoute = location.pathname
+ post({ type: 'a11y:scanning', route: activeRoute })
reporter?.scanning()
// Suspend observation so attribute-stamping during the scan doesn't
// retrigger us.
observer.disconnect()
try {
- lastReport = await scan()
- post({ type: 'a11y:report', report: lastReport })
- reporter?.report(lastReport)
+ const report = await scan({ tags: config.axeTags, runOptions: config.axeRunOptions })
+ engine = report.engine
+ routes.set(report.route, report)
+ activeRoute = report.route
+ saveRoutes()
+ logNewIssues(report)
+ broadcastState()
+ reporter?.report(report)
}
catch (error) {
console.error('[a11y-inspector] scan failed', error)
@@ -108,40 +212,124 @@ function start(context?: A11yAgentContext) {
}
}
+ // ── route tracking ──────────────────────────────────────────────────────
+ // Framework-neutral: patch the History API + listen for popstate/hashchange,
+ // and bucket by pathname. A new route resets pins (panel-driven) and scans.
+ function onLocationChange() {
+ if (location.pathname === activeRoute)
+ return
+ activeRoute = location.pathname
+ overlay.setPins([])
+ broadcastState()
+ scheduleScan()
+ }
+ const origPush = history.pushState
+ const origReplace = history.replaceState
+ history.pushState = function pushState(...args: Parameters) {
+ const result = origPush.apply(this, args)
+ onLocationChange()
+ return result
+ }
+ history.replaceState = function replaceState(...args: Parameters) {
+ const result = origReplace.apply(this, args)
+ onLocationChange()
+ return result
+ }
+ addEventListener('popstate', onLocationChange)
+ addEventListener('hashchange', onLocationChange)
+
+ // ── pins / preview ──────────────────────────────────────────────────────
+ function resolvePin(pin: PinTarget, number: number): PinInfo | null {
+ const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(pin.nodeId)}"]`)
+ ?? resolveElement(pin.target)
+ if (!el)
+ return null
+ return { el, impact: pin.impact, ruleId: pin.ruleId, number }
+ }
+
channel.addEventListener('message', (event: MessageEvent) => {
const message = event.data
switch (message.type) {
case 'a11y:panel-ready':
- post({ type: 'a11y:agent-ready', url: location.href })
- if (lastReport)
- post({ type: 'a11y:report', report: lastReport })
+ post({ type: 'a11y:agent-ready', url: location.href, route: activeRoute })
+ if (routes.size > 0)
+ broadcastState()
else
void runScan()
break
+ case 'a11y:config':
+ applyConfig(message.config)
+ break
case 'a11y:highlight': {
- const el = document.querySelector(`[data-df-a11y-node="${CSS.escape(message.nodeId)}"]`)
+ const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(message.nodeId)}"]`)
?? resolveElement(message.target)
if (el) {
- const impact = findImpact(lastReport, message.nodeId) ?? 'minor'
- const ruleId = findRule(lastReport, message.nodeId) ?? 'element'
- overlay.show(el, { impact, ruleId })
+ const active = routes.get(activeRoute) ?? null
+ const impact = findImpact(active, message.nodeId) ?? 'minor'
+ const ruleId = findRule(active, message.nodeId) ?? 'element'
+ overlay.preview(el, { impact, ruleId })
}
else {
- overlay.hide()
+ overlay.clearPreview()
}
break
}
case 'a11y:clear':
- overlay.hide()
+ overlay.clearPreview()
+ break
+ case 'a11y:pins': {
+ const infos: PinInfo[] = []
+ message.pins.forEach((pin, i) => {
+ const info = resolvePin(pin, i + 1)
+ if (info)
+ infos.push(info)
+ })
+ overlay.setPins(infos)
break
+ }
case 'a11y:rescan':
void runScan()
break
+ case 'a11y:set-autoscan':
+ config.autoScan = message.enabled
+ if (message.enabled)
+ bindInteractions()
+ else
+ unbindInteractions()
+ break
+ case 'a11y:clear-route':
+ routes.delete(message.route)
+ loggedRules.delete(message.route)
+ saveRoutes()
+ broadcastState()
+ break
+ case 'a11y:clear-all':
+ routes.clear()
+ loggedRules.clear()
+ saveRoutes()
+ broadcastState()
+ break
}
})
+ function applyConfig(next: AgentConfig) {
+ config.logIssues = next.logIssues
+ config.axeTags = next.axeTags
+ config.axeRunOptions = next.axeRunOptions
+ config.activateDockId = next.activateDockId
+ config.autoScan = next.autoScan
+ if (config.autoScan)
+ bindInteractions()
+ else
+ unbindInteractions()
+ }
+
+ bindInteractions()
+
// Announce ourselves and run the first scan once the page has settled.
- post({ type: 'a11y:agent-ready', url: location.href })
+ post({ type: 'a11y:agent-ready', url: location.href, route: activeRoute })
+ if (routes.size > 0)
+ broadcastState()
if (document.readyState === 'complete')
void runScan()
else
diff --git a/plugins/a11y/src/inject/messages.ts b/plugins/a11y/src/inject/messages.ts
index 470a8827..4aa97730 100644
--- a/plugins/a11y/src/inject/messages.ts
+++ b/plugins/a11y/src/inject/messages.ts
@@ -10,6 +10,20 @@
* by the terminals and code-server plugins for `ctx.terminals`.
*/
import type { Impact, ScanReport } from '../shared/protocol.ts'
+import { A11Y_DEFAULT_DOCK_ID } from '../shared/protocol.ts'
+
+/**
+ * Structural slice of the hub's `DevframeMessageAction` the agent emits — a
+ * labeled control that, when clicked in the messages panel, activates a dock
+ * (deep-linking via `params`). Kept as a discriminated union so future action
+ * kinds can be added without reshaping the field.
+ */
+export interface HubMessageAction {
+ id: string
+ label: string
+ kind: 'activate'
+ activate: { dockId: string, params?: Record }
+}
/** Structural slice of the hub's `DevframeMessageEntryInput` the agent emits. */
export interface HubMessageInput {
@@ -17,12 +31,19 @@ export interface HubMessageInput {
message: string
description?: string
level: 'info' | 'warn' | 'error' | 'success' | 'debug'
+ /**
+ * Grouping category shown in the messages panel. Set explicitly to a short
+ * `'a11y'` label — the hub client host otherwise defaults it to the dock id
+ * (e.g. `devframes_plugin_a11y`), and input fields win over that default.
+ */
+ category?: string
labels?: string[]
elementPosition?: {
selector?: string
boundingBox?: { x: number, y: number, width: number, height: number }
description?: string
}
+ actions?: HubMessageAction[]
status?: 'loading' | 'idle'
}
@@ -40,6 +61,8 @@ export interface A11yAgentContext {
messages?: HubMessagesClient
}
+/** Short, human-facing grouping label shown in the messages panel. */
+const MESSAGE_CATEGORY = 'a11y'
/** One deduplicated summary entry tracks the scan lifecycle. */
const SCAN_MESSAGE_ID = 'devframes:plugin:a11y:scan'
/** One deduplicated entry per violated rule; removed when the rule clears. */
@@ -68,6 +91,11 @@ export interface MessagesReporterOptions {
* box is fine: each re-scan refreshes the entry.
*/
resolveBoundingBox?: (target: string[]) => { x: number, y: number, width: number, height: number } | undefined
+ /**
+ * The dock id the navigation actions target. Read at call time so a config
+ * update (custom devframe id) takes effect on the next scan.
+ */
+ dockId?: () => string
}
/**
@@ -84,8 +112,11 @@ export function createMessagesReporter(
): MessagesReporter {
let reportedRules = new Set()
// Fire-and-forget: the feed is a mirror, never a gate for the scan loop.
- const send = (input: HubMessageInput) => void messages.add(input).catch(() => {})
+ // Every entry is grouped under the short `a11y` category.
+ const send = (input: HubMessageInput) =>
+ void messages.add({ category: MESSAGE_CATEGORY, ...input }).catch(() => {})
const drop = (id: string) => void messages.remove(id).catch(() => {})
+ const dockId = () => options.dockId?.() ?? A11Y_DEFAULT_DOCK_ID
return {
scanning() {
@@ -107,6 +138,12 @@ export function createMessagesReporter(
: `${nodeCount} accessibility issue${nodeCount === 1 ? '' : 's'} across ${ruleCount} rule${ruleCount === 1 ? '' : 's'}`,
description: report.url,
level: nodeCount === 0 ? 'success' : 'warn',
+ actions: [{
+ id: 'dashboard',
+ label: 'Open a11y dashboard',
+ kind: 'activate',
+ activate: { dockId: dockId(), params: { tab: 'dashboard' } },
+ }],
status: 'idle',
})
@@ -127,6 +164,15 @@ export function createMessagesReporter(
description: first.failureSummary,
}
: undefined,
+ actions: [{
+ id: 'view',
+ label: 'View in a11y inspector',
+ kind: 'activate',
+ activate: {
+ dockId: dockId(),
+ params: { tab: 'violations', ruleId: violation.ruleId, route: report.route },
+ },
+ }],
})
}
for (const ruleId of reportedRules) {
diff --git a/plugins/a11y/src/inject/overlay.ts b/plugins/a11y/src/inject/overlay.ts
index 701f41a7..f0b054df 100644
--- a/plugins/a11y/src/inject/overlay.ts
+++ b/plugins/a11y/src/inject/overlay.ts
@@ -9,37 +9,41 @@ export interface HighlightInfo {
impact: Impact
}
+/** A pinned element plus its display metadata and 1-based badge number. */
+export interface PinInfo extends HighlightInfo {
+ el: Element
+ /** 1-based badge number, unique across the whole pin set. */
+ number: number
+}
+
export interface Overlay {
root: HTMLElement
- show: (el: Element, info: HighlightInfo) => void
- hide: () => void
+ /** Draw the transient hover-preview ring around an element. */
+ preview: (el: Element, info: HighlightInfo) => void
+ /** Clear the transient hover-preview ring. */
+ clearPreview: () => void
+ /** Render the full set of numbered pinned rings, replacing any prior set. */
+ setPins: (pins: PinInfo[]) => void
}
-/**
- * A pointer-transparent overlay drawn over the host page. It mirrors the
- * panel's "focus ring" motif: hovering a violation in the panel paints the
- * same ring around the offending element here, tying the list back to the page.
- *
- * Everything is inline-styled and stamped with a unique attribute so it can't
- * inherit from — or be restyled by — the host application's CSS.
- */
-export function createOverlay(): Overlay {
- const root = document.createElement('div')
- root.setAttribute('data-df-a11y-overlay', '')
- root.style.cssText = [
- 'position:fixed',
- 'inset:0',
- 'pointer-events:none',
- 'z-index:2147483646',
- 'contain:strict',
- 'display:none',
- ].join(';')
+/** True for the document root elements we must not wrap in a full-page ring. */
+function isRootElement(el: Element): boolean {
+ return el === document.documentElement || el === document.body
+}
+
+interface Marker {
+ box: HTMLElement
+ label: HTMLElement
+ el: Element | null
+}
+function createMarker(root: HTMLElement): Marker {
const box = document.createElement('div')
box.style.cssText = [
'position:absolute',
'box-sizing:border-box',
'border-radius:3px',
+ 'display:none',
`transition:${PREFERS_REDUCED_MOTION ? 'none' : 'all .12s cubic-bezier(.4,0,.2,1)'}`,
].join(';')
@@ -47,6 +51,7 @@ export function createOverlay(): Overlay {
label.style.cssText = [
'position:absolute',
'left:0',
+ 'top:0',
'transform:translateY(-100%)',
'font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace',
'letter-spacing:.02em',
@@ -58,22 +63,84 @@ export function createOverlay(): Overlay {
box.appendChild(label)
root.appendChild(box)
+ return { box, label, el: null }
+}
+
+/**
+ * A pointer-transparent overlay drawn over the host page. It mirrors the
+ * panel's "focus ring" motif: hovering a violation in the panel paints a
+ * transient ring, and pinning violations paints persistent, numbered rings —
+ * so a row and its highlighted element read as one object.
+ *
+ * Everything is inline-styled and stamped with a unique attribute so it can't
+ * inherit from — or be restyled by — the host application's CSS. Root-element
+ * (``/``) targets get a non-positioned corner notice instead of a
+ * viewport-filling ring.
+ */
+export function createOverlay(): Overlay {
+ const root = document.createElement('div')
+ root.setAttribute('data-df-a11y-overlay', '')
+ root.style.cssText = [
+ 'position:fixed',
+ 'inset:0',
+ 'pointer-events:none',
+ 'z-index:2147483646',
+ 'display:block',
+ ].join(';')
+
+ // A dedicated corner notice for root-element targets that can't be ringed.
+ const notice = document.createElement('div')
+ notice.style.cssText = [
+ 'position:absolute',
+ 'left:12px',
+ 'bottom:12px',
+ 'max-width:320px',
+ 'display:none',
+ 'font:600 11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace',
+ 'padding:6px 10px',
+ 'border-radius:6px',
+ 'color:#0b0e13',
+ 'box-shadow:0 8px 30px rgb(0 0 0 / 35%)',
+ ].join(';')
+ root.appendChild(notice)
+
+ const preview = createMarker(root)
+ const pins: Marker[] = []
- let current: Element | null = null
let rafId = 0
+ let noticeText = ''
+
+ function styleBox(marker: Marker, color: string, dim: boolean) {
+ marker.box.style.border = `2px solid ${color}`
+ marker.box.style.boxShadow = dim
+ ? `0 0 0 3px ${color}26`
+ : `0 0 0 4px ${color}33, 0 8px 30px ${color}26`
+ marker.box.style.background = `${color}14`
+ marker.label.style.background = color
+ }
- function place() {
- if (!current)
+ function place(marker: Marker) {
+ const el = marker.el
+ if (!el)
return
- const rect = current.getBoundingClientRect()
+ const rect = el.getBoundingClientRect()
const pad = 2
- box.style.top = `${rect.top - pad}px`
- box.style.left = `${rect.left - pad}px`
- box.style.width = `${rect.width + pad * 2}px`
- box.style.height = `${rect.height + pad * 2}px`
+ marker.box.style.top = `${rect.top - pad}px`
+ marker.box.style.left = `${rect.left - pad}px`
+ marker.box.style.width = `${rect.width + pad * 2}px`
+ marker.box.style.height = `${rect.height + pad * 2}px`
// Flip the label below the box when it would clip past the top edge.
- label.style.transform = rect.top < 22 ? 'translateY(0)' : 'translateY(-100%)'
- label.style.top = rect.top < 22 ? '100%' : '0'
+ marker.label.style.transform = rect.top < 22 ? 'translateY(0)' : 'translateY(-100%)'
+ marker.label.style.top = rect.top < 22 ? '100%' : '0'
+ }
+
+ function placeAll() {
+ if (preview.el)
+ place(preview)
+ for (const marker of pins) {
+ if (marker.el)
+ place(marker)
+ }
}
function onViewportChange() {
@@ -81,37 +148,110 @@ export function createOverlay(): Overlay {
return
rafId = requestAnimationFrame(() => {
rafId = 0
- place()
+ placeAll()
})
}
- function show(el: Element, info: HighlightInfo) {
- current = el
- const color = IMPACT_COLOR[info.impact]
- box.style.border = `2px solid ${color}`
- box.style.boxShadow = `0 0 0 4px ${color}33, 0 8px 30px ${color}26`
- box.style.background = `${color}14`
- label.style.background = color
- label.textContent = `${info.impact} · ${info.ruleId}`
-
- root.style.display = 'block'
- // Bring the target into view if it is off-screen.
+ let listening = false
+ function ensureListening() {
+ if (listening)
+ return
+ listening = true
+ addEventListener('scroll', onViewportChange, { passive: true, capture: true })
+ addEventListener('resize', onViewportChange, { passive: true })
+ }
+ function maybeStopListening() {
+ if (listening && !preview.el && pins.length === 0) {
+ listening = false
+ removeEventListener('scroll', onViewportChange, { capture: true } as EventListenerOptions)
+ removeEventListener('resize', onViewportChange)
+ }
+ }
+
+ function scrollIntoViewIfNeeded(el: Element) {
const rect = el.getBoundingClientRect()
const offscreen = rect.bottom < 0 || rect.top > innerHeight
if (offscreen)
el.scrollIntoView({ block: 'center', behavior: PREFERS_REDUCED_MOTION ? 'auto' : 'smooth' })
- place()
-
- addEventListener('scroll', onViewportChange, { passive: true, capture: true })
- addEventListener('resize', onViewportChange, { passive: true })
}
- function hide() {
- current = null
- root.style.display = 'none'
- removeEventListener('scroll', onViewportChange, { capture: true } as EventListenerOptions)
- removeEventListener('resize', onViewportChange)
+ function showNotice(color: string, text: string) {
+ noticeText = text
+ notice.style.background = color
+ notice.textContent = text
+ notice.style.display = 'block'
}
+ function hideNotice() {
+ noticeText = ''
+ notice.style.display = 'none'
+ }
+
+ return {
+ root,
+
+ preview(el, info) {
+ if (isRootElement(el)) {
+ preview.el = null
+ preview.box.style.display = 'none'
+ showNotice(IMPACT_COLOR[info.impact], `${info.impact} · ${info.ruleId} — whole page`)
+ return
+ }
+ preview.el = el
+ const color = IMPACT_COLOR[info.impact]
+ styleBox(preview, color, false)
+ preview.label.textContent = `${info.impact} · ${info.ruleId}`
+ preview.box.style.display = 'block'
+ ensureListening()
+ scrollIntoViewIfNeeded(el)
+ place(preview)
+ },
+
+ clearPreview() {
+ preview.el = null
+ preview.box.style.display = 'none'
+ if (noticeText.includes('whole page') && pins.length === 0)
+ hideNotice()
+ maybeStopListening()
+ },
- return { root, show, hide }
+ setPins(next) {
+ // Rebuild the marker pool to match the requested pin count.
+ while (pins.length < next.length)
+ pins.push(createMarker(root))
+ while (pins.length > next.length) {
+ const marker = pins.pop()!
+ marker.box.remove()
+ }
+
+ let rootPin: PinInfo | null = null
+ next.forEach((pin, i) => {
+ const marker = pins[i]!
+ if (isRootElement(pin.el)) {
+ marker.el = null
+ marker.box.style.display = 'none'
+ rootPin = pin
+ return
+ }
+ marker.el = pin.el
+ const color = IMPACT_COLOR[pin.impact]
+ styleBox(marker, color, true)
+ marker.label.textContent = `${pin.number} · ${pin.ruleId}`
+ marker.box.style.display = 'block'
+ place(marker)
+ })
+
+ if (rootPin)
+ showNotice(IMPACT_COLOR[(rootPin as PinInfo).impact], `${(rootPin as PinInfo).number} · ${(rootPin as PinInfo).ruleId} — whole page`)
+ else if (!preview.el)
+ hideNotice()
+
+ if (next.length > 0) {
+ ensureListening()
+ const first = next.find(p => !isRootElement(p.el))
+ if (first)
+ scrollIntoViewIfNeeded(first.el)
+ }
+ maybeStopListening()
+ },
+ }
}
diff --git a/plugins/a11y/src/inject/scanner.ts b/plugins/a11y/src/inject/scanner.ts
index 17a4da08..ff81d86c 100644
--- a/plugins/a11y/src/inject/scanner.ts
+++ b/plugins/a11y/src/inject/scanner.ts
@@ -1,6 +1,6 @@
import type { ScanReport, Violation, ViolationNode } from '../shared/protocol.ts'
import axe from 'axe-core'
-import { A11Y_NODE_ATTR, emptyCounts, IMPACT_ORDER } from '../shared/protocol.ts'
+import { A11Y_NODE_ATTR, DEFAULT_AXE_TAGS, emptyCounts, IMPACT_ORDER } from '../shared/protocol.ts'
const IMPACTS = new Set(IMPACT_ORDER)
let counter = 0
@@ -51,20 +51,29 @@ function stamp(el: Element): string {
return id
}
+export interface ScanOptions {
+ /** axe rule tags to run (defaults to {@link DEFAULT_AXE_TAGS}). */
+ tags?: string[]
+ /** Extra axe `run` options merged over the defaults. */
+ runOptions?: Record
+}
+
/**
* Run axe against the live document and shape the result into a {@link ScanReport}.
* Stamps each violating element with {@link A11Y_NODE_ATTR} as a side effect.
*/
-export async function scan(): Promise {
+export async function scan(options: ScanOptions = {}): Promise {
+ const tags = options.tags?.length ? options.tags : [...DEFAULT_AXE_TAGS]
const results = await axe.run(document, {
resultTypes: ['violations'],
- // Keep the report focused on real failures; skip best-practice noise that
- // would bury the actionable items for a first-pass tool.
- runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
+ // Broadened past strict WCAG A/AA to WCAG 2.2 + best-practice; the panel
+ // tags best-practice rules and can filter them back out.
+ runOnly: { type: 'tag', values: tags },
// Stay in the host document — don't descend into the devtools panel's own
// iframe (or any other frame), which would mix unrelated nodes into the
// report and risk scanning ourselves.
iframes: false,
+ ...options.runOptions,
})
const counts = emptyCounts()
@@ -89,6 +98,7 @@ export async function scan(): Promise {
description: rule.description,
helpUrl: rule.helpUrl,
tags: rule.tags.filter(tag => tag.startsWith('wcag')),
+ bestPractice: rule.tags.includes('best-practice'),
nodes,
}
})
@@ -97,6 +107,7 @@ export async function scan(): Promise {
violations.sort((a, b) => IMPACT_ORDER.indexOf(a.impact) - IMPACT_ORDER.indexOf(b.impact))
return {
+ route: location.pathname,
url: location.href,
scannedAt: Date.now(),
engine: results.testEngine?.version ?? 'unknown',
diff --git a/plugins/a11y/src/node/index.ts b/plugins/a11y/src/node/index.ts
index d369e26c..dc07fd92 100644
--- a/plugins/a11y/src/node/index.ts
+++ b/plugins/a11y/src/node/index.ts
@@ -1,13 +1,15 @@
import type { DevframeNodeContext } from 'devframe/types'
-import { serverFunctions } from '../rpc/index.ts'
+import type { A11yRuntimeConfig } from '../rpc/functions/get-config.ts'
+import { buildServerFunctions, serverFunctions } from '../rpc/index.ts'
/**
* Register the a11y inspector's RPC functions on a devframe node context.
* Called from the definition's `setup(ctx)` and reusable by host adapters
- * that wire their own context.
+ * that wire their own context. Author options (auto-scan, logging, axe tags,
+ * default-highlight) are surfaced through the `get-config` function.
*/
-export function setupA11y(ctx: DevframeNodeContext): void {
- for (const fn of serverFunctions)
+export function setupA11y(ctx: DevframeNodeContext, options: A11yRuntimeConfig = {}): void {
+ for (const fn of buildServerFunctions(options))
ctx.rpc.register(fn)
}
diff --git a/plugins/a11y/src/rpc/functions/get-config.ts b/plugins/a11y/src/rpc/functions/get-config.ts
index acbb8d1c..5999f997 100644
--- a/plugins/a11y/src/rpc/functions/get-config.ts
+++ b/plugins/a11y/src/rpc/functions/get-config.ts
@@ -1,5 +1,6 @@
+import type { AgentConfig } from '../../shared/protocol.ts'
import { defineRpcFunction } from 'devframe'
-import { A11Y_CHANNEL, A11Y_NODE_ATTR, IMPACT_ORDER } from '../../shared/protocol.ts'
+import { A11Y_CHANNEL, A11Y_DEFAULT_DOCK_ID, A11Y_NODE_ATTR, IMPACT_ORDER } from '../../shared/protocol.ts'
const IMPACT_COPY = {
critical: {
@@ -20,23 +21,56 @@ const IMPACT_COPY = {
},
} as const
+/** Author-supplied runtime configuration surfaced through `get-config`. */
+export interface A11yRuntimeConfig {
+ /** The devframe id, so messages-feed actions can activate this dock. */
+ dockId?: string
+ /** Rescan on debounced user interaction (default `true`). */
+ autoScan?: boolean
+ /** Log newly-appeared violations to the browser console (default `true`). */
+ logIssues?: boolean
+ /** Auto-pin all of a route's violations the first time it's scanned (default `false`). */
+ defaultHighlight?: boolean
+ /** axe configuration. */
+ axe?: {
+ /** Rule tags to run (defaults to the broadened WCAG 2.0–2.2 + best-practice set). */
+ tags?: string[]
+ /** Extra axe `run` options merged over the defaults. */
+ runOptions?: Record
+ }
+}
+
/**
- * Static metadata the panel reads once on load: the impact taxonomy (with
- * developer-facing copy) and the channel coordinates the panel uses to find
- * its injected agent.
- *
- * Declared `static`, so the value is resolved live over WebSocket in dev and
- * baked into the RPC dump for static builds — the panel's legend renders the
- * same in both modes.
+ * Build the `get-config` RPC function from author options. Declared `static`,
+ * so the value resolves live over WebSocket in dev and is baked into the RPC
+ * dump for static builds — the panel's legend + runtime config render the same
+ * in both modes. The panel forwards the `agent` slice to the in-page agent over
+ * the BroadcastChannel, keeping the agent free of any RPC dependency.
*/
-export const getConfig = defineRpcFunction({
- name: 'devframes:plugin:a11y:get-config',
- type: 'static',
- jsonSerializable: true,
- handler: () => ({
- channel: A11Y_CHANNEL,
- nodeAttr: A11Y_NODE_ATTR,
- docsBase: 'https://dequeuniversity.com/rules/axe/',
- impacts: IMPACT_ORDER.map(id => ({ id, ...IMPACT_COPY[id] })),
- }),
-})
+export function createGetConfig(options: A11yRuntimeConfig = {}) {
+ const dockId = options.dockId ?? A11Y_DEFAULT_DOCK_ID
+ const agent: AgentConfig = {
+ logIssues: options.logIssues ?? true,
+ autoScan: options.autoScan ?? true,
+ axeTags: options.axe?.tags,
+ axeRunOptions: options.axe?.runOptions,
+ activateDockId: dockId,
+ }
+ return defineRpcFunction({
+ name: 'devframes:plugin:a11y:get-config',
+ type: 'static',
+ jsonSerializable: true,
+ handler: () => ({
+ channel: A11Y_CHANNEL,
+ nodeAttr: A11Y_NODE_ATTR,
+ docsBase: 'https://dequeuniversity.com/rules/axe/',
+ dockId,
+ defaultHighlight: options.defaultHighlight ?? false,
+ agent,
+ impacts: IMPACT_ORDER.map(id => ({ id, ...IMPACT_COPY[id] })),
+ }),
+ })
+}
+
+/** Default `get-config` instance — used for the RPC type augmentation. */
+export const getConfig = createGetConfig()
diff --git a/plugins/a11y/src/rpc/index.ts b/plugins/a11y/src/rpc/index.ts
index b1b55030..e1c5f5b8 100644
--- a/plugins/a11y/src/rpc/index.ts
+++ b/plugins/a11y/src/rpc/index.ts
@@ -1,8 +1,15 @@
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
-import { getConfig } from './functions/get-config.ts'
+import type { A11yRuntimeConfig } from './functions/get-config.ts'
+import { createGetConfig, getConfig } from './functions/get-config.ts'
+/** Default function set — drives the RPC type augmentation below. */
export const serverFunctions = [getConfig] as const
+/** Build the function set configured with author options. */
+export function buildServerFunctions(options: A11yRuntimeConfig = {}) {
+ return [createGetConfig(options)] as const
+}
+
declare module 'devframe' {
interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {}
}
diff --git a/plugins/a11y/src/shared/protocol.ts b/plugins/a11y/src/shared/protocol.ts
index 0c64c211..f27ab6d0 100644
--- a/plugins/a11y/src/shared/protocol.ts
+++ b/plugins/a11y/src/shared/protocol.ts
@@ -1,8 +1,8 @@
/**
* Wire contract shared by the two browser-side halves of the a11y inspector:
*
- * - the **agent** — injected into the host application's page, runs axe-core
- * and owns the highlight overlay (`src/inject`);
+ * - the **agent** — injected into the host application's page, runs axe-core,
+ * tracks issues per route, and owns the highlight overlay (`src/inject`);
* - the **panel** — the Solid SPA that lists violations (`src/client`).
*
* They talk over a same-origin {@link https://developer.mozilla.org/docs/Web/API/BroadcastChannel BroadcastChannel}
@@ -11,20 +11,47 @@
* RPC) or as a baked static build — neither half needs a server to find the
* other, only a shared browser origin (host page + devtools iframe).
*
- * devframe RPC still carries the data model on top of this: `get-config`
- * (rule catalogue + impact metadata) is a `static` function, so it resolves
- * live in dev and from the baked dump in a static build.
+ * The agent owns the authoritative route → report map (backed by
+ * sessionStorage) and broadcasts the whole {@link A11yState} aggregate on every
+ * change, so the panel stays a pure render of it. Runtime configuration
+ * (`get-config`, a `static` RPC the panel resolves) is forwarded to the agent
+ * over the same channel as an {@link AgentConfig}, keeping the agent itself
+ * free of any RPC dependency.
*/
/** BroadcastChannel name. Namespaced with the devframe id, per convention. */
export const A11Y_CHANNEL = 'devframes:plugin:a11y'
+/** Default dock id — the devframe id a hub registers the panel under. */
+export const A11Y_DEFAULT_DOCK_ID = 'devframes_plugin_a11y'
+
+/** sessionStorage key the agent persists its route → report map under. */
+export const A11Y_STORAGE_KEY = 'devframes:plugin:a11y:routes'
+
+/** Shared-state slot the hub mirrors the active dock activation into. */
+export const A11Y_DOCKS_ACTIVE_KEY = 'devframe:docks:active'
+
/**
* Attribute the agent stamps on each violating element so the panel can ask
* for a stable target that survives re-scans and DOM reshuffles.
*/
export const A11Y_NODE_ATTR = 'data-df-a11y-node'
+/**
+ * Default axe rule tags the scanner runs. Broadened past the strict WCAG A/AA
+ * set to include WCAG 2.2 and best-practice rules — the best-practice ones are
+ * flagged ({@link Violation.bestPractice}) so the panel can filter them out.
+ */
+export const DEFAULT_AXE_TAGS = [
+ 'wcag2a',
+ 'wcag2aa',
+ 'wcag21a',
+ 'wcag21aa',
+ 'wcag22a',
+ 'wcag22aa',
+ 'best-practice',
+] as const
+
/** axe-core impact buckets, ordered most → least severe. */
export const IMPACT_ORDER = ['critical', 'serious', 'moderate', 'minor'] as const
export type Impact = (typeof IMPACT_ORDER)[number]
@@ -64,11 +91,15 @@ export interface Violation {
helpUrl: string
/** WCAG tags the rule maps to, e.g. `["wcag2a", "wcag111"]`. */
tags?: string[]
+ /** Whether this is an axe best-practice rule rather than a WCAG success criterion. */
+ bestPractice?: boolean
nodes: ViolationNode[]
}
export interface ScanReport {
- /** Location of the scanned document. */
+ /** Route bucket key — `location.pathname` of the scanned document. */
+ route: string
+ /** Full location of the scanned document. */
url: string
/** Epoch ms the scan finished. */
scannedAt: number
@@ -79,27 +110,77 @@ export interface ScanReport {
counts: Record
}
+/**
+ * The whole route → report aggregate the agent broadcasts. `routes` holds one
+ * report per tracked route; `activeRoute` is the pathname currently in view in
+ * the host page.
+ */
+export interface A11yState {
+ /** axe engine version (from the most recent scan). */
+ engine: string
+ /** `location.pathname` currently in view in the host page. */
+ activeRoute: string
+ /** One report per tracked route. */
+ routes: ScanReport[]
+}
+
+/** A single element to pin/highlight, carried from panel to agent. */
+export interface PinTarget {
+ /** Node id from {@link ViolationNode.id}. */
+ nodeId: string
+ /** axe target selector(s); the fallback when the id no longer resolves. */
+ target: string[]
+ impact: Impact
+ ruleId: string
+}
+
+/**
+ * Runtime configuration the panel resolves from the `get-config` RPC and
+ * forwards to the agent over the channel (the agent keeps no RPC dependency).
+ */
+export interface AgentConfig {
+ /** Log newly-appeared violations to the browser console. */
+ logIssues: boolean
+ /** Rescan on debounced user interaction, on top of the DOM MutationObserver. */
+ autoScan: boolean
+ /** axe rule tags to run (defaults to {@link DEFAULT_AXE_TAGS}). */
+ axeTags?: string[]
+ /** Extra axe `run` options merged over the defaults. */
+ axeRunOptions?: Record
+ /**
+ * Dock id the messages-feed navigation actions target (the devframe id).
+ * Defaults to {@link A11Y_DEFAULT_DOCK_ID}.
+ */
+ activateDockId?: string
+}
+
/* ── agent → panel ─────────────────────────────────────────────────────── */
export interface AgentReadyMessage {
type: 'a11y:agent-ready'
url: string
+ route: string
}
-export interface ReportMessage {
- type: 'a11y:report'
- report: ScanReport
+export interface StateMessage {
+ type: 'a11y:state'
+ state: A11yState
}
export interface ScanningMessage {
type: 'a11y:scanning'
+ route: string
}
-export type AgentToPanel = AgentReadyMessage | ReportMessage | ScanningMessage
+export type AgentToPanel = AgentReadyMessage | StateMessage | ScanningMessage
/* ── panel → agent ─────────────────────────────────────────────────────── */
export interface PanelReadyMessage {
type: 'a11y:panel-ready'
}
+export interface ConfigMessage {
+ type: 'a11y:config'
+ config: AgentConfig
+}
export interface HighlightMessage {
type: 'a11y:highlight'
/** Node id from {@link ViolationNode.id}; falls back to the first target. */
@@ -109,15 +190,36 @@ export interface HighlightMessage {
export interface ClearHighlightMessage {
type: 'a11y:clear'
}
+export interface PinsMessage {
+ type: 'a11y:pins'
+ /** The full desired pin set; the agent draws numbered rings in this order. */
+ pins: PinTarget[]
+}
export interface RescanMessage {
type: 'a11y:rescan'
}
+export interface SetAutoScanMessage {
+ type: 'a11y:set-autoscan'
+ enabled: boolean
+}
+export interface ClearRouteMessage {
+ type: 'a11y:clear-route'
+ route: string
+}
+export interface ClearAllMessage {
+ type: 'a11y:clear-all'
+}
export type PanelToAgent
= | PanelReadyMessage
+ | ConfigMessage
| HighlightMessage
| ClearHighlightMessage
+ | PinsMessage
| RescanMessage
+ | SetAutoScanMessage
+ | ClearRouteMessage
+ | ClearAllMessage
export type A11yMessage = AgentToPanel | PanelToAgent
@@ -125,3 +227,13 @@ export type A11yMessage = AgentToPanel | PanelToAgent
export function emptyCounts(): Record {
return { critical: 0, serious: 0, moderate: 0, minor: 0 }
}
+
+/** Roll the per-impact counts of many reports into one total. */
+export function sumCounts(reports: ScanReport[]): Record {
+ const total = emptyCounts()
+ for (const report of reports) {
+ for (const impact of IMPACT_ORDER)
+ total[impact] += report.counts[impact]
+ }
+ return total
+}
diff --git a/plugins/a11y/src/spa/app.tsx b/plugins/a11y/src/spa/app.tsx
index cedce164..aaac455f 100644
--- a/plugins/a11y/src/spa/app.tsx
+++ b/plugins/a11y/src/spa/app.tsx
@@ -1,133 +1,424 @@
-import type { Impact } from '../shared/protocol.ts'
-import { createMemo, createSignal, Match, Show, Switch } from 'solid-js'
+import type { Impact, PinTarget, Violation, ViolationNode } from '../shared/protocol.ts'
+import type { SelectedItem } from './lib/fix-prompt.ts'
+import type { RouteGroupModel, SelectionApi } from './lib/violation-view.ts'
+import { batch, createEffect, createMemo, createSignal, Match, on, Show, Switch } from 'solid-js'
import { emptyCounts } from '../shared/protocol.ts'
-import { Header, MetaLine, Summary } from './components/header.tsx'
-import { CheckCircle, PlugIcon } from './components/icons.tsx'
-import { ViolationList } from './components/violations.tsx'
+import { EmptyState } from './components/EmptyState.tsx'
+import { FixPromptsDialog } from './components/FixPromptsDialog.tsx'
+import { Header } from './components/Header.tsx'
+import { MetaLine } from './components/MetaLine.tsx'
+import { SummaryBar } from './components/SummaryBar.tsx'
+import { ViolationList } from './components/ViolationList.tsx'
import { createA11yChannel } from './lib/channel.ts'
import { connectDevframeState } from './lib/devframe.ts'
-import { IMPACT_LABEL } from './lib/impact.ts'
+import { ruleCardId } from './lib/violation-view.ts'
const SNIPPET = ''
+const AUTOSCAN_KEY = 'devframes:plugin:a11y:autoscan'
+
+const selKey = (route: string, ruleId: string) => `${route}::${ruleId}`
+function nodePin(v: Violation, node: ViolationNode): PinTarget {
+ return { nodeId: node.id, target: node.target, impact: v.impact, ruleId: v.ruleId }
+}
export function App() {
const channel = createA11yChannel()
const devframe = connectDevframeState()
const [filter, setFilter] = createSignal(null)
- const [expanded, setExpanded] = createSignal>(new Set())
-
- const counts = () => channel.report()?.counts ?? emptyCounts()
- const violations = () => channel.report()?.violations ?? []
- const total = () => violations().length
- const filtered = createMemo(() => {
- const active = filter()
- return active ? violations().filter(v => v.impact === active) : violations()
+ const [showBestPractice, setShowBestPractice] = createSignal(true)
+ const [expandedRoutes, setExpandedRoutes] = createSignal>(new Set())
+ const [expandedRules, setExpandedRules] = createSignal>(new Set())
+ // Selected violations (`route::ruleId`) — drives both the in-page highlight
+ // and the "Generate fix prompts" dialog.
+ const [selected, setSelected] = createSignal>(new Set())
+ const [dialogOpen, setDialogOpen] = createSignal(false)
+ // Transient severity preview: hovering a summary chip lights up every element
+ // of that impact, overriding the selection highlight until the hover ends.
+ const [hoverImpact, setHoverImpact] = createSignal(null)
+
+ const storedAuto = (() => {
+ try {
+ return sessionStorage.getItem(AUTOSCAN_KEY)
+ }
+ catch {
+ return null
+ }
+ })()
+ const [autoScan, setAutoScan] = createSignal(storedAuto == null ? true : storedAuto === '1')
+
+ const routes = () => channel.state()?.routes ?? []
+ const activeRoute = () => channel.activeRoute()
+ const activeReport = createMemo(() => routes().find(r => r.route === activeRoute()) ?? null)
+ const engine = () => channel.state()?.engine
+
+ const includeViolation = (v: Violation) => showBestPractice() || !v.bestPractice
+ const matchFilter = (v: Violation) => filter() === null || v.impact === filter()
+
+ // Chip counts respect the best-practice toggle but not the impact filter, so
+ // the chips can drive the filter.
+ const chipCounts = createMemo(() => {
+ const counts = emptyCounts()
+ for (const report of routes()) {
+ for (const v of report.violations) {
+ if (!includeViolation(v))
+ continue
+ counts[v.impact] += v.nodes.length
+ }
+ }
+ return counts
+ })
+
+ const totalNodes = createMemo(() =>
+ routes().reduce((n, r) => n + r.violations.filter(includeViolation).reduce((m, v) => m + v.nodes.length, 0), 0))
+ const totalRules = createMemo(() =>
+ routes().reduce((n, r) => n + r.violations.filter(includeViolation).length, 0))
+
+ // Grouped, filtered violations — active route first, empty groups dropped.
+ const groups = createMemo(() => {
+ const models = routes().map(r => ({
+ report: r,
+ active: r.route === activeRoute(),
+ violations: r.violations.filter(v => includeViolation(v) && matchFilter(v)),
+ })).filter(g => g.violations.length > 0)
+ models.sort((a, b) => Number(b.active) - Number(a.active))
+ return models
+ })
+
+ const shownViolations = createMemo(() => groups().reduce((n, g) => n + g.violations.length, 0))
+ const totalFilterable = createMemo(() =>
+ routes().reduce((n, r) => n + r.violations.filter(includeViolation).length, 0))
+
+ const collapsedRoutes = createMemo(() => {
+ const collapsed = new Set()
+ for (const r of routes()) {
+ if (!expandedRoutes().has(r.route))
+ collapsed.add(r.route)
+ }
+ return collapsed
+ })
+
+ // ── selection → highlight + fix-prompt context ────────────────────────────
+ // Highlighted nodes, in a stable order, for numbered badges.
+ const selectedPins = createMemo(() => {
+ const sel = selected()
+ const out: PinTarget[] = []
+ for (const report of routes()) {
+ for (const v of report.violations) {
+ if (sel.has(selKey(report.route, v.ruleId))) {
+ for (const node of v.nodes)
+ out.push(nodePin(v, node))
+ }
+ }
+ }
+ return out
+ })
+
+ // Every element of a given impact (across routes), for the chip-hover preview.
+ const impactPins = (impact: Impact): PinTarget[] => {
+ const out: PinTarget[] = []
+ for (const report of routes()) {
+ for (const v of report.violations) {
+ if (includeViolation(v) && v.impact === impact) {
+ for (const node of v.nodes)
+ out.push(nodePin(v, node))
+ }
+ }
+ }
+ return out
+ }
+
+ const selectedItems = createMemo(() => {
+ const sel = selected()
+ const out: SelectedItem[] = []
+ for (const report of routes()) {
+ for (const v of report.violations) {
+ if (sel.has(selKey(report.route, v.ruleId)))
+ out.push({ route: report.route, url: report.url, violation: v })
+ }
+ }
+ return out
+ })
+
+ // Keys of the currently-visible (filtered) violations, for bulk selection.
+ const visibleKeys = createMemo(() =>
+ groups().flatMap(g => g.violations.map(v => selKey(g.report.route, v.ruleId))))
+ const allVisibleSelected = createMemo(() => {
+ const keys = visibleKeys()
+ return keys.length > 0 && keys.every(k => selected().has(k))
+ })
+
+ // Select every visible (filtered) violation.
+ function selectAll() {
+ const keys = visibleKeys()
+ if (keys.length === 0)
+ return
+ setSelected((prev) => {
+ const next = new Set(prev)
+ for (const k of keys)
+ next.add(k)
+ return next
+ })
+ }
+ // Invert the selection across the visible violations (selected ↔ unselected).
+ function invertSelection() {
+ const keys = visibleKeys()
+ if (keys.length === 0)
+ return
+ setSelected((prev) => {
+ const next = new Set(prev)
+ for (const k of keys) {
+ if (next.has(k))
+ next.delete(k)
+ else
+ next.add(k)
+ }
+ return next
+ })
+ }
+ // Clear the entire selection, including any hidden by the current filter.
+ function clearSelection() {
+ setSelected(new Set())
+ }
+
+ const selectionApi: SelectionApi = {
+ isSelected: (route, ruleId) => selected().has(selKey(route, ruleId)),
+ toggle: (route, ruleId) => {
+ const key = selKey(route, ruleId)
+ setSelected((prev) => {
+ const next = new Set(prev)
+ if (next.has(key))
+ next.delete(key)
+ else
+ next.add(key)
+ return next
+ })
+ },
+ numberOf: (nodeId) => {
+ const i = selectedPins().findIndex(p => p.nodeId === nodeId)
+ return i === -1 ? null : i + 1
+ },
+ }
+
+ // ── config → agent, and initial auto-scan default ─────────────────────────
+ let autoInit = false
+ createEffect(() => {
+ const cfg = devframe.config()
+ if (!cfg)
+ return
+ channel.sendConfig(cfg.agent)
+ if (!autoInit && storedAuto == null) {
+ autoInit = true
+ setAutoScan(cfg.agent.autoScan)
+ }
+ })
+
+ createEffect(on(autoScan, (v) => {
+ channel.setAutoScan(v)
+ try {
+ sessionStorage.setItem(AUTOSCAN_KEY, v ? '1' : '0')
+ }
+ catch {
+ // best-effort
+ }
+ }))
+
+ // Keep the active route's group expanded.
+ createEffect(() => {
+ const r = activeRoute()
+ if (r)
+ setExpandedRoutes(prev => (prev.has(r) ? prev : new Set(prev).add(r)))
+ })
+
+ // Push the highlight set to the in-page agent: the hovered impact's elements
+ // while a summary chip is hovered, otherwise the selection.
+ createEffect(() => {
+ const hov = hoverImpact()
+ channel.setPins(hov ? impactPins(hov) : selectedPins())
+ })
+
+ // defaultHighlight: select all of a route's violations the first time it's scanned.
+ const highlighted = new Set()
+ createEffect(() => {
+ const cfg = devframe.config()
+ const report = activeReport()
+ if (!cfg?.defaultHighlight || !report || highlighted.has(report.route))
+ return
+ highlighted.add(report.route)
+ setSelected((prev) => {
+ const next = new Set(prev)
+ for (const v of report.violations)
+ next.add(selKey(report.route, v.ruleId))
+ return next
+ })
})
+ // ── deep-linking from other docks (e.g. the messages feed) ────────────────
+ createEffect(on(devframe.activation, (act) => {
+ if (!act)
+ return
+ const cfg = devframe.config()
+ if (cfg && act.dockId !== cfg.dockId)
+ return
+ const params = act.params ?? {}
+ const route = typeof params.route === 'string' ? params.route : undefined
+ const ruleId = typeof params.ruleId === 'string' ? params.ruleId : undefined
+
+ // A dashboard/summary activation (or one with no target) just scrolls to top.
+ if (params.tab === 'dashboard' || !route) {
+ document.querySelector('#a11y-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
+ return
+ }
+
+ batch(() => {
+ setExpandedRoutes(prev => new Set(prev).add(route))
+ if (ruleId) {
+ setExpandedRules(prev => new Set(prev).add(`${route}::${ruleId}`))
+ setSelected(prev => new Set(prev).add(selKey(route, ruleId)))
+ }
+ })
+ if (ruleId)
+ setTimeout(() => document.getElementById(ruleCardId(route, ruleId))?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 60)
+ }, { defer: true }))
+
function toggleFilter(impact: Impact) {
setFilter(prev => (prev === impact ? null : impact))
}
- function toggleExpand(ruleId: string) {
- setExpanded((prev) => {
+ function toggleGroup(route: string) {
+ setExpandedRoutes((prev) => {
const next = new Set(prev)
- if (next.has(ruleId))
- next.delete(ruleId)
+ if (next.has(route))
+ next.delete(route)
else
- next.add(ruleId)
+ next.add(route)
+ return next
+ })
+ }
+ function toggleRule(route: string, ruleId: string) {
+ const key = `${route}::${ruleId}`
+ setExpandedRules((prev) => {
+ const next = new Set(prev)
+ if (next.has(key))
+ next.delete(key)
+ else
+ next.add(key)
return next
})
}
const announce = () => {
- if (!channel.report())
+ if (!channel.state())
return channel.scanning() ? 'Scanning the page' : ''
- return `${total()} accessibility ${total() === 1 ? 'issue' : 'issues'} found`
+ return `${totalNodes()} accessibility ${totalNodes() === 1 ? 'issue' : 'issues'} found across ${routes().length} ${routes().length === 1 ? 'route' : 'routes'}`
}
return (
-
+
setDialogOpen(true)}
onRescan={channel.rescan}
/>
-
-
- 0}>
-
-
+
- {announce()}
+ {announce()}
-
)
}
diff --git a/plugins/a11y/src/spa/components/EmptyState.stories.tsx b/plugins/a11y/src/spa/components/EmptyState.stories.tsx
new file mode 100644
index 00000000..bfe2494c
--- /dev/null
+++ b/plugins/a11y/src/spa/components/EmptyState.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { EmptyState } from './EmptyState.tsx'
+
+// The centered full-height message shown when there's nothing to list.
+const meta = {
+ title: 'A11y/EmptyState',
+ component: EmptyState,
+ parameters: { layout: 'fullscreen' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const NoPage: Story = {
+ args: {
+ icon: 'i-ph-plugs-duotone text-4xl',
+ title: 'No page connected',
+ body: 'Load the inspector agent in the app you want to check, then this panel will list its accessibility issues live.',
+ code: '',
+ },
+}
+
+export const Scanning: Story = {
+ args: {
+ icon: 'i-ph-plugs-duotone text-4xl',
+ title: 'Scanning the page…',
+ body: 'Running axe-core against the connected document.',
+ },
+}
+
+export const Clean: Story = {
+ args: {
+ clean: true,
+ icon: 'i-ph-check-circle-duotone text-4xl',
+ title: 'No violations',
+ body: 'axe-core found nothing to flag across the tracked routes.',
+ },
+}
diff --git a/plugins/a11y/src/spa/components/EmptyState.tsx b/plugins/a11y/src/spa/components/EmptyState.tsx
new file mode 100644
index 00000000..40c46786
--- /dev/null
+++ b/plugins/a11y/src/spa/components/EmptyState.tsx
@@ -0,0 +1,32 @@
+import type { JSX } from 'solid-js'
+import { Show } from 'solid-js'
+
+interface EmptyStateProps {
+ /** Icon utility class (with its own size), e.g. `i-ph-plugs-duotone text-4xl`. */
+ icon: string
+ title: string
+ body: JSX.Element
+ /** Tints the glyph success-green for the "all clear" case. */
+ clean?: boolean
+ /** Optional code snippet shown below the body. */
+ code?: string
+}
+
+/**
+ * The centered full-height message shown when there's nothing to list: no page
+ * connected, scanning, all-clear, or filtered-to-empty.
+ */
+export function EmptyState(props: EmptyStateProps) {
+ return (
+
+
+
{props.title}
+
{props.body}
+
+
+ {props.code}
+
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/FixPromptsDialog.stories.tsx b/plugins/a11y/src/spa/components/FixPromptsDialog.stories.tsx
new file mode 100644
index 00000000..86b23006
--- /dev/null
+++ b/plugins/a11y/src/spa/components/FixPromptsDialog.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import type { SelectedItem } from './FixPromptsDialog.tsx'
+import { makeViolation } from './_fixtures.ts'
+import { FixPromptsDialog } from './FixPromptsDialog.tsx'
+
+// The fix-prompts dialog: gathers the selected violations' context into one
+// paste-ready AI prompt, with a copy button.
+const meta = {
+ title: 'A11y/FixPromptsDialog',
+ component: FixPromptsDialog,
+ parameters: { layout: 'fullscreen' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const items: SelectedItem[] = [
+ { route: '/', url: 'https://example.test/', violation: makeViolation('image-alt', 'critical', 1) },
+ { route: '/forms', url: 'https://example.test/forms', violation: makeViolation('label', 'serious', 1) },
+]
+
+export const TwoViolations: Story = { args: { items, onClose: () => {} } }
diff --git a/plugins/a11y/src/spa/components/FixPromptsDialog.tsx b/plugins/a11y/src/spa/components/FixPromptsDialog.tsx
new file mode 100644
index 00000000..630f3c88
--- /dev/null
+++ b/plugins/a11y/src/spa/components/FixPromptsDialog.tsx
@@ -0,0 +1,89 @@
+import type { SelectedItem } from '../lib/fix-prompt.ts'
+import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
+import { button } from '../design'
+import { buildFixPrompt } from '../lib/fix-prompt.ts'
+
+export type { SelectedItem } from '../lib/fix-prompt.ts'
+
+interface FixPromptsDialogProps {
+ items: SelectedItem[]
+ onClose: () => void
+}
+
+/**
+ * A modal listing the AI fix prompt for the selected violations, with a copy
+ * button. Built to the same accessibility bar the tool enforces: labelled
+ * dialog, Escape/backdrop to close, focus moved in on open.
+ */
+export function FixPromptsDialog(props: FixPromptsDialogProps) {
+ const prompt = createMemo(() => buildFixPrompt(props.items))
+ const ruleCount = () => props.items.length
+ const [copied, setCopied] = createSignal(false)
+ let closeBtn: HTMLButtonElement | undefined
+
+ function copy() {
+ void navigator.clipboard?.writeText(prompt()).then(() => {
+ setCopied(true)
+ setTimeout(setCopied, 1500, false)
+ })
+ }
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape')
+ props.onClose()
+ }
+
+ onMount(() => {
+ addEventListener('keydown', onKeyDown)
+ closeBtn?.focus()
+ })
+ onCleanup(() => removeEventListener('keydown', onKeyDown))
+
+ return (
+ props.onClose()}>
+
e.stopPropagation()}
+ >
+
+
+
Fix prompts
+
+ {ruleCount()}
+ {' '}
+ {ruleCount() === 1 ? 'violation' : 'violations'}
+ {' '}
+ selected — copy the prompt into your AI assistant.
+
+
+
props.onClose()}>
+
+
+
+
+
+
+
+
+
+ {(item, i) => (
+ <>
+ 0}>{', '}
+ {item.violation.ruleId}
+ >
+ )}
+
+
+
+
+
+ {copied() ? 'Copied' : 'Copy prompt'}
+
+
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/Header.stories.tsx b/plugins/a11y/src/spa/components/Header.stories.tsx
new file mode 100644
index 00000000..d54a8b40
--- /dev/null
+++ b/plugins/a11y/src/spa/components/Header.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { Header } from './Header.tsx'
+
+// The top nav bar: brand, connection status dot, generate-prompts, and rescan.
+const meta = {
+ title: 'A11y/Header',
+ component: Header,
+ parameters: { layout: 'fullscreen' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+const base = { selectedCount: 0, onGenerate: noop, onRescan: noop }
+
+export const Connected: Story = { args: { ...base, agentReady: true, scanning: false } }
+export const Scanning: Story = { args: { ...base, agentReady: true, scanning: true } }
+export const WithSelection: Story = { args: { ...base, agentReady: true, scanning: false, selectedCount: 3 } }
+export const Disconnected: Story = { args: { ...base, agentReady: false, scanning: false } }
diff --git a/plugins/a11y/src/spa/components/Header.tsx b/plugins/a11y/src/spa/components/Header.tsx
new file mode 100644
index 00000000..3fd0c7ca
--- /dev/null
+++ b/plugins/a11y/src/spa/components/Header.tsx
@@ -0,0 +1,56 @@
+import { Show } from 'solid-js'
+import { button, nav, navBrand } from '../design'
+
+interface HeaderProps {
+ agentReady: boolean
+ scanning: boolean
+ selectedCount: number
+ onGenerate: () => void
+ onRescan: () => void
+}
+
+/** The top nav bar: brand, connection status, generate-prompts, and rescan. */
+export function Header(props: HeaderProps) {
+ const statusLabel = () =>
+ !props.agentReady ? 'No page connected' : props.scanning ? 'Scanning…' : 'Connected'
+ const dotClass = () =>
+ !props.agentReady ? 'bg-neutral-400' : props.scanning ? 'bg-primary-500 animate-pulse' : 'bg-success'
+
+ return (
+
+
+
+ A11y Inspector
+
+
+
+
+ {statusLabel()}
+
+ props.onGenerate()}
+ disabled={props.selectedCount === 0}
+ title="Generate AI fix prompts for the selected violations"
+ >
+
+ Generate fix prompts
+ 0}>
+
+ {props.selectedCount}
+
+
+
+ props.onRescan()}
+ disabled={!props.agentReady || props.scanning}
+ >
+
+ Rescan
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/MetaLine.stories.tsx b/plugins/a11y/src/spa/components/MetaLine.stories.tsx
new file mode 100644
index 00000000..d03a1fab
--- /dev/null
+++ b/plugins/a11y/src/spa/components/MetaLine.stories.tsx
@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { MetaLine } from './MetaLine.tsx'
+
+// The mono meta strip under the nav: scanned URL, backend tag, axe version.
+const meta = {
+ title: 'A11y/MetaLine',
+ component: MetaLine,
+ parameters: { layout: 'padded' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const base = {
+ url: 'https://example.test/checkout',
+ engine: '4.10.0',
+ backend: () => 'websocket',
+ status: () => 'connected',
+}
+
+/** Live dev server (WebSocket backend). */
+export const Dev: Story = { args: base }
+
+/** Baked static build. */
+export const Static: Story = { args: { ...base, backend: () => 'static' } }
+
+/** Degraded backend connection — surfaced as a quiet amber tag. */
+export const Degraded: Story = { args: { ...base, status: () => 'disconnected' } }
diff --git a/plugins/a11y/src/spa/components/MetaLine.tsx b/plugins/a11y/src/spa/components/MetaLine.tsx
new file mode 100644
index 00000000..0e0d4801
--- /dev/null
+++ b/plugins/a11y/src/spa/components/MetaLine.tsx
@@ -0,0 +1,38 @@
+import { Show } from 'solid-js'
+
+interface MetaLineProps {
+ url?: string
+ engine?: string
+ backend: () => string | null
+ status?: () => string | null
+}
+
+/** The mono meta strip under the nav: scanned URL, backend tag, axe version. */
+export function MetaLine(props: MetaLineProps) {
+ // The backend is optional here, so a degraded connection is shown as a quiet
+ // tag rather than taking over the panel.
+ const degraded = () => {
+ const s = props.status?.()
+ return s === 'disconnected' || s === 'unauthorized' || s === 'error' ? s : null
+ }
+ return (
+
+
+ {props.url}
+ {b => {b()} } }
+ >
+ {s => {s()} }
+
+
+
+ axe
+ {' '}
+ {props.engine}
+
+
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/RouteGroup.stories.tsx b/plugins/a11y/src/spa/components/RouteGroup.stories.tsx
new file mode 100644
index 00000000..5afdb10d
--- /dev/null
+++ b/plugins/a11y/src/spa/components/RouteGroup.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { makeReport, makeViolation, stubChannel, stubSelection } from './_fixtures.ts'
+import { RouteGroup } from './RouteGroup.tsx'
+
+// A collapsible group of one route's violations, headed by its path + counts.
+const meta = {
+ title: 'A11y/RouteGroup',
+ component: RouteGroup,
+ parameters: { layout: 'padded' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+const violations = [makeViolation('image-alt', 'critical', 2), makeViolation('label', 'serious', 1)]
+const base = {
+ report: makeReport('/forms', violations),
+ violations,
+ onToggleGroup: noop,
+ onClearRoute: noop,
+ expandedRules: new Set(['/forms::image-alt']),
+ onToggleRule: noop,
+ channel: stubChannel,
+ selection: stubSelection,
+}
+
+export const ActiveExpanded: Story = { args: { ...base, active: true, collapsed: false } }
+export const Collapsed: Story = { args: { ...base, active: false, collapsed: true } }
diff --git a/plugins/a11y/src/spa/components/RouteGroup.tsx b/plugins/a11y/src/spa/components/RouteGroup.tsx
new file mode 100644
index 00000000..c91c57f4
--- /dev/null
+++ b/plugins/a11y/src/spa/components/RouteGroup.tsx
@@ -0,0 +1,71 @@
+import type { ScanReport, Violation } from '../../shared/protocol.ts'
+import type { A11yChannel } from '../lib/channel.ts'
+import type { SelectionApi } from '../lib/violation-view.ts'
+import { For, Show } from 'solid-js'
+import { ViolationRow } from './ViolationRow.tsx'
+
+interface RouteGroupProps {
+ report: ScanReport
+ active: boolean
+ violations: Violation[]
+ collapsed: boolean
+ onToggleGroup: () => void
+ onClearRoute: () => void
+ expandedRules: Set
+ onToggleRule: (ruleId: string) => void
+ channel: A11yChannel
+ selection: SelectionApi
+}
+
+/** A collapsible group of one route's violations, headed by its path + counts. */
+export function RouteGroup(props: RouteGroupProps) {
+ return (
+
+
+ props.onToggleGroup()}
+ >
+
+ {props.report.route}
+
+ active
+
+
+ {props.violations.length}
+ {' '}
+ {props.violations.length === 1 ? 'rule' : 'rules'}
+
+
+ props.onClearRoute()}
+ >
+
+
+
+
+
+
+ {violation => (
+ props.onToggleRule(violation.ruleId)}
+ onToggleSelect={() => props.selection.toggle(props.report.route, violation.ruleId)}
+ channel={props.channel}
+ numberOf={props.selection.numberOf}
+ />
+ )}
+
+
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/Summary.stories.tsx b/plugins/a11y/src/spa/components/Summary.stories.tsx
new file mode 100644
index 00000000..8eb115d7
--- /dev/null
+++ b/plugins/a11y/src/spa/components/Summary.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { Summary } from './Summary.tsx'
+
+// The severity summary chips — the one expressive, domain-specific color in the
+// inspector, and the impact filter. Presentational: driven by per-impact counts.
+const meta = {
+ title: 'A11y/Summary',
+ component: Summary,
+ parameters: { layout: 'padded' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+const counts = { critical: 3, serious: 5, moderate: 2, minor: 8 }
+
+export const Issues: Story = { args: { counts, active: null, onToggle: noop } }
+export const Filtered: Story = { args: { counts, active: 'serious', onToggle: noop } }
+export const Clean: Story = {
+ args: { counts: { critical: 0, serious: 0, moderate: 0, minor: 0 }, active: null, onToggle: noop },
+}
diff --git a/plugins/a11y/src/spa/components/Summary.tsx b/plugins/a11y/src/spa/components/Summary.tsx
new file mode 100644
index 00000000..3d284a0d
--- /dev/null
+++ b/plugins/a11y/src/spa/components/Summary.tsx
@@ -0,0 +1,57 @@
+import type { Impact } from '../../shared/protocol.ts'
+import { For } from 'solid-js'
+import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts'
+
+interface SummaryProps {
+ counts: Record
+ active: Impact | null
+ onToggle: (impact: Impact) => void
+ /** Hover/focus a chip to preview every element of that impact in the page. */
+ onHover?: (impact: Impact | null) => void
+}
+
+const IMPACTS: Impact[] = ['critical', 'serious', 'moderate', 'minor']
+
+/**
+ * The severity summary chips — also the impact filter. The one expressive,
+ * domain-specific color (WCAG severity) rides an inline `--impact` CSS var so
+ * the utility classes can reference it.
+ */
+export function Summary(props: SummaryProps) {
+ return (
+
+
+ {(impact) => {
+ const count = () => props.counts[impact]
+ const pressed = () => props.active === impact
+ return (
+ 0,
+ 'color-muted': count() === 0,
+ }}
+ style={{ '--impact': IMPACT_COLOR[impact] }}
+ aria-pressed={pressed()}
+ aria-label={`${count()} ${IMPACT_LABEL[impact]} issues — filter and highlight`}
+ onClick={() => props.onToggle(impact)}
+ onMouseEnter={() => props.onHover?.(impact)}
+ onMouseLeave={() => props.onHover?.(null)}
+ onFocus={() => props.onHover?.(impact)}
+ onBlur={() => props.onHover?.(null)}
+ >
+
+ {count()}
+
+
+ {IMPACT_LABEL[impact]}
+
+
+ )
+ }}
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/SummaryBar.stories.tsx b/plugins/a11y/src/spa/components/SummaryBar.stories.tsx
new file mode 100644
index 00000000..617765f2
--- /dev/null
+++ b/plugins/a11y/src/spa/components/SummaryBar.stories.tsx
@@ -0,0 +1,39 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { SummaryBar } from './SummaryBar.tsx'
+
+// The single-page summary band: severity chips (impact filter), a one-line
+// count, and the scan / best-practice / clear controls.
+const meta = {
+ title: 'A11y/SummaryBar',
+ component: SummaryBar,
+ parameters: { layout: 'padded' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+const base = {
+ counts: { critical: 3, serious: 5, moderate: 2, minor: 8 },
+ filter: null,
+ onToggleFilter: noop,
+ onHoverImpact: noop,
+ totalNodes: 18,
+ totalRules: 7,
+ routeCount: 3,
+ selectedCount: 0,
+ allSelected: false,
+ onSelectAll: noop,
+ onInvertSelection: noop,
+ onClearSelection: noop,
+ autoScan: true,
+ onToggleAutoScan: noop,
+ showBestPractice: true,
+ onToggleBestPractice: noop,
+ onClearAll: noop,
+}
+
+export const Overview: Story = { args: base }
+export const Filtered: Story = { args: { ...base, filter: 'serious' } }
+export const WithSelection: Story = { args: { ...base, selectedCount: 4 } }
+export const AllSelected: Story = { args: { ...base, selectedCount: 7, allSelected: true } }
diff --git a/plugins/a11y/src/spa/components/SummaryBar.tsx b/plugins/a11y/src/spa/components/SummaryBar.tsx
new file mode 100644
index 00000000..dc70547d
--- /dev/null
+++ b/plugins/a11y/src/spa/components/SummaryBar.tsx
@@ -0,0 +1,105 @@
+import type { Impact } from '../../shared/protocol.ts'
+import { Show } from 'solid-js'
+import { Summary } from './Summary.tsx'
+import { Switch } from './Switch.tsx'
+
+interface SummaryBarProps {
+ counts: Record
+ filter: Impact | null
+ onToggleFilter: (impact: Impact) => void
+ onHoverImpact: (impact: Impact | null) => void
+ totalNodes: number
+ totalRules: number
+ routeCount: number
+ /** Number of violations currently selected. */
+ selectedCount: number
+ /** Whether every currently-visible violation is selected. */
+ allSelected: boolean
+ /** Select all visible violations. */
+ onSelectAll: () => void
+ /** Invert the selection across the visible violations. */
+ onInvertSelection: () => void
+ /** Clear the entire selection (including any hidden by the filter). */
+ onClearSelection: () => void
+ autoScan: boolean
+ onToggleAutoScan: (enabled: boolean) => void
+ showBestPractice: boolean
+ onToggleBestPractice: (show: boolean) => void
+ onClearAll: () => void
+}
+
+const ACTION = 'inline-flex items-center gap-1.5 text-xs color-muted bg-secondary border border-base rounded-md px-2.5 py-1 cursor-pointer transition hover:bg-active outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:op-40 disabled:cursor-default'
+
+/**
+ * The compact, sticky summary band that heads the single-page panel: the
+ * severity chips (doubling as the impact filter), a one-line count, bulk
+ * selection actions, and the scan / best-practice / clear controls.
+ */
+export function SummaryBar(props: SummaryBarProps) {
+ const plural = (n: number, one: string) => `${n} ${n === 1 ? one : `${one}s`}`
+ return (
+
+
+
+
+
+ {plural(props.totalNodes, 'issue')}
+ {' · '}
+ {plural(props.totalRules, 'rule')}
+ {' · '}
+ {plural(props.routeCount, 'route')}
+
+
+ props.onSelectAll()}
+ disabled={props.allSelected}
+ title="Select all visible violations"
+ >
+
+ Select all
+
+
+ props.onInvertSelection()}
+ title="Invert the selection across the visible violations"
+ >
+
+ Invert
+
+
+ 0}>
+ props.onClearSelection()}
+ title="Clear the whole selection"
+ >
+
+ Clear selection
+ {props.selectedCount}
+
+
+
+
+
+
+
+
+ props.onClearAll()}
+ title="Clear all tracked routes"
+ >
+
+ Clear
+
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/Switch.stories.tsx b/plugins/a11y/src/spa/components/Switch.stories.tsx
new file mode 100644
index 00000000..b1608fad
--- /dev/null
+++ b/plugins/a11y/src/spa/components/Switch.stories.tsx
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { Switch } from './Switch.tsx'
+
+// The small labelled toggle used for the scan / best-practice controls.
+const meta = {
+ title: 'A11y/Switch',
+ component: Switch,
+ parameters: { layout: 'centered' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+
+export const On: Story = { args: { label: 'Auto-scan', checked: true, onChange: noop } }
+export const Off: Story = { args: { label: 'Best-practice', checked: false, onChange: noop } }
diff --git a/plugins/a11y/src/spa/components/Switch.tsx b/plugins/a11y/src/spa/components/Switch.tsx
new file mode 100644
index 00000000..1de6fcca
--- /dev/null
+++ b/plugins/a11y/src/spa/components/Switch.tsx
@@ -0,0 +1,23 @@
+interface SwitchProps {
+ label: string
+ checked: boolean
+ onChange: (checked: boolean) => void
+}
+
+/** A small labelled toggle switch, styled from `@antfu/design` semantic tokens. */
+export function Switch(props: SwitchProps) {
+ return (
+
+
+ props.onChange(e.currentTarget.checked)}
+ />
+
+
+ {props.label}
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/ViolationList.stories.tsx b/plugins/a11y/src/spa/components/ViolationList.stories.tsx
new file mode 100644
index 00000000..f2f82b3c
--- /dev/null
+++ b/plugins/a11y/src/spa/components/ViolationList.stories.tsx
@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { sampleGroups, stubChannel, stubSelection } from './_fixtures.ts'
+import { ViolationList } from './ViolationList.tsx'
+
+// The full, per-route-grouped violation list.
+const meta = {
+ title: 'A11y/ViolationList',
+ component: ViolationList,
+ parameters: { layout: 'padded' },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+
+export const Grouped: Story = {
+ args: {
+ groups: sampleGroups,
+ collapsedRoutes: new Set(['/about']),
+ onToggleGroup: noop,
+ onClearRoute: noop,
+ expandedRules: new Set(['/::image-alt']),
+ onToggleRule: noop,
+ channel: stubChannel,
+ selection: stubSelection,
+ },
+}
diff --git a/plugins/a11y/src/spa/components/ViolationList.tsx b/plugins/a11y/src/spa/components/ViolationList.tsx
new file mode 100644
index 00000000..75b31da2
--- /dev/null
+++ b/plugins/a11y/src/spa/components/ViolationList.tsx
@@ -0,0 +1,39 @@
+import type { A11yChannel } from '../lib/channel.ts'
+import type { RouteGroupModel, SelectionApi } from '../lib/violation-view.ts'
+import { For } from 'solid-js'
+import { RouteGroup } from './RouteGroup.tsx'
+
+interface ViolationListProps {
+ groups: RouteGroupModel[]
+ collapsedRoutes: Set
+ onToggleGroup: (route: string) => void
+ onClearRoute: (route: string) => void
+ expandedRules: Set
+ onToggleRule: (route: string, ruleId: string) => void
+ channel: A11yChannel
+ selection: SelectionApi
+}
+
+/** The full, per-route-grouped violation list. */
+export function ViolationList(props: ViolationListProps) {
+ return (
+
+
+ {group => (
+ props.onToggleGroup(group.report.route)}
+ onClearRoute={() => props.onClearRoute(group.report.route)}
+ expandedRules={props.expandedRules}
+ onToggleRule={ruleId => props.onToggleRule(group.report.route, ruleId)}
+ channel={props.channel}
+ selection={props.selection}
+ />
+ )}
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/ViolationRow.stories.tsx b/plugins/a11y/src/spa/components/ViolationRow.stories.tsx
new file mode 100644
index 00000000..d0cd5aae
--- /dev/null
+++ b/plugins/a11y/src/spa/components/ViolationRow.stories.tsx
@@ -0,0 +1,40 @@
+import type { Meta, StoryObj } from 'storybook-solidjs-vite'
+import { makeViolation, stubChannel, stubSelection } from './_fixtures.ts'
+import { ViolationRow } from './ViolationRow.tsx'
+
+// One violation card: severity-spined, with a select checkbox and (expanded)
+// the offending elements. Wrapped in a `` so the list-item renders.
+const meta = {
+ title: 'A11y/ViolationRow',
+ component: ViolationRow,
+ parameters: { layout: 'padded' },
+ decorators: [(Story: () => any) => ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function noop() {}
+const base = {
+ route: '/',
+ onToggle: noop,
+ onToggleSelect: noop,
+ channel: stubChannel,
+ numberOf: stubSelection.numberOf,
+}
+
+export const Collapsed: Story = {
+ args: { ...base, violation: makeViolation('color-contrast', 'serious', 3), expanded: false, selected: false },
+}
+
+export const Expanded: Story = {
+ args: { ...base, violation: makeViolation('image-alt', 'critical', 2), expanded: true, selected: false },
+}
+
+export const Selected: Story = {
+ args: { ...base, violation: makeViolation('image-alt', 'critical', 2), expanded: true, selected: true },
+}
+
+export const BestPractice: Story = {
+ args: { ...base, violation: makeViolation('region', 'moderate', 1, true), expanded: false, selected: false },
+}
diff --git a/plugins/a11y/src/spa/components/ViolationRow.tsx b/plugins/a11y/src/spa/components/ViolationRow.tsx
new file mode 100644
index 00000000..5524c6cf
--- /dev/null
+++ b/plugins/a11y/src/spa/components/ViolationRow.tsx
@@ -0,0 +1,111 @@
+import type { Violation } from '../../shared/protocol.ts'
+import type { A11yChannel } from '../lib/channel.ts'
+import { For, Show } from 'solid-js'
+import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts'
+import { ruleCardId } from '../lib/violation-view.ts'
+
+interface ViolationRowProps {
+ route: string
+ violation: Violation
+ expanded: boolean
+ selected: boolean
+ onToggle: () => void
+ onToggleSelect: () => void
+ channel: A11yChannel
+ numberOf: (nodeId: string) => number | null
+}
+
+/**
+ * One violation card: a severity-spined row with a select checkbox (drives the
+ * in-page highlight + fix-prompt selection) and, when expanded, the offending
+ * elements with their markup, selector, and axe failure summary.
+ */
+export function ViolationRow(props: ViolationRowProps) {
+ const v = () => props.violation
+ const panelId = () => `nodes-${ruleCardId(props.route, v().ruleId)}`
+ const first = () => v().nodes[0]
+
+ return (
+ props.channel.clearPreview()}
+ >
+
+ props.onToggleSelect()}
+ onMouseEnter={() => first() && props.channel.preview(first())}
+ />
+ props.onToggle()}
+ onMouseEnter={() => first() && props.channel.preview(first())}
+ onFocus={() => first() && props.channel.preview(first())}
+ onBlur={() => props.channel.clearPreview()}
+ >
+
+ {IMPACT_LABEL[v().impact]}
+
+ best practice
+
+ {v().ruleId}
+
+ {v().nodes.length}
+ {' '}
+ {v().nodes.length === 1 ? 'element' : 'elements'}
+
+
+
+ {v().help}
+
+
+
+
+
+
+ {node => (
+
+ props.channel.preview(node)}
+ onFocus={() => props.channel.preview(node)}
+ onMouseLeave={() => props.channel.clearPreview()}
+ onBlur={() => props.channel.clearPreview()}
+ >
+
+
+ {n => {n()} }
+
+ {node.html}
+
+
+ {node.target.join(' ')}
+
+
+ {node.failureSummary}
+
+
+
+ )}
+
+
+
+ Learn how to fix
+ {' '}
+ {v().ruleId}
+ {' ↗'}
+
+
+
+ )
+}
diff --git a/plugins/a11y/src/spa/components/_fixtures.ts b/plugins/a11y/src/spa/components/_fixtures.ts
new file mode 100644
index 00000000..5131b047
--- /dev/null
+++ b/plugins/a11y/src/spa/components/_fixtures.ts
@@ -0,0 +1,51 @@
+import type { ScanReport, Violation } from '../../shared/protocol.ts'
+import type { A11yChannel } from '../lib/channel.ts'
+import type { RouteGroupModel, SelectionApi } from '../lib/violation-view.ts'
+import { emptyCounts } from '../../shared/protocol.ts'
+
+function noop() {}
+
+/** A no-op channel stub — enough for the hover/clear calls the rows fire. */
+export const stubChannel = { preview: noop, clearPreview: noop, clearRoute: noop } as unknown as A11yChannel
+
+/** A selection stub that marks `image-alt` selected and numbers its nodes. */
+export const stubSelection: SelectionApi = {
+ isSelected: (_route, ruleId) => ruleId === 'image-alt',
+ toggle: noop,
+ numberOf: nodeId => (nodeId.startsWith('image-alt') ? Number(nodeId.split('-').pop()) + 1 : null),
+}
+
+export function makeViolation(ruleId: string, impact: Violation['impact'], nodes = 1, bestPractice = false): Violation {
+ return {
+ ruleId,
+ impact,
+ help: `Ensure ${ruleId} is correct`,
+ description: `Ensures ${ruleId}`,
+ helpUrl: `https://dequeuniversity.com/rules/axe/${ruleId}`,
+ tags: ['wcag2a', 'wcag111'],
+ bestPractice,
+ nodes: Array.from({ length: nodes }, (_, i) => ({
+ id: `${ruleId}-${i}`,
+ target: [`#${ruleId}-${i}`],
+ html: `…
`,
+ failureSummary: `Fix any of the following:\n Fix the ${ruleId} element`,
+ })),
+ }
+}
+
+export function makeReport(route: string, violations: Violation[]): ScanReport {
+ return { route, url: `https://example.test${route}`, scannedAt: 1, engine: '4.10.0', violations, counts: emptyCounts() }
+}
+
+export const sampleGroups: RouteGroupModel[] = [
+ {
+ report: makeReport('/', []),
+ active: true,
+ violations: [makeViolation('image-alt', 'critical', 2), makeViolation('color-contrast', 'serious', 3)],
+ },
+ {
+ report: makeReport('/about', []),
+ active: false,
+ violations: [makeViolation('region', 'moderate', 1, true)],
+ },
+]
diff --git a/plugins/a11y/src/spa/components/header.tsx b/plugins/a11y/src/spa/components/header.tsx
deleted file mode 100644
index eb303a98..00000000
--- a/plugins/a11y/src/spa/components/header.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import type { Accessor } from 'solid-js'
-import type { Impact, ScanReport } from '../../shared/protocol.ts'
-import { For, Show } from 'solid-js'
-import { IMPACT_ORDER } from '../../shared/protocol.ts'
-import { button, nav, navBrand } from '../design'
-import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts'
-
-interface HeaderProps {
- agentReady: boolean
- scanning: boolean
- onRescan: () => void
-}
-
-export function Header(props: HeaderProps) {
- const statusLabel = () =>
- !props.agentReady ? 'No page connected' : props.scanning ? 'Scanning…' : 'Connected'
- const dotClass = () =>
- !props.agentReady
- ? 'status__dot'
- : props.scanning
- ? 'status__dot status__dot--scanning'
- : 'status__dot status__dot--live'
-
- return (
-
-
-
- A11y Inspector
-
-
-
-
- {statusLabel()}
-
- props.onRescan()}
- disabled={!props.agentReady || props.scanning}
- >
-
- Rescan
-
-
- )
-}
-
-export function MetaLine(props: {
- report: Accessor
- backend: Accessor
- status?: Accessor
-}) {
- // The backend is optional here, so a degraded connection is shown as a quiet
- // tag rather than taking over the panel.
- const degraded = () => {
- const s = props.status?.()
- return s === 'disconnected' || s === 'unauthorized' || s === 'error' ? s : null
- }
- return (
-
- {report => (
-
- {report().url}
- {b => {b()} } }>
- {s => {s()} }
-
-
- axe
- {' '}
- {report().engine}
-
-
- )}
-
- )
-}
-
-interface SummaryProps {
- counts: Record
- active: Impact | null
- onToggle: (impact: Impact) => void
-}
-
-export function Summary(props: SummaryProps) {
- return (
-
-
- {(impact) => {
- const count = () => props.counts[impact]
- return (
- props.onToggle(impact)}
- >
- {count()}
- {IMPACT_LABEL[impact]}
-
- )
- }}
-
-
- )
-}
diff --git a/plugins/a11y/src/spa/components/icons.tsx b/plugins/a11y/src/spa/components/icons.tsx
deleted file mode 100644
index fd9621ca..00000000
--- a/plugins/a11y/src/spa/components/icons.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { JSX } from 'solid-js'
-import { splitProps } from 'solid-js'
-
-type IconProps = JSX.HTMLAttributes
-
-/**
- * The inspector's icons come from the shared Phosphor set (`i-ph-*`, duotone
- * preferred) via UnoCSS, so they match the other devframe plugins. Each icon
- * carries its own size; callers pass `class` for color/transform.
- */
-function makeIcon(iconClass: string) {
- return (props: IconProps) => {
- const [local, rest] = splitProps(props, ['class'])
- return
- }
-}
-
-/** Disclosure chevron for a violation row. */
-export const Chevron = makeIcon('i-ph-caret-right size-3.5')
-
-/** Large "all clear" glyph for the empty/clean state. */
-export const CheckCircle = makeIcon('i-ph-check-circle-duotone size-10')
-
-/** Large "no page connected" glyph for the disconnected state. */
-export const PlugIcon = makeIcon('i-ph-plugs-duotone size-10')
diff --git a/plugins/a11y/src/spa/components/summary.stories.tsx b/plugins/a11y/src/spa/components/summary.stories.tsx
deleted file mode 100644
index addc03eb..00000000
--- a/plugins/a11y/src/spa/components/summary.stories.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { Meta, StoryObj } from 'storybook-solidjs-vite'
-import { Summary } from './header.tsx'
-
-// The severity summary chips — the one expressive, domain-specific color in the
-// inspector. Presentational: driven entirely by the per-impact counts, so it
-// stories offline without a live scan.
-const meta = {
- title: 'A11y/Summary',
- component: Summary,
- parameters: { layout: 'centered' },
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-const counts = { critical: 3, serious: 5, moderate: 2, minor: 8 }
-
-/** A spread of violations across every severity bucket. */
-export const Issues: Story = {
- args: { counts, active: null, onToggle: () => {} },
-}
-
-/** One severity selected as the active filter. */
-export const Filtered: Story = {
- args: { counts, active: 'serious', onToggle: () => {} },
-}
-
-/** A clean report — every bucket at zero. */
-export const Clean: Story = {
- args: {
- counts: { critical: 0, serious: 0, moderate: 0, minor: 0 },
- active: null,
- onToggle: () => {},
- },
-}
diff --git a/plugins/a11y/src/spa/components/violations.tsx b/plugins/a11y/src/spa/components/violations.tsx
deleted file mode 100644
index 42348077..00000000
--- a/plugins/a11y/src/spa/components/violations.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import type { Violation } from '../../shared/protocol.ts'
-import type { A11yChannel } from '../lib/channel.ts'
-import { createMemo, For, Show } from 'solid-js'
-import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts'
-import { Chevron } from './icons.tsx'
-
-interface RowProps {
- violation: Violation
- index: number
- expanded: boolean
- onToggle: () => void
- channel: A11yChannel
-}
-
-function ViolationRow(props: RowProps) {
- const v = () => props.violation
- const panelId = createMemo(() => `nodes-${props.index}`)
- const first = () => v().nodes[0]
-
- return (
- props.channel.clearHighlight()}
- >
- props.onToggle()}
- onMouseEnter={() => first() && props.channel.highlight(first())}
- onFocus={() => first() && props.channel.highlight(first())}
- onBlur={() => props.channel.clearHighlight()}
- >
-
- {IMPACT_LABEL[v().impact]}
- {v().ruleId}
-
- {v().nodes.length}
- {' '}
- {v().nodes.length === 1 ? 'element' : 'elements'}
-
-
-
- {v().help}
-
-
-
-
-
- {node => (
-
- props.channel.highlight(node)}
- onFocus={() => props.channel.highlight(node)}
- onBlur={() => props.channel.clearHighlight()}
- onClick={() => props.channel.highlight(node)}
- >
- {node.html}
-
- {node.target.join(' ')}
-
-
- {node.failureSummary}
-
-
-
- )}
-
-
-
- Learn how to fix
- {' '}
- {v().ruleId}
- {' ↗'}
-
-
-
- )
-}
-
-interface ListProps {
- violations: Violation[]
- expanded: Set
- onToggle: (ruleId: string) => void
- channel: A11yChannel
-}
-
-export function ViolationList(props: ListProps) {
- return (
-
-
- {(violation, i) => (
- props.onToggle(violation.ruleId)}
- channel={props.channel}
- />
- )}
-
-
- )
-}
diff --git a/plugins/a11y/src/spa/lib/channel.ts b/plugins/a11y/src/spa/lib/channel.ts
index c19fd2db..5c050ecf 100644
--- a/plugins/a11y/src/spa/lib/channel.ts
+++ b/plugins/a11y/src/spa/lib/channel.ts
@@ -1,31 +1,45 @@
import type { Accessor } from 'solid-js'
-import type { A11yMessage, ScanReport, ViolationNode } from '../../shared/protocol.ts'
+import type { A11yMessage, A11yState, AgentConfig, PinTarget } from '../../shared/protocol.ts'
import { createSignal, onCleanup } from 'solid-js'
import { A11Y_CHANNEL } from '../../shared/protocol.ts'
export interface A11yChannel {
- /** Latest scan report, or `null` until the agent reports in. */
- report: Accessor
+ /** Latest full route → report aggregate, or `null` until the agent reports in. */
+ state: Accessor
/** Whether an agent has announced itself on this origin. */
agentReady: Accessor
/** Whether the agent is mid-scan. */
scanning: Accessor
- /** Ask the agent to draw the highlight ring around a node's element. */
- highlight: (node: ViolationNode) => void
- /** Clear any active highlight. */
- clearHighlight: () => void
+ /** `location.pathname` currently in view in the host page. */
+ activeRoute: Accessor
+ /** Draw the transient hover-preview ring around a node's element. */
+ preview: (node: { id: string, target: string[] }) => void
+ /** Clear the transient hover-preview ring. */
+ clearPreview: () => void
+ /** Replace the pinned (numbered) highlight set drawn in the host page. */
+ setPins: (pins: PinTarget[]) => void
/** Ask the agent to re-run the scan. */
rescan: () => void
+ /** Forward runtime configuration to the agent. */
+ sendConfig: (config: AgentConfig) => void
+ /** Toggle the agent's interaction-driven auto-scan. */
+ setAutoScan: (enabled: boolean) => void
+ /** Drop one route's tracked history. */
+ clearRoute: (route: string) => void
+ /** Drop the whole tracked-route history. */
+ clearAll: () => void
}
/**
* Panel half of the agent↔panel BroadcastChannel. Returns reactive accessors
- * that track the agent's state plus the actions the UI fires on hover/click.
+ * that track the agent's aggregate state plus the actions the UI fires on
+ * hover/click/rescan.
*/
export function createA11yChannel(): A11yChannel {
- const [report, setReport] = createSignal(null)
+ const [state, setState] = createSignal(null)
const [agentReady, setAgentReady] = createSignal(false)
const [scanning, setScanning] = createSignal(false)
+ const [activeRoute, setActiveRoute] = createSignal(null)
const channel = new BroadcastChannel(A11Y_CHANNEL)
const post = (message: A11yMessage) => channel.postMessage(message)
@@ -35,37 +49,46 @@ export function createA11yChannel(): A11yChannel {
switch (message.type) {
case 'a11y:agent-ready':
setAgentReady(true)
+ setActiveRoute(message.route)
// Closes the startup race: if our panel-ready landed before the agent
- // was listening, asking again now pulls down the current report.
- if (!report())
+ // was listening, asking again now pulls down the current state.
+ if (!state())
post({ type: 'a11y:panel-ready' })
break
- case 'a11y:report':
+ case 'a11y:state':
setAgentReady(true)
setScanning(false)
- setReport(message.report)
+ setActiveRoute(message.state.activeRoute)
+ setState(message.state)
break
case 'a11y:scanning':
setAgentReady(true)
+ setActiveRoute(message.route)
setScanning(true)
break
}
})
- // Announce the panel so a previously-loaded agent replays its last report.
+ // Announce the panel so a previously-loaded agent replays its current state.
post({ type: 'a11y:panel-ready' })
onCleanup(() => channel.close())
return {
- report,
+ state,
agentReady,
scanning,
- highlight: node => post({ type: 'a11y:highlight', nodeId: node.id, target: node.target }),
- clearHighlight: () => post({ type: 'a11y:clear' }),
+ activeRoute,
+ preview: node => post({ type: 'a11y:highlight', nodeId: node.id, target: node.target }),
+ clearPreview: () => post({ type: 'a11y:clear' }),
+ setPins: pins => post({ type: 'a11y:pins', pins }),
rescan: () => {
setScanning(true)
post({ type: 'a11y:rescan' })
},
+ sendConfig: config => post({ type: 'a11y:config', config }),
+ setAutoScan: enabled => post({ type: 'a11y:set-autoscan', enabled }),
+ clearRoute: route => post({ type: 'a11y:clear-route', route }),
+ clearAll: () => post({ type: 'a11y:clear-all' }),
}
}
diff --git a/plugins/a11y/src/spa/lib/devframe.ts b/plugins/a11y/src/spa/lib/devframe.ts
index e0022f89..bcb29368 100644
--- a/plugins/a11y/src/spa/lib/devframe.ts
+++ b/plugins/a11y/src/spa/lib/devframe.ts
@@ -1,8 +1,9 @@
import type { DevframeConnectionStatus } from 'devframe/client'
import type { Accessor } from 'solid-js'
-import type { Impact } from '../../shared/protocol.ts'
+import type { AgentConfig, Impact } from '../../shared/protocol.ts'
import { connectDevframe } from 'devframe/client'
import { createSignal } from 'solid-js'
+import { A11Y_DOCKS_ACTIVE_KEY } from '../../shared/protocol.ts'
export interface ImpactMeta {
id: Impact
@@ -14,9 +15,21 @@ export interface A11yConfig {
channel: string
nodeAttr: string
docsBase: string
+ /** The devframe id this dock is registered under. */
+ dockId: string
+ /** Auto-pin all of a route's violations the first time it's scanned. */
+ defaultHighlight: boolean
+ /** Runtime configuration forwarded to the in-page agent. */
+ agent: AgentConfig
impacts: ImpactMeta[]
}
+/** A dock-activation intent the hub mirrors into shared state. */
+export interface DockActivation {
+ dockId: string
+ params?: Record
+}
+
export interface DevframeState {
/** `'websocket'` in dev, `'static'` for a baked build, `null` while/if unreachable. */
backend: Accessor
@@ -26,20 +39,25 @@ export interface DevframeState {
* surfaced as a tag rather than blocking the UI.
*/
status: Accessor
- /** Impact taxonomy + copy from the `get-config` RPC. */
+ /** Impact taxonomy + runtime config from the `get-config` RPC. */
config: Accessor
+ /** Latest dock-activation intent targeting this dock (deep-link support). */
+ activation: Accessor
}
/**
- * Connect to the devframe backend for supplementary data (the impact legend).
- * Intentionally non-blocking and failure-tolerant: the panel's core scan loop
- * runs over BroadcastChannel, so the UI stays useful even if the backend is
- * unreachable.
+ * Connect to the devframe backend for supplementary data: the impact legend,
+ * the runtime config the panel forwards to the agent, and the dock-activation
+ * shared state that powers deep-linking (e.g. a messages-feed entry navigating
+ * here). Intentionally non-blocking and failure-tolerant — the panel's core
+ * scan loop runs over BroadcastChannel, so the UI stays useful even if the
+ * backend is unreachable.
*/
export function connectDevframeState(): DevframeState {
const [backend, setBackend] = createSignal(null)
const [status, setStatus] = createSignal(null)
const [config, setConfig] = createSignal(null)
+ const [activation, setActivation] = createSignal(null)
connectDevframe()
.then(async (rpc) => {
@@ -58,11 +76,28 @@ export function connectDevframeState(): DevframeState {
const cfg = await callConfig('devframes:plugin:a11y:get-config')
if (cfg)
setConfig(cfg)
+
+ // The hub (when present) mirrors dock activations here; used for
+ // deep-linking from other docks (e.g. the messages feed).
+ try {
+ const shared = await rpc.sharedState.get(A11Y_DOCKS_ACTIVE_KEY, {
+ initialValue: { activation: null },
+ }) as { value: () => { activation: DockActivation | null }, on: (e: string, cb: (v: any) => void) => void }
+ const apply = (v: { activation: DockActivation | null } | undefined) => {
+ if (v?.activation)
+ setActivation({ ...v.activation })
+ }
+ apply(shared.value())
+ shared.on('updated', apply)
+ }
+ catch {
+ // No hub / no shared state — deep-linking simply stays inert.
+ }
})
.catch(() => {
// No reachable backend (e.g. agent loaded outside a devframe host).
setStatus('error')
})
- return { backend, status, config }
+ return { backend, status, config, activation }
}
diff --git a/plugins/a11y/src/spa/lib/fix-prompt.ts b/plugins/a11y/src/spa/lib/fix-prompt.ts
new file mode 100644
index 00000000..25e58c17
--- /dev/null
+++ b/plugins/a11y/src/spa/lib/fix-prompt.ts
@@ -0,0 +1,55 @@
+import type { Violation } from '../../shared/protocol.ts'
+
+/** One selected violation, with the route context it was found on. */
+export interface SelectedItem {
+ route: string
+ url: string
+ violation: Violation
+}
+
+/**
+ * Build a single, paste-ready AI prompt that gathers the full context needed to
+ * fix the selected violations: rule metadata, WCAG tags, docs, and every
+ * offending element's selector, markup, and axe failure summary — grouped by
+ * the route they were found on.
+ */
+export function buildFixPrompt(items: SelectedItem[]): string {
+ const lines: string[] = [
+ 'You are an accessibility engineer. Fix the following WCAG issues found by axe-core.',
+ 'For each violation, change the markup and/or styles so the rule passes while preserving the design and behavior, and briefly explain each fix.',
+ '',
+ ]
+
+ const byRoute = new Map()
+ for (const item of items) {
+ const bucket = byRoute.get(item.route) ?? []
+ bucket.push(item)
+ byRoute.set(item.route, bucket)
+ }
+
+ for (const [route, bucket] of byRoute) {
+ const url = bucket[0]?.url
+ lines.push(`## Route: ${route}${url ? ` (${url})` : ''}`)
+ for (const { violation: v } of bucket) {
+ lines.push('')
+ lines.push(`### ${v.ruleId} — ${v.help}`)
+ lines.push(`- Impact: ${v.impact}${v.bestPractice ? ' (best practice)' : ''}`)
+ if (v.tags?.length)
+ lines.push(`- WCAG: ${v.tags.join(', ')}`)
+ lines.push(`- Docs: ${v.helpUrl}`)
+ lines.push(`- What it checks: ${v.description}`)
+ lines.push(`- Affected element${v.nodes.length === 1 ? '' : 's'} (${v.nodes.length}):`)
+ for (const node of v.nodes) {
+ lines.push(` - selector: \`${node.target.join(' ')}\``)
+ lines.push(' ```html')
+ lines.push(` ${node.html}`)
+ lines.push(' ```')
+ if (node.failureSummary)
+ lines.push(` fix hint: ${node.failureSummary.replace(/\s*\n\s*/g, ' ')}`)
+ }
+ }
+ lines.push('')
+ }
+
+ return lines.join('\n').trimEnd()
+}
diff --git a/plugins/a11y/src/spa/lib/violation-view.ts b/plugins/a11y/src/spa/lib/violation-view.ts
new file mode 100644
index 00000000..ef13f482
--- /dev/null
+++ b/plugins/a11y/src/spa/lib/violation-view.ts
@@ -0,0 +1,21 @@
+import type { ScanReport, Violation } from '../../shared/protocol.ts'
+
+/** Controller the violation list uses to read/mutate the selection. */
+export interface SelectionApi {
+ isSelected: (route: string, ruleId: string) => boolean
+ toggle: (route: string, ruleId: string) => void
+ /** 1-based badge number for a highlighted node, or `null`. */
+ numberOf: (nodeId: string) => number | null
+}
+
+/** One route's filtered violations, as rendered by a {@link RouteGroup}. */
+export interface RouteGroupModel {
+ report: ScanReport
+ active: boolean
+ violations: Violation[]
+}
+
+/** Stable DOM id for a rule card, used by deep-link scroll-into-view. */
+export function ruleCardId(route: string, ruleId: string): string {
+ return `rule-${encodeURIComponent(route)}-${ruleId}`
+}
diff --git a/plugins/a11y/src/spa/styles.css b/plugins/a11y/src/spa/styles.css
index 325940cf..0d30cdac 100644
--- a/plugins/a11y/src/spa/styles.css
+++ b/plugins/a11y/src/spa/styles.css
@@ -1,503 +1,49 @@
/*
- * A11y Inspector panel — an "audit console" surface.
+ * A11y Inspector panel base styles.
*
- * Design intent: a quiet, dark instrument where the only expressive color is
- * WCAG severity. Code (rule ids, selectors, markup) is set in mono because
- * that is what developers scan by. The signature device is the left "severity
- * spine" + a focus ring on hover that matches the ring the agent paints in the
- * page — so a row and its highlighted element read as one object.
+ * The surface is built almost entirely from UnoCSS utilities + `@antfu/design`
+ * semantic tokens (`bg-base`, `color-muted`, `border-base`, …), which flip with
+ * the `.dark` class on . Only the handful of things utilities can't
+ * express live here: the root sizing reset, the scroll area's thin scrollbar,
+ * and the reduced-motion guard. WCAG severity — the one domain-specific color —
+ * rides an inline `--impact` CSS var consumed by arbitrary-value utilities.
*/
-/* The inspector keeps its bespoke "audit console" CSS but sources every color
- from `@antfu/design`'s palette. The local `--df-*` source tokens below mirror
- the design system's surfaces/text and flip via the `.dark` class; the audit
- vars (`--ink`, `--surface`, …) derive from them. Severity is the one
- domain-specific palette, set inline from IMPACT_COLOR. */
-:root {
- --df-background: #fff;
- --df-card: #fff;
- --df-secondary: #f5f5f5;
- --df-border: rgba(136, 136, 136, 0.13);
- --df-foreground: #262626;
- --df-muted-foreground: #525252;
- --df-primary: #3a6a45;
- --df-success: #12b76a;
- --df-accent: rgba(136, 136, 136, 0.067);
-
- --ink: var(--df-background);
- --surface: var(--df-card);
- --surface-2: var(--df-secondary);
- --line: var(--df-border);
- --line-soft: color-mix(in srgb, var(--df-foreground) 8%, transparent);
- --text: var(--df-foreground);
- --muted: var(--df-muted-foreground);
- --faint: color-mix(in srgb, var(--df-muted-foreground) 60%, transparent);
- --ring: var(--df-primary);
- --ok: var(--df-success);
-
- --font-ui: var(--font-sans, system-ui, -apple-system, "Segoe UI", roboto, sans-serif);
- --font-mono: ui-monospace, sfmono-regular, "SF Mono", menlo, consolas, monospace;
-}
-
-.dark {
- --df-background: #111;
- --df-card: #181818;
- --df-secondary: #1a1a1a;
- --df-border: rgba(136, 136, 136, 0.13);
- --df-foreground: #e5e5e5;
- --df-muted-foreground: #a3a3a3;
- --df-primary: #6fb07d;
- --df-success: #34d27f;
- --df-accent: rgba(136, 136, 136, 0.067);
-}
-
-* {
- box-sizing: border-box;
-}
-
html,
body {
margin: 0;
height: 100%;
}
-body {
- background: var(--ink);
- color: var(--text);
- font-family: var(--font-ui);
- font-size: 14px;
- -webkit-font-smoothing: antialiased;
-}
-
#app {
height: 100%;
}
-.app {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
-}
-
-/* ── top bar: nav, brand and the Rescan button come from the co-located
- `@antfu/design` class helpers; only the connection status pill below
- stays bespoke. ───────────────────────────────────────────────────────── */
-
-.status {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- font-size: 12px;
- color: var(--muted);
-}
-
-.status__dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--faint);
- box-shadow: 0 0 0 0 transparent;
-}
-
-.status__dot--live {
- background: var(--ok);
-}
-
-.status__dot--scanning {
- background: var(--ring);
- animation: pulse 1.1s ease-in-out infinite;
-}
-
-@keyframes pulse {
- 0%,
- 100% {
- box-shadow: 0 0 0 0 color-mix(in srgb, var(--ring) 50%, transparent);
- }
- 50% {
- box-shadow: 0 0 0 5px transparent;
- }
-}
-
-/* ── meta line ─────────────────────────────────────────────────────────── */
-
-.meta {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 7px 16px;
- font-family: var(--font-mono);
- font-size: 11.5px;
- color: var(--faint);
- border-bottom: 1px solid var(--line-soft);
- background: var(--ink);
-}
-
-.meta__url {
- color: var(--muted);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- flex: 1;
-}
-
-.meta__tag {
- flex: none;
- padding: 1px 6px;
- border: 1px solid var(--line);
- border-radius: 5px;
-}
-
-.meta__tag--warn {
- border-color: color-mix(in oklab, #d9a441 60%, var(--line));
- color: #d9a441;
- text-transform: capitalize;
+body {
+ -webkit-font-smoothing: antialiased;
}
-/* ── severity summary ──────────────────────────────────────────────────── */
-
-.summary {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 8px;
- padding: 14px 16px;
+/* Thin custom scrollbar for the panel's scroll area. */
+.a11y-scroll::-webkit-scrollbar {
+ width: 10px;
}
-.chip {
- --impact: var(--faint);
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 3px;
- padding: 9px 11px;
- background: var(--surface);
- border: 1px solid var(--line);
- border-left: 3px solid var(--impact);
+.a11y-scroll::-webkit-scrollbar-thumb {
+ background: rgb(128 128 128 / 0.28);
+ border: 3px solid transparent;
+ background-clip: padding-box;
border-radius: 8px;
- cursor: pointer;
- text-align: left;
- font: inherit;
- transition: border-color 0.12s, background 0.12s, transform 0.12s;
-}
-
-.chip:hover {
- background: var(--surface-2);
-}
-
-.chip[aria-pressed="true"] {
- background: color-mix(in srgb, var(--impact) 14%, var(--surface));
- border-color: var(--impact);
-}
-
-.chip:focus-visible {
- outline: 2px solid var(--ring);
- outline-offset: 2px;
-}
-
-.chip__count {
- font-size: 22px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
- line-height: 1;
- color: var(--text);
-}
-
-.chip--zero .chip__count {
- color: var(--faint);
-}
-
-.chip__label {
- font-size: 10.5px;
- font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--impact);
-}
-
-.chip--zero .chip__label {
- color: var(--faint);
-}
-
-/* ── violations list ───────────────────────────────────────────────────── */
-
-.scroll {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- padding: 0 16px 20px;
-}
-
-.list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.rule {
- --impact: var(--faint);
- position: relative;
- background: var(--surface);
- border: 1px solid var(--line);
- border-radius: 9px;
- overflow: hidden;
- transition: box-shadow 0.12s, border-color 0.12s;
-}
-
-.rule::before {
- content: "";
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 3px;
- background: var(--impact);
- transition: width 0.12s;
-}
-
-.rule:hover,
-.rule:focus-within {
- border-color: color-mix(in srgb, var(--impact) 55%, var(--line));
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--impact) 45%, transparent),
- 0 6px 22px rgb(0 0 0 / 35%);
}
-.rule:hover::before,
-.rule:focus-within::before {
- width: 5px;
-}
-
-.rule__toggle {
- display: block;
- width: 100%;
- text-align: left;
- background: none;
- border: 0;
- font: inherit;
- color: inherit;
- cursor: pointer;
- padding: 12px 14px 12px 18px;
-}
-
-.rule__toggle:focus-visible {
- outline: none;
-}
-
-.rule__head {
- display: flex;
- align-items: center;
- gap: 9px;
- margin-bottom: 5px;
-}
-
-.rule__impact {
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
- color: var(--impact);
-}
-
-.rule__id {
- font-family: var(--font-mono);
- font-size: 12.5px;
- color: var(--text);
-}
-
-.rule__count {
- margin-left: auto;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 11.5px;
- color: var(--muted);
- font-variant-numeric: tabular-nums;
-}
-
-.rule__chevron {
- transition: transform 0.15s;
- color: var(--faint);
-}
-
-.rule__chevron--open {
- transform: rotate(90deg);
-}
-
-.rule__help {
- font-size: 12.5px;
- color: var(--muted);
- line-height: 1.45;
-}
-
-.nodes {
- list-style: none;
- margin: 0;
- padding: 0 10px 10px 18px;
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.rule__docslink {
- align-self: flex-start;
- margin: 2px 0 2px 18px;
- font-size: 11.5px;
- color: var(--faint);
- text-decoration: none;
-}
-
-.rule__docslink:hover {
- color: var(--ring);
- text-decoration: underline;
-}
-
-.node {
- border-radius: 7px;
-}
-
-.node__btn {
- display: block;
- width: 100%;
- text-align: left;
- background: var(--ink);
- border: 1px solid var(--line-soft);
- border-radius: 7px;
- font: inherit;
- color: inherit;
- cursor: pointer;
- padding: 9px 11px;
- transition: border-color 0.12s, background 0.12s;
-}
-
-.node__btn:hover {
- border-color: color-mix(in srgb, var(--impact) 50%, var(--line));
- background: var(--df-accent);
-}
-
-.node__btn:focus-visible {
- outline: 2px solid var(--ring);
- outline-offset: 1px;
-}
-
-.node__html {
- display: block;
- font-family: var(--font-mono);
- font-size: 11.5px;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
- margin-bottom: 6px;
-}
-
-.node__target {
- display: inline-block;
- font-family: var(--font-mono);
- font-size: 11px;
- color: var(--ring);
- background: color-mix(in srgb, var(--ring) 12%, transparent);
- border-radius: 4px;
- padding: 1px 6px;
-}
-
-.node__summary {
- display: block;
- margin-top: 7px;
- font-size: 11.5px;
- line-height: 1.5;
- color: var(--muted);
- white-space: pre-wrap;
-}
-
-.node__docs {
- display: inline-block;
- margin-top: 7px;
- font-size: 11px;
- color: var(--faint);
- text-decoration: none;
-}
-
-.node__docs:hover {
- color: var(--ring);
- text-decoration: underline;
-}
-
-/* ── states ────────────────────────────────────────────────────────────── */
-
-.state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 12px;
- height: 100%;
- padding: 32px;
- text-align: center;
- color: var(--muted);
-}
-
-.state__glyph {
- color: var(--faint);
-}
-
-.state--clean .state__glyph {
- color: var(--ok);
-}
-
-.state__title {
- font-size: 15px;
- font-weight: 650;
- color: var(--text);
-}
-
-.state__body {
- font-size: 12.5px;
- line-height: 1.55;
- max-width: 38ch;
-}
-
-.state__code {
- font-family: var(--font-mono);
- font-size: 11.5px;
- color: var(--muted);
- background: var(--surface);
- border: 1px solid var(--line);
- border-radius: 7px;
- padding: 10px 12px;
- white-space: pre-wrap;
- word-break: break-all;
- text-align: left;
- max-width: 100%;
-}
-
-.visually-hidden {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: -1px;
- padding: 0;
- border: 0;
- clip-path: inset(50%);
- overflow: hidden;
- white-space: nowrap;
-}
-
-/* thin custom scrollbar */
-.scroll::-webkit-scrollbar {
- width: 10px;
-}
-.scroll::-webkit-scrollbar-thumb {
- background: var(--line);
- border: 3px solid var(--ink);
- border-radius: 8px;
-}
-.scroll::-webkit-scrollbar-thumb:hover {
- background: color-mix(in srgb, var(--df-foreground) 30%, transparent);
+.a11y-scroll::-webkit-scrollbar-thumb:hover {
+ background: rgb(128 128 128 / 0.45);
+ background-clip: padding-box;
}
@media (prefers-reduced-motion: reduce) {
- * {
+ *,
+ ::before,
+ ::after {
transition: none !important;
animation: none !important;
}
diff --git a/plugins/a11y/tests/dev-server.test.ts b/plugins/a11y/tests/dev-server.test.ts
index 7b456e18..6a7d0eda 100644
--- a/plugins/a11y/tests/dev-server.test.ts
+++ b/plugins/a11y/tests/dev-server.test.ts
@@ -45,10 +45,17 @@ describe('dev-server (CLI surface)', () => {
const config = await rpc.$call('devframes:plugin:a11y:get-config') as {
channel: string
nodeAttr: string
+ dockId: string
+ defaultHighlight: boolean
+ agent: { autoScan: boolean, logIssues: boolean, activateDockId: string }
impacts: { id: string }[]
}
expect(config.channel).toBe('devframes:plugin:a11y')
expect(config.nodeAttr).toBe('data-df-a11y-node')
expect(config.impacts.map(i => i.id)).toEqual(['critical', 'serious', 'moderate', 'minor'])
+ // Runtime config the panel forwards to the in-page agent.
+ expect(config.dockId).toBe('devframes_plugin_a11y')
+ expect(config.defaultHighlight).toBe(false)
+ expect(config.agent).toMatchObject({ autoScan: true, logIssues: true, activateDockId: 'devframes_plugin_a11y' })
})
})
diff --git a/plugins/a11y/tests/fix-prompt.test.ts b/plugins/a11y/tests/fix-prompt.test.ts
new file mode 100644
index 00000000..7d4252d3
--- /dev/null
+++ b/plugins/a11y/tests/fix-prompt.test.ts
@@ -0,0 +1,59 @@
+import type { Violation } from '../src/shared/protocol.ts'
+import type { SelectedItem } from '../src/spa/lib/fix-prompt.ts'
+import { describe, expect, it } from 'vitest'
+import { buildFixPrompt } from '../src/spa/lib/fix-prompt.ts'
+
+function violation(ruleId: string, over: Partial = {}): Violation {
+ return {
+ ruleId,
+ impact: 'critical',
+ help: `Fix ${ruleId}`,
+ description: `Ensures ${ruleId}`,
+ helpUrl: `https://dequeuniversity.com/rules/axe/${ruleId}`,
+ tags: ['wcag2a', 'wcag111'],
+ nodes: [{
+ id: `${ruleId}-0`,
+ target: [`#${ruleId}`],
+ html: ` `,
+ failureSummary: 'Fix any of the following:\n Element has no alt',
+ }],
+ ...over,
+ }
+}
+
+const items: SelectedItem[] = [
+ { route: '/', url: 'https://example.test/', violation: violation('image-alt') },
+ { route: '/forms', url: 'https://example.test/forms', violation: violation('label', { impact: 'serious', bestPractice: true }) },
+]
+
+describe('buildFixPrompt', () => {
+ it('includes an instruction preamble and groups violations by route', () => {
+ const out = buildFixPrompt(items)
+ expect(out).toContain('You are an accessibility engineer')
+ expect(out).toContain('## Route: / (https://example.test/)')
+ expect(out).toContain('## Route: /forms (https://example.test/forms)')
+ })
+
+ it('carries the full fixing context: rule, impact, WCAG tags, docs, selector, markup, and hint', () => {
+ const out = buildFixPrompt(items)
+ expect(out).toContain('### image-alt — Fix image-alt')
+ expect(out).toContain('- Impact: critical')
+ expect(out).toContain('- WCAG: wcag2a, wcag111')
+ expect(out).toContain('- Docs: https://dequeuniversity.com/rules/axe/image-alt')
+ expect(out).toContain('selector: `#image-alt`')
+ expect(out).toContain(' ')
+ // Failure summary newlines are flattened into a single-line hint.
+ expect(out).toContain('fix hint: Fix any of the following: Element has no alt')
+ })
+
+ it('marks best-practice rules', () => {
+ const out = buildFixPrompt(items)
+ expect(out).toContain('- Impact: serious (best practice)')
+ })
+
+ it('returns just the preamble when nothing is selected', () => {
+ const out = buildFixPrompt([])
+ expect(out).toContain('You are an accessibility engineer')
+ expect(out).not.toContain('## Route:')
+ })
+})
diff --git a/plugins/a11y/tests/inject-messages.test.ts b/plugins/a11y/tests/inject-messages.test.ts
index 93eabe33..eaa5d585 100644
--- a/plugins/a11y/tests/inject-messages.test.ts
+++ b/plugins/a11y/tests/inject-messages.test.ts
@@ -38,9 +38,10 @@ function violation(ruleId: string, impact: Violation['impact'], nodes = 1): Viol
}
}
-function report(violations: Violation[]): ScanReport {
+function report(violations: Violation[], route = '/'): ScanReport {
return {
- url: 'https://example.test/',
+ route,
+ url: `https://example.test${route}`,
scannedAt: 1,
engine: 'axe-test',
violations,
@@ -65,6 +66,7 @@ describe('createMessagesReporter', () => {
id: 'devframes:plugin:a11y:scan',
message: 'No accessibility issues found',
level: 'success',
+ category: 'a11y',
status: 'idle',
})
})
@@ -88,6 +90,7 @@ describe('createMessagesReporter', () => {
expect(imageAlt).toMatchObject({
message: 'Fix image-alt (2)',
level: 'error',
+ category: 'a11y',
labels: ['critical', 'wcag2a'],
elementPosition: {
selector: '#image-alt-0',
@@ -104,6 +107,38 @@ describe('createMessagesReporter', () => {
.toBeUndefined()
})
+ it('carries dock-navigation actions deep-linked to the rule + route, and the summary to the dashboard', () => {
+ const { client, added } = createStubMessages()
+ const reporter = createMessagesReporter(client, { dockId: () => 'custom_a11y' })
+
+ reporter.report(report([violation('image-alt', 'critical')], '/about'))
+
+ const summary = added.find(m => m.id === 'devframes:plugin:a11y:scan')
+ expect(summary?.actions).toEqual([{
+ id: 'dashboard',
+ label: 'Open a11y dashboard',
+ kind: 'activate',
+ activate: { dockId: 'custom_a11y', params: { tab: 'dashboard' } },
+ }])
+
+ const rule = added.find(m => m.id === 'devframes:plugin:a11y:rule:image-alt')
+ expect(rule?.actions).toEqual([{
+ id: 'view',
+ label: 'View in a11y inspector',
+ kind: 'activate',
+ activate: { dockId: 'custom_a11y', params: { tab: 'violations', ruleId: 'image-alt', route: '/about' } },
+ }])
+ })
+
+ it('falls back to the default dock id when none is configured', () => {
+ const { client, added } = createStubMessages()
+ const reporter = createMessagesReporter(client)
+
+ reporter.report(report([violation('image-alt', 'critical')]))
+ const rule = added.find(m => m.id === 'devframes:plugin:a11y:rule:image-alt')
+ expect(rule?.actions?.[0]?.activate.dockId).toBe('devframes_plugin_a11y')
+ })
+
it('removes entries for rules that no longer violate on re-scan', () => {
const { client, added, removed } = createStubMessages()
const reporter = createMessagesReporter(client)
diff --git a/plugins/a11y/tests/protocol.test.ts b/plugins/a11y/tests/protocol.test.ts
new file mode 100644
index 00000000..70681352
--- /dev/null
+++ b/plugins/a11y/tests/protocol.test.ts
@@ -0,0 +1,62 @@
+import type { ScanReport } from '../src/shared/protocol.ts'
+import { describe, expect, it } from 'vitest'
+import { createGetConfig } from '../src/rpc/functions/get-config.ts'
+import { DEFAULT_AXE_TAGS, emptyCounts, sumCounts } from '../src/shared/protocol.ts'
+
+function report(counts: Partial>, route = '/'): ScanReport {
+ return {
+ route,
+ url: `https://example.test${route}`,
+ scannedAt: 1,
+ engine: 'axe-test',
+ violations: [],
+ counts: { ...emptyCounts(), ...counts },
+ }
+}
+
+describe('sumCounts', () => {
+ it('rolls per-impact counts across many route reports', () => {
+ const total = sumCounts([
+ report({ critical: 2, minor: 1 }, '/'),
+ report({ critical: 1, serious: 3 }, '/about'),
+ ])
+ expect(total).toEqual({ critical: 3, serious: 3, moderate: 0, minor: 1 })
+ })
+
+ it('returns an empty counter for no reports', () => {
+ expect(sumCounts([])).toEqual(emptyCounts())
+ })
+})
+
+describe('createGetConfig', () => {
+ it('defaults auto-scan + logging on, best-practice tags in, default-highlight off', () => {
+ const cfg = createGetConfig().handler!() as any
+ expect(cfg.dockId).toBe('devframes_plugin_a11y')
+ expect(cfg.defaultHighlight).toBe(false)
+ expect(cfg.agent.autoScan).toBe(true)
+ expect(cfg.agent.logIssues).toBe(true)
+ expect(cfg.agent.activateDockId).toBe('devframes_plugin_a11y')
+ // Broadened default tag set includes WCAG 2.2 + best-practice.
+ expect([...DEFAULT_AXE_TAGS]).toContain('best-practice')
+ expect([...DEFAULT_AXE_TAGS]).toContain('wcag22aa')
+ })
+
+ it('threads author options through to the agent config and dock id', () => {
+ const cfg = createGetConfig({
+ dockId: 'my_a11y',
+ autoScan: false,
+ logIssues: false,
+ defaultHighlight: true,
+ axe: { tags: ['wcag2a'], runOptions: { iframes: true } },
+ }).handler!() as any
+ expect(cfg.dockId).toBe('my_a11y')
+ expect(cfg.defaultHighlight).toBe(true)
+ expect(cfg.agent).toMatchObject({
+ autoScan: false,
+ logIssues: false,
+ axeTags: ['wcag2a'],
+ axeRunOptions: { iframes: true },
+ activateDockId: 'my_a11y',
+ })
+ })
+})
diff --git a/plugins/a11y/uno.config.ts b/plugins/a11y/uno.config.ts
index cb712b84..f122eddc 100644
--- a/plugins/a11y/uno.config.ts
+++ b/plugins/a11y/uno.config.ts
@@ -14,5 +14,8 @@ export default mergeConfigs([
include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/],
},
},
+ safelist: [
+ 'sr-only',
+ ],
},
])
diff --git a/plugins/messages/src/client/App.vue b/plugins/messages/src/client/App.vue
index 91c104e4..0fcd664a 100644
--- a/plugins/messages/src/client/App.vue
+++ b/plugins/messages/src/client/App.vue
@@ -1,10 +1,10 @@