From a7e1356783878d1ee469810a4887e5d3ecc963f5 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 01:10:09 +0200 Subject: [PATCH 1/2] feat(animation): per-element chromatic aberration (issue #344 point 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chromatic_wipe already splits red/cyan channels, but only as a scene transition composited on two finished frame-buffers. Issue #344 point 4 asks for the same fringe on a single arriving element (an icon card glitching into place), which a transition can't express since nothing in it survives past the cut. Add `chromatic_aberration` as a node-level animation effect: `amount` sets the peak channel separation in px, reached the instant the effect starts, decaying to exactly zero by the end of `duration` — mirroring chromatic_wipe's own "zero at the edges" guarantee so no element is left permanently fringed. Unlike the transition's symmetric tent curve (zero at both ends, since it has to hand off cleanly to the next scene), this is an arrival effect: max at the start, settled by the end. Implementation composes two `ImageFilter`s on the node's own paint layer instead of hand-rolling pixel buffers like the transition does: each is the node's content shifted a few px and colour-matrixed down to just its red or cyan channels, then summed with `BlendMode::Plus`. Where both copies overlap the sum reconstructs the original colour exactly; at the edges, where a shifted sample lands outside the node's own painted content, only one channel survives and the fringe shows. This reuses the exact machinery `style.filter` (blur, drop-shadow...) already has for composing an image filter onto a node's save_layer, including its bleed-expansion of the layer bounds, rather than adding a second parallel path. Necessary edits outside the originally scoped file set: `AnimationEffect` (schema/video.rs) is the single tag="name" enum every `style.animation` entry parses through, so the new variant has to live there for the JSON shape to deserialize at all — `ChromaticAberrationConfig` itself lives in schema/animation.rs as scoped. That in turn made the exhaustive match in validate_schema.rs's `entrance_budget` non-exhaustive; giving it its own arm was the right call anyway, not just a compile fix — it's exactly the kind of one-shot effect that check exists for, and it now catches a scene too short to let the fringe fully decay before the cut. --- crates/rustmotion-core/src/engine/animator.rs | 39 ++++ .../rustmotion-core/src/engine/paint_pass.rs | 205 +++++++++++++++++- .../rustmotion-core/src/schema/animation.rs | 59 +++++ crates/rustmotion-core/src/schema/video.rs | 9 +- .../skills/rules/chromatic-aberration.md | 54 +++++ .../src/cli/commands/validate_schema.rs | 2 + 6 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 crates/rustmotion/skills/rules/chromatic-aberration.md diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 65f28f4..d247b02 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -270,6 +270,45 @@ pub fn ease(t: f64, easing: &EasingType) -> f64 { } } +pub fn chromatic_aberration_shift( + cfg: &crate::schema::ChromaticAberrationConfig, + progress: f32, +) -> f32 { + let eased = ease(progress as f64, &cfg.easing) as f32; + cfg.amount * (1.0 - eased) +} + +#[cfg(test)] +mod chromatic_aberration_shift_tests { + use super::*; + use crate::schema::{ChromaticAberrationConfig, EasingType}; + + fn cfg(amount: f32) -> ChromaticAberrationConfig { + ChromaticAberrationConfig { + delay: 0.0, + duration: 0.6, + amount, + easing: EasingType::Linear, + } + } + + #[test] + fn peaks_at_the_full_amount_when_progress_is_zero() { + assert_eq!(chromatic_aberration_shift(&cfg(6.0), 0.0), 6.0); + } + + #[test] + fn decays_to_exactly_zero_when_progress_reaches_one() { + assert_eq!(chromatic_aberration_shift(&cfg(6.0), 1.0), 0.0); + } + + #[test] + fn is_between_zero_and_the_amount_mid_flight() { + let shift = chromatic_aberration_shift(&cfg(6.0), 0.5); + assert!(shift > 0.0 && shift < 6.0, "got {shift}"); + } +} + fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { let t_curve = find_bezier_t_for_x(t, x1, x2); bezier_component(t_curve, y1, y2) diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 1b82711..4f579f6 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -269,12 +269,24 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .filter .as_deref() .and_then(|list| filters_to_image_filter(list, &length_ctx)); - let opened_opacity_layer = if opacity < 1.0 || content_filter.is_some() { + let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time) + .map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress)); + let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter); + let combined_filter = { + use skia_safe::image_filters; + match (content_filter, aberration_filter) { + (Some(cf), Some(af)) => image_filters::compose(af, cf), + (Some(cf), None) => Some(cf), + (None, Some(af)) => Some(af), + (None, None) => None, + } + }; + let opened_opacity_layer = if opacity < 1.0 || combined_filter.is_some() { let mut paint = Paint::default(); if opacity < 1.0 { paint.set_alpha((opacity * 255.0) as u8); } - if let Some(filter) = content_filter { + if let Some(filter) = combined_filter { paint.set_image_filter(filter); } let filter_bleed_px = node @@ -289,7 +301,10 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .as_deref() .map(|shadows| box_shadow_bleed(shadows, &length_ctx)) .unwrap_or(0.0); - let bleed = filter_bleed_px.max(shadow_bleed_px); + let aberration_bleed_px = aberration_shift.map(|s| s.abs().ceil()).unwrap_or(0.0); + let bleed = filter_bleed_px + .max(shadow_bleed_px) + .max(aberration_bleed_px); let mut bounds = Rect::from_xywh( box_layout.x - bleed, box_layout.y - bleed, @@ -484,6 +499,60 @@ fn paint_shimmer_band( ); } +fn active_chromatic_aberration( + css: &CssStyle, + time: f64, +) -> Option<(&crate::schema::ChromaticAberrationConfig, f32)> { + let cfg = css.animation.iter().find_map(|e| match e { + crate::schema::AnimationEffect::ChromaticAberration(c) => Some(c), + _ => None, + })?; + if cfg.duration <= 0.0 { + return None; + } + let elapsed = time - cfg.delay; + if elapsed < 0.0 || elapsed >= cfg.duration { + return None; + } + Some((cfg, (elapsed / cfg.duration) as f32)) +} + +fn chromatic_aberration_image_filter(shift: f32) -> Option { + if shift.abs() < 0.05 { + return None; + } + use skia_safe::{color_filters, image_filters, BlendMode}; + + #[rustfmt::skip] + const RED_ONLY: [f32; 20] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + #[rustfmt::skip] + const CYAN_ONLY: [f32; 20] = [ + 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + + let red_shifted = image_filters::offset((-shift, 0.0), None, None)?; + let cyan_shifted = image_filters::offset((shift, 0.0), None, None)?; + let red = image_filters::color_filter( + color_filters::matrix_row_major(&RED_ONLY, None), + Some(red_shifted), + None, + )?; + let cyan = image_filters::color_filter( + color_filters::matrix_row_major(&CYAN_ONLY, None), + Some(cyan_shifted), + None, + )?; + image_filters::blend(BlendMode::Plus, Some(red), Some(cyan), None) +} + fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 { use crate::css::style::FilterFn; let mut bleed = 0.0f32; @@ -2639,6 +2708,7 @@ mod paint_order_tests { use crate::css::units::{Length, LengthPercentage as CLP}; use crate::engine::box_tree::{BoxKind, BoxNode}; use crate::engine::layout_pass::run_layout; + use crate::schema::{AnimationEffect, ChromaticAberrationConfig, EasingType}; fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { @@ -2988,6 +3058,135 @@ mod paint_order_tests { let far = probe(20, 20); assert_eq!(far, 0, "far corner must stay untouched, got r={far}"); } + + fn render_pixels_at(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + let frame = PaintFrame { + time, + ..test_frame(w, h) + }; + paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0)); + buf + } + + fn white_square(animation: Vec) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(100.0)), + top: Some(CLP::Px(100.0)), + width: Some(CSize::Length(CLP::Px(120.0))), + height: Some(CSize::Length(CLP::Px(120.0))), + background: Some(Background::Color(CssColor::String("#ffffff".into()))), + animation, + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn probe(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8) { + let i = ((y * w + x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2]) + } + + #[test] + fn chromatic_aberration_shows_a_red_fringe_on_one_edge_and_a_cyan_fringe_on_the_other() { + let cfg = ChromaticAberrationConfig { + delay: 0.0, + duration: 0.6, + amount: 10.0, + easing: EasingType::Linear, + }; + let mut root = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::ChromaticAberration( + cfg, + )])], + ); + let buf = render_pixels_at(&mut root, 400, 400, 0.3); + + let left_edge = probe(&buf, 400, 100, 160); + let right_edge = probe(&buf, 400, 220, 160); + assert!( + left_edge.0 > 200 && left_edge.1 < 50 && left_edge.2 < 50, + "expected a red-leaning fringe on the left edge mid-flight, got {left_edge:?}" + ); + assert!( + right_edge.0 < 50 && right_edge.1 > 200 && right_edge.2 > 200, + "expected a cyan-leaning fringe on the right edge mid-flight, got {right_edge:?}" + ); + } + + #[test] + fn chromatic_aberration_is_gone_by_the_end_of_the_animation() { + let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + + let cfg = ChromaticAberrationConfig { + delay: 0.0, + duration: 0.6, + amount: 10.0, + easing: EasingType::Linear, + }; + let mut animated = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::ChromaticAberration( + cfg, + )])], + ); + let at_end = render_pixels_at(&mut animated, 400, 400, 0.6); + + assert_eq!( + baseline, at_end, + "the node must be pixel-identical to one with no effect at all once the \ + animation's duration has elapsed — no permanent fringe left behind" + ); + } + + #[test] + fn a_node_without_the_effect_is_untouched() { + let mut root_a = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let a = render_pixels_at(&mut root_a, 400, 400, 0.0); + let mut root_b = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let b = render_pixels_at(&mut root_b, 400, 400, 5.0); + assert_eq!( + a, b, + "a node with no chromatic_aberration effect must not vary with time" + ); + + let left_edge = probe(&a, 400, 100, 160); + let right_edge = probe(&a, 400, 219, 160); + assert_eq!( + left_edge, + (255, 255, 255), + "no effect means no fringe on the left edge either, got {left_edge:?}" + ); + assert_eq!( + right_edge, + (255, 255, 255), + "no effect means no fringe on the right edge either, got {right_edge:?}" + ); + } } #[cfg(test)] diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index 8443de9..7b6e2d9 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -262,6 +262,40 @@ fn default_preset_duration() -> f64 { 0.8 } +/// Per-element channel-split effect: the red and cyan (green+blue) channels +/// of the node's own rendered content separate by `amount` px and converge +/// back to a perfect overlap by the end of `duration` — the same "zero at +/// the end" guarantee `TransitionType::ChromaticWipe` gives its reveal edge +/// (see `engine::transition::chromatic_wipe`), so no node is left +/// permanently fringed once the effect has played. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ChromaticAberrationConfig { + /// Delay before the split starts (seconds). + #[serde(default)] + pub delay: f64, + /// How long the channels take to converge back to zero separation + /// (seconds). + #[serde(default = "default_chromatic_aberration_duration")] + pub duration: f64, + /// How far the red and cyan channels separate at the peak (px), reached + /// the instant the effect starts (`delay`) and decaying to `0` by + /// `delay + duration`. + #[serde(default = "default_chromatic_aberration_amount")] + pub amount: f32, + /// Easing applied to the decay from `amount` down to zero. + #[serde(default = "default_easing")] + pub easing: EasingType, +} + +fn default_chromatic_aberration_duration() -> f64 { + 0.6 +} + +fn default_chromatic_aberration_amount() -> f32 { + 6.0 +} + #[cfg(test)] mod deny_unknown_fields_tests { use super::*; @@ -312,4 +346,29 @@ mod deny_unknown_fields_tests { assert_eq!(k.time, 0.5); assert!(k.easing.is_some()); } + + #[test] + fn chromatic_aberration_config_rejects_unknown_fields() { + let json = json!({ "amount": 6.0, "duratoin": 0.6 }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field must be rejected, not silently ignored"); + assert!(err.to_string().contains("duratoin"), "got: {err}"); + } + + #[test] + fn chromatic_aberration_config_defaults() { + let json = json!({}); + let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.delay, 0.0); + assert_eq!(cfg.duration, default_chromatic_aberration_duration()); + assert_eq!(cfg.amount, default_chromatic_aberration_amount()); + } + + #[test] + fn chromatic_aberration_config_accepts_issue_shape() { + let json = json!({ "amount": 6, "duration": 0.6 }); + let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.amount, 6.0); + assert_eq!(cfg.duration, 0.6); + } } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 7e29794..2f836ae 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -2,7 +2,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::Path as SkiaPath; -use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig}; +use super::animation::{ + Animation, AnimationPreset, ChromaticAberrationConfig, EasingType, PresetConfig, SpringConfig, +}; use super::style::{FontWeight, TextAlign, VerticalAlign}; /// A single animation effect. Discriminated by `"type"` in JSON. @@ -101,6 +103,10 @@ pub enum AnimationEffect { /// coordinate space path points are interpreted in, and how degenerate /// paths (empty, single-point, zero-length) are handled. MotionPath(MotionPathConfig), + /// Per-element channel-split: the node's own rendered content splits into + /// red/cyan fringes that converge back to zero separation by the end. + /// See [`ChromaticAberrationConfig`]'s doc comment. + ChromaticAberration(ChromaticAberrationConfig), } impl AnimationEffect { @@ -121,6 +127,7 @@ impl AnimationEffect { Keyframes(c) => c.delay += by, MotionPath(c) => c.delay += by, Shimmer(c) => c.delay += by, + ChromaticAberration(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } diff --git a/crates/rustmotion/skills/rules/chromatic-aberration.md b/crates/rustmotion/skills/rules/chromatic-aberration.md new file mode 100644 index 0000000..391a477 --- /dev/null +++ b/crates/rustmotion/skills/rules/chromatic-aberration.md @@ -0,0 +1,54 @@ +# Rule: Chromatic Aberration (per-élément) + +`chromatic_wipe` (voir CLAUDE.md) sépare les canaux rouge/cyan **entre deux scènes**, comme composite de deux frame-buffers déjà rendus. `chromatic_aberration` fait la même séparation de canaux, mais comme effet d'animation sur **un seul nœud** — l'icône, la carte, le texte qui arrive avec un franc glitch chromatique avant de se stabiliser. Pas de transition, pas de deuxième frame : juste le contenu déjà peint de l'élément, dédoublé et décalé. + +## La forme + +```json +{ + "type": "icon", + "name": "zap", + "style": { + "animation": [{ + "name": "chromatic_aberration", + "amount": 6, + "duration": 0.6 + }] + } +} +``` + +| Champ | Rôle | Défaut | +|---|---|---| +| `delay` | Attente avant le début de la séparation (s) | `0` | +| `duration` | Temps pour revenir à une séparation nulle (s) | `0.6` | +| `amount` | Écart maximal entre les canaux, en px, atteint dès `delay` | `6` | +| `easing` | Courbe appliquée à la décroissance | `ease_out` | + +## Ce n'est pas un aller-retour + +Contrairement à `chromatic_wipe`, dont le pic se situe **au milieu** de la transition (nul aux deux bouts, pour ne pas laisser de frange sur la scène suivante), `chromatic_aberration` est **maximal dès le premier instant** (`delay`) et décroît vers zéro à `delay + duration`. C'est un effet d'arrivée — l'élément se matérialise dans un éclat chromatique puis se stabilise — pas un flash symétrique. Après `delay + duration`, l'élément est pixel pour pixel identique à un élément sans l'effet : aucune frange ne reste accrochée. + +Si tu veux un flash qui pique au milieu plutôt qu'au début, compose deux `chromatic_aberration` avec des `delay` décalés ou pilote `amount` via ta propre courbe — le champ n'accepte qu'une seule forme de décroissance (pic au début). + +## Comment c'est peint + +Le contenu du nœud (fond, bordure, enfants, `shimmer`…) est peint une seule fois dans le layer d'opacité du nœud, puis un `ImageFilter` Skia recompose deux copies décalées de ce layer : une isolée sur le canal rouge décalée d'un côté, une isolée sur le canal cyan (vert+bleu) décalée de l'autre, sommées en mode `Plus`. Loin des bords de l'élément les deux copies se recouvrent et reconstituent la couleur d'origine exactement ; c'est seulement à la frontière — où l'une des deux copies échantillonne en dehors du contenu peint — qu'une frange colorée apparaît. + +Conséquence pratique : c'est le même mécanisme que `style.filter` (blur, drop-shadow…) — un filtre d'image posé sur le layer du nœud — donc ça se compose avec un `style.filter` existant sur le même nœud, et ça hérite des mêmes règles de bleed : + +- Un parent `overflow: hidden` clippe la frange, comme il clipperait un flou qui déborde. +- Le propre `overflow: hidden` du nœud ne la clippe **pas** — il ne clippe que les enfants, jamais le contenu du nœud lui-même (même règle que pour son ombre portée sortante). + +## Piège : `amount`, pas `amplitude` + +`float_3d` et les presets oscillants voisins utilisent `amplitude` pour leur intensité en px. `chromatic_aberration` n'est pas un preset de la même famille — c'est un effet à config dédiée comme `shimmer`, `glow` ou `wiggle` — et son champ s'appelle **`amount`**. `amplitude` sur `chromatic_aberration` est un champ inconnu, rejeté par le schéma. + +De la même façon, `chromatic_aberration` ne prend ni `spring`, ni `overshoot`, ni `loop` / `repeat` : ce sont des champs de `AnimationTiming`/`PresetConfig` (la famille `fade_in`, `scale_in`, …), pas de cette config. + +## Cas dégénérés + +- `amount: 0` (ou négatif au point de devenir imperceptible) : aucune frange visible, pas d'erreur — l'effet est simplement inerte. +- `amount` négatif mais non nul : la séparation est réelle, seuls les côtés rouge/cyan s'inversent. +- `duration: 0` (ou négative) : l'effet ne s'active jamais — traité comme absent à chaque frame. +- `delay` seul ne rejoue jamais l'effet : il n'y a pas de `loop` ici, une seule décroissance par nœud. diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index 538a630..34995e0 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -664,6 +664,8 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { } } + AnimationEffect::ChromaticAberration(c) => Some((c.delay, c.duration)), + AnimationEffect::Glow(_) | AnimationEffect::Wiggle(_) | AnimationEffect::Orbit(_) From ba30be7b870964c34ad57af8cc92ea7ee7f30790 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 01:20:47 +0200 Subject: [PATCH 2/2] docs(skills): link the chromatic aberration rule into the index --- crates/rustmotion/skills/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 02b9105..847fba8 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -235,6 +235,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis - [rules/zoom-blur-transition.md](rules/zoom-blur-transition.md) - The radial "tunnel" cut: `zoom_blur`'s `strength`/`origin`, why it had to be a transition and not an effect, and the pivot-coincident-edge trap +- [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap