From d433b5bd31aa687509fbd2b706dfcb97d7a3ddc3 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 24 Jul 2026 05:17:13 +0000 Subject: [PATCH 01/11] feat(messages): navigable message actions that focus a dock Add an optional `actions` field to `DevframeMessageEntry` (a discriminated union, `activate` implemented) so a message can request the viewer switch its focused dock, deep-linking via the existing `hub:docks:activate` primitive. The messages panel renders the actions in the detail pane and only when a hub docks-activate capability is present, so standalone panels show no dead buttons. Co-authored-with an agent. --- packages/hub/src/types/messages.ts | 26 +++++++++++++++++ plugins/messages/src/client/App.vue | 29 +++++++++++++++++-- .../src/client/components/MessagesView.vue | 19 +++++++++++- plugins/messages/src/types.ts | 2 ++ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 ++ .../tsnapi/@devframes/hub/types.snapshot.d.ts | 2 ++ .../plugin-messages/types.snapshot.d.ts | 2 ++ 7 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/hub/src/types/messages.ts b/packages/hub/src/types/messages.ts index 32e63caf..d5e63b71 100644 --- a/packages/hub/src/types/messages.ts +++ b/packages/hub/src/types/messages.ts @@ -21,6 +21,27 @@ export interface DevframeMessageFilePosition { column?: number } +/** + * A labeled control a message can carry. Rendered by the messages panel; when + * clicked it drives the described intent. Discriminated by `kind` so further + * action kinds can be added without reshaping the field. + * + * `'activate'` requests the viewer switch its focused dock to `activate.dockId` + * (deep-linking via the opaque, serializable `activate.params` bag the target + * dock interprets), via the hub's `hub:docks:activate` RPC. + */ +export interface DevframeMessageActivateAction { + /** Stable id for the action within its entry. */ + id: string + /** Button label shown in the messages panel. */ + label: string + kind: 'activate' + /** The dock to focus, plus an optional deep-link params bag. */ + activate: { dockId: string, params?: Record } +} + +export type DevframeMessageAction = DevframeMessageActivateAction + export interface DevframeMessageEntry { /** * Unique identifier for this message entry (auto-generated if not provided) @@ -66,6 +87,11 @@ export interface DevframeMessageEntry { * Optional tags/labels for filtering */ labels?: string[] + /** + * Optional labeled actions (e.g. "navigate to a dock") the panel renders as + * clickable controls in the entry's detail view. + */ + actions?: DevframeMessageAction[] /** * Time in ms to auto-dismiss the toast notification (client-side) */ diff --git a/plugins/messages/src/client/App.vue b/plugins/messages/src/client/App.vue index eb68a4b5..4d27fb4a 100644 --- a/plugins/messages/src/client/App.vue +++ b/plugins/messages/src/client/App.vue @@ -1,8 +1,8 @@ ` — or let a @@ -12,9 +13,21 @@ * export receives the hub's client-script context and additionally mirrors * each scan into the hub's messages feed. */ -import type { A11yMessage, ScanReport } from '../shared/protocol.ts' +import type { + A11yMessage, + A11yState, + AgentConfig, + PinTarget, + ScanReport, +} from '../shared/protocol.ts' import type { A11yAgentContext } from './messages.ts' -import { A11Y_CHANNEL } from '../shared/protocol.ts' +import type { PinInfo } from './overlay.ts' +import { + A11Y_CHANNEL, + A11Y_DEFAULT_DOCK_ID, + A11Y_NODE_ATTR, + A11Y_STORAGE_KEY, +} from '../shared/protocol.ts' import { createMessagesReporter } from './messages.ts' import { createOverlay } from './overlay.ts' import { resolveElement, scan } from './scanner.ts' @@ -31,10 +44,13 @@ function start(context?: A11yAgentContext) { const overlay = createOverlay() document.documentElement.appendChild(overlay.root) - // Booted as a hub dock client script — mirror every scan into the hub's - // messages feed. Standalone boots have no context and skip the mirror. + // Booted as a hub dock client script — mirror the active route's scan into + // the hub's messages feed. Standalone boots have no context and skip it. + const config: AgentConfig = { logIssues: true, autoScan: true } + const reporter = context?.messages ? createMessagesReporter(context.messages, { + dockId: () => config.activateDockId ?? A11Y_DEFAULT_DOCK_ID, resolveBoundingBox: (target) => { const rect = resolveElement(target)?.getBoundingClientRect() if (!rect) @@ -49,13 +65,48 @@ function start(context?: A11yAgentContext) { }) : undefined - let lastReport: ScanReport | null = null + // Authoritative route → report map, persisted so history survives reloads / + // MPA navigations within the tab session. + const routes = new Map(loadRoutes()) + /** Rule ids logged to the console per route, to dedupe under auto-scan. */ + const loggedRules = new Map>() + let activeRoute = location.pathname + let engine = routes.get(activeRoute)?.engine ?? 'unknown' + let scanning = false let rescanQueued = false let debounceTimer = 0 const post = (message: A11yMessage) => channel.postMessage(message) + function loadRoutes(): [string, ScanReport][] { + try { + const raw = sessionStorage.getItem(A11Y_STORAGE_KEY) + if (!raw) + return [] + const parsed = JSON.parse(raw) as ScanReport[] + return parsed.map(report => [report.route, report]) + } + catch { + return [] + } + } + function saveRoutes() { + try { + sessionStorage.setItem(A11Y_STORAGE_KEY, JSON.stringify([...routes.values()])) + } + catch { + // Storage full/unavailable — history is best-effort, carry on. + } + } + + function buildState(): A11yState { + return { engine, activeRoute, routes: [...routes.values()] } + } + function broadcastState() { + post({ type: 'a11y:state', state: buildState() }) + } + const observer = new MutationObserver((records) => { // Ignore our own overlay mutations; everything else may have changed the // accessibility tree, so debounce a fresh scan. @@ -78,21 +129,74 @@ function start(context?: A11yAgentContext) { debounceTimer = window.setTimeout(runScan, 600) } + // Interaction-driven rescans, layered on top of the DOM observer. Bound only + // while auto-scan is enabled. + const INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'] as const + const onInteraction = () => scheduleScan() + let interactionsBound = false + function bindInteractions() { + if (interactionsBound || !config.autoScan) + return + interactionsBound = true + for (const type of INTERACTION_EVENTS) + addEventListener(type, onInteraction, { passive: true, capture: true }) + } + function unbindInteractions() { + if (!interactionsBound) + return + interactionsBound = false + for (const type of INTERACTION_EVENTS) + removeEventListener(type, onInteraction, { capture: true } as EventListenerOptions) + } + + function logNewIssues(report: ScanReport) { + if (!config.logIssues) + return + const previous = loggedRules.get(report.route) ?? new Set() + const current = new Set(report.violations.map(v => v.ruleId)) + const fresh = report.violations.filter(v => !previous.has(v.ruleId)) + loggedRules.set(report.route, current) + if (fresh.length === 0) + return + // eslint-disable-next-line no-console + console.groupCollapsed( + `%c a11y %c ${fresh.length} new issue${fresh.length === 1 ? '' : 's'} on ${report.route}`, + 'background:#6fb07d;color:#0b0e13;border-radius:3px;padding:1px 4px;font-weight:700', + 'color:inherit', + ) + for (const v of fresh) { + // eslint-disable-next-line no-console + console.log( + `%c${v.impact}%c ${v.ruleId} — ${v.help} (${v.nodes.length})\n${v.helpUrl}`, + 'font-weight:700', + 'font-weight:400', + ) + } + // eslint-disable-next-line no-console + console.groupEnd() + } + async function runScan() { if (scanning) { rescanQueued = true return } scanning = true - post({ type: 'a11y:scanning' }) + activeRoute = location.pathname + post({ type: 'a11y:scanning', route: activeRoute }) reporter?.scanning() // Suspend observation so attribute-stamping during the scan doesn't // retrigger us. observer.disconnect() try { - lastReport = await scan() - post({ type: 'a11y:report', report: lastReport }) - reporter?.report(lastReport) + const report = await scan({ tags: config.axeTags, runOptions: config.axeRunOptions }) + engine = report.engine + routes.set(report.route, report) + activeRoute = report.route + saveRoutes() + logNewIssues(report) + broadcastState() + reporter?.report(report) } catch (error) { console.error('[a11y-inspector] scan failed', error) @@ -108,40 +212,124 @@ function start(context?: A11yAgentContext) { } } + // ── route tracking ────────────────────────────────────────────────────── + // Framework-neutral: patch the History API + listen for popstate/hashchange, + // and bucket by pathname. A new route resets pins (panel-driven) and scans. + function onLocationChange() { + if (location.pathname === activeRoute) + return + activeRoute = location.pathname + overlay.setPins([]) + broadcastState() + scheduleScan() + } + const origPush = history.pushState + const origReplace = history.replaceState + history.pushState = function pushState(...args: Parameters) { + const result = origPush.apply(this, args) + onLocationChange() + return result + } + history.replaceState = function replaceState(...args: Parameters) { + const result = origReplace.apply(this, args) + onLocationChange() + return result + } + addEventListener('popstate', onLocationChange) + addEventListener('hashchange', onLocationChange) + + // ── pins / preview ────────────────────────────────────────────────────── + function resolvePin(pin: PinTarget, number: number): PinInfo | null { + const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(pin.nodeId)}"]`) + ?? resolveElement(pin.target) + if (!el) + return null + return { el, impact: pin.impact, ruleId: pin.ruleId, number } + } + channel.addEventListener('message', (event: MessageEvent) => { const message = event.data switch (message.type) { case 'a11y:panel-ready': - post({ type: 'a11y:agent-ready', url: location.href }) - if (lastReport) - post({ type: 'a11y:report', report: lastReport }) + post({ type: 'a11y:agent-ready', url: location.href, route: activeRoute }) + if (routes.size > 0) + broadcastState() else void runScan() break + case 'a11y:config': + applyConfig(message.config) + break case 'a11y:highlight': { - const el = document.querySelector(`[data-df-a11y-node="${CSS.escape(message.nodeId)}"]`) + const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(message.nodeId)}"]`) ?? resolveElement(message.target) if (el) { - const impact = findImpact(lastReport, message.nodeId) ?? 'minor' - const ruleId = findRule(lastReport, message.nodeId) ?? 'element' - overlay.show(el, { impact, ruleId }) + const active = routes.get(activeRoute) ?? null + const impact = findImpact(active, message.nodeId) ?? 'minor' + const ruleId = findRule(active, message.nodeId) ?? 'element' + overlay.preview(el, { impact, ruleId }) } else { - overlay.hide() + overlay.clearPreview() } break } case 'a11y:clear': - overlay.hide() + overlay.clearPreview() + break + case 'a11y:pins': { + const infos: PinInfo[] = [] + message.pins.forEach((pin, i) => { + const info = resolvePin(pin, i + 1) + if (info) + infos.push(info) + }) + overlay.setPins(infos) break + } case 'a11y:rescan': void runScan() break + case 'a11y:set-autoscan': + config.autoScan = message.enabled + if (message.enabled) + bindInteractions() + else + unbindInteractions() + break + case 'a11y:clear-route': + routes.delete(message.route) + loggedRules.delete(message.route) + saveRoutes() + broadcastState() + break + case 'a11y:clear-all': + routes.clear() + loggedRules.clear() + saveRoutes() + broadcastState() + break } }) + function applyConfig(next: AgentConfig) { + config.logIssues = next.logIssues + config.axeTags = next.axeTags + config.axeRunOptions = next.axeRunOptions + config.activateDockId = next.activateDockId + config.autoScan = next.autoScan + if (config.autoScan) + bindInteractions() + else + unbindInteractions() + } + + bindInteractions() + // Announce ourselves and run the first scan once the page has settled. - post({ type: 'a11y:agent-ready', url: location.href }) + post({ type: 'a11y:agent-ready', url: location.href, route: activeRoute }) + if (routes.size > 0) + broadcastState() if (document.readyState === 'complete') void runScan() else diff --git a/plugins/a11y/src/inject/messages.ts b/plugins/a11y/src/inject/messages.ts index 470a8827..c5bf2fb9 100644 --- a/plugins/a11y/src/inject/messages.ts +++ b/plugins/a11y/src/inject/messages.ts @@ -10,6 +10,20 @@ * by the terminals and code-server plugins for `ctx.terminals`. */ import type { Impact, ScanReport } from '../shared/protocol.ts' +import { A11Y_DEFAULT_DOCK_ID } from '../shared/protocol.ts' + +/** + * Structural slice of the hub's `DevframeMessageAction` the agent emits — a + * labeled control that, when clicked in the messages panel, activates a dock + * (deep-linking via `params`). Kept as a discriminated union so future action + * kinds can be added without reshaping the field. + */ +export interface HubMessageAction { + id: string + label: string + kind: 'activate' + activate: { dockId: string, params?: Record } +} /** Structural slice of the hub's `DevframeMessageEntryInput` the agent emits. */ export interface HubMessageInput { @@ -23,6 +37,7 @@ export interface HubMessageInput { boundingBox?: { x: number, y: number, width: number, height: number } description?: string } + actions?: HubMessageAction[] status?: 'loading' | 'idle' } @@ -68,6 +83,11 @@ export interface MessagesReporterOptions { * box is fine: each re-scan refreshes the entry. */ resolveBoundingBox?: (target: string[]) => { x: number, y: number, width: number, height: number } | undefined + /** + * The dock id the navigation actions target. Read at call time so a config + * update (custom devframe id) takes effect on the next scan. + */ + dockId?: () => string } /** @@ -86,6 +106,7 @@ export function createMessagesReporter( // Fire-and-forget: the feed is a mirror, never a gate for the scan loop. const send = (input: HubMessageInput) => void messages.add(input).catch(() => {}) const drop = (id: string) => void messages.remove(id).catch(() => {}) + const dockId = () => options.dockId?.() ?? A11Y_DEFAULT_DOCK_ID return { scanning() { @@ -107,6 +128,12 @@ export function createMessagesReporter( : `${nodeCount} accessibility issue${nodeCount === 1 ? '' : 's'} across ${ruleCount} rule${ruleCount === 1 ? '' : 's'}`, description: report.url, level: nodeCount === 0 ? 'success' : 'warn', + actions: [{ + id: 'dashboard', + label: 'Open a11y dashboard', + kind: 'activate', + activate: { dockId: dockId(), params: { tab: 'dashboard' } }, + }], status: 'idle', }) @@ -127,6 +154,15 @@ export function createMessagesReporter( description: first.failureSummary, } : undefined, + actions: [{ + id: 'view', + label: 'View in a11y inspector', + kind: 'activate', + activate: { + dockId: dockId(), + params: { tab: 'violations', ruleId: violation.ruleId, route: report.route }, + }, + }], }) } for (const ruleId of reportedRules) { diff --git a/plugins/a11y/src/inject/overlay.ts b/plugins/a11y/src/inject/overlay.ts index 701f41a7..f0b054df 100644 --- a/plugins/a11y/src/inject/overlay.ts +++ b/plugins/a11y/src/inject/overlay.ts @@ -9,37 +9,41 @@ export interface HighlightInfo { impact: Impact } +/** A pinned element plus its display metadata and 1-based badge number. */ +export interface PinInfo extends HighlightInfo { + el: Element + /** 1-based badge number, unique across the whole pin set. */ + number: number +} + export interface Overlay { root: HTMLElement - show: (el: Element, info: HighlightInfo) => void - hide: () => void + /** Draw the transient hover-preview ring around an element. */ + preview: (el: Element, info: HighlightInfo) => void + /** Clear the transient hover-preview ring. */ + clearPreview: () => void + /** Render the full set of numbered pinned rings, replacing any prior set. */ + setPins: (pins: PinInfo[]) => void } -/** - * A pointer-transparent overlay drawn over the host page. It mirrors the - * panel's "focus ring" motif: hovering a violation in the panel paints the - * same ring around the offending element here, tying the list back to the page. - * - * Everything is inline-styled and stamped with a unique attribute so it can't - * inherit from — or be restyled by — the host application's CSS. - */ -export function createOverlay(): Overlay { - const root = document.createElement('div') - root.setAttribute('data-df-a11y-overlay', '') - root.style.cssText = [ - 'position:fixed', - 'inset:0', - 'pointer-events:none', - 'z-index:2147483646', - 'contain:strict', - 'display:none', - ].join(';') +/** True for the document root elements we must not wrap in a full-page ring. */ +function isRootElement(el: Element): boolean { + return el === document.documentElement || el === document.body +} + +interface Marker { + box: HTMLElement + label: HTMLElement + el: Element | null +} +function createMarker(root: HTMLElement): Marker { const box = document.createElement('div') box.style.cssText = [ 'position:absolute', 'box-sizing:border-box', 'border-radius:3px', + 'display:none', `transition:${PREFERS_REDUCED_MOTION ? 'none' : 'all .12s cubic-bezier(.4,0,.2,1)'}`, ].join(';') @@ -47,6 +51,7 @@ export function createOverlay(): Overlay { label.style.cssText = [ 'position:absolute', 'left:0', + 'top:0', 'transform:translateY(-100%)', 'font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace', 'letter-spacing:.02em', @@ -58,22 +63,84 @@ export function createOverlay(): Overlay { box.appendChild(label) root.appendChild(box) + return { box, label, el: null } +} + +/** + * A pointer-transparent overlay drawn over the host page. It mirrors the + * panel's "focus ring" motif: hovering a violation in the panel paints a + * transient ring, and pinning violations paints persistent, numbered rings — + * so a row and its highlighted element read as one object. + * + * Everything is inline-styled and stamped with a unique attribute so it can't + * inherit from — or be restyled by — the host application's CSS. Root-element + * (``/``) targets get a non-positioned corner notice instead of a + * viewport-filling ring. + */ +export function createOverlay(): Overlay { + const root = document.createElement('div') + root.setAttribute('data-df-a11y-overlay', '') + root.style.cssText = [ + 'position:fixed', + 'inset:0', + 'pointer-events:none', + 'z-index:2147483646', + 'display:block', + ].join(';') + + // A dedicated corner notice for root-element targets that can't be ringed. + const notice = document.createElement('div') + notice.style.cssText = [ + 'position:absolute', + 'left:12px', + 'bottom:12px', + 'max-width:320px', + 'display:none', + 'font:600 11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace', + 'padding:6px 10px', + 'border-radius:6px', + 'color:#0b0e13', + 'box-shadow:0 8px 30px rgb(0 0 0 / 35%)', + ].join(';') + root.appendChild(notice) + + const preview = createMarker(root) + const pins: Marker[] = [] - let current: Element | null = null let rafId = 0 + let noticeText = '' + + function styleBox(marker: Marker, color: string, dim: boolean) { + marker.box.style.border = `2px solid ${color}` + marker.box.style.boxShadow = dim + ? `0 0 0 3px ${color}26` + : `0 0 0 4px ${color}33, 0 8px 30px ${color}26` + marker.box.style.background = `${color}14` + marker.label.style.background = color + } - function place() { - if (!current) + function place(marker: Marker) { + const el = marker.el + if (!el) return - const rect = current.getBoundingClientRect() + const rect = el.getBoundingClientRect() const pad = 2 - box.style.top = `${rect.top - pad}px` - box.style.left = `${rect.left - pad}px` - box.style.width = `${rect.width + pad * 2}px` - box.style.height = `${rect.height + pad * 2}px` + marker.box.style.top = `${rect.top - pad}px` + marker.box.style.left = `${rect.left - pad}px` + marker.box.style.width = `${rect.width + pad * 2}px` + marker.box.style.height = `${rect.height + pad * 2}px` // Flip the label below the box when it would clip past the top edge. - label.style.transform = rect.top < 22 ? 'translateY(0)' : 'translateY(-100%)' - label.style.top = rect.top < 22 ? '100%' : '0' + marker.label.style.transform = rect.top < 22 ? 'translateY(0)' : 'translateY(-100%)' + marker.label.style.top = rect.top < 22 ? '100%' : '0' + } + + function placeAll() { + if (preview.el) + place(preview) + for (const marker of pins) { + if (marker.el) + place(marker) + } } function onViewportChange() { @@ -81,37 +148,110 @@ export function createOverlay(): Overlay { return rafId = requestAnimationFrame(() => { rafId = 0 - place() + placeAll() }) } - function show(el: Element, info: HighlightInfo) { - current = el - const color = IMPACT_COLOR[info.impact] - box.style.border = `2px solid ${color}` - box.style.boxShadow = `0 0 0 4px ${color}33, 0 8px 30px ${color}26` - box.style.background = `${color}14` - label.style.background = color - label.textContent = `${info.impact} · ${info.ruleId}` - - root.style.display = 'block' - // Bring the target into view if it is off-screen. + let listening = false + function ensureListening() { + if (listening) + return + listening = true + addEventListener('scroll', onViewportChange, { passive: true, capture: true }) + addEventListener('resize', onViewportChange, { passive: true }) + } + function maybeStopListening() { + if (listening && !preview.el && pins.length === 0) { + listening = false + removeEventListener('scroll', onViewportChange, { capture: true } as EventListenerOptions) + removeEventListener('resize', onViewportChange) + } + } + + function scrollIntoViewIfNeeded(el: Element) { const rect = el.getBoundingClientRect() const offscreen = rect.bottom < 0 || rect.top > innerHeight if (offscreen) el.scrollIntoView({ block: 'center', behavior: PREFERS_REDUCED_MOTION ? 'auto' : 'smooth' }) - place() - - addEventListener('scroll', onViewportChange, { passive: true, capture: true }) - addEventListener('resize', onViewportChange, { passive: true }) } - function hide() { - current = null - root.style.display = 'none' - removeEventListener('scroll', onViewportChange, { capture: true } as EventListenerOptions) - removeEventListener('resize', onViewportChange) + function showNotice(color: string, text: string) { + noticeText = text + notice.style.background = color + notice.textContent = text + notice.style.display = 'block' } + function hideNotice() { + noticeText = '' + notice.style.display = 'none' + } + + return { + root, + + preview(el, info) { + if (isRootElement(el)) { + preview.el = null + preview.box.style.display = 'none' + showNotice(IMPACT_COLOR[info.impact], `${info.impact} · ${info.ruleId} — whole page`) + return + } + preview.el = el + const color = IMPACT_COLOR[info.impact] + styleBox(preview, color, false) + preview.label.textContent = `${info.impact} · ${info.ruleId}` + preview.box.style.display = 'block' + ensureListening() + scrollIntoViewIfNeeded(el) + place(preview) + }, + + clearPreview() { + preview.el = null + preview.box.style.display = 'none' + if (noticeText.includes('whole page') && pins.length === 0) + hideNotice() + maybeStopListening() + }, - return { root, show, hide } + setPins(next) { + // Rebuild the marker pool to match the requested pin count. + while (pins.length < next.length) + pins.push(createMarker(root)) + while (pins.length > next.length) { + const marker = pins.pop()! + marker.box.remove() + } + + let rootPin: PinInfo | null = null + next.forEach((pin, i) => { + const marker = pins[i]! + if (isRootElement(pin.el)) { + marker.el = null + marker.box.style.display = 'none' + rootPin = pin + return + } + marker.el = pin.el + const color = IMPACT_COLOR[pin.impact] + styleBox(marker, color, true) + marker.label.textContent = `${pin.number} · ${pin.ruleId}` + marker.box.style.display = 'block' + place(marker) + }) + + if (rootPin) + showNotice(IMPACT_COLOR[(rootPin as PinInfo).impact], `${(rootPin as PinInfo).number} · ${(rootPin as PinInfo).ruleId} — whole page`) + else if (!preview.el) + hideNotice() + + if (next.length > 0) { + ensureListening() + const first = next.find(p => !isRootElement(p.el)) + if (first) + scrollIntoViewIfNeeded(first.el) + } + maybeStopListening() + }, + } } diff --git a/plugins/a11y/src/inject/scanner.ts b/plugins/a11y/src/inject/scanner.ts index 17a4da08..ff81d86c 100644 --- a/plugins/a11y/src/inject/scanner.ts +++ b/plugins/a11y/src/inject/scanner.ts @@ -1,6 +1,6 @@ import type { ScanReport, Violation, ViolationNode } from '../shared/protocol.ts' import axe from 'axe-core' -import { A11Y_NODE_ATTR, emptyCounts, IMPACT_ORDER } from '../shared/protocol.ts' +import { A11Y_NODE_ATTR, DEFAULT_AXE_TAGS, emptyCounts, IMPACT_ORDER } from '../shared/protocol.ts' const IMPACTS = new Set(IMPACT_ORDER) let counter = 0 @@ -51,20 +51,29 @@ function stamp(el: Element): string { return id } +export interface ScanOptions { + /** axe rule tags to run (defaults to {@link DEFAULT_AXE_TAGS}). */ + tags?: string[] + /** Extra axe `run` options merged over the defaults. */ + runOptions?: Record +} + /** * Run axe against the live document and shape the result into a {@link ScanReport}. * Stamps each violating element with {@link A11Y_NODE_ATTR} as a side effect. */ -export async function scan(): Promise { +export async function scan(options: ScanOptions = {}): Promise { + const tags = options.tags?.length ? options.tags : [...DEFAULT_AXE_TAGS] const results = await axe.run(document, { resultTypes: ['violations'], - // Keep the report focused on real failures; skip best-practice noise that - // would bury the actionable items for a first-pass tool. - runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, + // Broadened past strict WCAG A/AA to WCAG 2.2 + best-practice; the panel + // tags best-practice rules and can filter them back out. + runOnly: { type: 'tag', values: tags }, // Stay in the host document — don't descend into the devtools panel's own // iframe (or any other frame), which would mix unrelated nodes into the // report and risk scanning ourselves. iframes: false, + ...options.runOptions, }) const counts = emptyCounts() @@ -89,6 +98,7 @@ export async function scan(): Promise { description: rule.description, helpUrl: rule.helpUrl, tags: rule.tags.filter(tag => tag.startsWith('wcag')), + bestPractice: rule.tags.includes('best-practice'), nodes, } }) @@ -97,6 +107,7 @@ export async function scan(): Promise { violations.sort((a, b) => IMPACT_ORDER.indexOf(a.impact) - IMPACT_ORDER.indexOf(b.impact)) return { + route: location.pathname, url: location.href, scannedAt: Date.now(), engine: results.testEngine?.version ?? 'unknown', diff --git a/plugins/a11y/src/node/index.ts b/plugins/a11y/src/node/index.ts index d369e26c..dc07fd92 100644 --- a/plugins/a11y/src/node/index.ts +++ b/plugins/a11y/src/node/index.ts @@ -1,13 +1,15 @@ import type { DevframeNodeContext } from 'devframe/types' -import { serverFunctions } from '../rpc/index.ts' +import type { A11yRuntimeConfig } from '../rpc/functions/get-config.ts' +import { buildServerFunctions, serverFunctions } from '../rpc/index.ts' /** * Register the a11y inspector's RPC functions on a devframe node context. * Called from the definition's `setup(ctx)` and reusable by host adapters - * that wire their own context. + * that wire their own context. Author options (auto-scan, logging, axe tags, + * default-highlight) are surfaced through the `get-config` function. */ -export function setupA11y(ctx: DevframeNodeContext): void { - for (const fn of serverFunctions) +export function setupA11y(ctx: DevframeNodeContext, options: A11yRuntimeConfig = {}): void { + for (const fn of buildServerFunctions(options)) ctx.rpc.register(fn) } diff --git a/plugins/a11y/src/rpc/functions/get-config.ts b/plugins/a11y/src/rpc/functions/get-config.ts index acbb8d1c..5999f997 100644 --- a/plugins/a11y/src/rpc/functions/get-config.ts +++ b/plugins/a11y/src/rpc/functions/get-config.ts @@ -1,5 +1,6 @@ +import type { AgentConfig } from '../../shared/protocol.ts' import { defineRpcFunction } from 'devframe' -import { A11Y_CHANNEL, A11Y_NODE_ATTR, IMPACT_ORDER } from '../../shared/protocol.ts' +import { A11Y_CHANNEL, A11Y_DEFAULT_DOCK_ID, A11Y_NODE_ATTR, IMPACT_ORDER } from '../../shared/protocol.ts' const IMPACT_COPY = { critical: { @@ -20,23 +21,56 @@ const IMPACT_COPY = { }, } as const +/** Author-supplied runtime configuration surfaced through `get-config`. */ +export interface A11yRuntimeConfig { + /** The devframe id, so messages-feed actions can activate this dock. */ + dockId?: string + /** Rescan on debounced user interaction (default `true`). */ + autoScan?: boolean + /** Log newly-appeared violations to the browser console (default `true`). */ + logIssues?: boolean + /** Auto-pin all of a route's violations the first time it's scanned (default `false`). */ + defaultHighlight?: boolean + /** axe configuration. */ + axe?: { + /** Rule tags to run (defaults to the broadened WCAG 2.0–2.2 + best-practice set). */ + tags?: string[] + /** Extra axe `run` options merged over the defaults. */ + runOptions?: Record + } +} + /** - * Static metadata the panel reads once on load: the impact taxonomy (with - * developer-facing copy) and the channel coordinates the panel uses to find - * its injected agent. - * - * Declared `static`, so the value is resolved live over WebSocket in dev and - * baked into the RPC dump for static builds — the panel's legend renders the - * same in both modes. + * Build the `get-config` RPC function from author options. Declared `static`, + * so the value resolves live over WebSocket in dev and is baked into the RPC + * dump for static builds — the panel's legend + runtime config render the same + * in both modes. The panel forwards the `agent` slice to the in-page agent over + * the BroadcastChannel, keeping the agent free of any RPC dependency. */ -export const getConfig = defineRpcFunction({ - name: 'devframes:plugin:a11y:get-config', - type: 'static', - jsonSerializable: true, - handler: () => ({ - channel: A11Y_CHANNEL, - nodeAttr: A11Y_NODE_ATTR, - docsBase: 'https://dequeuniversity.com/rules/axe/', - impacts: IMPACT_ORDER.map(id => ({ id, ...IMPACT_COPY[id] })), - }), -}) +export function createGetConfig(options: A11yRuntimeConfig = {}) { + const dockId = options.dockId ?? A11Y_DEFAULT_DOCK_ID + const agent: AgentConfig = { + logIssues: options.logIssues ?? true, + autoScan: options.autoScan ?? true, + axeTags: options.axe?.tags, + axeRunOptions: options.axe?.runOptions, + activateDockId: dockId, + } + return defineRpcFunction({ + name: 'devframes:plugin:a11y:get-config', + type: 'static', + jsonSerializable: true, + handler: () => ({ + channel: A11Y_CHANNEL, + nodeAttr: A11Y_NODE_ATTR, + docsBase: 'https://dequeuniversity.com/rules/axe/', + dockId, + defaultHighlight: options.defaultHighlight ?? false, + agent, + impacts: IMPACT_ORDER.map(id => ({ id, ...IMPACT_COPY[id] })), + }), + }) +} + +/** Default `get-config` instance — used for the RPC type augmentation. */ +export const getConfig = createGetConfig() diff --git a/plugins/a11y/src/rpc/index.ts b/plugins/a11y/src/rpc/index.ts index b1b55030..e1c5f5b8 100644 --- a/plugins/a11y/src/rpc/index.ts +++ b/plugins/a11y/src/rpc/index.ts @@ -1,8 +1,15 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { getConfig } from './functions/get-config.ts' +import type { A11yRuntimeConfig } from './functions/get-config.ts' +import { createGetConfig, getConfig } from './functions/get-config.ts' +/** Default function set — drives the RPC type augmentation below. */ export const serverFunctions = [getConfig] as const +/** Build the function set configured with author options. */ +export function buildServerFunctions(options: A11yRuntimeConfig = {}) { + return [createGetConfig(options)] as const +} + declare module 'devframe' { interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} } diff --git a/plugins/a11y/src/shared/protocol.ts b/plugins/a11y/src/shared/protocol.ts index 0c64c211..f27ab6d0 100644 --- a/plugins/a11y/src/shared/protocol.ts +++ b/plugins/a11y/src/shared/protocol.ts @@ -1,8 +1,8 @@ /** * Wire contract shared by the two browser-side halves of the a11y inspector: * - * - the **agent** — injected into the host application's page, runs axe-core - * and owns the highlight overlay (`src/inject`); + * - the **agent** — injected into the host application's page, runs axe-core, + * tracks issues per route, and owns the highlight overlay (`src/inject`); * - the **panel** — the Solid SPA that lists violations (`src/client`). * * They talk over a same-origin {@link https://developer.mozilla.org/docs/Web/API/BroadcastChannel BroadcastChannel} @@ -11,20 +11,47 @@ * RPC) or as a baked static build — neither half needs a server to find the * other, only a shared browser origin (host page + devtools iframe). * - * devframe RPC still carries the data model on top of this: `get-config` - * (rule catalogue + impact metadata) is a `static` function, so it resolves - * live in dev and from the baked dump in a static build. + * The agent owns the authoritative route → report map (backed by + * sessionStorage) and broadcasts the whole {@link A11yState} aggregate on every + * change, so the panel stays a pure render of it. Runtime configuration + * (`get-config`, a `static` RPC the panel resolves) is forwarded to the agent + * over the same channel as an {@link AgentConfig}, keeping the agent itself + * free of any RPC dependency. */ /** BroadcastChannel name. Namespaced with the devframe id, per convention. */ export const A11Y_CHANNEL = 'devframes:plugin:a11y' +/** Default dock id — the devframe id a hub registers the panel under. */ +export const A11Y_DEFAULT_DOCK_ID = 'devframes_plugin_a11y' + +/** sessionStorage key the agent persists its route → report map under. */ +export const A11Y_STORAGE_KEY = 'devframes:plugin:a11y:routes' + +/** Shared-state slot the hub mirrors the active dock activation into. */ +export const A11Y_DOCKS_ACTIVE_KEY = 'devframe:docks:active' + /** * Attribute the agent stamps on each violating element so the panel can ask * for a stable target that survives re-scans and DOM reshuffles. */ export const A11Y_NODE_ATTR = 'data-df-a11y-node' +/** + * Default axe rule tags the scanner runs. Broadened past the strict WCAG A/AA + * set to include WCAG 2.2 and best-practice rules — the best-practice ones are + * flagged ({@link Violation.bestPractice}) so the panel can filter them out. + */ +export const DEFAULT_AXE_TAGS = [ + 'wcag2a', + 'wcag2aa', + 'wcag21a', + 'wcag21aa', + 'wcag22a', + 'wcag22aa', + 'best-practice', +] as const + /** axe-core impact buckets, ordered most → least severe. */ export const IMPACT_ORDER = ['critical', 'serious', 'moderate', 'minor'] as const export type Impact = (typeof IMPACT_ORDER)[number] @@ -64,11 +91,15 @@ export interface Violation { helpUrl: string /** WCAG tags the rule maps to, e.g. `["wcag2a", "wcag111"]`. */ tags?: string[] + /** Whether this is an axe best-practice rule rather than a WCAG success criterion. */ + bestPractice?: boolean nodes: ViolationNode[] } export interface ScanReport { - /** Location of the scanned document. */ + /** Route bucket key — `location.pathname` of the scanned document. */ + route: string + /** Full location of the scanned document. */ url: string /** Epoch ms the scan finished. */ scannedAt: number @@ -79,27 +110,77 @@ export interface ScanReport { counts: Record } +/** + * The whole route → report aggregate the agent broadcasts. `routes` holds one + * report per tracked route; `activeRoute` is the pathname currently in view in + * the host page. + */ +export interface A11yState { + /** axe engine version (from the most recent scan). */ + engine: string + /** `location.pathname` currently in view in the host page. */ + activeRoute: string + /** One report per tracked route. */ + routes: ScanReport[] +} + +/** A single element to pin/highlight, carried from panel to agent. */ +export interface PinTarget { + /** Node id from {@link ViolationNode.id}. */ + nodeId: string + /** axe target selector(s); the fallback when the id no longer resolves. */ + target: string[] + impact: Impact + ruleId: string +} + +/** + * Runtime configuration the panel resolves from the `get-config` RPC and + * forwards to the agent over the channel (the agent keeps no RPC dependency). + */ +export interface AgentConfig { + /** Log newly-appeared violations to the browser console. */ + logIssues: boolean + /** Rescan on debounced user interaction, on top of the DOM MutationObserver. */ + autoScan: boolean + /** axe rule tags to run (defaults to {@link DEFAULT_AXE_TAGS}). */ + axeTags?: string[] + /** Extra axe `run` options merged over the defaults. */ + axeRunOptions?: Record + /** + * Dock id the messages-feed navigation actions target (the devframe id). + * Defaults to {@link A11Y_DEFAULT_DOCK_ID}. + */ + activateDockId?: string +} + /* ── agent → panel ─────────────────────────────────────────────────────── */ export interface AgentReadyMessage { type: 'a11y:agent-ready' url: string + route: string } -export interface ReportMessage { - type: 'a11y:report' - report: ScanReport +export interface StateMessage { + type: 'a11y:state' + state: A11yState } export interface ScanningMessage { type: 'a11y:scanning' + route: string } -export type AgentToPanel = AgentReadyMessage | ReportMessage | ScanningMessage +export type AgentToPanel = AgentReadyMessage | StateMessage | ScanningMessage /* ── panel → agent ─────────────────────────────────────────────────────── */ export interface PanelReadyMessage { type: 'a11y:panel-ready' } +export interface ConfigMessage { + type: 'a11y:config' + config: AgentConfig +} export interface HighlightMessage { type: 'a11y:highlight' /** Node id from {@link ViolationNode.id}; falls back to the first target. */ @@ -109,15 +190,36 @@ export interface HighlightMessage { export interface ClearHighlightMessage { type: 'a11y:clear' } +export interface PinsMessage { + type: 'a11y:pins' + /** The full desired pin set; the agent draws numbered rings in this order. */ + pins: PinTarget[] +} export interface RescanMessage { type: 'a11y:rescan' } +export interface SetAutoScanMessage { + type: 'a11y:set-autoscan' + enabled: boolean +} +export interface ClearRouteMessage { + type: 'a11y:clear-route' + route: string +} +export interface ClearAllMessage { + type: 'a11y:clear-all' +} export type PanelToAgent = | PanelReadyMessage + | ConfigMessage | HighlightMessage | ClearHighlightMessage + | PinsMessage | RescanMessage + | SetAutoScanMessage + | ClearRouteMessage + | ClearAllMessage export type A11yMessage = AgentToPanel | PanelToAgent @@ -125,3 +227,13 @@ export type A11yMessage = AgentToPanel | PanelToAgent export function emptyCounts(): Record { return { critical: 0, serious: 0, moderate: 0, minor: 0 } } + +/** Roll the per-impact counts of many reports into one total. */ +export function sumCounts(reports: ScanReport[]): Record { + const total = emptyCounts() + for (const report of reports) { + for (const impact of IMPACT_ORDER) + total[impact] += report.counts[impact] + } + return total +} diff --git a/plugins/a11y/src/spa/app.tsx b/plugins/a11y/src/spa/app.tsx index cedce164..c1bbb293 100644 --- a/plugins/a11y/src/spa/app.tsx +++ b/plugins/a11y/src/spa/app.tsx @@ -1,48 +1,249 @@ -import type { Impact } from '../shared/protocol.ts' -import { createMemo, createSignal, Match, Show, Switch } from 'solid-js' +import type { Impact, PinTarget, ScanReport, Violation, ViolationNode } from '../shared/protocol.ts' +import type { TabId } from './components/header.tsx' +import type { PinsApi, RouteGroupModel } from './components/violations.tsx' +import { batch, createEffect, createMemo, createSignal, Match, on, Show, Switch } from 'solid-js' import { emptyCounts } from '../shared/protocol.ts' -import { Header, MetaLine, Summary } from './components/header.tsx' +import { Dashboard } from './components/dashboard.tsx' +import { Header, MetaLine } from './components/header.tsx' import { CheckCircle, PlugIcon } from './components/icons.tsx' -import { ViolationList } from './components/violations.tsx' +import { ruleCardId, ViolationList } from './components/violations.tsx' import { createA11yChannel } from './lib/channel.ts' import { connectDevframeState } from './lib/devframe.ts' -import { IMPACT_LABEL } from './lib/impact.ts' const SNIPPET = '' +const AUTOSCAN_KEY = 'devframes:plugin:a11y:autoscan' + +function nodePin(v: Violation, node: ViolationNode): PinTarget { + return { nodeId: node.id, target: node.target, impact: v.impact, ruleId: v.ruleId } +} +function allPins(report: ScanReport): PinTarget[] { + return report.violations.flatMap(v => v.nodes.map(n => nodePin(v, n))) +} export function App() { const channel = createA11yChannel() const devframe = connectDevframeState() + const [activeTab, setActiveTab] = createSignal('dashboard') const [filter, setFilter] = createSignal(null) - const [expanded, setExpanded] = createSignal>(new Set()) - - const counts = () => channel.report()?.counts ?? emptyCounts() - const violations = () => channel.report()?.violations ?? [] - const total = () => violations().length - const filtered = createMemo(() => { - const active = filter() - return active ? violations().filter(v => v.impact === active) : violations() + const [showBestPractice, setShowBestPractice] = createSignal(true) + const [expandedRoutes, setExpandedRoutes] = createSignal>(new Set()) + const [expandedRules, setExpandedRules] = createSignal>(new Set()) + const [pins, setPins] = createSignal([]) + + const storedAuto = (() => { + try { + return sessionStorage.getItem(AUTOSCAN_KEY) + } + catch { + return null + } + })() + const [autoScan, setAutoScan] = createSignal(storedAuto == null ? true : storedAuto === '1') + + const routes = () => channel.state()?.routes ?? [] + const activeRoute = () => channel.activeRoute() + const activeReport = createMemo(() => routes().find(r => r.route === activeRoute()) ?? null) + const engine = () => channel.state()?.engine + + const includeViolation = (v: Violation) => showBestPractice() || !v.bestPractice + const matchFilter = (v: Violation) => filter() === null || v.impact === filter() + + // Chip counts respect the best-practice toggle but not the impact filter, so + // the chips can drive the filter. + const chipCounts = createMemo(() => { + const counts = emptyCounts() + for (const report of routes()) { + for (const v of report.violations) { + if (!includeViolation(v)) + continue + counts[v.impact] += v.nodes.length + } + } + return counts + }) + + const totalNodes = createMemo(() => + routes().reduce((n, r) => n + r.violations.filter(includeViolation).reduce((m, v) => m + v.nodes.length, 0), 0)) + const totalRules = createMemo(() => + routes().reduce((n, r) => n + r.violations.filter(includeViolation).length, 0)) + + const routeSummaries = createMemo(() => + routes().map(r => ({ + route: r.route, + active: r.route === activeRoute(), + ruleCount: r.violations.filter(includeViolation).length, + nodeCount: r.violations.filter(includeViolation).reduce((m, v) => m + v.nodes.length, 0), + }))) + + // Grouped, filtered violations for the Violations tab — active route first, + // empty groups dropped. + const groups = createMemo(() => { + const models = routes().map(r => ({ + report: r, + active: r.route === activeRoute(), + violations: r.violations.filter(v => includeViolation(v) && matchFilter(v)), + })).filter(g => g.violations.length > 0) + models.sort((a, b) => Number(b.active) - Number(a.active)) + return models }) + const shownViolations = createMemo(() => groups().reduce((n, g) => n + g.violations.length, 0)) + const totalFilterable = createMemo(() => + routes().reduce((n, r) => n + r.violations.filter(includeViolation).length, 0)) + + const collapsedRoutes = createMemo(() => { + const collapsed = new Set() + for (const r of routes()) { + if (!expandedRoutes().has(r.route)) + collapsed.add(r.route) + } + return collapsed + }) + + // ── config → agent, and initial auto-scan default ───────────────────────── + let autoInit = false + createEffect(() => { + const cfg = devframe.config() + if (!cfg) + return + channel.sendConfig(cfg.agent) + if (!autoInit && storedAuto == null) { + autoInit = true + setAutoScan(cfg.agent.autoScan) + } + }) + + createEffect(on(autoScan, (v) => { + channel.setAutoScan(v) + try { + sessionStorage.setItem(AUTOSCAN_KEY, v ? '1' : '0') + } + catch { + // best-effort + } + })) + + // Keep the active route's group expanded. + createEffect(() => { + const r = activeRoute() + if (r) + setExpandedRoutes(prev => (prev.has(r) ? prev : new Set(prev).add(r))) + }) + + // ── pins ────────────────────────────────────────────────────────────────── + createEffect(() => channel.setPins(pins())) + + // Pins reference live DOM, so clear them when the host route changes. + createEffect(on(activeRoute, () => setPins([]), { defer: true })) + + // defaultHighlight: pin a route's violations the first time it's scanned. + const highlighted = new Set() + createEffect(() => { + const cfg = devframe.config() + const report = activeReport() + if (!cfg?.defaultHighlight || !report || highlighted.has(report.route)) + return + highlighted.add(report.route) + setPins(allPins(report)) + }) + + const pinsApi: PinsApi = { + isPinned: nodeId => pins().some(p => p.nodeId === nodeId), + numberOf: (nodeId) => { + const i = pins().findIndex(p => p.nodeId === nodeId) + return i === -1 ? null : i + 1 + }, + isRulePinned: v => v.nodes.length > 0 && v.nodes.every(n => pins().some(p => p.nodeId === n.id)), + toggleNode: (v, node) => { + setPins(prev => (prev.some(p => p.nodeId === node.id) + ? prev.filter(p => p.nodeId !== node.id) + : [...prev, nodePin(v, node)])) + }, + toggleRule: (v) => { + const allPinned = v.nodes.length > 0 && v.nodes.every(n => pins().some(p => p.nodeId === n.id)) + if (allPinned) { + const ids = new Set(v.nodes.map(n => n.id)) + setPins(prev => prev.filter(p => !ids.has(p.nodeId))) + } + else { + setPins((prev) => { + const have = new Set(prev.map(p => p.nodeId)) + return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))] + }) + } + }, + } + + // ── deep-linking from other docks (e.g. the messages feed) ──────────────── + createEffect(on(devframe.activation, (act) => { + if (!act) + return + const cfg = devframe.config() + if (cfg && act.dockId !== cfg.dockId) + return + const params = act.params ?? {} + const tab = params.tab === 'dashboard' ? 'dashboard' : 'violations' + setActiveTab(tab) + if (tab === 'dashboard') + return + const route = typeof params.route === 'string' ? params.route : undefined + const ruleId = typeof params.ruleId === 'string' ? params.ruleId : undefined + if (!route) + return + batch(() => { + setExpandedRoutes(prev => new Set(prev).add(route)) + if (ruleId) + setExpandedRules(prev => new Set(prev).add(`${route}::${ruleId}`)) + }) + if (ruleId) { + const report = routes().find(r => r.route === route) + const v = report?.violations.find(x => x.ruleId === ruleId) + if (v) { + setPins((prev) => { + const have = new Set(prev.map(p => p.nodeId)) + return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))] + }) + } + // Scroll the card into view once it has rendered. + setTimeout(() => document.getElementById(ruleCardId(route, ruleId))?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 60) + } + }, { defer: true })) + function toggleFilter(impact: Impact) { setFilter(prev => (prev === impact ? null : impact)) } - function toggleExpand(ruleId: string) { - setExpanded((prev) => { + function toggleGroup(route: string) { + setExpandedRoutes((prev) => { const next = new Set(prev) - if (next.has(ruleId)) - next.delete(ruleId) + if (next.has(route)) + next.delete(route) else - next.add(ruleId) + next.add(route) return next }) } + function toggleRule(route: string, ruleId: string) { + const key = `${route}::${ruleId}` + setExpandedRules((prev) => { + const next = new Set(prev) + if (next.has(key)) + next.delete(key) + else + next.add(key) + return next + }) + } + function selectRoute(route: string) { + setActiveTab('violations') + setExpandedRoutes(prev => new Set(prev).add(route)) + setTimeout(() => document.getElementById(`group-${encodeURIComponent(route)}`)?.scrollIntoView({ block: 'start', behavior: 'smooth' }), 60) + } const announce = () => { - if (!channel.report()) + if (!channel.state()) return channel.scanning() ? 'Scanning the page' : '' - return `${total()} accessibility ${total() === 1 ? 'issue' : 'issues'} found` + return `${totalNodes()} accessibility ${totalNodes() === 1 ? 'issue' : 'issues'} found across ${routes().length} ${routes().length === 1 ? 'route' : 'routes'}` } return ( @@ -50,20 +251,24 @@ export function App() {
- - - 0}> - - +

{announce()}

{/* No agent has announced itself on this origin yet. */} - +

No page connected

@@ -75,8 +280,8 @@ export function App() {
- {/* Agent present, first report not in yet. */} - + {/* Agent present, first state not in yet. */} +

Scanning the page…

@@ -84,46 +289,72 @@ export function App() {
- {/* Report in, zero violations. */} - + {/* Dashboard. */} + + + + + {/* Violations — clean. */} +
-

No WCAG A & AA violations

+

No violations

- axe-core found nothing to flag on this page. Re-run after changes - to keep it that way. + axe-core found nothing to flag across the tracked routes. Re-run + after changes to keep it that way.

- {/* Filter active but empty for that impact. */} - + {/* Violations — filtered to empty. */} +
-

- No - {' '} - {filter() ? IMPACT_LABEL[filter()!] : ''} - {' '} - issues -

+

Nothing matches the filter

- {total()} + {totalFilterable()} {' '} - {total() === 1 ? 'issue' : 'issues'} + {totalFilterable() === 1 ? 'rule' : 'rules'} {' '} at other severities. Clear the filter to see them.

- {/* The list. */} - 0}> + {/* Violations — grouped list. */} + 0}> + +

+ Showing + {' '} + {shownViolations()} + {' of '} + {totalFilterable()} + {' rules'} +

+
diff --git a/plugins/a11y/src/spa/components/dashboard.stories.tsx b/plugins/a11y/src/spa/components/dashboard.stories.tsx new file mode 100644 index 00000000..6f8bd6d3 --- /dev/null +++ b/plugins/a11y/src/spa/components/dashboard.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from 'storybook-solidjs-vite' +import { Dashboard } from './dashboard.tsx' +import '../styles.css' + +// The dashboard landing view: totals, the severity summary/filter, scan + +// best-practice controls, and the per-route inventory. Presentational — every +// input is a prop, so it stories offline without a live scan. +const meta = { + title: 'A11y/Dashboard', + component: Dashboard, + parameters: { layout: 'padded' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function noop() {} + +const base = { + counts: { critical: 3, serious: 5, moderate: 2, minor: 8 }, + filter: null, + onToggleFilter: noop, + totalNodes: 18, + totalRules: 7, + routes: [ + { route: '/', active: true, ruleCount: 4, nodeCount: 11 }, + { route: '/about', active: false, ruleCount: 3, nodeCount: 7 }, + { route: '/contact', active: false, ruleCount: 0, nodeCount: 0 }, + ], + onSelectRoute: noop, + autoScan: true, + onToggleAutoScan: noop, + showBestPractice: true, + onToggleBestPractice: noop, + onClearAll: noop, +} + +/** A spread of violations across several tracked routes. */ +export const Overview: Story = { args: base } + +/** One severity selected as the active filter. */ +export const Filtered: Story = { args: { ...base, filter: 'serious' } } + +/** Nothing scanned yet — the empty route inventory. */ +export const Empty: Story = { + args: { + ...base, + counts: { critical: 0, serious: 0, moderate: 0, minor: 0 }, + totalNodes: 0, + totalRules: 0, + routes: [], + }, +} diff --git a/plugins/a11y/src/spa/components/dashboard.tsx b/plugins/a11y/src/spa/components/dashboard.tsx new file mode 100644 index 00000000..d3d384bf --- /dev/null +++ b/plugins/a11y/src/spa/components/dashboard.tsx @@ -0,0 +1,117 @@ +import type { Impact } from '../../shared/protocol.ts' +import { For, Show } from 'solid-js' +import { Summary } from './header.tsx' + +export interface RouteSummary { + route: string + active: boolean + ruleCount: number + nodeCount: number +} + +interface DashboardProps { + counts: Record + filter: Impact | null + onToggleFilter: (impact: Impact) => void + totalNodes: number + totalRules: number + routes: RouteSummary[] + onSelectRoute: (route: string) => void + autoScan: boolean + onToggleAutoScan: (enabled: boolean) => void + showBestPractice: boolean + onToggleBestPractice: (show: boolean) => void + onClearAll: () => void +} + +export function Dashboard(props: DashboardProps) { + return ( +
+
+
+ {props.totalNodes} + Affected elements +
+
+ {props.totalRules} + Rules violated +
+
+ {props.routes.length} + Routes tracked +
+
+ + + +
+ + + + + + + +
+ +
+

Routes

+ 0} + fallback={

No routes scanned yet.

} + > +
    + + {r => ( +
  • + +
  • + )} +
    +
+
+
+
+ ) +} diff --git a/plugins/a11y/src/spa/components/header.tsx b/plugins/a11y/src/spa/components/header.tsx index eb303a98..519e2fbd 100644 --- a/plugins/a11y/src/spa/components/header.tsx +++ b/plugins/a11y/src/spa/components/header.tsx @@ -1,13 +1,15 @@ -import type { Accessor } from 'solid-js' -import type { Impact, ScanReport } from '../../shared/protocol.ts' +import type { Impact } from '../../shared/protocol.ts' import { For, Show } from 'solid-js' -import { IMPACT_ORDER } from '../../shared/protocol.ts' -import { button, nav, navBrand } from '../design' +import { button, nav, navBrand, tab, tabsList } from '../design' import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts' +export type TabId = 'dashboard' | 'violations' + interface HeaderProps { agentReady: boolean scanning: boolean + activeTab: TabId + onTab: (tab: TabId) => void onRescan: () => void } @@ -21,12 +23,36 @@ export function Header(props: HeaderProps) { ? 'status__dot status__dot--scanning' : 'status__dot status__dot--live' + const tabs: { id: TabId, label: string, icon: string }[] = [ + { id: 'dashboard', label: 'Dashboard', icon: 'i-ph-gauge-duotone' }, + { id: 'violations', label: 'Violations', icon: 'i-ph-list-checks-duotone' }, + ] + return (
A11y Inspector + +
+ + {t => ( + + )} + +
+ @@ -45,11 +71,15 @@ export function Header(props: HeaderProps) { ) } -export function MetaLine(props: { - report: Accessor - backend: Accessor - status?: Accessor -}) { +interface MetaLineProps { + url?: string + route?: string + engine?: string + backend: () => string | null + status?: () => string | null +} + +export function MetaLine(props: MetaLineProps) { // The backend is optional here, so a degraded connection is shown as a quiet // tag rather than taking over the panel. const degraded = () => { @@ -57,20 +87,20 @@ export function MetaLine(props: { return s === 'disconnected' || s === 'unauthorized' || s === 'error' ? s : null } return ( - - {report => ( -
- {report().url} - {b => {b()}}}> - {s => {s()}} - + +
+ {props.url} + {b => {b()}}}> + {s => {s()}} + + axe {' '} - {report().engine} + {props.engine} -
- )} +
+
) } @@ -81,10 +111,11 @@ interface SummaryProps { onToggle: (impact: Impact) => void } +/** The severity summary chips — also serve as the impact filter. */ export function Summary(props: SummaryProps) { return (
- + {(impact) => { const count = () => props.counts[impact] return ( diff --git a/plugins/a11y/src/spa/components/violations.stories.tsx b/plugins/a11y/src/spa/components/violations.stories.tsx new file mode 100644 index 00000000..191f89d4 --- /dev/null +++ b/plugins/a11y/src/spa/components/violations.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from 'storybook-solidjs-vite' +import type { ScanReport, Violation } from '../../shared/protocol.ts' +import type { A11yChannel } from '../lib/channel.ts' +import type { PinsApi, RouteGroupModel } from './violations.tsx' +import { emptyCounts } from '../../shared/protocol.ts' +import { ViolationList } from './violations.tsx' +import '../styles.css' + +// The grouped, per-route violation list with its pin controls. Driven by plain +// data + no-op controllers so it stories without a live agent. +const meta = { + title: 'A11y/ViolationList', + component: ViolationList, + parameters: { layout: 'padded' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function noop() {} + +const channel = { + preview: noop, + clearPreview: noop, + clearRoute: noop, +} as unknown as A11yChannel + +const pins: PinsApi = { + isPinned: () => false, + numberOf: () => null, + isRulePinned: () => false, + toggleNode: noop, + toggleRule: noop, +} + +function violation(ruleId: string, impact: Violation['impact'], nodes = 1, bestPractice = false): Violation { + return { + ruleId, + impact, + help: `Ensure ${ruleId} is correct`, + description: `Ensures ${ruleId}`, + helpUrl: `https://dequeuniversity.com/rules/axe/${ruleId}`, + tags: ['wcag2a'], + bestPractice, + nodes: Array.from({ length: nodes }, (_, i) => ({ + id: `${ruleId}-${i}`, + target: [`#${ruleId}-${i}`], + html: `
`, + failureSummary: `Fix the ${ruleId} element`, + })), + } +} + +function report(route: string, violations: Violation[]): ScanReport { + return { route, url: `https://example.test${route}`, scannedAt: 1, engine: '4.10.0', violations, counts: emptyCounts() } +} + +const groups: RouteGroupModel[] = [ + { + report: report('/', [violation('image-alt', 'critical', 2), violation('color-contrast', 'serious', 3)]), + active: true, + violations: [violation('image-alt', 'critical', 2), violation('color-contrast', 'serious', 3)], + }, + { + report: report('/about', [violation('region', 'moderate', 1, true)]), + active: false, + violations: [violation('region', 'moderate', 1, true)], + }, +] + +/** Two tracked routes, the active one expanded. */ +export const Grouped: Story = { + args: { + groups, + collapsedRoutes: new Set(['/about']), + onToggleGroup: noop, + onClearRoute: noop, + expandedRules: new Set(['/::image-alt']), + onToggleRule: noop, + channel, + pins, + }, +} diff --git a/plugins/a11y/src/spa/components/violations.tsx b/plugins/a11y/src/spa/components/violations.tsx index 42348077..5cd34284 100644 --- a/plugins/a11y/src/spa/components/violations.tsx +++ b/plugins/a11y/src/spa/components/violations.tsx @@ -1,27 +1,44 @@ -import type { Violation } from '../../shared/protocol.ts' +import type { ScanReport, Violation, ViolationNode } from '../../shared/protocol.ts' import type { A11yChannel } from '../lib/channel.ts' import { createMemo, For, Show } from 'solid-js' import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts' import { Chevron } from './icons.tsx' +/** Controller the list uses to read/mutate the shared pin set. */ +export interface PinsApi { + isPinned: (nodeId: string) => boolean + /** 1-based badge number for a pinned node, or `null`. */ + numberOf: (nodeId: string) => number | null + isRulePinned: (v: Violation) => boolean + toggleNode: (v: Violation, node: ViolationNode) => void + toggleRule: (v: Violation) => void +} + +/** Stable DOM id for a rule card, used by deep-link scroll-into-view. */ +export function ruleCardId(route: string, ruleId: string): string { + return `rule-${encodeURIComponent(route)}-${ruleId}` +} + interface RowProps { + route: string violation: Violation - index: number expanded: boolean onToggle: () => void channel: A11yChannel + pins: PinsApi } function ViolationRow(props: RowProps) { const v = () => props.violation - const panelId = createMemo(() => `nodes-${props.index}`) + const panelId = createMemo(() => `nodes-${ruleCardId(props.route, v().ruleId)}`) const first = () => v().nodes[0] return (
  • props.channel.clearHighlight()} + onMouseLeave={() => props.channel.clearPreview()} > + props.pins.toggleRule(v())} + onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), props.pins.toggleRule(v()))} + > + + +
      @@ -53,13 +85,19 @@ function ViolationRow(props: RowProps) {
    • + +
  • + +
      + + {violation => ( + props.onToggleRule(violation.ruleId)} + channel={props.channel} + pins={props.pins} + /> + )} + +
    +
    + + ) +} + +export interface RouteGroupModel { + report: ScanReport + active: boolean violations: Violation[] - expanded: Set - onToggle: (ruleId: string) => void +} + +interface ListProps { + groups: RouteGroupModel[] + collapsedRoutes: Set + onToggleGroup: (route: string) => void + onClearRoute: (route: string) => void + expandedRules: Set + onToggleRule: (route: string, ruleId: string) => void channel: A11yChannel + pins: PinsApi } export function ViolationList(props: ListProps) { return ( -
      - - {(violation, i) => ( - props.onToggle(violation.ruleId)} +
      + + {group => ( + props.onToggleGroup(group.report.route)} + onClearRoute={() => props.onClearRoute(group.report.route)} + expandedRules={props.expandedRules} + onToggleRule={ruleId => props.onToggleRule(group.report.route, ruleId)} channel={props.channel} + pins={props.pins} /> )} -
    +
    ) } diff --git a/plugins/a11y/src/spa/lib/channel.ts b/plugins/a11y/src/spa/lib/channel.ts index c19fd2db..5c050ecf 100644 --- a/plugins/a11y/src/spa/lib/channel.ts +++ b/plugins/a11y/src/spa/lib/channel.ts @@ -1,31 +1,45 @@ import type { Accessor } from 'solid-js' -import type { A11yMessage, ScanReport, ViolationNode } from '../../shared/protocol.ts' +import type { A11yMessage, A11yState, AgentConfig, PinTarget } from '../../shared/protocol.ts' import { createSignal, onCleanup } from 'solid-js' import { A11Y_CHANNEL } from '../../shared/protocol.ts' export interface A11yChannel { - /** Latest scan report, or `null` until the agent reports in. */ - report: Accessor + /** Latest full route → report aggregate, or `null` until the agent reports in. */ + state: Accessor /** Whether an agent has announced itself on this origin. */ agentReady: Accessor /** Whether the agent is mid-scan. */ scanning: Accessor - /** Ask the agent to draw the highlight ring around a node's element. */ - highlight: (node: ViolationNode) => void - /** Clear any active highlight. */ - clearHighlight: () => void + /** `location.pathname` currently in view in the host page. */ + activeRoute: Accessor + /** Draw the transient hover-preview ring around a node's element. */ + preview: (node: { id: string, target: string[] }) => void + /** Clear the transient hover-preview ring. */ + clearPreview: () => void + /** Replace the pinned (numbered) highlight set drawn in the host page. */ + setPins: (pins: PinTarget[]) => void /** Ask the agent to re-run the scan. */ rescan: () => void + /** Forward runtime configuration to the agent. */ + sendConfig: (config: AgentConfig) => void + /** Toggle the agent's interaction-driven auto-scan. */ + setAutoScan: (enabled: boolean) => void + /** Drop one route's tracked history. */ + clearRoute: (route: string) => void + /** Drop the whole tracked-route history. */ + clearAll: () => void } /** * Panel half of the agent↔panel BroadcastChannel. Returns reactive accessors - * that track the agent's state plus the actions the UI fires on hover/click. + * that track the agent's aggregate state plus the actions the UI fires on + * hover/click/rescan. */ export function createA11yChannel(): A11yChannel { - const [report, setReport] = createSignal(null) + const [state, setState] = createSignal(null) const [agentReady, setAgentReady] = createSignal(false) const [scanning, setScanning] = createSignal(false) + const [activeRoute, setActiveRoute] = createSignal(null) const channel = new BroadcastChannel(A11Y_CHANNEL) const post = (message: A11yMessage) => channel.postMessage(message) @@ -35,37 +49,46 @@ export function createA11yChannel(): A11yChannel { switch (message.type) { case 'a11y:agent-ready': setAgentReady(true) + setActiveRoute(message.route) // Closes the startup race: if our panel-ready landed before the agent - // was listening, asking again now pulls down the current report. - if (!report()) + // was listening, asking again now pulls down the current state. + if (!state()) post({ type: 'a11y:panel-ready' }) break - case 'a11y:report': + case 'a11y:state': setAgentReady(true) setScanning(false) - setReport(message.report) + setActiveRoute(message.state.activeRoute) + setState(message.state) break case 'a11y:scanning': setAgentReady(true) + setActiveRoute(message.route) setScanning(true) break } }) - // Announce the panel so a previously-loaded agent replays its last report. + // Announce the panel so a previously-loaded agent replays its current state. post({ type: 'a11y:panel-ready' }) onCleanup(() => channel.close()) return { - report, + state, agentReady, scanning, - highlight: node => post({ type: 'a11y:highlight', nodeId: node.id, target: node.target }), - clearHighlight: () => post({ type: 'a11y:clear' }), + activeRoute, + preview: node => post({ type: 'a11y:highlight', nodeId: node.id, target: node.target }), + clearPreview: () => post({ type: 'a11y:clear' }), + setPins: pins => post({ type: 'a11y:pins', pins }), rescan: () => { setScanning(true) post({ type: 'a11y:rescan' }) }, + sendConfig: config => post({ type: 'a11y:config', config }), + setAutoScan: enabled => post({ type: 'a11y:set-autoscan', enabled }), + clearRoute: route => post({ type: 'a11y:clear-route', route }), + clearAll: () => post({ type: 'a11y:clear-all' }), } } diff --git a/plugins/a11y/src/spa/lib/devframe.ts b/plugins/a11y/src/spa/lib/devframe.ts index e0022f89..bcb29368 100644 --- a/plugins/a11y/src/spa/lib/devframe.ts +++ b/plugins/a11y/src/spa/lib/devframe.ts @@ -1,8 +1,9 @@ import type { DevframeConnectionStatus } from 'devframe/client' import type { Accessor } from 'solid-js' -import type { Impact } from '../../shared/protocol.ts' +import type { AgentConfig, Impact } from '../../shared/protocol.ts' import { connectDevframe } from 'devframe/client' import { createSignal } from 'solid-js' +import { A11Y_DOCKS_ACTIVE_KEY } from '../../shared/protocol.ts' export interface ImpactMeta { id: Impact @@ -14,9 +15,21 @@ export interface A11yConfig { channel: string nodeAttr: string docsBase: string + /** The devframe id this dock is registered under. */ + dockId: string + /** Auto-pin all of a route's violations the first time it's scanned. */ + defaultHighlight: boolean + /** Runtime configuration forwarded to the in-page agent. */ + agent: AgentConfig impacts: ImpactMeta[] } +/** A dock-activation intent the hub mirrors into shared state. */ +export interface DockActivation { + dockId: string + params?: Record +} + export interface DevframeState { /** `'websocket'` in dev, `'static'` for a baked build, `null` while/if unreachable. */ backend: Accessor @@ -26,20 +39,25 @@ export interface DevframeState { * surfaced as a tag rather than blocking the UI. */ status: Accessor - /** Impact taxonomy + copy from the `get-config` RPC. */ + /** Impact taxonomy + runtime config from the `get-config` RPC. */ config: Accessor + /** Latest dock-activation intent targeting this dock (deep-link support). */ + activation: Accessor } /** - * Connect to the devframe backend for supplementary data (the impact legend). - * Intentionally non-blocking and failure-tolerant: the panel's core scan loop - * runs over BroadcastChannel, so the UI stays useful even if the backend is - * unreachable. + * Connect to the devframe backend for supplementary data: the impact legend, + * the runtime config the panel forwards to the agent, and the dock-activation + * shared state that powers deep-linking (e.g. a messages-feed entry navigating + * here). Intentionally non-blocking and failure-tolerant — the panel's core + * scan loop runs over BroadcastChannel, so the UI stays useful even if the + * backend is unreachable. */ export function connectDevframeState(): DevframeState { const [backend, setBackend] = createSignal(null) const [status, setStatus] = createSignal(null) const [config, setConfig] = createSignal(null) + const [activation, setActivation] = createSignal(null) connectDevframe() .then(async (rpc) => { @@ -58,11 +76,28 @@ export function connectDevframeState(): DevframeState { const cfg = await callConfig('devframes:plugin:a11y:get-config') if (cfg) setConfig(cfg) + + // The hub (when present) mirrors dock activations here; used for + // deep-linking from other docks (e.g. the messages feed). + try { + const shared = await rpc.sharedState.get(A11Y_DOCKS_ACTIVE_KEY, { + initialValue: { activation: null }, + }) as { value: () => { activation: DockActivation | null }, on: (e: string, cb: (v: any) => void) => void } + const apply = (v: { activation: DockActivation | null } | undefined) => { + if (v?.activation) + setActivation({ ...v.activation }) + } + apply(shared.value()) + shared.on('updated', apply) + } + catch { + // No hub / no shared state — deep-linking simply stays inert. + } }) .catch(() => { // No reachable backend (e.g. agent loaded outside a devframe host). setStatus('error') }) - return { backend, status, config } + return { backend, status, config, activation } } diff --git a/plugins/a11y/src/spa/styles.css b/plugins/a11y/src/spa/styles.css index 325940cf..b3a414ea 100644 --- a/plugins/a11y/src/spa/styles.css +++ b/plugins/a11y/src/spa/styles.css @@ -496,6 +496,392 @@ body { background: color-mix(in srgb, var(--df-foreground) 30%, transparent); } +/* ── dashboard ─────────────────────────────────────────────────────────── */ + +.dash { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 0 4px; +} + +.dash__totals { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 11px 13px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 9px; +} + +.stat__num { + font-size: 24px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.stat__label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--faint); +} + +.dash .summary { + padding: 0; +} + +.dash__controls { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.switch__track { + position: relative; + width: 32px; + height: 18px; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--line); + transition: background 0.15s, border-color 0.15s; +} + +.switch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--faint); + transition: transform 0.15s, background 0.15s; +} + +.switch input:checked + .switch__track { + background: color-mix(in srgb, var(--ring) 40%, var(--surface-2)); + border-color: var(--ring); +} + +.switch input:checked + .switch__track .switch__thumb { + transform: translateX(14px); + background: var(--ring); +} + +.switch input:focus-visible + .switch__track { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.switch__label { + font-size: 12px; + color: var(--muted); +} + +.dash__clear { + display: inline-flex; + align-items: center; + gap: 5px; + font: inherit; + font-size: 12px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + padding: 5px 10px; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; +} + +.dash__clear:hover:not(:disabled) { + color: #ff6b81; + border-color: color-mix(in srgb, #ff6b81 45%, var(--line)); +} + +.dash__clear:disabled { + opacity: 0.4; + cursor: default; +} + +.dash__routes-title { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + margin: 0 0 8px; +} + +.dash__empty { + font-size: 12.5px; + color: var(--faint); +} + +.routes { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.route { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + font: inherit; + padding: 9px 11px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} + +.route:hover { + background: var(--surface-2); + border-color: color-mix(in srgb, var(--ring) 35%, var(--line)); +} + +.route--active { + border-color: color-mix(in srgb, var(--ring) 55%, var(--line)); +} + +.route__path { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 60%; +} + +.route__active { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ring); + border: 1px solid color-mix(in srgb, var(--ring) 45%, var(--line)); + border-radius: 999px; + padding: 0 6px; +} + +.route__count { + font-size: 11.5px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.route__count--clean { + color: var(--ok); +} + +.route__go { + color: var(--faint); +} + +/* ── route groups (violations tab) ─────────────────────────────────────── */ + +.groups { + display: flex; + flex-direction: column; + gap: 14px; + padding-top: 12px; +} + +.group__bar { + display: flex; + align-items: center; + gap: 4px; + position: sticky; + top: 0; + background: var(--ink); + padding: 4px 0; + z-index: 1; +} + +.group__toggle { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + font: inherit; + text-align: left; + background: none; + border: 0; + color: inherit; + cursor: pointer; + padding: 4px 2px; +} + +.group__chevron { + color: var(--faint); + transition: transform 0.15s; +} + +.group__chevron--open { + transform: rotate(90deg); +} + +.group__route { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.group--active .group__route { + color: var(--ring); +} + +.group__active { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ring); + border: 1px solid color-mix(in srgb, var(--ring) 45%, var(--line)); + border-radius: 999px; + padding: 0 6px; +} + +.group__count { + margin-left: auto; + font-size: 11px; + color: var(--faint); + font-variant-numeric: tabular-nums; +} + +.group__clear { + flex: none; + display: inline-flex; + padding: 4px; + border: 0; + background: none; + color: var(--faint); + border-radius: 6px; + cursor: pointer; + transition: color 0.12s, background 0.12s; +} + +.group__clear:hover { + color: #ff6b81; + background: var(--surface); +} + +.group .list { + margin-top: 8px; +} + +.filtered-note { + font-size: 11.5px; + color: var(--faint); + padding: 12px 2px 0; +} + +/* ── rule best-practice tag + pin control ──────────────────────────────── */ + +.rule__bp { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 6px; +} + +.rule__pin { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + color: var(--faint); + cursor: pointer; + transition: color 0.12s, background 0.12s; +} + +.rule__pin:hover { + color: var(--text); + background: var(--surface-2); +} + +.rule__pin--on { + color: var(--ring); + background: color-mix(in srgb, var(--ring) 14%, transparent); +} + +.rule__pin:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.node__row { + display: flex; + align-items: flex-start; + gap: 7px; +} + +.node__badge { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 999px; + background: var(--ring); + color: var(--ink); + font-size: 10.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.node__btn--pinned { + border-color: color-mix(in srgb, var(--ring) 55%, var(--line)); + background: color-mix(in srgb, var(--ring) 8%, var(--ink)); +} + @media (prefers-reduced-motion: reduce) { * { transition: none !important; diff --git a/plugins/a11y/tests/dev-server.test.ts b/plugins/a11y/tests/dev-server.test.ts index 7b456e18..6a7d0eda 100644 --- a/plugins/a11y/tests/dev-server.test.ts +++ b/plugins/a11y/tests/dev-server.test.ts @@ -45,10 +45,17 @@ describe('dev-server (CLI surface)', () => { const config = await rpc.$call('devframes:plugin:a11y:get-config') as { channel: string nodeAttr: string + dockId: string + defaultHighlight: boolean + agent: { autoScan: boolean, logIssues: boolean, activateDockId: string } impacts: { id: string }[] } expect(config.channel).toBe('devframes:plugin:a11y') expect(config.nodeAttr).toBe('data-df-a11y-node') expect(config.impacts.map(i => i.id)).toEqual(['critical', 'serious', 'moderate', 'minor']) + // Runtime config the panel forwards to the in-page agent. + expect(config.dockId).toBe('devframes_plugin_a11y') + expect(config.defaultHighlight).toBe(false) + expect(config.agent).toMatchObject({ autoScan: true, logIssues: true, activateDockId: 'devframes_plugin_a11y' }) }) }) diff --git a/plugins/a11y/tests/inject-messages.test.ts b/plugins/a11y/tests/inject-messages.test.ts index 93eabe33..d9afe496 100644 --- a/plugins/a11y/tests/inject-messages.test.ts +++ b/plugins/a11y/tests/inject-messages.test.ts @@ -38,9 +38,10 @@ function violation(ruleId: string, impact: Violation['impact'], nodes = 1): Viol } } -function report(violations: Violation[]): ScanReport { +function report(violations: Violation[], route = '/'): ScanReport { return { - url: 'https://example.test/', + route, + url: `https://example.test${route}`, scannedAt: 1, engine: 'axe-test', violations, @@ -104,6 +105,38 @@ describe('createMessagesReporter', () => { .toBeUndefined() }) + it('carries dock-navigation actions deep-linked to the rule + route, and the summary to the dashboard', () => { + const { client, added } = createStubMessages() + const reporter = createMessagesReporter(client, { dockId: () => 'custom_a11y' }) + + reporter.report(report([violation('image-alt', 'critical')], '/about')) + + const summary = added.find(m => m.id === 'devframes:plugin:a11y:scan') + expect(summary?.actions).toEqual([{ + id: 'dashboard', + label: 'Open a11y dashboard', + kind: 'activate', + activate: { dockId: 'custom_a11y', params: { tab: 'dashboard' } }, + }]) + + const rule = added.find(m => m.id === 'devframes:plugin:a11y:rule:image-alt') + expect(rule?.actions).toEqual([{ + id: 'view', + label: 'View in a11y inspector', + kind: 'activate', + activate: { dockId: 'custom_a11y', params: { tab: 'violations', ruleId: 'image-alt', route: '/about' } }, + }]) + }) + + it('falls back to the default dock id when none is configured', () => { + const { client, added } = createStubMessages() + const reporter = createMessagesReporter(client) + + reporter.report(report([violation('image-alt', 'critical')])) + const rule = added.find(m => m.id === 'devframes:plugin:a11y:rule:image-alt') + expect(rule?.actions?.[0]?.activate.dockId).toBe('devframes_plugin_a11y') + }) + it('removes entries for rules that no longer violate on re-scan', () => { const { client, added, removed } = createStubMessages() const reporter = createMessagesReporter(client) diff --git a/plugins/a11y/tests/protocol.test.ts b/plugins/a11y/tests/protocol.test.ts new file mode 100644 index 00000000..70681352 --- /dev/null +++ b/plugins/a11y/tests/protocol.test.ts @@ -0,0 +1,62 @@ +import type { ScanReport } from '../src/shared/protocol.ts' +import { describe, expect, it } from 'vitest' +import { createGetConfig } from '../src/rpc/functions/get-config.ts' +import { DEFAULT_AXE_TAGS, emptyCounts, sumCounts } from '../src/shared/protocol.ts' + +function report(counts: Partial>, route = '/'): ScanReport { + return { + route, + url: `https://example.test${route}`, + scannedAt: 1, + engine: 'axe-test', + violations: [], + counts: { ...emptyCounts(), ...counts }, + } +} + +describe('sumCounts', () => { + it('rolls per-impact counts across many route reports', () => { + const total = sumCounts([ + report({ critical: 2, minor: 1 }, '/'), + report({ critical: 1, serious: 3 }, '/about'), + ]) + expect(total).toEqual({ critical: 3, serious: 3, moderate: 0, minor: 1 }) + }) + + it('returns an empty counter for no reports', () => { + expect(sumCounts([])).toEqual(emptyCounts()) + }) +}) + +describe('createGetConfig', () => { + it('defaults auto-scan + logging on, best-practice tags in, default-highlight off', () => { + const cfg = createGetConfig().handler!() as any + expect(cfg.dockId).toBe('devframes_plugin_a11y') + expect(cfg.defaultHighlight).toBe(false) + expect(cfg.agent.autoScan).toBe(true) + expect(cfg.agent.logIssues).toBe(true) + expect(cfg.agent.activateDockId).toBe('devframes_plugin_a11y') + // Broadened default tag set includes WCAG 2.2 + best-practice. + expect([...DEFAULT_AXE_TAGS]).toContain('best-practice') + expect([...DEFAULT_AXE_TAGS]).toContain('wcag22aa') + }) + + it('threads author options through to the agent config and dock id', () => { + const cfg = createGetConfig({ + dockId: 'my_a11y', + autoScan: false, + logIssues: false, + defaultHighlight: true, + axe: { tags: ['wcag2a'], runOptions: { iframes: true } }, + }).handler!() as any + expect(cfg.dockId).toBe('my_a11y') + expect(cfg.defaultHighlight).toBe(true) + expect(cfg.agent).toMatchObject({ + autoScan: false, + logIssues: false, + axeTags: ['wcag2a'], + axeRunOptions: { iframes: true }, + activateDockId: 'my_a11y', + }) + }) +}) diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/index.snapshot.d.ts index 263dc237..cd4d25c9 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/index.snapshot.d.ts @@ -8,6 +8,13 @@ export interface A11yDevframeOptions { icon?: string; basePath?: string; port?: number; + autoScan?: boolean; + logIssues?: boolean; + defaultHighlight?: boolean; + axe?: { + tags?: string[]; + runOptions?: Record; + }; } // #endregion diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.d.ts index 88f95275..647201c6 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.d.ts @@ -2,7 +2,7 @@ * Generated by tsnapi — public API snapshot of `@devframes/plugin-a11y/node` */ // #region Functions -export declare function setupA11y(_: DevframeNodeContext): void; +export declare function setupA11y(_: DevframeNodeContext, _?: A11yRuntimeConfig): void; // #endregion // #region Other diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.js index be2d5f87..4166a405 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-a11y/node.snapshot.js @@ -2,7 +2,7 @@ * Generated by tsnapi — public API snapshot of `@devframes/plugin-a11y/node` */ // #region Functions -export function setupA11y(_) {} +export function setupA11y(_, _) {} // #endregion // #region Other From 56513faf859c4c00fffcb2a2857ae633bfb853be Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 24 Jul 2026 05:49:39 +0000 Subject: [PATCH 03/11] =?UTF-8?q?test(examples):=20a11y=20=C3=97=20message?= =?UTF-8?q?s=20hub=20playground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/a11y-messages-playground/README.md | 84 +++++++++++ examples/a11y-messages-playground/index.html | 67 +++++++++ .../a11y-messages-playground/package.json | 27 ++++ .../src/a11y-messages-playground.ts | 123 +++++++++++++++++ .../src/client/app-under-test.ts | 130 ++++++++++++++++++ .../src/client/env.d.ts | 3 + .../src/client/icons.ts | 13 ++ .../src/client/main.ts | 128 +++++++++++++++++ .../a11y-messages-playground/tsconfig.json | 12 ++ .../a11y-messages-playground/uno.config.ts | 13 ++ .../a11y-messages-playground/vite.config.ts | 24 ++++ pnpm-lock.yaml | 38 ++++- 12 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 examples/a11y-messages-playground/README.md create mode 100644 examples/a11y-messages-playground/index.html create mode 100644 examples/a11y-messages-playground/package.json create mode 100644 examples/a11y-messages-playground/src/a11y-messages-playground.ts create mode 100644 examples/a11y-messages-playground/src/client/app-under-test.ts create mode 100644 examples/a11y-messages-playground/src/client/env.d.ts create mode 100644 examples/a11y-messages-playground/src/client/icons.ts create mode 100644 examples/a11y-messages-playground/src/client/main.ts create mode 100644 examples/a11y-messages-playground/tsconfig.json create mode 100644 examples/a11y-messages-playground/uno.config.ts create mode 100644 examples/a11y-messages-playground/vite.config.ts diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md new file mode 100644 index 00000000..1822e126 --- /dev/null +++ b/examples/a11y-messages-playground/README.md @@ -0,0 +1,84 @@ +# 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. **Pin + highlight** — hover a violation to ring the element in the page; click + a rule to pin all its elements with numbered badges. +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) | diff --git a/examples/a11y-messages-playground/index.html b/examples/a11y-messages-playground/index.html new file mode 100644 index 00000000..5911f1fb --- /dev/null +++ b/examples/a11y-messages-playground/index.html @@ -0,0 +1,67 @@ + + + + + + A11y × Messages Playground + + + + +
    +
    +

    + + A11y × Messages Playground +

    +

    Connecting…

    +
    + +
    + +
    +

    App under test

    +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    + + + diff --git a/examples/a11y-messages-playground/package.json b/examples/a11y-messages-playground/package.json new file mode 100644 index 00000000..2fdba2d9 --- /dev/null +++ b/examples/a11y-messages-playground/package.json @@ -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" + } +} diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts new file mode 100644 index 00000000..842e4ee3 --- /dev/null +++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts @@ -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 +} + +/** + * 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 `__connection.json`. + */ +export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin { + const base = normalizeBase(options.base ?? '/__hub/') + let viteConfig: ResolvedConfig | undefined + let started: { close: () => Promise } | 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 +} diff --git a/examples/a11y-messages-playground/src/client/app-under-test.ts b/examples/a11y-messages-playground/src/client/app-under-test.ts new file mode 100644 index 00000000..86f2395b --- /dev/null +++ b/examples/a11y-messages-playground/src/client/app-under-test.ts @@ -0,0 +1,130 @@ +// The "app under test": an intentionally-inaccessible, client-side-routed mini +// app the a11y agent scans. Each route seeds a different family of axe +// violations, and navigating between them (History API pushState — which the +// a11y agent patches) exercises the inspector's per-route tracking. +// +// Keep the chrome (the route nav) accessible so the deliberate violations stay +// concentrated in each route's
    , where they're easy to reason about. + +interface Route { + path: string + label: string + /** What the route is designed to make axe flag — shown as a hint. */ + hint: string + render: () => string +} + +const ROUTES: Route[] = [ + { + path: '/', + label: 'Home', + hint: 'image-alt · button-name · link-name · heading-order', + render: () => ` +

    Welcome to the broken shop

    +

    Featured (heading level skips from 1 to 4)

    + +

    An icon-only button with no accessible name:

    + +

    A link with no discernible text:

    + + `, + }, + { + path: '/images', + label: 'Images', + hint: 'image-alt (several)', + render: () => ` +

    Gallery

    +
    + ${['#c0653a', '#3a8fc0', '#4aa06a', '#b04a9a'] + .map(c => ``) + .join('')} +
    +

    None of the images above carry an alt attribute.

    + `, + }, + { + path: '/forms', + label: 'Forms', + hint: 'label · select-name · placeholder-only', + render: () => ` +

    Checkout

    +
    + + + + I agree to the terms + +
    +

    The inputs and the select have no associated labels; the checkbox's + text is not programmatically linked.

    + `, + }, + { + path: '/contrast', + label: 'Contrast', + hint: 'color-contrast · region (best-practice)', + render: () => ` +

    Low-contrast heading

    +

    + This paragraph sets light grey text on a white background — well below + the WCAG AA contrast ratio for body text. +

    + + Yellow-on-white call to action + +
    +

    The grey square is a custom control with a role but no accessible name.

    + `, + }, +] + +/** A tiny inline SVG banner used as decorative (alt-less) imagery. */ +function banner(color: string): string { + return encodeURIComponent( + ``, + ) +} + +function currentRoute(): Route { + return ROUTES.find(r => r.path === location.pathname) ?? ROUTES[0]! +} + +/** + * Mount the app-under-test into `container`. Renders a route nav plus the + * current route's (deliberately broken) content, and navigates with + * `history.pushState` so the a11y agent re-scans and buckets per route. + */ +export function mountAppUnderTest(container: HTMLElement): void { + function render(): void { + const active = currentRoute() + container.innerHTML = ` + +

    Route ${active.path} · seeds: ${active.hint}

    +
    ${active.render()}
    + ` + } + + container.addEventListener('click', (event) => { + const btn = (event.target as HTMLElement).closest('button[data-route]') + if (!btn) + return + const path = btn.dataset.route! + if (path === location.pathname) + return + history.pushState({}, '', path) + render() + }) + + window.addEventListener('popstate', render) + render() +} diff --git a/examples/a11y-messages-playground/src/client/env.d.ts b/examples/a11y-messages-playground/src/client/env.d.ts new file mode 100644 index 00000000..01b43943 --- /dev/null +++ b/examples/a11y-messages-playground/src/client/env.d.ts @@ -0,0 +1,3 @@ +/// + +declare module 'virtual:uno.css' diff --git a/examples/a11y-messages-playground/src/client/icons.ts b/examples/a11y-messages-playground/src/client/icons.ts new file mode 100644 index 00000000..24971868 --- /dev/null +++ b/examples/a11y-messages-playground/src/client/icons.ts @@ -0,0 +1,13 @@ +// @unocss-include +// Map dock `icon` strings (ph:*) to statically-extractable UnoCSS classes, so +// the offline Phosphor set renders without a runtime icon library. UnoCSS only +// emits classes it can see in source, hence the literal map. +const ICONS: Record = { + 'ph:wheelchair-duotone': 'i-ph-wheelchair-duotone', + 'ph:notification-duotone': 'i-ph-notification-duotone', +} + +/** Class chain for a dock icon, or `''` when unmapped (caller falls back). */ +export function iconClass(name: string | undefined): string { + return (name && ICONS[name]) ?? '' +} diff --git a/examples/a11y-messages-playground/src/client/main.ts b/examples/a11y-messages-playground/src/client/main.ts new file mode 100644 index 00000000..d24164c0 --- /dev/null +++ b/examples/a11y-messages-playground/src/client/main.ts @@ -0,0 +1,128 @@ +import type { DevframeDockEntry } from '@devframes/hub/types' +import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' +import { mountAppUnderTest } from './app-under-test' +import { iconClass } from './icons' +import 'virtual:uno.css' +import '@antfu/design/styles.css' + +const HUB_BASE = '/__hub/' + +const connEl = document.querySelector('#conn')! +const docksEl = document.querySelector('#docks')! +const stageEl = document.querySelector('#dock-stage')! +const appEl = document.querySelector('#app-under-test')! + +function setStatus(text: string, kind?: 'ready' | 'error') { + const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400' + connEl.innerHTML = `${text}` +} + +function dockIcon(entry: DevframeDockEntry): string { + const cls = iconClass(typeof entry.icon === 'string' ? entry.icon : undefined) + if (cls) + return `` + const initial = (entry.title?.[0] ?? '?').toUpperCase() + return `${initial}` +} + +function isIframeDock(d: DevframeDockEntry): d is DevframeDockEntry & { type: 'iframe', url: string } { + return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' +} + +async function main() { + setStatus('Connecting…') + + // The app under test renders immediately — it's plain page content the a11y + // agent (imported below as the a11y dock's client script) will scan. + mountAppUnderTest(appEl) + + const rpc = await connectDevframe({ baseURL: HUB_BASE }) + setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready') + + // Boot the hub client runtime: it publishes the shared client context and + // imports each dock's client script into this page — here, the a11y agent, + // which then scans this document live and mirrors findings into the messages + // feed. The rail below reads the same `devframe:docks` shared state. + const host = await createDevframeClientHost({ rpc }) + const docksCtx = host.context.docks + + const docks = await rpc.sharedState.get('devframe:docks', { initialValue: [] }) + + // Keep-alive iframe pool: one iframe per dock, toggled by visibility so the + // a11y panel keeps its BroadcastChannel connection while you switch docks. + const iframePool = new Map() + + function ensureIframe(entry: DevframeDockEntry & { url: string }): HTMLIFrameElement { + let el = iframePool.get(entry.id) + if (!el) { + el = document.createElement('iframe') + el.title = entry.title + el.className = 'absolute inset-0 block h-full w-full border-0 bg-base' + el.hidden = true + el.src = entry.url + stageEl.appendChild(el) + iframePool.set(entry.id, el) + const state = docksCtx.getStateById(entry.id) + if (state) { + state.domElements.iframe = el + state.events.emit('dom:iframe:mounted', el) + } + } + return el + } + + function showSelection(list: DevframeDockEntry[]): void { + const entry = docksCtx.selectedId ? list.find(d => d.id === docksCtx.selectedId) ?? null : null + const active = entry && isIframeDock(entry) ? ensureIframe(entry) : null + for (const el of iframePool.values()) el.hidden = el !== active + } + + const wired = new Set() + function render(): void { + const list = docksCtx.entries.filter(isIframeDock) + + if (!docksCtx.selectedId && list.length > 0) + void docksCtx.switchEntry(list[0]!.id) + + for (const entry of list) { + if (wired.has(entry.id)) + continue + const state = docksCtx.getStateById(entry.id) + if (!state) + continue + wired.add(entry.id) + state.events.on('entry:activated', render) + } + + if (!list.length) { + docksEl.innerHTML = '
  • Waiting for docks…
  • ' + showSelection(list) + return + } + + docksEl.innerHTML = list.map(d => + `
  • `, + ).join('') + + showSelection(list) + } + + docksEl.addEventListener('click', (event) => { + const target = (event.target as HTMLElement).closest('button[data-dock-id]') + if (!target) + return + const id = target.dataset.dockId + if (!id || id === docksCtx.selectedId) + return + void docksCtx.switchEntry(id) + }) + + docks.on('updated', render) + render() +} + +main().catch((err) => { + setStatus(`Failed: ${(err as Error).message}`, 'error') + console.error(err) +}) diff --git a/examples/a11y-messages-playground/tsconfig.json b/examples/a11y-messages-playground/tsconfig.json new file mode 100644 index 00000000..46dbffd9 --- /dev/null +++ b/examples/a11y-messages-playground/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/a11y-messages-playground/uno.config.ts b/examples/a11y-messages-playground/uno.config.ts new file mode 100644 index 00000000..668ea1be --- /dev/null +++ b/examples/a11y-messages-playground/uno.config.ts @@ -0,0 +1,13 @@ +import { mergeConfigs } from 'unocss' +import { designConfig } from '../../design/uno.config' + +// Compose the repo-shared design preset. `content.pipeline.include` opts the +// vanilla `.ts`/`.html` sources into extraction, since the dock rail and the +// app-under-test class strings live in plain TS/HTML rather than a framework +// file UnoCSS scans by default. +export default mergeConfigs([ + designConfig, + { + content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } }, + }, +]) diff --git a/examples/a11y-messages-playground/vite.config.ts b/examples/a11y-messages-playground/vite.config.ts new file mode 100644 index 00000000..50791a28 --- /dev/null +++ b/examples/a11y-messages-playground/vite.config.ts @@ -0,0 +1,24 @@ +import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' +import messagesDevframe from '@devframes/plugin-messages' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../alias' +import { a11yMessagesPlayground } from './src/a11y-messages-playground' + +export default defineConfig({ + resolve: { alias }, + server: { allowedHosts: true, strictPort: false }, + optimizeDeps: { exclude: ['@antfu/design'] }, + plugins: [ + UnoCSS(), + a11yMessagesPlayground({ + devframes: [a11yDevframe, messagesDevframe], + // Attach the a11y agent as the a11y dock's client script — served over + // Vite's `/@fs/` so it shares this page's origin (the BroadcastChannel the + // agent and panel talk over rides that origin). + clientScripts: { + [a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` }, + }, + }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4c6745..0b5227e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -429,6 +429,40 @@ importers: specifier: catalog:docs version: 2.0.17(mermaid@11.16.0)(vitepress@2.0.0-alpha.18(@types/node@26.1.1)(change-case@5.4.4)(esbuild@0.28.0)(fuse.js@7.5.0)(jiti@2.7.0)(postcss@8.5.16)(terser@5.47.1)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0)) + examples/a11y-messages-playground: + dependencies: + '@antfu/design': + specifier: catalog:frontend + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + '@devframes/hub': + specifier: workspace:* + version: link:../../packages/hub + '@devframes/plugin-a11y': + specifier: workspace:* + version: link:../../plugins/a11y + '@devframes/plugin-messages': + specifier: workspace:* + version: link:../../plugins/messages + devframe: + specifier: workspace:* + version: link:../../packages/devframe + devDependencies: + '@iconify-json/ph': + specifier: catalog:frontend + version: 1.2.2 + get-port-please: + specifier: catalog:deps + version: 3.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 + unocss: + specifier: catalog:frontend + version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vite: + specifier: catalog:build + version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + examples/files-inspector: dependencies: '@antfu/design': @@ -13004,7 +13038,7 @@ snapshots: pathe: 2.0.3 perfect-debounce: 2.1.0 tinyglobby: 0.2.17 - unplugin-utils: 0.3.1 + unplugin-utils: 0.3.2 '@unocss/config@66.7.5': dependencies: @@ -13133,7 +13167,7 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 tinyglobby: 0.2.17 - unplugin-utils: 0.3.1 + unplugin-utils: 0.3.2 vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) '@upsetjs/venn.js@2.0.0': From 4a0846a08d5c5d75086a45f34c7bd248560ff4e1 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 24 Jul 2026 07:02:14 +0000 Subject: [PATCH 04/11] refactor(a11y): single-page panel, compact rows, icon + category fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse the Dashboard/Violations tabs into one scrolling page: a sticky summary band (severity chips + counts + auto-scan/best-practice/clear controls) over the grouped, per-route violation list. - Tighten the violation rows, chips, and group headers for a denser list. - Fix icon rendering: pair every icon with a companion utility class (and mark it aria-hidden), matching how @antfu/design renders icons — a bare single-class `i-ph-*` span isn't extracted by UnoCSS, so Rescan/pin/clear icons were blank. - Show the messages-feed entries under a short `a11y` category instead of the `devframes_plugin_a11y` dock id. Co-authored-with an agent. --- plugins/a11y/src/inject/messages.ts | 12 +- plugins/a11y/src/spa/app.tsx | 129 ++++---- .../src/spa/components/dashboard.stories.tsx | 53 ---- plugins/a11y/src/spa/components/dashboard.tsx | 117 -------- plugins/a11y/src/spa/components/header.tsx | 34 +-- .../spa/components/summary-bar.stories.tsx | 36 +++ plugins/a11y/src/spa/components/summary.tsx | 78 +++++ .../a11y/src/spa/components/violations.tsx | 4 +- plugins/a11y/src/spa/styles.css | 275 +++++------------- plugins/a11y/tests/inject-messages.test.ts | 2 + 10 files changed, 260 insertions(+), 480 deletions(-) delete mode 100644 plugins/a11y/src/spa/components/dashboard.stories.tsx delete mode 100644 plugins/a11y/src/spa/components/dashboard.tsx create mode 100644 plugins/a11y/src/spa/components/summary-bar.stories.tsx create mode 100644 plugins/a11y/src/spa/components/summary.tsx diff --git a/plugins/a11y/src/inject/messages.ts b/plugins/a11y/src/inject/messages.ts index c5bf2fb9..4aa97730 100644 --- a/plugins/a11y/src/inject/messages.ts +++ b/plugins/a11y/src/inject/messages.ts @@ -31,6 +31,12 @@ export interface HubMessageInput { message: string description?: string level: 'info' | 'warn' | 'error' | 'success' | 'debug' + /** + * Grouping category shown in the messages panel. Set explicitly to a short + * `'a11y'` label — the hub client host otherwise defaults it to the dock id + * (e.g. `devframes_plugin_a11y`), and input fields win over that default. + */ + category?: string labels?: string[] elementPosition?: { selector?: string @@ -55,6 +61,8 @@ export interface A11yAgentContext { messages?: HubMessagesClient } +/** Short, human-facing grouping label shown in the messages panel. */ +const MESSAGE_CATEGORY = 'a11y' /** One deduplicated summary entry tracks the scan lifecycle. */ const SCAN_MESSAGE_ID = 'devframes:plugin:a11y:scan' /** One deduplicated entry per violated rule; removed when the rule clears. */ @@ -104,7 +112,9 @@ export function createMessagesReporter( ): MessagesReporter { let reportedRules = new Set() // Fire-and-forget: the feed is a mirror, never a gate for the scan loop. - const send = (input: HubMessageInput) => void messages.add(input).catch(() => {}) + // Every entry is grouped under the short `a11y` category. + const send = (input: HubMessageInput) => + void messages.add({ category: MESSAGE_CATEGORY, ...input }).catch(() => {}) const drop = (id: string) => void messages.remove(id).catch(() => {}) const dockId = () => options.dockId?.() ?? A11Y_DEFAULT_DOCK_ID diff --git a/plugins/a11y/src/spa/app.tsx b/plugins/a11y/src/spa/app.tsx index c1bbb293..bedfd53c 100644 --- a/plugins/a11y/src/spa/app.tsx +++ b/plugins/a11y/src/spa/app.tsx @@ -1,11 +1,10 @@ import type { Impact, PinTarget, ScanReport, Violation, ViolationNode } from '../shared/protocol.ts' -import type { TabId } from './components/header.tsx' import type { PinsApi, RouteGroupModel } from './components/violations.tsx' import { batch, createEffect, createMemo, createSignal, Match, on, Show, Switch } from 'solid-js' import { emptyCounts } from '../shared/protocol.ts' -import { Dashboard } from './components/dashboard.tsx' import { Header, MetaLine } from './components/header.tsx' import { CheckCircle, PlugIcon } from './components/icons.tsx' +import { SummaryBar } from './components/summary.tsx' import { ruleCardId, ViolationList } from './components/violations.tsx' import { createA11yChannel } from './lib/channel.ts' import { connectDevframeState } from './lib/devframe.ts' @@ -24,7 +23,6 @@ export function App() { const channel = createA11yChannel() const devframe = connectDevframeState() - const [activeTab, setActiveTab] = createSignal('dashboard') const [filter, setFilter] = createSignal(null) const [showBestPractice, setShowBestPractice] = createSignal(true) const [expandedRoutes, setExpandedRoutes] = createSignal>(new Set()) @@ -68,16 +66,7 @@ export function App() { const totalRules = createMemo(() => routes().reduce((n, r) => n + r.violations.filter(includeViolation).length, 0)) - const routeSummaries = createMemo(() => - routes().map(r => ({ - route: r.route, - active: r.route === activeRoute(), - ruleCount: r.violations.filter(includeViolation).length, - nodeCount: r.violations.filter(includeViolation).reduce((m, v) => m + v.nodes.length, 0), - }))) - - // Grouped, filtered violations for the Violations tab — active route first, - // empty groups dropped. + // Grouped, filtered violations — active route first, empty groups dropped. const groups = createMemo(() => { const models = routes().map(r => ({ report: r, @@ -183,14 +172,15 @@ export function App() { if (cfg && act.dockId !== cfg.dockId) return const params = act.params ?? {} - const tab = params.tab === 'dashboard' ? 'dashboard' : 'violations' - setActiveTab(tab) - if (tab === 'dashboard') - return const route = typeof params.route === 'string' ? params.route : undefined const ruleId = typeof params.ruleId === 'string' ? params.ruleId : undefined - if (!route) + + // A dashboard/summary activation (or one with no target) just scrolls to top. + if (params.tab === 'dashboard' || !route) { + document.querySelector('.scroll')?.scrollTo({ top: 0, behavior: 'smooth' }) return + } + batch(() => { setExpandedRoutes(prev => new Set(prev).add(route)) if (ruleId) @@ -205,7 +195,6 @@ export function App() { return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))] }) } - // Scroll the card into view once it has rendered. setTimeout(() => document.getElementById(ruleCardId(route, ruleId))?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 60) } }, { defer: true })) @@ -234,11 +223,6 @@ export function App() { return next }) } - function selectRoute(route: string) { - setActiveTab('violations') - setExpandedRoutes(prev => new Set(prev).add(route)) - setTimeout(() => document.getElementById(`group-${encodeURIComponent(route)}`)?.scrollIntoView({ block: 'start', behavior: 'smooth' }), 60) - } const announce = () => { if (!channel.state()) @@ -251,8 +235,6 @@ export function App() {
    - {/* Dashboard. */} - - - - - {/* Violations — clean. */} + {/* Report in, nothing to flag. */}
    @@ -319,43 +283,48 @@ export function App() {
    - {/* Violations — filtered to empty. */} - -
    - -

    Nothing matches the filter

    -

    - {totalFilterable()} - {' '} - {totalFilterable() === 1 ? 'rule' : 'rules'} - {' '} - at other severities. Clear the filter to see them. -

    -
    -
    + {/* Single page: summary band + grouped violations. */} + 0}> + - {/* Violations — grouped list. */} - 0}> - -

    - Showing - {' '} - {shownViolations()} - {' of '} - {totalFilterable()} - {' rules'} -

    + 0} + fallback={( +
    + +

    Nothing matches the filter

    +

    + {totalFilterable()} + {' '} + {totalFilterable() === 1 ? 'rule' : 'rules'} + {' at other severities. Clear the filter to see them.'} +

    +
    + )} + > +
    -
    diff --git a/plugins/a11y/src/spa/components/dashboard.stories.tsx b/plugins/a11y/src/spa/components/dashboard.stories.tsx deleted file mode 100644 index 6f8bd6d3..00000000 --- a/plugins/a11y/src/spa/components/dashboard.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Meta, StoryObj } from 'storybook-solidjs-vite' -import { Dashboard } from './dashboard.tsx' -import '../styles.css' - -// The dashboard landing view: totals, the severity summary/filter, scan + -// best-practice controls, and the per-route inventory. Presentational — every -// input is a prop, so it stories offline without a live scan. -const meta = { - title: 'A11y/Dashboard', - component: Dashboard, - parameters: { layout: 'padded' }, -} satisfies Meta - -export default meta -type Story = StoryObj - -function noop() {} - -const base = { - counts: { critical: 3, serious: 5, moderate: 2, minor: 8 }, - filter: null, - onToggleFilter: noop, - totalNodes: 18, - totalRules: 7, - routes: [ - { route: '/', active: true, ruleCount: 4, nodeCount: 11 }, - { route: '/about', active: false, ruleCount: 3, nodeCount: 7 }, - { route: '/contact', active: false, ruleCount: 0, nodeCount: 0 }, - ], - onSelectRoute: noop, - autoScan: true, - onToggleAutoScan: noop, - showBestPractice: true, - onToggleBestPractice: noop, - onClearAll: noop, -} - -/** A spread of violations across several tracked routes. */ -export const Overview: Story = { args: base } - -/** One severity selected as the active filter. */ -export const Filtered: Story = { args: { ...base, filter: 'serious' } } - -/** Nothing scanned yet — the empty route inventory. */ -export const Empty: Story = { - args: { - ...base, - counts: { critical: 0, serious: 0, moderate: 0, minor: 0 }, - totalNodes: 0, - totalRules: 0, - routes: [], - }, -} diff --git a/plugins/a11y/src/spa/components/dashboard.tsx b/plugins/a11y/src/spa/components/dashboard.tsx deleted file mode 100644 index d3d384bf..00000000 --- a/plugins/a11y/src/spa/components/dashboard.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { Impact } from '../../shared/protocol.ts' -import { For, Show } from 'solid-js' -import { Summary } from './header.tsx' - -export interface RouteSummary { - route: string - active: boolean - ruleCount: number - nodeCount: number -} - -interface DashboardProps { - counts: Record - filter: Impact | null - onToggleFilter: (impact: Impact) => void - totalNodes: number - totalRules: number - routes: RouteSummary[] - onSelectRoute: (route: string) => void - autoScan: boolean - onToggleAutoScan: (enabled: boolean) => void - showBestPractice: boolean - onToggleBestPractice: (show: boolean) => void - onClearAll: () => void -} - -export function Dashboard(props: DashboardProps) { - return ( -
    -
    -
    - {props.totalNodes} - Affected elements -
    -
    - {props.totalRules} - Rules violated -
    -
    - {props.routes.length} - Routes tracked -
    -
    - - - -
    - - - - - - - -
    - -
    -

    Routes

    - 0} - fallback={

    No routes scanned yet.

    } - > -
      - - {r => ( -
    • - -
    • - )} -
      -
    -
    -
    -
    - ) -} diff --git a/plugins/a11y/src/spa/components/header.tsx b/plugins/a11y/src/spa/components/header.tsx index 519e2fbd..d1f8fb45 100644 --- a/plugins/a11y/src/spa/components/header.tsx +++ b/plugins/a11y/src/spa/components/header.tsx @@ -1,15 +1,11 @@ import type { Impact } from '../../shared/protocol.ts' import { For, Show } from 'solid-js' -import { button, nav, navBrand, tab, tabsList } from '../design' +import { button, nav, navBrand } from '../design' import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts' -export type TabId = 'dashboard' | 'violations' - interface HeaderProps { agentReady: boolean scanning: boolean - activeTab: TabId - onTab: (tab: TabId) => void onRescan: () => void } @@ -23,36 +19,12 @@ export function Header(props: HeaderProps) { ? 'status__dot status__dot--scanning' : 'status__dot status__dot--live' - const tabs: { id: TabId, label: string, icon: string }[] = [ - { id: 'dashboard', label: 'Dashboard', icon: 'i-ph-gauge-duotone' }, - { id: 'violations', label: 'Violations', icon: 'i-ph-list-checks-duotone' }, - ] - return (
    - + A11y Inspector - -
    - - {t => ( - - )} - -
    - @@ -64,7 +36,7 @@ export function Header(props: HeaderProps) { onClick={() => props.onRescan()} disabled={!props.agentReady || props.scanning} > - + Rescan
    diff --git a/plugins/a11y/src/spa/components/summary-bar.stories.tsx b/plugins/a11y/src/spa/components/summary-bar.stories.tsx new file mode 100644 index 00000000..60a1313c --- /dev/null +++ b/plugins/a11y/src/spa/components/summary-bar.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from 'storybook-solidjs-vite' +import { SummaryBar } from './summary.tsx' +import '../styles.css' + +// The single-page summary band: severity chips (impact filter), a one-line +// count, and the scan / best-practice / clear controls. Presentational. +const meta = { + title: 'A11y/SummaryBar', + component: SummaryBar, + parameters: { layout: 'padded' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function noop() {} + +const base = { + counts: { critical: 3, serious: 5, moderate: 2, minor: 8 }, + filter: null, + onToggleFilter: noop, + totalNodes: 18, + totalRules: 7, + routeCount: 3, + autoScan: true, + onToggleAutoScan: noop, + showBestPractice: true, + onToggleBestPractice: noop, + onClearAll: noop, +} + +/** A spread of violations across several tracked routes. */ +export const Overview: Story = { args: base } + +/** One severity selected as the active filter. */ +export const Filtered: Story = { args: { ...base, filter: 'serious' } } diff --git a/plugins/a11y/src/spa/components/summary.tsx b/plugins/a11y/src/spa/components/summary.tsx new file mode 100644 index 00000000..889f9fd4 --- /dev/null +++ b/plugins/a11y/src/spa/components/summary.tsx @@ -0,0 +1,78 @@ +import type { Impact } from '../../shared/protocol.ts' +import { Summary } from './header.tsx' + +interface SummaryBarProps { + counts: Record + filter: Impact | null + onToggleFilter: (impact: Impact) => void + totalNodes: number + totalRules: number + routeCount: number + autoScan: boolean + onToggleAutoScan: (enabled: boolean) => void + showBestPractice: boolean + onToggleBestPractice: (show: boolean) => void + onClearAll: () => void +} + +/** + * The compact summary band that heads the single-page panel: the severity + * chips (doubling as the impact filter), a one-line count, and the scan / + * best-practice / clear controls. + */ +export function SummaryBar(props: SummaryBarProps) { + return ( +
    + + +
    + + {props.totalNodes} + {' '} + {props.totalNodes === 1 ? 'issue' : 'issues'} + {' · '} + {props.totalRules} + {' '} + {props.totalRules === 1 ? 'rule' : 'rules'} + {' · '} + {props.routeCount} + {' '} + {props.routeCount === 1 ? 'route' : 'routes'} + + + + + + + + + +
    +
    + ) +} diff --git a/plugins/a11y/src/spa/components/violations.tsx b/plugins/a11y/src/spa/components/violations.tsx index 5cd34284..23db408f 100644 --- a/plugins/a11y/src/spa/components/violations.tsx +++ b/plugins/a11y/src/spa/components/violations.tsx @@ -75,7 +75,7 @@ function ViolationRow(props: RowProps) { onClick={() => props.pins.toggleRule(v())} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), props.pins.toggleRule(v()))} > - + @@ -160,7 +160,7 @@ function RouteGroup(props: GroupProps) { title="Clear this route's history" onClick={() => props.onClearRoute()} > - + diff --git a/plugins/a11y/src/spa/styles.css b/plugins/a11y/src/spa/styles.css index b3a414ea..fc74c062 100644 --- a/plugins/a11y/src/spa/styles.css +++ b/plugins/a11y/src/spa/styles.css @@ -159,21 +159,19 @@ body { .summary { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 8px; - padding: 14px 16px; + gap: 6px; } .chip { --impact: var(--faint); display: flex; - flex-direction: column; - align-items: flex-start; - gap: 3px; - padding: 9px 11px; + align-items: baseline; + gap: 6px; + padding: 6px 9px; background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--impact); - border-radius: 8px; + border-radius: 7px; cursor: pointer; text-align: left; font: inherit; @@ -195,7 +193,7 @@ body { } .chip__count { - font-size: 22px; + font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; @@ -207,9 +205,9 @@ body { } .chip__label { - font-size: 10.5px; + font-size: 9.5px; font-weight: 600; - letter-spacing: 0.08em; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--impact); } @@ -233,7 +231,7 @@ body { padding: 0; display: flex; flex-direction: column; - gap: 8px; + gap: 4px; } .rule { @@ -241,7 +239,7 @@ body { position: relative; background: var(--surface); border: 1px solid var(--line); - border-radius: 9px; + border-radius: 7px; overflow: hidden; transition: box-shadow 0.12s, border-color 0.12s; } @@ -270,7 +268,9 @@ body { } .rule__toggle { - display: block; + display: flex; + flex-direction: column; + gap: 2px; width: 100%; text-align: left; background: none; @@ -278,7 +278,7 @@ body { font: inherit; color: inherit; cursor: pointer; - padding: 12px 14px 12px 18px; + padding: 7px 34px 7px 14px; } .rule__toggle:focus-visible { @@ -288,21 +288,20 @@ body { .rule__head { display: flex; align-items: center; - gap: 9px; - margin-bottom: 5px; + gap: 7px; } .rule__impact { - font-size: 10px; + font-size: 9px; font-weight: 700; - letter-spacing: 0.09em; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--impact); } .rule__id { font-family: var(--font-mono); - font-size: 12.5px; + font-size: 12px; color: var(--text); } @@ -310,9 +309,9 @@ body { margin-left: auto; display: inline-flex; align-items: center; - gap: 6px; - font-size: 11.5px; - color: var(--muted); + gap: 5px; + font-size: 11px; + color: var(--faint); font-variant-numeric: tabular-nums; } @@ -326,24 +325,27 @@ body { } .rule__help { - font-size: 12.5px; + font-size: 12px; color: var(--muted); - line-height: 1.45; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .nodes { list-style: none; margin: 0; - padding: 0 10px 10px 18px; + padding: 0 10px 8px 14px; display: flex; flex-direction: column; - gap: 6px; + gap: 4px; } .rule__docslink { align-self: flex-start; - margin: 2px 0 2px 18px; - font-size: 11.5px; + margin: 0 0 8px 14px; + font-size: 11px; color: var(--faint); text-decoration: none; } @@ -363,11 +365,11 @@ body { text-align: left; background: var(--ink); border: 1px solid var(--line-soft); - border-radius: 7px; + border-radius: 6px; font: inherit; color: inherit; cursor: pointer; - padding: 9px 11px; + padding: 6px 9px; transition: border-color 0.12s, background 0.12s; } @@ -384,17 +386,17 @@ body { .node__html { display: block; font-family: var(--font-mono); - font-size: 11.5px; + font-size: 11px; color: var(--text); white-space: pre-wrap; word-break: break-word; - margin-bottom: 6px; } .node__target { display: inline-block; + margin-top: 5px; font-family: var(--font-mono); - font-size: 11px; + font-size: 10.5px; color: var(--ring); background: color-mix(in srgb, var(--ring) 12%, transparent); border-radius: 4px; @@ -403,9 +405,9 @@ body { .node__summary { display: block; - margin-top: 7px; - font-size: 11.5px; - line-height: 1.5; + margin-top: 5px; + font-size: 11px; + line-height: 1.45; color: var(--muted); white-space: pre-wrap; } @@ -496,56 +498,55 @@ body { background: color-mix(in srgb, var(--df-foreground) 30%, transparent); } -/* ── dashboard ─────────────────────────────────────────────────────────── */ +/* ── summary bar (single-page header band) ─────────────────────────────── */ -.dash { +.summary-bar { display: flex; flex-direction: column; - gap: 14px; - padding: 14px 0 4px; -} - -.dash__totals { - display: grid; - grid-template-columns: repeat(3, 1fr); gap: 8px; + padding: 12px 0 10px; + position: sticky; + top: 0; + z-index: 2; + background: var(--ink); } -.stat { +.summary-bar__toolbar { display: flex; - flex-direction: column; - gap: 3px; - padding: 11px 13px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: 9px; + align-items: center; + gap: 12px; + flex-wrap: wrap; } -.stat__num { - font-size: 24px; - font-weight: 700; - line-height: 1; +.summary-bar__stat { + font-size: 11.5px; + color: var(--muted); font-variant-numeric: tabular-nums; - color: var(--text); } -.stat__label { - font-size: 10.5px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--faint); +.summary-bar__clear { + display: inline-flex; + align-items: center; + gap: 5px; + font: inherit; + font-size: 12px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + padding: 4px 9px; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; } -.dash .summary { - padding: 0; +.summary-bar__clear:hover:not(:disabled) { + color: #ff6b81; + border-color: color-mix(in srgb, #ff6b81 45%, var(--line)); } -.dash__controls { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; +.summary-bar__clear:disabled { + opacity: 0.4; + cursor: default; } .switch { @@ -603,131 +604,19 @@ body { color: var(--muted); } -.dash__clear { - display: inline-flex; - align-items: center; - gap: 5px; - font: inherit; - font-size: 12px; - color: var(--muted); - background: var(--surface); - border: 1px solid var(--line); - border-radius: 7px; - padding: 5px 10px; - cursor: pointer; - transition: color 0.12s, border-color 0.12s; -} - -.dash__clear:hover:not(:disabled) { - color: #ff6b81; - border-color: color-mix(in srgb, #ff6b81 45%, var(--line)); -} - -.dash__clear:disabled { - opacity: 0.4; - cursor: default; -} - -.dash__routes-title { - font-size: 10.5px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--faint); - margin: 0 0 8px; -} - -.dash__empty { - font-size: 12.5px; - color: var(--faint); -} - -.routes { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -.route { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - text-align: left; - font: inherit; - padding: 9px 11px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: 8px; - cursor: pointer; - transition: border-color 0.12s, background 0.12s; -} - -.route:hover { - background: var(--surface-2); - border-color: color-mix(in srgb, var(--ring) 35%, var(--line)); -} - -.route--active { - border-color: color-mix(in srgb, var(--ring) 55%, var(--line)); -} - -.route__path { - font-family: var(--font-mono); - font-size: 12px; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 60%; -} - -.route__active { - font-size: 9.5px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--ring); - border: 1px solid color-mix(in srgb, var(--ring) 45%, var(--line)); - border-radius: 999px; - padding: 0 6px; -} - -.route__count { - font-size: 11.5px; - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.route__count--clean { - color: var(--ok); -} - -.route__go { - color: var(--faint); -} - -/* ── route groups (violations tab) ─────────────────────────────────────── */ +/* ── route groups ──────────────────────────────────────────────────────── */ .groups { display: flex; flex-direction: column; - gap: 14px; - padding-top: 12px; + gap: 10px; } .group__bar { display: flex; align-items: center; gap: 4px; - position: sticky; - top: 0; - background: var(--ink); - padding: 4px 0; - z-index: 1; + padding: 2px 0; } .group__toggle { @@ -803,13 +692,7 @@ body { } .group .list { - margin-top: 8px; -} - -.filtered-note { - font-size: 11.5px; - color: var(--faint); - padding: 12px 2px 0; + margin-top: 4px; } /* ── rule best-practice tag + pin control ──────────────────────────────── */ @@ -827,13 +710,13 @@ body { .rule__pin { position: absolute; - top: 10px; - right: 10px; + top: 5px; + right: 6px; display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 24px; + height: 24px; border-radius: 6px; color: var(--faint); cursor: pointer; diff --git a/plugins/a11y/tests/inject-messages.test.ts b/plugins/a11y/tests/inject-messages.test.ts index d9afe496..eaa5d585 100644 --- a/plugins/a11y/tests/inject-messages.test.ts +++ b/plugins/a11y/tests/inject-messages.test.ts @@ -66,6 +66,7 @@ describe('createMessagesReporter', () => { id: 'devframes:plugin:a11y:scan', message: 'No accessibility issues found', level: 'success', + category: 'a11y', status: 'idle', }) }) @@ -89,6 +90,7 @@ describe('createMessagesReporter', () => { expect(imageAlt).toMatchObject({ message: 'Fix image-alt (2)', level: 'error', + category: 'a11y', labels: ['critical', 'wcag2a'], elementPosition: { selector: '#image-alt-0', From 143c6eceb5f9f8807b3160080e780559818ece00 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 24 Jul 2026 07:21:29 +0000 Subject: [PATCH 05/11] feat(a11y): select violations to highlight + generate AI fix prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- examples/a11y-messages-playground/README.md | 6 +- plugins/a11y/README.md | 11 +- plugins/a11y/src/spa/app.tsx | 128 ++++++----- .../spa/components/fix-prompts.stories.tsx | 58 +++++ .../a11y/src/spa/components/fix-prompts.tsx | 88 ++++++++ plugins/a11y/src/spa/components/header.tsx | 15 ++ .../src/spa/components/violations.stories.tsx | 14 +- .../a11y/src/spa/components/violations.tsx | 108 ++++----- plugins/a11y/src/spa/lib/fix-prompt.ts | 55 +++++ plugins/a11y/src/spa/styles.css | 210 +++++++++++++++--- plugins/a11y/tests/fix-prompt.test.ts | 59 +++++ 11 files changed, 601 insertions(+), 151 deletions(-) create mode 100644 plugins/a11y/src/spa/components/fix-prompts.stories.tsx create mode 100644 plugins/a11y/src/spa/components/fix-prompts.tsx create mode 100644 plugins/a11y/src/spa/lib/fix-prompt.ts create mode 100644 plugins/a11y/tests/fix-prompt.test.ts diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md index 1822e126..83983259 100644 --- a/examples/a11y-messages-playground/README.md +++ b/examples/a11y-messages-playground/README.md @@ -41,8 +41,10 @@ The window is split in two: 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. **Pin + highlight** — hover a violation to ring the element in the page; click - a rule to pin all its elements with numbered badges. +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 diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md index 55f4f6f4..a2d0c473 100644 --- a/plugins/a11y/README.md +++ b/plugins/a11y/README.md @@ -14,10 +14,13 @@ surfaces the violations in a [Solid](https://www.solidjs.com/) panel: - **Dashboard + grouped violations** — a Dashboard tab (totals, severity breakdown, per-route inventory, scan controls) and a Violations tab listing every tracked route, grouped, with the active route marked. -- **Pin + numbered highlights** — hover previews the offending element; clicking a - rule pins all its elements (clicking a single element toggles just that one) - with globally-numbered badges drawn in the page. ``/`` targets get a - corner notice instead of a viewport-filling ring. +- **Select + highlight** — hover previews the offending element; ticking a + violation's checkbox selects it, giving the card a distinct state and drawing + globally-numbered badges over all its elements in the page. ``/`` + targets get a corner notice instead of a viewport-filling ring. +- **Generate fix prompts** — a nav button gathers the selected violations (rule + metadata, WCAG tags, docs, and each element's selector/markup/failure summary, + grouped by route) into one paste-ready AI prompt in a dialog. - **Constant scanning** — a DOM `MutationObserver` plus debounced mouse/keyboard/touch rescans, toggleable from the Dashboard. - **WCAG 2.0–2.2 + best-practice** — the broadened axe tag set, with best-practice diff --git a/plugins/a11y/src/spa/app.tsx b/plugins/a11y/src/spa/app.tsx index bedfd53c..dee7d515 100644 --- a/plugins/a11y/src/spa/app.tsx +++ b/plugins/a11y/src/spa/app.tsx @@ -1,7 +1,9 @@ -import type { Impact, PinTarget, ScanReport, Violation, ViolationNode } from '../shared/protocol.ts' -import type { PinsApi, RouteGroupModel } from './components/violations.tsx' +import type { Impact, PinTarget, Violation, ViolationNode } from '../shared/protocol.ts' +import type { SelectedItem } from './components/fix-prompts.tsx' +import type { RouteGroupModel, SelectionApi } from './components/violations.tsx' import { batch, createEffect, createMemo, createSignal, Match, on, Show, Switch } from 'solid-js' import { emptyCounts } from '../shared/protocol.ts' +import { FixPromptsDialog } from './components/fix-prompts.tsx' import { Header, MetaLine } from './components/header.tsx' import { CheckCircle, PlugIcon } from './components/icons.tsx' import { SummaryBar } from './components/summary.tsx' @@ -12,12 +14,10 @@ import { connectDevframeState } from './lib/devframe.ts' const SNIPPET = '' const AUTOSCAN_KEY = 'devframes:plugin:a11y:autoscan' +const selKey = (route: string, ruleId: string) => `${route}::${ruleId}` function nodePin(v: Violation, node: ViolationNode): PinTarget { return { nodeId: node.id, target: node.target, impact: v.impact, ruleId: v.ruleId } } -function allPins(report: ScanReport): PinTarget[] { - return report.violations.flatMap(v => v.nodes.map(n => nodePin(v, n))) -} export function App() { const channel = createA11yChannel() @@ -27,7 +27,10 @@ export function App() { const [showBestPractice, setShowBestPractice] = createSignal(true) const [expandedRoutes, setExpandedRoutes] = createSignal>(new Set()) const [expandedRules, setExpandedRules] = createSignal>(new Set()) - const [pins, setPins] = createSignal([]) + // Selected violations (`route::ruleId`) — drives both the in-page highlight + // and the "Generate fix prompts" dialog. + const [selected, setSelected] = createSignal>(new Set()) + const [dialogOpen, setDialogOpen] = createSignal(false) const storedAuto = (() => { try { @@ -90,6 +93,53 @@ export function App() { return collapsed }) + // ── selection → highlight + fix-prompt context ──────────────────────────── + // Highlighted nodes, in a stable order, for numbered badges. + const selectedPins = createMemo(() => { + const sel = selected() + const out: PinTarget[] = [] + for (const report of routes()) { + for (const v of report.violations) { + if (sel.has(selKey(report.route, v.ruleId))) { + for (const node of v.nodes) + out.push(nodePin(v, node)) + } + } + } + return out + }) + + const selectedItems = createMemo(() => { + const sel = selected() + const out: SelectedItem[] = [] + for (const report of routes()) { + for (const v of report.violations) { + if (sel.has(selKey(report.route, v.ruleId))) + out.push({ route: report.route, url: report.url, violation: v }) + } + } + return out + }) + + const selectionApi: SelectionApi = { + isSelected: (route, ruleId) => selected().has(selKey(route, ruleId)), + toggle: (route, ruleId) => { + const key = selKey(route, ruleId) + setSelected((prev) => { + const next = new Set(prev) + if (next.has(key)) + next.delete(key) + else + next.add(key) + return next + }) + }, + numberOf: (nodeId) => { + const i = selectedPins().findIndex(p => p.nodeId === nodeId) + return i === -1 ? null : i + 1 + }, + } + // ── config → agent, and initial auto-scan default ───────────────────────── let autoInit = false createEffect(() => { @@ -120,13 +170,10 @@ export function App() { setExpandedRoutes(prev => (prev.has(r) ? prev : new Set(prev).add(r))) }) - // ── pins ────────────────────────────────────────────────────────────────── - createEffect(() => channel.setPins(pins())) - - // Pins reference live DOM, so clear them when the host route changes. - createEffect(on(activeRoute, () => setPins([]), { defer: true })) + // Push the highlight set (derived from the selection) to the in-page agent. + createEffect(() => channel.setPins(selectedPins())) - // defaultHighlight: pin a route's violations the first time it's scanned. + // defaultHighlight: select all of a route's violations the first time it's scanned. const highlighted = new Set() createEffect(() => { const cfg = devframe.config() @@ -134,36 +181,14 @@ export function App() { if (!cfg?.defaultHighlight || !report || highlighted.has(report.route)) return highlighted.add(report.route) - setPins(allPins(report)) + setSelected((prev) => { + const next = new Set(prev) + for (const v of report.violations) + next.add(selKey(report.route, v.ruleId)) + return next + }) }) - const pinsApi: PinsApi = { - isPinned: nodeId => pins().some(p => p.nodeId === nodeId), - numberOf: (nodeId) => { - const i = pins().findIndex(p => p.nodeId === nodeId) - return i === -1 ? null : i + 1 - }, - isRulePinned: v => v.nodes.length > 0 && v.nodes.every(n => pins().some(p => p.nodeId === n.id)), - toggleNode: (v, node) => { - setPins(prev => (prev.some(p => p.nodeId === node.id) - ? prev.filter(p => p.nodeId !== node.id) - : [...prev, nodePin(v, node)])) - }, - toggleRule: (v) => { - const allPinned = v.nodes.length > 0 && v.nodes.every(n => pins().some(p => p.nodeId === n.id)) - if (allPinned) { - const ids = new Set(v.nodes.map(n => n.id)) - setPins(prev => prev.filter(p => !ids.has(p.nodeId))) - } - else { - setPins((prev) => { - const have = new Set(prev.map(p => p.nodeId)) - return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))] - }) - } - }, - } - // ── deep-linking from other docks (e.g. the messages feed) ──────────────── createEffect(on(devframe.activation, (act) => { if (!act) @@ -183,20 +208,13 @@ export function App() { batch(() => { setExpandedRoutes(prev => new Set(prev).add(route)) - if (ruleId) + if (ruleId) { setExpandedRules(prev => new Set(prev).add(`${route}::${ruleId}`)) - }) - if (ruleId) { - const report = routes().find(r => r.route === route) - const v = report?.violations.find(x => x.ruleId === ruleId) - if (v) { - setPins((prev) => { - const have = new Set(prev.map(p => p.nodeId)) - return [...prev, ...v.nodes.filter(n => !have.has(n.id)).map(n => nodePin(v, n))] - }) + setSelected(prev => new Set(prev).add(selKey(route, ruleId))) } + }) + if (ruleId) setTimeout(() => document.getElementById(ruleCardId(route, ruleId))?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 60) - } }, { defer: true })) function toggleFilter(impact: Impact) { @@ -235,6 +253,8 @@ export function App() {
    setDialogOpen(true)} onRescan={channel.rescan} /> + + 0}> + setDialogOpen(false)} /> + ) } diff --git a/plugins/a11y/src/spa/components/fix-prompts.stories.tsx b/plugins/a11y/src/spa/components/fix-prompts.stories.tsx new file mode 100644 index 00000000..dbec7e96 --- /dev/null +++ b/plugins/a11y/src/spa/components/fix-prompts.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from 'storybook-solidjs-vite' +import type { SelectedItem } from './fix-prompts.tsx' +import { FixPromptsDialog } from './fix-prompts.tsx' +import '../styles.css' + +// The fix-prompts dialog: gathers the selected violations' context into one +// paste-ready AI prompt, with a copy button. +const meta = { + title: 'A11y/FixPromptsDialog', + component: FixPromptsDialog, + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const items: SelectedItem[] = [ + { + route: '/', + url: 'https://example.test/', + violation: { + ruleId: 'image-alt', + impact: 'critical', + help: 'Images must have alternative text', + description: 'Ensures elements have alternate text or a role of none or presentation', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/image-alt', + tags: ['wcag2a', 'wcag111'], + nodes: [{ + id: 'a1', + target: ['img.hero'], + html: '', + failureSummary: 'Fix any of the following:\n Element does not have an alt attribute', + }], + }, + }, + { + route: '/forms', + url: 'https://example.test/forms', + violation: { + ruleId: 'label', + impact: 'serious', + help: 'Form elements must have labels', + description: 'Ensures every form element has a label', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/label', + tags: ['wcag2a', 'wcag412'], + nodes: [{ + id: 'a2', + target: ['#email'], + html: '', + failureSummary: 'Fix any of the following:\n Form element does not have an implicit label', + }], + }, + }, +] + +export const TwoViolations: Story = { + args: { items, onClose: () => {} }, +} diff --git a/plugins/a11y/src/spa/components/fix-prompts.tsx b/plugins/a11y/src/spa/components/fix-prompts.tsx new file mode 100644 index 00000000..dfc3387b --- /dev/null +++ b/plugins/a11y/src/spa/components/fix-prompts.tsx @@ -0,0 +1,88 @@ +import type { SelectedItem } from '../lib/fix-prompt.ts' +import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js' +import { buildFixPrompt } from '../lib/fix-prompt.ts' + +export type { SelectedItem } from '../lib/fix-prompt.ts' + +interface DialogProps { + items: SelectedItem[] + onClose: () => void +} + +/** + * A modal listing the AI fix prompt for the selected violations, with a copy + * button. Built to the same accessibility bar the tool enforces: labelled + * dialog, Escape/backdrop to close, focus moved in on open. + */ +export function FixPromptsDialog(props: DialogProps) { + const prompt = createMemo(() => buildFixPrompt(props.items)) + const ruleCount = () => props.items.length + const [copied, setCopied] = createSignal(false) + let closeBtn: HTMLButtonElement | undefined + + function copy() { + void navigator.clipboard?.writeText(prompt()).then(() => { + setCopied(true) + setTimeout(setCopied, 1500, false) + }) + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') + props.onClose() + } + + onMount(() => { + addEventListener('keydown', onKeyDown) + closeBtn?.focus() + }) + onCleanup(() => removeEventListener('keydown', onKeyDown)) + + return ( + + ) +} diff --git a/plugins/a11y/src/spa/components/header.tsx b/plugins/a11y/src/spa/components/header.tsx index d1f8fb45..26df9239 100644 --- a/plugins/a11y/src/spa/components/header.tsx +++ b/plugins/a11y/src/spa/components/header.tsx @@ -6,6 +6,8 @@ import { IMPACT_COLOR, IMPACT_LABEL } from '../lib/impact.ts' interface HeaderProps { agentReady: boolean scanning: boolean + selectedCount: number + onGenerate: () => void onRescan: () => void } @@ -30,6 +32,19 @@ export function Header(props: HeaderProps) { {statusLabel()} + - - props.pins.toggleRule(v())} - onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), props.pins.toggleRule(v()))} - > - - + {v().help} + +
      @@ -85,15 +84,14 @@ function ViolationRow(props: RowProps) {
    • - + -