makepad-ports/skills/makepad-ui-craft/SKILL.md
andodeki e09f6bbb51 Add makepad-ui-craft skill pack: Emil Kowalski + Jakub Krehel doctrine translated to Makepad 2.0
- 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
2026-09-01 18:36:57 +00:00

12 KiB
Raw Permalink Blame History


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 → 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.800.98, d2 0.950.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.10.16 on release
Hover on/off 0.00.1
Tooltips, small popovers 0.1250.2
Dropdowns, drawers (side), selects 0.150.25
Modals, bottom sheets, toasts 0.20.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.
  • 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.950.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 (812px equivalent), fade, OutQuad, ~6070% 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.030.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 RoundedViews 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.51.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 ~6075ch 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.10.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