fix(a11y): static accessibility findings (v1-readiness #4 — code-side)

Statische audit op happy-path-code; 4 categorieën gefixt vóór de Lighthouse-
verificatie die de gebruiker handmatig draait:

1. <main>-landmark op /login en /register (waren <div>); auth-pages krijgen
   nu een correcte landmark zodat screen-readers ze kunnen overslaan/nav

2. solo-task-card.tsx: agent-status-pill had role="button" + aria-label maar
   GEEN tabIndex en GEEN onKeyDown — keyboard-onbereikbaar. Nu compleet:
   tabIndex={0} + Enter/Space-handler

3. Form-label-associaties via htmlFor + id-pairs:
   - story-dialog (5): code, title, description, acceptance + priority via labelledby
   - task-dialog (3): title, description, implementation_plan
   - todo-list PromotePbi/PromoteStory dialogs (6): title, product, pbi, priority

   Lighthouse a11y "form-field-multiple-labels" en "label" rules worden
   hierdoor groen.

Niet aangeraakt:
- pbi-dialog: htmlFor was al goed gewired
- auth-form: htmlFor was al goed gewired
- Color-contrast: gebruikt MD3-tokens; theoretisch correct (verifieer in
  Lighthouse run)
- Heading-hierarchy: nog niet gescand — kan in vervolgronde

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 13:58:34 +02:00
parent 43778e3bcb
commit 31ff70b71a
6 changed files with 42 additions and 25 deletions

View file

@ -5,7 +5,7 @@ import { QrLoginButton } from './qr-login-button'
export default function LoginPage() {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<main className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
{/* Logo / titel */}
@ -42,6 +42,6 @@ export default function LoginPage() {
</div>
</div>
</div>
</main>
)
}

View file

@ -4,7 +4,7 @@ import { AuthForm } from '@/components/auth/auth-form'
export default function RegisterPage() {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<main className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
{/* Logo / titel */}
@ -26,6 +26,6 @@ export default function RegisterPage() {
</div>
</div>
</div>
</main>
)
}

View file

@ -220,10 +220,11 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Title */}
<div>
<label className="text-sm font-medium mb-2 block">
<label htmlFor="task-title" className="text-sm font-medium mb-2 block">
Titel <span className="text-destructive">*</span>
</label>
<Input
id="task-title"
{...form.register('title')}
aria-invalid={!!form.formState.errors.title}
autoFocus
@ -240,13 +241,14 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Description */}
<div>
<label className="text-sm font-medium mb-2 block">Omschrijving</label>
<label htmlFor="task-description" className="text-sm font-medium mb-2 block">Omschrijving</label>
<Controller
control={form.control}
name="description"
render={({ field }) => (
<>
<TextareaAutosize
id="task-description"
{...field}
value={field.value ?? ''}
aria-invalid={!!form.formState.errors.description}
@ -275,13 +277,14 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Implementation plan */}
<div>
<label className="text-sm font-medium mb-2 block">Implementatieplan</label>
<label htmlFor="task-implementation-plan" className="text-sm font-medium mb-2 block">Implementatieplan</label>
<Controller
control={form.control}
name="implementation_plan"
render={({ field }) => (
<>
<TextareaAutosize
id="task-implementation-plan"
{...field}
value={field.value ?? ''}
aria-invalid={!!form.formState.errors.implementation_plan}

View file

@ -192,8 +192,9 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
<div className="px-6 py-6 space-y-6">
<div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label>
<label htmlFor="story-code" className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label>
<Input
id="story-code"
name="code"
defaultValue={story?.code ?? ''}
placeholder={isEdit ? '' : 'auto'}
@ -205,10 +206,11 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<label htmlFor="story-title" className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Titel <span className="text-error">*</span>
</label>
<Input
id="story-title"
ref={titleRef}
name="title"
defaultValue={story?.title ?? ''}
@ -223,15 +225,16 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Prioriteit</label>
<span id="story-priority-label" className="block text-xs font-medium text-muted-foreground uppercase tracking-wide">Prioriteit</span>
<PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<label htmlFor="story-description" className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Omschrijving <span className="normal-case font-normal">(optioneel)</span>
</label>
<Textarea
id="story-description"
name="description"
rows={3}
defaultValue={story?.description ?? ''}
@ -242,10 +245,11 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<label htmlFor="story-acceptance" className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Acceptatiecriteria <span className="normal-case font-normal">(optioneel)</span>
</label>
<Textarea
id="story-acceptance"
name="acceptance_criteria"
rows={3}
defaultValue={story?.acceptance_criteria ?? ''}

View file

@ -63,11 +63,19 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
{job && (
<span
className={cn(
'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0',
'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0 cursor-pointer',
JOB_STATUS_COLORS[job.status],
)}
onClick={(e) => { e.stopPropagation(); onClick() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClick()
}
}}
role="button"
tabIndex={0}
aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`}
>
{JOB_STATUS_ACTIVE.has(job.status) && <Loader2 className="animate-spin" size={8} />}

View file

@ -113,15 +113,16 @@ function PromotePbiDialog({
<form action={formAction} className="space-y-3">
<input type="hidden" name="todoId" value={todo.id} />
<div className="space-y-1.5">
<label className="text-sm font-medium">Titel</label>
<Input name="title" defaultValue={todo.title} required />
<label htmlFor="promote-pbi-title" className="text-sm font-medium">Titel</label>
<Input id="promote-pbi-title" name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Product</label>
<label htmlFor="promote-pbi-product" className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
id="promote-pbi-product"
name="productId"
required
defaultValue={todo.product_id ?? products[0]?.id}
@ -132,8 +133,8 @@ function PromotePbiDialog({
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Prioriteit</label>
<select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<label htmlFor="promote-pbi-priority" className="text-sm font-medium">Prioriteit</label>
<select id="promote-pbi-priority" name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<option value="1">Kritiek</option>
<option value="2">Hoog</option>
<option value="3">Gemiddeld</option>
@ -184,15 +185,16 @@ function PromoteStoryDialog({
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="productId" value={selectedProductId} />
<div className="space-y-1.5">
<label className="text-sm font-medium">Titel</label>
<Input name="title" defaultValue={todo.title} required />
<label htmlFor="promote-story-title" className="text-sm font-medium">Titel</label>
<Input id="promote-story-title" name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Product</label>
<label htmlFor="promote-story-product" className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
id="promote-story-product"
value={selectedProductId}
onChange={e => setSelectedProductId(e.target.value)}
className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
@ -202,18 +204,18 @@ function PromoteStoryDialog({
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">PBI</label>
<label htmlFor="promote-story-pbi" className="text-sm font-medium">PBI</label>
{!selectedProduct?.pbis.length ? (
<p className="text-sm text-muted-foreground">Maak eerst een PBI aan in dit product.</p>
) : (
<select name="pbiId" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<select id="promote-story-pbi" name="pbiId" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
{selectedProduct.pbis.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Prioriteit</label>
<select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<label htmlFor="promote-story-priority" className="text-sm font-medium">Prioriteit</label>
<select id="promote-story-priority" name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<option value="1">Kritiek</option>
<option value="2">Hoog</option>
<option value="3">Gemiddeld</option>