fix(insights): a11y + UX Pro Max audit — accessible list view, reduced-motion, focus, lazy-load
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

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:
Antigravity
2026-06-28 09:24:34 +00:00
parent 40292f4c00
commit 056b0260cf
17 changed files with 200 additions and 39 deletions

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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