Skip to content

Commit a8da191

Browse files
committed
refactor(a11y): one component per file, UnoCSS over CSS, stories for all
- Split the multi-component files (header/violations/icons/summary/fix-prompts) into one component per file: Header, MetaLine, Summary, SummaryBar, Switch, EmptyState, ViolationRow, RouteGroup, ViolationList, FixPromptsDialog. Shared view types + helpers move to lib/violation-view.ts. - Replace the ~920-line bespoke styles.css with UnoCSS utilities built on @antfu/design semantic tokens (bg-base/color-muted/border-base/…). The one domain color (WCAG severity) rides an inline --impact CSS var consumed by arbitrary-value utilities. styles.css now only carries the root reset, scrollbar, and reduced-motion guard. - Add a Storybook story for every component. Co-authored-with an agent.
1 parent fe9c597 commit a8da191

31 files changed

Lines changed: 877 additions & 1589 deletions

plugins/a11y/src/spa/app.tsx

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import type { Impact, PinTarget, Violation, ViolationNode } from '../shared/protocol.ts'
2-
import type { SelectedItem } from './components/fix-prompts.tsx'
3-
import type { RouteGroupModel, SelectionApi } from './components/violations.tsx'
2+
import type { SelectedItem } from './lib/fix-prompt.ts'
3+
import type { RouteGroupModel, SelectionApi } from './lib/violation-view.ts'
44
import { batch, createEffect, createMemo, createSignal, Match, on, Show, Switch } from 'solid-js'
55
import { emptyCounts } from '../shared/protocol.ts'
6-
import { FixPromptsDialog } from './components/fix-prompts.tsx'
7-
import { Header, MetaLine } from './components/header.tsx'
8-
import { CheckCircle, PlugIcon } from './components/icons.tsx'
9-
import { SummaryBar } from './components/summary.tsx'
10-
import { ruleCardId, ViolationList } from './components/violations.tsx'
6+
import { EmptyState } from './components/EmptyState.tsx'
7+
import { FixPromptsDialog } from './components/FixPromptsDialog.tsx'
8+
import { Header } from './components/Header.tsx'
9+
import { MetaLine } from './components/MetaLine.tsx'
10+
import { SummaryBar } from './components/SummaryBar.tsx'
11+
import { ViolationList } from './components/ViolationList.tsx'
1112
import { createA11yChannel } from './lib/channel.ts'
1213
import { connectDevframeState } from './lib/devframe.ts'
14+
import { ruleCardId } from './lib/violation-view.ts'
1315

1416
const SNIPPET = '<script type="module" src="…/inject.js"></script>'
1517
const AUTOSCAN_KEY = 'devframes:plugin:a11y:autoscan'
@@ -223,7 +225,7 @@ export function App() {
223225

224226
// A dashboard/summary activation (or one with no target) just scrolls to top.
225227
if (params.tab === 'dashboard' || !route) {
226-
document.querySelector('.scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
228+
document.querySelector('#a11y-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
227229
return
228230
}
229231

@@ -270,7 +272,7 @@ export function App() {
270272
}
271273

272274
return (
273-
<div class="app">
275+
<div class="flex flex-col h-full min-h-0 bg-base color-base font-sans">
274276
<Header
275277
agentReady={channel.agentReady()}
276278
scanning={channel.scanning()}
@@ -280,48 +282,42 @@ export function App() {
280282
/>
281283
<MetaLine
282284
url={activeReport()?.url}
283-
route={activeReport()?.route}
284285
engine={engine()}
285286
backend={devframe.backend}
286287
status={devframe.status}
287288
/>
288289

289-
<p class="visually-hidden" aria-live="polite">{announce()}</p>
290+
<p class="sr-only" aria-live="polite">{announce()}</p>
290291

291-
<div class="scroll">
292+
<div id="a11y-scroll" class="a11y-scroll flex-1 min-h-0 overflow-y-auto px-4 pb-5">
292293
<Switch>
293294
{/* No agent has announced itself on this origin yet. */}
294295
<Match when={!channel.state() && !channel.agentReady()}>
295-
<div class="state">
296-
<PlugIcon class="state__glyph" />
297-
<p class="state__title">No page connected</p>
298-
<p class="state__body">
299-
Load the inspector agent in the app you want to check, then this
300-
panel will list its accessibility issues live.
301-
</p>
302-
<code class="state__code">{SNIPPET}</code>
303-
</div>
296+
<EmptyState
297+
icon="i-ph-plugs-duotone text-4xl"
298+
title="No page connected"
299+
body="Load the inspector agent in the app you want to check, then this panel will list its accessibility issues live."
300+
code={SNIPPET}
301+
/>
304302
</Match>
305303

306304
{/* Agent present, first state not in yet. */}
307305
<Match when={!channel.state()}>
308-
<div class="state">
309-
<PlugIcon class="state__glyph" />
310-
<p class="state__title">Scanning the page…</p>
311-
<p class="state__body">Running axe-core against the connected document.</p>
312-
</div>
306+
<EmptyState
307+
icon="i-ph-plugs-duotone text-4xl"
308+
title="Scanning the page…"
309+
body="Running axe-core against the connected document."
310+
/>
313311
</Match>
314312

315313
{/* Report in, nothing to flag. */}
316314
<Match when={totalFilterable() === 0}>
317-
<div class="state state--clean">
318-
<CheckCircle class="state__glyph" />
319-
<p class="state__title">No violations</p>
320-
<p class="state__body">
321-
axe-core found nothing to flag across the tracked routes. Re-run
322-
after changes to keep it that way.
323-
</p>
324-
</div>
315+
<EmptyState
316+
clean
317+
icon="i-ph-check-circle-duotone text-4xl"
318+
title="No violations"
319+
body="axe-core found nothing to flag across the tracked routes. Re-run after changes to keep it that way."
320+
/>
325321
</Match>
326322

327323
{/* Single page: summary band + grouped violations. */}
@@ -344,16 +340,19 @@ export function App() {
344340
<Show
345341
when={shownViolations() > 0}
346342
fallback={(
347-
<div class="state">
348-
<CheckCircle class="state__glyph" />
349-
<p class="state__title">Nothing matches the filter</p>
350-
<p class="state__body">
351-
{totalFilterable()}
352-
{' '}
353-
{totalFilterable() === 1 ? 'rule' : 'rules'}
354-
{' at other severities. Clear the filter to see them.'}
355-
</p>
356-
</div>
343+
<EmptyState
344+
clean
345+
icon="i-ph-check-circle-duotone text-4xl"
346+
title="Nothing matches the filter"
347+
body={(
348+
<>
349+
{totalFilterable()}
350+
{' '}
351+
{totalFilterable() === 1 ? 'rule' : 'rules'}
352+
{' at other severities. Clear the filter to see them.'}
353+
</>
354+
)}
355+
/>
357356
)}
358357
>
359358
<ViolationList
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Meta, StoryObj } from 'storybook-solidjs-vite'
2+
import { EmptyState } from './EmptyState.tsx'
3+
4+
// The centered full-height message shown when there's nothing to list.
5+
const meta = {
6+
title: 'A11y/EmptyState',
7+
component: EmptyState,
8+
parameters: { layout: 'fullscreen' },
9+
} satisfies Meta<typeof EmptyState>
10+
11+
export default meta
12+
type Story = StoryObj<typeof meta>
13+
14+
export const NoPage: Story = {
15+
args: {
16+
icon: 'i-ph-plugs-duotone text-4xl',
17+
title: 'No page connected',
18+
body: 'Load the inspector agent in the app you want to check, then this panel will list its accessibility issues live.',
19+
code: '<script type="module" src="…/inject.js"></script>',
20+
},
21+
}
22+
23+
export const Scanning: Story = {
24+
args: {
25+
icon: 'i-ph-plugs-duotone text-4xl',
26+
title: 'Scanning the page…',
27+
body: 'Running axe-core against the connected document.',
28+
},
29+
}
30+
31+
export const Clean: Story = {
32+
args: {
33+
clean: true,
34+
icon: 'i-ph-check-circle-duotone text-4xl',
35+
title: 'No violations',
36+
body: 'axe-core found nothing to flag across the tracked routes.',
37+
},
38+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { JSX } from 'solid-js'
2+
import { Show } from 'solid-js'
3+
4+
interface EmptyStateProps {
5+
/** Icon utility class (with its own size), e.g. `i-ph-plugs-duotone text-4xl`. */
6+
icon: string
7+
title: string
8+
body: JSX.Element
9+
/** Tints the glyph success-green for the "all clear" case. */
10+
clean?: boolean
11+
/** Optional code snippet shown below the body. */
12+
code?: string
13+
}
14+
15+
/**
16+
* The centered full-height message shown when there's nothing to list: no page
17+
* connected, scanning, all-clear, or filtered-to-empty.
18+
*/
19+
export function EmptyState(props: EmptyStateProps) {
20+
return (
21+
<div class="flex flex-col items-center justify-center gap-3 h-full p-8 text-center color-muted">
22+
<span aria-hidden class={`${props.icon} ${props.clean ? 'text-success' : 'color-faint'}`} />
23+
<p class="text-[15px] font-semibold color-base">{props.title}</p>
24+
<p class="text-xs leading-relaxed max-w-[38ch]">{props.body}</p>
25+
<Show when={props.code}>
26+
<code class="font-mono text-[11.5px] color-muted bg-secondary border border-base rounded-lg px-3 py-2.5 whitespace-pre-wrap break-all text-left max-w-full">
27+
{props.code}
28+
</code>
29+
</Show>
30+
</div>
31+
)
32+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Meta, StoryObj } from 'storybook-solidjs-vite'
2+
import type { SelectedItem } from './FixPromptsDialog.tsx'
3+
import { makeViolation } from './_fixtures.ts'
4+
import { FixPromptsDialog } from './FixPromptsDialog.tsx'
5+
6+
// The fix-prompts dialog: gathers the selected violations' context into one
7+
// paste-ready AI prompt, with a copy button.
8+
const meta = {
9+
title: 'A11y/FixPromptsDialog',
10+
component: FixPromptsDialog,
11+
parameters: { layout: 'fullscreen' },
12+
} satisfies Meta<typeof FixPromptsDialog>
13+
14+
export default meta
15+
type Story = StoryObj<typeof meta>
16+
17+
const items: SelectedItem[] = [
18+
{ route: '/', url: 'https://example.test/', violation: makeViolation('image-alt', 'critical', 1) },
19+
{ route: '/forms', url: 'https://example.test/forms', violation: makeViolation('label', 'serious', 1) },
20+
]
21+
22+
export const TwoViolations: Story = { args: { items, onClose: () => {} } }

plugins/a11y/src/spa/components/fix-prompts.tsx renamed to plugins/a11y/src/spa/components/FixPromptsDialog.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { SelectedItem } from '../lib/fix-prompt.ts'
22
import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
3+
import { button } from '../design'
34
import { buildFixPrompt } from '../lib/fix-prompt.ts'
45

56
export type { SelectedItem } from '../lib/fix-prompt.ts'
67

7-
interface DialogProps {
8+
interface FixPromptsDialogProps {
89
items: SelectedItem[]
910
onClose: () => void
1011
}
@@ -14,7 +15,7 @@ interface DialogProps {
1415
* button. Built to the same accessibility bar the tool enforces: labelled
1516
* dialog, Escape/backdrop to close, focus moved in on open.
1617
*/
17-
export function FixPromptsDialog(props: DialogProps) {
18+
export function FixPromptsDialog(props: FixPromptsDialogProps) {
1819
const prompt = createMemo(() => buildFixPrompt(props.items))
1920
const ruleCount = () => props.items.length
2021
const [copied, setCopied] = createSignal(false)
@@ -39,45 +40,45 @@ export function FixPromptsDialog(props: DialogProps) {
3940
onCleanup(() => removeEventListener('keydown', onKeyDown))
4041

4142
return (
42-
<div class="modal" onClick={() => props.onClose()}>
43+
<div class="fixed inset-0 z-modal-content flex items-center justify-center p-6 bg-black/55 backdrop-blur-sm" onClick={() => props.onClose()}>
4344
<div
44-
class="modal__card"
45+
class="flex flex-col gap-3 w-[min(680px,100%)] max-h-full p-4 bg-secondary border border-base rounded-xl shadow-2xl"
4546
role="dialog"
4647
aria-modal="true"
4748
aria-labelledby="fix-prompts-title"
4849
onClick={e => e.stopPropagation()}
4950
>
50-
<div class="modal__head">
51+
<div class="flex items-start gap-2.5">
5152
<div>
52-
<h2 id="fix-prompts-title" class="modal__title">Fix prompts</h2>
53-
<p class="modal__sub">
53+
<h2 id="fix-prompts-title" class="m-0 text-[15px] font-semibold color-base">Fix prompts</h2>
54+
<p class="mt-0.5 text-xs color-muted">
5455
{ruleCount()}
5556
{' '}
5657
{ruleCount() === 1 ? 'violation' : 'violations'}
5758
{' '}
5859
selected — copy the prompt into your AI assistant.
5960
</p>
6061
</div>
61-
<button type="button" ref={closeBtn} class="modal__close" aria-label="Close" onClick={() => props.onClose()}>
62+
<button type="button" ref={closeBtn} class="ml-auto inline-flex p-1.5 rounded color-muted cursor-pointer transition hover:bg-active hover:color-base outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40" aria-label="Close" onClick={() => props.onClose()}>
6263
<span aria-hidden class="i-ph-x shrink-0" />
6364
</button>
6465
</div>
6566

66-
<textarea class="modal__text" readonly aria-label="Generated fix prompt">{prompt()}</textarea>
67+
<textarea class="flex-1 min-h-60 resize-none w-full p-3 bg-base border border-base rounded-lg font-mono text-[11.5px] leading-relaxed color-base whitespace-pre overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40" readonly aria-label="Generated fix prompt">{prompt()}</textarea>
6768

68-
<div class="modal__actions">
69-
<span class="modal__hint">
69+
<div class="flex items-center gap-2.5">
70+
<span class="min-w-0 truncate text-[11px] color-faint">
7071
<For each={props.items}>
7172
{(item, i) => (
7273
<>
7374
<Show when={i() > 0}>{', '}</Show>
74-
<code>{item.violation.ruleId}</code>
75+
<code class="font-mono">{item.violation.ruleId}</code>
7576
</>
7677
)}
7778
</For>
7879
</span>
7980
<span class="flex-1" />
80-
<button type="button" class="modal__btn" onClick={copy}>
81+
<button type="button" class={button({ variant: 'primary', size: 'sm' })} onClick={copy}>
8182
<span aria-hidden class={`shrink-0 ${copied() ? 'i-ph-check' : 'i-ph-copy'}`} />
8283
{copied() ? 'Copied' : 'Copy prompt'}
8384
</button>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Meta, StoryObj } from 'storybook-solidjs-vite'
2+
import { Header } from './Header.tsx'
3+
4+
// The top nav bar: brand, connection status dot, generate-prompts, and rescan.
5+
const meta = {
6+
title: 'A11y/Header',
7+
component: Header,
8+
parameters: { layout: 'fullscreen' },
9+
} satisfies Meta<typeof Header>
10+
11+
export default meta
12+
type Story = StoryObj<typeof meta>
13+
14+
function noop() {}
15+
const base = { selectedCount: 0, onGenerate: noop, onRescan: noop }
16+
17+
export const Connected: Story = { args: { ...base, agentReady: true, scanning: false } }
18+
export const Scanning: Story = { args: { ...base, agentReady: true, scanning: true } }
19+
export const WithSelection: Story = { args: { ...base, agentReady: true, scanning: false, selectedCount: 3 } }
20+
export const Disconnected: Story = { args: { ...base, agentReady: false, scanning: false } }
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Show } from 'solid-js'
2+
import { button, nav, navBrand } from '../design'
3+
4+
interface HeaderProps {
5+
agentReady: boolean
6+
scanning: boolean
7+
selectedCount: number
8+
onGenerate: () => void
9+
onRescan: () => void
10+
}
11+
12+
/** The top nav bar: brand, connection status, generate-prompts, and rescan. */
13+
export function Header(props: HeaderProps) {
14+
const statusLabel = () =>
15+
!props.agentReady ? 'No page connected' : props.scanning ? 'Scanning…' : 'Connected'
16+
const dotClass = () =>
17+
!props.agentReady ? 'bg-neutral-400' : props.scanning ? 'bg-primary-500 animate-pulse' : 'bg-success'
18+
19+
return (
20+
<header class={nav()}>
21+
<span class={navBrand()}>
22+
<span aria-hidden class="i-ph-person-simple-circle-duotone text-base color-active" />
23+
<span>A11y Inspector</span>
24+
</span>
25+
<span class="flex-1" />
26+
<span class="inline-flex items-center gap-1.5 text-xs color-muted select-none">
27+
<span class={`size-2 rounded-full shrink-0 ${dotClass()}`} />
28+
{statusLabel()}
29+
</span>
30+
<button
31+
type="button"
32+
class={button({ variant: 'secondary', size: 'sm' })}
33+
onClick={() => props.onGenerate()}
34+
disabled={props.selectedCount === 0}
35+
title="Generate AI fix prompts for the selected violations"
36+
>
37+
<span aria-hidden class="i-ph-sparkle-duotone shrink-0" />
38+
Generate fix prompts
39+
<Show when={props.selectedCount > 0}>
40+
<span class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-active text-[10px] font-bold tabular-nums">
41+
{props.selectedCount}
42+
</span>
43+
</Show>
44+
</button>
45+
<button
46+
type="button"
47+
class={button({ variant: 'primary', size: 'sm' })}
48+
onClick={() => props.onRescan()}
49+
disabled={!props.agentReady || props.scanning}
50+
>
51+
<span aria-hidden class="i-ph-arrows-clockwise shrink-0" classList={{ 'animate-spin': props.scanning }} />
52+
Rescan
53+
</button>
54+
</header>
55+
)
56+
}

0 commit comments

Comments
 (0)