From b2d1a616390caadd8cf66d39526ce7cf48159ca5 Mon Sep 17 00:00:00 2001 From: cbiering Date: Mon, 24 Aug 2026 11:50:47 +0200 Subject: [PATCH 1/2] feat!: cap rendering at 60fps by default and park the loop when the active speed is 0 Two rendering-cost changes measured on a real wallpaper deployment (powermetrics, M4 Pro): uncapped rAF on ProMotion displays ran the loop at 120Hz, and a speed-0 (paused) field kept repainting identical frames. - New `maxFps` config option: excess animation frames are skipped while the rAF chain stays alive; skipped time folds into the next frame's delta so delta-integrating animations stay correct. `0` disables the cap. - When the active animation's speed is exactly 0 the computed field is time-invariant (verified per-animation), so the loop now draws one frame and parks via the existing idle machinery; any update() re-wakes it. - update() now pins the frame clock when resuming from a park so delta-based animations don't integrate the whole parked stretch. - release-please: bump-minor-pre-major so this releases as 0.7.0. BREAKING CHANGE: rendering is capped at 60fps by default; pass maxFps: 0 to restore uncapped rendering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015oFoSH9ZnH1mheL8R7wNQY --- release-please-config.json | 1 + wavedance/src/core/config.test.ts | 18 +++++ wavedance/src/core/config.ts | 2 + wavedance/src/core/field.test.ts | 39 ++++++++++ wavedance/src/types.ts | 8 ++ wavedance/src/wavedance.test.ts | 124 ++++++++++++++++++++++++++++++ wavedance/src/wavedance.ts | 34 +++++++- 7 files changed, 222 insertions(+), 4 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index a46ed70..1388dbb 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,6 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "include-component-in-tag": true, + "bump-minor-pre-major": true, "packages": { "wavedance": { "component": "wavedance", diff --git a/wavedance/src/core/config.test.ts b/wavedance/src/core/config.test.ts index 763f61a..6a8d7ef 100644 --- a/wavedance/src/core/config.test.ts +++ b/wavedance/src/core/config.test.ts @@ -107,3 +107,21 @@ describe("resolveConfig devicePixelRatio", () => { expect(resolveConfig({ devicePixelRatio: 3, maxDevicePixelRatio: 2 }).devicePixelRatio).toBe(3) }) }) + +describe("resolveConfig maxFps", () => { + it("defaults maxFps to 60", () => { + expect(resolveConfig({}).maxFps).toBe(60) + }) + + it("accepts an explicit cap", () => { + expect(resolveConfig({ maxFps: 30 }).maxFps).toBe(30) + }) + + it("treats 0 as uncapped", () => { + expect(resolveConfig({ maxFps: 0 }).maxFps).toBe(0) + }) + + it("clamps negative values to 0", () => { + expect(resolveConfig({ maxFps: -5 }).maxFps).toBe(0) + }) +}) diff --git a/wavedance/src/core/config.ts b/wavedance/src/core/config.ts index 33a9c9d..babd785 100644 --- a/wavedance/src/core/config.ts +++ b/wavedance/src/core/config.ts @@ -3,6 +3,7 @@ import { normalizeHexColor } from "./color" const DEFAULT_GAP = 8 const DEFAULT_DOT_SIZE = 1 +const DEFAULT_MAX_FPS = 60 export function clampOpacity(value: number | undefined, fallback = 1): number { if (value === undefined) { @@ -80,6 +81,7 @@ export function resolveConfig(config: WavedanceConfig = {}): ResolvedWavedanceCo devicePixelRatio: config.devicePixelRatio ?? Math.min(autoDpr, maxDevicePixelRatio), maxDevicePixelRatio, maxDots: config.maxDots ?? 100_000, + maxFps: Math.max(0, config.maxFps ?? DEFAULT_MAX_FPS), respectReducedMotion: config.respectReducedMotion ?? true, } } diff --git a/wavedance/src/core/field.test.ts b/wavedance/src/core/field.test.ts index 7b841ca..3b9e96e 100644 --- a/wavedance/src/core/field.test.ts +++ b/wavedance/src/core/field.test.ts @@ -93,3 +93,42 @@ describe("FieldComputer", () => { expect(second).toBe(first) }) }) + +describe("FieldComputer static speed", () => { + const grid = buildGrid({ + width: 300, + height: 200, + dotSize: 2, + gap: { x: 12, y: 12 }, + dpr: 1, + maxDots: 10_000, + }) + + for (const animation of ["wave", "plasma", "arc", "ribbon"] as const) { + it(`${animation} is time-invariant when speed is 0`, () => { + const config = resolveConfig({ animation, [animation]: { speed: 0 } }) + const field = new FieldComputer(config.maxDots, config) + const early = Float32Array.from(field.compute(grid, 0, 16)) + const late = field.compute(grid, 5_000, 16) + + for (let i = 0; i < grid.count; i++) { + expect(late[i]).toBe(early[i]) + } + }) + } + + it("random is invariant after its first frame when speed is 0", () => { + const config = resolveConfig({ animation: "random", random: { speed: 0, seed: 11 } }) + const field = new FieldComputer(config.maxDots, config) + const first = Float32Array.from(field.compute(grid, 0, 16)) + + field.compute(grid, 5_000, 16) + const later = field.compute(grid, 10_000, 5_000) + + for (let i = 0; i < grid.count; i++) { + expect(later[i]).toBe(first[i]) + expect(later[i]).toBeGreaterThanOrEqual(config.random.minOpacity) + expect(later[i]).toBeLessThanOrEqual(config.random.maxOpacity) + } + }) +}) diff --git a/wavedance/src/types.ts b/wavedance/src/types.ts index 8b85557..956f310 100644 --- a/wavedance/src/types.ts +++ b/wavedance/src/types.ts @@ -124,6 +124,13 @@ export interface WavedanceConfig { maxDevicePixelRatio?: number /** Safety cap on dot count. Default: 100000 */ maxDots?: number + /** + * Upper bound on drawn frames per second. Excess animation frames are + * skipped (the rAF loop stays alive) so average fps ≈ `maxFps`, and the + * skipped time folds into the next frame's delta. `0` disables the cap and + * draws on every animation frame. Default: 60 + */ + maxFps?: number /** Pause animation when prefers-reduced-motion is set. Default: true */ respectReducedMotion?: boolean } @@ -146,6 +153,7 @@ export interface ResolvedWavedanceConfig { devicePixelRatio: number maxDevicePixelRatio: number maxDots: number + maxFps: number respectReducedMotion: boolean } diff --git a/wavedance/src/wavedance.test.ts b/wavedance/src/wavedance.test.ts index 42e3ef2..f212e38 100644 --- a/wavedance/src/wavedance.test.ts +++ b/wavedance/src/wavedance.test.ts @@ -119,6 +119,130 @@ describe("createWavedance", () => { ) }) + /** Fake-rAF harness: transparent background keeps clearRect as the per-draw signal. */ + function frameHarness() { + const clearRect = vi.fn() + const ctx = { + fillStyle: "", + globalAlpha: 1, + fillRect: vi.fn(), + clearRect, + } as unknown as CanvasRenderingContext2D + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx) + + const scheduled: FrameRequestCallback[] = [] + const requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => { + scheduled.push(cb) + return scheduled.length + }) + vi.stubGlobal("requestAnimationFrame", requestAnimationFrame) + vi.stubGlobal("cancelAnimationFrame", vi.fn()) + + const drive = (frames: number, stepMs = 16) => { + for (let i = 0; i < frames; i++) { + const next = scheduled[scheduled.length - 1] + if (next) next((i + 1) * stepMs) + } + } + return { clearRect, requestAnimationFrame, drive } + } + + it("caps drawn frames at maxFps while keeping the loop alive", () => { + const { clearRect, requestAnimationFrame, drive } = frameHarness() + const instance = createWavedance(container, { + animation: "wave", + maxFps: 30, + backgroundOpacity: 0, + }) + drive(21) // 21 frames at 16ms spacing ≈ 62.5fps for 336ms + + // ~30fps over ~336ms ≈ 10-11 draws (tolerant band — drift correction + // makes exact counts brittle), strictly fewer than one per rAF frame. + const draws = clearRect.mock.calls.length + expect(draws).toBeGreaterThanOrEqual(8) + expect(draws).toBeLessThanOrEqual(12) + // The rAF chain never stops: one schedule at start + one per driven frame. + expect(requestAnimationFrame).toHaveBeenCalledTimes(22) + instance.destroy() + }) + + it("maxFps 0 draws on every animation frame", () => { + const { clearRect, drive } = frameHarness() + const instance = createWavedance(container, { + animation: "wave", + maxFps: 0, + backgroundOpacity: 0, + }) + drive(5) + expect(clearRect).toHaveBeenCalledTimes(5) + instance.destroy() + }) + + it("draws once and stops scheduling when the active speed is 0", () => { + const { clearRect, requestAnimationFrame, drive } = frameHarness() + const instance = createWavedance(container, { + animation: "wave", + wave: { speed: 0 }, + maxFps: 0, + backgroundOpacity: 0, + }) + drive(5) + + expect(clearRect).toHaveBeenCalledTimes(1) + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + instance.destroy() + }) + + it("resumes from a static park when update raises the speed", () => { + const { clearRect, drive } = frameHarness() + const instance = createWavedance(container, { + animation: "wave", + wave: { speed: 0 }, + maxFps: 0, + backgroundOpacity: 0, + }) + drive(2) + expect(clearRect).toHaveBeenCalledTimes(1) + + instance.update({ wave: { speed: 0.001 } }) + // update() draws immediately… + expect(clearRect).toHaveBeenCalledTimes(2) + // …and the loop keeps drawing again. + drive(3) + expect(clearRect).toHaveBeenCalledTimes(5) + instance.destroy() + }) + + it("static random draws its seeded first frame once, then parks", () => { + const { clearRect, requestAnimationFrame, drive } = frameHarness() + const instance = createWavedance(container, { + animation: "random", + random: { speed: 0, seed: 11 }, + maxFps: 0, + backgroundOpacity: 0, + }) + drive(5) + + expect(clearRect).toHaveBeenCalledTimes(1) + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + instance.destroy() + }) + + it("update draws immediately even inside a throttle window", () => { + const { clearRect, drive } = frameHarness() + const instance = createWavedance(container, { + animation: "wave", + maxFps: 30, + backgroundOpacity: 0, + }) + drive(1) + expect(clearRect).toHaveBeenCalledTimes(1) + + instance.update({ foreground: "#ffffff" }) + expect(clearRect).toHaveBeenCalledTimes(2) + instance.destroy() + }) + it("draws once and then idles when prefers-reduced-motion is set", () => { const clearRect = vi.fn() const ctx = { diff --git a/wavedance/src/wavedance.ts b/wavedance/src/wavedance.ts index f7403c0..20aaeb3 100644 --- a/wavedance/src/wavedance.ts +++ b/wavedance/src/wavedance.ts @@ -65,6 +65,10 @@ export function createWavedance( let inView = true let startTime = performance.now() let lastFrameTime = startTime + // Clock of the last *drawn* frame, for the maxFps throttle. Distinct from + // `lastFrameTime`: skipped frames must keep accumulating delta for the + // delta-integrating animations. -Infinity guarantees the first frame draws. + let lastDrawTime = Number.NEGATIVE_INFINITY let resizeObserver: ResizeObserver | null = null let intersectionObserver: IntersectionObserver | null = null let motionMediaQuery: MediaQueryList | null = null @@ -72,8 +76,14 @@ export function createWavedance( let visibilityHandler: (() => void) | null = null let resizeTimer = 0 + // With the active animation's speed at exactly 0 the computed field is + // time-invariant, so repainting is pure waste. Strict equality: negative + // speeds animate backwards and must not park. + const isStatic = (): boolean => resolved[resolved.animation].speed === 0 + const shouldAnimate = (): boolean => { if (resolved.respectReducedMotion && prefersReducedMotion()) return false + if (isStatic()) return false return visible && inView } @@ -128,8 +138,16 @@ export function createWavedance( // Resuming from an idle stretch: reset the frame clock so the delta-based // animations don't jump on the first frame back. if (idleDrawn) lastFrameTime = time - drawFrame(time) - idleDrawn = false + const interval = resolved.maxFps > 0 ? 1000 / resolved.maxFps : 0 + const elapsed = time - lastDrawTime + if (interval === 0 || elapsed >= interval) { + drawFrame(time) + // Keep the fractional remainder so average fps ≈ maxFps; after a long + // gap (or the -Infinity first frame) snap to `time` — the remainder + // would be NaN or overcorrect. + lastDrawTime = elapsed < interval * 2 ? time - (elapsed % interval) : time + idleDrawn = false + } ensureLoop() } else if (grid && !idleDrawn) { // Not animating (hidden, off-screen, or reduced-motion): draw one static @@ -144,6 +162,7 @@ export function createWavedance( running = true startTime = performance.now() lastFrameTime = startTime + lastDrawTime = Number.NEGATIVE_INFINITY rafId = requestAnimationFrame(loop) } @@ -162,7 +181,9 @@ export function createWavedance( resizeTimer = window.setTimeout(() => { rebuildGrid() if (grid) { - drawFrame(performance.now()) + const now = performance.now() + drawFrame(now) + lastDrawTime = now if (shouldAnimate()) { idleDrawn = false ensureLoop() @@ -233,7 +254,12 @@ export function createWavedance( } if (grid) { - drawFrame(performance.now()) + const now = performance.now() + // Pin the frame clock when resuming from a park, or the delta-based + // animations would integrate the whole parked stretch in one frame. + if (idleDrawn) lastFrameTime = now + drawFrame(now) + lastDrawTime = now if (shouldAnimate()) { idleDrawn = false ensureLoop() From 04efef7c32e810b7fdd56dae1c75d3b8f0f868aa Mon Sep 17 00:00:00 2001 From: cbiering Date: Thu, 17 Sep 2026 14:09:12 +0200 Subject: [PATCH 2/2] fix: url fix --- apps/landing/src/lib/base.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/landing/src/lib/base.ts b/apps/landing/src/lib/base.ts index 3736d08..8583b9d 100644 --- a/apps/landing/src/lib/base.ts +++ b/apps/landing/src/lib/base.ts @@ -1,5 +1,4 @@ export function withBase(path: string): string { - const base = import.meta.env.BASE_URL - if (path === "/") return base - return `${base}${path.replace(/^\//, "")}` + const base = import.meta.env.BASE_URL.replace(/\/$/, "") + return `${base}/${path.replace(/^\//, "")}` }