Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Useful extra commands:
- `pnpm dev` runs the local Vite demo app
- `pnpm dev:watch` rebuilds the library on change
- `pnpm test:pbt` runs the property-based invariants only
- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input
- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, pointer delivery, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input
- `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned
- `pnpm check:package` runs `publint`
- `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized
Expand All @@ -115,7 +115,7 @@ The dev app includes a browser/device harness for interaction work:
- Use the panel toggles to isolate pointer, scroll, and device-motion physics.
- Use `Spoof Tilt` and `Neutral Tilt` to verify TinyVectors motion wiring without relying on browser sensor tooling.
- On a phone or tablet, open the dev URL, tap `Request Motion`, keep the device still, tap `Calibrate`, then tilt the device.
- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP reduced-motion media emulation and accelerometer override paths.
- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP pointer, reduced-motion media emulation, and accelerometer override paths.

## Release Truth

Expand Down
6 changes: 3 additions & 3 deletions docs/physics-feel-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s
## Current Status

- Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path.
- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion.
- Pointer IO currently updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; it does not apply a standalone pointer force yet.
- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, return to neutral on idle or reduced motion, and receive a real CDP pointer move while pointer physics is active.
- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; unit coverage verifies the first standalone route applies a small local pointer field only after real pointer input.
- Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom.

## Implementation Slices

1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work.
2. Add pure field helpers and unit tests without changing runtime feel.
3. Route gravity/device-orientation through the field helper while preserving ambient motion.
4. Route pointer and scroll values through field helpers one input at a time.
4. Route pointer and scroll values through field helpers one input at a time, starting with a low-strength local pointer field.
5. Add browser probes for directional bias, pointer locality, and scroll decay.
6. Revisit renderer stylability after interaction feel is stable.
44 changes: 44 additions & 0 deletions scripts/probe-motion-cdp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,14 @@ try {
at: performance.now()
});
});
originalAddEventListener.call(window, 'pointermove', (event) => {
window.__tinyvectorsEvents.push({
type: 'pointermove',
x: event.clientX,
y: event.clientY,
at: performance.now()
});
});
})();
`,
});
Expand Down Expand Up @@ -535,6 +543,37 @@ try {
`Expected one deviceorientation listener, got ${listenerInitial.listeners.deviceorientation}.`,
);

const beforePointerMove = await evaluate(client, `({
firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null,
pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length
})`);
await client.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x: 120,
y: 180,
button: 'none',
});
await delay(500);
const afterPointerMove = await evaluate(client, `({
firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null,
pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length,
lastPointerEvent: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').at(-1)
})`);
assert(beforePointerMove.firstBodyPath, 'Pointer probe could not read initial blob geometry.');
assert(afterPointerMove.firstBodyPath, 'Pointer probe could not read updated blob geometry.');
assert(
afterPointerMove.pointerEvents > beforePointerMove.pointerEvents,
'CDP pointer move did not reach the page.',
);
assert(
afterPointerMove.lastPointerEvent?.x === 120 && afterPointerMove.lastPointerEvent?.y === 180,
`CDP pointer move reached the page with unexpected coordinates ${JSON.stringify(afterPointerMove.lastPointerEvent)}.`,
);
assert(
afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath,
'Pointer probe did not observe animated blob geometry movement after pointer delivery.',
);

await client.send('Runtime.evaluate', {
expression: `document.getElementById('scroll-physics')?.click()`,
awaitPromise: true,
Expand Down Expand Up @@ -653,6 +692,11 @@ try {
pathChanged: cdpAccelerometerChanged,
note: 'TinyVectors uses DeviceOrientationEvent/TiltSource; raw accelerometer CDP is informational.',
},
pointerDelivery: {
events: afterPointerMove.pointerEvents - beforePointerMove.pointerEvents,
pathChanged: afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath,
lastEvent: afterPointerMove.lastPointerEvent,
},
listenerLifecycle: {
initial: listenerInitial.listeners,
afterScrollOff: afterScrollOff.listeners,
Expand Down
21 changes: 17 additions & 4 deletions src/core/BlobPhysics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ export class BlobPhysics {
private mouseY = 50;
private mouseVelX = 0;
private mouseVelY = 0;
private lastMouseX = 50;
private lastMouseY = 50;


private gravity: GravityVector = { x: 0, y: 0 };
Expand Down Expand Up @@ -214,8 +212,6 @@ export class BlobPhysics {
const previousMouseY = this.mouseY;
this.mouseVelX = x - previousMouseX;
this.mouseVelY = y - previousMouseY;
this.lastMouseX = previousMouseX;
this.lastMouseY = previousMouseY;
this.mouseX = x;
this.mouseY = y;
}
Expand Down Expand Up @@ -457,6 +453,9 @@ export class BlobPhysics {

this.applyAccelerometerForces(blob);


this.applyPointerField(blob);


this.updateMovementWithAccelerometer(blob, time);

Expand Down Expand Up @@ -494,6 +493,20 @@ export class BlobPhysics {
}
}

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

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 Neutral sentinel silently disables field at coordinate center

mouseX === 50 && mouseY === 50 is the sentinel for "no pointer input yet / pointer reset to neutral." If the coordinate space is [0, 100] (which mouseX = 50 at init and the mouseDistance formula suggest), a user whose cursor legitimately lands at the exact center will get no pointer attraction. The sentinel conflates "uninitialized" with a valid position. A separate boolean flag (e.g. private pointerActive = false) would eliminate the ambiguity without a performance cost.


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

const normalized = 1 - distance / 34;
const scale = (normalized * normalized * 0.0014) / distance;
blob.velocityX += dx * scale;
blob.velocityY += dy * scale;
}
Comment on lines +496 to +508

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 Magic numbers for pointer radius and strength

The hardcoded 34 (radius) and 0.0014 (strength) are embedded directly in the arithmetic without named constants. When the next tuning slice arrives (per the physics contract, "pointer-locality probes should be the next slice"), it will be easy to miss one of the two sites since the same values also appear implicitly in the test (distance >= 34, velocityX > 0). Extracting them as private named constants keeps the relationship explicit.

Suggested change
private applyPointerField(blob: ConvexBlob): void {
if (this.mouseX === 50 && this.mouseY === 50) return;
const dx = this.mouseX - blob.currentX;
const dy = this.mouseY - blob.currentY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0 || distance >= 34) return;
const normalized = 1 - distance / 34;
const scale = (normalized * normalized * 0.0014) / distance;
blob.velocityX += dx * scale;
blob.velocityY += dy * scale;
}
private static readonly POINTER_FIELD_RADIUS = 34;
private static readonly POINTER_FIELD_STRENGTH = 0.0014;
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 >= BlobPhysics.POINTER_FIELD_RADIUS) return;
const normalized = 1 - distance / BlobPhysics.POINTER_FIELD_RADIUS;
const scale = (normalized * normalized * BlobPhysics.POINTER_FIELD_STRENGTH) / distance;
blob.velocityX += dx * scale;
blob.velocityY += dy * scale;
}

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!


private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void {

const neutralDriftX = (Math.random() - 0.5) * 0.001;
Expand Down
5 changes: 3 additions & 2 deletions src/core/InteractionField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ export function pointAttractorField({
const dx = target.x - origin.x;
const dy = target.y - origin.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return { x: 0, y: 0 };
if (distance === 0 || radius <= 0 || distance >= radius) return { x: 0, y: 0 };

const falloff = smoothDistanceFalloff(distance, radius);
const normalized = 1 - distance / radius;
const falloff = normalized * normalized;
const scale = (falloff * strength) / distance;
return {
x: dx * scale,
Expand Down
30 changes: 25 additions & 5 deletions tests/unit/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ describe('BlobPhysics', () => {
expect(blob.velocityX / blob.velocityY).toBeCloseTo(3 / 4);
});

it('tracks pointer position and velocity without applying standalone pointer force', () => {
it('tracks pointer position and velocity without applying distant pointer force', () => {
const physics = new BlobPhysics(0);
const blob = createTestConvexBlob(30, 50, 20);
const internals = physics as unknown as {
Expand All @@ -343,11 +343,33 @@ describe('BlobPhysics', () => {
expect(blob.mouseDistance).toBeCloseTo(Math.sqrt((30 - 75) ** 2 + (50 - 25) ** 2));
});

it('applies pointer influence as a local field after pointer input', () => {
const physics = new BlobPhysics(0);
const near = createTestConvexBlob(60, 50, 20);
const far = createTestConvexBlob(5, 50, 20);
const centered = createTestConvexBlob(60, 50, 20);
const internals = physics as unknown as {
applyPointerField(blob: ConvexBlob): void;
};

internals.applyPointerField(centered);

expect(centered.velocityX).toBe(0);
expect(centered.velocityY).toBe(0);

physics.updateMousePosition(75, 50);
internals.applyPointerField(near);
internals.applyPointerField(far);

expect(near.velocityX).toBeGreaterThan(0);
expect(near.velocityY).toBe(0);
expect(far.velocityX).toBe(0);
expect(far.velocityY).toBe(0);
});

it('computes pointer velocity from the previous pointer anchor', () => {
const physics = new BlobPhysics(0);
const internals = physics as unknown as {
lastMouseX: number;
lastMouseY: number;
mouseVelX: number;
mouseVelY: number;
};
Expand All @@ -357,8 +379,6 @@ describe('BlobPhysics', () => {

expect(internals.mouseVelX).toBe(5);
expect(internals.mouseVelY).toBe(-5);
expect(internals.lastMouseX).toBe(75);
expect(internals.lastMouseY).toBe(25);
});
});

Expand Down
Loading