From a041a9384a4017c9ce4b5a5f6f6809c775f6500d Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 30 Apr 2026 12:25:06 -0400 Subject: [PATCH 01/44] 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 02/44] 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 03/44] 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 05/44] 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 06/44] 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 07/44] 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 08/44] 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 09/44] 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 10/44] 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 11/44] 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 12/44] 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 13/44] 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 14/44] 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 15/44] 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 16/44] 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 17/44] 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 18/44] 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 19/44] 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 20/44] 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 21/44] 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 22/44] 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 23/44] 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 24/44] 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 25/44] 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 26/44] 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 27/44] 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 28/44] 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 29/44] 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 30/44] 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 31/44] 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 32/44] 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 33/44] 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 34/44] 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 35/44] 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 36/44] 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 37/44] 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 38/44] 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 39/44] 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 40/44] 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 41/44] 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 42/44] 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 43/44] 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 44/44] 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); + }); });