# 003 — Insurance sheet: momentum projection + velocity-handoff settle - **Status**: TODO - **Commit**: d50006e - **Severity**: MEDIUM (the app's marquee interaction) - **Category**: Interruptibility / gesture physics (Apple §4–6) - **Estimated scope**: `insurance/src/model.rs` (+~60 lines), `insurance/src/app.rs` (FingerUp/NextFrame handling, ~50 lines), unit + UI tests ## Problem The drag itself is a faithful 1:1 port (grab-offset, velocity thresholds −26/−12 @ −0.2 px·ms⁻¹ up, +36/+18 @ +0.28 down, −140 px peek clamp, 8 px tap tolerance), but release discards everything the model measured and hard-snaps the chrome: ```rust // insurance/src/app.rs:1580 — current Hit::FingerUp(_) => { if let Some(drag) = self.sheet_drag.take() { self.flow.finish_drag(&drag); // decides mode… self.refresh_chrome(cx); // …and snaps the sheet there instantly } } ``` ```rust // insurance/src/model.rs:605 — current pub fn finish_drag(&mut self, drag: &SheetDrag) { match drag.end(self.sheet) { DragOutcome::To(mode) => self.set_sheet(mode), DragOutcome::SnapBack => { if !drag.moved() { self.handle_tap(); } } } } ``` The seam between finger and animation is where "fluid" lives; a snap reads as the app ignoring the flick it just measured. ## Target 1. **Projection picks the target** (replaces raw thresholds only where they disagree — thresholds remain the tap/short-drag fallback): on release, compute `projected = pos + craft::motion::project(v_px_per_s, 0.998)` and choose the snap state (closed / peek / expanded) whose rest position is nearest `projected`. Keep the existing clamps (−140 px peek clamp stays authoritative). 2. **Velocity-handoff settle** (RECIPES.md §4): animate the sheet's bottom-margin from the *current* drag position to the target using `craft::motion::settle(from, target, v0, λ=12.0, t)` stepped by `NextFrame`, applying via the existing `script_apply_eval!(cx, sheet, { margin: Inset{… bottom: #(b)} })` write. No easing curve — the motion *is* the physics; done-threshold per `settle` (|x| < 0.5 px). 3. **Interruptible**: `Hit::FingerDown` on the grab handle during a settle cancels it and resumes 1:1 drag from the current on-screen position (not the logical target). 4. **Rubber-band** while dragging past bounds: apply `craft::motion::rubberband(over, 140.0)` above the expanded stop instead of the hard clamp feel (the −140 clamp still bounds state, the visual just approaches it softly). 5. `flow.set_sheet(mode)` commits only when the settle finishes (or immediately under `reduce_motion`, which skips the settle and snaps — motion is never the only channel, so chrome text/FAB state must not depend on the settle running). ## Repo conventions to follow - All physics stays in `model.rs` as pure Rust (the crate's model/app split is the point of the port — see `SheetDrag`); `app.rs` only steps time and writes margins. - `SheetDrag` already tracks velocity in px/ms — convert once (`* 1000.0`) at the seam. - Sheet position writes: the existing `script_apply_eval!` margin pattern at `app.rs:1570`. ## Steps 1. `model.rs`: add `pub struct SheetSettle {from, target_px, target_mode, v0, start_ms}` and `pub fn release(&self, drag: &SheetDrag) -> ReleaseOutcome` returning `Tap | Settle(SheetSettle)`; unit tests: - flick up from peek at `v = −0.5 px/ms` from 20 px of travel → target = expanded (projection overrides the −26 px distance threshold); - slow 10 px drift up (`|v| < 0.2`) → snap back to peek (thresholds win); - settle position is continuous at `t=0` and monotonically approaches target for `v0 = 0`; - `!drag.moved()` still yields `Tap` (8 px tolerance preserved). 2. `app.rs`: replace the FingerUp arm — on `Settle`, store it + `cx.new_next_frame()`; add the NextFrame arm stepping `settle`, writing the margin, committing mode on done. 3. `app.rs`: FingerDown during an active settle → cancel settle, `SheetDrag::begin` at the current interpolated position. 4. Rubber-band the over-drag branch of `drag.translate` display value. 5. UI test: open sheet → synthesize drag up + release → wait ≤ 1 s → assert expanded state and chrome consistent; drag-and-release below the down threshold → assert it returns to peek. (End states only; ~48 s headless budget for this crate is fine.) ## Boundaries - Do NOT alter the drag-move path (1:1 tracking is already correct) except the rubber-band display transform. - Do NOT change the sheet's DSL layout, sizes, or the 390×1280 test window (README bug 8 depends on it). - Do NOT use the Animator for the settle (it can't take an initial velocity) — NextFrame only. - If `hits_with_capture_overload` behaves differently mid-settle than mid-drag, STOP and report. ## Verification - **Mechanical**: `cargo test -p makepad-example-insurance --lib --test integration` (28+14 + new units), `MAKEPAD=headless … --test ui` (8 + 2 new). - **Feel check**: flick the sheet up gently — it *keeps moving* at your speed and eases home with no seam; grab it mid-settle — it sticks to the finger instantly; drag past the top — it resists progressively, never walls. - **Done when**: no code path calls `refresh_chrome` directly from FingerUp for the sheet, and all tests are green.