mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
3487666481
commit
68a49091f8
4 changed files with 192 additions and 3 deletions
138
packages/memory-graph/src/__tests__/reduced-motion.test.ts
Normal file
138
packages/memory-graph/src/__tests__/reduced-motion.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
packages/memory-graph/src/canvas/reduced-motion.ts
Normal file
18
packages/memory-graph/src/canvas/reduced-motion.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue