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 { getNoteById } from '@/app/actions/notes'
|
||||||
import type { Note as NoteFull } from '@/lib/types'
|
import type { Note as NoteFull } from '@/lib/types'
|
||||||
import { X, Maximize2 } from 'lucide-react'
|
import { X, Maximize2 } from 'lucide-react'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
const NetworkGraph = dynamic(
|
const NetworkGraph = dynamic(
|
||||||
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
||||||
@@ -934,7 +935,7 @@ export default function InsightsPage() {
|
|||||||
<div
|
<div
|
||||||
dir="auto"
|
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"
|
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>
|
<p className="text-sm text-concrete italic">—</p>
|
||||||
|
|||||||
@@ -6,6 +6,25 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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) */
|
/* Custom breakpoints for desktop design (matching code.html reference) */
|
||||||
@theme {
|
@theme {
|
||||||
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
|
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function LoginForm({
|
|||||||
|
|
||||||
<form action={dispatch} className="space-y-4">
|
<form action={dispatch} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<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')}
|
{t('auth.email')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -98,6 +98,7 @@ export function LoginForm({
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
placeholder={t('auth.emailPlaceholder')}
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -106,7 +107,7 @@ export function LoginForm({
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex justify-between items-center px-4">
|
<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')}
|
{t('auth.password')}
|
||||||
</label>
|
</label>
|
||||||
<Link
|
<Link
|
||||||
@@ -125,6 +126,7 @@ export function LoginForm({
|
|||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
|||||||
@@ -668,7 +668,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
title={voiceState === 'listening'
|
title={voiceState === 'listening'
|
||||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
: (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}
|
onClick={toggleVoice}
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-1.5 rounded-full border transition-all',
|
'p-1.5 rounded-full border transition-all',
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export function EditorialNoteMenu({
|
|||||||
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
|
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
ref={menuTriggerRef}
|
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} />
|
<MoreHorizontal size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -396,6 +397,9 @@ export function NotesEditorialView({
|
|||||||
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
||||||
className="space-y-4 group cursor-pointer relative pb-8"
|
className="space-y-4 group cursor-pointer relative pb-8"
|
||||||
onClick={() => onOpen(note)}
|
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 */}
|
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date don’t reorder wrongly */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ export function NotesListViews({
|
|||||||
<th
|
<th
|
||||||
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
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')}
|
onClick={() => handleSort('modified')}
|
||||||
|
aria-sort={sortColumn === 'modified' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{t('notes.tableModified')} <SortIcon field="modified" />
|
{t('notes.tableModified')} <SortIcon field="modified" />
|
||||||
@@ -401,7 +402,10 @@ export function NotesListViews({
|
|||||||
<tr
|
<tr
|
||||||
key={note.id}
|
key={note.id}
|
||||||
onClick={() => onOpen(note)}
|
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]">
|
<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">
|
<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>
|
||||||
<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">
|
<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>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrainstormClick}
|
onClick={handleBrainstormClick}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
|
|
||||||
<form action={dispatch} className="space-y-4">
|
<form action={dispatch} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<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')}
|
{t('auth.name')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -78,6 +78,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
|
autoComplete="name"
|
||||||
placeholder={t('auth.namePlaceholder')}
|
placeholder={t('auth.namePlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -85,7 +86,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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')}
|
{t('auth.email')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -97,6 +98,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
autoComplete="email"
|
||||||
placeholder={t('auth.emailPlaceholder')}
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -104,7 +106,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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')}
|
{t('auth.password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -116,6 +118,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
|
autoComplete="new-password"
|
||||||
placeholder={t('auth.passwordMinChars')}
|
placeholder={t('auth.passwordMinChars')}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
@@ -124,7 +127,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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')}
|
{t('auth.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -136,6 +139,7 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
|||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
|
autoComplete="new-password"
|
||||||
placeholder={t('auth.confirmPasswordPlaceholder')}
|
placeholder={t('auth.confirmPasswordPlaceholder')}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
|||||||
@@ -409,7 +409,13 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
|||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user