Skip to content

Commit 10eda87

Browse files
committed
feat(examples): add a11y inspector plugin
A devframe example that audits a host application for accessibility issues. axe-core scans the live page, a Solid SPA lists the WCAG A/AA violations grouped by severity, and hovering a warning highlights the offending element in the page (scrolling it into view when off-screen). The injected agent (host page) and the panel (devtools iframe) talk over a same-origin BroadcastChannel rather than the devframe backend, so the scan + highlight loop behaves identically whether the plugin runs as a dev server (WebSocket RPC) or a baked static build. The get-config RPC carries the impact taxonomy — live in dev, from the baked dump in a build. Ships a same-origin demo (intentional a11y bugs + docked panel) for both modes, plus dev-server and static-build tests.
1 parent 3828b2d commit 10eda87

33 files changed

Lines changed: 2499 additions & 8 deletions

examples/a11y-inspector/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
node_modules
3+
.turbo

examples/a11y-inspector/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# devframe-a11y-inspector
2+
3+
An accessibility inspector built on [devframe](../../packages/devframe). It runs
4+
[axe-core](https://github.com/dequelabs/axe-core) against a host application,
5+
lists the WCAG A/AA violations in a [Solid](https://www.solidjs.com/) panel, and
6+
**highlights the offending element in the page when you hover a warning**.
7+
8+
The scan + highlight loop works the same whether the plugin runs as a live dev
9+
server or as a baked static build.
10+
11+
## How it works
12+
13+
Three pieces, two of them browser-side:
14+
15+
| Piece | Runs in | Role |
16+
|-------|---------|------|
17+
| **Agent** (`src/inject`) | the host app's page | runs axe-core, broadcasts the report, draws the highlight ring |
18+
| **Panel** (`src/client`) | the devtools iframe | Solid SPA: lists violations, fires highlight/clear on hover |
19+
| **Node** (`src/devframe.ts`, `src/rpc`) | the devframe backend | `get-config` RPC (impact taxonomy) — live in dev, baked in a static build |
20+
21+
The agent and panel talk over a same-origin
22+
[`BroadcastChannel`](src/shared/protocol.ts), not the devframe RPC backend. That
23+
is what keeps the live loop working in **both modes**: neither half needs a
24+
server to reach the other, only a shared browser origin (host page + panel
25+
iframe). devframe RPC carries the data model on top — `get-config` is a `static`
26+
function, so it resolves over WebSocket in dev and from the baked dump in a
27+
static build.
28+
29+
devframe deliberately provides no access to the host application's DOM, so the
30+
agent is the author-provided bridge: load one module script in the page you want
31+
to check and it scans, reports, and highlights on demand.
32+
33+
## Run the demo
34+
35+
The demo serves an intentionally-broken host page and the panel from **one
36+
origin** so they share the channel.
37+
38+
```sh
39+
pnpm -C examples/a11y-inspector build # build the panel + the agent bundle
40+
pnpm -C examples/a11y-inspector demo # dev: live WebSocket RPC → http://localhost:4477/
41+
42+
pnpm -C examples/a11y-inspector cli:build # bake the static deploy (dist/static)
43+
pnpm -C examples/a11y-inspector demo:build # static: baked RPC dump, no server
44+
```
45+
46+
Open the URL, then hover any row in the panel — the matching element in the page
47+
gets a focus ring (and scrolls into view if it's off-screen). Both demo modes
48+
behave identically; the panel's `websocket` / `static` tag is the only tell.
49+
50+
Standalone, without a host app:
51+
52+
```sh
53+
pnpm -C examples/a11y-inspector dev # panel only, at /__devframe-a11y-inspector/
54+
```
55+
56+
## File map
57+
58+
| Path | Purpose |
59+
|------|---------|
60+
| `src/devframe.ts` | the `DevframeDefinition` consumed by every adapter |
61+
| `src/rpc/` | `get-config` static RPC + the type-safe client registry |
62+
| `src/shared/protocol.ts` | the agent ↔ panel `BroadcastChannel` contract |
63+
| `src/inject/` | the host-page agent (axe scan, highlight overlay) → `dist/inject/inject.js` |
64+
| `src/client/` | the Solid panel SPA → `dist/client` |
65+
| `demo/` | same-origin host page + server (dev + static modes) |
66+
| `tests/` | dev-server RPC + static-build dump |

examples/a11y-inspector/bin.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process'
3+
import { createCli } from 'devframe/adapters/cli'
4+
import devframe from './src/devframe.ts'
5+
6+
async function main() {
7+
const cli = createCli(devframe)
8+
await cli.parse()
9+
}
10+
11+
main().catch((error) => {
12+
console.error(error)
13+
process.exit(1)
14+
})
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width,initial-scale=1" />
6+
<title>Sunny Beans — demo host app</title>
7+
<style>
8+
:root {
9+
--cream: #fbf6ec;
10+
--espresso: #2c2118;
11+
--bean: #6f4e37;
12+
--crema: #c98b54;
13+
}
14+
* { box-sizing: border-box; }
15+
body {
16+
margin: 0;
17+
font-family: Georgia, "Times New Roman", serif;
18+
color: var(--espresso);
19+
background: var(--cream);
20+
/* leave room for the docked inspector on the right */
21+
padding-right: 440px;
22+
}
23+
.wrap { max-width: 760px; margin: 0 auto; padding: 0 28px; }
24+
nav {
25+
display: flex;
26+
align-items: center;
27+
gap: 14px;
28+
padding: 18px 28px;
29+
border-bottom: 1px solid #e7dcc8;
30+
}
31+
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; }
32+
.nav-spacer { flex: 1; }
33+
.icon-btn {
34+
width: 40px; height: 40px;
35+
display: grid; place-items: center;
36+
background: var(--espresso); color: var(--cream);
37+
border: 0; border-radius: 10px; cursor: pointer;
38+
}
39+
.hero { padding: 56px 0 40px; }
40+
.hero h1 { font-size: 44px; line-height: 1.05; margin: 0 0 14px; }
41+
.hero .cup {
42+
width: 100%; height: 260px; border-radius: 16px; display: block;
43+
margin: 22px 0;
44+
}
45+
/* deliberately low contrast helper copy */
46+
.muted { color: #cdbfa8; font-size: 15px; }
47+
.section { padding: 36px 0; border-top: 1px solid #e7dcc8; }
48+
.section h2 { font-size: 26px; margin: 0 0 16px; }
49+
form { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
50+
input[type="email"] {
51+
flex: 1; min-width: 240px;
52+
padding: 12px 14px; font-size: 16px;
53+
border: 1px solid var(--bean); border-radius: 10px; background: #fff;
54+
}
55+
.btn {
56+
padding: 12px 20px; font-size: 16px; font-weight: 700;
57+
background: var(--crema); color: var(--espresso);
58+
border: 0; border-radius: 10px; cursor: pointer;
59+
}
60+
.roasts { list-style: none; padding: 0; display: grid; gap: 12px; }
61+
.roasts li {
62+
display: flex; align-items: center; gap: 14px;
63+
padding: 14px; background: #fff; border-radius: 12px;
64+
border: 1px solid #ece2cf;
65+
}
66+
.swatch { width: 46px; height: 46px; border-radius: 8px; }
67+
footer {
68+
padding: 40px 0 80px; margin-top: 24px;
69+
border-top: 1px solid #e7dcc8;
70+
}
71+
footer a { color: var(--bean); }
72+
.fineprint { color: #d7cbb4; font-size: 13px; }
73+
74+
/* docked inspector — this chrome is intentionally accessible so it
75+
doesn't show up in its own report */
76+
.df-dock {
77+
position: fixed;
78+
top: 0; right: 0; bottom: 0;
79+
width: 420px;
80+
border-left: 1px solid #d8cbb4;
81+
box-shadow: -16px 0 40px rgb(44 33 24 / 12%);
82+
background: #0d1017;
83+
z-index: 5;
84+
}
85+
.df-dock__frame { width: 100%; height: 100%; border: 0; display: block; }
86+
@media (max-width: 900px) {
87+
body { padding-right: 0; padding-bottom: 50vh; }
88+
.df-dock { top: auto; width: auto; height: 50vh; border-left: 0; border-top: 1px solid #d8cbb4; }
89+
}
90+
</style>
91+
</head>
92+
<body>
93+
<nav>
94+
<span class="logo">Sunny Beans</span>
95+
<span class="nav-spacer"></span>
96+
<!-- a11y bug: icon-only button with no accessible name -->
97+
<button class="icon-btn" type="button">
98+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
99+
<path d="M4 6h16M4 12h16M4 18h16" />
100+
</svg>
101+
</button>
102+
</nav>
103+
104+
<div class="wrap">
105+
<section class="hero">
106+
<h1>Small-batch coffee, roasted the morning it ships.</h1>
107+
<!-- a11y bug: image without alt text -->
108+
<img
109+
class="cup"
110+
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='760' height='260'%3E%3Crect width='760' height='260' fill='%236f4e37'/%3E%3Ccircle cx='380' cy='130' r='70' fill='%23c98b54'/%3E%3C/svg%3E"
111+
/>
112+
<!-- a11y bug: low-contrast text -->
113+
<p class="muted">
114+
Sourced from a dozen farms, roasted in tiny drums, and sent the same day.
115+
</p>
116+
</section>
117+
118+
<section class="section">
119+
<h2>Get the first-Friday drop</h2>
120+
<form onsubmit="return false">
121+
<!-- a11y bug: input with no associated label -->
122+
<input type="email" placeholder="you@example.com" />
123+
<button class="btn" type="submit">Notify me</button>
124+
</form>
125+
<p class="fineprint">We email once a month. Unsubscribe anytime.</p>
126+
</section>
127+
128+
<section class="section">
129+
<h2>This week's roasts</h2>
130+
<ul class="roasts">
131+
<li>
132+
<span class="swatch" style="background:#3b2317"></span>
133+
<span><strong>Midnight Drum</strong> — dark, cocoa, molasses</span>
134+
</li>
135+
<li>
136+
<span class="swatch" style="background:#8a5a32"></span>
137+
<span><strong>Sunrise House</strong> — balanced, caramel, citrus</span>
138+
</li>
139+
<li>
140+
<span class="swatch" style="background:#c98b54"></span>
141+
<span><strong>Golden Hour</strong> — light, floral, stone fruit</span>
142+
</li>
143+
</ul>
144+
</section>
145+
146+
<footer>
147+
<p>Questions? Visit our help center or follow along:</p>
148+
<!-- a11y bug: link with no discernible text (icon only, no label) -->
149+
<a href="https://example.com/social">
150+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
151+
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z" />
152+
</svg>
153+
</a>
154+
<p class="fineprint">© Sunny Beans Coffee Co. All rights reserved.</p>
155+
</footer>
156+
</div>
157+
158+
<!-- Docked A11y Inspector panel (same origin → shares the BroadcastChannel). -->
159+
<aside class="df-dock" aria-label="A11y Inspector">
160+
<iframe class="df-dock__frame" title="A11y Inspector panel" src="/__devframe-a11y-inspector/"></iframe>
161+
</aside>
162+
163+
<!-- The injected a11y agent: scans this page and answers the panel. -->
164+
<script type="module" src="/__df-inject/inject.js"></script>
165+
</body>
166+
</html>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Same-origin demo host for the a11y inspector.
4+
*
5+
* Serves three things off one origin so the injected agent (host page) and the
6+
* panel (devtools iframe) share a BroadcastChannel:
7+
*
8+
* GET / → the demo page (intentional a11y bugs)
9+
* GET /__df-inject/inject.js → the injected agent bundle
10+
* GET /__devframe-a11y-inspector/** → the Solid panel SPA
11+
*
12+
* Two modes prove the plugin works either way:
13+
*
14+
* node demo/server.mjs dev — live WebSocket RPC (`dist/client`)
15+
* node demo/server.mjs build static — baked RPC dump, (`dist/static`)
16+
*
17+
* The scan/highlight loop is identical in both: it rides the BroadcastChannel,
18+
* not the devframe backend.
19+
*/
20+
import { existsSync } from 'node:fs'
21+
import { readFile } from 'node:fs/promises'
22+
import { createServer } from 'node:http'
23+
import process from 'node:process'
24+
import { fileURLToPath } from 'node:url'
25+
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
26+
import { createH3DevframeHost, createHostContext, startHttpAndWs } from 'devframe/node'
27+
import { mountStaticHandler } from 'devframe/utils/serve-static'
28+
import { getPort } from 'get-port-please'
29+
import { H3, toNodeHandler } from 'h3'
30+
import { resolve } from 'pathe'
31+
import devframe from '../src/devframe.ts'
32+
33+
const HERE = fileURLToPath(new URL('.', import.meta.url))
34+
const ROOT = resolve(HERE, '..')
35+
36+
const mode = process.argv[2] === 'build' ? 'build' : 'dev'
37+
const basePath = devframe.basePath
38+
const injectDir = resolve(ROOT, 'dist/inject')
39+
const panelDir = mode === 'build' ? resolve(ROOT, 'dist/static') : resolve(ROOT, 'dist/client')
40+
41+
function requireBuilt(file, hint) {
42+
if (!existsSync(file)) {
43+
console.error(`\n[a11y-inspector demo] missing ${file}\n → run \`${hint}\` first.\n`)
44+
process.exit(1)
45+
}
46+
}
47+
48+
function banner(origin) {
49+
const label = mode === 'build' ? 'static build (baked RPC dump)' : 'dev (live WebSocket RPC)'
50+
process.stdout.write(
51+
`\n A11y Inspector demo — ${label}\n`
52+
+ ` ▸ host app + docked panel: ${origin}/\n`
53+
+ ` ▸ panel only: ${origin}${basePath}\n\n`
54+
+ ' Hover a violation in the panel to highlight its element in the page.\n\n',
55+
)
56+
}
57+
58+
async function main() {
59+
requireBuilt(resolve(injectDir, 'inject.js'), 'pnpm -C examples/a11y-inspector build')
60+
requireBuilt(
61+
resolve(panelDir, 'index.html'),
62+
mode === 'build'
63+
? 'pnpm -C examples/a11y-inspector build && pnpm -C examples/a11y-inspector cli:build'
64+
: 'pnpm -C examples/a11y-inspector build',
65+
)
66+
67+
const bindHost = '0.0.0.0'
68+
const port = await getPort({ host: bindHost, port: 4477 })
69+
const demoHtml = await readFile(resolve(HERE, 'index.html'), 'utf-8')
70+
71+
const app = new H3()
72+
73+
// 1. The demo host page (exact `/`).
74+
app.use('/', (event) => {
75+
event.res.headers.set('content-type', 'text/html; charset=utf-8')
76+
return demoHtml
77+
})
78+
79+
// 2. The injected agent bundle.
80+
mountStaticHandler(app, '/__df-inject/', injectDir)
81+
82+
if (mode === 'dev') {
83+
const origin = `http://localhost:${port}`
84+
const h3Host = createH3DevframeHost({
85+
origin,
86+
appName: devframe.id,
87+
mount: (base, dir) => mountStaticHandler(app, base, dir),
88+
})
89+
const ctx = await createHostContext({ cwd: ROOT, mode: 'dev', host: h3Host })
90+
await devframe.setup(ctx)
91+
92+
// 3a. Connection meta (must precede the catch-all static mount) + WS RPC.
93+
app.use(
94+
`${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`,
95+
() => ({ backend: 'websocket', websocket: port }),
96+
)
97+
mountStaticHandler(app, basePath, panelDir)
98+
99+
await startHttpAndWs({ context: ctx, host: bindHost, port, app, auth: false })
100+
banner(origin)
101+
}
102+
else {
103+
// 3b. Static build already carries its own __connection.json + __rpc-dump.
104+
mountStaticHandler(app, basePath, panelDir)
105+
const server = createServer(toNodeHandler(app))
106+
await new Promise(r => server.listen(port, bindHost, r))
107+
banner(`http://localhost:${port}`)
108+
}
109+
}
110+
111+
main().catch((error) => {
112+
console.error(error)
113+
process.exit(1)
114+
})

0 commit comments

Comments
 (0)