# 002 — Toast enter/exit motion (kill the `set_visible` pop) - **Status**: TODO - **Commit**: d50006e - **Severity**: MEDIUM (all three crates) - **Category**: Physicality & interruptibility - **Estimated scope**: `craft/src/widgets.rs` (+~80 lines), 3 × `app.rs` (replace ~10 lines each), UI tests ## Problem All three apps pop toasts in and out with `set_visible` — appearance from nothing, hard cut on dismiss, mid-lifecycle re-shows snap: ```rust // insurance/src/app.rs:1313 — current (rider:1004/:1279 and koboyo:558/:589 are equivalent) fn show_toast(&mut self, cx: &mut Cx, msg: &str) { self.toast_gen = self.flow.show_toast(msg); self.ui.label(cx, ids!(toast_lbl)).set_text(cx, msg); self.ui.view(cx, ids!(toast_view)).set_visible(cx, true); if let Some(t) = self.toast_timer.take() { cx.stop_timer(t); } self.toast_timer = Some(cx.start_timeout(TOAST_MS as f64 / 1000.0)); } ``` ## Target One `CraftToast` widget in the craft crate (RECIPES.md §3), adopted by all three apps: - **Enter**: rise + fade driven by a `slide: instance(0.0→1.0)`, `Forward {duration: 0.3}`, `ease: mod.craft.ease_out_strong` (`Bezier {cp0: 0.23, cp1: 1.0, cp2: 0.32, cp3: 1.0}`). Rest alpha follows `slide`; vertical offset = `(1.0 - slide) * 12px` in shader space. Nothing starts at scale/offset zero. - **Exit**: same path downward, `Forward {duration: 0.2}`, `ease: OutQuad` — subtler and faster than the enter, same direction (spatial consistency). - **Interruptible**: a re-show mid-exit plays `open.on` from the current interpolated value (free with the animator — no special code, just don't `set_visible(false)` early). - `set_visible(false)` happens only after the exit completes: on the dismiss timer, play `open.off` and start a `0.2s` cleanup timeout that flips visibility; a `show` arriving in that window cancels the cleanup. - Existing TTL generations (`toast_gen`) and durations (insurance 2200 ms etc.) unchanged. - Reduced motion: when the app's `reduce_motion` flag is set, `animator_cut` both ways (opacity still lands — state never depends on motion). ## Repo conventions to follow - Keep each app's toast placement/styling DSL (`toast_view := RoundedView{…}` overlay at `insurance/src/app.rs:1243–1260`, rider `:882`, koboyo's equivalent) — `CraftToast` replaces the RoundedView, inheriting its `draw_bg` colors via `+:` merge. - Timer style: `Option` fields + `cx.start_timeout`, as all three apps already do. ## Steps 1. Implement `CraftToast` in the craft crate: DSL (slide instance + animator per above), Rust (`show(cx, msg)`, `dismiss(cx)`, internal cleanup timer, `reduce_motion: bool` prop). 2. insurance: swap `toast_view` DSL; rewrite `show_toast` (`app.rs:1313–1321`) and the timer arm in `handle_timer` to `toast.show/dismiss`. Delete the raw `set_visible` pair. 3. rider: same at `app.rs:882/:1004/:1279`. 4. koboyo: same at `app.rs:558/:589`. 5. UI tests per crate: trigger a toast action → assert toast text + visible; advance past TTL + 0.3s → assert hidden. Add a **re-show mid-exit** test: show, wait past TTL so exit starts, show again immediately → assert visible with the new text (no flash-to-hidden). ## Boundaries - Do NOT change toast copy, TTLs, or `model.rs` toast generation logic. - Do NOT introduce per-app toast forks — one widget, three consumers. - Headless tests assert visibility/text/model state only, never mid-flight `slide` values. ## Verification - **Mechanical**: full matrix green + new toast lifecycle tests. - **Feel check**: trigger a toast → rises in with a fast start that eases out (~0.3s); expires → slips down faster (~0.2s); spam the triggering button → the toast retargets smoothly, never blinks or restarts from the bottom. - **Done when**: `grep -n "toast_view)).set_visible" */src/app.rs` returns nothing and the matrix is green.