makepad-ports/plans/001-press-contract.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.5 KiB
Raw Permalink Blame History

001 — Press contract: feedback on down, commit on up-if-over, 0.96 scale

  • Status: TODO
  • Commit: d50006e
  • Severity: HIGH (all three crates)
  • Category: Purpose & frequency / Physicality — the responsiveness contract
  • Estimated scope: craft/src/widgets.rs (+~150 lines), insurance/src/app.rs, rider/src/app.rs, koboyo/src/app.rs (DSL + handler edits at ~40 call sites), UI tests

Problem

Every plain-View "button" in all three apps fires its action the moment a finger touches it, with zero visual feedback. Users cannot cancel a mis-touch by dragging away (the standard tap contract on every platform), and the UI never acknowledges a press.

// insurance/src/app.rs:1427 — current (same pattern ~15×: tabs, back btns, quick actions, fab, chips)
for (id, screen) in tab_rows {
    if ui.view(cx, &[id]).finger_down(actions).is_some() {
        self.flow.go(screen);
        self.refresh_chrome(cx);
        return;
    }
}
// rider/src/app.rs:1110 — current (same pattern ~12×; note rider ALSO has real Buttons using
// clicked() at :1200/:1215, so identical-looking controls commit at different moments)
if ui.view(cx, &[id]).finger_down(actions).is_some() {
    let fx = self.flow.go(screen);
    self.apply_fx(cx, fx);
    return;
}
// koboyo/src/app.rs:824 — current (rail tools, t_lock, t_shapes, t_connect, t_all_tools,
// drawer_close, zoom_minus/plus, …)
for (id, tool) in TOOL_BTNS {
    if ui.view(cx, &[id]).finger_down(actions).is_some() {
        self.set_tool(cx, tool);
        return;
    }
}

Target

A Pressable widget in the craft crate; every tappable View becomes one. Exact behavior:

  • Hit::FingerDown → play hover.down: down reaches 1.0 via snap(1.0) (instant), visible as scale 0.96 computed in the SDF shader (RECIPES.md §1 — scale around center, radius scales with it) plus the widget's existing pressed tint if any.
  • Hit::FingerUp{is_over: true} → play release (hover.on for pointer devices / hover.off otherwise) with Forward {duration: 0.15}, ease: OutQuad, and emit PressableAction::Clicked — the ONLY place the action fires.
  • Hit::FingerUp{is_over: false} → play hover.off, emit nothing (tap-cancel).
  • Hit::FingerHoverIn/Outhover 1.0/0.0 over Forward {duration: 0.1} / 0.1, OutQuad.
  • Animator track is exactly the upstream widgets/src/button.rs hover structure (off/on/down states, snap(1.0) entries); durations from mod.craft.* tokens.
  • High-frequency exception (koboyo rail): construct with feedback: minimal — color/opacity only, no scale, durations unchanged. Direct-manipulation exception: tools that arm a drag (pen/lasso in koboyo) may keep act-on-down; mark those call sites with a comment // arms a drag: act-on-down is intentional instead of migrating them.

Repo conventions to follow

  • Handler style: apps match on collected actions in handle_actions; Pressable should surface clicked(actions) exactly like stock Button so migration is mechanical: ui.view(cx, &[id]).finger_down(actions).is_some()pressable_clicked(ui, cx, &[id], actions) or per-widget ui.widget(cx, &[id]).….
  • Dynamic ids in loops MUST use &[id], never ids!(id) (README bug 1).
  • Register with the let Pressable = #(Pressable::register_widget(vm)) {…} pattern (bug 3).

Steps

  1. Implement Pressable in craft/src/widgets.rs per RECIPES.md §1 (struct, DSL, animator, hit handling, PressableAction::Clicked). Include minimal: bool live prop.
  2. insurance: wrap the ~15 tappable Views (tab_*, back_*, quick actions, dl_btn, fab_btn, backdrop_view stays a plain scrim, chips at :1507/:1531) as Pressable; change app.rs:14271531 handlers from finger_down to clicked-on-up. backdrop_view dismiss may remain act-on-down (scrims dismiss on touch — platform convention).
  3. rider: same for app.rs:11081214 (nav rows, avatars, back_found, wtc_pill, suggestion rows, pills). Real Buttons at :1200/:1215 keep clicked() but gain the 0.96 press track via a craft-styled button variant so look == behavior.
  4. koboyo: same for app.rs:822883+ rail/zoom/drawer-close controls with minimal: true; leave pen/lasso arming sites act-on-down with the marker comment.
  5. Tests, per crate:
    • Integration (model untouched — should pass unchanged).
    • UI (tests/ui.rs): add a tap-cancel test: synthesize FingerDown on a nav tile, FingerMove outside its rect, FingerUp → assert screen/model did NOT change; then a plain click → assert it did. Assert end-state via model/screen, not animator mid-values.

Boundaries

  • Do NOT change what any action does — only when it fires (up-if-over) and its feedback.
  • Do NOT animate keyboard-driven paths.
  • Do NOT touch canvas gesture code in koboyo/src/canvas_view.rs.
  • If a call site's action deliberately needs act-on-down beyond the listed exceptions, STOP and report rather than guessing.

Verification

  • Mechanical: full test matrix green; new tap-cancel UI tests pass in MAKEPAD=headless mode.
  • Feel check (run each app windowed): press-and-hold any tile → it visibly sinks to 0.96 instantly; drag off and release → nothing happens; release over it → action fires and the tile relaxes over ~0.15s. Spam-tap → no restart-from-zero flicker (animator retargets).
  • Done when: grep -rn "finger_down(actions)" */src/app.rs returns only the documented act-on-down exceptions, and all 140+ tests are green.