From 8755be795af9ee9e210a8d827cf33c634bbfdf4d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 10:18:38 +0200 Subject: [PATCH] docs: describe the six capabilities shipped since the differential was re-scored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six features landed in #168-#178 that neither CLAUDE.md nor any rule mentions. For a tool whose scenarios are generated, a capability the rules do not describe may as well not exist — issue #165 was exactly this failure one level down, where the distribution channel dropped 17 rules. Two new rules: - `templates-and-iteration.md` — `components` / `for-each` / `use`, the largest new vocabulary, aimed at the failure mode the audit called dominant. - `motion-path.md` — the effect, its SVG path syntax, and why coordinates are deltas from the layout position. Two extended: - `geometry-safety.md` gains `text-autofit` as a fourth viewport control, next to `white-space`, `auto_scroll` and `overflow` — with the floor and the fact that a violation is still reported when shrinking is not enough. - `timeline-sequencing.md` gains what `style.transition` actually smooths: the four interpolated properties, and the three reasons a property snaps instead, since `validate` now names them. CLAUDE.md picks up the same, plus `--frames` / `concat`, `--hardware-acceleration`, and the `--fix` refusals. Writing the docs found a real trap, which is why the examples were run rather than only written. A `for-each` item that omits a field, forwarded through a `use`'s `props`, passes the literal `$name` instead of falling back to the component's `default` — a `params` default applies when `props` omits the key, not when it forwards an unresolved binding. Nothing is silent about it (an unresolved-variable warning, then the consumer rejects the value) but it is the natural way to write "optional field", so it now has its own section. The example that exposed it is corrected and validates. The two new rules ship without touching `skills.rs`: `skills install` goes from 49 files to 51, and the exhaustiveness test stays green. That is PR #168's build script doing what it was written for. --- .../rustmotion/rules/geometry-safety.md | 23 ++- .../skills/rustmotion/rules/motion-path.md | 66 +++++++++ .../rules/templates-and-iteration.md | 140 ++++++++++++++++++ .../rustmotion/rules/timeline-sequencing.md | 25 ++++ CLAUDE.md | 29 +++- 5 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/rustmotion/rules/motion-path.md create mode 100644 .claude/skills/rustmotion/rules/templates-and-iteration.md diff --git a/.claude/skills/rustmotion/rules/geometry-safety.md b/.claude/skills/rustmotion/rules/geometry-safety.md index 83eb529f..0601fc3a 100644 --- a/.claude/skills/rustmotion/rules/geometry-safety.md +++ b/.claude/skills/rustmotion/rules/geometry-safety.md @@ -1,6 +1,6 @@ # Rule: Keep Content Inside the Viewport -No textual content may bleed out of the device viewport. The renderer enforces this through three opt-in mechanisms, all checked by `rustmotion validate`. +No textual content may bleed out of the device viewport. The renderer enforces this through four opt-in mechanisms, all checked by `rustmotion validate`. ## 1. Text wrapping (`style.white-space`) @@ -29,7 +29,26 @@ When you give a `codeblock` or `terminal` a fixed `size` smaller than its natura } ``` -## 3. Container `style.overflow` +## 3. Text shrink-to-fit (`style.text-autofit`) + +`text` and `gradient_text` accept `text-autofit: true`, which reduces their font size until the content fits the resolved box — width, and height when taffy resolves one. + +```json +{ "type": "text", "content": "A headline too long for its box", + "style": { "width": "320px", "height": "90px", "font-size": 120, "text-autofit": true } } +``` + +Use it when the copy is data-driven and you cannot know in advance whether it fits — a label coming from a `for-each`, a headline injected through a variable. Do **not** reach for it to paper over a layout you can simply size correctly: shrinking is a fallback, not a design. + +Three things to know: + +- **It has a floor.** Shrinking stops at a calibrated legibility threshold and never goes below it. If the text still does not fit at the floor, the geometry violation is **still reported** — `text-autofit` narrows that failure class, it does not silence it. +- **`white-space` still decides whether the text wraps**; `text-autofit` only decides at what size. They compose. +- **Only these two components implement it.** Declaring it on a `caption`, a `codeblock` or a `table` is inert — those painters never read it. + +On a canvas taller than 1080, `validate` warns that autofit may shrink below the legibility floor for that frame height. That warning is about the *rendered* size, not the declared one. + +## 4. Container `style.overflow` CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at the parent box. The validator only fails when content escapes the **viewport**, not a `visible` parent — a badge sticking out of a card is legal. diff --git a/.claude/skills/rustmotion/rules/motion-path.md b/.claude/skills/rustmotion/rules/motion-path.md new file mode 100644 index 00000000..8e481ec6 --- /dev/null +++ b/.claude/skills/rustmotion/rules/motion-path.md @@ -0,0 +1,66 @@ +# Rule: Motion Path + +Pour faire suivre une trajectoire à un composant — une courbe, un arc, un tracé en S — utilise l'effet d'animation `motion_path` plutôt que d'empiler des `translate` successifs dans une `timeline`. + +## La forme + +```json +{ + "type": "shape", + "shape": "circle", + "fill": "#F68F2B", + "position": "absolute", + "x": 160, + "y": 700, + "style": { + "width": "56px", + "height": "56px", + "animation": [{ + "name": "motion_path", + "path": "M0,0 C300,-320 750,-320 1100,-60", + "delay": 0.2, + "duration": 2.4, + "orient": true, + "orient_offset": 90, + "easing": "ease_in_out" + }] + } +} +``` + +| Champ | Rôle | +|---|---| +| `path` | Données de chemin SVG (`M`/`L`/`H`/`V`/`C`/`S`/`Q`/`T`/`A`/`Z`) — **la même syntaxe** que `shape: { "type": "path", "data": ... }` | +| `delay`, `duration` | Fenêtre temporelle, comme tout autre effet | +| `loop` | Reprend au début à la fin du parcours | +| `orient` | Oriente le composant selon la tangente | +| `orient_offset` | Correction d'angle, en degrés | +| `easing` | Appliqué à la progression **le long du chemin**. Défaut linéaire = vitesse constante sur la courbe | + +## Les coordonnées sont des deltas + +Le chemin est relatif à la position que le layout aurait donnée au composant. `M0,0` est donc son point de repos, pas le coin du device — même convention qu'`orbit`. + +Concrètement : positionne le composant normalement (`x`/`y`, ou le flux), puis décris la trajectoire **depuis là**. + +## `orient_offset` n'est pas décoratif + +Un composant est orienté selon la tangente, et la tangente pointe dans le sens du parcours. Si ton visuel pointe naturellement vers le haut — une flèche, une icône de fusée, un curseur — il apparaîtra tourné de 90° sur un chemin horizontal. `orient_offset: 90` le corrige. + +Vérifie l'orientation au repos de ton visuel avant de conclure que `orient` est cassé. + +## Le validateur voit la trajectoire + +La position résout en `transform`, donc `rustmotion validate --strict-anim` détecte un composant qui sort du cadre en suivant sa courbe, et nomme l'instant : + +``` +bbox: [2046, 700] -> [2102, 756] (viewport: 1920x1080) +hint: at t=1.70s (57% of scene), animation transforms (tx=1886, ty=0, …) + push the bbox out of the viewport +``` + +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. + +## 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/templates-and-iteration.md b/.claude/skills/rustmotion/rules/templates-and-iteration.md new file mode 100644 index 00000000..c4add167 --- /dev/null +++ b/.claude/skills/rustmotion/rules/templates-and-iteration.md @@ -0,0 +1,140 @@ +# Rule: Templates & Iteration + +Ne duplique pas un sous-arbre. Si dix cartes ne diffèrent que par leurs données, écris-en une et itère. + +C'est le mode d'échec le plus fréquent de la génération : dix copies écrites à la main, dont l'une finit par diverger sur une couleur, une `font-size` ou un `position` oublié. + +## `for-each` — répéter un sous-arbre + +Utilisable dans n'importe quel tableau `children`. + +```json +{ + "for-each": [ + { "label": "Revenue", "value": 1250, "accent": "#22C55E" }, + { "label": "Users", "value": 340, "accent": "#3B82F6" } + ], + "template": { + "type": "card", + "style": { "width": "300px", "height": "160px", "background": "#111827" }, + "children": [ + { "type": "text", "content": "$label", "style": { "color": "$accent" } } + ] + } +} +``` + +Chaque élément du tableau **lie ses propres champs directement** : `$label`, `$value`, `$accent`. Il n'y a pas d'accès par chemin pointé — écrire `$item.label` ne fonctionne pas. + +Deux liaisons sont fournies en plus : + +| Liaison | Contenu | +|---|---| +| `$index` | La position dans le tableau, à partir de 0 | +| `$item` | L'élément entier, pour le transmettre tel quel | + +Une donnée portant explicitement le nom `index` ou `item` gagne toujours sur la liaison intégrée. + +Le `template` peut être un objet unique **ou un tableau** — dans ce cas ses éléments sont insérés comme des frères, pas imbriqués. + +## `components` + `use` — définir une fois, instancier partout + +Bloc racine, à côté de `scenes` / `composition` : + +```json +{ + "components": { + "stat_card": { + "params": { + "label": { "type": "string" }, + "value": { "type": "number", "default": 0 }, + "accent": { "type": "string", "default": "#6366F1" } + }, + "template": { + "type": "card", + "children": [ + { "type": "text", "content": "$label", "style": { "color": "$accent" } } + ] + } + } + } +} +``` + +`params` a exactement la forme de `config` : `type`, `default`, `description`. **Omettre `default` rend le paramètre requis** — l'instancier sans le fournir est une erreur nommée, pas un défaut silencieux. + +Instanciation : + +```json +{ "use": "stat_card", "props": { "label": "Revenue", "value": 1250 } } +``` + +La clé s'appelle **`props`**, pas `config`. Ce n'est pas une inconsistance : la substitution de variables saute délibérément tout objet portant la clé `config`, pour protéger le bloc de déclarations racine. Utiliser ce nom ici laisserait tout `for-each` imbriqué dans un `use` silencieusement non substitué. + +## Les deux se composent + +C'est la forme la plus utile — une définition, une liste de données : + +```json +{ + "for-each": [ + { "label": "Revenue", "value": 1250, "accent": "#22C55E" }, + { "label": "Users", "value": 340, "accent": "#3B82F6" }, + { "label": "Growth", "value": 8, "accent": "#F59E0B" } + ], + "template": { + "use": "stat_card", + "props": { "label": "$label", "value": "$value", "accent": "$accent" } + } +} +``` + +## Le piège : un champ omis dans un élément + +**Chaque élément d'un `for-each` doit fournir tous les champs que le template référence.** + +Ceci ne fonctionne pas : + +```json +"for-each": [ + { "label": "Revenue", "accent": "#22C55E" }, + { "label": "Users" } +], +"template": { "use": "stat_card", "props": { "label": "$label", "accent": "$accent" } } +``` + +Le second élément n'a pas d'`accent`, donc `$accent` reste littéral et le composant reçoit la chaîne `"$accent"` — pas son `default`. Un `default` de `params` s'applique quand `props` **omet la clé**, pas quand `props` transmet une liaison non résolue. + +Ce n'est pas silencieux : la validation émet un avertissement de variable non résolue, et le composant qui consomme la valeur échoue à son tour (ici, `color '$accent' is not a recognized CSS color`). Mais corrige la donnée plutôt que le symptôme — remplis le champ dans chaque élément : + +```json +"for-each": [ + { "label": "Revenue", "accent": "#22C55E" }, + { "label": "Users", "accent": "#6366F1" } +] +``` + +## Ordre des passes, et ce qu'il autorise + +Substitution des variables → expansion des directives → `include`, appliqué par document. + +- **Tu peux** itérer sur un tableau venu d'une variable `config` ou de `--var` : la substitution tourne avant l'expansion. +- **Tu ne peux pas** instancier un composant défini dans un fichier inclus. `components` est strictement local au fichier qui le déclare, comme `config`. Dans les deux sens, c'est une erreur nommée, jamais une portée silencieusement fausse. + +## Ce que ça coûte + +**`--fix` refuse de réécrire un scénario qui utilise ces directives.** Les chemins de violation portent des index post-expansion ; une itération sur dix éléments décale de neuf tout ce qui suit, donc `--fix` patcherait le mauvais nœud. Il refuse plutôt que de corriger à côté — exactement comme pour `include`. + +Tu peux toujours valider (`rustmotion validate` voit l'arbre expansé, donc la géométrie est vérifiée sur ce qui sera réellement rendu). Seule la réécriture automatique est indisponible. + +## Erreurs nommées + +Aucune de ces situations ne passe en silence : + +| Situation | Diagnostic | +|---|---| +| Cycle entre composants | La chaîne complète (`a -> b -> a`), jamais un débordement de pile | +| `for-each` sur autre chose qu'un tableau | Ce qui a été trouvé à la place, avec un indice si ça ressemble à un `$var` non résolu | +| `use` d'un composant inconnu | Le nom manquant | +| Paramètre requis absent | Le nom du paramètre | +| Clé de `props` non déclarée | Le nom de la clé | diff --git a/.claude/skills/rustmotion/rules/timeline-sequencing.md b/.claude/skills/rustmotion/rules/timeline-sequencing.md index 350e03c6..e4a73885 100644 --- a/.claude/skills/rustmotion/rules/timeline-sequencing.md +++ b/.claude/skills/rustmotion/rules/timeline-sequencing.md @@ -106,3 +106,28 @@ For items entering one by one without exits, use increasing `delay` on sibling e { "type": "text", "content": "Second", "style": { "animation": [{ "name": "fade_in_up", "delay": 0.2, "duration": 0.6 }] } }, { "type": "text", "content": "Third", "style": { "animation": [{ "name": "fade_in_up", "delay": 0.4, "duration": 0.6 }] } } ``` + +## Ce que `style.transition` lisse réellement + +`style.transition` fait interpoler les changements posés par un `timeline` — mais **seulement pour certaines propriétés**. Toutes les autres sautent à l'instant du pas. + +Interpolées aujourd'hui : + +| Propriété | Restriction | +|---|---| +| `opacity` | — | +| `color` | sur `text` / `counter` | +| `background` | couleur solide uniquement | +| `border-radius` | rayon uniforme, en px absolus | + +Tout le reste saute. Ce n'est pas silencieux : dès que `style.transition` est posé, `rustmotion validate` inspecte les diffs entre états de `timeline` et **nomme chaque propriété qui ne sera pas lissée**, avec la raison. + +Trois raisons distinctes, et le message le dit : + +- **Propriété de layout** (`width`, `height`, `margin`, `padding`, `gap`, `font-size`, `top`/`left`…) — interpoler demanderait de relancer le layout à chaque frame échantillonnée. Le message suggère l'alternative : `transform: translate` ou `scale`, qui sont côté peinture et s'interpolent, elles. +- **Propriété discrète** (`display`, `position`, `overflow`, `font-weight`, `text-align`…) — il n'existe aucune valeur intermédiaire. Le saut est le comportement CSS attendu, pas une limite de rustmotion, et le message le précise pour t'éviter de chercher un bug. +- **Peinture non supportée** (`transform`, `box-shadow`, `filter`, `clip-path`…) — continue en principe, pas encore implémenté. + +Les unités relatives (`%`, `em`, `rem`, `vw`, `vh`) et les rayons par coin sont refusés à l'interpolation et signalés : avant le layout, ces unités n'ont pas de base fiable. + +**En pratique :** pour animer une taille ou une position, préfère `transform` à `width`/`top`. C'est ce que le validateur te dira, et c'est aussi ce qui coûte le moins cher à rendre. diff --git a/CLAUDE.md b/CLAUDE.md index 50b85c90..75e41d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,28 +9,49 @@ Tout JSON de scénario généré doit être validé avec `rustmotion validate` a ## Sécurité géométrique (viewport) -Aucun contenu textuel ne doit dépasser du device. Trois propriétés contrôlent ce comportement : +Aucun contenu textuel ne doit dépasser du device. Quatre propriétés contrôlent ce comportement : - `style.white-space` (default `normal`, donc wrap actif) sur `text` : le texte wrap sur la largeur du parent par défaut. `white-space: "nowrap"` (ou `"pre"`) est légitime uniquement si un `max-width` fini + `font-size` raisonnable garantissent que la ligne tient. Le validateur émet `unwrappable_text_overflow` sinon. Il n'existe pas de champ `style.wrap` — c'est un vocabulaire hérité de l'ancien modèle de style, supprimé de `CssStyle`. Voir [rules/geometry-safety.md](.claude/skills/rustmotion/rules/geometry-safety.md). - `auto_scroll` (default `true`) sur `codeblock` et `terminal` : quand le contenu dépasse la hauteur du `size`, le moteur scrolle (clip + translate) sans réduire la `font-size`. `auto_scroll: false` → `auto_scroll_disabled_overflow`. +- `style.text-autofit` (default absent) sur `text` et `gradient_text` : réduit la `font-size` jusqu'à ce que le contenu tienne dans sa boîte. À réserver au texte piloté par des données, dont on ne peut pas connaître la longueur à l'avance — pas pour compenser une mise en page qu'on peut simplement dimensionner. Le rétrécissement s'arrête à un plancher de lisibilité calibré ; si ça ne suffit pas, **la violation est toujours signalée**. Seuls ces deux composants l'implémentent : le déclarer ailleurs est inerte. - `style.overflow` (default `visible`) sur les conteneurs : sémantique CSS. `hidden` clippe au bord du parent. Le validateur ne se plaint que si le contenu sort du **viewport**, pas d'un parent `visible`. `marquee` et `cursor` sont exemptés (leur rôle est de bleed). CLI : - `rustmotion validate -f file.json` — schema + geometry -- `--fix` — auto-fix sûr : `auto_scroll: true` sur `auto_scroll_disabled_overflow`, et retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au défaut `normal`, donc au wrapping). Les débordements de viewport et de boîte ne sont jamais corrigés automatiquement : ils demandent un arbitrage de mise en page. +- `--fix` — auto-fix sûr : `auto_scroll: true` sur `auto_scroll_disabled_overflow`, retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au wrapping), et `text-autofit: true` sur `content_overflows_box` pour `text`/`gradient_text`. Les débordements de viewport restent non corrigés : ils demandent un arbitrage de mise en page. `--fix` **refuse** d'écrire sur un scénario templaté, utilisant `include`, ou utilisant `for-each`/`use` — les index de chemin ne correspondraient plus à la source. - `--report r.json` — rapport JSON -- `--strict-anim` — vérification frame par frame ; ajoute la détection `animated_text_overflow` (transform animé qui sort du viewport à un instant échantillonné) +- `--strict-anim` — vérification frame par frame ; ajoute la détection `animated_text_overflow` (transform animé qui sort du viewport à un instant échantillonné). L'échantillonnage s'arrête à `scene.freeze_at`, puisque rien n'est rendu au-delà. - `--strict-attrs` — promeut en erreurs les attributs inconnus (détection schéma + did-you-mean, activée par défaut en warnings) - `--lenient` — warnings au lieu d'errors ## Encodage - ffmpeg est auto-détecté et utilisé par défaut (10-bit H.264, meilleure qualité sur les gradients sombres) +- `--hardware-acceleration` sonde `ffmpeg -encoders` et bascule sur VideoToolbox/NVENC/QSV/AMF si la machine en offre un. Indisponible → message explicite et repli logiciel, jamais de bascule silencieuse. Le CRF n'a pas de sens sur la plupart des encodeurs matériels : le passer avec l'accélération produit un avertissement. +- `--frames a-b` rend une plage de frames en segment autonome, avec **sa** tranche d'audio (les pistes ne repartent pas de zéro). `rustmotion concat seg1.mp4 seg2.mp4 -o out.mp4` les recolle via le concat demuxer de ffmpeg. C'est la brique d'un rendu distribué. - Sans ffmpeg, le fallback openh264 intégré encode en 8-bit - Pour les vidéos avec des gradients sombres, recommander `--codec prores` pour une qualité maximale +## Factorisation : `components`, `for-each`, `use` + +Ne duplique pas un sous-arbre. Si dix cartes ne diffèrent que par leurs données, écris-en une et itère — c'est le mode d'échec le plus fréquent de la génération, chaque copie étant une occasion de diverger. + +```json +"components": { "stat_card": { "params": { "label": { "type": "string" } }, "template": { … } } }, +"children": [{ + "for-each": [ { "label": "Revenue" }, { "label": "Users" } ], + "template": { "use": "stat_card", "props": { "label": "$label" } } +}] +``` + +Chaque élément du `for-each` lie ses champs directement (`$label`), plus `$index` et `$item`. `params` a la forme de `config` ; omettre `default` rend le paramètre requis. La clé d'overrides est **`props`**, pas `config` — ce nom-là est réservé et serait sauté par la substitution. + +`components` est local au fichier qui le déclare. On peut itérer sur un tableau venu d'une variable ; on ne peut pas instancier un composant défini dans un fichier inclus. Toute erreur — cycle, tableau manquant, composant inconnu, paramètre absent — est nommée et située. Voir [rules/templates-and-iteration.md](.claude/skills/rustmotion/rules/templates-and-iteration.md). + +> `--fix` refuse de réécrire un scénario qui utilise ces directives : les index de chemin ne correspondent plus à la source. `validate` fonctionne normalement, sur l'arbre expansé. + ## Composition : `scenes` vs `composition` (vues `slide` / `world`) 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). @@ -90,6 +111,8 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en ### Diagrammes `arrow`, `connector`, `timeline`, `line` +> Pour faire suivre une trajectoire à un composant, utilise l'effet d'animation `motion_path` (données de chemin SVG, orientation optionnelle selon la tangente) plutôt que d'empiler des `translate`. Voir [rules/motion-path.md](.claude/skills/rustmotion/rules/motion-path.md). + ### Média `mockup`, `lottie`, `cursor`, `particle`, `qr_code`