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

103 lines
5.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```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.