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 00000000..e3847d73 --- /dev/null +++ b/packages/memory-graph/src/__tests__/reduced-motion.test.ts @@ -0,0 +1,140 @@ +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.settle() + 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 00000000..89b75535 --- /dev/null +++ b/packages/memory-graph/src/canvas/reduced-motion.ts @@ -0,0 +1,9 @@ +// SSR-safe; 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 01bca6b0..a221241e 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 @@ -67,7 +68,11 @@ export class ForceSimulation { : FORCE_CONFIG.preSettleTicks for (let i = 0; i < preSettleTicks; i++) this.sim.tick() - if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) { + // Dense graphs stay static for perf; reduced-motion reuses that path. + if ( + nodes.length > DENSE_GRAPH_STATIC_THRESHOLD || + prefersReducedMotion() + ) { this.stop() } else { this.settle() @@ -87,6 +92,8 @@ export class ForceSimulation { } reheat(): void { + // Dragged node still moves via InputHandler; neighbours stay put. + if (prefersReducedMotion()) return this.holdingHeat = true this.sim?.on("tick.settle", null) this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart() @@ -94,7 +101,7 @@ export class ForceSimulation { settle(): void { const sim = this.sim - if (!sim || this.holdingHeat) return + if (!sim || this.holdingHeat || prefersReducedMotion()) return let ticks = 0 let stableTicks = 0 sim diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index 648b6ce8..cb970593 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -21,6 +21,16 @@ export class ViewportState { private static readonly MAX_ZOOM = 5.0 private minZoom = ViewportState.DEFAULT_MIN_ZOOM + // Cached query; `.matches` still tracks live OS changes. + 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 @@ -59,6 +69,7 @@ export class ViewportState { } releaseWithVelocity(vx: number, vy: number): void { + if (this.reducedMotion) return this.velocityX = vx this.velocityY = vy } @@ -133,6 +144,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) { @@ -149,7 +161,7 @@ export class ViewportState { const zoomDiff = this.targetZoom - this.zoom if (Math.abs(zoomDiff) > this.zoom * 0.001) { const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY) - this.zoom += zoomDiff * this.zoomSpring + 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 @@ -158,11 +170,18 @@ 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 { + // Reduced-motion always snaps here, so report the jump as movement. + if ( + reduced && + (this.panX !== this.targetPanX || this.panY !== this.targetPanY) + ) { + moving = true + } this.panX = this.targetPanX this.panY = this.targetPanY this.targetPanX = null