- 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
209 lines
12 KiB
Markdown
209 lines
12 KiB
Markdown
---
|
||
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 |
|