Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .claude/skills/rustmotion/rules/card-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Rule: Resize a card (don't scale it)

A compact card growing into a detail panel is a **layout** change: its new box reflows its content. A `scale` stretches the pixels it already had, text included — that's a zoom, not a resize, and it shows immediately as blurred, oversized text.

Animate `width` / `height` with a `keyframes` effect: they reach taffy, so layout is recomputed every frame.

```json
{
"type": "card",
"style": {
"width": "330px",
"height": "132px",
"background": "#111C33",
"border-radius": 20,
"justify-content": "center",
"align-items": "center",
"animation": [{
"name": "keyframes",
"delay": 1.2,
"duration": 0.9,
"keyframes": [
{ "property": "width", "easing": "ease_out_cubic",
"keyframes": [{ "time": 0.0, "value": 330 }, { "time": 0.9, "value": 620 }] },
{ "property": "height", "easing": "ease_out_cubic",
"keyframes": [{ "time": 0.0, "value": 132 }, { "time": 0.9, "value": 240 }] }
]
}]
},
"children": [ { "type": "text", "content": "…" } ]
}
```

## Keyframe times are relative to `delay`

`{"time": 0.0}` is the start of the effect, not the start of the scene. The effect's `delay` shifts the whole track.

## Animated size wins over intrinsic size

A component that declares its own size (a `shape`, a `badge`) has it overridden for the duration of the animation. That's intended, but it also means a value left at 0 on the last keyframe makes the box disappear.

## Make the content actually follow

Without `justify-content` / `align-items`, the child stays pinned to the top-left and only the box grows: the motion reads as empty. Centre the content (or give it `flex: 1`) so the growth actually reads.

## The validator sees the final box

`validate --strict-anim` samples the animation: a card growing past the device edge is flagged the instant it happens. Check that there's still margin at the maximum size, not just at the initial one.
48 changes: 48 additions & 0 deletions .claude/skills/rustmotion/rules/char-animation-tuning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Rule: Tuning a per-character or per-word animation

The seven `char_*` presets (`char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`, `char_blur_in`) share one config. Six fields tune it, all optional, **all defaulting to the historical behaviour**: an existing scenario doesn't move.

```json
{
"type": "text",
"content": "CASCADE",
"style": {
"font-size": 92,
"animation": [{
"name": "char_slide_up",
"direction": "down",
"distance": 1.6,
"scale_from": 0.9,
"duration": 0.5,
"stagger": 0.035
}]
}
}
```

| Field | Role | Default |
|---|---|---|
| `direction` | `up` / `down` / `left` / `right` — where the unit arrives from | `up` |
| `distance` | Displacement multiplier (0.5 tight, 1.85 pronounced) | `1.0` |
| `scale_from` | Starting scale of each unit (0.82 = "pop", 0.92 = barely) | absent |
| `jitter` + `seed` | Deterministic irregularity of the `stagger` | `0` |
| `ink_from` | Starting colour, converges to `style.color` | absent |
| `blur` | Starting sigma (`char_blur_in` only) | `14` |

## What each preset actually reads

`direction` and `distance` only make sense for presets whose motion **is** a translation: `char_slide_up` and `char_blur_in`. The others (`scale_in`, `bounce`, `rotate_in`, `fade_in`, `wave`) have no displacement axis to redirect, and ignore them.

`scale_from` **composes** with the preset instead of replacing it — except on `char_scale_in` and `char_bounce`, which already own their own scale curve and ignore it (two stacked scale curves fight each other instead of composing).

## The name `char_slide_up` doesn't constrain the direction

`char_slide_up` with `"direction": "down"` makes the letters fall from above. The name is historical: it's the "translation" preset, and `up` is its default. There is no `char_slide_down`.

## `granularity` decides what a unit is

`"granularity": "word"` animates words, `"char"` (default) animates characters. A 40-character title animated at `char` granularity with `stagger: 0.05` takes 2s to settle before even hitting its own `duration` — count the number of units before picking a `stagger`, or switch to `word`.

## A note on `char_blur_in`

It goes through the same resolution path as its six siblings: it **inherits** `stagger` from a parent container and works inside a `timeline` step. (That wasn't always the case.)
30 changes: 30 additions & 0 deletions .claude/skills/rustmotion/rules/hyperframes-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Rule: Hyperframes → rustmotion mapping

If you're asked for an effect from the Hyperframes catalogue (or an effect described in that vocabulary — "streaming text", "number wheel", "badge pop"…), **look it up in this table before writing anything**. Half of these effects already exist under another name, and hand-rebuilding them gives a worse result the validator can't verify.

| Hyperframes | In rustmotion |
|---|---|
| Blur In | `style.animation: [{ "name": "char_blur_in", "granularity": "word" }]` |
| Staggered Fade Up | `char_blur_in` / `char_slide_up` + `direction`, `distance`, `scale_from` |
| Top Down Letters | `char_slide_up` with `"direction": "down"` |
| Text Stagger | `char_blur_in` (rise) + `shimmer` effect (sweep) on the same `text` |
| Number Pop In | `char_blur_in` with `"granularity": "char"`, `"scale_from": 0.82` |
| Streaming Text | `char_blur_in` with `jitter`/`seed`/`ink_from` — see [streaming-text.md](streaming-text.md) |
| Typewriter | `typewriter` preset + `text.caret` |
| Text State Swap | `text.states` + `text.swap` |
| Number Wheel | `number_wheel` component — see [number-wheel.md](number-wheel.md) |
| Badge Pop | `badge` + `style.animation: [{ "name": "pop_in" }]` |
| Success Check | `success_check` component |
| Simulated Cursor | `pointer` component — see [pointer-walkthrough.md](pointer-walkthrough.md) |
| Card Resize | `keyframes` on `width`/`height` — see [card-resize.md](card-resize.md) |
| Arc Motion Path | `motion_path` effect + `orient` — see [motion-path.md](motion-path.md) |
| SVG Line Draw Loader | `draw_in` / `stroke_reveal` preset on an `svg` |
| Dynamic Grid | `animated-background` preset `grid_lines` |
| Page Slide | `transition: { "type": "slide" }` |
| Chromatic Aberration Wipe | `transition: { "type": "chromatic_wipe" }` |

## Two naming traps

`cursor` is **not** a mouse pointer: it's a text caret (a blinking bar). The mouse pointer with its click ring is `pointer`.

`counter` is **not** a digit wheel: it interpolates a *value* and rewrites the number every frame, so the glyphs jump. `number_wheel` scrolls strips of digits, like a mechanical odometer. A count going from 0 to 30,222 → `counter`. A figure landing → `number_wheel`.
16 changes: 16 additions & 0 deletions .claude/skills/rustmotion/rules/motion-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ hint: at t=1.70s (57% of scene), animation transforms (tx=1886, ty=0, …)

C'est la raison de préférer `motion_path` à une position calculée à la main : une trajectoire écrite en dur dans des keyframes reste vérifiable, mais tu perds l'orientation automatique et la vitesse constante le long de la courbe.

## Recipe: the arc

The "arc motion path" effect — a dot that traces a curve while rotating to keep facing its trajectory — is a `motion_path` with a single cubic and `orient: true`. There's no `arc` shortcut: a cubic describes it exactly, and legibly.

```json
"animation": [{
"name": "motion_path",
"path": "M0,0 C260,-220 620,-220 880,0",
"duration": 2.4,
"orient": true,
"easing": "ease_in_out"
}]
```

Both control points at the same height (`-220`) give a symmetric arc; pulling them closer to their respective endpoints flattens the middle and deepens the ends. For a **downward** arc, flip the sign: `C260,220 620,220 880,0`.

## Cas dégénérés

Un chemin vide ou impossible à parser est **rejeté au chargement**. Un chemin d'un seul point, ou de longueur nulle, tient la position avec une rotation nulle. Une `duration` négative ou nulle est rejetée par `validate`. Aucun de ces cas ne produit de `NaN`.
44 changes: 44 additions & 0 deletions .claude/skills/rustmotion/rules/number-wheel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Rule: `number_wheel` vs `counter`

Two components display an animated number. They don't tell the same story.

**`counter`** interpolates a *value* and rewrites the number every frame. It answers "how much, right now?" — a rising gauge, an accumulating total. Its glyphs jump, because 8,999 and then 9,000 have nothing in common.

**`number_wheel`** scrolls strips of digits, like a mechanical odometer. It answers "the figure lands" — a KPI settling, a result being revealed. What you watch is the motion; what remains is the requested digit.

```json
{
"type": "number_wheel",
"value": "30,222",
"spin": "double",
"duration": 1.1,
"delay": 0.3,
"stagger_per_column": 0.09,
"style": { "font-size": 120, "font-weight": 700, "color": "#38BDF8" }
}
```

| Field | Role | Default |
|---|---|---|
| `value` | The figure exactly as written: `"30,222"`, `"5.7"`, `"98%"` | required |
| `spin` | `single` / `double` / `triple` — 0-9 loops before landing | `single` |
| `duration` | Landing time for **one** reel | `1.2` |
| `delay` | Before the first reel starts | `0` |
| `stagger_per_column` | Offset per column, left to right | `0.08` |
| `easing` | Curve of the travel | `ease_out_cubic` |

## `value` is a string, not a number

The digits roll; everything else — comma, dot, sign, unit — is painted where it stands, motionless. That's what lets you write `"1,204 €"` or `"98%"` without the separator going haywire.

## `spin` changes the speed, not the duration

Every reel takes `duration` no matter what. `triple` doesn't make the animation longer: it scrolls three times as many digits in the same time. A `triple` on a short `duration` turns into an unreadable blur.

## `stagger_per_column: 0` is a default to avoid

All the reels land together, which reads as a single flip. The left-to-right offset is what makes the last digit the one that *settles* the figure.

## The box reserves space for the widest digit

Each column is as wide as the widest digit in the font, not the width of the final digit — otherwise a `111` would reserve a narrow box and then overflow while a `0` scrolls past. The validator measures the same thing.
49 changes: 49 additions & 0 deletions .claude/skills/rustmotion/rules/pointer-walkthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Rule: Simulated mouse pointer (`pointer`)

For a product demo or an agent walkthrough — the arrow that moves to a control and clicks it — use `pointer`.

**`cursor` is not that.** `cursor` is a text caret: a blinking vertical bar. Its `cursor_style: "pointer"` field is dead metadata — it draws a bar either way.

```json
{
"type": "pointer",
"position": "absolute",
"x": 0,
"y": 0,
"size": 52,
"tone": "light",
"click_ring": "bold",
"ring_color": "#38BDF8",
"click_duration": 0.5,
"path": [
{ "time": 0.4, "x": 1500, "y": 820 },
{ "time": 2.0, "x": 480, "y": 330 },
{ "time": 3.6, "x": 900, "y": 690 }
]
}
```

| Field | Role |
|---|---|
| `size` | Height of the arrow in px. The click ring scales with it. |
| `tone` | `light` (white arrow, dark outline) or `dark` |
| `color` / `outline_color` | Override `tone` |
| `click_ring` | `subtle` / `standard` / `bold` / `none` |
| `path` | Waypoints `{time, x, y}` — the pointer **clicks on arrival** at each one |
| `click_at` | Clicks for a stationary pointer. **Ignored if `path` is present** |
| `click_duration` | Duration of the click, *and* the pause on the waypoint before moving on |
| `path_easing` | `ease_in_out` (default), `linear`, `ease_out`, `step` |

## Coordinates are relative to the component's own origin

A waypoint's `x`/`y` are relative to the `pointer`'s box, not to the device. Place the component with `position: absolute, x: 0, y: 0` and the waypoints then read as scene coordinates — that's the form to prefer for a walkthrough.

## The box is the glyph, not the path

The component's box is the size of the arrow: the waypoints translate it. Sizing the box to the path would push a `flex` sibling around because of an element that's just a cursor.

Corollary: `pointer` is **exempt from the viewport overflow check**, like `marquee` and `cursor`. A demo that brings the arrow near an edge legitimately puts its tail off-screen.

## The move pauses on the click

Between two waypoints, the pointer doesn't set off again until the click animation is done (`click_duration`). That's what makes the gesture read: arrive, click, leave. A `click_duration` close to the gap between two waypoints barely leaves time for the travel — leave at least double.
42 changes: 42 additions & 0 deletions .claude/skills/rustmotion/rules/streaming-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Rule: Text that "streams" (tokens arriving)

To depict a model's response being written, **don't use `typewriter`**: a typewriter reveals character by character at a fixed cadence, which reads as a typewriter, not as a stream of tokens. A model emits whole words, in uneven bursts, and each word settles visually instead of snapping in all at once.

```json
{
"type": "text",
"content": "Words arrive in uneven bursts, like tokens.",
"style": {
"font-size": 40,
"color": "#E2E8F0",
"animation": [{
"name": "char_blur_in",
"granularity": "word",
"duration": 0.28,
"stagger": 0.09,
"jitter": 0.7,
"seed": 12,
"ink_from": "#475569",
"blur": 6
}]
}
}
```

Three fields do all the work:

- **`granularity: "word"`** — the unit is the word, not the letter.
- **`jitter`** — offsets each unit's start by ±`jitter × stagger`. This is what breaks the metronomic cadence. 0.5–0.8 reads as streaming; past 1.0 words overlap and the reading order gets muddled.
- **`ink_from`** — each word starts in this colour and converges to `style.color` over its duration. A desaturated grey reproduces the "not yet accepted by the eye" token.

## `jitter` is deterministic, not random

The offsets are derived from `seed` and the unit's index, never from an RNG. This is a constraint, not a detail: frames are rendered out of order, in parallel, and sometimes in separate processes (`--frames a-b`). A word whose start depended on a random draw would jump between two neighbouring frames.

Changing `seed` reshuffles the rhythm without changing its statistics — useful so two neighbouring paragraphs don't "breathe" identically.

No unit can start before the effect's `delay`: a negative offset on the first unit would make it appear half-animated right from frame 0.

## Budget

`stagger × word count + duration` is the total settling time. For a 12-word sentence with `stagger: 0.09`, that's ~1.4s — check the scene is long enough; `validate` flags it if not.
54 changes: 54 additions & 0 deletions .claude/skills/rustmotion/rules/text-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Rule: The four text finishes

Four mechanisms that each used to demand a hand-assembled sub-tree now write in one line. None of them replaces an entry preset: they layer on top.

## `shimmer` — the light sweeps over the letters

An animation effect, not a component field. The band only lights up **pixels that are actually painted** (composited `SrcATop` inside the node's layer): on a `text`, the light catches the glyphs, not the box.

```json
"animation": [{
"name": "shimmer",
"delay": 1.0, "duration": 1.1,
"color": "#7DD3FC", "intensity": 0.85,
"width": 0.3, "angle": 22, "loop": true
}]
```

`width` is the band's width as a fraction of the sweep (0.3 = a sharp glint, 0.8 = a soft wash). `angle` tilts the band: `0` is vertical and sweeps left to right; ~20° is what makes it read as a reflection rather than a wipe. The isolated layer is only opened during the effect's window — an unlooped `shimmer` costs nothing for the rest of the scene.

Combined with `char_blur_in` on the same `text`, this reproduces "text stagger": the words rise while unblurring, then the light sweeps through.

## `text.states` — a label that becomes another

```json
{
"type": "text",
"content": "Saving draft",
"states": [{ "at": 2.6, "content": "Saved" }],
"swap": { "duration": 0.45, "distance": 22, "blur": 9 },
"style": { "font-size": 52, "white-space": "nowrap", "max-width": "600px" }
}
```

Without `swap`, labels cut sharply at each `at` — abrupt, but that's exactly what omitting the field asks for. With `swap`, both are on screen during the window: the outgoing one rises while blurring, the incoming one rises from below while unblurring.

**The box is measured on the longest label**, not the first one. A box sized for `"Saved"` would overflow the moment it returns to `"Saving draft"` — and the validator would have caught it.

## `text.caret` — the caret follows the reveal

```json
{ "type": "text", "content": "rustmotion --frames 0-60",
"caret": { "shape": "block", "blink": 0.9, "color": "#38BDF8" },
"style": { "animation": [{ "name": "typewriter", "duration": 2.0 }] } }
```

`shape`: `line` (thin rule) or `block` (terminal-style). `blink` is the full period in seconds (`0` = fixed). `hide_when_done: true` removes the caret once the reveal is finished instead of leaving it parked.

That's the field's reason to exist: a `cursor` composited next to the text would stay where it was placed while the text grows underneath it. The caret is also present **before** the first character, otherwise the first frame is empty and then caret and letter appear together, which reads as a glitch.

## `pop_in` — the badge arrival

An animation preset: the element grows from nothing with a `back-out` overshoot, **then** a short elastic pulse once it settles. `overshoot` sets the pulse's amplitude (default 0.18 = 118%); `0` removes it and leaves a plain scale-in.

Both beats matter: the first *places* the element, the second draws the eye back to it. Merged into a single curve, they'd read as a tremor.
Loading
Loading