feat: Adjust roadmap priorities to elevate accessibility and implement ARIA attributes in AppHeader for improved screen reader support.

This commit is contained in:
2026-02-25 16:06:52 -03:00
parent 44b2160590
commit 1868f64415
5 changed files with 100 additions and 42 deletions
+1 -2
View File
@@ -115,7 +115,6 @@
- [x] **Ecosistema de Integración**:
- [x] **SSO (Single Sign-On)**: Soporte completo OIDC (Google, Okta, Azure AD) (Completado)
- [x] **LTI 1.3 Tool Provider**: Integración segura con LMS externos como Canvas o Moodle (Completado)
- [ ] **Apps Móviles**: (Postpuesto por ahora)
- [ ] **Accesibilidad**: Auditoría y correcciones WCAG 2.1
## Fase 9: Portabilidad de Cursos ✅
@@ -224,6 +223,6 @@
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3 con soporte para Deep Linking**, **Analíticas Predictivas de Riesgo de Abandono**, **Integración de Videoconferencia (Jitsi)** y **Portafolios con Perfiles Públicos**.
**Próximas Prioridades**:
1. **Apps Móviles**: Desarrollo de versiones nativas para iOS y Android.
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos.
3. **IA Generativa Avanzada**: Generación automática de contenido de video a partir de guiones.
+28 -16
View File
@@ -17,7 +17,7 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLUListElement>(null);
useEffect(() => {
// Load session from localStorage on mount
@@ -62,19 +62,24 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 right-6 w-14 h-14 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-500/40 flex items-center justify-center hover:scale-110 transition-all z-[100] group"
title="Abrir Tutor de IA"
aria-label="Abrir Tutor de IA"
aria-expanded="false"
>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" />
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" />
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 border-2 border-black rounded-full animate-pulse" aria-hidden="true" />
<MessageSquare className="w-6 h-6 group-hover:rotate-12 transition-transform" aria-hidden="true" />
</button>
);
}
return (
<div className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-black/80 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden">
<div
className="fixed bottom-24 right-6 w-80 md:w-96 h-[500px] glass bg-black/80 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl flex flex-col z-[200] animate-in slide-in-from-bottom-6 duration-500 overflow-hidden"
role="dialog"
aria-label="Tutor de IA"
>
{/* Header */}
<div className="p-4 border-b border-white/5 bg-blue-600/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3" aria-hidden="true">
<div className="w-10 h-10 rounded-xl bg-blue-500 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Bot className="w-6 h-6 text-white" />
</div>
@@ -89,38 +94,43 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-white/5 rounded-lg text-gray-400 hover:text-white transition-colors"
aria-label="Cerrar Tutor de IA"
>
<X size={20} />
</button>
</div>
{/* Messages */}
<div
<ul
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide"
aria-live="polite"
aria-relevant="additions"
>
{messages.map((msg, i) => (
<div
<li
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-2 max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${msg.role === 'user' ? 'bg-white/5' : 'bg-blue-600/20 text-blue-400'}`}>
<div className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${msg.role === 'user' ? 'bg-white/5' : 'bg-blue-600/20 text-blue-400'}`} aria-hidden="true">
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed ${msg.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white/5 text-gray-200 border border-white/5 rounded-tl-none'
}`}>
}`}
aria-label={msg.role === 'user' ? 'Tú dijiste' : 'El tutor dijo'}
>
{msg.content}
</div>
</div>
</div>
</li>
))}
{isLoading && (
<div className="flex justify-start animate-in fade-in duration-300">
<li className="flex justify-start animate-in fade-in duration-300" aria-busy="true">
<div className="flex gap-2 max-w-[85%]">
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-400 flex items-center justify-center">
<div className="shrink-0 w-8 h-8 rounded-lg bg-blue-600/20 text-blue-400 flex items-center justify-center" aria-hidden="true">
<Bot size={16} />
</div>
<div className="bg-white/5 text-gray-400 border border-white/5 p-3 rounded-2xl rounded-tl-none flex items-center gap-2">
@@ -128,9 +138,9 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
<span className="text-[10px] font-bold uppercase tracking-widest">El tutor está pensando...</span>
</div>
</div>
</div>
</li>
)}
</div>
</ul>
{/* Input */}
<div className="p-4 border-t border-white/5 bg-black/40">
@@ -141,14 +151,16 @@ export default function AITutor({ lessonId }: { lessonId: string }) {
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Escribe tu duda aquí..."
aria-label="Escribe tu duda para el tutor"
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 px-4 pr-12 text-xs font-medium focus:outline-none focus:border-blue-500/50 transition-colors placeholder:text-gray-600"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="absolute right-2 top-1.5 p-1.5 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:bg-gray-600 transition-all hover:bg-blue-500"
aria-label="Enviar mensaje"
>
<Send size={16} />
<Send size={16} aria-hidden="true" />
</button>
</div>
<p className="mt-2 text-[9px] text-gray-600 font-bold uppercase tracking-widest text-center">
+16 -5
View File
@@ -22,12 +22,12 @@ export default function AppHeader() {
return (
<header className="h-16 glass sticky top-0 z-[100] px-4 md:px-6 flex items-center justify-between backdrop-blur-xl bg-black/40 border-b border-white/5">
<Link href="/" className="flex items-center gap-2 md:gap-3 group">
<Link href="/" className="flex items-center gap-2 md:gap-3 group" aria-label={`${platformName} - Dashboard`}>
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg md:rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
{branding?.logo_url ? (
<Image src={getImageUrl(branding.logo_url)} alt={branding.name} fill className="object-contain" sizes="40px" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700">
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700" aria-hidden="true">
{platformName.charAt(0).toUpperCase()}
</div>
)}
@@ -62,10 +62,12 @@ export default function AppHeader() {
<NotificationCenter />
<div className="hidden sm:flex items-center gap-2 border-l border-white/10 pl-4">
<Globe size={14} className="text-gray-500" />
<Globe size={14} className="text-gray-500" aria-hidden="true" />
<select
id="language-selector"
value={language}
onChange={(e) => setLanguage(e.target.value)}
aria-label={t('nav.selectLanguage') || 'Select Language'}
className="bg-transparent text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors focus:outline-none cursor-pointer"
>
<option value="en" className="bg-[#0f1115]">EN</option>
@@ -84,6 +86,7 @@ export default function AppHeader() {
onClick={logout}
className="p-2 hover:bg-red-500/10 rounded-full text-gray-400 hover:text-red-400 transition-colors"
title={t('nav.signOut')}
aria-label={t('nav.signOut')}
>
<LogOut size={16} />
</button>
@@ -93,6 +96,8 @@ export default function AppHeader() {
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="md:hidden p-2 hover:bg-white/5 rounded-lg text-gray-400 transition-colors"
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={isMenuOpen}
>
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
@@ -105,7 +110,11 @@ export default function AppHeader() {
<div className="absolute right-0 top-0 bottom-0 w-64 glass border-l border-white/10 p-6 flex flex-col animate-in slide-in-from-right duration-300">
<div className="flex justify-between items-center mb-8">
<span className="font-black text-xs uppercase tracking-[0.2em] text-gray-500">Menú</span>
<button onClick={() => setIsMenuOpen(false)} className="p-2 hover:bg-white/5 rounded-lg">
<button
onClick={() => setIsMenuOpen(false)}
className="p-2 hover:bg-white/5 rounded-lg"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
@@ -144,10 +153,12 @@ export default function AppHeader() {
<div className="pt-6 mt-6 border-t border-white/5 space-y-4">
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-white/5">
<Globe size={16} className="text-gray-500" />
<Globe size={16} className="text-gray-500" aria-hidden="true" />
<select
id="mobile-language-selector"
value={language}
onChange={(e) => setLanguage(e.target.value)}
aria-label={t('nav.selectLanguage') || 'Select Language'}
className="bg-transparent text-xs font-bold uppercase tracking-widest text-gray-300 focus:outline-none flex-1"
>
<option value="en" className="bg-[#0f1115]">English</option>
@@ -60,10 +60,16 @@ export default function NotificationCenter() {
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 rounded-xl glass border-white/10 text-gray-400 hover:text-white transition-all hover:bg-white/5"
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
aria-haspopup="true"
aria-expanded={isOpen}
>
<Bell size={20} />
<Bell size={20} aria-hidden="true" />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white text-[10px] font-black rounded-full flex items-center justify-center border-2 border-[#1a1c20] -translate-to-1/4">
<span
className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white text-[10px] font-black rounded-full flex items-center justify-center border-2 border-[#1a1c20] -translate-to-1/4"
aria-hidden="true"
>
{unreadCount}
</span>
)}
@@ -72,10 +78,18 @@ export default function NotificationCenter() {
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute right-0 mt-4 w-96 glass-card border-white/10 z-50 shadow-2xl animate-in fade-in zoom-in-95 duration-200 overflow-hidden bg-[#1a1c20]/95 backdrop-blur-xl">
<div
className="absolute right-0 mt-4 w-96 glass-card border-white/10 z-50 shadow-2xl animate-in fade-in zoom-in-95 duration-200 overflow-hidden bg-[#1a1c20]/95 backdrop-blur-xl"
role="dialog"
aria-label="Notifications Panel"
>
<div className="p-4 border-b border-white/5 flex items-center justify-between bg-white/5">
<h3 className="text-sm font-black uppercase tracking-widest text-white">Notificaciones</h3>
<button onClick={() => setIsOpen(false)} className="text-gray-500 hover:text-white p-1">
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-white p-1"
aria-label="Close notifications"
>
<X size={18} />
</button>
</div>
@@ -86,15 +100,23 @@ export default function NotificationCenter() {
) : notifications.length === 0 ? (
<div className="p-12 text-center text-gray-500 text-xs font-bold uppercase tracking-widest italic">No hay notificaciones</div>
) : (
<div className="divide-y divide-white/5">
<ul className="divide-y divide-white/5">
{notifications.map((n) => (
<div
<li
key={n.id}
className={`p-4 hover:bg-white/5 transition-all group ${!n.is_read ? 'bg-blue-500/5' : ''}`}
role="button"
tabIndex={0}
className={`p-4 hover:bg-white/5 transition-all group cursor-pointer outline-none focus:bg-white/5 ${!n.is_read ? 'bg-blue-500/5' : ''}`}
onClick={() => !n.is_read && markAsRead(n.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
!n.is_read && markAsRead(n.id);
}
}}
aria-label={`Notification: ${n.title}. ${n.message}`}
>
<div className="flex gap-4">
<div className="mt-1">{getIcon(n.notification_type)}</div>
<div className="mt-1" aria-hidden="true">{getIcon(n.notification_type)}</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-bold text-white leading-tight">{n.title}</p>
<p className="text-xs text-gray-400 leading-relaxed">{n.message}</p>
@@ -106,7 +128,10 @@ export default function NotificationCenter() {
<Link
href={n.link_url}
className="text-[10px] font-black uppercase tracking-widest text-blue-400 hover:text-blue-300 flex items-center gap-1"
onClick={() => setIsOpen(false)}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
Ver detalles
</Link>
@@ -114,9 +139,9 @@ export default function NotificationCenter() {
</div>
</div>
</div>
</div>
</li>
))}
</div>
</ul>
)}
</div>
@@ -76,9 +76,13 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
<div className="space-y-8">
{questions.map((q) => (
<div key={q.id} className="space-y-4 p-8 glass border-white/5 rounded-3xl">
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
<div className="grid gap-3">
<fieldset key={q.id} className="space-y-4 p-8 glass border-white/5 rounded-3xl" aria-labelledby={`q-${q.id}-text`}>
<legend id={`q-${q.id}-text`} className="font-bold text-xl text-gray-100 leading-tight mb-4">{q.question}</legend>
<div
className="grid gap-3"
role={q.type === 'multiple-select' ? 'group' : 'radiogroup'}
aria-label="Opciones de respuesta"
>
{q.options.map((opt, oIdx) => {
const isSelected = userAnswers[q.id]?.includes(oIdx);
const isCorrect = q.correct?.includes(oIdx);
@@ -99,20 +103,27 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
return (
<button
key={oIdx}
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
aria-checked={isSelected}
aria-disabled={submitted}
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 ${style}`}
>
<div className="flex items-center justify-between">
<span>{opt}</span>
{submitted && isActuallyCorrect && <span></span>}
{submitted && isWrongSelection && <span></span>}
{submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Respuesta Correcta</span>}
{submitted && (
<div className="flex items-center gap-2">
{isActuallyCorrect && <span role="img" aria-label="Correcto"></span>}
{isWrongSelection && <span role="img" aria-label="Incorrecto"></span>}
{missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter text-orange-400">Respuesta Correcta</span>}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
</fieldset>
))}
{allowRetry && (