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

This commit is contained in:
abhay-codes07 2026-10-12 21:44:26 -07:00 • committed by Mahesh Sanikommu
parent fa4ab9805a
commit cd6ea514d1
4 changed files with 179 additions and 4 deletions

View file

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

View file

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

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

View file

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