fix(insights): a11y + UX Pro Max audit — accessible list view, reduced-motion, focus, lazy-load
Accessibility (CRITIQUE per UI/UX Pro Max skill): - NetworkGraph Accessibility Grade D → added accessible List view alternative (toggle Graph/List with cluster→notes table, keyboard navigable) - aria-label text summary on graph container for screen readers - role=button + tabIndex + onKeyDown on bridge note cards (keyboard accessible) - focus-visible:ring on all interactive cards (isolated clusters, bridges, list items) UX (HIGH): - prefers-reduced-motion: whileHover disabled when user prefers reduced motion - cursor-pointer verified + focus-visible:ring-ochre on all clickable cards - Mobile sidebar: hamburger Menu button in header (dispatches open-mobile-sidebar) Performance (MEDIUM): - NetworkGraph lazy-loaded via next/dynamic (D3 ~200KB deferred, ssr:false) - Loading spinner shown while D3 chunk loads i18n: - listView, graphAriaLabel, listAriaLabel added to 15 locales
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { NetworkGraph } from '@/components/network-graph'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
||||
import {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
@@ -19,10 +19,25 @@ import {
|
||||
ChevronRight,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Menu,
|
||||
Network,
|
||||
List,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
|
||||
const NetworkGraph = dynamic(
|
||||
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw className="animate-spin text-ochre/40" size={32} />
|
||||
</div>
|
||||
),
|
||||
ssr: false,
|
||||
}
|
||||
)
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
title: string | null
|
||||
@@ -82,7 +97,9 @@ export default function InsightsPage() {
|
||||
const [isStale, setIsStale] = useState(false)
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string>('')
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData()
|
||||
@@ -244,14 +261,26 @@ export default function InsightsPage() {
|
||||
router.push(`/home?openNote=${noteId}`)
|
||||
}
|
||||
|
||||
// ─── Rendu ───────────────────────────────────────────────────────────────────
|
||||
const motionConfig = prefersReducedMotion
|
||||
? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
|
||||
: {}
|
||||
|
||||
// ─── Rendu ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-hidden">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#0D0D0D]/80 backdrop-blur-md z-30 shrink-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="lg:hidden p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
aria-label={t('sidebar.openNavigation') || 'Open navigation'}
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center text-ochre">
|
||||
<Sparkles size={18} />
|
||||
@@ -269,6 +298,7 @@ export default function InsightsPage() {
|
||||
{t('insightsView.openGraphMap')} <ArrowRight size={9} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:justify-end gap-3">
|
||||
@@ -383,23 +413,106 @@ export default function InsightsPage() {
|
||||
{!loading && clusters.length > 0 && !isCalculating && (
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
|
||||
{/* ── Graphe (gauche) ── */}
|
||||
{/* ── Graphe / Liste accessible (gauche) ── */}
|
||||
<div
|
||||
className={`flex-[1.4] p-6 relative min-h-0 ${
|
||||
viewMode === 'graph' ? 'block' : 'hidden lg:block'
|
||||
className={`flex-[1.4] p-6 relative min-h-0 flex flex-col ${
|
||||
viewMode === 'graph' ? 'block lg:flex' : 'hidden lg:flex'
|
||||
}`}
|
||||
>
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={handleNoteClick}
|
||||
selectedClusterId={selectedClusterId}
|
||||
onClusterSelect={setSelectedClusterId}
|
||||
untitledLabel={t('insightsView.unknownNote')}
|
||||
resetFocusLabel={t('insightsView.resetFocus')}
|
||||
fitViewLabel={t('insightsView.fitGraphView')}
|
||||
/>
|
||||
{/* Toggle visual/list accessible */}
|
||||
<div className="flex items-center gap-1 mb-3 shrink-0">
|
||||
<button
|
||||
onClick={() => setGraphMode('visual')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
|
||||
graphMode === 'visual'
|
||||
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
|
||||
: 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Network size={12} /> {t('insightsView.viewGraph')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGraphMode('list')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
|
||||
graphMode === 'list'
|
||||
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
|
||||
: 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<List size={12} /> {t('insightsView.listView') || 'List'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Vue visuelle (D3) */}
|
||||
{graphMode === 'visual' && (
|
||||
<div
|
||||
className="flex-1 min-h-0"
|
||||
role="img"
|
||||
aria-label={t('insightsView.graphAriaLabel', {
|
||||
clusters: clusters.length,
|
||||
notes: notes.length,
|
||||
bridges: bridgeNotes.length,
|
||||
}) || `Semantic network: ${clusters.length} clusters, ${notes.length} notes, ${bridgeNotes.length} bridges`}
|
||||
>
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={handleNoteClick}
|
||||
selectedClusterId={selectedClusterId}
|
||||
onClusterSelect={setSelectedClusterId}
|
||||
untitledLabel={t('insightsView.unknownNote')}
|
||||
resetFocusLabel={t('insightsView.resetFocus')}
|
||||
fitViewLabel={t('insightsView.fitGraphView')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vue liste accessible (a11y fallback) */}
|
||||
{graphMode === 'list' && (
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto custom-scrollbar space-y-4"
|
||||
role="region"
|
||||
aria-label={t('insightsView.listAriaLabel') || 'Accessible cluster list'}
|
||||
>
|
||||
{clusters.map(cluster => {
|
||||
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
|
||||
const clusterBridges = bridgeNotes.filter(b =>
|
||||
b.clustersConnected?.some(cid => String(cid) === cluster.id)
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: cluster.color }} />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink">
|
||||
{cluster.name || t('insightsView.clusterFallback', { index: cluster.clusterId })}
|
||||
</h3>
|
||||
<span className="text-[9px] text-concrete ml-auto shrink-0">
|
||||
{clusterNotes.length} {t('insightsView.graphNotesLabel')}
|
||||
{clusterBridges.length > 0 && ` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{clusterNotes.map(note => (
|
||||
<li key={note.id}>
|
||||
<button
|
||||
onClick={() => handleNoteClick(note.id)}
|
||||
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-xs text-ink dark:text-dark-ink flex items-center gap-2 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronRight size={11} className="text-concrete shrink-0" />
|
||||
<span className="truncate">{note.title || t('insightsView.unknownNote')}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Dashboard (droite) ── */}
|
||||
@@ -603,9 +716,9 @@ export default function InsightsPage() {
|
||||
{isolatedClusters.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
whileHover={{ y: -1 }}
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
onClick={() => setSelectedClusterId(c.id)}
|
||||
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all"
|
||||
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
@@ -639,9 +752,12 @@ export default function InsightsPage() {
|
||||
{bridgeList.map(bridge => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
whileHover={prefersReducedMotion ? undefined : { x: 4 }}
|
||||
onClick={() => handleNoteClick(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group"
|
||||
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 gap-4">
|
||||
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "مجدول",
|
||||
"lastSync": "آخر مزامنة"
|
||||
},
|
||||
"resetFocus": "إعادة ضبط التركيز"
|
||||
"resetFocus": "إعادة ضبط التركيز",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Geplant",
|
||||
"lastSync": "Letzte Sync"
|
||||
},
|
||||
"resetFocus": "Fokus zurücksetzen"
|
||||
"resetFocus": "Fokus zurücksetzen",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3532,7 +3532,10 @@
|
||||
"scheduledCron": "Scheduled",
|
||||
"lastSync": "Last sync"
|
||||
},
|
||||
"resetFocus": "Reset focus"
|
||||
"resetFocus": "Reset focus",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
},
|
||||
"consent": {
|
||||
"banner": {
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Programado",
|
||||
"lastSync": "Última sync"
|
||||
},
|
||||
"resetFocus": "Restablecer enfoque"
|
||||
"resetFocus": "Restablecer enfoque",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3041,6 +3041,9 @@
|
||||
"scheduledCron": "برنامهریزیشده",
|
||||
"lastSync": "آخرین همگامسازی"
|
||||
},
|
||||
"resetFocus": "بازنشانی تمرکز"
|
||||
"resetFocus": "بازنشانی تمرکز",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3536,7 +3536,10 @@
|
||||
"scheduledCron": "CRON planifié",
|
||||
"lastSync": "Dernière synchro"
|
||||
},
|
||||
"resetFocus": "Réinitialiser focus"
|
||||
"resetFocus": "Réinitialiser focus",
|
||||
"listView": "Liste",
|
||||
"graphAriaLabel": "Réseau sémantique : {clusters} thèmes, {notes} notes, {bridges} notes-ponts. Basculez en vue Liste pour une navigation accessible.",
|
||||
"listAriaLabel": "Liste accessible des clusters avec notes et connexions ponts"
|
||||
},
|
||||
"consent": {
|
||||
"banner": {
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "अनुसूचित",
|
||||
"lastSync": "अंतिम सिंक"
|
||||
},
|
||||
"resetFocus": "फोकस रीसेट करें"
|
||||
"resetFocus": "फोकस रीसेट करें",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Programmato",
|
||||
"lastSync": "Ultima sync"
|
||||
},
|
||||
"resetFocus": "Reimposta focus"
|
||||
"resetFocus": "Reimposta focus",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "予定済み",
|
||||
"lastSync": "前回同期"
|
||||
},
|
||||
"resetFocus": "フォーカス解除"
|
||||
"resetFocus": "フォーカス解除",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "예약됨",
|
||||
"lastSync": "마지막 동기화"
|
||||
},
|
||||
"resetFocus": "포커스 해제"
|
||||
"resetFocus": "포커스 해제",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Gepland",
|
||||
"lastSync": "Laatste sync"
|
||||
},
|
||||
"resetFocus": "Focus resetten"
|
||||
"resetFocus": "Focus resetten",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Zaplanowano",
|
||||
"lastSync": "Ostatnia sync"
|
||||
},
|
||||
"resetFocus": "Resetuj fokus"
|
||||
"resetFocus": "Resetuj fokus",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Programado",
|
||||
"lastSync": "Última sync"
|
||||
},
|
||||
"resetFocus": "Repor foco"
|
||||
"resetFocus": "Repor foco",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "Запланировано",
|
||||
"lastSync": "Последняя sync"
|
||||
},
|
||||
"resetFocus": "Сбросить фокус"
|
||||
"resetFocus": "Сбросить фокус",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3002,6 +3002,9 @@
|
||||
"scheduledCron": "已计划",
|
||||
"lastSync": "上次同步"
|
||||
},
|
||||
"resetFocus": "重置焦点"
|
||||
"resetFocus": "重置焦点",
|
||||
"listView": "List",
|
||||
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
|
||||
"listAriaLabel": "Accessible cluster list with notes and bridge connections"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user