feat(dashboard): respect prefers-reduced-motion in the base 3D graph

Disable camera auto-rotate (the dominant continuous motion) when the OS
reduce-motion setting is on; live-toggle aware via matchMedia change listener,
cleaned up on destroy. Graph stays fully usable (manual orbit/hover/select/live
events). Closes the a11y gap where 0 of ~3,200 graph LOC honoured the setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-21 23:53:40 -05:00
parent a6798c2fca
commit 95750f0a85

View file

@ -50,6 +50,20 @@
let ctx: SceneContext;
let animationId: number;
// Accessibility: honour the OS "reduce motion" setting. The dominant
// continuous motion in the graph is the camera auto-rotate; disabling it
// removes the vestibular-trigger while keeping the graph fully usable
// (manual orbit, hover, selection, live events all still work). Tracked
// reactively so a mid-session OS toggle is respected.
let prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
let reducedMotionMq: MediaQueryList | null = null;
function onReducedMotionChange(e: MediaQueryListEvent) {
prefersReducedMotion = e.matches;
if (ctx?.controls) ctx.controls.autoRotate = !prefersReducedMotion;
}
// Modules
let nodeManager: NodeManager;
let edgeManager: EdgeManager;
@ -74,6 +88,15 @@
onMount(() => {
ctx = createScene(container);
// Respect reduced-motion: the scene defaults to auto-rotate on; turn it
// off up front for users who asked for less motion, and listen for live
// OS-setting changes.
if (prefersReducedMotion) ctx.controls.autoRotate = false;
if (typeof window !== 'undefined' && window.matchMedia) {
reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
reducedMotionMq.addEventListener?.('change', onReducedMotionChange);
}
// Nebula background
const nebula = createNebulaBackground(ctx.scene);
nebulaMaterial = nebula.material;
@ -113,6 +136,7 @@
onDestroy(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
reducedMotionMq?.removeEventListener?.('change', onReducedMotionChange);
container?.removeEventListener('pointermove', onPointerMove);
container?.removeEventListener('click', onClick);
effects?.dispose();