# Motion

How the shell moves, and who is allowed to move a window. The code is `surfaces/quickshell/services/ZoneTransition.qml`, `modules/souveraine/navigation/`, and `crates/compositor/src/transition.rs` in viewtop.

## One clock, two strategies

The rail measures a thumb and reports one number: how far it has climbed. It derives nothing. `ZoneTransition.pullTo(travel, detent)` takes it, and `clock` is `travel / detent` — 0 at rest, 1 at the multitasking detent, and it keeps counting past that, because the climb toward home is more of the same motion and not a second gesture.

Everything visible is a curve over that one number, and every curve lives in that one file.

`shift` is what the compositor carries the window on. `presence` is how present the destination is. They are genuinely different: the window keeps travelling as the climb continues toward home, while the destination has to recede, or a release would arrive somewhere it was never going to take you. Two curves is correct. Two *places* is the fault — a scale on the rail and a scale on the card, tuned by hand to agree, which is how the original was built and what TASK-60 was written to remove.

## The card rect is one number, not two that agree

A card is the usable zone drawn small. One scale factor, origin included, because `Viewtop::carried` takes its scale from the width alone and applies it uniformly — a destination of any other aspect would letterbox the live window inside the card it is supposed to *be*.

`ZoneTransition` owns the rect. `ZoneOverview` lays its card out at `cardInset*`; the carry names `cardX`/`cardY` as the destination. They are the same rectangle in two coordinate spaces, not two rectangles kept in agreement.

`contentTop` comes off a window's `at.y` before scaling. The card is a picture of the usable zone, not of the whole output, and a window tiled below the bar's reservation starts at the card's top edge. Miss the subtraction in one of the two places and the carried window lands a bar's height off its own picture.

## Releasing is arriving

Every swipe resolves to exactly one end target: `home`, `overview`, `last_zone`, or a named zone. The transform is released by `commit` or `cancel` inside the compositor and there is no third way for a carry to end, so a window cannot outlive the gesture that carried it.

That is structural, not careful. The old shape was a transform one surface applied and a *different* surface had to remember to undo — so tapping a card, which never touches the rail, left a window shrunk with no way back.

`last_zone` is a destination with a name, not an abandon branch, for the same reason.

## The settle is a spring, not a duration

A lift at 60% of the climb used to commit *at* 60%: the window jumped from where the thumb left it to its final state. Two motions with a cut between them.

A duration and an easing curve cannot carry release velocity, so a flick and a drift settled identically. The settle is a critically damped spring integrated on `FrameAnimation` — the render clock, so the carry advances once per painted frame — seeded with the velocity the rail measures over the last 100 ms of the gesture. Critically damped and never underdamped: a navigation surface that rings reads as a toy.

Hard-capped at 600 ms. The commit is what releases the carry, so a settle that never converged has to arrive anyway.

Known and not yet fixed: on the `last_zone` path `travel` clamps at 0 while the exit test still waits for `|velocity| < 0.02`, so the commit can lag the visual arrival by up to that cap on the commonest gesture there is.

## The transport is fast and lossy, and only the second one matters

Measured on blueline, 2026-08-16, 200 samples, full connect → write → event-loop hop → reply → close: `workspaces` 0.47 ms median, 1.04 ms worst. Under 3% of a frame. A quantiser protecting against that cost is protecting against nothing, and `_lastShift`'s 0.01 bought a visible staircase — one step per two logical pixels across 275 px of travel. It is 0.002 now.

What the transport *is* is lossy: 52 failures and 113 `PeerClosedError`s in one 90-minute boot, each of which used to execute `_queue = []`. A lost `overview_commit` leaves the compositor carrying windows the shell believes it released and nothing in the system will ever put them back. `overview_commit`, `overview_cancel` and `workspace` are idempotent and are redelivered once. `overview_progress` is a level and still drops.

Rate is bounded by the render clock, not by a quantiser. `WindowResize` pumps its `place` the same way.

## The curve bank

`Appearance.animationCurves` is Material 3 Expressive and it has been in the tree since the `ii` days. Every spatial curve in it overshoots — `expressiveFastSpatial` peaks at 1.67, `expressiveDefaultSpatial` at 1.21 — and there is a `clickBounce` beside them.

Audited 2026-08-16: twelve souveraine surfaces, 31 hand-typed animations, three calls into that bank from two files. Every navigation surface was animating on a flat linear ramp with a hand-typed duration. That was the whole of "the reference compositors have more whoosh."

Use the bank. A hand-typed `NumberAnimation { duration: N }` in a new surface is a defect unless it says in a comment why the bank is wrong for it.

## The rule under all of it

The shell does not transform a window. It names a destination and the compositor carries it there.

Any shell-side scale over a window's geometry is a second writer, and a transform one surface applies and another must remember to undo always has a path that forgets. Where a card *must* have motion of its own — depth, the entry rise — it is gated on `ZoneTransition.inFlight` so that during a carry every card sits at its true rect and the motion eases in on the commit frame.

## Not yet true

`transition.rs:79` clamps `shift_to` to `0..=1`, so the resistance past the detent is written, shipped and inert. Raising it to `maxOvershoot` (1.22) and updating `shift_is_clamped` is a compositor change. Safe to ship the shell ahead of it: an old compositor clamps and the gesture behaves exactly as it did.

The control socket also serves one request per connection. The QML client is
correct to make commit/cancel/workspace idempotent and retry once today. A
persistent connection needs the compositor handler to loop to EOF with one
`BufReader`; rebuilding the reader for each request can discard bytes it read
past the first newline. Persistence changes transport cost and failure shape,
not the authority or acknowledgement rules above.
