feat(memory-graph): respect prefers-reduced-motion

The graph is in near-constant motion — the force simulation animates nodes into
place on load and keeps settling, panning has fling momentum, and zoom/centering
ease via a spring. There was no path for users who set "reduce motion", even
though the rest of the app honors it (loaders, confetti, the review modal).

Honor prefers-reduced-motion in the two engines that drive that motion, with no
loss of functionality:

- ForceSimulation: pre-settle as usual, then stay static instead of running the
  perpetual settle; reheat() (fired on drag) is a no-op. Dragging still moves the
  grabbed node directly, so interaction is unchanged — just no neighbour jiggle.
- ViewportState: drop fling momentum, and snap zoom / centering to their targets
  in a single tick instead of easing.

Detection lives in canvas/reduced-motion.ts (SSR-safe matchMedia). Behavior is
unchanged when reduce-motion is off. Added reduced-motion.test.ts (9 tests); full
package suite 205 passing, build and type-check clean.
This commit is contained in:
abhay-codes07 2026-08-22 05:12:38 +05:30
parent 3487666481
commit 68a49091f8
No known key found for this signature in database
4 changed files with 192 additions and 3 deletions

View file

@ -0,0 +1,138 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { prefersReducedMotion } from "../canvas/reduced-motion"
import { ForceSimulation } from "../canvas/simulation"
import { ViewportState } from "../canvas/viewport"
import type { GraphEdge, GraphNode } from "../types"
function stubReducedMotion(matches: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("reduce") ? matches : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
onchange: null,
dispatchEvent: () => false,
}))
}
afterEach(() => vi.unstubAllGlobals())
function makeNode(id: string, x: number, y: number): GraphNode {
return {
id,
type: "document",
x,
y,
size: 50,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories: [],
},
}
}
const nodes: GraphNode[] = [
makeNode("a", 0, 0),
makeNode("b", 100, 0),
makeNode("c", 0, 100),
]
const edges: GraphEdge[] = [
{
id: "a-b",
source: "a",
target: "b",
edgeType: "derives",
visualProps: { opacity: 1, thickness: 1 },
},
]
describe("prefersReducedMotion", () => {
it("returns false when matchMedia is unavailable", () => {
vi.stubGlobal("matchMedia", undefined)
expect(prefersReducedMotion()).toBe(false)
})
it("reflects the matchMedia result", () => {
stubReducedMotion(true)
expect(prefersReducedMotion()).toBe(true)
stubReducedMotion(false)
expect(prefersReducedMotion()).toBe(false)
})
})
describe("ForceSimulation reduced-motion", () => {
it("leaves the layout static after init and ignores reheat", () => {
stubReducedMotion(true)
const sim = new ForceSimulation()
sim.init(nodes, edges)
expect(sim.isActive()).toBe(false)
sim.reheat()
expect(sim.isActive()).toBe(false)
sim.destroy()
})
it("keeps the simulation running after init when motion is allowed", () => {
stubReducedMotion(false)
const sim = new ForceSimulation()
sim.init(nodes, edges)
expect(sim.isActive()).toBe(true)
sim.destroy()
})
})
describe("ViewportState reduced-motion", () => {
it("drops fling momentum under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)
vp.releaseWithVelocity(50, 50)
vp.tick()
expect(vp.panX).toBe(0)
expect(vp.panY).toBe(0)
})
it("keeps fling momentum when motion is allowed", () => {
stubReducedMotion(false)
const vp = new ViewportState(0, 0, 1)
vp.releaseWithVelocity(50, 50)
vp.tick()
expect(vp.panX).toBeGreaterThan(0)
})
it("snaps zoom to target in a single tick under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)
vp.zoomTo(3, 100, 100)
vp.tick()
expect(vp.zoom).toBe(3)
})
it("eases zoom across ticks when motion is allowed", () => {
stubReducedMotion(false)
const vp = new ViewportState(0, 0, 1)
vp.zoomTo(3, 100, 100)
vp.tick()
expect(vp.zoom).toBeGreaterThan(1)
expect(vp.zoom).toBeLessThan(3)
})
it("snaps a pan target instantly under reduced motion", () => {
stubReducedMotion(true)
const vp = new ViewportState(0, 0, 1)
vp.centerOn(500, 500, 800, 600)
const moved = vp.tick()
expect(moved).toBe(true)
// target = width/2 - worldX*zoom = 400 - 500 = -100, etc.
expect(vp.panX).toBe(-100)
expect(vp.panY).toBe(-200)
})
})

View file

@ -0,0 +1,18 @@
/**
* Detects the user's `prefers-reduced-motion` setting.
*
* The graph is otherwise in constant motion (force simulation settling,
* momentum panning, spring zoom), which can be uncomfortable for people with
* vestibular / motion sensitivities. Callers use this to render a calm, static
* layout instead while keeping every interaction available.
*
* SSR-safe and defensive: returns false when `matchMedia` is unavailable.
*/
export function prefersReducedMotion(): boolean {
if (typeof globalThis.matchMedia !== "function") return false
try {
return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches
} catch {
return false
}
}

View file

@ -1,6 +1,7 @@
import * as d3 from "d3-force"
import type { DocumentNodeData, GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
import { prefersReducedMotion } from "./reduced-motion"
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
@ -69,7 +70,13 @@ export class ForceSimulation {
: FORCE_CONFIG.preSettleTicks
for (let i = 0; i < preSettleTicks; i++) this.sim.tick()
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
// A dense graph is pre-settled and left static for performance; under
// reduced-motion we do the same for comfort, so the layout appears
// already-settled instead of visibly animating into place.
if (
nodes.length > DENSE_GRAPH_STATIC_THRESHOLD ||
prefersReducedMotion()
) {
this.stop()
} else {
this.sim.alphaTarget(0).restart()
@ -89,6 +96,9 @@ export class ForceSimulation {
}
reheat(): void {
// Dragging still repositions the dragged node directly; skip the
// perpetual re-settle so neighbours don't jiggle under reduced-motion.
if (prefersReducedMotion()) return
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}

View file

@ -21,6 +21,17 @@ export class ViewportState {
private static readonly MAX_ZOOM = 5.0
private minZoom = ViewportState.DEFAULT_MIN_ZOOM
// Cached once so the per-frame check stays cheap; `.matches` still reflects
// live changes to the OS setting.
private readonly reducedMotionQuery: MediaQueryList | null =
typeof globalThis.matchMedia === "function"
? globalThis.matchMedia("(prefers-reduced-motion: reduce)")
: null
private get reducedMotion(): boolean {
return this.reducedMotionQuery?.matches ?? false
}
constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) {
this.panX = initialPanX
this.panY = initialPanY
@ -50,6 +61,8 @@ export class ViewportState {
}
releaseWithVelocity(vx: number, vy: number): void {
// No fling/momentum under reduced-motion — the pan simply stops.
if (this.reducedMotion) return
this.velocityX = vx
this.velocityY = vy
}
@ -118,6 +131,7 @@ export class ViewportState {
}
tick(): boolean {
const reduced = this.reducedMotion
let moving = false
if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) {
@ -134,7 +148,8 @@ export class ViewportState {
const zoomDiff = this.targetZoom - this.zoom
if (Math.abs(zoomDiff) > 0.001) {
const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY)
this.zoom += zoomDiff * this.zoomSpring
// Reduced-motion snaps straight to the target zoom instead of easing.
this.zoom += reduced ? zoomDiff : zoomDiff * this.zoomSpring
this.panX = this.zoomAnchorX - world.x * this.zoom
this.panY = this.zoomAnchorY - world.y * this.zoom
moving = true
@ -143,11 +158,19 @@ export class ViewportState {
if (this.targetPanX !== null && this.targetPanY !== null) {
const dx = this.targetPanX - this.panX
const dy = this.targetPanY - this.panY
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
if (!reduced && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
this.panX += dx * this.panLerp
this.panY += dy * this.panLerp
moving = true
} else {
// Snap to the target. Under reduced-motion this is the only branch,
// so report movement when the position actually changed.
if (
reduced &&
(this.panX !== this.targetPanX || this.panY !== this.targetPanY)
) {
moving = true
}
this.panX = this.targetPanX
this.panY = this.targetPanY
this.targetPanX = null