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

209 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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.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](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.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 `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.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 |