Skip to content

Commit 56513fa

Browse files
committed
test(examples): a11y × messages hub playground
Add a focused hub example that pairs the a11y and messages plugins over an intentionally-broken, multi-route app under test. It exercises live axe scanning, per-route tracking (History-API navigation), pin/highlight, and the message→dock navigation feature end to end: a11y scan messages carry actions that activate the a11y dock deep-linked to the offending rule + route. Mirrors the minimal-vite-devframe-hub host shape, trimmed to the two plugins. Co-authored-with an agent.
1 parent 75d663f commit 56513fa

12 files changed

Lines changed: 660 additions & 2 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# A11y × Messages Playground
2+
3+
A focused hub playground for testing **[`@devframes/plugin-a11y`](../../plugins/a11y)**
4+
alongside **[`@devframes/plugin-messages`](../../plugins/messages)** — and, above
5+
all, the **message → dock navigation** they share.
6+
7+
Where [`minimal-vite-devframe-hub`](../minimal-vite-devframe-hub) mounts every
8+
built-in plugin against the hub's own (mostly accessible) UI, this example ships a
9+
deliberately **inaccessible, multi-route app under test** so the a11y scanner
10+
always has real violations to find, track per route, and link back to from the
11+
messages feed.
12+
13+
## Run it
14+
15+
```sh
16+
pnpm install
17+
pnpm --filter a11y-messages-playground dev
18+
```
19+
20+
The `dev` script builds the workspace first (the a11y agent bundle and both
21+
plugin SPAs must exist), then starts Vite bound to `0.0.0.0`. Open the printed
22+
URL.
23+
24+
## What you'll see
25+
26+
The window is split in two:
27+
28+
- **Left — App under test.** A tiny client-side-routed app whose every route is
29+
broken on purpose:
30+
- `/` — a missing `alt`, an icon-only button, an empty link, a skipped heading
31+
level
32+
- `/images` — a gallery of `alt`-less images
33+
- `/forms` — inputs and a select with no labels
34+
- `/contrast` — low-contrast text and a custom control with no accessible name
35+
- **Right — Devtools.** The **A11y Inspector** and **Messages** docks.
36+
37+
## What to try
38+
39+
1. **Live scanning** — open the **A11y Inspector**. It scans the left pane on
40+
load; the Dashboard shows the totals and severity breakdown.
41+
2. **Route tracking** — click the route tabs in the app under test (`Home`,
42+
`Images`, `Forms`, `Contrast`). Each navigation is a `history.pushState`,
43+
which the agent patches — the Violations tab accrues one group per route.
44+
3. **Pin + highlight** — hover a violation to ring the element in the page; click
45+
a rule to pin all its elements with numbered badges.
46+
4. **Message → dock navigation** *(the headline)* — open the **Messages** dock.
47+
Each scan mirrors a summary entry plus one entry per violated rule, and every
48+
entry carries a navigation action. Select an entry and click **View in a11y
49+
inspector** (or **Open a11y dashboard**): the hub switches the focused dock to
50+
the A11y Inspector, deep-linked to that rule + route, and pins the offending
51+
elements.
52+
53+
## How it's wired
54+
55+
`src/a11y-messages-playground.ts` is the whole host — a ~120-line Vite plugin
56+
that runs `@devframes/hub` in the dev server, mounts the two plugins as docks,
57+
and attaches the a11y agent as the a11y dock's `clientScript`:
58+
59+
```ts
60+
a11yMessagesPlayground({
61+
devframes: [a11yDevframe, messagesDevframe],
62+
clientScripts: {
63+
[a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` },
64+
},
65+
})
66+
```
67+
68+
`src/client/main.ts` boots the hub's client runtime with
69+
`createDevframeClientHost()`, which imports that agent into the host page (so it
70+
scans the app under test) and renders the dock rail from `devframe:docks` shared
71+
state. The navigation itself rides existing hub primitives: the messages panel
72+
calls `hub:docks:activate`, the hub broadcasts it, and the client host switches
73+
the focused dock — the same path a manual dock click takes.
74+
75+
## Files
76+
77+
| File | Role |
78+
|---|---|
79+
| `src/a11y-messages-playground.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS |
80+
| `vite.config.ts` | Mounts a11y + messages; attaches the a11y agent as its dock's `clientScript` |
81+
| `src/client/main.ts` | Boots the client host, renders the dock rail + iframe stage |
82+
| `src/client/app-under-test.ts` | The intentionally-broken, multi-route app the agent scans |
83+
| `src/client/icons.ts` | Offline Phosphor icons for the dock rail |
84+
| `index.html` | The two-pane shell (app under test · devtools) |
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<!doctype html>
2+
<html lang="en" class="h-full">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>A11y × Messages Playground</title>
7+
<script>
8+
// Follow the OS theme without a toggle (@antfu/design dark: is class-based).
9+
document.documentElement.classList.toggle(
10+
'dark',
11+
window.matchMedia('(prefers-color-scheme: dark)').matches,
12+
)
13+
</script>
14+
<style>
15+
/* Bespoke styling for the app-under-test pane. Deliberately plain so the
16+
a11y findings come from its markup, not from missing structure. */
17+
.aut-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
18+
.aut-tab {
19+
font: inherit; font-size: 13px; padding: 5px 12px; border-radius: 7px;
20+
border: 1px solid rgba(127, 127, 127, 0.3); background: transparent;
21+
color: inherit; cursor: pointer;
22+
}
23+
.aut-tab--active { background: rgba(127, 127, 127, 0.16); font-weight: 600; }
24+
.aut-hint {
25+
font: 12px/1.5 ui-monospace, monospace; opacity: 0.7; margin: 0 0 16px;
26+
}
27+
.aut-main { max-width: 60ch; }
28+
.aut-main h1 { font-size: 22px; margin: 0 0 10px; }
29+
.aut-main h4 { margin: 12px 0 6px; }
30+
.aut-main p { line-height: 1.55; }
31+
.aut-main code { font-family: ui-monospace, monospace; font-size: 0.9em; }
32+
</style>
33+
</head>
34+
<body class="h-full m0 of-hidden bg-base color-base">
35+
<div class="h-full flex flex-col">
36+
<header class="shrink-0 flex items-center gap-3 h-nav px-3 border-b border-base bg-base">
37+
<h1 class="m0 flex items-center gap-1.5 shrink-0 text-sm font-semibold select-none">
38+
<span class="i-ph-wheelchair-duotone text-base color-active"></span>
39+
<span>A11y × Messages Playground</span>
40+
</h1>
41+
<p id="status" class="m0 text-xs font-mono op-fade"><span id="conn">Connecting…</span></p>
42+
</header>
43+
44+
<div class="grid grid-cols-[1fr_1.1fr] min-h-0 flex-1">
45+
<!-- Left: the app under test — plain page content the a11y agent scans -->
46+
<section class="min-w-0 of-auto border-r border-base p5">
47+
<p class="mt0 mb3 text-[0.68rem] uppercase tracking-wider color-muted">App under test</p>
48+
<div id="app-under-test"></div>
49+
</section>
50+
51+
<!-- Right: the devtools — a11y + messages docks -->
52+
<div class="grid grid-cols-[210px_1fr] min-w-0 min-h-0">
53+
<aside class="flex flex-col gap-0.5 of-auto border-r border-base bg-secondary p2">
54+
<h2 class="px2 py1 text-[0.68rem] uppercase tracking-wider color-muted">Docks</h2>
55+
<ul id="docks" class="m0 flex flex-col list-none gap-0.5 p0">
56+
<li class="op-mute px2 text-sm">Waiting for docks…</li>
57+
</ul>
58+
</aside>
59+
<main class="relative min-w-0 of-hidden bg-secondary">
60+
<div id="dock-stage" class="absolute inset-0"></div>
61+
</main>
62+
</div>
63+
</div>
64+
</div>
65+
<script type="module" src="/src/client/main.ts"></script>
66+
</body>
67+
</html>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "a11y-messages-playground",
3+
"type": "module",
4+
"version": "0.7.12",
5+
"private": true,
6+
"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.",
7+
"homepage": "https://github.com/devframes/devframe/tree/main/examples/a11y-messages-playground",
8+
"scripts": {
9+
"dev": "pnpm -C ../.. run build && vite --host",
10+
"build": "vite build",
11+
"typecheck": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@antfu/design": "catalog:frontend",
15+
"@devframes/hub": "workspace:*",
16+
"@devframes/plugin-a11y": "workspace:*",
17+
"@devframes/plugin-messages": "workspace:*",
18+
"devframe": "workspace:*"
19+
},
20+
"devDependencies": {
21+
"@iconify-json/ph": "catalog:frontend",
22+
"get-port-please": "catalog:deps",
23+
"pathe": "catalog:deps",
24+
"unocss": "catalog:frontend",
25+
"vite": "catalog:build"
26+
}
27+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { DevframeHubContext } from '@devframes/hub/node'
2+
import type { ClientScriptEntry } from '@devframes/hub/types'
3+
import type { DevframeDefinition, DevframeHost } from 'devframe/types'
4+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
5+
import { homedir } from 'node:os'
6+
import { createHubContext, mountDevframe } from '@devframes/hub/node'
7+
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
8+
import { startHttpAndWs } from 'devframe/node'
9+
import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
10+
import { getPort } from 'get-port-please'
11+
import { join } from 'pathe'
12+
13+
export interface A11yMessagesPlaygroundOptions {
14+
/** Mount path for the hub's connection-meta endpoint. Default: `/__hub/`. */
15+
base?: string
16+
/** Preferred port for the side-car RPC/WS server. Default: a free port near 9878. */
17+
port?: number
18+
/** Devframes to mount as docks (here: a11y + messages). */
19+
devframes?: DevframeDefinition[]
20+
/**
21+
* Per-dock client scripts, keyed by devframe id. Attached to the mounted
22+
* iframe dock so the hub client runtime imports them into the host page —
23+
* this is how the a11y inspector's in-page agent gets into the page it scans.
24+
*/
25+
clientScripts?: Record<string, ClientScriptEntry>
26+
}
27+
28+
/**
29+
* A tiny Vite plugin that runs `@devframes/hub` inside the Vite dev server —
30+
* the same shape as `examples/minimal-vite-devframe-hub`, trimmed to the two
31+
* plugins this playground pairs (a11y + messages). It creates a hub context,
32+
* implements the framework-neutral `DevframeHost` surface, mounts each devframe
33+
* as a dock (attaching the a11y agent as its client script), and exposes the
34+
* side-car WS endpoint at `<base>__connection.json`.
35+
*/
36+
export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin {
37+
const base = normalizeBase(options.base ?? '/__hub/')
38+
let viteConfig: ResolvedConfig | undefined
39+
let started: { close: () => Promise<void> } | undefined
40+
41+
return {
42+
name: 'a11y-messages-playground',
43+
apply: 'serve',
44+
45+
configResolved(config) {
46+
viteConfig = config
47+
},
48+
49+
async configureServer(server: ViteDevServer) {
50+
// Vite re-invokes `configureServer` on restart — tear the old server down
51+
// so we don't leak the WS port.
52+
await started?.close().catch(() => {})
53+
started = undefined
54+
55+
const cwd = viteConfig!.root
56+
const port = options.port ?? await getPort({ port: 9878, portRange: [9878, 9978] })
57+
58+
const serveConnectionMeta = (metaBase: string): void => {
59+
const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}`
60+
server.middlewares.use(metaPath, (_req, res) => {
61+
res.setHeader('Content-Type', 'application/json')
62+
res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
63+
})
64+
}
65+
66+
const host: DevframeHost = {
67+
mountStatic(base, distDir) {
68+
server.middlewares.use(base, serveStaticNodeMiddleware(distDir))
69+
},
70+
mountConnectionMeta(base) {
71+
serveConnectionMeta(base)
72+
},
73+
resolveOrigin() {
74+
const resolved = server.resolvedUrls?.local?.[0]
75+
return resolved ? new URL(resolved).origin : 'http://localhost:5173'
76+
},
77+
getStorageDir(scope) {
78+
if (scope === 'workspace')
79+
return join(cwd, '.devframe')
80+
if (scope === 'project')
81+
return join(cwd, 'node_modules/.a11y-messages-playground')
82+
return join(homedir(), '.a11y-messages-playground')
83+
},
84+
}
85+
86+
const context: DevframeHubContext = await createHubContext({
87+
cwd,
88+
workspaceRoot: cwd,
89+
mode: 'dev',
90+
host,
91+
})
92+
93+
// Mount each devframe as a dock, attaching its client script when one is
94+
// configured (the a11y agent). `mountDevframe` runs the def's `setup(ctx)`,
95+
// so `setupA11y` / `setupMessages` register their RPCs automatically.
96+
for (const def of options.devframes ?? []) {
97+
const clientScript = options.clientScripts?.[def.id]
98+
await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
99+
}
100+
101+
started = await startHttpAndWs({ context, port, auth: false })
102+
103+
// Tell the hub UI (served at `base`) where to find the WS endpoint.
104+
serveConnectionMeta(base)
105+
106+
server.httpServer?.once('close', () => {
107+
void started?.close().catch(() => {})
108+
})
109+
},
110+
111+
async closeBundle() {
112+
await started?.close().catch(() => {})
113+
started = undefined
114+
},
115+
}
116+
}
117+
118+
function normalizeBase(base: string): string {
119+
let out = base.startsWith('/') ? base : `/${base}`
120+
if (!out.endsWith('/'))
121+
out = `${out}/`
122+
return out
123+
}

0 commit comments

Comments
 (0)