Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@
}
}

/* Global focus-visible indicator for accessibility */
*:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}

/* Screen reader only utility (redundant with Tailwind sr-only, but ensures availability) */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: 0.5rem 1rem;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
}

/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
Expand Down
45 changes: 35 additions & 10 deletions frontend/src/layouts/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {

return (
<div className="min-h-screen bg-muted/30">
{/* Skip to main content link */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
{t('accessibility.skipToMain', 'Skip to main content')}
</a>

{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}

{/* Sidebar */}
<aside
aria-label={t('accessibility.sidebar', 'Sidebar navigation')}
className={cn(
'fixed left-0 top-0 h-full w-64 bg-card border-r border-border z-50 flex flex-col transform transition-transform duration-200 lg:transform-none',
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
Expand All @@ -83,6 +93,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Link>
<button
onClick={() => setSidebarOpen(false)}
aria-label={t('accessibility.closeSidebar', 'Close sidebar')}
className="lg:hidden p-2 hover:bg-muted rounded-lg"
>
<X className="w-5 h-5" />
Expand All @@ -92,22 +103,23 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Scrollable area: nav + bottom items */}
<div className="flex-1 flex flex-col overflow-y-auto">
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
<nav aria-label={t('accessibility.mainNav', 'Main navigation')} className="flex-1 p-4 space-y-1">
{sidebarItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
onClick={() => setSidebarOpen(false)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="w-5 h-5" />
<item.icon className="w-5 h-5" aria-hidden="true" />
{t(item.labelKey)}
</Link>
);
Expand Down Expand Up @@ -147,14 +159,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Main content area */}
<div className="lg:ml-64">
{/* Header */}
<header className="h-16 bg-card border-b border-border sticky top-0 z-30">
<header className="h-16 bg-card border-b border-border sticky top-0 z-30" role="banner">
<div className="h-full px-4 flex items-center justify-between">
{/* Mobile menu button */}
<button
onClick={() => setSidebarOpen(true)}
aria-label={t('accessibility.openMenu', 'Open navigation menu')}
className="lg:hidden p-2 hover:bg-muted rounded-lg"
>
<Menu className="w-5 h-5" />
<Menu className="w-5 h-5" aria-hidden="true" />
</button>

{/* Spacer */}
Expand All @@ -170,6 +183,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="relative ml-2">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
aria-expanded={userMenuOpen}
aria-haspopup="true"
aria-label={t('accessibility.userMenu', 'User menu')}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted transition-colors"
>
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
Expand All @@ -191,7 +207,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{user?.plan} {t('common.plan')}
</p>
</div>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
<ChevronDown className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
</button>

{/* Dropdown menu */}
Expand All @@ -200,34 +216,43 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div
className="fixed inset-0 z-40"
onClick={() => setUserMenuOpen(false)}
aria-hidden="true"
/>
<div className="absolute right-0 top-full mt-2 w-52 bg-card border border-border rounded-lg shadow-lg z-50">
<div
role="menu"
aria-label={t('accessibility.userMenu', 'User menu')}
onKeyDown={(e) => { if (e.key === 'Escape') setUserMenuOpen(false); }}
className="absolute right-0 top-full mt-2 w-52 bg-card border border-border rounded-lg shadow-lg z-50"
>
<div className="p-2">
<Link
to="/dashboard/subscription"
role="menuitem"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
>
<CreditCard className="w-4 h-4" />
<CreditCard className="w-4 h-4" aria-hidden="true" />
{t('common.subscription')}
<span className="ml-auto px-1.5 py-0.5 bg-green-500/10 text-green-500 rounded text-[10px] font-medium capitalize">
{user?.plan}
</span>
</Link>
<Link
to="/dashboard/settings"
role="menuitem"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
>
<Settings className="w-4 h-4" />
<Settings className="w-4 h-4" aria-hidden="true" />
{t('common.settings')}
</Link>
<div className="border-t border-border my-1" />
<button
role="menuitem"
onClick={handleLogout}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors text-red-500"
>
<LogOut className="w-4 h-4" />
<LogOut className="w-4 h-4" aria-hidden="true" />
{t('common.logout')}
</button>
</div>
Expand All @@ -239,7 +264,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</header>

{/* Page content */}
<main className="p-4 lg:p-6">{children}</main>
<main id="main-content" className="p-4 lg:p-6" role="main">{children}</main>
</div>
</div>
);
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/dashboard/DashboardHomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function QuickActionCard({
to={href}
className="bg-card border border-border rounded-xl p-5 hover:border-green-500/50 hover:shadow-lg transition-all group"
>
<div className={`w-10 h-10 rounded-lg ${color} flex items-center justify-center mb-3`}>
<div className={`w-10 h-10 rounded-lg ${color} flex items-center justify-center mb-3`} aria-hidden="true">
<Icon className="w-5 h-5" />
</div>
<h3 className="font-semibold mb-1 group-hover:text-green-500 transition-colors">{title}</h3>
Expand Down Expand Up @@ -98,7 +98,7 @@ function StatCard({
className="bg-card border border-border rounded-xl p-5"
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${color} flex items-center justify-center`}>
<div className={`w-12 h-12 rounded-xl ${color} flex items-center justify-center`} aria-hidden="true">
<Icon className="w-6 h-6" />
</div>
<div>
Expand Down Expand Up @@ -134,7 +134,7 @@ function RecentStudySetCard({ studySet }: { studySet: StudySet }) {
{studySet.flashcardsCount} {t('common.cards')}
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
<ArrowRight className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
</Link>
);
}
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/pages/dashboard/QuizPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ function GeneratingScreen() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="text-center py-12"
aria-live="polite"
role="status"
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
className="w-20 h-20 rounded-full bg-green-500/10 border-2 border-green-500/30 border-t-green-500 flex items-center justify-center mx-auto mb-6"
aria-hidden="true"
>
<Sparkles className="w-8 h-8 text-green-500" />
</motion.div>
Expand Down Expand Up @@ -148,16 +151,17 @@ function QuizConfigScreen({
{/* Error */}
{error && (
<motion.div
role="alert"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start gap-3"
>
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" aria-hidden="true" />
<div className="flex-1">
<p className="text-sm font-medium text-red-500">{error}</p>
</div>
<button onClick={onClearError} className="text-red-400 hover:text-red-500">
<XCircle className="w-4 h-4" />
<button onClick={onClearError} aria-label="Dismiss error" className="text-red-400 hover:text-red-500">
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</motion.div>
)}
Expand Down Expand Up @@ -831,9 +835,10 @@ export function QuizPage() {
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate(`/dashboard/study-sets/${studySetId}`)}
aria-label={t('common.back')}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<ArrowLeft className="w-5 h-5" aria-hidden="true" />
<span className="text-sm">{t('common.back')}</span>
</button>
{phase === 'quiz' && (
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/pages/dashboard/ReviewQueuePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,10 @@ export default function ReviewQueuePage() {
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/dashboard/exam-clone')}
aria-label={t('reviewQueue.back')}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<ArrowLeft className="w-5 h-5" aria-hidden="true" />
<span>{t('reviewQueue.back')}</span>
</button>

Expand Down Expand Up @@ -213,8 +214,10 @@ export default function ReviewQueuePage() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center py-20"
role="status"
aria-live="polite"
>
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" aria-hidden="true" />
<p className="text-muted-foreground">{t('reviewQueue.loadingReviewQueue')}</p>
</motion.div>
)}
Expand Down Expand Up @@ -383,6 +386,7 @@ export default function ReviewQueuePage() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-4"
aria-live="polite"
>
{!explanation && (
<Button
Expand Down
Loading