fix(ux-audit): CRITICAL + HIGH fixes — a11y, reduced-motion, forms, keyboard nav
Global (globals.css): - prefers-reduced-motion: désactive toutes les animations/transitions - focus-visible:ring global sur tous les éléments interactifs Search modal: - role="dialog" + aria-modal="true" + aria-label - onClick backdrop ferme la modale Insights peek: - DOMPurify.sanitize() sur dangerouslySetInnerHTML (XSS fix) Login form: - autoComplete="email" / "current-password" - htmlFor sur tous les labels Register form: - autoComplete="name" / "email" / "new-password" - htmlFor sur tous les labels GridCard (notes-list-views): - Actions visibles sur mobile (opacity-100 sm:opacity-0) - aria-sort sur colonne triable - role="button" + tabIndex + onKeyDown sur lignes table Editorial view: - role="button" + tabIndex + onKeyDown sur articles - Menu trigger aria-label + visible mobile Toolbar: - aria-label voice i18n
This commit is contained in:
@@ -28,6 +28,7 @@ import Link from 'next/link'
|
||||
import { getNoteById } from '@/app/actions/notes'
|
||||
import type { Note as NoteFull } from '@/lib/types'
|
||||
import { X, Maximize2 } from 'lucide-react'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const NetworkGraph = dynamic(
|
||||
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
||||
@@ -934,7 +935,7 @@ export default function InsightsPage() {
|
||||
<div
|
||||
dir="auto"
|
||||
className="prose prose-sm dark:prose-invert max-w-none text-ink dark:text-dark-ink [&_a]:text-blue-600 [&_blockquote]:border-l-blue-400"
|
||||
dangerouslySetInnerHTML={{ __html: peekNote.content }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(peekNote.content) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-concrete italic">—</p>
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
|
||||
|
||||
/* CRITICAL: prefers-reduced-motion — désactive toutes les animations */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* CRITICAL: focus-visible ring global sur tous les éléments interactifs */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Custom breakpoints for desktop design (matching code.html reference) */
|
||||
@theme {
|
||||
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
|
||||
|
||||
@@ -86,7 +86,7 @@ export function LoginForm({
|
||||
|
||||
<form action={dispatch} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -98,6 +98,7 @@ export function LoginForm({
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -106,7 +107,7 @@ export function LoginForm({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center px-4">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)]">
|
||||
<label htmlFor="password" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)]">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<Link
|
||||
@@ -125,6 +126,7 @@ export function LoginForm({
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
|
||||
@@ -668,7 +668,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? 'Stop voice' : 'Start voice'}
|
||||
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
|
||||
@@ -157,7 +157,8 @@ export function EditorialNoteMenu({
|
||||
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground"
|
||||
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
aria-label={t('notes.moreOptions') || 'Options'}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
@@ -396,6 +397,9 @@ export function NotesEditorialView({
|
||||
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
||||
className="space-y-4 group cursor-pointer relative pb-8"
|
||||
onClick={() => onOpen(note)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date don’t reorder wrongly */}
|
||||
<div
|
||||
|
||||
@@ -385,6 +385,7 @@ export function NotesListViews({
|
||||
<th
|
||||
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('modified')}
|
||||
aria-sort={sortColumn === 'modified' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableModified')} <SortIcon field="modified" />
|
||||
@@ -401,7 +402,10 @@ export function NotesListViews({
|
||||
<tr
|
||||
key={note.id}
|
||||
onClick={() => onOpen(note)}
|
||||
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onOpen(note) }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group focus-visible:bg-foreground/[0.04]"
|
||||
>
|
||||
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
|
||||
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
|
||||
@@ -781,7 +785,7 @@ const GridCard = memo(function GridCard({
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 mt-auto border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
<span>{formattedDate}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover/card:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrainstormClick}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
|
||||
<form action={dispatch} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="name" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.name')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -78,6 +78,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="name"
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -85,7 +86,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -97,6 +98,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
@@ -104,7 +106,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="password" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -116,6 +118,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('auth.passwordMinChars')}
|
||||
required
|
||||
minLength={6}
|
||||
@@ -124,7 +127,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
<label htmlFor="confirmPassword" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
{t('auth.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
@@ -136,6 +139,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('auth.confirmPasswordPlaceholder')}
|
||||
required
|
||||
minLength={6}
|
||||
|
||||
@@ -409,7 +409,13 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Recherche"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
|
||||
Reference in New Issue
Block a user