From 25a2376c4aad58c293b154c736ac500dd4d346ba Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:00:46 -0400 Subject: [PATCH 01/57] perf(build): enable esbuild minification (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace minify: false with minify: 'esbuild' and remove the stale $-collision comment. The historical concern about esbuild's $-prefixed variable names colliding with Svelte's reserved $ prefix does not reproduce on current esbuild + svelte-vite-plugin. Realistic consumer footprint: ~15.0 KB gz → ~13.4 KB gz (-1.6 KB / ~11%). Largest per-chunk savings: BlobPhysics.js -0.65, TinyVectors.js -0.34, SpringSystem.js -0.26, BlobSVG.js -0.19. Two barrel files grow slightly under preserveModules: true minify; net is still -1.6. TIN-825 --- vite.config.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 737a69a..557e8c2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -61,10 +61,7 @@ export default defineConfig({ target: 'es2022', - // Don't minify — library consumers handle minification. - // esbuild minification uses `$` as a variable name which conflicts - // with Svelte's reserved `$` prefix in downstream builds. - minify: false, + minify: 'esbuild', }, From ea816103fb62f46c26ccc436dff43fec81bd1fb5 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:05:37 -0400 Subject: [PATCH 02/57] feat(motion): add internal OneEuro filter (Casiez et al. CHI 2012) (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline TypeScript implementation of the One-Euro filter — adaptive low-pass that smooths at rest and stays responsive when fast. Math: α = 1 / (1 + τ/dt) where τ = 1/(2π·fc) fc = minCutoff + β·|dx̂| where dx̂ is itself low-passed at dCutoff. Internal-only — not exported from src/motion/index.ts. Will be wired up when A8 (TIN-834) rewrites DeviceMotion as TiltSource. Adding the filter and its tests in a separate PR keeps each change small and individually reviewable. Five unit tests cover: first-sample passthrough, exact EMA-formula match at β=0, adaptive cutoff (higher β → faster step response), reset() lifecycle, dt clamp at identical timestamps. TIN-832 --- src/motion/OneEuro.ts | 58 ++++++++++++++++++++++++++++++++++++++ tests/unit/oneeuro.test.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/motion/OneEuro.ts create mode 100644 tests/unit/oneeuro.test.ts diff --git a/src/motion/OneEuro.ts b/src/motion/OneEuro.ts new file mode 100644 index 0000000..da61364 --- /dev/null +++ b/src/motion/OneEuro.ts @@ -0,0 +1,58 @@ +// One-Euro filter — Casiez, Roussel & Vogel, CHI 2012. + +const TAU = (fc: number) => 1 / (2 * Math.PI * fc); +const alpha = (fc: number, dt: number) => 1 / (1 + TAU(fc) / dt); + +class LowPass { + private y: number | undefined; + + filter(x: number, a: number): number { + this.y = this.y === undefined ? x : a * x + (1 - a) * this.y; + return this.y; + } + + reset(): void { + this.y = undefined; + } +} + +export interface OneEuroParams { + /** Hz — cutoff at zero velocity. Lower = smoother at rest. */ + minCutoff: number; + /** Hz/(unit/s) — cutoff increase per unit of speed. Higher = faster response. */ + beta: number; + /** Hz — speed-estimate smoothing cutoff. */ + dCutoff: number; +} + +export class OneEuro { + private x = new LowPass(); + private dx = new LowPass(); + private prevX: number | undefined; + private prevT: number | undefined; + + constructor(private p: OneEuroParams) {} + + /** Filter sample `x` at time `tMs` (milliseconds). */ + filter(x: number, tMs: number): number { + if (this.prevT === undefined || this.prevX === undefined) { + this.prevT = tMs; + this.prevX = x; + return this.x.filter(x, 1); + } + const dt = Math.max(1e-3, (tMs - this.prevT) / 1000); + const dxRaw = (x - this.prevX) / dt; + const dxHat = this.dx.filter(dxRaw, alpha(this.p.dCutoff, dt)); + const fc = this.p.minCutoff + this.p.beta * Math.abs(dxHat); + const xHat = this.x.filter(x, alpha(fc, dt)); + this.prevX = x; + this.prevT = tMs; + return xHat; + } + + reset(): void { + this.x.reset(); + this.dx.reset(); + this.prevX = this.prevT = undefined; + } +} diff --git a/tests/unit/oneeuro.test.ts b/tests/unit/oneeuro.test.ts new file mode 100644 index 0000000..587e294 --- /dev/null +++ b/tests/unit/oneeuro.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { OneEuro } from '../../src/motion/OneEuro.js'; + +describe('OneEuro', () => { + it('passes through the first sample', () => { + const f = new OneEuro({ minCutoff: 1, beta: 0, dCutoff: 1 }); + expect(f.filter(42, 0)).toBe(42); + }); + + it('with beta=0, output matches exact EMA formula at α(minCutoff, dt)', () => { + const minCutoff = 1; + const dtSec = 0.016; + const tau = 1 / (2 * Math.PI * minCutoff); + const expectedAlpha = 1 / (1 + tau / dtSec); + + const f = new OneEuro({ minCutoff, beta: 0, dCutoff: 1 }); + f.filter(0, 0); + const out1 = f.filter(1.0, 16); + expect(out1).toBeCloseTo(expectedAlpha, 5); + const out2 = f.filter(1.0, 32); + expect(out2).toBeCloseTo(expectedAlpha + (1 - expectedAlpha) * expectedAlpha, 5); + }); + + it('higher β responds faster to step inputs (adaptive cutoff)', () => { + const slow = new OneEuro({ minCutoff: 0.5, beta: 0.001, dCutoff: 1 }); + const fast = new OneEuro({ minCutoff: 0.5, beta: 0.5, dCutoff: 1 }); + slow.filter(0, 0); + fast.filter(0, 0); + let lastSlow = 0; + let lastFast = 0; + for (let t = 16; t <= 200; t += 16) { + lastSlow = slow.filter(100, t); + lastFast = fast.filter(100, t); + } + expect(lastFast).toBeGreaterThan(lastSlow); + }); + + it('reset() restores first-sample-passthrough state', () => { + const f = new OneEuro({ minCutoff: 1, beta: 0.1, dCutoff: 1 }); + f.filter(0, 0); + f.filter(10, 16); + f.filter(10, 32); + f.reset(); + expect(f.filter(99, 100)).toBe(99); + }); + + it('clamps tiny dt so identical timestamps do not blow up', () => { + const f = new OneEuro({ minCutoff: 1, beta: 0.01, dCutoff: 1 }); + f.filter(0, 100); + expect(() => f.filter(1, 100)).not.toThrow(); + expect(Number.isFinite(f.filter(2, 100))).toBe(true); + }); +}); From b44d80762074b92fc9b5cdc4ee621e31f355ea2e Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:05:58 -0400 Subject: [PATCH 03/57] fix(motion): cancel in-flight RAF before respawning decay loop (#31) ScrollHandler.startDecay() recursively scheduled requestAnimationFrame without tracking the returned id, so rapid handleScroll() calls (up to 100+/sec on a fast scroll wheel) could spawn overlapping decay loops that all run next frame. Track the active rafId on the instance, cancelAnimationFrame() it before kicking off a new decay, and clear it when decay self-terminates. Two unit tests: 50 rapid wheel events leave exactly one scheduled RAF and 49 cancellations; decay self-cleanup nulls rafId so the next handleScroll() does not redundantly cancel a finished loop. TIN-830 --- src/motion/ScrollHandler.ts | 13 +++++- tests/unit/scroll-handler.test.ts | 70 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/unit/scroll-handler.test.ts diff --git a/src/motion/ScrollHandler.ts b/src/motion/ScrollHandler.ts index c30acd6..fb9c13b 100644 --- a/src/motion/ScrollHandler.ts +++ b/src/motion/ScrollHandler.ts @@ -23,6 +23,7 @@ export class ScrollHandler { private scrollDirection = 0; private pullForces: PullForce[] = []; private peakVelocity = 0; + private rafId: number | null = null; constructor(config?: ScrollHandlerConfig) { if (config?.decayRate) this.decayRate = config.decayRate; @@ -110,6 +111,13 @@ export class ScrollHandler { } private startDecay(): void { + // Cancel any in-flight decay so rapid handleScroll() calls don't + // queue overlapping RAF callbacks. + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + const decay = () => { this.stickiness *= this.decayRate; this.scrollVelocity *= this.decayRate; @@ -126,13 +134,14 @@ export class ScrollHandler { })); if (this.stickiness > 0.01 || this.pullForces.length > 0) { - requestAnimationFrame(decay); + this.rafId = requestAnimationFrame(decay); } else { this.stickiness = 0; this.scrollVelocity = 0; + this.rafId = null; } }; - requestAnimationFrame(decay); + this.rafId = requestAnimationFrame(decay); } public getStickiness(): number { diff --git a/tests/unit/scroll-handler.test.ts b/tests/unit/scroll-handler.test.ts new file mode 100644 index 0000000..925727d --- /dev/null +++ b/tests/unit/scroll-handler.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ScrollHandler } from '../../src/motion/ScrollHandler.js'; + +describe('ScrollHandler RAF backpressure', () => { + let nextId = 1; + let scheduled: Map; + let cancelled: Set; + + beforeEach(() => { + nextId = 1; + scheduled = new Map(); + cancelled = new Set(); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + const id = nextId++; + scheduled.set(id, cb); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + cancelled.add(id); + scheduled.delete(id); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('cancels in-flight decay before respawning under rapid scroll', () => { + const h = new ScrollHandler(); + const fakeWheel = (deltaY: number) => + ({ deltaY } as unknown as WheelEvent); + + for (let i = 0; i < 50; i++) { + h.handleScroll(fakeWheel(100)); + } + + // Exactly one active RAF after 50 rapid handleScroll() calls. + expect(scheduled.size).toBe(1); + // And 49 prior RAFs were cancelled. + expect(cancelled.size).toBe(49); + }); + + it('clears rafId when decay completes', () => { + const h = new ScrollHandler(); + const fakeWheel = (deltaY: number) => + ({ deltaY } as unknown as WheelEvent); + + // One scroll event kicks off decay. + h.handleScroll(fakeWheel(50)); + expect(scheduled.size).toBe(1); + + // Drain all scheduled callbacks until decay self-terminates. + // `decay` re-schedules itself only while stickiness > 0.01 or pullForces > 0. + // With decayRate 0.92, this exits within a few hundred frames. + let safety = 1000; + while (scheduled.size > 0 && safety-- > 0) { + const [[id, cb]] = scheduled.entries(); + scheduled.delete(id); + cb(performance.now()); + } + expect(scheduled.size).toBe(0); + + // A subsequent scroll should kick off a fresh decay (no cancel needed + // because the previous loop exited cleanly). + const cancelledBefore = cancelled.size; + h.handleScroll(fakeWheel(50)); + expect(cancelled.size).toBe(cancelledBefore); + expect(scheduled.size).toBe(1); + }); +}); From 425ba3e9fb801a69e08501fae8deca79b1c3e844 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:11:28 -0400 Subject: [PATCH 04/57] perf(physics): drop .toFixed(2) from generateSmoothBlobPath (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each .toFixed call allocates a fresh string. At 5 blobs × 12 control points × 5 toFixed calls per segment × 60 fps that's ~18,000 small string allocations per second, just for SVG path coordinate precision. Drop .toFixed entirely. Number.prototype.toString() (called implicitly by template-literal interpolation) is faster in V8 and SVG accepts any precision. Path strings get ~9 chars longer per coord — roughly 2.7 KB/ frame extra in DOM-attribute size, trivially cheap, in exchange for eliminating the alloc churn. TIN-829 --- src/core/BlobPhysics.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index aaa49b4..cd93bc1 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -249,21 +249,23 @@ export class BlobPhysics { const convexPoints = this.generateConvexHull(points); - - let path = `M ${convexPoints[0].x.toFixed(2)},${convexPoints[0].y.toFixed(2)}`; + // Numbers are interpolated directly: Number.prototype.toString() in + // V8 is faster than toFixed and SVG accepts any precision. Each + // .toFixed call allocated a fresh string ~18,000 times/sec at + // 5 blobs × 12 control points × 60 fps. + let path = `M ${convexPoints[0].x},${convexPoints[0].y}`; for (let i = 0; i < convexPoints.length; i++) { const current = convexPoints[i]; const next = convexPoints[(i + 1) % convexPoints.length]; const nextNext = convexPoints[(i + 2) % convexPoints.length]; - const cp1x = current.x + (next.x - current.x) * 0.15; const cp1y = current.y + (next.y - current.y) * 0.15; const cp2x = next.x - (nextNext.x - current.x) * 0.05; const cp2y = next.y - (nextNext.y - current.y) * 0.05; - path += ` C ${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(2)},${cp2y.toFixed(2)} ${next.x.toFixed(2)},${next.y.toFixed(2)}`; + path += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${next.x},${next.y}`; } path += ' Z'; From cd398e6f898da7eab9bb823e0a0fe1abdf68f6be Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:12:22 -0400 Subject: [PATCH 05/57] chore: strip dead debug UI and unused containerElement plumbing (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tightly-related cleanups in the Svelte components: 1. Remove the dead debug-overlay block in TinyVectors.svelte. The {#if isMobileDevice && false} ... {/if} guard meant the contained div never rendered. Author left dev tooling in. 2. Drop the containerElement prop end-to-end. It was bound on the wrapper div in TinyVectors.svelte, passed through to BlobSVG, and then ignored — never read or used in either template or script. Removing the binding, the prop, the interface field, and the destructure cleans up an unused contract surface. Neither change is on the public API: containerElement was never on the Props interface, and the debug overlay was dev-only output gated to never render. TIN-827 --- src/svelte/BlobSVG.svelte | 3 +-- src/svelte/TinyVectors.svelte | 11 +---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/svelte/BlobSVG.svelte b/src/svelte/BlobSVG.svelte index 1b86fe7..740938c 100644 --- a/src/svelte/BlobSVG.svelte +++ b/src/svelte/BlobSVG.svelte @@ -6,11 +6,10 @@ // Props using Svelte 5 $props() syntax interface Props { blobs?: ConvexBlob[]; - containerElement?: HTMLElement | undefined; physics?: BlobPhysics | null; } - let { blobs = [], containerElement = undefined, physics = null }: Props = $props(); + let { blobs = [], physics = null }: Props = $props(); // Track dark mode for blend mode switching let isDarkMode = $state(false); diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index f284cc5..17cf43b 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -42,7 +42,6 @@ }: Props = $props(); // State - use regular variables for non-reactive state - let containerElement: HTMLDivElement | undefined = $state(undefined); let blobs = $state>([]); let isReady = $state(false); let isMobileDevice = $state(false); @@ -231,20 +230,12 @@ {#if shouldLoad && themeColors.length > 0} {/if} From 322fca1c161659379368b6b7ffd43af2e376d7c6 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:24:54 -0400 Subject: [PATCH 06/57] refactor(core): replace wildcard re-exports with explicit public surface (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/core/index.ts previously emitted `export * from './types.js'` and `export * from './schema.js'`, which made the public API of the /core entry point opaque (every type, value, and event helper from those files leaked through). Replace the wildcards with explicit named re-exports, grouped by category (physics, path generation, spring system, spatial utilities, browser detection, blob/motion types, configuration, themes, render blob shapes, event types, theme presets). Same surface; clearer. Tightening to a smaller surface is intentionally deferred — that would be a true API break and wants its own consideration. This change is purely a documentation-of-current-surface refactor. TIN-826 --- src/core/index.ts | 101 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index a704a23..6dddcf9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,34 +1,19 @@ +// Curated public surface for the /core entry point. All exports below +// were previously emitted via `export *` from types.js and schema.js; +// this file makes the surface explicit and tree-shake-friendly without +// changing it. - - - - - -export * from './types.js'; - - -export * from './schema.js'; - - +// — Physics core — export { BlobPhysics, type BlobPhysicsConfig } from './BlobPhysics.js'; - +// — Path generation — export { generateSmoothBlobPath, generateSmoothBlobPathSync, preInitPathGenerator, } from './PathGenerator.js'; - -export { browser, isBrowser } from './browser.js'; - - -export { GaussianKernel } from './GaussianKernel.js'; - - -export { SpatialHash } from './SpatialHash.js'; - - +// — Spring system — export { SpringSystem, type SpringConfig, @@ -39,3 +24,75 @@ export { enforceAreaConservation, createControlPointVelocities, } from './SpringSystem.js'; + +// — Spatial / kernel utilities — +export { GaussianKernel } from './GaussianKernel.js'; +export { SpatialHash } from './SpatialHash.js'; + +// — Browser detection — +export { browser, isBrowser } from './browser.js'; + +// — Blob and motion types — +export type { + ControlPoint, + ControlPointVelocity, + ConvexBlob, + ColorDefinition, + DeviceMotionData, + GravityVector, + TiltVector, + PullForce, +} from './types.js'; + +// — Configuration types — +export type { + TinyVectorsConfig, + CoreConfig, + PhysicsConfig, + RenderingConfig, + ThemeConfig, + FeatureFlags, + TinyVectorsConfigOverride, + DeepPartial, +} from './schema.js'; + +// — Theme types — +export type { + ThemePresetName, + BlendMode, + ThemeColor, + ThemePreset, +} from './schema.js'; + +// — Render blob shapes — +export type { + BlobCore, + RenderBlob, + PhysicsBlob, +} from './schema.js'; + +// — Input event types — +export type { + ScrollData, + PointerData, +} from './schema.js'; + +// — Custom event types — +export type { + TinyVectorsEventType, + TinyVectorsEventHandler, + TinyVectorsEvent, + FrameEventData, + ThemeChangeEventData, +} from './schema.js'; + +// — Theme presets and config — +export { + DEFAULT_CONFIG, + TRANS_THEME, + PRIDE_THEME, + TINYLAND_THEME, + HIGH_CONTRAST_THEME, + THEME_PRESETS, + mergeConfig, +} from './schema.js'; From 110f83c53d9f437555dae15c19e3b54c030e3693 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 00:34:37 -0400 Subject: [PATCH 07/57] =?UTF-8?q?perf(physics):=20stable=20blob=20refs=20i?= =?UTF-8?q?n=20getBlobs()=20=E2=80=94=20mutate=20color=20in=20place=20(#35?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(physics): mutate blob.color in place in getBlobs() for stable refs BlobPhysics.getBlobs(themeColors) previously spread each blob into a new object and returned a fresh array, allocating ~60 arrays + 300 objects/sec at 5 blobs × 60 fps purely to attach a color string per frame. Mutate this.blobs[i].color directly when themeColors is supplied, and return the live this.blobs array. The result is a stable reference across calls — the same array, the same blob objects — which lets Svelte's keyed reconciliation in BlobSVG short-circuit on unchanged fields rather than re-evaluating per-blob expressions every frame. Behavior change: getBlobs(themeColors) now mutates internal state instead of producing a snapshot. The canonical consumer in TinyVectors.svelte.tick() is the only caller and treats the returned array as live, so this is invisible in practice. Documented in a code comment. Four new tests: stable array reference, stable blob references, in-place color cycling when themeColors is shorter than blob count, and the no-themeColors branch returning the live array unchanged. TIN-828 * fix(physics): return shallow copy from getBlobs to keep Svelte reactive Greptile review on PR #35 caught a real bug: TinyVectors.svelte assigns `blobs = physics.getBlobs(themeColors)` inside its rAF loop. Svelte 5's signal compares by reference — returning the same this.blobs each call would freeze the animation after frame 1 because the assignment looks like a no-op. Return this.blobs.slice() instead. The outer array shell is re-allocated each call (~60 arr/sec at 60fps, same as the prior .map approach), but the blob object refs inside are stable across calls — which kills the 300 obj-spreads/sec that the .map(blob => ({...blob})) was paying. Net win is the 80% of the original goal, without the freeze-after-frame-1 bug. Update tests accordingly: assert fresh array reference each call, keep the assertions about same blob object refs and in-place color mutation cycling. TIN-828 --- src/core/BlobPhysics.ts | 18 +++++++---- tests/unit/getblobs-stable.test.ts | 49 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 tests/unit/getblobs-stable.test.ts diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index cd93bc1..cbb2fe3 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -210,15 +210,21 @@ export class BlobPhysics { + // Mutate blob.color in place when themeColors is supplied — kills the + // 300 object spreads/sec the previous .map(blob => ({...blob, color})) + // performed at 5 blobs × 60 fps. Return a *shallow copy* so the array + // reference is fresh each call: TinyVectors.svelte assigns the result + // to a $state rune inside its rAF loop, and Svelte 5's signal compares + // by reference — returning the same array would freeze the animation + // after frame 1. Same blob object refs across calls; only the outer + // array shell is reallocated. getBlobs(themeColors?: string[]): ConvexBlob[] { if (themeColors && themeColors.length > 0) { - - return this.blobs.map((blob, i) => ({ - ...blob, - color: themeColors[i % themeColors.length], - })); + for (let i = 0; i < this.blobs.length; i++) { + this.blobs[i].color = themeColors[i % themeColors.length]; + } } - return this.blobs; + return this.blobs.slice(); } diff --git a/tests/unit/getblobs-stable.test.ts b/tests/unit/getblobs-stable.test.ts new file mode 100644 index 0000000..fa8b1b5 --- /dev/null +++ b/tests/unit/getblobs-stable.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { BlobPhysics } from '../../src/core/BlobPhysics.js'; + +describe('BlobPhysics.getBlobs reference stability', () => { + it('returns a fresh array reference each call (so Svelte signals fire)', async () => { + const physics = new BlobPhysics(5, {}); + await physics.init(); + const colors = ['red', 'green', 'blue', 'yellow', 'purple']; + const a = physics.getBlobs(colors); + const b = physics.getBlobs(colors); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); + + it('returns the same blob object references across calls (no per-frame spread)', async () => { + const physics = new BlobPhysics(3, {}); + await physics.init(); + const colors = ['red', 'green', 'blue']; + const a = physics.getBlobs(colors); + const b = physics.getBlobs(colors); + for (let i = 0; i < a.length; i++) { + expect(a[i]).toBe(b[i]); + } + }); + + it('applies themeColors via in-place mutation, cycling when shorter than blob count', async () => { + const physics = new BlobPhysics(5, {}); + await physics.init(); + const colors = ['red', 'green']; + const blobs = physics.getBlobs(colors); + expect(blobs[0].color).toBe('red'); + expect(blobs[1].color).toBe('green'); + expect(blobs[2].color).toBe('red'); + expect(blobs[3].color).toBe('green'); + expect(blobs[4].color).toBe('red'); + }); + + it('without themeColors still returns a fresh array of the same blobs', async () => { + const physics = new BlobPhysics(2, {}); + await physics.init(); + const blobs1 = physics.getBlobs(['#aaa', '#bbb']); + expect(blobs1[0].color).toBe('#aaa'); + // Calling without colors does not reset; previous theming remains. + const blobs2 = physics.getBlobs(); + expect(blobs2).not.toBe(blobs1); + expect(blobs2[0]).toBe(blobs1[0]); + expect(blobs2[0].color).toBe('#aaa'); + }); +}); From 95851b88073d9d2e2268676a7b5edd5dd22fea60 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 10:40:46 -0400 Subject: [PATCH 08/57] perf(svelte): @property + calc() for blob gradient stop opacities (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 12 stop-opacity attributes per blob each held a JS arithmetic expression: stop-opacity={blob.intensity * }. Each frame, blobs is reassigned (new array reference) so Svelte re-evaluates *all* of them — 60+ small multiplications per frame even when blob.intensity is static across animation frames. Register --tv-blob-intensity as an @property (syntax: '', inherits, initial-value: 1) so calc() in stop-opacity is interpolated as a number rather than a string. Set it inline once per from blob.intensity. Stops compute their own opacity via calc(var(--tv-blob-intensity) * ). Effect: ~12 reactive expressions per blob become 1 inline-style assignment. At 5 blobs × 60 fps that's 300 expression evaluations per second instead of 3,600. The browser also handles the calc() GPU-side once registered, so when intensity does change, transitions are smoother than reactive Svelte updates would produce. Browser support: @property is Baseline 2024 (Chrome 85+, Safari 16.4+, Firefox 128+). Falls back to string substitution on older browsers, which would break calc() — acceptable for tinyvectors v0.3 target matrix; the rendered intensity is the only affected behavior and older browsers see static (unfiltered) blobs. TIN-831 --- src/svelte/BlobSVG.svelte | 32 +++++++++++++++++++------------- src/themes/vector-colors.css | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/svelte/BlobSVG.svelte b/src/svelte/BlobSVG.svelte index 740938c..50d3dbd 100644 --- a/src/svelte/BlobSVG.svelte +++ b/src/svelte/BlobSVG.svelte @@ -67,29 +67,35 @@ - + {#each blobs as blob, i (blob.gradientId)} - - - - + + + + - - - - + + + + - - - - + + + + {/each} diff --git a/src/themes/vector-colors.css b/src/themes/vector-colors.css index 3d6bbf6..0767214 100644 --- a/src/themes/vector-colors.css +++ b/src/themes/vector-colors.css @@ -2,6 +2,21 @@ * Uses Skeleton's .dark class for theme switching */ +/* Per-blob intensity, registered so calc() in SVG stop-opacity is + * interpolated as a number rather than a string. Set inline on each + * from blob.intensity in BlobSVG.svelte; gradient + * stops compute their actual opacity via calc(var(--tv-blob-intensity) + * * ). Avoids ~60 reactive expressions per frame in + * Svelte (one per stop-opacity arithmetic) — the inline style still + * re-runs each frame, but only ~5 evaluations per frame total + * (one per blob, not per stop). + */ +@property --tv-blob-intensity { + syntax: ''; + inherits: true; + initial-value: 1; +} + /* Light mode defaults */ :root { /* === Trans Pride Colors === */ From 8120fe24afc4ef0345fab15597a5a4a08a8b08c7 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 10:45:44 -0400 Subject: [PATCH 09/57] =?UTF-8?q?feat(physics):=20Phase=20A=20physics-feel?= =?UTF-8?q?=20cluster=20=E2=80=94=20gel-like=20swarm=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(physics): replace ad-hoc smoothControlPoints with Laplacian skin tension The previous smoothControlPoints used 3-point averaging plus an 'anti-difference' clamp that fired when neighbor radii diverged beyond size*0.1 — physically ad-hoc and produced ringing rather than gel-like deformation. Replace with the discrete surface-tension force on a closed control- point ring (Young-Laplace pressure): r_i ← r_i + k · (0.5·(r_{i-1} + r_{i+1}) - r_i) Two-pass so we read all targets before writing — otherwise we'd be smoothing against half-already-smoothed neighbors. Plus a viscous radial-velocity bleed (Kelvin-Voigt dashpot half) so energy dissipates with each correction rather than ringing. Pre-allocate the targets scratch buffer on the instance to keep the hot path allocation-free. Reuses a Float32Array sized to the largest control-point count seen. TIN-833 * feat(physics): XSPH inter-blob velocity coupling for fluid swarm feel After updateScreensaverPhysics has updated each blob, sample the neighborhood velocity field and nudge each blob toward its Gaussian- weighted neighbor mean: v_i ← v_i + ε · Σ_j W(r_ij) · (v_j - v_i) This is XSPH viscosity from Position Based Fluids (Macklin & Müller, SIGGRAPH 2013). It bleeds *relative* motion between neighbors, which is categorically different from drag (bleeds *absolute* motion): drag feels like air resistance; XSPH feels like wading through gel and is what makes a small set of blobs read as a fluid rather than 5 independent things. Defaults: eps=0.4, sigma=80 (the latter is in 0..100 viewBox space, so coupling is meaningful within ~half-screen). Pre-allocate dvX/dvY Float32Arrays on the instance, sized to numBlobs, fill(0) each tick. O(N²) — fine for 5–10 blobs; existing SpatialHash could neighbor-cull later if high-N modes need it. TIN-835 * feat(physics): soft-wall force replaces specular bounce on blob centers handleWallBouncing previously did position-snap + velocity-reverse × damping at four hard wall lines, producing a discontinuous force that read as billiard-ball impact — wrong for a gel. Replace with a continuous penetration-based restoring force: F = -k · max(0, penetration) The blob can drift into the soft band, gets pushed back smoothly, and the velocity curve has no kink. Edges feel like the blob flattens against the wall rather than bouncing off. Keep a hard outer clamp at a smaller margin so under extreme dt or large external forces the blob can never escape the canvas — the hard clamp still records bounces so existing time-since-bounce logic continues to work. For full-fidelity wall-flattening (perimeter points clamp individually), defer to the Tier-2 PBD refactor (TIN-841 / Phase C). This change is the cheap version that already removes the bouncy feel. TIN-836 * perf(physics): Gaussian-falloff anti-clustering replaces step-function repulsion Both applyAntiClusteringWithSpatialHash and the applyEnhancedAntiClustering O(N²) fallback previously used a step function: zero force above a 'requiredDistance' threshold, plus a separate sharp 3.5× proximity multiplier when distance dipped below 0.7 × requiredDistance. Both produced discontinuous forces that read as a 'click' or sudden swerve when blobs nearly-touched. Replace with Gaussian falloff: w = exp(-r² / 2σ²), σ = required- distance × 0.5. Force grows continuously as blobs approach, peaks at zero distance, decays smoothly. C∞ continuous, no kink at any radius. Reuses the same Gaussian family as the existing GaussianKernel. Tuning: σ = requiredDistance / 2 means most of the contribution comes from within 'personal space'; falloff to ~10% by 1.5× and ~1% by 2×. Spatial-hash neighbor query continues to limit which pairs are checked, so nothing far-away suddenly receives weak Gaussian forces. Keep the existing distance < requiredDistance gate ONLY for setting blob.lastRepulsionTime (used by addEscapeVelocity to give blobs a post-bump impulse). The actual force is now continuous everywhere. TIN-837 * fix(physics): respect bounceDamping config; tighten XSPH null guard Greptile review on PR #36 caught two issues: 1. The hard-clamp fallback in handleWallBouncing hardcoded 0.5 in all four reflection branches, silently dropping any consumer-tuned value of config.bounceDamping (default 0.7). Read this.config.bounceDamping for hard reflections — matches the pre-soft-wall behavior for the rare case the hard clamp fires. 2. The XSPH buffer-allocation guard checked xsphDvX but then asserted xsphDvY non-null without a check. In practice both are always allocated together, but the asymmetric guard was a footgun and the non-null assertion masks any future divergence. TIN-836 TIN-835 * docs(physics): clarify intentional lastRepulsionTime decoupling Greptile review on PR #36: lastRepulsionTime is decoupled from force application — force is continuous (Gaussian), stamp gated on close-contact threshold. The decoupling is deliberate: the stamp exists for downstream addEscapeVelocity which uses it as a 'blobs just pushed each other apart' event detector, not as a generic 'any neighbor contributed' flag. Adding a code comment above the stamp so future readers do not 'restore' the coupling. TIN-837 --- src/core/BlobPhysics.ts | 254 +++++++++++++++++++++++++++------------- 1 file changed, 173 insertions(+), 81 deletions(-) diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index cbb2fe3..5bb61bc 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -73,6 +73,11 @@ export class BlobPhysics { private gaussianKernel: GaussianKernel; private springSystem: SpringSystem; + // Pre-allocated scratch buffers for hot-path passes (no per-frame allocation). + private skinTensionScratch: Float32Array | null = null; + private xsphDvX: Float32Array | null = null; + private xsphDvY: Float32Array | null = null; + constructor(numBlobs: number, config: Partial = {}) { this.numBlobs = numBlobs; this.config = { ...DEFAULT_CONFIG, ...config }; @@ -154,16 +159,70 @@ export class BlobPhysics { this.updateScreensaverPhysics(blob, deltaTime, time) ); - + // XSPH viscosity coupling — each blob's velocity drifts toward its + // neighborhood-weighted velocity. This is what makes the swarm + // behave as a fluid rather than 5 independent things; drag bleeds + // absolute motion, XSPH bleeds *relative* motion between neighbors. + // Macklin & Müller, Position Based Fluids, SIGGRAPH 2013. + this.applyXSPHCoupling(); + + this.mouseVelX *= 0.96; this.mouseVelY *= 0.96; } + private applyXSPHCoupling(): void { + const blobs = this.blobs; + const n = blobs.length; + if (n < 2) return; + + if (!this.xsphDvX || !this.xsphDvY || this.xsphDvX.length < n) { + this.xsphDvX = new Float32Array(n); + this.xsphDvY = new Float32Array(n); + } + const dvX = this.xsphDvX; + const dvY = this.xsphDvY; + dvX.fill(0); + dvY.fill(0); + + const eps = 0.4; + const sigma = 80; + const twoSigmaSq = 2 * sigma * sigma; + + for (let i = 0; i < n; i++) { + const a = blobs[i]; + for (let j = i + 1; j < n; j++) { + const b = blobs[j]; + const dx = b.currentX - a.currentX; + const dy = b.currentY - a.currentY; + const w = Math.exp(-(dx * dx + dy * dy) / twoSigmaSq); + const dvx = w * (b.velocityX - a.velocityX); + const dvy = w * (b.velocityY - a.velocityY); + dvX[i] += dvx; + dvY[i] += dvy; + dvX[j] -= dvx; + dvY[j] -= dvy; + } + } + + for (let i = 0; i < n; i++) { + blobs[i].velocityX += eps * dvX[i]; + blobs[i].velocityY += eps * dvY[i]; + } + } + + // Anti-clustering with Gaussian-falloff repulsion. The previous step- + // function variant ((distance < requiredDistance) ? force : 0, plus + // a separate sharp proximity multiplier at requiredDistance * 0.7) + // produced a discontinuous force read as a "click" on near-contact. + // exp(-r² / 2σ²) is C∞ smooth — force grows continuously, peaks at + // zero distance, decays smoothly. Reuses the same Gaussian family as + // the existing GaussianKernel. private applyAntiClusteringWithSpatialHash(): void { - const maxPersonalSpace = 60; + const maxPersonalSpace = 60; for (const blob of this.blobs) { const neighbors = this.spatialHash.queryNeighbors(blob, maxPersonalSpace); @@ -172,30 +231,38 @@ export class BlobPhysics { const dx = other.currentX - blob.currentX; const dy = other.currentY - blob.currentY; const distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= 0) continue; - const requiredDistance = Math.max(blob.personalSpace || 50, other.personalSpace || 50); - - if (distance < requiredDistance && distance > 0) { - const overlap = requiredDistance - distance; - const repulsionForce = (overlap / requiredDistance) * 0.055 * this.config.antiClusteringStrength / 0.15; - - const normalizedDx = dx / distance; - const normalizedDy = dy / distance; - - const forceMultiplier = blob.repulsionStrength || 0.03; - const proximityMultiplier = distance < requiredDistance * 0.7 ? 3.5 : 1.0; - - - blob.velocityX -= normalizedDx * repulsionForce * forceMultiplier * proximityMultiplier * 0.5; - blob.velocityY -= normalizedDy * repulsionForce * forceMultiplier * proximityMultiplier * 0.5; - + const requiredDistance = Math.max( + blob.personalSpace || 50, + other.personalSpace || 50 + ); + const sigma = requiredDistance * 0.5; + const w = Math.exp(-(distance * distance) / (2 * sigma * sigma)); + const repulsionForce = + w * 0.055 * (this.config.antiClusteringStrength / 0.15); + + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; + const forceMultiplier = blob.repulsionStrength || 0.03; + + blob.velocityX -= normalizedDx * repulsionForce * forceMultiplier; + blob.velocityY -= normalizedDy * repulsionForce * forceMultiplier; + + // Force is now Gaussian (continuous, applies at any range + // inside the spatial-hash query). lastRepulsionTime stays + // gated on the close-contact threshold because downstream + // addEscapeVelocity uses it as a "blobs were just pushing + // each other apart" event detector — not as a generic + // "any neighbor contributed" flag. Decoupling is intentional. + if (distance < requiredDistance) { blob.lastRepulsionTime = Date.now(); } } } } - + updateMousePosition(x: number, y: number): void { @@ -393,6 +460,9 @@ export class BlobPhysics { } } + // Fallback when useSpatialHash is false. Same Gaussian-falloff + // repulsion as applyAntiClusteringWithSpatialHash, applied + // pairwise in O(N²). private applyEnhancedAntiClustering(): void { for (let i = 0; i < this.blobs.length; i++) { const blob1 = this.blobs[i]; @@ -403,28 +473,29 @@ export class BlobPhysics { const dx = blob2.currentX - blob1.currentX; const dy = blob2.currentY - blob1.currentY; const distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= 0) continue; - const requiredDistance = Math.max(blob1.personalSpace || 50, blob2.personalSpace || 50); - - if (distance < requiredDistance && distance > 0) { - const overlap = requiredDistance - distance; - const repulsionForce = (overlap / requiredDistance) * 0.055 * this.config.antiClusteringStrength / 0.15; - - const normalizedDx = dx / distance; - const normalizedDy = dy / distance; - - const force1Multiplier = blob1.repulsionStrength || 0.03; - const force2Multiplier = blob2.repulsionStrength || 0.03; + const requiredDistance = Math.max( + blob1.personalSpace || 50, + blob2.personalSpace || 50 + ); + const sigma = requiredDistance * 0.5; + const w = Math.exp(-(distance * distance) / (2 * sigma * sigma)); + const repulsionForce = + w * 0.055 * (this.config.antiClusteringStrength / 0.15); - - const proximityMultiplier = distance < requiredDistance * 0.7 ? 3.5 : 1.0; + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; + const force1Multiplier = blob1.repulsionStrength || 0.03; + const force2Multiplier = blob2.repulsionStrength || 0.03; - blob1.velocityX -= normalizedDx * repulsionForce * force1Multiplier * proximityMultiplier; - blob1.velocityY -= normalizedDy * repulsionForce * force1Multiplier * proximityMultiplier; + blob1.velocityX -= normalizedDx * repulsionForce * force1Multiplier; + blob1.velocityY -= normalizedDy * repulsionForce * force1Multiplier; - blob2.velocityX += normalizedDx * repulsionForce * force2Multiplier * proximityMultiplier; - blob2.velocityY += normalizedDy * repulsionForce * force2Multiplier * proximityMultiplier; + blob2.velocityX += normalizedDx * repulsionForce * force2Multiplier; + blob2.velocityY += normalizedDy * repulsionForce * force2Multiplier; + if (distance < requiredDistance) { blob1.lastRepulsionTime = Date.now(); blob2.lastRepulsionTime = Date.now(); } @@ -656,31 +727,33 @@ export class BlobPhysics { }); } + // Laplacian skin-tension pass on the perimeter ring. + // r_i ← r_i + k · (0.5·(r_{i-1} + r_{i+1}) - r_i) is the discrete + // surface-tension force on a closed control-point ring (Young-Laplace + // pressure). Two-pass: read all targets first, then write — otherwise + // we'd be smoothing against half-already-smoothed neighbors. + // Plus a viscous radial-velocity bleed (Kelvin-Voigt dashpot half) so + // energy dissipates with each correction rather than ringing as it + // did with the previous spring-only model. private smoothControlPoints(blob: ConvexBlob): void { - if (!blob.controlPoints || blob.controlPoints.length < 3) return; - - for (let i = 0; i < blob.controlPoints.length; i++) { - const current = blob.controlPoints[i]; - const prev = blob.controlPoints[(i - 1 + blob.controlPoints.length) % blob.controlPoints.length]; - const next = blob.controlPoints[(i + 1) % blob.controlPoints.length]; - - - const avgRadius = (prev.radius + current.radius + next.radius) / 3; - const smoothingFactor = 0.05; - current.radius = current.radius * (1 - smoothingFactor) + avgRadius * smoothingFactor; + const cp = blob.controlPoints; + if (!cp || cp.length < 3) return; + const n = cp.length; + if (!this.skinTensionScratch || this.skinTensionScratch.length < n) { + this.skinTensionScratch = new Float32Array(n); + } + const target = this.skinTensionScratch; + const k = 0.15; - - const minRadiusDiff = blob.size * 0.1; - if (Math.abs(current.radius - prev.radius) > minRadiusDiff) { - const adjustment = (Math.abs(current.radius - prev.radius) - minRadiusDiff) * 0.5; - if (current.radius > prev.radius) { - current.radius -= adjustment; - prev.radius += adjustment; - } else { - current.radius += adjustment; - prev.radius -= adjustment; - } - } + for (let i = 0; i < n; i++) { + const prev = cp[(i - 1 + n) % n].radius; + const next = cp[(i + 1) % n].radius; + target[i] = 0.5 * (prev + next); + } + for (let i = 0; i < n; i++) { + cp[i].radius += (target[i] - cp[i].radius) * k; + const v = blob.controlVelocities?.[i]; + if (v) v.radialVelocity *= 1 - 0.5 * k; } } @@ -694,36 +767,55 @@ export class BlobPhysics { } } + // Soft-wall force: continuous penetration-based restoring force, no + // specular reflection or position snap. Edges deform along the wall + // (the blob "flattens") rather than bouncing — the gel cue. private handleWallBouncing(blob: ConvexBlob): void { const margin = blob.size * 0.8; - const damping = this.config.bounceDamping; + const yMargin = margin * 1.5; + const k = 0.08; const currentTime = Date.now(); - - if (blob.currentX < this.PHYSICS_MIN + margin) { - blob.currentX = this.PHYSICS_MIN + margin; - blob.velocityX = Math.abs(blob.velocityX) * damping; + const minX = this.PHYSICS_MIN + margin; + const maxX = this.PHYSICS_MAX - margin; + const minY = this.PHYSICS_MIN + yMargin; + const maxY = this.PHYSICS_MAX - yMargin; + + const px = + Math.max(0, minX - blob.currentX) - Math.max(0, blob.currentX - maxX); + const py = + Math.max(0, minY - blob.currentY) - Math.max(0, blob.currentY - maxY); + + if (px !== 0) blob.velocityX += k * px; + if (py !== 0) blob.velocityY += k * py; + + // Hard outer clamp — far outside the soft band, snap back so the + // blob can never escape the canvas under extreme dt or large + // external forces. Records a bounce so existing time-since-bounce + // logic continues to work. + const hardMargin = blob.size * 0.2; + const hardMinX = this.PHYSICS_MIN + hardMargin; + const hardMaxX = this.PHYSICS_MAX - hardMargin; + const hardMinY = this.PHYSICS_MIN + hardMargin; + const hardMaxY = this.PHYSICS_MAX - hardMargin; + const hardDamping = this.config.bounceDamping; + + if (blob.currentX < hardMinX) { + blob.currentX = hardMinX; + blob.velocityX = Math.abs(blob.velocityX) * hardDamping; this.recordBounce(blob, currentTime); - } - - - if (blob.currentX > this.PHYSICS_MAX - margin) { - blob.currentX = this.PHYSICS_MAX - margin; - blob.velocityX = -Math.abs(blob.velocityX) * damping; + } else if (blob.currentX > hardMaxX) { + blob.currentX = hardMaxX; + blob.velocityX = -Math.abs(blob.velocityX) * hardDamping; this.recordBounce(blob, currentTime); } - - - if (blob.currentY < this.PHYSICS_MIN + margin * 1.5) { - blob.currentY = this.PHYSICS_MIN + margin * 1.5; - blob.velocityY = Math.abs(blob.velocityY) * damping; + if (blob.currentY < hardMinY) { + blob.currentY = hardMinY; + blob.velocityY = Math.abs(blob.velocityY) * hardDamping; this.recordBounce(blob, currentTime); - } - - - if (blob.currentY > this.PHYSICS_MAX - margin * 1.5) { - blob.currentY = this.PHYSICS_MAX - margin * 1.5; - blob.velocityY = -Math.abs(blob.velocityY) * damping; + } else if (blob.currentY > hardMaxY) { + blob.currentY = hardMaxY; + blob.velocityY = -Math.abs(blob.velocityY) * hardDamping; this.recordBounce(blob, currentTime); } } From b126ec47c3518b44cfdc2f19b0f2998266f39482 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 10:53:56 -0400 Subject: [PATCH 10/57] feat(motion): rewrite DeviceMotion with One-Euro + screen remap (TiltSource) (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(motion): rewrite DeviceMotion as TiltSource with One-Euro + screen remap Replace the raw-acceleration DeviceMotion implementation with a DeviceOrientationEvent-based pipeline that produces ambient-feel tilt vectors: raw beta/gamma ↓ remapToScreen (4-case axis switch on screen.orientation.angle) ↓ slow continuous baseline subtraction (α ≈ 0.0008, ~30s τ) ↓ ÷ range (default 45° → ±1) ↓ One-Euro filter (minCutoff=0.5, beta=0.01, dCutoff=1.0) ↓ clamp to [-1, 1] emitted to callback Edges handled: - prefers-reduced-motion: hard-disable; live-listen for runtime toggle - Face-down (|beta| > 120°): emit zero rather than wild values - 250ms warmup discard on first events (iOS warmup glitches) - 2s event gap → reset filter state (handles tab visibility resume) - Per-event read of screen.orientation.angle (rotation-locked devices never fire 'change' events) - visibilitychange → reset filter state on tab hide Why the change: - Old code used DeviceMotionEvent.accelerationIncludingGravity (raw, noisy). DeviceOrientationEvent is OS-fused and the right primitive for tilt — already low-noise, no manual gravity isolation needed. - Old fixed-α EMA at 0.35 ≈ 5 Hz cutoff was 6× too jittery for ambient feel. One-Euro is adaptive: smooth at rest, responsive under fast motion. - ±45° normalization beats ±90° because phones are rarely tilted past 45° in casual use, leaving most of the old range unused. Verified 'enableDeviceMotion={false}' in production (tinyland.dev) suggests the existing implementation feels bad. With this rewrite the canonical consumer should be able to flip it on. Same DeviceMotion export name (drop-in for callers). Same callback shape ({x, y, z}) — z is now always 0 since orientation events don't carry a meaningful third axis. TinyVectors.svelte handleDeviceMotion simplified accordingly: drop the redundant inner EMA (TiltSource filters internally) and the axis swap (TiltSource emits already screen-aligned). Tests cover the pure remapToScreen function for all four screen orientations plus zero/edge cases. TIN-834 * fix(motion): remove dead getPermissionApi guard; document Y-axis sign change Greptile review on PR #37 (3/5): 1. Dead guard at DeviceMotion.ts:129 — typeof getPermissionApi !== 'undefined' was always true (it is a module-level function declaration). The branch is reachable when reduced-motion is toggled off mid-session; no guard is needed since requestPermission() handles the no-API case internally. 2. Y-gravity sign — Greptile flagged the removed negation as a regression. Intentionally so: the previous code computed gravityY = -beta, which made forward tilt drive gravity UP the screen (away from viewer). The canonical consumer (tinyland.dev) shipped with enableDeviceMotion={false} explicitly because of this counter-intuitive direction. Removing the negation fixes the axis. Adding a code comment in TinyVectors.svelte documenting why so future readers do not 'restore' the bug. TIN-834 * fix(motion): guard against post-cleanup listener leak in DeviceMotion Greptile review on PR #37 (P1): The reducedMotionListener and initialize() both dispatch requestPermission() asynchronously and call startListening() on resolution. If cleanup() fires while iOS' permission prompt is still open (the user can dismiss it after the component unmounts), the resolved .then() reattaches a deviceorientation listener on the already-cleaned-up instance — a permanent leak. Add a disposed flag set first thing in cleanup(); guard the async .then() callbacks and the initialize() entry point against it. Stops the iOS-prompt-during-cleanup race cleanly. TIN-834 --- src/motion/DeviceMotion.ts | 319 ++++++++++++++++++++++---------- src/svelte/TinyVectors.svelte | 28 +-- tests/unit/devicemotion.test.ts | 43 +++++ 3 files changed, 276 insertions(+), 114 deletions(-) create mode 100644 tests/unit/devicemotion.test.ts diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index 7a54094..c627f5d 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -1,146 +1,261 @@ +import { OneEuro } from './OneEuro.js'; +export type DeviceMotionCallback = (data: { + x: number; + y: number; + z: number; +}) => void; +export interface DeviceMotionOptions { + /** One-Euro min cutoff (Hz). Lower = smoother at rest. Default 0.5. */ + oneEuroMinCutoff?: number; + /** One-Euro speed-responsiveness. Default 0.01 (low for ambient). */ + oneEuroBeta?: number; + /** One-Euro speed-estimate cutoff (Hz). Default 1.0. */ + oneEuroDCutoff?: number; + /** Slow continuous baseline EMA. Default 0.0008 (~30 s τ). */ + baselineAlpha?: number; + /** Discard events for the first N ms after first sample. Default 250. */ + warmupMs?: number; + /** Suppress output when |beta| exceeds this. Default 120°. */ + faceDownThreshold?: number; + /** Reset filter/baseline if event gap exceeds this. Default 2000 ms. */ + staleEventMs?: number; + /** Degrees mapped to ±1. Default 45 (matches casual tilt range). */ + range?: number; +} + +const DEFAULTS = { + oneEuroMinCutoff: 0.5, + oneEuroBeta: 0.01, + oneEuroDCutoff: 1.0, + baselineAlpha: 0.0008, + warmupMs: 250, + faceDownThreshold: 120, + staleEventMs: 2000, + range: 45, +} satisfies Required; +// Convert raw (beta, gamma) → screen-aligned (sx, sy) given +// screen.orientation.angle. sx is "left-right tilt felt by the user", +// sy is "front-back tilt felt by the user". Pure for unit tests. +export function remapToScreen( + beta: number, + gamma: number, + angle: number +): [number, number] { + switch (angle) { + case 90: + return [beta, -gamma]; + case 180: + return [-gamma, -beta]; + case 270: + return [-beta, gamma]; + case 0: + default: + return [gamma, beta]; + } +} +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} -export type DeviceMotionCallback = (data: { x: number; y: number; z: number }) => void; +type OrientationPermission = () => Promise; +function getPermissionApi(): OrientationPermission | null { + if (typeof DeviceOrientationEvent === 'undefined') return null; + const fn = ( + DeviceOrientationEvent as unknown as { requestPermission?: OrientationPermission } + ).requestPermission; + return typeof fn === 'function' ? fn : null; +} +// Replaces the previous raw-acceleration DeviceMotion implementation. +// Listens to DeviceOrientationEvent (OS-fused, low-noise) instead of +// DeviceMotionEvent.accelerationIncludingGravity. Filters with One-Euro +// for an ambient-feel adaptive low-pass; subtracts a slow baseline so +// resting pose (cable bias, pocket lean) is absorbed without killing +// gravity feel. Honors prefers-reduced-motion as a hard disable. export class DeviceMotion { private callback: DeviceMotionCallback; + private opts: Required; private isListening = false; - private useMotionAPI = false; + private disposed = false; + private filterX: OneEuro; + private filterY: OneEuro; + private baseX = 0; + private baseY = 0; + private firstEventAt = 0; + private lastEventAt = 0; + private boundOrientation: ((e: DeviceOrientationEvent) => void) | null = null; + private boundVisibility: (() => void) | null = null; + private reducedMotionMql: MediaQueryList | null = null; + private reducedMotionListener: (() => void) | null = null; - constructor(callback: DeviceMotionCallback) { + constructor(callback: DeviceMotionCallback, options: DeviceMotionOptions = {}) { this.callback = callback; + this.opts = { ...DEFAULTS, ...options }; + const eu = { + minCutoff: this.opts.oneEuroMinCutoff, + beta: this.opts.oneEuroBeta, + dCutoff: this.opts.oneEuroDCutoff, + }; + this.filterX = new OneEuro(eu); + this.filterY = new OneEuro(eu); } async initialize(): Promise { - - if (typeof window === 'undefined') { - return; - } - - + if (this.disposed) return; + if (typeof window === 'undefined') return; if (!window.isSecureContext) { console.warn('DeviceMotion APIs require a secure context (HTTPS)'); return; } - - - if ('DeviceMotionEvent' in window) { - this.useMotionAPI = true; - } else if ('DeviceOrientationEvent' in window) { - this.useMotionAPI = false; - } else { - console.log('No device motion/orientation APIs supported'); + if (!('DeviceOrientationEvent' in window)) { + console.log('DeviceOrientationEvent not supported'); return; } - - const hasPermission = await this.requestPermission(); - if (hasPermission) { - this.startListening(); - } else { - console.warn('Device motion permission denied or not available'); - } + // prefers-reduced-motion is a hard disable. Subscribe to changes so + // we honor a runtime toggle (Apple users can flip this from Control + // Center) — but never auto-listen until explicitly initialized. + this.reducedMotionMql = + window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null; + this.reducedMotionListener = () => { + if (this.disposed || !this.reducedMotionMql) return; + if (this.reducedMotionMql.matches && this.isListening) { + this.stopListening(); + } else if (!this.reducedMotionMql.matches && !this.isListening) { + // Re-engage if user disabled reduced-motion mid-session. + // requestPermission() handles the no-API case internally. + // Guard against post-cleanup resolution: iOS may have a + // permission prompt open when cleanup() fires. + void this.requestPermission().then((ok) => { + if (ok && !this.disposed) this.startListening(); + }); + } + }; + this.reducedMotionMql?.addEventListener('change', this.reducedMotionListener); + + if (this.reducedMotionMql?.matches) return; + + const ok = await this.requestPermission(); + if (ok && !this.disposed) this.startListening(); } async requestPermission(): Promise { - - if ( - this.useMotionAPI && - typeof (DeviceMotionEvent as unknown as { requestPermission?: () => Promise }) - .requestPermission === 'function' - ) { - try { - const response = await ( - DeviceMotionEvent as unknown as { requestPermission: () => Promise } - ).requestPermission(); - return response === 'granted'; - } catch (error) { - console.error('Error requesting device motion permission:', error); - return false; - } - } else if ( - !this.useMotionAPI && - typeof (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise }) - .requestPermission === 'function' - ) { - try { - const response = await ( - DeviceOrientationEvent as unknown as { requestPermission: () => Promise } - ).requestPermission(); - return response === 'granted'; - } catch (error) { - console.error('Error requesting device orientation permission:', error); - return false; - } + const api = getPermissionApi(); + if (!api) return true; + try { + const r = await api(); + return r === 'granted'; + } catch (err) { + console.error('Error requesting device orientation permission:', err); + return false; } - - - return true; } private startListening(): void { if (this.isListening) return; + this.boundOrientation = (e: DeviceOrientationEvent) => this.handle(e); + window.addEventListener('deviceorientation', this.boundOrientation, { + passive: true, + } as AddEventListenerOptions); + + this.boundVisibility = () => { + if (document.hidden) this.resetFilterState(); + }; + document.addEventListener('visibilitychange', this.boundVisibility); - if (this.useMotionAPI) { - window.addEventListener('devicemotion', this.handleMotion); - } else { - window.addEventListener('deviceorientation', this.handleOrientation); - } this.isListening = true; } - private handleMotion = (event: DeviceMotionEvent): void => { - try { - if (!event.accelerationIncludingGravity) return; - - const { x, y, z } = event.accelerationIncludingGravity; - if (x === null || y === null || z === null) return; - - - - const data = { - x: Math.max(-1, Math.min(1, x / 9.8)), - y: Math.max(-1, Math.min(1, y / 9.8)), - z: Math.max(-1, Math.min(1, z / 9.8)), - }; - - this.callback(data); - } catch (error) { - console.error('Error handling device motion:', error); + private stopListening(): void { + if (!this.isListening) return; + if (this.boundOrientation) { + window.removeEventListener('deviceorientation', this.boundOrientation); + this.boundOrientation = null; } - }; + if (this.boundVisibility) { + document.removeEventListener('visibilitychange', this.boundVisibility); + this.boundVisibility = null; + } + this.isListening = false; + } - private handleOrientation = (event: DeviceOrientationEvent): void => { - try { - if (event.beta === null || event.gamma === null) return; + private resetFilterState(): void { + this.filterX.reset(); + this.filterY.reset(); + this.firstEventAt = 0; + this.lastEventAt = 0; + } + + private handle(event: DeviceOrientationEvent): void { + if (event.beta == null || event.gamma == null) return; - - - + const now = + typeof performance !== 'undefined' ? performance.now() : Date.now(); - const data = { - x: event.beta / 90, - y: event.gamma / 90, - z: event.alpha ? event.alpha / 360 : 0, - }; + if (this.firstEventAt === 0) this.firstEventAt = now; + if (now - this.firstEventAt < this.opts.warmupMs) return; - this.callback(data); - } catch (error) { - console.error('Error handling device orientation:', error); + if ( + this.lastEventAt > 0 && + now - this.lastEventAt > this.opts.staleEventMs + ) { + this.resetFilterState(); + this.firstEventAt = now; + this.lastEventAt = now; + return; } - }; + this.lastEventAt = now; + + // Face-down or upside-down: emit zero rather than wild values. + if (Math.abs(event.beta) > this.opts.faceDownThreshold) { + this.callback({ x: 0, y: 0, z: 0 }); + return; + } + + const angle = + (typeof screen !== 'undefined' && screen.orientation?.angle) || 0; + const [sx, sy] = remapToScreen(event.beta, event.gamma, angle); + + // Slow continuous baseline absorbs cable bias / pocket lean over + // ~30 s without killing gravity feel. + const a = this.opts.baselineAlpha; + this.baseX += a * (sx - this.baseX); + this.baseY += a * (sy - this.baseY); + + const range = this.opts.range; + const xRaw = (sx - this.baseX) / range; + const yRaw = (sy - this.baseY) / range; + + const xFiltered = this.filterX.filter(xRaw, now); + const yFiltered = this.filterY.filter(yRaw, now); + + this.callback({ + x: clamp(xFiltered, -1, 1), + y: clamp(yFiltered, -1, 1), + z: 0, + }); + } cleanup(): void { - if (this.isListening) { - if (this.useMotionAPI) { - window.removeEventListener('devicemotion', this.handleMotion); - } else { - window.removeEventListener('deviceorientation', this.handleOrientation); - } - this.isListening = false; + // Set disposed first so any in-flight requestPermission() promise + // that resolves after cleanup() short-circuits before re-attaching + // a deviceorientation listener (iOS keeps the permission prompt + // open across tab navigation; the user can dismiss after the + // component has unmounted). + this.disposed = true; + this.stopListening(); + if (this.reducedMotionMql && this.reducedMotionListener) { + this.reducedMotionMql.removeEventListener( + 'change', + this.reducedMotionListener + ); } + this.reducedMotionMql = null; + this.reducedMotionListener = null; + this.resetFilterState(); } } diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index 17cf43b..c480f5f 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -92,23 +92,27 @@ return (isMobileUserAgent || (isMobileScreen && hasTouchScreen)) && hasOrientationAPI; }; - // Handle device motion - no reactive state updates + // DeviceMotion now emits already-filtered, axis-remapped, screen-aligned + // tilt vectors in [-1, 1] (One-Euro internally, slow baseline subtraction, + // face-down suppression, screen.orientation remap). Pass through directly + // — no extra EMA, no axis swap, no negation. The 0.8 magnitude scaler is + // preserved so gravity strength matches the previous code's feel at the + // physics layer. + // + // Y-gravity sign note: the previous handler computed gravityY = -beta, + // which made forward-tilt drive gravity UP the screen (away from the + // viewer). That was counter-intuitive — forward tilt should pull stuff + // toward the viewer (positive screen-Y, downward). TiltSource emits + // screen-aligned values directly, so we use motionData.y unchanged. + // This is the intentional fix that the canonical consumer was working + // around by setting enableDeviceMotion={false}. const handleDeviceMotion = (motionData: { x: number; y: number; z: number }) => { if (!hasAccelerometerAccess || !physics) return; - tiltX = motionData.x; tiltY = motionData.y; tiltZ = motionData.z; - - // Convert to gravity vector - const newX = motionData.y * 0.8; - const newY = -motionData.x * 0.8; - - // Smooth the values - gravityX = newX * 0.7 + gravityX * 0.3; - gravityY = newY * 0.7 + gravityY * 0.3; - - // Pass to physics + gravityX = motionData.x * 0.8; + gravityY = motionData.y * 0.8; physics.setGravity({ x: gravityX, y: gravityY }); physics.setTilt({ x: tiltX, y: tiltY, z: tiltZ }); }; diff --git a/tests/unit/devicemotion.test.ts b/tests/unit/devicemotion.test.ts new file mode 100644 index 0000000..6ddb700 --- /dev/null +++ b/tests/unit/devicemotion.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { remapToScreen } from '../../src/motion/DeviceMotion.js'; + +describe('remapToScreen', () => { + it('portrait (angle=0): gamma → screen-X, beta → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 0); + expect(sx).toBe(30); // gamma + expect(sy).toBe(20); // beta + }); + + it('landscape-left (angle=90): beta → screen-X, -gamma → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 90); + expect(sx).toBe(20); // beta + expect(sy).toBe(-30); // -gamma + }); + + it('upside-down portrait (angle=180): -gamma → screen-X, -beta → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 180); + expect(sx).toBe(-30); + expect(sy).toBe(-20); + }); + + it('landscape-right (angle=270): -beta → screen-X, gamma → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 270); + expect(sx).toBe(-20); + expect(sy).toBe(30); + }); + + it('unknown angle falls back to portrait mapping', () => { + const [sx, sy] = remapToScreen(20, 30, 45); + expect(sx).toBe(30); + expect(sy).toBe(20); + }); + + it('preserves zero on flat-on-table input', () => { + // Negation can produce -0 in JS; numerically equivalent to 0. + for (const angle of [0, 90, 180, 270]) { + const [sx, sy] = remapToScreen(0, 0, angle); + expect(Math.abs(sx)).toBe(0); + expect(Math.abs(sy)).toBe(0); + } + }); +}); From a041a9384a4017c9ce4b5a5f6f6809c775f6500d Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 12:25:06 -0400 Subject: [PATCH 11/57] feat(rendering): tune gel gradients and theme colors --- src/core/index.ts | 8 +- src/core/schema.ts | 139 ++++----------------------- src/core/theme-colors.ts | 30 ++++++ src/core/theme-presets.ts | 92 ++++++++++++++++++ src/svelte/BlobSVG.svelte | 159 ++++++++++++++++++++++--------- src/themes/index.ts | 4 +- src/themes/vector-colors.css | 4 +- tests/unit/theme-presets.test.ts | 34 +++++++ 8 files changed, 297 insertions(+), 173 deletions(-) create mode 100644 src/core/theme-colors.ts create mode 100644 src/core/theme-presets.ts create mode 100644 tests/unit/theme-presets.test.ts diff --git a/src/core/index.ts b/src/core/index.ts index 6dddcf9..c4264da 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -62,7 +62,7 @@ export type { BlendMode, ThemeColor, ThemePreset, -} from './schema.js'; +} from './theme-presets.js'; // — Render blob shapes — export type { @@ -88,11 +88,15 @@ export type { // — Theme presets and config — export { - DEFAULT_CONFIG, TRANS_THEME, PRIDE_THEME, TINYLAND_THEME, HIGH_CONTRAST_THEME, THEME_PRESETS, +} from './theme-presets.js'; +export { THEME_PRESET_COLORS } from './theme-colors.js'; + +export { + DEFAULT_CONFIG, mergeConfig, } from './schema.js'; diff --git a/src/core/schema.ts b/src/core/schema.ts index 1de1ac2..6873d06 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -7,6 +7,17 @@ import type { ControlPoint, ControlPointVelocity, DeviceMotionData } from './types.js'; +import type { + BlendMode, + ThemeColor, + ThemePresetName, +} from './theme-presets.js'; +export type { + BlendMode, + ThemeColor, + ThemePreset, + ThemePresetName, +} from './theme-presets.js'; @@ -166,53 +177,16 @@ export interface FeatureFlags { -export type ThemePresetName = 'tinyland' | 'trans' | 'pride' | 'high-contrast' | 'custom'; -export type BlendMode = 'multiply' | 'screen' | 'overlay' | 'soft-light' | 'normal'; -export interface ThemeColor { - - id: string; - - color: string; - - attractive: boolean; - - scrollAffinity: number; - - layer: 'background' | 'mid' | 'foreground'; -} - - - - -export interface ThemePreset { - - name: ThemePresetName; - - - label: string; - - - colors: ThemeColor[]; - - - blendModeLight: BlendMode; - - - blendModeDark: BlendMode; - - - hasVectors: boolean; -} @@ -510,89 +484,14 @@ export const DEFAULT_CONFIG: TinyVectorsConfig = { -export const TRANS_THEME: ThemePreset = { - name: 'trans', - label: 'Trans Pride', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [ - { id: 'trans-blue', color: 'rgba(91, 206, 250, 0.60)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'trans-pink', color: 'rgba(245, 169, 184, 0.65)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'trans-white', color: 'rgba(242, 242, 245, 0.50)', attractive: false, scrollAffinity: 0.5, layer: 'mid' }, - { id: 'trans-sky-blue', color: 'rgba(170, 225, 250, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, - { id: 'trans-powder-blue', color: 'rgba(160, 190, 255, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, - { id: 'trans-rose-pink', color: 'rgba(250, 200, 210, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, - { id: 'trans-blush-pink', color: 'rgba(255, 160, 220, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, - { id: 'trans-lavender', color: 'rgba(220, 220, 255, 0.55)', attractive: false, scrollAffinity: 0.5, layer: 'background' }, - ], -}; - - - - -export const PRIDE_THEME: ThemePreset = { - name: 'pride', - label: 'Pride Rainbow', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [ - { id: 'pride-red', color: 'rgba(228, 3, 3, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, - { id: 'pride-orange', color: 'rgba(255, 140, 0, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'pride-yellow', color: 'rgba(255, 237, 0, 0.55)', attractive: false, scrollAffinity: 0.7, layer: 'mid' }, - { id: 'pride-green', color: 'rgba(0, 128, 38, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'pride-blue', color: 'rgba(36, 64, 142, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, - { id: 'pride-purple', color: 'rgba(115, 41, 130, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - ], -}; - - - - -export const TINYLAND_THEME: ThemePreset = { - name: 'tinyland', - label: 'Tinyland', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [ - { id: 'tinyland-purple', color: 'rgba(139, 92, 246, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'tinyland-blue', color: 'rgba(59, 130, 246, 0.55)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, - { id: 'tinyland-pink', color: 'rgba(236, 72, 153, 0.50)', attractive: true, scrollAffinity: 0.8, layer: 'mid' }, - { id: 'tinyland-white', color: 'rgba(242, 242, 245, 0.45)', attractive: false, scrollAffinity: 0.4, layer: 'background' }, - ], -}; - - - - -export const HIGH_CONTRAST_THEME: ThemePreset = { - name: 'high-contrast', - label: 'High Contrast', - hasVectors: false, - blendModeLight: 'normal', - blendModeDark: 'normal', - colors: [], -}; - - - - -export const THEME_PRESETS: Record = { - tinyland: TINYLAND_THEME, - trans: TRANS_THEME, - pride: PRIDE_THEME, - 'high-contrast': HIGH_CONTRAST_THEME, - custom: { - name: 'custom', - label: 'Custom', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [], - }, -}; +export { + HIGH_CONTRAST_THEME, + PRIDE_THEME, + THEME_PRESETS, + TINYLAND_THEME, + TRANS_THEME, +} from './theme-presets.js'; +export { THEME_PRESET_COLORS } from './theme-colors.js'; diff --git a/src/core/theme-colors.ts b/src/core/theme-colors.ts new file mode 100644 index 0000000..46029c7 --- /dev/null +++ b/src/core/theme-colors.ts @@ -0,0 +1,30 @@ +import type { ThemePresetName } from './theme-presets.js'; + +export const THEME_PRESET_COLORS: Record = { + tinyland: [ + 'rgba(139,92,246,.55)', + 'rgba(59,130,246,.55)', + 'rgba(236,72,153,.50)', + 'rgba(242,242,245,.45)', + ], + trans: [ + 'rgba(91,206,250,.60)', + 'rgba(245,169,184,.65)', + 'rgba(242,242,245,.50)', + 'rgba(170,225,250,.55)', + 'rgba(160,190,255,.65)', + 'rgba(250,200,210,.55)', + 'rgba(255,160,220,.65)', + 'rgba(220,220,255,.55)', + ], + pride: [ + 'rgba(228,3,3,.55)', + 'rgba(255,140,0,.55)', + 'rgba(255,237,0,.55)', + 'rgba(0,128,38,.55)', + 'rgba(36,64,142,.55)', + 'rgba(115,41,130,.55)', + ], + 'high-contrast': [], + custom: [], +}; diff --git a/src/core/theme-presets.ts b/src/core/theme-presets.ts new file mode 100644 index 0000000..02be596 --- /dev/null +++ b/src/core/theme-presets.ts @@ -0,0 +1,92 @@ +export type ThemePresetName = 'tinyland' | 'trans' | 'pride' | 'high-contrast' | 'custom'; + +export type BlendMode = 'multiply' | 'screen' | 'overlay' | 'soft-light' | 'normal'; + +export interface ThemeColor { + id: string; + color: string; + attractive: boolean; + scrollAffinity: number; + layer: 'background' | 'mid' | 'foreground'; +} + +export interface ThemePreset { + name: ThemePresetName; + label: string; + colors: ThemeColor[]; + blendModeLight: BlendMode; + blendModeDark: BlendMode; + hasVectors: boolean; +} + +export const TRANS_THEME: ThemePreset = { + name: 'trans', + label: 'Trans Pride', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [ + { id: 'trans-blue', color: 'rgba(91, 206, 250, 0.60)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'trans-pink', color: 'rgba(245, 169, 184, 0.65)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'trans-white', color: 'rgba(242, 242, 245, 0.50)', attractive: false, scrollAffinity: 0.5, layer: 'mid' }, + { id: 'trans-sky-blue', color: 'rgba(170, 225, 250, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, + { id: 'trans-powder-blue', color: 'rgba(160, 190, 255, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, + { id: 'trans-rose-pink', color: 'rgba(250, 200, 210, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, + { id: 'trans-blush-pink', color: 'rgba(255, 160, 220, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, + { id: 'trans-lavender', color: 'rgba(220, 220, 255, 0.55)', attractive: false, scrollAffinity: 0.5, layer: 'background' }, + ], +}; + +export const PRIDE_THEME: ThemePreset = { + name: 'pride', + label: 'Pride Rainbow', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [ + { id: 'pride-red', color: 'rgba(228, 3, 3, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, + { id: 'pride-orange', color: 'rgba(255, 140, 0, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'pride-yellow', color: 'rgba(255, 237, 0, 0.55)', attractive: false, scrollAffinity: 0.7, layer: 'mid' }, + { id: 'pride-green', color: 'rgba(0, 128, 38, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'pride-blue', color: 'rgba(36, 64, 142, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, + { id: 'pride-purple', color: 'rgba(115, 41, 130, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + ], +}; + +export const TINYLAND_THEME: ThemePreset = { + name: 'tinyland', + label: 'Tinyland', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [ + { id: 'tinyland-purple', color: 'rgba(139, 92, 246, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'tinyland-blue', color: 'rgba(59, 130, 246, 0.55)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, + { id: 'tinyland-pink', color: 'rgba(236, 72, 153, 0.50)', attractive: true, scrollAffinity: 0.8, layer: 'mid' }, + { id: 'tinyland-white', color: 'rgba(242, 242, 245, 0.45)', attractive: false, scrollAffinity: 0.4, layer: 'background' }, + ], +}; + +export const HIGH_CONTRAST_THEME: ThemePreset = { + name: 'high-contrast', + label: 'High Contrast', + hasVectors: false, + blendModeLight: 'normal', + blendModeDark: 'normal', + colors: [], +}; + +export const THEME_PRESETS: Record = { + tinyland: TINYLAND_THEME, + trans: TRANS_THEME, + pride: PRIDE_THEME, + 'high-contrast': HIGH_CONTRAST_THEME, + custom: { + name: 'custom', + label: 'Custom', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [], + }, +}; diff --git a/src/svelte/BlobSVG.svelte b/src/svelte/BlobSVG.svelte index 50d3dbd..a9e4e14 100644 --- a/src/svelte/BlobSVG.svelte +++ b/src/svelte/BlobSVG.svelte @@ -3,19 +3,16 @@ import type { BlobPhysics } from '../core/BlobPhysics.js'; import type { ConvexBlob } from '../core/types.js'; - // Props using Svelte 5 $props() syntax interface Props { blobs?: ConvexBlob[]; physics?: BlobPhysics | null; } + const svgId = $props.id(); let { blobs = [], physics = null }: Props = $props(); - // Track dark mode for blend mode switching let isDarkMode = $state(false); - let primaryBlend = $derived(isDarkMode ? 'screen' : 'multiply'); - // Watch for dark mode changes $effect(() => { if (browser) { isDarkMode = document.documentElement.classList.contains('dark'); @@ -34,98 +31,166 @@ } }); - // Generate simple circle path (fast) - used for glow/core layers function getCirclePath(cx: number, cy: number, r: number): string { return `M ${cx - r},${cy} A ${r},${r} 0 1,1 ${cx + r},${cy} A ${r},${r} 0 1,1 ${cx - r},${cy}`; } - // Generate organic path for main blob body only function getBlobPath(blob: ConvexBlob): string { if (physics && blob.controlPoints && blob.controlPoints.length > 0) { return physics.generateSmoothBlobPath(blob); } return getCirclePath(blob.currentX, blob.currentY, blob.size); } + + function getDefinitionId(name: string): string { + return `${svgId}-${name}`; + } + + function getBlobDefinitionId(blob: ConvexBlob, name: string): string { + return `${svgId}-${blob.gradientId}-${name}`; + } - - - - + + - - - + + - {#each blobs as blob, i (blob.gradientId)} - - - - - + {#each blobs as blob (blob.gradientId)} + + + + - - - - - + + + + + - - - - - + + + + + + + + + + {/each} - - + {#each blobs as blob (blob.gradientId)} {/each} - - + + {#each blobs as blob (blob.gradientId)} + + {/each} + + + {#each blobs as blob (blob.gradientId)} {/each} - - + {#each blobs as blob (blob.gradientId)} {/each} diff --git a/src/themes/index.ts b/src/themes/index.ts index 72068d4..46ead4d 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -2,7 +2,7 @@ -import { THEME_PRESETS, type ThemePreset } from '../core/schema.js'; +import { THEME_PRESETS, type ThemePreset } from '../core/theme-presets.js'; export { THEME_PRESETS, @@ -13,7 +13,7 @@ export { type ThemePreset, type ThemeColor, type ThemePresetName, -} from '../core/schema.js'; +} from '../core/theme-presets.js'; diff --git a/src/themes/vector-colors.css b/src/themes/vector-colors.css index 0767214..a8a4971 100644 --- a/src/themes/vector-colors.css +++ b/src/themes/vector-colors.css @@ -5,13 +5,13 @@ /* Per-blob intensity, registered so calc() in SVG stop-opacity is * interpolated as a number rather than a string. Set inline on each * from blob.intensity in BlobSVG.svelte; gradient - * stops compute their actual opacity via calc(var(--tv-blob-intensity) + * stops compute their actual opacity via calc(var(--tvi) * * ). Avoids ~60 reactive expressions per frame in * Svelte (one per stop-opacity arithmetic) — the inline style still * re-runs each frame, but only ~5 evaluations per frame total * (one per blob, not per stop). */ -@property --tv-blob-intensity { +@property --tvi { syntax: ''; inherits: true; initial-value: 1; diff --git a/tests/unit/theme-presets.test.ts b/tests/unit/theme-presets.test.ts new file mode 100644 index 0000000..63b21cd --- /dev/null +++ b/tests/unit/theme-presets.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { + THEME_PRESETS, + type ThemePresetName, +} from '../../src/core/theme-presets.js'; +import { THEME_PRESET_COLORS } from '../../src/core/theme-colors.js'; +import { + THEME_PRESET_COLORS as SCHEMA_THEME_PRESET_COLORS, + THEME_PRESETS as SCHEMA_THEME_PRESETS, +} from '../../src/core/schema.js'; + +describe('theme presets', () => { + it('keeps lightweight color presets aligned with full theme presets', () => { + const names = Object.keys(THEME_PRESETS) as ThemePresetName[]; + + expect(Object.keys(THEME_PRESET_COLORS).sort()).toEqual([...names].sort()); + + for (const name of names) { + expect(THEME_PRESET_COLORS[name]).toEqual( + THEME_PRESETS[name].colors.map((color) => compactRgba(color.color)), + ); + } + }); + + it('preserves schema re-exports for the existing public surface', () => { + expect(SCHEMA_THEME_PRESETS).toBe(THEME_PRESETS); + expect(SCHEMA_THEME_PRESET_COLORS).toBe(THEME_PRESET_COLORS); + }); +}); + +function compactRgba(color: string): string { + return color.replaceAll(' ', '').replace(',0.', ',.'); +} From 3c8196bc45d44879aec2239c04a1581857ff8e3f Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 12:25:17 -0400 Subject: [PATCH 12/57] feat(motion): harden device orientation tilt source --- src/motion/DeviceMotion.ts | 370 ++++++++++++++++++++----------- src/motion/OneEuro.ts | 7 +- tests/unit/device-motion.test.ts | 223 +++++++++++++++++++ tests/unit/oneeuro.test.ts | 5 + 4 files changed, 473 insertions(+), 132 deletions(-) create mode 100644 tests/unit/device-motion.test.ts diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index c627f5d..af89d86 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -1,28 +1,48 @@ import { OneEuro } from './OneEuro.js'; -export type DeviceMotionCallback = (data: { +export interface MotionVector { x: number; y: number; z: number; -}) => void; +} + +export type DeviceMotionCallback = (data: MotionVector) => void; + +export type DeviceMotionPermissionState = + | 'unknown' + | 'unsupported' + | 'insecure' + | 'prompt' + | 'granted' + | 'denied'; export interface DeviceMotionOptions { /** One-Euro min cutoff (Hz). Lower = smoother at rest. Default 0.5. */ oneEuroMinCutoff?: number; - /** One-Euro speed-responsiveness. Default 0.01 (low for ambient). */ + /** One-Euro speed responsiveness. Default 0.01 for ambient backgrounds. */ oneEuroBeta?: number; /** One-Euro speed-estimate cutoff (Hz). Default 1.0. */ oneEuroDCutoff?: number; - /** Slow continuous baseline EMA. Default 0.0008 (~30 s τ). */ + /** Slow continuous baseline EMA. Default 0.0008, roughly 30 s tau. */ baselineAlpha?: number; - /** Discard events for the first N ms after first sample. Default 250. */ + /** Discard events for the first N ms after listener startup. Default 250. */ warmupMs?: number; - /** Suppress output when |beta| exceeds this. Default 120°. */ + /** Suppress output when |beta| exceeds this. Default 120 degrees. */ faceDownThreshold?: number; - /** Reset filter/baseline if event gap exceeds this. Default 2000 ms. */ + /** Reset filters if event gap exceeds this. Default 2000 ms. */ staleEventMs?: number; - /** Degrees mapped to ±1. Default 45 (matches casual tilt range). */ + /** Degrees mapped to +/-1. Default 45, matching casual tilt range. */ range?: number; + /** Manual calibration sample count used by calibrate(). Default 8. */ + calibrationSamples?: number; + /** Values smaller than this are treated as rest-state noise. Default 0.015. */ + deadZone?: number; +} + +interface MotionWindow { + DeviceOrientationEvent?: { + requestPermission?: () => Promise<'granted' | 'denied'>; + }; } const DEFAULTS = { @@ -34,16 +54,13 @@ const DEFAULTS = { faceDownThreshold: 120, staleEventMs: 2000, range: 45, + calibrationSamples: 8, + deadZone: 0.015, } satisfies Required; -// Convert raw (beta, gamma) → screen-aligned (sx, sy) given -// screen.orientation.angle. sx is "left-right tilt felt by the user", -// sy is "front-back tilt felt by the user". Pure for unit tests. -export function remapToScreen( - beta: number, - gamma: number, - angle: number -): [number, number] { +// Convert raw (beta, gamma) to screen-aligned tilt. sx is left/right +// tilt as felt by the user; sy is front/back tilt as felt by the user. +export function remapToScreen(beta: number, gamma: number, angle: number): [number, number] { switch (angle) { case 90: return [beta, -gamma]; @@ -57,37 +74,44 @@ export function remapToScreen( } } -function clamp(v: number, lo: number, hi: number): number { - return v < lo ? lo : v > hi ? hi : v; +function clamp(value: number, min = -1, max = 1): number { + return Math.max(min, Math.min(max, value)); +} + +function applyDeadZone(value: number, deadZone: number): number { + return Math.abs(value) < deadZone ? 0 : value; } -type OrientationPermission = () => Promise; -function getPermissionApi(): OrientationPermission | null { - if (typeof DeviceOrientationEvent === 'undefined') return null; - const fn = ( - DeviceOrientationEvent as unknown as { requestPermission?: OrientationPermission } - ).requestPermission; - return typeof fn === 'function' ? fn : null; +function getPermissionApi(): (() => Promise<'granted' | 'denied'>) | null { + if (typeof window === 'undefined') return null; + const constructor = (window as unknown as MotionWindow).DeviceOrientationEvent; + const requestPermission = constructor?.requestPermission; + return typeof requestPermission === 'function' ? requestPermission.bind(constructor) : null; +} + +function getScreenOrientationAngle(): number { + if (typeof screen === 'undefined') return 0; + return screen.orientation?.angle ?? 0; } -// Replaces the previous raw-acceleration DeviceMotion implementation. -// Listens to DeviceOrientationEvent (OS-fused, low-noise) instead of -// DeviceMotionEvent.accelerationIncludingGravity. Filters with One-Euro -// for an ambient-feel adaptive low-pass; subtracts a slow baseline so -// resting pose (cable bias, pocket lean) is absorbed without killing -// gravity feel. Honors prefers-reduced-motion as a hard disable. export class DeviceMotion { - private callback: DeviceMotionCallback; - private opts: Required; + private readonly callback: DeviceMotionCallback; + private readonly opts: Required; + private readonly filterX: OneEuro; + private readonly filterY: OneEuro; + private permissionState: DeviceMotionPermissionState = 'unknown'; private isListening = false; private disposed = false; - private filterX: OneEuro; - private filterY: OneEuro; + private listenerStartedAt = 0; + private lastEventAt = 0; private baseX = 0; private baseY = 0; - private firstEventAt = 0; - private lastEventAt = 0; - private boundOrientation: ((e: DeviceOrientationEvent) => void) | null = null; + private lastScreen: { x: number; y: number } | null = null; + private calibrationRemaining = 0; + private calibrationTargetSamples = 0; + private calibrationTotalX = 0; + private calibrationTotalY = 0; + private boundOrientation: ((event: DeviceOrientationEvent) => void) | null = null; private boundVisibility: (() => void) | null = null; private reducedMotionMql: MediaQueryList | null = null; private reducedMotionListener: (() => void) | null = null; @@ -95,167 +119,251 @@ export class DeviceMotion { constructor(callback: DeviceMotionCallback, options: DeviceMotionOptions = {}) { this.callback = callback; this.opts = { ...DEFAULTS, ...options }; - const eu = { + const params = { minCutoff: this.opts.oneEuroMinCutoff, beta: this.opts.oneEuroBeta, dCutoff: this.opts.oneEuroDCutoff, }; - this.filterX = new OneEuro(eu); - this.filterY = new OneEuro(eu); + this.filterX = new OneEuro(params); + this.filterY = new OneEuro(params); } - async initialize(): Promise { - if (this.disposed) return; - if (typeof window === 'undefined') return; - if (!window.isSecureContext) { - console.warn('DeviceMotion APIs require a secure context (HTTPS)'); + async initialize(): Promise { + if (!this.detectSupport()) return false; + this.observeReducedMotion(); + + if (this.prefersReducedMotion()) { + this.permissionState = 'denied'; + this.stopListening(); + return false; + } + + if (getPermissionApi()) { + this.permissionState = 'prompt'; + return false; + } + + this.permissionState = 'granted'; + this.startListening(); + return true; + } + + async requestPermission(): Promise { + if (!this.detectSupport()) return false; + this.observeReducedMotion(); + + if (this.prefersReducedMotion()) { + this.permissionState = 'denied'; + this.stopListening(); + return false; + } + + const requestPermission = getPermissionApi(); + if (requestPermission) { + this.permissionState = 'prompt'; + try { + this.permissionState = await requestPermission(); + } catch { + this.permissionState = 'denied'; + } + } else { + this.permissionState = 'granted'; + } + + if (this.permissionState !== 'granted' || this.disposed) { + this.stopListening(); + return false; + } + + this.startListening(); + return true; + } + + calibrate(samples = this.opts.calibrationSamples): void { + const sampleCount = Math.max(0, Math.floor(samples)); + this.resetFilterState(); + + if (sampleCount === 0) { + if (this.lastScreen) { + this.baseX = this.lastScreen.x; + this.baseY = this.lastScreen.y; + } + this.calibrationRemaining = 0; return; } + + this.calibrationRemaining = sampleCount; + this.calibrationTargetSamples = sampleCount; + this.calibrationTotalX = 0; + this.calibrationTotalY = 0; + } + + getPermissionState(): DeviceMotionPermissionState { + return this.permissionState; + } + + isActive(): boolean { + return this.isListening; + } + + cleanup(): void { + this.disposed = true; + this.stopListening(); + + if (this.reducedMotionMql && this.reducedMotionListener) { + this.reducedMotionMql.removeEventListener('change', this.reducedMotionListener); + } + this.reducedMotionMql = null; + this.reducedMotionListener = null; + this.resetFilterState(); + } + + private detectSupport(): boolean { + if (this.disposed) return false; + + if (typeof window === 'undefined') { + this.permissionState = 'unsupported'; + return false; + } + + if (!window.isSecureContext) { + this.permissionState = 'insecure'; + return false; + } + if (!('DeviceOrientationEvent' in window)) { - console.log('DeviceOrientationEvent not supported'); - return; + this.permissionState = 'unsupported'; + return false; } - // prefers-reduced-motion is a hard disable. Subscribe to changes so - // we honor a runtime toggle (Apple users can flip this from Control - // Center) — but never auto-listen until explicitly initialized. - this.reducedMotionMql = - window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null; + return true; + } + + private observeReducedMotion(): void { + if (this.reducedMotionMql || typeof window === 'undefined') return; + + this.reducedMotionMql = window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null; this.reducedMotionListener = () => { if (this.disposed || !this.reducedMotionMql) return; - if (this.reducedMotionMql.matches && this.isListening) { + + if (this.reducedMotionMql.matches) { this.stopListening(); - } else if (!this.reducedMotionMql.matches && !this.isListening) { - // Re-engage if user disabled reduced-motion mid-session. - // requestPermission() handles the no-API case internally. - // Guard against post-cleanup resolution: iOS may have a - // permission prompt open when cleanup() fires. - void this.requestPermission().then((ok) => { - if (ok && !this.disposed) this.startListening(); - }); + return; + } + + if (this.permissionState === 'granted') { + this.startListening(); } }; this.reducedMotionMql?.addEventListener('change', this.reducedMotionListener); - - if (this.reducedMotionMql?.matches) return; - - const ok = await this.requestPermission(); - if (ok && !this.disposed) this.startListening(); } - async requestPermission(): Promise { - const api = getPermissionApi(); - if (!api) return true; - try { - const r = await api(); - return r === 'granted'; - } catch (err) { - console.error('Error requesting device orientation permission:', err); - return false; - } + private prefersReducedMotion(): boolean { + return this.reducedMotionMql?.matches ?? false; } private startListening(): void { - if (this.isListening) return; - this.boundOrientation = (e: DeviceOrientationEvent) => this.handle(e); - window.addEventListener('deviceorientation', this.boundOrientation, { - passive: true, - } as AddEventListenerOptions); + if (this.disposed || this.isListening || typeof window === 'undefined') return; + + this.boundOrientation = (event: DeviceOrientationEvent) => this.handleOrientation(event); + window.addEventListener('deviceorientation', this.boundOrientation, { passive: true }); this.boundVisibility = () => { if (document.hidden) this.resetFilterState(); }; document.addEventListener('visibilitychange', this.boundVisibility); + this.listenerStartedAt = this.now(); + this.lastEventAt = 0; this.isListening = true; } private stopListening(): void { if (!this.isListening) return; + if (this.boundOrientation) { window.removeEventListener('deviceorientation', this.boundOrientation); this.boundOrientation = null; } + if (this.boundVisibility) { document.removeEventListener('visibilitychange', this.boundVisibility); this.boundVisibility = null; } - this.isListening = false; - } - private resetFilterState(): void { - this.filterX.reset(); - this.filterY.reset(); - this.firstEventAt = 0; - this.lastEventAt = 0; + this.isListening = false; } - private handle(event: DeviceOrientationEvent): void { - if (event.beta == null || event.gamma == null) return; + private handleOrientation(event: DeviceOrientationEvent): void { + if (this.disposed || event.beta == null || event.gamma == null) return; - const now = - typeof performance !== 'undefined' ? performance.now() : Date.now(); + const now = this.now(); + if (now - this.listenerStartedAt < this.opts.warmupMs) return; - if (this.firstEventAt === 0) this.firstEventAt = now; - if (now - this.firstEventAt < this.opts.warmupMs) return; - - if ( - this.lastEventAt > 0 && - now - this.lastEventAt > this.opts.staleEventMs - ) { + if (this.lastEventAt > 0 && now - this.lastEventAt > this.opts.staleEventMs) { this.resetFilterState(); - this.firstEventAt = now; + this.listenerStartedAt = now; this.lastEventAt = now; return; } this.lastEventAt = now; - // Face-down or upside-down: emit zero rather than wild values. if (Math.abs(event.beta) > this.opts.faceDownThreshold) { this.callback({ x: 0, y: 0, z: 0 }); return; } - const angle = - (typeof screen !== 'undefined' && screen.orientation?.angle) || 0; - const [sx, sy] = remapToScreen(event.beta, event.gamma, angle); + const [screenX, screenY] = remapToScreen( + event.beta, + event.gamma, + getScreenOrientationAngle(), + ); + this.lastScreen = { x: screenX, y: screenY }; - // Slow continuous baseline absorbs cable bias / pocket lean over - // ~30 s without killing gravity feel. - const a = this.opts.baselineAlpha; - this.baseX += a * (sx - this.baseX); - this.baseY += a * (sy - this.baseY); + if (!this.consumeCalibrationSample(screenX, screenY)) return; - const range = this.opts.range; - const xRaw = (sx - this.baseX) / range; - const yRaw = (sy - this.baseY) / range; + const alpha = this.opts.baselineAlpha; + this.baseX += alpha * (screenX - this.baseX); + this.baseY += alpha * (screenY - this.baseY); + const xRaw = (screenX - this.baseX) / this.opts.range; + const yRaw = (screenY - this.baseY) / this.opts.range; const xFiltered = this.filterX.filter(xRaw, now); const yFiltered = this.filterY.filter(yRaw, now); this.callback({ - x: clamp(xFiltered, -1, 1), - y: clamp(yFiltered, -1, 1), + x: applyDeadZone(clamp(xFiltered), this.opts.deadZone), + y: applyDeadZone(clamp(yFiltered), this.opts.deadZone), z: 0, }); } - cleanup(): void { - // Set disposed first so any in-flight requestPermission() promise - // that resolves after cleanup() short-circuits before re-attaching - // a deviceorientation listener (iOS keeps the permission prompt - // open across tab navigation; the user can dismiss after the - // component has unmounted). - this.disposed = true; - this.stopListening(); - if (this.reducedMotionMql && this.reducedMotionListener) { - this.reducedMotionMql.removeEventListener( - 'change', - this.reducedMotionListener - ); - } - this.reducedMotionMql = null; - this.reducedMotionListener = null; + private consumeCalibrationSample(screenX: number, screenY: number): boolean { + if (this.calibrationRemaining <= 0) return true; + + this.calibrationTotalX += screenX; + this.calibrationTotalY += screenY; + this.calibrationRemaining -= 1; + + if (this.calibrationRemaining > 0) return false; + + const sampleCount = Math.max(1, this.calibrationTargetSamples); + this.baseX = this.calibrationTotalX / sampleCount; + this.baseY = this.calibrationTotalY / sampleCount; + this.calibrationTotalX = 0; + this.calibrationTotalY = 0; this.resetFilterState(); + return false; + } + + private resetFilterState(): void { + this.filterX.reset(); + this.filterY.reset(); + this.listenerStartedAt = this.now(); + this.lastEventAt = 0; + } + + private now(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); } } diff --git a/src/motion/OneEuro.ts b/src/motion/OneEuro.ts index da61364..d7b9348 100644 --- a/src/motion/OneEuro.ts +++ b/src/motion/OneEuro.ts @@ -31,7 +31,11 @@ export class OneEuro { private prevX: number | undefined; private prevT: number | undefined; - constructor(private p: OneEuroParams) {} + constructor(private p: OneEuroParams) { + if (p.minCutoff <= 0 || p.dCutoff <= 0) { + throw new RangeError('OneEuro: minCutoff and dCutoff must be > 0'); + } + } /** Filter sample `x` at time `tMs` (milliseconds). */ filter(x: number, tMs: number): number { @@ -40,6 +44,7 @@ export class OneEuro { this.prevX = x; return this.x.filter(x, 1); } + // tMs must be monotonically non-decreasing; backward jumps clamp to 1 ms. const dt = Math.max(1e-3, (tMs - this.prevT) / 1000); const dxRaw = (x - this.prevX) / dt; const dxHat = this.dx.filter(dxRaw, alpha(this.p.dCutoff, dt)); diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts new file mode 100644 index 0000000..d03cf69 --- /dev/null +++ b/tests/unit/device-motion.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DeviceMotion } from '../../src/motion/DeviceMotion.js'; + +type PermissionResponse = 'granted' | 'denied'; + +interface MockMotionWindow extends Partial { + DeviceOrientationEvent?: { requestPermission?: () => Promise }; +} + +let now = 0; + +function createMotionEnvironment(options: { + secure?: boolean; + orientation?: boolean; + permission?: () => Promise; + reducedMotion?: boolean; + angle?: number; +} = {}) { + const windowListeners = new Map>(); + const documentListeners = new Map>(); + const mqlListeners = new Set<() => void>(); + + const addWindowListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + const listeners = windowListeners.get(type) ?? new Set(); + listeners.add(listener); + windowListeners.set(type, listeners); + }); + const removeWindowListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + windowListeners.get(type)?.delete(listener); + }); + const addDocumentListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + const listeners = documentListeners.get(type) ?? new Set(); + listeners.add(listener); + documentListeners.set(type, listeners); + }); + const removeDocumentListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + documentListeners.get(type)?.delete(listener); + }); + + const mql = { + matches: options.reducedMotion ?? false, + addEventListener: vi.fn((_type: string, listener: () => void) => { + mqlListeners.add(listener); + }), + removeEventListener: vi.fn((_type: string, listener: () => void) => { + mqlListeners.delete(listener); + }), + }; + + const motionWindow: MockMotionWindow = { + isSecureContext: options.secure ?? true, + addEventListener: addWindowListener, + removeEventListener: removeWindowListener, + matchMedia: vi.fn(() => mql as unknown as MediaQueryList), + }; + + if (options.orientation ?? true) { + motionWindow.DeviceOrientationEvent = {}; + if (options.permission) { + motionWindow.DeviceOrientationEvent.requestPermission = options.permission; + } + } + + vi.stubGlobal('window', motionWindow); + vi.stubGlobal('document', { + hidden: false, + addEventListener: addDocumentListener, + removeEventListener: removeDocumentListener, + }); + vi.stubGlobal('screen', { + orientation: { + angle: options.angle ?? 0, + }, + }); + + const dispatchWindow = (type: string, event: unknown) => { + for (const listener of windowListeners.get(type) ?? []) { + if (typeof listener === 'function') { + listener(event as Event); + } else { + listener.handleEvent(event as Event); + } + } + }; + + return { + addDocumentListener, + addWindowListener, + dispatchOrientation(beta: number, gamma: number, alpha: number | null = null) { + dispatchWindow('deviceorientation', { beta, gamma, alpha }); + }, + mql, + removeDocumentListener, + removeWindowListener, + }; +} + +beforeEach(() => { + now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('DeviceMotion', () => { + it('reports unsupported when initialized without a browser window', async () => { + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('unsupported'); + expect(motion.isActive()).toBe(false); + }); + + it('reports insecure contexts without starting listeners', async () => { + const env = createMotionEnvironment({ secure: false }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('insecure'); + expect(env.addWindowListener).not.toHaveBeenCalled(); + }); + + it('starts immediately when no permission API is required', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 250, + }); + + await expect(motion.initialize()).resolves.toBe(true); + now = 300; + env.dispatchOrientation(22.5, -45); + + expect(motion.getPermissionState()).toBe('granted'); + expect(motion.isActive()).toBe(true); + expect(callback).toHaveBeenCalledWith({ x: -1, y: 0.5, z: 0 }); + }); + + it('defers listener startup until explicit permission is granted', async () => { + const requestPermission = vi.fn().mockResolvedValue('granted' as const); + const env = createMotionEnvironment({ permission: requestPermission }); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 250, + }); + + await expect(motion.initialize()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('prompt'); + expect(env.addWindowListener).not.toHaveBeenCalled(); + + await expect(motion.requestPermission()).resolves.toBe(true); + now = 300; + env.dispatchOrientation(45, 0); + + expect(requestPermission).toHaveBeenCalledOnce(); + expect(motion.getPermissionState()).toBe('granted'); + expect(callback).toHaveBeenCalledWith({ x: 0, y: 1, z: 0 }); + }); + + it('does not restart listeners after cleanup resolves an in-flight permission request', async () => { + let resolvePermission: (value: PermissionResponse) => void = () => {}; + const permission = new Promise((resolve) => { + resolvePermission = resolve; + }); + const env = createMotionEnvironment({ permission: () => permission }); + const motion = new DeviceMotion(vi.fn()); + + const request = motion.requestPermission(); + motion.cleanup(); + resolvePermission('granted'); + + await expect(request).resolves.toBe(false); + expect(env.addWindowListener).not.toHaveBeenCalled(); + expect(motion.isActive()).toBe(false); + }); + + it('calibrates against caller-requested samples', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await motion.initialize(); + motion.calibrate(1); + + now = 10; + env.dispatchOrientation(10, 20); + now = 20; + env.dispatchOrientation(20, 30); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith({ + x: (30 - 20) / 45, + y: (20 - 10) / 45, + z: 0, + }); + }); + + it('honors reduced motion as a hard disable', async () => { + const env = createMotionEnvironment({ reducedMotion: true }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + await expect(motion.requestPermission()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('denied'); + expect(env.addWindowListener).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/oneeuro.test.ts b/tests/unit/oneeuro.test.ts index 587e294..2e52e57 100644 --- a/tests/unit/oneeuro.test.ts +++ b/tests/unit/oneeuro.test.ts @@ -50,4 +50,9 @@ describe('OneEuro', () => { expect(() => f.filter(1, 100)).not.toThrow(); expect(Number.isFinite(f.filter(2, 100))).toBe(true); }); + + it('rejects non-positive cutoff values', () => { + expect(() => new OneEuro({ minCutoff: 0, beta: 0, dCutoff: 1 })).toThrow(RangeError); + expect(() => new OneEuro({ minCutoff: 1, beta: 0, dCutoff: -1 })).toThrow(RangeError); + }); }); From 6871a8f333b190807bf7aba28db40bbb5ac77fa1 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 12:25:30 -0400 Subject: [PATCH 13/57] feat(interactions): wire pointer physics and browser motion harness --- dev/App.svelte | 65 ++- dev/index.html | 117 ++++- dev/main.ts | 153 ++++++- scripts/probe-motion-cdp.mjs | 413 ++++++++++++++++++ src/motion/PointerMapper.ts | 43 ++ src/motion/PointerPhysicsController.ts | 100 +++++ src/motion/ScrollHandler.ts | 64 ++- src/motion/index.ts | 23 +- src/svelte/BlobSVG.svelte.d.ts | 11 + src/svelte/TinyVectors.svelte | 201 +++++---- src/svelte/TinyVectors.svelte.d.ts | 41 ++ tests/unit/pointer-mapper.test.ts | 46 ++ tests/unit/pointer-physics-controller.test.ts | 191 ++++++++ tests/unit/scroll-handler.test.ts | 127 +++--- vite.dev.config.ts | 9 +- 15 files changed, 1402 insertions(+), 202 deletions(-) create mode 100644 scripts/probe-motion-cdp.mjs create mode 100644 src/motion/PointerMapper.ts create mode 100644 src/motion/PointerPhysicsController.ts create mode 100644 src/svelte/BlobSVG.svelte.d.ts create mode 100644 src/svelte/TinyVectors.svelte.d.ts create mode 100644 tests/unit/pointer-mapper.test.ts create mode 100644 tests/unit/pointer-physics-controller.test.ts diff --git a/dev/App.svelte b/dev/App.svelte index 33d3bf0..23e96f1 100644 --- a/dev/App.svelte +++ b/dev/App.svelte @@ -1,25 +1,57 @@
@@ -32,7 +64,9 @@ diff --git a/dev/index.html b/dev/index.html index 614988b..29c36df 100644 --- a/dev/index.html +++ b/dev/index.html @@ -9,38 +9,73 @@ html, body { width: 100%; height: 100%; + min-height: 100vh; + min-height: 100dvh; font-family: system-ui, -apple-system, sans-serif; + overflow: hidden; } + html, body { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); } + html.light, body.light { background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 50%, #d0d0d0 100%); } #app { + position: fixed; + inset: 0; width: 100%; - height: 100%; + height: 100vh; + height: 100dvh; } .controls { position: fixed; top: 16px; right: 16px; z-index: 1000; - background: rgba(0, 0, 0, 0.7); + width: min(282px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + max-height: calc(100dvh - 32px); + overflow: auto; + background: rgba(6, 8, 18, 0.9); + backdrop-filter: blur(14px); padding: 16px; border-radius: 8px; color: white; font-size: 14px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32); } .controls label { - display: block; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; margin-bottom: 8px; } + .controls .checkbox-row { + grid-template-columns: auto minmax(0, 1fr); + justify-content: start; + } + .controls .range-row { + grid-template-columns: minmax(0, 1fr) minmax(118px, 1.2fr) auto; + } .controls select, .controls input { - margin-left: 8px; padding: 4px 8px; border-radius: 4px; border: none; + min-width: 0; + } + .controls select { + max-width: 132px; + } + .controls input[type="range"] { + width: 100%; + } + .controls input[type="checkbox"] { + width: 14px; + height: 14px; + padding: 0; } .controls button { margin-top: 8px; @@ -51,15 +86,36 @@ color: white; cursor: pointer; } + .controls .button-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + .controls .button-row button { + margin-top: 0; + padding: 8px 10px; + } + .controls .status { + display: block; + margin-top: 8px; + min-height: 18px; + opacity: 0.75; + } .controls button:hover { background: #4a7de0; } + .hide-controls .controls { + display: none; + } /* Mobile responsive */ - @media (max-width: 480px) { + @media (max-width: 720px) { .controls { top: 8px; - right: 8px; left: 8px; + right: auto; + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + max-height: calc(100dvh - 16px); padding: 12px; font-size: 12px; } @@ -68,18 +124,22 @@ margin-bottom: 8px; } .controls label { - display: flex; - align-items: center; - justify-content: space-between; margin-bottom: 6px; } - .controls select, .controls input[type="range"] { - max-width: 120px; + .controls select { + width: min(132px, 40vw); + max-width: 132px; + } + .controls .range-row { + grid-template-columns: minmax(0, 1fr) minmax(96px, 36vw) auto; } .controls button { width: 100%; padding: 10px; } + .controls .button-row { + grid-template-columns: 1fr; + } } @@ -88,26 +148,47 @@

TinyVectors Dev

-
diff --git a/dev/main.ts b/dev/main.ts index 8bff2d2..03c66d2 100644 --- a/dev/main.ts +++ b/dev/main.ts @@ -1,11 +1,90 @@ import { mount, unmount } from 'svelte'; import App from './App.svelte'; +import type { MotionVector } from '../src/motion/DeviceMotion.js'; + +interface DevAppHandle { + requestDeviceMotionPermission: () => Promise; + calibrateDeviceMotion: (samples?: number) => void; +} + +const params = new URLSearchParams(window.location.search); +const themes = ['tinyland', 'trans', 'pride'] as const; + +function booleanParam(name: string, fallback: boolean): boolean { + const value = params.get(name); + if (value === null) return fallback; + return !['0', 'false', 'off', 'no'].includes(value.toLowerCase()); +} + +function numberParam(name: string, fallback: number, min: number, max: number): number { + const value = Number(params.get(name)); + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.round(value))); +} + +function themeParam(): (typeof themes)[number] { + const value = params.get('theme'); + return themes.includes(value as (typeof themes)[number]) ? (value as (typeof themes)[number]) : 'tinyland'; +} + +const initialDarkMode = booleanParam('dark', true); +const showControls = booleanParam('controls', true); +document.body.classList.toggle('dark', initialDarkMode); +document.body.classList.toggle('light', !initialDarkMode); +document.body.classList.toggle('hide-controls', !showControls); +document.documentElement.classList.toggle('dark', initialDarkMode); +document.documentElement.classList.toggle('light', !initialDarkMode); + +let app: (ReturnType & DevAppHandle) | null = null; + +function updateMotionStatus(text: string): void { + const motionStatus = document.getElementById('motion-status'); + if (motionStatus) { + motionStatus.textContent = text; + } +} + +function formatMotionSample(sample: MotionVector): string { + const x = sample.x.toFixed(2); + const y = sample.y.toFixed(2); + const z = sample.z.toFixed(2); + return `motion x ${x} y ${y} z ${z}`; +} + +function createDeviceOrientationEvent(alpha: number, beta: number, gamma: number): Event { + if (typeof DeviceOrientationEvent === 'function') { + return new DeviceOrientationEvent('deviceorientation', { + alpha, + beta, + gamma, + absolute: false, + }); + } + + const event = new Event('deviceorientation'); + Object.defineProperties(event, { + alpha: { value: alpha }, + beta: { value: beta }, + gamma: { value: gamma }, + absolute: { value: false }, + }); + return event; +} + +function spoofOrientation(alpha: number, beta: number, gamma: number): void { + window.dispatchEvent(createDeviceOrientationEvent(alpha, beta, gamma)); +} -let app: ReturnType | null = null; let currentProps = { - theme: 'tinyland' as 'tinyland' | 'trans' | 'pride', - blobCount: 12, - animated: true, + theme: themeParam(), + blobCount: numberParam('blobs', 8, 4, 16), + animated: booleanParam('animated', true), + enableDeviceMotion: booleanParam('deviceMotion', true), + enableScrollPhysics: booleanParam('scrollPhysics', true), + enablePointerPhysics: booleanParam('pointerPhysics', true), + onMotionSample(sample: MotionVector) { + updateMotionStatus(formatMotionSample(sample)); + }, }; function mountApp() { @@ -19,7 +98,7 @@ function mountApp() { app = mount(App, { target, props: currentProps, - }); + }) as ReturnType & DevAppHandle; } @@ -27,6 +106,9 @@ mountApp(); const themeSelect = document.getElementById('theme-select') as HTMLSelectElement; +if (themeSelect) { + themeSelect.value = currentProps.theme; +} themeSelect?.addEventListener('change', () => { currentProps.theme = themeSelect.value as 'tinyland' | 'trans' | 'pride'; mountApp(); @@ -35,6 +117,12 @@ themeSelect?.addEventListener('change', () => { const blobCountSlider = document.getElementById('blob-count') as HTMLInputElement; const blobCountValue = document.getElementById('blob-count-value'); +if (blobCountSlider) { + blobCountSlider.value = String(currentProps.blobCount); +} +if (blobCountValue) { + blobCountValue.textContent = String(currentProps.blobCount); +} blobCountSlider?.addEventListener('input', () => { currentProps.blobCount = parseInt(blobCountSlider.value, 10); if (blobCountValue) { @@ -47,19 +135,74 @@ blobCountSlider?.addEventListener('change', () => { const darkModeCheckbox = document.getElementById('dark-mode') as HTMLInputElement; +if (darkModeCheckbox) { + darkModeCheckbox.checked = initialDarkMode; +} darkModeCheckbox?.addEventListener('change', () => { document.body.classList.toggle('dark', darkModeCheckbox.checked); document.body.classList.toggle('light', !darkModeCheckbox.checked); document.documentElement.classList.toggle('dark', darkModeCheckbox.checked); + document.documentElement.classList.toggle('light', !darkModeCheckbox.checked); }); const animatedCheckbox = document.getElementById('animated') as HTMLInputElement; +if (animatedCheckbox) { + animatedCheckbox.checked = currentProps.animated; +} animatedCheckbox?.addEventListener('change', () => { currentProps.animated = animatedCheckbox.checked; mountApp(); }); +const deviceMotionCheckbox = document.getElementById('device-motion') as HTMLInputElement; +if (deviceMotionCheckbox) { + deviceMotionCheckbox.checked = currentProps.enableDeviceMotion; +} +deviceMotionCheckbox?.addEventListener('change', () => { + currentProps.enableDeviceMotion = deviceMotionCheckbox.checked; + mountApp(); +}); + +const scrollPhysicsCheckbox = document.getElementById('scroll-physics') as HTMLInputElement; +if (scrollPhysicsCheckbox) { + scrollPhysicsCheckbox.checked = currentProps.enableScrollPhysics; +} +scrollPhysicsCheckbox?.addEventListener('change', () => { + currentProps.enableScrollPhysics = scrollPhysicsCheckbox.checked; + mountApp(); +}); + +const pointerPhysicsCheckbox = document.getElementById('pointer-physics') as HTMLInputElement; +if (pointerPhysicsCheckbox) { + pointerPhysicsCheckbox.checked = currentProps.enablePointerPhysics; +} +pointerPhysicsCheckbox?.addEventListener('change', () => { + currentProps.enablePointerPhysics = pointerPhysicsCheckbox.checked; + mountApp(); +}); + +const requestMotionBtn = document.getElementById('request-motion-btn'); +requestMotionBtn?.addEventListener('click', async () => { + const granted = (await app?.requestDeviceMotionPermission()) ?? false; + updateMotionStatus(granted ? 'motion granted; waiting for sample' : 'motion unavailable'); +}); + +const calibrateMotionBtn = document.getElementById('calibrate-motion-btn'); +calibrateMotionBtn?.addEventListener('click', () => { + app?.calibrateDeviceMotion(10); + updateMotionStatus('calibration queued'); +}); + +const spoofTiltBtn = document.getElementById('spoof-tilt-btn'); +spoofTiltBtn?.addEventListener('click', () => { + spoofOrientation(120, 35, -45); +}); + +const neutralTiltBtn = document.getElementById('neutral-tilt-btn'); +neutralTiltBtn?.addEventListener('click', () => { + spoofOrientation(0, 0, 0); +}); const reloadBtn = document.getElementById('reload-btn'); reloadBtn?.addEventListener('click', () => { diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs new file mode 100644 index 0000000..78acc63 --- /dev/null +++ b/scripts/probe-motion-cdp.mjs @@ -0,0 +1,413 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const host = '127.0.0.1'; +const vitePort = Number(process.env.TINYVECTORS_VITE_PORT ?? 5176); +const viteHmrPort = Number(process.env.TINYVECTORS_VITE_HMR_PORT ?? vitePort + 19000); +const cdpPort = Number(process.env.TINYVECTORS_CDP_PORT ?? 9228); +const chromePath = findChrome(); + +if (!chromePath) { + console.error('Chrome executable not found. Set CHROME_PATH to run this probe.'); + process.exit(1); +} + +const children = new Set(); + +process.on('exit', () => { + for (const child of children) { + child.kill('SIGTERM'); + } +}); + +function spawnChild(command, args, options = {}) { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + ...options, + }); + children.add(child); + child.once('exit', () => children.delete(child)); + return child; +} + +function findChrome() { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + + const candidates = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + 'google-chrome', + 'google-chrome-stable', + 'chromium', + 'chromium-browser', + ]; + + for (const candidate of candidates) { + if (candidate.startsWith('/')) { + const result = spawnSync('test', ['-x', candidate]); + if (result.status === 0) return candidate; + continue; + } + + const result = spawnSync('command', ['-v', candidate], { + shell: true, + encoding: 'utf8', + }); + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim(); + } + } + + return null; +} + +async function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForFetch(url, timeoutMs = 15000) { + const started = Date.now(); + let lastError; + + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) return response; + lastError = new Error(`${response.status} ${response.statusText}`); + } catch (error) { + lastError = error; + } + await delay(100); + } + + throw lastError ?? new Error(`Timed out waiting for ${url}`); +} + +async function waitForJson(url, timeoutMs = 15000) { + const response = await waitForFetch(url, timeoutMs); + return await response.json(); +} + +async function terminateChildren() { + const exiting = [...children].map( + (child) => + new Promise((resolve) => { + child.once('exit', resolve); + child.kill('SIGTERM'); + setTimeout(resolve, 2000); + }), + ); + + await Promise.all(exiting); + children.clear(); +} + +async function removeDirectoryWithRetry(directory) { + for (let attempt = 0; attempt < 5; attempt++) { + try { + await rm(directory, { recursive: true, force: true }); + return; + } catch (error) { + if (attempt === 4) throw error; + await delay(200); + } + } +} + +class CdpClient { + constructor(url) { + this.nextId = 1; + this.pending = new Map(); + this.ws = new WebSocket(url); + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener('open', resolve, { once: true }); + this.ws.addEventListener('error', reject, { once: true }); + }); + this.ws.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (!message.id || !this.pending.has(message.id)) return; + + const pending = this.pending.get(message.id); + this.pending.delete(message.id); + + if (message.error) { + pending.reject(new Error(`${message.error.code}: ${message.error.message}`)); + } else { + pending.resolve(message.result ?? {}); + } + }); + } + + async send(method, params = {}) { + await this.ready; + + const id = this.nextId++; + this.ws.send(JSON.stringify({ id, method, params })); + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.pending.has(id)) return; + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method}`)); + }, 10000); + + this.pending.set(id, { + resolve(value) { + clearTimeout(timer); + resolve(value); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); + }); + } + + close() { + this.ws.close(); + } +} + +async function evaluate(client, expression) { + const result = await client.send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }); + + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)); + } + + return result.result.value; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +let chromeProfile; +let client; + +try { + const vite = spawnChild('pnpm', [ + 'exec', + 'vite', + '--config', + 'vite.dev.config.ts', + '--host', + host, + '--port', + String(vitePort), + ], { + env: { + ...process.env, + CI: 'true', + TINYVECTORS_VITE_PORT: String(vitePort), + TINYVECTORS_VITE_HMR_PORT: String(viteHmrPort), + }, + }); + + vite.stderr.on('data', (chunk) => { + process.stderr.write(chunk); + }); + + await waitForFetch(`http://${host}:${vitePort}/`); + + chromeProfile = await mkdtemp(join(tmpdir(), 'tinyvectors-cdp-profile-')); + const chrome = spawnChild(chromePath, [ + '--headless=new', + '--disable-gpu', + '--no-first-run', + '--no-default-browser-check', + `--remote-debugging-address=${host}`, + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${chromeProfile}`, + 'about:blank', + ]); + + chrome.stderr.on('data', (chunk) => { + if (process.env.DEBUG_CHROME === 'true') { + process.stderr.write(chunk); + } + }); + + const version = await waitForJson(`http://${host}:${cdpPort}/json/version`); + const tabs = await waitForJson(`http://${host}:${cdpPort}/json/list`); + const page = tabs.find((tab) => tab.type === 'page') ?? tabs[0]; + + client = new CdpClient(page.webSocketDebuggerUrl); + await client.send('Runtime.enable'); + await client.send('Page.enable'); + await client.send('Page.addScriptToEvaluateOnNewDocument', { + source: ` + window.__tinyvectorsEvents = []; + window.addEventListener('deviceorientation', (event) => { + window.__tinyvectorsEvents.push({ + type: 'deviceorientation', + alpha: event.alpha, + beta: event.beta, + gamma: event.gamma, + at: performance.now() + }); + }); + window.addEventListener('devicemotion', (event) => { + const gravity = event.accelerationIncludingGravity; + window.__tinyvectorsEvents.push({ + type: 'devicemotion', + x: gravity && gravity.x, + y: gravity && gravity.y, + z: gravity && gravity.z, + at: performance.now() + }); + }); + `, + }); + + const pageUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=false&scrollPhysics=false&blobs=8`; + await client.send('Page.navigate', { url: pageUrl }); + await delay(1500); + + const initial = await evaluate(client, `({ + secure: window.isSecureContext, + hasDeviceMotionEvent: 'DeviceMotionEvent' in window, + hasDeviceOrientationEvent: 'DeviceOrientationEvent' in window, + hasAccelerometer: 'Accelerometer' in window, + status: document.getElementById('motion-status')?.textContent ?? null, + pathCount: document.querySelectorAll('path').length, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + assert(initial.secure, 'Page must be a secure context for device motion APIs.'); + assert(initial.hasDeviceOrientationEvent, 'DeviceOrientationEvent is not exposed in Chrome.'); + assert(initial.pathCount > 0, 'TinyVectors SVG paths were not rendered.'); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('spoof-tilt-btn')?.click()`, + awaitPromise: true, + }); + await delay(1000); + + const afterSpoof = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + assert( + afterSpoof.status?.startsWith('motion x '), + `Synthetic orientation did not reach TinyVectors; status was ${afterSpoof.status}`, + ); + assert(afterSpoof.events.length > initial.events.length, 'Synthetic orientation was not observed.'); + assert(afterSpoof.firstPath !== initial.firstPath, 'Synthetic orientation did not change blob geometry.'); + + await client.send('DeviceOrientation.setDeviceOrientationOverride', { + alpha: 180, + beta: 50, + gamma: -40, + }); + await delay(1000); + + const afterCdpOrientation = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + assert( + afterCdpOrientation.events.length > afterSpoof.events.length, + 'CDP device orientation override did not emit a page event.', + ); + assert( + afterCdpOrientation.firstPath !== afterSpoof.firstPath, + 'CDP device orientation override did not change blob geometry.', + ); + + await client.send('Emulation.setSensorOverrideEnabled', { + type: 'accelerometer', + enabled: true, + metadata: { available: true, minimumFrequency: 1, maximumFrequency: 60 }, + }); + await client.send('Page.navigate', { url: `${pageUrl}&accelerometerProbe=1` }); + await delay(1500); + + const beforeCdpAccelerometer = await evaluate(client, `({ + hasAccelerometer: 'Accelerometer' in window, + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + await client.send('Emulation.setSensorOverrideReadings', { + type: 'accelerometer', + reading: { + xyz: { + x: 4, + y: -3, + z: 9.80665, + }, + }, + }); + await delay(1000); + + const afterCdpAccelerometer = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + const cdpAccelerometerChanged = + afterCdpAccelerometer.firstPath !== beforeCdpAccelerometer.firstPath; + + console.log( + JSON.stringify( + { + chrome: version.Browser, + pageUrl, + initial: { + secure: initial.secure, + hasDeviceMotionEvent: initial.hasDeviceMotionEvent, + hasDeviceOrientationEvent: initial.hasDeviceOrientationEvent, + hasAccelerometer: initial.hasAccelerometer, + pathCount: initial.pathCount, + }, + syntheticOrientation: { + status: afterSpoof.status, + events: afterSpoof.events.length, + pathChanged: afterSpoof.firstPath !== initial.firstPath, + }, + cdpOrientation: { + status: afterCdpOrientation.status, + events: afterCdpOrientation.events.length, + pathChanged: afterCdpOrientation.firstPath !== afterSpoof.firstPath, + lastEvent: afterCdpOrientation.events.at(-1), + }, + cdpAccelerometer: { + hasAccelerometer: beforeCdpAccelerometer.hasAccelerometer, + status: afterCdpAccelerometer.status, + windowEvents: afterCdpAccelerometer.events.length, + pathChanged: cdpAccelerometerChanged, + note: 'TinyVectors uses DeviceOrientationEvent/TiltSource; raw accelerometer CDP is informational.', + }, + }, + null, + 2, + ), + ); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + client?.close(); + await terminateChildren(); + if (chromeProfile) { + await removeDirectoryWithRetry(chromeProfile); + } +} diff --git a/src/motion/PointerMapper.ts b/src/motion/PointerMapper.ts new file mode 100644 index 0000000..adb48e3 --- /dev/null +++ b/src/motion/PointerMapper.ts @@ -0,0 +1,43 @@ +export interface PointerBounds { + left: number; + top: number; + width: number; + height: number; +} + +export interface PhysicsPoint { + x: number; + y: number; +} + +export interface PhysicsRange { + min: number; + max: number; +} + +const DEFAULT_RANGE: PhysicsRange = { min: 0, max: 100 }; + +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + +export function mapClientPointToPhysics( + clientX: number, + clientY: number, + bounds: PointerBounds, + range: PhysicsRange = DEFAULT_RANGE, +): PhysicsPoint { + if (bounds.width <= 0 || bounds.height <= 0) { + return { + x: (range.min + range.max) / 2, + y: (range.min + range.max) / 2, + }; + } + + const span = range.max - range.min; + const normalizedX = clamp01((clientX - bounds.left) / bounds.width); + const normalizedY = clamp01((clientY - bounds.top) / bounds.height); + + return { + x: range.min + normalizedX * span, + y: range.min + normalizedY * span, + }; +} diff --git a/src/motion/PointerPhysicsController.ts b/src/motion/PointerPhysicsController.ts new file mode 100644 index 0000000..5c0e85e --- /dev/null +++ b/src/motion/PointerPhysicsController.ts @@ -0,0 +1,100 @@ +import { + mapClientPointToPhysics, + type PhysicsPoint, + type PhysicsRange, + type PointerBounds, +} from './PointerMapper.js'; + +export type PointerMoveEventName = 'pointermove' | 'mousemove'; + +export interface PointerPhysicsEventTarget { + addEventListener( + type: PointerMoveEventName, + listener: EventListener, + options?: AddEventListenerOptions, + ): void; + removeEventListener(type: PointerMoveEventName, listener: EventListener): void; +} + +export interface PointerLikeEvent { + clientX: number; + clientY: number; + getCoalescedEvents?: () => PointerLikeEvent[]; +} + +export interface PointerPhysicsControllerOptions { + target: PointerPhysicsEventTarget; + getBounds: () => PointerBounds; + updatePosition: (position: PhysicsPoint) => void; + range?: PhysicsRange; + supportsPointerEvents?: boolean; + requestFrame?: (callback: FrameRequestCallback) => number; + cancelFrame?: (handle: number) => void; +} + +export interface PointerPhysicsController { + readonly eventName: PointerMoveEventName; + flush(): void; + dispose(): void; +} + +export function getLatestPointerEvent(event: PointerLikeEvent): PointerLikeEvent { + const coalesced = + typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : []; + return coalesced.length > 0 ? coalesced[coalesced.length - 1] : event; +} + +export function createPointerPhysicsController( + options: PointerPhysicsControllerOptions, +): PointerPhysicsController { + const requestFrame = options.requestFrame ?? requestAnimationFrame; + const cancelFrame = options.cancelFrame ?? cancelAnimationFrame; + const supportsPointerEvents = + options.supportsPointerEvents ?? typeof PointerEvent !== 'undefined'; + const eventName: PointerMoveEventName = supportsPointerEvents ? 'pointermove' : 'mousemove'; + + let frame: number | null = null; + let pendingPosition: PhysicsPoint | null = null; + let disposed = false; + + const flush = () => { + frame = null; + if (disposed || !pendingPosition) return; + + options.updatePosition(pendingPosition); + pendingPosition = null; + }; + + const handleMove: EventListener = (event) => { + if (disposed) return; + + const pointerEvent = getLatestPointerEvent(event as unknown as PointerLikeEvent); + pendingPosition = mapClientPointToPhysics( + pointerEvent.clientX, + pointerEvent.clientY, + options.getBounds(), + options.range, + ); + + if (frame === null) { + frame = requestFrame(flush); + } + }; + + options.target.addEventListener(eventName, handleMove, { passive: true }); + + return { + eventName, + flush, + dispose() { + if (disposed) return; + disposed = true; + options.target.removeEventListener(eventName, handleMove); + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + pendingPosition = null; + }, + }; +} diff --git a/src/motion/ScrollHandler.ts b/src/motion/ScrollHandler.ts index fb9c13b..412945e 100644 --- a/src/motion/ScrollHandler.ts +++ b/src/motion/ScrollHandler.ts @@ -1,9 +1,3 @@ - - - - - - export interface ScrollHandlerConfig { decayRate?: number; maxForces?: number; @@ -23,13 +17,17 @@ export class ScrollHandler { private scrollDirection = 0; private pullForces: PullForce[] = []; private peakVelocity = 0; - private rafId: number | null = null; + private decayFrame: number | null = null; + private scrollEndTimer: ReturnType | null = null; + private disposed = false; constructor(config?: ScrollHandlerConfig) { if (config?.decayRate) this.decayRate = config.decayRate; } public handleScroll(event: WheelEvent): void { + if (this.disposed) return; + const currentTime = Date.now(); const deltaTime = currentTime - this.lastScrollTime; @@ -68,9 +66,17 @@ export class ScrollHandler { this.lastScrollTime = currentTime; this.startDecay(); + this.scheduleScrollEnd(); + } + + private scheduleScrollEnd(): void { + if (this.scrollEndTimer !== null) { + clearTimeout(this.scrollEndTimer); + } - setTimeout(() => { - if (currentTime - this.lastScrollTime >= 200) { + this.scrollEndTimer = setTimeout(() => { + this.scrollEndTimer = null; + if (Date.now() - this.lastScrollTime >= 200) { this.isScrolling = false; this.totalScrollDistance = 0; this.peakVelocity = 0; @@ -82,7 +88,7 @@ export class ScrollHandler { speedStickiness: number, distanceStickiness: number, direction: number, - explosive: boolean + explosive: boolean, ): void { if (direction <= 0 || speedStickiness > 0.4 || distanceStickiness > 0.4 || explosive) { let pullStrength = speedStickiness + distanceStickiness * 0.7; @@ -101,7 +107,7 @@ export class ScrollHandler { strength: pullStrength, time: 0, randomness: randomnessFactor, - explosive: explosive, + explosive, }); if (this.pullForces.length > (explosive ? 10 : 8)) { @@ -111,14 +117,12 @@ export class ScrollHandler { } private startDecay(): void { - // Cancel any in-flight decay so rapid handleScroll() calls don't - // queue overlapping RAF callbacks. - if (this.rafId !== null) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + if (this.decayFrame !== null) return; const decay = () => { + this.decayFrame = null; + if (this.disposed) return; + this.stickiness *= this.decayRate; this.scrollVelocity *= this.decayRate; @@ -134,14 +138,13 @@ export class ScrollHandler { })); if (this.stickiness > 0.01 || this.pullForces.length > 0) { - this.rafId = requestAnimationFrame(decay); + this.decayFrame = requestAnimationFrame(decay); } else { this.stickiness = 0; this.scrollVelocity = 0; - this.rafId = null; } }; - this.rafId = requestAnimationFrame(decay); + this.decayFrame = requestAnimationFrame(decay); } public getStickiness(): number { @@ -171,4 +174,25 @@ export class ScrollHandler { public getPeakVelocity(): number { return this.peakVelocity; } + + public dispose(): void { + this.disposed = true; + + if (this.decayFrame !== null) { + cancelAnimationFrame(this.decayFrame); + this.decayFrame = null; + } + + if (this.scrollEndTimer !== null) { + clearTimeout(this.scrollEndTimer); + this.scrollEndTimer = null; + } + + this.stickiness = 0; + this.scrollVelocity = 0; + this.totalScrollDistance = 0; + this.peakVelocity = 0; + this.isScrolling = false; + this.pullForces = []; + } } diff --git a/src/motion/index.ts b/src/motion/index.ts index 5ded2ef..2800bcf 100644 --- a/src/motion/index.ts +++ b/src/motion/index.ts @@ -2,5 +2,26 @@ -export { DeviceMotion, type DeviceMotionCallback } from './DeviceMotion.js'; +export { + DeviceMotion, + type DeviceMotionCallback, + type DeviceMotionOptions, + type DeviceMotionPermissionState, + type MotionVector, +} from './DeviceMotion.js'; +export { + mapClientPointToPhysics, + type PhysicsPoint, + type PhysicsRange, + type PointerBounds, +} from './PointerMapper.js'; +export { + createPointerPhysicsController, + getLatestPointerEvent, + type PointerLikeEvent, + type PointerMoveEventName, + type PointerPhysicsController, + type PointerPhysicsControllerOptions, + type PointerPhysicsEventTarget, +} from './PointerPhysicsController.js'; export { ScrollHandler, type ScrollHandlerConfig, type PullForce } from './ScrollHandler.js'; diff --git a/src/svelte/BlobSVG.svelte.d.ts b/src/svelte/BlobSVG.svelte.d.ts new file mode 100644 index 0000000..4e9ee49 --- /dev/null +++ b/src/svelte/BlobSVG.svelte.d.ts @@ -0,0 +1,11 @@ +import type { Component } from 'svelte'; +import type { BlobPhysics } from '../core/BlobPhysics.js'; +import type { ConvexBlob } from '../core/types.js'; + +export interface BlobSVGProps { + blobs?: ConvexBlob[]; + physics?: BlobPhysics | null; +} + +declare const BlobSVG: Component; +export default BlobSVG; diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index c480f5f..ec24cac 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -2,12 +2,20 @@ import { browser } from '../core/browser.js'; import { untrack } from 'svelte'; import { BlobPhysics, type BlobPhysicsConfig } from '../core/BlobPhysics.js'; - import { DeviceMotion } from '../motion/DeviceMotion.js'; + import { + DeviceMotion, + type MotionVector, + } from '../motion/DeviceMotion.js'; + import { + createPointerPhysicsController, + type PointerPhysicsController, + } from '../motion/PointerPhysicsController.js'; + import type { PointerBounds } from '../motion/PointerMapper.js'; import { ScrollHandler } from '../motion/ScrollHandler.js'; - import { THEME_PRESETS, type ThemePresetName } from '../core/schema.js'; + import { THEME_PRESET_COLORS } from '../core/theme-colors.js'; + import type { ThemePresetName } from '../core/theme-presets.js'; import BlobSVG from './BlobSVG.svelte'; - // Props interface Props { /** Theme preset name */ theme?: ThemePresetName; @@ -23,10 +31,18 @@ blobCount?: number; /** Physics configuration */ physicsConfig?: Partial; - /** Enable device motion (accelerometer) */ + /** Enable device orientation based motion */ enableDeviceMotion?: boolean; /** Enable scroll physics */ enableScrollPhysics?: boolean; + /** Enable pointer/mouse physics */ + enablePointerPhysics?: boolean; + /** Scales normalized screen-aligned tilt vectors before applying them to physics. */ + deviceMotionStrength?: number; + /** Samples used by calibrateDeviceMotion() when no explicit count is supplied. */ + deviceMotionCalibrationSamples?: number; + /** Optional diagnostics hook for browser/dev harnesses. */ + onDeviceMotion?: (motionData: MotionVector) => void; } let { @@ -39,101 +55,65 @@ physicsConfig = {}, enableDeviceMotion = true, enableScrollPhysics = true, + enablePointerPhysics = true, + deviceMotionStrength = 0.8, + deviceMotionCalibrationSamples = 8, + onDeviceMotion, }: Props = $props(); - // State - use regular variables for non-reactive state + let containerElement: HTMLDivElement | undefined = $state(undefined); let blobs = $state>([]); let isReady = $state(false); - let isMobileDevice = $state(false); - let hasAccelerometerAccess = $state(false); - // Internal handles that are passed into child components need to stay reactive. let physics = $state(null); let animationFrame: number | null = null; let lastTime = 0; let deviceMotion: DeviceMotion | null = null; let scrollHandler: ScrollHandler | null = null; - let gravityX = 0; - let gravityY = 0; - let tiltX = 0; - let tiltY = 0; - let tiltZ = 0; + let pointerController: PointerPhysicsController | null = null; - // Get theme colors - use $derived.by for computed values const themeColors = $derived.by(() => { if (colors.length > 0) return colors; - const preset = THEME_PRESETS[theme]; - if (!preset || !preset.hasVectors) return []; - return preset.colors.map((c) => c.color); + return THEME_PRESET_COLORS[theme] ?? []; }); - // Default physics config - const defaultPhysicsConfig: BlobPhysicsConfig = { - antiClusteringStrength: 0.15, - bounceDamping: 0.7, - deformationSpeed: 0.5, - territoryStrength: 0.1, - viscosity: 0.3, - useSpatialHash: true, - useGaussianSmoothing: true, - useSpringSystem: true, - springConfig: {}, + const detectDeviceMotionCapability = (): boolean => { + if (!browser || !window.isSecureContext) return false; + return 'DeviceOrientationEvent' in window; }; - // Detect mobile device - const detectMobileDevice = (): boolean => { - if (!browser) return false; - const userAgent = navigator.userAgent.toLowerCase(); - const mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; - const isMobileUserAgent = mobileKeywords.some((keyword) => userAgent.includes(keyword)); - const isMobileScreen = window.innerWidth <= 768 || window.innerHeight <= 768; - const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0; - const hasOrientationAPI = 'DeviceOrientationEvent' in window; - return (isMobileUserAgent || (isMobileScreen && hasTouchScreen)) && hasOrientationAPI; - }; + const createDeviceMotion = (): DeviceMotion => + new DeviceMotion(handleDeviceMotion, { + calibrationSamples: deviceMotionCalibrationSamples, + deadZone: 0.015, + }); - // DeviceMotion now emits already-filtered, axis-remapped, screen-aligned - // tilt vectors in [-1, 1] (One-Euro internally, slow baseline subtraction, - // face-down suppression, screen.orientation remap). Pass through directly - // — no extra EMA, no axis swap, no negation. The 0.8 magnitude scaler is - // preserved so gravity strength matches the previous code's feel at the - // physics layer. - // - // Y-gravity sign note: the previous handler computed gravityY = -beta, - // which made forward-tilt drive gravity UP the screen (away from the - // viewer). That was counter-intuitive — forward tilt should pull stuff - // toward the viewer (positive screen-Y, downward). TiltSource emits - // screen-aligned values directly, so we use motionData.y unchanged. - // This is the intentional fix that the canonical consumer was working - // around by setting enableDeviceMotion={false}. - const handleDeviceMotion = (motionData: { x: number; y: number; z: number }) => { - if (!hasAccelerometerAccess || !physics) return; - tiltX = motionData.x; - tiltY = motionData.y; - tiltZ = motionData.z; - gravityX = motionData.x * 0.8; - gravityY = motionData.y * 0.8; - physics.setGravity({ x: gravityX, y: gravityY }); - physics.setTilt({ x: tiltX, y: tiltY, z: tiltZ }); - }; + const handleDeviceMotion = (motionData: MotionVector) => { + if (!physics) return; - // Request accelerometer permission - const requestAccelerometerPermission = async (): Promise => { - if (!isMobileDevice || !deviceMotion) return; + onDeviceMotion?.(motionData); - try { - const hasPermission = await deviceMotion.requestPermission(); - hasAccelerometerAccess = hasPermission; - if (hasPermission) { - console.log('[TinyVectors] Accelerometer access granted'); - } - } catch (error) { - console.log('[TinyVectors] Could not request accelerometer permission:', error); - hasAccelerometerAccess = false; - } + // DeviceMotion emits screen-aligned tilt, so no old beta/gamma axis swap. + physics.setGravity({ + x: motionData.x * deviceMotionStrength, + y: motionData.y * deviceMotionStrength, + }); + physics.setTilt(motionData); }; - // Handle scroll passively — never block native scrolling + export async function requestDeviceMotionPermission(): Promise { + if (!browser || !enableDeviceMotion || !detectDeviceMotionCapability()) return false; + + deviceMotion ??= createDeviceMotion(); + + const hasPermission = await deviceMotion.requestPermission(); + return hasPermission; + } + + export function calibrateDeviceMotion(samples?: number): void { + deviceMotion?.calibrate(samples); + } + const handleScroll = (event: WheelEvent) => { if (!scrollHandler || !physics) return; scrollHandler.handleScroll(event); @@ -141,14 +121,32 @@ physics.setScrollStickiness(stickiness); }; - // Animation tick function - updates blobs state once per frame + const getPointerBounds = (): PointerBounds => { + const rect = containerElement?.getBoundingClientRect(); + + if (rect && rect.width > 0 && rect.height > 0) { + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + } + + return { + left: 0, + top: 0, + width: window.innerWidth || 1, + height: window.innerHeight || 1, + }; + }; + function tick(currentTime: number) { const dt = Math.min((currentTime - lastTime) / 1000, 0.033); lastTime = currentTime; if (physics) { physics.tick(dt, currentTime / 1000); - // Update blobs state - this triggers re-render blobs = physics.getBlobs(themeColors); } @@ -168,34 +166,25 @@ } } - // Single initialization effect $effect(() => { if (!browser || !shouldLoad) return; - // Use untrack to prevent this effect from re-running on state changes untrack(() => { - const config = { ...defaultPhysicsConfig, ...physicsConfig }; - physics = new BlobPhysics(blobCount, config); + physics = new BlobPhysics(blobCount, physicsConfig); physics.init().then(() => { - // Detect mobile - isMobileDevice = detectMobileDevice(); + const hasDeviceMotionCapability = detectDeviceMotionCapability(); isReady = true; - // Initialize device motion on mobile - if (enableDeviceMotion && isMobileDevice) { - deviceMotion = new DeviceMotion(handleDeviceMotion); - deviceMotion.initialize().then(() => { - setTimeout(requestAccelerometerPermission, 1000); - }); + if (enableDeviceMotion && hasDeviceMotionCapability) { + deviceMotion = createDeviceMotion(); + void deviceMotion.initialize(); } - // Initialize scroll handler if (enableScrollPhysics) { scrollHandler = new ScrollHandler(); } - // Start animation if enabled if (animated) { startAnimation(); } else if (physics) { @@ -203,10 +192,20 @@ } }); - // Set up scroll listener if (enableScrollPhysics) { window.addEventListener('wheel', handleScroll, { passive: true }); } + + if (enablePointerPhysics) { + pointerController = createPointerPhysicsController({ + target: window, + getBounds: getPointerBounds, + supportsPointerEvents: 'PointerEvent' in window, + updatePosition(position) { + physics?.updateMousePosition(position.x, position.y); + }, + }); + } }); return () => { @@ -214,13 +213,17 @@ if (enableScrollPhysics && browser) { window.removeEventListener('wheel', handleScroll); } + pointerController?.dispose(); + pointerController = null; deviceMotion?.cleanup(); + deviceMotion = null; + scrollHandler?.dispose(); + scrollHandler = null; physics?.dispose(); physics = null; }; }); - // Handle animated prop changes $effect(() => { if (!isReady) return; @@ -234,7 +237,13 @@ {#if shouldLoad && themeColors.length > 0} ``` +Device motion must be requested from a user gesture on browsers that gate sensor APIs: + +```svelte + + + + + +``` + ## Entry Points The package exports these public entry points: @@ -67,8 +90,20 @@ Useful extra commands: - `pnpm dev` runs the local Vite demo app - `pnpm dev:watch` rebuilds the library on change - `pnpm test:pbt` runs the property-based invariants only +- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, and CDP accelerometer input - `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned - `pnpm check:package` runs `publint` +- `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized +- `pnpm check:package-consumer` validates the Bazel-built package from `./bazel-bin/pkg` in a temporary consumer workspace + +The Bazel-to-npm release flow is documented in [docs/release-flow.md](./docs/release-flow.md). + +The dev app includes a browser/device harness for interaction work: + +- Use the panel toggles to isolate pointer, scroll, and device-motion physics. +- Use `Spoof Tilt` and `Neutral Tilt` to verify TinyVectors motion wiring without relying on browser sensor tooling. +- On a phone or tablet, open the dev URL, tap `Request Motion`, keep the device still, tap `Calibrate`, then tilt the device. +- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP accelerometer override path. ## Release Truth diff --git a/docs/release-flow.md b/docs/release-flow.md new file mode 100644 index 0000000..ff3e539 --- /dev/null +++ b/docs/release-flow.md @@ -0,0 +1,60 @@ +# Release Flow + +This repo publishes the npm package as the primary consumer artifact. Bazel exists here to produce and validate the same package shape used by downstream Bazel consumers. + +## Authority Chain + +1. `package.json` is the npm package authority for name, version, entry points, package manager, and publish config. +2. `MODULE.bazel` mirrors the package version for Bzlmod consumers. +3. `BUILD.bazel` builds the runtime package with Vite, emits declarations with `tsc`, and assembles `//:pkg` with `npm_package`. +4. `.bazelversion` pins the Bazel runtime. Local Nix exposes `bazel` through Bazelisk so the dev shell follows that pin. +5. `.github/workflows/ci.yml` and `.github/workflows/publish.yml` call the same pinned reusable package workflow. + +`pnpm run check:release-metadata` verifies these surfaces stay aligned before CI, Bazel, or npm publish steps run. + +## Local Verification + +Run the CI checks plus the local consumer check for the Bazel-built package: + +```bash +pnpm run check:release-metadata +pnpm run check +pnpm run test +pnpm run build +pnpm run check:package +pnpm run check:bundle-size +nix develop . --command bazel build //:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test --verbose_failures +pnpm run check:package-consumer +npm pack --dry-run ./bazel-bin/pkg +npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg +``` + +`//:package_consumer_check` and `pnpm run check:package-consumer` both validate the Bazel-built package as an installed consumer would. The pnpm command expects `./bazel-bin/pkg` to exist. It links that package into a temporary consumer workspace with the Svelte peer dependency, verifies runtime subpath exports, and runs TypeScript against the packaged declarations. + +`pnpm run check:bundle-size` measures a realistic tree-shaken consumer import, `import { TinyVectors } from '@tummycrypt/tinyvectors'`, with Svelte externalized as a peer dependency. `//:bundle_size_check` runs the same measurement against the Bazel-built package artifact. The current Phase A gate is 12 KiB gzip and the target remains 11 KiB gzip, so the check reports target headroom or overage while leaving a small CI buffer. + +`bazel query //...` should also work locally. `.bazelignore` excludes direnv, Nix, package-manager, and build-output directories so Bazel does not walk generated local artifacts. + +## CI Flow + +Pull requests and pushes to `main` run `Verify`, which calls `tinyland-inc/ci-templates/.github/workflows/js-bazel-package.yml` at a pinned commit. The reusable workflow: + +- installs the configured pnpm and Node major; +- runs metadata, typecheck, test, build, package, and bundle-size checks; +- builds `//:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test` through Bazelisk; +- validates the Bazel-built package with `npm pack --dry-run`; +- validates npm publication with `npm publish --dry-run --ignore-scripts`. + +This means CI treats the Bazel package output as the release candidate, not the local `dist/` directory alone. + +## Publish Flow + +Tags matching `v*` run `Publish to npm`. The publish workflow reuses the same package workflow with `dry_run: false`, downloads the Bazel-built package artifact, and publishes that isolated artifact to npm. + +The workflow has `id-token: write` because npm provenance and trusted publishing both depend on OIDC-capable CI. The current reusable template still accepts `NPM_TOKEN`; moving fully to npm trusted publishing should happen in the shared template, not only in this repo. + +## FlakeHub Status + +The flake is currently a development environment only. It does not publish TinyVectors to FlakeHub and does not expose package outputs. + +If FlakeHub publication becomes useful, add it as a separate release surface with its own workflow and metadata checks. FlakeHub publication should use its trusted-platform publishing model rather than ad hoc local publishing. diff --git a/flake.nix b/flake.nix index c0e14ea..3657547 100644 --- a/flake.nix +++ b/flake.nix @@ -4,12 +4,25 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - let pkgs = nixpkgs.legacyPackages.${system}; in { + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + bazel = pkgs.writeShellScriptBin "bazel" '' + exec ${pkgs.bazelisk}/bin/bazelisk "$@" + ''; + in + { devShells.default = pkgs.mkShell { buildInputs = [ - pkgs.bazel_8 + bazel + pkgs.bazelisk pkgs.nodejs_22 (pkgs.pnpm_9 or pkgs.pnpm) ]; @@ -17,10 +30,10 @@ echo "tinyvectors dev shell" echo " node $(node --version)" echo " pnpm $(pnpm --version)" - echo " bazel $(bazel --version | head -n1)" + echo " bazel $(cat .bazelversion) via bazelisk" ''; }; - formatter = pkgs.nixfmt-rfc-style; + formatter = pkgs.nixfmt; } ); } diff --git a/package.json b/package.json index 389cac7..d40b895 100644 --- a/package.json +++ b/package.json @@ -67,16 +67,19 @@ "**/*.css" ], "scripts": { - "build": "vite build && tsc -p tsconfig.declarations.json", + "build": "vite build && node scripts/build-declarations.mjs", "dev": "vite --config vite.dev.config.ts", "dev:watch": "vite build --watch", "check": "svelte-check --tsconfig ./tsconfig.json", "check:release-metadata": "node scripts/check-release-metadata.mjs", "check:package": "publint", + "check:bundle-size": "node scripts/check-bundle-size.mjs", + "check:package-consumer": "node scripts/check-package-consumer.mjs ./bazel-bin/pkg", "test": "vitest run", + "test:browser:motion": "node scripts/probe-motion-cdp.mjs", "test:watch": "vitest", "test:pbt": "vitest run --testNamePattern='INVARIANT'", - "prepublishOnly": "pnpm run check:release-metadata && pnpm run build && pnpm run check:package" + "prepublishOnly": "pnpm run check:release-metadata && pnpm run build && pnpm run check:package && pnpm run check:bundle-size" }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f16ea..ef64141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -597,8 +597,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} publint@0.2.12: @@ -1217,7 +1217,7 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.8: + postcss@8.5.12: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -1342,7 +1342,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.12 rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: diff --git a/scripts/build-declarations.mjs b/scripts/build-declarations.mjs new file mode 100644 index 0000000..c28a673 --- /dev/null +++ b/scripts/build-declarations.mjs @@ -0,0 +1,14 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const tscPath = require.resolve('typescript/lib/tsc.js'); +const result = spawnSync(process.execPath, [tscPath, '-p', 'tsconfig.declarations.json'], { + stdio: 'inherit', +}); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +await import('./copy-svelte-declarations.mjs'); diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100644 index 0000000..92a7294 --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,96 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { build } from 'vite'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageRoot = resolve(process.cwd(), process.argv[2] ?? '.'); +const distEntry = resolve(packageRoot, 'dist/index.js'); +const targetGzipKiB = Number(process.env.TINYVECTORS_TARGET_GZIP_KIB ?? 11); +const maxGzipKiB = Number(process.env.TINYVECTORS_MAX_GZIP_KIB ?? 12); + +if (!existsSync(distEntry)) { + console.error(`Bundle entry is missing: ${distEntry}`); + console.error('Run: pnpm run build'); + process.exit(1); +} + +if (!Number.isFinite(targetGzipKiB) || targetGzipKiB <= 0) { + console.error('TINYVECTORS_TARGET_GZIP_KIB must be a positive number'); + process.exit(1); +} + +if (!Number.isFinite(maxGzipKiB) || maxGzipKiB <= 0) { + console.error('TINYVECTORS_MAX_GZIP_KIB must be a positive number'); + process.exit(1); +} + +const tempDir = await mkdtemp(join(tmpdir(), 'tinyvectors-bundle-size-')); + +try { + const entry = join(tempDir, 'consumer-entry.js'); + await writeFile( + entry, + ` +import { TinyVectors } from ${JSON.stringify(distEntry)}; +console.log(TinyVectors); +`.trimStart(), + ); + + const output = await build({ + configFile: false, + logLevel: 'silent', + build: { + write: false, + minify: 'esbuild', + target: 'es2022', + rollupOptions: { + input: entry, + external: (id) => id === 'svelte' || id.startsWith('svelte/'), + output: { + format: 'es', + inlineDynamicImports: true, + }, + }, + }, + }); + + const outputs = Array.isArray(output) + ? output.flatMap((bundle) => bundle.output) + : output.output; + const js = outputs + .filter((item) => item.type === 'chunk') + .map((item) => item.code) + .join('\n'); + const rawKiB = js.length / 1024; + const gzipKiB = gzipSync(js).length / 1024; + const targetDelta = gzipKiB - targetGzipKiB; + + console.log( + [ + `bundle size check for ${relativeFromRepo(distEntry)}`, + `consumer import: { TinyVectors }`, + `raw ${rawKiB.toFixed(2)} KiB, gzip ${gzipKiB.toFixed(2)} KiB`, + `target ${targetGzipKiB.toFixed(2)} KiB, gate ${maxGzipKiB.toFixed(2)} KiB`, + targetDelta <= 0 + ? `target headroom ${Math.abs(targetDelta).toFixed(2)} KiB` + : `target overage ${targetDelta.toFixed(2)} KiB`, + ].join('\n'), + ); + + if (gzipKiB > maxGzipKiB) { + console.error( + `Consumer bundle gzip ${gzipKiB.toFixed(2)} KiB exceeds ${maxGzipKiB.toFixed(2)} KiB gate`, + ); + process.exit(1); + } +} finally { + await rm(tempDir, { recursive: true, force: true }); +} + +function relativeFromRepo(path) { + return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path; +} diff --git a/scripts/check-package-consumer.mjs b/scripts/check-package-consumer.mjs new file mode 100644 index 0000000..ac816df --- /dev/null +++ b/scripts/check-package-consumer.mjs @@ -0,0 +1,144 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageDir = resolve(process.cwd(), process.argv[2] ?? './bazel-bin/pkg'); +const svelteDir = resolve(repoRoot, 'node_modules/svelte'); +const tscPath = require.resolve('typescript/lib/tsc.js'); + +if (!existsSync(resolve(packageDir, 'package.json'))) { + console.error(`Package directory is missing package.json: ${packageDir}`); + console.error('Run: nix develop . --command bazel build //:pkg'); + process.exit(1); +} + +if (!existsSync(resolve(svelteDir, 'package.json'))) { + console.error(`Svelte peer dependency is missing: ${svelteDir}`); + console.error('Run: pnpm install'); + process.exit(1); +} + +const tempDir = await mkdtemp(join(tmpdir(), 'tinyvectors-consumer-')); + +try { + await mkdir(join(tempDir, 'node_modules/@tummycrypt'), { recursive: true }); + await symlink( + packageDir, + join(tempDir, 'node_modules/@tummycrypt/tinyvectors'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await symlink( + svelteDir, + join(tempDir, 'node_modules/svelte'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await writeFile( + join(tempDir, 'consumer-runtime.mjs'), + ` +import * as root from '@tummycrypt/tinyvectors'; +import * as motion from '@tummycrypt/tinyvectors/motion'; +import * as core from '@tummycrypt/tinyvectors/core'; +import * as themes from '@tummycrypt/tinyvectors/themes'; +import * as svelteComponents from '@tummycrypt/tinyvectors/svelte'; +import { readFileSync } from 'node:fs'; + +const cssUrl = import.meta.resolve('@tummycrypt/tinyvectors/themes/css'); +const css = readFileSync(new URL(cssUrl), 'utf8'); +const requiredRoot = ['BlobPhysics', 'DeviceMotion', 'TinyVectors', 'THEME_PRESETS']; +const requiredMotion = ['DeviceMotion', 'ScrollHandler', 'mapClientPointToPhysics', 'createPointerPhysicsController']; +const tinylandColors = root.THEME_PRESETS?.tinyland?.colors?.map((color) => color.color) ?? []; +const missing = [ + ...requiredRoot.filter((name) => !(name in root)).map((name) => \`root:\${name}\`), + ...requiredMotion.filter((name) => !(name in motion)).map((name) => \`motion:\${name}\`), + ...(!('BlobPhysics' in core) ? ['core:BlobPhysics'] : []), + ...(!('THEME_PRESETS' in themes) ? ['themes:THEME_PRESETS'] : []), + ...(tinylandColors.includes('rgba(139, 92, 246, 0.55)') ? [] : ['root:THEME_PRESETS.tinyland.colors']), + ...(themes.getThemePreset?.('tinyland') === themes.THEME_PRESETS?.tinyland ? [] : ['themes:getThemePreset']), + ...(!('TinyVectors' in svelteComponents) ? ['svelte:TinyVectors'] : []), + ...(!('BlobSVG' in svelteComponents) ? ['svelte:BlobSVG'] : []), + ...(css.includes('--vector-tinyland-purple') ? [] : ['themes/css:variables']), +]; + +if (missing.length > 0) { + throw new Error(\`Missing exports: \${missing.join(', ')}\`); +} +`.trimStart(), + ); + + await writeFile( + join(tempDir, 'consumer-types.ts'), + ` +import { BlobPhysics, DeviceMotion, TinyVectors, THEME_PRESETS } from '@tummycrypt/tinyvectors'; +import type { ThemePreset, ThemePresetName } from '@tummycrypt/tinyvectors/core'; +import { +\tScrollHandler, +\tcreatePointerPhysicsController, +\tmapClientPointToPhysics, +\ttype MotionVector, +\ttype PointerBounds, +} from '@tummycrypt/tinyvectors/motion'; +import { getThemePreset } from '@tummycrypt/tinyvectors/themes'; +import { BlobSVG, type BlobSVGProps, type TinyVectorsProps } from '@tummycrypt/tinyvectors/svelte'; +import type { ComponentProps } from 'svelte'; + +const bounds: PointerBounds = { left: 0, top: 0, width: 100, height: 100 }; +const point = mapClientPointToPhysics(50, 50, bounds); +const sample: MotionVector = { x: 0, y: 0, z: 1 }; +const props: ComponentProps = { theme: 'tinyland', enableDeviceMotion: true }; +const explicitProps: TinyVectorsProps = props; +const blobProps: BlobSVGProps = { blobs: [] }; +const themeName: ThemePresetName = 'tinyland'; +const themePreset: ThemePreset = THEME_PRESETS[themeName]; +const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, point, sample, explicitProps, blobProps, themePreset]; +console.log(names.length); +`.trimStart(), + ); + + await writeFile( + join(tempDir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + skipLibCheck: false, + noEmit: true, + }, + include: ['consumer-types.ts'], + }, + null, + 2, + )}\n`, + ); + + run(process.execPath, ['consumer-runtime.mjs'], tempDir); + run(process.execPath, [tscPath, '-p', 'tsconfig.json'], tempDir); + console.log(`package consumer check passed for ${packageDir}`); +} finally { + if (process.env.TINYVECTORS_KEEP_CONSUMER_CHECK !== '1') { + await rm(tempDir, { recursive: true, force: true }); + } else { + console.log(`kept consumer check workspace: ${tempDir}`); + } +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + stdio: 'inherit', + env: process.env, + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/scripts/check-release-metadata.mjs b/scripts/check-release-metadata.mjs index 2c95fde..0032888 100644 --- a/scripts/check-release-metadata.mjs +++ b/scripts/check-release-metadata.mjs @@ -6,7 +6,33 @@ const read = (relativePath) => const packageJson = JSON.parse(read('../package.json')); const moduleBazel = read('../MODULE.bazel'); const buildBazel = read('../BUILD.bazel'); +const ciWorkflow = read('../.github/workflows/ci.yml'); +const publishWorkflow = read('../.github/workflows/publish.yml'); const expectedPnpmVersion = packageJson.packageManager?.replace(/^pnpm@/, ''); +const expectedNodeMajor = packageJson.engines?.node?.match(/>=\s*(\d+)/)?.[1]; +const expectedBazelTargets = + '//:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test'; +const expectedPackageDir = './bazel-bin/pkg'; +const expectedPackageConsumerCommand = 'node scripts/check-package-consumer.mjs ./bazel-bin/pkg'; +const expectedPackageCheckCommand = 'pnpm run check:package && pnpm run check:bundle-size'; +const expectedPrepublishOnlyCommand = + 'pnpm run check:release-metadata && pnpm run build && pnpm run check:package && pnpm run check:bundle-size'; +const expectedSharedWorkflowInputs = { + runner_mode: 'hosted', + workspace_mode: 'isolated', + publish_mode: 'same_runner', + node_versions: `["${expectedNodeMajor}"]`, + publish_node_version: expectedNodeMajor, + pnpm_version: expectedPnpmVersion, + metadata_check_command: 'pnpm run check:release-metadata', + typecheck_command: 'pnpm run check', + unit_test_command: 'pnpm run test', + build_command: 'pnpm run build', + package_check_command: expectedPackageCheckCommand, + bazel_targets: expectedBazelTargets, + package_dir: expectedPackageDir, + npm_access: 'public', +}; const extract = (source, pattern, label) => { const match = source.match(pattern); @@ -16,6 +42,24 @@ const extract = (source, pattern, label) => { return match[1]; }; +const extractWorkflowValue = (source, key, label) => { + const rawValue = extract(source, new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, 'm'), label).trim(); + if ( + (rawValue.startsWith("'") && rawValue.endsWith("'")) || + (rawValue.startsWith('"') && rawValue.endsWith('"')) + ) { + return rawValue.slice(1, -1); + } + return rawValue; +}; + +const sharedPackageWorkflow = (source, label) => + extract( + source, + /uses:\s*(tinyland-inc\/ci-templates\/\.github\/workflows\/js-bazel-package\.yml@[0-9a-f]{40})/, + label, + ); + const checks = [ { label: 'MODULE.bazel version', @@ -49,10 +93,68 @@ const checks = [ actual: extract(moduleBazel, /pnpm_version = "([^"]+)"/, 'pnpm_version'), expected: expectedPnpmVersion, }, + { + label: 'MODULE.bazel Node toolchain major', + actual: extract(moduleBazel, /node_version = "(\d+)\./, 'node_version'), + expected: expectedNodeMajor, + }, + { + label: 'CI reusable package workflow', + actual: sharedPackageWorkflow(ciWorkflow, 'CI reusable workflow'), + expected: sharedPackageWorkflow(publishWorkflow, 'publish reusable workflow'), + }, + { + label: 'package consumer check script', + actual: packageJson.scripts?.['check:package-consumer'], + expected: expectedPackageConsumerCommand, + }, + { + label: 'bundle size check script', + actual: packageJson.scripts?.['check:bundle-size'], + expected: 'node scripts/check-bundle-size.mjs', + }, + { + label: 'prepublishOnly script', + actual: packageJson.scripts?.prepublishOnly, + expected: expectedPrepublishOnlyCommand, + }, + { + label: 'CI publish dry-run', + actual: extractWorkflowValue(ciWorkflow, 'dry_run', 'CI dry_run'), + expected: 'true', + }, + { + label: 'tag publish dry-run', + actual: extractWorkflowValue(publishWorkflow, 'dry_run', 'publish dry_run'), + expected: 'false', + }, ]; +for (const [key, expected] of Object.entries(expectedSharedWorkflowInputs)) { + checks.push( + { + label: `CI ${key}`, + actual: extractWorkflowValue(ciWorkflow, key, `CI ${key}`), + expected, + }, + { + label: `publish ${key}`, + actual: extractWorkflowValue(publishWorkflow, key, `publish ${key}`), + expected, + }, + ); +} + const failures = checks.filter((check) => check.actual !== check.expected); +if (packageJson.publishConfig?.provenance && !/id-token:\s*write/.test(publishWorkflow)) { + failures.push({ + label: 'publish workflow id-token permission', + actual: 'missing', + expected: 'write', + }); +} + if (failures.length > 0) { for (const failure of failures) { console.error( diff --git a/scripts/copy-svelte-declarations.mjs b/scripts/copy-svelte-declarations.mjs new file mode 100644 index 0000000..cd52b75 --- /dev/null +++ b/scripts/copy-svelte-declarations.mjs @@ -0,0 +1,31 @@ +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; + +const declarationPairs = [ + ['BlobSVG.svelte.d.ts', 'BlobSVG.d.ts'], + ['TinyVectors.svelte.d.ts', 'TinyVectors.d.ts'], +]; +const sourceDir = new URL('../src/svelte/', import.meta.url); +const outputDir = new URL('../dist-types/svelte/', import.meta.url); + +await mkdir(outputDir, { recursive: true }); + +await Promise.all( + declarationPairs.map(([sourceName, outputName]) => + copyFile(new URL(sourceName, sourceDir), new URL(outputName, outputDir)), + ), +); + +await writeFile( + new URL('index.d.ts', outputDir), + [ + "export { default as TinyVectors, type TinyVectorsExports, type TinyVectorsProps } from './TinyVectors.js';", + "export { default as BlobSVG, type BlobSVGProps } from './BlobSVG.js';", + '', + ].join('\n'), +); + +await Promise.all([ + rm(new URL('index.d.ts.map', outputDir), { force: true }), + rm(new URL('BlobSVG.svelte.d.ts', outputDir), { force: true }), + rm(new URL('TinyVectors.svelte.d.ts', outputDir), { force: true }), +]); From cf14b048da97dba50aacd361d887285d37854cc6 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 12:40:34 -0400 Subject: [PATCH 15/57] fix(svelte): guard TinyVectors async init cleanup --- src/svelte/TinyVectors.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index ec24cac..864921a 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -169,10 +169,15 @@ $effect(() => { if (!browser || !shouldLoad) return; + let disposed = false; + untrack(() => { - physics = new BlobPhysics(blobCount, physicsConfig); + const currentPhysics = new BlobPhysics(blobCount, physicsConfig); + physics = currentPhysics; + + currentPhysics.init().then(() => { + if (disposed || physics !== currentPhysics) return; - physics.init().then(() => { const hasDeviceMotionCapability = detectDeviceMotionCapability(); isReady = true; @@ -209,6 +214,7 @@ }); return () => { + disposed = true; stopAnimation(); if (enableScrollPhysics && browser) { window.removeEventListener('wheel', handleScroll); @@ -221,6 +227,8 @@ scrollHandler = null; physics?.dispose(); physics = null; + isReady = false; + blobs = []; }; }); From a484a01181e972d848381cf555fe6fb352da1f13 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 12:58:06 -0400 Subject: [PATCH 16/57] fix(review): address motion calibration feedback --- docs/release-flow.md | 6 +++++ scripts/probe-motion-cdp.mjs | 1 + src/core/BlobPhysics.ts | 4 ++-- src/motion/DeviceMotion.ts | 10 ++++---- src/svelte/TinyVectors.svelte | 1 + tests/unit/core.test.ts | 38 ++++++++++++++++++++++++++++++ tests/unit/device-motion.test.ts | 40 ++++++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+), 6 deletions(-) diff --git a/docs/release-flow.md b/docs/release-flow.md index ff3e539..ea5c0ef 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -35,6 +35,12 @@ npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg `bazel query //...` should also work locally. `.bazelignore` excludes direnv, Nix, package-manager, and build-output directories so Bazel does not walk generated local artifacts. +## Compatibility Notes + +Carry these notes into the v0.3 release notes: + +- Blob gradient stop opacity now uses the renderer-private `--tvi` custom property. Consumers overriding the previous `--tv-blob-intensity` property must migrate that override. + ## CI Flow Pull requests and pushes to `main` run `Verify`, which calls `tinyland-inc/ci-templates/.github/workflows/js-bazel-package.yml` at a pinned commit. The reusable workflow: diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index 78acc63..cee051c 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -224,6 +224,7 @@ try { const chrome = spawnChild(chromePath, [ '--headless=new', '--disable-gpu', + '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', `--remote-debugging-address=${host}`, diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 5bb61bc..dcaddc4 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -33,7 +33,7 @@ export interface BlobPhysicsConfig { springConfig: Partial; } -const DEFAULT_CONFIG: BlobPhysicsConfig = { +export const DEFAULT_BLOB_PHYSICS_CONFIG: BlobPhysicsConfig = { antiClusteringStrength: 0.15, bounceDamping: 0.7, deformationSpeed: 0.5, @@ -80,7 +80,7 @@ export class BlobPhysics { constructor(numBlobs: number, config: Partial = {}) { this.numBlobs = numBlobs; - this.config = { ...DEFAULT_CONFIG, ...config }; + this.config = { ...DEFAULT_BLOB_PHYSICS_CONFIG, ...config }; this.spatialHash = new SpatialHash(60); diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index af89d86..adf2ea2 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -181,7 +181,7 @@ export class DeviceMotion { calibrate(samples = this.opts.calibrationSamples): void { const sampleCount = Math.max(0, Math.floor(samples)); - this.resetFilterState(); + this.resetFilterState({ resetWarmup: false }); if (sampleCount === 0) { if (this.lastScreen) { @@ -352,14 +352,16 @@ export class DeviceMotion { this.baseY = this.calibrationTotalY / sampleCount; this.calibrationTotalX = 0; this.calibrationTotalY = 0; - this.resetFilterState(); + this.resetFilterState({ resetWarmup: false }); return false; } - private resetFilterState(): void { + private resetFilterState({ resetWarmup = true } = {}): void { this.filterX.reset(); this.filterY.reset(); - this.listenerStartedAt = this.now(); + if (resetWarmup) { + this.listenerStartedAt = this.now(); + } this.lastEventAt = 0; } diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index 864921a..c3baf1a 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -172,6 +172,7 @@ let disposed = false; untrack(() => { + // BlobPhysics owns base defaults; this component forwards caller overrides. const currentPhysics = new BlobPhysics(blobCount, physicsConfig); physics = currentPhysics; diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index efcc51c..1a3626a 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -16,6 +16,11 @@ import { } from '../../src/core/PathGenerator.js'; import { SpatialHash } from '../../src/core/SpatialHash.js'; import { GaussianKernel } from '../../src/core/GaussianKernel.js'; +import { + BlobPhysics, + DEFAULT_BLOB_PHYSICS_CONFIG, + type BlobPhysicsConfig, +} from '../../src/core/BlobPhysics.js'; import type { RenderBlob } from '../../src/core/schema.js'; import type { ConvexBlob, ControlPoint } from '../../src/core/types.js'; @@ -223,6 +228,39 @@ describe('PathGenerator', () => { +describe('BlobPhysics', () => { + it('owns the TinyVectors default physics configuration', () => { + expect(DEFAULT_BLOB_PHYSICS_CONFIG).toEqual({ + antiClusteringStrength: 0.15, + bounceDamping: 0.7, + deformationSpeed: 0.5, + territoryStrength: 0.1, + viscosity: 0.3, + useSpatialHash: true, + useGaussianSmoothing: true, + useSpringSystem: true, + springConfig: {}, + }); + }); + + it('merges caller overrides on top of internal defaults', () => { + const physics = new BlobPhysics(2, { + antiClusteringStrength: 0.25, + useSpatialHash: false, + }); + const config = (physics as unknown as { config: BlobPhysicsConfig }).config; + + expect(config).toEqual({ + ...DEFAULT_BLOB_PHYSICS_CONFIG, + antiClusteringStrength: 0.25, + useSpatialHash: false, + }); + }); +}); + + + + describe('SpatialHash', () => { describe('constructor', () => { it('should create with default cell size', () => { diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index d03cf69..012a366 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -91,6 +91,7 @@ function createMotionEnvironment(options: { dispatchWindow('deviceorientation', { beta, gamma, alpha }); }, mql, + motionWindow, removeDocumentListener, removeWindowListener, }; @@ -168,6 +169,20 @@ describe('DeviceMotion', () => { expect(callback).toHaveBeenCalledWith({ x: 0, y: 1, z: 0 }); }); + it('calls the permission API with DeviceOrientationEvent as receiver', async () => { + let receiver: unknown; + const requestPermission = vi.fn(function (this: unknown) { + receiver = this; + return Promise.resolve('granted' as const); + }); + const env = createMotionEnvironment({ permission: requestPermission }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.requestPermission()).resolves.toBe(true); + + expect(receiver).toBe(env.motionWindow.DeviceOrientationEvent); + }); + it('does not restart listeners after cleanup resolves an in-flight permission request', async () => { let resolvePermission: (value: PermissionResponse) => void = () => {}; const permission = new Promise((resolve) => { @@ -210,6 +225,31 @@ describe('DeviceMotion', () => { }); }); + it('does not re-arm warmup after calibration completes', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 250, + }); + + await motion.initialize(); + now = 300; + motion.calibrate(1); + + env.dispatchOrientation(10, 20); + now = 301; + env.dispatchOrientation(20, 30); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith({ + x: (30 - 20) / 45, + y: (20 - 10) / 45, + z: 0, + }); + }); + it('honors reduced motion as a hard disable', async () => { const env = createMotionEnvironment({ reducedMotion: true }); const motion = new DeviceMotion(vi.fn()); From 5437ff35bd5bcae0f460961d12dab661a2782d75 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 13:11:28 -0400 Subject: [PATCH 17/57] fix(review): document calibration and env parsing --- scripts/check-bundle-size.mjs | 26 ++++++++++++++------------ src/motion/DeviceMotion.ts | 4 ++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index 92a7294..dc7a1be 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -9,8 +9,8 @@ import { build } from 'vite'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packageRoot = resolve(process.cwd(), process.argv[2] ?? '.'); const distEntry = resolve(packageRoot, 'dist/index.js'); -const targetGzipKiB = Number(process.env.TINYVECTORS_TARGET_GZIP_KIB ?? 11); -const maxGzipKiB = Number(process.env.TINYVECTORS_MAX_GZIP_KIB ?? 12); +const targetGzipKiB = parsePositiveKiB('TINYVECTORS_TARGET_GZIP_KIB', 11); +const maxGzipKiB = parsePositiveKiB('TINYVECTORS_MAX_GZIP_KIB', 12); if (!existsSync(distEntry)) { console.error(`Bundle entry is missing: ${distEntry}`); @@ -18,16 +18,6 @@ if (!existsSync(distEntry)) { process.exit(1); } -if (!Number.isFinite(targetGzipKiB) || targetGzipKiB <= 0) { - console.error('TINYVECTORS_TARGET_GZIP_KIB must be a positive number'); - process.exit(1); -} - -if (!Number.isFinite(maxGzipKiB) || maxGzipKiB <= 0) { - console.error('TINYVECTORS_MAX_GZIP_KIB must be a positive number'); - process.exit(1); -} - const tempDir = await mkdtemp(join(tmpdir(), 'tinyvectors-bundle-size-')); try { @@ -94,3 +84,15 @@ console.log(TinyVectors); function relativeFromRepo(path) { return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path; } + +function parsePositiveKiB(envName, defaultValue) { + const rawValue = process.env[envName]; + const value = rawValue == null ? defaultValue : Number(rawValue.trim()); + + if (!Number.isFinite(value) || value <= 0) { + console.error(`${envName} must be a positive number`); + process.exit(1); + } + + return value; +} diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index adf2ea2..33b8ff0 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -179,6 +179,10 @@ export class DeviceMotion { return true; } + /** + * Re-zero tilt from the next N orientation samples. Calibration samples are + * consumed for the baseline only; normal output resumes on the following event. + */ calibrate(samples = this.opts.calibrationSamples): void { const sampleCount = Math.max(0, Math.floor(samples)); this.resetFilterState({ resetWarmup: false }); From 4a812ad15ee3fb9499783859afe7b1c7e03bca58 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 13:15:58 -0400 Subject: [PATCH 18/57] fix(build): validate bundle size thresholds --- scripts/check-bundle-size.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index dc7a1be..f640296 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -12,6 +12,13 @@ const distEntry = resolve(packageRoot, 'dist/index.js'); const targetGzipKiB = parsePositiveKiB('TINYVECTORS_TARGET_GZIP_KIB', 11); const maxGzipKiB = parsePositiveKiB('TINYVECTORS_MAX_GZIP_KIB', 12); +if (maxGzipKiB < targetGzipKiB) { + console.error( + 'TINYVECTORS_MAX_GZIP_KIB must be greater than or equal to TINYVECTORS_TARGET_GZIP_KIB', + ); + process.exit(1); +} + if (!existsSync(distEntry)) { console.error(`Bundle entry is missing: ${distEntry}`); console.error('Run: pnpm run build'); From 82afa1fe7626962597bda787a161804f2a9a89fa Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 13:28:13 -0400 Subject: [PATCH 19/57] fix(motion): preserve permission-created device motion --- src/svelte/TinyVectors.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index c3baf1a..e65b2f4 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -183,8 +183,10 @@ isReady = true; if (enableDeviceMotion && hasDeviceMotionCapability) { - deviceMotion = createDeviceMotion(); - void deviceMotion.initialize(); + if (!deviceMotion) { + deviceMotion = createDeviceMotion(); + void deviceMotion.initialize(); + } } if (enableScrollPhysics) { From 3b9a360b24c92688004b3adf89576fb76b454e1b Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 13:37:17 -0400 Subject: [PATCH 20/57] fix(package): tighten Svelte peer floor --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a2b911..f8a67d2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ pnpm add @tummycrypt/tinyvectors Peer dependency: -- `svelte@^5` +- `svelte@>=5.20.0` ## Quick Start diff --git a/package.json b/package.json index d40b895..6f679fb 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "prepublishOnly": "pnpm run check:release-metadata && pnpm run build && pnpm run check:package && pnpm run check:bundle-size" }, "peerDependencies": { - "svelte": "^5.0.0" + "svelte": ">=5.20.0" }, "devDependencies": { "@sveltejs/package": "^2.5.7", From 4684fc191802533987c960237a35ddd3f53e24c1 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 13:39:36 -0400 Subject: [PATCH 21/57] fix(motion): re-engage after reduced motion changes --- src/motion/DeviceMotion.ts | 16 +++++++++++++++ tests/unit/device-motion.test.ts | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index 33b8ff0..b7996b8 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -115,6 +115,7 @@ export class DeviceMotion { private boundVisibility: (() => void) | null = null; private reducedMotionMql: MediaQueryList | null = null; private reducedMotionListener: (() => void) | null = null; + private blockedByReducedMotion = false; constructor(callback: DeviceMotionCallback, options: DeviceMotionOptions = {}) { this.callback = callback; @@ -133,6 +134,7 @@ export class DeviceMotion { this.observeReducedMotion(); if (this.prefersReducedMotion()) { + this.blockedByReducedMotion = true; this.permissionState = 'denied'; this.stopListening(); return false; @@ -153,6 +155,7 @@ export class DeviceMotion { this.observeReducedMotion(); if (this.prefersReducedMotion()) { + this.blockedByReducedMotion = true; this.permissionState = 'denied'; this.stopListening(); return false; @@ -219,6 +222,7 @@ export class DeviceMotion { } this.reducedMotionMql = null; this.reducedMotionListener = null; + this.blockedByReducedMotion = false; this.resetFilterState(); } @@ -251,10 +255,22 @@ export class DeviceMotion { if (this.disposed || !this.reducedMotionMql) return; if (this.reducedMotionMql.matches) { + this.blockedByReducedMotion = true; this.stopListening(); return; } + if (this.blockedByReducedMotion) { + this.blockedByReducedMotion = false; + if (this.permissionState === 'granted' || !getPermissionApi()) { + this.permissionState = 'granted'; + this.startListening(); + return; + } + this.permissionState = 'prompt'; + return; + } + if (this.permissionState === 'granted') { this.startListening(); } diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index 012a366..2d1b026 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -94,6 +94,11 @@ function createMotionEnvironment(options: { motionWindow, removeDocumentListener, removeWindowListener, + dispatchReducedMotionChange() { + for (const listener of mqlListeners) { + listener(); + } + }, }; } @@ -260,4 +265,34 @@ describe('DeviceMotion', () => { expect(motion.getPermissionState()).toBe('denied'); expect(env.addWindowListener).not.toHaveBeenCalled(); }); + + it('restarts after reduced motion is disabled when no permission prompt is needed', async () => { + const env = createMotionEnvironment({ reducedMotion: true }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + env.mql.matches = false; + env.dispatchReducedMotionChange(); + + expect(motion.getPermissionState()).toBe('granted'); + expect(motion.isActive()).toBe(true); + expect(env.addWindowListener).toHaveBeenCalledWith('deviceorientation', expect.any(Function), { + passive: true, + }); + }); + + it('returns to prompt after reduced motion is disabled when permission is gated', async () => { + const requestPermission = vi.fn().mockResolvedValue('granted' as const); + const env = createMotionEnvironment({ permission: requestPermission, reducedMotion: true }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + env.mql.matches = false; + env.dispatchReducedMotionChange(); + + expect(motion.getPermissionState()).toBe('prompt'); + expect(motion.isActive()).toBe(false); + expect(env.addWindowListener).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + }); }); From 84a7fc837aa5f1049b08b42939703382ad767d6c Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 13:46:47 -0400 Subject: [PATCH 22/57] fix(svelte): clean up scroll listener by setup state --- src/svelte/TinyVectors.svelte | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index e65b2f4..fade83b 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -170,6 +170,9 @@ if (!browser || !shouldLoad) return; let disposed = false; + const scrollPhysicsEnabled = enableScrollPhysics; + const pointerPhysicsEnabled = enablePointerPhysics; + let wheelListenerAttached = false; untrack(() => { // BlobPhysics owns base defaults; this component forwards caller overrides. @@ -189,7 +192,7 @@ } } - if (enableScrollPhysics) { + if (scrollPhysicsEnabled) { scrollHandler = new ScrollHandler(); } @@ -200,11 +203,12 @@ } }); - if (enableScrollPhysics) { + if (scrollPhysicsEnabled) { window.addEventListener('wheel', handleScroll, { passive: true }); + wheelListenerAttached = true; } - if (enablePointerPhysics) { + if (pointerPhysicsEnabled) { pointerController = createPointerPhysicsController({ target: window, getBounds: getPointerBounds, @@ -219,7 +223,7 @@ return () => { disposed = true; stopAnimation(); - if (enableScrollPhysics && browser) { + if (wheelListenerAttached) { window.removeEventListener('wheel', handleScroll); } pointerController?.dispose(); From c9148d083ae2931f822fc8133596b4d635786f19 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 14:11:56 -0400 Subject: [PATCH 23/57] test(browser): cover interaction listener cleanup --- scripts/probe-motion-cdp.mjs | 119 +++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 4 deletions(-) diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index cee051c..ea0dcf9 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -249,7 +249,48 @@ try { await client.send('Page.addScriptToEvaluateOnNewDocument', { source: ` window.__tinyvectorsEvents = []; - window.addEventListener('deviceorientation', (event) => { + (() => { + const originalAddEventListener = EventTarget.prototype.addEventListener; + const originalRemoveEventListener = EventTarget.prototype.removeEventListener; + const listenerIds = new WeakMap(); + const activeWindowListeners = new Map(); + let nextListenerId = 1; + + const listenerId = (listener) => { + if ((typeof listener !== 'function' && typeof listener !== 'object') || listener === null) { + return String(listener); + } + if (!listenerIds.has(listener)) { + listenerIds.set(listener, nextListenerId++); + } + return listenerIds.get(listener); + }; + + window.__tinyvectorsListenerLedger = { + snapshot() { + const counts = {}; + for (const type of activeWindowListeners.values()) { + counts[type] = (counts[type] || 0) + 1; + } + return counts; + }, + }; + + EventTarget.prototype.addEventListener = function(type, listener, options) { + if (this === window && listener) { + activeWindowListeners.set(type + ':' + listenerId(listener), type); + } + return originalAddEventListener.call(this, type, listener, options); + }; + + EventTarget.prototype.removeEventListener = function(type, listener, options) { + if (this === window && listener) { + activeWindowListeners.delete(type + ':' + listenerId(listener)); + } + return originalRemoveEventListener.call(this, type, listener, options); + }; + + originalAddEventListener.call(window, 'deviceorientation', (event) => { window.__tinyvectorsEvents.push({ type: 'deviceorientation', alpha: event.alpha, @@ -257,8 +298,8 @@ try { gamma: event.gamma, at: performance.now() }); - }); - window.addEventListener('devicemotion', (event) => { + }); + originalAddEventListener.call(window, 'devicemotion', (event) => { const gravity = event.accelerationIncludingGravity; window.__tinyvectorsEvents.push({ type: 'devicemotion', @@ -267,7 +308,8 @@ try { z: gravity && gravity.z, at: performance.now() }); - }); + }); + })(); `, }); @@ -331,6 +373,69 @@ try { 'CDP device orientation override did not change blob geometry.', ); + const listenerProbeUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=true&scrollPhysics=true&blobs=8&listenerProbe=1`; + await client.send('Page.navigate', { url: listenerProbeUrl }); + await delay(1500); + + const listenerInitial = await evaluate(client, `({ + pathCount: document.querySelectorAll('path').length, + bodyPathCount: document.querySelectorAll('svg g')[1]?.querySelectorAll('path').length ?? 0, + gradientCount: document.querySelectorAll('radialGradient').length, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert(listenerInitial.pathCount === 32, `Expected 32 SVG paths, got ${listenerInitial.pathCount}.`); + assert(listenerInitial.bodyPathCount === 8, `Expected 8 body paths, got ${listenerInitial.bodyPathCount}.`); + assert( + listenerInitial.gradientCount === 32, + `Expected 32 radial gradients, got ${listenerInitial.gradientCount}.`, + ); + assert( + listenerInitial.listeners.wheel === 1, + `Expected one wheel listener, got ${listenerInitial.listeners.wheel}.`, + ); + assert( + listenerInitial.listeners.pointermove === 1, + `Expected one pointermove listener, got ${listenerInitial.listeners.pointermove}.`, + ); + assert( + listenerInitial.listeners.deviceorientation === 1, + `Expected one deviceorientation listener, got ${listenerInitial.listeners.deviceorientation}.`, + ); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('scroll-physics')?.click()`, + awaitPromise: true, + }); + await delay(300); + const afterScrollOff = await evaluate(client, `({ + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + assert(!afterScrollOff.listeners.wheel, 'Wheel listener leaked after disabling scroll physics.'); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('pointer-physics')?.click()`, + awaitPromise: true, + }); + await delay(300); + const afterPointerOff = await evaluate(client, `({ + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + assert(!afterPointerOff.listeners.pointermove, 'Pointer listener leaked after disabling pointer physics.'); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('device-motion')?.click()`, + awaitPromise: true, + }); + await delay(300); + const afterDeviceMotionOff = await evaluate(client, `({ + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + assert( + !afterDeviceMotionOff.listeners.deviceorientation, + 'Device orientation listener leaked after disabling device motion.', + ); + await client.send('Emulation.setSensorOverrideEnabled', { type: 'accelerometer', enabled: true, @@ -397,6 +502,12 @@ try { pathChanged: cdpAccelerometerChanged, note: 'TinyVectors uses DeviceOrientationEvent/TiltSource; raw accelerometer CDP is informational.', }, + listenerLifecycle: { + initial: listenerInitial.listeners, + afterScrollOff: afterScrollOff.listeners, + afterPointerOff: afterPointerOff.listeners, + afterDeviceMotionOff: afterDeviceMotionOff.listeners, + }, }, null, 2, From ef2669383c47062496400f30788a097b1ca7a4c4 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 14:19:26 -0400 Subject: [PATCH 24/57] fix(motion): gate permission requests after unmount --- src/svelte/TinyVectors.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index fade83b..3f25d27 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -102,7 +102,7 @@ }; export async function requestDeviceMotionPermission(): Promise { - if (!browser || !enableDeviceMotion || !detectDeviceMotionCapability()) return false; + if (!browser || !enableDeviceMotion || !physics || !detectDeviceMotionCapability()) return false; deviceMotion ??= createDeviceMotion(); From 325b6ae241a47e991d694524cc4557de6a3a7abd Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 14:27:37 -0400 Subject: [PATCH 25/57] fix(motion): track device motion toggle in setup --- src/svelte/TinyVectors.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index 3f25d27..d06b816 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -170,6 +170,7 @@ if (!browser || !shouldLoad) return; let disposed = false; + const deviceMotionEnabled = enableDeviceMotion; const scrollPhysicsEnabled = enableScrollPhysics; const pointerPhysicsEnabled = enablePointerPhysics; let wheelListenerAttached = false; @@ -185,7 +186,7 @@ const hasDeviceMotionCapability = detectDeviceMotionCapability(); isReady = true; - if (enableDeviceMotion && hasDeviceMotionCapability) { + if (deviceMotionEnabled && hasDeviceMotionCapability) { if (!deviceMotion) { deviceMotion = createDeviceMotion(); void deviceMotion.initialize(); From 0d2821fae2d36bbb740b6492ebcb552b1a8c97f3 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 15:01:25 -0400 Subject: [PATCH 26/57] fix(physics): restore gravity-led gel feel --- src/core/BlobPhysics.ts | 64 ++++++++++----------------- src/svelte/BlobSVG.svelte | 6 +-- tests/unit/blob-physics-feel.test.ts | 65 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 44 deletions(-) create mode 100644 tests/unit/blob-physics-feel.test.ts diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index dcaddc4..06cf809 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -77,6 +77,7 @@ export class BlobPhysics { private skinTensionScratch: Float32Array | null = null; private xsphDvX: Float32Array | null = null; private xsphDvY: Float32Array | null = null; + private xsphWeight: Float32Array | null = null; constructor(numBlobs: number, config: Partial = {}) { this.numBlobs = numBlobs; @@ -176,16 +177,19 @@ export class BlobPhysics { const n = blobs.length; if (n < 2) return; - if (!this.xsphDvX || !this.xsphDvY || this.xsphDvX.length < n) { + if (!this.xsphDvX || !this.xsphDvY || !this.xsphWeight || this.xsphDvX.length < n) { this.xsphDvX = new Float32Array(n); this.xsphDvY = new Float32Array(n); + this.xsphWeight = new Float32Array(n); } const dvX = this.xsphDvX; const dvY = this.xsphDvY; + const weights = this.xsphWeight; dvX.fill(0); dvY.fill(0); + weights.fill(0); - const eps = 0.4; + const eps = 0.12; const sigma = 80; const twoSigmaSq = 2 * sigma * sigma; @@ -196,6 +200,8 @@ export class BlobPhysics { const dx = b.currentX - a.currentX; const dy = b.currentY - a.currentY; const w = Math.exp(-(dx * dx + dy * dy) / twoSigmaSq); + weights[i] += w; + weights[j] += w; const dvx = w * (b.velocityX - a.velocityX); const dvy = w * (b.velocityY - a.velocityY); dvX[i] += dvx; @@ -206,8 +212,9 @@ export class BlobPhysics { } for (let i = 0; i < n; i++) { - blobs[i].velocityX += eps * dvX[i]; - blobs[i].velocityY += eps * dvY[i]; + const normalizer = Math.max(1, weights[i]); + blobs[i].velocityX += eps * (dvX[i] / normalizer); + blobs[i].velocityY += eps * (dvY[i] / normalizer); } } @@ -249,12 +256,6 @@ export class BlobPhysics { blob.velocityX -= normalizedDx * repulsionForce * forceMultiplier; blob.velocityY -= normalizedDy * repulsionForce * forceMultiplier; - // Force is now Gaussian (continuous, applies at any range - // inside the spatial-hash query). lastRepulsionTime stays - // gated on the close-contact threshold because downstream - // addEscapeVelocity uses it as a "blobs were just pushing - // each other apart" event detector — not as a generic - // "any neighbor contributed" flag. Decoupling is intentional. if (distance < requiredDistance) { blob.lastRepulsionTime = Date.now(); } @@ -519,9 +520,6 @@ export class BlobPhysics { this.updateMovementWithAccelerometer(blob, time); - this.addEscapeVelocity(blob); - - this.updateSafeOrganicDeformation(blob, time); @@ -542,34 +540,30 @@ export class BlobPhysics { } private applyAccelerometerForces(blob: ConvexBlob): void { - const accelerometerStrength = 0.0008; - const maxForce = 0.003; + const accelerometerStrength = 0.003; + const maxForce = 0.012; const gravityX = Math.max(-maxForce, Math.min(maxForce, this.gravity.x * accelerometerStrength)); const gravityY = Math.max(-maxForce, Math.min(maxForce, this.gravity.y * accelerometerStrength)); blob.velocityX += gravityX; blob.velocityY += gravityY; - - - if (blob.controlPoints && (Math.abs(this.gravity.x) > 0.3 || Math.abs(this.gravity.y) > 0.3)) { - const deformationAmount = Math.min(0.08, (Math.abs(this.gravity.x) + Math.abs(this.gravity.y)) * 0.02); - blob.chaosLevel = Math.min((blob.chaosLevel || 0) + deformationAmount, 0.2); - } } private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void { + const gravityMagnitude = Math.min(1, Math.sqrt(this.gravity.x * this.gravity.x + this.gravity.y * this.gravity.y)); + const ambientScale = 1 - gravityMagnitude * 0.75; - const neutralDriftX = (Math.random() - 0.5) * 0.001; - const neutralDriftY = (Math.random() - 0.5) * 0.001; + const neutralDriftX = (Math.random() - 0.5) * 0.00045 * ambientScale; + const neutralDriftY = (Math.random() - 0.5) * 0.00045 * ambientScale; blob.velocityX += neutralDriftX; blob.velocityY += neutralDriftY; const brownianTime = time * 0.1 + blob.phase; - const brownianX = Math.sin(brownianTime + (blob.driftAngle || 0)) * 0.0005; - const brownianY = Math.cos(brownianTime * 1.3 + (blob.driftAngle || 0)) * 0.0005; + const brownianX = Math.sin(brownianTime + (blob.driftAngle || 0)) * 0.00025 * ambientScale; + const brownianY = Math.cos(brownianTime * 1.3 + (blob.driftAngle || 0)) * 0.00025 * ambientScale; blob.velocityX += brownianX; blob.velocityY += brownianY; @@ -599,8 +593,10 @@ export class BlobPhysics { } - blob.velocityX += (Math.random() - 0.5) * 0.003; - blob.velocityY += (Math.random() - 0.5) * 0.003; + const gravityMagnitude = Math.min(1, Math.sqrt(this.gravity.x * this.gravity.x + this.gravity.y * this.gravity.y)); + const ambientScale = 1 - gravityMagnitude * 0.75; + blob.velocityX += (Math.random() - 0.5) * 0.0012 * ambientScale; + blob.velocityY += (Math.random() - 0.5) * 0.0012 * ambientScale; if (time % 45 < 0.1) { @@ -616,16 +612,6 @@ export class BlobPhysics { } } - private addEscapeVelocity(blob: ConvexBlob): void { - if (blob.lastRepulsionTime && Date.now() - blob.lastRepulsionTime < 3000) { - const escapeStrength = 0.01; - const escapeAngle = Math.random() * Math.PI * 2; - - blob.velocityX += Math.cos(escapeAngle) * escapeStrength; - blob.velocityY += Math.sin(escapeAngle) * escapeStrength; - } - } - private updateSafeOrganicDeformation(blob: ConvexBlob, time: number): void { if (!blob.controlPoints || !blob.controlVelocities) return; @@ -825,10 +811,6 @@ export class BlobPhysics { blob.lastBounceTime = currentTime; - blob.velocityX += (Math.random() - 0.5) * 0.05; - blob.velocityY += (Math.random() - 0.5) * 0.05; - - blob.driftAngle = Math.random() * Math.PI * 2; diff --git a/src/svelte/BlobSVG.svelte b/src/svelte/BlobSVG.svelte index a9e4e14..36a3f6f 100644 --- a/src/svelte/BlobSVG.svelte +++ b/src/svelte/BlobSVG.svelte @@ -133,8 +133,8 @@ fy="20%" style:--tvi={blob.intensity} > - - + + @@ -173,7 +173,7 @@ {/each} - + {#each blobs as blob (blob.gradientId)} { + let index = 0; + const values = [0.13, 0.87, 0.31, 0.69, 0.47, 0.53, 0.22, 0.78]; + vi.spyOn(Math, 'random').mockImplementation(() => { + const value = values[index % values.length]; + index += 1; + return value; + }); +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('BlobPhysics feel', () => { + it('lets device gravity dominate ambient drift', async () => { + useDeterministicRandom(); + + const physics = new BlobPhysics(8); + await physics.init(); + const before = physics.getBlobs().map((blob) => ({ + x: blob.currentX, + y: blob.currentY, + })); + + physics.setGravity({ x: 0, y: 1 }); + + for (let frame = 0; frame < 180; frame++) { + physics.tick(1 / 60, frame / 60); + } + + const after = physics.getBlobs(); + const deltas = after.map((blob, index) => ({ + x: blob.currentX - before[index].x, + y: blob.currentY - before[index].y, + })); + const downwardBlobs = deltas.filter((delta) => delta.y > 0).length; + const averageY = + deltas.reduce((total, delta) => total + delta.y, 0) / deltas.length; + const averageAbsX = + deltas.reduce((total, delta) => total + Math.abs(delta.x), 0) / deltas.length; + + expect(downwardBlobs).toBeGreaterThanOrEqual(6); + expect(averageY).toBeGreaterThan(4); + expect(averageY).toBeGreaterThan(averageAbsX); + }); + + it('does not turn steady device gravity into random deformation chaos', async () => { + useDeterministicRandom(); + + const physics = new BlobPhysics(1); + await physics.init(); + physics.setGravity({ x: 0, y: 1 }); + + for (let frame = 0; frame < 60; frame++) { + physics.tick(1 / 60, frame / 60); + } + + expect(physics.getBlobs()[0].chaosLevel).toBe(0); + }); +}); From 000ee8d198fcee402b4e8230d4d9c7cb6a0f7f81 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 15:17:35 -0400 Subject: [PATCH 27/57] revert(physics): restore pre-phase-a blob feel --- scripts/probe-motion-cdp.mjs | 6 +- src/core/BlobPhysics.ts | 292 ++++++++++----------------- src/svelte/BlobSVG.svelte | 159 +++++---------- tests/unit/blob-physics-feel.test.ts | 65 ------ 4 files changed, 159 insertions(+), 363 deletions(-) delete mode 100644 tests/unit/blob-physics-feel.test.ts diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index ea0dcf9..9d52739 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -384,11 +384,11 @@ try { listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} })`); - assert(listenerInitial.pathCount === 32, `Expected 32 SVG paths, got ${listenerInitial.pathCount}.`); + assert(listenerInitial.pathCount === 24, `Expected 24 SVG paths, got ${listenerInitial.pathCount}.`); assert(listenerInitial.bodyPathCount === 8, `Expected 8 body paths, got ${listenerInitial.bodyPathCount}.`); assert( - listenerInitial.gradientCount === 32, - `Expected 32 radial gradients, got ${listenerInitial.gradientCount}.`, + listenerInitial.gradientCount === 24, + `Expected 24 radial gradients, got ${listenerInitial.gradientCount}.`, ); assert( listenerInitial.listeners.wheel === 1, diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 06cf809..2dc40d9 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -73,12 +73,6 @@ export class BlobPhysics { private gaussianKernel: GaussianKernel; private springSystem: SpringSystem; - // Pre-allocated scratch buffers for hot-path passes (no per-frame allocation). - private skinTensionScratch: Float32Array | null = null; - private xsphDvX: Float32Array | null = null; - private xsphDvY: Float32Array | null = null; - private xsphWeight: Float32Array | null = null; - constructor(numBlobs: number, config: Partial = {}) { this.numBlobs = numBlobs; this.config = { ...DEFAULT_BLOB_PHYSICS_CONFIG, ...config }; @@ -160,76 +154,16 @@ export class BlobPhysics { this.updateScreensaverPhysics(blob, deltaTime, time) ); - // XSPH viscosity coupling — each blob's velocity drifts toward its - // neighborhood-weighted velocity. This is what makes the swarm - // behave as a fluid rather than 5 independent things; drag bleeds - // absolute motion, XSPH bleeds *relative* motion between neighbors. - // Macklin & Müller, Position Based Fluids, SIGGRAPH 2013. - this.applyXSPHCoupling(); - - + this.mouseVelX *= 0.96; this.mouseVelY *= 0.96; } - private applyXSPHCoupling(): void { - const blobs = this.blobs; - const n = blobs.length; - if (n < 2) return; - - if (!this.xsphDvX || !this.xsphDvY || !this.xsphWeight || this.xsphDvX.length < n) { - this.xsphDvX = new Float32Array(n); - this.xsphDvY = new Float32Array(n); - this.xsphWeight = new Float32Array(n); - } - const dvX = this.xsphDvX; - const dvY = this.xsphDvY; - const weights = this.xsphWeight; - dvX.fill(0); - dvY.fill(0); - weights.fill(0); - - const eps = 0.12; - const sigma = 80; - const twoSigmaSq = 2 * sigma * sigma; - - for (let i = 0; i < n; i++) { - const a = blobs[i]; - for (let j = i + 1; j < n; j++) { - const b = blobs[j]; - const dx = b.currentX - a.currentX; - const dy = b.currentY - a.currentY; - const w = Math.exp(-(dx * dx + dy * dy) / twoSigmaSq); - weights[i] += w; - weights[j] += w; - const dvx = w * (b.velocityX - a.velocityX); - const dvy = w * (b.velocityY - a.velocityY); - dvX[i] += dvx; - dvY[i] += dvy; - dvX[j] -= dvx; - dvY[j] -= dvy; - } - } - - for (let i = 0; i < n; i++) { - const normalizer = Math.max(1, weights[i]); - blobs[i].velocityX += eps * (dvX[i] / normalizer); - blobs[i].velocityY += eps * (dvY[i] / normalizer); - } - } - - // Anti-clustering with Gaussian-falloff repulsion. The previous step- - // function variant ((distance < requiredDistance) ? force : 0, plus - // a separate sharp proximity multiplier at requiredDistance * 0.7) - // produced a discontinuous force read as a "click" on near-contact. - // exp(-r² / 2σ²) is C∞ smooth — force grows continuously, peaks at - // zero distance, decays smoothly. Reuses the same Gaussian family as - // the existing GaussianKernel. private applyAntiClusteringWithSpatialHash(): void { - const maxPersonalSpace = 60; + const maxPersonalSpace = 60; for (const blob of this.blobs) { const neighbors = this.spatialHash.queryNeighbors(blob, maxPersonalSpace); @@ -238,32 +172,30 @@ export class BlobPhysics { const dx = other.currentX - blob.currentX; const dy = other.currentY - blob.currentY; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance <= 0) continue; - const requiredDistance = Math.max( - blob.personalSpace || 50, - other.personalSpace || 50 - ); - const sigma = requiredDistance * 0.5; - const w = Math.exp(-(distance * distance) / (2 * sigma * sigma)); - const repulsionForce = - w * 0.055 * (this.config.antiClusteringStrength / 0.15); + const requiredDistance = Math.max(blob.personalSpace || 50, other.personalSpace || 50); - const normalizedDx = dx / distance; - const normalizedDy = dy / distance; - const forceMultiplier = blob.repulsionStrength || 0.03; + if (distance < requiredDistance && distance > 0) { + const overlap = requiredDistance - distance; + const repulsionForce = (overlap / requiredDistance) * 0.055 * this.config.antiClusteringStrength / 0.15; - blob.velocityX -= normalizedDx * repulsionForce * forceMultiplier; - blob.velocityY -= normalizedDy * repulsionForce * forceMultiplier; + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; + + const forceMultiplier = blob.repulsionStrength || 0.03; + const proximityMultiplier = distance < requiredDistance * 0.7 ? 3.5 : 1.0; + + + blob.velocityX -= normalizedDx * repulsionForce * forceMultiplier * proximityMultiplier * 0.5; + blob.velocityY -= normalizedDy * repulsionForce * forceMultiplier * proximityMultiplier * 0.5; - if (distance < requiredDistance) { blob.lastRepulsionTime = Date.now(); } } } } - + updateMousePosition(x: number, y: number): void { @@ -461,9 +393,6 @@ export class BlobPhysics { } } - // Fallback when useSpatialHash is false. Same Gaussian-falloff - // repulsion as applyAntiClusteringWithSpatialHash, applied - // pairwise in O(N²). private applyEnhancedAntiClustering(): void { for (let i = 0; i < this.blobs.length; i++) { const blob1 = this.blobs[i]; @@ -474,29 +403,28 @@ export class BlobPhysics { const dx = blob2.currentX - blob1.currentX; const dy = blob2.currentY - blob1.currentY; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance <= 0) continue; - const requiredDistance = Math.max( - blob1.personalSpace || 50, - blob2.personalSpace || 50 - ); - const sigma = requiredDistance * 0.5; - const w = Math.exp(-(distance * distance) / (2 * sigma * sigma)); - const repulsionForce = - w * 0.055 * (this.config.antiClusteringStrength / 0.15); + const requiredDistance = Math.max(blob1.personalSpace || 50, blob2.personalSpace || 50); + + if (distance < requiredDistance && distance > 0) { + const overlap = requiredDistance - distance; + const repulsionForce = (overlap / requiredDistance) * 0.055 * this.config.antiClusteringStrength / 0.15; - const normalizedDx = dx / distance; - const normalizedDy = dy / distance; - const force1Multiplier = blob1.repulsionStrength || 0.03; - const force2Multiplier = blob2.repulsionStrength || 0.03; + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; - blob1.velocityX -= normalizedDx * repulsionForce * force1Multiplier; - blob1.velocityY -= normalizedDy * repulsionForce * force1Multiplier; + const force1Multiplier = blob1.repulsionStrength || 0.03; + const force2Multiplier = blob2.repulsionStrength || 0.03; - blob2.velocityX += normalizedDx * repulsionForce * force2Multiplier; - blob2.velocityY += normalizedDy * repulsionForce * force2Multiplier; + + const proximityMultiplier = distance < requiredDistance * 0.7 ? 3.5 : 1.0; + + blob1.velocityX -= normalizedDx * repulsionForce * force1Multiplier * proximityMultiplier; + blob1.velocityY -= normalizedDy * repulsionForce * force1Multiplier * proximityMultiplier; + + blob2.velocityX += normalizedDx * repulsionForce * force2Multiplier * proximityMultiplier; + blob2.velocityY += normalizedDy * repulsionForce * force2Multiplier * proximityMultiplier; - if (distance < requiredDistance) { blob1.lastRepulsionTime = Date.now(); blob2.lastRepulsionTime = Date.now(); } @@ -520,6 +448,9 @@ export class BlobPhysics { this.updateMovementWithAccelerometer(blob, time); + this.addEscapeVelocity(blob); + + this.updateSafeOrganicDeformation(blob, time); @@ -540,30 +471,34 @@ export class BlobPhysics { } private applyAccelerometerForces(blob: ConvexBlob): void { - const accelerometerStrength = 0.003; - const maxForce = 0.012; + const accelerometerStrength = 0.0008; + const maxForce = 0.003; const gravityX = Math.max(-maxForce, Math.min(maxForce, this.gravity.x * accelerometerStrength)); const gravityY = Math.max(-maxForce, Math.min(maxForce, this.gravity.y * accelerometerStrength)); blob.velocityX += gravityX; blob.velocityY += gravityY; + + + if (blob.controlPoints && (Math.abs(this.gravity.x) > 0.3 || Math.abs(this.gravity.y) > 0.3)) { + const deformationAmount = Math.min(0.08, (Math.abs(this.gravity.x) + Math.abs(this.gravity.y)) * 0.02); + blob.chaosLevel = Math.min((blob.chaosLevel || 0) + deformationAmount, 0.2); + } } private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void { - const gravityMagnitude = Math.min(1, Math.sqrt(this.gravity.x * this.gravity.x + this.gravity.y * this.gravity.y)); - const ambientScale = 1 - gravityMagnitude * 0.75; - const neutralDriftX = (Math.random() - 0.5) * 0.00045 * ambientScale; - const neutralDriftY = (Math.random() - 0.5) * 0.00045 * ambientScale; + const neutralDriftX = (Math.random() - 0.5) * 0.001; + const neutralDriftY = (Math.random() - 0.5) * 0.001; blob.velocityX += neutralDriftX; blob.velocityY += neutralDriftY; const brownianTime = time * 0.1 + blob.phase; - const brownianX = Math.sin(brownianTime + (blob.driftAngle || 0)) * 0.00025 * ambientScale; - const brownianY = Math.cos(brownianTime * 1.3 + (blob.driftAngle || 0)) * 0.00025 * ambientScale; + const brownianX = Math.sin(brownianTime + (blob.driftAngle || 0)) * 0.0005; + const brownianY = Math.cos(brownianTime * 1.3 + (blob.driftAngle || 0)) * 0.0005; blob.velocityX += brownianX; blob.velocityY += brownianY; @@ -593,10 +528,8 @@ export class BlobPhysics { } - const gravityMagnitude = Math.min(1, Math.sqrt(this.gravity.x * this.gravity.x + this.gravity.y * this.gravity.y)); - const ambientScale = 1 - gravityMagnitude * 0.75; - blob.velocityX += (Math.random() - 0.5) * 0.0012 * ambientScale; - blob.velocityY += (Math.random() - 0.5) * 0.0012 * ambientScale; + blob.velocityX += (Math.random() - 0.5) * 0.003; + blob.velocityY += (Math.random() - 0.5) * 0.003; if (time % 45 < 0.1) { @@ -612,6 +545,16 @@ export class BlobPhysics { } } + private addEscapeVelocity(blob: ConvexBlob): void { + if (blob.lastRepulsionTime && Date.now() - blob.lastRepulsionTime < 3000) { + const escapeStrength = 0.01; + const escapeAngle = Math.random() * Math.PI * 2; + + blob.velocityX += Math.cos(escapeAngle) * escapeStrength; + blob.velocityY += Math.sin(escapeAngle) * escapeStrength; + } + } + private updateSafeOrganicDeformation(blob: ConvexBlob, time: number): void { if (!blob.controlPoints || !blob.controlVelocities) return; @@ -713,33 +656,31 @@ export class BlobPhysics { }); } - // Laplacian skin-tension pass on the perimeter ring. - // r_i ← r_i + k · (0.5·(r_{i-1} + r_{i+1}) - r_i) is the discrete - // surface-tension force on a closed control-point ring (Young-Laplace - // pressure). Two-pass: read all targets first, then write — otherwise - // we'd be smoothing against half-already-smoothed neighbors. - // Plus a viscous radial-velocity bleed (Kelvin-Voigt dashpot half) so - // energy dissipates with each correction rather than ringing as it - // did with the previous spring-only model. private smoothControlPoints(blob: ConvexBlob): void { - const cp = blob.controlPoints; - if (!cp || cp.length < 3) return; - const n = cp.length; - if (!this.skinTensionScratch || this.skinTensionScratch.length < n) { - this.skinTensionScratch = new Float32Array(n); - } - const target = this.skinTensionScratch; - const k = 0.15; + if (!blob.controlPoints || blob.controlPoints.length < 3) return; - for (let i = 0; i < n; i++) { - const prev = cp[(i - 1 + n) % n].radius; - const next = cp[(i + 1) % n].radius; - target[i] = 0.5 * (prev + next); - } - for (let i = 0; i < n; i++) { - cp[i].radius += (target[i] - cp[i].radius) * k; - const v = blob.controlVelocities?.[i]; - if (v) v.radialVelocity *= 1 - 0.5 * k; + for (let i = 0; i < blob.controlPoints.length; i++) { + const current = blob.controlPoints[i]; + const prev = blob.controlPoints[(i - 1 + blob.controlPoints.length) % blob.controlPoints.length]; + const next = blob.controlPoints[(i + 1) % blob.controlPoints.length]; + + + const avgRadius = (prev.radius + current.radius + next.radius) / 3; + const smoothingFactor = 0.05; + current.radius = current.radius * (1 - smoothingFactor) + avgRadius * smoothingFactor; + + + const minRadiusDiff = blob.size * 0.1; + if (Math.abs(current.radius - prev.radius) > minRadiusDiff) { + const adjustment = (Math.abs(current.radius - prev.radius) - minRadiusDiff) * 0.5; + if (current.radius > prev.radius) { + current.radius -= adjustment; + prev.radius += adjustment; + } else { + current.radius += adjustment; + prev.radius -= adjustment; + } + } } } @@ -753,55 +694,36 @@ export class BlobPhysics { } } - // Soft-wall force: continuous penetration-based restoring force, no - // specular reflection or position snap. Edges deform along the wall - // (the blob "flattens") rather than bouncing — the gel cue. private handleWallBouncing(blob: ConvexBlob): void { const margin = blob.size * 0.8; - const yMargin = margin * 1.5; - const k = 0.08; + const damping = this.config.bounceDamping; const currentTime = Date.now(); - const minX = this.PHYSICS_MIN + margin; - const maxX = this.PHYSICS_MAX - margin; - const minY = this.PHYSICS_MIN + yMargin; - const maxY = this.PHYSICS_MAX - yMargin; - - const px = - Math.max(0, minX - blob.currentX) - Math.max(0, blob.currentX - maxX); - const py = - Math.max(0, minY - blob.currentY) - Math.max(0, blob.currentY - maxY); - - if (px !== 0) blob.velocityX += k * px; - if (py !== 0) blob.velocityY += k * py; - - // Hard outer clamp — far outside the soft band, snap back so the - // blob can never escape the canvas under extreme dt or large - // external forces. Records a bounce so existing time-since-bounce - // logic continues to work. - const hardMargin = blob.size * 0.2; - const hardMinX = this.PHYSICS_MIN + hardMargin; - const hardMaxX = this.PHYSICS_MAX - hardMargin; - const hardMinY = this.PHYSICS_MIN + hardMargin; - const hardMaxY = this.PHYSICS_MAX - hardMargin; - const hardDamping = this.config.bounceDamping; - - if (blob.currentX < hardMinX) { - blob.currentX = hardMinX; - blob.velocityX = Math.abs(blob.velocityX) * hardDamping; + + if (blob.currentX < this.PHYSICS_MIN + margin) { + blob.currentX = this.PHYSICS_MIN + margin; + blob.velocityX = Math.abs(blob.velocityX) * damping; this.recordBounce(blob, currentTime); - } else if (blob.currentX > hardMaxX) { - blob.currentX = hardMaxX; - blob.velocityX = -Math.abs(blob.velocityX) * hardDamping; + } + + + if (blob.currentX > this.PHYSICS_MAX - margin) { + blob.currentX = this.PHYSICS_MAX - margin; + blob.velocityX = -Math.abs(blob.velocityX) * damping; this.recordBounce(blob, currentTime); } - if (blob.currentY < hardMinY) { - blob.currentY = hardMinY; - blob.velocityY = Math.abs(blob.velocityY) * hardDamping; + + + if (blob.currentY < this.PHYSICS_MIN + margin * 1.5) { + blob.currentY = this.PHYSICS_MIN + margin * 1.5; + blob.velocityY = Math.abs(blob.velocityY) * damping; this.recordBounce(blob, currentTime); - } else if (blob.currentY > hardMaxY) { - blob.currentY = hardMaxY; - blob.velocityY = -Math.abs(blob.velocityY) * hardDamping; + } + + + if (blob.currentY > this.PHYSICS_MAX - margin * 1.5) { + blob.currentY = this.PHYSICS_MAX - margin * 1.5; + blob.velocityY = -Math.abs(blob.velocityY) * damping; this.recordBounce(blob, currentTime); } } @@ -811,6 +733,10 @@ export class BlobPhysics { blob.lastBounceTime = currentTime; + blob.velocityX += (Math.random() - 0.5) * 0.05; + blob.velocityY += (Math.random() - 0.5) * 0.05; + + blob.driftAngle = Math.random() * Math.PI * 2; diff --git a/src/svelte/BlobSVG.svelte b/src/svelte/BlobSVG.svelte index 36a3f6f..50d3dbd 100644 --- a/src/svelte/BlobSVG.svelte +++ b/src/svelte/BlobSVG.svelte @@ -3,16 +3,19 @@ import type { BlobPhysics } from '../core/BlobPhysics.js'; import type { ConvexBlob } from '../core/types.js'; + // Props using Svelte 5 $props() syntax interface Props { blobs?: ConvexBlob[]; physics?: BlobPhysics | null; } - const svgId = $props.id(); let { blobs = [], physics = null }: Props = $props(); + // Track dark mode for blend mode switching let isDarkMode = $state(false); + let primaryBlend = $derived(isDarkMode ? 'screen' : 'multiply'); + // Watch for dark mode changes $effect(() => { if (browser) { isDarkMode = document.documentElement.classList.contains('dark'); @@ -31,166 +34,98 @@ } }); + // Generate simple circle path (fast) - used for glow/core layers function getCirclePath(cx: number, cy: number, r: number): string { return `M ${cx - r},${cy} A ${r},${r} 0 1,1 ${cx + r},${cy} A ${r},${r} 0 1,1 ${cx - r},${cy}`; } + // Generate organic path for main blob body only function getBlobPath(blob: ConvexBlob): string { if (physics && blob.controlPoints && blob.controlPoints.length > 0) { return physics.generateSmoothBlobPath(blob); } return getCirclePath(blob.currentX, blob.currentY, blob.size); } - - function getDefinitionId(name: string): string { - return `${svgId}-${name}`; - } - - function getBlobDefinitionId(blob: ConvexBlob, name: string): string { - return `${svgId}-${blob.gradientId}-${name}`; - } + - - + + + - - + + + - {#each blobs as blob (blob.gradientId)} - - - - + {#each blobs as blob, i (blob.gradientId)} + + + + + - - - - - + + + + + - - - - - - - - - - + + + + + {/each} - + + {#each blobs as blob (blob.gradientId)} {/each} - - {#each blobs as blob (blob.gradientId)} - - {/each} - - - + + {#each blobs as blob (blob.gradientId)} {/each} - + + {#each blobs as blob (blob.gradientId)} {/each} diff --git a/tests/unit/blob-physics-feel.test.ts b/tests/unit/blob-physics-feel.test.ts deleted file mode 100644 index b6c257c..0000000 --- a/tests/unit/blob-physics-feel.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { BlobPhysics } from '../../src/core/BlobPhysics.js'; - -const useDeterministicRandom = () => { - let index = 0; - const values = [0.13, 0.87, 0.31, 0.69, 0.47, 0.53, 0.22, 0.78]; - vi.spyOn(Math, 'random').mockImplementation(() => { - const value = values[index % values.length]; - index += 1; - return value; - }); -}; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('BlobPhysics feel', () => { - it('lets device gravity dominate ambient drift', async () => { - useDeterministicRandom(); - - const physics = new BlobPhysics(8); - await physics.init(); - const before = physics.getBlobs().map((blob) => ({ - x: blob.currentX, - y: blob.currentY, - })); - - physics.setGravity({ x: 0, y: 1 }); - - for (let frame = 0; frame < 180; frame++) { - physics.tick(1 / 60, frame / 60); - } - - const after = physics.getBlobs(); - const deltas = after.map((blob, index) => ({ - x: blob.currentX - before[index].x, - y: blob.currentY - before[index].y, - })); - const downwardBlobs = deltas.filter((delta) => delta.y > 0).length; - const averageY = - deltas.reduce((total, delta) => total + delta.y, 0) / deltas.length; - const averageAbsX = - deltas.reduce((total, delta) => total + Math.abs(delta.x), 0) / deltas.length; - - expect(downwardBlobs).toBeGreaterThanOrEqual(6); - expect(averageY).toBeGreaterThan(4); - expect(averageY).toBeGreaterThan(averageAbsX); - }); - - it('does not turn steady device gravity into random deformation chaos', async () => { - useDeterministicRandom(); - - const physics = new BlobPhysics(1); - await physics.init(); - physics.setGravity({ x: 0, y: 1 }); - - for (let frame = 0; frame < 60; frame++) { - physics.tick(1 / 60, frame / 60); - } - - expect(physics.getBlobs()[0].chaosLevel).toBe(0); - }); -}); From a1011ebf9e9d92b2a4657889564430462adc1de6 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 15:50:30 -0400 Subject: [PATCH 28/57] docs(physics): define field-based feel contract --- README.md | 1 + docs/physics-feel-contract.md | 52 +++++++++++++++++ src/core/InteractionField.ts | 83 ++++++++++++++++++++++++++++ tests/unit/interaction-field.test.ts | 77 ++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 docs/physics-feel-contract.md create mode 100644 src/core/InteractionField.ts create mode 100644 tests/unit/interaction-field.test.ts diff --git a/README.md b/README.md index f8a67d2..698c685 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Useful extra commands: - `pnpm check:package-consumer` validates the Bazel-built package from `./bazel-bin/pkg` in a temporary consumer workspace The Bazel-to-npm release flow is documented in [docs/release-flow.md](./docs/release-flow.md). +The physics interaction direction is documented in [docs/physics-feel-contract.md](./docs/physics-feel-contract.md). The dev app includes a browser/device harness for interaction work: diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md new file mode 100644 index 0000000..45db74a --- /dev/null +++ b/docs/physics-feel-contract.md @@ -0,0 +1,52 @@ +# TinyVectors Physics Feel Contract + +TinyVectors is an expressive background system for Svelte and SvelteKit apps, not a physics demo. The animation should feel alive before any user input happens. Device motion, pointer movement, and scrolling should bias that ambient motion instead of taking control of it. + +This document is the local source of truth for the field-based interaction work tracked in Linear `TIN-853` and GitHub #40. + +## Product Intent + +- Pleasant by default: idle blobs drift, breathe, and deform subtly. +- App-safe: the component stays SSR-safe, reduced-motion aware, listener-clean, and small enough for background use. +- Stylable: gel/fluid is a visual language exposed through themes, colors, opacity, and restrained renderer controls. +- Performant: interaction work must preserve the package's bundle budget and avoid heavyweight simulation dependencies. + +## Interaction Model + +Every input should become a small field sampled by the blob physics loop: + +- Ambient field: always on, low-frequency, bounded motion. This is the baseline feel. +- Gravity field: slow directional bias from device orientation. It should make blobs lean or pool, not fall like marbles. +- Pointer field: local soft influence around the pointer. Nearby blobs should react more than distant blobs. +- Scroll field: transient impulse or stickiness that decays. It should not create permanent acceleration. +- Wall field: bounds should keep the background composed without hard visual snaps. + +Fields may combine, but input fields must not erase the ambient field. If a field makes the background look frozen, jittery, or overly coherent, it violates the contract. + +## Non-Goals + +- Do not revive the Phase A XSPH, soft-wall, and Gaussian anti-clustering rewrite as-is. +- Do not ship coefficient-only tuning without a contract and browser/demo validation. +- Do not introduce a heavyweight fluid solver. +- Do not make the background capture pointer events. + +## Test Strategy + +Tests should describe perceptual behavior in tolerant terms: + +- idle drift is present and bounded; +- gravity creates directional bias without overpowering all motion; +- pointer influence is local and distance-weighted; +- scroll effects decay; +- listener lifecycle stays clean; +- bundle size stays within the configured gate. + +Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off screenshot pixels unless the assertion is about a real compatibility contract. + +## Implementation Slices + +1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work. +2. Add pure field helpers and unit tests without changing runtime feel. +3. Route existing gravity, pointer, and scroll values through field helpers one input at a time. +4. Add browser probes for directional bias, pointer locality, and scroll decay. +5. Revisit renderer stylability after interaction feel is stable. diff --git a/src/core/InteractionField.ts b/src/core/InteractionField.ts new file mode 100644 index 0000000..e5ef781 --- /dev/null +++ b/src/core/InteractionField.ts @@ -0,0 +1,83 @@ +export interface FieldVector { + x: number; + y: number; +} + +export interface PointFieldOptions { + origin: FieldVector; + target: FieldVector; + radius: number; + strength: number; +} + +const magnitude = (vector: FieldVector): number => + Math.sqrt(vector.x * vector.x + vector.y * vector.y); + +export function clampFieldVector(vector: FieldVector, maxMagnitude = 1): FieldVector { + const max = Math.max(0, maxMagnitude); + const currentMagnitude = magnitude(vector); + if (max === 0 || currentMagnitude === 0) return { x: 0, y: 0 }; + if (currentMagnitude <= max) return vector; + + const scale = max / currentMagnitude; + return { + x: vector.x * scale, + y: vector.y * scale, + }; +} + +export function combineFieldVectors( + fields: FieldVector[], + maxMagnitude = 1, +): FieldVector { + const total = fields.reduce( + (accumulator, field) => ({ + x: accumulator.x + field.x, + y: accumulator.y + field.y, + }), + { x: 0, y: 0 }, + ); + + return clampFieldVector(total, maxMagnitude); +} + +export function directionalBiasField( + input: FieldVector, + strength: number, + maxMagnitude = 1, +): FieldVector { + return clampFieldVector( + { + x: input.x * strength, + y: input.y * strength, + }, + maxMagnitude, + ); +} + +export function smoothDistanceFalloff(distance: number, radius: number): number { + const boundedDistance = Math.max(0, distance); + if (radius <= 0 || boundedDistance >= radius) return 0; + + const normalized = 1 - boundedDistance / radius; + return normalized * normalized; +} + +export function pointAttractorField({ + origin, + target, + radius, + strength, +}: PointFieldOptions): FieldVector { + const dx = target.x - origin.x; + const dy = target.y - origin.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance === 0) return { x: 0, y: 0 }; + + const falloff = smoothDistanceFalloff(distance, radius); + const scale = (falloff * strength) / distance; + return { + x: dx * scale, + y: dy * scale, + }; +} diff --git a/tests/unit/interaction-field.test.ts b/tests/unit/interaction-field.test.ts new file mode 100644 index 0000000..4aa7d7f --- /dev/null +++ b/tests/unit/interaction-field.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + clampFieldVector, + combineFieldVectors, + directionalBiasField, + pointAttractorField, + smoothDistanceFalloff, +} from '../../src/core/InteractionField.js'; + +const vectorMagnitude = ({ x, y }: { x: number; y: number }) => + Math.sqrt(x * x + y * y); + +describe('InteractionField', () => { + it('clamps vectors without changing direction', () => { + const vector = clampFieldVector({ x: 3, y: 4 }, 2); + + expect(vectorMagnitude(vector)).toBeCloseTo(2); + expect(vector.x / vector.y).toBeCloseTo(3 / 4); + }); + + it('turns zero-sized clamp bounds into a zero vector', () => { + expect(clampFieldVector({ x: 1, y: 1 }, 0)).toEqual({ x: 0, y: 0 }); + expect(clampFieldVector({ x: 1, y: 1 }, -1)).toEqual({ x: 0, y: 0 }); + }); + + it('combines fields under a maximum magnitude', () => { + const vector = combineFieldVectors( + [ + { x: 0.8, y: 0 }, + { x: 0.8, y: 0 }, + ], + 1, + ); + + expect(vector).toEqual({ x: 1, y: 0 }); + }); + + it('converts gravity-like input into a bounded directional bias', () => { + const vector = directionalBiasField({ x: 0.25, y: 1 }, 0.8, 0.5); + + expect(vector.y).toBeGreaterThan(0); + expect(vectorMagnitude(vector)).toBeLessThanOrEqual(0.5); + }); + + it('uses smooth local falloff for point fields', () => { + const atCenter = smoothDistanceFalloff(-5, 50); + const near = smoothDistanceFalloff(10, 50); + const far = smoothDistanceFalloff(40, 50); + const outside = smoothDistanceFalloff(60, 50); + + expect(atCenter).toBe(1); + expect(near).toBeGreaterThan(far); + expect(far).toBeGreaterThan(0); + expect(outside).toBe(0); + }); + + it('samples a soft pointer-style attraction toward the target', () => { + const near = pointAttractorField({ + origin: { x: 40, y: 50 }, + target: { x: 50, y: 50 }, + radius: 30, + strength: 0.2, + }); + const far = pointAttractorField({ + origin: { x: 20, y: 50 }, + target: { x: 50, y: 50 }, + radius: 30, + strength: 0.2, + }); + + expect(near.x).toBeGreaterThan(0); + expect(Math.abs(near.y)).toBe(0); + expect(vectorMagnitude(near)).toBeGreaterThan(vectorMagnitude(far)); + expect(far).toEqual({ x: 0, y: 0 }); + }); +}); From 5ebf1dde1408fc7e165eba42ccc9375294afe2cf Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 16:04:10 -0400 Subject: [PATCH 29/57] docs(release): align renderer truth after restore --- docs/release-flow.md | 6 ++---- src/themes/vector-colors.css | 13 +++++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/release-flow.md b/docs/release-flow.md index ea5c0ef..137181c 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -31,15 +31,13 @@ npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg `//:package_consumer_check` and `pnpm run check:package-consumer` both validate the Bazel-built package as an installed consumer would. The pnpm command expects `./bazel-bin/pkg` to exist. It links that package into a temporary consumer workspace with the Svelte peer dependency, verifies runtime subpath exports, and runs TypeScript against the packaged declarations. -`pnpm run check:bundle-size` measures a realistic tree-shaken consumer import, `import { TinyVectors } from '@tummycrypt/tinyvectors'`, with Svelte externalized as a peer dependency. `//:bundle_size_check` runs the same measurement against the Bazel-built package artifact. The current Phase A gate is 12 KiB gzip and the target remains 11 KiB gzip, so the check reports target headroom or overage while leaving a small CI buffer. +`pnpm run check:bundle-size` measures a realistic tree-shaken consumer import, `import { TinyVectors } from '@tummycrypt/tinyvectors'`, with Svelte externalized as a peer dependency. `//:bundle_size_check` runs the same measurement against the Bazel-built package artifact. The current gate is 12 KiB gzip and the target remains 11 KiB gzip, so the check reports target headroom or overage while leaving a small CI buffer. `bazel query //...` should also work locally. `.bazelignore` excludes direnv, Nix, package-manager, and build-output directories so Bazel does not walk generated local artifacts. ## Compatibility Notes -Carry these notes into the v0.3 release notes: - -- Blob gradient stop opacity now uses the renderer-private `--tvi` custom property. Consumers overriding the previous `--tv-blob-intensity` property must migrate that override. +The v0.3 branch currently keeps the renderer-private `--tv-blob-intensity` custom property used by the restored three-layer renderer. Do not document a migration to `--tvi`; that abbreviation was part of the reverted gel-rendering rewrite. ## CI Flow diff --git a/src/themes/vector-colors.css b/src/themes/vector-colors.css index a8a4971..1e48a02 100644 --- a/src/themes/vector-colors.css +++ b/src/themes/vector-colors.css @@ -3,15 +3,12 @@ */ /* Per-blob intensity, registered so calc() in SVG stop-opacity is - * interpolated as a number rather than a string. Set inline on each - * from blob.intensity in BlobSVG.svelte; gradient - * stops compute their actual opacity via calc(var(--tvi) - * * ). Avoids ~60 reactive expressions per frame in - * Svelte (one per stop-opacity arithmetic) — the inline style still - * re-runs each frame, but only ~5 evaluations per frame total - * (one per blob, not per stop). + * interpolated as a number rather than a string. BlobSVG.svelte sets + * --tv-blob-intensity inline on each ; gradient stops + * compute their actual opacity via calc(var(--tv-blob-intensity) + * * ). */ -@property --tvi { +@property --tv-blob-intensity { syntax: ''; inherits: true; initial-value: 1; From 300fc58e425be7c51030e092dcfa732d778736d3 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 16:06:25 -0400 Subject: [PATCH 30/57] refactor(package): make root exports explicit --- src/index.ts | 107 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0578c05..0e631dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,30 +1,99 @@ +// Curated public package root. Keep this explicit so the npm surface stays +// reviewable and tree-shake-friendly. - - - - - -export * from './core/index.js'; - - -export * from './motion/index.js'; - - -export * from './themes/index.js'; - - -export * from './svelte/index.js'; - +export { + BlobPhysics, + generateSmoothBlobPath, + generateSmoothBlobPathSync, + preInitPathGenerator, + SpringSystem, + DEFAULT_SPRING_CONFIG, + computePolygonArea, + computeCircularity, + enforceAreaConservation, + createControlPointVelocities, + GaussianKernel, + SpatialHash, + browser, + isBrowser, + TRANS_THEME, + PRIDE_THEME, + TINYLAND_THEME, + HIGH_CONTRAST_THEME, + THEME_PRESETS, + THEME_PRESET_COLORS, + DEFAULT_CONFIG, + mergeConfig, +} from './core/index.js'; export type { + BlobPhysicsConfig, + SpringConfig, + GelControlPoint, + ControlPoint, + ControlPointVelocity, + ConvexBlob, + ColorDefinition, + DeviceMotionData, + GravityVector, + TiltVector, TinyVectorsConfig, CoreConfig, PhysicsConfig, RenderingConfig, ThemeConfig, FeatureFlags, - RenderBlob, + TinyVectorsConfigOverride, + DeepPartial, + ThemePresetName, + BlendMode, ThemeColor, ThemePreset, - ThemePresetName, -} from './core/schema.js'; + BlobCore, + RenderBlob, + PhysicsBlob, + ScrollData, + PointerData, + TinyVectorsEventType, + TinyVectorsEventHandler, + TinyVectorsEvent, + FrameEventData, + ThemeChangeEventData, +} from './core/index.js'; + +export { + DeviceMotion, + mapClientPointToPhysics, + createPointerPhysicsController, + getLatestPointerEvent, + ScrollHandler, +} from './motion/index.js'; + +export type { + DeviceMotionCallback, + DeviceMotionOptions, + DeviceMotionPermissionState, + MotionVector, + PhysicsPoint, + PhysicsRange, + PointerBounds, + PointerLikeEvent, + PointerMoveEventName, + PointerPhysicsController, + PointerPhysicsControllerOptions, + PointerPhysicsEventTarget, + ScrollHandlerConfig, + PullForce, +} from './motion/index.js'; + +export { + getThemePreset, + generateThemeCSS, + isDarkMode, + watchDarkMode, +} from './themes/index.js'; + +export { + TinyVectors, + BlobSVG, +} from './svelte/index.js'; From becf93dc36377cee66723033986f41abf0d4c6d9 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 16:10:49 -0400 Subject: [PATCH 31/57] test(bundle): guard inert field helper from consumer bundle --- docs/release-flow.md | 2 ++ scripts/check-bundle-size.mjs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/release-flow.md b/docs/release-flow.md index 137181c..1437e5a 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -33,6 +33,8 @@ npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg `pnpm run check:bundle-size` measures a realistic tree-shaken consumer import, `import { TinyVectors } from '@tummycrypt/tinyvectors'`, with Svelte externalized as a peer dependency. `//:bundle_size_check` runs the same measurement against the Bazel-built package artifact. The current gate is 12 KiB gzip and the target remains 11 KiB gzip, so the check reports target headroom or overage while leaving a small CI buffer. +The bundle-size check also asserts that internal future-work modules stay out of that consumer bundle. For example, `dist/core/InteractionField.js` is allowed to ship as an internal preserved module, but it must not be pulled into the `{ TinyVectors }` bundle until runtime physics actually imports it. + `bazel query //...` should also work locally. `.bazelignore` excludes direnv, Nix, package-manager, and build-output directories so Bazel does not walk generated local artifacts. ## Compatibility Notes diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index f640296..f4f1369 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -11,6 +11,7 @@ const packageRoot = resolve(process.cwd(), process.argv[2] ?? '.'); const distEntry = resolve(packageRoot, 'dist/index.js'); const targetGzipKiB = parsePositiveKiB('TINYVECTORS_TARGET_GZIP_KIB', 11); const maxGzipKiB = parsePositiveKiB('TINYVECTORS_MAX_GZIP_KIB', 12); +const forbiddenConsumerModules = ['dist/core/InteractionField.js']; if (maxGzipKiB < targetGzipKiB) { console.error( @@ -58,6 +59,26 @@ console.log(TinyVectors); const outputs = Array.isArray(output) ? output.flatMap((bundle) => bundle.output) : output.output; + const chunks = outputs.filter((item) => item.type === 'chunk'); + const includedModules = new Set( + chunks.flatMap((chunk) => + chunk.moduleIds.map((moduleId) => moduleId.replaceAll('\\', '/')), + ), + ); + const forbiddenIncluded = forbiddenConsumerModules.filter((modulePath) => + [...includedModules].some((moduleId) => moduleId.endsWith(`/${modulePath}`)), + ); + + if (forbiddenIncluded.length > 0) { + console.error( + [ + 'Consumer bundle included internal future-work modules:', + ...forbiddenIncluded.map((modulePath) => `- ${modulePath}`), + ].join('\n'), + ); + process.exit(1); + } + const js = outputs .filter((item) => item.type === 'chunk') .map((item) => item.code) From 93d20a70585d4da414f9dc32ac51dee4bb370972 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 16:28:52 -0400 Subject: [PATCH 32/57] fix(motion): neutralize stale device IO --- README.md | 8 +++ dev/App.svelte | 3 + dev/main.ts | 1 + docs/physics-feel-contract.md | 1 + scripts/probe-motion-cdp.mjs | 18 +++++- src/index.ts | 2 + src/motion/DeviceMotion.ts | 47 +++++++++++++- src/motion/PointerPhysicsController.ts | 18 ++++++ src/motion/index.ts | 2 + src/svelte/TinyVectors.svelte | 24 ++++--- src/svelte/TinyVectors.svelte.d.ts | 2 + tests/unit/device-motion.test.ts | 64 +++++++++++++++++-- tests/unit/pointer-physics-controller.test.ts | 25 ++++++++ 13 files changed, 196 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 698c685..2fcac3c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Device motion must be requested from a user gesture on browsers that gate sensor ``` +TinyVectors auto-starts device-orientation motion on secure browsers that do not require a +permission prompt. On permission-gated browsers, keep `enableDeviceMotion={true}` and call +`requestDeviceMotionPermission()` from a user gesture. If sensor events pause or the document is +hidden, TinyVectors resets device motion to neutral so stale tilt cannot keep steering the blobs. +Tune that watchdog with `deviceMotionIdleResetMs` when a host app needs faster or slower sensor +liveness handling. Pointer physics is enabled by default only when pointer, touch, or mouse input is +detected. + ## Entry Points The package exports these public entry points: diff --git a/dev/App.svelte b/dev/App.svelte index 23e96f1..4999f67 100644 --- a/dev/App.svelte +++ b/dev/App.svelte @@ -15,6 +15,7 @@ enableDeviceMotion?: boolean; enableScrollPhysics?: boolean; enablePointerPhysics?: boolean; + deviceMotionIdleResetMs?: number; onMotionSample?: (sample: MotionVector) => void; } @@ -25,6 +26,7 @@ enableDeviceMotion = true, enableScrollPhysics = true, enablePointerPhysics = true, + deviceMotionIdleResetMs = 2000, onMotionSample, }: Props = $props(); @@ -51,6 +53,7 @@ {enableScrollPhysics} {enablePointerPhysics} deviceMotionCalibrationSamples={0} + {deviceMotionIdleResetMs} onDeviceMotion={onMotionSample} /> diff --git a/dev/main.ts b/dev/main.ts index 03c66d2..4d46751 100644 --- a/dev/main.ts +++ b/dev/main.ts @@ -82,6 +82,7 @@ let currentProps = { enableDeviceMotion: booleanParam('deviceMotion', true), enableScrollPhysics: booleanParam('scrollPhysics', true), enablePointerPhysics: booleanParam('pointerPhysics', true), + deviceMotionIdleResetMs: numberParam('motionIdleReset', 2000, 0, 10000), onMotionSample(sample: MotionVector) { updateMotionStatus(formatMotionSample(sample)); }, diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 45db74a..6bfb6f3 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -20,6 +20,7 @@ Every input should become a small field sampled by the blob physics loop: - Pointer field: local soft influence around the pointer. Nearby blobs should react more than distant blobs. - Scroll field: transient impulse or stickiness that decays. It should not create permanent acceleration. - Wall field: bounds should keep the background composed without hard visual snaps. +- Input liveness: real sensor and pointer IO should auto-enable only when available. If device-orientation events go quiet or the tab is hidden, the field must return to neutral instead of preserving stale gravity. Fields may combine, but input fields must not erase the ambient field. If a field makes the background look frozen, jittery, or overly coherent, it violates the contract. diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index 9d52739..c269b28 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -313,7 +313,7 @@ try { `, }); - const pageUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=false&scrollPhysics=false&blobs=8`; + const pageUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=false&scrollPhysics=false&blobs=8&motionIdleReset=700`; await client.send('Page.navigate', { url: pageUrl }); await delay(1500); @@ -336,7 +336,7 @@ try { expression: `document.getElementById('spoof-tilt-btn')?.click()`, awaitPromise: true, }); - await delay(1000); + await delay(350); const afterSpoof = await evaluate(client, `({ status: document.getElementById('motion-status')?.textContent ?? null, @@ -351,12 +351,23 @@ try { assert(afterSpoof.events.length > initial.events.length, 'Synthetic orientation was not observed.'); assert(afterSpoof.firstPath !== initial.firstPath, 'Synthetic orientation did not change blob geometry.'); + await delay(550); + const afterIdleReset = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + events: window.__tinyvectorsEvents + })`); + + assert( + afterIdleReset.status === 'motion x 0.00 y 0.00 z 0.00', + `Device orientation idle reset did not neutralize motion; status was ${afterIdleReset.status}`, + ); + await client.send('DeviceOrientation.setDeviceOrientationOverride', { alpha: 180, beta: 50, gamma: -40, }); - await delay(1000); + await delay(350); const afterCdpOrientation = await evaluate(client, `({ status: document.getElementById('motion-status')?.textContent ?? null, @@ -488,6 +499,7 @@ try { status: afterSpoof.status, events: afterSpoof.events.length, pathChanged: afterSpoof.firstPath !== initial.firstPath, + idleResetStatus: afterIdleReset.status, }, cdpOrientation: { status: afterCdpOrientation.status, diff --git a/src/index.ts b/src/index.ts index 0e631dc..3fd5312 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,7 @@ export { DeviceMotion, mapClientPointToPhysics, createPointerPhysicsController, + detectPointerPhysicsCapability, getLatestPointerEvent, ScrollHandler, } from './motion/index.js'; @@ -77,6 +78,7 @@ export type { PhysicsPoint, PhysicsRange, PointerBounds, + PointerCapabilityEnvironment, PointerLikeEvent, PointerMoveEventName, PointerPhysicsController, diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index b7996b8..ae5a19f 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -31,6 +31,8 @@ export interface DeviceMotionOptions { faceDownThreshold?: number; /** Reset filters if event gap exceeds this. Default 2000 ms. */ staleEventMs?: number; + /** Emit neutral output if events stop for this long. Default 2000 ms. */ + idleResetMs?: number; /** Degrees mapped to +/-1. Default 45, matching casual tilt range. */ range?: number; /** Manual calibration sample count used by calibrate(). Default 8. */ @@ -53,6 +55,7 @@ const DEFAULTS = { warmupMs: 250, faceDownThreshold: 120, staleEventMs: 2000, + idleResetMs: 2000, range: 45, calibrationSamples: 8, deadZone: 0.015, @@ -116,6 +119,7 @@ export class DeviceMotion { private reducedMotionMql: MediaQueryList | null = null; private reducedMotionListener: (() => void) | null = null; private blockedByReducedMotion = false; + private idleResetTimer: ReturnType | null = null; constructor(callback: DeviceMotionCallback, options: DeviceMotionOptions = {}) { this.callback = callback; @@ -289,17 +293,22 @@ export class DeviceMotion { window.addEventListener('deviceorientation', this.boundOrientation, { passive: true }); this.boundVisibility = () => { - if (document.hidden) this.resetFilterState(); + if (document.hidden) { + this.resetFilterState(); + this.emitNeutral(); + } }; document.addEventListener('visibilitychange', this.boundVisibility); this.listenerStartedAt = this.now(); this.lastEventAt = 0; this.isListening = true; + this.armIdleReset(); } private stopListening(): void { if (!this.isListening) return; + this.clearIdleReset(); if (this.boundOrientation) { window.removeEventListener('deviceorientation', this.boundOrientation); @@ -324,12 +333,15 @@ export class DeviceMotion { this.resetFilterState(); this.listenerStartedAt = now; this.lastEventAt = now; + this.emitNeutral(); + this.armIdleReset(); return; } this.lastEventAt = now; if (Math.abs(event.beta) > this.opts.faceDownThreshold) { - this.callback({ x: 0, y: 0, z: 0 }); + this.emitNeutral(); + this.armIdleReset(); return; } @@ -340,7 +352,10 @@ export class DeviceMotion { ); this.lastScreen = { x: screenX, y: screenY }; - if (!this.consumeCalibrationSample(screenX, screenY)) return; + if (!this.consumeCalibrationSample(screenX, screenY)) { + this.armIdleReset(); + return; + } const alpha = this.opts.baselineAlpha; this.baseX += alpha * (screenX - this.baseX); @@ -356,6 +371,7 @@ export class DeviceMotion { y: applyDeadZone(clamp(yFiltered), this.opts.deadZone), z: 0, }); + this.armIdleReset(); } private consumeCalibrationSample(screenX: number, screenY: number): boolean { @@ -385,6 +401,31 @@ export class DeviceMotion { this.lastEventAt = 0; } + private emitNeutral(): void { + this.callback({ x: 0, y: 0, z: 0 }); + } + + private armIdleReset(): void { + this.clearIdleReset(); + if (!this.isListening || this.opts.idleResetMs <= 0) return; + + this.idleResetTimer = setTimeout(() => { + this.idleResetTimer = null; + if (this.disposed || !this.isListening) return; + + this.resetFilterState({ resetWarmup: false }); + this.emitNeutral(); + }, this.opts.idleResetMs); + + (this.idleResetTimer as { unref?: () => void }).unref?.(); + } + + private clearIdleReset(): void { + if (this.idleResetTimer === null) return; + clearTimeout(this.idleResetTimer); + this.idleResetTimer = null; + } + private now(): number { return typeof performance !== 'undefined' ? performance.now() : Date.now(); } diff --git a/src/motion/PointerPhysicsController.ts b/src/motion/PointerPhysicsController.ts index 5c0e85e..6b68f77 100644 --- a/src/motion/PointerPhysicsController.ts +++ b/src/motion/PointerPhysicsController.ts @@ -32,6 +32,15 @@ export interface PointerPhysicsControllerOptions { cancelFrame?: (handle: number) => void; } +export interface PointerCapabilityEnvironment { + PointerEvent?: unknown; + MouseEvent?: unknown; + navigator?: { + maxTouchPoints?: number; + }; + matchMedia?: (query: string) => { matches: boolean }; +} + export interface PointerPhysicsController { readonly eventName: PointerMoveEventName; flush(): void; @@ -44,6 +53,15 @@ export function getLatestPointerEvent(event: PointerLikeEvent): PointerLikeEvent return coalesced.length > 0 ? coalesced[coalesced.length - 1] : event; } +export function detectPointerPhysicsCapability( + environment: PointerCapabilityEnvironment = globalThis, +): boolean { + if (typeof environment.PointerEvent !== 'undefined') return true; + if ((environment.navigator?.maxTouchPoints ?? 0) > 0) return true; + if (environment.matchMedia?.('(pointer: fine), (pointer: coarse)').matches) return true; + return typeof environment.MouseEvent !== 'undefined'; +} + export function createPointerPhysicsController( options: PointerPhysicsControllerOptions, ): PointerPhysicsController { diff --git a/src/motion/index.ts b/src/motion/index.ts index 2800bcf..633cf77 100644 --- a/src/motion/index.ts +++ b/src/motion/index.ts @@ -17,7 +17,9 @@ export { } from './PointerMapper.js'; export { createPointerPhysicsController, + detectPointerPhysicsCapability, getLatestPointerEvent, + type PointerCapabilityEnvironment, type PointerLikeEvent, type PointerMoveEventName, type PointerPhysicsController, diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index d06b816..8b1bd6d 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -8,6 +8,7 @@ } from '../motion/DeviceMotion.js'; import { createPointerPhysicsController, + detectPointerPhysicsCapability, type PointerPhysicsController, } from '../motion/PointerPhysicsController.js'; import type { PointerBounds } from '../motion/PointerMapper.js'; @@ -41,6 +42,8 @@ deviceMotionStrength?: number; /** Samples used by calibrateDeviceMotion() when no explicit count is supplied. */ deviceMotionCalibrationSamples?: number; + /** Milliseconds before paused device-orientation IO resets to neutral. */ + deviceMotionIdleResetMs?: number; /** Optional diagnostics hook for browser/dev harnesses. */ onDeviceMotion?: (motionData: MotionVector) => void; } @@ -58,6 +61,7 @@ enablePointerPhysics = true, deviceMotionStrength = 0.8, deviceMotionCalibrationSamples = 8, + deviceMotionIdleResetMs = 2000, onDeviceMotion, }: Props = $props(); @@ -86,6 +90,7 @@ new DeviceMotion(handleDeviceMotion, { calibrationSamples: deviceMotionCalibrationSamples, deadZone: 0.015, + idleResetMs: deviceMotionIdleResetMs, }); const handleDeviceMotion = (motionData: MotionVector) => { @@ -210,14 +215,17 @@ } if (pointerPhysicsEnabled) { - pointerController = createPointerPhysicsController({ - target: window, - getBounds: getPointerBounds, - supportsPointerEvents: 'PointerEvent' in window, - updatePosition(position) { - physics?.updateMousePosition(position.x, position.y); - }, - }); + const hasPointerCapability = detectPointerPhysicsCapability(window); + if (hasPointerCapability) { + pointerController = createPointerPhysicsController({ + target: window, + getBounds: getPointerBounds, + supportsPointerEvents: 'PointerEvent' in window, + updatePosition(position) { + physics?.updateMousePosition(position.x, position.y); + }, + }); + } } }); diff --git a/src/svelte/TinyVectors.svelte.d.ts b/src/svelte/TinyVectors.svelte.d.ts index 27aa109..210a4f1 100644 --- a/src/svelte/TinyVectors.svelte.d.ts +++ b/src/svelte/TinyVectors.svelte.d.ts @@ -28,6 +28,8 @@ export interface TinyVectorsProps { deviceMotionStrength?: number; /** Samples used by calibrateDeviceMotion() when no explicit count is supplied. */ deviceMotionCalibrationSamples?: number; + /** Milliseconds before paused device-orientation IO resets to neutral. */ + deviceMotionIdleResetMs?: number; /** Optional diagnostics hook for browser/dev harnesses. */ onDeviceMotion?: (motionData: MotionVector) => void; } diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index 2d1b026..99a507f 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -54,6 +54,11 @@ function createMotionEnvironment(options: { removeEventListener: removeWindowListener, matchMedia: vi.fn(() => mql as unknown as MediaQueryList), }; + const motionDocument = { + hidden: false, + addEventListener: addDocumentListener, + removeEventListener: removeDocumentListener, + }; if (options.orientation ?? true) { motionWindow.DeviceOrientationEvent = {}; @@ -63,11 +68,7 @@ function createMotionEnvironment(options: { } vi.stubGlobal('window', motionWindow); - vi.stubGlobal('document', { - hidden: false, - addEventListener: addDocumentListener, - removeEventListener: removeDocumentListener, - }); + vi.stubGlobal('document', motionDocument); vi.stubGlobal('screen', { orientation: { angle: options.angle ?? 0, @@ -87,10 +88,20 @@ function createMotionEnvironment(options: { return { addDocumentListener, addWindowListener, + dispatchDocument(type: string) { + for (const listener of documentListeners.get(type) ?? []) { + if (typeof listener === 'function') { + listener({ type } as Event); + } else { + listener.handleEvent({ type } as Event); + } + } + }, dispatchOrientation(beta: number, gamma: number, alpha: number | null = null) { dispatchWindow('deviceorientation', { beta, gamma, alpha }); }, mql, + motionDocument, motionWindow, removeDocumentListener, removeWindowListener, @@ -108,6 +119,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -255,6 +267,48 @@ describe('DeviceMotion', () => { }); }); + it('emits neutral motion when sensor events go idle', async () => { + vi.useFakeTimers(); + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + idleResetMs: 100, + warmupMs: 0, + }); + + await motion.initialize(); + now = 10; + env.dispatchOrientation(45, 0); + vi.advanceTimersByTime(99); + + expect(callback).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(1); + + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + motion.cleanup(); + }); + + it('neutralizes motion when the document is hidden', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await motion.initialize(); + now = 10; + env.dispatchOrientation(45, 0); + env.motionDocument.hidden = true; + env.dispatchDocument('visibilitychange'); + + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + }); + it('honors reduced motion as a hard disable', async () => { const env = createMotionEnvironment({ reducedMotion: true }); const motion = new DeviceMotion(vi.fn()); diff --git a/tests/unit/pointer-physics-controller.test.ts b/tests/unit/pointer-physics-controller.test.ts index 01ba86e..74d18eb 100644 --- a/tests/unit/pointer-physics-controller.test.ts +++ b/tests/unit/pointer-physics-controller.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createPointerPhysicsController, + detectPointerPhysicsCapability, getLatestPointerEvent, type PointerLikeEvent, type PointerMoveEventName, @@ -53,6 +54,30 @@ describe('getLatestPointerEvent', () => { }); }); +describe('detectPointerPhysicsCapability', () => { + it('accepts pointer events as direct pointer IO support', () => { + expect(detectPointerPhysicsCapability({ PointerEvent: function PointerEvent() {} })).toBe( + true, + ); + }); + + it('accepts touch points and pointer media queries', () => { + expect(detectPointerPhysicsCapability({ navigator: { maxTouchPoints: 1 } })).toBe(true); + expect( + detectPointerPhysicsCapability({ + matchMedia: (query) => ({ matches: query.includes('pointer') }), + }), + ).toBe(true); + }); + + it('falls back to mouse IO and rejects environments without pointer input', () => { + expect(detectPointerPhysicsCapability({ MouseEvent: function MouseEvent() {} })).toBe( + true, + ); + expect(detectPointerPhysicsCapability({})).toBe(false); + }); +}); + describe('createPointerPhysicsController', () => { const bounds = { left: 10, From 176d666c3f016453c0d7eaf8a42d03171b495347 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 16:40:12 -0400 Subject: [PATCH 33/57] fix(pointer): reset stale pointer IO --- README.md | 2 +- docs/physics-feel-contract.md | 2 +- scripts/probe-motion-cdp.mjs | 10 ++ src/index.ts | 3 + src/motion/PointerPhysicsController.ts | 43 ++++++++- src/motion/index.ts | 3 + tests/unit/pointer-physics-controller.test.ts | 91 ++++++++++++++++++- 7 files changed, 145 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2fcac3c..601976b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ permission prompt. On permission-gated browsers, keep `enableDeviceMotion={true} hidden, TinyVectors resets device motion to neutral so stale tilt cannot keep steering the blobs. Tune that watchdog with `deviceMotionIdleResetMs` when a host app needs faster or slower sensor liveness handling. Pointer physics is enabled by default only when pointer, touch, or mouse input is -detected. +detected, and resets to center when the pointer leaves the viewport or the window blurs. ## Entry Points diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 6bfb6f3..2841823 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -20,7 +20,7 @@ Every input should become a small field sampled by the blob physics loop: - Pointer field: local soft influence around the pointer. Nearby blobs should react more than distant blobs. - Scroll field: transient impulse or stickiness that decays. It should not create permanent acceleration. - Wall field: bounds should keep the background composed without hard visual snaps. -- Input liveness: real sensor and pointer IO should auto-enable only when available. If device-orientation events go quiet or the tab is hidden, the field must return to neutral instead of preserving stale gravity. +- Input liveness: real sensor and pointer IO should auto-enable only when available. If device-orientation events go quiet, the tab is hidden, the pointer leaves the viewport, or the window blurs, the field must return to neutral instead of preserving stale input. Fields may combine, but input fields must not erase the ambient field. If a field makes the background look frozen, jittery, or overly coherent, it violates the contract. diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index c269b28..e8494e7 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -409,6 +409,14 @@ try { listenerInitial.listeners.pointermove === 1, `Expected one pointermove listener, got ${listenerInitial.listeners.pointermove}.`, ); + assert( + listenerInitial.listeners.pointerout === 1, + `Expected one pointerout listener, got ${listenerInitial.listeners.pointerout}.`, + ); + assert( + listenerInitial.listeners.blur === 1, + `Expected one blur listener, got ${listenerInitial.listeners.blur}.`, + ); assert( listenerInitial.listeners.deviceorientation === 1, `Expected one deviceorientation listener, got ${listenerInitial.listeners.deviceorientation}.`, @@ -433,6 +441,8 @@ try { listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} })`); assert(!afterPointerOff.listeners.pointermove, 'Pointer listener leaked after disabling pointer physics.'); + assert(!afterPointerOff.listeners.pointerout, 'Pointer exit listener leaked after disabling pointer physics.'); + assert(!afterPointerOff.listeners.blur, 'Pointer blur listener leaked after disabling pointer physics.'); await client.send('Runtime.evaluate', { expression: `document.getElementById('device-motion')?.click()`, diff --git a/src/index.ts b/src/index.ts index 3fd5312..ad5036b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,9 @@ export type { PhysicsRange, PointerBounds, PointerCapabilityEnvironment, + PointerExitEventName, + PointerExitLikeEvent, + PointerLifecycleEventName, PointerLikeEvent, PointerMoveEventName, PointerPhysicsController, diff --git a/src/motion/PointerPhysicsController.ts b/src/motion/PointerPhysicsController.ts index 6b68f77..218e729 100644 --- a/src/motion/PointerPhysicsController.ts +++ b/src/motion/PointerPhysicsController.ts @@ -6,14 +6,16 @@ import { } from './PointerMapper.js'; export type PointerMoveEventName = 'pointermove' | 'mousemove'; +export type PointerExitEventName = 'pointerout' | 'mouseout'; +export type PointerLifecycleEventName = PointerMoveEventName | PointerExitEventName | 'blur'; export interface PointerPhysicsEventTarget { addEventListener( - type: PointerMoveEventName, + type: PointerLifecycleEventName, listener: EventListener, options?: AddEventListenerOptions, ): void; - removeEventListener(type: PointerMoveEventName, listener: EventListener): void; + removeEventListener(type: PointerLifecycleEventName, listener: EventListener): void; } export interface PointerLikeEvent { @@ -22,6 +24,10 @@ export interface PointerLikeEvent { getCoalescedEvents?: () => PointerLikeEvent[]; } +export interface PointerExitLikeEvent { + relatedTarget?: EventTarget | null; +} + export interface PointerPhysicsControllerOptions { target: PointerPhysicsEventTarget; getBounds: () => PointerBounds; @@ -43,6 +49,7 @@ export interface PointerCapabilityEnvironment { export interface PointerPhysicsController { readonly eventName: PointerMoveEventName; + readonly exitEventName: PointerExitEventName; flush(): void; dispose(): void; } @@ -62,6 +69,11 @@ export function detectPointerPhysicsCapability( return typeof environment.MouseEvent !== 'undefined'; } +function getRangeCenter(range?: PhysicsRange): PhysicsPoint { + const center = range ? (range.min + range.max) / 2 : 50; + return { x: center, y: center }; +} + export function createPointerPhysicsController( options: PointerPhysicsControllerOptions, ): PointerPhysicsController { @@ -70,6 +82,7 @@ export function createPointerPhysicsController( const supportsPointerEvents = options.supportsPointerEvents ?? typeof PointerEvent !== 'undefined'; const eventName: PointerMoveEventName = supportsPointerEvents ? 'pointermove' : 'mousemove'; + const exitEventName: PointerExitEventName = supportsPointerEvents ? 'pointerout' : 'mouseout'; let frame: number | null = null; let pendingPosition: PhysicsPoint | null = null; @@ -83,6 +96,15 @@ export function createPointerPhysicsController( pendingPosition = null; }; + const resetPosition = () => { + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + pendingPosition = null; + options.updatePosition(getRangeCenter(options.range)); + }; + const handleMove: EventListener = (event) => { if (disposed) return; @@ -99,15 +121,32 @@ export function createPointerPhysicsController( } }; + const handleExit: EventListener = (event) => { + if (disposed) return; + const exitEvent = event as unknown as PointerExitLikeEvent; + if (exitEvent.relatedTarget) return; + resetPosition(); + }; + + const handleBlur: EventListener = () => { + if (disposed) return; + resetPosition(); + }; + options.target.addEventListener(eventName, handleMove, { passive: true }); + options.target.addEventListener(exitEventName, handleExit, { passive: true }); + options.target.addEventListener('blur', handleBlur); return { eventName, + exitEventName, flush, dispose() { if (disposed) return; disposed = true; options.target.removeEventListener(eventName, handleMove); + options.target.removeEventListener(exitEventName, handleExit); + options.target.removeEventListener('blur', handleBlur); if (frame !== null) { cancelFrame(frame); frame = null; diff --git a/src/motion/index.ts b/src/motion/index.ts index 633cf77..4fa5fef 100644 --- a/src/motion/index.ts +++ b/src/motion/index.ts @@ -20,6 +20,9 @@ export { detectPointerPhysicsCapability, getLatestPointerEvent, type PointerCapabilityEnvironment, + type PointerExitEventName, + type PointerExitLikeEvent, + type PointerLifecycleEventName, type PointerLikeEvent, type PointerMoveEventName, type PointerPhysicsController, diff --git a/tests/unit/pointer-physics-controller.test.ts b/tests/unit/pointer-physics-controller.test.ts index 74d18eb..ffb8acc 100644 --- a/tests/unit/pointer-physics-controller.test.ts +++ b/tests/unit/pointer-physics-controller.test.ts @@ -4,18 +4,24 @@ import { createPointerPhysicsController, detectPointerPhysicsCapability, getLatestPointerEvent, + type PointerLifecycleEventName, type PointerLikeEvent, - type PointerMoveEventName, } from '../../src/motion/PointerPhysicsController.js'; +type PointerTestEvent = Partial & { relatedTarget?: EventTarget | null }; + function createTarget() { - const listeners = new Map(); + const listeners = new Map(); const addEventListener = vi.fn( - (type: PointerMoveEventName, listener: EventListener, _options?: AddEventListenerOptions) => { + ( + type: PointerLifecycleEventName, + listener: EventListener, + _options?: AddEventListenerOptions, + ) => { listeners.set(type, listener); }, ); - const removeEventListener = vi.fn((type: PointerMoveEventName, listener: EventListener) => { + const removeEventListener = vi.fn((type: PointerLifecycleEventName, listener: EventListener) => { if (listeners.get(type) === listener) { listeners.delete(type); } @@ -23,7 +29,7 @@ function createTarget() { return { addEventListener, - dispatch(type: PointerMoveEventName, event: PointerLikeEvent) { + dispatch(type: PointerLifecycleEventName, event: PointerTestEvent = {}) { listeners.get(type)?.(event as unknown as Event); }, listeners, @@ -98,11 +104,18 @@ describe('createPointerPhysicsController', () => { }); expect(controller.eventName).toBe('pointermove'); + expect(controller.exitEventName).toBe('pointerout'); expect(target.addEventListener).toHaveBeenCalledWith( 'pointermove', expect.any(Function), { passive: true }, ); + expect(target.addEventListener).toHaveBeenCalledWith( + 'pointerout', + expect.any(Function), + { passive: true }, + ); + expect(target.addEventListener).toHaveBeenCalledWith('blur', expect.any(Function)); controller.dispose(); }); @@ -119,11 +132,17 @@ describe('createPointerPhysicsController', () => { }); expect(controller.eventName).toBe('mousemove'); + expect(controller.exitEventName).toBe('mouseout'); expect(target.addEventListener).toHaveBeenCalledWith( 'mousemove', expect.any(Function), { passive: true }, ); + expect(target.addEventListener).toHaveBeenCalledWith( + 'mouseout', + expect.any(Function), + { passive: true }, + ); controller.dispose(); }); @@ -188,6 +207,66 @@ describe('createPointerPhysicsController', () => { expect(updatePosition).toHaveBeenCalledWith({ x: 100, y: 100 }); }); + it('resets stale pointer position when pointer IO leaves the viewport', () => { + const target = createTarget(); + const cancelFrame = vi.fn(); + const updatePosition = vi.fn(); + const controller = createPointerPhysicsController({ + target, + getBounds: () => bounds, + range: { min: -1, max: 1 }, + supportsPointerEvents: true, + requestFrame: vi.fn(() => 42), + cancelFrame, + updatePosition, + }); + + target.dispatch('pointermove', { clientX: 110, clientY: 70 }); + target.dispatch('pointerout', { + relatedTarget: null, + }); + + expect(cancelFrame).toHaveBeenCalledWith(42); + expect(updatePosition).toHaveBeenCalledWith({ x: 0, y: 0 }); + expect(controller.exitEventName).toBe('pointerout'); + }); + + it('ignores pointerout transitions that stay inside the document', () => { + const target = createTarget(); + const updatePosition = vi.fn(); + createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: true, + requestFrame: vi.fn(), + cancelFrame: vi.fn(), + updatePosition, + }); + + target.dispatch('pointerout', { + relatedTarget: {} as EventTarget, + }); + + expect(updatePosition).not.toHaveBeenCalled(); + }); + + it('resets stale pointer position on window blur', () => { + const target = createTarget(); + const updatePosition = vi.fn(); + createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: false, + requestFrame: vi.fn(), + cancelFrame: vi.fn(), + updatePosition, + }); + + target.dispatch('blur'); + + expect(updatePosition).toHaveBeenCalledWith({ x: 50, y: 50 }); + }); + it('removes listeners and cancels pending work during cleanup', () => { const target = createTarget(); const cancelFrame = vi.fn(); @@ -211,6 +290,8 @@ describe('createPointerPhysicsController', () => { expect(cancelFrame).toHaveBeenCalledWith(42); expect(target.removeEventListener).toHaveBeenCalledWith('pointermove', expect.any(Function)); + expect(target.removeEventListener).toHaveBeenCalledWith('pointerout', expect.any(Function)); + expect(target.removeEventListener).toHaveBeenCalledWith('blur', expect.any(Function)); expect(updatePosition).not.toHaveBeenCalled(); }); }); From b35e989b30c5fd7ed06d3187bdd583b6816e880c Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 18:55:44 -0400 Subject: [PATCH 34/57] feat(motion): expose device motion status --- README.md | 2 ++ dev/App.svelte | 14 ++++++++++++++ dev/main.ts | 8 ++++++++ scripts/check-package-consumer.mjs | 16 +++++++++++++-- scripts/copy-svelte-declarations.mjs | 1 + scripts/probe-motion-cdp.mjs | 9 +++++++++ src/index.ts | 4 ++++ src/svelte/TinyVectors.svelte | 29 ++++++++++++++++++++++++++++ src/svelte/TinyVectors.svelte.d.ts | 2 ++ src/svelte/index.ts | 3 +++ src/svelte/types.ts | 9 +++++++++ 11 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/svelte/types.ts diff --git a/README.md b/README.md index 601976b..059489c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ hidden, TinyVectors resets device motion to neutral so stale tilt cannot keep st Tune that watchdog with `deviceMotionIdleResetMs` when a host app needs faster or slower sensor liveness handling. Pointer physics is enabled by default only when pointer, touch, or mouse input is detected, and resets to center when the pointer leaves the viewport or the window blurs. +Use `getDeviceMotionStatus()` on the component handle to inspect support, permission, and listener +state before deciding whether to show motion-permission UI. ## Entry Points diff --git a/dev/App.svelte b/dev/App.svelte index 4999f67..80b92ba 100644 --- a/dev/App.svelte +++ b/dev/App.svelte @@ -2,10 +2,12 @@ import TinyVectors from '../src/svelte/TinyVectors.svelte'; import type { ThemePresetName } from '../src/core/schema.js'; import type { MotionVector } from '../src/motion/DeviceMotion.js'; + import type { TinyVectorsDeviceMotionStatus } from '../src/svelte/types.js'; interface TinyVectorsHandle { requestDeviceMotionPermission: () => Promise; calibrateDeviceMotion: (samples?: number) => void; + getDeviceMotionStatus: () => TinyVectorsDeviceMotionStatus; } interface Props { @@ -39,6 +41,18 @@ export function calibrateDeviceMotion(samples?: number): void { vectorLayer?.calibrateDeviceMotion(samples); } + + export function getDeviceMotionStatus(): TinyVectorsDeviceMotionStatus { + return ( + vectorLayer?.getDeviceMotionStatus() ?? { + enabled: enableDeviceMotion, + supported: false, + requiresPermission: false, + permissionState: 'unknown', + active: false, + } + ); + }
diff --git a/dev/main.ts b/dev/main.ts index 4d46751..95c9d9b 100644 --- a/dev/main.ts +++ b/dev/main.ts @@ -1,10 +1,16 @@ import { mount, unmount } from 'svelte'; import App from './App.svelte'; import type { MotionVector } from '../src/motion/DeviceMotion.js'; +import type { TinyVectorsDeviceMotionStatus } from '../src/svelte/types.js'; interface DevAppHandle { requestDeviceMotionPermission: () => Promise; calibrateDeviceMotion: (samples?: number) => void; + getDeviceMotionStatus: () => TinyVectorsDeviceMotionStatus; +} + +interface DevWindow extends Window { + __tinyvectorsDeviceMotionStatus?: () => TinyVectorsDeviceMotionStatus | null; } const params = new URLSearchParams(window.location.search); @@ -29,6 +35,7 @@ function themeParam(): (typeof themes)[number] { const initialDarkMode = booleanParam('dark', true); const showControls = booleanParam('controls', true); +const devWindow = window as DevWindow; document.body.classList.toggle('dark', initialDarkMode); document.body.classList.toggle('light', !initialDarkMode); document.body.classList.toggle('hide-controls', !showControls); @@ -100,6 +107,7 @@ function mountApp() { target, props: currentProps, }) as ReturnType & DevAppHandle; + devWindow.__tinyvectorsDeviceMotionStatus = () => app?.getDeviceMotionStatus() ?? null; } diff --git a/scripts/check-package-consumer.mjs b/scripts/check-package-consumer.mjs index ac816df..b334cf9 100644 --- a/scripts/check-package-consumer.mjs +++ b/scripts/check-package-consumer.mjs @@ -85,7 +85,12 @@ import { \ttype PointerBounds, } from '@tummycrypt/tinyvectors/motion'; import { getThemePreset } from '@tummycrypt/tinyvectors/themes'; -import { BlobSVG, type BlobSVGProps, type TinyVectorsProps } from '@tummycrypt/tinyvectors/svelte'; +import { +\tBlobSVG, +\ttype BlobSVGProps, +\ttype TinyVectorsDeviceMotionStatus, +\ttype TinyVectorsProps, +} from '@tummycrypt/tinyvectors/svelte'; import type { ComponentProps } from 'svelte'; const bounds: PointerBounds = { left: 0, top: 0, width: 100, height: 100 }; @@ -93,10 +98,17 @@ const point = mapClientPointToPhysics(50, 50, bounds); const sample: MotionVector = { x: 0, y: 0, z: 1 }; const props: ComponentProps = { theme: 'tinyland', enableDeviceMotion: true }; const explicitProps: TinyVectorsProps = props; +const motionStatus: TinyVectorsDeviceMotionStatus = { +\tenabled: true, +\tsupported: true, +\trequiresPermission: false, +\tpermissionState: 'granted', +\tactive: true, +}; const blobProps: BlobSVGProps = { blobs: [] }; const themeName: ThemePresetName = 'tinyland'; const themePreset: ThemePreset = THEME_PRESETS[themeName]; -const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, point, sample, explicitProps, blobProps, themePreset]; +const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, point, sample, explicitProps, motionStatus, blobProps, themePreset]; console.log(names.length); `.trimStart(), ); diff --git a/scripts/copy-svelte-declarations.mjs b/scripts/copy-svelte-declarations.mjs index cd52b75..c14489e 100644 --- a/scripts/copy-svelte-declarations.mjs +++ b/scripts/copy-svelte-declarations.mjs @@ -19,6 +19,7 @@ await writeFile( new URL('index.d.ts', outputDir), [ "export { default as TinyVectors, type TinyVectorsExports, type TinyVectorsProps } from './TinyVectors.js';", + "export type { TinyVectorsDeviceMotionStatus } from './types.js';", "export { default as BlobSVG, type BlobSVGProps } from './BlobSVG.js';", '', ].join('\n'), diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index e8494e7..881f21b 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -322,6 +322,7 @@ try { hasDeviceMotionEvent: 'DeviceMotionEvent' in window, hasDeviceOrientationEvent: 'DeviceOrientationEvent' in window, hasAccelerometer: 'Accelerometer' in window, + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, status: document.getElementById('motion-status')?.textContent ?? null, pathCount: document.querySelectorAll('path').length, firstPath: document.querySelector('path')?.getAttribute('d') ?? null, @@ -331,6 +332,13 @@ try { assert(initial.secure, 'Page must be a secure context for device motion APIs.'); assert(initial.hasDeviceOrientationEvent, 'DeviceOrientationEvent is not exposed in Chrome.'); assert(initial.pathCount > 0, 'TinyVectors SVG paths were not rendered.'); + assert(initial.motionStatus?.enabled === true, 'Device motion status did not report enabled.'); + assert(initial.motionStatus?.supported === true, 'Device motion status did not report support.'); + assert( + initial.motionStatus?.permissionState === 'granted', + `Device motion status did not report granted; got ${initial.motionStatus?.permissionState}.`, + ); + assert(initial.motionStatus?.active === true, 'Device motion status did not report active listener.'); await client.send('Runtime.evaluate', { expression: `document.getElementById('spoof-tilt-btn')?.click()`, @@ -503,6 +511,7 @@ try { hasDeviceMotionEvent: initial.hasDeviceMotionEvent, hasDeviceOrientationEvent: initial.hasDeviceOrientationEvent, hasAccelerometer: initial.hasAccelerometer, + motionStatus: initial.motionStatus, pathCount: initial.pathCount, }, syntheticOrientation: { diff --git a/src/index.ts b/src/index.ts index ad5036b..b6588d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,3 +102,7 @@ export { TinyVectors, BlobSVG, } from './svelte/index.js'; + +export type { + TinyVectorsDeviceMotionStatus, +} from './svelte/index.js'; diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index 8b1bd6d..c355b77 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -4,6 +4,7 @@ import { BlobPhysics, type BlobPhysicsConfig } from '../core/BlobPhysics.js'; import { DeviceMotion, + type DeviceMotionPermissionState, type MotionVector, } from '../motion/DeviceMotion.js'; import { @@ -15,6 +16,7 @@ import { ScrollHandler } from '../motion/ScrollHandler.js'; import { THEME_PRESET_COLORS } from '../core/theme-colors.js'; import type { ThemePresetName } from '../core/theme-presets.js'; + import type { TinyVectorsDeviceMotionStatus } from './types.js'; import BlobSVG from './BlobSVG.svelte'; interface Props { @@ -86,6 +88,20 @@ return 'DeviceOrientationEvent' in window; }; + const getDeviceMotionCapabilityState = (): DeviceMotionPermissionState => { + if (!browser || typeof window === 'undefined') return 'unsupported'; + if (!window.isSecureContext) return 'insecure'; + return 'DeviceOrientationEvent' in window ? 'unknown' : 'unsupported'; + }; + + const requiresDeviceMotionPermission = (): boolean => { + if (!browser || typeof window === 'undefined') return false; + const constructor = (window as unknown as { + DeviceOrientationEvent?: { requestPermission?: unknown }; + }).DeviceOrientationEvent; + return typeof constructor?.requestPermission === 'function'; + }; + const createDeviceMotion = (): DeviceMotion => new DeviceMotion(handleDeviceMotion, { calibrationSamples: deviceMotionCalibrationSamples, @@ -119,6 +135,19 @@ deviceMotion?.calibrate(samples); } + export function getDeviceMotionStatus(): TinyVectorsDeviceMotionStatus { + const capabilityState = getDeviceMotionCapabilityState(); + const permissionState = deviceMotion?.getPermissionState() ?? capabilityState; + + return { + enabled: enableDeviceMotion, + supported: capabilityState !== 'unsupported' && capabilityState !== 'insecure', + requiresPermission: requiresDeviceMotionPermission(), + permissionState, + active: deviceMotion?.isActive() ?? false, + }; + } + const handleScroll = (event: WheelEvent) => { if (!scrollHandler || !physics) return; scrollHandler.handleScroll(event); diff --git a/src/svelte/TinyVectors.svelte.d.ts b/src/svelte/TinyVectors.svelte.d.ts index 210a4f1..69d178a 100644 --- a/src/svelte/TinyVectors.svelte.d.ts +++ b/src/svelte/TinyVectors.svelte.d.ts @@ -2,6 +2,7 @@ import type { Component } from 'svelte'; import type { BlobPhysicsConfig } from '../core/BlobPhysics.js'; import type { ThemePresetName } from '../core/theme-presets.js'; import type { MotionVector } from '../motion/DeviceMotion.js'; +import type { TinyVectorsDeviceMotionStatus } from './types.js'; export interface TinyVectorsProps { /** Theme preset name */ @@ -37,6 +38,7 @@ export interface TinyVectorsProps { export interface TinyVectorsExports { requestDeviceMotionPermission(): Promise; calibrateDeviceMotion(samples?: number): void; + getDeviceMotionStatus(): TinyVectorsDeviceMotionStatus; } declare const TinyVectors: Component; diff --git a/src/svelte/index.ts b/src/svelte/index.ts index 1089ede..01c2ebb 100644 --- a/src/svelte/index.ts +++ b/src/svelte/index.ts @@ -4,6 +4,9 @@ export { default as TinyVectors } from './TinyVectors.svelte'; +export type { + TinyVectorsDeviceMotionStatus, +} from './types.js'; export { default as BlobSVG } from './BlobSVG.svelte'; diff --git a/src/svelte/types.ts b/src/svelte/types.ts new file mode 100644 index 0000000..6d14687 --- /dev/null +++ b/src/svelte/types.ts @@ -0,0 +1,9 @@ +import type { DeviceMotionPermissionState } from '../motion/DeviceMotion.js'; + +export interface TinyVectorsDeviceMotionStatus { + enabled: boolean; + supported: boolean; + requiresPermission: boolean; + permissionState: DeviceMotionPermissionState; + active: boolean; +} From 9357969db0856875535f802d9cf694695fc1498e Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 20:05:03 -0400 Subject: [PATCH 35/57] refactor(motion): share device capability checks --- src/motion/DeviceMotion.ts | 25 +++++++++++++------------ src/svelte/TinyVectors.svelte | 22 ++++------------------ tests/unit/device-motion.test.ts | 24 +++++++++++++++++++++++- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index ae5a19f..bd8abbf 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -92,6 +92,16 @@ function getPermissionApi(): (() => Promise<'granted' | 'denied'>) | null { return typeof requestPermission === 'function' ? requestPermission.bind(constructor) : null; } +export function getDeviceMotionCapabilityState(): DeviceMotionPermissionState { + if (typeof window === 'undefined') return 'unsupported'; + if (!window.isSecureContext) return 'insecure'; + return 'DeviceOrientationEvent' in window ? 'unknown' : 'unsupported'; +} + +export function isDeviceMotionPermissionRequired(): boolean { + return getPermissionApi() !== null; +} + function getScreenOrientationAngle(): number { if (typeof screen === 'undefined') return 0; return screen.orientation?.angle ?? 0; @@ -233,18 +243,9 @@ export class DeviceMotion { private detectSupport(): boolean { if (this.disposed) return false; - if (typeof window === 'undefined') { - this.permissionState = 'unsupported'; - return false; - } - - if (!window.isSecureContext) { - this.permissionState = 'insecure'; - return false; - } - - if (!('DeviceOrientationEvent' in window)) { - this.permissionState = 'unsupported'; + const capabilityState = getDeviceMotionCapabilityState(); + if (capabilityState !== 'unknown') { + this.permissionState = capabilityState; return false; } diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index c355b77..8a70989 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -4,7 +4,8 @@ import { BlobPhysics, type BlobPhysicsConfig } from '../core/BlobPhysics.js'; import { DeviceMotion, - type DeviceMotionPermissionState, + getDeviceMotionCapabilityState, + isDeviceMotionPermissionRequired, type MotionVector, } from '../motion/DeviceMotion.js'; import { @@ -84,22 +85,7 @@ }); const detectDeviceMotionCapability = (): boolean => { - if (!browser || !window.isSecureContext) return false; - return 'DeviceOrientationEvent' in window; - }; - - const getDeviceMotionCapabilityState = (): DeviceMotionPermissionState => { - if (!browser || typeof window === 'undefined') return 'unsupported'; - if (!window.isSecureContext) return 'insecure'; - return 'DeviceOrientationEvent' in window ? 'unknown' : 'unsupported'; - }; - - const requiresDeviceMotionPermission = (): boolean => { - if (!browser || typeof window === 'undefined') return false; - const constructor = (window as unknown as { - DeviceOrientationEvent?: { requestPermission?: unknown }; - }).DeviceOrientationEvent; - return typeof constructor?.requestPermission === 'function'; + return browser && getDeviceMotionCapabilityState() === 'unknown'; }; const createDeviceMotion = (): DeviceMotion => @@ -142,7 +128,7 @@ return { enabled: enableDeviceMotion, supported: capabilityState !== 'unsupported' && capabilityState !== 'insecure', - requiresPermission: requiresDeviceMotionPermission(), + requiresPermission: browser && isDeviceMotionPermissionRequired(), permissionState, active: deviceMotion?.isActive() ?? false, }; diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index 99a507f..2dfdc93 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DeviceMotion } from '../../src/motion/DeviceMotion.js'; +import { + DeviceMotion, + getDeviceMotionCapabilityState, + isDeviceMotionPermissionRequired, +} from '../../src/motion/DeviceMotion.js'; type PermissionResponse = 'granted' | 'denied'; @@ -125,6 +129,24 @@ afterEach(() => { }); describe('DeviceMotion', () => { + it('reports capability and permission requirement without creating a listener', () => { + const requestPermission = vi.fn().mockResolvedValue('granted' as const); + createMotionEnvironment({ permission: requestPermission }); + + expect(getDeviceMotionCapabilityState()).toBe('unknown'); + expect(isDeviceMotionPermissionRequired()).toBe(true); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it('reports insecure and unsupported capability states', () => { + createMotionEnvironment({ secure: false }); + expect(getDeviceMotionCapabilityState()).toBe('insecure'); + + createMotionEnvironment({ orientation: false }); + expect(getDeviceMotionCapabilityState()).toBe('unsupported'); + expect(isDeviceMotionPermissionRequired()).toBe(false); + }); + it('reports unsupported when initialized without a browser window', async () => { const motion = new DeviceMotion(vi.fn()); From d0a0ffde3ef7cf5231c9e38bf55a0fa7185fe139 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 20:13:10 -0400 Subject: [PATCH 36/57] fix(physics): remove smoothing order bias --- src/core/BlobPhysics.ts | 22 ++++++++++++---------- tests/unit/core.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 2dc40d9..bc6af1c 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -659,26 +659,28 @@ export class BlobPhysics { private smoothControlPoints(blob: ConvexBlob): void { if (!blob.controlPoints || blob.controlPoints.length < 3) return; - for (let i = 0; i < blob.controlPoints.length; i++) { + const originalRadii = blob.controlPoints.map((point) => point.radius); + const pointCount = blob.controlPoints.length; + + for (let i = 0; i < pointCount; i++) { const current = blob.controlPoints[i]; - const prev = blob.controlPoints[(i - 1 + blob.controlPoints.length) % blob.controlPoints.length]; - const next = blob.controlPoints[(i + 1) % blob.controlPoints.length]; + const prevRadius = originalRadii[(i - 1 + pointCount) % pointCount]; + const currentRadius = originalRadii[i]; + const nextRadius = originalRadii[(i + 1) % pointCount]; - const avgRadius = (prev.radius + current.radius + next.radius) / 3; + const avgRadius = (prevRadius + currentRadius + nextRadius) / 3; const smoothingFactor = 0.05; - current.radius = current.radius * (1 - smoothingFactor) + avgRadius * smoothingFactor; + current.radius = currentRadius * (1 - smoothingFactor) + avgRadius * smoothingFactor; const minRadiusDiff = blob.size * 0.1; - if (Math.abs(current.radius - prev.radius) > minRadiusDiff) { - const adjustment = (Math.abs(current.radius - prev.radius) - minRadiusDiff) * 0.5; - if (current.radius > prev.radius) { + if (Math.abs(current.radius - prevRadius) > minRadiusDiff) { + const adjustment = (Math.abs(current.radius - prevRadius) - minRadiusDiff) * 0.5; + if (current.radius > prevRadius) { current.radius -= adjustment; - prev.radius += adjustment; } else { current.radius += adjustment; - prev.radius -= adjustment; } } } diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index 1a3626a..ac095a3 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -256,6 +256,37 @@ describe('BlobPhysics', () => { useSpatialHash: false, }); }); + + it('smooths control points without start-index directional bias', () => { + const runSmoothing = (radii: number[]) => { + const blob = createTestConvexBlob(50, 50, 20); + blob.controlPoints?.forEach((point, index) => { + point.radius = radii[index]; + point.baseRadius = radii[index]; + point.targetRadius = radii[index]; + }); + + const physics = new BlobPhysics(0); + ( + physics as unknown as { + smoothControlPoints(blob: ConvexBlob): void; + } + ).smoothControlPoints(blob); + + return blob.controlPoints?.map((point) => point.radius) ?? []; + }; + + const radii = [20, 40, 20, 10, 30, 20, 35, 15]; + const rotatedRadii = [radii[radii.length - 1], ...radii.slice(0, -1)]; + const expected = runSmoothing(radii); + const rotatedResult = runSmoothing(rotatedRadii); + const rotatedBack = [...rotatedResult.slice(1), rotatedResult[0]]; + + expect(rotatedBack).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(rotatedBack[i]).toBeCloseTo(expected[i], 10); + } + }); }); From 1f2125efc9751a29fcaf9199a796bac48ce557ec Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 20:29:32 -0400 Subject: [PATCH 37/57] test(browser): assert disabled io starts clean --- scripts/probe-motion-cdp.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index 881f21b..d8a5ecd 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -313,6 +313,35 @@ try { `, }); + const disabledUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=false&pointerPhysics=false&scrollPhysics=false&blobs=8&listenerProbe=1`; + await client.send('Page.navigate', { url: disabledUrl }); + await delay(1000); + + const disabledInitial = await evaluate(client, `({ + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + pathCount: document.querySelectorAll('path').length, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert(disabledInitial.pathCount > 0, 'TinyVectors did not render when IO features were disabled.'); + assert( + disabledInitial.motionStatus?.enabled === false, + 'Disabled device motion page reported device motion enabled.', + ); + assert( + disabledInitial.motionStatus?.active === false, + 'Disabled device motion page reported an active listener.', + ); + assert(!disabledInitial.listeners.wheel, 'Wheel listener attached when scroll physics was disabled.'); + assert( + !disabledInitial.listeners.pointermove, + 'Pointer listener attached when pointer physics was disabled.', + ); + assert( + !disabledInitial.listeners.deviceorientation, + 'Device orientation listener attached when device motion was disabled.', + ); + const pageUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=false&scrollPhysics=false&blobs=8&motionIdleReset=700`; await client.send('Page.navigate', { url: pageUrl }); await delay(1500); @@ -514,6 +543,11 @@ try { motionStatus: initial.motionStatus, pathCount: initial.pathCount, }, + disabledInitial: { + motionStatus: disabledInitial.motionStatus, + pathCount: disabledInitial.pathCount, + listeners: disabledInitial.listeners, + }, syntheticOrientation: { status: afterSpoof.status, events: afterSpoof.events.length, From e2e42e2c80a60ac275745268788640556de3aeb6 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 20:33:45 -0400 Subject: [PATCH 38/57] fix(motion): neutralize on reduced motion --- src/motion/DeviceMotion.ts | 2 ++ tests/unit/device-motion.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index bd8abbf..6f963ea 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -262,6 +262,8 @@ export class DeviceMotion { if (this.reducedMotionMql.matches) { this.blockedByReducedMotion = true; this.stopListening(); + this.resetFilterState(); + this.emitNeutral(); return; } diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index 2dfdc93..36253c9 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -342,6 +342,25 @@ describe('DeviceMotion', () => { expect(env.addWindowListener).not.toHaveBeenCalled(); }); + it('neutralizes active motion when reduced motion is enabled', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await expect(motion.initialize()).resolves.toBe(true); + now = 10; + env.dispatchOrientation(45, 0); + env.mql.matches = true; + env.dispatchReducedMotionChange(); + + expect(motion.isActive()).toBe(false); + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + }); + it('restarts after reduced motion is disabled when no permission prompt is needed', async () => { const env = createMotionEnvironment({ reducedMotion: true }); const motion = new DeviceMotion(vi.fn()); From d4b87784120c7a200167d956a5615413349c5305 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 20:52:45 -0400 Subject: [PATCH 39/57] fix(pointer): reset canceled pointer io --- README.md | 3 ++- docs/physics-feel-contract.md | 2 +- scripts/check-package-consumer.mjs | 4 ++- scripts/probe-motion-cdp.mjs | 9 +++++++ src/index.ts | 1 + src/motion/PointerPhysicsController.ts | 18 ++++++++++++- src/motion/index.ts | 1 + tests/unit/pointer-physics-controller.test.ts | 25 +++++++++++++++++++ 8 files changed, 59 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 059489c..906b227 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ permission prompt. On permission-gated browsers, keep `enableDeviceMotion={true} hidden, TinyVectors resets device motion to neutral so stale tilt cannot keep steering the blobs. Tune that watchdog with `deviceMotionIdleResetMs` when a host app needs faster or slower sensor liveness handling. Pointer physics is enabled by default only when pointer, touch, or mouse input is -detected, and resets to center when the pointer leaves the viewport or the window blurs. +detected, and resets to center when pointer input is canceled, the pointer leaves the viewport, or +the window blurs. Use `getDeviceMotionStatus()` on the component handle to inspect support, permission, and listener state before deciding whether to show motion-permission UI. diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 2841823..fa33975 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -20,7 +20,7 @@ Every input should become a small field sampled by the blob physics loop: - Pointer field: local soft influence around the pointer. Nearby blobs should react more than distant blobs. - Scroll field: transient impulse or stickiness that decays. It should not create permanent acceleration. - Wall field: bounds should keep the background composed without hard visual snaps. -- Input liveness: real sensor and pointer IO should auto-enable only when available. If device-orientation events go quiet, the tab is hidden, the pointer leaves the viewport, or the window blurs, the field must return to neutral instead of preserving stale input. +- Input liveness: real sensor and pointer IO should auto-enable only when available. If device-orientation events go quiet, the tab is hidden, pointer input is canceled, the pointer leaves the viewport, or the window blurs, the field must return to neutral instead of preserving stale input. Fields may combine, but input fields must not erase the ambient field. If a field makes the background look frozen, jittery, or overly coherent, it violates the contract. diff --git a/scripts/check-package-consumer.mjs b/scripts/check-package-consumer.mjs index b334cf9..7a743f5 100644 --- a/scripts/check-package-consumer.mjs +++ b/scripts/check-package-consumer.mjs @@ -83,6 +83,7 @@ import { \tmapClientPointToPhysics, \ttype MotionVector, \ttype PointerBounds, +\ttype PointerCancelEventName, } from '@tummycrypt/tinyvectors/motion'; import { getThemePreset } from '@tummycrypt/tinyvectors/themes'; import { @@ -94,6 +95,7 @@ import { import type { ComponentProps } from 'svelte'; const bounds: PointerBounds = { left: 0, top: 0, width: 100, height: 100 }; +const cancelEvent: PointerCancelEventName = 'pointercancel'; const point = mapClientPointToPhysics(50, 50, bounds); const sample: MotionVector = { x: 0, y: 0, z: 1 }; const props: ComponentProps = { theme: 'tinyland', enableDeviceMotion: true }; @@ -108,7 +110,7 @@ const motionStatus: TinyVectorsDeviceMotionStatus = { const blobProps: BlobSVGProps = { blobs: [] }; const themeName: ThemePresetName = 'tinyland'; const themePreset: ThemePreset = THEME_PRESETS[themeName]; -const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, point, sample, explicitProps, motionStatus, blobProps, themePreset]; +const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, point, sample, explicitProps, motionStatus, blobProps, themePreset, cancelEvent]; console.log(names.length); `.trimStart(), ); diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index d8a5ecd..f6cf8d7 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -337,6 +337,10 @@ try { !disabledInitial.listeners.pointermove, 'Pointer listener attached when pointer physics was disabled.', ); + assert( + !disabledInitial.listeners.pointercancel, + 'Pointer cancel listener attached when pointer physics was disabled.', + ); assert( !disabledInitial.listeners.deviceorientation, 'Device orientation listener attached when device motion was disabled.', @@ -450,6 +454,10 @@ try { listenerInitial.listeners.pointerout === 1, `Expected one pointerout listener, got ${listenerInitial.listeners.pointerout}.`, ); + assert( + listenerInitial.listeners.pointercancel === 1, + `Expected one pointercancel listener, got ${listenerInitial.listeners.pointercancel}.`, + ); assert( listenerInitial.listeners.blur === 1, `Expected one blur listener, got ${listenerInitial.listeners.blur}.`, @@ -479,6 +487,7 @@ try { })`); assert(!afterPointerOff.listeners.pointermove, 'Pointer listener leaked after disabling pointer physics.'); assert(!afterPointerOff.listeners.pointerout, 'Pointer exit listener leaked after disabling pointer physics.'); + assert(!afterPointerOff.listeners.pointercancel, 'Pointer cancel listener leaked after disabling pointer physics.'); assert(!afterPointerOff.listeners.blur, 'Pointer blur listener leaked after disabling pointer physics.'); await client.send('Runtime.evaluate', { diff --git a/src/index.ts b/src/index.ts index b6588d5..9bbf0fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ export type { PhysicsRange, PointerBounds, PointerCapabilityEnvironment, + PointerCancelEventName, PointerExitEventName, PointerExitLikeEvent, PointerLifecycleEventName, diff --git a/src/motion/PointerPhysicsController.ts b/src/motion/PointerPhysicsController.ts index 218e729..69a7e3b 100644 --- a/src/motion/PointerPhysicsController.ts +++ b/src/motion/PointerPhysicsController.ts @@ -7,7 +7,12 @@ import { export type PointerMoveEventName = 'pointermove' | 'mousemove'; export type PointerExitEventName = 'pointerout' | 'mouseout'; -export type PointerLifecycleEventName = PointerMoveEventName | PointerExitEventName | 'blur'; +export type PointerCancelEventName = 'pointercancel'; +export type PointerLifecycleEventName = + | PointerMoveEventName + | PointerExitEventName + | PointerCancelEventName + | 'blur'; export interface PointerPhysicsEventTarget { addEventListener( @@ -50,6 +55,7 @@ export interface PointerCapabilityEnvironment { export interface PointerPhysicsController { readonly eventName: PointerMoveEventName; readonly exitEventName: PointerExitEventName; + readonly cancelEventName: PointerCancelEventName | null; flush(): void; dispose(): void; } @@ -83,6 +89,9 @@ export function createPointerPhysicsController( options.supportsPointerEvents ?? typeof PointerEvent !== 'undefined'; const eventName: PointerMoveEventName = supportsPointerEvents ? 'pointermove' : 'mousemove'; const exitEventName: PointerExitEventName = supportsPointerEvents ? 'pointerout' : 'mouseout'; + const cancelEventName: PointerCancelEventName | null = supportsPointerEvents + ? 'pointercancel' + : null; let frame: number | null = null; let pendingPosition: PhysicsPoint | null = null; @@ -135,17 +144,24 @@ export function createPointerPhysicsController( options.target.addEventListener(eventName, handleMove, { passive: true }); options.target.addEventListener(exitEventName, handleExit, { passive: true }); + if (cancelEventName) { + options.target.addEventListener(cancelEventName, handleBlur); + } options.target.addEventListener('blur', handleBlur); return { eventName, exitEventName, + cancelEventName, flush, dispose() { if (disposed) return; disposed = true; options.target.removeEventListener(eventName, handleMove); options.target.removeEventListener(exitEventName, handleExit); + if (cancelEventName) { + options.target.removeEventListener(cancelEventName, handleBlur); + } options.target.removeEventListener('blur', handleBlur); if (frame !== null) { cancelFrame(frame); diff --git a/src/motion/index.ts b/src/motion/index.ts index 4fa5fef..1e26e10 100644 --- a/src/motion/index.ts +++ b/src/motion/index.ts @@ -20,6 +20,7 @@ export { detectPointerPhysicsCapability, getLatestPointerEvent, type PointerCapabilityEnvironment, + type PointerCancelEventName, type PointerExitEventName, type PointerExitLikeEvent, type PointerLifecycleEventName, diff --git a/tests/unit/pointer-physics-controller.test.ts b/tests/unit/pointer-physics-controller.test.ts index ffb8acc..100feb7 100644 --- a/tests/unit/pointer-physics-controller.test.ts +++ b/tests/unit/pointer-physics-controller.test.ts @@ -105,6 +105,7 @@ describe('createPointerPhysicsController', () => { expect(controller.eventName).toBe('pointermove'); expect(controller.exitEventName).toBe('pointerout'); + expect(controller.cancelEventName).toBe('pointercancel'); expect(target.addEventListener).toHaveBeenCalledWith( 'pointermove', expect.any(Function), @@ -115,6 +116,7 @@ describe('createPointerPhysicsController', () => { expect.any(Function), { passive: true }, ); + expect(target.addEventListener).toHaveBeenCalledWith('pointercancel', expect.any(Function)); expect(target.addEventListener).toHaveBeenCalledWith('blur', expect.any(Function)); controller.dispose(); @@ -133,6 +135,7 @@ describe('createPointerPhysicsController', () => { expect(controller.eventName).toBe('mousemove'); expect(controller.exitEventName).toBe('mouseout'); + expect(controller.cancelEventName).toBeNull(); expect(target.addEventListener).toHaveBeenCalledWith( 'mousemove', expect.any(Function), @@ -231,6 +234,27 @@ describe('createPointerPhysicsController', () => { expect(controller.exitEventName).toBe('pointerout'); }); + it('resets stale pointer position when browser pointer IO is canceled', () => { + const target = createTarget(); + const cancelFrame = vi.fn(); + const updatePosition = vi.fn(); + createPointerPhysicsController({ + target, + getBounds: () => bounds, + range: { min: -1, max: 1 }, + supportsPointerEvents: true, + requestFrame: vi.fn(() => 42), + cancelFrame, + updatePosition, + }); + + target.dispatch('pointermove', { clientX: 110, clientY: 70 }); + target.dispatch('pointercancel'); + + expect(cancelFrame).toHaveBeenCalledWith(42); + expect(updatePosition).toHaveBeenCalledWith({ x: 0, y: 0 }); + }); + it('ignores pointerout transitions that stay inside the document', () => { const target = createTarget(); const updatePosition = vi.fn(); @@ -291,6 +315,7 @@ describe('createPointerPhysicsController', () => { expect(cancelFrame).toHaveBeenCalledWith(42); expect(target.removeEventListener).toHaveBeenCalledWith('pointermove', expect.any(Function)); expect(target.removeEventListener).toHaveBeenCalledWith('pointerout', expect.any(Function)); + expect(target.removeEventListener).toHaveBeenCalledWith('pointercancel', expect.any(Function)); expect(target.removeEventListener).toHaveBeenCalledWith('blur', expect.any(Function)); expect(updatePosition).not.toHaveBeenCalled(); }); From 8197ffd2685db79791d60d93ff058663d5664da5 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 20:59:22 -0400 Subject: [PATCH 40/57] fix(motion): block reduced-motion permission race --- src/motion/DeviceMotion.ts | 15 ++++++++++++++- tests/unit/device-motion.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index 6f963ea..63a7a0b 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -187,7 +187,20 @@ export class DeviceMotion { this.permissionState = 'granted'; } - if (this.permissionState !== 'granted' || this.disposed) { + if (this.disposed) { + this.stopListening(); + return false; + } + + if (this.prefersReducedMotion()) { + this.blockedByReducedMotion = true; + this.stopListening(); + this.resetFilterState(); + this.emitNeutral(); + return false; + } + + if (this.permissionState !== 'granted') { this.stopListening(); return false; } diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index 36253c9..ad8047f 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -239,6 +239,35 @@ describe('DeviceMotion', () => { expect(motion.isActive()).toBe(false); }); + it('does not start listeners when reduced motion is enabled during a permission request', async () => { + let resolvePermission: (value: PermissionResponse) => void = () => {}; + const permission = new Promise((resolve) => { + resolvePermission = resolve; + }); + const env = createMotionEnvironment({ permission: () => permission }); + const callback = vi.fn(); + const motion = new DeviceMotion(callback); + + const request = motion.requestPermission(); + env.mql.matches = true; + env.dispatchReducedMotionChange(); + resolvePermission('granted'); + + await expect(request).resolves.toBe(false); + expect(motion.isActive()).toBe(false); + expect(env.addWindowListener).not.toHaveBeenCalled(); + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + + env.mql.matches = false; + env.dispatchReducedMotionChange(); + + expect(motion.getPermissionState()).toBe('granted'); + expect(motion.isActive()).toBe(true); + expect(env.addWindowListener).toHaveBeenCalledWith('deviceorientation', expect.any(Function), { + passive: true, + }); + }); + it('calibrates against caller-requested samples', async () => { const env = createMotionEnvironment(); const callback = vi.fn(); From c03793f68809db384b91d59b12b3ed7186a3ac2f Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 21:02:43 -0400 Subject: [PATCH 41/57] test(browser): cover reduced-motion listener lifecycle --- scripts/probe-motion-cdp.mjs | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index f6cf8d7..24b5fd6 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -403,6 +403,49 @@ try { `Device orientation idle reset did not neutralize motion; status was ${afterIdleReset.status}`, ); + await client.send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }], + }); + await delay(350); + + const afterReducedMotion = await evaluate(client, `({ + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + status: document.getElementById('motion-status')?.textContent ?? null, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert( + afterReducedMotion.status === 'motion x 0.00 y 0.00 z 0.00', + `Reduced motion did not neutralize active motion; status was ${afterReducedMotion.status}`, + ); + assert( + afterReducedMotion.motionStatus?.active === false, + 'Reduced motion did not stop the device orientation listener.', + ); + assert( + !afterReducedMotion.listeners.deviceorientation, + 'Device orientation listener leaked while reduced motion was enabled.', + ); + + await client.send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }], + }); + await delay(350); + + const afterReducedMotionRestore = await evaluate(client, `({ + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert( + afterReducedMotionRestore.motionStatus?.active === true, + 'Device orientation listener did not restart after reduced motion was disabled.', + ); + assert( + afterReducedMotionRestore.listeners.deviceorientation === 1, + `Expected one deviceorientation listener after reduced motion restore, got ${afterReducedMotionRestore.listeners.deviceorientation}.`, + ); + await client.send('DeviceOrientation.setDeviceOrientationOverride', { alpha: 180, beta: 50, @@ -563,6 +606,13 @@ try { pathChanged: afterSpoof.firstPath !== initial.firstPath, idleResetStatus: afterIdleReset.status, }, + reducedMotion: { + status: afterReducedMotion.status, + activeAfterReduce: afterReducedMotion.motionStatus?.active, + listenersAfterReduce: afterReducedMotion.listeners, + activeAfterRestore: afterReducedMotionRestore.motionStatus?.active, + listenersAfterRestore: afterReducedMotionRestore.listeners, + }, cdpOrientation: { status: afterCdpOrientation.status, events: afterCdpOrientation.events.length, From 63c3e526fd906473cd857cc001f1aec5b6f90320 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 21:10:44 -0400 Subject: [PATCH 42/57] fix(motion): support legacy reduced-motion listeners --- src/motion/DeviceMotion.ts | 29 +++++++++++++++++-- tests/unit/device-motion.test.ts | 48 ++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index 63a7a0b..70ef5ef 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -47,6 +47,11 @@ interface MotionWindow { }; } +interface LegacyMediaQueryList { + addListener?: (listener: () => void) => void; + removeListener?: (listener: () => void) => void; +} + const DEFAULTS = { oneEuroMinCutoff: 0.5, oneEuroBeta: 0.01, @@ -107,6 +112,24 @@ function getScreenOrientationAngle(): number { return screen.orientation?.angle ?? 0; } +function addMediaQueryChangeListener(mql: MediaQueryList, listener: () => void): void { + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', listener); + return; + } + + (mql as LegacyMediaQueryList).addListener?.(listener); +} + +function removeMediaQueryChangeListener(mql: MediaQueryList, listener: () => void): void { + if (typeof mql.removeEventListener === 'function') { + mql.removeEventListener('change', listener); + return; + } + + (mql as LegacyMediaQueryList).removeListener?.(listener); +} + export class DeviceMotion { private readonly callback: DeviceMotionCallback; private readonly opts: Required; @@ -245,7 +268,7 @@ export class DeviceMotion { this.stopListening(); if (this.reducedMotionMql && this.reducedMotionListener) { - this.reducedMotionMql.removeEventListener('change', this.reducedMotionListener); + removeMediaQueryChangeListener(this.reducedMotionMql, this.reducedMotionListener); } this.reducedMotionMql = null; this.reducedMotionListener = null; @@ -295,7 +318,9 @@ export class DeviceMotion { this.startListening(); } }; - this.reducedMotionMql?.addEventListener('change', this.reducedMotionListener); + if (this.reducedMotionMql) { + addMediaQueryChangeListener(this.reducedMotionMql, this.reducedMotionListener); + } } private prefersReducedMotion(): boolean { diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts index ad8047f..256b4fa 100644 --- a/tests/unit/device-motion.test.ts +++ b/tests/unit/device-motion.test.ts @@ -20,6 +20,7 @@ function createMotionEnvironment(options: { permission?: () => Promise; reducedMotion?: boolean; angle?: number; + legacyReducedMotionListener?: boolean; } = {}) { const windowListeners = new Map>(); const documentListeners = new Map>(); @@ -44,12 +45,23 @@ function createMotionEnvironment(options: { const mql = { matches: options.reducedMotion ?? false, - addEventListener: vi.fn((_type: string, listener: () => void) => { - mqlListeners.add(listener); - }), - removeEventListener: vi.fn((_type: string, listener: () => void) => { - mqlListeners.delete(listener); - }), + ...(options.legacyReducedMotionListener + ? { + addListener: vi.fn((listener: () => void) => { + mqlListeners.add(listener); + }), + removeListener: vi.fn((listener: () => void) => { + mqlListeners.delete(listener); + }), + } + : { + addEventListener: vi.fn((_type: string, listener: () => void) => { + mqlListeners.add(listener); + }), + removeEventListener: vi.fn((_type: string, listener: () => void) => { + mqlListeners.delete(listener); + }), + }), }; const motionWindow: MockMotionWindow = { @@ -390,6 +402,30 @@ describe('DeviceMotion', () => { expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); }); + it('supports legacy reduced-motion media query listeners', async () => { + const env = createMotionEnvironment({ legacyReducedMotionListener: true }); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await expect(motion.initialize()).resolves.toBe(true); + expect(env.mql.addListener).toHaveBeenCalledWith(expect.any(Function)); + + now = 10; + env.dispatchOrientation(45, 0); + env.mql.matches = true; + env.dispatchReducedMotionChange(); + + expect(motion.isActive()).toBe(false); + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + + motion.cleanup(); + expect(env.mql.removeListener).toHaveBeenCalledWith(expect.any(Function)); + }); + it('restarts after reduced motion is disabled when no permission prompt is needed', async () => { const env = createMotionEnvironment({ reducedMotion: true }); const motion = new DeviceMotion(vi.fn()); From 3f011e223aac906f0ec1ad80510fcfe29be17a56 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 21:23:06 -0400 Subject: [PATCH 43/57] docs(browser): note reduced-motion probe coverage --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 906b227..785a854 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Useful extra commands: - `pnpm dev` runs the local Vite demo app - `pnpm dev:watch` rebuilds the library on change - `pnpm test:pbt` runs the property-based invariants only -- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, and CDP accelerometer input +- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, reduced-motion listener lifecycle, and CDP accelerometer input - `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned - `pnpm check:package` runs `publint` - `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized @@ -115,7 +115,7 @@ The dev app includes a browser/device harness for interaction work: - Use the panel toggles to isolate pointer, scroll, and device-motion physics. - Use `Spoof Tilt` and `Neutral Tilt` to verify TinyVectors motion wiring without relying on browser sensor tooling. - On a phone or tablet, open the dev URL, tap `Request Motion`, keep the device still, tap `Calibrate`, then tilt the device. -- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP accelerometer override path. +- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP reduced-motion media emulation and accelerometer override paths. ## Release Truth From 1a6a1e4d899749cb0688f2ede7a28cb4a72a5fcd Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 21:26:25 -0400 Subject: [PATCH 44/57] fix(scroll): honor max pull-force config --- src/motion/ScrollHandler.ts | 9 +++++++-- tests/unit/scroll-handler.test.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/motion/ScrollHandler.ts b/src/motion/ScrollHandler.ts index 412945e..b47fd96 100644 --- a/src/motion/ScrollHandler.ts +++ b/src/motion/ScrollHandler.ts @@ -20,9 +20,13 @@ export class ScrollHandler { private decayFrame: number | null = null; private scrollEndTimer: ReturnType | null = null; private disposed = false; + private maxForces: number | null = null; constructor(config?: ScrollHandlerConfig) { if (config?.decayRate) this.decayRate = config.decayRate; + if (typeof config?.maxForces === 'number') { + this.maxForces = Math.max(0, Math.floor(config.maxForces)); + } } public handleScroll(event: WheelEvent): void { @@ -110,8 +114,9 @@ export class ScrollHandler { explosive, }); - if (this.pullForces.length > (explosive ? 10 : 8)) { - this.pullForces.shift(); + const maxForces = this.maxForces ?? (explosive ? 10 : 8); + if (this.pullForces.length > maxForces) { + this.pullForces.splice(0, this.pullForces.length - maxForces); } } } diff --git a/tests/unit/scroll-handler.test.ts b/tests/unit/scroll-handler.test.ts index cb5ba21..cb96910 100644 --- a/tests/unit/scroll-handler.test.ts +++ b/tests/unit/scroll-handler.test.ts @@ -62,6 +62,26 @@ describe('ScrollHandler', () => { expect(requestAnimationFrame).toHaveBeenCalledTimes(2); }); + it('honors caller-configured pull-force caps', () => { + const handler = new ScrollHandler({ maxForces: 2 }); + + for (let i = 0; i < 5; i++) { + vi.setSystemTime(1_000 + i * 16); + handler.handleScroll({ deltaY: 240 } as WheelEvent); + } + + expect(handler.getPullForces()).toHaveLength(2); + }); + + it('allows callers to disable retained pull forces', () => { + const handler = new ScrollHandler({ maxForces: 0 }); + + handler.handleScroll({ deltaY: 240 } as WheelEvent); + + expect(handler.getStickiness()).toBeGreaterThan(0); + expect(handler.getPullForces()).toEqual([]); + }); + it('cleans up scheduled decay and scroll-end work', () => { const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); const handler = new ScrollHandler(); From fe958b462b863cbf6dee58d79de62ede571a394f Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 21:50:12 -0400 Subject: [PATCH 45/57] test(field): harden interaction invariants --- tests/unit/interaction-field.test.ts | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/interaction-field.test.ts b/tests/unit/interaction-field.test.ts index 4aa7d7f..d17cb04 100644 --- a/tests/unit/interaction-field.test.ts +++ b/tests/unit/interaction-field.test.ts @@ -36,6 +36,16 @@ describe('InteractionField', () => { expect(vector).toEqual({ x: 1, y: 0 }); }); + it('INVARIANT: combines empty and opposing fields to neutral', () => { + expect(combineFieldVectors([])).toEqual({ x: 0, y: 0 }); + expect( + combineFieldVectors([ + { x: 0.35, y: -0.2 }, + { x: -0.35, y: 0.2 }, + ]), + ).toEqual({ x: 0, y: 0 }); + }); + it('converts gravity-like input into a bounded directional bias', () => { const vector = directionalBiasField({ x: 0.25, y: 1 }, 0.8, 0.5); @@ -43,6 +53,13 @@ describe('InteractionField', () => { expect(vectorMagnitude(vector)).toBeLessThanOrEqual(0.5); }); + it('INVARIANT: directional bias preserves direction when clamped', () => { + const vector = directionalBiasField({ x: 3, y: 4 }, 1, 1); + + expect(vectorMagnitude(vector)).toBeCloseTo(1); + expect(vector.x / vector.y).toBeCloseTo(3 / 4); + }); + it('uses smooth local falloff for point fields', () => { const atCenter = smoothDistanceFalloff(-5, 50); const near = smoothDistanceFalloff(10, 50); @@ -74,4 +91,25 @@ describe('InteractionField', () => { expect(vectorMagnitude(near)).toBeGreaterThan(vectorMagnitude(far)); expect(far).toEqual({ x: 0, y: 0 }); }); + + it('INVARIANT: point fields stay bounded by strength and fall off with distance', () => { + const target = { x: 50, y: 50 }; + const strength = 0.4; + const close = pointAttractorField({ + origin: { x: 45, y: 50 }, + target, + radius: 40, + strength, + }); + const farther = pointAttractorField({ + origin: { x: 30, y: 50 }, + target, + radius: 40, + strength, + }); + + expect(vectorMagnitude(close)).toBeLessThanOrEqual(strength); + expect(vectorMagnitude(farther)).toBeLessThanOrEqual(strength); + expect(vectorMagnitude(close)).toBeGreaterThan(vectorMagnitude(farther)); + }); }); From 236dd1a797c0a52be5cf62858a3f4fc585db76f9 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 22:10:29 -0400 Subject: [PATCH 46/57] test(package): cover scroll handler config exports --- scripts/check-package-consumer.mjs | 7 ++++++- src/motion/ScrollHandler.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/check-package-consumer.mjs b/scripts/check-package-consumer.mjs index 7a743f5..b3a9784 100644 --- a/scripts/check-package-consumer.mjs +++ b/scripts/check-package-consumer.mjs @@ -76,6 +76,7 @@ if (missing.length > 0) { join(tempDir, 'consumer-types.ts'), ` import { BlobPhysics, DeviceMotion, TinyVectors, THEME_PRESETS } from '@tummycrypt/tinyvectors'; +import type { ScrollHandlerConfig as RootScrollHandlerConfig } from '@tummycrypt/tinyvectors'; import type { ThemePreset, ThemePresetName } from '@tummycrypt/tinyvectors/core'; import { \tScrollHandler, @@ -84,6 +85,7 @@ import { \ttype MotionVector, \ttype PointerBounds, \ttype PointerCancelEventName, +\ttype ScrollHandlerConfig, } from '@tummycrypt/tinyvectors/motion'; import { getThemePreset } from '@tummycrypt/tinyvectors/themes'; import { @@ -96,6 +98,9 @@ import type { ComponentProps } from 'svelte'; const bounds: PointerBounds = { left: 0, top: 0, width: 100, height: 100 }; const cancelEvent: PointerCancelEventName = 'pointercancel'; +const scrollConfig: ScrollHandlerConfig = { decayRate: 0.9, maxForces: 2 }; +const rootScrollConfig: RootScrollHandlerConfig = { maxForces: 0 }; +const scrollHandler = new ScrollHandler(scrollConfig); const point = mapClientPointToPhysics(50, 50, bounds); const sample: MotionVector = { x: 0, y: 0, z: 1 }; const props: ComponentProps = { theme: 'tinyland', enableDeviceMotion: true }; @@ -110,7 +115,7 @@ const motionStatus: TinyVectorsDeviceMotionStatus = { const blobProps: BlobSVGProps = { blobs: [] }; const themeName: ThemePresetName = 'tinyland'; const themePreset: ThemePreset = THEME_PRESETS[themeName]; -const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, point, sample, explicitProps, motionStatus, blobProps, themePreset, cancelEvent]; +const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, scrollHandler, scrollConfig, rootScrollConfig, point, sample, explicitProps, motionStatus, blobProps, themePreset, cancelEvent]; console.log(names.length); `.trimStart(), ); diff --git a/src/motion/ScrollHandler.ts b/src/motion/ScrollHandler.ts index b47fd96..3b3c389 100644 --- a/src/motion/ScrollHandler.ts +++ b/src/motion/ScrollHandler.ts @@ -1,5 +1,7 @@ export interface ScrollHandlerConfig { + /** Per-frame decay multiplier for scroll stickiness and velocity. Defaults to 0.92. */ decayRate?: number; + /** Maximum retained pull-force impulses. Defaults to 8, or 10 for explosive scrolls. Use 0 to keep scroll stickiness without retained pull forces. */ maxForces?: number; } From a6d8ae458a9ae571df5a09589096aceb246d79a8 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 22:20:10 -0400 Subject: [PATCH 47/57] feat(physics): route gravity through field helper --- docs/physics-feel-contract.md | 7 ++++--- docs/release-flow.md | 2 +- scripts/check-bundle-size.mjs | 17 +++++------------ src/core/BlobPhysics.ts | 9 ++++----- tests/unit/core.test.ts | 19 +++++++++++++++++++ 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index fa33975..de66a9f 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -48,6 +48,7 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s 1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work. 2. Add pure field helpers and unit tests without changing runtime feel. -3. Route existing gravity, pointer, and scroll values through field helpers one input at a time. -4. Add browser probes for directional bias, pointer locality, and scroll decay. -5. Revisit renderer stylability after interaction feel is stable. +3. Route gravity/device-orientation through the field helper while preserving ambient motion. +4. Route pointer and scroll values through field helpers one input at a time. +5. Add browser probes for directional bias, pointer locality, and scroll decay. +6. Revisit renderer stylability after interaction feel is stable. diff --git a/docs/release-flow.md b/docs/release-flow.md index 1437e5a..3c72bf3 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -33,7 +33,7 @@ npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg `pnpm run check:bundle-size` measures a realistic tree-shaken consumer import, `import { TinyVectors } from '@tummycrypt/tinyvectors'`, with Svelte externalized as a peer dependency. `//:bundle_size_check` runs the same measurement against the Bazel-built package artifact. The current gate is 12 KiB gzip and the target remains 11 KiB gzip, so the check reports target headroom or overage while leaving a small CI buffer. -The bundle-size check also asserts that internal future-work modules stay out of that consumer bundle. For example, `dist/core/InteractionField.js` is allowed to ship as an internal preserved module, but it must not be pulled into the `{ TinyVectors }` bundle until runtime physics actually imports it. +The bundle-size check also reports tracked runtime modules that enter that consumer bundle. `dist/core/InteractionField.js` is expected to appear once runtime physics routes an input through the field contract, and the gzip result is the source of truth for whether that cost is acceptable. `bazel query //...` should also work locally. `.bazelignore` excludes direnv, Nix, package-manager, and build-output directories so Bazel does not walk generated local artifacts. diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index f4f1369..7365b0d 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -11,7 +11,7 @@ const packageRoot = resolve(process.cwd(), process.argv[2] ?? '.'); const distEntry = resolve(packageRoot, 'dist/index.js'); const targetGzipKiB = parsePositiveKiB('TINYVECTORS_TARGET_GZIP_KIB', 11); const maxGzipKiB = parsePositiveKiB('TINYVECTORS_MAX_GZIP_KIB', 12); -const forbiddenConsumerModules = ['dist/core/InteractionField.js']; +const trackedConsumerModules = ['dist/core/InteractionField.js']; if (maxGzipKiB < targetGzipKiB) { console.error( @@ -65,20 +65,10 @@ console.log(TinyVectors); chunk.moduleIds.map((moduleId) => moduleId.replaceAll('\\', '/')), ), ); - const forbiddenIncluded = forbiddenConsumerModules.filter((modulePath) => + const trackedIncluded = trackedConsumerModules.filter((modulePath) => [...includedModules].some((moduleId) => moduleId.endsWith(`/${modulePath}`)), ); - if (forbiddenIncluded.length > 0) { - console.error( - [ - 'Consumer bundle included internal future-work modules:', - ...forbiddenIncluded.map((modulePath) => `- ${modulePath}`), - ].join('\n'), - ); - process.exit(1); - } - const js = outputs .filter((item) => item.type === 'chunk') .map((item) => item.code) @@ -96,6 +86,9 @@ console.log(TinyVectors); targetDelta <= 0 ? `target headroom ${Math.abs(targetDelta).toFixed(2)} KiB` : `target overage ${targetDelta.toFixed(2)} KiB`, + trackedIncluded.length > 0 + ? `tracked modules included: ${trackedIncluded.join(', ')}` + : 'tracked modules included: none', ].join('\n'), ); diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index bc6af1c..dde5273 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -16,6 +16,7 @@ import type { ConvexBlob, GravityVector, TiltVector } from './types.js'; import { SpatialHash } from './SpatialHash.js'; import { GaussianKernel } from './GaussianKernel.js'; import { SpringSystem, DEFAULT_SPRING_CONFIG, type SpringConfig } from './SpringSystem.js'; +import { directionalBiasField } from './InteractionField.js'; export interface BlobPhysicsConfig { antiClusteringStrength: number; @@ -473,12 +474,10 @@ export class BlobPhysics { private applyAccelerometerForces(blob: ConvexBlob): void { const accelerometerStrength = 0.0008; const maxForce = 0.003; + const gravityField = directionalBiasField(this.gravity, accelerometerStrength, maxForce); - const gravityX = Math.max(-maxForce, Math.min(maxForce, this.gravity.x * accelerometerStrength)); - const gravityY = Math.max(-maxForce, Math.min(maxForce, this.gravity.y * accelerometerStrength)); - - blob.velocityX += gravityX; - blob.velocityY += gravityY; + blob.velocityX += gravityField.x; + blob.velocityY += gravityField.y; if (blob.controlPoints && (Math.abs(this.gravity.x) > 0.3 || Math.abs(this.gravity.y) > 0.3)) { diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index ac095a3..e28da78 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -287,6 +287,25 @@ describe('BlobPhysics', () => { expect(rotatedBack[i]).toBeCloseTo(expected[i], 10); } }); + + it('applies device gravity as a bounded directional field', () => { + const physics = new BlobPhysics(0); + const blob = createTestConvexBlob(50, 50, 20); + const applyAccelerometerForces = ( + physics as unknown as { + applyAccelerometerForces(blob: ConvexBlob): void; + } + ).applyAccelerometerForces.bind(physics); + + physics.setGravity({ x: 3, y: 4 }); + applyAccelerometerForces(blob); + + const magnitude = Math.sqrt(blob.velocityX * blob.velocityX + blob.velocityY * blob.velocityY); + expect(magnitude).toBeCloseTo(0.003); + expect(blob.velocityX).toBeGreaterThan(0); + expect(blob.velocityY).toBeGreaterThan(0); + expect(blob.velocityX / blob.velocityY).toBeCloseTo(3 / 4); + }); }); From 8c29ecef3c7268848bd72735e5aba452acba7260 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 22:24:10 -0400 Subject: [PATCH 48/57] perf(physics): cache gravity field force --- src/core/BlobPhysics.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index dde5273..05cb188 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -18,6 +18,9 @@ import { GaussianKernel } from './GaussianKernel.js'; import { SpringSystem, DEFAULT_SPRING_CONFIG, type SpringConfig } from './SpringSystem.js'; import { directionalBiasField } from './InteractionField.js'; +const ACCELEROMETER_STRENGTH = 0.0008; +const ACCELEROMETER_MAX_FORCE = 0.003; + export interface BlobPhysicsConfig { antiClusteringStrength: number; bounceDamping: number; @@ -62,6 +65,7 @@ export class BlobPhysics { private gravity: GravityVector = { x: 0, y: 0 }; + private gravityField: GravityVector = { x: 0, y: 0 }; private tilt: TiltVector = { x: 0, y: 0, z: 0 }; private scrollStickiness = 0; @@ -116,6 +120,11 @@ export class BlobPhysics { setGravity(gravity: GravityVector): void { this.gravity = gravity; + this.gravityField = directionalBiasField( + gravity, + ACCELEROMETER_STRENGTH, + ACCELEROMETER_MAX_FORCE, + ); } @@ -472,12 +481,8 @@ export class BlobPhysics { } private applyAccelerometerForces(blob: ConvexBlob): void { - const accelerometerStrength = 0.0008; - const maxForce = 0.003; - const gravityField = directionalBiasField(this.gravity, accelerometerStrength, maxForce); - - blob.velocityX += gravityField.x; - blob.velocityY += gravityField.y; + blob.velocityX += this.gravityField.x; + blob.velocityY += this.gravityField.y; if (blob.controlPoints && (Math.abs(this.gravity.x) > 0.3 || Math.abs(this.gravity.y) > 0.3)) { From 6ad98bd67b16ea858186399a4f71ed3ad7bb9096 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 22:37:50 -0400 Subject: [PATCH 49/57] perf(field): inline directional bias clamp --- src/core/InteractionField.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/core/InteractionField.ts b/src/core/InteractionField.ts index e5ef781..62ec522 100644 --- a/src/core/InteractionField.ts +++ b/src/core/InteractionField.ts @@ -46,13 +46,19 @@ export function directionalBiasField( strength: number, maxMagnitude = 1, ): FieldVector { - return clampFieldVector( - { - x: input.x * strength, - y: input.y * strength, - }, - maxMagnitude, - ); + const max = Math.max(0, maxMagnitude); + const x = input.x * strength; + const y = input.y * strength; + const currentMagnitude = Math.sqrt(x * x + y * y); + + if (max === 0 || currentMagnitude === 0) return { x: 0, y: 0 }; + if (currentMagnitude <= max) return { x, y }; + + const scale = max / currentMagnitude; + return { + x: x * scale, + y: y * scale, + }; } export function smoothDistanceFalloff(distance: number, radius: number): number { From 7da72e36514a13dc23af2e78a4791139b844fc30 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 22:50:09 -0400 Subject: [PATCH 50/57] test(browser): assert orientation direction signs --- README.md | 2 +- scripts/probe-motion-cdp.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 785a854..3b8de5a 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Useful extra commands: - `pnpm dev` runs the local Vite demo app - `pnpm dev:watch` rebuilds the library on change - `pnpm test:pbt` runs the property-based invariants only -- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, reduced-motion listener lifecycle, and CDP accelerometer input +- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input - `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned - `pnpm check:package` runs `publint` - `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index 24b5fd6..4aaac0e 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -192,6 +192,16 @@ function assert(condition, message) { } } +function parseMotionStatus(status) { + const match = /^motion x (-?\d+(?:\.\d+)?) y (-?\d+(?:\.\d+)?) z (-?\d+(?:\.\d+)?)$/.exec(status ?? ''); + if (!match) return null; + return { + x: Number(match[1]), + y: Number(match[2]), + z: Number(match[3]), + }; +} + let chromeProfile; let client; @@ -391,6 +401,12 @@ try { ); assert(afterSpoof.events.length > initial.events.length, 'Synthetic orientation was not observed.'); assert(afterSpoof.firstPath !== initial.firstPath, 'Synthetic orientation did not change blob geometry.'); + const syntheticMotion = parseMotionStatus(afterSpoof.status); + assert(syntheticMotion, `Synthetic orientation status was not parseable: ${afterSpoof.status}`); + assert( + syntheticMotion.x < 0 && syntheticMotion.y > 0, + `Synthetic orientation did not preserve expected direction; got ${afterSpoof.status}`, + ); await delay(550); const afterIdleReset = await evaluate(client, `({ @@ -467,6 +483,15 @@ try { afterCdpOrientation.firstPath !== afterSpoof.firstPath, 'CDP device orientation override did not change blob geometry.', ); + const cdpOrientationMotion = parseMotionStatus(afterCdpOrientation.status); + assert( + cdpOrientationMotion, + `CDP orientation status was not parseable: ${afterCdpOrientation.status}`, + ); + assert( + cdpOrientationMotion.x < 0 && cdpOrientationMotion.y > 0, + `CDP orientation did not preserve expected direction; got ${afterCdpOrientation.status}`, + ); const listenerProbeUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=true&scrollPhysics=true&blobs=8&listenerProbe=1`; await client.send('Page.navigate', { url: listenerProbeUrl }); @@ -602,6 +627,7 @@ try { }, syntheticOrientation: { status: afterSpoof.status, + motion: syntheticMotion, events: afterSpoof.events.length, pathChanged: afterSpoof.firstPath !== initial.firstPath, idleResetStatus: afterIdleReset.status, @@ -615,6 +641,7 @@ try { }, cdpOrientation: { status: afterCdpOrientation.status, + motion: cdpOrientationMotion, events: afterCdpOrientation.events.length, pathChanged: afterCdpOrientation.firstPath !== afterSpoof.firstPath, lastEvent: afterCdpOrientation.events.at(-1), From d254179caa9222dcdaef6f5670be80589597ea1d Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 22:52:27 -0400 Subject: [PATCH 51/57] docs(physics): record field route status --- docs/physics-feel-contract.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index de66a9f..9dce22c 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -44,6 +44,12 @@ Tests should describe perceptual behavior in tolerant terms: Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off screenshot pixels unless the assertion is about a real compatibility contract. +## Current Status + +- Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. +- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion. +- Pointer and scroll still use the restored pre-Phase-A physics path. Route them through fields only after preserving the current feel and bundle headroom. + ## Implementation Slices 1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work. From 481a5b95bf84cf5ca002a067844c9011ffe5ee20 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 23:07:06 -0400 Subject: [PATCH 52/57] test(physics): capture pointer state contract --- docs/physics-feel-contract.md | 3 ++- tests/unit/core.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 9dce22c..316ed25 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -48,7 +48,8 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s - Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. - The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion. -- Pointer and scroll still use the restored pre-Phase-A physics path. Route them through fields only after preserving the current feel and bundle headroom. +- Pointer IO currently updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; it does not apply a standalone pointer force yet. +- Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom. ## Implementation Slices diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index e28da78..8c6f339 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -306,6 +306,29 @@ describe('BlobPhysics', () => { expect(blob.velocityY).toBeGreaterThan(0); expect(blob.velocityX / blob.velocityY).toBeCloseTo(3 / 4); }); + + it('tracks pointer position and velocity without applying standalone pointer force', () => { + const physics = new BlobPhysics(0); + const blob = createTestConvexBlob(30, 50, 20); + const internals = physics as unknown as { + mouseX: number; + mouseY: number; + mouseVelX: number; + mouseVelY: number; + updateScreensaverPhysics(blob: ConvexBlob, deltaTime: number, time: number): void; + }; + + physics.updateMousePosition(75, 25); + + expect(internals.mouseX).toBe(75); + expect(internals.mouseY).toBe(25); + expect(internals.mouseVelX).toBe(25); + expect(internals.mouseVelY).toBe(-25); + + internals.updateScreensaverPhysics(blob, 0.016, 0); + + expect(blob.mouseDistance).toBeCloseTo(Math.sqrt((30 - 75) ** 2 + (50 - 25) ** 2)); + }); }); From 269fe0b4bcbdea69cd0330f4124593eedbc23997 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 23:12:12 -0400 Subject: [PATCH 53/57] perf(physics): smooth blobs without winding bias --- src/core/BlobPhysics.ts | 24 +++++++++++-------- tests/unit/core.test.ts | 53 +++++++++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 05cb188..e5228f1 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -77,6 +77,7 @@ export class BlobPhysics { private spatialHash: SpatialHash; private gaussianKernel: GaussianKernel; private springSystem: SpringSystem; + private controlRadiusScratch: number[] = []; constructor(numBlobs: number, config: Partial = {}) { this.numBlobs = numBlobs; @@ -663,11 +664,16 @@ export class BlobPhysics { private smoothControlPoints(blob: ConvexBlob): void { if (!blob.controlPoints || blob.controlPoints.length < 3) return; - const originalRadii = blob.controlPoints.map((point) => point.radius); - const pointCount = blob.controlPoints.length; + const controlPoints = blob.controlPoints; + const originalRadii = this.controlRadiusScratch; + const pointCount = controlPoints.length; for (let i = 0; i < pointCount; i++) { - const current = blob.controlPoints[i]; + originalRadii[i] = controlPoints[i].radius; + } + + for (let i = 0; i < pointCount; i++) { + const current = controlPoints[i]; const prevRadius = originalRadii[(i - 1 + pointCount) % pointCount]; const currentRadius = originalRadii[i]; const nextRadius = originalRadii[(i + 1) % pointCount]; @@ -679,13 +685,11 @@ export class BlobPhysics { const minRadiusDiff = blob.size * 0.1; - if (Math.abs(current.radius - prevRadius) > minRadiusDiff) { - const adjustment = (Math.abs(current.radius - prevRadius) - minRadiusDiff) * 0.5; - if (current.radius > prevRadius) { - current.radius -= adjustment; - } else { - current.radius += adjustment; - } + const neighborRadius = (prevRadius + nextRadius) * 0.5; + const radiusDiff = current.radius - neighborRadius; + const excessRadiusDiff = Math.abs(radiusDiff) - minRadiusDiff; + if (excessRadiusDiff > 0) { + current.radius -= radiusDiff > 0 ? excessRadiusDiff * 0.5 : -excessRadiusDiff * 0.5; } } } diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index 8c6f339..eb1b864 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -98,6 +98,24 @@ function createTestConvexBlob(x: number, y: number, size: number = 25): ConvexBl }; } +function smoothTestControlRadii(radii: number[]): number[] { + const blob = createTestConvexBlob(50, 50, 20); + blob.controlPoints?.forEach((point, index) => { + point.radius = radii[index]; + point.baseRadius = radii[index]; + point.targetRadius = radii[index]; + }); + + const physics = new BlobPhysics(0); + ( + physics as unknown as { + smoothControlPoints(blob: ConvexBlob): void; + } + ).smoothControlPoints(blob); + + return blob.controlPoints?.map((point) => point.radius) ?? []; +} + @@ -258,28 +276,10 @@ describe('BlobPhysics', () => { }); it('smooths control points without start-index directional bias', () => { - const runSmoothing = (radii: number[]) => { - const blob = createTestConvexBlob(50, 50, 20); - blob.controlPoints?.forEach((point, index) => { - point.radius = radii[index]; - point.baseRadius = radii[index]; - point.targetRadius = radii[index]; - }); - - const physics = new BlobPhysics(0); - ( - physics as unknown as { - smoothControlPoints(blob: ConvexBlob): void; - } - ).smoothControlPoints(blob); - - return blob.controlPoints?.map((point) => point.radius) ?? []; - }; - const radii = [20, 40, 20, 10, 30, 20, 35, 15]; const rotatedRadii = [radii[radii.length - 1], ...radii.slice(0, -1)]; - const expected = runSmoothing(radii); - const rotatedResult = runSmoothing(rotatedRadii); + const expected = smoothTestControlRadii(radii); + const rotatedResult = smoothTestControlRadii(rotatedRadii); const rotatedBack = [...rotatedResult.slice(1), rotatedResult[0]]; expect(rotatedBack).toHaveLength(expected.length); @@ -288,6 +288,19 @@ describe('BlobPhysics', () => { } }); + it('smooths control points without winding-order directional bias', () => { + const radii = [20, 40, 20, 10, 30, 20, 35, 15]; + const mirroredRadii = [radii[0], ...radii.slice(1).reverse()]; + const expected = smoothTestControlRadii(radii); + const mirroredResult = smoothTestControlRadii(mirroredRadii); + const mirroredBack = [mirroredResult[0], ...mirroredResult.slice(1).reverse()]; + + expect(mirroredBack).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(mirroredBack[i]).toBeCloseTo(expected[i], 10); + } + }); + it('applies device gravity as a bounded directional field', () => { const physics = new BlobPhysics(0); const blob = createTestConvexBlob(50, 50, 20); From c8d0bbe15216869435249134366bdf3be3f09f28 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 23:32:41 -0400 Subject: [PATCH 54/57] fix(physics): compute pointer velocity from current anchor --- src/core/BlobPhysics.ts | 10 ++++++---- tests/unit/core.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index e5228f1..1f22e88 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -210,10 +210,12 @@ export class BlobPhysics { updateMousePosition(x: number, y: number): void { - this.mouseVelX = x - this.lastMouseX; - this.mouseVelY = y - this.lastMouseY; - this.lastMouseX = this.mouseX; - this.lastMouseY = this.mouseY; + const previousMouseX = this.mouseX; + const previousMouseY = this.mouseY; + this.mouseVelX = x - previousMouseX; + this.mouseVelY = y - previousMouseY; + this.lastMouseX = previousMouseX; + this.lastMouseY = previousMouseY; this.mouseX = x; this.mouseY = y; } diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index eb1b864..5fa4b9d 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -342,6 +342,24 @@ describe('BlobPhysics', () => { expect(blob.mouseDistance).toBeCloseTo(Math.sqrt((30 - 75) ** 2 + (50 - 25) ** 2)); }); + + it('computes pointer velocity from the previous pointer anchor', () => { + const physics = new BlobPhysics(0); + const internals = physics as unknown as { + lastMouseX: number; + lastMouseY: number; + mouseVelX: number; + mouseVelY: number; + }; + + physics.updateMousePosition(75, 25); + physics.updateMousePosition(80, 20); + + expect(internals.mouseVelX).toBe(5); + expect(internals.mouseVelY).toBe(-5); + expect(internals.lastMouseX).toBe(75); + expect(internals.lastMouseY).toBe(25); + }); }); From 6fc548f7de294f11fb7adfc8b8e986bad54c1d03 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 1 May 2026 00:08:49 -0400 Subject: [PATCH 55/57] feat(physics): add local pointer field --- docs/physics-feel-contract.md | 4 ++-- src/core/BlobPhysics.ts | 21 +++++++++++++++++---- src/core/InteractionField.ts | 5 +++-- tests/unit/core.test.ts | 30 +++++++++++++++++++++++++----- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 316ed25..8732ab7 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -48,7 +48,7 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s - Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. - The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion. -- Pointer IO currently updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; it does not apply a standalone pointer force yet. +- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; the first standalone route applies a small local pointer field only after real pointer input. - Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom. ## Implementation Slices @@ -56,6 +56,6 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s 1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work. 2. Add pure field helpers and unit tests without changing runtime feel. 3. Route gravity/device-orientation through the field helper while preserving ambient motion. -4. Route pointer and scroll values through field helpers one input at a time. +4. Route pointer and scroll values through field helpers one input at a time, starting with a low-strength local pointer field. 5. Add browser probes for directional bias, pointer locality, and scroll decay. 6. Revisit renderer stylability after interaction feel is stable. diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 1f22e88..1e54972 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -60,8 +60,6 @@ export class BlobPhysics { private mouseY = 50; private mouseVelX = 0; private mouseVelY = 0; - private lastMouseX = 50; - private lastMouseY = 50; private gravity: GravityVector = { x: 0, y: 0 }; @@ -214,8 +212,6 @@ export class BlobPhysics { const previousMouseY = this.mouseY; this.mouseVelX = x - previousMouseX; this.mouseVelY = y - previousMouseY; - this.lastMouseX = previousMouseX; - this.lastMouseY = previousMouseY; this.mouseX = x; this.mouseY = y; } @@ -457,6 +453,9 @@ export class BlobPhysics { this.applyAccelerometerForces(blob); + + this.applyPointerField(blob); + this.updateMovementWithAccelerometer(blob, time); @@ -494,6 +493,20 @@ export class BlobPhysics { } } + private applyPointerField(blob: ConvexBlob): void { + if (this.mouseX === 50 && this.mouseY === 50) return; + + const dx = this.mouseX - blob.currentX; + const dy = this.mouseY - blob.currentY; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance === 0 || distance >= 34) return; + + const normalized = 1 - distance / 34; + const scale = (normalized * normalized * 0.0014) / distance; + blob.velocityX += dx * scale; + blob.velocityY += dy * scale; + } + private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void { const neutralDriftX = (Math.random() - 0.5) * 0.001; diff --git a/src/core/InteractionField.ts b/src/core/InteractionField.ts index 62ec522..14bf8cd 100644 --- a/src/core/InteractionField.ts +++ b/src/core/InteractionField.ts @@ -78,9 +78,10 @@ export function pointAttractorField({ const dx = target.x - origin.x; const dy = target.y - origin.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance === 0) return { x: 0, y: 0 }; + if (distance === 0 || radius <= 0 || distance >= radius) return { x: 0, y: 0 }; - const falloff = smoothDistanceFalloff(distance, radius); + const normalized = 1 - distance / radius; + const falloff = normalized * normalized; const scale = (falloff * strength) / distance; return { x: dx * scale, diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index 5fa4b9d..726a96d 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -320,7 +320,7 @@ describe('BlobPhysics', () => { expect(blob.velocityX / blob.velocityY).toBeCloseTo(3 / 4); }); - it('tracks pointer position and velocity without applying standalone pointer force', () => { + it('tracks pointer position and velocity without applying distant pointer force', () => { const physics = new BlobPhysics(0); const blob = createTestConvexBlob(30, 50, 20); const internals = physics as unknown as { @@ -343,11 +343,33 @@ describe('BlobPhysics', () => { expect(blob.mouseDistance).toBeCloseTo(Math.sqrt((30 - 75) ** 2 + (50 - 25) ** 2)); }); + it('applies pointer influence as a local field after pointer input', () => { + const physics = new BlobPhysics(0); + const near = createTestConvexBlob(60, 50, 20); + const far = createTestConvexBlob(5, 50, 20); + const centered = createTestConvexBlob(60, 50, 20); + const internals = physics as unknown as { + applyPointerField(blob: ConvexBlob): void; + }; + + internals.applyPointerField(centered); + + expect(centered.velocityX).toBe(0); + expect(centered.velocityY).toBe(0); + + physics.updateMousePosition(75, 50); + internals.applyPointerField(near); + internals.applyPointerField(far); + + expect(near.velocityX).toBeGreaterThan(0); + expect(near.velocityY).toBe(0); + expect(far.velocityX).toBe(0); + expect(far.velocityY).toBe(0); + }); + it('computes pointer velocity from the previous pointer anchor', () => { const physics = new BlobPhysics(0); const internals = physics as unknown as { - lastMouseX: number; - lastMouseY: number; mouseVelX: number; mouseVelY: number; }; @@ -357,8 +379,6 @@ describe('BlobPhysics', () => { expect(internals.mouseVelX).toBe(5); expect(internals.mouseVelY).toBe(-5); - expect(internals.lastMouseX).toBe(75); - expect(internals.lastMouseY).toBe(25); }); }); From 5641f2836637e3aa0cbd32cdb517e067277feb7b Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 1 May 2026 11:24:33 -0400 Subject: [PATCH 56/57] test(browser): cover CDP pointer delivery --- README.md | 4 ++-- docs/physics-feel-contract.md | 4 ++-- scripts/probe-motion-cdp.mjs | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3b8de5a..e08f56b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Useful extra commands: - `pnpm dev` runs the local Vite demo app - `pnpm dev:watch` rebuilds the library on change - `pnpm test:pbt` runs the property-based invariants only -- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input +- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, pointer delivery, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input - `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned - `pnpm check:package` runs `publint` - `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized @@ -115,7 +115,7 @@ The dev app includes a browser/device harness for interaction work: - Use the panel toggles to isolate pointer, scroll, and device-motion physics. - Use `Spoof Tilt` and `Neutral Tilt` to verify TinyVectors motion wiring without relying on browser sensor tooling. - On a phone or tablet, open the dev URL, tap `Request Motion`, keep the device still, tap `Calibrate`, then tilt the device. -- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP reduced-motion media emulation and accelerometer override paths. +- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP pointer, reduced-motion media emulation, and accelerometer override paths. ## Release Truth diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 8732ab7..b7e7b7b 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -47,8 +47,8 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s ## Current Status - Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. -- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion. -- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; the first standalone route applies a small local pointer field only after real pointer input. +- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, return to neutral on idle or reduced motion, and receive a real CDP pointer move while pointer physics is active. +- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; unit coverage verifies the first standalone route applies a small local pointer field only after real pointer input. - Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom. ## Implementation Slices diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index 4aaac0e..ab73e77 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -319,6 +319,14 @@ try { at: performance.now() }); }); + originalAddEventListener.call(window, 'pointermove', (event) => { + window.__tinyvectorsEvents.push({ + type: 'pointermove', + x: event.clientX, + y: event.clientY, + at: performance.now() + }); + }); })(); `, }); @@ -535,6 +543,37 @@ try { `Expected one deviceorientation listener, got ${listenerInitial.listeners.deviceorientation}.`, ); + const beforePointerMove = await evaluate(client, `({ + firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null, + pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length + })`); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: 120, + y: 180, + button: 'none', + }); + await delay(500); + const afterPointerMove = await evaluate(client, `({ + firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null, + pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length, + lastPointerEvent: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').at(-1) + })`); + assert(beforePointerMove.firstBodyPath, 'Pointer probe could not read initial blob geometry.'); + assert(afterPointerMove.firstBodyPath, 'Pointer probe could not read updated blob geometry.'); + assert( + afterPointerMove.pointerEvents > beforePointerMove.pointerEvents, + 'CDP pointer move did not reach the page.', + ); + assert( + afterPointerMove.lastPointerEvent?.x === 120 && afterPointerMove.lastPointerEvent?.y === 180, + `CDP pointer move reached the page with unexpected coordinates ${JSON.stringify(afterPointerMove.lastPointerEvent)}.`, + ); + assert( + afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath, + 'Pointer probe did not observe animated blob geometry movement after pointer delivery.', + ); + await client.send('Runtime.evaluate', { expression: `document.getElementById('scroll-physics')?.click()`, awaitPromise: true, @@ -653,6 +692,11 @@ try { pathChanged: cdpAccelerometerChanged, note: 'TinyVectors uses DeviceOrientationEvent/TiltSource; raw accelerometer CDP is informational.', }, + pointerDelivery: { + events: afterPointerMove.pointerEvents - beforePointerMove.pointerEvents, + pathChanged: afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath, + lastEvent: afterPointerMove.lastPointerEvent, + }, listenerLifecycle: { initial: listenerInitial.listeners, afterScrollOff: afterScrollOff.listeners, From 749f72a6f6a10d5a60938e7abc1c863db6fafaa4 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 1 May 2026 11:42:42 -0400 Subject: [PATCH 57/57] chore(release): prepare tinyvectors 0.3.0 --- BUILD.bazel | 3 ++- CHANGELOG.md | 9 +++++++++ MODULE.bazel | 4 ++-- MODULE.bazel.lock | 2 +- package.json | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md diff --git a/BUILD.bazel b/BUILD.bazel index 9a90022..030b481 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -138,12 +138,13 @@ npm_package( srcs = [ "package.json", "README.md", + "CHANGELOG.md", "LICENSE", "src/themes/vector-colors.css", ":tinyvectors", ], package = "@tummycrypt/tinyvectors", - version = "0.2.5", + version = "0.3.0", visibility = ["//visibility:public"], ) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1b98cb4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.3.0 - 2026-05-01 + +- Restores the pre-Phase-A gel/blob feel while keeping gravity-led motion, ambient movement, and safer smoothing. +- Adds device-motion status, permission, calibration, idle reset, reduced-motion, and Chrome/CDP browser harness coverage. +- Adds pointer and scroll lifecycle cleanup, stale IO reset behavior, pointer velocity coverage, and a conservative local pointer field. +- Hardens the package release surface with explicit exports, Bazel-built package validation, bundle-size checks, and consumer-package checks. +- Keeps the release bundle under the 12 KiB gzip gate while documenting the remaining 11 KiB target pressure. diff --git a/MODULE.bazel b/MODULE.bazel index 181fd29..128fa67 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,12 +2,12 @@ MODULE.bazel for standalone Bazel consumption of @tummycrypt/tinyvectors. Usage from external repo: - bazel_dep(name = "tummycrypt_tinyvectors", version = "0.2.5") + bazel_dep(name = "tummycrypt_tinyvectors", version = "0.3.0") """ module( name = "tummycrypt_tinyvectors", - version = "0.2.5", + version = "0.3.0", compatibility_level = 1, ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index bd64d2a..00d1c12 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -566,7 +566,7 @@ "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=", - "usagesDigest": "54g5qwvCETxU3t3RV9TXKVf7z0lF330Y4TLGkdrrROE=", + "usagesDigest": "1w4kFiZokOU0Da4EvQBCGLHbkIzIrOE2BPRmgFcvAvU=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, diff --git a/package.json b/package.json index 6f679fb..9b73641 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tummycrypt/tinyvectors", - "version": "0.2.5", + "version": "0.3.0", "description": "Animated vector blob backgrounds with physics simulation for Svelte 5", "type": "module", "packageManager": "pnpm@9.15.9", @@ -57,6 +57,7 @@ } }, "files": [ + "CHANGELOG.md", "dist", "dist-types", "src/themes/vector-colors.css",