- skills/README.md: provenance + index - SKILL.md: merged design-engineering doctrine, Makepad-native (animator, script_mod!) - EASING.md: CSS cubic-bezier -> Ease.Bezier translation verified against dev@b41e740 - RECIPES.md: press scale 0.96, toast enter/exit, sheet settle w/ velocity handoff, stagger, reduced motion - PORTS-AUDIT.md: Before/After audit of rider/koboyo/insurance with file:line refs
12 KiB
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):
- Entering or exiting →
OutQuartorEase.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 upstreamcheck_box.rsships) - Constant motion (spinner, marquee,
Loop) →Linear - Momentum / "alive" settle →
Ease.ExpDecay {d1: 0.82, d2: 0.97}(upstreamfile_tree.rs/fold_button.rsrange: 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 onHit::FingerUponly 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.96while held. In Makepad, scale in the SDF shader via adowninstance — never via layout. Enter the down state withsnap(1.0)(instant), leave it withForward {duration: 0.15}+OutQuad(asymmetric: press instant, release eased). See RECIPES.md §1. - Hover states change color/opacity only,
≤0.1s. Gate hover to pointer devices if the app targets touch. - Upstream
Buttoninwidgets/src/button.rsalready ships the canonical 3-statehover/on/downtrack withsnap(1.0)entries and0.1sexits — 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), neverscale 0, never a visibility pop. In Makepad: animatedraw_bg/draw_textinstances;set_visible(true)then immediatelyanimator_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::Forwardstart 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_cutto the resting state at startup,animator_playonly 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 tracking with grab-offset. Content sticks to the finger measured from where it was
grabbed. Use
event.hits_with_capture_overloadso the drag survives leaving the widget bounds (pointer capture). Ignore extra touch points after the drag starts. - Velocity history. Keep the last few (position, time) samples; you need release velocity.
- Momentum projection. Don't snap to the nearest state from the release point — project:
projected = pos + (v / 1000.0) * rate / (1.0 - rate)withrate = 0.998(0.99 = snappier), then pick the snap target nearest the projection. - 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)inwidgets/src/scroll_motion.rs, or run anExpDecayfrom the current value (the animator always starts from the presentation value, which is the other half of this rule). - Rubber-band past boundaries.
over * dim * 0.55 / (dim + 0.55 * |over|)— resistance, not a wall. - Interruptible mid-settle. A new
FingerDownduring the settle grabs the sheet at its current position. Never lock input during a transition. - Damping defaults: no bounce for UI that just appears; slight bounce (
d1closer to 0.9, or a small overshoot) only when the user's gesture carried momentum.
5. Surfaces (Jakub)
- Concentric radii:
outer_radius = inner_radius + paddingfor nestedRoundedViews 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#7b5cf6is unthemable and unreviewable — put shared brand values in thescript_mod!modobject 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
currentColorrule), 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/paddingre-runs layout every frame. For high-frequency motion keep it in thepixel/vertexfn. Occasional sheet/drawer position changes may animate walk values — that's the Makepad analog of "transform/opacity only, except where you must".
- repaint; animating
- Respect the
#[repr(C)]/#[deref]field-ordering rule for draw structs (AGENTS.md §16) — a misplaced#[rust]field corrupts the instance buffer. Looptracks 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 |
|---|
Locationispath/file.rs:line.Whynames 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 |