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
5.3 KiB
5.3 KiB
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:
// 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
- 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 nearestprojected. Keep the existing clamps (−140 px peek clamp stays authoritative). - 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 byNextFrame, applying via the existingscript_apply_eval!(cx, sheet, { margin: Inset{… bottom: #(b)} })write. No easing curve — the motion is the physics; done-threshold persettle(|x| < 0.5 px). - Interruptible:
Hit::FingerDownon the grab handle during a settle cancels it and resumes 1:1 drag from the current on-screen position (not the logical target). - 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). flow.set_sheet(mode)commits only when the settle finishes (or immediately underreduce_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.rsas pure Rust (the crate's model/app split is the point of the port — seeSheetDrag);app.rsonly steps time and writes margins. SheetDragalready tracks velocity in px/ms — convert once (* 1000.0) at the seam.- Sheet position writes: the existing
script_apply_eval!margin pattern atapp.rs:1570.
Steps
model.rs: addpub struct SheetSettle {from, target_px, target_mode, v0, start_ms}andpub fn release(&self, drag: &SheetDrag) -> ReleaseOutcomereturningTap | Settle(SheetSettle); unit tests:- flick up from peek at
v = −0.5 px/msfrom 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=0and monotonically approaches target forv0 = 0; !drag.moved()still yieldsTap(8 px tolerance preserved).
- flick up from peek at
app.rs: replace the FingerUp arm — onSettle, store it +cx.new_next_frame(); add the NextFrame arm steppingsettle, writing the margin, committing mode on done.app.rs: FingerDown during an active settle → cancel settle,SheetDrag::beginat the current interpolated position.- Rubber-band the over-drag branch of
drag.translatedisplay value. - 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_overloadbehaves 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).
- new units),
- 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_chromedirectly from FingerUp for the sheet, and all tests are green.