Prepare tinyvectors 0.3.0 release#42
Conversation
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
Add local pointer field
Greptile SummaryThis PR prepares the
Confidence Score: 3/5Hold 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
Reviews (1): Last reviewed commit: "chore(release): prepare tinyvectors 0.3...." | Re-trigger Greptile |
| GaussianKernel, | ||
| SpatialHash, | ||
| browser, | ||
| isBrowser, |
There was a problem hiding this comment.
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.
| } | ||
| } | ||
|
|
||
| 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; |
There was a problem hiding this comment.
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!
| 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: [], |
There was a problem hiding this comment.
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.
|
Release complete.
Note: the PR was merged with the explicit admin path after normal merge was blocked by |
Summary
Prepares
@tummycrypt/tinyvectors@0.3.0for the tag-driven npm release flow.v0.3.0-develrelease candidate into the mainline release path0.2.5to0.3.0CHANGELOG.mdin the npm/Bazel package artifactMODULE.bazel.lockafter the module version changeRefs #26, #40, #39, and #41.
Validation
pnpm run check:release-metadatapnpm run checkpnpm run test(181 tests)pnpm run buildpnpm run check:packagepnpm run check:bundle-size(11.00 KiBgzip; below12.00 KiBgate)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_failurespnpm run check:package-consumernpm pack --dry-run ./bazel-bin/pkg(@tummycrypt/tinyvectors@0.3.0, 98 files, includesCHANGELOG.md)npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg