Skip to content

Commit 38596ca

Browse files
authored
Post-audit reliability hardening — wave 1 (critical + high) (#108)
* fix(css): parse every common colour and length form Colours written `#fff`, `#FFF`, `white` or `rgb(255,255,255)` deserialised without error and then fell back to SColor::BLACK when painting. On the dark backgrounds this engine targets that is invisible text, and `validate` still reported "Valid scenario" — the worst possible failure mode. Only the 6-digit hex form actually worked. Add `parse_css_color` covering 3/4/6/8-digit hex, rgb/rgba, hsl/hsla and the CSS named-colour set, and route the paint pass through it. An unresolvable colour now warns on stderr and renders as opaque magenta rather than black: black is a legitimate colour choice and disappears into a dark frame, magenta never blends in. Lengths had the same shape of bug — an unparseable value silently became 0px. Add fallible `try_parse` counterparts so callers can detect it. Alpha now rounds in both notations, so `0.5` and `50%` agree on 128. The old parser truncated to 127; that was an artefact of the formula rather than a decision, and it made the two spellings of one value disagree. Refs #104 * feat(layout): wire grid track definitions through to taffy The grid component accepted `grid-template-columns`, the validator made it mandatory, and taffy was never told about it — the only grid reference in taffy_bridge was the Display::Grid mapping. Taffy therefore laid out a grid with no tracks and collapsed every child into one implicit column, so a three-column KPI row rendered as three stacked full-width rows while validation reported no problem. Translate grid-template-columns/rows, grid-column, grid-row and grid-auto-flow into their taffy equivalents. Also reorder GridTrack's untagged variants. Length's inner String arm matched any JSON string or number, so it swallowed bare numbers meant for Fr and keywords meant for Keyword, leaving both unreachable. That is why [1,1,1] used to mean three 1px tracks instead of three equal fractions. Single fr values use taffy's flex() helper (minmax(0, Nfr)) rather than fr(), so tracks stay evenly sized regardless of content — matching the flex-row equivalent byte for byte, which matters for a video renderer. Refs #105 * feat(schema): export the component schema and load fonts before validating `rustmotion schema` described none of the 57 components: Scene.children is a Vec<serde_json::Value>, so schemars emitted "items": true — literally any JSON. Constrained generation was therefore impossible, which is the structural reason the authoring surface needs tens of thousands of words of prose rules to be usable. The full schema already existed internally, built by validate_attrs to populate a warning allowlist and never surfaced. Merge schema_for!(Component) into the exported document and type Scene.children as a $ref to it. Definitions go from 38 to 178, with a 57-variant oneOf. Validation also measured text through the Helvetica/Arial fallback while the render used the declared face, because load_custom_fonts only ran on render paths. Call it in run_checks, before any measurement. Delete LayerStyle and its orphaned siblings (BlendMode, Overflow, CardDisplay, Size). LayerStyle is the pre-CSS style model: dead code, but still exported and still carrying wrap/motion-path/size, which kept that vocabulary alive in the documentation long after the code stopped accepting it. Refs #106 * fix(validate): make --fix non-destructive and close geometry blind spots --fix wrote style.wrap on an unwrappable_text_overflow. CssStyle is deny_unknown_fields and has no wrap field, so the component failed to deserialise and was dropped from the render — and because the geometry walker then saw nothing left to check, validation reported clean. The auto-fixer turned text that overflowed into text that was absent, and certified it. Remove style.white-space instead, which falls back to the wrapping default. Violation paths indexed the post-filter child vector while navigate() indexed raw JSON, so a single malformed sibling made --fix patch an unrelated component. Preserve the raw index. container_clips was computed and never read. Honouring it fixes both directions at once: content clipped by an ancestor is no longer a false positive, which is what made deliberate frame-bleeding typography — the signature of the reference style — impossible to author. Its old heuristic also treated a background as clipping, which would have suppressed real overflow on any card that had one. Add ContentOverflowsBox: wrapping text measured against the box it was actually assigned. A paragraph in a fixed-height card paints far outside it while both boxes stay inside the frame, so the viewport check never saw it. --strict-anim discarded t=0 through an opacity guard, and every entrance preset ramps opacity from zero — so the frame of maximum displacement was always the one skipped. It also forked the renderer's effect resolution and re-timed on start_at, which the renderer does not do. Reuse effective_effects and resolve_props_for_effects, and sample proportionally to scene duration. Static css.transform and non-keyframed scene.camera now contribute to bboxes; both were silently ignored. Camera folding is skipped when a scene uses per-plane depth, where the global formula would not apply. Refs #103 * docs(skills): realign the authoring guide with the schema The skills corpus is the de-facto schema an LLM generates from, and it had drifted onto the pre-CSS style model. Because CssStyle is deny_unknown_fields, every stale property silently drops the whole component — so the guide was teaching people to write invisible scenarios. Its canonical Example 1 produced a dropped shape. Move fill/stroke, timeline/stagger and text-background to component root; fix box-shadow to an array with kebab keys; flex-wrap to an enum; transform to an array of functions; reduce grid-template-columns to its one valid syntax; remove motion-path and style.size, which do not exist. Rewrite geometry-safety around white-space, document the fifth violation kind and the clipping exemptions, and correct --fix now that it is safe to use. Resolve the contradictions: position inside card, end_at on counters (the checklist forbade it and two examples used it), the preset counts (31/45/39, actually 40) and the easing count (11, actually 17 plus cubic_bezier). Component count 51 to 57, qrcode to qr_code, and document qr_code, waveform and audio_spectrum, which appeared nowhere. Link the orphaned rules and reference examples/, which is more accurate than the prose was: extracting the 217 fenced JSON blocks and validating each takes failures from 26 to 14, with the remainder being illustrative paths and deliberately-invalid teaching examples. Refs #107 * chore(fmt): apply rustfmt across the wave-1 changes
1 parent b48767d commit 38596ca

25 files changed

Lines changed: 2803 additions & 638 deletions

.claude/skills/rustmotion/SKILL.md

Lines changed: 90 additions & 64 deletions
Large diffs are not rendered by default.

.claude/skills/rustmotion/rules/color-palettes.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,28 +95,29 @@ Pick the palette in Phase 2. Every scene, every card, every text element uses co
9595

9696
### Rule P2: Use $ref for backgrounds
9797

98-
If the same gradient background appears in multiple scenes, define it once and reference it:
98+
If the same gradient background appears in multiple scenes, define it once and reference it. Entries under the top-level `backgrounds` map are stored as raw JSON and only get type-checked when a scene actually resolves the `$ref` — at that point they must deserialize as an `AnimatedBackground`, which **requires a `preset`** (`gradient_shift`, `grid_dots`, `concentric_circles`, `halo`, or `heropattern`) plus that preset's config nested under its own name key. A template shaped like `{ "type": "gradient", "gradient": {...} }` is not an `AnimatedBackground` at all — `rustmotion validate` does not catch this (the `backgrounds` map and `background.$ref` overrides are permissive `serde_json::Value`s at the schema level), so the file validates clean but **renders a flat, ungra­dient background** at render time:
9999

100100
```json
101101
{
102102
"backgrounds": {
103103
"dark_radial": {
104-
"type": "gradient",
105-
"gradient": {
106-
"type": "radial",
104+
"preset": "gradient_shift",
105+
"gradient_shift": {
107106
"colors": ["#1e1b4b", "#0f172a"],
108-
"center_x": 0.5,
109-
"center_y": 0.4
110-
}
107+
"gradient_type": "radial"
108+
},
109+
"speed": 10
111110
}
112111
},
113112
"scenes": [
114-
{ "background": { "$ref": "dark_radial" }, ... },
115-
{ "background": { "$ref": "dark_radial" }, ... }
113+
{ "background": { "$ref": "dark_radial" }, "duration": 3.0, "children": [] },
114+
{ "background": { "$ref": "dark_radial" }, "duration": 3.0, "children": [] }
116115
]
117116
}
118117
```
119118

119+
`speed` defaults to `0` (static) if omitted — set it low for a near-static gradient, higher for a visibly rotating one (degrees/sec for `gradient_shift`). Per-scene overrides merge on top of the template (e.g. `{ "$ref": "dark_radial", "gradient_shift": { "colors": [...] } }` swaps just the colors) — everything except `$ref` and `transition` deep-merges into the referenced template.
120+
120121
### Rule P3: Accent color as the single highlight
121122

122123
Use the accent color for: CTAs, icon fills, badge backgrounds, glow effects, border highlights.

.claude/skills/rustmotion/rules/depth-layering.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,18 @@ Background elements feel farther away when slightly blurred. Use `backdrop-filte
102102

103103
## Scale + opacity gradient across siblings
104104

105-
When cards or icons are in a row, give back-row items a smaller scale and lower opacity to simulate perspective receding.
105+
When cards or icons are in a row, give back-row items a smaller scale and lower opacity to simulate perspective receding. `transform` is an **array of tagged transform functions** (`{ "fn": "scale", "x": ..., "y": ... }`), never a CSS string like `"scale(0.82)"` — a string fails to deserialize and drops the component.
106106

107107
```json
108108
[
109-
{ "type": "card", "style": { "opacity": 0.4, "transform": "scale(0.82)" }, "..." : "back" },
110-
{ "type": "card", "style": { "opacity": 0.7, "transform": "scale(0.91)" }, "..." : "mid" },
111-
{ "type": "card", "style": { "opacity": 1.0, "transform": "scale(1.00)" }, "..." : "front" }
109+
{ "type": "card", "style": { "opacity": 0.4, "transform": [{ "fn": "scale", "x": 0.82, "y": 0.82 }] } },
110+
{ "type": "card", "style": { "opacity": 0.7, "transform": [{ "fn": "scale", "x": 0.91, "y": 0.91 }] } },
111+
{ "type": "card", "style": { "opacity": 1.0, "transform": [{ "fn": "scale", "x": 1.00, "y": 1.00 }] } }
112112
]
113113
```
114114

115+
(Back/mid/front comment above each entry omitted here for brevity — each object above is a separate sibling `card`, e.g. inside a `div` with `flex-direction: "row"`.)
116+
115117
Use `scale` steps of ~0.08–0.12 between planes. More than 3 planes starts looking mechanical.
116118

117119
---

.claude/skills/rustmotion/rules/easing-guidelines.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ Entrance presets already use appropriate easing internally.
1414

1515
## Available Easing Functions
1616

17-
`linear`, `ease_in`, `ease_out`, `ease_in_out`, `ease_in_quad`, `ease_out_quad`, `ease_in_cubic`, `ease_out_cubic`, `ease_in_expo`, `ease_out_expo`, `spring`
17+
17 named easings, plus `cubic_bezier` for a custom curve — 18 total:
18+
19+
`linear`, `ease_in`, `ease_out`, `ease_in_out`, `ease_in_quad`, `ease_out_quad`, `ease_in_cubic`, `ease_out_cubic`, `ease_in_expo`, `ease_out_expo`, `ease_in_out_quad`, `ease_in_out_expo`, `ease_in_back`, `ease_out_back`, `ease_out_elastic`, `bounce`, `spring`
20+
21+
`cubic_bezier` takes a parameter object instead of a bare string — `{ "cubic_bezier": { "x1": 0.4, "y1": 0.0, "x2": 0.2, "y2": 1.0 } }` — anywhere an `easing` value is accepted (top-level `animation.easing`, per-keyframe `easing`).
1822

1923
### Spring Physics
2024

.claude/skills/rustmotion/rules/geometry-safety.md

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
No textual content may bleed out of the device viewport. The renderer enforces this through three opt-in mechanisms, all checked by `rustmotion validate`.
44

5-
## 1. Text wrapping (`style.wrap`)
5+
## 1. Text wrapping (`style.white-space`)
66

7-
`text` is constraint-aware: it wraps to its parent's allocated width by default. Set `style.wrap: false` only when you intentionally want the text to render on one line and you guarantee it fits (e.g. a marquee that bleeds, a ticker, a title with a fixed `max-width`).
7+
`text` is constraint-aware: it wraps to its parent's allocated width by default. There is no `style.wrap` field — that boolean belonged to the pre-CSS `LayerStyle` model and no longer exists in `CssStyle` (`deny_unknown_fields` rejects it and silently drops the component). Wrapping is controlled by the standard CSS `white-space` property instead:
88

9-
- Default: `wrap: true` (wraps at parent or `max_width`, whichever is smaller).
10-
- `wrap: false`validator fails with `unwrappable_text_overflow` if the natural width exceeds the box.
9+
- Default: unset / `"normal"` (and `"pre-line"`, `"pre-wrap"`, `"break-spaces"`) — wraps at parent width or `max-width`, whichever is smaller.
10+
- `white-space: "nowrap"` or `"pre"` → the text renders on one line. The validator measures its natural (unbounded) width and fails with `unwrappable_text_overflow` if that width exceeds the box. Only set this when you intentionally want a single line and a finite `max-width` + reasonable `font-size` guarantee it fits (e.g. a title, a ticker-like label — not a marquee, which has its own component).
1111

1212
```json
13-
{ "type": "text", "content": "Long sentence...", "style": { "wrap": true, "max-width": 800 } }
13+
{ "type": "text", "content": "Long sentence...", "style": { "white-space": "nowrap", "max-width": 800 } }
1414
```
1515

1616
## 2. Codeblock / Terminal `auto_scroll`
@@ -39,38 +39,41 @@ CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at t
3939

4040
## What the validator catches
4141

42-
`rustmotion validate scenario.json` reports three geometry violation kinds:
42+
`rustmotion validate scenario.json` reports five geometry violation kinds:
4343

4444
- `viewport_overflow` — absolute bbox crosses the device edge
45-
- `unwrappable_text_overflow``wrap: false` but natural width > available width
45+
- `unwrappable_text_overflow``white-space: "nowrap"`/`"pre"` but natural width > available width
46+
- `content_overflows_box` — wrapping text needs more room than the box it was actually assigned, typically a paragraph inside a card with a fixed `height` too small for it. Text painters never clip themselves, so this paints outside its box even when the box sits comfortably inside the frame — which is why the viewport check alone never caught it.
4647
- `auto_scroll_disabled_overflow``auto_scroll: false` but content > box
48+
- `animated_text_overflow` — an animated transform (scale/translate/wiggle/orbit) pushes the bbox out of the viewport at some sampled time. Only checked with `--strict-anim` (default runs check the resting, untransformed layout only).
4749

48-
`marquee` and `cursor` are exempt (their job is to bleed).
50+
`marquee` and `cursor` are exempt (their job is to bleed). A node is also exempt when it clips itself, or when any ancestor clips it — `overflow` set to anything other than `visible`. Deliberate bleed under a clipping parent is a composition technique, not a defect: that is how you get giant type running off the frame.
4951

5052
## CLI usage
5153

5254
```bash
5355
rustmotion validate scenario.json # human-readable
5456
rustmotion validate scenario.json --report report.json # JSON report
5557
rustmotion validate scenario.json --fix # safe auto-fixes
56-
rustmotion validate scenario.json --strict-anim # per-frame check
58+
rustmotion validate scenario.json --strict-anim # per-frame check, adds animated_text_overflow
5759
rustmotion validate scenario.json --lenient # warnings only
5860
```
5961

60-
`--fix` rewrites the file in place. It only applies *safe* mutations:
61-
- sets `style.wrap: true` on text that overflowed because wrap was off
62-
- sets `auto_scroll: true` on codeblock/terminal with overflow
62+
`--fix` rewrites the file in place:
63+
- `auto_scroll_disabled_overflow`sets `auto_scroll: true`. Safe.
64+
- `unwrappable_text_overflow` → removes `style.white-space`, so the text falls back to the `normal` default and wraps again. Non-destructive: it only ever deletes the property that caused the violation. If you want the line to stay unbroken, widen the box or lower `font-size` by hand instead of running `--fix`.
6365

64-
Position/size clamping is never auto-applied — fix those by hand.
66+
Position/size clamping (`viewport_overflow`) is never auto-applied — fix those by hand too.
6567

6668
## When to use what
6769

6870
| Symptom | Fix |
6971
|---|---|
70-
| Long sentence cut at viewport edge | leave `wrap: true` (default) and ensure parent has finite width |
71-
| Need a single-line title that must fit | set `max-width` and a small enough `font-size`, leave `wrap: true` |
72-
| Marquee / ticker text that intentionally scrolls past edges | use `marquee` (exempt) — never `text` with `wrap: false` |
72+
| Long sentence cut at viewport edge | leave `white-space` unset (default wraps) and ensure parent has finite width |
73+
| Need a single-line title that must fit | set `max-width` and a small enough `font-size`, leave `white-space` unset |
74+
| Marquee / ticker text that intentionally scrolls past edges | use `marquee` (exempt) — never `text` with `white-space: "nowrap"` |
7375
| Code listing taller than its box | leave `auto_scroll: true` (default) |
7476
| Terminal log streaming many lines | leave `auto_scroll: true` |
7577
| Badge protruding from a card on purpose | container has `overflow: visible` (default) — no change needed |
7678
| Hard-clip children to a card border | container `style.overflow: "hidden"` |
79+
| Animated element (wiggle/orbit/keyframe scale) might drift off-screen | run `rustmotion validate --strict-anim` to sample frames, not just the resting layout |

.claude/skills/rustmotion/rules/glassmorphism.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,11 @@ La bordure et l'ombre reprennent la teinte accent — le verre a une couleur.
236236

237237
```json
238238
{
239-
"video": { "background": "#0f172a" },
239+
"video": { "width": 1080, "height": 1920, "fps": 30, "background": "#0f172a" },
240240
"scenes": [{
241+
"duration": 3.0,
241242
"children": [
242-
{ "type": "card", "style": { "backdrop-filter": [{ "fn": "blur", "radius": 24 }], "background": "#FFFFFF18" } }
243+
{ "type": "card", "style": { "width": 800, "height": 400, "backdrop-filter": [{ "fn": "blur", "radius": 24 }], "background": "#FFFFFF18" } }
243244
]
244245
}]
245246
}
@@ -252,6 +253,6 @@ Fond uni `#0f172a` + verre → on ne voit rien à travers le flou, la carte est
252253
`backdrop-filter` floute ce qui est **derrière le composant dans le viewport**, pas ce qui est dans le parent. Si appliqué à un `text` ou `icon` enfant d'une carte opaque, il n'a aucun effet visible.
253254

254255
```json
255-
{ "type": "text", "style": { "backdrop-filter": [{ "fn": "blur", "radius": 20 }], "color": "#fff" } }
256+
{ "type": "text", "content": "Label", "style": { "backdrop-filter": [{ "fn": "blur", "radius": 20 }], "color": "#fff" } }
256257
```
257258
Aucun effet — `backdrop-filter` est uniquement pertinent sur les éléments directement superposés à une texture/image/blob de fond. ✗

.claude/skills/rustmotion/rules/icon-sizing-hierarchy.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,28 @@ Each card is 230px = 77px CSS → icon and text completely unreadable.
100100

101101
## GOOD: 2×2 grid on mobile
102102

103+
`grid-template-columns` takes an array (`["1fr", "1fr"]`), never a raw CSS shorthand string like `"1fr 1fr"` — a bare string fails to deserialize (`Vec<GridTrack>` expected) and drops the card. Grid containers also need an explicit `height` (not `"auto"`) — see [rules/grid-card-height.md](grid-card-height.md).
104+
103105
```json
104106
{
105107
"type": "card",
106-
"style": { "width": 984, "display": "grid", "grid-template-columns": "1fr 1fr", "gap": 24 },
108+
"style": {
109+
"width": 984,
110+
"height": 640,
111+
"display": "grid",
112+
"grid-template-columns": ["1fr", "1fr"],
113+
"grid-template-rows": ["1fr", "1fr"],
114+
"gap": 24
115+
},
107116
"children": [
108-
{ "type": "card", "style": { "width": 480, "padding": 40 }, "children": [...] },
109-
{ "type": "card", "style": { "width": 480, "padding": 40 }, "children": [...] },
110-
{ "type": "card", "style": { "width": 480, "padding": 40 }, "children": [...] },
111-
{ "type": "card", "style": { "width": 480, "padding": 40 }, "children": [...] }
117+
{ "type": "card", "style": { "padding": 40 }, "children": [] },
118+
{ "type": "card", "style": { "padding": 40 }, "children": [] },
119+
{ "type": "card", "style": { "padding": 40 }, "children": [] },
120+
{ "type": "card", "style": { "padding": 40 }, "children": [] }
112121
]
113122
}
114123
```
115-
480px per card = 160px CSS → readable content. 24px gap. ✓
124+
Each cell fills its grid track (~480px wide here) = 160px CSS → readable content. 24px gap. ✓
116125

117126
## BAD: Icon inconsistency across scenes
118127

.claude/skills/rustmotion/rules/module-structure.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The workspace is split into three crates:
77
| Crate | Role |
88
|---|---|
99
| `rustmotion-core` | Engine, CSS model, schema types, Painter trait |
10-
| `rustmotion-components` | 51 component structs + Painter impls + box builder |
10+
| `rustmotion-components` | 57 component structs + Painter impls + box builder |
1111
| `rustmotion-cli` | CLI commands (render, validate, schema, info) |
1212

1313
---

.claude/skills/rustmotion/rules/prefer-presets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Rule: Prefer Presets Over Manual Keyframes
22

3-
Presets are simpler, less error-prone, and produce consistent motion design. Only use `keyframes` animation effects for custom behavior not covered by the 31 built-in presets.
3+
Presets are simpler, less error-prone, and produce consistent motion design. Only use `keyframes` animation effects for custom behavior not covered by the 40 built-in presets (+ 6 char-only presets on `text`).
44

55
**GOOD:**
66
```json

.claude/skills/rustmotion/rules/responsive-device-sizing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ Reference: Tailwind CSS default spacing (4px base unit).
129129

130130
Sizing rules above are guidelines — `rustmotion validate` is the source of truth. It refuses any scenario whose layout tree leaves the device viewport. See [geometry-safety.md](geometry-safety.md):
131131

132-
- `text` wraps automatically at the parent's max width — leave `style.wrap` at its default (`true`) unless you have a finite `max-width`.
132+
- `text` wraps automatically at the parent's max width — leave `style.white-space` unset (default wraps) unless you have a finite `max-width`.
133133
- `codeblock` / `terminal` auto-scroll when content exceeds their `size` — leave `auto_scroll: true` (default).
134-
- Long single-line content that should bleed must use `marquee`, never `text` with `wrap: false`.
134+
- Long single-line content that should bleed must use `marquee`, never `text` with `white-space: "nowrap"`.
135135

136136
## BAD: Using desktop sizes on mobile
137137

0 commit comments

Comments
 (0)