Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ npm_package(
":tinyvectors",
],
package = "@tummycrypt/tinyvectors",
version = "0.3.6",
version = "0.3.7",
visibility = ["//visibility:public"],
)

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
> [tinyland-inc/bazel-registry](https://github.com/tinyland-inc/bazel-registry)
> only. See the README's Install section for the sanctioned consumption paths.

## 0.3.7 - 2026-09-01

- Idle drift cruise: `updateScreensaverPhysics` now steers each blob's velocity toward its own `driftAngle`/`driftSpeed` heading every substep (`DRIFT_CRUISE_STEERING = 0.05`, target speed `driftSpeed * 3`). Both fields were initialized per blob since the pre-Phase-A baseline but never read by the loop, so with no pointer, scroll, or device-motion input the only idle motion was a zero-mean jitter random walk that the `*= 0.992` damping erased in ~1.4s — blobs jiggled near their territory instead of drifting. The settled cruise (~1.5-3.9 units/s in the -40..140 physics space) delivers the "idle blobs drift" baseline of `docs/physics-feel-contract.md` on every environment, with no permission grant or sensor required; the existing re-heading roll (~every 8s) and the wall-bounce re-randomization keep paths wandering rather than ballistic, and pointer/scroll/gravity fields still bias motion on top exactly as before. Deterministic seeded runs measure idle mean net displacement rising from ~4.3-5.0 to ~16.4-25.5 units over 12s, peak speeds bounded near 0.15 units/substep across 300 simulated seconds; a headed-Chrome A/B probe of the built package measures the net/path coherence of idle motion rising from ~0.5-0.64 (decorrelating jitter) to ~0.72-0.81 (directed travel) at 60fps, and a `reducedMotion: 'reduce'` context still renders a fully frozen frame (0 of 15 paths move) (`tests/unit/blob-physics-idle-drift.test.ts`).
- `TinyVectors`' rAF frame-delta clamp is raised from `0.033`s to `8/60`s to match `BlobPhysics.tick()`'s own catch-up ceiling (8 substeps of 1/60s, added in 0.3.6). The old clamp pre-empted the engine's fixed-timestep accumulator: any sustained render rate below 30fps (software raster, heavy blur scenes on hiDPI displays) dilated simulated time proportionally (~3x slower motion at 11fps). Now sustained rendering down to ~7.5fps keeps real-time motion speed; a backgrounded-tab resume is still bounded by the same engine substep cap.

## 0.3.6 - 2026-07-25

- Adds a `respectReducedMotion?: boolean` prop (default `true`) to `TinyVectors`: when `(prefers-reduced-motion: reduce)` matches, the component renders the existing static single frame (the same path `animated={false}` already uses) instead of running the rAF loop, and switches live if the media query changes. Pass `respectReducedMotion={false}` to animate regardless (TIN-3170).
Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Usage from external repo:

module(
name = "tummycrypt_tinyvectors",
version = "0.3.6",
version = "0.3.7",
compatibility_level = 1,
)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tummycrypt/tinyvectors",
"version": "0.3.6",
"version": "0.3.7",
"description": "Animated vector blob backgrounds with physics simulation for Svelte 5",
"type": "module",
"packageManager": "pnpm@9.15.9",
Expand Down
23 changes: 23 additions & 0 deletions src/core/BlobPhysics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ const FIXED_TIMESTEP_SECONDS = 1 / 60;
// after a paused/backgrounded tab resumes), so a long real-time gap can't
// spiral into an unbounded number of substeps in one frame.
const MAX_PHYSICS_SUBSTEPS = 8;
// Per-substep steering gain for the ambient drift cruise. Each blob carries a
// driftAngle/driftSpeed pair (initialized below, re-randomized on wall bounce
// and by the slow re-heading roll in updateMovementWithAccelerometer); the
// cruise steers velocity toward that heading every substep. Against the
// *= 0.992 damping this settles near 0.86x of the scaled target, a sustained
// gentle coherent travel in the -40..140 physics space — the
// "idle blobs drift" baseline of docs/physics-feel-contract.md — instead of
// the zero-mean jitter random walk that damping otherwise erases in ~1.4s.
const DRIFT_CRUISE_STEERING = 0.05;
// The cruise target speed is driftSpeed scaled by this factor. The raw
// driftSpeed field (0.01-0.025/substep) sits below the speed the jitter
// kicks already reach, so unscaled it disappears into the random walk;
// scaled, the settled cruise (~1.5-3.9 units/s) reads as travel while staying
// well under pointer/scroll/gravity speeds so inputs still dominate.
const DRIFT_CRUISE_SPEED_SCALE = 3;

export class BlobPhysics {
private blobs: ConvexBlob[] = [];
Expand Down Expand Up @@ -565,6 +580,14 @@ export class BlobPhysics {
}

private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void {
// Ambient cruise: the always-on baseline field. driftAngle/driftSpeed
// were initialized per-blob since the pre-Phase-A baseline but never
// read by the loop, so idle motion was only the bounded jitter below.
const driftAngle = blob.driftAngle || 0;
const cruiseSpeed = (blob.driftSpeed || 0) * DRIFT_CRUISE_SPEED_SCALE;
blob.velocityX += (Math.cos(driftAngle) * cruiseSpeed - blob.velocityX) * DRIFT_CRUISE_STEERING;
blob.velocityY += (Math.sin(driftAngle) * cruiseSpeed - blob.velocityY) * DRIFT_CRUISE_STEERING;


const neutralDriftX = (Math.random() - 0.5) * 0.001;
const neutralDriftY = (Math.random() - 0.5) * 0.001;
Expand Down
6 changes: 5 additions & 1 deletion src/svelte/TinyVectors.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,11 @@
};

function tick(currentTime: number) {
const dt = Math.min((currentTime - lastTime) / 1000, 0.033);
// Clamp matches the engine's own catch-up ceiling (8 substeps of 1/60s
// in BlobPhysics.tick), so sustained low-fps rendering down to ~7.5fps
// keeps real-time motion speed instead of dilating simulated time. A
// tab-resume jump is still bounded by the same engine substep cap.
const dt = Math.min((currentTime - lastTime) / 1000, 8 / 60);
lastTime = currentTime;

if (physics) {
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/blob-physics-idle-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { BlobPhysics } from '../../src/core/BlobPhysics.js';
import type { ConvexBlob } from '../../src/core/types.js';

// Deterministic xorshift32 PRNG (same shape as blob-physics-timestep.test.ts)
// so every run of a scenario draws the exact same "random" sequence.
function seededRandom(seed: number): () => number {
let state = seed >>> 0;
return () => {
state ^= state << 13;
state >>>= 0;
state ^= state >>> 17;
state ^= state << 5;
state >>>= 0;
return state / 4294967296;
};
}

let randomSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
randomSpy = vi.spyOn(Math, 'random');
});

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

interface IdleRunResult {
meanNetDisplacement: number;
maxSpeed: number;
minCoordinate: number;
maxCoordinate: number;
}

// Simulates `seconds` of untouched idle time (no pointer, scroll, or gravity
// input) at a 60Hz tick cadence and reports perceptual aggregates. When
// `zeroDriftSpeed` is set, each blob's cruise field is zeroed after init so
// the run measures the jitter-only baseline the cruise must rise above.
async function runIdle(seed: number, seconds: number, zeroDriftSpeed: boolean): Promise<IdleRunResult> {
randomSpy.mockImplementation(seededRandom(seed));

const physics = new BlobPhysics(8);
await physics.init();

const blobs = physics.getBlobs() as ConvexBlob[];
if (zeroDriftSpeed) {
for (const blob of blobs) blob.driftSpeed = 0;
}
const start = blobs.map((blob) => ({ x: blob.currentX, y: blob.currentY }));

const frames = Math.round(seconds * 60);
let maxSpeed = 0;
let minCoordinate = Number.POSITIVE_INFINITY;
let maxCoordinate = Number.NEGATIVE_INFINITY;
for (let i = 0; i < frames; i++) {
physics.tick(1 / 60, i / 60);
for (const blob of physics.getBlobs() as ConvexBlob[]) {
maxSpeed = Math.max(maxSpeed, Math.hypot(blob.velocityX, blob.velocityY));
minCoordinate = Math.min(minCoordinate, blob.currentX, blob.currentY);
maxCoordinate = Math.max(maxCoordinate, blob.currentX, blob.currentY);
}
}

const end = physics.getBlobs() as ConvexBlob[];
const displacements = end.map((blob, i) =>
Math.hypot(blob.currentX - start[i].x, blob.currentY - start[i].y)
);

return {
meanNetDisplacement: displacements.reduce((sum, d) => sum + d, 0) / displacements.length,
maxSpeed,
minCoordinate,
maxCoordinate,
};
}

// Perceptual contract (docs/physics-feel-contract.md): "idle drift is present
// and bounded". Thresholds are deliberately loose — they assert travel vs.
// jiggle, not exact coefficients or frame-by-frame positions.
describe('BlobPhysics idle drift cruise (ambient field)', () => {
const SEEDS = [42, 7, 1234];

it('idle blobs travel with no input at all: net displacement well above the jitter-only baseline', async () => {
for (const seed of SEEDS) {
const cruise = await runIdle(seed, 12, false);
const jitterOnly = await runIdle(seed, 12, true);

// Measured cruise means sit at 16.4-25.5 units over 12s in the
// -40..140 physics space; jitter-only at 4.3-5.0. Floors leave
// generous room for feel tuning while still failing if the cruise
// field ever regresses to dead code again.
expect(cruise.meanNetDisplacement).toBeGreaterThan(8);
expect(cruise.meanNetDisplacement).toBeGreaterThan(jitterOnly.meanNetDisplacement * 1.5);
}
});

it('idle drift stays bounded: gentle speeds, positions inside the physics walls', async () => {
for (const seed of SEEDS) {
const cruise = await runIdle(seed, 12, false);

// ~0.08 units/substep measured peak over 12s; 0.15 (9 units/s)
// is the "no longer gentle" ceiling.
expect(cruise.maxSpeed).toBeLessThan(0.15);
expect(cruise.minCoordinate).toBeGreaterThanOrEqual(-40);
expect(cruise.maxCoordinate).toBeLessThanOrEqual(140);
}
});
});
Loading