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.5 KiB
5.5 KiB
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→ playhover.down:downreaches 1.0 viasnap(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.onfor pointer devices /hover.offotherwise) withForward {duration: 0.15},ease: OutQuad, and emitPressableAction::Clicked— the ONLY place the action fires.Hit::FingerUp{is_over: false}→ playhover.off, emit nothing (tap-cancel).Hit::FingerHoverIn/Out→hover1.0/0.0 overForward {duration: 0.1}/0.1,OutQuad.- Animator track is exactly the upstream
widgets/src/button.rshoverstructure (off/on/down states,snap(1.0)entries); durations frommod.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 intentionalinstead of migrating them.
Repo conventions to follow
- Handler style: apps match on collected
actionsinhandle_actions;Pressableshould surfaceclicked(actions)exactly like stockButtonso migration is mechanical:ui.view(cx, &[id]).finger_down(actions).is_some()→pressable_clicked(ui, cx, &[id], actions)or per-widgetui.widget(cx, &[id]).…. - Dynamic ids in loops MUST use
&[id], neverids!(id)(README bug 1). - Register with the
let Pressable = #(Pressable::register_widget(vm)) {…}pattern (bug 3).
Steps
- Implement
Pressableincraft/src/widgets.rsper RECIPES.md §1 (struct, DSL, animator, hit handling,PressableAction::Clicked). Includeminimal: boollive prop. - insurance: wrap the ~15 tappable Views (
tab_*,back_*, quick actions,dl_btn,fab_btn,backdrop_viewstays a plain scrim, chips at:1507/:1531) asPressable; changeapp.rs:1427–1531handlers fromfinger_downto clicked-on-up.backdrop_viewdismiss may remain act-on-down (scrims dismiss on touch — platform convention). - rider: same for
app.rs:1108–1214(nav rows, avatars,back_found,wtc_pill, suggestion rows, pills). RealButtons at:1200/:1215keepclicked()but gain the 0.96 press track via acraft-styled button variant so look == behavior. - koboyo: same for
app.rs:822–883+rail/zoom/drawer-close controls withminimal: true; leave pen/lasso arming sites act-on-down with the marker comment. - 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=headlessmode. - 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.rsreturns only the documented act-on-down exceptions, and all 140+ tests are green.