From 8055cb1aaeeb62115c6048a1c46ebb4231662c06 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Wed, 19 Aug 2026 15:20:00 +0200 Subject: [PATCH 1/3] 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). --- .../skills/rustmotion/rules/card-resize.md | 47 + .../rustmotion/rules/char-animation-tuning.md | 48 + .../rustmotion/rules/hyperframes-mapping.md | 30 + .../skills/rustmotion/rules/motion-path.md | 16 + .../skills/rustmotion/rules/number-wheel.md | 44 + .../rustmotion/rules/pointer-walkthrough.md | 49 + .../skills/rustmotion/rules/streaming-text.md | 42 + .../skills/rustmotion/rules/text-polish.md | 54 + CLAUDE.md | 28 +- README.md | 148 +- .../rustmotion-cli/src/commands/geometry.rs | 12 +- .../src/commands/validate_schema.rs | 12 + .../rustmotion-components/src/box_builder.rs | 77 +- crates/rustmotion-components/src/cursor.rs | 29 +- crates/rustmotion-components/src/intrinsic.rs | 80 +- crates/rustmotion-components/src/lib.rs | 21 + .../rustmotion-components/src/number_wheel.rs | 357 + crates/rustmotion-components/src/pointer.rs | 343 + .../src/success_check.rs | 266 + crates/rustmotion-components/src/text.rs | 1095 ++- crates/rustmotion-core/src/css/animation.rs | 28 +- crates/rustmotion-core/src/engine/animator.rs | 223 +- .../rustmotion-core/src/engine/paint_pass.rs | 116 + .../rustmotion-core/src/engine/transition.rs | 141 +- .../rustmotion-core/src/schema/animation.rs | 1 + .../rustmotion-core/src/schema/background.rs | 54 + crates/rustmotion-core/src/schema/scenario.rs | 29 + crates/rustmotion-core/src/schema/video.rs | 303 +- .../rustmotion-core/tests/chromatic_wipe.rs | 147 + crates/rustmotion/src/encode/video/tasks.rs | 66 +- .../src/engine/render/background.rs | 180 +- crates/rustmotion/src/tests.rs | 6 + crates/rustmotion/tests/animated_box_size.rs | 138 + crates/rustmotion/tests/shimmer_effect.rs | 166 + examples/assets/ferriskey-logo.svg | 189 + examples/ferriskey-launch-60s.json | 7295 +++++++++++++++++ examples/hyperframes-vocabulary.json | 192 + 37 files changed, 11809 insertions(+), 263 deletions(-) create mode 100644 .claude/skills/rustmotion/rules/card-resize.md create mode 100644 .claude/skills/rustmotion/rules/char-animation-tuning.md create mode 100644 .claude/skills/rustmotion/rules/hyperframes-mapping.md create mode 100644 .claude/skills/rustmotion/rules/number-wheel.md create mode 100644 .claude/skills/rustmotion/rules/pointer-walkthrough.md create mode 100644 .claude/skills/rustmotion/rules/streaming-text.md create mode 100644 .claude/skills/rustmotion/rules/text-polish.md create mode 100644 crates/rustmotion-components/src/number_wheel.rs create mode 100644 crates/rustmotion-components/src/pointer.rs create mode 100644 crates/rustmotion-components/src/success_check.rs create mode 100644 crates/rustmotion-core/tests/chromatic_wipe.rs create mode 100644 crates/rustmotion/tests/animated_box_size.rs create mode 100644 crates/rustmotion/tests/shimmer_effect.rs create mode 100644 examples/assets/ferriskey-logo.svg create mode 100644 examples/ferriskey-launch-60s.json create mode 100644 examples/hyperframes-vocabulary.json diff --git a/.claude/skills/rustmotion/rules/card-resize.md b/.claude/skills/rustmotion/rules/card-resize.md new file mode 100644 index 0000000..dcaeabd --- /dev/null +++ b/.claude/skills/rustmotion/rules/card-resize.md @@ -0,0 +1,47 @@ +# Rule: Redimensionner une carte (et pas la mettre à l'échelle) + +Une carte compacte qui grandit en panneau de détail est un changement de **layout** : sa nouvelle boîte reflue son contenu. Un `scale` étire les pixels qu'elle avait déjà, texte compris — c'est un zoom, pas un redimensionnement, et ça se voit immédiatement sur le texte devenu flou et surdimensionné. + +Anime `width` / `height` avec un effet `keyframes` : ils atteignent taffy, donc la mise en page est recalculée à chaque 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": "…" } ] +} +``` + +## Les temps des keyframes sont relatifs au `delay` + +`{"time": 0.0}` est le début de l'effet, pas le début de la scène. Le `delay` de l'effet décale toute la piste. + +## La taille animée gagne sur la taille intrinsèque + +Un composant qui déclare sa propre taille (une `shape`, un `badge`) la voit remplacée pendant l'animation. C'est voulu, mais ça veut aussi dire qu'une valeur oubliée à 0 sur la dernière keyframe fait disparaître la boîte. + +## Que le contenu suive vraiment + +Sans `justify-content` / `align-items`, l'enfant reste collé en haut à gauche et seule la boîte grandit : le mouvement paraît vide. Centre le contenu (ou donne-lui un `flex: 1`) pour que la croissance se lise. + +## Le validateur voit la boîte finale + +`validate --strict-anim` échantillonne l'animation : une carte qui grandit hors du device est signalée à l'instant où ça arrive. Vérifie qu'il reste de la marge à la taille maximale, pas seulement à la taille initiale. diff --git a/.claude/skills/rustmotion/rules/char-animation-tuning.md b/.claude/skills/rustmotion/rules/char-animation-tuning.md new file mode 100644 index 0000000..c297c9e --- /dev/null +++ b/.claude/skills/rustmotion/rules/char-animation-tuning.md @@ -0,0 +1,48 @@ +# Rule: Régler une animation par caractère ou par mot + +Les sept presets `char_*` (`char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`, `char_blur_in`) partagent une même config. Six champs la règlent, tous optionnels, **tous par défaut au comportement historique** : un scénario existant ne bouge pas. + +```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 + }] + } +} +``` + +| Champ | Rôle | Défaut | +|---|---|---| +| `direction` | `up` / `down` / `left` / `right` — d'où l'unité arrive | `up` | +| `distance` | Multiplicateur du déplacement (0.5 serré, 1.85 marqué) | `1.0` | +| `scale_from` | Échelle de départ de chaque unité (0.82 = «pop», 0.92 = à peine) | absent | +| `jitter` + `seed` | Irrégularité déterministe du `stagger` | `0` | +| `ink_from` | Couleur de départ, converge vers `style.color` | absent | +| `blur` | Sigma de départ (`char_blur_in` seul) | `14` | + +## Ce que chaque preset lit + +`direction` et `distance` n'ont de sens que pour les presets dont le mouvement **est** une translation : `char_slide_up` et `char_blur_in`. Les autres (`scale_in`, `bounce`, `rotate_in`, `fade_in`, `wave`) n'ont pas d'axe de déplacement à rediriger et les ignorent. + +`scale_from` se **compose** avec le preset au lieu de le remplacer — sauf sur `char_scale_in` et `char_bounce`, qui possèdent déjà leur propre courbe d'échelle et l'ignorent (deux échelles empilées se battent au lieu de se composer). + +## Le nom `char_slide_up` ne contraint pas la direction + +`char_slide_up` avec `"direction": "down"` fait tomber les lettres depuis le haut. Le nom est historique : c'est le preset «translation», `up` en est le défaut. Il n'existe pas de `char_slide_down`. + +## `granularity` décide de ce qu'est une unité + +`"granularity": "word"` anime des mots, `"char"` (défaut) des caractères. Un titre de 40 caractères animé en `char` avec `stagger: 0.05` prend 2 s à s'installer avant même sa `duration` — compte le nombre d'unités avant de choisir le `stagger`, ou passe en `word`. + +## Rappel sur `char_blur_in` + +Il passe par le même chemin de résolution que ses six frères : il **hérite** du `stagger` d'un conteneur parent et fonctionne dans une étape de `timeline`. (Ça n'a pas toujours été le cas.) diff --git a/.claude/skills/rustmotion/rules/hyperframes-mapping.md b/.claude/skills/rustmotion/rules/hyperframes-mapping.md new file mode 100644 index 0000000..3608111 --- /dev/null +++ b/.claude/skills/rustmotion/rules/hyperframes-mapping.md @@ -0,0 +1,30 @@ +# Rule: Correspondance Hyperframes → rustmotion + +Si on te demande un effet du catalogue Hyperframes (ou un effet décrit dans ce vocabulaire — «streaming text», «number wheel», «badge pop»…), **cherche-le dans cette table avant d'écrire quoi que ce soit**. La moitié de ces effets existe déjà sous un autre nom, et la reconstruire à la main donne un résultat moins bon et non vérifiable par le validateur. + +| Hyperframes | En 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` avec `"direction": "down"` | +| Text Stagger | `char_blur_in` (montée) + effet `shimmer` (balayage) sur le même `text` | +| Number Pop In | `char_blur_in` avec `"granularity": "char"`, `"scale_from": 0.82` | +| Streaming Text | `char_blur_in` avec `jitter`/`seed`/`ink_from` — voir [streaming-text.md](streaming-text.md) | +| Typewriter | preset `typewriter` + `text.caret` | +| Text State Swap | `text.states` + `text.swap` | +| Number Wheel | composant `number_wheel` — voir [number-wheel.md](number-wheel.md) | +| Badge Pop | `badge` + `style.animation: [{ "name": "pop_in" }]` | +| Success Check | composant `success_check` | +| Simulated Cursor | composant `pointer` — voir [pointer-walkthrough.md](pointer-walkthrough.md) | +| Card Resize | `keyframes` sur `width`/`height` — voir [card-resize.md](card-resize.md) | +| Arc Motion Path | effet `motion_path` + `orient` — voir [motion-path.md](motion-path.md) | +| SVG Line Draw Loader | preset `draw_in` / `stroke_reveal` sur un `svg` | +| Dynamic Grid | `animated-background` preset `grid_lines` | +| Page Slide | `transition: { "type": "slide" }` | +| Chromatic Aberration Wipe | `transition: { "type": "chromatic_wipe" }` | + +## Deux pièges de nommage + +`cursor` **n'est pas** un curseur de souris : c'est un caret texte (une barre clignotante). Le pointeur de souris avec son anneau de clic, c'est `pointer`. + +`counter` **n'est pas** une roue de chiffres : il interpole une *valeur* et réécrit le nombre à chaque frame, donc les glyphes sautent. `number_wheel` fait défiler des bandes de chiffres, comme un compteur mécanique. Un compteur qui monte de 0 à 30 222 → `counter`. Un chiffre qui atterrit → `number_wheel`. diff --git a/.claude/skills/rustmotion/rules/motion-path.md b/.claude/skills/rustmotion/rules/motion-path.md index 8e481ec..b590f59 100644 --- a/.claude/skills/rustmotion/rules/motion-path.md +++ b/.claude/skills/rustmotion/rules/motion-path.md @@ -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. +## Recette : l'arc + +L'effet « arc motion path » — une pastille qui décrit une courbe en pivotant pour rester face à sa trajectoire — est un `motion_path` à une seule cubique et `orient: true`. Il n'y a pas de raccourci `arc` : une cubique le décrit exactement, et lisiblement. + +```json +"animation": [{ + "name": "motion_path", + "path": "M0,0 C260,-220 620,-220 880,0", + "duration": 2.4, + "orient": true, + "easing": "ease_in_out" +}] +``` + +Les deux points de contrôle à la même hauteur (`-220`) donnent un arc symétrique ; les rapprocher de leurs extrémités respectives l'aplatit au milieu et le creuse aux bouts. Pour un arc **descendant**, inverse le signe : `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`. diff --git a/.claude/skills/rustmotion/rules/number-wheel.md b/.claude/skills/rustmotion/rules/number-wheel.md new file mode 100644 index 0000000..454d71f --- /dev/null +++ b/.claude/skills/rustmotion/rules/number-wheel.md @@ -0,0 +1,44 @@ +# Rule: `number_wheel` vs `counter` + +Deux composants affichent un nombre qui s'anime. Ils ne racontent pas la même chose. + +**`counter`** interpole une *valeur* et réécrit le nombre à chaque frame. Il répond à « combien, en ce moment ? » — une jauge qui monte, un total qui se cumule. Ses glyphes sautent, parce que 8 999 puis 9 000 n'ont rien en commun. + +**`number_wheel`** fait défiler des bandes de chiffres, comme un compteur mécanique. Il répond à « la figure atterrit » — un KPI qui se pose, un résultat qui se révèle. Ce qu'on regarde, c'est le mouvement ; ce qui reste, c'est le chiffre demandé. + +```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" } +} +``` + +| Champ | Rôle | Défaut | +|---|---|---| +| `value` | La figure telle qu'écrite : `"30,222"`, `"5.7"`, `"98%"` | requis | +| `spin` | `single` / `double` / `triple` — tours de 0-9 avant l'atterrissage | `single` | +| `duration` | Durée d'atterrissage **d'une** roue | `1.2` | +| `delay` | Avant le départ de la première roue | `0` | +| `stagger_per_column` | Décalage par colonne, de gauche à droite | `0.08` | +| `easing` | Courbe du trajet | `ease_out_cubic` | + +## `value` est une chaîne, pas un nombre + +Les chiffres roulent ; tout le reste — virgule, point, signe, unité — est peint à sa place, immobile. C'est ce qui permet d'écrire `"1 204 €"` ou `"98%"` sans que le séparateur ne parte en vrille. + +## `spin` change la vitesse, pas la durée + +Chaque roue prend `duration` quoi qu'il arrive. `triple` ne rend pas l'animation plus longue : il fait défiler trois fois plus de chiffres dans le même temps. Un `triple` sur une `duration` courte devient une bouillie illisible. + +## `stagger_per_column: 0` est un défaut à éviter + +Toutes les roues atterrissent alors ensemble, ce qui lit comme un simple flip. Le décalage gauche→droite est ce qui fait que le dernier chiffre est celui qui *règle* la figure. + +## La boîte réserve la place du chiffre le plus large + +Chaque colonne fait la largeur du chiffre le plus large de la police, pas celle du chiffre final : sinon un `111` réserverait une boîte étroite puis déborderait pendant qu'un `0` défile. Le validateur mesure la même chose. diff --git a/.claude/skills/rustmotion/rules/pointer-walkthrough.md b/.claude/skills/rustmotion/rules/pointer-walkthrough.md new file mode 100644 index 0000000..8d57468 --- /dev/null +++ b/.claude/skills/rustmotion/rules/pointer-walkthrough.md @@ -0,0 +1,49 @@ +# Rule: Curseur de souris simulé (`pointer`) + +Pour une démo produit ou un walkthrough d'agent — la flèche qui se déplace vers un contrôle et clique dessus — utilise `pointer`. + +**`cursor` n'est pas ça.** `cursor` est un caret texte : une barre verticale clignotante. Son champ `cursor_style: "pointer"` est de la métadonnée morte, il dessine une barre dans les deux cas. + +```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 } + ] +} +``` + +| Champ | Rôle | +|---|---| +| `size` | Hauteur de la flèche en px. L'anneau de clic suit. | +| `tone` | `light` (flèche blanche, contour sombre) ou `dark` | +| `color` / `outline_color` | Surchargent `tone` | +| `click_ring` | `subtle` / `standard` / `bold` / `none` | +| `path` | Waypoints `{time, x, y}` — le pointeur **clique en arrivant** sur chacun | +| `click_at` | Clics d'un pointeur immobile. **Ignoré si `path` est présent** | +| `click_duration` | Durée du clic, *et* pause sur le waypoint avant de repartir | +| `path_easing` | `ease_in_out` (défaut), `linear`, `ease_out`, `step` | + +## Les coordonnées partent de l'origine du composant + +`x`/`y` d'un waypoint sont relatifs à la boîte du `pointer`, pas au device. Place le composant en `position: absolute, x: 0, y: 0` et les waypoints se lisent alors comme des coordonnées de scène — c'est la forme à privilégier pour un walkthrough. + +## La boîte est le glyphe, pas le parcours + +La boîte du composant fait la taille de la flèche : ce sont les waypoints qui la translatent. Dimensionner la boîte au parcours ferait pousser les frères d'un `flex` par un élément qui n'est qu'un curseur. + +Corollaire : `pointer` est **exempté du contrôle de débordement viewport**, comme `marquee` et `cursor`. Une démo qui amène la flèche près d'un bord met légitimement sa queue dehors. + +## Le déplacement fait une pause sur le clic + +Entre deux waypoints, le pointeur ne repart qu'une fois l'animation de clic finie (`click_duration`). C'est ce qui rend le geste lisible : arriver, cliquer, repartir. Un `click_duration` proche de l'écart entre deux waypoints laisse à peine le temps du trajet — laisse au moins le double. diff --git a/.claude/skills/rustmotion/rules/streaming-text.md b/.claude/skills/rustmotion/rules/streaming-text.md new file mode 100644 index 0000000..768bd3e --- /dev/null +++ b/.claude/skills/rustmotion/rules/streaming-text.md @@ -0,0 +1,42 @@ +# Rule: Texte qui « streame » (arrivée de tokens) + +Pour figurer une réponse de modèle en train de s'écrire, **n'utilise pas `typewriter`** : un typewriter révèle caractère par caractère à cadence fixe, ce qui lit comme une machine à écrire, pas comme un flux de tokens. Un modèle émet des mots entiers, par bouffées inégales, et chaque mot s'installe visuellement au lieu d'apparaître net d'un coup. + +```json +{ + "type": "text", + "content": "Les mots arrivent par bouffées inégales, comme des 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 + }] + } +} +``` + +Trois champs font tout le travail : + +- **`granularity: "word"`** — l'unité est le mot, pas la lettre. +- **`jitter`** — décale le départ de chaque unité de ±`jitter × stagger`. C'est ce qui casse la cadence métronomique. 0.5–0.8 lit comme du streaming ; au-delà de 1.0 les mots se croisent et l'ordre de lecture se brouille. +- **`ink_from`** — chaque mot démarre dans cette couleur et converge vers `style.color` sur sa durée. Un gris désaturé reproduit le token « pas encore accepté par l'œil ». + +## Le `jitter` est déterministe, pas aléatoire + +Les décalages sont dérivés de `seed` et de l'index de l'unité, jamais d'un RNG. C'est une contrainte, pas un détail : les frames sont rendues dans le désordre, en parallèle, et parfois dans des processus séparés (`--frames a-b`). Un mot dont le départ dépendrait d'un tirage sauterait entre deux frames voisines. + +Changer `seed` rebat le rythme sans en changer la statistique — utile pour que deux paragraphes voisins ne « respirent » pas à l'identique. + +Aucune unité ne peut démarrer avant le `delay` de l'effet : un décalage négatif sur la première unité la ferait apparaître à moitié animée dès la frame 0. + +## Budget + +`stagger × nombre de mots + duration` est le temps total d'installation. Sur une phrase de 12 mots avec `stagger: 0.09`, c'est ~1.4 s — vérifie que la scène est assez longue, `validate` le signale sinon. diff --git a/.claude/skills/rustmotion/rules/text-polish.md b/.claude/skills/rustmotion/rules/text-polish.md new file mode 100644 index 0000000..e14232d --- /dev/null +++ b/.claude/skills/rustmotion/rules/text-polish.md @@ -0,0 +1,54 @@ +# Rule: Les quatre finitions de texte + +Quatre mécanismes qui demandaient chacun un sous-arbre bricolé à la main s'écrivent maintenant en une ligne. Aucun ne remplace un preset d'entrée : ils s'ajoutent par-dessus. + +## `shimmer` — la lumière passe sur les lettres + +Effet d'animation, pas champ de composant. La bande n'éclaire **que les pixels réellement peints** (composée en `SrcATop` dans la couche du nœud) : sur un `text`, la lumière accroche les glyphes, pas la boîte. + +```json +"animation": [{ + "name": "shimmer", + "delay": 1.0, "duration": 1.1, + "color": "#7DD3FC", "intensity": 0.85, + "width": 0.3, "angle": 22, "loop": true +}] +``` + +`width` est la largeur de la bande en fraction du trajet (0.3 = glint net, 0.8 = lavage doux). `angle` incline la bande : `0` est verticale et balaie de gauche à droite ; ~20° est ce qui la fait lire comme un reflet plutôt que comme un essuyage. La couche isolée n'est ouverte que pendant la fenêtre de l'effet — un `shimmer` non bouclé ne coûte rien le reste de la scène. + +Combiné à `char_blur_in` sur le même `text`, ça reproduit le « text stagger » : les mots montent en se défloutant, puis la lumière passe. + +## `text.states` — un libellé qui en devient un autre + +```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" } +} +``` + +Sans `swap`, les libellés se coupent net à chaque `at` — abrupt, mais c'est exactement ce que demande l'absence du champ. Avec `swap`, les deux sont à l'écran pendant la fenêtre : le sortant monte en floutant, l'entrant monte du bas en se défloutant. + +**La boîte est mesurée sur le libellé le plus long**, pas sur le premier. Une boîte dimensionnée pour `"Saved"` déborderait à l'instant du retour vers `"Saving draft"` — et le validateur aurait signé. + +## `text.caret` — le caret suit la révélation + +```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` (règle fine) ou `block` (terminal). `blink` est la période complète en secondes (`0` = fixe). `hide_when_done: true` retire le caret une fois la révélation finie au lieu de le laisser garé. + +C'est la raison d'être du champ : un `cursor` composé à côté du texte reste où on l'a mis pendant que le texte pousse sous lui. Le caret est aussi présent **avant** le premier caractère, sinon la première frame est vide puis caret et lettre apparaissent ensemble, ce qui lit comme un glitch. + +## `pop_in` — l'arrivée de badge + +Preset d'animation : l'élément grossit depuis rien avec un dépassement `back-out`, **puis** une courte impulsion élastique une fois posé. `overshoot` règle l'amplitude de l'impulsion (défaut 0.18 = 118 %) ; `0` la supprime et laisse un simple scale-in. + +Les deux temps comptent : le premier place l'élément, le second y ramène l'œil. Fondus en une seule courbe, ils lisent comme un tremblement. diff --git a/CLAUDE.md b/CLAUDE.md index 75e41d2..b4eee62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,13 +56,21 @@ Chaque élément du `for-each` lie ses champs directement (`$label`), plus `$ind Un scénario est soit une liste plate `scenes` (racine) — implicitement enveloppée dans une seule vue `slide` — soit un `composition: [...]` explicite, un tableau de **vues** typées `"slide"` ou `"world"`. Les deux sont mutuellement exclusifs (`CompositionAndScenesConflict` si les deux sont présents). -Dans une vue `slide`, les `transition` entre scènes sont des **composites pixel de deux frame-buffers déjà rendus** (fade, wipe, zoom, flip, iris, slide…) : aucun élément ne survit à la coupe, seuls les pixels sont mélangés. +Dans une vue `slide`, les `transition` entre scènes sont des **composites pixel de deux frame-buffers déjà rendus** (fade, wipe, zoom, flip, iris, slide, chromatic_wipe…) : aucun élément ne survit à la coupe, seuls les pixels sont mélangés. + +> `chromatic_wipe` est un slide rapide dont le bord de révélation se dédouble en rouge/cyan au pic puis se recompose. `direction` (`left`/`right`/`up`/`down`) l'oriente, `aberration` dose l'écart des canaux (`0` = slide nu, `2` = doublé). Le flash est nul aux deux extrémités : une frange résiduelle sur la dernière frame baverait dans la scène suivante. La vue **`world`** est le seul mécanisme qui produit une continuité réelle entre beats : une caméra virtuelle se déplace en continu à travers un espace 2D où chaque scène occupe une position (`world-position`), avec un fondu de recouvrement pendant le pan au lieu d'une coupe. C'est la brique à utiliser pour une vidéo qui doit se lire comme un plan continu, sans limite de scène perceptible. Voir [rules/world-view.md](.claude/skills/rustmotion/rules/world-view.md) pour le modèle de coordonnées (le piège `world-position` = waypoint caméra, pas origine de scène), la recette du halo ambiant en `view.background`, et un exemple multi-beat validé. **Piège de casing à connaître :** `world-position` (scène) est en kebab-case, alors que son voisin `freeze_at` (même struct `Scene`) est en snake_case. Vraie inconsistance du schéma, pas une faute de frappe — copier la casse telle quelle. -## Composants disponibles (57) +## Fonds animés + +`scene["animated-background"]` (kebab-case, comme `world-position`) accepte un `preset` parmi `gradient_shift`, `grid_dots`, `grid_lines`, `concentric_circles`, `halo`, `pixel_grid`, `heropattern`, avec sa config sous une clé du même nom, plus les `x`/`y`/`speed`/`direction` communs qui font dériver la texture. + +> `grid_dots` marque les intersections et lit comme une texture ; `grid_lines` est une grille de **lignes** réglées et lit comme une structure — c'est celle à mettre derrière un graphe ou un panneau de code. Config : `cell`, `weight`, `color`, plus `major_every`/`major_weight` pour l'effet papier millimétré. + +## Composants disponibles (60) ### Basiques `text`, `shape`, `image`, `icon`, `svg`, `video`, `gif`, `caption`, `rich_text`, `gradient_text` @@ -82,6 +90,7 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `dot_map` — carte mondiale en dot-pattern avec points de données, pulse, lat/lng - `progress` — barre linéaire ou circulaire - `counter` — compteur animé (standalone uniquement, pas dans les cards) +- `number_wheel` — chiffres qui défilent façon compteur mécanique et atterrissent sur la figure. À ne pas confondre avec `counter`, qui interpole une valeur et réécrit le nombre (ses glyphes sautent). Voir [rules/number-wheel.md](.claude/skills/rustmotion/rules/number-wheel.md). - `table` — tableau avec column_widths, column_align, cell_padding, show_borders ### UI Components @@ -103,6 +112,8 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `tag_cloud` — nuage de mots avec tailles pondérées - `callout` — bulle avec flèche - `divider` — séparateur visuel +- `success_check` — coche qui se trace dans un halo, avec pop et rotation résorbée +- `pointer` — curseur de **souris** simulé (flèche + anneau de clic) suivant des waypoints. `cursor` est un caret texte, pas ça. Voir [rules/pointer-walkthrough.md](.claude/skills/rustmotion/rules/pointer-walkthrough.md). ### Code & Terminal - `codeblock` — code syntax-highlighted avec reveal, diff mode (`diff: true`), state transitions @@ -122,6 +133,19 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en > Voir [rules/audio-reactive.md](.claude/skills/rustmotion/rules/audio-reactive.md) pour lier un composant à une piste `audio` via `style.audio-reactive`. +## Finitions de texte + +Quatre mécanismes qui demandaient chacun un sous-arbre bricolé à la main. Détails et pièges dans [rules/text-polish.md](.claude/skills/rustmotion/rules/text-polish.md). + +- **`shimmer`** — effet d'animation : une bande de lumière balaie l'élément. Composée en `SrcATop` dans la couche du nœud, donc elle n'éclaire **que les pixels réellement peints** (les glyphes, pas la boîte). Combinée à `char_blur_in`, c'est le « text stagger ». +- **`text.states` + `text.swap`** — un libellé qui en devient un autre : le sortant monte en floutant, l'entrant monte du bas en se défloutant. La boîte est mesurée sur le libellé **le plus long**, pas le premier. +- **`text.caret`** — un caret (`line` ou `block`) accroché à la tête de révélation d'un `typewriter`. Un `cursor` composé à côté resterait où on l'a mis pendant que le texte pousse sous lui. +- **`pop_in`** — preset d'entrée en deux temps : scale back-out qui *place* l'élément, puis courte impulsion élastique qui y ramène l'œil. + +Les sept presets `char_*` se règlent par `direction` (up/down/left/right), `distance`, `scale_from`, `jitter`+`seed` (irrégularité déterministe du stagger) et `ink_from` (couleur de départ de chaque unité). Voir [rules/char-animation-tuning.md](.claude/skills/rustmotion/rules/char-animation-tuning.md) et, pour l'arrivée de tokens, [rules/streaming-text.md](.claude/skills/rustmotion/rules/streaming-text.md). + +> Si on te demande un effet nommé dans le vocabulaire Hyperframes (« streaming text », « number wheel », « badge pop », « top-down letters »…), consulte d'abord la table de correspondance : [rules/hyperframes-mapping.md](.claude/skills/rustmotion/rules/hyperframes-mapping.md). + ## Architecture ### Render Pipeline (CSS engine) diff --git a/README.md b/README.md index fd3d005..f9b34b6 100644 --- a/README.md +++ b/README.md @@ -232,12 +232,22 @@ Transitions blend between two consecutive scenes. Set on the **second** scene. | `iris` | Expanding circle from the center reveals scene B | | `slide` | Scene B pushes scene A to the left | | `dissolve` | Per-pixel noise dissolve | +| `corner_reveal` | Rectangular uncover anchored at a corner | +| `pixel_dissolve` | Cell-by-cell dissolve on a scattered order | +| `camera_pan` | Foregrounds slide over a composited background | +| `chromatic_wipe` | Fast slide whose reveal edge splits into red/cyan at the peak | | `none` | Hard cut at the midpoint | | Field | Type | Default | Description | |---|---|---|---| | `type` | `string` | (required) | One of the transition types above | | `duration` | `f64` | `0.5` | Transition duration in seconds | +| `easing` | `string` | `ease_in_out` | Easing of the transition's own progress | +| `corner` | `string` | `top_right` | `corner_reveal` only | +| `cell` / `seed` / `order` | | `48` / `11` | `pixel_dissolve` only | +| `background` | `string` | `static` | `camera_pan` only | +| `direction` | `string` | `left` | `chromatic_wipe` only — `left`/`right`/`up`/`down` | +| `aberration` | `f32` | `1.0` | `chromatic_wipe` only — channel-split multiplier; `0` leaves a plain fast slide | --- @@ -391,7 +401,29 @@ All visual properties are inside a `"style"` object: } ``` -**Root fields:** `content` (required), `max_width` +**Root fields:** `content` (required), `max_width`, `caret`, `states`, `swap` + +`caret` pins a caret to a `typewriter` reveal's head, so it travels with the text instead of standing where you put it: + +| `caret` field | Type | Default | Description | +|---|---|---|---| +| `shape` | `string` | `"line"` | `"line"` (thin rule) or `"block"` (terminal) | +| `color` | `string` | | Defaults to the text's own colour | +| `blink` | `f32` | `1.0` | Full off/on period in seconds; `0` = solid | +| `hide_when_done` | `bool` | `false` | Remove the caret once the reveal finishes instead of parking it | + +`states` + `swap` cross a label into a later one — the outgoing rises and blurs out, the incoming rises from below and sharpens: + +```json +{ + "type": "text", + "content": "Saving draft", + "states": [{ "at": 2.6, "content": "Saved" }], + "swap": { "duration": 0.45, "distance": 22, "blur": 9 } +} +``` + +Without `swap`, the labels cut over at each `at`. The box is measured for the **longest** label the text can show, not the first — otherwise a swap back to a longer label would overflow a box the geometry validator had already signed off on. | Style field | Type | Default | Description | |---|---|---|---| @@ -1293,6 +1325,94 @@ Animated cursor with click effects, blinking, and smooth path animation. When `auto_path` is set, clicks trigger at each waypoint. Uses Catmull-Rom spline interpolation for smooth curves. +> `cursor` is a **text caret** — a blinking bar. For a simulated *mouse* pointer, see `pointer` below. + +--- + +### Pointer + +A simulated mouse pointer: the arrow glyph plus the ring that pulses out of its tip when it clicks. For product walkthroughs and agent demos. + +```json +{ + "type": "pointer", + "position": "absolute", + "x": 0, + "y": 0, + "size": 52, + "tone": "light", + "click_ring": "bold", + "path": [ + { "time": 0.4, "x": 1500, "y": 820 }, + { "time": 2.0, "x": 480, "y": 330 } + ] +} +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `size` | `f32` | `44.0` | Arrow height in px; the click ring scales with it | +| `tone` | `string` | `"light"` | `"light"` (white arrow, dark outline) or `"dark"` | +| `color` / `outline_color` | `string` | | Override `tone` | +| `click_ring` | `string` | `"standard"` | `"subtle"`, `"standard"`, `"bold"`, `"none"` | +| `ring_color` | `string` | | Defaults to the arrow's fill | +| `path` | `array` | `[]` | Waypoints `[{ "time", "x", "y" }]`; the pointer clicks on arrival at each | +| `click_at` | `f64[]` | `[]` | Clicks for a stationary pointer — ignored when `path` is set | +| `click_duration` | `f32` | `0.45` | Click duration, and the pause on a waypoint before moving on | +| `path_easing` | `string` | `"ease_in_out"` | `"linear"`, `"ease_out"`, `"ease_in_out"`, `"step"` | + +Waypoint `x`/`y` are relative to the component's own origin. Place it at `position: absolute, x: 0, y: 0` and they read as scene coordinates. The box is the arrow glyph, not the travel — `pointer` is exempt from the viewport-overflow check, like `marquee` and `cursor`. + +--- + +### Number Wheel + +Odometer-style digits that roll into place. Distinct from `counter`, which interpolates a *value* and rewrites the number each frame (its glyphs jump). + +```json +{ + "type": "number_wheel", + "value": "30,222", + "spin": "double", + "duration": 1.1, + "stagger_per_column": 0.09, + "style": { "font-size": 120, "font-weight": 700, "color": "#38BDF8" } +} +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `value` | `string` | (required) | The figure as written. Digits roll; separators, signs and units stay put | +| `spin` | `string` | `"single"` | `"single"`, `"double"`, `"triple"` — 0-9 revolutions before landing | +| `duration` | `f64` | `1.2` | How long **one** reel takes to land | +| `delay` | `f64` | `0.0` | Before the first reel starts | +| `stagger_per_column` | `f64` | `0.08` | Per-column delay, left to right | +| `easing` | `string` | `"ease_out_cubic"` | Curve of a reel's travel | + +`spin` changes the speed, not the duration: every reel takes `duration` regardless. Each column reserves the width of the font's widest digit, so a `111` doesn't overflow while a `0` rolls past. + +--- + +### Success Check + +A checkmark drawing itself inside a halo, arriving with a pop and a rotation it resolves as it lands. + +```json +{ "type": "success_check", "size": 110, "tint": "#22C55E", "delay": 1.6, "duration": 0.8 } +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `size` | `f32` | `82.0` | Halo diameter in px; the stroke scales with it | +| `tint` | `string` | `"#22C55E"` | Colour of the mark | +| `ring` | `f32` | `0.28` | Halo opacity, 0..1 | +| `ring_color` | `string` | | Overrides `tint` for the halo | +| `spin` | `f32` | `1.0` | Entrance-rotation multiplier; `0` lands square. The mark always finishes upright | +| `stroke_width` | `f32` | `size * 0.09` | Stroke width of the mark | +| `delay` / `duration` | `f64` | `0` / `0.7` | Arrival window | + +The stroke starts once the halo has landed — a mark that writes itself while still flying in reads as two animations fighting. + --- ### Line @@ -1431,11 +1551,21 @@ When `transition` is specified, background properties interpolate smoothly from | `colors` | `string[]` | `[]` | Gradient colors (hex) | | `speed` | `f32` | `30.0` | Animation speed | | `gradient_type` | `string` | `"linear"` | `"linear"` or `"radial"` | -| `preset` | `string` | | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"halo"` | +| `preset` | `string` | | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"grid_lines"`, `"halo"`, `"pixel_grid"`, `"heropattern"` | | `element_size` | `f32` | `4.0` | Dot size for grid_dots; stroke width for concentric_circles | | `spacing` | `f32` | `60.0` | Element spacing for grid_dots/concentric_circles | | `count` | `u32` | | Number of circles for concentric_circles (overrides spacing) | +**`grid_lines`** is the ruled counterpart of `grid_dots`: dots mark the intersections and read as texture, ruled lines read as structure — the one to put behind a chart or a code panel. Nothing pulses; motion comes only from the shared `speed`/`direction` scroll. + +| `grid_lines` field | Type | Default | Description | +|---|---|---|---| +| `color` | `string` | `"#FFFFFF14"` | Line colour (alpha welcome) | +| `cell` | `f32` | `72.0` | Cell edge in px (small = graph paper, large = panels) | +| `weight` | `f32` | `1.0` | Line thickness in px | +| `major_every` | `u32` | `0` | Draw every Nth line heavier; `0` = no major lines | +| `major_weight` | `f32` | `2.0` | Thickness of a major line | + **Background transition fields** (inside `$ref` entries): | Field | Type | Default | Description | @@ -1467,12 +1597,13 @@ All animation effects are defined inside `style.animation` as a **typed array**, | Effect name | Fields | Description | |---|---|---| | *preset name* | `delay`, `duration`, `loop`, `overshoot` | Any of the 39 presets (e.g. `fade_in_up`, `scale_in`) | -| *char preset* | `delay`, `duration`, `stagger`, `granularity`, `easing`, `overshoot` | Per-char/word text animation: `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` | +| *char preset* | `delay`, `duration`, `stagger`, `granularity`, `easing`, `overshoot`, `direction`, `distance`, `scale_from`, `jitter`, `seed`, `ink_from`, `blur` | Per-char/word text animation: `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`, `char_blur_in` | | `glow` | `color`, `radius`, `intensity` | Luminous halo effect | | `wiggle` | `property`, `amplitude`, `frequency`, `mode`, `seed`, ... | Procedural noise animation | | `orbit` | `radius_x`, `radius_y`, `speed`, `depth`, `tilt`, ... | Elliptical orbital motion with pseudo-3D | | `keyframes` | `keyframes` | Custom keyframe animations | | `motion_blur` | `intensity` | Motion blur effect | +| `shimmer` | `delay`, `duration`, `color`, `width`, `intensity`, `angle`, `loop` | A band of light sweeping across the element's **own painted pixels** (composited `SrcATop` inside the node's layer, so on a `text` it catches the glyphs, not the box) | Components also support a `timeline` field (array of `{ "at": f64, "animation": [...] }` steps) for multi-phase sequential animations within a scene. @@ -1511,6 +1642,7 @@ Components also support a `timeline` field (array of `{ "at": f64, "animation": | `blur_in` | Fade in from blurred | | `rotate_in` | Rotate + scale from half size | | `elastic_in` | Elastic underdamped spring scale | +| `pop_in` | Back-out scale that *places* the element, then a short elastic pulse (`overshoot` sets its amplitude, default 0.18) | #### Exit Presets @@ -1677,7 +1809,7 @@ Creates continuous circular or elliptical orbital motion with pseudo-3D depth. A Animate each character or word independently with staggered timing. Use `char_*` animation presets inside `style.animation` on `text` components. -**Char animation presets:** `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` +**Char animation presets:** `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`, `char_blur_in` ```json { @@ -1698,6 +1830,14 @@ Animate each character or word independently with staggered timing. Use `char_*` | `easing` | `string` | `"linear"` | Easing function | | `granularity` | `string` | `"char"` | `"char"` (per-character) or `"word"` (per-word) | | `overshoot` | `f64` | `0.08` | Overshoot intensity for `char_scale_in`/`char_bounce` (0.0 = none) | +| `blur` | `f64` | `14.0` | Starting blur sigma, `char_blur_in` only | +| `direction` | `string` | `"up"` | Where each unit travels in from: `"up"`, `"down"`, `"left"`, `"right"`. Read by the translate-based presets (`char_slide_up`, `char_blur_in`); the others have no travel axis and ignore it | +| `distance` | `f64` | `1.0` | Multiplier on the travel distance (~0.5 tight, ~1.85 pronounced) | +| `scale_from` | `f64` | | Scale each unit starts at, growing to 1.0. Composes with the preset's own motion; ignored by `char_scale_in`/`char_bounce`, which own their scale curve | +| `jitter` + `seed` | `f64`, `u32` | `0`, `0` | Randomise each unit's start by ±`jitter × stagger`, so units arrive in uneven bursts rather than on a metronome. Derived from `seed` and the unit index — never from an RNG, so a frame renders identically in any process or `--frames` segment | +| `ink_from` | `string` | | Colour each unit starts at, converging to `style.color` over its animation | + +`char_slide_up` with `"direction": "down"` drops letters in from above — the name is historical (it is *the* translate preset, `up` is its default); there is no `char_slide_down`. **Per-word mode** (`"granularity": "word"`) splits text by whitespace and animates each word as a unit. Use larger stagger values (0.1–0.3s) for word reveals: diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index 7a7ad0f..707f741 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -383,7 +383,14 @@ fn bbox_of(layout: &BoxLayout) -> BBox { } fn is_exempted(c: &Component) -> bool { - matches!(c, Component::Marquee(_) | Component::Cursor(_)) + matches!( + c, + // A pointer joins `marquee`/`cursor` for the same reason: its + // waypoints are authored against the scene, so a demo that walks it + // to a control near the edge legitimately puts the arrow's tail + // past it. Its box is the glyph, not the content it points at. + Component::Marquee(_) | Component::Cursor(_) | Component::Pointer(_) + ) } /// Extends the exemption above with an *opt-in* declaration: a component @@ -1159,6 +1166,9 @@ fn component_kind(c: &Component) -> &'static str { Component::Gif(_) => "gif", Component::Counter(_) => "counter", Component::Cursor(_) => "cursor", + Component::Pointer(_) => "pointer", + Component::NumberWheel(_) => "number_wheel", + Component::SuccessCheck(_) => "success_check", Component::Caption(_) => "caption", Component::Codeblock(_) => "codeblock", Component::Avatar(_) => "avatar", diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index 17dd94d..518c4b2 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -794,6 +794,7 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { | AnimationEffect::BlurIn(t) | AnimationEffect::RotateIn(t) | AnimationEffect::ElasticIn(t) + | AnimationEffect::PopIn(t) | AnimationEffect::Pulse(t) | AnimationEffect::Float(t) | AnimationEffect::Shake(t) @@ -825,6 +826,17 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { | AnimationEffect::CharSlideUp(t) | AnimationEffect::CharBlurIn(t) => char_budget(t), + // A sweep of light is decoration over an element that is already + // there — it has a completion time like an entrance does, so it is + // budgeted the same way, but a looping one never completes. + AnimationEffect::Shimmer(s) => { + if s.repeat { + None + } else { + Some((s.delay, s.duration)) + } + } + // Custom keyframes AnimationEffect::Keyframes(k) => { if k.repeat { diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index e766e12..21d151f 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -326,6 +326,7 @@ fn build_ghosts<'a>( apply_animated_props(&mut css, &props); } apply_glow_effect(&mut css, &ghost_effects); + carry_paint_pass_effects(&mut css, &ghost_effects); } // Scale opacity: multiply the base opacity by the ghost opacity factor. let base_opacity = css.opacity.unwrap_or(1.0); @@ -527,9 +528,11 @@ fn build_child<'a>( } // Resolve animations and apply transform/opacity/filter overrides on the - // box's CSS. Only paint-time properties (transform, opacity, filter, - // perspective) flow into CSS — internal animations like draw_progress or - // char_animation remain on the `AnimatedProperties` legacy path. + // box's CSS. Paint-time properties (transform, opacity, filter, + // perspective) plus the box size (`width`/`height`, which taffy needs so a + // resize reflows its children instead of stretching pixels) flow into CSS + // — internal animations like draw_progress or char_animation remain on the + // `AnimatedProperties` legacy path. if let Some(actx) = local_actx { if let Some(effects) = effective_effects(&child.component, stagger_delay) { let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); @@ -537,6 +540,7 @@ fn build_child<'a>( apply_animated_props(&mut css, &props); } apply_glow_effect(&mut css, &effects); + carry_paint_pass_effects(&mut css, &effects); } } @@ -1039,6 +1043,40 @@ fn props_has_paint_overrides(p: &AnimatedProperties) -> bool { || p.blur > 0.0 || (p.glow_radius > 0.0 && p.glow_intensity > 0.0) || p.perspective > 0.0 + // An animated box size is a *layout* override rather than a paint one, + // but it travels through the same bridge, so the gate has to let it + // through or `apply_animated_props` never runs for a scenario whose + // only animated property is `width`/`height` (the card-resize case). + // -1.0 is the animator's "never animated" sentinel. + || p.width >= 0.0 + || p.height >= 0.0 +} + +/// Hand the effects that the *paint pass* resolves for itself down to the box +/// node, in their delay-shifted form. +/// +/// Everything else on this path is resolved here and lands on `css` as a +/// finished value. `shimmer` cannot be: it composites against the pixels the +/// node paints, which do not exist until the paint pass has run. So the paint +/// pass reads it off `css.animation` — and it has to read the *shifted* copy +/// (container stagger, `timeline` step `at`) rather than the author's raw +/// `style.animation`, or a shimmer inside a staggered list would sweep in step +/// with the list's first item instead of its own. +fn carry_paint_pass_effects( + css: &mut CssStyle, + effects: &[rustmotion_core::schema::AnimationEffect], +) { + use rustmotion_core::schema::AnimationEffect; + if effects + .iter() + .any(|e| matches!(e, AnimationEffect::Shimmer(_))) + { + css.animation = effects + .iter() + .filter(|e| matches!(e, AnimationEffect::Shimmer(_))) + .cloned() + .collect(); + } } /// M3: apply the static `glow` animation effect (a coloured halo — not @@ -1109,6 +1147,9 @@ fn component_intrinsic( Counter(c) => Some(Arc::new(crate::intrinsic::CounterIntrinsic::from_counter( c, ))), + NumberWheel(w) => Some(Arc::new( + crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w), + )), Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))), Terminal(t) => Some(Arc::new( crate::intrinsic::TerminalIntrinsic::from_terminal(t), @@ -1398,6 +1439,24 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { css.height = Some(CSize::Length(CLP::Px(cur.height))); } } + SuccessCheck(c) => { + // The halo is the box: the entrance scales *within* it (0.72→1), + // so the mark never needs more room than its own diameter. + apply_default_size(css, c.size, c.size); + } + Pointer(p) => { + // The box is the arrow glyph, not the area it travels over: the + // waypoints translate the glyph away from this box, and the + // geometry checker exempts `pointer` for exactly that reason. + // Sizing the box to the travel instead would make the pointer + // shove its flex siblings around. + if css.width.is_none() { + css.width = Some(CSize::Length(CLP::Px(p.size * 0.6))); + } + if css.height.is_none() { + css.height = Some(CSize::Length(CLP::Px(p.size))); + } + } Particle(_) => { // Particles fill their parent (legacy returned the max constraints). if css.width.is_none() { @@ -2000,6 +2059,9 @@ fn component_style(c: &Component) -> &CssStyle { PillNav(c) => &c.style, Progress(c) => &c.style, QrCode(c) => &c.style, + NumberWheel(c) => &c.style, + SuccessCheck(c) => &c.style, + Pointer(c) => &c.style, Rating(c) => &c.style, Skeleton(c) => &c.style, Slider(c) => &c.style, @@ -2064,6 +2126,9 @@ pub fn component_kind(c: &Component) -> &'static str { PillNav(_) => "pill_nav", Progress(_) => "progress", QrCode(_) => "qrcode", + NumberWheel(_) => "number_wheel", + SuccessCheck(_) => "success_check", + Pointer(_) => "pointer", Rating(_) => "rating", Skeleton(_) => "skeleton", Slider(_) => "slider", @@ -2148,6 +2213,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }), position: None, x: None, @@ -2326,6 +2394,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }), position: None, x: None, diff --git a/crates/rustmotion-components/src/cursor.rs b/crates/rustmotion-components/src/cursor.rs index 49681c4..0c3f64c 100644 --- a/crates/rustmotion-components/src/cursor.rs +++ b/crates/rustmotion-components/src/cursor.rs @@ -129,15 +129,32 @@ impl Cursor { /// Compute cursor position offset from auto_path at the given time. /// Returns (dx, dy) translation to apply. fn auto_path_offset(&self, time: f64) -> (f32, f32) { - if self.auto_path.len() < 2 { - if let Some(wp) = self.auto_path.first() { + waypoint_offset(&self.auto_path, time, self.click_duration, self.path_easing) + } +} + +/// Where a waypoint path sits at `time`: `(dx, dy)` from the component's own +/// origin. +/// +/// Shared by `cursor` (a text caret) and `pointer` (a mouse pointer). They +/// draw entirely different glyphs, but "hold at the first waypoint, glide to +/// each next one on a Catmull-Rom curve, pause for the click before moving +/// on" is the same walkthrough choreography in both, and it is worth having +/// exactly one implementation of it. +pub(crate) fn waypoint_offset( + waypoints: &[CursorWaypoint], + time: f64, + click_duration: f32, + path_easing: CursorPathEasing, +) -> (f32, f32) { + { + if waypoints.len() < 2 { + if let Some(wp) = waypoints.first() { return (wp.x, wp.y); } return (0.0, 0.0); } - let waypoints = &self.auto_path; - // Before first waypoint: stay at first position if time <= waypoints[0].time { return (waypoints[0].x, waypoints[0].y); @@ -166,7 +183,7 @@ impl Cursor { } // Account for click pause: don't start moving until click animation finishes - let click_end = wp0.time + self.click_duration as f64; + let click_end = wp0.time + click_duration as f64; let move_start = if seg_idx > 0 { click_end } else { wp0.time }; let move_duration = wp1.time - move_start; @@ -177,7 +194,7 @@ impl Cursor { let raw_t = ((time - move_start) / move_duration).clamp(0.0, 1.0); // Apply easing - let t = match self.path_easing { + let t = match path_easing { // Hold the departure point for the whole segment; the jump happens // when `time` reaches the next waypoint and the segment changes. CursorPathEasing::Step => 0.0, diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 5ca2571..3375e19 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -126,7 +126,16 @@ impl TextIntrinsic { text.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) ); - Self::from_parts_with_wrap(&text.content, &text.style, text.max_width, wrap) + // Measure the *longest* label the text can ever show, not just the + // first: a box sized for "Saved" would be overrun the moment a + // `states` entry swapped in "Saving draft…", and the geometry + // validator — which measures through here — would have signed off on + // the overflow. + let widest = text + .all_labels() + .max_by_key(|label| label.chars().count()) + .unwrap_or(&text.content); + Self::from_parts_with_wrap(widest, &text.style, text.max_width, wrap) .with_autofit(matches!(text.style.text_autofit, Some(true))) } @@ -641,6 +650,57 @@ impl IntrinsicMeasure for CounterIntrinsic { } } +/// Intrinsic measurer for [`crate::number_wheel::NumberWheel`]. +/// +/// Every digit column is as wide as the *widest* digit, because that is how +/// the painter lays the reels out — otherwise a figure that lands on `111` +/// would reserve a narrow box and then overflow it while a `0` rolls past. +/// Measuring the value once per possible digit and keeping the largest gives +/// exactly the painter's own `max over digits` per column, separators +/// included, without duplicating its layout arithmetic here. +pub struct NumberWheelIntrinsic(TextIntrinsic); + +impl NumberWheelIntrinsic { + pub fn from_number_wheel(w: &crate::number_wheel::NumberWheel) -> Self { + let widest = (0..10) + .map(|d| { + let ch = char::from_digit(d, 10).expect("0..10 is a digit"); + w.value + .chars() + .map(|c| if c.is_ascii_digit() { ch } else { c }) + .collect::() + }) + .max_by(|a, b| { + let measure = |s: &str| { + TextIntrinsic::from_parts_with_wrap(s, &w.style, None, false) + .measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ) + .0 + }; + measure(a) + .partial_cmp(&measure(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or_else(|| w.value.clone()); + // A wheel is atomic: it never wraps. + Self(TextIntrinsic::from_parts_with_wrap( + &widest, &w.style, None, false, + )) + } +} + +impl IntrinsicMeasure for NumberWheelIntrinsic { + fn measure( + &self, + known: (Option, Option), + available: (AvailableSpace, AvailableSpace), + ) -> (f32, f32) { + self.0.measure(known, available) + } +} + /// Intrinsic measurer for [`Badge`] — measures the label text plus icon, gap, /// and the size-derived horizontal/vertical padding. pub struct BadgeIntrinsic { @@ -1070,6 +1130,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }; let m = TextIntrinsic::from_text(&text); let (w, h) = m.measure( @@ -1099,6 +1162,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }; let m = TextIntrinsic::from_text(&text); let (_w_unwrapped, h_unwrapped) = m.measure( @@ -1132,6 +1198,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }; let m = TextIntrinsic::from_text(&text); let (w, h) = m.measure( @@ -1159,6 +1228,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, } } @@ -1439,6 +1511,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, } } @@ -1754,6 +1829,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, } } diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 185a9ca..f889ac5 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -35,8 +35,10 @@ pub mod lottie; pub mod marquee; pub mod mockup; pub mod notification; +pub mod number_wheel; pub mod particle; pub mod pill_nav; +pub mod pointer; pub mod positioned; pub mod progress; pub mod qrcode; @@ -48,6 +50,7 @@ pub mod slider; pub mod sparkline; pub mod stat; pub mod stepper; +pub mod success_check; pub mod svg; pub mod switch; pub mod table; @@ -99,8 +102,10 @@ pub use lottie::Lottie; pub use marquee::Marquee; pub use mockup::Mockup; pub use notification::Notification; +pub use number_wheel::NumberWheel; pub use particle::Particle; pub use pill_nav::PillNav; +pub use pointer::Pointer; pub use positioned::Positioned; pub use progress::Progress; pub use qrcode::QrCode; @@ -112,6 +117,7 @@ pub use slider::Slider; pub use sparkline::Sparkline; pub use stat::Stat; pub use stepper::Stepper; +pub use success_check::SuccessCheck; pub use svg::Svg; pub use switch::Switch; pub use table::Table; @@ -385,6 +391,9 @@ pub enum Component { #[serde(alias = "progress_bar")] Progress(Progress), QrCode(QrCode), + NumberWheel(NumberWheel), + SuccessCheck(SuccessCheck), + Pointer(Pointer), Rating(Rating), Skeleton(Skeleton), Slider(Slider), @@ -451,6 +460,9 @@ impl Component { Component::PillNav(c) => Some(c), Component::Progress(c) => Some(c), Component::QrCode(c) => Some(c), + Component::NumberWheel(c) => Some(c), + Component::SuccessCheck(c) => Some(c), + Component::Pointer(c) => Some(c), Component::Rating(c) => Some(c), Component::Skeleton(c) => Some(c), Component::Slider(c) => Some(c), @@ -512,6 +524,9 @@ impl Component { Component::PillNav(c) => Some(c), Component::Progress(c) => Some(c), Component::QrCode(c) => Some(c), + Component::NumberWheel(c) => Some(c), + Component::SuccessCheck(c) => Some(c), + Component::Pointer(c) => Some(c), Component::Rating(c) => Some(c), Component::Skeleton(c) => Some(c), Component::Slider(c) => Some(c), @@ -575,6 +590,9 @@ impl Component { Component::PillNav(c) => c, Component::Progress(c) => c, Component::QrCode(c) => c, + Component::NumberWheel(c) => c, + Component::SuccessCheck(c) => c, + Component::Pointer(c) => c, Component::Rating(c) => c, Component::Skeleton(c) => c, Component::Slider(c) => c, @@ -631,6 +649,9 @@ impl Component { Component::RichText(c) => Some(c), Component::Switch(c) => Some(c), Component::Slider(c) => Some(c), + Component::NumberWheel(c) => Some(c), + Component::SuccessCheck(c) => Some(c), + Component::Pointer(c) => Some(c), Component::Rating(c) => Some(c), Component::Stepper(c) => Some(c), Component::Comparison(c) => Some(c), diff --git a/crates/rustmotion-components/src/number_wheel.rs b/crates/rustmotion-components/src/number_wheel.rs new file mode 100644 index 0000000..ded5fbb --- /dev/null +++ b/crates/rustmotion-components/src/number_wheel.rs @@ -0,0 +1,357 @@ +//! A rolling-digit counter: each digit column spins through 0-9 like a +//! mechanical odometer reel before landing on its target. +//! +//! Distinct from [`crate::counter::Counter`], which interpolates a *value* +//! and re-renders the number each frame — its digits change by arithmetic, +//! and the glyphs jump. Here the digits are physically on a strip that +//! travels: what lands is the number you asked for, and what you watch is the +//! travel. Reels land left-to-right, which is what makes the final digit read +//! as the one that settles the figure. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use skia_safe::{Canvas, ClipOp, Font, FontStyle, Rect}; + +use rustmotion_core::css::style::{ + FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, +}; +use rustmotion_core::css::CssStyle; +use rustmotion_core::engine::animator::{ease, AnimatedProperties}; +use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::{ + draw_text_with_fallback, measure_text_with_fallback, paint_from_hex, typeface_with_fallback, +}; +use rustmotion_core::schema::{EasingType, FontStyleType, FontWeight, TimelineStep}; +use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; + +/// How many full 0-9 revolutions a reel makes before landing. +/// +/// The reel covers the same *time* whichever this is, so a higher setting is +/// a faster spin, not a longer one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WheelSpin { + #[default] + Single, + Double, + Triple, +} + +impl WheelSpin { + fn revolutions(self) -> f32 { + match self { + Self::Single => 1.0, + Self::Double => 2.0, + Self::Triple => 3.0, + } + } +} + +fn default_wheel_duration() -> f64 { + 1.2 +} + +fn default_wheel_stagger() -> f64 { + 0.08 +} + +fn default_wheel_easing() -> EasingType { + EasingType::EaseOutCubic +} + +/// An odometer-style number where each digit rolls into place. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct NumberWheel { + /// The figure to land on, as written — `"30,222"`, `"5.7"`, `"98%"`. + /// Digits roll; every other character (separators, signs, units) is + /// painted where it stands. + pub value: String, + /// How far each reel travels before landing. + #[serde(default)] + pub spin: WheelSpin, + /// How long one reel takes to land (seconds). + #[serde(default = "default_wheel_duration")] + pub duration: f64, + /// Delay before the first reel starts (seconds). + #[serde(default)] + pub delay: f64, + /// Extra delay per digit column, left to right (seconds). `0` lands + /// every reel at once, which reads as a single flip rather than as a + /// counter settling. + #[serde(default = "default_wheel_stagger")] + pub stagger_per_column: f64, + /// Easing of a reel's travel. The default decelerates into the landing, + /// which is what makes it read as mechanical rather than as a fade. + #[serde(default = "default_wheel_easing")] + pub easing: EasingType, + #[serde(flatten)] + pub timing: TimingConfig, + #[serde(default)] + pub style: CssStyle, + #[serde(default)] + pub timeline: Vec, + #[serde(default)] + pub stagger: Option, +} + +rustmotion_core::impl_traits!(NumberWheel { + Animatable => animation, + Timed => timing, + Styled => style, +}); + +/// One character of the figure: a rolling reel, or a fixed glyph. +pub(crate) enum Cell { + /// A digit reel landing on this value. + Digit(u32), + /// A separator, sign or unit, painted as-is. + Fixed(char), +} + +impl NumberWheel { + pub(crate) fn cells(value: &str) -> Vec { + value + .chars() + .map(|c| match c.to_digit(10) { + Some(d) => Cell::Digit(d), + None => Cell::Fixed(c), + }) + .collect() + } + + /// How far reel `column` has travelled at `time`, in cells. + /// + /// Cell 0 is the digit `0`; the reel counts upward through 0-9, wrapping, + /// and stops exactly on `revolutions * 10 + target` so that the landing + /// is on the requested digit rather than near it. + pub(crate) fn reel_position(&self, column: usize, target: u32, time: f64) -> f32 { + let start = self.delay + column as f64 * self.stagger_per_column; + let raw = if self.duration <= 0.0 { + 1.0 + } else { + ((time - start) / self.duration).clamp(0.0, 1.0) + }; + let p = ease(raw, &self.easing) as f32; + let travel = self.spin.revolutions() * 10.0 + target as f32; + travel * p + } + + /// The digit-column width: the widest digit's advance, so the reels line + /// up in a column instead of jittering as a 1 rolls past an 8. + fn digit_advance(font: &Font, emoji: &Option, letter_spacing: f32) -> f32 { + (0..10) + .map(|d| measure_text_with_fallback(&d.to_string(), font, emoji, letter_spacing)) + .fold(0.0f32, f32::max) + } + + pub(crate) fn build_font(&self, font_size: f32) -> Option { + let font_family = self.style.font_family_or("Inter"); + let weight = match &self.style.font_weight { + Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => { + FontWeight::Bold + } + Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold, + Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n), + _ => FontWeight::Normal, + }; + let slant = match self.style.font_style { + Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic, + Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique, + _ => skia_safe::font_style::Slant::Upright, + }; + let weight = match weight { + FontWeight::Bold => skia_safe::font_style::Weight::BOLD, + FontWeight::Normal => skia_safe::font_style::Weight::NORMAL, + FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32), + }; + let _ = FontStyleType::Normal; // keep the schema import meaningful + let typeface = typeface_with_fallback( + font_family, + FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant), + ) + .ok()?; + Some(Font::from_typeface(typeface, font_size)) + } +} + +impl Painter for NumberWheel { + fn paint_content( + &self, + canvas: &Canvas, + layout: &BoxLayout, + props: &AnimatedProperties, + ctx: &PaintCtx, + ) { + let base_ctx = crate::intrinsic::font_size_ctx( + ctx.video_width as f32, + ctx.video_height as f32, + layout.width.max(0.0), + ); + let font_size = self.style.font_size_px_ctx(&base_ctx, 72.0); + let Some(font) = self.build_font(font_size) else { + return; + }; + let emoji_font = rustmotion_core::engine::renderer::emoji_typeface() + .map(|tf| Font::from_typeface(tf, font_size)); + let own_ctx = rustmotion_core::css::units::LengthContext { + font_size, + ..base_ctx + }; + let letter_spacing = self.style.letter_spacing_px_ctx(&own_ctx); + + let color = props + .color + .as_deref() + .unwrap_or_else(|| self.style.color_str_or("#FFFFFF")); + let paint = paint_from_hex(color); + + let (_, metrics) = font.metrics(); + let ascent = -metrics.ascent; + let descent = metrics.descent; + // One cell is a full line box: the strip advances by exactly this, so + // consecutive digits never overlap inside the clip. + let cell_h = ascent + descent; + let baseline = ascent; + + let digit_w = Self::digit_advance(&font, &emoji_font, letter_spacing); + let cells = Self::cells(&self.value); + + let mut x = 0.0f32; + let mut column = 0usize; + for cell in &cells { + match cell { + Cell::Fixed(c) => { + let s = c.to_string(); + let w = measure_text_with_fallback(&s, &font, &emoji_font, letter_spacing); + draw_text_with_fallback( + canvas, + &s, + &font, + &emoji_font, + letter_spacing, + x, + baseline, + &paint, + ); + x += w; + } + Cell::Digit(target) => { + let pos = self.reel_position(column, *target, ctx.time); + let whole = pos.floor(); + let frac = pos - whole; + + canvas.save(); + // The clip is the window in the odometer's housing: it is + // what turns a long strip of digits into one visible one. + canvas.clip_rect( + Rect::from_xywh(x, 0.0, digit_w, cell_h), + ClipOp::Intersect, + false, + ); + + // Outgoing digit sliding up and out, incoming one rising + // into its place from below. + for (step, offset) in [(0.0f32, -frac * cell_h), (1.0, (1.0 - frac) * cell_h)] { + let digit = ((whole + step) as i64).rem_euclid(10); + let s = digit.to_string(); + let w = measure_text_with_fallback(&s, &font, &emoji_font, letter_spacing); + draw_text_with_fallback( + canvas, + &s, + &font, + &emoji_font, + letter_spacing, + // Digits are centred in the column so a 1 does not + // sit off to one side of the reel it shares with an 8. + x + (digit_w - w) / 2.0, + baseline + offset, + &paint, + ); + } + canvas.restore(); + + x += digit_w; + column += 1; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wheel(json: serde_json::Value) -> NumberWheel { + serde_json::from_value(json).expect("number_wheel fixture") + } + + #[test] + fn only_digits_become_reels() { + let cells = NumberWheel::cells("1,204.5%"); + let digits = cells.iter().filter(|c| matches!(c, Cell::Digit(_))).count(); + assert_eq!( + digits, 5, + "1 2 0 4 5 roll; the comma, dot and percent do not" + ); + } + + #[test] + fn a_reel_lands_exactly_on_its_target_digit() { + // Landing "near" the digit is the failure mode worth pinning: the + // whole point of a reel over a fade is that it stops on the figure. + let w = + wheel(serde_json::json!({ "value": "7", "duration": 1.0, "stagger_per_column": 0.0 })); + let landed = w.reel_position(0, 7, 5.0); + assert!( + (landed % 10.0 - 7.0).abs() < 1e-4, + "the reel should rest on 7, got cell {landed}" + ); + } + + #[test] + fn a_reel_starts_on_zero_before_it_moves() { + let w = wheel(serde_json::json!({ "value": "42", "delay": 0.5 })); + assert_eq!( + w.reel_position(0, 4, 0.0), + 0.0, + "before its delay a reel shows 0, it does not preview the answer" + ); + } + + #[test] + fn spin_changes_the_distance_not_the_landing() { + let single = wheel(serde_json::json!({ "value": "3", "spin": "single" })); + let triple = wheel(serde_json::json!({ "value": "3", "spin": "triple" })); + + // Mid-travel the triple is further along... + assert!( + triple.reel_position(0, 3, 0.4) > single.reel_position(0, 3, 0.4), + "a triple spin covers more ground in the same time" + ); + // ...but both come to rest on the same digit. + assert!( + (single.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4 + && (triple.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4, + "both must land on 3" + ); + } + + #[test] + fn columns_land_left_to_right() { + let w = wheel(serde_json::json!({ + "value": "99", "duration": 0.5, "stagger_per_column": 0.25 + })); + // At the moment the first reel has landed, the second is still moving. + let first = w.reel_position(0, 9, 0.5); + let second = w.reel_position(1, 9, 0.5); + assert!( + (first % 10.0 - 9.0).abs() < 1e-4, + "the leftmost reel should have landed by t=0.5" + ); + assert!( + second < first, + "the next reel should still be travelling (first={first}, second={second})" + ); + } +} diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs new file mode 100644 index 0000000..3ad96c9 --- /dev/null +++ b/crates/rustmotion-components/src/pointer.rs @@ -0,0 +1,343 @@ +//! A simulated mouse pointer — the arrow, plus the ring that pulses out of +//! its tip when it clicks. +//! +//! Not to be confused with [`crate::cursor::Cursor`], which is a *text* caret +//! (a blinking bar). Its `cursor_style: "pointer"` field has never drawn +//! anything but that bar. Product walkthroughs and agent demos need the other +//! thing: an arrow that travels to a control and visibly clicks it. +//! +//! Waypoint choreography — hold, glide, pause on the click — is shared with +//! `cursor` via [`crate::cursor::waypoint_offset`], so the two stay in step. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use skia_safe::{Canvas, Paint, PaintStyle, Path}; + +use rustmotion_core::css::CssStyle; +use rustmotion_core::engine::animator::AnimatedProperties; +use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color}; +use rustmotion_core::schema::TimelineStep; +use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; + +use crate::cursor::{waypoint_offset, CursorPathEasing, CursorWaypoint}; + +/// Colour scheme of the pointer, so a scene picks one word instead of two +/// hex values that have to stay in contrast with each other. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PointerTone { + /// White arrow, dark outline — for dark frames. + #[default] + Light, + /// Dark arrow, light outline — for light frames. + Dark, +} + +/// How loud the click ring is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ClickRing { + /// A thin ring that stays close to the tip. + Subtle, + #[default] + Standard, + /// A thick ring travelling well past the tip. + Bold, + /// No ring — the arrow still nudges, nothing expands. + None, +} + +impl ClickRing { + /// `(stroke width, travel)` as multiples of the pointer's `size`. + fn metrics(self) -> Option<(f32, f32)> { + match self { + Self::Subtle => Some((0.05, 0.55)), + Self::Standard => Some((0.09, 0.85)), + Self::Bold => Some((0.16, 1.25)), + Self::None => None, + } + } +} + +fn default_pointer_size() -> f32 { + 44.0 +} + +fn default_pointer_click_duration() -> f32 { + 0.45 +} + +/// A mouse pointer that travels a waypoint path and clicks along the way. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct Pointer { + /// Height of the arrow in px. The click ring scales with it. + #[serde(default = "default_pointer_size")] + pub size: f32, + /// Colour scheme. Overridden by `color` / `outline_color` when set. + #[serde(default)] + pub tone: PointerTone, + /// Arrow fill (hex), overriding `tone`. + #[serde(default)] + pub color: Option, + /// Arrow outline (hex), overriding `tone`. + #[serde(default)] + pub outline_color: Option, + /// Click ring size. `none` removes it. + #[serde(default)] + pub click_ring: ClickRing, + /// Ring colour (hex). Defaults to the arrow's fill. + #[serde(default)] + pub ring_color: Option, + /// Waypoints the pointer travels between, in scene-local seconds. Each + /// `x`/`y` is relative to the component's own origin — place the + /// component with `position: absolute` and read the waypoints as scene + /// coordinates. The pointer clicks on arrival at each one. + #[serde(default)] + pub path: Vec, + /// Extra click times (seconds), for a pointer that clicks without + /// travelling. Ignored when `path` is set — the waypoints carry their + /// own clicks. + #[serde(default)] + pub click_at: Vec, + /// How long one click animation runs (seconds). Also how long the + /// pointer pauses on a waypoint before setting off for the next. + #[serde(default = "default_pointer_click_duration")] + pub click_duration: f32, + /// Easing between waypoints. + #[serde(default)] + pub path_easing: CursorPathEasing, + #[serde(flatten)] + pub timing: TimingConfig, + #[serde(default)] + pub style: CssStyle, + #[serde(default)] + pub timeline: Vec, + #[serde(default)] + pub stagger: Option, +} + +rustmotion_core::impl_traits!(Pointer { + Animatable => animation, + Timed => timing, + Styled => style, +}); + +impl Pointer { + fn click_times(&self) -> Vec { + if self.path.is_empty() { + self.click_at.clone() + } else { + self.path.iter().map(|w| w.time).collect() + } + } + + /// The click running at `time`, as progress 0..1, if any. + fn click_progress(&self, time: f64) -> Option { + if self.click_duration <= 0.0 { + return None; + } + self.click_times() + .into_iter() + // The *last* qualifying click, so overlapping clicks resolve to + // the most recent rather than to whichever happens to be first. + .rfind(|&t| time >= t && time < t + self.click_duration as f64) + .map(|t| ((time - t) / self.click_duration as f64) as f32) + } + + fn colors(&self) -> (String, String) { + let (fill, outline) = match self.tone { + PointerTone::Light => ("#FFFFFF", "#111827"), + PointerTone::Dark => ("#111827", "#FFFFFF"), + }; + ( + self.color.clone().unwrap_or_else(|| fill.to_string()), + self.outline_color + .clone() + .unwrap_or_else(|| outline.to_string()), + ) + } + + /// The classic arrow, drawn tip-first at the origin and scaled to + /// `size`. Coordinates are in units of the pointer's height, so the + /// glyph keeps its proportions at any size. + fn arrow_path(size: f32) -> Path { + const OUTLINE: [(f32, f32); 7] = [ + (0.0, 0.0), // tip + (0.0, 0.72), // down the left edge + (0.19, 0.56), // into the notch + (0.30, 0.84), // out along the tail + (0.43, 0.78), // tail's far side + (0.32, 0.51), // back up + (0.54, 0.51), // the right shoulder + ]; + let mut path = Path::new(); + for (i, (x, y)) in OUTLINE.iter().enumerate() { + let p = (x * size, y * size); + if i == 0 { + path.move_to(p); + } else { + path.line_to(p); + } + } + path.close(); + path + } +} + +impl Painter for Pointer { + fn paint_content( + &self, + canvas: &Canvas, + _layout: &BoxLayout, + _props: &AnimatedProperties, + ctx: &PaintCtx, + ) { + let (dx, dy) = if self.path.is_empty() { + (0.0, 0.0) + } else { + waypoint_offset(&self.path, ctx.time, self.click_duration, self.path_easing) + }; + let click = self.click_progress(ctx.time); + let (fill, outline) = self.colors(); + + canvas.save(); + canvas.translate((dx, dy)); + + // The ring expands out of the tip and fades as it goes, so the eye + // reads the click as happening *at* the tip rather than around the + // whole pointer. Painted under the arrow so it never veils it. + if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) { + let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(&fill)); + let alpha = ((1.0 - p) * 200.0) as u8; + if alpha > 0 { + let mut ring = Paint::default(); + ring.set_style(PaintStyle::Stroke); + ring.set_anti_alias(true); + ring.set_stroke_width(stroke_f * self.size); + ring.set_color(skia_safe::Color::from_argb(alpha, r, g, b)); + canvas.draw_circle((0.0, 0.0), p * travel_f * self.size, &ring); + } + } + + // A small dip on the press, released as the click finishes — the + // arrow's own acknowledgement, independent of the ring (which + // `click_ring: "none"` can switch off). + if let Some(p) = click { + let scale = if p < 0.35 { + 1.0 - 0.12 * (p / 0.35) + } else { + 0.88 + 0.12 * ((p - 0.35) / 0.65) + }; + canvas.scale((scale, scale)); + } + + let path = Self::arrow_path(self.size); + let mut outline_paint = paint_from_hex(&outline); + outline_paint.set_style(PaintStyle::Stroke); + outline_paint.set_stroke_width((self.size * 0.07).max(1.0)); + outline_paint.set_stroke_join(skia_safe::PaintJoin::Round); + outline_paint.set_anti_alias(true); + + let mut fill_paint = paint_from_hex(&fill); + fill_paint.set_style(PaintStyle::Fill); + fill_paint.set_anti_alias(true); + + canvas.draw_path(&path, &fill_paint); + canvas.draw_path(&path, &outline_paint); + + canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pointer(json: serde_json::Value) -> Pointer { + serde_json::from_value(json).expect("pointer fixture") + } + + #[test] + fn a_pointer_with_no_path_sits_at_its_own_origin() { + let p = pointer(serde_json::json!({})); + assert!(p.path.is_empty()); + assert_eq!(p.click_progress(0.0), None, "no clicks were asked for"); + } + + #[test] + fn clicks_come_from_the_waypoints_when_a_path_is_given() { + // `click_at` is for a stationary pointer; a travelling one clicks on + // arrival, so listing both must not produce two overlapping sets. + let p = pointer(serde_json::json!({ + "click_at": [9.0], + "path": [ + { "time": 0.0, "x": 0.0, "y": 0.0 }, + { "time": 1.0, "x": 200.0, "y": 100.0 } + ] + })); + assert_eq!(p.click_times(), vec![0.0, 1.0]); + assert!( + p.click_progress(9.1).is_none(), + "a `click_at` entry must be ignored once the pointer has a path" + ); + } + + #[test] + fn a_click_runs_for_exactly_its_duration() { + let p = pointer(serde_json::json!({ + "click_at": [1.0], + "click_duration": 0.5 + })); + assert_eq!(p.click_progress(0.9), None, "before the click"); + assert_eq!(p.click_progress(1.0), Some(0.0), "at the click"); + assert!( + matches!(p.click_progress(1.25), Some(t) if (t - 0.5).abs() < 1e-5), + "halfway through" + ); + assert_eq!(p.click_progress(1.5), None, "the instant it ends"); + } + + #[test] + fn overlapping_clicks_resolve_to_the_most_recent() { + // Two clicks closer together than `click_duration`: the second must + // restart the animation, not be swallowed by the first still running. + let p = pointer(serde_json::json!({ + "click_at": [1.0, 1.2], + "click_duration": 0.5 + })); + let at = p.click_progress(1.3).expect("a click is running at 1.3"); + assert!( + (at - 0.2).abs() < 1e-5, + "expected 0.1s into the second click (0.2 of its duration), got {at}" + ); + } + + #[test] + fn the_pointer_holds_its_first_waypoint_before_the_path_starts() { + let p = pointer(serde_json::json!({ + "path": [ + { "time": 1.0, "x": 100.0, "y": 50.0 }, + { "time": 2.0, "x": 400.0, "y": 50.0 } + ] + })); + assert_eq!( + waypoint_offset(&p.path, 0.0, p.click_duration, p.path_easing), + (100.0, 50.0), + "before the first waypoint's time the pointer waits there, it does not fly in" + ); + } + + #[test] + fn none_removes_the_ring_without_removing_the_click() { + let p = pointer(serde_json::json!({ + "click_at": [1.0], + "click_ring": "none" + })); + assert!(p.click_ring.metrics().is_none(), "no ring to draw"); + assert!( + p.click_progress(1.1).is_some(), + "the click itself still runs — the arrow still dips" + ); + } +} diff --git a/crates/rustmotion-components/src/success_check.rs b/crates/rustmotion-components/src/success_check.rs new file mode 100644 index 0000000..255792e --- /dev/null +++ b/crates/rustmotion-components/src/success_check.rs @@ -0,0 +1,266 @@ +//! The confirmation mark: a checkmark drawing itself inside a pale halo, +//! arriving with a small pop and a rotation it resolves as it lands. +//! +//! Assembling this out of a `shape` circle, an `svg` with `draw_in` and a +//! `scale_in` is possible, and was the only way before this existed — but the +//! three have to be kept in time with each other by hand, and the mark is the +//! single most repeated beat in a product video. One component, one timeline. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use skia_safe::{Canvas, PaintStyle, Path}; + +use rustmotion_core::css::CssStyle; +use rustmotion_core::engine::animator::{ease, AnimatedProperties}; +use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color}; +use rustmotion_core::schema::{EasingType, TimelineStep}; +use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; + +fn default_check_size() -> f32 { + 82.0 +} + +fn default_check_tint() -> String { + "#22C55E".to_string() +} + +fn default_check_ring() -> f32 { + 0.28 +} + +fn default_check_spin() -> f32 { + 1.0 +} + +fn default_check_duration() -> f64 { + 0.7 +} + +/// A checkmark that draws itself inside a halo. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SuccessCheck { + /// Diameter of the halo in px. The stroke scales with it. + #[serde(default = "default_check_size")] + pub size: f32, + /// Colour of the mark (hex). + #[serde(default = "default_check_tint")] + pub tint: String, + /// Halo opacity, 0..1. The halo takes `tint` unless `ring_color` says + /// otherwise, so the default reads as the mark's own colour behind it. + #[serde(default = "default_check_ring")] + pub ring: f32, + /// Halo colour (hex), overriding `tint`. + #[serde(default)] + pub ring_color: Option, + /// Multiplier on the entrance rotation. `0` lands the mark square with + /// no swing; `2` gives it a pronounced one. The mark always finishes + /// upright whatever this is. + #[serde(default = "default_check_spin")] + pub spin: f32, + /// Stroke width of the mark in px. Defaults to 9% of `size`. + #[serde(default)] + pub stroke_width: Option, + /// Delay before the mark starts arriving (seconds). + #[serde(default)] + pub delay: f64, + /// How long the arrival takes (seconds). + #[serde(default = "default_check_duration")] + pub duration: f64, + #[serde(flatten)] + pub timing: TimingConfig, + #[serde(default)] + pub style: CssStyle, + #[serde(default)] + pub timeline: Vec, + #[serde(default)] + pub stagger: Option, +} + +rustmotion_core::impl_traits!(SuccessCheck { + Animatable => animation, + Timed => timing, + Styled => style, +}); + +/// Where the mark is in its arrival at a given instant. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct CheckPhase { + /// Eased 0..1 over the whole arrival — drives scale, rotation, opacity. + pub arrival: f32, + /// Eased 0..1 over the stroke's own, later window. + pub stroke: f32, +} + +impl SuccessCheck { + /// The stroke starts once the halo has mostly landed: a mark that draws + /// itself while still flying in reads as two unrelated animations. + const STROKE_START: f64 = 0.35; + + pub(crate) fn phase_at(&self, time: f64) -> CheckPhase { + if self.duration <= 0.0 { + return CheckPhase { + arrival: 1.0, + stroke: 1.0, + }; + } + let raw = ((time - self.delay) / self.duration).clamp(0.0, 1.0); + if raw <= 0.0 { + // `ease_out_back(0)` is 0 only up to float residue (~2e-16), and + // "has it started?" is a question this type should answer + // exactly rather than approximately. + return CheckPhase { + arrival: 0.0, + stroke: 0.0, + }; + } + let stroke_raw = ((raw - Self::STROKE_START) / (1.0 - Self::STROKE_START)).clamp(0.0, 1.0); + CheckPhase { + arrival: ease(raw, &EasingType::EaseOutBack) as f32, + stroke: ease(stroke_raw, &EasingType::EaseOutQuad) as f32, + } + } + + /// The checkmark itself, in units of `size`. + pub(crate) fn check_path(size: f32) -> Path { + let mut path = Path::new(); + path.move_to((0.28 * size, 0.52 * size)); + path.line_to((0.44 * size, 0.69 * size)); + path.line_to((0.73 * size, 0.33 * size)); + path + } +} + +impl Painter for SuccessCheck { + fn paint_content( + &self, + canvas: &Canvas, + _layout: &BoxLayout, + _props: &AnimatedProperties, + ctx: &PaintCtx, + ) { + let phase = self.phase_at(ctx.time); + if phase.arrival <= 0.0 { + return; + } + let size = self.size; + let centre = size / 2.0; + + // Scale up from 72% and unwind the entrance rotation. `ease_out_back` + // already overshoots past 1, so the mark settles by springing back + // rather than by decelerating into place. + let scale = 0.72 + 0.28 * phase.arrival; + let angle = -18.0 * self.spin * (1.0 - phase.arrival); + + canvas.save(); + canvas.translate((centre, centre)); + canvas.scale((scale, scale)); + canvas.rotate(angle, None); + canvas.translate((-centre, -centre)); + + // Halo + if self.ring > 0.0 { + let hex = self.ring_color.as_deref().unwrap_or(&self.tint); + let (r, g, b, _) = parse_hex_color(hex); + let alpha = (self.ring.clamp(0.0, 1.0) * phase.arrival.clamp(0.0, 1.0) * 255.0) as u8; + let mut halo = skia_safe::Paint::default(); + halo.set_style(PaintStyle::Fill); + halo.set_anti_alias(true); + halo.set_color(skia_safe::Color::from_argb(alpha, r, g, b)); + canvas.draw_circle((centre, centre), size * 0.5, &halo); + } + + // The mark, drawn on rather than faded in: a dash whose gap shrinks + // to nothing is what makes it read as being written. + let path = Self::check_path(size); + let mut stroke = paint_from_hex(&self.tint); + stroke.set_style(PaintStyle::Stroke); + stroke.set_anti_alias(true); + stroke.set_stroke_width(self.stroke_width.unwrap_or(size * 0.09)); + stroke.set_stroke_cap(skia_safe::PaintCap::Round); + stroke.set_stroke_join(skia_safe::PaintJoin::Round); + + if phase.stroke <= 0.0 { + canvas.restore(); + return; + } + if phase.stroke < 1.0 { + let mut measure = skia_safe::PathMeasure::new(&path, false, None); + let len = measure.length(); + let drawn = len * phase.stroke; + if let Some(dash) = skia_safe::PathEffect::dash(&[drawn, len - drawn + 1.0], 0.0) { + stroke.set_path_effect(dash); + } + } + canvas.draw_path(&path, &stroke); + + canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(json: serde_json::Value) -> SuccessCheck { + serde_json::from_value(json).expect("success_check fixture") + } + + #[test] + fn nothing_is_drawn_before_the_delay() { + let c = check(serde_json::json!({ "delay": 1.0 })); + let p = c.phase_at(0.5); + assert_eq!(p.arrival, 0.0, "the mark has not started arriving"); + assert_eq!(p.stroke, 0.0, "and nothing of it is drawn"); + } + + #[test] + fn the_stroke_starts_after_the_halo_has_landed() { + // A mark that writes itself while still flying in reads as two + // animations fighting rather than as one gesture. + let c = check(serde_json::json!({ "duration": 1.0 })); + let early = c.phase_at(0.2); + assert!( + early.arrival > 0.0, + "the halo is already arriving at 20% of the window" + ); + assert_eq!( + early.stroke, 0.0, + "but the stroke has not begun — it waits for the landing" + ); + assert!( + c.phase_at(0.6).stroke > 0.0, + "by 60% the stroke is under way" + ); + } + + #[test] + fn both_phases_are_complete_once_the_window_has_passed() { + let c = check(serde_json::json!({ "duration": 0.5, "delay": 0.25 })); + let done = c.phase_at(5.0); + assert!((done.arrival - 1.0).abs() < 1e-5); + assert!((done.stroke - 1.0).abs() < 1e-5); + } + + #[test] + fn a_zero_duration_lands_immediately_instead_of_dividing_by_zero() { + let c = check(serde_json::json!({ "duration": 0.0 })); + let p = c.phase_at(0.0); + assert_eq!((p.arrival, p.stroke), (1.0, 1.0)); + } + + #[test] + fn the_mark_finishes_upright_whatever_the_spin() { + // `spin` scales the swing on the way in; it must never leave the + // finished mark tilted, or a still frame of the end state is wrong. + for spin in [0.0, 1.0, 2.0, 5.0] { + let c = check(serde_json::json!({ "spin": spin, "duration": 0.5 })); + let settled = c.phase_at(2.0).arrival; + let angle = -18.0 * spin * (1.0 - settled); + assert!( + angle.abs() < 1e-4, + "spin={spin} left the settled mark rotated by {angle}°" + ); + } + } +} diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 77f912d..398b7fc 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -10,26 +10,18 @@ use rustmotion_core::css::style::{ TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace, }; use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::animator::AnimatedProperties; +use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimation}; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, typeface_with_fallback, wrap_text_with_tracking, }; use rustmotion_core::schema::{ - AnimationEffect, CharAnimPreset, CharAnimation, FontStyleType, FontWeight, Stroke, TextAlign, - TextAnimGranularity, TextBackground, TextShadow, TimelineStep, + CaretConfig, CaretShape, CharAnimPreset, FontStyleType, FontWeight, Stroke, TextAlign, + TextAnimGranularity, TextBackground, TextShadow, TextState, TextSwapConfig, TimelineStep, }; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; -/// Default starting blur sigma (px) for `char_blur_in` when -/// `CharAnimationTiming.blur` is not set. Tuned against rendered output at -/// 120px display type (see issue #118's render proof): low enough that -/// individual letterforms stay ghost-legible at the start of a unit's -/// reveal (this is a *reveal*, not a smoke effect), high enough that the -/// blur is unmistakable next to the settled, sharp frame. -const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0; - #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Text { pub content: String, @@ -49,6 +41,16 @@ pub struct Text { pub stroke: Option, #[serde(default, rename = "text-background")] pub text_background: Option, + /// A caret pinned to the reveal head of a `typewriter` animation. + /// See [`CaretConfig`]. + #[serde(default)] + pub caret: Option, + /// Later labels this text swaps to. See [`TextState`]. + #[serde(default)] + pub states: Vec, + /// How the crossing between `states` is animated. See [`TextSwapConfig`]. + #[serde(default)] + pub swap: Option, } rustmotion_core::impl_traits!(Text { @@ -57,6 +59,41 @@ rustmotion_core::impl_traits!(Text { Styled => style, }); +/// Eased progress (0..1) of unit `idx` at `time`, honouring the config's +/// deterministic jitter. +fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { + let unit_start = cfg.unit_start(idx); + let unit_end = unit_start + cfg.duration as f64; + let raw_t = if time <= unit_start { + 0.0 + } else if time >= unit_end { + 1.0 + } else { + (time - unit_start) / (unit_end - unit_start) + }; + ease(raw_t, &cfg.easing) as f32 +} + +/// The paint a unit draws with at progress `t`: the base paint, tinted from +/// `ink_from` towards the text's own colour when the config asks for it. +/// +/// `None` means "use the base paint unchanged" — worth keeping distinct from +/// a clone, since the caller may already be mutating its own copy. +fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option { + let from = cfg.ink_from.as_deref()?; + let start = paint_from_hex(from).color(); + let end = paint.color(); + let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8; + let mut p = paint.clone(); + p.set_color(skia_safe::Color::from_argb( + end.a(), + lerp(start.r(), end.r()), + lerp(start.g(), end.g()), + lerp(start.b(), end.b()), + )); + Some(p) +} + /// Apply a text animation preset to a single unit (char or word). /// Returns the text draw position adjustments and paint modifications. fn apply_text_anim_preset( @@ -73,17 +110,34 @@ fn apply_text_anim_preset( // swallow the inter-word space — visible only once the overrun approaches // a space's width, i.e. at small sizes or long words. letter_spacing: f32, - preset: &CharAnimPreset, + cfg: &ResolvedCharAnimation, t: f32, time: f64, unit_idx: usize, font_size: f32, - overshoot: f32, - blur_radius: f32, ) { + let preset = &cfg.preset; + let overshoot = cfg.overshoot; + let blur_radius = cfg.blur; let center_x = cursor_x + unit_width / 2.0; let center_y = line_y; + // `ink_from` and `scale_from` are cross-cutting: they compose with + // whatever the preset itself does rather than replacing it, so they are + // resolved once here instead of inside each arm. + let inked = ink_paint(cfg, paint, t); + let paint = inked.as_ref().unwrap_or(paint); + if let Some(from) = cfg.scale_from { + // The scale-driven presets own their scale curve outright; stacking a + // second one on top would fight it rather than compose with it. + if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) { + let s = from + (1.0 - from) * t.clamp(0.0, 1.0); + canvas.translate((center_x, center_y)); + canvas.scale((s, s)); + canvas.translate((-center_x, -center_y)); + } + } + match preset { CharAnimPreset::ScaleIn => { // 0→(1+overshoot) at 70%, then settle to 1.0 @@ -186,7 +240,10 @@ fn apply_text_anim_preset( ); } CharAnimPreset::SlideUp => { - let offset_y = (1.0 - t) * font_size * 0.8; + // Despite the name, the travel axis is `direction`'s to choose — + // `up` (the default) is what the preset has always done. + let travel = (1.0 - t) * font_size * 0.8 * cfg.distance; + let (dx, dy) = cfg.direction.offset(travel); let mut p = paint.clone(); p.set_alpha_f(t * paint.alpha_f()); draw_text_with_fallback( @@ -195,8 +252,8 @@ fn apply_text_anim_preset( font, emoji_font, letter_spacing, - cursor_x, - line_y + offset_y, + cursor_x + dx, + line_y + dy, &p, ); } @@ -205,7 +262,8 @@ fn apply_text_anim_preset( // components at once (blur settle, upward drift, opacity // ramp) rather than sequencing them as separate effects. let tt = t.clamp(0.0, 1.0); - let offset_y = (1.0 - tt) * font_size * 0.12; + let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance; + let (dx, dy) = cfg.direction.offset(travel); let sigma = ((1.0 - tt) * blur_radius).max(0.0); let mut p = paint.clone(); p.set_alpha_f(tt * paint.alpha_f()); @@ -225,8 +283,8 @@ fn apply_text_anim_preset( font, emoji_font, letter_spacing, - cursor_x, - line_y + offset_y, + cursor_x + dx, + line_y + dy, &p, ); } @@ -246,10 +304,8 @@ fn render_char_animation( line_height_val: f32, baseline_offset: f32, lines: &[String], - char_anim: &CharAnimation, + char_anim: &ResolvedCharAnimation, time: f64, - overshoot: f32, - blur_radius: f32, ) { let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word); let mut global_unit_idx = 0usize; @@ -310,17 +366,7 @@ fn render_char_animation( measure_text_with_fallback(&word, font, emoji_font, letter_spacing); // Calculate animation progress for this word - let unit_start = - char_anim.delay as f64 + global_unit_idx as f64 * char_anim.stagger as f64; - let unit_end = unit_start + char_anim.duration as f64; - let raw_t = if time <= unit_start { - 0.0 - } else if time >= unit_end { - 1.0 - } else { - (time - unit_start) / (unit_end - unit_start) - }; - let t = ease(raw_t, &char_anim.easing) as f32; + let t = unit_progress(char_anim, global_unit_idx, time); canvas.save(); apply_text_anim_preset( @@ -333,13 +379,11 @@ fn render_char_animation( line_y, word_width, letter_spacing, - &char_anim.preset, + char_anim, t, time, global_unit_idx, font.size(), - overshoot, - blur_radius, ); canvas.restore(); @@ -354,17 +398,7 @@ fn render_char_animation( let (ch_width, _) = font.measure_str(&ch_str, None); let ch_width = ch_width + letter_spacing; - let unit_start = - char_anim.delay as f64 + global_unit_idx as f64 * char_anim.stagger as f64; - let unit_end = unit_start + char_anim.duration as f64; - let raw_t = if time <= unit_start { - 0.0 - } else if time >= unit_end { - 1.0 - } else { - (time - unit_start) / (unit_end - unit_start) - }; - let t = ease(raw_t, &char_anim.easing) as f32; + let t = unit_progress(char_anim, global_unit_idx, time); canvas.save(); apply_text_anim_preset( @@ -381,13 +415,11 @@ fn render_char_animation( // to a default, since the word path above needs the real // value and the two must not drift apart. 0.0, - &char_anim.preset, + char_anim, t, time, global_unit_idx, font.size(), - overshoot, - blur_radius, ); canvas.restore(); @@ -399,6 +431,56 @@ fn render_char_animation( } impl Text { + /// Every label this text can display, in order — `content` followed by + /// each state's. + /// + /// Used for measurement: a box sized for the first label alone would be + /// overrun the moment the text swapped to a longer one, and the geometry + /// validator would have signed off on it. + pub fn all_labels(&self) -> impl Iterator { + std::iter::once(self.content.as_str()).chain(self.states.iter().map(|s| s.content.as_str())) + } + + /// The label showing at `time`: the last state whose `at` has passed, or + /// `content` before any of them. + fn label_at(&self, time: f64) -> &str { + self.states + .iter() + .rfind(|s| s.at <= time) + .map(|s| s.content.as_str()) + .unwrap_or(&self.content) + } + + /// The swap in progress at `time`, if any. + /// + /// `None` when the text declares no `swap` config, even if it declares + /// `states`: the labels then simply cut over at each `at`, which is a + /// legitimate (if abrupt) choice and the one the field's absence asks + /// for. + fn active_swap(&self, time: f64) -> Option { + let cfg = self.swap.as_ref()?; + if cfg.duration <= 0.0 { + return None; + } + let (idx, state) = self + .states + .iter() + .enumerate() + .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?; + let from = if idx == 0 { + self.content.clone() + } else { + self.states[idx - 1].content.clone() + }; + Some(ActiveSwap { + from, + to: state.content.clone(), + progress: ((time - state.at) / cfg.duration) as f32, + distance: cfg.distance, + blur: cfg.blur, + }) + } + fn paint( &self, canvas: &Canvas, @@ -543,16 +625,21 @@ impl Text { let wrap_width = if nowrap { None } else { box_width }; // Apply typewriter effect: limit visible characters based on animation progress + let label = self.label_at(time); let content = if props.visible_chars_progress >= 0.0 { - let chars: Vec = self.content.chars().collect(); + let chars: Vec = label.chars().collect(); let visible = (props.visible_chars_progress * chars.len() as f32).round() as usize; let visible = visible.min(chars.len()); - if visible == 0 { + if visible == 0 && self.caret.is_none() { return Ok(()); } + // With a caret, an empty reveal still has something to paint: the + // caret itself, sitting where the first character is about to + // appear. Bailing out here would make it pop into existence + // alongside that character instead of waiting for it. chars[..visible].iter().collect::() } else { - self.content.clone() + label.to_string() }; // Tracking-aware wrap (issue #125 §1): the fit test now measures @@ -614,16 +701,11 @@ impl Text { max_w }; - // Per-character animation mode (via style.animation char_* presets) + // Per-character animation mode (via style.animation char_* presets). + // All seven presets — `char_blur_in` included since it was routed + // through `extract_effects` like its siblings — arrive here already + // resolved, with container-level stagger folded into `delay`. if let Some(ref resolved) = props.char_animation { - let char_anim = CharAnimation { - preset: resolved.preset.clone(), - granularity: resolved.granularity.clone(), - stagger: resolved.stagger, - duration: resolved.duration, - easing: resolved.easing.clone(), - delay: resolved.delay, - }; render_char_animation( canvas, &content, @@ -636,127 +718,264 @@ impl Text { line_height_val, baseline_offset, &lines, - &char_anim, + resolved, time, - resolved.overshoot, - 0.0, // blur is only meaningful for char_blur_in, handled below ); return Ok(()); } - // `char_blur_in` is read directly off `style.animation` rather than - // through `engine::animator::extract_effects` → `props.char_animation` - // like its five siblings above: that resolution path lives outside - // this workstream's file scope (schema/video.rs + text.rs only, see - // issue #118 / chantier #117 W1). This is a top-level - // `style.animation` entry lookup, so — unlike the branch above — it - // does not pick up container-level stagger delay shifting or - // `timeline`-step-embedded copies. - if let Some(timing) = self.style.animation.iter().find_map(|effect| match effect { - AnimationEffect::CharBlurIn(t) => Some(t), - _ => None, - }) { - let char_anim = CharAnimation { - preset: CharAnimPreset::BlurIn, - granularity: timing.granularity.clone(), - stagger: timing.stagger as f32, - duration: timing.duration as f32, - easing: timing.easing.clone(), - delay: timing.delay as f32, - }; - render_char_animation( - canvas, - &content, - &font, - &emoji_font, - &paint, - letter_spacing, - align, - align_width, - line_height_val, - baseline_offset, - &lines, - &char_anim, - time, - timing.overshoot.unwrap_or(0.08) as f32, - timing - .blur - .map(|b| b as f32) - .unwrap_or(DEFAULT_CHAR_BLUR_SIGMA), - ); + // A state swap has two labels on screen at once, each with its own + // travel, blur and opacity. Everything above — font, alignment, + // metrics, decorations — is shared between them; only the label text + // and the motion differ. + if let Some(swap) = self.active_swap(time) { + for (label, offset_y, blur, alpha) in swap.labels() { + let label_lines = + wrap_text_with_tracking(&label, &font, &emoji_font, wrap_width, letter_spacing); + let mut p = paint.clone(); + p.set_alpha_f(alpha * paint.alpha_f()); + if blur > 0.05 { + if let Some(filter) = skia_safe::image_filters::blur( + (blur, blur), + skia_safe::TileMode::Clamp, + None, + None, + ) { + p.set_image_filter(filter); + } + } + draw_text_lines( + canvas, + &label_lines, + &font, + &emoji_font, + &p, + &shadow_paints, + stroke_paint.as_ref(), + self.text_background.as_ref(), + letter_spacing, + &align, + align_width, + line_height_val, + baseline_offset, + offset_y, + ); + } return Ok(()); } - for (i, line) in lines.iter().enumerate() { - if line.is_empty() { - continue; - } - - let advance_width = - measure_text_with_fallback(line, &font, &emoji_font, letter_spacing); - - let x = match align { - TextAlign::Left => 0.0, - TextAlign::Center => (align_width - advance_width) / 2.0, - TextAlign::Right => align_width - advance_width, - }; - let y = i as f32 * line_height_val + baseline_offset; - - // Draw background highlight behind text - if let Some(ref bg) = self.text_background { - let bg_paint = paint_from_hex(&bg.color); - let (_, font_rect) = font.measure_str(line, None); - let bg_rect = Rect::from_xywh( - x - bg.padding + font_rect.left, - y + font_rect.top - bg.padding / 2.0, - advance_width + bg.padding * 2.0, - -font_rect.top + font_rect.bottom + bg.padding, - ); - if bg.corner_radius > 0.0 { - let rrect = - skia_safe::RRect::new_rect_xy(bg_rect, bg.corner_radius, bg.corner_radius); - canvas.draw_rrect(rrect, &bg_paint); - } else { - canvas.draw_rect(bg_rect, &bg_paint); - } - } + draw_text_lines( + canvas, + &lines, + &font, + &emoji_font, + &paint, + &shadow_paints, + stroke_paint.as_ref(), + self.text_background.as_ref(), + letter_spacing, + &align, + align_width, + line_height_val, + baseline_offset, + 0.0, + ); - // Draw shadows — reverse order so the first CSS shadow ends on top. - for (sp, ox, oy) in shadow_paints.iter().rev() { - draw_text_with_fallback( + // The caret rides the reveal head: the end of the last line that has + // been revealed so far, which is where the next character will land. + if let Some(caret) = &self.caret { + let done = props.visible_chars_progress < 0.0 || props.visible_chars_progress >= 1.0; + if !(done && caret.hide_when_done) { + let last = lines.len().saturating_sub(1); + let line = lines.last().map(String::as_str).unwrap_or(""); + let advance_width = + measure_text_with_fallback(line, &font, &emoji_font, letter_spacing); + let x = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - advance_width) / 2.0, + TextAlign::Right => align_width - advance_width, + }; + let baseline = last as f32 * line_height_val + baseline_offset; + draw_caret( canvas, - line, + caret, + x + advance_width, + baseline, &font, - &emoji_font, - letter_spacing, - x + ox, - y + oy, - sp, + &paint, + time, ); } + } - // Draw stroke (outline) - if let Some(ref sp) = stroke_paint { - draw_text_with_fallback(canvas, line, &font, &emoji_font, letter_spacing, x, y, sp); + Ok(()) + } +} + +/// Draw already-wrapped `lines` with their background, shadows, stroke and +/// fill, shifted down by `offset_y`. +/// +/// Shared by the plain draw and by each half of a state swap, so a swapping +/// label keeps the decorations (`text-background`, `text-shadow`, `stroke`) +/// the same text has when it is not swapping. +#[allow(clippy::too_many_arguments)] +fn draw_text_lines( + canvas: &Canvas, + lines: &[String], + font: &Font, + emoji_font: &Option, + paint: &Paint, + shadow_paints: &[(Paint, f32, f32)], + stroke_paint: Option<&Paint>, + text_background: Option<&TextBackground>, + letter_spacing: f32, + align: &TextAlign, + align_width: f32, + line_height_val: f32, + baseline_offset: f32, + offset_y: f32, +) { + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + + let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing); + + let x = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - advance_width) / 2.0, + TextAlign::Right => align_width - advance_width, + }; + let y = i as f32 * line_height_val + baseline_offset + offset_y; + + // Draw background highlight behind text + if let Some(bg) = text_background { + let bg_paint = paint_from_hex(&bg.color); + let (_, font_rect) = font.measure_str(line, None); + let bg_rect = Rect::from_xywh( + x - bg.padding + font_rect.left, + y + font_rect.top - bg.padding / 2.0, + advance_width + bg.padding * 2.0, + -font_rect.top + font_rect.bottom + bg.padding, + ); + if bg.corner_radius > 0.0 { + let rrect = + skia_safe::RRect::new_rect_xy(bg_rect, bg.corner_radius, bg.corner_radius); + canvas.draw_rrect(rrect, &bg_paint); + } else { + canvas.draw_rect(bg_rect, &bg_paint); } + } - // Draw fill + // Draw shadows — reverse order so the first CSS shadow ends on top. + for (sp, ox, oy) in shadow_paints.iter().rev() { draw_text_with_fallback( canvas, line, - &font, - &emoji_font, + font, + emoji_font, letter_spacing, - x, - y, - &paint, + x + ox, + y + oy, + sp, ); } - Ok(()) + // Draw stroke (outline) + if let Some(sp) = stroke_paint { + draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, sp); + } + + // Draw fill + draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, paint); + } +} + +/// A state swap in progress: the label leaving, the label arriving, and how +/// far through the crossing we are. +struct ActiveSwap { + from: String, + to: String, + progress: f32, + distance: f32, + blur: f32, +} + +impl ActiveSwap { + /// `(label, offset_y, blur_sigma, alpha)` for each of the two labels. + /// + /// The outgoing label leaves upwards and the incoming one arrives from + /// below, so the pair reads as one value moving up a slot rather than as + /// two labels passing each other. + fn labels(&self) -> [(String, f32, f32, f32); 2] { + let p = self.progress.clamp(0.0, 1.0); + [ + ( + self.from.clone(), + -self.distance * p, + self.blur * p, + 1.0 - p, + ), + ( + self.to.clone(), + self.distance * (1.0 - p), + self.blur * (1.0 - p), + p, + ), + ] } } +/// Paint a caret whose left edge sits at `x`, aligned to the text `baseline`. +fn draw_caret( + canvas: &Canvas, + cfg: &CaretConfig, + x: f32, + baseline: f32, + font: &Font, + text_paint: &Paint, + time: f64, +) { + // A blink is a square wave over one full period, so `blink: 1.0` reads as + // "on for half a second, off for half a second" rather than as a rate + // nobody can predict from the number. + if cfg.blink > 0.0 { + let phase = (time / cfg.blink as f64).rem_euclid(1.0); + if phase >= 0.5 { + return; + } + } + + let (_, metrics) = font.metrics(); + let ascent = -metrics.ascent; + let descent = metrics.descent; + let size = font.size(); + + let (width, gap) = match cfg.shape { + // Proportional to the type size: a 3px rule that reads as a caret at + // 24px is a hairline at 120px. + CaretShape::Line => ((size * 0.07).max(1.5), size * 0.05), + // Roughly one character cell, the terminal look. + CaretShape::Block => (size * 0.55, size * 0.04), + }; + + let mut paint = match &cfg.color { + Some(hex) => paint_from_hex(hex), + None => text_paint.clone(), + }; + paint.set_style(PaintStyle::Fill); + paint.set_anti_alias(true); + // A caret is a solid mark, not a ghost: it must not inherit a stroke or + // image filter the text set up for itself. + paint.set_image_filter(None); + + canvas.draw_rect( + Rect::from_xywh(x + gap, baseline - ascent, width, ascent + descent), + &paint, + ); +} + impl Painter for Text { fn paint_content( &self, @@ -784,7 +1003,9 @@ mod tests { use rustmotion_core::css::style::CssStyle; use rustmotion_core::css::Length; use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; - use rustmotion_core::schema::{CharAnimationTiming, EasingType}; + use rustmotion_core::schema::{ + AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection, + }; fn make_text(content: &str, white_space: Option) -> Text { Text { @@ -802,6 +1023,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, } } @@ -939,6 +1163,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }; const W: i32 = 400; const H: i32 = 200; @@ -977,6 +1204,9 @@ mod tests { text_shadow: None, stroke: None, text_background: None, + caret: None, + states: Vec::new(), + swap: None, }; const W: i32 = 400; const H: i32 = 200; @@ -1028,6 +1258,20 @@ mod tests { soft as f32 / inked as f32 } + /// Resolve `style.animation` the way the engine does before it paints, so + /// a char-animation test exercises the real wiring + /// (`extract_effects` → `props.char_animation`) instead of a + /// painter-private lookup. + fn props_for(text: &Text) -> AnimatedProperties { + AnimatedProperties { + char_animation: rustmotion_core::engine::animator::extract_effects( + &text.style.animation, + ) + .char_animation, + ..Default::default() + } + } + /// Build the same `Font` the renderer would build for `family`/`px`, so /// tests can measure exact word boundaries instead of guessing pixel /// coordinates. @@ -1056,14 +1300,13 @@ mod tests { stagger: 0.03, granularity: TextAnimGranularity::Word, easing: EasingType::Linear, - overshoot: None, - blur: None, + ..Default::default() })]; const W: i32 = 700; const H: i32 = 220; let ctx = test_ctx(); - let props = AnimatedProperties::default(); + let props = props_for(&text); let render_at = |t: f64| -> Vec { let mut surface = @@ -1126,14 +1369,14 @@ mod tests { stagger: 0.2, granularity: TextAnimGranularity::Word, easing: EasingType::Linear, - overshoot: None, blur: Some(18.0), + ..Default::default() })]; const W: i32 = 1400; const H: i32 = 180; let ctx = test_ctx(); - let props = AnimatedProperties::default(); + let props = props_for(&text); let font = inter_font(FONT_PX); let first_w = measure_text_with_fallback("FIRST", &font, &None, 0.0); @@ -1177,14 +1420,14 @@ mod tests { stagger: 0.6, granularity: TextAnimGranularity::Word, easing: EasingType::Linear, - overshoot: None, blur: Some(16.0), + ..Default::default() })]; const W: i32 = 900; const H: i32 = 180; let ctx = test_ctx(); - let props = AnimatedProperties::default(); + let props = props_for(&text); let font = inter_font(FONT_PX); let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32; @@ -1487,4 +1730,524 @@ mod tests { "without text-autofit, nowrap must still bleed past its box exactly as before" ); } + + // ─── Text state swap ────────────────────────────────────────────────────── + + fn swapping_text(swap: Option) -> Text { + let mut text = make_text("Saving draft", Some(CssWhiteSpace::Nowrap)); + text.style.font_size = Some(Length::Px(48.0)); + text.states = vec![TextState { + at: 1.0, + content: "Saved".into(), + }]; + text.swap = swap; + text + } + + fn render_plain(text: &Text, time: f64) -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint( + canvas, + CARET_W as f32, + None, + time, + &AnimatedProperties::default(), + &test_ctx(), + ) + .expect("paint succeeds"); + } + alpha_grid(&mut surface, CARET_W, CARET_H) + } + + #[test] + fn states_cut_over_at_their_own_time_without_a_swap_config() { + // `states` on its own is a hard cut: abrupt, but it is exactly what + // omitting `swap` asks for, and it must not silently animate. + let text = swapping_text(None); + let before = render_plain(&text, 0.5); + let after = render_plain(&text, 1.5); + + let width_of = |g: &[u8]| max_ink_x(g, CARET_W, CARET_H).unwrap_or(0); + assert!( + width_of(&before) > width_of(&after) + 20, + "\"Saving draft\" should be visibly wider than \"Saved\" — the label must actually \ + have changed at t=1.0" + ); + // A cut has exactly one label on screen at each instant, so the frame + // right after the boundary equals the settled one. + assert_eq!( + render_plain(&text, 1.01), + render_plain(&text, 1.5), + "without a `swap`, the new label must be fully in place immediately" + ); + } + + #[test] + fn a_swap_puts_both_labels_on_screen_at_once() { + let text = swapping_text(Some(TextSwapConfig::default())); + + // Just before, only the outgoing label; mid-window, both; well after, + // only the incoming one. "Both" shows up as ink covering more rows + // than either label alone occupies, since they are offset vertically. + let rows_with_ink = |time: f64| -> usize { + let grid = render_plain(&text, time); + (0..CARET_H) + .filter(|&y| (0..CARET_W).any(|x| grid[(y * CARET_W + x) as usize] > 0)) + .count() + }; + + let settled = rows_with_ink(0.5); + let mid = rows_with_ink(1.0 + 0.45 / 2.0); + assert!( + mid > settled, + "mid-swap, the two offset labels should span more rows than one settled label \ + (settled={settled}, mid={mid})" + ); + } + + #[test] + fn a_finished_swap_settles_on_the_incoming_label_alone() { + let swapped = swapping_text(Some(TextSwapConfig::default())); + let cut = swapping_text(None); + // Past `at + duration` the animated version must be indistinguishable + // from a plain cut — no residual offset, blur or ghost of the old + // label parked behind the new one. + assert_eq!( + render_plain(&swapped, 2.0), + render_plain(&cut, 2.0), + "once the swap window has passed, the frame must match a plain cut exactly" + ); + } + + #[test] + fn the_box_is_measured_for_the_widest_label_not_the_first() { + // A box sized for "Saved" would be overrun the instant the text + // swapped back to "Saving draft" — and the geometry validator, which + // measures through this same intrinsic, would have signed off on it. + let mut short_first = make_text("Saved", Some(CssWhiteSpace::Nowrap)); + short_first.states = vec![TextState { + at: 1.0, + content: "Saving draft".into(), + }]; + let only_short = make_text("Saved", Some(CssWhiteSpace::Nowrap)); + + let measure = |t: &Text| { + TextIntrinsic::from_text(t) + .measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ) + .0 + }; + + assert!( + measure(&short_first) > measure(&only_short) + 10.0, + "the reserved width must cover the longest label the text can show \ + (with states={}, without={})", + measure(&short_first), + measure(&only_short) + ); + } + + // ─── Typewriter caret ───────────────────────────────────────────────────── + + const CARET_W: i32 = 900; + const CARET_H: i32 = 160; + + fn typewriter_text(caret: Option) -> Text { + let mut text = make_text("HELLO WORLD", Some(CssWhiteSpace::Nowrap)); + text.style.font_size = Some(Length::Px(64.0)); + text.caret = caret; + text + } + + /// Render a `visible_chars_progress` reveal at `progress`, at `time`. + fn render_reveal(text: &Text, progress: f32, time: f64) -> Vec { + let props = AnimatedProperties { + visible_chars_progress: progress, + ..AnimatedProperties::default() + }; + let mut surface = + skia_safe::surfaces::raster_n32_premul((CARET_W, CARET_H)).expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint(canvas, CARET_W as f32, None, time, &props, &test_ctx()) + .expect("paint succeeds"); + } + alpha_grid(&mut surface, CARET_W, CARET_H) + } + + #[test] + fn the_caret_follows_the_reveal_head_instead_of_standing_still() { + // The whole reason this is a field on `text` rather than a separate + // `cursor` component placed next to it: a hand-placed caret stays + // put while the text grows out from under it. + let text = typewriter_text(Some(CaretConfig { + blink: 0.0, + ..Default::default() + })); + let plain = typewriter_text(None); + + // Rightmost ink, with and without the caret: the difference is the + // caret's own position. + let caret_x = |progress: f32| -> i32 { + let with = render_reveal(&text, progress, 0.0); + let without = render_reveal(&plain, progress, 0.0); + let with_right = max_ink_x(&with, CARET_W, CARET_H).expect("caret paints"); + let without_right = max_ink_x(&without, CARET_W, CARET_H).unwrap_or(0); + assert!( + with_right > without_right, + "the caret should extend past the last revealed glyph \ + (with={with_right}, without={without_right}) at progress {progress}" + ); + with_right + }; + + let early = caret_x(0.25); + let late = caret_x(0.75); + assert!( + late > early + 40, + "the caret should have travelled with the reveal head (early={early}, late={late})" + ); + } + + #[test] + fn the_caret_blinks_off_for_half_of_each_period() { + let text = typewriter_text(Some(CaretConfig { + blink: 1.0, + ..Default::default() + })); + let plain = typewriter_text(None); + + let right_edge = |grid: &[u8]| max_ink_x(grid, CARET_W, CARET_H).unwrap_or(0); + let baseline = right_edge(&render_reveal(&plain, 0.5, 0.0)); + + // First half of the period: caret visible, so ink extends past the + // text. Second half: it must be gone, i.e. back to the text's own + // right edge. + let on = right_edge(&render_reveal(&text, 0.5, 0.1)); + let off = right_edge(&render_reveal(&text, 0.5, 0.6)); + assert!(on > baseline, "caret should be visible at phase 0.1"); + assert_eq!( + off, baseline, + "caret should be blinked out at phase 0.6, leaving only the text's own ink" + ); + } + + #[test] + fn hide_when_done_removes_the_caret_once_the_reveal_finishes() { + let hiding = typewriter_text(Some(CaretConfig { + blink: 0.0, + hide_when_done: true, + ..Default::default() + })); + let staying = typewriter_text(Some(CaretConfig { + blink: 0.0, + ..Default::default() + })); + let plain = typewriter_text(None); + + let right_edge = |t: &Text, progress: f32| { + max_ink_x(&render_reveal(t, progress, 0.0), CARET_W, CARET_H).unwrap_or(0) + }; + let text_edge = right_edge(&plain, 1.0); + + assert_eq!( + right_edge(&hiding, 1.0), + text_edge, + "with hide_when_done, a finished reveal must leave no caret behind" + ); + assert!( + right_edge(&staying, 1.0) > text_edge, + "without hide_when_done, the caret parks at the end of the text" + ); + } + + #[test] + fn the_caret_is_there_before_the_first_character_is() { + // At 0% reveal there is no text yet, but a typewriter that starts + // with a blank frame and pops both caret and first letter together + // reads as a glitch rather than as typing. + let text = typewriter_text(Some(CaretConfig { + blink: 0.0, + ..Default::default() + })); + let grid = render_reveal(&text, 0.0, 0.0); + assert!( + has_ink_in(&grid, CARET_W, 0, CARET_W, 0, CARET_H), + "the caret must be painting at 0% reveal, before any glyph" + ); + } + + // ─── Char-animation tuning (direction / distance / scale_from / ink_from) ── + + const TUNING_W: i32 = 520; + const TUNING_H: i32 = 360; + + /// A single 90px word carrying `timing` as a `char_slide_up` effect. + fn tuned_slide_up(timing: CharAnimationTiming) -> Text { + let mut text = make_text("GO", None); + text.style.font_size = Some(Length::Px(90.0)); + text.style.white_space = Some(CssWhiteSpace::Nowrap); + text.style.animation = vec![AnimationEffect::CharSlideUp(timing)]; + text + } + + fn render_alpha(text: &Text, time: f64) -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)).expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint( + canvas, + TUNING_W as f32, + None, + time, + &props_for(text), + &test_ctx(), + ) + .expect("paint succeeds"); + } + alpha_grid(&mut surface, TUNING_W, TUNING_H) + } + + /// Topmost inked row, i.e. how high on the canvas the glyphs sit. + fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option { + (0..height) + .find(|&y| (0..surface_width).any(|x| grid[(y * surface_width + x) as usize] > 0)) + } + + #[test] + fn direction_down_starts_the_unit_above_its_line_instead_of_below() { + // `char_slide_up` used to hardcode a downward starting offset. With + // `direction: "down"` the same preset has to start *above* the line + // and fall — the "letters cascading from the top" look. Sampled + // mid-travel, where the two are furthest apart. + let base = || CharAnimationTiming { + duration: 1.0, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + distance: Some(0.5), + ..Default::default() + }; + let up = tuned_slide_up(CharAnimationTiming { + direction: TextAnimDirection::Up, + ..base() + }); + let down = tuned_slide_up(CharAnimationTiming { + direction: TextAnimDirection::Down, + ..base() + }); + + let up_grid = render_alpha(&up, 0.5); + let down_grid = render_alpha(&down, 0.5); + + let up_top = min_ink_y(&up_grid, TUNING_W, TUNING_H).expect("up-travelling word paints"); + let down_top = + min_ink_y(&down_grid, TUNING_W, TUNING_H).expect("down-travelling word paints"); + + assert!( + down_top < up_top - 10, + "at the same instant, a `down` unit should sit clearly higher on the canvas than an \ + `up` one (down_top={down_top}, up_top={up_top})" + ); + } + + #[test] + fn distance_scales_how_far_the_unit_travels() { + let base = || CharAnimationTiming { + duration: 1.0, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + ..Default::default() + }; + let close = tuned_slide_up(CharAnimationTiming { + distance: Some(0.25), + ..base() + }); + let far = tuned_slide_up(CharAnimationTiming { + distance: Some(1.0), + ..base() + }); + + // Same instant, same preset: the only difference is how far each has + // left to travel, which shows up as how far below its line it sits. + let close_top = + min_ink_y(&render_alpha(&close, 0.5), TUNING_W, TUNING_H).expect("close word paints"); + let far_top = + min_ink_y(&render_alpha(&far, 0.5), TUNING_W, TUNING_H).expect("far word paints"); + + assert!( + far_top > close_top + 10, + "a `distance: 1.0` unit should still be further below its line than a `0.25` one at \ + the same instant (far_top={far_top}, close_top={close_top})" + ); + } + + #[test] + fn settled_units_land_in_the_same_place_whatever_the_direction_and_distance() { + // Whatever route it took, a unit's resting position is its laid-out + // one — otherwise the tuning knobs would silently move the finished + // frame, which is the frame that has to match the layout. + let settled = |timing: CharAnimationTiming| { + let grid = render_alpha(&tuned_slide_up(timing), 5.0); + min_ink_y(&grid, TUNING_W, TUNING_H).expect("settled word paints") + }; + let base = || CharAnimationTiming { + duration: 1.0, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + ..Default::default() + }; + + let plain = settled(base()); + let downward = settled(CharAnimationTiming { + direction: TextAnimDirection::Down, + distance: Some(1.85), + ..base() + }); + let sideways = settled(CharAnimationTiming { + direction: TextAnimDirection::Right, + distance: Some(1.85), + ..base() + }); + + assert_eq!( + plain, downward, + "a settled `down` unit must land on its line" + ); + assert_eq!( + plain, sideways, + "a settled `right` unit must land on its line" + ); + } + + #[test] + fn scale_from_shrinks_the_unit_at_the_start_and_releases_it_by_the_end() { + let timing = CharAnimationTiming { + duration: 1.0, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + // Isolate the scale: no travel to move the ink around. + distance: Some(0.0), + scale_from: Some(0.5), + ..Default::default() + }; + let text = tuned_slide_up(timing); + + let ink_width = |time: f64| -> i32 { + let grid = render_alpha(&text, time); + let left = (0..TUNING_W) + .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0)); + let right = (0..TUNING_W) + .rev() + .find(|&x| (0..TUNING_H).any(|y| grid[(y * TUNING_W + x) as usize] > 0)); + match (left, right) { + (Some(l), Some(r)) => r - l, + _ => 0, + } + }; + + let early = ink_width(0.35); + let settled = ink_width(5.0); + assert!(early > 0, "the word must be painting by t=0.35"); + assert!( + (early as f32) < settled as f32 * 0.9, + "a `scale_from: 0.5` unit should still be visibly narrower than its settled self \ + early on (early={early}px, settled={settled}px)" + ); + } + + #[test] + fn ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own() { + // `char_scale_in` is the one preset that leaves alpha alone, so the + // measurement reads the colour ramp instead of a fade. + let mut text = make_text("INK", None); + text.style.font_size = Some(Length::Px(90.0)); + text.style.white_space = Some(CssWhiteSpace::Nowrap); + text.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())); + text.style.animation = vec![AnimationEffect::CharScaleIn(CharAnimationTiming { + duration: 1.0, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + overshoot: Some(0.0), + // Pure red start → the green channel is the whole measurement. + ink_from: Some("#FF0000".into()), + ..Default::default() + })]; + + // Mean green over inked pixels: 0 at pure red, 255 once white. + let mean_green = |time: f64| -> f32 { + let mut surface = skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)) + .expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint( + canvas, + TUNING_W as f32, + None, + time, + &props_for(&text), + &test_ctx(), + ) + .expect("paint succeeds"); + } + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (TUNING_W, TUNING_H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (TUNING_W * TUNING_H * 4) as usize]; + assert!(snapshot.read_pixels( + &info, + &mut buf, + (TUNING_W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + )); + // Only fully-opaque pixels — antialiased edges carry blended + // colour that would muddy the reading. + let (sum, n) = (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, n), i| { + if buf[i * 4 + 3] == 255 { + (s + buf[i * 4 + 1] as u64, n + 1) + } else { + (s, n) + } + }); + assert!( + n > 0, + "some fully-opaque glyph pixels must exist at t={time}" + ); + sum as f32 / n as f32 + }; + + let early = mean_green(0.1); + let mid = mean_green(0.5); + let settled = mean_green(5.0); + + assert!( + early < 60.0, + "at 10% the word should read nearly pure red (mean green {early:.1})" + ); + assert!( + mid > early + 40.0 && mid < settled - 40.0, + "at 50% the word should be halfway between its start colour and the text colour \ + (early={early:.1}, mid={mid:.1}, settled={settled:.1})" + ); + assert!( + settled > 250.0, + "once settled the word must be the text's own white, not a tint of it \ + (mean green {settled:.1})" + ); + } } diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index bf7fe49..c14e48e 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -8,7 +8,7 @@ //! disappears. use crate::css::style::CssStyle; -use crate::css::style::{FilterFn, TransformFn}; +use crate::css::style::{FilterFn, Size, TransformFn}; use crate::css::units::{Length, LengthPercentage}; use crate::engine::animator::AnimatedProperties; @@ -92,12 +92,26 @@ pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) { css.perspective = Some(Length::Px(props.perspective)); } - // Note: `border_radius`, `font_size`, `width`, `height`, `gap`, `padding`, - // `stroke_width`, `shadow_blur`, `draw_progress`, `motion_progress`, - // `visible_chars*`, `char_animation`, `color` are NOT translated to CSS - // here. Those are component-internal animations and remain accessible - // through the legacy props on the dispatcher path. They will move into - // CSS once each component's painter is migrated. + // ---- box size ---- + // An animated `width`/`height` has to reach taffy, not just the painter: + // resizing a card is a *layout* change (its children reflow inside the new + // box), which is what separates it from a `scale` transform stretching the + // pixels it already had. The animator's sentinel for "never animated" is + // -1.0 (see `AnimatedProperties::default`), so a 0 is a real, authored 0. + // The layout pass runs per frame, so writing here is enough. + if props.width >= 0.0 { + css.width = Some(Size::Length(LengthPercentage::Px(props.width))); + } + if props.height >= 0.0 { + css.height = Some(Size::Length(LengthPercentage::Px(props.height))); + } + + // Note: `border_radius`, `font_size`, `gap`, `padding`, `stroke_width`, + // `shadow_blur`, `draw_progress`, `motion_progress`, `visible_chars*`, + // `char_animation`, `color` are NOT translated to CSS here. Those are + // component-internal animations and remain accessible through the legacy + // props on the dispatcher path. They will move into CSS once each + // component's painter is migrated. } #[cfg(test)] diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 78ec381..89e9331 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -1,9 +1,17 @@ use crate::schema::{ Animation, AnimationEffect, AnimationPreset, CharAnimPreset, EasingType, GlowConfig, Keyframe, - KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, TextAnimGranularity, - WiggleConfig, + KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, TextAnimDirection, + TextAnimGranularity, WiggleConfig, }; +/// Default starting blur sigma (px) for `char_blur_in` when +/// `CharAnimationTiming.blur` is not set. Tuned against rendered output at +/// 120px display type (see issue #118's render proof): low enough that +/// individual letterforms stay ghost-legible at the start of a unit's +/// reveal (this is a *reveal*, not a smoke effect), high enough that the +/// blur is unmistakable next to the settled, sharp frame. +pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0; + /// Safe division that returns `fallback` when the denominator is too small to /// produce a meaningful result (within 1e-9). Use this for any calculation /// where a zero-or-near-zero duration could otherwise produce NaN/∞ that @@ -39,6 +47,50 @@ pub struct ResolvedCharAnimation { pub easing: EasingType, pub delay: f32, pub overshoot: f32, + /// Starting blur sigma in px (`char_blur_in` only; 0 elsewhere). + pub blur: f32, + /// Travel direction for the presets whose motion is a translate. + pub direction: TextAnimDirection, + /// Multiplier on the preset's own travel distance (1.0 = as tuned). + pub distance: f32, + /// Scale each unit starts at, or `None` for no scaling. + pub scale_from: Option, + /// ±fraction of `stagger` each unit's start is nudged by (0 = even). + pub jitter: f32, + /// Seed for the deterministic jitter offsets. + pub seed: u32, + /// Colour each unit starts at before settling to the text's own. + pub ink_from: Option, +} + +impl ResolvedCharAnimation { + /// When unit `idx` starts, in seconds, including its jitter nudge. + /// + /// The nudge is a pure function of `(idx, seed)` — deliberately not an + /// RNG. Frames are rendered out of order, in parallel, and sometimes in + /// separate processes (`--frames a-b` segments), so anything stateful + /// here would make a unit jump between neighbouring frames. + /// + /// It is also clamped so a unit never starts before the effect's own + /// `delay`: a negative start would make the first units appear already + /// half-animated on frame 0. + pub fn unit_start(&self, idx: usize) -> f64 { + let even = self.delay as f64 + idx as f64 * self.stagger as f64; + if self.jitter.abs() < 1e-6 || self.stagger.abs() < 1e-6 { + return even; + } + // Bit-mixing hash (splitmix64's finalizer) over the unit index and + // seed → a well-distributed value in -1.0..1.0. + let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (self.seed as u64); + h ^= h >> 30; + h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); + h ^= h >> 27; + h = h.wrapping_mul(0x94D0_49BB_1331_11EB); + h ^= h >> 31; + let unit = (h >> 11) as f64 / (1u64 << 53) as f64; // 0.0..1.0 + let nudge = (unit * 2.0 - 1.0) * self.jitter as f64 * self.stagger as f64; + (even + nudge).max(self.delay as f64) + } } /// Extracted and categorized animation effects from an AnimationEffect slice. @@ -115,7 +167,8 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { | AnimationEffect::CharWave(t) | AnimationEffect::CharBounce(t) | AnimationEffect::CharRotateIn(t) - | AnimationEffect::CharSlideUp(t) => { + | AnimationEffect::CharSlideUp(t) + | AnimationEffect::CharBlurIn(t) => { let preset = match effect { AnimationEffect::CharScaleIn(_) => CharAnimPreset::ScaleIn, AnimationEffect::CharFadeIn(_) => CharAnimPreset::FadeIn, @@ -123,6 +176,12 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { AnimationEffect::CharBounce(_) => CharAnimPreset::Bounce, AnimationEffect::CharRotateIn(_) => CharAnimPreset::RotateIn, AnimationEffect::CharSlideUp(_) => CharAnimPreset::SlideUp, + // `char_blur_in` used to be resolved separately, off + // `style.animation` inside `text.rs`'s painter, which + // meant it silently missed container-level stagger + // shifting and `timeline`-embedded copies. It goes + // through the same door as its five siblings now. + AnimationEffect::CharBlurIn(_) => CharAnimPreset::BlurIn, _ => unreachable!(), }; result.char_animation = Some(ResolvedCharAnimation { @@ -133,6 +192,13 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { easing: t.easing.clone(), delay: t.delay as f32, overshoot: t.overshoot.unwrap_or(0.08) as f32, + blur: t.blur.map(|b| b as f32).unwrap_or(DEFAULT_CHAR_BLUR_SIGMA), + direction: t.direction, + distance: t.distance.unwrap_or(1.0) as f32, + scale_from: t.scale_from.map(|s| s as f32), + jitter: t.jitter.unwrap_or(0.0) as f32, + seed: t.seed.unwrap_or(0), + ink_from: t.ink_from.clone(), }); } AnimationEffect::Glow(config) => { @@ -1604,6 +1670,52 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec { vec![kf_anim_spring_underdamped("scale", delay, 0.0, end, 1.0)] } + AnimationPreset::PopIn => { + // Two beats, not one: the back-out scale places the element, then + // a short pulse draws the eye back to it. Collapsing them into a + // single overshooting curve reads as one wobble instead — the + // second beat has to land *after* the element has visibly settled. + let pulse = 1.0 + config.overshoot.unwrap_or(0.18); + let placed = delay + dur * 0.6; + let peak = delay + dur * 0.8; + vec![ + kf_anim( + "opacity", + delay, + 0.0, + delay + dur * 0.25, + 1.0, + EasingType::EaseOut, + ), + Animation { + property: "scale".to_string(), + keyframes: vec![ + Keyframe { + time: delay, + value: KeyframeValue::Number(0.0), + easing: Some(EasingType::EaseOutBack), + }, + Keyframe { + time: placed, + value: KeyframeValue::Number(1.0), + easing: Some(EasingType::EaseOutQuad), + }, + Keyframe { + time: peak, + value: KeyframeValue::Number(pulse), + easing: Some(EasingType::EaseOutElastic), + }, + Keyframe { + time: end, + value: KeyframeValue::Number(1.0), + easing: None, + }, + ], + easing: EasingType::EaseOut, + spring: None, + }, + ] + } // ── Sorties ────────────────────────────────────────────────────── AnimationPreset::FadeOut => { @@ -3270,3 +3382,108 @@ mod motion_path_tests { ); } } + +#[cfg(test)] +mod char_animation_tuning_tests { + use super::*; + + fn anim(stagger: f32, jitter: f32, seed: u32) -> ResolvedCharAnimation { + ResolvedCharAnimation { + preset: CharAnimPreset::SlideUp, + granularity: TextAnimGranularity::Word, + stagger, + duration: 0.4, + easing: EasingType::Linear, + delay: 0.5, + overshoot: 0.08, + blur: DEFAULT_CHAR_BLUR_SIGMA, + direction: TextAnimDirection::Up, + distance: 1.0, + scale_from: None, + jitter, + seed, + ink_from: None, + } + } + + #[test] + fn without_jitter_units_are_evenly_spaced() { + let a = anim(0.2, 0.0, 0); + for i in 0..6 { + let expected = 0.5 + i as f64 * 0.2; + // f32 fields widened to f64 — compare at f32 precision. + assert!( + (a.unit_start(i) - expected).abs() < 1e-6, + "unit {i} should start at {expected}, got {}", + a.unit_start(i) + ); + } + } + + #[test] + fn jitter_is_a_pure_function_of_index_and_seed() { + // Frames are rendered out of order, in parallel, and across separate + // processes (`--frames a-b` segments). If the nudge came from an RNG, + // a word would land at a different time in each of those, i.e. jump + // between neighbouring frames of the same video. + let a = anim(0.2, 0.6, 42); + let b = anim(0.2, 0.6, 42); + for i in 0..32 { + assert_eq!( + a.unit_start(i).to_bits(), + b.unit_start(i).to_bits(), + "unit {i} must resolve bit-identically for the same seed" + ); + } + } + + #[test] + fn a_different_seed_reshuffles_the_rhythm() { + let a = anim(0.2, 0.6, 1); + let b = anim(0.2, 0.6, 2); + let differing = (0..32) + .filter(|&i| a.unit_start(i) != b.unit_start(i)) + .count(); + assert!( + differing > 24, + "changing the seed should move nearly every unit, but only {differing}/32 moved" + ); + } + + #[test] + fn jitter_actually_perturbs_the_even_spacing() { + let even = anim(0.2, 0.0, 7); + let jittered = anim(0.2, 0.8, 7); + let moved = (1..32) + .filter(|&i| (even.unit_start(i) - jittered.unit_start(i)).abs() > 1e-6) + .count(); + assert!( + moved > 20, + "jitter should visibly perturb the march, but only {moved}/31 units moved" + ); + } + + #[test] + fn no_unit_starts_before_the_effects_own_delay() { + // A negative nudge on the first unit would have it appear already + // half-animated on frame 0 — the one artefact the clamp exists for. + let a = anim(0.2, 2.0, 99); + for i in 0..64 { + assert!( + a.unit_start(i) >= 0.5 - 1e-9, + "unit {i} started at {} — before the effect's own 0.5s delay", + a.unit_start(i) + ); + } + } + + #[test] + fn a_zero_stagger_is_unaffected_by_jitter() { + // Nothing to spread out: every unit shares one start time, and + // `jitter` scales off `stagger`, so it has nothing to scale. + let a = anim(0.0, 1.0, 3); + for i in 0..8 { + assert!((a.unit_start(i) - 0.5).abs() < 1e-9); + } + } +} diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 2b36364..0255ce4 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -426,6 +426,25 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u paint_border(canvas, box_layout, &node.css, border, &length_ctx); } + // 7.5. shimmer layer. The band composites against the pixels this node + // paints, which do not exist yet — so an isolated layer is opened here, + // filled by steps 9 and 10 below, and the band is stamped onto it with + // `SrcATop` just before it closes. + let shimmer = active_shimmer(&node.css, ctx.frame.time); + let opened_shimmer_layer = if shimmer.is_some() { + let bounds = Rect::from_xywh( + box_layout.x, + box_layout.y, + box_layout.width, + box_layout.height, + ); + let rec = SaveLayerRec::default().bounds(&bounds); + canvas.save_layer(&rec); + true + } else { + false + }; + // 8. clip overflow:hidden / clip — scoped to this node's own content and // its children only (see step 5-7's comment for why the box's own // decorations must stay outside this clip). @@ -479,12 +498,109 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } } + if let Some((cfg, progress)) = shimmer { + paint_shimmer_band(canvas, box_layout, cfg, progress); + } + if opened_shimmer_layer { + canvas.restore(); + } + if opened_opacity_layer { canvas.restore(); } canvas.restore(); } +/// The shimmer effect on this node and how far through its sweep it is at +/// `time`, or `None` when there is none or the sweep is not running. +/// +/// Returning `None` outside the sweep window is what keeps the cost off every +/// other node and every other frame: no window, no isolated layer. +fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> { + let cfg = css.animation.iter().find_map(|e| match e { + crate::schema::AnimationEffect::Shimmer(c) => Some(c), + _ => None, + })?; + if cfg.duration <= 0.0 || cfg.intensity <= 0.0 { + return None; + } + let elapsed = time - cfg.delay; + if elapsed < 0.0 { + return None; + } + let progress = if cfg.repeat { + (elapsed / cfg.duration).rem_euclid(1.0) + } else if elapsed > cfg.duration { + return None; + } else { + elapsed / cfg.duration + }; + Some((cfg, progress as f32)) +} + +/// Stamp the sweeping band onto the layer built by steps 9-10, restricted to +/// the pixels that layer actually painted. +fn paint_shimmer_band( + canvas: &Canvas, + layout: &BoxLayout, + cfg: &crate::schema::ShimmerConfig, + progress: f32, +) { + if layout.width <= 0.0 || layout.height <= 0.0 { + return; + } + let (r, g, b, a) = crate::engine::renderer::parse_hex_color(&cfg.color); + let peak_alpha = (cfg.intensity.clamp(0.0, 1.0) * a as f32) as u8; + let transparent = SColor::from_argb(0, r, g, b); + let highlight = SColor::from_argb(peak_alpha, r, g, b); + + // The band's axis, and the extent of the box measured along it. Using the + // projected extent rather than the width keeps an angled band sweeping + // fully off both ends instead of stopping short on the diagonal. + let theta = cfg.angle.to_radians(); + let (dx, dy) = (theta.cos(), theta.sin()); + let cx = layout.x + layout.width / 2.0; + let cy = layout.y + layout.height / 2.0; + let half_extent = (layout.width * dx).abs() / 2.0 + (layout.height * dy).abs() / 2.0; + + let band = (cfg.width.max(0.01) * half_extent * 2.0).max(1.0); + // Travel from fully off one end to fully off the other, so the element is + // clean at both ends of the sweep rather than starting mid-glint. + let start = -half_extent - band; + let centre = start + progress * (2.0 * half_extent + 2.0 * band); + + let p0 = Point::new(cx + dx * (centre - band), cy + dy * (centre - band)); + let p1 = Point::new(cx + dx * (centre + band), cy + dy * (centre + band)); + + let Some(shader) = skia_safe::shader::Shader::linear_gradient( + (p0, p1), + skia_safe::gradient_shader::GradientShaderColors::Colors(&[ + transparent, + highlight, + transparent, + ]), + None, + skia_safe::TileMode::Clamp, + None, + None, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_style(PaintStyle::Fill); + paint.set_anti_alias(true); + paint.set_shader(shader); + // The whole point: light only the pixels the element itself painted, so + // the sheen reads as catching the glyphs rather than as a rectangle + // sliding past them. + paint.set_blend_mode(skia_safe::BlendMode::SrcATop); + canvas.draw_rect( + Rect::from_xywh(layout.x, layout.y, layout.width, layout.height), + &paint, + ); +} + /// Conservative outward bleed (px) a `filter` chain can paint beyond the /// node's own box — used to size the opacity/filter layer's `SaveLayerRec` /// bounds generously enough that `blur`/`drop-shadow` never get clipped at diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 913907b..da1859a 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,9 +1,58 @@ use crate::engine::animator::ease; use crate::schema::{ - EasingType, PanBackground, PixelDissolveOrder, TransitionCorner, TransitionType, + EasingType, PanBackground, PixelDissolveOrder, Transition, TransitionCorner, + TransitionDirection, TransitionType, }; use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, Path, Rect}; +/// The per-type knobs a transition may read, bundled. +/// +/// Each of these is inert for every transition but the one or two that read +/// it, so they travel together rather than as a growing tail of positional +/// arguments threaded through the render task queue — a shape that made +/// adding a transition a five-file edit. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransitionOptions { + /// `corner_reveal`: which corner the reveal grows from. + pub corner: TransitionCorner, + /// `pixel_dissolve`: cell edge in px. + pub cell: f32, + /// `pixel_dissolve`: scatter seed. + pub seed: u32, + /// `pixel_dissolve`: which cells turn first. + pub order: PixelDissolveOrder, + /// `chromatic_wipe`: which way it travels. + pub direction: TransitionDirection, + /// `chromatic_wipe`: channel-split multiplier. + pub aberration: f32, +} + +impl Default for TransitionOptions { + fn default() -> Self { + Self { + corner: TransitionCorner::default(), + cell: 48.0, + seed: 11, + order: PixelDissolveOrder::default(), + direction: TransitionDirection::default(), + aberration: 1.0, + } + } +} + +impl From<&Transition> for TransitionOptions { + fn from(t: &Transition) -> Self { + Self { + corner: t.corner, + cell: t.cell, + seed: t.seed, + order: t.order, + direction: t.direction, + aberration: t.aberration, + } + } +} + /// Composite two RGBA frames during a transition. /// `progress` goes from 0.0 (fully frame_a) to 1.0 (fully frame_b). pub fn apply_transition( @@ -13,12 +62,17 @@ pub fn apply_transition( height: u32, progress: f64, transition_type: &TransitionType, - corner: TransitionCorner, - cell: f32, - seed: u32, - order: PixelDissolveOrder, + opts: &TransitionOptions, ) -> Vec { let progress = progress.clamp(0.0, 1.0) as f32; + let TransitionOptions { + corner, + cell, + seed, + order, + direction, + aberration, + } = *opts; match transition_type { TransitionType::Fade => blend_fade(frame_a, frame_b, progress), @@ -48,6 +102,9 @@ pub fn apply_transition( pixel_dissolve(frame_a, frame_b, width, height, progress, cell, seed, order) } TransitionType::CameraPan => blend_fade(frame_a, frame_b, progress), + TransitionType::ChromaticWipe => chromatic_wipe( + frame_a, frame_b, width, height, progress, direction, aberration, + ), TransitionType::None => { if progress < 0.5 { frame_a.to_vec() @@ -557,6 +614,80 @@ fn slide_transition( surface_to_pixels(surface, width, height) } +/// A fast slide whose reveal edge splits into red and cyan at the peak. +/// +/// Both frames travel the same way — the incoming one is simply one screen +/// behind — so the edge between them is a hard seam rather than a dissolve. +/// The channel split is applied to that composite, peaking mid-transition and +/// gone by the time it lands, so the flash reads as an artefact of the *speed* +/// of the cut rather than as a colour treatment on either scene. +fn chromatic_wipe( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + direction: TransitionDirection, + aberration: f32, +) -> Vec { + let (w, h) = (width as f32, height as f32); + // Slide axis, as a unit vector. Both frames move along it; B starts one + // full screen back. + let (ux, uy) = match direction { + TransitionDirection::Left => (-1.0, 0.0), + TransitionDirection::Right => (1.0, 0.0), + TransitionDirection::Up => (0.0, -1.0), + TransitionDirection::Down => (0.0, 1.0), + }; + + let slid = { + let mut surface = match create_skia_surface(width, height) { + Some(s) => s, + None => return blend_fade(frame_a, frame_b, progress), + }; + let (Some(img_a), Some(img_b)) = ( + frame_to_image(frame_a, width, height), + frame_to_image(frame_b, width, height), + ) else { + return blend_fade(frame_a, frame_b, progress); + }; + let canvas = surface.canvas(); + let (dx, dy) = (ux * progress * w, uy * progress * h); + canvas.draw_image(&img_a, (dx, dy), None); + canvas.draw_image(&img_b, (dx - ux * w, dy - uy * h), None); + surface_to_pixels(surface, width, height) + }; + + // Peak at the midpoint, nothing at either end: a split still present on + // the last frame would bleed into the scene that follows. + let peak = 1.0 - (progress * 2.0 - 1.0).abs(); + let shift = (aberration.max(0.0) * peak * w * 0.012).round() as i32; + if shift == 0 { + return slid; + } + + // Red leads the travel, blue trails it — the two channels sampled from + // either side of where green is, which is what a lens does under speed. + let mut out = slid.clone(); + let (sx, sy) = ( + (ux * shift as f32).round() as i32, + (uy * shift as f32).round() as i32, + ); + let sample = |buf: &[u8], x: i32, y: i32, channel: usize| -> u8 { + let cx = x.clamp(0, width as i32 - 1); + let cy = y.clamp(0, height as i32 - 1); + buf[((cy as u32 * width + cx as u32) * 4) as usize + channel] + }; + for y in 0..height as i32 { + for x in 0..width as i32 { + let base = ((y as u32 * width + x as u32) * 4) as usize; + out[base] = sample(&slid, x - sx, y - sy, 0); + out[base + 2] = sample(&slid, x + sx, y + sy, 2); + } + } + out +} + fn dissolve_transition( frame_a: &[u8], frame_b: &[u8], diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index 9d8cf02..bf39842 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -159,6 +159,7 @@ pub enum AnimationPreset { BlurIn, RotateIn, ElasticIn, + PopIn, // Sorties FadeOut, FadeOutUp, diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index e9904d9..c24b363 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -46,6 +46,49 @@ fn default_grid_dots_color() -> String { "#FFFFFF15".to_string() } +/// Config for the `grid_lines` preset — a ruled grid, not a dotted one. +/// +/// `grid_dots` marks the intersections and reads as texture; ruled lines read +/// as structure, which is what a SaaS/data scene wants behind a chart or a +/// code panel. Same scroll machinery (`x`/`y`/`speed`/`direction`) as the +/// other tiled presets. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GridLinesConfig { + /// Line colour (hex, alpha welcome). + #[serde(default = "default_grid_lines_color")] + pub color: String, + /// Cell edge in px. Small reads as graph paper, large as panels. + #[serde(default = "default_grid_lines_cell")] + pub cell: f32, + /// Line thickness in px. + #[serde(default = "default_grid_lines_weight")] + pub weight: f32, + /// Draw every Nth line at `major_weight` instead, for the + /// graph-paper look where the coarse grid reads through the fine one. + /// `0` (default) means no major lines. + #[serde(default)] + pub major_every: u32, + /// Thickness of a major line. + #[serde(default = "default_grid_lines_major_weight")] + pub major_weight: f32, +} + +fn default_grid_lines_color() -> String { + "#FFFFFF14".to_string() +} + +fn default_grid_lines_cell() -> f32 { + 72.0 +} + +fn default_grid_lines_weight() -> f32 { + 1.0 +} + +fn default_grid_lines_major_weight() -> f32 { + 2.0 +} + /// Config for the `concentric_circles` preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ConcentricCirclesConfig { @@ -196,6 +239,7 @@ fn default_hero_scale() -> f32 { pub enum BackgroundPreset { GradientShift(GradientShiftConfig), GridDots(GridDotsConfig), + GridLines(GridLinesConfig), ConcentricCircles(ConcentricCirclesConfig), Halo(HaloConfig), PixelGrid(PixelGridConfig), @@ -207,6 +251,7 @@ impl BackgroundPreset { match self { BackgroundPreset::GradientShift(_) => "gradient_shift", BackgroundPreset::GridDots(_) => "grid_dots", + BackgroundPreset::GridLines(_) => "grid_lines", BackgroundPreset::ConcentricCircles(_) => "concentric_circles", BackgroundPreset::Halo(_) => "halo", BackgroundPreset::PixelGrid(_) => "pixel_grid", @@ -238,6 +283,7 @@ impl Serialize for AnimatedBackground { match &self.preset { BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?, BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?, + BackgroundPreset::GridLines(cfg) => map.serialize_entry("grid_lines", cfg)?, BackgroundPreset::ConcentricCircles(cfg) => { map.serialize_entry("concentric_circles", cfg)? } @@ -266,6 +312,7 @@ impl Serialize for AnimatedBackground { const KNOWN_BACKGROUND_PRESETS: &[&str] = &[ "gradient_shift", "grid_dots", + "grid_lines", "concentric_circles", "halo", "pixel_grid", @@ -462,6 +509,9 @@ fn deserialize_preset_config( "grid_dots" => Ok(BackgroundPreset::GridDots( serde_json::from_value(sub).map_err(E::custom)?, )), + "grid_lines" => Ok(BackgroundPreset::GridLines( + serde_json::from_value(sub).map_err(E::custom)?, + )), "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles( serde_json::from_value(sub).map_err(E::custom)?, )), @@ -508,6 +558,10 @@ impl JsonSchema for AnimatedBackground { "grid_dots".to_string(), gen.subschema_for::>(), ); + props.insert( + "grid_lines".to_string(), + gen.subschema_for::>(), + ); props.insert( "concentric_circles".to_string(), gen.subschema_for::>(), diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 9d36f06..72d56a4 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -621,6 +621,18 @@ pub enum TransitionCorner { BottomLeft, } +/// Which way a directional transition travels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TransitionDirection { + /// Both frames travel leftwards; the incoming one enters from the right. + #[default] + Left, + Right, + Up, + Down, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Transition { @@ -639,6 +651,15 @@ pub struct Transition { /// `pixel_dissolve` only: which cells turn first. #[serde(default)] pub order: PixelDissolveOrder, + /// Which way a `chromatic_wipe` travels. Ignored by every other type — + /// the `wipe_*`/`slide` family encodes its direction in the type name. + #[serde(default)] + pub direction: TransitionDirection, + /// `chromatic_wipe` only: how far the red and cyan channels split at the + /// peak of the wipe, as a multiple of the tuned default. `0` removes the + /// colour flash and leaves a plain fast slide; `2` doubles it. + #[serde(default = "default_transition_aberration")] + pub aberration: f32, #[serde(default = "default_transition_duration")] pub duration: f64, #[serde(default = "default_transition_easing")] @@ -685,6 +706,10 @@ pub enum TransitionType { CornerReveal, PixelDissolve, CameraPan, + /// A fast slide in which the reveal edge splits into its red and cyan + /// channels at the peak and recombines as it lands — the glitch-flash + /// cut. `direction` steers it, `aberration` scales the split. + ChromaticWipe, None, } @@ -696,6 +721,10 @@ fn default_transition_seed() -> u32 { 11 } +fn default_transition_aberration() -> f32 { + 1.0 +} + fn default_transition_duration() -> f64 { 0.5 } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index fe35ade..0496632 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -34,6 +34,12 @@ pub enum AnimationEffect { BlurIn(AnimationTiming), RotateIn(AnimationTiming), ElasticIn(AnimationTiming), + /// Scale up from nothing with a back-out overshoot, then a short elastic + /// pulse before settling — the notification-badge arrival, where the + /// second beat is what draws the eye after the first has placed the + /// element. `AnimationTiming.overshoot` sets the pulse amplitude + /// (default 0.18 = 118%); 0 reduces it to a plain back-out scale-in. + PopIn(AnimationTiming), // --- Exit presets --- FadeOut(AnimationTiming), FadeOutUp(AnimationTiming), @@ -78,20 +84,21 @@ pub enum AnimationEffect { /// a slight upward translate and an opacity ramp — one continuous /// per-unit animation driven by the same progress value, not three /// independently-timed effects. `CharAnimationTiming.blur` sets the - /// starting blur sigma in px (default tuned for 100px+ display type; - /// see `crates/rustmotion-components/src/text.rs`). + /// starting blur sigma in px (default + /// `engine::animator::DEFAULT_CHAR_BLUR_SIGMA`, tuned for 100px+ display + /// type). /// - /// Resolved directly off `style.animation` inside - /// `rustmotion_components::text::Text::paint` rather than through - /// `engine::animator::extract_effects`/`ResolvedCharAnimation` like its - /// five siblings — that resolution path lives in a file outside this - /// workstream's scope (chantier #117 W1). Functionally equivalent for - /// the documented use (a top-level `style.animation` entry); it does - /// not participate in container-level stagger delay shifting the way - /// the other char presets do when nested in a staggered list/grid. + /// Resolved through `engine::animator::extract_effects` → + /// `ResolvedCharAnimation` like its five siblings, so it picks up + /// container-level stagger shifting and `timeline`-embedded copies. (It + /// used to be read directly off `style.animation` inside + /// `rustmotion_components::text::Text::paint` and therefore missed both.) CharBlurIn(CharAnimationTiming), // --- Non-preset effects --- Glow(GlowConfig), + /// A band of light sweeping across the element's own painted pixels. + /// See [`ShimmerConfig`]. + Shimmer(ShimmerConfig), Wiggle(WiggleConfig), Orbit(OrbitConfig), Keyframes(KeyframesConfig), @@ -117,17 +124,18 @@ impl AnimationEffect { match self { FadeIn(t) | FadeInUp(t) | FadeInDown(t) | FadeInLeft(t) | FadeInRight(t) | SlideInLeft(t) | SlideInRight(t) | SlideInUp(t) | SlideInDown(t) | ScaleIn(t) - | BounceIn(t) | BlurIn(t) | RotateIn(t) | ElasticIn(t) | FadeOut(t) | FadeOutUp(t) - | FadeOutDown(t) | SlideOutLeft(t) | SlideOutRight(t) | SlideOutUp(t) - | SlideOutDown(t) | ScaleOut(t) | BounceOut(t) | BlurOut(t) | RotateOut(t) - | Pulse(t) | Float(t) | Shake(t) | Spin(t) | FlipInX(t) | FlipInY(t) | FlipOutX(t) - | FlipOutY(t) | DrawIn(t) | StrokeReveal(t) | Typewriter(t) | WipeLeft(t) - | WipeRight(t) | Float3d(t) => t.delay += by, + | BounceIn(t) | BlurIn(t) | RotateIn(t) | ElasticIn(t) | PopIn(t) | FadeOut(t) + | FadeOutUp(t) | FadeOutDown(t) | SlideOutLeft(t) | SlideOutRight(t) + | SlideOutUp(t) | SlideOutDown(t) | ScaleOut(t) | BounceOut(t) | BlurOut(t) + | RotateOut(t) | Pulse(t) | Float(t) | Shake(t) | Spin(t) | FlipInX(t) | FlipInY(t) + | FlipOutX(t) | FlipOutY(t) | DrawIn(t) | StrokeReveal(t) | Typewriter(t) + | WipeLeft(t) | WipeRight(t) | Float3d(t) => t.delay += by, TiltIn(c) => c.delay += by, CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c) | CharSlideUp(c) | CharBlurIn(c) => c.delay += by, Keyframes(c) => c.delay += by, MotionPath(c) => c.delay += by, + Shimmer(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } @@ -149,6 +157,7 @@ impl AnimationEffect { Self::BlurIn(t) => Some((AnimationPreset::BlurIn, t)), Self::RotateIn(t) => Some((AnimationPreset::RotateIn, t)), Self::ElasticIn(t) => Some((AnimationPreset::ElasticIn, t)), + Self::PopIn(t) => Some((AnimationPreset::PopIn, t)), Self::FadeOut(t) => Some((AnimationPreset::FadeOut, t)), Self::FadeOutUp(t) => Some((AnimationPreset::FadeOutUp, t)), Self::FadeOutDown(t) => Some((AnimationPreset::FadeOutDown, t)), @@ -291,6 +300,66 @@ pub struct CharAnimationTiming { /// in issue #118). #[serde(default)] pub blur: Option, + /// Which way each unit travels in from. Only `char_slide_up` and + /// `char_blur_in` have a travel axis to redirect; the other presets + /// ignore it. Default `up` — the historical behaviour. + #[serde(default)] + pub direction: TextAnimDirection, + /// Multiplier on how far each unit travels, `1.0` being each preset's + /// own tuned distance (0.8em for `slide_up`, 0.12em for `blur_in`). + /// Use ~0.5 for a tighter arrival, ~1.85 for a pronounced staircase. + #[serde(default)] + pub distance: Option, + /// Scale each unit starts at, growing to 1.0 over its animation — + /// combined with, not instead of, the preset's own motion. 0.82 gives + /// the punchy "number pops in" arrival, 0.92 a barely-perceptible + /// settle. Unset means no scaling (the historical behaviour); the + /// scale-based presets (`char_scale_in`, `char_bounce`) own their scale + /// curve outright and ignore this. + #[serde(default)] + pub scale_from: Option, + /// Randomises each unit's start time by up to ±`jitter × stagger`, so + /// the units arrive in uneven bursts instead of a metronomic march. + /// This is what separates a streaming-token look from a typewriter: + /// language models don't emit words on a clock. 0 (default) keeps the + /// exact even spacing. + /// + /// The offsets are derived from `seed` and the unit's index, never from + /// a live RNG — a frame must render identically no matter which + /// process, thread or `--frames` segment computes it. + #[serde(default)] + pub jitter: Option, + /// Seed for `jitter`. Changing it reshuffles the arrival rhythm without + /// changing its statistics. + #[serde(default)] + pub seed: Option, + /// Colour each unit starts at before settling to the text's own colour + /// over its animation. A dim grey here reproduces the way freshly + /// streamed tokens read as unsettled before the eye accepts them. + #[serde(default)] + pub ink_from: Option, +} + +impl Default for CharAnimationTiming { + /// Mirrors the serde defaults exactly, so `CharAnimationTiming::default()` + /// and `serde_json::from_value(json!({}))` describe the same animation. + fn default() -> Self { + Self { + delay: 0.0, + duration: default_char_duration_f64(), + stagger: default_char_stagger_f64(), + granularity: TextAnimGranularity::default(), + easing: EasingType::default(), + overshoot: None, + blur: None, + direction: TextAnimDirection::default(), + distance: None, + scale_from: None, + jitter: None, + seed: None, + ink_from: None, + } + } } fn default_char_stagger_f64() -> f64 { @@ -336,6 +405,43 @@ pub enum TextAnimGranularity { Word, } +/// Which way a per-unit entrance travels from before it settles. +/// +/// The unit starts offset in the *opposite* direction of travel and moves +/// towards its laid-out position: `Up` (the default, and the historical +/// behaviour) starts below the baseline and rises; `Down` starts above and +/// falls, which is the "letters cascading from the top" look. +/// +/// Read by the `slide_up` and `blur_in` char presets — the ones whose motion +/// is a translate. `scale_in`, `bounce`, `rotate_in`, `fade_in` and `wave` +/// have no travel axis to redirect and ignore it. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TextAnimDirection { + /// Starts below its line and rises into place (default). + #[default] + Up, + /// Starts above its line and falls into place. + Down, + /// Starts to the right and slides left into place. + Left, + /// Starts to the left and slides right into place. + Right, +} + +impl TextAnimDirection { + /// The `(x, y)` offset, in px, a unit sits at when its animation has not + /// started yet. `travel` is the distance the unit covers. + pub fn offset(self, travel: f32) -> (f32, f32) { + match self { + Self::Up => (0.0, travel), + Self::Down => (0.0, -travel), + Self::Left => (travel, 0.0), + Self::Right => (-travel, 0.0), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "snake_case")] #[derive(PartialEq)] @@ -934,6 +1040,171 @@ pub struct GlowConfig { pub intensity: f32, } +/// A later label a `text` swaps to, and when. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TextState { + /// Time (seconds, scene-local) at which this label takes over. + pub at: f64, + /// The label from `at` onwards. + pub content: String, +} + +/// How a `text` crosses from one of its `states` to the next. +/// +/// Both labels are on screen at once during the window: the outgoing one +/// leaves upwards while blurring out, the incoming one arrives from below +/// while sharpening. Cutting between them instead reads as a glitch, and +/// fading alone reads as two unrelated labels rather than one value changing. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TextSwapConfig { + /// How long the crossing takes (seconds). + #[serde(default = "default_swap_duration")] + pub duration: f64, + /// Vertical travel of each label, in px. + #[serde(default = "default_swap_distance")] + pub distance: f32, + /// Peak blur sigma (px) reached by a label at the far end of its travel. + /// `0` gives a pure slide-and-fade. + #[serde(default = "default_swap_blur")] + pub blur: f32, +} + +impl Default for TextSwapConfig { + fn default() -> Self { + Self { + duration: default_swap_duration(), + distance: default_swap_distance(), + blur: default_swap_blur(), + } + } +} + +fn default_swap_duration() -> f64 { + 0.45 +} + +fn default_swap_distance() -> f32 { + 18.0 +} + +fn default_swap_blur() -> f32 { + 8.0 +} + +/// Shape of a text caret. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CaretShape { + /// A thin vertical rule, like a text editor's insertion point. + #[default] + Line, + /// A filled block covering a character cell, like a terminal. + Block, +} + +/// A caret pinned to a `text`'s reveal head. +/// +/// Only meaningful alongside a `typewriter` animation: the caret follows the +/// last revealed character and stops at the end of the line once the reveal +/// finishes. Composing a standalone `cursor` component next to the text gets +/// you a caret that stays where you put it while the text grows out from +/// under it, which is the thing this exists to avoid. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CaretConfig { + /// Caret shape. + #[serde(default)] + pub shape: CaretShape, + /// Caret colour (hex). Defaults to the text's own colour, so a caret + /// inherits a theme change without being restated. + #[serde(default)] + pub color: Option, + /// Blink period in seconds — one full off/on cycle. `0` disables the + /// blink and leaves the caret solid. + #[serde(default = "default_caret_blink")] + pub blink: f32, + /// Hide the caret once the reveal has finished, instead of leaving it + /// parked at the end of the text. + #[serde(default)] + pub hide_when_done: bool, +} + +impl Default for CaretConfig { + fn default() -> Self { + Self { + shape: CaretShape::default(), + color: None, + blink: default_caret_blink(), + hide_when_done: false, + } + } +} + +fn default_caret_blink() -> f32 { + 1.0 +} + +/// A band of light sweeping across the element, restricted to the pixels the +/// element actually painted. +/// +/// That restriction is the whole point: painted over the element's *box*, a +/// sheen reads as a rectangle sliding past. Painted over the element's own +/// alpha, it reads as light catching the glyphs (or the icon, or the chart +/// bars) themselves. See `engine::paint_pass`, which composites it with +/// `BlendMode::SrcATop` inside the node's own layer. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ShimmerConfig { + /// Delay before the sweep starts (seconds). + #[serde(default)] + pub delay: f64, + /// How long one sweep takes (seconds). + #[serde(default = "default_shimmer_duration")] + pub duration: f64, + /// Colour of the band (hex). + #[serde(default = "default_shimmer_color")] + pub color: String, + /// Band width as a fraction of the distance it sweeps (0.35 = a band + /// covering roughly a third of the element at any instant). Wider reads + /// as a soft wash, narrower as a hard glint. + #[serde(default = "default_shimmer_width")] + pub width: f32, + /// Peak opacity of the band (0..1). + #[serde(default = "default_shimmer_intensity")] + pub intensity: f32, + /// Lean of the band in degrees. `0` is an upright band sweeping + /// left-to-right; the default 20 tilts it, which is what makes it read + /// as a reflection rather than a wipe. The band always travels + /// perpendicular to itself, so this angles the travel too. + #[serde(default = "default_shimmer_angle")] + pub angle: f32, + /// Repeat the sweep for the rest of the scene. + #[serde(default, rename = "loop")] + pub repeat: bool, +} + +fn default_shimmer_duration() -> f64 { + 0.9 +} + +fn default_shimmer_color() -> String { + "#FFFFFF".to_string() +} + +fn default_shimmer_width() -> f32 { + 0.35 +} + +fn default_shimmer_intensity() -> f32 { + 0.75 +} + +fn default_shimmer_angle() -> f32 { + 20.0 +} + fn default_glow_color() -> String { "#FFFFFF80".to_string() } diff --git a/crates/rustmotion-core/tests/chromatic_wipe.rs b/crates/rustmotion-core/tests/chromatic_wipe.rs new file mode 100644 index 0000000..271b227 --- /dev/null +++ b/crates/rustmotion-core/tests/chromatic_wipe.rs @@ -0,0 +1,147 @@ +//! `chromatic_wipe`: a fast slide whose reveal edge splits into red and cyan +//! at the peak and recombines as it lands. +//! +//! The two properties worth pinning are that `aberration: 0` degrades to a +//! plain slide (so the knob genuinely goes to zero rather than to "a bit +//! less"), and that the split is *gone* by the time the transition ends — +//! a residual channel offset on the last frame would bleed a coloured fringe +//! into the scene that follows. + +use rustmotion_core::engine::transition::{apply_transition, TransitionOptions}; +use rustmotion_core::schema::{TransitionDirection, TransitionType}; + +const W: u32 = 64; +const H: u32 = 48; + +/// Two flat frames, so any channel offset shows up as a colour that is in +/// neither of them. +fn frames() -> (Vec, Vec) { + let a: Vec = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect(); + let b: Vec = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect(); + (a, b) +} + +fn opts(aberration: f32, direction: TransitionDirection) -> TransitionOptions { + TransitionOptions { + aberration, + direction, + ..TransitionOptions::default() + } +} + +fn composite(progress: f64, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, &TransitionType::ChromaticWipe, o) +} + +fn slide(progress: f64) -> Vec { + let (a, b) = frames(); + apply_transition( + &a, + &b, + W, + H, + progress, + &TransitionType::Slide, + &TransitionOptions::default(), + ) +} + +#[test] +fn aberration_zero_is_a_plain_slide() { + for p in [0.0, 0.25, 0.5, 0.75, 1.0] { + assert_eq!( + composite(p, &opts(0.0, TransitionDirection::Left)), + slide(p), + "with the split turned off, the composite must be byte-identical to `slide` at \ + progress {p}" + ); + } +} + +#[test] +fn the_split_peaks_in_the_middle_and_is_gone_at_both_ends() { + let o = opts(1.0, TransitionDirection::Left); + // At the ends the frames are whole, so the composite matches a plain + // slide exactly. + assert_eq!( + composite(0.0, &o), + slide(0.0), + "no split on the first frame of the transition" + ); + assert_eq!( + composite(1.0, &o), + slide(1.0), + "no split on the last frame — a residual fringe would bleed into the next scene" + ); + // In the middle, the seam between the two frames has to differ. + assert_ne!( + composite(0.5, &o), + slide(0.5), + "mid-transition the channels should be visibly split" + ); +} + +#[test] +fn a_bigger_aberration_splits_further() { + let mid = 0.5; + let subtle = composite(mid, &opts(0.5, TransitionDirection::Left)); + let loud = composite(mid, &opts(2.0, TransitionDirection::Left)); + let plain = slide(mid); + + // Distance from the un-split composite: more aberration, more difference. + let distance = |x: &[u8]| -> u64 { + x.iter() + .zip(plain.iter()) + .map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs() as u64) + .sum() + }; + assert!( + distance(&loud) > distance(&subtle), + "aberration 2.0 should split further than 0.5 (loud={}, subtle={})", + distance(&loud), + distance(&subtle) + ); +} + +#[test] +fn every_direction_lands_on_the_incoming_frame() { + // Whichever way it travels, the transition has to *finish*: a direction + // whose arithmetic left the incoming frame off-screen at progress 1 + // would end the cut on the wrong scene. + let (_, b) = frames(); + for direction in [ + TransitionDirection::Left, + TransitionDirection::Right, + TransitionDirection::Up, + TransitionDirection::Down, + ] { + let end = composite(1.0, &opts(1.0, direction)); + assert_eq!( + end, b, + "travelling {direction:?}, progress 1.0 must show frame B alone" + ); + } +} + +#[test] +fn each_direction_produces_a_different_mid_frame() { + let mids: Vec> = [ + TransitionDirection::Left, + TransitionDirection::Right, + TransitionDirection::Up, + TransitionDirection::Down, + ] + .into_iter() + .map(|d| composite(0.35, &opts(1.0, d))) + .collect(); + + for i in 0..mids.len() { + for j in (i + 1)..mids.len() { + assert_ne!( + mids[i], mids[j], + "directions {i} and {j} produced the same frame — the direction is being ignored" + ); + } + } +} diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 7075b75..ca6f8e6 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -1,8 +1,8 @@ -use crate::engine::transition::{apply_transition, camera_pan_transition}; +use crate::engine::transition::{apply_transition, camera_pan_transition, TransitionOptions}; use crate::error::{Result, RustmotionError}; use crate::schema::{ - EasingType, PixelDissolveOrder, ResolvedScenario as Scenario, ResolvedView, Scene, - TransitionCorner, TransitionType, VideoConfig, ViewType, + EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionType, VideoConfig, + ViewType, }; /// Description of what to render for a specific frame @@ -40,12 +40,10 @@ pub enum FrameTask { scene_a_total_frames: u32, scene_b_total_frames: u32, transition_type: TransitionType, - /// Which corner a `corner_reveal` grows from; inert for every other type. - corner: TransitionCorner, - /// `pixel_dissolve` only: cell edge and scatter seed. - cell: f32, - seed: u32, - order: PixelDissolveOrder, + /// Per-type knobs (`corner_reveal`'s corner, `pixel_dissolve`'s + /// cell/seed/order, `chromatic_wipe`'s direction/aberration); each + /// is inert for every transition but the one that reads it. + options: TransitionOptions, transition_duration: f64, easing: EasingType, }, @@ -75,12 +73,10 @@ pub enum FrameTask { view_b_idx: usize, frame_in_transition: u32, transition_type: TransitionType, - /// Which corner a `corner_reveal` grows from; inert for every other type. - corner: TransitionCorner, - /// `pixel_dissolve` only: cell edge and scatter seed. - cell: f32, - seed: u32, - order: PixelDissolveOrder, + /// Per-type knobs (`corner_reveal`'s corner, `pixel_dissolve`'s + /// cell/seed/order, `chromatic_wipe`'s direction/aberration); each + /// is inert for every transition but the one that reads it. + options: TransitionOptions, transition_duration: f64, easing: EasingType, }, @@ -173,10 +169,7 @@ pub fn render_frame_task_scaled( scene_a_total_frames, scene_b_total_frames, transition_type, - corner, - cell, - seed, - order, + options, transition_duration, easing, } => { @@ -310,10 +303,7 @@ pub fn render_frame_task_scaled( scaled_h, progress, transition_type, - *corner, - *cell, - *seed, - *order, + options, ); apply_post_effects( &mut composited, @@ -368,10 +358,7 @@ pub fn render_frame_task_scaled( view_b_idx, frame_in_transition, transition_type, - corner, - cell, - seed, - order, + options, transition_duration, easing: _, } => { @@ -401,10 +388,7 @@ pub fn render_frame_task_scaled( scaled_h, progress, transition_type, - *corner, - *cell, - *seed, - *order, + options, ); // By symmetry with `SlideTransition` (which applies scene_b's // effects to the blended result, "the transition is the entry @@ -576,10 +560,7 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { view_b_idx: view_idx, frame_in_transition: f, transition_type: transition.transition_type.clone(), - corner: transition.corner, - cell: transition.cell, - seed: transition.seed, - order: transition.order, + options: transition.into(), transition_duration: transition.duration, easing: transition.easing.clone(), }); @@ -708,10 +689,7 @@ fn build_slide_view_tasks( scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), - corner: transition.corner, - cell: transition.cell, - seed: transition.seed, - order: transition.order, + options: transition.into(), transition_duration: outgoing_effective_duration, easing: easing.clone(), }); @@ -903,10 +881,7 @@ pub(super) fn build_slot_frame_tasks( view_b_idx: *view_idx, frame_in_transition: f, transition_type: transition.transition_type.clone(), - corner: transition.corner, - cell: transition.cell, - seed: transition.seed, - order: transition.order, + options: transition.into(), transition_duration: transition.duration, easing: transition.easing.clone(), }); @@ -976,10 +951,7 @@ pub(super) fn build_scene_frame_tasks_in_view( scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), - corner: transition.corner, - cell: transition.cell, - seed: transition.seed, - order: transition.order, + options: transition.into(), transition_duration: outgoing_effective_duration, easing: easing.clone(), }); diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index c5d0ae2..a50db3f 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -2,8 +2,8 @@ use skia_safe::{Canvas, ColorType, ImageInfo, Paint}; use crate::schema::{ AnimatedBackground, BackgroundPreset, ConcentricCirclesConfig, GradientShiftConfig, - GradientType, GridDotsConfig, HaloConfig, HaloZone, HeropatternConfig, PixelDensityRamp, - PixelGridConfig, PixelGridMotion, ScrollDirection, + GradientType, GridDotsConfig, GridLinesConfig, HaloConfig, HaloZone, HeropatternConfig, + PixelDensityRamp, PixelGridConfig, PixelGridMotion, ScrollDirection, }; use rustmotion_core::engine::renderer::{color4f_from_hex, paint_from_hex}; @@ -42,6 +42,7 @@ pub(super) fn draw_animated_background( BackgroundPreset::GridDots(cfg) => { draw_bg_grid_dots(canvas, cfg, time, width, height, phase_origin) } + BackgroundPreset::GridLines(cfg) => draw_bg_grid_lines(canvas, cfg, width, height), BackgroundPreset::ConcentricCircles(cfg) => { draw_bg_concentric_circles(canvas, cfg, bg.speed, time, width, height) } @@ -390,6 +391,45 @@ fn draw_bg_grid_dots( } } +/// A ruled grid of horizontal and vertical lines. +/// +/// Unlike `grid_dots`, nothing here pulses: a grid is structure behind the +/// content, and structure that breathes competes with what sits on it. The +/// motion, if any, comes from the shared `speed`/`direction` scroll applied by +/// the caller's canvas translate. +fn draw_bg_grid_lines(canvas: &Canvas, cfg: &GridLinesConfig, width: f32, height: f32) { + let cell = cfg.cell.max(4.0); + let mut minor = paint_from_hex(&cfg.color); + minor.set_anti_alias(false); // hairlines stay crisp unblurred + minor.set_stroke_width(cfg.weight.max(0.5)); + minor.set_style(skia_safe::PaintStyle::Stroke); + let mut major = minor.clone(); + major.set_stroke_width(cfg.major_weight.max(0.5)); + + // One cell of overscan on every side: the caller wraps the scroll offset + // into `(-cell, cell)`, so without the margin a scrolled grid leaves a + // blank band on the leading edge (the same trap `draw_bg_grid_dots` + // documents on its own loop). + let pick = |index: i32| -> &Paint { + if cfg.major_every > 0 && index.rem_euclid(cfg.major_every as i32) == 0 { + &major + } else { + &minor + } + }; + + let cols = ((width / cell).ceil() as i32) + 2; + for i in -1..cols { + let x = i as f32 * cell; + canvas.draw_line((x, -cell), (x, height + cell), pick(i)); + } + let rows = ((height / cell).ceil() as i32) + 2; + for j in -1..rows { + let y = j as f32 * cell; + canvas.draw_line((-cell, y), (width + cell, y), pick(j)); + } +} + /// Deterministic 0..1 value for a cell. /// /// A hash of the coordinates, not a random number generator: the same cell @@ -754,6 +794,7 @@ pub(super) fn interpolate_animated_bg( fn tile_spacing(preset: &BackgroundPreset) -> f32 { match preset { BackgroundPreset::GridDots(cfg) => cfg.spacing.max(20.0), + BackgroundPreset::GridLines(cfg) => cfg.cell.max(4.0), // The lattice repeats on `spacing`, so the world-view camera can wrap // on it and the tiling stays seamless as the camera pans. BackgroundPreset::PixelGrid(cfg) => cfg.spacing.max(cfg.size.max(1.0)), @@ -1242,3 +1283,138 @@ mod pixel_grid_tests { draw_bg_pixel_grid(canvas, &over, 1.0, 0.0, 32.0, 32.0); } } + +#[cfg(test)] +mod grid_lines_tests { + //! `grid_lines` is the ruled counterpart of `grid_dots`: dots mark the + //! intersections and read as texture, lines read as structure. The + //! properties worth pinning are that the lines actually land on the cell + //! pitch, that `major_every` thickens the right ones, and that the grid + //! covers the frame edge to edge (the trap `grid_dots` already documents + //! on its own loop: an asymmetric overscan leaves a blank leading band + //! once the background scrolls). + + use super::*; + use crate::schema::GridLinesConfig; + + const W: i32 = 200; + const H: i32 = 120; + + fn render(cfg: GridLinesConfig) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + draw_bg_grid_lines(surface.canvas(), &cfg, W as f32, H as f32); + let snapshot = surface.image_snapshot(); + let info = ImageInfo::new( + (W, H), + ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (W * H * 4) as usize]; + assert!(snapshot.read_pixels( + &info, + &mut buf, + (W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + )); + buf + } + + fn alpha_at(buf: &[u8], x: i32, y: i32) -> u8 { + buf[((y * W + x) * 4) as usize + 3] + } + + fn column_ink(buf: &[u8], x: i32) -> u32 { + (0..H).map(|y| alpha_at(buf, x, y) as u32).sum() + } + + fn base(cell: f32) -> GridLinesConfig { + GridLinesConfig { + color: "#FFFFFFFF".into(), + cell, + weight: 1.0, + major_every: 0, + major_weight: 2.0, + } + } + + #[test] + fn lines_land_on_the_cell_pitch() { + let buf = render(base(40.0)); + for x in [0, 40, 80, 120, 160] { + assert!( + column_ink(&buf, x) > 0, + "a vertical line should sit at x={x} (one cell pitch apart)" + ); + } + // And a mid-cell column carries only where the *horizontal* lines + // cross it — a few pixels, not a full column. Without that gap this + // would be a wash, not a grid. + for x in [20, 60, 100] { + assert!( + column_ink(&buf, x) * 10 < column_ink(&buf, 40), + "x={x} sits mid-cell: it should only carry the horizontal crossings ({}) not a \ + full line ({})", + column_ink(&buf, x), + column_ink(&buf, 40) + ); + } + } + + #[test] + fn the_grid_reaches_all_four_edges() { + let buf = render(base(40.0)); + // Corners: the horizontal line at y=0 and the vertical at x=0 both + // pass through, and the far edges are covered by the overscan. + assert!(alpha_at(&buf, 0, 0) > 0, "top-left corner is on the grid"); + assert!( + (0..H).any(|y| alpha_at(&buf, W - 1, y) > 0), + "the right edge must be reached by horizontal lines" + ); + assert!( + (0..W).any(|x| alpha_at(&buf, x, H - 1) > 0), + "the bottom edge must be reached by vertical lines" + ); + } + + #[test] + fn major_every_thickens_only_the_major_lines() { + let cfg = GridLinesConfig { + major_every: 2, + major_weight: 5.0, + ..base(40.0) + }; + let buf = render(cfg); + // Index 2 (x=80) is major, index 1 (x=40) is not. Compare the ink in + // a band around each: the thick line spills into its neighbours. + let band = + |centre: i32| -> u32 { (centre - 3..=centre + 3).map(|x| column_ink(&buf, x)).sum() }; + assert!( + band(80) > band(40) * 2, + "the major line at x=80 should be markedly heavier than the minor one at x=40 \ + (major={}, minor={})", + band(80), + band(40) + ); + } + + #[test] + fn a_zero_major_every_leaves_every_line_the_same_weight() { + let buf = render(base(40.0)); + let band = + |centre: i32| -> u32 { (centre - 3..=centre + 3).map(|x| column_ink(&buf, x)).sum() }; + assert_eq!( + band(40), + band(80), + "with major_every: 0 no line is special-cased" + ); + } + + #[test] + fn a_degenerate_cell_does_not_hang_the_render() { + // 0 would be an infinite loop's worth of lines; the painter clamps. + let buf = render(base(0.0)); + assert_eq!(buf.len(), (W * H * 4) as usize, "it still produced a frame"); + } +} diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 54bf562..784e3d1 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -49,6 +49,12 @@ mod component_smoke { "qr_code", r#"{"type":"qr_code","content":"https://example.com"}"#, ), + ("success_check", r#"{"type":"success_check"}"#), + ("number_wheel", r#"{"type":"number_wheel","value":"30222"}"#), + ( + "pointer", + r#"{"type":"pointer","path":[{"time":0.0,"x":0.0,"y":0.0},{"time":1.0,"x":200.0,"y":120.0}]}"#, + ), ("rating", r#"{"type":"rating","value":3.5}"#), ("skeleton", r#"{"type":"skeleton"}"#), ("slider", r#"{"type":"slider","value":50}"#), diff --git a/crates/rustmotion/tests/animated_box_size.rs b/crates/rustmotion/tests/animated_box_size.rs new file mode 100644 index 0000000..50d1bdb --- /dev/null +++ b/crates/rustmotion/tests/animated_box_size.rs @@ -0,0 +1,138 @@ +//! Regression test — animated `width`/`height` must reach the layout pass. +//! +//! `engine::animator` has always resolved a `keyframes` effect targeting +//! `width`/`height` into `AnimatedProperties::{width,height}` (animator.rs, +//! the `"width" => props.width = …` arm), but `css::animation:: +//! apply_animated_props` deliberately dropped those two on the floor: they +//! were never translated into `CssStyle`, so taffy never saw them and the box +//! kept its authored size for the whole scene. The animation resolved to +//! numbers nobody read. +//! +//! That is the difference between a card *resizing* — its children reflowing +//! inside a new box — and a card being *scaled*, which stretches the pixels it +//! already had, text included. Only the former is expressible with `scale`'s +//! absence here; the latter was the only thing authors could actually get. + +use rustmotion::engine::render::render_scene_hits; +use rustmotion::schema::{Scene, VideoConfig}; + +fn config() -> VideoConfig { + serde_json::from_value(serde_json::json!({ + "width": 1920, + "height": 1080, + "fps": 30 + })) + .expect("video config is schema-valid") +} + +/// A card that grows 330×132 → 560×210 over the first second, holding a text +/// child whose own box has to follow the parent's new width. +fn resizing_card_scene() -> Scene { + serde_json::from_value(serde_json::json!({ + "duration": 2.0, + "children": [{ + "type": "card", + "style": { + "width": 330, + "height": 132, + "background": "#1E293B", + // Centred content, so the child's *position* tracks the box's + // growth on both axes — not just its stretched height. + "justify-content": "center", + "align-items": "center", + "animation": [{ + "name": "keyframes", + "duration": 1.0, + "keyframes": [ + { + "property": "width", + "easing": "linear", + "keyframes": [ + { "time": 0.0, "value": 330 }, + { "time": 1.0, "value": 560 } + ] + }, + { + "property": "height", + "easing": "linear", + "keyframes": [ + { "time": 0.0, "value": 132 }, + { "time": 1.0, "value": 210 } + ] + } + ] + }] + }, + "children": [ + { "type": "text", "content": "Detail", "style": { "font-size": 24, "color": "#FFFFFF" } } + ] + }] + })) + .expect("scene is schema-valid") +} + +fn card_rect(frame: u32) -> (f32, f32) { + let hits = render_scene_hits(&config(), &resizing_card_scene(), frame); + let card = hits + .iter() + .find(|h| h.kind == "card") + .expect("card hit present in render_scene_hits output"); + (card.rect.w, card.rect.h) +} + +#[test] +fn animated_width_and_height_resize_the_laid_out_box() { + let (w0, h0) = card_rect(0); // t = 0.0s + let (w_mid, h_mid) = card_rect(15); // t = 0.5s + let (w_end, h_end) = card_rect(30); // t = 1.0s + + assert!( + (w0 - 330.0).abs() < 1.0 && (h0 - 132.0).abs() < 1.0, + "at t=0 the card should still be at its authored 330×132, got {w0}×{h0}" + ); + assert!( + (w_end - 560.0).abs() < 1.0 && (h_end - 210.0).abs() < 1.0, + "at t=1s the card should have reached 560×210, got {w_end}×{h_end}" + ); + // The interesting frame: strictly between the two, which is what fails + // when the animated size never reaches taffy (it would read 330×132). + assert!( + w_mid > w0 + 1.0 && w_mid < w_end - 1.0, + "mid-animation width should sit strictly between 330 and 560, got {w_mid}" + ); + assert!( + h_mid > h0 + 1.0 && h_mid < h_end - 1.0, + "mid-animation height should sit strictly between 132 and 210, got {h_mid}" + ); +} + +#[test] +fn the_child_reflows_inside_the_resized_card() { + // A resize is a layout change, so the child's box has to move with the + // parent's new content box. A `scale` transform would leave the child's + // laid-out rect untouched and only stretch its pixels — this assertion is + // what separates the two. + let hits_start = render_scene_hits(&config(), &resizing_card_scene(), 0); + let hits_end = render_scene_hits(&config(), &resizing_card_scene(), 30); + + let child_rect = |hits: &[rustmotion_core::engine::paint_pass::EnrichedHit]| { + hits.iter() + .find(|h| h.kind == "text") + .expect("text child hit present") + .rect + }; + + let start = child_rect(&hits_start); + let end = child_rect(&hits_end); + + assert!( + (start.x - end.x).abs() > 1.0, + "the centred text child should have been pushed right by the card's extra width, but its \ + x is unchanged: start={start:?} end={end:?}" + ); + assert!( + (start.y - end.y).abs() > 1.0, + "the centred text child should have been pushed down by the card's extra height, but its \ + y is unchanged: start={start:?} end={end:?}" + ); +} diff --git a/crates/rustmotion/tests/shimmer_effect.rs b/crates/rustmotion/tests/shimmer_effect.rs new file mode 100644 index 0000000..18e8ed5 --- /dev/null +++ b/crates/rustmotion/tests/shimmer_effect.rs @@ -0,0 +1,166 @@ +//! The `shimmer` effect lights the element's own pixels, not its box. +//! +//! A sweeping band painted over a node's *rectangle* reads as a lit rectangle +//! sliding past the element. Painted against the node's own alpha it reads as +//! light catching the element itself — which is the effect anyone asking for a +//! shimmer actually wants. `engine::paint_pass` gets that by isolating the +//! node in its own layer and stamping the band with `BlendMode::SrcATop`. +//! +//! The proof below is a circle: the corners of its box are inside the +//! rectangle a naive implementation would light, and outside the shape. + +use rustmotion::engine::render::render_scene_frame_scaled; +use rustmotion::schema::{Scene, VideoConfig}; + +const W: u32 = 400; +const H: u32 = 400; + +fn config() -> VideoConfig { + serde_json::from_value(serde_json::json!({ "width": W, "height": H, "fps": 30 })) + .expect("config") +} + +/// A white circle inscribed in a 200×200 box at (100, 100), on black. +/// `shimmer` is attached only when `with_shimmer` — the two renders are +/// otherwise byte-identical, so any difference is the band and nothing else. +fn circle_scene(with_shimmer: bool) -> Scene { + let animation = if with_shimmer { + serde_json::json!([{ + "name": "shimmer", + "delay": 0.0, + "duration": 1.0, + "color": "#FF0000", + "intensity": 1.0, + "width": 0.9, + "angle": 0.0 + }]) + } else { + serde_json::json!([]) + }; + serde_json::from_value(serde_json::json!({ + "duration": 2.0, + "background": "#000000", + "children": [{ + "type": "shape", + "shape": "circle", + "fill": "#FFFFFF", + "position": "absolute", + "x": 100, + "y": 100, + "style": { "width": "200px", "height": "200px", "animation": animation } + }] + })) + .expect("scene is schema-valid") +} + +fn pixel_at(buf: &[u8], x: u32, y: u32) -> [u8; 4] { + let base = ((y * W + x) * 4) as usize; + [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]] +} + +/// The scene is 2s at 30fps; scene-local time comes from the frame index. +const SCENE_FRAMES: u32 = 60; + +fn render(with_shimmer: bool, time: f64) -> Vec { + let frame = (time * 30.0).round() as u32; + render_scene_frame_scaled( + &config(), + &circle_scene(with_shimmer), + frame, + time, + SCENE_FRAMES, + 1.0, + ) + .expect("render") +} + +#[test] +fn the_band_lights_the_shape_but_never_the_empty_corners_of_its_box() { + // Mid-sweep, with a band wide enough to span the whole box. + let plain = render(false, 0.5); + let shimmered = render(true, 0.5); + + // Inside the circle: the red band must have visibly tinted it. + let centre_plain = pixel_at(&plain, 200, 200); + let centre_lit = pixel_at(&shimmered, 200, 200); + assert_eq!( + centre_plain, + [255, 255, 255, 255], + "test setup: the circle's centre should be pure white without a shimmer" + ); + assert!( + centre_lit[1] < 230 || centre_lit[2] < 230, + "the band should have visibly tinted the circle's centre, got {centre_lit:?}" + ); + + // The corners of the same box are inside the rectangle a box-wide band + // would light, but outside the shape — they must be untouched. + for (x, y) in [(105, 105), (295, 105), (105, 295), (295, 295)] { + assert_eq!( + pixel_at(&plain, x, y), + pixel_at(&shimmered, x, y), + "({x},{y}) is inside the shape's box but outside the shape — the band must not \ + reach it" + ); + } +} + +#[test] +fn the_sweep_moves_across_the_element_over_time() { + // A narrow band at two instants must light two different parts of the + // circle — otherwise it is a static wash, not a sweep. + let narrow = |time: f64| -> Vec { + let scene: Scene = serde_json::from_value(serde_json::json!({ + "duration": 2.0, + "background": "#000000", + "children": [{ + "type": "shape", + "shape": "circle", + "fill": "#FFFFFF", + "position": "absolute", + "x": 100, + "y": 100, + "style": { + "width": "200px", "height": "200px", + "animation": [{ + "name": "shimmer", "duration": 1.0, "color": "#FF0000", + "intensity": 1.0, "width": 0.15, "angle": 0.0 + }] + } + }] + })) + .expect("scene is schema-valid"); + let frame = (time * 30.0).round() as u32; + render_scene_frame_scaled(&config(), &scene, frame, time, SCENE_FRAMES, 1.0) + .expect("render") + }; + + // Sample a row through the circle's centre and find where the band is + // darkest in green (i.e. most saturated red). + let band_centre = |buf: &[u8]| -> Option { + (130..270) + .filter(|&x| pixel_at(buf, x, 200)[3] == 255) + .min_by_key(|&x| pixel_at(buf, x, 200)[1]) + }; + + let early = band_centre(&narrow(0.3)).expect("band visible at 30%"); + let late = band_centre(&narrow(0.7)).expect("band visible at 70%"); + + assert!( + late > early + 20, + "the band should have travelled measurably across the circle between 30% and 70% of \ + the sweep (early={early}, late={late})" + ); +} + +#[test] +fn a_finished_sweep_leaves_the_element_exactly_as_it_was() { + // Past its duration a non-looping shimmer must be gone — not parked at + // the far edge still tinting the last column of pixels. + let plain = render(false, 1.5); + let after = render(true, 1.5); + assert_eq!( + plain, after, + "after its duration, a non-looping shimmer must leave no trace on the frame" + ); +} diff --git a/examples/assets/ferriskey-logo.svg b/examples/assets/ferriskey-logo.svg new file mode 100644 index 0000000..15afc5b --- /dev/null +++ b/examples/assets/ferriskey-logo.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/ferriskey-launch-60s.json b/examples/ferriskey-launch-60s.json new file mode 100644 index 0000000..ebd2cd0 --- /dev/null +++ b/examples/ferriskey-launch-60s.json @@ -0,0 +1,7295 @@ +{ + "version": "1.0", + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "background": "#09090b" + }, + "fonts": [ + { + "family": "Inter", + "source": "google", + "weights": [ + 400, + 600, + 700, + 900 + ] + } + ], + "scenes": [ + { + "duration": 3.9, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#7C4A21", + "position": "absolute", + "x": 500, + "y": 80, + "style": { + "width": 920, + "height": 920, + "opacity": 1.0, + "filter": [ + { + "fn": "blur", + "radius": 190 + } + ], + "animation": [ + { + "name": "keyframes", + "delay": 0.6, + "duration": 2.3, + "keyframes": [ + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0 + }, + { + "time": 2.3, + "value": 0.5 + } + ] + }, + { + "property": "scale", + "easing": { + "cubic_bezier": { + "x1": 0.22, + "y1": 1.0, + "x2": 0.36, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.12 + }, + { + "time": 2.3, + "value": 1.0 + } + ] + } + ] + } + ] + } + } + ] + }, + { + "type": "shape", + "shape": "circle", + "fill": "#a1a1aa", + "position": "absolute", + "x": 948, + "y": 300, + "bleed": true, + "style": { + "width": 24, + "height": 24, + "opacity": 0.9, + "animation": [ + { + "name": "keyframes", + "delay": 0.0, + "duration": 0.6, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.55, + "y1": 0, + "x2": 0.95, + "y2": 0.35 + } + }, + "keyframes": [ + { + "time": 0, + "value": -320 + }, + { + "time": 0.55, + "value": 228 + } + ] + }, + { + "property": "scale", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0.53, + "value": 1.0 + }, + { + "time": 0.6, + "value": 0.0 + } + ] + } + ] + } + ] + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#00000000", + "stroke": { + "color": "#7C4A21", + "width": 3 + }, + "position": "absolute", + "x": 860, + "y": 440, + "style": { + "width": 200, + "height": 200, + "animation": [ + { + "name": "keyframes", + "delay": 0.58, + "duration": 1.2, + "keyframes": [ + { + "property": "scale", + "easing": { + "cubic_bezier": { + "x1": 0.22, + "y1": 1.0, + "x2": 0.36, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.1 + }, + { + "time": 1.2, + "value": 3.4 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.65, + "y1": 0.0, + "x2": 0.35, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.85 + }, + { + "time": 1.2, + "value": 0.0 + } + ] + } + ] + } + ] + } + }, + { + "type": "svg", + "position": "absolute", + "x": 260, + "y": 140, + "draw_stroke_width": 3, + "draw_overlap": 0.5, + "data": "", + "style": { + "width": 1400, + "height": 800, + "opacity": 0.85, + "animation": [ + { + "name": "draw_in", + "delay": 0.7, + "duration": 2.4 + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.1 + }, + { + "time": 0.5, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "pixel_grid", + "pixel_grid": { + "colors": [ + "#ffffff07" + ], + "size": 3, + "spacing": 7, + "density": 0.55, + "density_ramp": "none", + "seed": 41 + }, + "speed": 0 + } + ] + }, + { + "duration": 3.7, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#7C4A21", + "position": "absolute", + "x": 560, + "y": 160, + "style": { + "width": 900, + "height": 820, + "opacity": 0.4, + "filter": [ + { + "fn": "blur", + "radius": 200 + } + ] + } + } + ] + }, + { + "type": "svg", + "position": "absolute", + "x": 300, + "y": 230, + "draw_stroke_width": 2, + "draw_overlap": 0.85, + "data": "", + "style": { + "width": 1320, + "height": 620, + "opacity": 0.4, + "animation": [ + { + "name": "draw_in", + "delay": 0.0, + "duration": 1.4 + } + ] + } + }, + { + "type": "div", + "position": "absolute", + "x": 300, + "y": 230, + "style": { + "width": 1320, + "height": 620 + }, + "children": [ + { + "for-each": [ + { + "cx": 158, + "cy": 108, + "r": 26, + "d": 0.0 + }, + { + "cx": 778, + "cy": 138, + "r": 30, + "d": 0.08 + }, + { + "cx": 1108, + "cy": 98, + "r": 22, + "d": 0.16 + }, + { + "cx": 288, + "cy": 318, + "r": 22, + "d": 0.24 + }, + { + "cx": 1168, + "cy": 348, + "r": 20, + "d": 0.32 + }, + { + "cx": 828, + "cy": 458, + "r": 28, + "d": 0.4 + } + ], + "template": { + "type": "shape", + "shape": "circle", + "fill": "#7C4A21", + "stroke": { + "color": "#8A5A2B", + "width": 2 + }, + "position": "absolute", + "x": "$cx", + "y": "$cy", + "style": { + "width": "$r", + "height": "$r", + "animation": [ + { + "name": "scale_in", + "delay": "$d", + "duration": 0.4, + "overshoot": 0.0 + } + ] + } + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 1.0, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 0, + "padding": { + "top": 0, + "right": 120, + "bottom": 0, + "left": 120 + } + }, + "children": [ + { + "type": "text", + "content": "NOT A HACK.", + "style": { + "font-family": "Inter", + "font-size": 88, + "font-weight": 900, + "color": "#fafafa", + "line-height": 1.28, + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.15, + "duration": 0.38, + "stagger": 0.045, + "scale_from": 0.9, + "blur": 18, + "distance": 1.4 + } + ] + } + }, + { + "type": "text", + "content": "A MEMORY LEAK. A NULL POINTER.", + "style": { + "font-family": "Inter", + "font-size": 88, + "font-weight": 900, + "color": "#fafafa", + "line-height": 1.28, + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.26, + "duration": 0.38, + "stagger": 0.045, + "scale_from": 0.9, + "blur": 18, + "distance": 1.4 + } + ] + } + }, + { + "type": "text", + "content": "A CVE NOBODY WANTED TO NAME.", + "style": { + "font-family": "Inter", + "font-size": 88, + "font-weight": 900, + "color": "#fafafa", + "line-height": 1.28, + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.37, + "duration": 0.38, + "stagger": 0.045, + "scale_from": 0.9, + "blur": 18, + "distance": 1.4 + } + ] + } + } + ] + } + ], + "transition": { + "type": "dissolve", + "duration": 0.28 + }, + "camera": { + "keyframes": [ + { + "property": "x", + "values": [ + { + "time": 0, + "value": -120 + }, + { + "time": 0.34, + "value": 0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + } + }, + { + "duration": 5.6, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#7C4A21", + "position": "absolute", + "x": 560, + "y": 140, + "style": { + "width": 840, + "height": 800, + "opacity": 1.0, + "filter": [ + { + "fn": "blur", + "radius": 200 + } + ], + "animation": [ + { + "name": "keyframes", + "delay": 0.1, + "duration": 1.3, + "keyframes": [ + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.65, + "y1": 0.0, + "x2": 0.35, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.46 + }, + { + "time": 1.3, + "value": 0.0 + } + ] + } + ] + } + ] + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#f59e0b", + "position": "absolute", + "x": 600, + "y": 180, + "style": { + "width": 780, + "height": 740, + "opacity": 1.0, + "filter": [ + { + "fn": "blur", + "radius": 210 + } + ], + "animation": [ + { + "name": "keyframes", + "delay": 0.3, + "duration": 1.8, + "keyframes": [ + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 1.8, + "value": 0.42 + } + ] + }, + { + "property": "scale", + "easing": { + "cubic_bezier": { + "x1": 0.22, + "y1": 1.0, + "x2": 0.36, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.3 + }, + { + "time": 1.8, + "value": 1.0 + } + ] + } + ] + } + ] + } + } + ] + }, + { + "type": "svg", + "position": "absolute", + "x": 260, + "y": 140, + "draw_stroke_width": 3, + "draw_overlap": 0.9, + "data": "", + "style": { + "width": 1400, + "height": 800, + "opacity": 0.5, + "animation": [ + { + "name": "keyframes", + "delay": 0.1, + "duration": 1.3, + "keyframes": [ + { + "property": "draw_progress", + "easing": { + "cubic_bezier": { + "x1": 0.65, + "y1": 0.0, + "x2": 0.35, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 1.0 + }, + { + "time": 1.3, + "value": 0.0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.65, + "y1": 0.0, + "x2": 0.35, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.5 + }, + { + "time": 1.3, + "value": 0.0 + } + ] + } + ] + } + ] + } + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 1.1, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 10 + }, + "children": [ + { + "type": "svg", + "src": "examples/assets/ferriskey-logo.svg", + "style": { + "width": 250, + "height": 270.0, + "animation": [ + { + "name": "keyframes", + "delay": 1.0, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 16 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + }, + { + "name": "shimmer", + "delay": 2.1, + "duration": 1.3, + "color": "#fcd34d", + "intensity": 0.8, + "width": 0.3, + "angle": 18 + } + ] + } + }, + { + "type": "text", + "content": "ferriskey_", + "style": { + "font-family": "Inter", + "font-size": 150, + "font-weight": 900, + "color": "#f59e0b", + "letter-spacing": -4, + "white-space": "nowrap", + "max-width": "1500px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "char", + "delay": 1.35, + "duration": 0.45, + "stagger": 0.035, + "scale_from": 0.86, + "ink_from": "#7C4A21", + "blur": 22 + } + ] + } + } + ] + } + ], + "transition": { + "type": "dissolve", + "duration": 0.26 + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.12 + }, + { + "time": 0.45, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + } + }, + { + "duration": 3.2, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#f59e0b", + "position": "absolute", + "x": 620, + "y": 200, + "style": { + "width": 720, + "height": 660, + "opacity": 0.2, + "filter": [ + { + "fn": "blur", + "radius": 200 + } + ] + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 1.0, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 0, + "padding": { + "top": 0, + "right": 120, + "bottom": 0, + "left": 120 + } + }, + "children": [ + { + "type": "text", + "content": "WRITTEN IN RUST.", + "style": { + "font-family": "Inter", + "font-size": 104, + "font-weight": 900, + "color": "#f59e0b", + "line-height": 0.94, + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.0, + "duration": 0.38, + "stagger": 0.045, + "scale_from": 0.9, + "blur": 18, + "distance": 1.4 + } + ] + } + }, + { + "type": "text", + "content": "WHOLE CLASSES OF", + "style": { + "font-family": "Inter", + "font-size": 104, + "font-weight": 900, + "color": "#fafafa", + "line-height": 0.94, + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.12, + "duration": 0.38, + "stagger": 0.045, + "scale_from": 0.9, + "blur": 18, + "distance": 1.4 + } + ] + } + }, + { + "type": "text", + "content": "THAT BUG DON'T COMPILE.", + "style": { + "font-family": "Inter", + "font-size": 104, + "font-weight": 900, + "color": "#fafafa", + "line-height": 0.94, + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.24, + "duration": 0.38, + "stagger": 0.045, + "scale_from": 0.9, + "blur": 18, + "distance": 1.4 + } + ] + } + } + ] + } + ], + "transition": { + "type": "chromatic_wipe", + "direction": "left", + "aberration": 1.2, + "duration": 0.32 + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.06 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + } + }, + { + "duration": 5.057, + "background": "#FAFAFA", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.45, + "overflow": "hidden" + }, + "children": [ + { + "type": "text", + "content": "IDENTITIES", + "position": "absolute", + "x": -200, + "y": 340, + "style": { + "font-family": "Inter", + "font-size": 320, + "font-weight": 900, + "color": "#EDEDF0", + "letter-spacing": -6, + "white-space": "nowrap", + "max-width": "3200px" + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 1.0, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "Every identity in one place.", + "style": { + "font-family": "Inter", + "font-size": 78, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1700px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.15, + "duration": 0.4, + "stagger": 0.09, + "scale_from": 0.92, + "blur": 14 + } + ] + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 190, + "y": 180, + "children": [ + { + "type": "card", + "style": { + "width": 330, + "background": "#FFFFFF", + "border-radius": 12, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 6, + "blur": 20, + "color": "#0000000F" + } + ], + "padding": 26, + "flex-direction": "column", + "gap": 10, + "animation": [ + { + "name": "keyframes", + "delay": 0.7, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 22 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Total users", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "12 480", + "style": { + "font-family": "Inter", + "font-size": 52, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1.5, + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "All registered users", + "style": { + "font-family": "Inter", + "font-size": 17, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + } + ] + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 1400, + "y": 220, + "children": [ + { + "type": "card", + "style": { + "width": 310, + "background": "#FFFFFF", + "border-radius": 12, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 6, + "blur": 20, + "color": "#0000000F" + } + ], + "padding": 26, + "flex-direction": "column", + "gap": 10, + "animation": [ + { + "name": "keyframes", + "delay": 0.95, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 22 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Enabled users", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "11 902", + "style": { + "font-family": "Inter", + "font-size": 52, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1.5, + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "95% active", + "style": { + "font-family": "Inter", + "font-size": 17, + "color": "#059669", + "white-space": "nowrap", + "max-width": "290px" + } + } + ] + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 300, + "y": 760, + "children": [ + { + "type": "card", + "style": { + "width": 760, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 1.25, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#F97316", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "A", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "Alice Mercier", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + }, + { + "type": "card", + "style": { + "background": "#EFF6FF", + "border-radius": 7, + "border": { + "color": "#93C5FD", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "user account", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#3B82F6", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "text", + "content": "a.mercier@acme.io", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 1180, + "y": 650, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 10, + "align-items": "center" + }, + "children": [ + { + "type": "card", + "style": { + "background": "#FFFFFF", + "border-radius": 7, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 1.5, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "All", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FEF3C7", + "border-radius": 7, + "border": { + "color": "#FCD34D", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 1.63, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Users", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FFFFFF", + "border-radius": 7, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 1.76, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Service Accounts", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "chromatic_wipe", + "direction": "up", + "aberration": 1.5, + "duration": 0.34 + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.05 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 4.757, + "background": "#FAFAFA", + "children": [ + { + "type": "flex", + "position": "absolute", + "x": 210, + "y": 190, + "style": { + "width": 1500, + "flex-direction": "row", + "gap": 0, + "align-items": "stretch", + "animation": [ + { + "name": "keyframes", + "delay": 0.0, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 26 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "card", + "style": { + "width": 340, + "height": 700, + "background": "#FFFFFF", + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "border-radius": 0, + "padding": 26, + "flex-direction": "column", + "gap": 13 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 11, + "height": 52 + }, + "children": [ + { + "type": "svg", + "src": "examples/assets/ferriskey-logo.svg", + "style": { + "width": 34, + "height": 37 + } + }, + { + "type": "text", + "content": "ferriskey", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "240px" + } + } + ] + }, + { + "type": "divider", + "style": { + "width": 288 + } + }, + { + "type": "text", + "content": "Overview", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.2, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Identities", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.23, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Organizations", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.26, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Roles", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.29000000000000004, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Applications", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.32, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Sign-in methods", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.35, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Identity providers", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.38, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Sessions", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.41000000000000003, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Logs & events", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.44, + "duration": 0.25 + } + ] + } + } + ] + }, + { + "type": "card", + "style": { + "width": 1160, + "height": 700, + "background": "#FAFAFA", + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "border-radius": 0, + "padding": 38, + "flex-direction": "column", + "gap": 22 + }, + "children": [ + { + "type": "text", + "content": "Identities", + "style": { + "font-family": "Inter", + "font-size": 40, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1, + "white-space": "nowrap", + "max-width": "1080px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.25, + "duration": 0.35, + "stagger": 0.05, + "scale_from": 0.95, + "blur": 10 + } + ] + } + }, + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 10, + "align-items": "center" + }, + "children": [ + { + "type": "card", + "style": { + "background": "#FFFFFF", + "border-radius": 7, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 0.4, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "All", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FEF3C7", + "border-radius": 7, + "border": { + "color": "#FCD34D", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 0.53, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Users", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FFFFFF", + "border-radius": 7, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 0.66, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Service Accounts", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 16 + }, + "children": [ + { + "type": "card", + "style": { + "width": 270, + "background": "#FFFFFF", + "border-radius": 12, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 6, + "blur": 20, + "color": "#0000000F" + } + ], + "padding": 26, + "flex-direction": "column", + "gap": 10, + "animation": [ + { + "name": "keyframes", + "delay": 0.55, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 22 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Total users", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "12 480", + "style": { + "font-family": "Inter", + "font-size": 52, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1.5, + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "All registered users", + "style": { + "font-family": "Inter", + "font-size": 17, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + } + ] + }, + { + "type": "card", + "style": { + "width": 270, + "background": "#FFFFFF", + "border-radius": 12, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 6, + "blur": 20, + "color": "#0000000F" + } + ], + "padding": 26, + "flex-direction": "column", + "gap": 10, + "animation": [ + { + "name": "keyframes", + "delay": 0.65, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 22 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Enabled users", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "11 902", + "style": { + "font-family": "Inter", + "font-size": 52, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1.5, + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "95% active", + "style": { + "font-family": "Inter", + "font-size": 17, + "color": "#059669", + "white-space": "nowrap", + "max-width": "290px" + } + } + ] + }, + { + "type": "card", + "style": { + "width": 270, + "background": "#FFFFFF", + "border-radius": 12, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 6, + "blur": 20, + "color": "#0000000F" + } + ], + "padding": 26, + "flex-direction": "column", + "gap": 10, + "animation": [ + { + "name": "keyframes", + "delay": 0.75, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 22 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Service accounts", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "38", + "style": { + "font-family": "Inter", + "font-size": 52, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1.5, + "white-space": "nowrap", + "max-width": "290px" + } + }, + { + "type": "text", + "content": "Machine identities", + "style": { + "font-family": "Inter", + "font-size": 17, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "290px" + } + } + ] + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 0 + }, + "children": [ + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.95, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#F97316", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "A", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "Alice Mercier", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + }, + { + "type": "card", + "style": { + "background": "#EFF6FF", + "border-radius": 7, + "border": { + "color": "#93C5FD", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "user account", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#3B82F6", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "text", + "content": "a.mercier@acme.io", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 1.05, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#8B5CF6", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "S", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "Service Account", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + }, + { + "type": "card", + "style": { + "background": "#F5F3FF", + "border-radius": 7, + "border": { + "color": "#D8B4FE", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "service account", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#8B5CF6", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "text", + "content": "svc-billing", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "zoom_in", + "duration": 0.24 + }, + "camera": { + "keyframes": [ + { + "property": "x", + "values": [ + { + "time": 0, + "value": 120 + }, + { + "time": 0.34, + "value": 0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 5.457, + "background": "#FAFAFA", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.45, + "overflow": "hidden" + }, + "children": [ + { + "type": "text", + "content": "PASSKEYS", + "position": "absolute", + "x": -200, + "y": 380, + "style": { + "font-family": "Inter", + "font-size": 300, + "font-weight": 900, + "color": "#EDEDF0", + "letter-spacing": -6, + "white-space": "nowrap", + "max-width": "3200px" + } + } + ] + }, + { + "for-each": [ + { + "d": 1.9, + "w": 6 + }, + { + "d": 2.03, + "w": 3 + } + ], + "template": { + "type": "shape", + "shape": "circle", + "fill": "#00000000", + "stroke": { + "color": "#f59e0b", + "width": "$w" + }, + "position": "absolute", + "x": 830, + "y": 545, + "bleed": true, + "style": { + "width": 260, + "height": 260, + "animation": [ + { + "name": "keyframes", + "delay": "$d", + "duration": 1.0, + "keyframes": [ + { + "property": "scale", + "easing": { + "cubic_bezier": { + "x1": 0.22, + "y1": 1.0, + "x2": 0.36, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.12 + }, + { + "time": 1.0, + "value": 1.7 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.65, + "y1": 0.0, + "x2": 0.35, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.7 + }, + { + "time": 1.0, + "value": 0.0 + } + ] + } + ] + } + ] + } + } + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 26 + }, + "children": [ + { + "type": "svg", + "src": "examples/assets/ferriskey-logo.svg", + "style": { + "width": 104, + "height": 112.32000000000001, + "animation": [ + { + "name": "keyframes", + "delay": 0.05, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 16 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + } + ] + } + }, + { + "type": "text", + "content": "Sign in to acme.io", + "style": { + "font-family": "Inter", + "font-size": 38, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "900px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.25, + "duration": 0.3, + "stagger": 0.05, + "blur": 9 + } + ] + } + }, + { + "type": "card", + "style": { + "background": "#f59e0b", + "border-radius": 56, + "padding": { + "top": 26, + "right": 68, + "bottom": 26, + "left": 68 + }, + "align-items": "center", + "justify-content": "center", + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 10, + "blur": 30, + "color": "#F59E0B44" + } + ], + "animation": [ + { + "name": "keyframes", + "delay": 0.4, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + }, + { + "name": "keyframes", + "delay": 1.9, + "duration": 0.4, + "keyframes": [ + { + "property": "scale", + "easing": { + "cubic_bezier": { + "x1": 0.34, + "y1": 1.56, + "x2": 0.64, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 1.0 + }, + { + "time": 0.12, + "value": 0.94 + }, + { + "time": 0.4, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Sign in with a passkey", + "states": [ + { + "at": 2.16, + "content": "Signed in" + } + ], + "swap": { + "duration": 0.34, + "distance": 26, + "blur": 10 + }, + "style": { + "font-family": "Inter", + "font-size": 42, + "font-weight": 700, + "color": "#FFFFFF", + "text-align": "center", + "white-space": "nowrap", + "max-width": "740px" + } + } + ] + } + ] + }, + { + "type": "pointer", + "position": "absolute", + "x": 0, + "y": 0, + "bleed": true, + "size": 56, + "tone": "dark", + "click_ring": "bold", + "ring_color": "#f59e0b", + "click_duration": 0.45, + "path_easing": "ease_out", + "path": [ + { + "time": 0.0, + "x": 2020, + "y": 900 + }, + { + "time": 1.9, + "x": 1010, + "y": 700 + }, + { + "time": 4.4, + "x": 2020, + "y": 320 + } + ] + } + ], + "transition": { + "type": "corner_reveal", + "duration": 0.26, + "corner": "bottom_left" + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.04 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 3.857, + "background": "#FAFAFA", + "children": [ + { + "type": "flex", + "position": "absolute", + "x": 210, + "y": 190, + "style": { + "width": 1500, + "flex-direction": "row", + "gap": 0, + "align-items": "stretch", + "animation": [ + { + "name": "keyframes", + "delay": 0.0, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 26 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "card", + "style": { + "width": 340, + "height": 700, + "background": "#FFFFFF", + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "border-radius": 0, + "padding": 26, + "flex-direction": "column", + "gap": 13 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 11, + "height": 52 + }, + "children": [ + { + "type": "svg", + "src": "examples/assets/ferriskey-logo.svg", + "style": { + "width": 34, + "height": 37 + } + }, + { + "type": "text", + "content": "ferriskey", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "240px" + } + } + ] + }, + { + "type": "divider", + "style": { + "width": 288 + } + }, + { + "type": "text", + "content": "Overview", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.2, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Identities", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.23, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Organizations", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.26, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Roles", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.29000000000000004, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Applications", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.32, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Sign-in methods", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.35, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Identity providers", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.38, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Sessions", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.41000000000000003, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Logs & events", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.44, + "duration": 0.25 + } + ] + } + } + ] + }, + { + "type": "card", + "style": { + "width": 1160, + "height": 700, + "background": "#FAFAFA", + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "border-radius": 0, + "padding": 38, + "flex-direction": "column", + "gap": 22 + }, + "children": [ + { + "type": "text", + "content": "Sign-in methods", + "style": { + "font-family": "Inter", + "font-size": 40, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1, + "white-space": "nowrap", + "max-width": "1080px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.25, + "duration": 0.35, + "stagger": 0.05, + "scale_from": 0.95, + "blur": 10 + } + ] + } + }, + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 10, + "align-items": "center" + }, + "children": [ + { + "type": "card", + "style": { + "background": "#FFFFFF", + "border-radius": 7, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 0.35, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "All", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FEF3C7", + "border-radius": 7, + "border": { + "color": "#FCD34D", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 0.48, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Enabled", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FFFFFF", + "border-radius": 7, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 0.61, + "duration": 0.4, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 0.4, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.27999999999999997, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "Disabled", + "style": { + "font-family": "Inter", + "font-size": 18, + "font-weight": 600, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 0 + }, + "children": [ + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.55, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#F97316", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "P", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "Passkeys", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + } + ] + }, + { + "type": "text", + "content": "WebAuthn · 11 902 enrolled", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.65, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#F97316", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "O", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "OpenID Connect", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + } + ] + }, + { + "type": "text", + "content": "3 identity providers", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.75, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#8B5CF6", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "M", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "mTLS", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + } + ] + }, + { + "type": "text", + "content": "Service accounts only", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "iris", + "duration": 0.28 + }, + "camera": { + "keyframes": [ + { + "property": "x", + "values": [ + { + "time": 0, + "value": -110 + }, + { + "time": 0.34, + "value": 0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 3.557, + "background": "#FAFAFA", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.45, + "overflow": "hidden" + }, + "children": [ + { + "type": "text", + "content": "SERVICES", + "position": "absolute", + "x": -200, + "y": 380, + "style": { + "font-family": "Inter", + "font-size": 300, + "font-weight": 900, + "color": "#EDEDF0", + "letter-spacing": -6, + "white-space": "nowrap", + "max-width": "3200px" + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "And every service, too.", + "style": { + "font-family": "Inter", + "font-size": 78, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -2, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1700px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.1, + "duration": 0.38, + "stagger": 0.085, + "scale_from": 0.92, + "blur": 14 + } + ] + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 300, + "y": 720, + "children": [ + { + "type": "card", + "style": { + "width": 740, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.75, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#8B5CF6", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "S", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "Service Account", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + }, + { + "type": "card", + "style": { + "background": "#F5F3FF", + "border-radius": 7, + "border": { + "color": "#D8B4FE", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "service account", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#8B5CF6", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "text", + "content": "svc-billing", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "pixel_dissolve", + "duration": 0.3, + "cell": 56, + "seed": 17 + }, + "camera": { + "keyframes": [ + { + "property": "x", + "values": [ + { + "time": 0, + "value": -120 + }, + { + "time": 0.34, + "value": 0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 4.657, + "background": "#FAFAFA", + "children": [ + { + "type": "flex", + "position": "absolute", + "x": 210, + "y": 190, + "style": { + "width": 1500, + "flex-direction": "row", + "gap": 0, + "align-items": "stretch", + "animation": [ + { + "name": "keyframes", + "delay": 0.0, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 26 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "card", + "style": { + "width": 340, + "height": 700, + "background": "#FFFFFF", + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "border-radius": 0, + "padding": 26, + "flex-direction": "column", + "gap": 13 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 11, + "height": 52 + }, + "children": [ + { + "type": "svg", + "src": "examples/assets/ferriskey-logo.svg", + "style": { + "width": 34, + "height": 37 + } + }, + { + "type": "text", + "content": "ferriskey", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "240px" + } + } + ] + }, + { + "type": "divider", + "style": { + "width": 288 + } + }, + { + "type": "text", + "content": "Overview", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.2, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Identities", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.23, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Organizations", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.26, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Roles", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.29000000000000004, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Applications", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.32, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Sign-in methods", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.35, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Identity providers", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.38, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Sessions", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.41000000000000003, + "duration": 0.25 + } + ] + } + }, + { + "type": "text", + "content": "Logs & events", + "style": { + "font-family": "Inter", + "font-size": 20, + "font-weight": 400, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "288px", + "animation": [ + { + "name": "fade_in", + "delay": 0.44, + "duration": 0.25 + } + ] + } + } + ] + }, + { + "type": "card", + "style": { + "width": 1160, + "height": 700, + "background": "#FAFAFA", + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "border-radius": 0, + "padding": 38, + "flex-direction": "column", + "gap": 22 + }, + "children": [ + { + "type": "text", + "content": "Applications", + "style": { + "font-family": "Inter", + "font-size": 40, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -1, + "white-space": "nowrap", + "max-width": "1080px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.25, + "duration": 0.35, + "stagger": 0.05, + "scale_from": 0.95, + "blur": 10 + } + ] + } + }, + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 12, + "align-items": "center" + }, + "children": [ + { + "for-each": [ + { + "label": "OAuth2", + "d": 1.0 + }, + { + "label": "OIDC", + "d": 1.12 + }, + { + "label": "WebAuthn", + "d": 1.24 + }, + { + "label": "AuthZEN", + "d": 1.36 + }, + { + "label": "RFC 8693", + "d": 1.48 + } + ], + "template": { + "type": "badge", + "text": "$label", + "variant": "solid", + "badge_size": "lg", + "style": { + "color": "#FFFFFF", + "background": "#f59e0b", + "animation": [ + { + "name": "fade_in_up", + "delay": "$d", + "duration": 0.45 + } + ] + } + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 0 + }, + "children": [ + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.6, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#F97316", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "W", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "acme-web", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + } + ] + }, + { + "type": "text", + "content": "OIDC · 1 client", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.7, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#F97316", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "A", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "acme-api", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + } + ] + }, + { + "type": "text", + "content": "OAuth2 + mTLS · 6 clients", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "width": 980, + "background": "#FFFFFF", + "border-radius": 0, + "border": { + "color": "#E4E4E7", + "width": 1 + }, + "padding": { + "top": 18, + "right": 32, + "bottom": 18, + "left": 32 + }, + "flex-direction": "row", + "align-items": "center", + "justify-content": "space-between", + "animation": [ + { + "name": "keyframes", + "delay": 0.8, + "duration": 0.45, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.45, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.315, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 18 + }, + "children": [ + { + "type": "card", + "style": { + "width": 52, + "height": 52, + "background": "#8B5CF6", + "border-radius": 26, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "O", + "style": { + "font-family": "Inter", + "font-size": 24, + "font-weight": 700, + "color": "#FFFFFF", + "white-space": "nowrap", + "max-width": "52px" + } + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "flex", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10 + }, + "children": [ + { + "type": "text", + "content": "ops-agent", + "style": { + "font-family": "Inter", + "font-size": 23, + "font-weight": 600, + "color": "#09090B", + "white-space": "nowrap", + "max-width": "420px" + } + } + ] + }, + { + "type": "text", + "content": "RFC 8693 · 3 clients", + "style": { + "font-family": "Inter", + "font-size": 18, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "460px" + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "background": "#ECFDF5", + "border-radius": 7, + "border": { + "color": "#6EE7B7", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "ACTIVE", + "style": { + "font-family": "Inter", + "font-size": 17, + "font-weight": 600, + "color": "#059669", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "zoom_out", + "duration": 0.24 + }, + "camera": { + "keyframes": [ + { + "property": "x", + "values": [ + { + "time": 0, + "value": 120 + }, + { + "time": 0.34, + "value": 0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 4.457, + "background": "#FAFAFA", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.45, + "overflow": "hidden" + }, + "children": [ + { + "type": "text", + "content": "AGENTS", + "position": "absolute", + "x": -200, + "y": 360, + "style": { + "font-family": "Inter", + "font-size": 320, + "font-weight": 900, + "color": "#EDEDF0", + "letter-spacing": -6, + "white-space": "nowrap", + "max-width": "3200px" + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 14 + }, + "children": [ + { + "type": "text", + "content": "The part almost nobody shipped.", + "style": { + "font-family": "Inter", + "font-size": 44, + "color": "#71717A", + "white-space": "nowrap", + "max-width": "1600px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.1, + "duration": 0.34, + "stagger": 0.06, + "blur": 10 + } + ] + } + }, + { + "type": "text", + "content": "Delegated identity for agents.", + "style": { + "font-family": "Inter", + "font-size": 92, + "font-weight": 700, + "color": "#09090B", + "letter-spacing": -2.5, + "text-align": "center", + "white-space": "nowrap", + "max-width": "1800px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.75, + "duration": 0.4, + "stagger": 0.09, + "scale_from": 0.9, + "blur": 16 + } + ] + } + }, + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 12, + "align-items": "center" + }, + "children": [ + { + "type": "card", + "style": { + "background": "#FEF3C7", + "border-radius": 7, + "border": { + "color": "#FCD34D", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "RFC 8693", + "style": { + "font-family": "Inter", + "font-size": 22, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FEF3C7", + "border-radius": 7, + "border": { + "color": "#FCD34D", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "AuthZEN", + "style": { + "font-family": "Inter", + "font-size": 22, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#FEF3C7", + "border-radius": 7, + "border": { + "color": "#FCD34D", + "width": 1 + }, + "padding": { + "top": 5, + "right": 12, + "bottom": 5, + "left": 12 + }, + "align-items": "center", + "justify-content": "center" + }, + "children": [ + { + "type": "text", + "content": "Token exchange", + "style": { + "font-family": "Inter", + "font-size": 22, + "font-weight": 600, + "color": "#f59e0b", + "white-space": "nowrap", + "max-width": "300px" + } + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "corner_reveal", + "duration": 0.26, + "corner": "top_right" + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.05 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + }, + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { + "color": "#0000000D", + "cell": 120, + "weight": 1, + "major_every": 0, + "major_weight": 1 + }, + "speed": 0 + } + ] + }, + { + "duration": 5.0, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#f59e0b", + "position": "absolute", + "x": 540, + "y": 220, + "style": { + "width": 800, + "height": 680, + "opacity": 0.16, + "filter": [ + { + "fn": "blur", + "radius": 210 + } + ] + } + } + ] + }, + { + "type": "terminal", + "position": "absolute", + "x": 300, + "y": 320, + "title": "ferriskey", + "theme": "dark", + "reveal": { + "mode": "typewriter", + "start": 0.25, + "duration": 2.6 + }, + "lines": [ + { + "text": "cargo run --release", + "line_type": "command" + }, + { + "text": " Compiling ferriskey v0.7.0", + "line_type": "output", + "color": "#a1a1aa" + }, + { + "text": " Finished release [optimized] in 4.82s", + "line_type": "output", + "color": "#a1a1aa" + }, + { + "text": " Running target/release/ferriskey", + "line_type": "output", + "color": "#a1a1aa" + }, + { + "text": " 0 unsafe blocks · 0 known CVEs", + "line_type": "output", + "color": "#22C55E" + } + ], + "style": { + "width": 1000, + "height": 320, + "font-size": 28, + "animation": [ + { + "name": "keyframes", + "delay": 0.05, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 26 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + } + ] + } + }, + { + "type": "success_check", + "position": "absolute", + "x": 1400, + "y": 400, + "size": 130, + "tint": "#22C55E", + "ring": 0.22, + "delay": 3.0, + "duration": 0.8 + } + ], + "transition": { + "type": "chromatic_wipe", + "direction": "down", + "aberration": 1.5, + "duration": 0.34 + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.05 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + } + }, + { + "duration": 4.0, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#f59e0b", + "position": "absolute", + "x": 600, + "y": 200, + "style": { + "width": 760, + "height": 660, + "opacity": 0.18, + "filter": [ + { + "fn": "blur", + "radius": 200 + } + ] + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 12 + }, + "children": [ + { + "type": "number_wheel", + "value": "40+", + "spin": "double", + "delay": 0.2, + "duration": 0.9, + "stagger_per_column": 0.08, + "style": { + "font-family": "Inter", + "font-size": 200, + "font-weight": 900, + "color": "#f59e0b" + } + }, + { + "type": "text", + "content": "CONTRIBUTORS. APACHE 2.0. EVERY LINE IN THE OPEN.", + "style": { + "font-family": "Inter", + "font-size": 46, + "font-weight": 700, + "color": "#fafafa", + "letter-spacing": 1, + "white-space": "nowrap", + "max-width": "1700px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 1.0, + "duration": 0.34, + "stagger": 0.055, + "scale_from": 0.93, + "blur": 12 + } + ] + } + } + ] + } + ], + "transition": { + "type": "wipe_up", + "duration": 0.3 + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.06 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + } + }, + { + "duration": 6.601, + "background": "#09090b", + "children": [ + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "depth": 0.3, + "overflow": "hidden" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#f59e0b", + "position": "absolute", + "x": 460, + "y": 140, + "style": { + "width": 1000, + "height": 820, + "opacity": 0.26, + "filter": [ + { + "fn": "blur", + "radius": 230 + } + ] + } + } + ] + }, + { + "type": "div", + "position": "absolute", + "x": 0, + "y": 0, + "style": { + "width": 1920, + "height": 1080, + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "gap": 22 + }, + "children": [ + { + "type": "svg", + "src": "examples/assets/ferriskey-logo.svg", + "style": { + "width": 190, + "height": 205.20000000000002, + "animation": [ + { + "name": "keyframes", + "delay": 0.05, + "duration": 0.55, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 16 + }, + { + "time": 0.55, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.385, + "value": 1.0 + } + ] + } + ] + }, + { + "name": "shimmer", + "delay": 1.1, + "duration": 1.8, + "color": "#fcd34d", + "intensity": 0.85, + "width": 0.28, + "angle": 18, + "loop": true + } + ] + } + }, + { + "type": "text", + "content": "ferriskey_", + "style": { + "font-family": "Inter", + "font-size": 150, + "font-weight": 900, + "color": "#f59e0b", + "letter-spacing": -4, + "white-space": "nowrap", + "max-width": "1600px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "char", + "delay": 0.3, + "duration": 0.45, + "stagger": 0.03, + "scale_from": 0.9, + "blur": 18 + } + ] + } + }, + { + "type": "text", + "content": "Identity infrastructure that doesn't corrode.", + "style": { + "font-family": "Inter", + "font-size": 44, + "color": "#fafafa", + "white-space": "nowrap", + "max-width": "1500px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.9, + "duration": 0.36, + "stagger": 0.06, + "scale_from": 0.93, + "blur": 12 + } + ] + } + }, + { + "type": "text", + "content": "Run it yourself, or let Cloud IAM run it for you.", + "style": { + "font-family": "Inter", + "font-size": 30, + "color": "#a1a1aa", + "white-space": "nowrap", + "max-width": "1400px", + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 2.2, + "duration": 0.32, + "stagger": 0.055, + "blur": 10 + } + ] + } + }, + { + "type": "flex", + "style": { + "flex-direction": "row", + "gap": 26, + "align-items": "center" + }, + "children": [ + { + "type": "card", + "style": { + "background": "#f59e0b", + "border-radius": 44, + "padding": { + "top": 22, + "right": 52, + "bottom": 22, + "left": 52 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 3.3, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ] + }, + "children": [ + { + "type": "text", + "content": "ferriskey.dev", + "style": { + "font-family": "Inter", + "font-size": 38, + "font-weight": 700, + "color": "#09090b", + "white-space": "nowrap", + "max-width": "700px" + } + } + ] + }, + { + "type": "card", + "style": { + "background": "#1a1206", + "border-radius": 44, + "padding": { + "top": 22, + "right": 52, + "bottom": 22, + "left": 52 + }, + "align-items": "center", + "justify-content": "center", + "animation": [ + { + "name": "keyframes", + "delay": 3.6, + "duration": 0.5, + "keyframes": [ + { + "property": "position.y", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 18 + }, + { + "time": 0.5, + "value": 0 + } + ] + }, + { + "property": "opacity", + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + }, + "keyframes": [ + { + "time": 0, + "value": 0.0 + }, + { + "time": 0.35, + "value": 1.0 + } + ] + } + ] + } + ], + "border": { + "color": "#f59e0b", + "width": 2 + } + }, + "children": [ + { + "type": "text", + "content": "Star us on GitHub", + "style": { + "font-family": "Inter", + "font-size": 38, + "font-weight": 700, + "color": "#fcd34d", + "white-space": "nowrap", + "max-width": "700px" + } + } + ] + } + ] + } + ] + } + ], + "transition": { + "type": "dissolve", + "duration": 0.38 + }, + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.07 + }, + { + "time": 0.38, + "value": 1.0 + } + ], + "easing": { + "cubic_bezier": { + "x1": 0.16, + "y1": 1.0, + "x2": 0.3, + "y2": 1.0 + } + } + } + ] + } + } + ] +} diff --git a/examples/hyperframes-vocabulary.json b/examples/hyperframes-vocabulary.json new file mode 100644 index 0000000..dd95aaf --- /dev/null +++ b/examples/hyperframes-vocabulary.json @@ -0,0 +1,192 @@ +{ + "video": { "width": 1920, "height": 1080, "fps": 30 }, + "scenes": [ + { + "duration": 4.0, + "background": "#0B1120", + "animated-background": [ + { + "preset": "grid_lines", + "grid_lines": { "color": "#38BDF81A", "cell": 72, "weight": 1, "major_every": 4, "major_weight": 2 }, + "speed": 6, + "direction": "up_left" + } + ], + "transition": { "type": "chromatic_wipe", "direction": "left", "aberration": 1.2, "duration": 0.45 }, + "children": [ + { + "type": "flex", + "position": "absolute", + "x": 200, + "y": 300, + "style": { "flex-direction": "column", "gap": 28, "width": "1520px" }, + "children": [ + { + "type": "text", + "content": "Letters cascade from above", + "style": { + "font-size": 92, + "font-weight": 700, + "color": "#F8FAFC", + "animation": [{ + "name": "char_slide_up", + "direction": "down", + "distance": 1.6, + "duration": 0.5, + "stagger": 0.035, + "easing": "ease_out_cubic" + }] + } + }, + { + "type": "text", + "content": "and words arrive in uneven bursts, the way tokens do", + "style": { + "font-size": 40, + "color": "#94A3B8", + "animation": [{ + "name": "char_blur_in", + "granularity": "word", + "delay": 0.8, + "duration": 0.3, + "stagger": 0.09, + "jitter": 0.7, + "seed": 12, + "ink_from": "#334155", + "blur": 6 + }] + } + } + ] + } + ] + }, + { + "duration": 4.5, + "background": "#0B1120", + "transition": { "type": "chromatic_wipe", "direction": "up", "aberration": 0.8, "duration": 0.4 }, + "children": [ + { + "type": "flex", + "position": "absolute", + "x": 260, + "y": 380, + "style": { "flex-direction": "row", "align-items": "center", "gap": 80 }, + "children": [ + { + "type": "number_wheel", + "value": "30,222", + "spin": "double", + "duration": 1.1, + "stagger_per_column": 0.09, + "style": { "font-size": 120, "font-weight": 700, "color": "#38BDF8" } + }, + { "type": "success_check", "size": 110, "tint": "#22C55E", "delay": 1.6, "duration": 0.8 }, + { + "type": "badge", + "text": "LIVE", + "variant": "solid", + "dot": true, + "dot_color": "#22C55E", + "style": { "animation": [{ "name": "pop_in", "delay": 2.1, "duration": 0.7, "overshoot": 0.2 }] } + } + ] + }, + { + "type": "text", + "content": "Saving draft", + "position": "absolute", + "x": 260, + "y": 620, + "states": [{ "at": 2.6, "content": "Saved" }], + "swap": { "duration": 0.45, "distance": 22, "blur": 9 }, + "style": { "font-size": 52, "color": "#E2E8F0", "white-space": "nowrap", "max-width": "600px" } + } + ] + }, + { + "duration": 5.0, + "background": "#0B1120", + "transition": { "type": "chromatic_wipe", "direction": "left", "aberration": 1.0, "duration": 0.4 }, + "children": [ + { + "type": "card", + "position": "absolute", + "x": 260, + "y": 260, + "style": { + "width": "330px", + "height": "132px", + "background": "#111C33", + "border-radius": 20, + "justify-content": "center", + "align-items": "center", + "animation": [{ + "name": "keyframes", + "duration": 0.9, + "delay": 1.2, + "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": "The card resizes, and its content reflows inside the new box", + "style": { "font-size": 26, "color": "#CBD5E1", "text-align": "center" } + } + ] + }, + { + "type": "text", + "content": "light catches the letters", + "position": "absolute", + "x": 260, + "y": 660, + "style": { + "font-size": 64, + "font-weight": 700, + "color": "#F8FAFC", + "white-space": "nowrap", + "max-width": "1100px", + "animation": [{ "name": "shimmer", "delay": 1.0, "duration": 1.1, "color": "#7DD3FC", "intensity": 0.85, "width": 0.3, "angle": 22, "loop": true }] + } + }, + { + "type": "text", + "content": "rustmotion --frames 0-60", + "position": "absolute", + "x": 260, + "y": 830, + "caret": { "shape": "block", "blink": 0.9, "color": "#38BDF8" }, + "style": { + "font-size": 40, + "font-family": "JetBrains Mono", + "color": "#94A3B8", + "white-space": "nowrap", + "max-width": "900px", + "animation": [{ "name": "typewriter", "delay": 0.6, "duration": 2.0 }] + } + }, + { + "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 } + ] + } + ] + } + ] +} From cc846bfd3e3994fd1089068ab4d45ef7d0e8877c Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Wed, 19 Aug 2026 15:42:33 +0200 Subject: [PATCH 2/3] fix(test): make ink_from's colour reading robust to sub-pixel glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/rustmotion-components/src/text.rs | 27 ++++++++++++------------ 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 398b7fc..2bebc23 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -2215,20 +2215,19 @@ mod tests { skia_safe::IPoint::new(0, 0), skia_safe::image::CachingHint::Disallow, )); - // Only fully-opaque pixels — antialiased edges carry blended - // colour that would muddy the reading. - let (sum, n) = (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, n), i| { - if buf[i * 4 + 3] == 255 { - (s + buf[i * 4 + 1] as u64, n + 1) - } else { - (s, n) - } - }); - assert!( - n > 0, - "some fully-opaque glyph pixels must exist at t={time}" - ); - sum as f32 / n as f32 + // Weight by alpha rather than requiring `alpha == 255`: the fill + // colour is uniform across a unit regardless of AA coverage, so + // this reads the same value it would with an opaque-only filter + // — but it stays correct once `char_scale_in` has shrunk the + // glyph enough (early in its ramp) that no single pixel is fully + // covered. + let (weighted, alpha_sum) = + (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, a), i| { + let alpha = buf[i * 4 + 3] as u64; + (s + buf[i * 4 + 1] as u64 * alpha, a + alpha) + }); + assert!(alpha_sum > 0, "some inked pixels must exist at t={time}"); + weighted as f32 / alpha_sum as f32 }; let early = mean_green(0.1); From f811203fe326e231d0585a38ed276a9e15641de5 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Wed, 19 Aug 2026 15:51:58 +0200 Subject: [PATCH 3/3] docs(skill): translate the new rule pages to English; trim redundant comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../skills/rustmotion/rules/card-resize.md | 22 ++++++------ .../rustmotion/rules/char-animation-tuning.md | 36 +++++++++---------- .../rustmotion/rules/hyperframes-mapping.md | 34 +++++++++--------- .../skills/rustmotion/rules/motion-path.md | 6 ++-- .../skills/rustmotion/rules/number-wheel.md | 36 +++++++++---------- .../rustmotion/rules/pointer-walkthrough.md | 36 +++++++++---------- .../skills/rustmotion/rules/streaming-text.md | 24 ++++++------- .../skills/rustmotion/rules/text-polish.md | 30 ++++++++-------- CLAUDE.md | 30 ++++++++-------- crates/rustmotion-components/src/pointer.rs | 21 +++++------ crates/rustmotion-components/src/text.rs | 3 -- 11 files changed, 138 insertions(+), 140 deletions(-) diff --git a/.claude/skills/rustmotion/rules/card-resize.md b/.claude/skills/rustmotion/rules/card-resize.md index dcaeabd..c8efa53 100644 --- a/.claude/skills/rustmotion/rules/card-resize.md +++ b/.claude/skills/rustmotion/rules/card-resize.md @@ -1,8 +1,8 @@ -# Rule: Redimensionner une carte (et pas la mettre à l'échelle) +# Rule: Resize a card (don't scale it) -Une carte compacte qui grandit en panneau de détail est un changement de **layout** : sa nouvelle boîte reflue son contenu. Un `scale` étire les pixels qu'elle avait déjà, texte compris — c'est un zoom, pas un redimensionnement, et ça se voit immédiatement sur le texte devenu flou et surdimensionné. +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. -Anime `width` / `height` avec un effet `keyframes` : ils atteignent taffy, donc la mise en page est recalculée à chaque frame. +Animate `width` / `height` with a `keyframes` effect: they reach taffy, so layout is recomputed every frame. ```json { @@ -30,18 +30,18 @@ Anime `width` / `height` avec un effet `keyframes` : ils atteignent taffy, donc } ``` -## Les temps des keyframes sont relatifs au `delay` +## Keyframe times are relative to `delay` -`{"time": 0.0}` est le début de l'effet, pas le début de la scène. Le `delay` de l'effet décale toute la piste. +`{"time": 0.0}` is the start of the effect, not the start of the scene. The effect's `delay` shifts the whole track. -## La taille animée gagne sur la taille intrinsèque +## Animated size wins over intrinsic size -Un composant qui déclare sa propre taille (une `shape`, un `badge`) la voit remplacée pendant l'animation. C'est voulu, mais ça veut aussi dire qu'une valeur oubliée à 0 sur la dernière keyframe fait disparaître la boîte. +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. -## Que le contenu suive vraiment +## Make the content actually follow -Sans `justify-content` / `align-items`, l'enfant reste collé en haut à gauche et seule la boîte grandit : le mouvement paraît vide. Centre le contenu (ou donne-lui un `flex: 1`) pour que la croissance se lise. +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. -## Le validateur voit la boîte finale +## The validator sees the final box -`validate --strict-anim` échantillonne l'animation : une carte qui grandit hors du device est signalée à l'instant où ça arrive. Vérifie qu'il reste de la marge à la taille maximale, pas seulement à la taille initiale. +`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. diff --git a/.claude/skills/rustmotion/rules/char-animation-tuning.md b/.claude/skills/rustmotion/rules/char-animation-tuning.md index c297c9e..6df4765 100644 --- a/.claude/skills/rustmotion/rules/char-animation-tuning.md +++ b/.claude/skills/rustmotion/rules/char-animation-tuning.md @@ -1,6 +1,6 @@ -# Rule: Régler une animation par caractère ou par mot +# Rule: Tuning a per-character or per-word animation -Les sept presets `char_*` (`char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`, `char_blur_in`) partagent une même config. Six champs la règlent, tous optionnels, **tous par défaut au comportement historique** : un scénario existant ne bouge pas. +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 { @@ -20,29 +20,29 @@ Les sept presets `char_*` (`char_scale_in`, `char_fade_in`, `char_wave`, `char_b } ``` -| Champ | Rôle | Défaut | +| Field | Role | Default | |---|---|---| -| `direction` | `up` / `down` / `left` / `right` — d'où l'unité arrive | `up` | -| `distance` | Multiplicateur du déplacement (0.5 serré, 1.85 marqué) | `1.0` | -| `scale_from` | Échelle de départ de chaque unité (0.82 = «pop», 0.92 = à peine) | absent | -| `jitter` + `seed` | Irrégularité déterministe du `stagger` | `0` | -| `ink_from` | Couleur de départ, converge vers `style.color` | absent | -| `blur` | Sigma de départ (`char_blur_in` seul) | `14` | +| `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` | -## Ce que chaque preset lit +## What each preset actually reads -`direction` et `distance` n'ont de sens que pour les presets dont le mouvement **est** une translation : `char_slide_up` et `char_blur_in`. Les autres (`scale_in`, `bounce`, `rotate_in`, `fade_in`, `wave`) n'ont pas d'axe de déplacement à rediriger et les ignorent. +`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` se **compose** avec le preset au lieu de le remplacer — sauf sur `char_scale_in` et `char_bounce`, qui possèdent déjà leur propre courbe d'échelle et l'ignorent (deux échelles empilées se battent au lieu de se composer). +`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). -## Le nom `char_slide_up` ne contraint pas la direction +## The name `char_slide_up` doesn't constrain the direction -`char_slide_up` avec `"direction": "down"` fait tomber les lettres depuis le haut. Le nom est historique : c'est le preset «translation», `up` en est le défaut. Il n'existe pas de `char_slide_down`. +`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` décide de ce qu'est une unité +## `granularity` decides what a unit is -`"granularity": "word"` anime des mots, `"char"` (défaut) des caractères. Un titre de 40 caractères animé en `char` avec `stagger: 0.05` prend 2 s à s'installer avant même sa `duration` — compte le nombre d'unités avant de choisir le `stagger`, ou passe en `word`. +`"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`. -## Rappel sur `char_blur_in` +## A note on `char_blur_in` -Il passe par le même chemin de résolution que ses six frères : il **hérite** du `stagger` d'un conteneur parent et fonctionne dans une étape de `timeline`. (Ça n'a pas toujours été le cas.) +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.) diff --git a/.claude/skills/rustmotion/rules/hyperframes-mapping.md b/.claude/skills/rustmotion/rules/hyperframes-mapping.md index 3608111..9c16492 100644 --- a/.claude/skills/rustmotion/rules/hyperframes-mapping.md +++ b/.claude/skills/rustmotion/rules/hyperframes-mapping.md @@ -1,30 +1,30 @@ -# Rule: Correspondance Hyperframes → rustmotion +# Rule: Hyperframes → rustmotion mapping -Si on te demande un effet du catalogue Hyperframes (ou un effet décrit dans ce vocabulaire — «streaming text», «number wheel», «badge pop»…), **cherche-le dans cette table avant d'écrire quoi que ce soit**. La moitié de ces effets existe déjà sous un autre nom, et la reconstruire à la main donne un résultat moins bon et non vérifiable par le validateur. +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 | En rustmotion | +| 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` avec `"direction": "down"` | -| Text Stagger | `char_blur_in` (montée) + effet `shimmer` (balayage) sur le même `text` | -| Number Pop In | `char_blur_in` avec `"granularity": "char"`, `"scale_from": 0.82` | -| Streaming Text | `char_blur_in` avec `jitter`/`seed`/`ink_from` — voir [streaming-text.md](streaming-text.md) | -| Typewriter | preset `typewriter` + `text.caret` | +| 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 | composant `number_wheel` — voir [number-wheel.md](number-wheel.md) | +| Number Wheel | `number_wheel` component — see [number-wheel.md](number-wheel.md) | | Badge Pop | `badge` + `style.animation: [{ "name": "pop_in" }]` | -| Success Check | composant `success_check` | -| Simulated Cursor | composant `pointer` — voir [pointer-walkthrough.md](pointer-walkthrough.md) | -| Card Resize | `keyframes` sur `width`/`height` — voir [card-resize.md](card-resize.md) | -| Arc Motion Path | effet `motion_path` + `orient` — voir [motion-path.md](motion-path.md) | -| SVG Line Draw Loader | preset `draw_in` / `stroke_reveal` sur un `svg` | +| 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" }` | -## Deux pièges de nommage +## Two naming traps -`cursor` **n'est pas** un curseur de souris : c'est un caret texte (une barre clignotante). Le pointeur de souris avec son anneau de clic, c'est `pointer`. +`cursor` is **not** a mouse pointer: it's a text caret (a blinking bar). The mouse pointer with its click ring is `pointer`. -`counter` **n'est pas** une roue de chiffres : il interpole une *valeur* et réécrit le nombre à chaque frame, donc les glyphes sautent. `number_wheel` fait défiler des bandes de chiffres, comme un compteur mécanique. Un compteur qui monte de 0 à 30 222 → `counter`. Un chiffre qui atterrit → `number_wheel`. +`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`. diff --git a/.claude/skills/rustmotion/rules/motion-path.md b/.claude/skills/rustmotion/rules/motion-path.md index b590f59..37f6ca5 100644 --- a/.claude/skills/rustmotion/rules/motion-path.md +++ b/.claude/skills/rustmotion/rules/motion-path.md @@ -61,9 +61,9 @@ 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. -## Recette : l'arc +## Recipe: the arc -L'effet « arc motion path » — une pastille qui décrit une courbe en pivotant pour rester face à sa trajectoire — est un `motion_path` à une seule cubique et `orient: true`. Il n'y a pas de raccourci `arc` : une cubique le décrit exactement, et lisiblement. +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": [{ @@ -75,7 +75,7 @@ L'effet « arc motion path » — une pastille qui décrit une courbe en pivotan }] ``` -Les deux points de contrôle à la même hauteur (`-220`) donnent un arc symétrique ; les rapprocher de leurs extrémités respectives l'aplatit au milieu et le creuse aux bouts. Pour un arc **descendant**, inverse le signe : `C260,220 620,220 880,0`. +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 diff --git a/.claude/skills/rustmotion/rules/number-wheel.md b/.claude/skills/rustmotion/rules/number-wheel.md index 454d71f..5ceb72a 100644 --- a/.claude/skills/rustmotion/rules/number-wheel.md +++ b/.claude/skills/rustmotion/rules/number-wheel.md @@ -1,10 +1,10 @@ # Rule: `number_wheel` vs `counter` -Deux composants affichent un nombre qui s'anime. Ils ne racontent pas la même chose. +Two components display an animated number. They don't tell the same story. -**`counter`** interpole une *valeur* et réécrit le nombre à chaque frame. Il répond à « combien, en ce moment ? » — une jauge qui monte, un total qui se cumule. Ses glyphes sautent, parce que 8 999 puis 9 000 n'ont rien en commun. +**`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`** fait défiler des bandes de chiffres, comme un compteur mécanique. Il répond à « la figure atterrit » — un KPI qui se pose, un résultat qui se révèle. Ce qu'on regarde, c'est le mouvement ; ce qui reste, c'est le chiffre demandé. +**`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 { @@ -18,27 +18,27 @@ Deux composants affichent un nombre qui s'anime. Ils ne racontent pas la même c } ``` -| Champ | Rôle | Défaut | +| Field | Role | Default | |---|---|---| -| `value` | La figure telle qu'écrite : `"30,222"`, `"5.7"`, `"98%"` | requis | -| `spin` | `single` / `double` / `triple` — tours de 0-9 avant l'atterrissage | `single` | -| `duration` | Durée d'atterrissage **d'une** roue | `1.2` | -| `delay` | Avant le départ de la première roue | `0` | -| `stagger_per_column` | Décalage par colonne, de gauche à droite | `0.08` | -| `easing` | Courbe du trajet | `ease_out_cubic` | +| `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` est une chaîne, pas un nombre +## `value` is a string, not a number -Les chiffres roulent ; tout le reste — virgule, point, signe, unité — est peint à sa place, immobile. C'est ce qui permet d'écrire `"1 204 €"` ou `"98%"` sans que le séparateur ne parte en vrille. +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` change la vitesse, pas la durée +## `spin` changes the speed, not the duration -Chaque roue prend `duration` quoi qu'il arrive. `triple` ne rend pas l'animation plus longue : il fait défiler trois fois plus de chiffres dans le même temps. Un `triple` sur une `duration` courte devient une bouillie illisible. +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` est un défaut à éviter +## `stagger_per_column: 0` is a default to avoid -Toutes les roues atterrissent alors ensemble, ce qui lit comme un simple flip. Le décalage gauche→droite est ce qui fait que le dernier chiffre est celui qui *règle* la figure. +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. -## La boîte réserve la place du chiffre le plus large +## The box reserves space for the widest digit -Chaque colonne fait la largeur du chiffre le plus large de la police, pas celle du chiffre final : sinon un `111` réserverait une boîte étroite puis déborderait pendant qu'un `0` défile. Le validateur mesure la même chose. +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. diff --git a/.claude/skills/rustmotion/rules/pointer-walkthrough.md b/.claude/skills/rustmotion/rules/pointer-walkthrough.md index 8d57468..f769e6e 100644 --- a/.claude/skills/rustmotion/rules/pointer-walkthrough.md +++ b/.claude/skills/rustmotion/rules/pointer-walkthrough.md @@ -1,8 +1,8 @@ -# Rule: Curseur de souris simulé (`pointer`) +# Rule: Simulated mouse pointer (`pointer`) -Pour une démo produit ou un walkthrough d'agent — la flèche qui se déplace vers un contrôle et clique dessus — utilise `pointer`. +For a product demo or an agent walkthrough — the arrow that moves to a control and clicks it — use `pointer`. -**`cursor` n'est pas ça.** `cursor` est un caret texte : une barre verticale clignotante. Son champ `cursor_style: "pointer"` est de la métadonnée morte, il dessine une barre dans les deux cas. +**`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 { @@ -23,27 +23,27 @@ Pour une démo produit ou un walkthrough d'agent — la flèche qui se déplace } ``` -| Champ | Rôle | +| Field | Role | |---|---| -| `size` | Hauteur de la flèche en px. L'anneau de clic suit. | -| `tone` | `light` (flèche blanche, contour sombre) ou `dark` | -| `color` / `outline_color` | Surchargent `tone` | +| `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}` — le pointeur **clique en arrivant** sur chacun | -| `click_at` | Clics d'un pointeur immobile. **Ignoré si `path` est présent** | -| `click_duration` | Durée du clic, *et* pause sur le waypoint avant de repartir | -| `path_easing` | `ease_in_out` (défaut), `linear`, `ease_out`, `step` | +| `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` | -## Les coordonnées partent de l'origine du composant +## Coordinates are relative to the component's own origin -`x`/`y` d'un waypoint sont relatifs à la boîte du `pointer`, pas au device. Place le composant en `position: absolute, x: 0, y: 0` et les waypoints se lisent alors comme des coordonnées de scène — c'est la forme à privilégier pour un walkthrough. +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. -## La boîte est le glyphe, pas le parcours +## The box is the glyph, not the path -La boîte du composant fait la taille de la flèche : ce sont les waypoints qui la translatent. Dimensionner la boîte au parcours ferait pousser les frères d'un `flex` par un élément qui n'est qu'un curseur. +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. -Corollaire : `pointer` est **exempté du contrôle de débordement viewport**, comme `marquee` et `cursor`. Une démo qui amène la flèche près d'un bord met légitimement sa queue dehors. +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. -## Le déplacement fait une pause sur le clic +## The move pauses on the click -Entre deux waypoints, le pointeur ne repart qu'une fois l'animation de clic finie (`click_duration`). C'est ce qui rend le geste lisible : arriver, cliquer, repartir. Un `click_duration` proche de l'écart entre deux waypoints laisse à peine le temps du trajet — laisse au moins le double. +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. diff --git a/.claude/skills/rustmotion/rules/streaming-text.md b/.claude/skills/rustmotion/rules/streaming-text.md index 768bd3e..2e58178 100644 --- a/.claude/skills/rustmotion/rules/streaming-text.md +++ b/.claude/skills/rustmotion/rules/streaming-text.md @@ -1,11 +1,11 @@ -# Rule: Texte qui « streame » (arrivée de tokens) +# Rule: Text that "streams" (tokens arriving) -Pour figurer une réponse de modèle en train de s'écrire, **n'utilise pas `typewriter`** : un typewriter révèle caractère par caractère à cadence fixe, ce qui lit comme une machine à écrire, pas comme un flux de tokens. Un modèle émet des mots entiers, par bouffées inégales, et chaque mot s'installe visuellement au lieu d'apparaître net d'un coup. +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": "Les mots arrivent par bouffées inégales, comme des tokens.", + "content": "Words arrive in uneven bursts, like tokens.", "style": { "font-size": 40, "color": "#E2E8F0", @@ -23,20 +23,20 @@ Pour figurer une réponse de modèle en train de s'écrire, **n'utilise pas `typ } ``` -Trois champs font tout le travail : +Three fields do all the work: -- **`granularity: "word"`** — l'unité est le mot, pas la lettre. -- **`jitter`** — décale le départ de chaque unité de ±`jitter × stagger`. C'est ce qui casse la cadence métronomique. 0.5–0.8 lit comme du streaming ; au-delà de 1.0 les mots se croisent et l'ordre de lecture se brouille. -- **`ink_from`** — chaque mot démarre dans cette couleur et converge vers `style.color` sur sa durée. Un gris désaturé reproduit le token « pas encore accepté par l'œil ». +- **`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. -## Le `jitter` est déterministe, pas aléatoire +## `jitter` is deterministic, not random -Les décalages sont dérivés de `seed` et de l'index de l'unité, jamais d'un RNG. C'est une contrainte, pas un détail : les frames sont rendues dans le désordre, en parallèle, et parfois dans des processus séparés (`--frames a-b`). Un mot dont le départ dépendrait d'un tirage sauterait entre deux frames voisines. +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. -Changer `seed` rebat le rythme sans en changer la statistique — utile pour que deux paragraphes voisins ne « respirent » pas à l'identique. +Changing `seed` reshuffles the rhythm without changing its statistics — useful so two neighbouring paragraphs don't "breathe" identically. -Aucune unité ne peut démarrer avant le `delay` de l'effet : un décalage négatif sur la première unité la ferait apparaître à moitié animée dès la frame 0. +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 × nombre de mots + duration` est le temps total d'installation. Sur une phrase de 12 mots avec `stagger: 0.09`, c'est ~1.4 s — vérifie que la scène est assez longue, `validate` le signale sinon. +`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. diff --git a/.claude/skills/rustmotion/rules/text-polish.md b/.claude/skills/rustmotion/rules/text-polish.md index e14232d..87cbf8f 100644 --- a/.claude/skills/rustmotion/rules/text-polish.md +++ b/.claude/skills/rustmotion/rules/text-polish.md @@ -1,10 +1,10 @@ -# Rule: Les quatre finitions de texte +# Rule: The four text finishes -Quatre mécanismes qui demandaient chacun un sous-arbre bricolé à la main s'écrivent maintenant en une ligne. Aucun ne remplace un preset d'entrée : ils s'ajoutent par-dessus. +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` — la lumière passe sur les lettres +## `shimmer` — the light sweeps over the letters -Effet d'animation, pas champ de composant. La bande n'éclaire **que les pixels réellement peints** (composée en `SrcATop` dans la couche du nœud) : sur un `text`, la lumière accroche les glyphes, pas la boîte. +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": [{ @@ -15,11 +15,11 @@ Effet d'animation, pas champ de composant. La bande n'éclaire **que les pixels }] ``` -`width` est la largeur de la bande en fraction du trajet (0.3 = glint net, 0.8 = lavage doux). `angle` incline la bande : `0` est verticale et balaie de gauche à droite ; ~20° est ce qui la fait lire comme un reflet plutôt que comme un essuyage. La couche isolée n'est ouverte que pendant la fenêtre de l'effet — un `shimmer` non bouclé ne coûte rien le reste de la scène. +`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. -Combiné à `char_blur_in` sur le même `text`, ça reproduit le « text stagger » : les mots montent en se défloutant, puis la lumière passe. +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` — un libellé qui en devient un autre +## `text.states` — a label that becomes another ```json { @@ -31,11 +31,11 @@ Combiné à `char_blur_in` sur le même `text`, ça reproduit le « text stagger } ``` -Sans `swap`, les libellés se coupent net à chaque `at` — abrupt, mais c'est exactement ce que demande l'absence du champ. Avec `swap`, les deux sont à l'écran pendant la fenêtre : le sortant monte en floutant, l'entrant monte du bas en se défloutant. +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. -**La boîte est mesurée sur le libellé le plus long**, pas sur le premier. Une boîte dimensionnée pour `"Saved"` déborderait à l'instant du retour vers `"Saving draft"` — et le validateur aurait signé. +**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` — le caret suit la révélation +## `text.caret` — the caret follows the reveal ```json { "type": "text", "content": "rustmotion --frames 0-60", @@ -43,12 +43,12 @@ Sans `swap`, les libellés se coupent net à chaque `at` — abrupt, mais c'est "style": { "animation": [{ "name": "typewriter", "duration": 2.0 }] } } ``` -`shape`: `line` (règle fine) ou `block` (terminal). `blink` est la période complète en secondes (`0` = fixe). `hide_when_done: true` retire le caret une fois la révélation finie au lieu de le laisser garé. +`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. -C'est la raison d'être du champ : un `cursor` composé à côté du texte reste où on l'a mis pendant que le texte pousse sous lui. Le caret est aussi présent **avant** le premier caractère, sinon la première frame est vide puis caret et lettre apparaissent ensemble, ce qui lit comme un glitch. +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` — l'arrivée de badge +## `pop_in` — the badge arrival -Preset d'animation : l'élément grossit depuis rien avec un dépassement `back-out`, **puis** une courte impulsion élastique une fois posé. `overshoot` règle l'amplitude de l'impulsion (défaut 0.18 = 118 %) ; `0` la supprime et laisse un simple scale-in. +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. -Les deux temps comptent : le premier place l'élément, le second y ramène l'œil. Fondus en une seule courbe, ils lisent comme un tremblement. +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. diff --git a/CLAUDE.md b/CLAUDE.md index b4eee62..fd93be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,17 +58,17 @@ Un scénario est soit une liste plate `scenes` (racine) — implicitement envelo Dans une vue `slide`, les `transition` entre scènes sont des **composites pixel de deux frame-buffers déjà rendus** (fade, wipe, zoom, flip, iris, slide, chromatic_wipe…) : aucun élément ne survit à la coupe, seuls les pixels sont mélangés. -> `chromatic_wipe` est un slide rapide dont le bord de révélation se dédouble en rouge/cyan au pic puis se recompose. `direction` (`left`/`right`/`up`/`down`) l'oriente, `aberration` dose l'écart des canaux (`0` = slide nu, `2` = doublé). Le flash est nul aux deux extrémités : une frange résiduelle sur la dernière frame baverait dans la scène suivante. +> `chromatic_wipe` is a fast slide whose reveal edge splits into red/cyan at its peak, then recomposes. `direction` (`left`/`right`/`up`/`down`) orients it, `aberration` sets how far the channels split (`0` = a plain slide, `2` = fully doubled). The flash is zero at both ends: a leftover fringe on the last frame would bleed into the next scene. La vue **`world`** est le seul mécanisme qui produit une continuité réelle entre beats : une caméra virtuelle se déplace en continu à travers un espace 2D où chaque scène occupe une position (`world-position`), avec un fondu de recouvrement pendant le pan au lieu d'une coupe. C'est la brique à utiliser pour une vidéo qui doit se lire comme un plan continu, sans limite de scène perceptible. Voir [rules/world-view.md](.claude/skills/rustmotion/rules/world-view.md) pour le modèle de coordonnées (le piège `world-position` = waypoint caméra, pas origine de scène), la recette du halo ambiant en `view.background`, et un exemple multi-beat validé. **Piège de casing à connaître :** `world-position` (scène) est en kebab-case, alors que son voisin `freeze_at` (même struct `Scene`) est en snake_case. Vraie inconsistance du schéma, pas une faute de frappe — copier la casse telle quelle. -## Fonds animés +## Animated backgrounds -`scene["animated-background"]` (kebab-case, comme `world-position`) accepte un `preset` parmi `gradient_shift`, `grid_dots`, `grid_lines`, `concentric_circles`, `halo`, `pixel_grid`, `heropattern`, avec sa config sous une clé du même nom, plus les `x`/`y`/`speed`/`direction` communs qui font dériver la texture. +`scene["animated-background"]` (kebab-case, like `world-position`) accepts a `preset` from `gradient_shift`, `grid_dots`, `grid_lines`, `concentric_circles`, `halo`, `pixel_grid`, `heropattern`, with its config under a key of the same name, plus the common `x`/`y`/`speed`/`direction` that drift the texture. -> `grid_dots` marque les intersections et lit comme une texture ; `grid_lines` est une grille de **lignes** réglées et lit comme une structure — c'est celle à mettre derrière un graphe ou un panneau de code. Config : `cell`, `weight`, `color`, plus `major_every`/`major_weight` pour l'effet papier millimétré. +> `grid_dots` marks the intersections and reads as a texture; `grid_lines` is a grid of ruled **lines** and reads as a structure — the one to put behind a chart or a code panel. Config: `cell`, `weight`, `color`, plus `major_every`/`major_weight` for the graph-paper effect. ## Composants disponibles (60) @@ -90,7 +90,7 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `dot_map` — carte mondiale en dot-pattern avec points de données, pulse, lat/lng - `progress` — barre linéaire ou circulaire - `counter` — compteur animé (standalone uniquement, pas dans les cards) -- `number_wheel` — chiffres qui défilent façon compteur mécanique et atterrissent sur la figure. À ne pas confondre avec `counter`, qui interpole une valeur et réécrit le nombre (ses glyphes sautent). Voir [rules/number-wheel.md](.claude/skills/rustmotion/rules/number-wheel.md). +- `number_wheel` — digits that scroll like a mechanical odometer and land on the figure. Not to be confused with `counter`, which interpolates a value and rewrites the number (its glyphs jump). See [rules/number-wheel.md](.claude/skills/rustmotion/rules/number-wheel.md). - `table` — tableau avec column_widths, column_align, cell_padding, show_borders ### UI Components @@ -112,8 +112,8 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `tag_cloud` — nuage de mots avec tailles pondérées - `callout` — bulle avec flèche - `divider` — séparateur visuel -- `success_check` — coche qui se trace dans un halo, avec pop et rotation résorbée -- `pointer` — curseur de **souris** simulé (flèche + anneau de clic) suivant des waypoints. `cursor` est un caret texte, pas ça. Voir [rules/pointer-walkthrough.md](.claude/skills/rustmotion/rules/pointer-walkthrough.md). +- `success_check` — a checkmark that draws itself inside a halo, with a pop and a settling rotation +- `pointer` — a simulated **mouse** cursor (arrow + click ring) following waypoints. `cursor` is a text caret, not this. See [rules/pointer-walkthrough.md](.claude/skills/rustmotion/rules/pointer-walkthrough.md). ### Code & Terminal - `codeblock` — code syntax-highlighted avec reveal, diff mode (`diff: true`), state transitions @@ -133,18 +133,18 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en > Voir [rules/audio-reactive.md](.claude/skills/rustmotion/rules/audio-reactive.md) pour lier un composant à une piste `audio` via `style.audio-reactive`. -## Finitions de texte +## Text finishes -Quatre mécanismes qui demandaient chacun un sous-arbre bricolé à la main. Détails et pièges dans [rules/text-polish.md](.claude/skills/rustmotion/rules/text-polish.md). +Four mechanisms that each used to demand a hand-assembled sub-tree. Details and pitfalls in [rules/text-polish.md](.claude/skills/rustmotion/rules/text-polish.md). -- **`shimmer`** — effet d'animation : une bande de lumière balaie l'élément. Composée en `SrcATop` dans la couche du nœud, donc elle n'éclaire **que les pixels réellement peints** (les glyphes, pas la boîte). Combinée à `char_blur_in`, c'est le « text stagger ». -- **`text.states` + `text.swap`** — un libellé qui en devient un autre : le sortant monte en floutant, l'entrant monte du bas en se défloutant. La boîte est mesurée sur le libellé **le plus long**, pas le premier. -- **`text.caret`** — un caret (`line` ou `block`) accroché à la tête de révélation d'un `typewriter`. Un `cursor` composé à côté resterait où on l'a mis pendant que le texte pousse sous lui. -- **`pop_in`** — preset d'entrée en deux temps : scale back-out qui *place* l'élément, puis courte impulsion élastique qui y ramène l'œil. +- **`shimmer`** — animation effect: a band of light sweeps over the element. Composited `SrcATop` inside the node's layer, so it only lights up **pixels that are actually painted** (the glyphs, not the box). Combined with `char_blur_in`, this is "text stagger". +- **`text.states` + `text.swap`** — a label that becomes another one: 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. +- **`text.caret`** — a caret (`line` or `block`) pinned to a `typewriter`'s reveal head. A `cursor` composited alongside would stay where it was placed while the text grows underneath it. +- **`pop_in`** — a two-beat entry preset: a back-out scale that *places* the element, then a short elastic pulse that draws the eye back to it. -Les sept presets `char_*` se règlent par `direction` (up/down/left/right), `distance`, `scale_from`, `jitter`+`seed` (irrégularité déterministe du stagger) et `ink_from` (couleur de départ de chaque unité). Voir [rules/char-animation-tuning.md](.claude/skills/rustmotion/rules/char-animation-tuning.md) et, pour l'arrivée de tokens, [rules/streaming-text.md](.claude/skills/rustmotion/rules/streaming-text.md). +The seven `char_*` presets are tuned via `direction` (up/down/left/right), `distance`, `scale_from`, `jitter`+`seed` (deterministic stagger irregularity) and `ink_from` (each unit's starting colour). See [rules/char-animation-tuning.md](.claude/skills/rustmotion/rules/char-animation-tuning.md) and, for tokens arriving, [rules/streaming-text.md](.claude/skills/rustmotion/rules/streaming-text.md). -> Si on te demande un effet nommé dans le vocabulaire Hyperframes (« streaming text », « number wheel », « badge pop », « top-down letters »…), consulte d'abord la table de correspondance : [rules/hyperframes-mapping.md](.claude/skills/rustmotion/rules/hyperframes-mapping.md). +> If you're asked for an effect named in the Hyperframes vocabulary ("streaming text", "number wheel", "badge pop", "top-down letters"…), check the mapping table first: [rules/hyperframes-mapping.md](.claude/skills/rustmotion/rules/hyperframes-mapping.md). ## Architecture diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs index 3ad96c9..9d0b913 100644 --- a/crates/rustmotion-components/src/pointer.rs +++ b/crates/rustmotion-components/src/pointer.rs @@ -158,18 +158,19 @@ impl Pointer { ) } - /// The classic arrow, drawn tip-first at the origin and scaled to - /// `size`. Coordinates are in units of the pointer's height, so the - /// glyph keeps its proportions at any size. + /// The classic arrow — tip, left edge, tail notch, and back up the right + /// shoulder — drawn tip-first at the origin and scaled to `size`. + /// Coordinates are in units of the pointer's height, so the glyph keeps + /// its proportions at any size. fn arrow_path(size: f32) -> Path { const OUTLINE: [(f32, f32); 7] = [ - (0.0, 0.0), // tip - (0.0, 0.72), // down the left edge - (0.19, 0.56), // into the notch - (0.30, 0.84), // out along the tail - (0.43, 0.78), // tail's far side - (0.32, 0.51), // back up - (0.54, 0.51), // the right shoulder + (0.0, 0.0), + (0.0, 0.72), + (0.19, 0.56), + (0.30, 0.84), + (0.43, 0.78), + (0.32, 0.51), + (0.54, 0.51), ]; let mut path = Path::new(); for (i, (x, y)) in OUTLINE.iter().enumerate() { diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 2bebc23..61f317b 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -882,12 +882,9 @@ fn draw_text_lines( ); } - // Draw stroke (outline) if let Some(sp) = stroke_paint { draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, sp); } - - // Draw fill draw_text_with_fallback(canvas, line, font, emoji_font, letter_spacing, x, y, paint); } }