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
55 changes: 55 additions & 0 deletions .claude/skills/rustmotion/rules/time-remapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Rule: Time Remapping on Containers (time_scale / time_offset)

Containers (`flex`, `card`, `grid`, `container`/`div`, `positioned`) accept two
optional fields that remap time for their **entire subtree**:

```
t_local = (t_global - time_offset) * time_scale
```

- `time_scale` (default `1.0`) — playback speed of the subtree. `0.5` = children
run at half speed (slow motion); `2.0` = double speed. **Must be > 0** —
`rustmotion validate` rejects `0` and negatives.
- `time_offset` (default `0.0`) — seconds before the subtree's timeline starts.
`2.0` = the subtree behaves as if the scene started 2 s later.

Everything inside follows the remap: animation presets, keyframes, timeline
steps, `start_at`/`end_at` windows, stagger, motion blur ghosts, and internal
animations (counter progress, `draw_in`, terminal typewriter…).

**Slow-motion example** — the card's children fade in at half speed:

```json
{
"type": "card",
"time_scale": 0.5,
"children": [
{ "type": "text", "content": "Slow entrance",
"style": { "animation": [{ "name": "fade_in", "duration": 1.0 }] } }
]
}
```

The fade lasts 1 s of *local* time = 2 s of real scene time. Budget the scene
duration accordingly: a child animation ending at `t_local` finishes at
`t_global = t_local / time_scale + time_offset`.

**Cascade** — nested remapped containers compose: a `time_scale: 0.5` container
inside another `time_scale: 0.5` container runs its subtree at 0.25× speed.
Offsets compose too (each `time_offset` is expressed in its parent's already
remapped time).

**start_at / end_at** — expressed in the subtree's local time. A child with
`start_at: 1` inside a `time_scale: 0.5` container appears at 2 s of real time.

**scene_duration stays global** — the physical scene window is not remapped.
Duration-relative behaviors (e.g. a counter spanning the whole scene) keep
their real-time span; only the elapsed-time input is remapped.

**HTML dialect** — use the snake_case attribute name:

```html
<rm-flex time_scale="0.5" time_offset="1">…</rm-flex>
```

The kebab-case form `time-scale` does NOT map to the field and is ignored.
58 changes: 58 additions & 0 deletions crates/rustmotion-cli/src/commands/validate_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ fn validate_children(
}
}

// Container time remapping: time_scale must be strictly positive
// (0 would freeze the subtree, negative would run it backwards —
// neither is supported; the builder clamps defensively but the
// author must be told).
if let Some(scale) = container_time_scale(&child.component) {
if scale <= 0.0 {
errors.push(format!(
"{}: time_scale must be > 0 (got {}). Use a small positive value \
(e.g. 0.1) to slow the subtree down.",
p, scale
));
}
}

// Animation completion budget check: ensure entrance animations finish within the scene.
if let Some(anim) = child.component.as_animatable() {
let start_at = child
Expand Down Expand Up @@ -202,6 +216,18 @@ fn validate_children(
}
}

/// The `time_scale` declared on a container component, if any.
fn container_time_scale(component: &Component) -> Option<f64> {
match component {
Component::Card(c) => c.time_scale,
Component::Flex(c) => c.time_scale,
Component::Grid(c) => c.time_scale,
Component::Container(c) => c.time_scale,
Component::Positioned(c) => c.time_scale,
_ => None,
}
}

/// Returns (delay, duration) for animations that have a completion budget,
/// or None for exit presets, looped animations, and non-timing effects.
fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> {
Expand Down Expand Up @@ -348,4 +374,36 @@ mod style_warning_tests {
"missing text-overflow warning: {warnings:?}"
);
}

#[test]
fn time_scale_zero_is_an_error() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "flex",
"time_scale": 0.0,
"children": [{ "type": "text", "content": "hi" }]
}))
.unwrap();
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
assert!(
errors.iter().any(|e| e.contains("time_scale must be > 0")),
"missing time_scale error: {errors:?}"
);
}

#[test]
fn positive_time_scale_is_accepted() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "flex",
"time_scale": 0.5,
"time_offset": 1.0,
"children": [{ "type": "text", "content": "hi" }]
}))
.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:?}");
}
}
Loading
Loading