fix(ux-audit): CRITICAL + HIGH fixes — a11y, reduced-motion, forms, keyboard nav
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m24s
CI / Deploy production (on server) (push) Successful in 23s

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:
Antigravity
2026-07-04 21:37:56 +00:00
parent cf5d6a202b
commit e72ca26f97
8 changed files with 52 additions and 12 deletions

View File

@@ -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>

View File

@@ -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) */

View File

@@ -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}

View File

@@ -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',

View File

@@ -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 dont reorder wrongly */}
<div

View File

@@ -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}

View File

@@ -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}

View File

@@ -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 }}