Skip to content

Prepare tinyvectors 0.3.0 release#42

Merged
Jess Sullivan (Jesssullivan) merged 59 commits into
mainfrom
jess/release-v0.3.0
May 1, 2026
Merged

Prepare tinyvectors 0.3.0 release#42
Jess Sullivan (Jesssullivan) merged 59 commits into
mainfrom
jess/release-v0.3.0

Conversation

@Jesssullivan

Copy link
Copy Markdown
Contributor

Summary

Prepares @tummycrypt/tinyvectors@0.3.0 for the tag-driven npm release flow.

  • merges the validated v0.3.0-devel release candidate into the mainline release path
  • bumps package and Bazel metadata from 0.2.5 to 0.3.0
  • includes CHANGELOG.md in the npm/Bazel package artifact
  • updates MODULE.bazel.lock after the module version change

Refs #26, #40, #39, and #41.

Validation

  • pnpm run check:release-metadata
  • pnpm run check
  • pnpm run test (181 tests)
  • pnpm run build
  • pnpm run check:package
  • pnpm run check:bundle-size (11.00 KiB gzip; below 12.00 KiB gate)
  • pnpm run test:browser:motion (Chrome 147; pointerDelivery.events: 1)
  • 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 (@tummycrypt/tinyvectors@0.3.0, 98 files, includes CHANGELOG.md)
  • npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg

Replace minify: false with minify: 'esbuild' and remove the stale
$-collision comment. The historical concern about esbuild's $-prefixed
variable names colliding with Svelte's reserved $ prefix does not
reproduce on current esbuild + svelte-vite-plugin.

Realistic consumer footprint: ~15.0 KB gz → ~13.4 KB gz (-1.6 KB / ~11%).
Largest per-chunk savings: BlobPhysics.js -0.65, TinyVectors.js -0.34,
SpringSystem.js -0.26, BlobSVG.js -0.19. Two barrel files grow slightly
under preserveModules: true minify; net is still -1.6.

TIN-825
Inline TypeScript implementation of the One-Euro filter — adaptive
low-pass that smooths at rest and stays responsive when fast. Math:
  α = 1 / (1 + τ/dt) where τ = 1/(2π·fc)
  fc = minCutoff + β·|dx̂|
where dx̂ is itself low-passed at dCutoff.

Internal-only — not exported from src/motion/index.ts. Will be wired
up when A8 (TIN-834) rewrites DeviceMotion as TiltSource. Adding
the filter and its tests in a separate PR keeps each change small
and individually reviewable.

Five unit tests cover: first-sample passthrough, exact EMA-formula
match at β=0, adaptive cutoff (higher β → faster step response),
reset() lifecycle, dt clamp at identical timestamps.

TIN-832
ScrollHandler.startDecay() recursively scheduled requestAnimationFrame
without tracking the returned id, so rapid handleScroll() calls (up to
100+/sec on a fast scroll wheel) could spawn overlapping decay loops
that all run next frame.

Track the active rafId on the instance, cancelAnimationFrame() it
before kicking off a new decay, and clear it when decay self-terminates.

Two unit tests: 50 rapid wheel events leave exactly one scheduled RAF
and 49 cancellations; decay self-cleanup nulls rafId so the next
handleScroll() does not redundantly cancel a finished loop.

TIN-830
Each .toFixed call allocates a fresh string. At 5 blobs × 12 control
points × 5 toFixed calls per segment × 60 fps that's ~18,000 small
string allocations per second, just for SVG path coordinate precision.

Drop .toFixed entirely. Number.prototype.toString() (called implicitly
by template-literal interpolation) is faster in V8 and SVG accepts any
precision. Path strings get ~9 chars longer per coord — roughly 2.7 KB/
frame extra in DOM-attribute size, trivially cheap, in exchange for
eliminating the alloc churn.

TIN-829
Two tightly-related cleanups in the Svelte components:

1. Remove the dead debug-overlay block in TinyVectors.svelte. The
   {#if isMobileDevice && false} ... {/if} guard meant the contained
   div never rendered. Author left dev tooling in.

2. Drop the containerElement prop end-to-end. It was bound on the
   wrapper div in TinyVectors.svelte, passed through to BlobSVG, and
   then ignored — never read or used in either template or script.
   Removing the binding, the prop, the interface field, and the
   destructure cleans up an unused contract surface.

Neither change is on the public API: containerElement was never on
the <TinyVectors> Props interface, and the debug overlay was dev-only
output gated to never render.

TIN-827
…ace (#34)

src/core/index.ts previously emitted `export * from './types.js'` and
`export * from './schema.js'`, which made the public API of the /core
entry point opaque (every type, value, and event helper from those
files leaked through).

Replace the wildcards with explicit named re-exports, grouped by
category (physics, path generation, spring system, spatial utilities,
browser detection, blob/motion types, configuration, themes, render
blob shapes, event types, theme presets). Same surface; clearer.

Tightening to a smaller surface is intentionally deferred — that would
be a true API break and wants its own consideration. This change is
purely a documentation-of-current-surface refactor.

TIN-826
…#35)

* perf(physics): mutate blob.color in place in getBlobs() for stable refs

BlobPhysics.getBlobs(themeColors) previously spread each blob into a
new object and returned a fresh array, allocating ~60 arrays + 300
objects/sec at 5 blobs × 60 fps purely to attach a color string per
frame.

Mutate this.blobs[i].color directly when themeColors is supplied, and
return the live this.blobs array. The result is a stable reference
across calls — the same array, the same blob objects — which lets
Svelte's keyed reconciliation in BlobSVG short-circuit on unchanged
fields rather than re-evaluating per-blob expressions every frame.

Behavior change: getBlobs(themeColors) now mutates internal state
instead of producing a snapshot. The canonical consumer in
TinyVectors.svelte.tick() is the only caller and treats the returned
array as live, so this is invisible in practice. Documented in a code
comment.

Four new tests: stable array reference, stable blob references,
in-place color cycling when themeColors is shorter than blob count,
and the no-themeColors branch returning the live array unchanged.

TIN-828

* fix(physics): return shallow copy from getBlobs to keep Svelte reactive

Greptile review on PR #35 caught a real bug: TinyVectors.svelte
assigns `blobs = physics.getBlobs(themeColors)` inside its rAF
loop. Svelte 5's signal compares by reference — returning the same
this.blobs each call would freeze the animation after frame 1
because the assignment looks like a no-op.

Return this.blobs.slice() instead. The outer array shell is
re-allocated each call (~60 arr/sec at 60fps, same as the prior
.map approach), but the blob object refs inside are stable across
calls — which kills the 300 obj-spreads/sec that the .map(blob =>
({...blob})) was paying. Net win is the 80% of the original goal,
without the freeze-after-frame-1 bug.

Update tests accordingly: assert fresh array reference each call,
keep the assertions about same blob object refs and in-place color
mutation cycling.

TIN-828
The 12 stop-opacity attributes per blob each held a JS arithmetic
expression: stop-opacity={blob.intensity * <stop-fraction>}. Each
frame, blobs is reassigned (new array reference) so Svelte
re-evaluates *all* of them — 60+ small multiplications per frame
even when blob.intensity is static across animation frames.

Register --tv-blob-intensity as an @Property (syntax: '<number>',
inherits, initial-value: 1) so calc() in stop-opacity is interpolated
as a number rather than a string. Set it inline once per
<radialGradient> from blob.intensity. Stops compute their own
opacity via calc(var(--tv-blob-intensity) * <stop-fraction>).

Effect: ~12 reactive expressions per blob become 1 inline-style
assignment. At 5 blobs × 60 fps that's 300 expression evaluations
per second instead of 3,600. The browser also handles the calc()
GPU-side once registered, so when intensity does change, transitions
are smoother than reactive Svelte updates would produce.

Browser support: @Property is Baseline 2024 (Chrome 85+, Safari 16.4+,
Firefox 128+). Falls back to string substitution on older browsers,
which would break calc() — acceptable for tinyvectors v0.3 target
matrix; the rendered intensity is the only affected behavior and
older browsers see static (unfiltered) blobs.

TIN-831
* perf(physics): replace ad-hoc smoothControlPoints with Laplacian skin tension

The previous smoothControlPoints used 3-point averaging plus an
'anti-difference' clamp that fired when neighbor radii diverged
beyond size*0.1 — physically ad-hoc and produced ringing rather
than gel-like deformation.

Replace with the discrete surface-tension force on a closed control-
point ring (Young-Laplace pressure):

  r_i ← r_i + k · (0.5·(r_{i-1} + r_{i+1}) - r_i)

Two-pass so we read all targets before writing — otherwise we'd be
smoothing against half-already-smoothed neighbors. Plus a viscous
radial-velocity bleed (Kelvin-Voigt dashpot half) so energy
dissipates with each correction rather than ringing.

Pre-allocate the targets scratch buffer on the instance to keep the
hot path allocation-free. Reuses a Float32Array sized to the largest
control-point count seen.

TIN-833

* feat(physics): XSPH inter-blob velocity coupling for fluid swarm feel

After updateScreensaverPhysics has updated each blob, sample the
neighborhood velocity field and nudge each blob toward its Gaussian-
weighted neighbor mean:

  v_i ← v_i + ε · Σ_j W(r_ij) · (v_j - v_i)

This is XSPH viscosity from Position Based Fluids (Macklin & Müller,
SIGGRAPH 2013). It bleeds *relative* motion between neighbors, which
is categorically different from drag (bleeds *absolute* motion):
drag feels like air resistance; XSPH feels like wading through gel
and is what makes a small set of blobs read as a fluid rather than
5 independent things.

Defaults: eps=0.4, sigma=80 (the latter is in 0..100 viewBox space,
so coupling is meaningful within ~half-screen). Pre-allocate dvX/dvY
Float32Arrays on the instance, sized to numBlobs, fill(0) each tick.
O(N²) — fine for 5–10 blobs; existing SpatialHash could neighbor-cull
later if high-N modes need it.

TIN-835

* feat(physics): soft-wall force replaces specular bounce on blob centers

handleWallBouncing previously did position-snap + velocity-reverse
× damping at four hard wall lines, producing a discontinuous force
that read as billiard-ball impact — wrong for a gel.

Replace with a continuous penetration-based restoring force:

  F = -k · max(0, penetration)

The blob can drift into the soft band, gets pushed back smoothly,
and the velocity curve has no kink. Edges feel like the blob
flattens against the wall rather than bouncing off.

Keep a hard outer clamp at a smaller margin so under extreme dt or
large external forces the blob can never escape the canvas — the
hard clamp still records bounces so existing time-since-bounce
logic continues to work.

For full-fidelity wall-flattening (perimeter points clamp
individually), defer to the Tier-2 PBD refactor (TIN-841 / Phase C).
This change is the cheap version that already removes the bouncy
feel.

TIN-836

* perf(physics): Gaussian-falloff anti-clustering replaces step-function repulsion

Both applyAntiClusteringWithSpatialHash and the
applyEnhancedAntiClustering O(N²) fallback previously used a step
function: zero force above a 'requiredDistance' threshold, plus a
separate sharp 3.5× proximity multiplier when distance dipped below
0.7 × requiredDistance. Both produced discontinuous forces that read
as a 'click' or sudden swerve when blobs nearly-touched.

Replace with Gaussian falloff: w = exp(-r² / 2σ²), σ = required-
distance × 0.5. Force grows continuously as blobs approach, peaks at
zero distance, decays smoothly. C∞ continuous, no kink at any radius.
Reuses the same Gaussian family as the existing GaussianKernel.

Tuning: σ = requiredDistance / 2 means most of the contribution comes
from within 'personal space'; falloff to ~10% by 1.5× and ~1% by 2×.
Spatial-hash neighbor query continues to limit which pairs are
checked, so nothing far-away suddenly receives weak Gaussian forces.

Keep the existing distance < requiredDistance gate ONLY for setting
blob.lastRepulsionTime (used by addEscapeVelocity to give blobs a
post-bump impulse). The actual force is now continuous everywhere.

TIN-837

* fix(physics): respect bounceDamping config; tighten XSPH null guard

Greptile review on PR #36 caught two issues:

1. The hard-clamp fallback in handleWallBouncing hardcoded 0.5 in all
   four reflection branches, silently dropping any consumer-tuned
   value of config.bounceDamping (default 0.7). Read
   this.config.bounceDamping for hard reflections — matches the
   pre-soft-wall behavior for the rare case the hard clamp fires.

2. The XSPH buffer-allocation guard checked xsphDvX but then asserted
   xsphDvY non-null without a check. In practice both are always
   allocated together, but the asymmetric guard was a footgun and
   the non-null assertion masks any future divergence.

TIN-836 TIN-835

* docs(physics): clarify intentional lastRepulsionTime decoupling

Greptile review on PR #36: lastRepulsionTime is decoupled from
force application — force is continuous (Gaussian), stamp gated on
close-contact threshold. The decoupling is deliberate: the stamp
exists for downstream addEscapeVelocity which uses it as a 'blobs
just pushed each other apart' event detector, not as a generic
'any neighbor contributed' flag. Adding a code comment above the
stamp so future readers do not 'restore' the coupling.

TIN-837
…Source) (#37)

* feat(motion): rewrite DeviceMotion as TiltSource with One-Euro + screen remap

Replace the raw-acceleration DeviceMotion implementation with a
DeviceOrientationEvent-based pipeline that produces ambient-feel
tilt vectors:

  raw beta/gamma
    ↓ remapToScreen (4-case axis switch on screen.orientation.angle)
    ↓ slow continuous baseline subtraction (α ≈ 0.0008, ~30s τ)
    ↓ ÷ range (default 45° → ±1)
    ↓ One-Euro filter (minCutoff=0.5, beta=0.01, dCutoff=1.0)
    ↓ clamp to [-1, 1]
  emitted to callback

Edges handled:
- prefers-reduced-motion: hard-disable; live-listen for runtime toggle
- Face-down (|beta| > 120°): emit zero rather than wild values
- 250ms warmup discard on first events (iOS warmup glitches)
- 2s event gap → reset filter state (handles tab visibility resume)
- Per-event read of screen.orientation.angle (rotation-locked devices
  never fire 'change' events)
- visibilitychange → reset filter state on tab hide

Why the change:
- Old code used DeviceMotionEvent.accelerationIncludingGravity (raw,
  noisy). DeviceOrientationEvent is OS-fused and the right primitive
  for tilt — already low-noise, no manual gravity isolation needed.
- Old fixed-α EMA at 0.35 ≈ 5 Hz cutoff was 6× too jittery for
  ambient feel. One-Euro is adaptive: smooth at rest, responsive
  under fast motion.
- ±45° normalization beats ±90° because phones are rarely tilted
  past 45° in casual use, leaving most of the old range unused.

Verified 'enableDeviceMotion={false}' in production (tinyland.dev)
suggests the existing implementation feels bad. With this rewrite
the canonical consumer should be able to flip it on.

Same DeviceMotion export name (drop-in for callers). Same callback
shape ({x, y, z}) — z is now always 0 since orientation events don't
carry a meaningful third axis. TinyVectors.svelte handleDeviceMotion
simplified accordingly: drop the redundant inner EMA (TiltSource
filters internally) and the axis swap (TiltSource emits already
screen-aligned).

Tests cover the pure remapToScreen function for all four screen
orientations plus zero/edge cases.

TIN-834

* fix(motion): remove dead getPermissionApi guard; document Y-axis sign change

Greptile review on PR #37 (3/5):

1. Dead guard at DeviceMotion.ts:129 — typeof getPermissionApi !==
   'undefined' was always true (it is a module-level function
   declaration). The branch is reachable when reduced-motion is
   toggled off mid-session; no guard is needed since
   requestPermission() handles the no-API case internally.

2. Y-gravity sign — Greptile flagged the removed negation as a
   regression. Intentionally so: the previous code computed
   gravityY = -beta, which made forward tilt drive gravity UP the
   screen (away from viewer). The canonical consumer (tinyland.dev)
   shipped with enableDeviceMotion={false} explicitly because of
   this counter-intuitive direction. Removing the negation fixes
   the axis. Adding a code comment in TinyVectors.svelte
   documenting why so future readers do not 'restore' the bug.

TIN-834

* fix(motion): guard against post-cleanup listener leak in DeviceMotion

Greptile review on PR #37 (P1):
The reducedMotionListener and initialize() both dispatch
requestPermission() asynchronously and call startListening() on
resolution. If cleanup() fires while iOS' permission prompt is
still open (the user can dismiss it after the component unmounts),
the resolved .then() reattaches a deviceorientation listener on
the already-cleaned-up instance — a permanent leak.

Add a disposed flag set first thing in cleanup(); guard the async
.then() callbacks and the initialize() entry point against it.
Stops the iOS-prompt-during-cleanup race cleanly.

TIN-834
Integrate motion, pointer, gel, and package validation
@Jesssullivan Jess Sullivan (Jesssullivan) merged commit 87f1de8 into main May 1, 2026
5 checks passed
@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR prepares the @tummycrypt/tinyvectors@0.3.0 release by merging the v0.3.0-devel candidate: it adds a full device-motion state machine (One-Euro filtering, permission flow, idle reset, reduced-motion support), a rAF-batched pointer physics controller, proper dispose() on all handlers, performance optimisations in BlobPhysics, and a hardened release surface with explicit named exports, bundle-size checks, and a consumer typecheck script.

  • P1 — broken public type export: GelControlPoint (defined in SpringSystem.ts) is listed as an export in src/index.ts but was accidentally omitted from src/core/index.ts's SpringSystem re-export block when the old export * barrel was replaced with explicit names. TypeScript consumers importing it from the package root will get a compile error.

Confidence Score: 3/5

Hold for the missing GelControlPoint re-export before tagging 0.3.0

One P1 finding: GelControlPoint is absent from src/core/index.ts's explicit SpringSystem re-exports but is listed in src/index.ts's public surface, producing a TypeScript error for consumers. The consumer type-check script doesn't import that specific type so it won't be caught at CI time. The rest of the change looks solid.

src/core/index.ts (missing GelControlPoint in SpringSystem re-export block) and src/index.ts (references it from ./core/index.js)

Important Files Changed

Filename Overview
src/index.ts Switched from broad export * barrels to explicit named exports; GelControlPoint is referenced in the public surface but missing from the src/core/index.ts re-export chain — TypeScript consumers will see a compile error
src/core/index.ts Replaces export * barrels with curated explicit exports; GelControlPoint from SpringSystem.ts is absent from this file's re-export list, breaking the chain expected by src/index.ts
src/core/BlobPhysics.ts Refactored mouse-velocity tracking, control-point smoothing, and color assignment for perf; adds applyPointerField that duplicates InteractionField.pointAttractorField with hardcoded magic numbers
src/core/theme-colors.ts New file duplicating all theme colors from theme-presets.ts with slightly different CSS formatting; creates a two-source-of-truth maintenance risk
src/motion/DeviceMotion.ts Major rewrite adding One-Euro filtering, permission state machine, idle-reset timer, reduced-motion support, screen-orientation remapping, and proper cleanup lifecycle
src/svelte/TinyVectors.svelte Significant expansion adding pointer physics, richer device-motion API (status, permission, calibration), and full teardown in effect cleanup; removes Tailwind class usage in favour of explicit inline styles
scripts/check-package-consumer.mjs New consumer-check script that symlinks the built package into a temp workspace and runs both a runtime import check and a strict TypeScript typecheck; does not explicitly import GelControlPoint so won't catch the broken re-export

Reviews (1): Last reviewed commit: "chore(release): prepare tinyvectors 0.3...." | Re-trigger Greptile

Comment thread src/index.ts
GaussianKernel,
SpatialHash,
browser,
isBrowser,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 GelControlPoint not re-exported from src/core/index.ts

GelControlPoint is defined in src/core/SpringSystem.ts and was previously exposed through the old export * from './SpringSystem.js' barrel, but the new explicit export block in src/core/index.ts omits it. Any TypeScript consumer that imports GelControlPoint from the package root will receive a "Module './core/index.js' has no exported member 'GelControlPoint'" error.

Add type GelControlPoint to the SpringSystem re-export block in src/core/index.ts.

Comment thread src/core/BlobPhysics.ts
Comment on lines 493 to +505
}
}

private applyPointerField(blob: ConvexBlob): void {
if (this.mouseX === 50 && this.mouseY === 50) return;

const dx = this.mouseX - blob.currentX;
const dy = this.mouseY - blob.currentY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0 || distance >= 34) return;

const normalized = 1 - distance / 34;
const scale = (normalized * normalized * 0.0014) / distance;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 applyPointerField duplicates pointAttractorField with magic numbers

The new private method inlines the exact same quadratic-falloff attractor formula that InteractionField.pointAttractorField already implements, with hardcoded radius = 34 and strength = 0.0014. Consider reusing the utility to keep the constants in one place and improve readability.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread src/core/theme-colors.ts
Comment on lines +3 to +29
export const THEME_PRESET_COLORS: Record<ThemePresetName, string[]> = {
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: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate color definitions diverge in format from theme-presets.ts

THEME_PRESET_COLORS replicates every color string already present in THEME_PRESETS, but uses a different CSS format — no spaces and shorthand alpha (e.g., 'rgba(139,92,246,.55)' vs 'rgba(139, 92, 246, 0.55)'). Having two sources of truth for the same colors means a future theme update requires edits in two places and risks silent divergence. Consider deriving THEME_PRESET_COLORS from THEME_PRESETS at module load time instead.

@Jesssullivan

Copy link
Copy Markdown
Contributor Author

Release complete.

Note: the PR was merged with the explicit admin path after normal merge was blocked by main branch protection requiring review. Actions were green before merge, and the tag-triggered publish workflow completed successfully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant