Skip to content

Commit b785ced

Browse files
authored
feat: text-polish system, three new components, chromatic_wipe transition (#211)
* feat: text-polish system, three new components, chromatic_wipe transition Text finishing toolkit (schema + engine): - text.states/text.swap: label-to-label swap (outgoing blurs up, incoming unblurs from below), box sized on the longest label. - text.caret: a line/block caret pinned to a typewriter's reveal head. - shimmer: light-sweep animation composited SrcATop so it only lights painted glyph pixels, not the box. - char_* presets gain direction/distance/scale_from/jitter+seed/ink_from tuning knobs. New components: - pointer: simulated mouse pointer (arrow + click ring) sharing waypoint choreography with cursor, for product walkthroughs. - number_wheel: odometer-style rolling digit reels, distinct from counter's value interpolation. - success_check: self-drawing checkmark in a halo with pop + settling rotation, replacing the hand-assembled shape+svg+scale_in stack. Engine/schema: - chromatic_wipe slide transition (red/cyan edge split at the peak, recomposing by the end). - animated_box_size and shimmer_effect regression tests; chromatic_wipe transition test. Docs: CLAUDE.md and README updated for the new components/finishes; new skill rule pages for card-resize, char-animation-tuning, hyperframes mapping, number-wheel, pointer walkthrough, streaming-text, text-polish. Not included: taap-it.mp4 (untracked stray render output in repo root, left out of this commit). * fix(test): make ink_from's colour reading robust to sub-pixel glyphs ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own used char_scale_in specifically because it "leaves alpha alone" — but the preset ties its scale to the same progress `t` the colour ramp uses, so at t=0.1 the glyph paints at 10% of a 90px font. That's thin enough that anti-aliasing can leave zero pixels at exactly alpha==255, depending on subpixel glyph placement — which differs by Skia build/ font metrics between machines, hence green locally and red in CI. The fill colour is uniform across a unit regardless of AA coverage, so weighting the mean by alpha reads the same value without requiring any pixel to be fully opaque. * docs(skill): translate the new rule pages to English; trim redundant comments The seven new .claude/skills/rustmotion/rules/*.md pages (plus the matching additions to CLAUDE.md and the arc recipe in motion-path.md) were authored in French, following the existing convention for that directory — but that convention wasn't the right call for net-new content, so they're English now. Pre-existing French elsewhere in those two files is untouched. Also trimmed a handful of comments in pointer.rs and text.rs that restated the line right below them without adding anything (per-vertex labels on a coordinate literal, "Draw fill" above a call to draw_text_with_fallback). Read through number_wheel.rs and success_check.rs too — their comments carry real rationale (why a scale curve is isolated, why the stroke waits on the halo) rather than padding, so left as-is.
1 parent 8f7275a commit b785ced

37 files changed

Lines changed: 11806 additions & 263 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Rule: Resize a card (don't scale it)
2+
3+
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.
4+
5+
Animate `width` / `height` with a `keyframes` effect: they reach taffy, so layout is recomputed every frame.
6+
7+
```json
8+
{
9+
"type": "card",
10+
"style": {
11+
"width": "330px",
12+
"height": "132px",
13+
"background": "#111C33",
14+
"border-radius": 20,
15+
"justify-content": "center",
16+
"align-items": "center",
17+
"animation": [{
18+
"name": "keyframes",
19+
"delay": 1.2,
20+
"duration": 0.9,
21+
"keyframes": [
22+
{ "property": "width", "easing": "ease_out_cubic",
23+
"keyframes": [{ "time": 0.0, "value": 330 }, { "time": 0.9, "value": 620 }] },
24+
{ "property": "height", "easing": "ease_out_cubic",
25+
"keyframes": [{ "time": 0.0, "value": 132 }, { "time": 0.9, "value": 240 }] }
26+
]
27+
}]
28+
},
29+
"children": [ { "type": "text", "content": "" } ]
30+
}
31+
```
32+
33+
## Keyframe times are relative to `delay`
34+
35+
`{"time": 0.0}` is the start of the effect, not the start of the scene. The effect's `delay` shifts the whole track.
36+
37+
## Animated size wins over intrinsic size
38+
39+
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.
40+
41+
## Make the content actually follow
42+
43+
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.
44+
45+
## The validator sees the final box
46+
47+
`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.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Rule: Tuning a per-character or per-word animation
2+
3+
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.
4+
5+
```json
6+
{
7+
"type": "text",
8+
"content": "CASCADE",
9+
"style": {
10+
"font-size": 92,
11+
"animation": [{
12+
"name": "char_slide_up",
13+
"direction": "down",
14+
"distance": 1.6,
15+
"scale_from": 0.9,
16+
"duration": 0.5,
17+
"stagger": 0.035
18+
}]
19+
}
20+
}
21+
```
22+
23+
| Field | Role | Default |
24+
|---|---|---|
25+
| `direction` | `up` / `down` / `left` / `right` — where the unit arrives from | `up` |
26+
| `distance` | Displacement multiplier (0.5 tight, 1.85 pronounced) | `1.0` |
27+
| `scale_from` | Starting scale of each unit (0.82 = "pop", 0.92 = barely) | absent |
28+
| `jitter` + `seed` | Deterministic irregularity of the `stagger` | `0` |
29+
| `ink_from` | Starting colour, converges to `style.color` | absent |
30+
| `blur` | Starting sigma (`char_blur_in` only) | `14` |
31+
32+
## What each preset actually reads
33+
34+
`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.
35+
36+
`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).
37+
38+
## The name `char_slide_up` doesn't constrain the direction
39+
40+
`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`.
41+
42+
## `granularity` decides what a unit is
43+
44+
`"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`.
45+
46+
## A note on `char_blur_in`
47+
48+
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.)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Rule: Hyperframes → rustmotion mapping
2+
3+
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.
4+
5+
| Hyperframes | In rustmotion |
6+
|---|---|
7+
| Blur In | `style.animation: [{ "name": "char_blur_in", "granularity": "word" }]` |
8+
| Staggered Fade Up | `char_blur_in` / `char_slide_up` + `direction`, `distance`, `scale_from` |
9+
| Top Down Letters | `char_slide_up` with `"direction": "down"` |
10+
| Text Stagger | `char_blur_in` (rise) + `shimmer` effect (sweep) on the same `text` |
11+
| Number Pop In | `char_blur_in` with `"granularity": "char"`, `"scale_from": 0.82` |
12+
| Streaming Text | `char_blur_in` with `jitter`/`seed`/`ink_from` — see [streaming-text.md](streaming-text.md) |
13+
| Typewriter | `typewriter` preset + `text.caret` |
14+
| Text State Swap | `text.states` + `text.swap` |
15+
| Number Wheel | `number_wheel` component — see [number-wheel.md](number-wheel.md) |
16+
| Badge Pop | `badge` + `style.animation: [{ "name": "pop_in" }]` |
17+
| Success Check | `success_check` component |
18+
| Simulated Cursor | `pointer` component — see [pointer-walkthrough.md](pointer-walkthrough.md) |
19+
| Card Resize | `keyframes` on `width`/`height` — see [card-resize.md](card-resize.md) |
20+
| Arc Motion Path | `motion_path` effect + `orient` — see [motion-path.md](motion-path.md) |
21+
| SVG Line Draw Loader | `draw_in` / `stroke_reveal` preset on an `svg` |
22+
| Dynamic Grid | `animated-background` preset `grid_lines` |
23+
| Page Slide | `transition: { "type": "slide" }` |
24+
| Chromatic Aberration Wipe | `transition: { "type": "chromatic_wipe" }` |
25+
26+
## Two naming traps
27+
28+
`cursor` is **not** a mouse pointer: it's a text caret (a blinking bar). The mouse pointer with its click ring is `pointer`.
29+
30+
`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`.

.claude/skills/rustmotion/rules/motion-path.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ hint: at t=1.70s (57% of scene), animation transforms (tx=1886, ty=0, …)
6161

6262
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.
6363

64+
## Recipe: the arc
65+
66+
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.
67+
68+
```json
69+
"animation": [{
70+
"name": "motion_path",
71+
"path": "M0,0 C260,-220 620,-220 880,0",
72+
"duration": 2.4,
73+
"orient": true,
74+
"easing": "ease_in_out"
75+
}]
76+
```
77+
78+
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`.
79+
6480
## Cas dégénérés
6581

6682
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`.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Rule: `number_wheel` vs `counter`
2+
3+
Two components display an animated number. They don't tell the same story.
4+
5+
**`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.
6+
7+
**`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.
8+
9+
```json
10+
{
11+
"type": "number_wheel",
12+
"value": "30,222",
13+
"spin": "double",
14+
"duration": 1.1,
15+
"delay": 0.3,
16+
"stagger_per_column": 0.09,
17+
"style": { "font-size": 120, "font-weight": 700, "color": "#38BDF8" }
18+
}
19+
```
20+
21+
| Field | Role | Default |
22+
|---|---|---|
23+
| `value` | The figure exactly as written: `"30,222"`, `"5.7"`, `"98%"` | required |
24+
| `spin` | `single` / `double` / `triple` — 0-9 loops before landing | `single` |
25+
| `duration` | Landing time for **one** reel | `1.2` |
26+
| `delay` | Before the first reel starts | `0` |
27+
| `stagger_per_column` | Offset per column, left to right | `0.08` |
28+
| `easing` | Curve of the travel | `ease_out_cubic` |
29+
30+
## `value` is a string, not a number
31+
32+
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.
33+
34+
## `spin` changes the speed, not the duration
35+
36+
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.
37+
38+
## `stagger_per_column: 0` is a default to avoid
39+
40+
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.
41+
42+
## The box reserves space for the widest digit
43+
44+
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.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Rule: Simulated mouse pointer (`pointer`)
2+
3+
For a product demo or an agent walkthrough — the arrow that moves to a control and clicks it — use `pointer`.
4+
5+
**`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.
6+
7+
```json
8+
{
9+
"type": "pointer",
10+
"position": "absolute",
11+
"x": 0,
12+
"y": 0,
13+
"size": 52,
14+
"tone": "light",
15+
"click_ring": "bold",
16+
"ring_color": "#38BDF8",
17+
"click_duration": 0.5,
18+
"path": [
19+
{ "time": 0.4, "x": 1500, "y": 820 },
20+
{ "time": 2.0, "x": 480, "y": 330 },
21+
{ "time": 3.6, "x": 900, "y": 690 }
22+
]
23+
}
24+
```
25+
26+
| Field | Role |
27+
|---|---|
28+
| `size` | Height of the arrow in px. The click ring scales with it. |
29+
| `tone` | `light` (white arrow, dark outline) or `dark` |
30+
| `color` / `outline_color` | Override `tone` |
31+
| `click_ring` | `subtle` / `standard` / `bold` / `none` |
32+
| `path` | Waypoints `{time, x, y}` — the pointer **clicks on arrival** at each one |
33+
| `click_at` | Clicks for a stationary pointer. **Ignored if `path` is present** |
34+
| `click_duration` | Duration of the click, *and* the pause on the waypoint before moving on |
35+
| `path_easing` | `ease_in_out` (default), `linear`, `ease_out`, `step` |
36+
37+
## Coordinates are relative to the component's own origin
38+
39+
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.
40+
41+
## The box is the glyph, not the path
42+
43+
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.
44+
45+
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.
46+
47+
## The move pauses on the click
48+
49+
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.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Rule: Text that "streams" (tokens arriving)
2+
3+
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.
4+
5+
```json
6+
{
7+
"type": "text",
8+
"content": "Words arrive in uneven bursts, like tokens.",
9+
"style": {
10+
"font-size": 40,
11+
"color": "#E2E8F0",
12+
"animation": [{
13+
"name": "char_blur_in",
14+
"granularity": "word",
15+
"duration": 0.28,
16+
"stagger": 0.09,
17+
"jitter": 0.7,
18+
"seed": 12,
19+
"ink_from": "#475569",
20+
"blur": 6
21+
}]
22+
}
23+
}
24+
```
25+
26+
Three fields do all the work:
27+
28+
- **`granularity: "word"`** — the unit is the word, not the letter.
29+
- **`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.
30+
- **`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.
31+
32+
## `jitter` is deterministic, not random
33+
34+
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.
35+
36+
Changing `seed` reshuffles the rhythm without changing its statistics — useful so two neighbouring paragraphs don't "breathe" identically.
37+
38+
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.
39+
40+
## Budget
41+
42+
`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.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Rule: The four text finishes
2+
3+
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.
4+
5+
## `shimmer` — the light sweeps over the letters
6+
7+
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.
8+
9+
```json
10+
"animation": [{
11+
"name": "shimmer",
12+
"delay": 1.0, "duration": 1.1,
13+
"color": "#7DD3FC", "intensity": 0.85,
14+
"width": 0.3, "angle": 22, "loop": true
15+
}]
16+
```
17+
18+
`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.
19+
20+
Combined with `char_blur_in` on the same `text`, this reproduces "text stagger": the words rise while unblurring, then the light sweeps through.
21+
22+
## `text.states` — a label that becomes another
23+
24+
```json
25+
{
26+
"type": "text",
27+
"content": "Saving draft",
28+
"states": [{ "at": 2.6, "content": "Saved" }],
29+
"swap": { "duration": 0.45, "distance": 22, "blur": 9 },
30+
"style": { "font-size": 52, "white-space": "nowrap", "max-width": "600px" }
31+
}
32+
```
33+
34+
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.
35+
36+
**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.
37+
38+
## `text.caret` — the caret follows the reveal
39+
40+
```json
41+
{ "type": "text", "content": "rustmotion --frames 0-60",
42+
"caret": { "shape": "block", "blink": 0.9, "color": "#38BDF8" },
43+
"style": { "animation": [{ "name": "typewriter", "duration": 2.0 }] } }
44+
```
45+
46+
`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.
47+
48+
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.
49+
50+
## `pop_in` — the badge arrival
51+
52+
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.
53+
54+
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.

0 commit comments

Comments
 (0)