Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/a11y-inspector/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
node_modules
.turbo
66 changes: 66 additions & 0 deletions examples/a11y-inspector/README.md
Original file line number Diff line number Diff line change
@@ -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 examples/a11y-inspector build # build the panel + the agent bundle
pnpm -C examples/a11y-inspector demo # dev: live WebSocket RPC → http://localhost:4477/

pnpm -C examples/a11y-inspector cli:build # bake the static deploy (dist/static)
pnpm -C examples/a11y-inspector 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 examples/a11y-inspector 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 |
14 changes: 14 additions & 0 deletions examples/a11y-inspector/bin.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
166 changes: 166 additions & 0 deletions examples/a11y-inspector/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Sunny Beans — demo host app</title>
<style>
:root {
--cream: #fbf6ec;
--espresso: #2c2118;
--bean: #6f4e37;
--crema: #c98b54;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
color: var(--espresso);
background: var(--cream);
/* leave room for the docked inspector on the right */
padding-right: 440px;
}
.wrap { max-width: 760px; margin: 0 auto; padding: 0 28px; }
nav {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 28px;
border-bottom: 1px solid #e7dcc8;
}
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; }
.nav-spacer { flex: 1; }
.icon-btn {
width: 40px; height: 40px;
display: grid; place-items: center;
background: var(--espresso); color: var(--cream);
border: 0; border-radius: 10px; cursor: pointer;
}
.hero { padding: 56px 0 40px; }
.hero h1 { font-size: 44px; line-height: 1.05; margin: 0 0 14px; }
.hero .cup {
width: 100%; height: 260px; border-radius: 16px; display: block;
margin: 22px 0;
}
/* deliberately low contrast helper copy */
.muted { color: #cdbfa8; font-size: 15px; }
.section { padding: 36px 0; border-top: 1px solid #e7dcc8; }
.section h2 { font-size: 26px; margin: 0 0 16px; }
form { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
input[type="email"] {
flex: 1; min-width: 240px;
padding: 12px 14px; font-size: 16px;
border: 1px solid var(--bean); border-radius: 10px; background: #fff;
}
.btn {
padding: 12px 20px; font-size: 16px; font-weight: 700;
background: var(--crema); color: var(--espresso);
border: 0; border-radius: 10px; cursor: pointer;
}
.roasts { list-style: none; padding: 0; display: grid; gap: 12px; }
.roasts li {
display: flex; align-items: center; gap: 14px;
padding: 14px; background: #fff; border-radius: 12px;
border: 1px solid #ece2cf;
}
.swatch { width: 46px; height: 46px; border-radius: 8px; }
footer {
padding: 40px 0 80px; margin-top: 24px;
border-top: 1px solid #e7dcc8;
}
footer a { color: var(--bean); }
.fineprint { color: #d7cbb4; font-size: 13px; }

/* docked inspector — this chrome is intentionally accessible so it
doesn't show up in its own report */
.df-dock {
position: fixed;
top: 0; right: 0; bottom: 0;
width: 420px;
border-left: 1px solid #d8cbb4;
box-shadow: -16px 0 40px rgb(44 33 24 / 12%);
background: #0d1017;
z-index: 5;
}
.df-dock__frame { width: 100%; height: 100%; border: 0; display: block; }
@media (max-width: 900px) {
body { padding-right: 0; padding-bottom: 50vh; }
.df-dock { top: auto; width: auto; height: 50vh; border-left: 0; border-top: 1px solid #d8cbb4; }
}
</style>
</head>
<body>
<nav>
<span class="logo">Sunny Beans</span>
<span class="nav-spacer"></span>
<!-- a11y bug: icon-only button with no accessible name -->
<button class="icon-btn" type="button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</nav>

<div class="wrap">
<section class="hero">
<h1>Small-batch coffee, roasted the morning it ships.</h1>
<!-- a11y bug: image without alt text -->
<img
class="cup"
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"
/>
<!-- a11y bug: low-contrast text -->
<p class="muted">
Sourced from a dozen farms, roasted in tiny drums, and sent the same day.
</p>
</section>

<section class="section">
<h2>Get the first-Friday drop</h2>
<form onsubmit="return false">
<!-- a11y bug: input with no associated label -->
<input type="email" placeholder="you@example.com" />
<button class="btn" type="submit">Notify me</button>
</form>
<p class="fineprint">We email once a month. Unsubscribe anytime.</p>
</section>

<section class="section">
<h2>This week's roasts</h2>
<ul class="roasts">
<li>
<span class="swatch" style="background:#3b2317"></span>
<span><strong>Midnight Drum</strong> — dark, cocoa, molasses</span>
</li>
<li>
<span class="swatch" style="background:#8a5a32"></span>
<span><strong>Sunrise House</strong> — balanced, caramel, citrus</span>
</li>
<li>
<span class="swatch" style="background:#c98b54"></span>
<span><strong>Golden Hour</strong> — light, floral, stone fruit</span>
</li>
</ul>
</section>

<footer>
<p>Questions? Visit our help center or follow along:</p>
<!-- a11y bug: link with no discernible text (icon only, no label) -->
<a href="https://example.com/social">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<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" />
</svg>
</a>
<p class="fineprint">© Sunny Beans Coffee Co. All rights reserved.</p>
</footer>
</div>

<!-- Docked A11y Inspector panel (same origin → shares the BroadcastChannel). -->
<aside class="df-dock" aria-label="A11y Inspector">
<iframe class="df-dock__frame" title="A11y Inspector panel" src="/__devframe-a11y-inspector/"></iframe>
</aside>

<!-- The injected a11y agent: scans this page and answers the panel. -->
<script type="module" src="/__df-inject/inject.js"></script>
</body>
</html>
114 changes: 114 additions & 0 deletions examples/a11y-inspector/demo/server.mjs
Original file line number Diff line number Diff line change
@@ -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 examples/a11y-inspector build')
requireBuilt(
resolve(panelDir, 'index.html'),
mode === 'build'
? 'pnpm -C examples/a11y-inspector build && pnpm -C examples/a11y-inspector cli:build'
: 'pnpm -C examples/a11y-inspector 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)
})
Loading