Skip to content
Merged
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
86 changes: 86 additions & 0 deletions examples/a11y-messages-playground/README.md
Original file line number Diff line number Diff line change
@@ -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) |
67 changes: 67 additions & 0 deletions examples/a11y-messages-playground/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A11y × Messages Playground</title>
<script>
// Follow the OS theme without a toggle (@antfu/design dark: is class-based).
document.documentElement.classList.toggle(
'dark',
window.matchMedia('(prefers-color-scheme: dark)').matches,
)
</script>
<style>
/* Bespoke styling for the app-under-test pane. Deliberately plain so the
a11y findings come from its markup, not from missing structure. */
.aut-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.aut-tab {
font: inherit; font-size: 13px; padding: 5px 12px; border-radius: 7px;
border: 1px solid rgba(127, 127, 127, 0.3); background: transparent;
color: inherit; cursor: pointer;
}
.aut-tab--active { background: rgba(127, 127, 127, 0.16); font-weight: 600; }
.aut-hint {
font: 12px/1.5 ui-monospace, monospace; opacity: 0.7; margin: 0 0 16px;
}
.aut-main { max-width: 60ch; }
.aut-main h1 { font-size: 22px; margin: 0 0 10px; }
.aut-main h4 { margin: 12px 0 6px; }
.aut-main p { line-height: 1.55; }
.aut-main code { font-family: ui-monospace, monospace; font-size: 0.9em; }
</style>
</head>
<body class="h-full m0 of-hidden bg-base color-base">
<div class="h-full flex flex-col">
<header class="shrink-0 flex items-center gap-3 h-nav px-3 border-b border-base bg-base">
<h1 class="m0 flex items-center gap-1.5 shrink-0 text-sm font-semibold select-none">
<span class="i-ph-person-simple-circle-duotone text-base color-active"></span>
<span>A11y × Messages Playground</span>
</h1>
<p id="status" class="m0 text-xs font-mono op-fade"><span id="conn">Connecting…</span></p>
</header>

<div class="grid grid-cols-[1fr_1.1fr] min-h-0 flex-1">
<!-- Left: the app under test — plain page content the a11y agent scans -->
<section class="min-w-0 of-auto border-r border-base p5">
<p class="mt0 mb3 text-[0.68rem] uppercase tracking-wider color-muted">App under test</p>
<div id="app-under-test"></div>
</section>

<!-- Right: the devtools — a11y + messages docks -->
<div class="grid grid-cols-[210px_1fr] min-w-0 min-h-0">
<aside class="flex flex-col gap-0.5 of-auto border-r border-base bg-secondary p2">
<h2 class="px2 py1 text-[0.68rem] uppercase tracking-wider color-muted">Docks</h2>
<ul id="docks" class="m0 flex flex-col list-none gap-0.5 p0">
<li class="op-mute px2 text-sm">Waiting for docks…</li>
</ul>
</aside>
<main class="relative min-w-0 of-hidden bg-secondary">
<div id="dock-stage" class="absolute inset-0"></div>
</main>
</div>
</div>
</div>
<script type="module" src="/src/client/main.ts"></script>
</body>
</html>
27 changes: 27 additions & 0 deletions examples/a11y-messages-playground/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
123 changes: 123 additions & 0 deletions examples/a11y-messages-playground/src/a11y-messages-playground.ts
Original file line number Diff line number Diff line change
@@ -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<string, ClientScriptEntry>
}

/**
* 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 `<base>__connection.json`.
*/
export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin {
const base = normalizeBase(options.base ?? '/__hub/')
let viteConfig: ResolvedConfig | undefined
let started: { close: () => Promise<void> } | 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
}
Loading
Loading