Skip to content

The owner tunes the ease, and a cue button answers the press (#175) - #176

Closed
Devski wants to merge 2 commits into
mainfrom
claude/175-motion-params
Closed

The owner tunes the ease, and a cue button answers the press (#175)#176
Devski wants to merge 2 commits into
mainfrom
claude/175-motion-params

Conversation

@Devski

@Devski Devski commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Closes #175.

Three things Dawid asked for after using #153 — all about how the motion is controlled, not what it does.

The ease is two amounts, not a switch

easeIn and easeOut, each 0..1, each absent meaning 1, beside the glide in r360_params — JSON, so no migration. Two sliders under the motion switch in the edit form (Rozbieg / Dobieg), shown as whole percent.

The curve is a blend between the straight line and the smoothstep #153 shipped, applied per half around the point the two agree on. That choice does the work:

  • full both ways is that smoothstep, not an approximation, so every work saved before the sliders feels exactly as it did;
  • each half slides towards a straight line on its own — 1 in and 0 out gathers pace and then runs flat into its frame;
  • and the halves meet where they always met, so no mixture puts a step in the middle.

One curve for every travel

travelCurve(params) is read once in the hook and handed to travelAlong — which is what a click on the ring, a click on a marker, and a press on a cue button all call. No hand can acquire a feel of its own. The click Dawid meant is the one on the navigation ring, and it goes through there.

The cue row answers the press, not the arrival

It was lighting cue.frame === orbit.frame — a readout of where the orbit is. Two consequences, both of them Dawid's complaints: a press did nothing visible until the orbit arrived, and every cue a travel crossed lit for the 28 ms the orbit stood on it — a blink.

The orbit now says where it was asked to be (aimedAt, set only by an aimed travel — a coast has an end, but nobody asked for it), and the row reads that: lit on the press, dropped the moment a hand takes over, and the cues on the way stay unlit.

What they do instead is pulse — a ring that grows and fades. Never the tones: this row deliberately has no colour transition because a fade between them passes through colours that cannot be read, which axe caught in the lightbox on #107. prefers-reduced-motion turns the pulse off.

Every button now says it is pressable

Tailwind 4's preflight no longer sets cursor: pointer on button (verified in node_modules/tailwindcss/preflight.css, 4.3.3) and the shared Button base never did — so every button in the product showed the plain arrow. Dawid noticed it on the cue row; it was true of all of them. One base rule; disabled buttons keep the cursor that says so.

Verified

pnpm check green, pnpm build green (the data-passing variant and the keyframe are in the generated CSS, with the reduced-motion rule beside them). r360.spec.ts 10/10 and works.spec.ts 11/11 against the test database; orbit.test.ts covers the new curve — each amount bending its own half, 0/0 matching a constant pace frame for frame, a value out of range or absent reading as the full ease, and the switch still winning over both.

The new e2e case earned itself immediately: run begins by cancelling the travel in flight, and cancelling is what drops the aim — so an aim set before that call was cleared a line later and the button lit only on arrival, which is precisely the bug this was meant to remove.

🤖 Generated with Claude Code

Devski and others added 2 commits September 12, 2026 17:15
Three things Dawid asked for after using #153, all in how the motion is
controlled rather than in what it does.

**The ease is two amounts, not a switch.** `easeIn` and `easeOut`, each 0..1
and each absent meaning 1, live beside the glide in `r360_params` — JSON, so
no migration. The curve is a blend between the straight line and the
smoothstep #153 shipped, taken per half around the point they share: full
both ways IS that smoothstep rather than an approximation of it, so every
work saved before the sliders existed feels exactly as it did, and each half
slides towards a straight line on its own. 1 in and 0 out gathers pace and
then runs flat into its frame.

**One curve for every travel.** `travelCurve(params)` is read once in the
hook and handed to `travelAlong`, which is what the ring, a marker on it and
a cue button all call — so no hand can acquire a feel of its own. The click
Dawid meant is the one on the navigation ring, and it goes through there.

**The cue row answers the press.** It was lighting `cue.frame ===
orbit.frame` — a readout of where the orbit IS, which meant a press did
nothing visible until the orbit arrived, and that every cue a travel crossed
lit for the 28 ms it stood there. The orbit now says where it was ASKED to
be (`aimedAt`), and the row reads that: lit on the press, dropped when a hand
takes over, and the cues on the way stay unlit. What they do instead is
pulse — a ring that grows and fades, never the tones, which cannot fade
between each other without passing through colours that cannot be read
(#107, caught by axe). Reduced motion turns it off.

**And every button in the product now says it is pressable.** Tailwind 4's
preflight no longer sets `cursor: pointer` and the shared Button base never
did, so all of them showed the plain arrow. One base rule, disabled ones kept
as they were.

The new e2e case earned itself: `run` begins by cancelling the travel in
flight, and cancelling is what drops the aim — so the aim set before it was
cleared a line later, and the button lit only on arrival, exactly the bug
this was meant to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The curve came back sound; the cue row did not, and none of it was visible
to a green suite — nothing asserted the pulse, and the e2e stubs out the
animation frames the pulse lives between.

- **The pressed button pulsed at the end of every travel.** `travelStop`
  stands the orbit on its last frame well before it arrives — a fifth of the
  way from the end on a full ease — so the destination was "crossed" while
  it was also "here": a focus-coloured ring running over the solid tone and
  then cut off. The frame being aimed at is exempt now.
- **The pulse was never seen anywhere else.** It was derived from the
  current frame, which is one animation frame wide — 28 ms, less through the
  fast middle of a travel — while the animation is 450 ms, and taking the
  attribute away cuts the animation off. That is the same blink moved from
  the fill to a shadow. Crossings are latched now and cleared on
  animationend, so each one plays out.
- **A coast still marked every cue it passed.** It goes through `run`
  directly, so nothing was aimed at, and the row fell back to the current
  frame: the solid tone for one frame on every cue a thrown orbit crossed —
  the longest motion there is, on the gesture a phone uses most. The orbit
  now says whether it is `moving` at all, and while it is, a frame with
  nobody asking for it is passed through rather than arrived at.

Also from the review: the curve object is memoised, exactly as the comment
three lines above it demands of the glide; `alongCurve` clamps what it is
handed, because the type is exported and an amount of 5 bends the blend back
down the path and hands out a frame that is not a number; a slider dragged
back to full writes nothing at all, so a work is as untouched as it was
found; the sliders step by whole percent, which the schema accepts, rather
than by ten, which native validation would refuse on save; the cursor rule
covers a button disabled with aria-disabled; and the motion hint names the
two sliders that now sit under it.

The comment claiming no step in the middle said more than it could: the
POSITION is continuous there, the speed is not, and the asymmetric settings
are exactly the ones that show it. It says that now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Devski

Devski commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

A review lane went over this. The curve came back sound — the blend is monotone for every pair of amounts, continuous at the midpoint, and 1/1 is literally the #153 smoothstep, which the unchanged #153 test proves. The cue row came back with three defects, and none of them could have been caught by the suite: nothing asserted the pulse, and the e2e stubs out the very animation frames the pulse lives between.

The pressed button pulsed at the end of every travel. travelStop stands the orbit on its last frame well before it arrives — about a fifth of the way from the end on a full ease, ~230 ms on a long travel — so the destination was passing while it was also here: a focus-coloured ring running over the solid tone, then cut off when the aim cleared. Every press ended in a flicker on the button you pressed. The frame being aimed at is exempt now.

The pulse was never seen on the cues it was for. It was derived from the current frame, which lasts one animation frame — 28 ms, less through the fast middle of an eased travel, which is exactly where crossings happen — while the animation is 450 ms, and removing the attribute removes the animation outright. So it played about 6% of its ease-out: a 1 px ring for 28 ms. That is the same blink, moved from the fill to a shadow. Crossings are latched in state now and cleared on animationend, so each one plays out.

A thrown orbit still marked every cue it passed. A coast calls run directly, so nothing is aimed at, and the row fell back to the current frame: the full solid tone for a single frame on every cue the coast crossed — the longest motion there is, on the gesture a phone uses most. The orbit now reports whether it is moving at all, and while it is, a frame nobody asked for is passed through rather than arrived at. Covered by construction, not by a test — catching a 28 ms window mid-coast in a browser is exactly the kind of assertion that turns red for the wrong reasons.

Also applied: the curve object is memoised (the comment three lines above it demands precisely that of the glide, and travelCurve returns a literal); alongCurve clamps what it is handed, because the type is exported and an amount of 5 bends the blend back down the path and hands out path[-3] from a function whose type promises a number; a slider dragged back to full writes nothing at all, so a work stays as untouched as it was found; the sliders step by whole percent — at ten, a value the schema happily accepts would be a stepMismatch and native validation would refuse the save, on a control that may be scrolled out of view; the cursor rule covers a button disabled with aria-disabled; and the motion hint names the two sliders now under it.

One comment was claiming more than it could: the position is continuous at the midpoint, the speed is not — 100 in and 0 out drops a third of the pace there. It says so now, as a thing the owner chose rather than a seam to hide.

Noted and not changed: aria-current now means "heading here" for up to 1.2 s, which is a change in what the word says to a screen reader. It is the honest reading of a button that lights on the press, but it is worth knowing.

@Devski

Devski commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Closed unmerged on Dawid's decision of 12.09.2026: the product is losing functions rather than gaining them, and this added three. Nothing of it reached main. #175 is closed as not planned alongside it.

Kept in the record because it is a real finding either way: Tailwind 4 no longer sets cursor: pointer on button and the shared Button base never did, so every button in the product shows the plain arrow. That one is not about motion and will outlive this branch.

@Devski Devski closed this Sep 12, 2026
@Devski
Devski deleted the claude/175-motion-params branch September 12, 2026 16:12
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.

The orbit's easing, tuned by the owner — and cue buttons that answer the press, not the arrival

1 participant