From 68a49091f85c8dc4f24146d81433e2352fbc40eb Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sat, 22 Aug 2026 05:12:38 +0530 Subject: [PATCH] feat(memory-graph): respect prefers-reduced-motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/__tests__/reduced-motion.test.ts | 138 ++++++++++++++++++ .../memory-graph/src/canvas/reduced-motion.ts | 18 +++ .../memory-graph/src/canvas/simulation.ts | 12 +- packages/memory-graph/src/canvas/viewport.ts | 27 +++- 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 packages/memory-graph/src/__tests__/reduced-motion.test.ts create mode 100644 packages/memory-graph/src/canvas/reduced-motion.ts diff --git a/packages/memory-graph/src/__tests__/reduced-motion.test.ts b/packages/memory-graph/src/__tests__/reduced-motion.test.ts new file mode 100644 index 000000000..26499eba9 --- /dev/null +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -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) + }) +}) diff --git a/packages/memory-graph/src/canvas/reduced-motion.ts b/packages/memory-graph/src/canvas/reduced-motion.ts new file mode 100644 index 000000000..5dc498b35 --- /dev/null +++ b/packages/memory-graph/src/canvas/reduced-motion.ts @@ -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 + } +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index b04cee274..982fa928c 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -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() } diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index c33a16810..6c16e24ae 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -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