diff --git a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts index 48f7727..198bfd8 100644 --- a/packages/devframe/src/node/__tests__/rpc-streaming.test.ts +++ b/packages/devframe/src/node/__tests__/rpc-streaming.test.ts @@ -5,17 +5,13 @@ import { createRpcClient } from 'devframe/rpc/client' import { createRpcServer } from 'devframe/rpc/server' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' +import { getPort } from 'get-port-please' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' import { RpcFunctionsHost } from '../host-functions' vi.stubGlobal('WebSocket', WebSocket) -let nextPort = 41000 -function allocatePort(): number { - return nextPort++ -} - interface Harness { port: number rpcHost: RpcFunctionsHost @@ -23,7 +19,7 @@ interface Harness { } async function bootHost(): Promise { - const port = allocatePort() + const port = await getPort({ host: '127.0.0.1', random: true }) const mockContext = {} as DevframeNodeContext const rpcHost = new RpcFunctionsHost(mockContext) diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index 037c3d5..9163a9a 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -39,12 +39,12 @@ describe('ws auth token in URL', () => { describe('devframe rpc', () => { it('should work w/ ws transport', async () => { - const PORT = 3333 // Use 127.0.0.1 on both client and server so they agree on the // address family — `localhost` resolution is ambiguous (IPv4 vs IPv6) // and differs between Windows/macOS/Linux, which causes the client // to hang when the two sides pick opposite families. const HOST = '127.0.0.1' + const PORT = await getPort({ host: HOST, random: true }) const WS_URL = `ws://${HOST}:${PORT}` const serverFunctions = { @@ -88,7 +88,7 @@ describe('devframe rpc', () => { // alongside the result path. it('returns a rejection (not a serialization crash) when a jsonSerializable RPC throws', async () => { const HOST = '127.0.0.1' - const PORT = await getPort({ port: 3334, host: HOST }) + const PORT = await getPort({ host: HOST, random: true }) const WS_URL = `ws://${HOST}:${PORT}` const serverFunctions = { diff --git a/plugins/a11y/.gitignore b/plugins/a11y/.gitignore new file mode 100644 index 0000000..50760c2 --- /dev/null +++ b/plugins/a11y/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.turbo diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md new file mode 100644 index 0000000..6b89f09 --- /dev/null +++ b/plugins/a11y/README.md @@ -0,0 +1,66 @@ +# devframe-a11y-inspector + +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**. + +The scan + highlight loop works the same whether the plugin runs as a live dev +server or as a baked static build. + +## How it works + +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/client`) | the devtools iframe | Solid SPA: lists violations, fires highlight/clear on hover | +| **Node** (`src/devframe.ts`, `src/rpc`) | the devframe backend | `get-config` RPC (impact taxonomy) — 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. + +devframe deliberately provides no access to the host application's DOM, so the +agent is the author-provided bridge: load one module script in the page you want +to check and it scans, reports, and highlights on demand. + +## Run the demo + +The demo serves an intentionally-broken host page and the panel from **one +origin** so they share the channel. + +```sh +pnpm -C plugins/a11y build # build the panel + the agent bundle +pnpm -C plugins/a11y demo # dev: live WebSocket RPC → http://localhost:4477/ + +pnpm -C plugins/a11y cli:build # bake the static deploy (dist/static) +pnpm -C plugins/a11y demo:build # static: baked RPC dump, no server +``` + +Open the URL, then hover any row in the panel — the matching element in the page +gets a focus ring (and scrolls into view if it's off-screen). Both demo modes +behave identically; the panel's `websocket` / `static` tag is the only tell. + +Standalone, without a host app: + +```sh +pnpm -C plugins/a11y dev # panel only, at /__devframe-a11y-inspector/ +``` + +## File map + +| Path | Purpose | +|------|---------| +| `src/devframe.ts` | the `DevframeDefinition` consumed by every adapter | +| `src/rpc/` | `get-config` static RPC + the type-safe client registry | +| `src/shared/protocol.ts` | the agent ↔ panel `BroadcastChannel` contract | +| `src/inject/` | the host-page agent (axe scan, highlight overlay) → `dist/inject/inject.js` | +| `src/client/` | the Solid panel SPA → `dist/client` | +| `demo/` | same-origin host page + server (dev + static modes) | +| `tests/` | dev-server RPC + static-build dump | diff --git a/plugins/a11y/bin.mjs b/plugins/a11y/bin.mjs new file mode 100755 index 0000000..e760d99 --- /dev/null +++ b/plugins/a11y/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCli } from 'devframe/adapters/cli' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCli(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/a11y/demo/index.html b/plugins/a11y/demo/index.html new file mode 100644 index 0000000..f85d3c9 --- /dev/null +++ b/plugins/a11y/demo/index.html @@ -0,0 +1,166 @@ + + + + + + Sunny Beans — demo host app + + + + + +
+
+

Small-batch coffee, roasted the morning it ships.

+ + + +

+ Sourced from a dozen farms, roasted in tiny drums, and sent the same day. +

+
+ +
+

Get the first-Friday drop

+
+ + + +
+

We email once a month. Unsubscribe anytime.

+
+ +
+

This week's roasts

+
    +
  • + + Midnight Drum — dark, cocoa, molasses +
  • +
  • + + Sunrise House — balanced, caramel, citrus +
  • +
  • + + Golden Hour — light, floral, stone fruit +
  • +
+
+ +
+

Questions? Visit our help center or follow along:

+ + + + +

© Sunny Beans Coffee Co. All rights reserved.

+
+
+ + + + + + + + diff --git a/plugins/a11y/demo/server.mjs b/plugins/a11y/demo/server.mjs new file mode 100644 index 0000000..00e7c17 --- /dev/null +++ b/plugins/a11y/demo/server.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Same-origin demo host for the a11y inspector. + * + * Serves three things off one origin so the injected agent (host page) and the + * panel (devtools iframe) share a BroadcastChannel: + * + * GET / → the demo page (intentional a11y bugs) + * GET /__df-inject/inject.js → the injected agent bundle + * GET /__devframe-a11y-inspector/** → the Solid panel SPA + * + * Two modes prove the plugin works either way: + * + * node demo/server.mjs dev — live WebSocket RPC (`dist/client`) + * node demo/server.mjs build static — baked RPC dump, (`dist/static`) + * + * The scan/highlight loop is identical in both: it rides the BroadcastChannel, + * not the devframe backend. + */ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { createH3DevframeHost, createHostContext, startHttpAndWs } from 'devframe/node' +import { mountStaticHandler } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import { H3, toNodeHandler } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/devframe.ts' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +const ROOT = resolve(HERE, '..') + +const mode = process.argv[2] === 'build' ? 'build' : 'dev' +const basePath = devframe.basePath +const injectDir = resolve(ROOT, 'dist/inject') +const panelDir = mode === 'build' ? resolve(ROOT, 'dist/static') : resolve(ROOT, 'dist/client') + +function requireBuilt(file, hint) { + if (!existsSync(file)) { + console.error(`\n[a11y-inspector demo] missing ${file}\n → run \`${hint}\` first.\n`) + process.exit(1) + } +} + +function banner(origin) { + const label = mode === 'build' ? 'static build (baked RPC dump)' : 'dev (live WebSocket RPC)' + process.stdout.write( + `\n A11y Inspector demo — ${label}\n` + + ` ▸ host app + docked panel: ${origin}/\n` + + ` ▸ panel only: ${origin}${basePath}\n\n` + + ' Hover a violation in the panel to highlight its element in the page.\n\n', + ) +} + +async function main() { + requireBuilt(resolve(injectDir, 'inject.js'), 'pnpm -C plugins/a11y build') + requireBuilt( + resolve(panelDir, 'index.html'), + mode === 'build' + ? 'pnpm -C plugins/a11y build && pnpm -C plugins/a11y cli:build' + : 'pnpm -C plugins/a11y build', + ) + + const bindHost = '0.0.0.0' + const port = await getPort({ host: bindHost, port: 4477 }) + const demoHtml = await readFile(resolve(HERE, 'index.html'), 'utf-8') + + const app = new H3() + + // 1. The demo host page (exact `/`). + app.use('/', (event) => { + event.res.headers.set('content-type', 'text/html; charset=utf-8') + return demoHtml + }) + + // 2. The injected agent bundle. + mountStaticHandler(app, '/__df-inject/', injectDir) + + if (mode === 'dev') { + const origin = `http://localhost:${port}` + const h3Host = createH3DevframeHost({ + origin, + appName: devframe.id, + mount: (base, dir) => mountStaticHandler(app, base, dir), + }) + const ctx = await createHostContext({ cwd: ROOT, mode: 'dev', host: h3Host }) + await devframe.setup(ctx) + + // 3a. Connection meta (must precede the catch-all static mount) + WS RPC. + app.use( + `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`, + () => ({ backend: 'websocket', websocket: port }), + ) + mountStaticHandler(app, basePath, panelDir) + + await startHttpAndWs({ context: ctx, host: bindHost, port, app, auth: false }) + banner(origin) + } + else { + // 3b. Static build already carries its own __connection.json + __rpc-dump. + mountStaticHandler(app, basePath, panelDir) + const server = createServer(toNodeHandler(app)) + await new Promise(r => server.listen(port, bindHost, r)) + banner(`http://localhost:${port}`) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/a11y/package.json b/plugins/a11y/package.json new file mode 100644 index 0000000..98b45bd --- /dev/null +++ b/plugins/a11y/package.json @@ -0,0 +1,34 @@ +{ + "name": "@devframes/a11y", + "type": "module", + "version": "0.5.2", + "private": true, + "description": "Devframe a11y inspector — runs axe-core against the host app, surfaces the violations through a Solid SPA, and highlights the offending element in the page on hover. Works in dev (WebSocket) and static build modes.", + "main": "src/devframe.ts", + "bin": { + "devframe-a11y-inspector": "./bin.mjs" + }, + "scripts": { + "build": "vite build --config src/client/vite.config.ts && vite build --config src/inject/vite.config.ts", + "build:client": "vite build --config src/client/vite.config.ts", + "build:inject": "vite build --config src/inject/vite.config.ts", + "dev": "node bin.mjs", + "cli:build": "node bin.mjs build --out-dir dist/static", + "demo": "node demo/server.mjs", + "demo:build": "node demo/server.mjs build", + "test": "vitest run" + }, + "dependencies": { + "axe-core": "catalog:frontend", + "devframe": "workspace:*", + "solid-js": "catalog:frontend" + }, + "devDependencies": { + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "vite": "catalog:build", + "vite-plugin-solid": "catalog:build", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/plugins/a11y/src/client/app.tsx b/plugins/a11y/src/client/app.tsx new file mode 100644 index 0000000..8e7c847 --- /dev/null +++ b/plugins/a11y/src/client/app.tsx @@ -0,0 +1,133 @@ +import type { Impact } from '../shared/protocol.ts' +import { createMemo, createSignal, Match, 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 { createA11yChannel } from './lib/channel.ts' +import { connectDevframeState } from './lib/devframe.ts' +import { IMPACT_LABEL } from './lib/impact.ts' + +const SNIPPET = '' + +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() + }) + + function toggleFilter(impact: Impact) { + setFilter(prev => (prev === impact ? null : impact)) + } + function toggleExpand(ruleId: string) { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(ruleId)) + next.delete(ruleId) + else + next.add(ruleId) + return next + }) + } + + const announce = () => { + if (!channel.report()) + return channel.scanning() ? 'Scanning the page' : '' + return `${total()} accessibility ${total() === 1 ? 'issue' : 'issues'} found` + } + + return ( +
+
+ + + 0}> + + + +

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

+
+
+ + {/* 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. +

+
+
+ + {/* The list. */} + 0}> + + +
+
+
+ ) +} diff --git a/plugins/a11y/src/client/components/header.tsx b/plugins/a11y/src/client/components/header.tsx new file mode 100644 index 0000000..98dd8a0 --- /dev/null +++ b/plugins/a11y/src/client/components/header.tsx @@ -0,0 +1,100 @@ +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 { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts' +import { BrandGlyph } from './icons.tsx' + +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 }) { + return ( + + {report => ( +
+ {report().url} + + {b => {b()}} + + + 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/client/components/icons.tsx b/plugins/a11y/src/client/components/icons.tsx new file mode 100644 index 0000000..df66699 --- /dev/null +++ b/plugins/a11y/src/client/components/icons.tsx @@ -0,0 +1,96 @@ +import type { JSX } from 'solid-js' + +type IconProps = { size?: number } & JSX.SvgSVGAttributes + +/** + * Focus-reticle brand mark — a viewfinder bracketing a target dot, echoing + * the highlight ring the tool paints in the page. + */ +export function BrandGlyph(props: IconProps) { + const size = () => props.size ?? 20 + return ( + + ) +} + +export function Chevron(props: IconProps) { + const size = () => props.size ?? 14 + return ( + + ) +} + +export function CheckCircle(props: IconProps) { + const size = () => props.size ?? 40 + return ( + + ) +} + +export function PlugIcon(props: IconProps) { + const size = () => props.size ?? 40 + return ( + + ) +} diff --git a/plugins/a11y/src/client/components/violations.tsx b/plugins/a11y/src/client/components/violations.tsx new file mode 100644 index 0000000..4234807 --- /dev/null +++ b/plugins/a11y/src/client/components/violations.tsx @@ -0,0 +1,108 @@ +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/client/index.html b/plugins/a11y/src/client/index.html new file mode 100644 index 0000000..ac43b79 --- /dev/null +++ b/plugins/a11y/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + + A11y Inspector + + +
    + + + diff --git a/plugins/a11y/src/client/lib/channel.ts b/plugins/a11y/src/client/lib/channel.ts new file mode 100644 index 0000000..c19fd2d --- /dev/null +++ b/plugins/a11y/src/client/lib/channel.ts @@ -0,0 +1,71 @@ +import type { Accessor } from 'solid-js' +import type { A11yMessage, ScanReport, ViolationNode } 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 + /** 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 + /** Ask the agent to re-run the scan. */ + rescan: () => 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. + */ +export function createA11yChannel(): A11yChannel { + const [report, setReport] = createSignal(null) + const [agentReady, setAgentReady] = createSignal(false) + const [scanning, setScanning] = createSignal(false) + + const channel = new BroadcastChannel(A11Y_CHANNEL) + const post = (message: A11yMessage) => channel.postMessage(message) + + channel.addEventListener('message', (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case 'a11y:agent-ready': + setAgentReady(true) + // Closes the startup race: if our panel-ready landed before the agent + // was listening, asking again now pulls down the current report. + if (!report()) + post({ type: 'a11y:panel-ready' }) + break + case 'a11y:report': + setAgentReady(true) + setScanning(false) + setReport(message.report) + break + case 'a11y:scanning': + setAgentReady(true) + setScanning(true) + break + } + }) + + // Announce the panel so a previously-loaded agent replays its last report. + post({ type: 'a11y:panel-ready' }) + + onCleanup(() => channel.close()) + + return { + report, + agentReady, + scanning, + highlight: node => post({ type: 'a11y:highlight', nodeId: node.id, target: node.target }), + clearHighlight: () => post({ type: 'a11y:clear' }), + rescan: () => { + setScanning(true) + post({ type: 'a11y:rescan' }) + }, + } +} diff --git a/plugins/a11y/src/client/lib/devframe.ts b/plugins/a11y/src/client/lib/devframe.ts new file mode 100644 index 0000000..90631e5 --- /dev/null +++ b/plugins/a11y/src/client/lib/devframe.ts @@ -0,0 +1,57 @@ +import type { Accessor } from 'solid-js' +import type { Impact } from '../../shared/protocol.ts' +import { connectDevframe } from 'devframe/client' +import { createSignal } from 'solid-js' + +export interface ImpactMeta { + id: Impact + label: string + blurb: string +} + +export interface A11yConfig { + channel: string + nodeAttr: string + docsBase: string + impacts: ImpactMeta[] +} + +export interface DevframeState { + /** `'websocket'` in dev, `'static'` for a baked build, `null` while/if unreachable. */ + backend: Accessor + /** Impact taxonomy + copy from the `get-config` RPC. */ + config: 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. + */ +export function connectDevframeState(): DevframeState { + const [backend, setBackend] = createSignal(null) + const [config, setConfig] = createSignal(null) + + connectDevframe() + .then(async (rpc) => { + setBackend(rpc.connectionMeta.backend) + try { + await rpc.ensureTrusted(2000) + } + catch { + // WS handshake refused/timed out — config is optional, carry on. + } + // The server-fn augmentation lives in `src/rpc` (node side); the client + // bundle doesn't import it, so call by name with a local return type. + const callConfig = rpc.callOptional as (name: string) => Promise + const cfg = await callConfig('devframe-a11y-inspector:get-config') + if (cfg) + setConfig(cfg) + }) + .catch(() => { + // No reachable backend (e.g. agent loaded outside a devframe host). + }) + + return { backend, config } +} diff --git a/plugins/a11y/src/client/lib/impact.ts b/plugins/a11y/src/client/lib/impact.ts new file mode 100644 index 0000000..a9f9e51 --- /dev/null +++ b/plugins/a11y/src/client/lib/impact.ts @@ -0,0 +1,20 @@ +import type { Impact } from '../../shared/protocol.ts' + +/** + * Severity palette — the only expressive color in the panel. Mirrors the + * ring colors the injected agent draws, so a row and its in-page highlight + * read as the same object. + */ +export const IMPACT_COLOR: Record = { + critical: '#ff5c7a', + serious: '#ff9b52', + moderate: '#f2d14e', + minor: '#6fb1fc', +} + +export const IMPACT_LABEL: Record = { + critical: 'Critical', + serious: 'Serious', + moderate: 'Moderate', + minor: 'Minor', +} diff --git a/plugins/a11y/src/client/main.tsx b/plugins/a11y/src/client/main.tsx new file mode 100644 index 0000000..a649ade --- /dev/null +++ b/plugins/a11y/src/client/main.tsx @@ -0,0 +1,10 @@ +/* @refresh reload */ +import { render } from 'solid-js/web' +import { App } from './app.tsx' +import './styles.css' + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') + +render(() => , root) diff --git a/plugins/a11y/src/client/styles.css b/plugins/a11y/src/client/styles.css new file mode 100644 index 0000000..cbdb97a --- /dev/null +++ b/plugins/a11y/src/client/styles.css @@ -0,0 +1,527 @@ +/* + * A11y Inspector panel — an "audit console" surface. + * + * 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. + */ + +:root { + --ink: #0d1017; + --surface: #141a23; + --surface-2: #1a212c; + --line: #262e3b; + --line-soft: #1e2531; + --text: #e8edf4; + --muted: #94a1b5; + --faint: #5c6779; + --ring: #4c9fff; + + --font-ui: system-ui, -apple-system, "Segoe UI", roboto, sans-serif; + --font-mono: ui-monospace, sfmono-regular, "SF Mono", menlo, consolas, monospace; + + color-scheme: dark; +} + +* { + 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 ───────────────────────────────────────────────────────────── */ + +.topbar { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + background: linear-gradient(180deg, var(--surface) 0%, var(--ink) 100%); + border-bottom: 1px solid var(--line); +} + +.brand { + display: flex; + align-items: center; + gap: 9px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.brand__glyph { + display: block; + color: var(--ring); +} + +.brand__name { + font-size: 14px; +} + +.brand__name b { + font-weight: 700; +} + +.topbar__spacer { + flex: 1; +} + +.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: #46d39a; +} + +.status__dot--scanning { + background: var(--ring); + animation: pulse 1.1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgb(76 159 255 / 50%); + } + 50% { + box-shadow: 0 0 0 5px rgb(76 159 255 / 0%); + } +} + +.rescan { + font: inherit; + font-size: 12px; + font-weight: 600; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 7px; + padding: 6px 12px; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} + +.rescan:hover { + border-color: var(--ring); + background: #1f2733; +} + +.rescan:disabled { + opacity: 0.55; + cursor: progress; +} + +/* ── 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; +} + +/* ── severity summary ──────────────────────────────────────────────────── */ + +.summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + padding: 14px 16px; +} + +.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); + 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: #10151d; +} + +.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: #c7d3e6; + 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: rgb(76 159 255 / 10%); + 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: #46d39a; +} + +.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: #313b4b; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} diff --git a/plugins/a11y/src/client/vite.config.ts b/plugins/a11y/src/client/vite.config.ts new file mode 100644 index 0000000..f0ccb13 --- /dev/null +++ b/plugins/a11y/src/client/vite.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { alias } from '../../../../alias' + +// `base: './'` + `` keeps the bundle mount-path portable: +// the same `dist/client` works whether devframe serves it at `/` (standalone) +// or `/__devframe-a11y-inspector/` (mounted in a hub). `connectDevframe` +// resolves its connection meta relative to `document.baseURI` to match. +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [solid()], + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/plugins/a11y/src/devframe.ts b/plugins/a11y/src/devframe.ts new file mode 100644 index 0000000..2eddea6 --- /dev/null +++ b/plugins/a11y/src/devframe.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import { serverFunctions } from './rpc/index.ts' + +const BASE_PATH = '/__devframe-a11y-inspector/' +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +export default defineDevframe({ + id: 'devframe-a11y-inspector', + name: 'A11y Inspector', + icon: 'ph:wheelchair-duotone', + basePath: BASE_PATH, + cli: { + command: 'devframe-a11y-inspector', + port: 9899, + distDir, + }, + spa: { loader: 'none' }, + setup(ctx) { + for (const fn of serverFunctions) + ctx.rpc.register(fn) + }, +}) diff --git a/plugins/a11y/src/inject/index.ts b/plugins/a11y/src/inject/index.ts new file mode 100644 index 0000000..95811f1 --- /dev/null +++ b/plugins/a11y/src/inject/index.ts @@ -0,0 +1,132 @@ +/** + * The a11y inspector **agent** — injected into the host application's page. + * + * It runs axe-core against the live DOM, broadcasts the report to the panel, + * and draws a highlight ring around any element the panel asks about. It talks + * to the panel purely over a same-origin BroadcastChannel, so it needs no + * server — the loop works the same in dev and in a static build. + * + * Load it from the host page with a single module script, e.g. + * ``. + */ +import type { A11yMessage, ScanReport } from '../shared/protocol.ts' +import { A11Y_CHANNEL } from '../shared/protocol.ts' +import { createOverlay } from './overlay.ts' +import { resolveElement, scan } from './scanner.ts' + +const GLOBAL_FLAG = '__DF_A11Y_AGENT__' + +function start() { + const w = window as unknown as Record + if (w[GLOBAL_FLAG]) + return + w[GLOBAL_FLAG] = true + + const channel = new BroadcastChannel(A11Y_CHANNEL) + const overlay = createOverlay() + document.documentElement.appendChild(overlay.root) + + let lastReport: ScanReport | null = null + let scanning = false + let rescanQueued = false + let debounceTimer = 0 + + const post = (message: A11yMessage) => channel.postMessage(message) + + const observer = new MutationObserver((records) => { + // Ignore our own overlay mutations; everything else may have changed the + // accessibility tree, so debounce a fresh scan. + const relevant = records.some(r => !overlay.root.contains(r.target as Node)) + if (relevant) + scheduleScan() + }) + + function observe() { + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['alt', 'role', 'aria-label', 'aria-labelledby', 'for', 'href', 'src', 'title', 'lang', 'type'], + }) + } + + function scheduleScan() { + clearTimeout(debounceTimer) + debounceTimer = window.setTimeout(runScan, 600) + } + + async function runScan() { + if (scanning) { + rescanQueued = true + return + } + scanning = true + post({ type: 'a11y: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 }) + } + catch (error) { + console.error('[a11y-inspector] scan failed', error) + } + finally { + observe() + scanning = false + if (rescanQueued) { + rescanQueued = false + scheduleScan() + } + } + } + + 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 }) + else + void runScan() + break + case 'a11y:highlight': { + const el = document.querySelector(`[data-df-a11y-node="${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 }) + } + else { + overlay.hide() + } + break + } + case 'a11y:clear': + overlay.hide() + break + case 'a11y:rescan': + void runScan() + break + } + }) + + // Announce ourselves and run the first scan once the page has settled. + post({ type: 'a11y:agent-ready', url: location.href }) + if (document.readyState === 'complete') + void runScan() + else + addEventListener('load', () => void runScan(), { once: true }) +} + +function findImpact(report: ScanReport | null, nodeId: string) { + return report?.violations.find(v => v.nodes.some(n => n.id === nodeId))?.impact +} +function findRule(report: ScanReport | null, nodeId: string) { + return report?.violations.find(v => v.nodes.some(n => n.id === nodeId))?.ruleId +} + +start() diff --git a/plugins/a11y/src/inject/overlay.ts b/plugins/a11y/src/inject/overlay.ts new file mode 100644 index 0000000..2fa54e1 --- /dev/null +++ b/plugins/a11y/src/inject/overlay.ts @@ -0,0 +1,123 @@ +import type { Impact } from '../shared/protocol.ts' + +const IMPACT_COLOR: Record = { + critical: '#ff5c7a', + serious: '#ff9b52', + moderate: '#f2d14e', + minor: '#6fb1fc', +} + +const PREFERS_REDUCED_MOTION + = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + +export interface HighlightInfo { + ruleId: string + impact: Impact +} + +export interface Overlay { + root: HTMLElement + show: (el: Element, info: HighlightInfo) => void + hide: () => 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(';') + + const box = document.createElement('div') + box.style.cssText = [ + 'position:absolute', + 'box-sizing:border-box', + 'border-radius:3px', + `transition:${PREFERS_REDUCED_MOTION ? 'none' : 'all .12s cubic-bezier(.4,0,.2,1)'}`, + ].join(';') + + const label = document.createElement('div') + label.style.cssText = [ + 'position:absolute', + 'left:0', + 'transform:translateY(-100%)', + 'font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace', + 'letter-spacing:.02em', + 'padding:1px 7px', + 'border-radius:3px 3px 3px 0', + 'white-space:nowrap', + 'color:#0b0e13', + ].join(';') + + box.appendChild(label) + root.appendChild(box) + + let current: Element | null = null + let rafId = 0 + + function place() { + if (!current) + return + const rect = current.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` + // 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' + } + + function onViewportChange() { + if (rafId) + return + rafId = requestAnimationFrame(() => { + rafId = 0 + place() + }) + } + + 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. + 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) + } + + return { root, show, hide } +} diff --git a/plugins/a11y/src/inject/scanner.ts b/plugins/a11y/src/inject/scanner.ts new file mode 100644 index 0000000..c499f36 --- /dev/null +++ b/plugins/a11y/src/inject/scanner.ts @@ -0,0 +1,105 @@ +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' + +const IMPACTS = new Set(IMPACT_ORDER) +let counter = 0 + +/** Coerce axe's nullable, frame-aware impact into our fixed taxonomy. */ +function normalizeImpact(value: unknown): Violation['impact'] { + return typeof value === 'string' && IMPACTS.has(value) + ? (value as Violation['impact']) + : 'minor' +} + +/** + * Flatten an axe target into plain selector strings. Top-level nodes yield a + * single selector; nodes inside frames yield one per frame depth. + */ +function flattenTarget(target: unknown): string[] { + if (!Array.isArray(target)) + return [String(target)] + return target.map(entry => (Array.isArray(entry) ? entry.join(' ') : String(entry))) +} + +/** Resolve the element a target points at within the top document. */ +export function resolveElement(target: string[]): Element | null { + // The deepest selector is the most specific; try it first, then fall back. + for (const selector of [...target].reverse()) { + try { + const el = document.querySelector(selector) + if (el) + return el + } + catch { + // Malformed selector — skip and try the next. + } + } + return null +} + +/** + * Stamp the element with a stable id (reusing one from a prior scan when + * present) so the panel can re-target it after the DOM shifts. + */ +function stamp(el: Element): string { + const existing = el.getAttribute(A11Y_NODE_ATTR) + if (existing) + return existing + const id = `a${(counter++).toString(36)}` + el.setAttribute(A11Y_NODE_ATTR, id) + return id +} + +/** + * 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 { + 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'] }, + // 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, + }) + + const counts = emptyCounts() + const violations: Violation[] = results.violations.map((rule) => { + const impact = normalizeImpact(rule.impact) + const nodes: ViolationNode[] = rule.nodes.map((node) => { + const target = flattenTarget(node.target) + const el = resolveElement(target) + const id = el ? stamp(el) : target.join('|') + counts[impact] += 1 + return { + id, + target, + html: node.html.trim().slice(0, 400), + failureSummary: (node.failureSummary ?? '').trim(), + } + }) + return { + ruleId: rule.id, + impact, + help: rule.help, + description: rule.description, + helpUrl: rule.helpUrl, + nodes, + } + }) + + // Surface the most severe rules first. + violations.sort((a, b) => IMPACT_ORDER.indexOf(a.impact) - IMPACT_ORDER.indexOf(b.impact)) + + return { + url: location.href, + scannedAt: Date.now(), + engine: results.testEngine?.version ?? 'unknown', + violations, + counts, + } +} diff --git a/plugins/a11y/src/inject/vite.config.ts b/plugins/a11y/src/inject/vite.config.ts new file mode 100644 index 0000000..ed7e5e6 --- /dev/null +++ b/plugins/a11y/src/inject/vite.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +// Builds the host-page agent into a single self-contained ES module +// (`dist/inject/inject.js`) with axe-core bundled in. Loaded by the host app +// via `