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…

+
+ +
+ +
+

App under test

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

+
+ + + + I agree to the terms + +
+

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

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 => + `
  • `, + ).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()}

    -
    +
    {/* No agent has announced itself on this origin yet. */} - -
    - -

    No page connected

    -

    - Load the inspector agent in the app you want to check, then this - panel will list its accessibility issues live. -

    - {SNIPPET} -
    -
    - - {/* Agent present, first report not in yet. */} - -
    - -

    Scanning the page…

    -

    Running axe-core against the connected document.

    -
    + + - {/* Report in, zero violations. */} - -
    - -

    No WCAG A & AA violations

    -

    - axe-core found nothing to flag on this page. Re-run after changes - to keep it that way. -

    -
    + {/* Agent present, first state not in yet. */} + + - {/* Filter active but empty for that impact. */} - -
    - -

    - No - {' '} - {filter() ? IMPACT_LABEL[filter()!] : ''} - {' '} - issues -

    -

    - {total()} - {' '} - {total() === 1 ? 'issue' : 'issues'} - {' '} - at other severities. Clear the filter to see them. -

    -
    + {/* Report in, nothing to flag. */} + + - {/* The list. */} - 0}> - 0}> + + + 0} + fallback={( + + {totalFilterable()} + {' '} + {totalFilterable() === 1 ? 'rule' : 'rules'} + {' at other severities. Clear the filter to see them.'} + + )} + /> + )} + > + +
    + + 0}> + setDialogOpen(false)} /> +
    ) } 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()}> + +
    + ) +} 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()} + + + +
    + ) +} 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 ( +
    +
    + + +
    + +
      + + {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 ( + + ) + }} + +
    + ) +} 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')} + + + + + + + 0}> + + + + + + + + + +
    +
    + ) +} 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 ( + + ) +} 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) =>
        {Story()}
      ], +} 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())} + /> + +
      + + +
        + + {node => ( +
      • + +
      • + )} +
        +
      + + 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()} - - -
      - ) -} - -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 ( - - ) - }} - -
      - ) -} 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()} - > - - - -
        - - {node => ( -
      • - -
      • - )} -
        -
      - - 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 @@