--- name: makepad-ui-craft description: Emil Kowalski + Jakub Krehel design-engineering doctrine translated to Makepad 2.0 (script_mod! DSL, Animator, Ease). Use when building or reviewing any Makepad UI — buttons, sheets, toasts, drawers, page transitions, hover/press states, radii, shadows, typography, color tokens. Kills AI-slop UI: state snaps, missing press feedback, ease-in, scale(0) entries, mismatched nested radii. --- # Makepad UI Craft Interfaces feel great because of a pile of small, mostly invisible details that compound. In a world where everyone's software is *good enough*, this is the differentiator. Every rule below is a specific value, not a range to approximate — `0.96` is not `0.95`. **The Makepad ground rule that changes everything:** Makepad's `Animator` retargets from the *current* interpolated value when you `animator_play` a new state — like CSS transitions, not keyframes. Every animator-driven state change is interruptible by construction. `set_visible`, `script_apply_eval!` of a final value, and `refresh_chrome`-style full snaps are the Makepad equivalents of "no transition at all" — they are the #1 slop tell in this codebase family. ## 1. The Animation Decision Framework (Emil) Answer these **in order** before writing any animation: ### 1a. Should it animate at all? | Frequency | Decision | |---|---| | 100+ times/day (keyboard shortcuts, command-palette toggle, canvas pan/zoom) | **No animation. Ever.** | | Tens of times/day (hover, list navigation, tab/page switches in a work tool) | Remove or drastically reduce — instant feedback or ≤0.15s opacity/color | | Occasional (modals, sheets, drawers, toasts) | Standard animation | | Rare / first-time (onboarding, success, empty states) | Can add delight, stagger, springs | **Never animate keyboard-initiated actions.** **Direct-manipulation must track 1:1** — pan, zoom-at-cursor, and drag follow the pointer with zero smoothing (the koboyo canvas is correct to be instant while dragging). But *button-triggered camera jumps* (zoom-to-fit, reset-zoom) are occasional → animate them. ### 1b. What's the purpose? Valid: spatial consistency, state indication, feedback, preventing jarring appearance/disappearance, explanation. Invalid: "looks cool" on anything seen often. Motion is **never the only feedback channel** (Jakub) — every animated state change also needs a static cue (color, icon, label) so it reads when the animation doesn't run. ### 1c. What easing? Decision order (Makepad names — full table in [EASING.md](EASING.md)): - Entering or exiting → **`OutQuart`** or `Ease.Bezier {cp0: 0.23, cp1: 1.0, cp2: 0.32, cp3: 1.0}` (starts fast — the frame the user is watching) - Moving/morphing on screen → **`InOutCubic`** / `Ease.Bezier {cp0: 0.77, cp1: 0.0, cp2: 0.175, cp3: 1.0}` - Hover / color mix → **`OutQuad`** (what upstream `check_box.rs` ships) - Constant motion (spinner, marquee, `Loop`) → **`Linear`** - Momentum / "alive" settle → **`Ease.ExpDecay {d1: 0.82, d2: 0.97}`** (upstream `file_tree.rs`/`fold_button.rs` range: d1 0.80–0.98, d2 0.95–0.97) **Never `In*` easing on UI.** `InQuad` at 0.3s *feels* slower than `OutQuad` at 0.3s because it delays the initial movement. The default built-ins are weak; prefer `OutQuart`/`OutQuint`/`OutExp` or an explicit `Bezier` for punch. ### 1d. How fast? Makepad `Play::Forward {duration}` is in **seconds**: | Element | Duration | |---|---| | Press feedback (down) | `Snap` (instant) on press, `0.1–0.16` on release | | Hover on/off | `0.0–0.1` | | Tooltips, small popovers | `0.125–0.2` | | Dropdowns, drawers (side), selects | `0.15–0.25` | | Modals, bottom sheets, toasts | `0.2–0.5` | | **Hard ceiling for UI** | **0.3** (only sheets/modals may reach 0.5) | ## 2. Press, hover, focus — the responsiveness contract (Emil + Apple) - **Respond on finger-down, not on release.** Visual feedback fires on `Hit::FingerDown`; the *action commits* on `Hit::FingerUp` **only if the pointer is still over the widget** (`is_over`), so users can cancel by dragging away. Committing the action itself on FingerDown breaks tap-cancel and is a HIGH finding. - **Every pressable scales to `0.96` while held.** In Makepad, scale in the SDF shader via a `down` instance — never via layout. Enter the down state with `snap(1.0)` (instant), leave it with `Forward {duration: 0.15}` + `OutQuad` (asymmetric: press instant, release eased). See [RECIPES.md §1](RECIPES.md). - Hover states change color/opacity only, `≤0.1s`. Gate hover to pointer devices if the app targets touch. - Upstream `Button` in `widgets/src/button.rs` already ships the canonical 3-state `hover/on/down` track with `snap(1.0)` entries and `0.1s` exits — copy its structure, don't reinvent. ## 3. Enter / exit (Emil + Jakub) - **Nothing appears from nothing.** Entries start at `opacity 0` + `scale 0.95–0.97` (icon swaps may go to 0.25), never `scale 0`, never a visibility pop. In Makepad: animate `draw_bg`/`draw_text` instances; `set_visible(true)` then immediately `animator_play(enter.on)`. - **Exits are subtler and faster than enters.** Small fixed offset (8–12px equivalent), fade, `OutQuad`, ~60–70% of the enter duration. Same direction as the enter (spatial consistency — a toast that rises from the bottom leaves toward the bottom). - **Origin-aware growth:** popovers/menus scale from their trigger corner, not center. In SDF space, scale around the anchor point instead of `rect_size * 0.5`. Modals are exempt (stay centered). - **Stagger** infrequent staged entrances (first load of a screen, success/empty states) by 0.03–0.08s per item — in Makepad use per-item `Play::Forward` start via timer, or a shared clock instance + per-item delay uniform. Never stagger routine interactions; never block input while a stagger plays. - **Skip enter animations on app boot** for elements already in their default state (Jakub's `initial={false}`): `animator_cut` to the resting state at startup, `animator_play` only on later changes. ## 4. Gestures, sheets, momentum (Apple, translated) The insurance sheet's 1:1 drag with thresholds is the right start. The full contract: 1. **1:1 tracking with grab-offset.** Content sticks to the finger measured from where it was grabbed. Use `event.hits_with_capture_overload` so the drag survives leaving the widget bounds (pointer capture). Ignore extra touch points after the drag starts. 2. **Velocity history.** Keep the last few (position, time) samples; you need release velocity. 3. **Momentum projection.** Don't snap to the nearest state from the release *point* — project: `projected = pos + (v / 1000.0) * rate / (1.0 - rate)` with `rate = 0.998` (0.99 = snappier), then pick the snap target nearest the projection. 4. **Velocity handoff.** The settle animation starts at the finger's velocity — no seam between drag and animation. Makepad has this built: `rubber_band_bounce(x0, v0, t, touch)` in `widgets/src/scroll_motion.rs`, or run an `ExpDecay` from the current value (the animator always starts from the *presentation* value, which is the other half of this rule). 5. **Rubber-band past boundaries.** `over * dim * 0.55 / (dim + 0.55 * |over|)` — resistance, not a wall. 6. **Interruptible mid-settle.** A new `FingerDown` during the settle grabs the sheet at its current position. Never lock input during a transition. 7. Damping defaults: no bounce for UI that just appears; slight bounce (`d1` closer to 0.9, or a small overshoot) *only* when the user's gesture carried momentum. ## 5. Surfaces (Jakub) - **Concentric radii:** `outer_radius = inner_radius + padding` for nested `RoundedView`s with a visible even inset. Equal radii on parent and child is the single most common "feels off". Past ~24px padding, treat layers as independent. - **Shadows for elevation, borders for structure.** A border that exists only to fake depth → layered translucent shadow. Keep borders that mean something (dividers, selected, focus). - **Images/avatars get a 1px outline** at 10% opacity — pure black in light mode, pure white in dark, never a tinted neutral. - **Optical alignment beats geometric.** Icon-side padding = text-side padding − 2px; nudge glyphs (play triangles, chevrons) that look off-center. - **Hit areas:** ≥44×44 logical px on touch, ≥40×40 in dense desktop UI. Extend the hit rect beyond the visible rect (Makepad: padding on the handling view), and never let two hit areas overlap. ## 6. Color (Jakub) - **Tokens, not hex.** Components reference semantic roles (`theme.color_bg_app`, `theme.color_text_secondary`-style), primitives live in one place. A screen full of literal `#7b5cf6` is unthemable and unreviewable — put shared brand values in the `script_mod!` `mod` object or theme and reference them. - One neutral ramp + one accent ramp + only the status ramps actually rendered. Each step exists because a role needs it. One color = one meaning. - Never report a contrast ratio you didn't compute. ## 7. Typography (Emil/Apple + Jakub) - Tracking is size-specific: slightly negative on large display text, slightly positive on small uppercase labels, ~0 for body. Line-height: ~1.1 headings, 1.5–1.6 body, ≥1.4 for anything wrapping to 3+ lines. - Hierarchy = weight + size + leading as a set. Below 18px stay at weight ≥400. - Tabular figures for anything that ticks (timers, prices, counts) so digits don't shift layout; use `…` (the character), not `...`. - Cap line length ~60–75ch for reading text. - **Emoji are not icons.** They carry no stroke weight, ignore your palette, and render differently per platform. Use SDF-drawn glyphs or an icon set; recolor per state via one drawn asset (the `currentColor` rule), outline by default, fill = active. ## 8. Performance (Makepad-specific) - **Animate shader instances, not layout.** `draw_bg: {t: instance(0.0)}` costs a uniform update + repaint; animating `width`/`height`/`margin`/`padding` re-runs layout every frame. For high-frequency motion keep it in the `pixel`/`vertex` fn. Occasional sheet/drawer position changes may animate walk values — that's the Makepad analog of "transform/opacity only, except where you must". - Respect the `#[repr(C)]`/`#[deref]` field-ordering rule for draw structs (AGENTS.md §16) — a misplaced `#[rust]` field corrupts the instance buffer. - `Loop` tracks run forever; make sure idle screens don't keep a redraw loop alive. ## 9. Reduced motion There is no OS media-query in Makepad — **own it**: a `reduce_motion: bool` in the app/theme. When set, replace movement with short cross-fades: same state changes via `animator_cut` (or a 0.1–0.2s opacity-only track), drop overshoot/bounce, keep color/opacity cues. Reduced ≠ zero. ## 10. Review protocol (mandatory format) When reviewing UI code with this skill, replay interactions mentally at 10% speed and walk every state: hover, focus, down, loading, empty, error. Findings MUST be a markdown table: | Severity | Location | Before | After | Why | | --- | --- | --- | --- | --- | - `Location` is `path/file.rs:line`. `Why` names the violated principle + user impact. - Severity: **HIGH** = broken interaction / state change visible only via motion / commit-on-down. **MEDIUM** = visible inconsistency (snap where a transition belongs, radius clash). **LOW** = polish. - One row per root cause, listing every occurrence. End with **Block** (any HIGH) or **Approve**. - Report anything you couldn't verify as `Not verified`. Never approve coverage you didn't inspect. - Also say what's already right — restraint that's correct (e.g. unanimated high-frequency actions) must not be "fixed". ## Quick slop checklist | Tell | Fix | |---|---| | `set_visible` toggling a toast/sheet/panel | Enter/exit animator track (RECIPES §3) | | Action commits on `FingerDown` | Feedback on down, commit on up-if-over | | No `down` state on a pressable | `0.96` shader scale, snap in / 0.15s out | | `In*` ease anywhere user-facing | `Out*` or Bezier equivalent | | Entry from nothing (alpha 0→1 only counts) | scale `0.95` + fade, origin-aware | | Same radius on nested rounded views | outer = inner + padding | | Release-position snapping on a drag | project momentum, hand off velocity | | Hex literals per-widget | semantic theme tokens | | Emoji as UI icons | drawn glyphs, one asset recolored per state | | Everything animates on boot | `animator_cut` to resting state at startup |