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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .claude/skills/rustmotion/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2247,13 +2247,24 @@ New style fields available on all components:

| Style field | Type | Default | Description |
| ----------------- | ------ | ------- | -------------------------------------------------------- |
| `backdrop-blur` | f32 | `null` | Glassmorphism blur effect (pixels) |
| `gradient-border` | object | `null` | `{ "colors": [...], "width": 2, "angle": 0 }` — gradient-colored border |
| `inner-shadow` | object | `null` | `{ "color": "#000", "offset_x": 0, "offset_y": 0, "blur": 10 }` — inset shadow |
| `gradient-border` | object | `null` | `{ "colors": ["#f00", "#00f"], "width": 2, "angle": 0 }` — gradient-colored border ring, border-radius aware, painted instead of `border` when both are set |
| `motion-path` | string | `null` | SVG path string that the element follows during animation |
| `stagger` | f32 | `null` | Auto-delay offset per child in a container (seconds) |
| `timeline` | array | `[]` | Intra-scene timeline steps — sequential animation phases |

**Deprecated (accepted but never rendered — the validator warns):**

| Legacy field | Use instead |
| --------------- | ------------------------------------------------------------ |
| `backdrop-blur` | `backdrop-filter: [{ "fn": "blur", "radius": N }]` |
| `inner-shadow` | `box-shadow: [{ ..., "inset": true }]` |

**Film grain (`noise` filter):** works in both `filter` and `backdrop-filter` chains. Deterministic — same `seed` produces identical grain on every frame.

```json
{ "backdrop-filter": [{ "fn": "blur", "radius": 24 }, { "fn": "noise", "intensity": 0.15, "seed": 42 }] }
```

---

### 3D Perspective Transforms
Expand All @@ -2269,7 +2280,7 @@ Any component can be rendered with true 3D perspective using keyframe animations
"height": 400,
"background": "#FFFFFF08",
"border-radius": 24,
"backdrop-blur": 15,
"backdrop-filter": [{ "fn": "blur", "radius": 15 }],
"border": { "color": "#FFFFFF14", "width": 1 },
"box-shadow": { "color": "#00000060", "offset_x": 0, "offset_y": 20, "blur": 60 },
"animation": [{
Expand Down
28 changes: 28 additions & 0 deletions .claude/skills/rustmotion/rules/3d-perspective.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,34 @@ Utilise la propriété `"name": "keyframes"` avec les propriétés `rotate_x`, `

Les rotations >30° déforment le contenu et rendent le texte illisible. Rester dans −30..30.

### Pivot configurable : `transform-origin` et `perspective-origin`

Le pivot de rotation/scale est configurable via `style["transform-origin"]` (et `style["perspective-origin"]` pour le point de fuite de la perspective). Les deux propriétés acceptent :

- Mots-clés : `"left"`, `"center"`, `"right"` (axe X) ; `"top"`, `"center"`, `"bottom"` (axe Y)
- Pourcentages : `"0%"` (coin), `"50%"` (centre, défaut), `"100%"` (bord opposé)
- Valeurs px : ex. `"20px"` (relatif au coin haut-gauche de la box)

```json
{
"style": {
"transform-origin": { "x": "left", "y": "center" },
"animation": [
{
"name": "keyframes",
"keyframes": [
{ "property": "rotate_y", "keyframes": [{ "time": 0, "value": -45 }, { "time": 1, "value": 0 }] },
{ "property": "perspective", "keyframes": [{ "time": 0, "value": 800 }, { "time": 1, "value": 800 }] }
]
}
]
}
}
```

L'absence de `transform-origin` = pivot au centre de la box (comportement par défaut, inchangé).
`perspective-origin` par défaut = même pivot que `transform-origin`.

---

## Shadow 3D adaptatif (automatique)
Expand Down
12 changes: 12 additions & 0 deletions .claude/skills/rustmotion/rules/easing-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ When easing is `spring`, configure with:
"spring": { "damping": 15, "stiffness": 100, "mass": 1 }
}
```

**Tout preset accepte `spring`.** Ajouter un objet `spring` à n'importe quel preset applique la physique de ressort à ses keyframes de mouvement (translate/scale/rotate) — l'opacity garde son ease (pas de flash d'alpha en overshoot) :

```json
{ "name": "fade_in_up", "duration": 0.8, "spring": { "damping": 8, "stiffness": 120 } }
```

- `bounce_in` / `elastic_in` : leurs springs intégrés sont les défauts ; un `spring` utilisateur les remplace.
- `scale_in` + spring : l'overshoot manuel est remplacé par celui du ressort.
- Oscillateurs continus (`pulse`, `shake`, `float`) : non affectés (leur forme est leur raison d'être).
- La `duration` reste la fenêtre de l'animation : le ressort est résolu en secondes réelles dans cette fenêtre et la valeur se cale sur la cible à la fin — choisir une duration suffisante (≥ 0.6s avec les défauts) pour laisser le ressort converger.
- Dialecte HTML : la DSL compacte accepte `spring:true` (défauts damping 15 / stiffness 100 / mass 1) ; la config fine passe par la forme JSON de `anim`.
39 changes: 38 additions & 1 deletion .claude/skills/rustmotion/rules/glassmorphism.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Sans bordure, la carte flotte sans contour visible.
Sans ombre, elle ne se détache pas du fond.

**Propriété correcte : `backdrop-filter`, pas `backdrop-blur`.**
`backdrop-blur` n'existe pas dans CssStyle — utiliser `backdrop-filter` avec `{ "fn": "blur", "radius": N }`.
`backdrop-blur` est accepté pour compat mais **jamais rendu** (le validateur émet un warning) — utiliser `backdrop-filter` avec `{ "fn": "blur", "radius": N }`. Idem pour `inner-shadow` → `box-shadow` avec `"inset": true`.

---

Expand Down Expand Up @@ -82,6 +82,43 @@ Le `box-shadow` inset blanc (`offset-y: -1`) simule un reflet de lumière en hau

---

## Grain « frosted glass » : le filtre `noise`

Le vrai verre dépoli a une micro-texture. Le filtre `noise` (déterministe : même `seed` = même grain à chaque frame) s'ajoute à la chaîne `backdrop-filter` après le blur :

```json
{
"backdrop-filter": [
{ "fn": "blur", "radius": 24 },
{ "fn": "noise", "intensity": 0.12, "seed": 42 }
]
}
```

| Paramètre | Défaut | Plage utile |
|---|---|---|
| `intensity` | 0.15 | 0.08–0.20 (subtil), 0.25–0.40 (texture visible) |
| `seed` | 42 | n'importe quel entier — varier entre panels pour éviter un grain identique |

Le filtre marche aussi dans `filter` (grain sur le contenu de l'élément lui-même, style pellicule photo).

---

## Bordure gradient : `gradient-border`

Alternative premium à la bordure translucide unie — un anneau dégradé, border-radius aware, peint **à la place** de `border` quand les deux sont présents :

```json
{
"border-radius": 32,
"gradient-border": { "colors": ["#FFFFFF50", "#FFFFFF08"], "width": 1.5, "angle": 180 }
}
```

L'angle suit la même convention que les gradients `background`. Un dégradé blanc→transparent vertical simule la lumière qui accroche le haut de la carte.

---

## Fond : les éléments qui brillent à travers le verre

Le glassmorphisme n'a d'intérêt que s'il y a quelque chose à voir derrière. Placer des blobs colorés **derrière** la carte (`z-index: 0`) :
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/rustmotion/rules/prefer-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ Presets are simpler, less error-prone, and produce consistent motion design. Onl
| Char (text only) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` |

`scale_in` and `scale_out` support `overshoot` (default 0.08 = 8%). Char presets support `stagger`, `granularity`, `easing`, and `overshoot`.

**Tout preset standard accepte `spring`** — un objet `{ "damping", "stiffness", "mass" }` qui remplace la courbe de mouvement du preset (translate/scale/rotate) par une physique de ressort ; l'opacity garde son ease. Exemple : `{ "name": "slide_in_left", "spring": { "damping": 10 } }`. Détails dans easing-guidelines.md. (Char presets et `tilt_in` : hors scope v1.)
44 changes: 44 additions & 0 deletions crates/rustmotion-cli/src/commands/validate_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ fn validate_children(
p
));
}
if style.backdrop_blur.is_some() {
warnings.push(format!(
"{}: style.backdrop-blur is accepted but not rendered — use \
backdrop-filter: [{{\"fn\": \"blur\", \"radius\": N}}]",
p
));
}
if style.inner_shadow.is_some() {
warnings.push(format!(
"{}: style.inner-shadow is accepted but not rendered — use \
box-shadow with \"inset\": true",
p
));
}

if let Some(timed) = child.component.as_timed() {
let (start, end) = timed.timing();
Expand Down Expand Up @@ -375,6 +389,36 @@ mod style_warning_tests {
);
}

#[test]
fn warns_on_legacy_backdrop_blur_and_inner_shadow() {
// Legacy glassmorphism fields are accepted for compat but never
// rendered; validate must point at the working CSS equivalents.
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "card",
"style": {
"backdrop-blur": 20,
"inner-shadow": { "color": "#000000", "offset_y": 2, "blur": 8 }
}
}))
.unwrap();
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(
warnings
.iter()
.any(|w| w.contains("backdrop-blur") && w.contains("backdrop-filter")),
"missing backdrop-blur warning: {warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("inner-shadow") && w.contains("inset")),
"missing inner-shadow warning: {warnings:?}"
);
}

#[test]
fn time_scale_zero_is_an_error() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
Expand Down
4 changes: 1 addition & 3 deletions crates/rustmotion-components/src/legacy_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,7 @@ mod tests {
height: Some(CSize::Length(CLP::Px(100.0))),
animation: vec![AnimationEffect::FadeIn(AnimationTiming {
duration: 0.5,
delay: 0.0,
repeat: false,
overshoot: None,
..Default::default()
})],
..Default::default()
},
Expand Down
55 changes: 0 additions & 55 deletions crates/rustmotion-components/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,58 +494,3 @@ impl Component {
}
}
}

/// Draw a gradient border on a rounded rectangle.
pub fn draw_gradient_border(
canvas: &skia_safe::Canvas,
rrect: &skia_safe::RRect,
gb: &rustmotion_core::schema::GradientBorder,
) {
use skia_safe::{gradient_shader::GradientShaderColors, Paint, PaintStyle, Point};

if gb.colors.len() < 2 {
return;
}

let bounds = rrect.bounds();
let angle_rad = gb.angle * std::f32::consts::PI / 180.0;
let cx = bounds.center_x();
let cy = bounds.center_y();
let half_diag = (bounds.width().powi(2) + bounds.height().powi(2)).sqrt() / 2.0;

let start = Point::new(
cx - angle_rad.cos() * half_diag,
cy - angle_rad.sin() * half_diag,
);
let end = Point::new(
cx + angle_rad.cos() * half_diag,
cy + angle_rad.sin() * half_diag,
);

let colors: Vec<skia_safe::Color> = gb
.colors
.iter()
.map(|c| {
let (r, g, b, a) = rustmotion_core::engine::renderer::parse_hex_color(c);
skia_safe::Color::from_argb(a, r, g, b)
})
.collect();

let shader = skia_safe::shader::Shader::linear_gradient(
(start, end),
GradientShaderColors::Colors(&colors),
None,
skia_safe::TileMode::Clamp,
None,
None,
);

if let Some(shader) = shader {
let mut paint = Paint::default();
paint.set_style(PaintStyle::Stroke);
paint.set_stroke_width(gb.width);
paint.set_anti_alias(true);
paint.set_shader(shader);
canvas.draw_rrect(rrect, &paint);
}
}
33 changes: 32 additions & 1 deletion crates/rustmotion-core/src/css/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::units::{Length, LengthPercentage};
use crate::schema::{deserialize_animation_effects, AnimationEffect};
// `GradientBorder` / `InnerShadow` are reused from the schema layer rather
// than mirrored: same crate, same serde/JsonSchema derives, identical JSON
// shape either way — a css-local mirror would only duplicate the struct.
use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow};

/// Top-level CSS style block. All fields are optional; `None` means "not set"
/// and lets the cascade fill in inherited / initial values.
Expand Down Expand Up @@ -80,6 +83,16 @@ pub struct CssStyle {
pub opacity: Option<f32>,
pub mix_blend_mode: Option<BlendMode>,
pub clip_path: Option<ClipPath>,
/// Gradient-colored border painted instead of `border` when present.
/// `{ "colors": [...], "width": 2, "angle": 0 }` — angle follows the same
/// convention as `background` linear gradients.
pub gradient_border: Option<GradientBorder>,

// ---- Legacy compat (accepted, never rendered — validator warns) ----
/// Deprecated: use `backdrop-filter: [{ "fn": "blur", "radius": N }]`.
pub backdrop_blur: Option<f32>,
/// Deprecated: use `box-shadow` with `"inset": true`.
pub inner_shadow: Option<InnerShadow>,

// ---- Filters / effects ----
pub filter: Option<Vec<FilterFn>>,
Expand Down Expand Up @@ -1011,6 +1024,24 @@ pub enum FilterFn {
Opacity {
value: f32,
},
/// Deterministic film-grain noise. Works in both `filter` and
/// `backdrop-filter` chains (frosted-glass grain).
Noise {
/// Grain strength in 0..1 (alpha of the noise layer). Default 0.15.
#[serde(default = "default_noise_intensity")]
intensity: f32,
/// Perlin-noise seed — same seed ⇒ identical grain on every frame.
#[serde(default = "default_noise_seed")]
seed: u64,
},
}

fn default_noise_intensity() -> f32 {
0.15
}

fn default_noise_seed() -> u64 {
42
}

// ---- Blend ----
Expand Down
17 changes: 17 additions & 0 deletions crates/rustmotion-core/src/css/units.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ impl ParsedLength {
}
}

/// Parse a CSS length/percentage string that may also contain transform-origin
/// axis keywords (`left`, `center`, `right`, `top`, `bottom`).
///
/// Keywords are normalised to `ParsedLength::Percent` so that the regular
/// `resolve` machinery handles them — the caller must still choose the correct
/// axis dimension (width for x, height for y) in the `LengthContext`.
pub fn parse_origin_component(s: &str) -> Option<ParsedLength> {
let lower = s.trim().to_ascii_lowercase();
match lower.as_str() {
"left" | "top" => return Some(ParsedLength::Percent(0.0)),
"center" => return Some(ParsedLength::Percent(50.0)),
"right" | "bottom" => return Some(ParsedLength::Percent(100.0)),
_ => {}
}
parse_length(s)
}

/// Parse a CSS length/percentage string. Whitespace tolerated.
pub fn parse_length(s: &str) -> Option<ParsedLength> {
let s = s.trim();
Expand Down
Loading
Loading