Merge pull request #138 from bhavikprit/feat/40-accessibility

feat(#40): improve frontend accessibility (WCAG 2.1 AA)
This commit is contained in:
nyk 2026-03-04 21:20:49 +07:00 committed by GitHub
commit 6a49751599
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 138 additions and 29 deletions

View File

@ -140,6 +140,9 @@ export default function Home() {
return ( return (
<div className="flex h-screen bg-background overflow-hidden"> <div className="flex h-screen bg-background overflow-hidden">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-2 focus:left-2 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm focus:font-medium">
Skip to main content
</a>
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */} {/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
<NavRail /> <NavRail />
@ -149,7 +152,7 @@ export default function Home() {
<LocalModeBanner /> <LocalModeBanner />
<UpdateBanner /> <UpdateBanner />
<PromoBanner /> <PromoBanner />
<main className="flex-1 overflow-auto pb-16 md:pb-0" role="main"> <main id="main-content" className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
<div aria-live="polite"> <div aria-live="polite">
<ErrorBoundary key={activeTab}> <ErrorBoundary key={activeTab}>
<ContentRouter tab={activeTab} /> <ContentRouter tab={activeTab} />

View File

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useMissionControl } from '@/store' import { useMissionControl } from '@/store'
import { useSmartPoll } from '@/lib/use-smart-poll' import { useSmartPoll } from '@/lib/use-smart-poll'
import { useFocusTrap } from '@/lib/use-focus-trap'
import { AgentAvatar } from '@/components/ui/agent-avatar' import { AgentAvatar } from '@/components/ui/agent-avatar'
import { MarkdownRenderer } from '@/components/markdown-renderer' import { MarkdownRenderer } from '@/components/markdown-renderer'
@ -268,8 +269,8 @@ export function TaskBoardPanel() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64" role="status" aria-live="polite">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" aria-hidden="true"></div>
<span className="ml-2 text-muted-foreground">Loading tasks...</span> <span className="ml-2 text-muted-foreground">Loading tasks...</span>
</div> </div>
) )
@ -298,11 +299,12 @@ export function TaskBoardPanel() {
{/* Error Display */} {/* Error Display */}
{error && ( {error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between"> <div role="alert" className="bg-red-500/10 border border-red-500/20 text-red-400 p-3 m-4 rounded-lg text-sm flex items-center justify-between">
<span>{error}</span> <span>{error}</span>
<button <button
onClick={() => setError(null)} onClick={() => setError(null)}
className="text-red-400/60 hover:text-red-400 ml-2" className="text-red-400/60 hover:text-red-400 ml-2"
aria-label="Dismiss error"
> >
× ×
</button> </button>
@ -310,10 +312,12 @@ export function TaskBoardPanel() {
)} )}
{/* Kanban Board */} {/* Kanban Board */}
<div className="flex-1 flex gap-4 p-4 overflow-x-auto"> <div className="flex-1 flex gap-4 p-4 overflow-x-auto" role="region" aria-label="Task board">
{statusColumns.map(column => ( {statusColumns.map(column => (
<div <div
key={column.key} key={column.key}
role="region"
aria-label={`${column.title} column, ${tasksByStatus[column.key]?.length || 0} tasks`}
className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col" className="flex-1 min-w-80 bg-card border border-border rounded-lg flex flex-col"
onDragEnter={(e) => handleDragEnter(e, column.key)} onDragEnter={(e) => handleDragEnter(e, column.key)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@ -334,8 +338,12 @@ export function TaskBoardPanel() {
<div <div
key={task.id} key={task.id}
draggable draggable
role="button"
tabIndex={0}
aria-label={`${task.title}, ${task.priority} priority, ${task.status}`}
onDragStart={(e) => handleDragStart(e, task)} onDragStart={(e) => handleDragStart(e, task)}
onClick={() => setSelectedTask(task)} onClick={() => setSelectedTask(task)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTask(task) } }}
className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${ className={`bg-surface-1 rounded-lg p-3 cursor-pointer hover:bg-surface-2 transition-smooth border-l-4 ${priorityColors[task.priority]} ${
draggedTask?.id === task.id ? 'opacity-50' : '' draggedTask?.id === task.id ? 'opacity-50' : ''
}`} }`}
@ -610,12 +618,14 @@ function TaskDetailModal({
</div> </div>
) )
const dialogRef = useFocusTrap(onClose)
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="task-detail-title" className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6"> <div className="p-6">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-bold text-foreground">{task.title}</h3> <h3 id="task-detail-title" className="text-xl font-bold text-foreground">{task.title}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => onEdit(task)} onClick={() => onEdit(task)}
@ -625,6 +635,7 @@ function TaskDetailModal({
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close task details"
className="text-muted-foreground hover:text-foreground text-2xl transition-smooth" className="text-muted-foreground hover:text-foreground text-2xl transition-smooth"
> >
× ×
@ -638,10 +649,13 @@ function TaskDetailModal({
) : ( ) : (
<p className="text-foreground/80 mb-4">No description</p> <p className="text-foreground/80 mb-4">No description</p>
)} )}
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4" role="tablist" aria-label="Task detail tabs">
{(['details', 'comments', 'quality'] as const).map(tab => ( {(['details', 'comments', 'quality'] as const).map(tab => (
<button <button
key={tab} key={tab}
role="tab"
aria-selected={activeTab === tab}
aria-controls={`tabpanel-${tab}`}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={`px-3 py-2 text-sm rounded-md transition-smooth ${ className={`px-3 py-2 text-sm rounded-md transition-smooth ${
activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2' activeTab === tab ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'
@ -653,7 +667,7 @@ function TaskDetailModal({
</div> </div>
{activeTab === 'details' && ( {activeTab === 'details' && (
<div className="grid grid-cols-2 gap-4 text-sm mt-4"> <div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
<div> <div>
<span className="text-muted-foreground">Status:</span> <span className="text-muted-foreground">Status:</span>
<span className="text-foreground ml-2">{task.status}</span> <span className="text-foreground ml-2">{task.status}</span>
@ -683,7 +697,7 @@ function TaskDetailModal({
)} )}
{activeTab === 'comments' && ( {activeTab === 'comments' && (
<div className="mt-6"> <div id="tabpanel-comments" role="tabpanel" aria-label="Comments" className="mt-6">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-semibold text-foreground">Comments</h4> <h4 className="text-lg font-semibold text-foreground">Comments</h4>
<button <button
@ -766,7 +780,7 @@ function TaskDetailModal({
)} )}
{activeTab === 'quality' && ( {activeTab === 'quality' && (
<div className="mt-6"> <div id="tabpanel-quality" role="tabpanel" aria-label="Quality Review" className="mt-6">
<h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</h5> <h5 className="text-sm font-medium text-foreground mb-2">Aegis Quality Review</h5>
{reviewError && ( {reviewError && (
<div className="text-xs text-red-400 mb-2">{reviewError}</div> <div className="text-xs text-red-400 mb-2">{reviewError}</div>
@ -873,16 +887,19 @@ function CreateTaskModal({
} }
} }
const dialogRef = useFocusTrap(onClose)
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-card border border-border rounded-lg max-w-md w-full"> <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="create-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
<form onSubmit={handleSubmit} className="p-6"> <form onSubmit={handleSubmit} className="p-6">
<h3 className="text-xl font-bold text-foreground mb-4">Create New Task</h3> <h3 id="create-task-title" className="text-xl font-bold text-foreground mb-4">Create New Task</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Title</label> <label htmlFor="create-title" className="block text-sm text-muted-foreground mb-1">Title</label>
<input <input
id="create-title"
type="text" type="text"
value={formData.title} value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
@ -892,8 +909,9 @@ function CreateTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Description</label> <label htmlFor="create-description" className="block text-sm text-muted-foreground mb-1">Description</label>
<textarea <textarea
id="create-description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -903,8 +921,9 @@ function CreateTaskModal({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Priority</label> <label htmlFor="create-priority" className="block text-sm text-muted-foreground mb-1">Priority</label>
<select <select
id="create-priority"
value={formData.priority} value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))} onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -917,8 +936,9 @@ function CreateTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Assign to</label> <label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
<select <select
id="create-assignee"
value={formData.assigned_to} value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -934,8 +954,9 @@ function CreateTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label> <label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
<input <input
id="create-tags"
type="text" type="text"
value={formData.tags} value={formData.tags}
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}
@ -1015,16 +1036,19 @@ function EditTaskModal({
} }
} }
const dialogRef = useFocusTrap(onClose)
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-card border border-border rounded-lg max-w-md w-full"> <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="edit-task-title" className="bg-card border border-border rounded-lg max-w-md w-full">
<form onSubmit={handleSubmit} className="p-6"> <form onSubmit={handleSubmit} className="p-6">
<h3 className="text-xl font-bold text-foreground mb-4">Edit Task</h3> <h3 id="edit-task-title" className="text-xl font-bold text-foreground mb-4">Edit Task</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Title</label> <label htmlFor="edit-title" className="block text-sm text-muted-foreground mb-1">Title</label>
<input <input
id="edit-title"
type="text" type="text"
value={formData.title} value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
@ -1034,8 +1058,9 @@ function EditTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Description</label> <label htmlFor="edit-description" className="block text-sm text-muted-foreground mb-1">Description</label>
<textarea <textarea
id="edit-description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -1045,8 +1070,9 @@ function EditTaskModal({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Status</label> <label htmlFor="edit-status" className="block text-sm text-muted-foreground mb-1">Status</label>
<select <select
id="edit-status"
value={formData.status} value={formData.status}
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))} onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as Task['status'] }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -1061,8 +1087,9 @@ function EditTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Priority</label> <label htmlFor="edit-priority" className="block text-sm text-muted-foreground mb-1">Priority</label>
<select <select
id="edit-priority"
value={formData.priority} value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))} onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as Task['priority'] }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -1076,8 +1103,9 @@ function EditTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Assign to</label> <label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
<select <select
id="edit-assignee"
value={formData.assigned_to} value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
@ -1092,8 +1120,9 @@ function EditTaskModal({
</div> </div>
<div> <div>
<label className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label> <label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
<input <input
id="edit-tags"
type="text" type="text"
value={formData.tags} value={formData.tags}
onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, tags: e.target.value }))}

77
src/lib/use-focus-trap.ts Normal file
View File

@ -0,0 +1,77 @@
import { useEffect, useRef, useCallback } from 'react'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
/**
* Traps keyboard focus within a container element.
* Handles Tab/Shift+Tab cycling and Escape to close.
*/
export function useFocusTrap(onClose?: () => void) {
const containerRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' && onClose) {
e.stopPropagation()
onClose()
return
}
if (e.key !== 'Tab') return
const container = containerRef.current
if (!container) return
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
},
[onClose]
)
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement
const container = containerRef.current
if (container) {
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
if (focusable.length > 0) {
focusable[0].focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
previousFocusRef.current?.focus()
}
}, [handleKeyDown])
return containerRef
}