diff --git a/.claude/skills/rustmotion/rules/time-remapping.md b/.claude/skills/rustmotion/rules/time-remapping.md
new file mode 100644
index 0000000..7737a17
--- /dev/null
+++ b/.claude/skills/rustmotion/rules/time-remapping.md
@@ -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
+…
+```
+
+The kebab-case form `time-scale` does NOT map to the field and is ignored.
diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs
index df5091a..08e2527 100644
--- a/crates/rustmotion-cli/src/commands/validate_schema.rs
+++ b/crates/rustmotion-cli/src/commands/validate_schema.rs
@@ -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
@@ -202,6 +216,18 @@ fn validate_children(
}
}
+/// The `time_scale` declared on a container component, if any.
+fn container_time_scale(component: &Component) -> Option {
+ 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)> {
@@ -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:?}");
+ }
}
diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs
index ca92d06..69d267f 100644
--- a/crates/rustmotion-components/src/box_builder.rs
+++ b/crates/rustmotion-components/src/box_builder.rs
@@ -53,6 +53,11 @@ pub struct BuiltScene<'a> {
/// dispatcher so internal animations shift by the same amount as the
/// CSS overrides resolved at build time.
pub stagger_delays: Vec,
+ /// Per-node affine time remap accumulated from ancestor containers'
+ /// `time_scale`/`time_offset`. Entry `i` is `(scale, shift)` where
+ /// `t_local = scale * t_global + shift`. Default `(1.0, 0.0)` = identity.
+ /// Indexed like `components` and `stagger_delays`.
+ pub time_params: Vec<(f64, f64)>,
}
/// Build a box tree for a flat list of scene-level children at a given
@@ -116,6 +121,7 @@ where
{
let mut components: Vec