Skip to content

R360: motion that eases, a drag that coasts, and the owner's switch (#153) - #171

Merged
Devski merged 1 commit into
mainfrom
claude/153-orbit-glide
Sep 12, 2026
Merged

R360: motion that eases, a drag that coasts, and the owner's switch (#153)#171
Devski merged 1 commit into
mainfrom
claude/153-orbit-glide

Conversation

@Devski

@Devski Devski commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

#153. A click on the ring ran at one pace
from the first frame to the last and stopped dead on arrival; a drag stopped the instant the hand
left the picture. Both have a shape now.

  • A travel eases. It gathers pace, runs fastest halfway and settles onto its frame — a click
    on the ring, and a cue point (#107) with it.
  • A drag coasts. Thrown rather than put down, the orbit keeps turning at the hand's speed and
    slows to a stop.
  • The owner's switch, per work. A sixth parameter beside the direction, stored in
    works.r360_params — so no migration.
  • Reduced motion still wins. A travel jumps and a release stops dead, whatever the owner chose.

How it works

The coast is not a second mechanism. coastAfterDrag reads the speed of the throw, works out how
far constant slowing carries it and how long that takes, and hands those frames to the same travel
a ring click uses. So a grab, a key, a frame count that changed under the hook and the
#161 landing timer hold a coast exactly as
they hold a travel, with none of them taught anything new.

Its curve is not a look chosen by eye either. 2t − t² IS where constant slowing puts a thing —
the same motion the distance was measured with — so the frames and the time cannot drift apart.

There is no "too slow to coast" threshold to keep in step with the rest: a throw that would not
carry a whole frame simply does not coast, which is the floor stating itself.

The bug review caught, and it was the important one. The speed was measured between pointer
moves, so the time a hand spends resting before it lifts never reached the divisor. The measured
speed stayed exactly what it had been while the hand was still travelling — right up to the moment
the sample window emptied, and then fell to nothing. A finger lifts tens of milliseconds after it
stops, which is the ordinary gesture, so "drag, stop, let go" would have thrown the orbit most of
the way round. Simulated on a 72-frame set: 51 frames of coast after a 60 ms rest, where it should
have been 6.

The fix is that the lift is now a reading like any other, not merely the moment of asking: resting
lengthens the span without lengthening the distance, and the throw drains smoothly to nothing. The
test named "drains the throw as the hand rests, rather than all at once" is that regression.

Two smaller things came out of the same pass. The press is no longer recorded as a reading — it is
where the finger landed, not motion, and counting it measured a speed across the drag slop, so a
tap that wobbled 8 px could throw the orbit. And the readings are stamped with event.timeStamp
rather than performance.now(): on an orbit page the main thread stalls decoding frames, the
queued moves then drain in one task, and handler time would read a whole swipe as having happened
at once.

The switch

Stored the other way about from the cue points: absent means ON, and only an owner turning it
off is ever written down. So every work saved before today glides too, isDefaultR360Params still
reads untouched parameters as untouched, and nothing has to be backfilled.

Open, for Dawid: the issue left "which way a work saved before it reads" to be decided. This
ships it ON, on the grounds that the motion is a quality every orbit should have had rather than
content the owner has to author — and that shipping it off would mean almost nobody ever sees it.
Say the word and it is a one-line change.

To try on the preview

The owner's own preview has a ring, so the feel can be judged in the edit form without saving
anything. What the issue asks to tune on a phone with Dawid — the curve, how long a coast lasts,
and whether a slow release coasts at all — is one constant, COAST_SLOWING in
src/lib/r360/orbit.ts. It sets how fast a coast sheds speed, and so both how long a throw runs
and how far it carries.

Two existing e2e drags had to be fixed

dragAcross in e2e/db/r360.spec.ts and the preview drag in e2e/db/works.spec.ts do
mouse.downmouse.move(…, {steps: 4})mouse.up with no pause. Playwright lands those moves
within a millisecond of one another — a speed no hand reaches — so every one of them became a
maximal throw, and the orbit coasted out from under the assertions that follow. The works-form one
would have failed outright: "Użyj tej klatki" reads the frame in view mid-spin, and the test ends
on an exact toEqual of the POST body.

Both now come to rest before letting go, which is what those tests always meant by a drag.
dragAcross takes { thrown: true } for the one case that wants a throw.

Also

The direction control and the new one are the same two-way toggle with different words in it, so
they are one component (TwoWayParam) now, and its hint is tied to the buttons with
aria-describedby — the legend names the parameter, not what choosing either way does.
travelPath in ring.ts and the coast both need the frames a turn passes through, so that is one
function (framesAlong) now. A travel's pace moved into lib/r360/orbit.ts so the coast's cap can
be derived from TRAVEL_MAX_MS instead of repeating 1200 in a second file.

Test plan

  • pnpm check — typecheck, eslint, 655 unit tests
  • pnpm build — the App Router pages compile
  • The release decision is a pure function (coastOnRelease), so all five ways of not coasting
    are tested in the existing node environment: a cancelled pointer, the glide off, reduced
    motion, a hand at rest, and a tap that wobbled. The hook keeps only the reading of the clock
    and of the event.
  • The coast's own tests assert properties — sign, twice-as-fast-runs-twice-as-long, the cap, the
    one-frame floor — and derive their inputs from the limits, so tuning the feel cannot break them
  • e2e: the owner's switch starts pressed on, and turned off and on again leaves no glide key
    in the saved parameters; a reduced-motion visitor's throw does not coast
  • By hand on the preview: a ring click, a throw, a slow release, a tap, and the same again with
    the switch off and with reduced motion on

🤖 Generated with Claude Code

…153)

A click on the ring ran at one pace from the first frame to the last and
stopped dead on arrival; a drag stopped the instant the hand left the
picture. Both have a shape now. A travel gathers pace, runs fastest halfway
and settles onto its frame; a drag thrown rather than put down keeps turning
at the hand's speed and slows to a stop.

The coast is not a second mechanism. `coastAfterDrag` reads the speed of the
throw, works out how far constant slowing carries it and how long that
takes, and hands those frames to the same travel a ring click uses — so a
grab, a key, a frame count that changed under the hook and the #161 landing
timer hold a coast exactly as they hold a travel, none of them taught
anything new. Nor is its curve a look chosen by eye: 2t − t² IS where
constant slowing puts a thing, the same motion the distance was measured
with, so the frames and the time cannot drift apart.

The owner gets a sixth parameter to turn both off, per work, in
works.r360_params — so no migration. It is stored the other way about from
the cue points: absent means ON, and only an owner turning it off is ever
written down, so every work saved before today glides too. A visitor whose
system asks for less motion gets neither, whatever the owner chose.

The direction control and the new one are the same two-way toggle with
different words in it, so they are one component now.

The feel itself — how fast a coast sheds speed, and so how long it runs and
how far it carries — is one constant in lib/r360/orbit.ts, to be tried on a
phone with Dawid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Devski
Devski merged commit 3ab5f69 into main Sep 12, 2026
7 checks passed
@Devski
Devski deleted the claude/153-orbit-glide branch September 12, 2026 14:24
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