makepad-ports/plans/003-sheet-settle.md
andodeki 3cfeaa289c Add implementation plans for makepad-ui-craft (Emil's improve-animations plan format)
plans/README.md: index, execution order 000->005, executor ground rules (test gate, DSL landmines)
000: shared craft crate (easing/duration/color tokens, Pressable, CraftToast, motion math + units)
001: HIGH press contract - feedback on down, commit on up-if-over, 0.96 scale (all 3 crates)
002: MEDIUM toast enter/exit, interruptible, reduced-motion aware (all 3 crates)
003: MEDIUM insurance sheet momentum projection + velocity-handoff settle, rubber-band
004: MEDIUM koboyo drawer slide, origin-aware popovers, animated camera fit
005: LOW polish - color tokens, concentric radii, real icons, tabular figures
2026-09-01 18:50:05 +00:00

5.3 KiB
Raw Permalink Blame History

003 — Insurance sheet: momentum projection + velocity-handoff settle

  • Status: TODO
  • Commit: d50006e
  • Severity: MEDIUM (the app's marquee interaction)
  • Category: Interruptibility / gesture physics (Apple §46)
  • 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:

// 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
    }
}
// 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.