Skip to content

Commit 143c6ec

Browse files
committed
feat(a11y): select violations to highlight + generate AI fix prompts
- Highlighting is now driven by selecting a violation (a checkbox on each rule card) rather than clicking inner elements. Selected cards get a distinct visual state and draw globally-numbered badges over all their elements. - Add a 'Generate fix prompts' nav button that opens an accessible dialog with a single paste-ready AI prompt gathering the selected violations' context — rule metadata, WCAG tags, docs, and each element's selector/markup/failure summary, grouped by route — with a copy button. Co-authored-with an agent.
1 parent 4a0846a commit 143c6ec

11 files changed

Lines changed: 601 additions & 151 deletions

File tree

examples/a11y-messages-playground/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ The window is split in two:
4141
2. **Route tracking** — click the route tabs in the app under test (`Home`,
4242
`Images`, `Forms`, `Contrast`). Each navigation is a `history.pushState`,
4343
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.
44+
3. **Select + highlight** — hover a violation to ring the element in the page;
45+
tick a violation's checkbox to highlight all its elements with numbered
46+
badges, then hit **Generate fix prompts** in the nav for a paste-ready AI
47+
prompt covering everything you selected.
4648
4. **Message → dock navigation** *(the headline)* — open the **Messages** dock.
4749
Each scan mirrors a summary entry plus one entry per violated rule, and every
4850
entry carries a navigation action. Select an entry and click **View in a11y

plugins/a11y/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ surfaces the violations in a [Solid](https://www.solidjs.com/) panel:
1414
- **Dashboard + grouped violations** — a Dashboard tab (totals, severity
1515
breakdown, per-route inventory, scan controls) and a Violations tab listing
1616
every tracked route, grouped, with the active route marked.
17-
- **Pin + numbered highlights** — hover previews the offending element; clicking a
18-
rule pins all its elements (clicking a single element toggles just that one)
19-
with globally-numbered badges drawn in the page. `<html>`/`<body>` targets get a
20-
corner notice instead of a viewport-filling ring.
17+
- **Select + highlight** — hover previews the offending element; ticking a
18+
violation's checkbox selects it, giving the card a distinct state and drawing
19+
globally-numbered badges over all its elements in the page. `<html>`/`<body>`
20+
targets get a corner notice instead of a viewport-filling ring.
21+
- **Generate fix prompts** — a nav button gathers the selected violations (rule
22+
metadata, WCAG tags, docs, and each element's selector/markup/failure summary,
23+
grouped by route) into one paste-ready AI prompt in a dialog.
2124
- **Constant scanning** — a DOM `MutationObserver` plus debounced
2225
mouse/keyboard/touch rescans, toggleable from the Dashboard.
2326
- **WCAG 2.0–2.2 + best-practice** — the broadened axe tag set, with best-practice

plugins/a11y/src/spa/app.tsx

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { Impact, PinTarget, ScanReport, Violation, ViolationNode } from '../shared/protocol.ts'
2-
import type { PinsApi, RouteGroupModel } from './components/violations.tsx'
1+
import type { Impact, PinTarget, Violation, ViolationNode } from '../shared/protocol.ts'
2+
import type { SelectedItem } from './components/fix-prompts.tsx'
3+
import type { RouteGroupModel, SelectionApi } from './components/violations.tsx'
34
import { batch, createEffect, createMemo, createSignal, Match, on, Show, Switch } from 'solid-js'
45
import { emptyCounts } from '../shared/protocol.ts'
6+
import { FixPromptsDialog } from './components/fix-prompts.tsx'
57
import { Header, MetaLine } from './components/header.tsx'
68
import { CheckCircle, PlugIcon } from './components/icons.tsx'
79
import { SummaryBar } from './components/summary.tsx'
@@ -12,12 +14,10 @@ import { connectDevframeState } from './lib/devframe.ts'
1214
const SNIPPET = '<script type="module" src="…/inject.js"></script>'
1315
const AUTOSCAN_KEY = 'devframes:plugin:a11y:autoscan'
1416

17+
const selKey = (route: string, ruleId: string) => `${route}::${ruleId}`
1518
function nodePin(v: Violation, node: ViolationNode): PinTarget {
1619
return { nodeId: node.id, target: node.target, impact: v.impact, ruleId: v.ruleId }
1720
}
18-
function allPins(report: ScanReport): PinTarget[] {
19-
return report.violations.flatMap(v => v.nodes.map(n => nodePin(v, n)))
20-
}
2121

2222
export function App() {
2323
const channel = createA11yChannel()
@@ -27,7 +27,10 @@ export function App() {
2727
const [showBestPractice, setShowBestPractice] = createSignal(true)
2828
const [expandedRoutes, setExpandedRoutes] = createSignal<Set<string>>(new Set())
2929
const [expandedRules, setExpandedRules] = createSignal<Set<string>>(new Set())
30-
const [pins, setPins] = createSignal<PinTarget[]>([])
30+
// Selected violations (`route::ruleId`) — drives both the in-page highlight
31+
// and the "Generate fix prompts" dialog.
32+
const [selected, setSelected] = createSignal<Set<string>>(new Set())
33+
const [dialogOpen, setDialogOpen] = createSignal(false)
3134

3235
const storedAuto = (() => {
3336
try {
@@ -90,6 +93,53 @@ export function App() {
9093
return collapsed
9194
})
9295

96+
// ── selection → highlight + fix-prompt context ────────────────────────────
97+
// Highlighted nodes, in a stable order, for numbered badges.
98+
const selectedPins = createMemo<PinTarget[]>(() => {
99+
const sel = selected()
100+
const out: PinTarget[] = []
101+
for (const report of routes()) {
102+
for (const v of report.violations) {
103+
if (sel.has(selKey(report.route, v.ruleId))) {
104+
for (const node of v.nodes)
105+
out.push(nodePin(v, node))
106+
}
107+
}
108+
}
109+
return out
110+
})
111+
112+
const selectedItems = createMemo<SelectedItem[]>(() => {
113+
const sel = selected()
114+
const out: SelectedItem[] = []
115+
for (const report of routes()) {
116+
for (const v of report.violations) {
117+
if (sel.has(selKey(report.route, v.ruleId)))
118+
out.push({ route: report.route, url: report.url, violation: v })
119+
}
120+
}
121+
return out
122+
})
123+
124+
const selectionApi: SelectionApi = {
125+
isSelected: (route, ruleId) => selected().has(selKey(route, ruleId)),
126+
toggle: (route, ruleId) => {
127+
const key = selKey(route, ruleId)
128+
setSelected((prev) => {
129+
const next = new Set(prev)
130+
if (next.has(key))
131+
next.delete(key)
132+
else
133+
next.add(key)
134+
return next
135+
})
136+
},
137+
numberOf: (nodeId) => {
138+
const i = selectedPins().findIndex(p => p.nodeId === nodeId)
139+
return i === -1 ? null : i + 1
140+
},
141+
}
142+
93143
// ── config → agent, and initial auto-scan default ─────────────────────────
94144
let autoInit = false
95145
createEffect(() => {
@@ -120,50 +170,25 @@ export function App() {
120170
setExpandedRoutes(prev => (prev.has(r) ? prev : new Set(prev).add(r)))
121171
})
122172

123-
// ── pins ──────────────────────────────────────────────────────────────────
124-
createEffect(() => channel.setPins(pins()))
125-
126-
// Pins reference live DOM, so clear them when the host route changes.
127-
createEffect(on(activeRoute, () => setPins([]), { defer: true }))
173+
// Push the highlight set (derived from the selection) to the in-page agent.
174+
createEffect(() => channel.setPins(selectedPins()))
128175

129-
// defaultHighlight: pin a route's violations the first time it's scanned.
176+
// defaultHighlight: select all of a route's violations the first time it's scanned.
130177
const highlighted = new Set<string>()
131178
createEffect(() => {
132179
const cfg = devframe.config()
133180
const report = activeReport()
134181
if (!cfg?.defaultHighlight || !report || highlighted.has(report.route))
135182
return
136183
highlighted.add(report.route)
137-
setPins(allPins(report))
184+
setSelected((prev) => {
185+
const next = new Set(prev)
186+
for (const v of report.violations)
187+
next.add(selKey(report.route, v.ruleId))
188+
return next
189+
})
138190
})
139191

140-
const pinsApi: PinsApi = {
141-
isPinned: nodeId => pins().some(p => p.nodeId === nodeId),
142-
numberOf: (nodeId) => {
143-
const i = pins().findIndex(p => p.nodeId === nodeId)
144-
return i === -1 ? null : i + 1
145-
},
146-
isRulePinned: v => v.nodes.length > 0 && v.nodes.every(n => pins().some(p => p.nodeId === n.id)),
147-
toggleNode: (v, node) => {
148-
setPins(prev => (prev.some(p => p.nodeId === node.id)
149-
? prev.filter(p => p.nodeId !== node.id)
150-
: [...prev, nodePin(v, node)]))
151-
},
152-
toggleRule: (v) => {
153-
const allPinned = v.nodes.length > 0 && v.nodes.every(n => pins().some(p => p.nodeId === n.id))
154-
if (allPinned) {
155-
const ids = new Set(v.nodes.map(n => n.id))
156-
setPins(prev => prev.filter(p => !ids.has(p.nodeId)))
157-
}
158-
else {
159-
setPins((prev) => {
160-
const have = new Set(prev.map(p => p.nodeId))
161-
return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))]
162-
})
163-
}
164-
},
165-
}
166-
167192
// ── deep-linking from other docks (e.g. the messages feed) ────────────────
168193
createEffect(on(devframe.activation, (act) => {
169194
if (!act)
@@ -183,20 +208,13 @@ export function App() {
183208

184209
batch(() => {
185210
setExpandedRoutes(prev => new Set(prev).add(route))
186-
if (ruleId)
211+
if (ruleId) {
187212
setExpandedRules(prev => new Set(prev).add(`${route}::${ruleId}`))
188-
})
189-
if (ruleId) {
190-
const report = routes().find(r => r.route === route)
191-
const v = report?.violations.find(x => x.ruleId === ruleId)
192-
if (v) {
193-
setPins((prev) => {
194-
const have = new Set(prev.map(p => p.nodeId))
195-
return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))]
196-
})
213+
setSelected(prev => new Set(prev).add(selKey(route, ruleId)))
197214
}
215+
})
216+
if (ruleId)
198217
setTimeout(() => document.getElementById(ruleCardId(route, ruleId))?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 60)
199-
}
200218
}, { defer: true }))
201219

202220
function toggleFilter(impact: Impact) {
@@ -235,6 +253,8 @@ export function App() {
235253
<Header
236254
agentReady={channel.agentReady()}
237255
scanning={channel.scanning()}
256+
selectedCount={selectedItems().length}
257+
onGenerate={() => setDialogOpen(true)}
238258
onRescan={channel.rescan}
239259
/>
240260
<MetaLine
@@ -322,12 +342,16 @@ export function App() {
322342
expandedRules={expandedRules()}
323343
onToggleRule={toggleRule}
324344
channel={channel}
325-
pins={pinsApi}
345+
selection={selectionApi}
326346
/>
327347
</Show>
328348
</Match>
329349
</Switch>
330350
</div>
351+
352+
<Show when={dialogOpen() && selectedItems().length > 0}>
353+
<FixPromptsDialog items={selectedItems()} onClose={() => setDialogOpen(false)} />
354+
</Show>
331355
</div>
332356
)
333357
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { Meta, StoryObj } from 'storybook-solidjs-vite'
2+
import type { SelectedItem } from './fix-prompts.tsx'
3+
import { FixPromptsDialog } from './fix-prompts.tsx'
4+
import '../styles.css'
5+
6+
// The fix-prompts dialog: gathers the selected violations' context into one
7+
// paste-ready AI prompt, with a copy button.
8+
const meta = {
9+
title: 'A11y/FixPromptsDialog',
10+
component: FixPromptsDialog,
11+
parameters: { layout: 'fullscreen' },
12+
} satisfies Meta<typeof FixPromptsDialog>
13+
14+
export default meta
15+
type Story = StoryObj<typeof meta>
16+
17+
const items: SelectedItem[] = [
18+
{
19+
route: '/',
20+
url: 'https://example.test/',
21+
violation: {
22+
ruleId: 'image-alt',
23+
impact: 'critical',
24+
help: 'Images must have alternative text',
25+
description: 'Ensures <img> elements have alternate text or a role of none or presentation',
26+
helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/image-alt',
27+
tags: ['wcag2a', 'wcag111'],
28+
nodes: [{
29+
id: 'a1',
30+
target: ['img.hero'],
31+
html: '<img class="hero" src="/banner.png">',
32+
failureSummary: 'Fix any of the following:\n Element does not have an alt attribute',
33+
}],
34+
},
35+
},
36+
{
37+
route: '/forms',
38+
url: 'https://example.test/forms',
39+
violation: {
40+
ruleId: 'label',
41+
impact: 'serious',
42+
help: 'Form elements must have labels',
43+
description: 'Ensures every form element has a label',
44+
helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/label',
45+
tags: ['wcag2a', 'wcag412'],
46+
nodes: [{
47+
id: 'a2',
48+
target: ['#email'],
49+
html: '<input id="email" type="email" placeholder="Email">',
50+
failureSummary: 'Fix any of the following:\n Form element does not have an implicit label',
51+
}],
52+
},
53+
},
54+
]
55+
56+
export const TwoViolations: Story = {
57+
args: { items, onClose: () => {} },
58+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { SelectedItem } from '../lib/fix-prompt.ts'
2+
import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
3+
import { buildFixPrompt } from '../lib/fix-prompt.ts'
4+
5+
export type { SelectedItem } from '../lib/fix-prompt.ts'
6+
7+
interface DialogProps {
8+
items: SelectedItem[]
9+
onClose: () => void
10+
}
11+
12+
/**
13+
* A modal listing the AI fix prompt for the selected violations, with a copy
14+
* button. Built to the same accessibility bar the tool enforces: labelled
15+
* dialog, Escape/backdrop to close, focus moved in on open.
16+
*/
17+
export function FixPromptsDialog(props: DialogProps) {
18+
const prompt = createMemo(() => buildFixPrompt(props.items))
19+
const ruleCount = () => props.items.length
20+
const [copied, setCopied] = createSignal(false)
21+
let closeBtn: HTMLButtonElement | undefined
22+
23+
function copy() {
24+
void navigator.clipboard?.writeText(prompt()).then(() => {
25+
setCopied(true)
26+
setTimeout(setCopied, 1500, false)
27+
})
28+
}
29+
30+
function onKeyDown(e: KeyboardEvent) {
31+
if (e.key === 'Escape')
32+
props.onClose()
33+
}
34+
35+
onMount(() => {
36+
addEventListener('keydown', onKeyDown)
37+
closeBtn?.focus()
38+
})
39+
onCleanup(() => removeEventListener('keydown', onKeyDown))
40+
41+
return (
42+
<div class="modal" onClick={() => props.onClose()}>
43+
<div
44+
class="modal__card"
45+
role="dialog"
46+
aria-modal="true"
47+
aria-labelledby="fix-prompts-title"
48+
onClick={e => e.stopPropagation()}
49+
>
50+
<div class="modal__head">
51+
<div>
52+
<h2 id="fix-prompts-title" class="modal__title">Fix prompts</h2>
53+
<p class="modal__sub">
54+
{ruleCount()}
55+
{' '}
56+
{ruleCount() === 1 ? 'violation' : 'violations'}
57+
{' '}
58+
selected — copy the prompt into your AI assistant.
59+
</p>
60+
</div>
61+
<button type="button" ref={closeBtn} class="modal__close" aria-label="Close" onClick={() => props.onClose()}>
62+
<span aria-hidden class="i-ph-x shrink-0" />
63+
</button>
64+
</div>
65+
66+
<textarea class="modal__text" readonly aria-label="Generated fix prompt">{prompt()}</textarea>
67+
68+
<div class="modal__actions">
69+
<span class="modal__hint">
70+
<For each={props.items}>
71+
{(item, i) => (
72+
<>
73+
<Show when={i() > 0}>{', '}</Show>
74+
<code>{item.violation.ruleId}</code>
75+
</>
76+
)}
77+
</For>
78+
</span>
79+
<span class="flex-1" />
80+
<button type="button" class="modal__btn" onClick={copy}>
81+
<span aria-hidden class={`shrink-0 ${copied() ? 'i-ph-check' : 'i-ph-copy'}`} />
82+
{copied() ? 'Copied' : 'Copy prompt'}
83+
</button>
84+
</div>
85+
</div>
86+
</div>
87+
)
88+
}

0 commit comments

Comments
 (0)