diff --git a/frontend/src/components/common/Button.jsx b/frontend/src/components/common/Button.jsx
new file mode 100644
index 0000000..58a22ec
--- /dev/null
+++ b/frontend/src/components/common/Button.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { Loader2 } from 'lucide-react';
+
+/**
+ * Shared button primitive.
+ *
+ * Consolidates the gradient / ghost / danger button styles that were inlined
+ * across the app into one accessible, consistent component.
+ *
+ * Props:
+ * - variant primary | gradient | ghost | danger | subtle (default primary)
+ * - size sm | md | lg (default md)
+ * - loading shows a spinner and disables the button
+ * - icon lucide icon component rendered before the label
+ * - iconAfter lucide icon component rendered after the label
+ * - fullWidth stretches to 100% width
+ * - as render as a different element (e.g. 'a') — defaults to 'button'
+ * Any other props (onClick, type, disabled, aria-*, href…) are forwarded.
+ */
+const VARIANTS = {
+ primary:
+ 'bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-400 hover:to-amber-400 text-white shadow-lg shadow-orange-500/25 hover:shadow-orange-500/40 hover:-translate-y-0.5',
+ gradient:
+ 'btn-gradient text-white shadow-lg',
+ purple:
+ 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white shadow-[0_4px_14px_rgba(124,58,237,0.35)] hover:shadow-[0_6px_20px_rgba(124,58,237,0.5)] hover:-translate-y-0.5',
+ ghost:
+ 'bg-slate-100 dark:bg-white/10 hover:bg-slate-200 dark:hover:bg-white/20 text-slate-700 dark:text-white',
+ subtle:
+ 'bg-transparent hover:bg-slate-100 dark:hover:bg-white/10 text-slate-600 dark:text-white/70',
+ danger:
+ 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400 text-white shadow-lg shadow-red-500/25',
+};
+
+const SIZES = {
+ sm: 'px-3 py-1.5 text-xs gap-1.5 rounded-xl',
+ md: 'px-4 py-2.5 text-sm gap-2 rounded-2xl',
+ lg: 'px-6 py-3 text-base gap-2.5 rounded-2xl',
+};
+
+const ICON_SIZE = { sm: 14, md: 15, lg: 18 };
+
+const Button = ({
+ variant = 'primary',
+ size = 'md',
+ loading = false,
+ icon: Icon,
+ iconAfter: IconAfter,
+ fullWidth = false,
+ as: Component = 'button',
+ className = '',
+ disabled,
+ children,
+ ...props
+}) => {
+ const isDisabled = disabled || loading;
+ const iconSize = ICON_SIZE[size] || ICON_SIZE.md;
+
+ return (
+
+ {loading
+ ?
+ : Icon && }
+ {children}
+ {!loading && IconAfter && }
+
+ );
+};
+
+export default Button;
diff --git a/frontend/src/components/common/CountUp.jsx b/frontend/src/components/common/CountUp.jsx
new file mode 100644
index 0000000..a404b8f
--- /dev/null
+++ b/frontend/src/components/common/CountUp.jsx
@@ -0,0 +1,32 @@
+import React, { useEffect } from 'react';
+import { motion, useSpring, useTransform } from 'framer-motion';
+
+/**
+ * Animates a number from its previous value to the new one with a spring.
+ * Mirrors the inline pattern used for the shop coin counter so XP / level /
+ * stat numbers across the app share one consistent count-up feel.
+ *
+ * Honours prefers-reduced-motion: jumps straight to the value.
+ *
+ * Props:
+ * - value target number
+ * - format optional (n) => string formatter (default toLocaleString)
+ * - className forwarded to the span
+ */
+const prefersReduced =
+ typeof window !== 'undefined' &&
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+
+const CountUp = ({ value = 0, format = (n) => Math.round(n).toLocaleString(), className }) => {
+ const spring = useSpring(value, { stiffness: 80, damping: 18 });
+ const display = useTransform(spring, format);
+
+ useEffect(() => {
+ if (prefersReduced) spring.jump(value);
+ else spring.set(value);
+ }, [value, spring]);
+
+ return {display};
+};
+
+export default CountUp;
diff --git a/frontend/src/components/common/EmptyState.jsx b/frontend/src/components/common/EmptyState.jsx
index ae17437..b457107 100644
--- a/frontend/src/components/common/EmptyState.jsx
+++ b/frontend/src/components/common/EmptyState.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import Button from './Button';
const EmptyState = ({ icon: Icon, title, description, action }) => (
@@ -14,12 +15,9 @@ const EmptyState = ({ icon: Icon, title, description, action }) => (
)}
{action && (
-
)}
);
diff --git a/frontend/src/components/common/Pagination.jsx b/frontend/src/components/common/Pagination.jsx
index 89836ef..dea9647 100644
--- a/frontend/src/components/common/Pagination.jsx
+++ b/frontend/src/components/common/Pagination.jsx
@@ -2,25 +2,29 @@ import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const Pagination = ({ page, pages, onPage }) => (
-
+
+
);
export default Pagination;
diff --git a/frontend/src/components/common/StatCard.jsx b/frontend/src/components/common/StatCard.jsx
new file mode 100644
index 0000000..9d35f57
--- /dev/null
+++ b/frontend/src/components/common/StatCard.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+
+/**
+ * Compact stat tile used on the dashboard and elsewhere.
+ *
+ * Props:
+ * - icon lucide icon component
+ * - value the number/string to highlight
+ * - label small uppercase caption
+ * - color tailwind bg-* class used for the subtle hover wash
+ * - glow className applied to the icon (color + drop-shadow glow)
+ */
+const StatCard = ({ icon: Icon, value, label, color = 'bg-orange-500', glow = '' }) => (
+
+
+ {Icon && }
+ {value}
+ {label}
+
+);
+
+export default StatCard;
diff --git a/frontend/src/components/dashboard/CourseSidebarItem.jsx b/frontend/src/components/dashboard/CourseSidebarItem.jsx
index de18f81..24d3ee1 100644
--- a/frontend/src/components/dashboard/CourseSidebarItem.jsx
+++ b/frontend/src/components/dashboard/CourseSidebarItem.jsx
@@ -1,12 +1,14 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ChevronRight, PlayCircle } from 'lucide-react';
+import { clickableProps } from '../../utils/a11y';
const CourseSidebarItem = ({ course, isSelected, onClick }) => {
return (
{
{/* ── Card column ── */}
handleToggleExpand(nodeId)}
+ {...clickableProps(() => handleToggleExpand(nodeId), `${isExpanded ? 'Collapse' : 'Expand'} ${node.title}`)}
+ aria-expanded={isExpanded}
whileHover={{ scale: 1.01, y: -1, transition: { duration: 0.15 } }}
whileTap={{ scale: 0.985 }}
className={`
diff --git a/frontend/src/components/index.jsx b/frontend/src/components/index.jsx
index 9120345..d3dcf46 100644
--- a/frontend/src/components/index.jsx
+++ b/frontend/src/components/index.jsx
@@ -1,4 +1,7 @@
// Design system barrel — used by design-sync converter
+export { default as Button } from './common/Button.jsx';
+export { default as StatCard } from './common/StatCard.jsx';
+export { default as CountUp } from './common/CountUp.jsx';
export { default as EmptyState } from './common/EmptyState.jsx';
export { default as LoadingSkeleton } from './common/LoadingSkeleton.jsx';
export { default as ToastManager } from './common/ToastManager.jsx';
diff --git a/frontend/src/components/layout/StudentLayout.jsx b/frontend/src/components/layout/StudentLayout.jsx
index 39ec06f..f86d981 100644
--- a/frontend/src/components/layout/StudentLayout.jsx
+++ b/frontend/src/components/layout/StudentLayout.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
+import { clickableProps } from '../../utils/a11y';
import { Settings, LogOut, Menu, Map, BookOpen, ChevronRight, ChevronDown, X, Flame, Zap, User, ShoppingBag, Trophy } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { io } from 'socket.io-client';
@@ -256,7 +257,7 @@ const SidebarContent = ({ location, navigate, courses, selectedCourseId, setSele
{/* Player Card with gradient border */}
{ navigate('/profile'); setIsSidebarOpen(false); }}
+ {...clickableProps(() => { navigate('/profile'); setIsSidebarOpen(false); }, 'Open my profile')}
className="cursor-pointer group/card mb-3"
>
{
return (
0 ? `Notifications, ${unread} unread` : 'Notifications'}
+ aria-haspopup="true"
+ aria-expanded={open}
>
{unread > 0 && (
@@ -61,15 +64,19 @@ const StudentNotificationBell = ({ dropUp = false }) => {
{notifications.length > 0 && (
<>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b96cb1c..aa79f43 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -47,6 +47,21 @@ body {
radial-gradient(ellipse 40% 40% at 50% 50%, rgba(196,97,61,0.04) 0%, transparent 70%);
}
+/* ── Global Keyboard Focus ───────────────────────────── */
+/* A single, consistent focus ring for every interactive element so keyboard
+ navigation is always visible. Uses :focus-visible so it never shows on
+ mouse/touch interaction. Components can opt out with `focus-visible:ring-0`. */
+:where(a, button, [role="button"], input, select, textarea, summary, [tabindex]):focus-visible {
+ outline: none;
+ border-radius: max(0.5rem, 4px);
+ box-shadow: 0 0 0 2px #F8F5F1, 0 0 0 4px var(--color-studylabs-orange);
+}
+.dark :where(a, button, [role="button"], input, select, textarea, summary, [tabindex]):focus-visible {
+ box-shadow: 0 0 0 2px #100C09, 0 0 0 4px var(--color-studylabs-orange);
+}
+/* Inputs already carry their own ring utilities in places — keep their radius */
+:where(input, select, textarea):focus-visible { border-radius: inherit; }
+
/* ── Scrollbar ───────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
diff --git a/frontend/src/pages/ClassRoster.jsx b/frontend/src/pages/ClassRoster.jsx
index 80ed7d4..c0d27df 100644
--- a/frontend/src/pages/ClassRoster.jsx
+++ b/frontend/src/pages/ClassRoster.jsx
@@ -4,6 +4,7 @@ import { Users, Zap, Flame, CheckSquare, Square, Mail, SlidersHorizontal, Trendi
import useCourseStore from '../store/courseStore';
import useEnrollmentStore from '../store/enrollmentStore';
import api from '../utils/api';
+import { clickableProps } from '../utils/a11y';
const MS_PER_DAY = 1000 * 60 * 60 * 24;
const daysSince = (date) => date ? Math.floor((Date.now() - new Date(date)) / MS_PER_DAY) : null;
@@ -257,7 +258,8 @@ const ClassRoster = () => {
return (
toggleSelect(student._id)}
+ {...clickableProps(() => toggleSelect(student._id), `Select ${student.name || 'student'}`)}
+ aria-pressed={isChecked}
className={`relative bg-white dark:bg-white/5 border-2 rounded-3xl p-5 cursor-pointer transition-all duration-200 hover:shadow-lg group ${
isChecked
? 'border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.15)]'
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index ea23dcc..8e20f81 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -21,6 +21,9 @@ import XpHistoryChart from '../components/gamification/XpHistoryChart';
import LoadingSkeleton from '../components/common/LoadingSkeleton';
import EmptyState from '../components/common/EmptyState';
import ProgressBar from '../components/common/ProgressBar';
+import Button from '../components/common/Button';
+import StatCard from '../components/common/StatCard';
+import { clickableProps } from '../utils/a11y';
import { calculateLevel } from '../utils/gamification';
import { XP_PER_LEVEL } from '../constants/gamification';
@@ -33,22 +36,6 @@ const getGreeting = () => {
return 'Good night';
};
-const StatCard = ({ icon, value, label, color, glow }) => {
- const Icon = icon;
- return (
-
-
-
- {value}
- {label}
-
- );
-};
-
const CourseProgressCard = ({ courses, navigate }) => {
if (!courses.length) return null;
@@ -76,7 +63,7 @@ const CourseProgressCard = ({ courses, navigate }) => {
key={course.id || course._id}
whileHover={{ x: 3 }}
className="group/course cursor-pointer"
- onClick={() => navigate(`/course/${course.id || course._id}`)}
+ {...clickableProps(() => navigate(`/course/${course.id || course._id}`), `Open course ${course.title}`)}
>
@@ -216,14 +203,14 @@ const Dashboard = () => {
}
-
navigate('/my-courses')}
- className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-400 hover:to-amber-400 text-white text-sm font-bold rounded-2xl shadow-lg shadow-orange-500/25 transition-all hover:shadow-orange-500/40 hover:-translate-y-0.5 shrink-0"
+ icon={BookOpen}
+ iconAfter={ArrowRight}
+ className="shrink-0"
>
-
Browse Courses
-
-
+
{/* ── Stats Strip ── */}
@@ -284,9 +271,12 @@ const Dashboard = () => {
)}
setIsRoadmapCollapsed(v => !v)}
className="w-7 h-7 rounded-xl bg-slate-200/70 dark:bg-white/10 hover:bg-slate-300/70 dark:hover:bg-white/20 flex items-center justify-center text-slate-500 dark:text-white/50 transition-colors"
title={isRoadmapCollapsed ? 'Expand' : 'Collapse'}
+ aria-label={isRoadmapCollapsed ? 'Expand learning path' : 'Collapse learning path'}
+ aria-expanded={!isRoadmapCollapsed}
>
{isRoadmapCollapsed ? : }
diff --git a/frontend/src/pages/ManagedCourses.jsx b/frontend/src/pages/ManagedCourses.jsx
index 1c239a2..88a8d7f 100644
--- a/frontend/src/pages/ManagedCourses.jsx
+++ b/frontend/src/pages/ManagedCourses.jsx
@@ -4,10 +4,11 @@ import InstructorLayout from '../components/layout/InstructorLayout';
import useCourseStore from '../store/courseStore';
import useEnrollmentStore from '../store/enrollmentStore';
import { getDeptStyle } from '../utils/departmentStyles';
+import Button from '../components/common/Button';
import {
Search, BookOpen, Users, ArrowRight, Loader2, Trash2,
Zap, Star, Layers, Check, X, Clock, AlertCircle,
- UserCheck, ChevronDown, Pencil, Save
+ UserCheck, ChevronDown, Pencil, Save, Plus
} from 'lucide-react';
const COURSE_AVATARS = [
@@ -123,17 +124,10 @@ const EditCourseModal = ({ course, onClose, onSave }) => {
{/* Footer */}
-
- Cancel
-
-
- {saving ? : }
+ Cancel
+
{saving ? 'Saving…' : 'Save Changes'}
-
+
@@ -390,12 +384,14 @@ const ManagedCourses = () => {
className="w-full pl-11 pr-4 py-3 rounded-2xl border border-slate-200 dark:border-white/10 bg-white/70 dark:bg-black/30 backdrop-blur-md text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-white/40 focus:border-[#D97757] focus:bg-white dark:focus:bg-slate-900/60 focus:outline-none transition-all shadow-sm focus:shadow-md dark:shadow-inner"
/>
- navigate('/instructor/create')}
- className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-6 py-3 rounded-2xl font-bold shadow-[0_4px_15px_rgba(124,58,237,0.35)] hover:shadow-[0_6px_20px_rgba(124,58,237,0.5)] hover:scale-[1.02] active:scale-[0.98] transition-all whitespace-nowrap flex items-center gap-2 text-sm"
+ icon={Plus}
+ className="whitespace-nowrap"
>
- + New Course
-
+ New Course
+
)}
diff --git a/frontend/src/pages/StudyShop.jsx b/frontend/src/pages/StudyShop.jsx
index 4d1957a..eca7b2c 100644
--- a/frontend/src/pages/StudyShop.jsx
+++ b/frontend/src/pages/StudyShop.jsx
@@ -7,6 +7,7 @@ import { Coins, ShoppingBag, Zap, HelpCircle, Clock, Star } from 'lucide-react';
import { motion, useSpring, useTransform, AnimatePresence } from 'framer-motion';
import ConfettiEffect from '../components/gamification/ConfettiEffect';
import ItemPreviewModal from '../components/shop/ItemPreviewModal';
+import { clickableProps } from '../utils/a11y';
// ── Sub-components ────────────────────────────────────────────────────────
@@ -36,7 +37,7 @@ const ItemCard = ({ item, category, isOwned, ownedCount, onPreview }) => {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
- onClick={() => onPreview(item, category)}
+ {...clickableProps(() => onPreview(item, category), `Preview ${item.name}`)}
className="group relative bg-white dark:bg-white/[0.03] border border-slate-200/60 dark:border-white/[0.06] rounded-2xl p-4 flex items-center justify-between gap-4 cursor-pointer hover:border-indigo-400/40 dark:hover:border-indigo-500/30 transition-all hover:shadow-lg"
style={{ '--accent': r.color }}
>
@@ -117,7 +118,7 @@ const FeaturedBanner = ({ item, coins, isOwned, onPreview }) => {
animate={{ opacity: 1, y: 0 }}
className="relative rounded-3xl overflow-hidden border cursor-pointer group"
style={{ borderColor: `${r.color}33`, background: `linear-gradient(135deg, ${r.color}11 0%, transparent 60%)` }}
- onClick={() => onPreview(item, item.category)}
+ {...clickableProps(() => onPreview(item, item.category), `Preview featured item ${item.name}`)}
>
diff --git a/frontend/src/utils/a11y.js b/frontend/src/utils/a11y.js
new file mode 100644
index 0000000..868e1bd
--- /dev/null
+++ b/frontend/src/utils/a11y.js
@@ -0,0 +1,23 @@
+/**
+ * Accessibility helpers for non-native interactive elements.
+ *
+ * When a `
`/`` must stay a div (e.g. for layout animations)
+ * but behaves like a button, spread `clickableProps()` onto it so it is
+ * reachable and operable by keyboard and announced correctly to screen readers.
+ *
+ * …
+ *
+ * Adds: role="button", tabIndex, and Enter/Space activation.
+ */
+export const clickableProps = (onClick, label) => ({
+ role: 'button',
+ tabIndex: 0,
+ 'aria-label': label,
+ onClick,
+ onKeyDown: (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onClick?.(e);
+ }
+ },
+});