diff --git a/.claude/skills/rustmotion/rules/html-css-mental-model.md b/.claude/skills/rustmotion/rules/html-css-mental-model.md
index 736b5b9..17619ac 100644
--- a/.claude/skills/rustmotion/rules/html-css-mental-model.md
+++ b/.claude/skills/rustmotion/rules/html-css-mental-model.md
@@ -83,17 +83,19 @@ Tout ce qui est **espace, alignement, distribution** se règle via les propriét
| Besoin | Propriété | Sur quel élément | Exemple |
|---|---|---|---|
| Espace entre enfants frères | `gap` | Parent (flex ou grid) | `"gap": 24` |
-| Espace entre contenu et bordure du container | `padding` | Le container lui-même | `"padding": 40` ou `"padding": [32, 48]` |
-| Décaler UN seul enfant par rapport aux autres | `margin` | L'enfant en question | `"margin-top": 16` |
+| Espace entre contenu et bordure du container | `padding` | Le container lui-même | `"padding": 40` ou `"padding": {"top": 32, "bottom": 32, "left": 48, "right": 48}` |
+| Décaler UN seul enfant par rapport aux autres | `margin` | L'enfant en question | `"margin": {"top": 16}` |
| Centrer horizontalement (axe principal = column) | `align-items: "center"` | Parent flex | `"align-items": "center"` |
| Centrer verticalement (axe principal = column) | `justify-content: "center"` | Parent flex | `"justify-content": "center"` |
-| Pousser un enfant à droite | `margin-left: "auto"` | Cet enfant | `"margin-left": "auto"` |
+| Pousser un enfant à droite | `margin: {"left": "auto"}` | Cet enfant | `"margin": {"left": "auto"}` |
| Élément prend tout l'espace restant | `flex-grow: 1` | L'enfant | `"flex-grow": 1` |
| Alignement différent pour un seul enfant | `align-self` | L'enfant | `"align-self": "flex-end"` |
| 2 colonnes égales | `grid-template-columns` | Parent grid | `["1fr","1fr"]` |
| 3 colonnes proportionnelles | `grid-template-columns` | Parent grid | `["2fr","1fr","1fr"]` |
| Colonne de taille fixe + reste | `grid-template-columns` | Parent grid | `[240, "1fr"]` |
+**Piège `margin-top` / `margin-left` :** il n'existe **pas** de champ `margin-top`, `margin-left`, `margin-right`, `margin-bottom` séparé — seulement `margin: Option` (`CssStyle` a `deny_unknown_fields`, donc un `"margin-top"` fait échouer la désérialisation du composant entier, qui disparaît silencieusement de la vidéo). `margin` accepte soit une valeur uniforme (`"margin": 16`), soit un objet par côté avec les côtés omis valant 0 : `"margin": {"top": 16}`, `"margin": {"left": "auto"}`. `padding` a exactement la même forme (`padding: Option`) et la même limitation — pas de `padding-top` isolé, et pas de raccourci tableau `[v, h]` façon CSS shorthand : `"padding": {"top": 32, "bottom": 32, "left": 48, "right": 48}`, pas `"padding": [32, 48]`.
+
### Règle de décision
```
@@ -174,8 +176,7 @@ Besoin d'une exception pour UN seul enfant ?
{ "type": "text", "position": "absolute", "x": 60, "y": 40 }
// ✅ — padding sur le container, les enfants sont en flow
-{ "type": "card", "style": { "padding": [40, 60], "gap": 24, "width": 900 }, "children": [...] }
-// ↑top/bottom ↑left/right
+{ "type": "card", "style": { "padding": { "top": 40, "bottom": 40, "left": 60, "right": 60 }, "gap": 24, "width": 900 }, "children": [...] }
```
---
diff --git a/.claude/skills/rustmotion/rules/module-structure.md b/.claude/skills/rustmotion/rules/module-structure.md
index c72d631..6d1ffc5 100644
--- a/.claude/skills/rustmotion/rules/module-structure.md
+++ b/.claude/skills/rustmotion/rules/module-structure.md
@@ -35,7 +35,7 @@ src/
│ │ ├── shapes.rs # rounded_rect, circle, arrow paths
│ │ └── text.rs # Skia text measurement (line metrics, wrapping)
│ └── text/
-│ └── cosmic.rs # cosmic-text FontSystem global + Skia glyph bridge
+│ └── cosmic.rs # cosmic-text FontSystem — dormant, pas sur le chemin de rendu
├── schema/ # JSON-serializable data models
│ ├── scenario.rs # Scenario, ResolvedScenario, View, ResolvedView, Scene, VideoConfig
│ ├── style.rs # Specialized types: CardBorder, CardShadow, Fill, Gradient, etc.
diff --git a/CLAUDE.md b/CLAUDE.md
index e559b6b..50b85c9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -138,7 +138,7 @@ crates/
│ │ ├── animator.rs # Résolution animations, easing, spring solver
│ │ ├── transition.rs # Transitions entre scènes
│ │ ├── renderer/ # Primitives Skia (colors, fonts, shapes, text)
-│ │ └── text/cosmic.rs # Bridge cosmic-text ↔ Skia (mesure + glyphs)
+│ │ └── text/cosmic.rs # Bridge cosmic-text — PAS branché sur le rendu réel
│ ├── schema/ # Modèles de données JSON
│ │ ├── scenario.rs # Scenario, ResolvedScenario, View, Scene, VideoConfig
│ │ ├── style.rs # Specialized types (CardBorder, CardShadow, Fill, etc.)
diff --git a/crates/rustmotion-cli/src/claude_md.rs b/crates/rustmotion-cli/src/claude_md.rs
new file mode 100644
index 0000000..00a8da6
--- /dev/null
+++ b/crates/rustmotion-cli/src/claude_md.rs
@@ -0,0 +1,128 @@
+//! Merging rustmotion's guidance into a project `CLAUDE.md` without owning the file.
+//!
+//! `skills install` used to write `CLAUDE.md` wholesale and `skills uninstall` used
+//! to delete it. Both treated a file the user authors as rustmotion's property: a
+//! project with its own build notes lost them on install, and lost the file itself
+//! on uninstall.
+//!
+//! Instead, rustmotion claims a delimited block and never touches anything outside
+//! it. The markers are HTML comments so they stay invisible in rendered Markdown.
+
+pub const START: &str = "";
+pub const END: &str = "";
+
+/// Byte range of the rustmotion block, markers included.
+fn block_span(text: &str) -> Option<(usize, usize)> {
+ let start = text.find(START)?;
+ let end = text[start..].find(END)? + start + END.len();
+ Some((start, end))
+}
+
+/// The document to write on install.
+///
+/// - no file yet: the block alone;
+/// - a file without a block: the block appended, existing content untouched;
+/// - a file with a block: only the block replaced, in place.
+pub fn merge(existing: Option<&str>, body: &str) -> String {
+ let block = format!("{START}\n{}\n{END}\n", body.trim_end());
+ let Some(existing) = existing else {
+ return block;
+ };
+ match block_span(existing) {
+ Some((start, end)) => {
+ let mut out = String::with_capacity(existing.len() + block.len());
+ out.push_str(&existing[..start]);
+ out.push_str(block.trim_end());
+ out.push_str(&existing[end..]);
+ out
+ }
+ None if existing.trim().is_empty() => block,
+ None => {
+ let mut out = existing.to_string();
+ if !out.ends_with('\n') {
+ out.push('\n');
+ }
+ out.push('\n');
+ out.push_str(&block);
+ out
+ }
+ }
+}
+
+/// The document to write on uninstall, or `None` when nothing rustmotion owns is
+/// left and the file should be removed.
+///
+/// A file the user also wrote in survives with its own content; a file that only
+/// ever held our block is reported as removable.
+pub fn strip(existing: &str) -> Option {
+ let Some((start, end)) = block_span(existing) else {
+ // No block: the file is entirely the user's. Never remove it.
+ return Some(existing.to_string());
+ };
+ let mut out = String::with_capacity(existing.len());
+ out.push_str(&existing[..start]);
+ out.push_str(&existing[end..]);
+ if out.trim().is_empty() {
+ None
+ } else {
+ Some(format!("{}\n", out.trim_end()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const BODY: &str = "# rustmotion\nGuidance.";
+
+ #[test]
+ fn a_project_claude_md_survives_install_and_uninstall() {
+ let user = "# My Project\nBuild with `make`. Never delete this.\n";
+
+ let installed = merge(Some(user), BODY);
+ assert!(
+ installed.contains("Never delete this."),
+ "install destroyed the user's content: {installed}"
+ );
+ assert!(installed.contains(BODY));
+
+ let uninstalled = strip(&installed).expect("a user-authored file is never removed");
+ assert!(uninstalled.contains("Never delete this."));
+ assert!(
+ !uninstalled.contains("Guidance."),
+ "uninstall left our block behind: {uninstalled}"
+ );
+ assert!(!uninstalled.contains(START));
+ }
+
+ #[test]
+ fn reinstalling_replaces_the_block_instead_of_stacking_copies() {
+ let once = merge(None, BODY);
+ let twice = merge(Some(&once), "# rustmotion\nNewer guidance.");
+ assert_eq!(twice.matches(START).count(), 1, "block duplicated: {twice}");
+ assert!(twice.contains("Newer guidance."));
+ assert!(!twice.contains("Guidance."));
+ }
+
+ #[test]
+ fn a_file_we_alone_created_is_reported_as_removable() {
+ let ours = merge(None, BODY);
+ assert!(strip(&ours).is_none());
+ }
+
+ #[test]
+ fn a_file_without_our_block_is_returned_untouched() {
+ let user = "# Theirs\nnothing of ours here\n";
+ assert_eq!(strip(user).as_deref(), Some(user));
+ }
+
+ #[test]
+ fn content_around_the_block_is_preserved_on_both_sides() {
+ let doc = format!("before\n\n{START}\nold\n{END}\n\nafter\n");
+ let merged = merge(Some(&doc), BODY);
+ assert!(merged.starts_with("before"));
+ assert!(merged.trim_end().ends_with("after"));
+ assert!(merged.contains("Guidance."));
+ assert!(!merged.contains("\nold\n"));
+ }
+}
diff --git a/crates/rustmotion-cli/src/commands/batch.rs b/crates/rustmotion-cli/src/commands/batch.rs
index 5bf6d48..f1f358c 100644
--- a/crates/rustmotion-cli/src/commands/batch.rs
+++ b/crates/rustmotion-cli/src/commands/batch.rs
@@ -1,22 +1,29 @@
//! Batch rendering: render one video per line of a JSONL data file.
//!
//! Design invariants:
-//! - All input is validated (JSONL parse + name template + variable checks) BEFORE
-//! any render starts. A corrupt line does not waste render time.
+//! - All input is validated (JSONL parse + name template + variable checks +
+//! the same schema/geometry pass `render` runs, see `validation::run_checks`)
+//! BEFORE any render starts. A corrupt or overflowing line does not waste
+//! render time, and does not silently produce a bad video either.
//! - Each data line is a JSON object; its fields become variable overrides.
//! - `{field}` in the name template is replaced by the field's JSON-rendered value;
-//! `{index}` by the 0-based line number.
+//! `{index}` by the 0-based line number. The resolved name may create
+//! subdirectories under `--output-dir` (e.g. `{lang}/{id}.mp4`) but may not
+//! escape it: a `..` component or an absolute path is rejected in preflight.
//! - Unknown variables (when the template has a `config` block) produce an actionable
//! error listing declared variables — same as the single-file path.
-//! - Exit code is non-zero if any render failed; partial success is reported.
+//! - Exit code is non-zero if any render failed *or panicked* (`--jobs > 1`
+//! dispatches renders across worker threads; a panic in one is caught and
+//! counted as a failure, never silently dropped); partial success is reported.
use rustmotion::error::{Result, RustmotionError};
use rustmotion::loader::load_input_with_vars;
use std::collections::HashMap;
-use std::path::{Path, PathBuf};
+use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::commands::render::cmd_render;
+use crate::commands::validation::{self, ValidationSource};
/// One row parsed from the JSONL data file.
struct BatchRow {
@@ -72,6 +79,38 @@ pub(crate) fn resolve_name_template(
Ok(out)
}
+/// Reject a resolved output name that would escape `--output-dir` once joined
+/// onto it. `Path::join` replaces the base entirely when the joined path is
+/// absolute, and a `..` component walks back out of it regardless — either
+/// way `output_dir.join(name)` can land anywhere on disk. `name` comes from
+/// `{field}` substitution of the (untrusted) JSONL data file, so this check
+/// runs in preflight, before any render starts, same as every other
+/// preflight check in this module.
+///
+/// A plain relative path — including one that creates subdirectories, e.g.
+/// `"en/abc.mp4"` — remains legal: only `ParentDir` (`..`), `RootDir`
+/// (a leading `/`), and `Prefix` (a Windows drive/UNC root) are rejected.
+fn reject_escaping_name(name: &str) -> std::result::Result<(), String> {
+ for component in Path::new(name).components() {
+ match component {
+ Component::ParentDir => {
+ return Err(format!(
+ "resolved output name '{name}' contains a '..' component, \
+ which would escape --output-dir"
+ ))
+ }
+ Component::RootDir | Component::Prefix(_) => {
+ return Err(format!(
+ "resolved output name '{name}' is an absolute path, \
+ which would escape --output-dir"
+ ))
+ }
+ Component::CurDir | Component::Normal(_) => {}
+ }
+ }
+ Ok(())
+}
+
/// Parse the JSONL file, resolve output names, and validate overrides against
/// the scenario template (dry-run load). Returns ordered rows or an error if
/// anything is invalid. No rendering happens here.
@@ -121,12 +160,35 @@ fn preflight(
continue;
}
};
+ if let Err(msg) = reject_escaping_name(&name) {
+ preflight_errors.push(format!("line {}: {}", index + 1, msg));
+ continue;
+ }
let output_path = output_dir.join(&name);
- // Validate overrides against the template (dry-run: load then discard)
- let path_buf = template_path.to_path_buf();
- if let Err(e) = load_input_with_vars(&path_buf, Some(&overrides)) {
- preflight_errors.push(format!("line {}: {}", index + 1, e));
+ // Validate overrides against the template: parse, resolve variables,
+ // then run the same schema + geometry pass `render` runs before it
+ // renders a single-file scenario (`validation::run_checks`, wired
+ // with the same defaults `render` uses with no flags: blocking
+ // geometry violations, no animated-frame sampling). A batch row is
+ // exactly the situation the module doc warns about — N videos
+ // produced in one shot — so it must not be the one path that skips
+ // the "unwrappable_text_overflow" / viewport-overflow gate CLAUDE.md
+ // requires. The loaded scenario itself is discarded here: `render_row`
+ // reloads it below, right before the actual render.
+ let loaded = match validation::load_with_vars(
+ ValidationSource::File(template_path),
+ Some(&overrides),
+ ) {
+ Ok(l) => l,
+ Err(e) => {
+ preflight_errors.push(format!("line {}: {}", index + 1, e));
+ continue;
+ }
+ };
+ let report = validation::run_checks(&loaded, false);
+ if report.is_blocking(false) {
+ preflight_errors.push(format!("line {}: {}", index + 1, report.to_error()));
continue;
}
@@ -274,7 +336,23 @@ pub fn cmd_batch(
.collect();
for h in handles {
- let _ = h.join(); // panics in threads are surfaced as failures below
+ // `h.join()` returns `Err` when the worker thread panicked instead
+ // of returning normally (e.g. a codec assertion on an odd frame
+ // dimension). That row was neither counted in `success_arc` nor
+ // pushed to `failures` by the closure above — it *only* runs its
+ // bookkeeping on the `Ok`/`Err` return path, which a panic skips
+ // entirely. Left unhandled, `_ = h.join()` was true to its
+ // comment ("panics ... are surfaced as failures below") in name
+ // only: nothing below ever inspected the join result, so a batch
+ // where every worker panicked reported "0/N succeeded" and still
+ // returned `Ok(())` — exit code 0 for a batch that rendered
+ // nothing. Record it as a failure explicitly instead.
+ if let Err(payload) = h.join() {
+ failures.lock().unwrap().push(format!(
+ "worker thread panicked: {}",
+ panic_message(&payload)
+ ));
+ }
}
success_count = *success_arc.lock().unwrap();
}
@@ -282,6 +360,19 @@ pub fn cmd_batch(
let failure_list = failures.lock().unwrap().clone();
let fail_count = failure_list.len();
+ // Defense in depth: every row must end up counted as either a success or
+ // a failure. This should already hold given the panic handling above,
+ // but if it doesn't — a future refactor drops a bookkeeping update, a
+ // panic happens somewhere neither counter is touched — fail loudly
+ // rather than silently report a batch as complete when it wasn't.
+ if success_count + fail_count != total {
+ return Err(RustmotionError::Generic(format!(
+ "batch accounting mismatch: {success_count} succeeded + {fail_count} failed \
+ != {total} total item(s) — {} item(s) neither succeeded nor were reported as failed",
+ total.saturating_sub(success_count + fail_count)
+ )));
+ }
+
if !quiet {
eprintln!("Batch complete: {}/{} succeeded.", success_count, total);
}
@@ -297,6 +388,23 @@ pub fn cmd_batch(
Ok(())
}
+/// Extract a human-readable message from a caught thread panic payload.
+/// `std::thread::Result`'s `Err` variant is `Box`; panics
+/// raised via `panic!("...")` / `.unwrap()` / `.expect(...)` box either a
+/// `&'static str` or a `String`, which covers the vast majority of real
+/// panics (including the openh264 `assert_eq!` this fix was written for).
+/// Anything else (a custom payload via `std::panic::panic_any`) still
+/// produces a readable, if generic, message instead of losing the row.
+fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
+ if let Some(s) = payload.downcast_ref::<&str>() {
+ (*s).to_string()
+ } else if let Some(s) = payload.downcast_ref::() {
+ s.clone()
+ } else {
+ "".to_string()
+ }
+}
+
fn render_row(
template_path: &Path,
row: &BatchRow,
@@ -382,6 +490,44 @@ mod name_template_tests {
}
}
+#[cfg(test)]
+mod escaping_name_tests {
+ use super::*;
+
+ #[test]
+ fn plain_relative_name_is_allowed() {
+ assert!(reject_escaping_name("abc.mp4").is_ok());
+ }
+
+ /// `{lang}/{id}.mp4`-style names are a documented, legitimate feature
+ /// (`field_placeholder_resolved` above): rejecting every non-`Normal`
+ /// path component would also reject this, which is why only
+ /// `ParentDir`/`RootDir`/`Prefix` are rejected, not every multi-segment
+ /// name.
+ #[test]
+ fn relative_subdirectory_name_is_allowed() {
+ assert!(reject_escaping_name("en/abc.mp4").is_ok());
+ }
+
+ #[test]
+ fn parent_dir_component_is_rejected() {
+ let err = reject_escaping_name("../../escaped.mp4").unwrap_err();
+ assert!(
+ err.contains(".."),
+ "error must call out the '..' component: {err}"
+ );
+ }
+
+ #[test]
+ fn absolute_unix_path_is_rejected() {
+ let err = reject_escaping_name("/tmp/scratch/pwned/absolute.mp4").unwrap_err();
+ assert!(
+ err.contains("absolute"),
+ "error must call out the absolute path: {err}"
+ );
+ }
+}
+
#[cfg(test)]
mod batch_integration_tests {
use super::*;
@@ -511,4 +657,344 @@ mod batch_integration_tests {
let _ = std::fs::remove_file(&data);
let _ = std::fs::remove_dir_all(&out_dir);
}
+
+ /// Constat #2/#4: a `{field}` value from the (untrusted) JSONL data file
+ /// containing `..` must not be able to walk `output_dir.join(name)` back
+ /// out of `--output-dir`. Reproduces the brief's scenario: `--output-dir
+ /// /sub/dir`, `--name-template '{name}.png'`, data field
+ /// `"../../escaped"` — before the fix this created
+ /// `/escaped.png/frame_00000.png`, outside `/sub/dir`.
+ #[test]
+ fn batch_rejects_name_that_escapes_output_dir_via_parent_dir() {
+ let template = write_json(&minimal_template("name"), "tmpl_escape_dotdot");
+ let data = write_jsonl(
+ &[serde_json::json!({"name": "../../escaped"})],
+ "escape_dotdot",
+ );
+ let base = std::env::temp_dir().join(format!("rm_batch_escdd_{}", std::process::id()));
+ let out_dir = base.join("sub").join("dir");
+ std::fs::create_dir_all(&out_dir).unwrap();
+
+ let err = cmd_batch(
+ &template,
+ &data,
+ &out_dir,
+ "{name}.png",
+ None,
+ None,
+ Some("png-seq".to_string()),
+ false,
+ 1,
+ true,
+ )
+ .expect_err("a name containing '..' must be rejected in preflight");
+
+ assert!(
+ err.to_string().contains(".."),
+ "error must call out the escaping component: {err}"
+ );
+
+ // `/sub/dir/../../escaped.png` lexically resolves to
+ // `/escaped.png` — it must never have been created.
+ let escaped_target = base.join("escaped.png");
+ assert!(
+ !escaped_target.exists(),
+ "traversal target must not have been created: {}",
+ escaped_target.display()
+ );
+
+ let _ = std::fs::remove_file(&template);
+ let _ = std::fs::remove_file(&data);
+ let _ = std::fs::remove_dir_all(&base);
+ }
+
+ /// Constat #2/#4, absolute-path variant: `Path::join` with an absolute
+ /// component discards the base entirely, so a `{field}` value that is
+ /// itself an absolute path writes straight to that path, ignoring
+ /// `--output-dir` completely.
+ #[test]
+ fn batch_rejects_name_that_is_an_absolute_path() {
+ let template = write_json(&minimal_template("name"), "tmpl_escape_abs");
+ let escape_target =
+ std::env::temp_dir().join(format!("rm_batch_abs_escape_target_{}", std::process::id()));
+ let name_value = escape_target.to_string_lossy().to_string();
+ let data = write_jsonl(&[serde_json::json!({"name": name_value})], "escape_abs");
+ let out_dir =
+ std::env::temp_dir().join(format!("rm_batch_out_escabs_{}", std::process::id()));
+ std::fs::create_dir_all(&out_dir).unwrap();
+
+ let target_with_ext = PathBuf::from(format!("{}.png", escape_target.display()));
+ let _ = std::fs::remove_dir_all(&target_with_ext);
+
+ let err = cmd_batch(
+ &template,
+ &data,
+ &out_dir,
+ "{name}.png",
+ None,
+ None,
+ Some("png-seq".to_string()),
+ false,
+ 1,
+ true,
+ )
+ .expect_err("an absolute output name must be rejected in preflight");
+
+ assert!(
+ err.to_string().to_lowercase().contains("absolute"),
+ "error must call out the absolute path: {err}"
+ );
+ assert!(
+ !target_with_ext.exists(),
+ "absolute-path target must not have been created: {}",
+ target_with_ext.display()
+ );
+
+ let _ = std::fs::remove_file(&template);
+ let _ = std::fs::remove_file(&data);
+ let _ = std::fs::remove_dir_all(&out_dir);
+ }
+
+ /// The traversal fix must not regress the documented `{field}/{field}`
+ /// nested-subdirectory feature (`field_placeholder_resolved` unit test):
+ /// only `..`/absolute components are rejected, plain relative
+ /// subdirectories still get created under `--output-dir`.
+ #[test]
+ fn batch_relative_subdirectory_name_still_creates_nested_output() {
+ let template = write_json(&minimal_template("lang"), "tmpl_subdir");
+ let data = write_jsonl(&[serde_json::json!({"lang": "en"})], "subdir");
+ let out_dir =
+ std::env::temp_dir().join(format!("rm_batch_out_subdir_{}", std::process::id()));
+ std::fs::create_dir_all(&out_dir).unwrap();
+
+ cmd_batch(
+ &template,
+ &data,
+ &out_dir,
+ "{lang}/{index}.png",
+ None,
+ None,
+ Some("png-seq".to_string()),
+ false,
+ 1,
+ true,
+ )
+ .expect("a relative subdirectory name must still be allowed");
+
+ let subdir = out_dir.join("en").join("0.png");
+ assert!(
+ subdir.exists() && subdir.is_dir(),
+ "expected nested output dir en/0.png to exist"
+ );
+
+ let _ = std::fs::remove_file(&template);
+ let _ = std::fs::remove_file(&data);
+ let _ = std::fs::remove_dir_all(&out_dir);
+ }
+
+ /// Constat #3: `batch` must run the same schema + geometry validation
+ /// `render` runs before it renders a single-file scenario — CLAUDE.md's
+ /// "schema + geometry, les deux doivent passer" rule applies just as
+ /// much to a batch row. Reproduces the brief's scenario: a 320×180
+ /// template with 96px `white-space: nowrap` text and a long `$title`
+ /// override overflows the viewport; `render` blocks on it, so `batch`
+ /// must too, before any render starts.
+ #[test]
+ fn batch_preflight_rejects_a_geometry_overflow() {
+ let template = write_json(
+ &serde_json::json!({
+ "config": { "title": { "type": "string", "default": "x" } },
+ "video": { "width": 320, "height": 180, "fps": 1 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "text",
+ "content": "$title",
+ "style": { "font-size": 96, "white-space": "nowrap" }
+ }]
+ }]
+ }),
+ "tmpl_overflow",
+ );
+ let data = write_jsonl(
+ &[serde_json::json!({"title": "A ridiculously long overflowing headline"})],
+ "overflow",
+ );
+ let out_dir =
+ std::env::temp_dir().join(format!("rm_batch_out_overflow_{}", std::process::id()));
+ std::fs::create_dir_all(&out_dir).unwrap();
+
+ let err = cmd_batch(
+ &template,
+ &data,
+ &out_dir,
+ "{index}.png",
+ None,
+ None,
+ Some("png-seq".to_string()),
+ false,
+ 1,
+ true,
+ )
+ .expect_err("a scenario that overflows the viewport must fail preflight, like `render`");
+
+ assert!(
+ err.to_string().contains("geometry violation"),
+ "error must surface the geometry violation, matching `render`'s message: {err}"
+ );
+ assert!(
+ !out_dir.join("0.png").exists(),
+ "preflight failure must mean no render started"
+ );
+
+ let _ = std::fs::remove_file(&template);
+ let _ = std::fs::remove_file(&data);
+ let _ = std::fs::remove_dir_all(&out_dir);
+ }
+
+ /// Guard that temporarily restricts `PATH` to a minimal, `ffmpeg`-free
+ /// set of directories, so `render`'s `Command::new("ffmpeg").arg(
+ /// "-version")` availability probe fails and the built-in openh264
+ /// fallback encoder runs instead — the codepath the audit's own repro
+ /// deliberately forced with `env -i PATH=/usr/bin:/bin ...` to make the
+ /// `YUVBuffer` even-dimension assertion panic reachable without a real
+ /// `ffmpeg` dependency in CI. Restores the original `PATH` on drop.
+ struct NoFfmpegPathGuard {
+ original: Option,
+ _permit: std::sync::MutexGuard<'static, ()>,
+ }
+
+ /// Serializes every test in this file that mutates `PATH` — required
+ /// because `std::env::set_var`/`remove_var` are `unsafe`: the standard
+ /// library only guarantees soundness when nothing else in the process
+ /// reads or writes the environment concurrently. This is the only test
+ /// file in `rustmotion-cli` whose tests spawn a real (non `png-seq` /
+ /// `gif` / `raw`) video encode — every other test in this crate's test
+ /// binary never reads `PATH` — so this lock only needs to protect
+ /// against concurrent runs of tests within this file.
+ static PATH_MUTATION_LOCK: Mutex<()> = Mutex::new(());
+
+ impl NoFfmpegPathGuard {
+ fn install() -> Self {
+ let permit = PATH_MUTATION_LOCK
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let original = std::env::var_os("PATH");
+ // SAFETY: `_permit` holds `PATH_MUTATION_LOCK` for this guard's
+ // entire lifetime (released on `Drop`, after `PATH` is restored
+ // below), and per the lock's doc comment no other thread in this
+ // binary reads/writes `PATH` while it is held.
+ unsafe { std::env::set_var("PATH", "/usr/bin:/bin") };
+ Self {
+ original,
+ _permit: permit,
+ }
+ }
+ }
+
+ impl Drop for NoFfmpegPathGuard {
+ fn drop(&mut self) {
+ // SAFETY: see `install`.
+ unsafe {
+ match &self.original {
+ Some(v) => std::env::set_var("PATH", v),
+ None => std::env::remove_var("PATH"),
+ }
+ }
+ }
+ }
+
+ /// Constat #1: with `--jobs > 1`, a worker thread that panics (instead of
+ /// returning `Err`) was invisible to `cmd_batch` — `let _ = h.join()`
+ /// discarded the panic, so neither `success_arc` nor `failures` was ever
+ /// incremented for that row. A batch where *every* worker panicked
+ /// reported "Batch complete: 0/N succeeded." and still returned `Ok(())`
+ /// — exit code 0 for a batch that rendered nothing.
+ ///
+ /// The brief's own reproduction (odd frame dimensions, e.g. 321×241,
+ /// forcing an `openh264` `assert_eq!(width % 2, 0, ...)` panic) is no
+ /// longer reachable *through `batch`* once constat #3 is fixed: schema
+ /// validation (`video.width and video.height must be even`, wired in by
+ /// the constat #3 fix above) now rejects that scenario in preflight,
+ /// before any worker thread is even spawned — a nice side effect, but it
+ /// means this test needs a different panic that survives preflight.
+ /// `create_encoder` (crates/rustmotion/src/encode/video/h264.rs) computes
+ /// `let pixels = width * height;` in plain (checked) `u32` arithmetic
+ /// before ever touching a frame buffer; 70000×70000 is even, positive,
+ /// and passes every schema/geometry check, but `70000 * 70000 =
+ /// 4_900_000_000` overflows `u32::MAX` (4_294_967_295) and panics with
+ /// "attempt to multiply with overflow" — deterministically, and before
+ /// any expensive Skia canvas allocation happens for the (nonexistent)
+ /// video frame. Still requires the openh264 fallback (`create_encoder`
+ /// is only reached when `ffmpeg` is unavailable), hence
+ /// `NoFfmpegPathGuard`.
+ #[test]
+ fn batch_parallel_worker_panic_is_reported_as_failure_not_silent_success() {
+ let template = write_json(
+ &serde_json::json!({
+ "video": { "width": 70000, "height": 70000, "fps": 1 },
+ "scenes": [{ "duration": 1.0, "children": [] }]
+ }),
+ "tmpl_overflow_dims",
+ );
+ let data = write_jsonl(
+ &[serde_json::json!({}), serde_json::json!({})],
+ "overflow_dims",
+ );
+ let out_dir =
+ std::env::temp_dir().join(format!("rm_batch_out_overflowdims_{}", std::process::id()));
+ std::fs::create_dir_all(&out_dir).unwrap();
+
+ let _no_ffmpeg = NoFfmpegPathGuard::install();
+
+ // Run `cmd_batch` on a worker so a hang (e.g. a deadlock introduced
+ // by a broken fix) fails the test instead of wedging the whole
+ // suite, mirroring `paint_within` in
+ // crates/rustmotion-components/tests/degenerate_inputs.rs.
+ let (tx, rx) = std::sync::mpsc::channel();
+ let t_template = template.clone();
+ let t_data = data.clone();
+ let t_out_dir = out_dir.clone();
+ let worker = std::thread::spawn(move || {
+ let result = cmd_batch(
+ &t_template,
+ &t_data,
+ &t_out_dir,
+ "{index}.mp4",
+ None,
+ None,
+ None, // default format: the mp4/openh264 path under test
+ false,
+ 2, // jobs: exercises the parallel path constat #1 is about
+ true,
+ );
+ let _ = tx.send(result);
+ });
+
+ let result = match rx.recv_timeout(std::time::Duration::from_secs(30)) {
+ Ok(r) => {
+ worker
+ .join()
+ .expect("outer worker thread must not itself panic");
+ r
+ }
+ Err(_) => {
+ panic!("cmd_batch did not return within 30s — possible deadlock in the join/accounting fix")
+ }
+ };
+
+ assert!(
+ result.is_err(),
+ "a batch where every worker panicked must not report success"
+ );
+ let msg = result.unwrap_err().to_string();
+ assert!(
+ msg.to_lowercase().contains("panic"),
+ "the failure must surface the panic, not just an opaque render failure: {msg}"
+ );
+
+ let _ = std::fs::remove_file(&template);
+ let _ = std::fs::remove_file(&data);
+ let _ = std::fs::remove_dir_all(&out_dir);
+ }
}
diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs
index ddf85fd..a70cc5e 100644
--- a/crates/rustmotion-cli/src/commands/geometry.rs
+++ b/crates/rustmotion-cli/src/commands/geometry.rs
@@ -13,8 +13,6 @@
//! * detect wrapping content whose natural size exceeds its own resolved
//! box (`text`/`gradient_text`/`caption`/`rich_text`/`table` — #128
//! item 1: originally `text`-only)
-//! * detect a component's own box extending past the nearest ancestor
-//! `card` that contains it, for every component type (#128 item 2)
//! * detect terminal/codeblock content that overflows their box when
//! `auto_scroll: false`
//! * exempt `marquee` and `cursor` (designed to bleed)
@@ -22,31 +20,54 @@
//! `auto` ancestor as a viewport overflow (H4) — the ancestor's own bbox
//! is still checked independently, at its own level
//!
+//! Deliberately NOT in scope: a component's box vs its nearest ancestor
+//! `card`'s box, independent of the viewport (#128 item 2, briefly added
+//! then retired — round 4 audit, constat 7). CLAUDE.md and
+//! `geometry-safety.md` both promise the validator only complains about
+//! content escaping the *viewport*, never about escaping a non-clipping
+//! (`overflow: visible`, the default) container — "a badge sticking out of
+//! a card is legal". A box-vs-card check can only ever fire in exactly that
+//! legal case (a clipping card already suppresses it the same way it
+//! suppresses every other check here, so there is nothing left for it to
+//! report when the card *does* clip either) — see the retired call site's
+//! comment in `walk` for the full reasoning.
+//!
//! Animation handling is layered: by default we only check the resting
-//! (untransformed) layout. With `--strict-anim`, we additionally sample
-//! frames — proportionally to scene duration — and reapply the *real*
-//! renderer's animation resolution (`effective_effects` +
-//! `resolve_props_for_effects`) plus the paint pass's start_at/end_at
-//! visibility window, rather than a hand-rolled fork of that logic (H6).
+//! (untransformed) layout, built once with `anim: None`. With
+//! `--strict-anim`, we additionally sample frames — proportionally to scene
+//! duration (H6; round 4 audit, constat 8: dense enough to stay near a
+//! promised 8/s up to 60s scenes) — and at EACH sample, rebuild the box
+//! tree and rerun layout with a real `BuildAnimationCtx` (round 4 audit,
+//! constats 2 & 9): the same engine path `render_with_new_pipeline_iter`
+//! calls once per rendered frame, rather than building once at rest and
+//! hand-deriving only translate/scale afterwards. This is what makes
+//! `timeline` style states, audio-reactive transforms, and animated
+//! rotation all visible to `--strict-anim`, not just translate/scale — see
+//! `validate_geometry_animated`'s doc comment. The paint pass's
+//! start_at/end_at visibility window (resolved at box-tree build time,
+//! independent of `anim`) is honoured the same way in both modes.
//!
//! This walker runs the new CSS-engine pipeline (taffy + cosmic-text) so the
//! geometry it checks matches what the renderer will actually paint.
use std::collections::HashSet;
-use rustmotion::components::box_builder::{build_scene_from_refs, effective_effects};
+use rustmotion::components::box_builder::{
+ build_scene_from_refs, effective_effects, BuildAnimationCtx,
+};
use rustmotion::components::intrinsic::{
- CaptionIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic, TextIntrinsic,
+ CaptionIntrinsic, CodeblockIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic,
+ TerminalIntrinsic, TextIntrinsic,
};
use rustmotion::components::{ChildComponent, Component};
-use rustmotion::core::css::style::{CssStyle, TransformFn, WhiteSpace};
+use rustmotion::core::css::style::{CssStyle, TransformFn, TransformOrigin, WhiteSpace};
use rustmotion::core::css::taffy_bridge::ConversionContext;
-use rustmotion::core::css::units::LengthContext;
-use rustmotion::core::engine::box_tree::{AvailableSpace, BoxNode, IntrinsicMeasure};
+use rustmotion::core::css::units::{parse_origin_component, LengthContext, ParsedLength};
+use rustmotion::core::engine::box_tree::{AvailableSpace, BoxKind, BoxNode, IntrinsicMeasure};
use rustmotion::core::engine::layout_pass::{run_layout, BoxLayout, LayoutResult};
use rustmotion::engine::animator::{resolve_props_for_effects, AnimatedProperties};
use rustmotion::engine::render;
-use rustmotion::schema::{Camera, ResolvedScenario, Scene};
+use rustmotion::schema::{Camera, ResolvedScenario, Scene, ViewType};
use serde::Serialize;
/// One detected layout violation.
@@ -94,13 +115,17 @@ pub enum ViolationKind {
/// painters never clip themselves, so this paints outside its box
/// regardless of where that box sits relative to the viewport.
ContentOverflowsBox,
- /// A component's own (resolved, post-layout) box extends past the
- /// nearest ancestor `card` that contains it — distinct from
- /// [`ViolationKind::ViewportOverflow`] (box vs frame) and
- /// [`ViolationKind::ContentOverflowsBox`] (content vs its OWN box): this
- /// is box vs the panel that's supposed to contain it (#128 item 2). A
- /// panel-based style relies on every graphic element staying inside its
- /// card, so this is the blind spot that matters most for it.
+ /// Retired (round 4 audit, constat 7) — no longer constructed by
+ /// `walk`/`walk_anim`. Was: a component's own (resolved, post-layout)
+ /// box extending past its nearest ancestor `card`'s box (#128 item 2),
+ /// unconditionally on any non-clipping card — exactly the "badge
+ /// sticking out of a card" pattern CLAUDE.md and `geometry-safety.md`
+ /// document as legal (`overflow: visible`, the default). Kept as a
+ /// variant — not renamed/removed — for `--fix`'s match arm and
+ /// `--report` JSON schema stability (frozen violation-kind contract);
+ /// see the module doc comment's "Deliberately NOT in scope" note for
+ /// the full reasoning.
+ #[allow(dead_code)] // never constructed by design — see doc comment above
ContentOverflowsCard,
/// Animated transform (scale/translate/wiggle/orbit) pushes the bbox out
/// of the viewport at some sampled time. Only emitted with `--strict-anim`.
@@ -120,13 +145,34 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec
let mut violations = Vec::new();
for (vi, view) in scenario.views.iter().enumerate() {
for (si, scene) in view.scenes.iter().enumerate() {
+ // Round 4 audit, constat 4: a `world` scene's decorative
+ // children (particles) are never fed into the flex box tree at
+ // render time either — `render_world_frame_scaled` paints them
+ // full-viewport via `paint_decorative_fullscreen`, filtered out
+ // of `render_with_new_pipeline_iter`'s children entirely (see
+ // `scene_children.iter().filter(|c| !c.is_decorative())` there).
+ // Leaving them in here would let them occupy a flex slot that
+ // pushes sibling positions around in a way that never happens
+ // at render, so they're dropped from the walk the same way for
+ // `world` views only — `slide` views never filtered them (a
+ // particle IS flex-flowed there), so scoping this to `world`
+ // keeps slide-view behaviour byte-identical.
+ let is_world = matches!(view.view_type, ViewType::World);
let indexed = deserialize_children_indexed(scene);
+ let indexed: Vec<(usize, ChildComponent)> = if is_world {
+ indexed
+ .into_iter()
+ .filter(|(_, c)| !c.is_decorative())
+ .collect()
+ } else {
+ indexed
+ };
let raw_indices: Vec = indexed.iter().map(|(i, _)| *i).collect();
let children: Vec = indexed.into_iter().map(|(_, c)| c).collect();
let viewport = (scenario.video.width, scenario.video.height);
let viewport_f = (viewport.0 as f32, viewport.1 as f32);
- let root_css = render::root_style(scene.layout.as_ref());
+ let root_css = render::root_style(scene.layout.as_ref(), view.view_type.clone());
let built = build_scene_from_refs(children.iter(), viewport_f, root_css, None);
let layouts = run_layout(&built.root, viewport_f, &ConversionContext::default());
@@ -151,9 +197,6 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec
/*parent_clips=*/
false,
camera,
- // No ancestor card at the top of a scene.
- /*nearest_card=*/
- None,
&mut violations,
);
}
@@ -213,14 +256,6 @@ fn walk(
path_indices: Option<&[usize]>,
parent_clips: bool,
camera: Option<&Camera>,
- // Bbox of the nearest ancestor `card`, in the same raw layout space as
- // `raw_bbox` below — `None` when no card encloses this level yet. Only
- // `Component::Card` updates this for its own children (see the
- // recursion below); every other container (`flex`/`grid`/`positioned`/
- // `container`) is layout-only per CLAUDE.md and passes it through
- // unchanged, so the check always compares against the panel actually
- // responsible for containing the content, not an incidental layout box.
- nearest_card: Option,
out: &mut Vec,
) {
let viewport_f = (viewport.0 as f32, viewport.1 as f32);
@@ -241,15 +276,30 @@ fn walk(
}
check_viewport(&child.component, &child_path, &vbbox, viewport, vi, si, out);
}
- check_unwrappable_text(
- &child.component,
- &child_path,
- &raw_bbox,
- viewport,
- vi,
- si,
- out,
- );
+ // Round 4 audit, constat 3: this natural-width-vs-own-box check
+ // is content vs its OWN box, exactly the same category as
+ // `check_content_overflows_box` below (just for the nowrap/
+ // single-line case instead of the wrapped one) — so it gets the
+ // identical double exemption: an ancestor that clips
+ // (`parent_clips`) genuinely crops the overflowing line before
+ // it can paint past the box, and a node that clips ITSELF
+ // (`container_clips`) does the same to its own content. Before
+ // this fix it ran unconditionally, contradicting
+ // geometry-safety.md's documented promise ("A node is also
+ // exempt when it clips itself, or when any ancestor clips it")
+ // and `--fix` would then strip a legitimate `white-space:
+ // nowrap` from a component that was never actually broken.
+ if !parent_clips && !container_clips(&child.component) {
+ check_unwrappable_text(
+ &child.component,
+ &child_path,
+ &raw_bbox,
+ viewport,
+ vi,
+ si,
+ out,
+ );
+ }
check_auto_scroll(
&child.component,
&child_path,
@@ -275,35 +325,34 @@ fn walk(
out,
);
}
- // #128 item 2: box vs the containing card. Suppressed under a
- // clipping ancestor exactly like check_viewport (content clipped
- // by the card itself, or by something between here and the
- // card, genuinely never paints past it). Deliberately NOT
- // suppressed by `bleed` — bleed is an assertion about crossing
- // the *frame* edge on purpose, not about disowning whatever a
- // component paints inside its own panel (see `bleeds`'s doc
- // comment; the same reasoning `check_content_overflows_box`
- // already applies).
- if !parent_clips {
- check_overflows_card(
- &child.component,
- &child_path,
- &raw_bbox,
- nearest_card,
- viewport,
- vi,
- si,
- out,
- );
- }
+ // #128 item 2 (`ContentOverflowsCard`) used to live here: a
+ // component's box vs its nearest ancestor `card`'s box,
+ // unconditionally (as long as nothing clipped in between).
+ // Round 4 audit, constat 7: that check is structurally
+ // incompatible with the validator's own documented contract.
+ // Its own suppression (`!parent_clips`, mirroring every other
+ // check here) is reachable if and only if the nearest card AND
+ // everything between it and this node is non-clipping — i.e. it
+ // could only ever fire in exactly the case CLAUDE.md ("le
+ // validateur ne se plaint que si le contenu sort du viewport,
+ // pas d'un parent visible") and geometry-safety.md:34/77 ("a
+ // badge sticking out of a card is legal" when the card's
+ // `overflow` is `visible`, the default — "no change needed")
+ // both promise is legal and must NOT be reported. Whenever the
+ // card *does* clip (`overflow: hidden`), `parent_clips` already
+ // suppresses this whole block, so the content is invisible
+ // anyway and there is nothing left to warn about either way.
+ // There is no configuration where firing is both reachable and
+ // consistent with the documented contract, so it is retired
+ // here rather than patched with a redundant escape hatch —
+ // `check_overflows_card` (and the `nearest_card` tracking that
+ // fed it) is deleted; the `ViolationKind::ContentOverflowsCard`
+ // variant itself is kept, unconstructed, for `--fix`'s match arm
+ // and `--report` JSON schema stability (frozen violation-kind
+ // contract — see that variant's doc comment).
}
if let Some(grandchildren) = container_children(&child.component) {
- let child_nearest_card = if matches!(child.component, Component::Card(_)) {
- Some(raw_bbox)
- } else {
- nearest_card
- };
walk(
grandchildren,
&box_node.children,
@@ -315,7 +364,6 @@ fn walk(
None,
parent_clips || container_clips(&child.component),
camera,
- child_nearest_card,
out,
);
}
@@ -396,8 +444,10 @@ fn container_clips(c: &Component) -> bool {
/// closes the rotation/skew gap) — maps the box's four corners through the
/// same ordered transform-function chain the paint pass's 2D fast path
/// applies (`canvas.translate/scale/rotate/skew`, called once per function
-/// in `style.transform` order, pivoted at the box centre), then takes the
-/// AABB of the four transformed corners. This is what makes rotation/skew
+/// in `style.transform` order, pivoted at `style.transform-origin` —
+/// resolved by [`resolve_transform_origin_2d`], defaulting to the box centre
+/// exactly like the paint pass does when it's absent), then takes the AABB
+/// of the four transformed corners. This is what makes rotation/skew
/// contribute correctly: an exact AABB under rotation needs the four
/// corners, not a translate/scale-only shortcut.
///
@@ -406,11 +456,9 @@ fn container_clips(c: &Component) -> bool {
/// `Matrix3d`) and the general 2D `Matrix` are intentionally still not
/// modeled (identity for that function) — an exact AABB there needs
/// projecting through the full 3D pipeline `apply_transform` uses for that
-/// path, out of scope for this fix. Also still assumes the pivot is the box
-/// centre (no `transform-origin` support), matching the prior partial
-/// behaviour. Animated transform-producing presets are folded separately in
-/// `walk_anim`; this only handles what a component declares directly in
-/// `style.transform`.
+/// path, out of scope for this fix. Animated transform-producing presets are
+/// folded separately in `walk_anim`; this only handles what a component
+/// declares directly in `style.transform`.
fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)) -> BBox {
let transform = match css.transform.as_deref() {
Some(t) if !t.is_empty() => t,
@@ -423,8 +471,7 @@ fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)
font_size: 16.0,
root_font_size: 16.0,
};
- let pivot_x = bbox.x + bbox.w / 2.0;
- let pivot_y = bbox.y + bbox.h / 2.0;
+ let (pivot_x, pivot_y) = resolve_transform_origin_2d(css.transform_origin.as_ref(), bbox, &ctx);
let corners = [
(bbox.x, bbox.y),
(bbox.x + bbox.w, bbox.y),
@@ -453,6 +500,62 @@ fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)
}
}
+/// Round 4 audit, constat 5: resolve `style.transform-origin` to an absolute
+/// viewport-space pivot `(x, y)`, in the same way the paint pass's own
+/// `resolve_origin` does (`crates/rustmotion-core/src/engine/paint_pass.rs`)
+/// — percentages resolve against the box's own width (x) / height (y), an
+/// absent axis defaults to 50%, and an absent `transform-origin` altogether
+/// defaults to dead-centre.
+///
+/// This mirrors `resolve_origin`'s 2D resolution rather than calling it
+/// directly: that function is private to `paint_pass.rs`, which sits outside
+/// this workstream's file perimeter (round 4 audit, lot VALIDATION
+/// GÉOMÉTRIQUE — geometry.rs/validate.rs/scene.rs only), so it cannot be
+/// marked `pub`/re-exported from here without touching a file outside that
+/// scope. What's duplicated is only the small resolution *orchestration*;
+/// the actual unit-conversion primitives it calls (`parse_origin_component`,
+/// `ParsedLength::resolve`) are `pub` in `rustmotion_core::css::units` and
+/// are the exact same functions `resolve_origin` itself calls, so the two
+/// can only drift on the orchestration shape, not on what a given length
+/// string resolves to. Keep this in sync with `resolve_origin` if that
+/// function's resolution rules change; the z component is intentionally not
+/// resolved (this fold is 2D-only, see this function's caller's doc comment
+/// on the 3D exemption).
+fn resolve_transform_origin_2d(
+ origin: Option<&TransformOrigin>,
+ bbox: &BBox,
+ ctx: &LengthContext,
+) -> (f32, f32) {
+ let Some(o) = origin else {
+ return (bbox.x + bbox.w / 2.0, bbox.y + bbox.h / 2.0);
+ };
+ let resolve_axis = |lp: &rustmotion::core::css::units::LengthPercentage,
+ axis_size: f32,
+ axis_origin: f32|
+ -> f32 {
+ let parsed = match lp {
+ rustmotion::core::css::units::LengthPercentage::String(s) => {
+ parse_origin_component(s).unwrap_or(ParsedLength::Percent(50.0))
+ }
+ rustmotion::core::css::units::LengthPercentage::Px(v) => ParsedLength::Px(*v),
+ };
+ let local_ctx = LengthContext {
+ parent_size: axis_size,
+ ..*ctx
+ };
+ axis_origin + parsed.resolve(&local_ctx).unwrap_or(axis_size / 2.0)
+ };
+ let ox =
+ o.x.as_ref()
+ .map(|lp| resolve_axis(lp, bbox.w, bbox.x))
+ .unwrap_or(bbox.x + bbox.w / 2.0);
+ let oy =
+ o.y.as_ref()
+ .map(|lp| resolve_axis(lp, bbox.h, bbox.y))
+ .unwrap_or(bbox.y + bbox.h / 2.0);
+ (ox, oy)
+}
+
/// Apply a `style.transform` function list to a point already expressed
/// relative to the pivot, in the same order `apply_transform`'s 2D fast path
/// composes them: `canvas.translate/scale/rotate/skew` are called once per
@@ -781,67 +884,20 @@ fn check_content_overflows_box(
});
}
-/// #128 item 2: does this component's own (resolved) box extend past the
-/// nearest ancestor `card`'s own box? Complementary to `check_viewport`
-/// (box vs frame) and `check_content_overflows_box` (content vs its OWN
-/// box) — this is the missing third comparison: box vs the panel it's
-/// supposed to live inside. Operates purely in layout space (no camera/
-/// css-transform folding), matching `check_content_overflows_box`'s scope:
-/// "does this content structurally fit inside its card as laid out",
-/// independent of wherever the camera happens to be pointing at paint time.
-fn check_overflows_card(
- component: &Component,
- path: &str,
- bbox: &BBox,
- nearest_card: Option,
- viewport: (u32, u32),
- vi: usize,
- si: usize,
- out: &mut Vec,
-) {
- let Some(card) = nearest_card else {
- return;
- };
- let eps = 0.5;
- let card_right = card.x + card.w;
- let card_bottom = card.y + card.h;
- let right = bbox.x + bbox.w;
- let bottom = bbox.y + bbox.h;
- let x_over = bbox.x < card.x - eps || right > card_right + eps;
- let y_over = bbox.y < card.y - eps || bottom > card_bottom + eps;
- if !x_over && !y_over {
- return;
- }
- let axis = match (x_over, y_over) {
- (true, true) => Axis::Both,
- (true, false) => Axis::X,
- (false, true) => Axis::Y,
- (false, false) => return,
- };
- out.push(GeometryViolation {
- view_index: vi,
- scene_index: si,
- path: path.to_string(),
- component: component_kind(component).to_string(),
- axis,
- kind: ViolationKind::ContentOverflowsCard,
- bbox: *bbox,
- viewport,
- hint: format!(
- "{} at [{:.0},{:.0}]→[{:.0},{:.0}] extends past its containing card [{:.0},{:.0}]→[{:.0},{:.0}] — grow the card, shrink/reflow the content, or set overflow: hidden on the card if the bleed is intentional",
- component_kind(component),
- bbox.x,
- bbox.y,
- right,
- bottom,
- card.x,
- card.y,
- card_right,
- card_bottom,
- ),
- });
-}
-
+/// Round 4 audit, constat 6: this used to hand-roll codeblock/terminal
+/// natural-height formulas with a hardcoded 16+16=32px padding assumption
+/// and (for terminal) the CSS `style.line-height` property — neither of
+/// which is what actually gets painted. `CodeblockIntrinsic`/
+/// `TerminalIntrinsic` are the exact measurers `component_intrinsic`
+/// (`box_builder.rs`) hands to the layout pass for these two components, so
+/// calling them here — instead of re-deriving the formula — keeps this
+/// check byte-for-byte in sync with `compute_code_dimensions` (codeblock,
+/// which DOES read `style.padding_px()`) and `terminal::line_height()`
+/// (terminal, which does NOT honour `style.line-height`, always using its
+/// own fixed `LINE_HEIGHT`/`FONT_SIZE` ratio). Measuring at
+/// `(None, None)`/`MaxContent` yields each component's natural (unbounded)
+/// size, exactly like `check_unwrappable_text`/`check_content_overflows_box`
+/// already do for the text-family intrinsics.
fn check_auto_scroll(
component: &Component,
path: &str,
@@ -851,18 +907,11 @@ fn check_auto_scroll(
si: usize,
out: &mut Vec,
) {
+ let max_content = (AvailableSpace::MaxContent, AvailableSpace::MaxContent);
match component {
Component::Codeblock(cb) if !cb.auto_scroll => {
- let font_size = cb.style.font_size_px_or(14.0);
- let actual_line_height = cb.style.line_height_for(font_size);
- let line_count = cb.code.lines().count().max(1) as f32;
- let chrome_h = if cb.chrome.as_ref().is_some_and(|c| c.enabled) {
- 36.0
- } else {
- 0.0
- };
- let pad = 32.0; // ~16 top + 16 bottom default
- let natural_h = chrome_h + pad + line_count * actual_line_height;
+ let (_, natural_h) =
+ CodeblockIntrinsic::from_codeblock(cb).measure((None, None), max_content);
if natural_h > bbox.h + 0.5 {
out.push(GeometryViolation {
view_index: vi,
@@ -881,11 +930,8 @@ fn check_auto_scroll(
}
}
Component::Terminal(t) if !t.auto_scroll => {
- let font_size = t.style.font_size_px_or(16.0);
- let actual_line_height = t.style.line_height_for(font_size);
- let chrome_h = if t.show_chrome { 36.0 } else { 0.0 };
- let pad = 32.0;
- let natural_h = chrome_h + pad + t.lines.len() as f32 * actual_line_height;
+ let (_, natural_h) =
+ TerminalIntrinsic::from_terminal(t).measure((None, None), max_content);
if natural_h > bbox.h + 0.5 {
out.push(GeometryViolation {
view_index: vi,
@@ -1125,7 +1171,24 @@ fn component_kind(c: &Component) -> &'static str {
/// on long, mostly-static scenes).
const ANIM_SAMPLES_PER_SECOND: f64 = 8.0;
const ANIM_MIN_SAMPLES: usize = 5;
-const ANIM_MAX_SAMPLES: usize = 40;
+/// Round 4 audit, constat 8: raised from 40 (a ~5s ceiling on the promised
+/// 8/s cadence) to 480 - 60s worth of samples at exactly 8/s, the audit's
+/// own reference duration ("pas de 0.51s a 20s, 1.0s a 40s, 1.5s a 60s").
+/// Past a 5s scene, the old cap widened the step linearly with duration
+/// (0.51s at 20s, 1.0s at 40s, 1.5s at 60s), so a brief transform excursion
+/// shorter than that step could land entirely between two samples and never
+/// get checked. Cost, measured on the box-tree-rebuild-per-sample walker
+/// this cap now drives (constats 2 & 9): a 15-component animated scene at
+/// 60s / 480 samples took 309ms wall-clock in a `--release` build
+/// (~0.64ms/sample) and 543ms in a debug build (~1.13ms/sample) - see
+/// `timing_probe_for_constat_8` (run with `--ignored`) for the harness.
+/// `--strict-anim` is opt-in, and `validate`/`render`'s implicit checks
+/// don't pass it, so this cost is paid only when explicitly asked for.
+/// Scenes longer than 60s still degrade past this cap - CLAUDE.md's own
+/// architecture favours many short scenes stitched by transitions/world
+/// panning over one very long scene, so a single-scene ceiling at 60s
+/// covers the documented common case.
+const ANIM_MAX_SAMPLES: usize = 480;
/// Sample times (seconds, scene-relative) for `--strict-anim`, spaced evenly
/// across `[0, scene_duration]`. Count scales with `scene_duration` (H6) —
@@ -1149,25 +1212,48 @@ fn anim_sample_times(scene_duration: f64) -> Vec {
/// animation resolution to each widget's bbox, and report viewport
/// overflows. Only emits `AnimatedTextOverflow` violations: the
/// resting-layout checks live in `validate_geometry`.
+///
+/// Round 4 audit, constats 2 & 9: rebuilds the box tree AND reruns layout at
+/// EACH sampled time, with a real `BuildAnimationCtx` — exactly the engine
+/// path `render_with_new_pipeline_iter` calls once per rendered frame
+/// (`build_scene_from_refs` + `run_layout`) — instead of building once at a
+/// frozen resting state (`anim: None`) and hand-deriving only
+/// translate/scale afterwards in `walk_anim`. This one change fixes two
+/// separate blind spots at once, because both are downstream of the SAME
+/// `anim: None`:
+/// * `build_child` only applies `apply_style_states` (`timeline` steps)
+/// and the audio-reactive CSS block at the times a REAL `local_actx` is
+/// available — a `timeline` step that changes a box-model property
+/// (e.g. `width`) was invisible at every sample (constat 2).
+/// * `apply_animated_props` bakes the resolved transform (translate,
+/// scale, AND rotation) into `css.transform` — so once the tree is
+/// rebuilt with the real time, `apply_static_node_transform` (already
+/// used for static `style.transform`, already handling rotation/skew
+/// via a four-corner AABB) picks up animated rotation too, with no
+/// separate rotation-aware fold needed (constat 9).
pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec {
let mut violations = Vec::new();
let mut seen: HashSet<(usize, usize, String)> = HashSet::new();
+ let fps = scenario.video.fps;
for (vi, view) in scenario.views.iter().enumerate() {
for (si, scene) in view.scenes.iter().enumerate() {
+ // Constat 4: same decorative-child filtering as `validate_geometry`
+ // — see that call site's comment for why.
+ let is_world = matches!(view.view_type, ViewType::World);
let indexed = deserialize_children_indexed(scene);
+ let indexed: Vec<(usize, ChildComponent)> = if is_world {
+ indexed
+ .into_iter()
+ .filter(|(_, c)| !c.is_decorative())
+ .collect()
+ } else {
+ indexed
+ };
let raw_indices: Vec = indexed.iter().map(|(i, _)| *i).collect();
let children: Vec = indexed.into_iter().map(|(_, c)| c).collect();
let viewport = (scenario.video.width, scenario.video.height);
let viewport_f = (viewport.0 as f32, viewport.1 as f32);
- // Use the resting layout as the base bbox. Animation transforms
- // (translate/scale/wiggle/orbit) are applied analytically per
- // sample — they don't reflow taffy, matching how the paint pass
- // applies them after layout.
- let root_css = render::root_style(scene.layout.as_ref());
- let built = build_scene_from_refs(children.iter(), viewport_f, root_css, None);
- let layouts = run_layout(&built.root, viewport_f, &ConversionContext::default());
-
let camera = scene
.camera
.as_ref()
@@ -1177,11 +1263,21 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec Vec impl Iterator- {
+ boxes
+ .iter()
+ .filter(|b| !matches!(b.kind, BoxKind::Ghost(_)))
+}
+
#[allow(clippy::too_many_arguments)]
fn walk_anim(
children: &[ChildComponent],
boxes: &[BoxNode],
layouts: &LayoutResult,
stagger_delays: &[f64],
+ time_params: &[(f64, f64)],
viewport: (u32, u32),
vi: usize,
si: usize,
@@ -1219,7 +1336,7 @@ fn walk_anim(
out: &mut Vec,
) {
let viewport_f = (viewport.0 as f32, viewport.1 as f32);
- for (i, (child, box_node)) in children.iter().zip(boxes.iter()).enumerate() {
+ for (i, (child, box_node)) in children.iter().zip(principal_boxes(boxes)).enumerate() {
let json_idx = path_indices.map(|idxs| idxs[i]).unwrap_or(i);
let child_path = format!("{}.children[{}]", path, json_idx);
let layout = match layouts.get(box_node.id) {
@@ -1245,58 +1362,94 @@ fn walk_anim(
&& !parent_clips
&& layout.width > 0.5
&& layout.height > 0.5
+ // Round 4 audit, constats 2 & 9: `box_node.css` was rebuilt at
+ // this sample's real time (see `validate_geometry_animated`),
+ // so `css.opacity` already reflects `apply_animated_props` —
+ // no separate `AnimatedProperties` re-derivation needed for
+ // the visibility short-circuit any more.
+ && box_node.css.opacity.unwrap_or(1.0) > 0.001
{
let stagger_delay = stagger_delays
.get(box_node.id as usize)
.copied()
.unwrap_or(0.0);
- // Reuse the renderer's own effect resolution instead of a
- // divergent fork (H6): `effective_effects` merges timeline
- // steps/synthesized transitions/stagger exactly like
- // `legacy_dispatch.rs` does, and `resolve_props_for_effects`
- // resolves them at absolute scene `time` — no re-timing by
- // start_at, which would double-shift components that also
- // declare a matching `animation` delay.
+ // `time` above is *global* scene time; the renderer never
+ // resolves effects at that raw value once a `time_scale`/
+ // `time_offset`-bearing container is in the ancestor chain —
+ // `build_child` remaps it first (`box_builder.rs`:
+ // `t_local = scale * t_global + shift`), and it already used
+ // this same remap to build `box_node.css` above. `local_time`
+ // is only still needed here to independently re-derive
+ // `AnimatedProperties.char_animation` (char-level overshoot),
+ // which `apply_animated_props` deliberately does NOT bake into
+ // CSS (component-internal, painter-only property — see that
+ // function's doc comment) — `built.time_params` carries the
+ // exact same accumulated `(scale, shift)` the renderer used, so
+ // this stays in lockstep with it.
+ let (scale, shift) = time_params
+ .get(box_node.id as usize)
+ .copied()
+ .unwrap_or((1.0, 0.0));
+ let local_time = scale * time + shift;
let props = match effective_effects(&child.component, stagger_delay) {
- Some(effects) => resolve_props_for_effects(&effects, time, scene_duration),
+ Some(effects) => resolve_props_for_effects(&effects, local_time, scene_duration),
None => AnimatedProperties::default(),
};
let raw_bbox = bbox_of(layout);
- let base_bbox = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f);
- if let Some(mut transformed) = transform_bbox(&base_bbox, &props) {
- if let Some(cam) = camera {
- transformed = fold_static_camera(&transformed, cam, viewport_f);
- }
- let vw = viewport.0 as f32;
- let vh = viewport.1 as f32;
- let eps = 0.5;
- let right = transformed.x + transformed.w;
- let bottom = transformed.y + transformed.h;
- let x_over = transformed.x < -eps || right > vw + eps;
- let y_over = transformed.y < -eps || bottom > vh + eps;
- if x_over || y_over {
- let axis = match (x_over, y_over) {
- (true, true) => Axis::Both,
- (true, false) => Axis::X,
- (false, true) => Axis::Y,
- _ => unreachable!(),
- };
- // Dedupe across samples: one violation per (view, scene, path).
- let key = (vi, si, child_path.clone());
- if seen.insert(key) {
- let component_name = component_kind(&child.component).to_string();
- out.push(GeometryViolation {
- view_index: vi,
- scene_index: si,
- path: child_path.clone(),
- component: component_name,
- axis,
- kind: ViolationKind::AnimatedTextOverflow,
- bbox: transformed,
- viewport,
- hint: hint_for_animated(&child.component, &props, time, scene_duration),
- });
- }
+ // `apply_static_node_transform` — the SAME fold `walk` uses for
+ // a *static* `style.transform` — now does the whole job:
+ // `box_node.css.transform` already carries the resolved
+ // translate/scale/rotation (`apply_animated_props`, baked in at
+ // box-tree build time for this sample) composed with any
+ // static `style.transform` the component also declares, and
+ // the four-corner AABB it computes already accounts for
+ // rotation (constat 9) the same way it does for a static
+ // `transform: rotate(...)`.
+ let mut transformed = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f);
+ // Char-level overshoot (e.g. `char_scale_in`'s default 1.08)
+ // is the one animated-transform contributor NOT baked into
+ // `css.transform` — fold it in as an extra uniform scale
+ // around the already-transformed box's own centre.
+ if let Some(overshoot) = props
+ .char_animation
+ .as_ref()
+ .map(|c| c.overshoot.max(0.0))
+ .filter(|o| *o > 1e-4)
+ {
+ transformed = scale_bbox_from_own_center(&transformed, 1.0 + overshoot);
+ }
+ if let Some(cam) = camera {
+ transformed = fold_static_camera(&transformed, cam, viewport_f);
+ }
+ let vw = viewport.0 as f32;
+ let vh = viewport.1 as f32;
+ let eps = 0.5;
+ let right = transformed.x + transformed.w;
+ let bottom = transformed.y + transformed.h;
+ let x_over = transformed.x < -eps || right > vw + eps;
+ let y_over = transformed.y < -eps || bottom > vh + eps;
+ if x_over || y_over {
+ let axis = match (x_over, y_over) {
+ (true, true) => Axis::Both,
+ (true, false) => Axis::X,
+ (false, true) => Axis::Y,
+ _ => unreachable!(),
+ };
+ // Dedupe across samples: one violation per (view, scene, path).
+ let key = (vi, si, child_path.clone());
+ if seen.insert(key) {
+ let component_name = component_kind(&child.component).to_string();
+ out.push(GeometryViolation {
+ view_index: vi,
+ scene_index: si,
+ path: child_path.clone(),
+ component: component_name,
+ axis,
+ kind: ViolationKind::AnimatedTextOverflow,
+ bbox: transformed,
+ viewport,
+ hint: hint_for_animated(&child.component, &props, time, scene_duration),
+ });
}
}
}
@@ -1307,6 +1460,7 @@ fn walk_anim(
&box_node.children,
layouts,
stagger_delays,
+ time_params,
viewport,
vi,
si,
@@ -1323,32 +1477,21 @@ fn walk_anim(
}
}
-/// Apply the canvas transforms the renderer applies (translate then scale
-/// around the bbox center) to a base bbox. Returns `None` if the resulting
-/// box is degenerate (fully transparent / zero size).
-fn transform_bbox(base: &BBox, props: &AnimatedProperties) -> Option {
- if props.opacity <= 0.001 {
- return None;
- }
- // Conservative: also account for char animations that overshoot the box
- // (e.g. char_scale_in defaults to 1.08). One extra factor on each axis.
- let char_overshoot = props
- .char_animation
- .as_ref()
- .map(|c| 1.0 + c.overshoot.max(0.0))
- .unwrap_or(1.0);
- let sx = props.scale_x.abs().max(0.001) * char_overshoot;
- let sy = props.scale_y.abs().max(0.001) * char_overshoot;
- let center_x = base.x + base.w / 2.0 + props.translate_x;
- let center_y = base.y + base.h / 2.0 + props.translate_y;
- let new_w = base.w * sx;
- let new_h = base.h * sy;
- Some(BBox {
- x: center_x - new_w / 2.0,
- y: center_y - new_h / 2.0,
- w: new_w,
- h: new_h,
- })
+/// Scale a bbox by `factor` around its OWN centre (as opposed to
+/// `apply_static_node_transform`'s pivot, which is `transform-origin`) —
+/// used only for the char-animation overshoot top-up in `walk_anim`, which
+/// is not a CSS transform and has no origin concept of its own.
+fn scale_bbox_from_own_center(bbox: &BBox, factor: f32) -> BBox {
+ let cx = bbox.x + bbox.w / 2.0;
+ let cy = bbox.y + bbox.h / 2.0;
+ let w = bbox.w * factor;
+ let h = bbox.h * factor;
+ BBox {
+ x: cx - w / 2.0,
+ y: cy - h / 2.0,
+ w,
+ h,
+ }
}
fn hint_for_animated(
@@ -1447,6 +1590,59 @@ mod tests {
);
}
+ // ─── Round 4 audit, constat 4: a `world` scene without its own `layout`
+ // must be validated against the SAME centred-column root layout
+ // `render_world_frame_scaled` synthesizes, not the plain top-aligned
+ // slide default ─────────────────────────────────────────────────────
+
+ #[test]
+ fn layoutless_world_scene_uses_the_centred_root_not_the_slide_default() {
+ // A single in-flow (no `position`) 1000×100 shape, wider than the
+ // 800px-wide viewport, inside a `world` scene with no `layout` of
+ // its own. `render_world_frame_scaled` synthesizes a centred column
+ // (`align_items: center`) for exactly this case.
+ //
+ // Red-phase capture (root forced back to the slide default): bbox
+ // = {x: 0, y: 0, w: 1000, h: 100}, hint "current right edge is
+ // 1000" — `align_items` unset resolves start-aligned for an item
+ // with an explicit size, so the shape sits at x=0, right edge=1000.
+ //
+ // Under the CORRECT (world-default, centred) root, a 1000px item in
+ // an 800px-wide container centres at x=(800-1000)/2=-100: bbox
+ // x=[-100,900]. Still a single-axis (X) overflow — both edges are
+ // crossed, but `Axis::Both` means "X and Y both overflow", not "X
+ // overflows on both sides" — but the reported bbox.x is materially
+ // different (-100 vs 0) and, before this fix, wrong.
+ let json = r##"{
+ "video": { "width": 800, "height": 600 },
+ "composition": [{
+ "type": "world",
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "style": { "width": "1000px", "height": "100px" },
+ "fill": "#ff0000"
+ }]
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+ let v = violations
+ .iter()
+ .find(|v| v.kind == ViolationKind::ViewportOverflow)
+ .unwrap_or_else(|| panic!("expected a ViewportOverflow: {:?}", violations));
+ assert_eq!(v.axis, Axis::X, "{:?}", v);
+ assert!(
+ (v.bbox.x - (-100.0)).abs() < 1.0,
+ "expected the shape centred at x=-100 (world root), got bbox.x={}: {:?}",
+ v.bbox.x,
+ v
+ );
+ }
+
#[test]
fn shape_past_right_edge_triggers_x_overflow() {
// A 400×100 shape positioned at x=1700 in a 1920-wide viewport spills
@@ -1525,6 +1721,81 @@ mod tests {
assert_eq!(v.axis, Axis::X);
}
+ // ─── Round 4 audit, constat 3: unwrappable_text_overflow must respect a
+ // clipping ancestor exactly like check_viewport/check_content_overflows_box
+ // already do ───────────────────────────────────────────────────────────
+
+ #[test]
+ fn unwrappable_text_is_suppressed_under_a_clipping_ancestor_card() {
+ // Same headline fixture as `unwrappable_text_in_narrow_card_is_flagged`
+ // (a 200px card, 96px nowrap text, natural width far exceeding 200px)
+ // but the card now clips (`overflow: hidden`): the text genuinely
+ // gets cropped to the card's edge at paint time, so nothing overflows
+ // on screen — geometry-safety.md promises this is exempt ("A node is
+ // also exempt when it clips itself, or when any ancestor clips it"),
+ // and `check_viewport`/`check_content_overflows_box` already honour
+ // it. This is a CORRECT scenario (the clip makes the excess
+ // invisible) that the validator wrongly rejected before this fix.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "card",
+ "x": 100, "y": 100,
+ "style": { "width": "200px", "height": "200px", "background": "#222244", "overflow": "hidden" },
+ "children": [{
+ "type": "text",
+ "content": "this string is too long to fit",
+ "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" }
+ }]
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+ assert!(
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::UnwrappableTextOverflow),
+ "nowrap text clipped by its own card must not be flagged: {:?}",
+ violations
+ );
+ }
+
+ #[test]
+ fn unwrappable_text_still_fires_without_a_clipping_ancestor() {
+ // Regression guard: the exact pre-existing fixture from
+ // `unwrappable_text_in_narrow_card_is_flagged` (card overflow left
+ // at the default `visible`) must keep firing — the fix must only add
+ // a clip-aware exemption, not silence the check generally.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "card",
+ "x": 100, "y": 100,
+ "style": { "width": "200px", "height": "200px", "background": "#222244" },
+ "children": [{
+ "type": "text",
+ "content": "this string is too long to fit",
+ "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" }
+ }]
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+ assert!(
+ violations
+ .iter()
+ .any(|v| v.kind == ViolationKind::UnwrappableTextOverflow),
+ "must still fire when nothing clips: {:?}",
+ violations
+ );
+ }
+
#[test]
fn marquee_is_exempted_from_overflow() {
// A marquee that bleeds past the viewport: no violation should fire,
@@ -1581,6 +1852,141 @@ mod tests {
assert_eq!(v.unwrap().component, "codeblock");
}
+ // ─── Round 4 audit, constat 6: check_auto_scroll must use the real
+ // painter's dimension formula (CodeblockIntrinsic/TerminalIntrinsic),
+ // not a hardcoded 16+16=32px padding assumption ──────────────────────
+
+ #[test]
+ fn codeblock_auto_scroll_check_honours_explicit_padding_not_a_hardcoded_16px() {
+ // 10 lines, font-size defaults to 14px (line-height 1.3 -> 18.2px/line
+ // -> 182px of text), auto_scroll: false, box height fixed at 250px.
+ // style.padding is *explicitly* 60px on every side (120px vertical
+ // budget) — nothing close to the hardcoded "16 top + 16 bottom" the
+ // old formula assumed. Real natural height (chrome disabled):
+ // 120 (padding) + 182 (text) = 302px, ~52px past the 250px box —
+ // a genuine overflow. The hardcoded-32px formula computed
+ // 32 + 182 = 214px, comfortably under 250px, and stayed silent.
+ let code_lines: String = (1..=10)
+ .map(|i| i.to_string())
+ .collect::>()
+ .join("\\n");
+ let json = format!(
+ r##"{{
+ "video": {{ "width": 1920, "height": 1080 }},
+ "scenes": [{{
+ "duration": 1.0,
+ "children": [{{
+ "type": "codeblock",
+ "code": "{code_lines}",
+ "auto_scroll": false,
+ "style": {{ "width": "600px", "height": "250px", "padding": "60px" }}
+ }}]
+ }}]
+ }}"##
+ );
+ let scenario = parse(&json);
+ let violations = validate_geometry(&scenario);
+ let v = violations
+ .iter()
+ .find(|v| v.kind == ViolationKind::AutoScrollDisabledOverflow);
+ assert!(
+ v.is_some(),
+ "expected AutoScrollDisabledOverflow for a 60px-padded codeblock the \
+ hardcoded-16px formula wrongly cleared (real natural height ~302px > \
+ 250px box): {:?}",
+ violations
+ );
+ }
+
+ #[test]
+ fn codeblock_auto_scroll_check_does_not_false_positive_on_tight_default_padding() {
+ // Complementary false-positive guard: 10 lines, DEFAULT padding
+ // (16px each side -> 32px vertical budget, matching
+ // CodeblockIntrinsic's own fallback for an all-zero/unset padding —
+ // see `CodeblockIntrinsic::from_codeblock`'s (16,16,16,16) default).
+ // Natural height: 32 + 182 = 214px. Box height 220px comfortably
+ // holds it — must NOT be flagged.
+ let code_lines: String = (1..=10)
+ .map(|i| i.to_string())
+ .collect::>()
+ .join("\\n");
+ let json = format!(
+ r##"{{
+ "video": {{ "width": 1920, "height": 1080 }},
+ "scenes": [{{
+ "duration": 1.0,
+ "children": [{{
+ "type": "codeblock",
+ "code": "{code_lines}",
+ "auto_scroll": false,
+ "style": {{ "width": "600px", "height": "220px" }}
+ }}]
+ }}]
+ }}"##
+ );
+ let scenario = parse(&json);
+ let violations = validate_geometry(&scenario);
+ assert!(
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow),
+ "a codeblock that genuinely fits its box must not be flagged: {:?}",
+ violations
+ );
+ }
+
+ #[test]
+ fn terminal_auto_scroll_check_uses_the_painters_fixed_line_height_ratio() {
+ // Terminal (unlike codeblock) does NOT honour `style.line-height` at
+ // paint time — `terminal.rs`'s own `line_height()` method always
+ // computes `(font_size * 22.0 / 14.0).ceil()` (a fixed ratio baked
+ // into the component, `terminal::LINE_HEIGHT`/`FONT_SIZE`), ignoring
+ // any CSS `line-height` override entirely. The old hand-rolled check
+ // used `t.style.line_height_for(font_size)` (the CSS property,
+ // honouring `style.line-height`) instead — so a `line-height: 3`
+ // override (unitless -> 3 * 14px = 42px/line) inflated the OLD
+ // formula's estimate even though the real painter still renders
+ // 22px lines and ignores the override.
+ //
+ // 8 lines, chrome disabled, font-size defaults to 14:
+ // real (TerminalIntrinsic/painter): 2*16 (fixed padding) +
+ // 8 * 22 (fixed ratio, ignores the override) = 32 + 176 = 208px
+ // old hand-rolled (CSS line-height, AND its own wrong default
+ // font-size of 16px instead of the real 14px):
+ // 32 + 8 * line_height_for(16) = 32 + 8 * 48 = 32 + 384 = 416px
+ // (captured red-phase output: "terminal content needs ~416px")
+ // Box height fixed at 300px sits strictly between the two: the real
+ // content fits (208 < 300), but the old formula's inflated 416px
+ // wrongly reported an overflow — a false positive this fix removes.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "terminal",
+ "lines": [
+ { "text": "one" }, { "text": "two" }, { "text": "three" },
+ { "text": "four" }, { "text": "five" }, { "text": "six" },
+ { "text": "seven" }, { "text": "eight" }
+ ],
+ "show_chrome": false,
+ "auto_scroll": false,
+ "style": { "width": "600px", "height": "300px", "line-height": 3 }
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+ assert!(
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow),
+ "terminal ignores style.line-height at paint time — the check must too, \
+ real content (208px) fits the 300px box: {:?}",
+ violations
+ );
+ }
+
// ─── C1: remediation hints must never name the nonexistent `wrap` field ──
#[test]
@@ -1900,10 +2306,65 @@ mod tests {
}
#[test]
- fn strict_anim_start_at_and_effect_delay_do_not_double_shift_the_timeline() {
- // A component with BOTH `start_at` (visibility gate) and a matching
- // animation `delay` — a common authoring pattern ("appear and
- // animate in at the same moment"). The old hand-rolled fork
+ fn strict_anim_respects_a_containers_time_offset_remap() {
+ // Constat #3: `walk_anim` used to resolve effects at raw *global*
+ // scene time, ignoring any `time_scale`/`time_offset` remap
+ // accumulated from ancestor containers — even though the renderer
+ // (`box_builder::build_child`) always resolves at the *local*
+ // remapped time (`t_local = scale * t_global + shift`).
+ //
+ // Here the shape's `slide_in_left` (delay=0, duration=1.0) sits
+ // inside a `flex` with `time_offset: -5.0`, which (per
+ // `rustmotion/src/tests.rs`'s time-remap tests) shifts local time to
+ // `t_local = t_global + 5.0`. Every sample in this 2s scene
+ // (t_global in [0, 2]) therefore resolves at local time in [5, 7] —
+ // 5-7s past the 1s animation window, fully settled at rest (x=100,
+ // well inside the 1920px-wide viewport). A walker that ignores the
+ // remap instead resolves at raw t_global in [0, 2], still inside the
+ // animation's own [0, 1] window for the first half of the scene,
+ // and reports a slide-in overflow that never actually happens at
+ // render time.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 2.0,
+ "children": [{
+ "type": "flex",
+ "time_offset": -5.0,
+ "style": { "width": "1920px", "height": "1080px" },
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 100, "y": 100,
+ "style": {
+ "width": "100px", "height": "100px",
+ "animation": [{ "name": "slide_in_left", "delay": 0, "duration": 1.0 }]
+ },
+ "fill": "#ff0000"
+ }]
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry_animated(&scenario);
+ let overflow: Vec<_> = violations
+ .iter()
+ .filter(|v| v.kind == ViolationKind::AnimatedTextOverflow)
+ .collect();
+ assert!(
+ overflow.is_empty(),
+ "time_offset=-5.0 settles the slide-in 5-7s before any sampled instant; a \
+ walker that honours the remap must report zero overflows, got: {:?}",
+ overflow
+ );
+ }
+
+ #[test]
+ fn strict_anim_start_at_and_effect_delay_do_not_double_shift_the_timeline() {
+ // A component with BOTH `start_at` (visibility gate) and a matching
+ // animation `delay` — a common authoring pattern ("appear and
+ // animate in at the same moment"). The old hand-rolled fork
// re-based time by subtracting start_at *again* before resolving
// the preset, which is already absolute-time-shifted by its own
// `delay`. That double shift pushed every sample right after
@@ -1976,6 +2437,169 @@ mod tests {
);
}
+ // ─── Round 4 audit, constat 2: --strict-anim must resolve `timeline`
+ // style states and audio-reactive transforms — both gated on
+ // `local_actx.is_some()` in `build_child`, which was always `None`
+ // here ────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn strict_anim_catches_a_brief_excursion_a_40_sample_cap_would_miss() {
+ // A 100×100 shape resting safely at x=100 (box x=[100,200]) in a
+ // 20s scene, with `slide_in_left` (delay=9.85s, duration=1.0s):
+ // `position.x` eases from -200 to 0 via EaseOutCubic, so the box
+ // only crosses the left edge (x < -0.5) for the FIRST ~20% of the
+ // 1s window (t in [9.85, ~10.06]) — a ~206ms excursion, while
+ // opacity has already ramped past its own [9.85, 10.15] fade-in
+ // window's midpoint by then (so it isn't filtered as invisible).
+ //
+ // With the OLD `ANIM_MAX_SAMPLES=40` cap, this 20s scene sampled at
+ // step 20/39 ≈ 0.513s — grid points at k·0.513s land at
+ // t=9.744 (k=19) and t=10.256 (k=20), straddling the whole ~206ms
+ // excursion without a single sample landing inside it. Confirmed by
+ // temporarily reverting the cap to 40 during development: this
+ // fixture produced ZERO violations (`violations: []`) — the exact
+ // false negative constat 8 describes.
+ //
+ // With the new 480-sample cap (step ≈ 0.042s), a sample lands well
+ // inside the excursion — this run finds one at t=9.94s with
+ // bbox.x≈-52.16 (tx≈-152 relative to the resting x=100).
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 20.0,
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 100, "y": 490,
+ "style": {
+ "width": "100px", "height": "100px",
+ "animation": [{ "name": "slide_in_left", "delay": 9.85, "duration": 1.0 }]
+ },
+ "fill": "#ff0000"
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry_animated(&scenario);
+ let v = violations
+ .iter()
+ .find(|v| v.kind == ViolationKind::AnimatedTextOverflow)
+ .unwrap_or_else(|| {
+ panic!(
+ "expected the ~206ms slide_in_left excursion around t≈9.85-10.06s \
+ to be caught by the denser sampling: {:?}",
+ violations
+ )
+ });
+ assert_eq!(v.component, "shape");
+ assert_eq!(v.axis, Axis::X);
+ assert!(
+ v.bbox.x < -0.5,
+ "expected a negative bbox.x (left-edge overflow), got {}",
+ v.bbox.x
+ );
+ }
+
+ #[test]
+ fn strict_anim_detects_a_timeline_width_step_that_overflows_later_in_the_scene() {
+ // A 200×100 shape, safely inside a 1920×1080 viewport at rest
+ // (x=[100,300]). A `timeline` step at t=1.0s grows `style.width` to
+ // 1900px — box_builder's `apply_style_states` runs on the CSS
+ // *before* layout, so this is a genuine box-model change, not a
+ // paint-only transform: at t>=1.0s the real render lays out a
+ // 1900px-wide box at x=100, right edge 2000 — 80px past the
+ // 1920px-wide frame.
+ //
+ // The OLD `--strict-anim` walker built its box tree ONCE with
+ // `anim: None` (so `apply_style_states` only ever evaluated at
+ // t=0, before the step's `at`) and never rebuilt it per sample —
+ // every one of the 16 samples in this 2s scene measured the
+ // resting 200px-wide box, so this never got flagged.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 2.0,
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 100, "y": 100,
+ "style": { "width": "200px", "height": "100px" },
+ "timeline": [{ "at": 1.0, "style": { "width": "1900px" } }],
+ "fill": "#ff0000"
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry_animated(&scenario);
+ let v = violations
+ .iter()
+ .find(|v| v.kind == ViolationKind::AnimatedTextOverflow)
+ .unwrap_or_else(|| {
+ panic!(
+ "expected AnimatedTextOverflow once the t=1.0s timeline step widens \
+ the box to 1900px: {:?}",
+ violations
+ )
+ });
+ assert_eq!(v.component, "shape");
+ assert_eq!(v.axis, Axis::X);
+ }
+
+ // ─── Round 4 audit, constat 9: --strict-anim must model rotation (and
+ // any other transform `apply_animated_props` bakes into `css.transform`),
+ // not just translate_x/y and scale_x/y ─────────────────────────────────
+
+ #[test]
+ fn strict_anim_detects_a_spin_animation_pushing_a_square_off_screen() {
+ // Same headline numbers as the static-transform regression
+ // `static_rotation_is_folded_into_the_viewport_check`: a 100×100
+ // square at (1810, 490) in a 1920×1080 viewport — resting box
+ // x=[1810,1910], 10px inside the right edge. `spin` animates
+ // `rotation` linearly 0deg->360deg over the 2s scene; ANY sampled
+ // angle away from a multiple of 90deg grows the AABB half-width
+ // beyond 50px * (|cos|+|sin|) > 50px, pushing the right edge past
+ // 1920 (e.g. at 20deg: half-width ~64.1px, right edge ~1924).
+ //
+ // The OLD `transform_bbox` only read `translate_x/y`/`scale_x/y`
+ // from `AnimatedProperties` — a pure-rotation preset leaves both at
+ // their identity values (0 and 1), so every sample folded to
+ // exactly the resting bbox and this never fired, at any sample,
+ // for the whole 2s sweep through 360 degrees.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 2.0,
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 1810, "y": 490,
+ "style": {
+ "width": "100px", "height": "100px",
+ "animation": [{ "name": "spin", "delay": 0, "duration": 2.0 }]
+ },
+ "fill": "#ff0000"
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry_animated(&scenario);
+ let v = violations
+ .iter()
+ .find(|v| v.kind == ViolationKind::AnimatedTextOverflow)
+ .unwrap_or_else(|| {
+ panic!(
+ "expected AnimatedTextOverflow from the spin preset rotating the \
+ square past the right edge at some sampled angle: {:?}",
+ violations
+ )
+ });
+ assert_eq!(v.component, "shape");
+ assert_eq!(v.axis, Axis::X);
+ }
+
#[test]
fn anim_sample_times_scale_with_scene_duration() {
let short = anim_sample_times(0.5);
@@ -2000,6 +2624,59 @@ mod tests {
);
}
+ #[test]
+ fn anim_sample_times_keeps_the_8_per_second_cadence_up_to_60s() {
+ // Round 4 audit, constat 8: with the old ANIM_MAX_SAMPLES=40 cap,
+ // the step between samples grew linearly past a 5s scene —
+ // 20s/39 ≈ 0.513s at 20s, 40s/39 ≈ 1.026s at 40s, 60s/39 ≈ 1.538s
+ // at 60s (the exact numbers the audit cited). With the raised cap
+ // (480), the step stays pinned near the promised 1/8s = 0.125s
+ // resolution across the same range.
+ for duration in [20.0, 40.0, 60.0] {
+ let samples = anim_sample_times(duration);
+ let step = duration / (samples.len() - 1) as f64;
+ assert!(
+ (step - 0.125).abs() < 0.001,
+ "duration={duration}s: expected ~0.125s step, got {step}s ({} samples)",
+ samples.len()
+ );
+ }
+ }
+
+ #[test]
+ #[ignore = "manual timing probe for constat 8's cap — not run in CI"]
+ fn timing_probe_for_constat_8() {
+ let mut children = String::new();
+ for i in 0..15 {
+ children.push_str(&format!(
+ r##"{{"type":"text","position":"absolute","x":{},"y":{},
+ "content":"item {}",
+ "style":{{"font-size":32,"color":"#ffffff",
+ "animation":[{{"name":"slide_in_left","delay":0.1,"duration":0.5}}]}}}},"##,
+ (i % 5) * 300,
+ (i / 5) * 200,
+ i
+ ));
+ }
+ children.pop(); // trailing comma
+ let json = format!(
+ r##"{{"video":{{"width":1920,"height":1080}},
+ "scenes":[{{"duration":60.0,"children":[{children}]}}]}}"##
+ );
+ let scenario = parse(&json);
+ let n = anim_sample_times(60.0).len();
+ let start = std::time::Instant::now();
+ let violations = validate_geometry_animated(&scenario);
+ let elapsed = start.elapsed();
+ eprintln!(
+ "timing_probe: {} samples, {:?} total, {:?}/sample, {} violations",
+ n,
+ elapsed,
+ elapsed / n.max(1) as u32,
+ violations.len()
+ );
+ }
+
// ─── H4 (second half): content larger than its own content box ───────────
//
// The first half of H4 (already fixed above) suppresses a *viewport*
@@ -2314,6 +2991,96 @@ mod tests {
assert_eq!(v.axis, Axis::X, "only the x-axis should overflow: {:?}", v);
}
+ // ─── Round 4 audit, constat 5: `transform-origin` must pivot the static
+ // transform fold, not always the box centre ─────────────────────────────
+
+ #[test]
+ fn transform_origin_right_edge_keeps_a_scaled_shape_inside_the_viewport() {
+ // 100×100 shape at (880, 450) in a 1000×1000 viewport: at rest,
+ // x=[880,980] — comfortably inside, 20px margin. `scale(x: 3)`
+ // pivoted at `transform-origin: { x: "right" }` (the box's own right
+ // edge, 100%) grows the box purely leftward from that fixed edge:
+ // left corner offset from pivot (980) is -100, ×3 = -300 -> new x =
+ // 680; right corner offset is 0 -> stays at 980. Correct AABB:
+ // x=[680,980], fully inside [0,1000] — this scenario is CORRECT.
+ //
+ // Before this fix, `apply_static_node_transform` always pivoted at
+ // the box centre (930): left corner offset -50×3=-150 -> x=780;
+ // right corner offset +50×3=+150 -> x=1080 — 80px past the 1000-wide
+ // frame, a false positive (captured in the red-phase run below).
+ let json = r##"{
+ "video": { "width": 1000, "height": 1000 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 880, "y": 450,
+ "style": {
+ "width": "100px", "height": "100px",
+ "transform": [{ "fn": "scale", "x": 3, "y": 1 }],
+ "transform-origin": { "x": "right" }
+ },
+ "fill": "#ff0000"
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+ assert!(
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::ViewportOverflow),
+ "transform-origin: right should pivot growth away from the right \
+ edge, keeping the shape inside the 1000px-wide viewport: {:?}",
+ violations
+ );
+ }
+
+ #[test]
+ fn transform_origin_50pct_is_identical_to_the_default_centre_pivot() {
+ // Sanity/regression guard: an *explicit* `transform-origin: 50% 50%`
+ // must fold to exactly the same AABB as no `transform-origin` at all
+ // — same fixture and expectation as
+ // `static_rotation_is_folded_into_the_viewport_check`.
+ let json = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 1810, "y": 490,
+ "style": {
+ "width": "100px", "height": "100px",
+ "transform": [{ "fn": "rotate", "deg": 45 }],
+ "transform-origin": { "x": "50%", "y": "50%" }
+ },
+ "fill": "#ff0000"
+ }]
+ }]
+ }"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+ let v = violations
+ .iter()
+ .find(|v| v.component == "shape" && v.kind == ViolationKind::ViewportOverflow)
+ .unwrap_or_else(|| {
+ panic!(
+ "explicit 50%/50% origin must behave like the default centre pivot: {:?}",
+ violations
+ )
+ });
+ assert_eq!(v.axis, Axis::X);
+ assert!(
+ (v.bbox.x + v.bbox.w - 1930.71).abs() < 1.0,
+ "right edge should land at the same ~1930.7 as the centre-pivot case: {:?}",
+ v
+ );
+ }
+
#[test]
fn unrotated_transform_folding_is_unchanged_by_the_corner_based_rewrite() {
// Regression guard for the H5 rewrite: a translate-only transform
@@ -2378,17 +3145,32 @@ mod tests {
assert_eq!(v.axis, Axis::Y);
}
- // ─── #128 item 2: component overflowing its containing card ──────────────
+ // ─── Round 4 audit, constat 7: `ContentOverflowsCard` (#128 item 2) is
+ // retired — a component escaping a non-clipping (`overflow: visible`,
+ // the default) card is the exact "badge sticking out of a card is
+ // legal" pattern CLAUDE.md / geometry-safety.md document as fine, so the
+ // validator must accept it, not report it. See the module doc comment's
+ // "Deliberately NOT in scope" note and `walk`'s retired call site for
+ // the full reasoning. These fixtures are the same ones that used to
+ // assert the (wrong) opposite — kept, with flipped assertions, as
+ // regression coverage across component types (text/codeblock/table/
+ // nested card/bleed) now that the check is gone. ─────────────────────
#[test]
- fn absolutely_positioned_text_spilling_past_its_card_is_flagged() {
- // The audit's headline repro: a text with no fixed height, inside a
- // card, grows to its natural (unclamped) size because it's taken
- // out of flex flow (`position: absolute`) — its OWN box already
- // matches its OWN content exactly (so `ContentOverflowsBox` must NOT
- // fire), yet that box spills well past the 80px-tall card it lives
- // in. Before #128 item 2, nothing ever compared a component to the
- // card containing it, so this validated clean.
+ fn absolutely_positioned_text_spilling_past_a_visible_card_is_legal() {
+ // The audit's original headline repro for #128 item 2: a text with
+ // no fixed height, inside a card, grows to its natural (unclamped)
+ // size because it's taken out of flex flow (`position: absolute`)
+ // — its OWN box already matches its OWN content exactly (so
+ // `ContentOverflowsBox` must NOT fire), and that box spills past
+ // the 80px-tall card it lives in. The card's `overflow` is
+ // `visible` (the documented default that permits exactly this) —
+ // per constat 7, `ContentOverflowsCard` must no longer fire here.
+ //
+ // Red-phase (before this fix): validate_geometry reported one
+ // ContentOverflowsCard violation for this fixture (component:
+ // "text", axis: Y, hint mentioning "extends past its containing
+ // card") — captured when this test asserted the opposite.
let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"},
"scenes":[{"duration":1.0,"children":[
{"type":"card","position":"absolute","x":330,"y":100,
@@ -2403,7 +3185,7 @@ mod tests {
violations
.iter()
.all(|v| v.kind != ViolationKind::ContentOverflowsBox),
- "the text's own box already matches its own (unclamped) content — must not also fire ContentOverflowsBox: {:?}",
+ "the text's own box already matches its own (unclamped) content — must not fire ContentOverflowsBox: {:?}",
violations
);
assert!(
@@ -2413,26 +3195,41 @@ mod tests {
"fixture should stay inside the 540px-tall frame by construction: {:?}",
violations
);
- let v = violations
- .iter()
- .find(|v| v.kind == ViolationKind::ContentOverflowsCard)
- .unwrap_or_else(|| {
- panic!(
- "expected ContentOverflowsCard for text spilling past its 80px card: {:?}",
- violations
- )
- });
- assert_eq!(v.component, "text");
- assert_eq!(
- v.axis,
- Axis::Y,
- "card is wide enough — only height should overflow: {:?}",
- v
+ assert!(
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::ContentOverflowsCard),
+ "text sticking out of a visible-overflow card is a legal, documented pattern: {:?}",
+ violations
);
+ }
+
+ /// The guarantee that makes retiring `ContentOverflowsCard` safe rather
+ /// than merely defensible: escaping a visible card is legal, but
+ /// escaping the *device* never is, and that is `check_viewport`'s job —
+ /// not the retired check's. Same fixture as
+ /// `absolutely_positioned_text_spilling_past_a_visible_card_is_legal`,
+ /// moved down the frame so the overspill leaves the viewport. If this
+ /// ever stops firing, the removal has opened a real blind spot.
+ #[test]
+ fn spilling_past_a_visible_card_is_still_caught_when_it_leaves_the_viewport() {
+ let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"},
+ "scenes":[{"duration":1.0,"children":[
+ {"type":"card","position":"absolute","x":330,"y":460,
+ "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"},
+ "children":[{"type":"text","position":"absolute","x":0,"y":0,
+ "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.",
+ "style":{"font-size":44,"color":"#ffffff","width":"300px"}}]}]}]}"##;
+ let scenario = parse(json);
+ let violations = validate_geometry(&scenario);
+
assert!(
- v.hint.contains("card"),
- "hint should mention the card: {}",
- v.hint
+ violations
+ .iter()
+ .any(|v| v.kind == ViolationKind::ViewportOverflow),
+ "content escaping a visible card AND the 540px frame must still be reported by \
+ check_viewport — retiring ContentOverflowsCard must not have removed this: {:?}",
+ violations
);
}
@@ -2483,16 +3280,16 @@ mod tests {
}
#[test]
- fn absolutely_positioned_codeblock_spilling_past_its_card_is_flagged() {
+ fn absolutely_positioned_codeblock_spilling_past_a_visible_card_is_legal() {
// #128 item 1's first repro ("a codeblock painting 578px inside a
// 300px card"): taken out of flex flow (`position: absolute`, like
// the analogous text/table tests above) so its own box is NOT
// shrunk to fit the card — it stays at its natural, unscrolled
// content height regardless of `auto_scroll`. `auto_scroll: true`
- // (the default) is used deliberately here: `check_auto_scroll` only
- // ever fires for `auto_scroll: false`, so this proves the
- // card-relative check is a genuinely independent, complementary
- // mechanism, not a duplicate of it.
+ // (the default) is used deliberately here so `check_auto_scroll`
+ // stays quiet too, isolating this from every other check: the card
+ // has default (`visible`) overflow, so per constat 7 this must
+ // validate clean.
let code_lines: String = (1..=30)
.map(|i| i.to_string())
.collect::>()
@@ -2533,16 +3330,13 @@ mod tests {
"fixture should stay inside the 1080px-tall frame by construction: {:?}",
violations
);
- let v = violations
- .iter()
- .find(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "codeblock")
- .unwrap_or_else(|| {
- panic!(
- "expected ContentOverflowsCard for a 30-line codeblock spilling past its 300px card: {:?}",
- violations
- )
- });
- assert_eq!(v.axis, Axis::Y);
+ assert!(
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::ContentOverflowsCard),
+ "codeblock sticking out of a visible-overflow card is a legal, documented pattern: {:?}",
+ violations
+ );
}
#[test]
@@ -2553,9 +3347,7 @@ mod tests {
// assigned box down to the card's 60px (same shrink-to-fit behaviour
// already established for `text`), so this surfaces via the
// generalized `ContentOverflowsBox` (own box too small for own
- // content) rather than the card check — see
- // `absolutely_positioned_table_spilling_past_its_card_is_flagged`
- // below for the complementary (unclamped, card-relative) case.
+ // content).
let rows: String = (1..=15)
.map(|i| format!(r#"["{i}a","{i}b","{i}c"]"#))
.collect::>()
@@ -2594,12 +3386,11 @@ mod tests {
}
#[test]
- fn absolutely_positioned_table_spilling_past_its_card_is_flagged() {
+ fn absolutely_positioned_table_spilling_past_a_visible_card_is_legal() {
// Same table, but taken out of flex flow (`position: absolute`) so
- // its own box isn't shrunk to fit the card — mirrors
- // `absolutely_positioned_text_spilling_past_its_card_is_flagged`,
- // isolating the card-relative check (item 2) from the own-box check
- // (item 1) exercised above.
+ // its own box isn't shrunk to fit the card — the card's `overflow`
+ // is `visible` (the default), so per constat 7 this must validate
+ // clean.
let rows: String = (1..=15)
.map(|i| format!(r#"["{i}a","{i}b","{i}c"]"#))
.collect::>()
@@ -2627,75 +3418,21 @@ mod tests {
);
let scenario = parse(&json);
let violations = validate_geometry(&scenario);
- let v = violations
- .iter()
- .find(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "table")
- .unwrap_or_else(|| {
- panic!(
- "expected ContentOverflowsCard for an absolutely positioned table spilling past its 60px card: {:?}",
- violations
- )
- });
- assert_eq!(v.axis, Axis::Y);
- }
-
- #[test]
- fn nested_non_card_container_still_compares_its_descendant_to_the_outer_card() {
- // A `container` (layout-only, no decoration per CLAUDE.md) sits
- // between the card and the codeblock. `container`/`flex`/`grid`/
- // `positioned` must NOT update `nearest_card` — only `Component::Card`
- // does — so the codeblock two levels down must still be compared
- // against the *outer* card, not silently un-checked.
- let code_lines: String = (1..=30)
- .map(|i| i.to_string())
- .collect::>()
- .join("\\n");
- let json = format!(
- r##"{{
- "video": {{ "width": 1920, "height": 1080 }},
- "scenes": [{{
- "duration": 1.0,
- "children": [{{
- "type": "card",
- "position": "absolute",
- "x": 100, "y": 100,
- "style": {{ "width": "600px", "height": "300px", "background": "#111111" }},
- "children": [{{
- "type": "container",
- "children": [{{
- "type": "codeblock",
- "position": "absolute",
- "x": 0, "y": 0,
- "code": "{code_lines}"
- }}]
- }}]
- }}]
- }}]
- }}"##
- );
- let scenario = parse(&json);
- let violations = validate_geometry(&scenario);
- let v = violations
- .iter()
- .find(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "codeblock")
- .unwrap_or_else(|| {
- panic!(
- "expected ContentOverflowsCard through the non-card `container` wrapper: {:?}",
- violations
- )
- });
assert!(
- v.path.contains("children[0].children[0].children[0]"),
- "path should reach through card -> container -> codeblock: {}",
- v.path
+ violations
+ .iter()
+ .all(|v| v.kind != ViolationKind::ContentOverflowsCard),
+ "table sticking out of a visible-overflow card is a legal, documented pattern: {:?}",
+ violations
);
}
#[test]
- fn nested_card_bigger_than_its_outer_card_is_flagged() {
- // A card nested inside another card, itself bigger than the outer
- // one it lives in — the inner card's own box (not just its
- // descendants') must be checked against the outer card too.
+ fn nested_card_bigger_than_its_visible_outer_card_is_legal() {
+ // A card nested inside another (default/`visible`-overflow) card,
+ // itself bigger than the outer one it lives in — per constat 7 this
+ // is the same "sticking out on purpose" pattern, now legal for any
+ // component type, cards included.
let json = r##"{
"video": { "width": 1920, "height": 1080 },
"scenes": [{
@@ -2718,19 +3455,23 @@ mod tests {
assert!(
violations
.iter()
- .any(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "card"),
- "the inner card's own box must be checked against the outer card: {:?}",
+ .all(|v| v.kind != ViolationKind::ContentOverflowsCard),
+ "an inner card bigger than its visible-overflow outer card is legal: {:?}",
violations
);
}
#[test]
- fn content_overflowing_its_card_is_suppressed_under_a_clipping_card() {
- // Same headline repro as `absolutely_positioned_text_spilling_past_its_card_is_flagged`,
- // but the card clips (`overflow: hidden`) — the text genuinely gets
- // clipped at paint time, so ContentOverflowsCard must not fire,
- // consistent with the `parent_clips` suppression already applied to
- // check_viewport/check_content_overflows_box.
+ fn content_overflowing_a_clipping_card_is_also_not_flagged() {
+ // Same headline repro, but the card clips (`overflow: hidden`) —
+ // the text genuinely gets clipped at paint time, so
+ // ContentOverflowsCard must not fire either, consistent with the
+ // `parent_clips` suppression already applied to
+ // check_viewport/check_content_overflows_box. Distinct from the
+ // `visible` fixtures above: this is the OTHER half of the "no
+ // configuration where it's both reachable and correct to fire"
+ // argument (constat 7) — a clipping card suppresses it for an
+ // unrelated reason (parent_clips), not because of the retirement.
let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"},
"scenes":[{"duration":1.0,"children":[
{"type":"card","position":"absolute","x":330,"y":100,
@@ -2749,31 +3490,6 @@ mod tests {
);
}
- #[test]
- fn bleed_true_does_not_exempt_content_overflows_card() {
- // Same headline repro, `bleed: true` added to the text. Per the
- // frozen `bleed` contract (see `bleeds`'s doc comment): a component
- // may leave the *frame* on purpose and still be responsible for its
- // own contents relative to its card — `bleed` must not exempt this
- // check any more than it exempts ContentOverflowsBox.
- let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"},
- "scenes":[{"duration":1.0,"children":[
- {"type":"card","position":"absolute","x":330,"y":100,
- "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"},
- "children":[{"type":"text","position":"absolute","x":0,"y":0,"bleed":true,
- "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.",
- "style":{"font-size":44,"color":"#ffffff","width":"300px"}}]}]}]}"##;
- let scenario = parse(json);
- let violations = validate_geometry(&scenario);
- assert!(
- violations
- .iter()
- .any(|v| v.kind == ViolationKind::ContentOverflowsCard && v.component == "text"),
- "bleed: true must NOT suppress ContentOverflowsCard: {:?}",
- violations
- );
- }
-
#[test]
fn component_that_fits_its_card_is_not_flagged() {
// Passing-case guard: same shape as the codeblock repro, but the
diff --git a/crates/rustmotion-cli/src/commands/still.rs b/crates/rustmotion-cli/src/commands/still.rs
index 8da5c13..df2f9ec 100644
--- a/crates/rustmotion-cli/src/commands/still.rs
+++ b/crates/rustmotion-cli/src/commands/still.rs
@@ -1,7 +1,51 @@
+use rustmotion::encode;
use rustmotion::engine;
use rustmotion::error::{Result, RustmotionError};
use rustmotion::schema::ResolvedScenario;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+
+/// A scratch path in the same directory as `output`, carrying the same
+/// extension so extension-sniffing encoders (the `image::save` fallback arm)
+/// still resolve the codec they would have resolved for `output` itself.
+///
+/// Constat #8: `File::create(output)` used to run *before* the encoder that
+/// could fail (JPEG always failed on the RGBA buffer), so every failure left
+/// a 0-byte file sitting at `output` — indistinguishable from a real, empty
+/// render to a downstream script. Encoding into this scratch path first and
+/// renaming onto `output` only on success means a failure never touches
+/// `output` at all: an old file there is left untouched, and no new
+/// truncated file appears.
+fn temp_sibling_path(output: &Path) -> PathBuf {
+ let ext = output.extension().and_then(|e| e.to_str());
+ let stem = output
+ .file_stem()
+ .and_then(|e| e.to_str())
+ .unwrap_or("still");
+ let name = match ext {
+ Some(ext) => format!(".{stem}.rustmotion-tmp.{ext}"),
+ None => format!(".{stem}.rustmotion-tmp"),
+ };
+ output.with_file_name(name)
+}
+
+/// Flatten RGBA onto an opaque background for encoders that cannot
+/// represent alpha (JPEG). Compositing onto `video.background` — the color
+/// the frame actually renders against — rather than dropping the alpha
+/// channel outright (which would implicitly composite onto black).
+fn flatten_to_rgb(img: &image::RgbaImage, bg: (u8, u8, u8)) -> Vec {
+ let (bg_r, bg_g, bg_b) = bg;
+ let mut rgb = Vec::with_capacity(img.as_raw().len() / 4 * 3);
+ for px in img.pixels() {
+ let [r, g, b, a] = px.0;
+ let a = a as u16;
+ let inv_a = 255 - a;
+ let blend = |fg: u8, bg: u8| -> u8 { ((fg as u16 * a + bg as u16 * inv_a) / 255) as u8 };
+ rgb.push(blend(r, bg_r));
+ rgb.push(blend(g, bg_g));
+ rgb.push(blend(b, bg_b));
+ }
+ rgb
+}
pub fn cmd_still(
scenario: ResolvedScenario,
@@ -18,70 +62,251 @@ pub fn cmd_still(
let config = &scenario.video;
let fps = config.fps;
- // Find which scene contains this time
- let all_scenes: Vec<_> = scenario.all_scenes().collect();
- let mut scene_start = 0.0f64;
- for (idx, scene) in all_scenes.iter().enumerate() {
- let scene_end = scene_start + scene.duration;
- if time < scene_end || idx == all_scenes.len() - 1 {
- let local_time = (time - scene_start).max(0.0);
- let frame_index = (local_time * fps as f64).round() as u32;
- let scene_frames = (scene.duration * fps as f64).round() as u32;
-
- let rgba = engine::render::render_scene_frame(
- config,
- scene,
- frame_index.min(scene_frames.saturating_sub(1)),
- scene_frames,
- )?;
-
- // Create parent directories
- if let Some(parent) = output.parent() {
- if !parent.as_os_str().is_empty() {
- std::fs::create_dir_all(parent)?;
- }
- }
+ // Constat #4: pick the frame the same way the encoder does. Summing
+ // scene durations linearly (the previous approach) ignores that
+ // `build_frame_tasks` truncates the entering scene's tail to make room
+ // for a transition's overlap — so `--time` landed on a frame that never
+ // appears, composited or otherwise, in the rendered video. Reusing
+ // `build_frame_tasks` + `render_frame_task_scaled` also picks up
+ // `apply_post_effects` (vignette, grain, ...) for free, which the old
+ // per-scene walk never applied at all.
+ let tasks = encode::build_frame_tasks(&scenario);
+ let total = tasks.len() as u32;
+ if total == 0 {
+ return Err(RustmotionError::NoFrames);
+ }
+
+ // Preserve the previous command's tolerant behavior: negative time
+ // clamps to frame 0, time beyond the video's duration clamps to the
+ // last frame, instead of erroring.
+ let raw_index = (time.max(0.0) * fps as f64).round();
+ let frame_index = if raw_index.is_finite() {
+ (raw_index as i64).clamp(0, total as i64 - 1) as u32
+ } else {
+ 0
+ };
+
+ let task = &tasks[frame_index as usize];
+ let rgba = encode::render_frame_task_scaled(config, &scenario, task, 1.0)?;
+
+ // Create parent directories
+ if let Some(parent) = output.parent() {
+ if !parent.as_os_str().is_empty() {
+ std::fs::create_dir_all(parent)?;
+ }
+ }
- let img = image::RgbaImage::from_raw(config.width, config.height, rgba)
- .ok_or(RustmotionError::PixelImage)?;
-
- let fmt = format
- .as_deref()
- .unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("png"));
-
- match fmt {
- "jpeg" | "jpg" => {
- use image::ImageEncoder;
- let file = std::fs::File::create(output)?;
- let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality);
- encoder.write_image(
- img.as_raw(),
- config.width,
- config.height,
- image::ExtendedColorType::Rgba8,
- )?;
- }
- "webp" => {
- use image::ImageEncoder;
- let file = std::fs::File::create(output)?;
- let encoder = image::codecs::webp::WebPEncoder::new_lossless(file);
- encoder.write_image(
- img.as_raw(),
- config.width,
- config.height,
- image::ExtendedColorType::Rgba8,
- )?;
- }
- _ => {
- img.save(output)?;
- }
+ let img = image::RgbaImage::from_raw(config.width, config.height, rgba)
+ .ok_or(RustmotionError::PixelImage)?;
+
+ let fmt = format
+ .as_deref()
+ .unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("png"));
+
+ let tmp_path = temp_sibling_path(output);
+ let encode_result: std::result::Result<(), RustmotionError> = (|| {
+ match fmt {
+ "jpeg" | "jpg" => {
+ use image::ImageEncoder;
+ let (bg_r, bg_g, bg_b, _) = engine::parse_hex_color(&config.background);
+ let rgb = flatten_to_rgb(&img, (bg_r, bg_g, bg_b));
+ let file = std::fs::File::create(&tmp_path)?;
+ let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality);
+ encoder.write_image(
+ &rgb,
+ config.width,
+ config.height,
+ image::ExtendedColorType::Rgb8,
+ )?;
+ }
+ "webp" => {
+ use image::ImageEncoder;
+ let file = std::fs::File::create(&tmp_path)?;
+ let encoder = image::codecs::webp::WebPEncoder::new_lossless(file);
+ encoder.write_image(
+ img.as_raw(),
+ config.width,
+ config.height,
+ image::ExtendedColorType::Rgba8,
+ )?;
}
+ _ => {
+ img.save(&tmp_path)?;
+ }
+ }
+ Ok(())
+ })();
- eprintln!("Still image saved to {}", output.display());
- return Ok(());
+ match encode_result {
+ Ok(()) => {
+ std::fs::rename(&tmp_path, output)?;
+ }
+ Err(e) => {
+ let _ = std::fs::remove_file(&tmp_path);
+ return Err(e);
}
- scene_start = scene_end;
}
- Err(RustmotionError::TimeOutOfRange { time })
+ eprintln!("Still image saved to {}", output.display());
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rustmotion::loader::load_scenario_from_source;
+
+ fn solid_rect_scenario(width: u32, height: u32, fps: u32, duration: f64, hex: &str) -> String {
+ format!(
+ r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}},
+ "scenes": [{{"duration": {duration}, "children": [
+ {{"type": "shape", "shape": "rect", "fill": "{hex}",
+ "position": "absolute", "x": 0, "y": 0,
+ "style": {{"width": {width}, "height": {height}}}}}
+ ]}}]}}"##
+ )
+ }
+
+ fn minimal_scenario(width: u32, height: u32, fps: u32, duration: f64) -> ResolvedScenario {
+ let json = solid_rect_scenario(width, height, fps, duration, "#ff0000");
+ load_scenario_from_source(None, Some(&json)).expect("load")
+ }
+
+ /// `name` (e.g. "still.jpg") must stay the *last* path component so its
+ /// extension survives — putting the uniqueness suffix after it (as an
+ /// earlier version of this helper did) turned "still.jpg" into
+ /// "still.jpg_1234_5678", whose "extension" per `Path::extension()`
+ /// becomes "jpg_1234_5678": unrecognized by every format-sniffing
+ /// encoder, so every test using it failed on a spurious
+ /// `Unsupported(PathExtension(..))` instead of exercising the code
+ /// under test at all.
+ fn scratch_path(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!(
+ "rm_still_test_{}_{}_{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos(),
+ name
+ ))
+ }
+
+ /// Constat #8: JPEG stills used to fail unconditionally (the `image`
+ /// crate's JPEG encoder rejects `Rgba8`) and leave a 0-byte file behind.
+ #[test]
+ fn still_jpeg_encodes_successfully_and_writes_a_nonempty_valid_file() {
+ let scenario = minimal_scenario(16, 16, 10, 1.0);
+ let out = scratch_path("jpeg_ok.jpg");
+ let _ = std::fs::remove_file(&out);
+
+ cmd_still(scenario, &out, 0.0, None, 90).expect("jpeg still must succeed");
+
+ let meta = std::fs::metadata(&out).expect("output file must exist");
+ assert!(meta.len() > 0, "jpeg output must not be empty");
+ let img = image::open(&out).expect("must decode as a valid image");
+ assert_eq!(img.width(), 16);
+ assert_eq!(img.height(), 16);
+
+ let _ = std::fs::remove_file(&out);
+ }
+
+ /// Constat #8 (aggravation noted by the verification pass): `--format
+ /// jpeg` must succeed regardless of the output path's own extension.
+ #[test]
+ fn still_format_flag_forces_jpeg_even_with_a_png_extension() {
+ let scenario = minimal_scenario(16, 16, 10, 1.0);
+ let out = scratch_path("forced.png");
+ let _ = std::fs::remove_file(&out);
+
+ cmd_still(scenario, &out, 0.0, Some("jpeg".to_string()), 90)
+ .expect("forced jpeg still must succeed");
+
+ let meta = std::fs::metadata(&out).expect("output file must exist");
+ assert!(meta.len() > 0, "forced jpeg output must not be empty");
+
+ let _ = std::fs::remove_file(&out);
+ }
+
+ /// `temp_sibling_path` is the mechanism constat #8's "never leave a
+ /// truncated file" fix relies on: encode into a scratch path first,
+ /// rename onto `output` only on success. Lock in its naming contract —
+ /// distinct from `output`, same directory (so the later rename is a
+ /// same-filesystem, near-atomic op), extension preserved so
+ /// extension-sniffing encoders still resolve the right codec.
+ #[test]
+ fn temp_sibling_path_is_distinct_same_directory_and_keeps_the_extension() {
+ let output = PathBuf::from("/some/dir/still.jpg");
+ let tmp = temp_sibling_path(&output);
+
+ assert_ne!(
+ tmp, output,
+ "scratch path must not collide with the final output path"
+ );
+ assert_eq!(
+ tmp.parent(),
+ output.parent(),
+ "scratch path must live in the same directory as output (same filesystem for rename)"
+ );
+ assert_eq!(
+ tmp.extension().and_then(|e| e.to_str()),
+ Some("jpg"),
+ "scratch path must keep output's extension for format-sniffing encoders"
+ );
+ }
+
+ /// Constat #4: `still --time` must match the frame the encoder actually
+ /// emits at that timestamp, not a linear per-scene walk that ignores
+ /// transition overlap. Scene A (2s) + scene B (2s, incoming 1s fade):
+ /// the rendered stream truncates scene A's tail by 1s, so `--time 2.5`
+ /// must resolve to the composited transition frame at global index
+ /// round(2.5 * fps), the same index `build_frame_tasks` would hand the
+ /// encoder — not scene B's raw, uncomposited frame at local time 0.5s.
+ #[test]
+ fn still_time_matches_the_encoders_frame_stream_across_a_transition() {
+ let fps = 30u32;
+ let json = format!(
+ r##"{{
+ "video": {{"width": 8, "height": 8, "fps": {fps}}},
+ "scenes": [
+ {{"duration": 2.0, "children": [
+ {{"type": "shape", "shape": "rect", "fill": "#ff0000",
+ "position": "absolute", "x": 0, "y": 0, "style": {{"width": 8, "height": 8}}}}
+ ]}},
+ {{"duration": 2.0, "transition": {{"type": "fade", "duration": 1.0}}, "children": [
+ {{"type": "shape", "shape": "rect", "fill": "#0000ff",
+ "position": "absolute", "x": 0, "y": 0, "style": {{"width": 8, "height": 8}}}}
+ ]}}
+ ]
+ }}"##
+ );
+ // `ResolvedScenario` isn't `Clone`, and `cmd_still` takes it by
+ // value — load it twice from the same JSON instead.
+ let scenario = load_scenario_from_source(None, Some(&json)).expect("load");
+ let scenario_for_expected = load_scenario_from_source(None, Some(&json)).expect("load");
+
+ let out = scratch_path("transition.png");
+ let _ = std::fs::remove_file(&out);
+ cmd_still(scenario, &out, 2.5, None, 90).expect("still must succeed");
+ let still_img = image::open(&out).expect("decode still").to_rgba8();
+
+ let tasks = encode::build_frame_tasks(&scenario_for_expected);
+ let frame_index = (2.5_f64 * fps as f64).round() as usize;
+ let expected_rgba = encode::render_frame_task_scaled(
+ &scenario_for_expected.video,
+ &scenario_for_expected,
+ &tasks[frame_index],
+ 1.0,
+ )
+ .expect("render expected frame");
+ let expected_img = image::RgbaImage::from_raw(8, 8, expected_rgba).unwrap();
+
+ assert_eq!(
+ still_img.as_raw(),
+ expected_img.as_raw(),
+ "still --time must match the encoder's frame stream, not a linear scene-boundary walk"
+ );
+
+ let _ = std::fs::remove_file(&out);
+ }
}
diff --git a/crates/rustmotion-cli/src/commands/validate.rs b/crates/rustmotion-cli/src/commands/validate.rs
index a5f65f6..28d0abe 100644
--- a/crates/rustmotion-cli/src/commands/validate.rs
+++ b/crates/rustmotion-cli/src/commands/validate.rs
@@ -27,6 +27,67 @@ fn announced_duration(scenario: &ResolvedScenario) -> f64 {
rustmotion::encode::build_frame_tasks(scenario).len() as f64 / fps
}
+/// Why `--fix` must not write over this input.
+///
+/// `--fix` serialises `LoadedScenario::raw`, which is the document *after*
+/// variable substitution and `include` resolution — not the document on disk. For
+/// a plain JSON scenario the two coincide and writing back is faithful. For
+/// anything templated they do not, and the write silently replaces the source
+/// with its own expansion: the `config` block and every `$var` disappear, includes
+/// get inlined into the parent, and an HTML input is replaced by JSON outright.
+///
+/// One rule covers all three: only write back a source `--fix` can reproduce.
+#[derive(Debug, PartialEq, Eq)]
+enum FixRefusal {
+ HtmlSource,
+ Templated,
+ UsesInclude,
+}
+
+impl FixRefusal {
+ fn explain(&self, path: &Path) -> String {
+ let p = path.display();
+ match self {
+ Self::HtmlSource => format!(
+ "--fix cannot rewrite {p}: it is an HTML source, and the fixer only knows how to \
+ emit JSON — applying it would replace your markup with the transpiled scenario. \
+ Apply the fix to the HTML by hand, or transpile first and fix the JSON."
+ ),
+ Self::Templated => format!(
+ "--fix cannot rewrite {p}: it declares `config` or uses `$variables`, and the \
+ fixer would write back the substituted scenario — dropping the template and \
+ making `--var` a silent no-op. Fix the template by hand."
+ ),
+ Self::UsesInclude => format!(
+ "--fix cannot rewrite {p}: it uses `include`, and the fixer would write back the \
+ resolved tree — inlining the included files into the parent and patching by a \
+ path that no longer means the same node. Fix the included file directly."
+ ),
+ }
+ }
+}
+
+/// `None` when `--fix` may write over `input`.
+fn refuse_fix(input: &Path, raw_source: &str) -> Option {
+ if rustmotion::loader::is_html_path(input) {
+ return Some(FixRefusal::HtmlSource);
+ }
+ // Inspect the bytes on disk, not the loaded tree: by then substitution has
+ // already erased the very markers that make the write unfaithful.
+ let source: serde_json::Value = match serde_json::from_str(raw_source) {
+ Ok(v) => v,
+ // Unparseable source is not something we should be overwriting either.
+ Err(_) => return Some(FixRefusal::Templated),
+ };
+ if source.get("config").is_some() || raw_source.contains("$") {
+ return Some(FixRefusal::Templated);
+ }
+ if raw_source.contains("\"include\"") {
+ return Some(FixRefusal::UsesInclude);
+ }
+ None
+}
+
pub fn cmd_validate(
input: &PathBuf,
report: Option<&Path>,
@@ -57,6 +118,10 @@ pub fn cmd_validate(
let mut applied_fixes = 0usize;
if fix && !report_out.geom_violations.is_empty() {
+ let raw_source = std::fs::read_to_string(input).unwrap_or_default();
+ if let Some(refusal) = refuse_fix(input, &raw_source) {
+ return Err(RustmotionError::Generic(refusal.explain(input)));
+ }
let mut json_value = loaded.raw.clone();
applied_fixes = apply_fixes(&mut json_value, &report_out.geom_violations);
if applied_fixes > 0 {
@@ -431,4 +496,212 @@ mod tests {
"expected 3.0s with no transitions, got {duration}"
);
}
+
+ /// `--fix` writes back the *resolved* tree. Anything the resolution erased is
+ /// erased on disk too, so these three inputs must be refused rather than
+ /// silently rewritten.
+ mod fix_refusals {
+ use super::super::{refuse_fix, FixRefusal};
+ use std::path::Path;
+
+ const PLAIN: &str = r#"{"video":{"width":320,"height":240,"fps":30},
+ "scenes":[{"duration":1.0,"children":[]}]}"#;
+
+ #[test]
+ fn a_plain_json_scenario_is_writable() {
+ assert_eq!(refuse_fix(Path::new("s.json"), PLAIN), None);
+ }
+
+ #[test]
+ fn an_html_source_is_refused() {
+ // Writing here replaces the author's markup with transpiled JSON.
+ assert_eq!(
+ refuse_fix(Path::new("s.html"), ""),
+ Some(FixRefusal::HtmlSource)
+ );
+ }
+
+ #[test]
+ fn a_templated_scenario_is_refused() {
+ // The write would bake in the substitution and make --var a no-op.
+ let with_config = r#"{"config":{"title":"hi"},"video":{"width":320,"height":240,
+ "fps":30},"scenes":[{"duration":1.0,"children":[]}]}"#;
+ assert_eq!(
+ refuse_fix(Path::new("s.json"), with_config),
+ Some(FixRefusal::Templated)
+ );
+
+ let with_var = r#"{"video":{"width":320,"height":240,"fps":30},
+ "scenes":[{"duration":1.0,"children":[
+ {"type":"text","content":"$title"}]}]}"#;
+ assert_eq!(
+ refuse_fix(Path::new("s.json"), with_var),
+ Some(FixRefusal::Templated)
+ );
+ }
+
+ #[test]
+ fn a_scenario_using_include_is_refused() {
+ // The resolved tree inlines the include, so a path-based patch lands on
+ // a node the source file does not contain.
+ let with_include = r#"{"video":{"width":320,"height":240,"fps":30},
+ "scenes":[{"include":"part.json"}]}"#;
+ assert_eq!(
+ refuse_fix(Path::new("s.json"), with_include),
+ Some(FixRefusal::UsesInclude)
+ );
+ }
+
+ #[test]
+ fn every_refusal_names_the_file_and_says_what_to_do_instead() {
+ let p = Path::new("scenes/hero.json");
+ for r in [
+ FixRefusal::HtmlSource,
+ FixRefusal::Templated,
+ FixRefusal::UsesInclude,
+ ] {
+ let msg = r.explain(p);
+ assert!(msg.contains("scenes/hero.json"), "{msg}");
+ assert!(msg.contains("by hand") || msg.contains("directly"), "{msg}");
+ }
+ }
+ }
+
+ /// Round 4 audit, constat 1: PR #145 introduced `refuse_fix`, gated on
+ /// the raw bytes on disk (not `loaded.raw`), and `apply_fixes`/`navigate`
+ /// already walk raw-preserving indices (H3, see
+ /// `geometry.rs::deserialize_children_indexed`'s doc comment). This
+ /// workstream's job is not to redo that fix — it's to prove, end to
+ /// end through `cmd_validate` (not just unit-testing `refuse_fix` in
+ /// isolation, as `fix_refusals` above does), that the refusal actually
+ /// engages for the two concrete failure modes constat 1 names:
+ /// - validate.rs:60 — `--fix` would otherwise serialise the
+ /// *post-substitution* document, dropping `config` and baking in
+ /// `$var` resolutions, silently destroying the template.
+ /// - validate.rs:198 — a violation path carries the *resolved* scene
+ /// index (post `include::resolve_entries` inlining), which does not
+ /// line up with the RAW `scenes` array `navigate` walks as soon as an
+ /// `include` expands to a scene count that shifts later positions.
+ ///
+ /// Both are already covered by the existing `refuse_fix` gate (a
+ /// `Templated`/`UsesInclude` scenario is refused outright, before
+ /// `apply_fixes` ever runs) — these two tests are the proof, not a new
+ /// fix. No RED phase: this constat is "verify existing behaviour", not
+ /// "here is a bug"; both tests pass on first run.
+ mod fix_refusals_end_to_end {
+ use super::super::cmd_validate;
+
+ #[test]
+ fn cmd_validate_fix_refuses_to_overwrite_a_templated_scenario_and_leaves_the_file_untouched(
+ ) {
+ let path = std::env::temp_dir().join(format!(
+ "rm_validate_fix_templated_{}.json",
+ std::process::id()
+ ));
+ // `config` + a whole-string `$title` reference, plus a real
+ // geometry violation (nowrap text far too wide for its card) so
+ // `--fix` actually attempts to write.
+ let original = r##"{
+ "config": { "title": { "type": "string", "default": "hi" } },
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{
+ "duration": 1.0,
+ "children": [{
+ "type": "card",
+ "x": 100, "y": 100,
+ "style": { "width": "200px", "height": "200px", "background": "#222244" },
+ "children": [{
+ "type": "text",
+ "content": "$title but also this string is too long to fit",
+ "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" }
+ }]
+ }]
+ }]
+ }"##;
+ std::fs::write(&path, original).expect("write fixture");
+
+ let result = cmd_validate(&path, None, /*fix=*/ true, false, false, false, None);
+
+ let after = std::fs::read_to_string(&path).expect("read back fixture");
+ std::fs::remove_file(&path).ok();
+
+ assert!(
+ result.is_err(),
+ "--fix on a templated scenario with a real violation must be refused, \
+ not silently applied"
+ );
+ assert_eq!(
+ after, original,
+ "the file must be byte-identical after a refused --fix — writing \
+ loaded.raw here would have dropped `config` and baked in the \
+ substituted $title"
+ );
+ }
+
+ #[test]
+ fn cmd_validate_fix_refuses_to_overwrite_a_scenario_using_include_and_leaves_files_untouched(
+ ) {
+ let dir = std::env::temp_dir()
+ .join(format!("rm_validate_fix_include_{}", std::process::id()));
+ std::fs::create_dir_all(&dir).expect("mkdir");
+ let part_path = dir.join("part.json");
+ let parent_path = dir.join("parent.json");
+
+ // The included file resolves to TWO scenes; the offending
+ // narrow-card/nowrap-text violation lives in the SECOND one, so
+ // its *resolved* scene index (1) does not correspond to any
+ // scene in the parent's own RAW `scenes` array (which has a
+ // single entry: the include directive) — the concrete index
+ // skew constat 1 names.
+ let part = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [
+ { "duration": 1.0, "children": [] },
+ {
+ "duration": 1.0,
+ "children": [{
+ "type": "card",
+ "x": 100, "y": 100,
+ "style": { "width": "200px", "height": "200px", "background": "#222244" },
+ "children": [{
+ "type": "text",
+ "content": "this string is too long to fit",
+ "style": { "color": "#ffffff", "font-size": "96px", "white-space": "nowrap" }
+ }]
+ }]
+ }
+ ]
+ }"##;
+ let parent = r##"{
+ "video": { "width": 1920, "height": 1080 },
+ "scenes": [{ "include": "part.json" }]
+ }"##;
+ std::fs::write(&part_path, part).expect("write part fixture");
+ std::fs::write(&parent_path, parent).expect("write parent fixture");
+
+ let result = cmd_validate(
+ &parent_path,
+ None,
+ /*fix=*/ true,
+ false,
+ false,
+ false,
+ None,
+ );
+
+ let parent_after = std::fs::read_to_string(&parent_path).expect("read back parent");
+ let part_after = std::fs::read_to_string(&part_path).expect("read back part");
+ std::fs::remove_dir_all(&dir).ok();
+
+ assert!(
+ result.is_err(),
+ "--fix on an include-using scenario with a real violation must be refused"
+ );
+ assert_eq!(
+ parent_after, parent,
+ "parent file must be byte-identical after a refused --fix"
+ );
+ assert_eq!(part_after, part, "included file must be untouched too");
+ }
+ }
}
diff --git a/crates/rustmotion-cli/src/commands/validate_attrs.rs b/crates/rustmotion-cli/src/commands/validate_attrs.rs
index 16d5d26..ce85c47 100644
--- a/crates/rustmotion-cli/src/commands/validate_attrs.rs
+++ b/crates/rustmotion-cli/src/commands/validate_attrs.rs
@@ -351,4 +351,65 @@ mod tests {
assert_eq!(errors.len(), 1, "expected one error: {errors:?}");
assert!(errors[0].contains("counter"), "got: {}", errors[0]);
}
+
+ #[test]
+ fn typo_inside_style_animation_effect_is_reported() {
+ // Constat #8: `walk_component` only compares a component's own
+ // top-level keys, then recurses into `children` — it never looks
+ // inside `style`, let alone `style.animation[*]`. A typo'd field on
+ // an animation effect (`duratoin` instead of `duration`) used to
+ // deserialize silently (the effect config structs had no
+ // `deny_unknown_fields`), so the author got a scenario that "worked"
+ // but quietly ran the default 0.8s duration instead of theirs.
+ //
+ // The fix lives in `schema/video.rs` (adding `deny_unknown_fields` to
+ // every `AnimationEffect` payload struct) rather than here: an
+ // internally-tagged enum's tag field is excluded from what the
+ // variant's own `Deserialize` sees, so this rejects the typo without
+ // ever flagging the legitimate `name` tag as unknown. That routes the
+ // typo through the *existing* typed-parse-failure path in
+ // `check_component_attrs` (the same one that already catches, e.g.,
+ // a missing required field) — it surfaces as a blocking error, not a
+ // `walk_component` warning.
+ let s = resolved(serde_json::json!([
+ {
+ "type": "text", "content": "hi",
+ "style": { "animation": [{ "name": "fade_in_up", "duratoin": 0.6 }] }
+ }
+ ]));
+ let (errors, _) = check_component_attrs(&s);
+ assert!(
+ errors.iter().any(|e| e.contains("duratoin")),
+ "expected the typo'd animation-effect field to be reported as an error: {errors:?}"
+ );
+ }
+
+ #[test]
+ fn well_formed_animation_effect_fields_are_not_flagged() {
+ // Sanity companion to the typo test: legitimate fields across a
+ // spread of effect kinds (preset timing, keyframes, wiggle, glow,
+ // motion_blur, tilt_in) must not trip the new deny_unknown_fields.
+ let s = resolved(serde_json::json!([
+ {
+ "type": "text", "content": "hi",
+ "style": { "animation": [
+ { "name": "fade_in_up", "delay": 0.2, "duration": 0.6, "loop": false,
+ "overshoot": 0.1, "spring": { "damping": 12, "stiffness": 100, "mass": 1 } },
+ { "name": "float_3d", "duration": 1.0, "amplitude": 20 },
+ { "name": "tilt_in", "delay": 0.0, "duration": 0.4, "rotate_x": 10.0 },
+ { "name": "wiggle", "property": "translate_y", "amplitude": 5, "frequency": 2, "seed": 3 },
+ { "name": "glow", "color": "#ffffff", "radius": 10, "intensity": 1.0 },
+ { "name": "motion_blur", "samples": 4, "shutter": 0.5 },
+ { "name": "keyframes", "keyframes": [
+ { "property": "opacity", "keyframes": [
+ { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }
+ ] }
+ ], "delay": 0.0, "duration": 1.0, "loop": true }
+ ] }
+ }
+ ]));
+ let (errors, warnings) = check_component_attrs(&s);
+ assert!(errors.is_empty(), "unexpected errors: {errors:?}");
+ assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
+ }
}
diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs
index 5b116b1..6b30b5b 100644
--- a/crates/rustmotion-cli/src/commands/validate_schema.rs
+++ b/crates/rustmotion-cli/src/commands/validate_schema.rs
@@ -5,7 +5,7 @@ use rustmotion::components::{ChildComponent, Component};
use rustmotion::core::css::style::{
Background, BackgroundLayer, Color, CssStyle, Display as CssDisplay,
};
-use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario};
+use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario, SpringConfig};
pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec) {
let mut errors = Vec::new();
@@ -123,27 +123,52 @@ fn validate_children(
}
// Animation completion budget check: ensure entrance animations finish within the scene.
+ //
+ // Constat #4: `start_at` is a *visibility* window, not a time
+ // origin — the engine resolves `animation.delay`/`duration` in
+ // absolute scene time regardless of `start_at` (PR #27's frozen
+ // semantics; `geometry.rs`'s `walk_anim` already states and relies
+ // on the same rule). The budget used to add `start_at` in here,
+ // which contradicts that: a scenario where the entrance genuinely
+ // finishes well inside the scene (just before the node becomes
+ // visible, so it appears already-settled) was rejected as if the
+ // animation ran late.
if let Some(anim) = child.component.as_animatable() {
- let start_at = child
- .component
- .as_timed()
- .and_then(|t| t.timing().0)
- .unwrap_or(0.0);
-
for effect in anim.animation_effects() {
if let Some((delay, duration)) = entrance_budget(effect) {
- let finishes_at = start_at + delay + duration;
+ let finishes_at = delay + duration;
// 50ms tolerance for floating-point edge cases.
if finishes_at > scene_duration + 0.05 {
let suggested = ((finishes_at + 0.5) * 10.0).ceil() / 10.0;
errors.push(format!(
- "{}: animation finishes at {:.2}s (start_at {:.2} + delay {:.2} + duration {:.2}) \
+ "{}: animation finishes at {:.2}s (delay {:.2} + duration {:.2}) \
but scene_duration is {:.2}s — it will be truncated. \
Increase scene duration to at least {:.1}s or reduce animation delay/duration.",
- p, finishes_at, start_at, delay, duration, scene_duration, suggested
+ p, finishes_at, delay, duration, scene_duration, suggested
));
}
}
+
+ // Constat #6: `SpringConfig` accepts any f64 unchecked — a
+ // preset's own `spring` override (`AnimationTiming::spring`,
+ // reachable via `as_preset()`) or a `keyframes` effect's
+ // per-`Animation` `spring` (used when that segment's easing
+ // is `spring`) both feed `engine::animator::spring_value`,
+ // where `mass <= 0`/`stiffness <= 0` produce NaN and negative
+ // `damping` diverges. Reject both regimes here so a bad
+ // config never reaches the solver.
+ if let Some((_, timing)) = effect.as_preset() {
+ if let Some(spring) = &timing.spring {
+ check_spring_config(spring, &p, errors);
+ }
+ }
+ if let AnimationEffect::Keyframes(k) = effect {
+ for kf_anim in &k.keyframes {
+ if let Some(spring) = &kf_anim.spring {
+ check_spring_config(spring, &p, errors);
+ }
+ }
+ }
}
}
@@ -347,6 +372,36 @@ fn check_color_str(s: &str, label: &str, path: &str, errors: &mut Vec) {
}
}
+/// Constat #6: reject `SpringConfig` values that would make
+/// `engine::animator::spring_value` produce NaN (`mass <= 0`, `stiffness <=
+/// 0`) or diverge instead of settle (`damping < 0`). The solver itself also
+/// floors these defensively (belt and suspenders — see `spring_value`'s doc
+/// comment), but catching it here gives the author an actionable error
+/// instead of a silently broken render.
+fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec) {
+ if spring.mass <= 0.0 {
+ errors.push(format!(
+ "{path}: spring.mass must be > 0 (got {}) — zero or negative mass makes the spring \
+ solver divide by zero and produce NaN",
+ spring.mass
+ ));
+ }
+ if spring.stiffness <= 0.0 {
+ errors.push(format!(
+ "{path}: spring.stiffness must be > 0 (got {}) — zero or negative stiffness makes \
+ the spring solver produce NaN",
+ spring.stiffness
+ ));
+ }
+ if spring.damping < 0.0 {
+ errors.push(format!(
+ "{path}: spring.damping must be >= 0 (got {}) — negative damping makes the spring \
+ diverge instead of settle",
+ spring.damping
+ ));
+ }
+}
+
/// The `time_scale` declared on a container component, if any.
fn container_time_scale(component: &Component) -> Option {
match component {
@@ -554,6 +609,113 @@ mod style_warning_tests {
);
}
+ #[test]
+ fn negative_spring_damping_is_an_error() {
+ // Constat #6: damping < 0 makes the spring solver diverge instead of
+ // settle (a `SpringConfig` accepts any f64 — nothing in
+ // `rustmotion-cli` checked `damping`/`stiffness` before this).
+ let child: ChildComponent = serde_json::from_value(serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": {
+ "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": -5, "stiffness": 100, "mass": 1 } }]
+ }
+ }))
+ .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("damping")),
+ "missing spring damping error: {errors:?}"
+ );
+ }
+
+ #[test]
+ fn zero_spring_stiffness_is_an_error() {
+ // stiffness <= 0 makes `spring_value`'s omega = sqrt(stiffness/mass) NaN.
+ let child: ChildComponent = serde_json::from_value(serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": {
+ "animation": [{ "name": "bounce_in", "duration": 0.6, "spring": { "damping": 10, "stiffness": 0, "mass": 1 } }]
+ }
+ }))
+ .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("stiffness")),
+ "missing spring stiffness error: {errors:?}"
+ );
+ }
+
+ #[test]
+ fn zero_spring_mass_is_an_error() {
+ // mass <= 0 makes omega = sqrt(stiffness/mass) divide by zero -> NaN.
+ let child: ChildComponent = serde_json::from_value(serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": {
+ "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 10, "stiffness": 100, "mass": 0 } }]
+ }
+ }))
+ .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("mass")),
+ "missing spring mass error: {errors:?}"
+ );
+ }
+
+ #[test]
+ fn spring_inside_a_keyframes_effect_is_also_checked() {
+ // Springs aren't only on presets: a `keyframes` effect's per-Animation
+ // `spring` field feeds the exact same solver.
+ let child: ChildComponent = serde_json::from_value(serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": {
+ "animation": [{
+ "name": "keyframes",
+ "keyframes": [{
+ "property": "scale",
+ "easing": "spring",
+ "spring": { "damping": -1, "stiffness": 100, "mass": 1 },
+ "keyframes": [{ "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }]
+ }]
+ }]
+ }
+ }))
+ .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("damping")),
+ "missing spring damping error inside a keyframes effect: {errors:?}"
+ );
+ }
+
+ #[test]
+ fn positive_spring_values_are_accepted() {
+ let child: ChildComponent = serde_json::from_value(serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": {
+ "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1 } }]
+ }
+ }))
+ .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:?}");
+ }
+
#[test]
fn positive_time_scale_is_accepted() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
@@ -568,6 +730,37 @@ mod style_warning_tests {
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
}
+
+ #[test]
+ fn completion_budget_does_not_add_start_at_to_delay_plus_duration() {
+ // Constat #4: `start_at` gates *visibility* only (PR #27) — the
+ // engine resolves `animation.delay`/`duration` in absolute scene
+ // time regardless of `start_at`, so an entrance that finishes at
+ // delay+duration=1.0s in a 2.0s scene is fine even if the node isn't
+ // visible until start_at=1.5s (it simply appears already-settled).
+ // The old formula added them (`start_at + delay + duration` =
+ // 1.5+0+1.0 = 2.5 > 2.0), rejecting this valid scenario.
+ let child: ChildComponent = serde_json::from_value(serde_json::json!({
+ "type": "shape",
+ "shape": "rect",
+ "position": "absolute",
+ "x": 100, "y": 100,
+ "start_at": 1.5,
+ "style": {
+ "width": "100px", "height": "100px",
+ "animation": [{ "name": "slide_in_left", "delay": 0.0, "duration": 1.0 }]
+ },
+ "fill": "#ff0000"
+ }))
+ .unwrap();
+ let mut errors = Vec::new();
+ let mut warnings = Vec::new();
+ validate_children(&[child], "test", 2.0, &mut errors, &mut warnings);
+ assert!(
+ errors.iter().all(|e| !e.contains("animation finishes")),
+ "start_at must not be added to the completion budget: {errors:?}"
+ );
+ }
}
/// C2 completion (issue #110 / #102): an unresolved colour must fail
diff --git a/crates/rustmotion-cli/src/lib.rs b/crates/rustmotion-cli/src/lib.rs
index 013f489..be15b12 100644
--- a/crates/rustmotion-cli/src/lib.rs
+++ b/crates/rustmotion-cli/src/lib.rs
@@ -1,3 +1,4 @@
+mod claude_md;
mod commands;
mod skills;
pub mod tui;
diff --git a/crates/rustmotion-cli/src/skills.rs b/crates/rustmotion-cli/src/skills.rs
index 33c1306..1267d67 100644
--- a/crates/rustmotion-cli/src/skills.rs
+++ b/crates/rustmotion-cli/src/skills.rs
@@ -192,10 +192,13 @@ pub fn install(global: bool) -> Result<()> {
}
}
- // Write CLAUDE.md only in local mode
+ // Write CLAUDE.md only in local mode. The project may already own this file —
+ // merge into a delimited block rather than claiming the whole document.
if !global {
let claude_path = root.join("CLAUDE.md");
- if write_if_changed(&claude_path, CLAUDE_MD)? {
+ let existing = std::fs::read_to_string(&claude_path).ok();
+ let merged = crate::claude_md::merge(existing.as_deref(), CLAUDE_MD);
+ if write_if_changed(&claude_path, &merged)? {
written += 1;
} else {
skipped += 1;
@@ -299,12 +302,24 @@ pub fn uninstall(global: bool) -> Result<()> {
std::fs::remove_dir_all(&skills_dir)?;
let mut removed = 1;
- // Remove CLAUDE.md only in local mode
+ // Remove only what we put there. A CLAUDE.md carrying the project's own
+ // instructions keeps them; the file is deleted only when our block was all it
+ // ever held.
if !global {
let claude_path = root.join("CLAUDE.md");
- if claude_path.exists() {
- std::fs::remove_file(&claude_path)?;
- removed += 1;
+ if let Ok(existing) = std::fs::read_to_string(&claude_path) {
+ match crate::claude_md::strip(&existing) {
+ Some(remaining) => {
+ if remaining != existing {
+ std::fs::write(&claude_path, remaining)?;
+ removed += 1;
+ }
+ }
+ None => {
+ std::fs::remove_file(&claude_path)?;
+ removed += 1;
+ }
+ }
}
}
diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs
index f1f0780..2ec06c0 100644
--- a/crates/rustmotion-components/src/box_builder.rs
+++ b/crates/rustmotion-components/src/box_builder.rs
@@ -142,6 +142,7 @@ where
format!("/children/{i}"),
0.0,
(1.0, 0.0),
+ &root_css,
));
}
@@ -226,6 +227,7 @@ fn build_ghosts<'a>(
stagger_delay: f64,
time_remap: (f64, f64),
effects: &[AnimationEffect],
+ parent_css: &CssStyle,
) -> Vec {
let (mb, tr) = detect_ghost_effects(effects);
@@ -274,6 +276,9 @@ fn build_ghosts<'a>(
if let Some(z) = child.z_index {
css.z_index = Some(z);
}
+ // Cascade: a ghost is the same component as the principal, painted
+ // at a different sampled time, so it inherits from the same parent.
+ rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
// Apply timeline style states at the ghost time.
if let Some(animatable) = child.component.as_animatable() {
let steps = animatable.timeline_steps();
@@ -399,6 +404,7 @@ fn build_child<'a>(
path: String,
stagger_delay: f64,
time_remap: (f64, f64),
+ parent_css: &CssStyle,
) -> Vec {
// Compute the local animation context for this node — remapped by the
// accumulated affine time transform from ancestor containers.
@@ -428,6 +434,7 @@ fn build_child<'a>(
stagger_delay,
time_remap,
&effects,
+ parent_css,
);
}
}
@@ -450,6 +457,15 @@ fn build_child<'a>(
css.z_index = Some(z);
}
+ // CSS cascade (round 4 audit, lot LAYOUT, constat 2): propagate
+ // inheritable properties (color, font-*, text-align, white-space, ...)
+ // from the parent's already-cascaded style into any of this node's own
+ // unset properties — mirrors CSS's "specified value" resolution, which
+ // happens before state/animation overrides compute the final value.
+ // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but
+ // nothing called it until this fix.
+ rustmotion_core::css::cascade::inherit_from(parent_css, &mut css);
+
// Timeline style states: merge every state whose (at + stagger) <= t
// into the box CSS. Opacity is excluded when a `transition` smooths it
// (the synthesized keyframes then own its whole history). States affect
@@ -569,6 +585,7 @@ fn build_child<'a>(
&path,
stagger_delay,
time_remap,
+ &css,
);
let intrinsic = component_intrinsic(&child.component);
@@ -908,6 +925,7 @@ fn container_children<'a>(
parent_path: &str,
inherited_delay: f64,
time_remap: (f64, f64),
+ parent_css: &CssStyle,
) -> Vec {
let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option, f64, f64) =
match component {
@@ -973,6 +991,7 @@ fn container_children<'a>(
format!("{parent_path}/children/{j}"),
inherited_delay + j as f64 * step,
child_remap,
+ parent_css,
));
}
result
@@ -1305,6 +1324,14 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
}
}
+ // ── Round 4 audit, lot LAYOUT, constat 4: the 23-components block
+ // below (`Callout` through `Lottie`) now routes every default size
+ // through `apply_default_size`, which honours an explicit
+ // `aspect-ratio` (see its own doc comment) instead of the two
+ // guards below reaching separate, aspect-ratio-blind defaults —
+ // `width: 400` + `aspect-ratio: 16/9` used to still get the
+ // component's unrelated hardcoded default height (e.g. `shape`'s
+ // 80px) instead of the 225px the ratio implies.
// ── #126 / W3: the 23 components with no size source ─────────────
//
// A card's default flex column gives every child its width via
@@ -1345,12 +1372,11 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0),
CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size),
};
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(text_w + h_pad * 2.0 + extra_w)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(line_h + v_pad + extra_h)));
- }
+ apply_default_size(
+ css,
+ text_w + h_pad * 2.0 + extra_w,
+ line_h + v_pad + extra_h,
+ );
}
Tooltip(t) => {
// Same shape as Callout above; padding value borrowed from
@@ -1368,12 +1394,11 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
(0.0, t.arrow_size)
}
};
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(text_w + h_pad * 2.0 + extra_w)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(line_h + v_pad + extra_h)));
- }
+ apply_default_size(
+ css,
+ text_w + h_pad * 2.0 + extra_w,
+ line_h + v_pad + extra_h,
+ );
}
PillNav(p) => {
// `height` is already a declared field on the component (like
@@ -1382,24 +1407,17 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// formula (h_pad = font_size*1.2 per side, `gap` before/after/
// between every pill) using the same public fields and the same
// `measure_text_with_fallback` call it makes internally.
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(p.height)));
- }
- if css.width.is_none() {
- let font_size = p.style.font_size_px_or(14.0);
- let family = p.style.font_family_or("Inter");
- let h_pad = font_size * 1.2;
- let n = p.items.len() as f32;
- let labels_w: f32 = p
- .items
- .iter()
- .map(|label| {
- measure_text_line_width(label, font_size, family, false) + h_pad * 2.0
- })
- .sum();
- let total_w = labels_w + p.gap * (n + 1.0).max(1.0);
- css.width = Some(CSize::Length(CLP::Px(total_w)));
- }
+ let font_size = p.style.font_size_px_or(14.0);
+ let family = p.style.font_family_or("Inter");
+ let h_pad = font_size * 1.2;
+ let n = p.items.len() as f32;
+ let labels_w: f32 = p
+ .items
+ .iter()
+ .map(|label| measure_text_line_width(label, font_size, family, false) + h_pad * 2.0)
+ .sum();
+ let total_w = labels_w + p.gap * (n + 1.0).max(1.0);
+ apply_default_size(css, total_w, p.height);
}
Marquee(m) => {
// Marquee's whole purpose is to scroll unbounded content, so
@@ -1418,12 +1436,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// `font_size: 24` paired with `style.height: 48`, i.e.
// `2 × font_size`.
let font_size = m.style.font_size_px_or(m.font_size);
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(800.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(font_size * 2.0)));
- }
+ apply_default_size(css, 800.0, font_size * 2.0);
}
Stepper(s) => {
// Same shape as `Timeline`'s formula above (r*2 + label metrics),
@@ -1448,36 +1461,26 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
.fold(0.0_f32, f32::max);
match s.orientation {
StepperOrientation::Horizontal => {
- if css.width.is_none() {
- // Per-step allocation: the node needs ~3 diameters of
- // breathing room (a common stepper-UI spacing
- // convention), or enough for its longest label/desc,
- // whichever is larger.
- let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0);
- css.width = Some(CSize::Length(CLP::Px(per_step * n)));
- }
- if css.height.is_none() {
- let label_h = LABEL_FS * 1.3;
- let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 };
- let h = s.node_size + 4.0 + 12.0 + label_h + desc_h;
- css.height = Some(CSize::Length(CLP::Px(h)));
- }
+ // Per-step allocation: the node needs ~3 diameters of
+ // breathing room (a common stepper-UI spacing
+ // convention), or enough for its longest label/desc,
+ // whichever is larger.
+ let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0);
+ let label_h = LABEL_FS * 1.3;
+ let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 };
+ let h = s.node_size + 4.0 + 12.0 + label_h + desc_h;
+ apply_default_size(css, per_step * n, h);
}
StepperOrientation::Vertical => {
- if css.width.is_none() {
- let label_w = max_label_w.max(max_desc_w);
- let w = s.node_size + 12.0 + label_w + 24.0;
- css.width = Some(CSize::Length(CLP::Px(w)));
- }
- if css.height.is_none() {
- let label_block = if has_desc {
- LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0
- } else {
- LABEL_FS * 1.3 + 8.0
- };
- let per_step = (s.node_size * 2.0).max(label_block);
- css.height = Some(CSize::Length(CLP::Px(per_step * n)));
- }
+ let label_w = max_label_w.max(max_desc_w);
+ let w = s.node_size + 12.0 + label_w + 24.0;
+ let label_block = if has_desc {
+ LABEL_FS * 1.3 + DESC_FS * 1.3 + 8.0
+ } else {
+ LABEL_FS * 1.3 + 8.0
+ };
+ let per_step = (s.node_size * 2.0).max(label_block);
+ apply_default_size(css, w, per_step * n);
}
}
}
@@ -1512,12 +1515,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
let lines = (total_w / box_w).ceil().max(1.0);
let line_h = tc.max_font_size * 1.3;
let box_h = lines * line_h + (lines - 1.0).max(0.0) * V_GAP;
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(box_w)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(box_h)));
- }
+ apply_default_size(css, box_w, box_h);
}
}
Heatmap(h) => {
@@ -1528,25 +1526,15 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
let rows = h.data.len();
let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0);
let step = h.cell_size + h.cell_gap;
- if css.width.is_none() {
- let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
- css.width = Some(CSize::Length(CLP::Px(w)));
- }
- if css.height.is_none() {
- let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
- css.height = Some(CSize::Length(CLP::Px(hh)));
- }
+ let w = (cols.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
+ let hh = (rows.max(1) as f32 - 1.0).max(0.0) * step + h.cell_size;
+ apply_default_size(css, w, hh);
}
Sparkline(_) => {
// "Sparkline: no axes, no labels, compact (120x40 default),
// inline use" — documented in
// .claude/skills/rustmotion/rules/data-viz-components.md.
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(120.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(40.0)));
- }
+ apply_default_size(css, 120.0, 40.0);
}
Stat(_) => {
// Documented default from
@@ -1555,12 +1543,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// the fix for the issue's second bug: three `stat`s in a flex
// row with no explicit size rendered zero pixels because width
// (not just height) collapsed to 0 in a row context.
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(280.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(180.0)));
- }
+ apply_default_size(css, 280.0, 180.0);
}
Gauge(g) => {
// Square — gauge.rs's own paint() derives its ring radius from
@@ -1575,12 +1558,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// icon-sizing-hierarchy.md.
const TARGET_RADIUS: f32 = 88.0;
let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0);
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(size)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(size)));
- }
+ apply_default_size(css, size, size);
}
DotMap(_) => {
// 2:1 — the standard aspect ratio for an equirectangular world
@@ -1588,24 +1566,14 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// dot_map.rs's own `geo_to_screen` implements. dot_map.rs always
// paints a full-box background rect first, so any positive size
// shows ink even with zero points.
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(640.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(320.0)));
- }
+ apply_default_size(css, 640.0, 320.0);
}
Comparison(_) => {
// No natural intrinsic size (the painter just splits whatever
// box it's given at the divider) — matches this project's own
// reference usage in examples/mega-showcase.json's `comparison`
// block.
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(520.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(280.0)));
- }
+ apply_default_size(css, 520.0, 280.0);
}
Treemap(_) => {
// Slice-and-dice treemap fills whatever box it's given — matches
@@ -1613,12 +1581,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// examples/mega-showcase.json's `treemap` block (near-square,
// the conventional treemap aspect since its rectangles are area-
// proportional in both axes).
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(416.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(368.0)));
- }
+ apply_default_size(css, 416.0, 368.0);
}
Chart(c) => {
// Pie/donut/radar/radial_bar are inherently circular — a square
@@ -1631,12 +1594,12 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
c.chart_type,
ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar
);
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(if round { 320.0 } else { 400.0 })));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(if round { 320.0 } else { 300.0 })));
- }
+ let (dw, dh) = if round {
+ (320.0, 320.0)
+ } else {
+ (400.0, 300.0)
+ };
+ apply_default_size(css, dw, dh);
}
Skeleton(s) => {
// `rectangle`: documented default from data-viz-components.md's
@@ -1648,31 +1611,12 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// formula above — matches skeleton.rs's own per-line paint loop
// (`y = i * (line_height + line_gap)`) exactly.
match s.variant {
- SkeletonVariant::Rectangle => {
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(400.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(200.0)));
- }
- }
- SkeletonVariant::Circle => {
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(64.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(64.0)));
- }
- }
+ SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0),
+ SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0),
SkeletonVariant::Text => {
let n = s.lines.max(1) as f32;
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(240.0)));
- }
- if css.height.is_none() {
- let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap;
- css.height = Some(CSize::Length(CLP::Px(h)));
- }
+ let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap;
+ apply_default_size(css, 240.0, h);
}
}
}
@@ -1686,12 +1630,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
MockupDevice::Laptop => (640.0, 400.0),
MockupDevice::Browser => (640.0, 360.0),
};
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(dw)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(dh)));
- }
+ apply_default_size(css, dw, dh);
}
Icon(_) => {
// 64×64 — the midpoint of the documented "card / feature icon"
@@ -1699,12 +1638,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// icon-sizing-hierarchy.md (desktop 40–56px, mobile 72–96px,
// square 60–80px), and a size icon asset systems near-universally
// ship as a default export (24/32/48/64 being the common family).
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(64.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(64.0)));
- }
+ apply_default_size(css, 64.0, 64.0);
}
Svg(_) => {
// 200×200 — square, since an arbitrary vector graphic (icon,
@@ -1712,12 +1646,7 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// common equal-aspect SVG viewBox convention and sits above
// Icon's 64px "card icon" role for the more elaborate content
// `svg` typically carries (illustrations/diagrams, not glyphs).
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(200.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(200.0)));
- }
+ apply_default_size(css, 200.0, 200.0);
}
Shape(_) => {
// 80×80 — matches the median of this project's own decorative
@@ -1726,51 +1655,92 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// (26, 36, 44, 60, 70, 140 — median ~55, rounded up for
// visibility as a standalone default rather than a same-scene
// accent tuned against neighbours).
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(80.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(80.0)));
- }
+ apply_default_size(css, 80.0, 80.0);
}
Image(_) => {
// 4:3 (400×300) — the traditional default photo aspect ratio,
// distinct from Video/Gif's 16:9 below so a generic still image
// doesn't presume widescreen framing.
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(400.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(300.0)));
- }
+ apply_default_size(css, 400.0, 300.0);
}
Video(_) | Gif(_) => {
// 16:9 (400×225) — the industry-standard video aspect ratio
// (matches every render resolution this project documents:
// 1920×1080, 1280×720), scaled down to a card-sized default.
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(400.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(225.0)));
- }
+ apply_default_size(css, 400.0, 225.0);
}
Lottie(_) => {
// 300×300 — square, matching the aspect the vast majority of
// Lottie animation assets ship at (LottieFiles' own marketplace
// preview convention is a 1:1 canvas).
- if css.width.is_none() {
- css.width = Some(CSize::Length(CLP::Px(300.0)));
- }
- if css.height.is_none() {
- css.height = Some(CSize::Length(CLP::Px(300.0)));
- }
+ apply_default_size(css, 300.0, 300.0);
}
_ => {}
}
}
+/// Apply a component's natural default size (`dw` × `dh`) to `css`, honouring
+/// an explicit `aspect-ratio` instead of always falling back to `dw`/`dh`
+/// independently (round 4 audit, lot LAYOUT, constat 4 — the previous code
+/// guarded each axis with its own `is_none()` check and never looked at
+/// `aspect-ratio`, so `width: 400` + `aspect-ratio: 16/9` still got the
+/// component's unrelated hardcoded default height instead of 225).
+///
+/// - Both axes already set: untouched (the author fully specified the box).
+/// - One axis set to a fixed pixel length, `aspect-ratio` present: the other
+/// axis is derived from it (`h = w / ratio` or `w = h * ratio`) — the CSS
+/// replaced-element sizing rule for a single definite axis plus a
+/// preferred aspect ratio.
+/// - Neither axis set: the natural default width is kept (there is no
+/// author-declared axis to derive from), and height is derived from
+/// `aspect-ratio` when present, the natural default height otherwise.
+///
+/// `min-*`/`max-*` need no equivalent guard here: taffy clamps the final
+/// used size against them at layout time regardless of what `size` resolves
+/// to (`style.min_size`/`max_size` in `taffy_bridge::to_taffy_style`), so a
+/// default below `min-width` is corrected downstream, not silently wrong.
+fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) {
+ let ratio = css.aspect_ratio.filter(|r| *r > 0.0);
+ match (css.width.is_some(), css.height.is_some()) {
+ (true, true) => {}
+ (true, false) => {
+ let h = fixed_px(css.width.as_ref())
+ .zip(ratio)
+ .map(|(w, r)| w / r)
+ .unwrap_or(dh);
+ css.height = Some(CSize::Length(CLP::Px(h)));
+ }
+ (false, true) => {
+ let w = fixed_px(css.height.as_ref())
+ .zip(ratio)
+ .map(|(h, r)| h * r)
+ .unwrap_or(dw);
+ css.width = Some(CSize::Length(CLP::Px(w)));
+ }
+ (false, false) => {
+ css.width = Some(CSize::Length(CLP::Px(dw)));
+ let h = ratio.map(|r| dw / r).unwrap_or(dh);
+ css.height = Some(CSize::Length(CLP::Px(h)));
+ }
+ }
+}
+
+/// Extract a fixed pixel value from a `Size`, if it resolves to one without a
+/// `LengthContext` (only `Size::Length(LengthPercentage::Px(_))` — a bare
+/// number or `"NNpx"`). `%`/`vw`/`vh`/`em`/`rem` and `auto` return `None`:
+/// `apply_default_size` can't derive a ratio from a length it can't resolve
+/// at build time, so it falls back to the component's hardcoded default.
+fn fixed_px(size: Option<&CSize>) -> Option {
+ match size? {
+ CSize::Length(lp) => match lp.try_parse()? {
+ rustmotion_core::css::units::ParsedLength::Px(v) => Some(v),
+ _ => None,
+ },
+ _ => None,
+ }
+}
+
/// Borrow the `CssStyle` from any component.
fn component_style(c: &Component) -> &CssStyle {
use Component::*;
@@ -1947,6 +1917,27 @@ mod tests {
}
}
+ fn make_text(content: &str, style: CssStyle) -> ChildComponent {
+ ChildComponent {
+ component: Component::Text(crate::text::Text {
+ content: content.to_string(),
+ max_width: None,
+ timing: Default::default(),
+ style,
+ timeline: Vec::new(),
+ stagger: None,
+ text_shadow: None,
+ stroke: None,
+ text_background: None,
+ }),
+ position: None,
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }
+ }
+
#[test]
fn empty_scene_has_only_root() {
let built = build_scene(&[], (1920.0, 1080.0));
@@ -2752,4 +2743,192 @@ mod tests {
);
}
}
+
+ // ── Round 4 audit, lot LAYOUT, constat 2: the CSS cascade is wired ──────
+ // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but
+ // nothing called it — `color`/`font-*` set on a container never reached
+ // children lacking their own value.
+
+ #[test]
+ fn card_color_cascades_to_text_child_with_no_color_of_its_own() {
+ use rustmotion_core::css::style::Color;
+
+ let card = make_card(
+ vec![make_text("hello", CssStyle::default())],
+ CssStyle {
+ color: Some(Color::String("#ff0000".into())),
+ ..Default::default()
+ },
+ );
+ let scene = vec![ChildComponent {
+ component: card,
+ position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }];
+ let built = build_scene(&scene, (800.0, 600.0));
+ let text_box = &built.root.children[0].children[0];
+ assert_eq!(
+ text_box.css.color,
+ Some(Color::String("#ff0000".into())),
+ "text child declares no color of its own — it should inherit the card's"
+ );
+ }
+
+ #[test]
+ fn text_own_color_wins_over_inherited_card_color() {
+ use rustmotion_core::css::style::Color;
+
+ let card = make_card(
+ vec![make_text(
+ "hello",
+ CssStyle {
+ color: Some(Color::String("#00ff00".into())),
+ ..Default::default()
+ },
+ )],
+ CssStyle {
+ color: Some(Color::String("#ff0000".into())),
+ ..Default::default()
+ },
+ );
+ let scene = vec![ChildComponent {
+ component: card,
+ position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }];
+ let built = build_scene(&scene, (800.0, 600.0));
+ let text_box = &built.root.children[0].children[0];
+ assert_eq!(
+ text_box.css.color,
+ Some(Color::String("#00ff00".into())),
+ "text child's own explicit color must win over the inherited card color"
+ );
+ }
+
+ #[test]
+ fn card_display_does_not_cascade_to_text_child() {
+ // `display` is not an inheritable CSS property — only the documented
+ // inheritable list (color, font-*, text-align, white-space, ...)
+ // should propagate.
+ let card = make_card(
+ vec![make_text("hello", CssStyle::default())],
+ CssStyle {
+ display: Some(Display::Flex),
+ ..Default::default()
+ },
+ );
+ let scene = vec![ChildComponent {
+ component: card,
+ position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }];
+ let built = build_scene(&scene, (800.0, 600.0));
+ let text_box = &built.root.children[0].children[0];
+ assert_eq!(text_box.css.display, None);
+ }
+
+ // ── Round 4 audit, lot LAYOUT, constat 4: `apply_intrinsic_overrides`'s
+ // default size ignored an explicit `aspect-ratio`. ─────────────────────
+
+ fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent {
+ ChildComponent {
+ component: Component::Shape(crate::shape::Shape {
+ shape: rustmotion_core::schema::ShapeType::Rect,
+ text: None,
+ timing: Default::default(),
+ style: CssStyle {
+ width: Some(CSize::Length(CLP::Px(width))),
+ aspect_ratio: Some(aspect_ratio),
+ ..Default::default()
+ },
+ timeline: Vec::new(),
+ stagger: None,
+ fill: None,
+ stroke: None,
+ }),
+ position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }
+ }
+
+ #[test]
+ fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() {
+ // `shape`'s hardcoded default is 80×80 (see `apply_intrinsic_overrides`).
+ // `width: 400` + `aspect-ratio: 16/9` should derive height = 225, not
+ // fall back to the unrelated 80px default.
+ let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)];
+ let built = build_scene(&scene, (1920.0, 1080.0));
+ let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
+ let l = layout
+ .get(built.root.children[0].id)
+ .expect("shape laid out");
+ assert!(
+ (l.width - 400.0).abs() < 1.0,
+ "width should stay the author's explicit 400, got {}",
+ l.width
+ );
+ assert!(
+ (l.height - 225.0).abs() < 1.0,
+ "height should derive from width/aspect-ratio (400/1.778=225), got {}",
+ l.height
+ );
+ }
+
+ #[test]
+ fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() {
+ // No width/height at all: the natural default width (80 for shape)
+ // is kept, but height should come from the aspect-ratio, not the
+ // unrelated 80px default.
+ let scene = vec![make_aspect_shape_no_width(2.0)];
+ let built = build_scene(&scene, (1920.0, 1080.0));
+ let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default());
+ let l = layout
+ .get(built.root.children[0].id)
+ .expect("shape laid out");
+ assert!(
+ (l.width - 80.0).abs() < 1.0,
+ "width should keep the natural default (80), got {}",
+ l.width
+ );
+ assert!(
+ (l.height - 40.0).abs() < 1.0,
+ "height should derive from the default width/aspect-ratio (80/2=40), got {}",
+ l.height
+ );
+ }
+
+ fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent {
+ ChildComponent {
+ component: Component::Shape(crate::shape::Shape {
+ shape: rustmotion_core::schema::ShapeType::Rect,
+ text: None,
+ timing: Default::default(),
+ style: CssStyle {
+ aspect_ratio: Some(aspect_ratio),
+ ..Default::default()
+ },
+ timeline: Vec::new(),
+ stagger: None,
+ fill: None,
+ stroke: None,
+ }),
+ position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }
+ }
}
diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs
index f4da120..056ebe6 100644
--- a/crates/rustmotion-components/src/caption.rs
+++ b/crates/rustmotion-components/src/caption.rs
@@ -2,7 +2,11 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, Font, FontStyle, Rect};
-use rustmotion_core::css::style::WhiteSpace as CssWhiteSpace;
+use rustmotion_core::css::style::{
+ FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
+ WhiteSpace as CssWhiteSpace,
+};
+use rustmotion_core::css::units::LengthContext;
use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
@@ -43,12 +47,32 @@ rustmotion_core::impl_traits!(Caption {
});
impl Caption {
- fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, time: f64) {
+ fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) {
+ let time = ctx.time;
let font_size = self.style.font_size_px_or(48.0);
let color = self.style.color_str_or("#FFFFFF");
let font_family = self.style.font_family_or("Inter");
- let Ok(typeface) = typeface_with_fallback(font_family, FontStyle::bold()) else {
+ // #9: `letter-spacing`/`line-height` `em`/`%` resolve against this
+ // element's own font-size (just above); `vw`/`vh` resolve against
+ // the real viewport, available here via `ctx` (mirrors
+ // `text.rs::paint`'s `type_ctx`).
+ let type_ctx = LengthContext {
+ viewport_width: ctx.video_width as f32,
+ viewport_height: ctx.video_height as f32,
+ parent_size: layout_width.max(0.0),
+ font_size,
+ root_font_size: 16.0,
+ };
+
+ // #9: derive weight/slant from `style.font-weight`/`font-style`
+ // instead of always painting bold. `CaptionIntrinsic` (via
+ // `TextIntrinsic`) measures at whatever weight the style declares
+ // (400/normal when unset) — painting an unconditional bold made the
+ // glyphs wider than the box that was centred/measured for them.
+ let font_style = Self::resolve_font_style(&self.style);
+
+ let Ok(typeface) = typeface_with_fallback(font_family, font_style) else {
return;
};
@@ -182,8 +206,20 @@ impl Caption {
self.style.white_space,
Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre)
);
+ // #1: when `max_width` is unset, wrap at the box `layout`
+ // actually gave this caption (matches `text.rs:442-451`)
+ // instead of never wrapping — `CaptionIntrinsic` measures
+ // (and taffy reserves a box) against that same width, so
+ // painting at `f32::MAX` here painted a single line far
+ // wider than the reserved box, bleeding past it and past
+ // the viewport with `validate` never seeing the mismatch
+ // (it re-measures via the same intrinsic, not this paint
+ // path).
let max_width = if nowrap {
f32::MAX
+ } else if layout_width.is_finite() && layout_width > 0.0 {
+ self.max_width
+ .map_or(layout_width, |mw| mw.min(layout_width))
} else {
self.max_width.unwrap_or(f32::MAX)
};
@@ -203,7 +239,16 @@ impl Caption {
current_x += word_width + space_width;
}
- let line_height = font_size * 1.4;
+ // #9: honour `style.line-height` like `CaptionIntrinsic`
+ // does (via `TextIntrinsic::from_parts` ->
+ // `line_height_for_ctx`) instead of a hardcoded 1.4 — the
+ // box taffy reserves is sized from the former, so painting
+ // with the latter drifted the line spacing away from what
+ // was measured (7.7% at the unset default, arbitrarily more
+ // with an explicit `line-height`), and the caption's own
+ // vertical clip (below in the outer `paint`) silently crops
+ // whatever spills past the mismatch.
+ let line_height = self.style.line_height_for_ctx(font_size, &type_ctx);
let cx = layout_width / 2.0;
if let Some(bg_color) = self.style.background_color_str() {
@@ -291,6 +336,29 @@ impl Caption {
let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR));
canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint);
}
+
+ /// #9: the Skia `FontStyle` to paint with, derived from `style.font-
+ /// weight`/`font-style` — mirrors `text.rs`'s weight/slant mapping and
+ /// `intrinsic.rs`'s `weight_to_u16` (used to measure the box), so the
+ /// weight the box was measured at and the weight painted into it always
+ /// agree. Pulled out as its own function so it's directly unit-testable
+ /// without needing to render anything.
+ fn resolve_font_style(style: &CssStyle) -> FontStyle {
+ let weight = match &style.font_weight {
+ Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
+ skia_safe::font_style::Weight::BOLD
+ }
+ Some(CssFontWeight::Number(n)) if *n >= 600 => skia_safe::font_style::Weight::BOLD,
+ Some(CssFontWeight::Number(n)) => skia_safe::font_style::Weight::from(*n as i32),
+ _ => skia_safe::font_style::Weight::NORMAL,
+ };
+ let slant = match style.font_style {
+ Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
+ Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
+ _ => skia_safe::font_style::Slant::Upright,
+ };
+ FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant)
+ }
}
impl Painter for Caption {
@@ -301,7 +369,7 @@ impl Painter for Caption {
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
- self.paint(canvas, layout.width, layout.height, ctx.time);
+ self.paint(canvas, layout.width, layout.height, ctx);
}
}
@@ -333,7 +401,30 @@ mod tests {
use rustmotion_core::css::Length;
use rustmotion_core::schema::CaptionWord;
+ /// A `PaintCtx` for tests that don't care about frame/fps bookkeeping —
+ /// only `time` and, since #9, the viewport dims threaded into the
+ /// `LengthContext` used to resolve `vw`/`vh` typography units.
+ fn test_ctx(time: f64) -> PaintCtx {
+ PaintCtx {
+ time,
+ scene_duration: 2.0,
+ frame_index: (time * 30.0) as u32,
+ fps: 30,
+ video_width: 1920,
+ video_height: 1080,
+ stagger_offset: 0.0,
+ }
+ }
+
fn make_caption(text: &str, white_space: Option) -> Caption {
+ make_caption_with_max_width(text, white_space, Some(80.0))
+ }
+
+ fn make_caption_with_max_width(
+ text: &str,
+ white_space: Option,
+ max_width: Option,
+ ) -> Caption {
let words = text
.split_whitespace()
.map(|w| CaptionWord {
@@ -346,7 +437,7 @@ mod tests {
words,
active_color: default_active_color(),
mode: CaptionStyle::Highlight,
- max_width: Some(80.0),
+ max_width,
pill_color: None,
style: CssStyle {
font_size: Some(Length::Px(28.0)),
@@ -410,7 +501,7 @@ mod tests {
let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
{
let canvas = surface.canvas();
- caption.paint(canvas, W as f32, H as f32, 0.5);
+ caption.paint(canvas, W as f32, H as f32, &test_ctx(0.5));
}
let (_minx, _maxx, miny, _maxy) =
ink_bounds(&mut surface, W, H).expect("caption must paint something");
@@ -431,7 +522,7 @@ mod tests {
let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
{
let canvas = surface.canvas();
- caption.paint(canvas, W as f32, H as f32, 0.1);
+ caption.paint(canvas, W as f32, H as f32, &test_ctx(0.1));
}
let (_minx, _maxx, miny, _maxy) =
ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something");
@@ -453,7 +544,7 @@ mod tests {
{
let canvas = surface.canvas();
canvas.translate((800.0, 250.0));
- caption.paint(canvas, 80.0, H as f32, 0.5);
+ caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
}
let (minx, maxx, miny, maxy) =
ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something");
@@ -479,7 +570,7 @@ mod tests {
{
let canvas = surface.canvas();
canvas.translate((800.0, 250.0));
- caption.paint(canvas, 80.0, H as f32, 0.5);
+ caption.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
}
let (minx, maxx, miny, maxy) =
ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something");
@@ -495,4 +586,186 @@ mod tests {
maxy - miny
);
}
+
+ // ─── #1: wrap at the box's layout_width when max_width is unset ───────
+
+ #[test]
+ fn wraps_at_layout_width_when_max_width_is_unset() {
+ // Reproduction: no `max_width` on the caption (the common case — a
+ // caption's box comes from wherever it's placed, e.g. a card), but
+ // the layout pass still hands `paint` a real, finite `layout_width`
+ // (mirrors `CaptionIntrinsic`, which measures against exactly this
+ // width). Before the fix, `max_width.unwrap_or(f32::MAX)` ignored
+ // `layout_width` entirely and painted one line stretching far past
+ // the box — and past the viewport in the audit's repro.
+ let caption = make_caption_with_max_width(
+ "the quick brown fox jumps over the lazy dog again",
+ None,
+ None, // no explicit max_width
+ );
+ const W: i32 = 1600;
+ const H: i32 = 400;
+ let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ canvas.translate((800.0, 200.0));
+ // The box the layout pass assigned: 300px wide, well short of
+ // this sentence's unwrapped width at font-size 28.
+ caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
+ }
+ let (minx, maxx, miny, maxy) =
+ ink_bounds(&mut surface, W, H).expect("caption must paint something");
+
+ assert!(
+ maxx - minx < 320,
+ "must wrap within ~layout_width (300px), got ink width {}",
+ maxx - minx
+ );
+ assert!(
+ maxy - miny > 50,
+ "must spread across multiple lines when max_width is unset, got ink height {}",
+ maxy - miny
+ );
+ }
+
+ #[test]
+ fn nowrap_still_ignores_layout_width_when_max_width_is_unset() {
+ // Regression guard: the #1 fix must not touch `white-space:
+ // nowrap`'s existing "always ignore any width constraint" contract.
+ let caption = make_caption_with_max_width(
+ "the quick brown fox jumps over the lazy dog",
+ Some(CssWhiteSpace::Nowrap),
+ None,
+ );
+ const W: i32 = 1600;
+ const H: i32 = 400;
+ let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ canvas.translate((800.0, 200.0));
+ caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5));
+ }
+ let (minx, maxx, miny, maxy) =
+ ink_bounds(&mut surface, W, H).expect("caption must paint something");
+
+ assert!(
+ maxx - minx > 400,
+ "nowrap must still bleed past layout_width, got ink width {}",
+ maxx - minx
+ );
+ assert!(
+ maxy - miny < 50,
+ "nowrap must stay on one line, got ink height {}",
+ maxy - miny
+ );
+ }
+
+ // ─── #9: line-height / font-weight measure-vs-paint parity ────────────
+
+ #[test]
+ fn honours_style_line_height_instead_of_hardcoded_1_4() {
+ // Reproduction: `style.line-height: 0.9` must change the vertical
+ // gap between wrapped lines. Before the fix, the painter always
+ // used `font_size * 1.4` regardless of `style.line-height`, while
+ // `CaptionIntrinsic` (the box taffy reserves) honoured it — a
+ // caption author following rules/typography-readability.md's
+ // guidance to set `line-height` got a box sized for their value but
+ // glyphs painted at a fixed 1.4.
+ let mut tight = make_caption_with_max_width(
+ "one two three four five six seven eight",
+ None,
+ Some(80.0),
+ );
+ tight.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(0.9));
+ let mut loose = make_caption_with_max_width(
+ "one two three four five six seven eight",
+ None,
+ Some(80.0),
+ );
+ loose.style.line_height = Some(rustmotion_core::css::style::LineHeight::Number(2.0));
+
+ const W: i32 = 1600;
+ const H: i32 = 800;
+
+ let mut surf_tight =
+ skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ {
+ let canvas = surf_tight.canvas();
+ canvas.translate((800.0, 50.0));
+ tight.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
+ }
+ let (_, _, _, tight_maxy) =
+ ink_bounds(&mut surf_tight, W, H).expect("tight caption must paint something");
+
+ let mut surf_loose =
+ skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ {
+ let canvas = surf_loose.canvas();
+ canvas.translate((800.0, 50.0));
+ loose.paint(canvas, 80.0, H as f32, &test_ctx(0.5));
+ }
+ let (_, _, _, loose_maxy) =
+ ink_bounds(&mut surf_loose, W, H).expect("loose caption must paint something");
+
+ assert!(
+ loose_maxy > tight_maxy + 50,
+ "line-height: 2.0 must spread lines much further than 0.9 \
+ (tight bottom={tight_maxy}, loose bottom={loose_maxy})"
+ );
+ }
+
+ // `Caption::resolve_font_style` is the exact weight/slant computation
+ // `paint` uses; testing it directly is deterministic regardless of
+ // whether the system's resolved "bold" and "normal" typefaces happen to
+ // have visually/metrically distinct advance widths on this particular
+ // host (on this machine, Helvetica's bold and normal share identical
+ // glyph metrics — a pixel-width comparison would pass whether or not
+ // `paint` used the right weight, which isn't a real check of the fix).
+
+ #[test]
+ fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() {
+ // #9 (weight half): `CaptionIntrinsic` (via `TextIntrinsic`'s
+ // `weight_to_u16`) measures at weight 400 when `style.font-weight`
+ // is unset. Before the fix, `paint` ignored `style.font-weight`
+ // entirely and always painted `FontStyle::bold()` (weight 700) — a
+ // silent measure-vs-paint weight mismatch on every caption that
+ // doesn't set an explicit font-weight (the common case).
+ let style = CssStyle::default();
+ let resolved = Caption::resolve_font_style(&style);
+ assert_eq!(
+ *resolved.weight(),
+ 400,
+ "unset font-weight must resolve to normal (400), not a hardcoded bold"
+ );
+ }
+
+ #[test]
+ fn resolve_font_style_honours_explicit_bold_and_numeric_weight() {
+ let bold = CssStyle {
+ font_weight: Some(CssFontWeight::Keyword(FontWeightKw::Bold)),
+ ..Default::default()
+ };
+ assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700);
+
+ // Below the >=600 "treat as bold" threshold (same threshold
+ // `text.rs`'s equivalent mapping uses), so the exact numeric value
+ // passes through unchanged.
+ let numeric = CssStyle {
+ font_weight: Some(CssFontWeight::Number(350)),
+ ..Default::default()
+ };
+ assert_eq!(*Caption::resolve_font_style(&numeric).weight(), 350);
+ }
+
+ #[test]
+ fn resolve_font_style_honours_italic() {
+ let italic = CssStyle {
+ font_style: Some(CssFontStyle::Italic),
+ ..Default::default()
+ };
+ assert_eq!(
+ Caption::resolve_font_style(&italic).slant(),
+ skia_safe::font_style::Slant::Italic
+ );
+ }
}
diff --git a/crates/rustmotion-components/src/chart/bar.rs b/crates/rustmotion-components/src/chart/bar.rs
index f492341..05c59f9 100644
--- a/crates/rustmotion-components/src/chart/bar.rs
+++ b/crates/rustmotion-components/src/chart/bar.rs
@@ -23,7 +23,16 @@ impl Chart {
// reduces to the previous `value / max_val`.
let (min_val, max_val, range) = self.value_extent();
- let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect();
+ // One slot per data point, unlabeled points included as `""`.
+ // `draw_axes` positions label `i` at slot `i` of `n = x_labels.len()`
+ // — a `filter_map` that dropped unlabeled points compacted the list,
+ // so `n` no longer matched `self.data.len()` and every surviving
+ // label slid onto the wrong bar as soon as one point had no label.
+ let x_labels: Vec = self
+ .data
+ .iter()
+ .map(|d| d.label.clone().unwrap_or_default())
+ .collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true,
@@ -269,33 +278,73 @@ impl Chart {
let chart_h = h - mt - mb;
let n_cats = self.categories.len();
- // Find max stacked total
- let max_val = (0..n_cats)
+ // A stacked total can go negative — either every series is negative
+ // (e.g. an all-cost breakdown) or the series mix signs within one
+ // category (revenue vs. cost). Scale from the true signed extent —
+ // positive segments stack above zero, negative segments stack below
+ // — the same zero-anchored contract `value_extent` gives
+ // `render_bar`. Scaling by the largest *positive* total alone
+ // (previously `max_val = ... .max(0.001)`, floored to 0.001 when
+ // every total was negative) sent every segment's height through a
+ // near-zero divisor and painted it thousands of chart-heights
+ // outside the box; even a bounded mixed-sign case (revenue stacked
+ // as if `max_val` were the net total) pushed the positive segment
+ // taller than the whole chart.
+ let totals: Vec<(f64, f64)> = (0..n_cats)
.map(|ci| {
self.series
.iter()
- .map(|s| s.data.get(ci).copied().unwrap_or(0.0))
- .sum::()
+ .fold((0.0_f64, 0.0_f64), |(pos, neg), s| {
+ let v = s.data.get(ci).copied().unwrap_or(0.0);
+ if v >= 0.0 {
+ (pos + v, neg)
+ } else {
+ (pos, neg + v)
+ }
+ })
})
- .fold(0.0_f64, f64::max)
- .max(0.001);
+ .collect();
+ let min_val = totals.iter().map(|(_, neg)| *neg).fold(0.0_f64, f64::min);
+ let max_val = totals.iter().map(|(pos, _)| *pos).fold(0.0_f64, f64::max);
+ let range = (max_val - min_val).max(0.001);
let x_labels: Vec = self.categories.clone();
self.draw_axes(
- canvas, ml, mt, chart_w, chart_h, 0.0, max_val, &x_labels, true,
+ canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true,
);
let gap = 8.0;
let bar_w = (chart_w - gap * (n_cats + 1) as f32) / n_cats as f32;
+ let zero_y = mt + chart_h - ((0.0 - min_val) / range) as f32 * chart_h;
for ci in 0..n_cats {
let x = ml + gap + ci as f32 * (bar_w + gap);
- let mut cumulative_h = 0.0_f32;
+ // Positive segments stack upward from `zero_y`; negative
+ // segments stack downward from it, independently — each
+ // direction has its own running edge.
+ let mut pos_top = zero_y;
+ let mut neg_bottom = zero_y;
+ let last_pos_si =
+ self.series.iter().enumerate().rev().find_map(|(si, s)| {
+ (s.data.get(ci).copied().unwrap_or(0.0) > 0.0).then_some(si)
+ });
+ let last_neg_si =
+ self.series.iter().enumerate().rev().find_map(|(si, s)| {
+ (s.data.get(ci).copied().unwrap_or(0.0) < 0.0).then_some(si)
+ });
for (si, series) in self.series.iter().enumerate() {
let val = series.data.get(ci).copied().unwrap_or(0.0);
- let seg_h = (val / max_val) as f32 * chart_h * progress;
- let y = mt + chart_h - cumulative_h - seg_h;
+ if val == 0.0 {
+ continue;
+ }
+ let seg_h = (val.abs() / range) as f32 * chart_h * progress;
+ let negative = val < 0.0;
+ let y = if negative {
+ neg_bottom
+ } else {
+ pos_top - seg_h
+ };
let color = series
.color
@@ -306,27 +355,328 @@ impl Chart {
paint.set_anti_alias(true);
let rect = Rect::from_xywh(x, y, bar_w, seg_h);
- // Rounded top on the topmost segment only
- if si == self.series.len() - 1 {
+ // Round the outer edge of each stack: the top of the
+ // topmost positive segment, the bottom of the bottommost
+ // negative segment.
+ let is_outer_edge = if negative {
+ Some(si) == last_neg_si
+ } else {
+ Some(si) == last_pos_si
+ };
+ if is_outer_edge {
let radius = (bar_w * 0.15).min(8.0);
- let rrect = skia_safe::RRect::new_rect_radii(
- rect,
- &[
- (radius, radius).into(),
- (radius, radius).into(),
- (0.0, 0.0).into(),
- (0.0, 0.0).into(),
- ],
- );
+ let round = (radius, radius).into();
+ let square = (0.0, 0.0).into();
+ let radii = if negative {
+ [square, square, round, round]
+ } else {
+ [round, round, square, square]
+ };
+ let rrect = skia_safe::RRect::new_rect_radii(rect, &radii);
canvas.draw_rrect(rrect, &paint);
} else {
canvas.draw_rect(rect, &paint);
}
- cumulative_h += seg_h;
+ if negative {
+ neg_bottom += seg_h;
+ } else {
+ pos_top -= seg_h;
+ }
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::chart::{ChartDataPoint, ChartSeries, ChartType};
+ use rustmotion_core::css::CssStyle;
+ use rustmotion_core::traits::TimingConfig;
+
+ fn base_chart(chart_type: ChartType) -> Chart {
+ Chart {
+ chart_type,
+ data: Vec::new(),
+ animated: true,
+ animation_duration: 1.5,
+ colors: None,
+ inner_radius: 0.6,
+ fill_opacity: 0.3,
+ smooth: false,
+ categories: Vec::new(),
+ series: Vec::new(),
+ axes: Vec::new(),
+ radar_data: Vec::new(),
+ points: Vec::new(),
+ direction: None,
+ show_grid: false,
+ show_x_labels: false,
+ show_y_labels: false,
+ grid_color: "#FFFFFF15".to_string(),
+ label_color: "#888888".to_string(),
+ label_font_size: 18.0,
+ show_labels: false,
+ timing: TimingConfig::default(),
+ style: CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+ }
+
+ /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent
+ /// pixel on the surface, or `None` if nothing was painted. Mirrors the
+ /// helper `stat.rs`/`caption.rs` already use for the same kind of proof.
+ fn ink_bounds(
+ surface: &mut skia_safe::Surface,
+ w: i32,
+ h: i32,
+ ) -> Option<(i32, i32, i32, i32)> {
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (w, h),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut buf = vec![0u8; (w * h * 4) as usize];
+ let ok = snapshot.read_pixels(
+ &info,
+ &mut buf,
+ (w * 4) as usize,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ );
+ assert!(ok, "pixel read should succeed");
+ let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
+ for y in 0..h {
+ for x in 0..w {
+ if buf[((y * w + x) * 4 + 3) as usize] > 0 {
+ minx = minx.min(x);
+ maxx = maxx.max(x);
+ miny = miny.min(y);
+ maxy = maxy.max(y);
+ }
+ }
+ }
+ (minx <= maxx).then_some((minx, maxx, miny, maxy))
+ }
+
+ /// Column-wise ink centers: for each x with any ink, returns the
+ /// vertical midpoint of the ink found in that column. Used to find the
+ /// horizontal center of a run of colored pixels (a bar or a label).
+ fn colored_columns(
+ surface: &mut skia_safe::Surface,
+ w: i32,
+ h: i32,
+ matches_color: impl Fn(u8, u8, u8) -> bool,
+ ) -> Vec {
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (w, h),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut buf = vec![0u8; (w * h * 4) as usize];
+ snapshot.read_pixels(
+ &info,
+ &mut buf,
+ (w * 4) as usize,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ );
+ let mut cols = vec![];
+ for x in 0..w {
+ let mut any = false;
+ for y in 0..h {
+ let idx = ((y * w + x) * 4) as usize;
+ let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]);
+ if a > 0 && matches_color(r, g, b) {
+ any = true;
+ break;
+ }
+ }
+ if any {
+ cols.push(x);
+ }
+ }
+ cols
+ }
+
+ /// Paint a stacked-bar chart for a `box_w`x`box_h` box, but into a
+ /// canvas with `MARGIN` px of headroom above and below it, and return
+ /// the ink's vertical extent in *box-local* coordinates (0 = box top).
+ ///
+ /// A same-size canvas hides the bug this exists to catch: an
+ /// overflowing segment's `Rect::from_xywh` gets a huge *negative*
+ /// height, which skia normalizes when drawing, so a coincidental sliver
+ /// of the inverted rect can still land inside a same-size canvas even
+ /// though the segment as a whole is nowhere near the box. Margin on
+ /// both sides catches overflow in either direction; local coordinates
+ /// let the assertion read directly as "outside the box" without redoing
+ /// the offset math at every call site.
+ fn stacked_bar_ink_local_range(chart: &Chart, box_w: i32, box_h: i32, time: f64) -> (i32, i32) {
+ const MARGIN: i32 = 300;
+ let surface_h = box_h + MARGIN * 2;
+ let mut surface =
+ skia_safe::surfaces::raster_n32_premul((box_w, surface_h)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ canvas.translate((0.0, MARGIN as f32));
+ chart
+ .paint(canvas, box_w as f32, box_h as f32, time)
+ .expect("paint must not error");
+ }
+ let (_minx, _maxx, miny, maxy) = ink_bounds(&mut surface, box_w, surface_h)
+ .expect("stacked bar chart must paint visible bars");
+ (miny - MARGIN, maxy - MARGIN)
+ }
+
+ #[test]
+ fn stacked_bar_with_all_negative_totals_stays_inside_the_box() {
+ // #2's exact repro: every category totals negative, so the old
+ // `max_val = ... .max(0.001)` floor made `seg_h` divide by a
+ // near-zero value and blew the segment thousands of chart-heights
+ // past the bottom of the box.
+ let mut chart = base_chart(ChartType::StackedBar);
+ chart.categories = vec!["Q1".to_string(), "Q2".to_string()];
+ chart.series = vec![ChartSeries {
+ name: "net".to_string(),
+ data: vec![-5.0, -3.0],
+ color: None,
+ }];
+
+ let (local_min, local_max) = stacked_bar_ink_local_range(&chart, 400, 300, 10.0);
+ assert!(
+ local_min >= 0 && local_max < 300,
+ "ink escaped the 400x300 box vertically: local y=[{local_min}..{local_max}]"
+ );
+ }
+
+ #[test]
+ fn stacked_bar_with_mixed_sign_series_stays_inside_the_box() {
+ // Revenue/cost style stack: mixed signs within the same category.
+ // `max_val` alone (the pre-fix scale) ignored the negative side
+ // entirely, so the cost segment was scaled as if it were tiny and
+ // painted far below the box.
+ let mut chart = base_chart(ChartType::StackedBar);
+ chart.categories = vec!["Q1".to_string(), "Q2".to_string()];
+ chart.series = vec![
+ ChartSeries {
+ name: "revenue".to_string(),
+ data: vec![10.0, 8.0],
+ color: None,
+ },
+ ChartSeries {
+ name: "cost".to_string(),
+ data: vec![-5.0, -3.0],
+ color: None,
+ },
+ ];
+
+ let (local_min, local_max) = stacked_bar_ink_local_range(&chart, 400, 300, 10.0);
+ assert!(
+ local_min >= 0 && local_max < 300,
+ "ink escaped the 400x300 box vertically: local y=[{local_min}..{local_max}]"
+ );
+ }
+
+ #[test]
+ fn bar_x_labels_align_with_their_own_bar_when_some_points_are_unlabeled() {
+ // #5's exact repro: with `filter_map` compacting the label list, the
+ // 2 surviving labels ("AAA", "DDD") were spread across only 2 of the
+ // 4 slots `draw_axes` computes from `x_labels.len()`, sliding every
+ // label onto the wrong bar.
+ let mut chart = base_chart(ChartType::Bar);
+ chart.show_x_labels = true;
+ chart.data = vec![
+ ChartDataPoint {
+ value: 50.0,
+ label: Some("AAA".to_string()),
+ color: Some("#0000FF".to_string()),
+ },
+ ChartDataPoint {
+ value: 50.0,
+ label: None,
+ color: Some("#0000FF".to_string()),
+ },
+ ChartDataPoint {
+ value: 50.0,
+ label: None,
+ color: Some("#0000FF".to_string()),
+ },
+ ChartDataPoint {
+ value: 50.0,
+ label: Some("DDD".to_string()),
+ color: Some("#0000FF".to_string()),
+ },
+ ];
+
+ const W: i32 = 400;
+ const H: i32 = 300;
+ let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ let bar_cols = {
+ let canvas = surface.canvas();
+ chart
+ .paint(canvas, W as f32, H as f32, 10.0)
+ .expect("paint must not error");
+ colored_columns(&mut surface, W, H, |r, g, b| r < 40 && g < 40 && b > 200)
+ };
+ // The bar (blue) columns split into 4 contiguous runs (gaps between
+ // them). The label (red) columns should center within a small
+ // distance of the *first* and *last* runs' centers, not drift onto
+ // neighboring slots.
+ assert!(!bar_cols.is_empty(), "no bars painted");
+ let mut runs: Vec<(i32, i32)> = vec![];
+ for x in bar_cols {
+ match runs.last_mut() {
+ Some((_, end)) if x <= *end + 1 => *end = x,
+ _ => runs.push((x, x)),
+ }
+ }
+ assert_eq!(runs.len(), 4, "expected 4 bar slots, got {runs:?}");
+ let bar0_center = (runs[0].0 + runs[0].1) as f32 / 2.0;
+ let bar3_center = (runs[3].0 + runs[3].1) as f32 / 2.0;
+
+ let mut label_surface =
+ skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ let label_cols = {
+ let canvas = label_surface.canvas();
+ chart
+ .paint(canvas, W as f32, H as f32, 10.0)
+ .expect("paint must not error");
+ colored_columns(&mut label_surface, W, H, |r, g, b| {
+ // label_color default #888888
+ (120..160).contains(&r) && (120..160).contains(&g) && (120..160).contains(&b)
+ })
+ };
+ assert!(!label_cols.is_empty(), "no labels painted");
+ let mut label_runs: Vec<(i32, i32)> = vec![];
+ for x in label_cols {
+ match label_runs.last_mut() {
+ Some((_, end)) if x <= *end + 4 => *end = x,
+ _ => label_runs.push((x, x)),
+ }
+ }
+ assert_eq!(
+ label_runs.len(),
+ 2,
+ "expected 2 label runs (AAA, DDD), got {label_runs:?}"
+ );
+ let label_aaa_center = (label_runs[0].0 + label_runs[0].1) as f32 / 2.0;
+ let label_ddd_center = (label_runs[1].0 + label_runs[1].1) as f32 / 2.0;
+
+ assert!(
+ (label_aaa_center - bar0_center).abs() < 15.0,
+ "AAA label (center {label_aaa_center}) should sit under bar 0 (center {bar0_center})"
+ );
+ assert!(
+ (label_ddd_center - bar3_center).abs() < 15.0,
+ "DDD label (center {label_ddd_center}) should sit under bar 3 (center {bar3_center})"
+ );
+ }
+}
diff --git a/crates/rustmotion-components/src/chart/line.rs b/crates/rustmotion-components/src/chart/line.rs
index 3576fc0..16951d6 100644
--- a/crates/rustmotion-components/src/chart/line.rs
+++ b/crates/rustmotion-components/src/chart/line.rs
@@ -43,7 +43,11 @@ impl Chart {
let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value));
let n = self.data.len();
- let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect();
+ let x_labels: Vec = self
+ .data
+ .iter()
+ .map(|d| d.label.clone().unwrap_or_default())
+ .collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false,
);
@@ -131,7 +135,11 @@ impl Chart {
let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value));
let n = self.data.len();
- let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect();
+ let x_labels: Vec = self
+ .data
+ .iter()
+ .map(|d| d.label.clone().unwrap_or_default())
+ .collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false,
);
diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs
index 3409e12..72e1b5c 100644
--- a/crates/rustmotion-components/src/chart/mod.rs
+++ b/crates/rustmotion-components/src/chart/mod.rs
@@ -210,7 +210,13 @@ impl Chart {
if !self.animated {
return 1.0;
}
- let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
+ // Ramp measured from `start_at`, not from scene time zero — matches
+ // `Counter::ramp_progress`. A chart delayed with `start_at` used to
+ // read raw scene time, so it was already fully drawn on the very
+ // first frame it became visible.
+ let start = self.timing.start_at.unwrap_or(0.0);
+ let elapsed = (time - start).max(0.0);
+ let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
// ease_out_cubic
1.0 - (1.0 - p).powi(3)
}
@@ -318,3 +324,87 @@ impl Painter for Chart {
let _ = self.paint(canvas, layout.width, layout.height, ctx.time);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rustmotion_core::traits::TimingConfig;
+
+ fn base_chart() -> Chart {
+ Chart {
+ chart_type: ChartType::Bar,
+ data: Vec::new(),
+ animated: true,
+ animation_duration: 1.5,
+ colors: None,
+ inner_radius: 0.6,
+ fill_opacity: 0.3,
+ smooth: false,
+ categories: Vec::new(),
+ series: Vec::new(),
+ axes: Vec::new(),
+ radar_data: Vec::new(),
+ points: Vec::new(),
+ direction: None,
+ show_grid: false,
+ show_x_labels: false,
+ show_y_labels: false,
+ grid_color: default_grid_color(),
+ label_color: default_label_color(),
+ label_font_size: default_label_font_size(),
+ show_labels: false,
+ timing: TimingConfig::default(),
+ style: rustmotion_core::css::CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+ }
+
+ #[test]
+ fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
+ // #3's exact repro: a chart delayed with `start_at: 2.0` and
+ // `animation_duration: 1.5` was already fully drawn (progress 1.0)
+ // on the very first frame it became visible, because the ramp read
+ // raw scene time instead of time-since-`start_at` — the same defect
+ // `Counter::ramp_progress` was fixed for.
+ let mut chart = base_chart();
+ chart.animation_duration = 1.5;
+ chart.timing = TimingConfig {
+ start_at: Some(2.0),
+ end_at: None,
+ };
+
+ assert_eq!(
+ chart.progress_at(2.0),
+ 0.0,
+ "no time has elapsed since start_at yet"
+ );
+ assert!(
+ chart.progress_at(2.75) < 1.0,
+ "still mid-ramp half a second after start_at"
+ );
+ assert_eq!(
+ chart.progress_at(3.5),
+ 1.0,
+ "animation_duration has fully elapsed since start_at"
+ );
+ }
+
+ #[test]
+ fn progress_ramp_with_no_start_at_behaves_like_before() {
+ let chart = base_chart();
+ assert_eq!(chart.progress_at(0.0), 0.0);
+ assert_eq!(chart.progress_at(1.5), 1.0);
+ }
+
+ #[test]
+ fn progress_ramp_when_not_animated_is_always_complete() {
+ let mut chart = base_chart();
+ chart.animated = false;
+ chart.timing = TimingConfig {
+ start_at: Some(2.0),
+ end_at: None,
+ };
+ assert_eq!(chart.progress_at(0.0), 1.0);
+ }
+}
diff --git a/crates/rustmotion-components/src/chart/waterfall.rs b/crates/rustmotion-components/src/chart/waterfall.rs
index 8b7c269..bda5090 100644
--- a/crates/rustmotion-components/src/chart/waterfall.rs
+++ b/crates/rustmotion-components/src/chart/waterfall.rs
@@ -35,7 +35,11 @@ impl Chart {
let max_val = all_vals.iter().fold(f64::MIN, |a, &b| a.max(b));
let range = (max_val - min_val).max(0.001);
- let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect();
+ let x_labels: Vec = self
+ .data
+ .iter()
+ .map(|d| d.label.clone().unwrap_or_default())
+ .collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true,
);
diff --git a/crates/rustmotion-components/src/codeblock/diff.rs b/crates/rustmotion-components/src/codeblock/diff.rs
index 5726c80..864d614 100644
--- a/crates/rustmotion-components/src/codeblock/diff.rs
+++ b/crates/rustmotion-components/src/codeblock/diff.rs
@@ -444,7 +444,7 @@ pub(super) fn compute_word_diff(old_line: &str, new_line: &str) -> Vec {
pending_delete.push_str(change.value());
@@ -456,7 +456,7 @@ pub(super) fn compute_word_diff(old_line: &str, new_line: &str) -> Vec Vec usize {
+ s.char_indices()
+ .nth(char_idx)
+ .map(|(i, _)| i)
+ .unwrap_or(s.len())
+}
+
pub(super) fn draw_cursor_edited_line(
canvas: &Canvas,
font: &Font,
@@ -498,7 +511,10 @@ pub(super) fn draw_cursor_edited_line(
return;
}
- let total_work: usize = edits.iter().map(|e| e.delete.len() + e.insert.len()).sum();
+ let total_work: usize = edits
+ .iter()
+ .map(|e| e.delete.chars().count() + e.insert.chars().count())
+ .sum();
if total_work == 0 {
let highlighted = highlight_code(new_line, language, theme);
if let Some(line) = highlighted.first() {
@@ -515,32 +531,33 @@ pub(super) fn draw_cursor_edited_line(
for edit in edits {
let adjusted_col = (edit.col as i64 + offset_adjust).max(0) as usize;
- let delete_len = edit.delete.len();
- let insert_len = edit.insert.len();
+ let delete_len = edit.delete.chars().count();
+ let insert_len = edit.insert.chars().count();
let edit_work = delete_len + insert_len;
if work_done + edit_work <= chars_progress {
- let end = (adjusted_col + delete_len).min(current_line.len());
- let start = adjusted_col.min(current_line.len());
- current_line.replace_range(start..end, &edit.insert);
+ let start = byte_at(¤t_line, adjusted_col);
+ let end = byte_at(¤t_line, adjusted_col + delete_len);
+ current_line.replace_range(start..end.max(start), &edit.insert);
offset_adjust += insert_len as i64 - delete_len as i64;
work_done += edit_work;
} else {
let remaining_progress = chars_progress - work_done;
if remaining_progress < delete_len {
let chars_deleted = remaining_progress;
- let del_start = (adjusted_col + delete_len - chars_deleted).min(current_line.len());
- let del_end = (adjusted_col + delete_len).min(current_line.len());
+ let first_deleted = adjusted_col + delete_len - chars_deleted;
+ let del_start = byte_at(¤t_line, first_deleted);
+ let del_end = byte_at(¤t_line, adjusted_col + delete_len);
if del_start < del_end {
current_line.replace_range(del_start..del_end, "");
}
- cursor_col = Some(del_start.min(current_line.len()));
+ cursor_col = Some(first_deleted);
} else {
let chars_inserted = remaining_progress - delete_len;
- let start = adjusted_col.min(current_line.len());
- let end = (adjusted_col + delete_len).min(current_line.len());
- let partial_insert = &edit.insert[..chars_inserted.min(edit.insert.len())];
- current_line.replace_range(start..end, partial_insert);
+ let start = byte_at(¤t_line, adjusted_col);
+ let end = byte_at(¤t_line, adjusted_col + delete_len);
+ let partial_insert = &edit.insert[..byte_at(&edit.insert, chars_inserted)];
+ current_line.replace_range(start..end.max(start), partial_insert);
cursor_col = Some(adjusted_col + chars_inserted);
}
break;
@@ -562,7 +579,8 @@ pub(super) fn draw_cursor_edited_line(
};
if should_show {
- let prefix = ¤t_line[..col.min(current_line.len())];
+ // `col` counts characters, like every other column here.
+ let prefix = ¤t_line[..byte_at(¤t_line, col)];
let (prefix_width, _) = font.measure_str(prefix, None);
let cursor_x = x + prefix_width;
let mut cursor_paint = paint_from_hex(cursor_color);
diff --git a/crates/rustmotion-components/src/codeblock/highlight.rs b/crates/rustmotion-components/src/codeblock/highlight.rs
index 8dfc5cf..dc9c6b5 100644
--- a/crates/rustmotion-components/src/codeblock/highlight.rs
+++ b/crates/rustmotion-components/src/codeblock/highlight.rs
@@ -539,6 +539,25 @@ pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
);
+
+ // #7: a custom/Google font declared in the scenario for `family` must
+ // win over the hardcoded monospace fallback list below — check the
+ // custom registry directly, first. Previously the only place that
+ // consulted it was `typeface_with_fallback`, reached solely through the
+ // final `.or_else` below; but the `fallbacks` list's `match_family_style`
+ // calls (which try `family` itself first, among plain system families)
+ // already return *something* on essentially every real system — Skia's
+ // system `FontMgr` almost never returns `None` for "JetBrains Mono"/
+ // "Fira Code"/"Menlo"/"Courier New"/"monospace" collectively — so that
+ // `.or_else` was never reached and a declared custom font (an Anton
+ // `.ttf`, a Google "IBM Plex Mono") was silently ignored (see commit
+ // b4603f9, which fixed the equivalent regression for `text`).
+ if let Some(typeface) =
+ rustmotion_core::engine::renderer::resolve_custom_typeface(family, style)
+ {
+ return Some(Font::from_typeface(typeface, size));
+ }
+
let fallbacks = [
family,
"JetBrains Mono",
@@ -556,3 +575,61 @@ pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight
})?;
Some(Font::from_typeface(typeface, size))
}
+
+#[cfg(test)]
+mod monospace_font_tests {
+ use super::*;
+
+ /// #7 reproduction: a codeblock's declared custom/Google font must
+ /// actually be used, not silently shadowed by the hardcoded monospace
+ /// fallback chain. Registers a real display face (Anton — visually
+ /// nothing like any of "JetBrains Mono"/"Fira Code"/"Menlo"/"Courier
+ /// New"/"monospace") under a family name that collides with none of
+ /// them, and asserts `resolve_monospace_font` actually resolves to it.
+ /// Skips on a cold font cache (no network access in CI) — the render QA
+ /// in `examples/` (e.g. `cb_anton.json`) is the visual counterpart.
+ #[test]
+ fn declared_custom_font_wins_over_hardcoded_monospace_fallbacks() {
+ let path = format!(
+ "{}/.cache/rustmotion/fonts/anton-400.ttf",
+ std::env::var("HOME").unwrap_or_default()
+ );
+ let Ok(bytes) = std::fs::read(&path) else {
+ return; // cold font cache → skip (render QA covers it)
+ };
+
+ let font_mgr = rustmotion_core::engine::renderer::font_mgr();
+ let parsed = font_mgr
+ .new_from_data(&skia_safe::Data::new_copy(&bytes), None)
+ .expect("cached TTF must parse");
+ let parsed_style = parsed.font_style();
+ rustmotion_core::engine::renderer::register_custom_font_variant(
+ "RmProbeCodeblockAnton",
+ bytes,
+ *parsed_style.weight(),
+ false,
+ );
+
+ let font = resolve_monospace_font("RmProbeCodeblockAnton", 20.0, FontWeight::Normal)
+ .expect("resolve_monospace_font must succeed");
+ assert_eq!(
+ font.typeface().family_name(),
+ "Anton",
+ "declared custom font must win over the hardcoded monospace fallback chain, got {}",
+ font.typeface().family_name()
+ );
+ }
+
+ /// Regression guard: an *undeclared* family (nothing in the custom
+ /// registry) must still fall through to a real monospace font via the
+ /// hardcoded chain, not fail or silently switch to some arbitrary
+ /// serif/sans system default.
+ #[test]
+ fn unregistered_family_still_falls_back_to_a_monospace_font() {
+ let font = resolve_monospace_font("RmProbeNoSuchFamilyXYZ", 20.0, FontWeight::Normal)
+ .expect("must still resolve a fallback font");
+ // Not asserting a specific family name (host-dependent) — just that
+ // resolution succeeds and doesn't panic/None out.
+ assert!(font.size() > 0.0);
+ }
+}
diff --git a/crates/rustmotion-components/src/codeblock/render.rs b/crates/rustmotion-components/src/codeblock/render.rs
index 6c770ba..1c98c46 100644
--- a/crates/rustmotion-components/src/codeblock/render.rs
+++ b/crates/rustmotion-components/src/codeblock/render.rs
@@ -94,7 +94,7 @@ pub(super) fn render_codeblock(
let x = layout.x;
let y = layout.y;
- let (pad_top, pad_right, _pad_bottom, pad_left) = padding;
+ let (pad_top, pad_right, pad_bottom, pad_left) = padding;
let corner_radius = layer.style.border_radius_px_or(12.0);
let bg_color = layer.style.background_color_str().unwrap_or("#2b303b");
@@ -114,8 +114,40 @@ pub(super) fn render_codeblock(
let code_x = x + pad_left + gutter_width;
let code_y = y + chrome_height + pad_top;
- let scroll_offset = if layer.auto_scroll && natural_height > total_height + 0.5 {
- natural_height - total_height
+ // #4: the non-transition (typewriter/reveal) path only paints
+ // `visible_lines` lines, not the full `current_code` — `natural_height`
+ // (used below) is the height of *all* the code, revealed or not. Using
+ // it for the scroll offset made the offset constant and maximal from
+ // t=0, translating the not-yet-revealed lines' eventual position
+ // upward by the full amount immediately: the first lines to reveal sit
+ // above the clip, invisible, until the reveal has caught up with that
+ // fixed offset (reproduced: 60% of a 4s typewriter reveal painted zero
+ // text pixels). Compute reveal state up front so the scroll offset can
+ // be based on what's actually drawn — matches `terminal.rs`'s
+ // `content_h = visible_lines * line_h + padding + chrome_h` formula,
+ // which has never had this bug.
+ let reveal_state = if transition.is_none() {
+ let highlighted = highlight_code(¤t_code, &layer.language, theme);
+ let (visible_lines, visible_chars, last_line_opacity) =
+ compute_reveal(layer, time, &highlighted);
+ Some((highlighted, visible_lines, visible_chars, last_line_opacity))
+ } else {
+ None
+ };
+
+ // Diff transitions (`render_diff_transition`) always paint the entire
+ // lerped diff, with no partial reveal — `natural_height` (the lerped
+ // dims_a/dims_b height) already matches what gets drawn for that path,
+ // so it needs no `visible_lines` adjustment; only the reveal path did.
+ let drawn_height = match &reveal_state {
+ Some((_, visible_lines, _, _)) => {
+ *visible_lines as f32 * actual_line_height + pad_top + pad_bottom + chrome_height
+ }
+ None => natural_height,
+ };
+
+ let scroll_offset = if layer.auto_scroll {
+ (drawn_height - total_height).max(0.0)
} else {
0.0
};
@@ -149,9 +181,8 @@ pub(super) fn render_codeblock(
trans,
);
} else {
- let highlighted = highlight_code(¤t_code, &layer.language, theme);
- let (visible_lines, visible_chars, last_line_opacity) =
- compute_reveal(layer, time, &highlighted);
+ let (highlighted, visible_lines, visible_chars, last_line_opacity) =
+ reveal_state.expect("reveal_state is always Some when transition is None");
if layer.show_line_numbers {
draw_line_numbers(
diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs
index 4bc85dd..d9511e4 100644
--- a/crates/rustmotion-components/src/counter.rs
+++ b/crates/rustmotion-components/src/counter.rs
@@ -295,7 +295,10 @@ mod tests {
suffix: None,
easing: EasingType::default(),
duration,
- timing: TimingConfig { start_at, end_at: None },
+ timing: TimingConfig {
+ start_at,
+ end_at: None,
+ },
style: CssStyle::default(),
timeline: Vec::new(),
stagger: None,
diff --git a/crates/rustmotion-components/src/dot_map.rs b/crates/rustmotion-components/src/dot_map.rs
index ae69d1f..5e5680a 100644
--- a/crates/rustmotion-components/src/dot_map.rs
+++ b/crates/rustmotion-components/src/dot_map.rs
@@ -133,7 +133,11 @@ impl DotMap {
if !self.animated {
return 1.0;
}
- let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
+ // Measure from `start_at`, like every other animated component: driving
+ // the ramp off raw scene time makes a delayed map arrive already drawn.
+ let start = self.timing.start_at.unwrap_or(0.0);
+ let elapsed = (time - start).max(0.0);
+ let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}
@@ -154,12 +158,23 @@ impl DotMap {
world_paint.set_style(PaintStyle::Fill);
world_paint.set_anti_alias(true);
- let spacing = self.dot_spacing;
+ // A spacing at or below zero makes the division +inf, and `inf as u32`
+ // saturates to u32::MAX in Rust — the nested loop below would then be
+ // scheduled for ~1.8e19 iterations and never return. The geometry pass
+ // rejects 0.01 but not 0, so the floor has to live here.
+ let spacing = if self.dot_spacing.is_finite() && self.dot_spacing >= 1.0 {
+ self.dot_spacing
+ } else {
+ 1.0
+ };
let radius = self.dot_radius;
let margin = spacing;
- let cols = ((w - margin * 2.0) / spacing) as u32;
- let rows = ((h - margin * 2.0) / spacing) as u32;
+ // Belt and braces: even a legal spacing on a very large box should not
+ // be able to schedule an unbounded amount of work.
+ const MAX_DOTS_PER_AXIS: u32 = 4096;
+ let cols = (((w - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
+ let rows = (((h - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS);
for row in 0..rows {
for col in 0..cols {
diff --git a/crates/rustmotion-components/src/gauge.rs b/crates/rustmotion-components/src/gauge.rs
index c62449d..bb939c9 100644
--- a/crates/rustmotion-components/src/gauge.rs
+++ b/crates/rustmotion-components/src/gauge.rs
@@ -95,7 +95,11 @@ impl Gauge {
if !self.animated {
return 1.0;
}
- let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
+ // Measure from `start_at`, like every other animated component: driving
+ // the ramp off raw scene time makes a delayed gauge arrive already full.
+ let start = self.timing.start_at.unwrap_or(0.0);
+ let elapsed = (time - start).max(0.0);
+ let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}
diff --git a/crates/rustmotion-components/src/heatmap.rs b/crates/rustmotion-components/src/heatmap.rs
index 246ae1f..bfb20e4 100644
--- a/crates/rustmotion-components/src/heatmap.rs
+++ b/crates/rustmotion-components/src/heatmap.rs
@@ -90,11 +90,16 @@ fn interpolate_color(scale: &[String], t: f32) -> (u8, u8, u8) {
return (r, g, b);
}
let n = scale.len() - 1;
- let segment = (t * n as f32).floor() as usize;
- let local_t = t * n as f32 - segment as f32;
- let i = segment.min(n - 1);
- let (r1, g1, b1, _) = parse_hex_color(&scale[i]);
- let (r2, g2, b2, _) = parse_hex_color(&scale[i + 1]);
+ let scaled = t * n as f32;
+ // Clamp the segment index (t=1.0 lands exactly on `n`, one past the
+ // last valid segment), but re-derive `local_t` from the *clamped*
+ // segment rather than reusing the unclamped one — otherwise t=1.0
+ // computed local_t=0.0 against the clamped (second-to-last) segment and
+ // resolved to the second-to-last color instead of the last one.
+ let segment = (scaled.floor() as usize).min(n - 1);
+ let local_t = (scaled - segment as f32).clamp(0.0, 1.0);
+ let (r1, g1, b1, _) = parse_hex_color(&scale[segment]);
+ let (r2, g2, b2, _) = parse_hex_color(&scale[segment + 1]);
(
lerp_u8(r1, r2, local_t),
lerp_u8(g1, g2, local_t),
@@ -107,7 +112,13 @@ impl Heatmap {
if !self.animated {
return 1.0;
}
- let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
+ // Ramp measured from `start_at`, not from scene time zero — matches
+ // `Counter::ramp_progress`. A heatmap delayed with `start_at` used
+ // to read raw scene time, so it was already fully revealed on the
+ // very first frame it became visible.
+ let start = self.timing.start_at.unwrap_or(0.0);
+ let elapsed = (time - start).max(0.0);
+ let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}
@@ -121,17 +132,6 @@ impl Heatmap {
let progress = self.progress_at(time);
- // Find min/max across all cells
- let mut min_val = f64::MAX;
- let mut max_val = f64::MIN;
- for row in &self.data {
- for &val in row {
- min_val = min_val.min(val);
- max_val = max_val.max(val);
- }
- }
- let range = (max_val - min_val).max(0.001);
-
// Animation: clip rect expanding from left to right
let clip_w = w * progress;
canvas.save();
@@ -145,7 +145,15 @@ impl Heatmap {
for (row_idx, row) in self.data.iter().enumerate() {
for (col_idx, &val) in row.iter().enumerate() {
- let normalized = ((val - min_val) / range) as f32;
+ // `color_scale` documents an absolute 0.0-1.0 semantic
+ // (SKILL.md: "2D array of f64, values 0.0-1.0"), not a
+ // per-render min-max scale. Renormalizing meant a grid of
+ // constant values (or any subrange, e.g. [0.8, 0.9, 1.0])
+ // painted identically to a grid of zeros — a flat or
+ // uniformly-high grid is not the same fact as "nothing
+ // happened". Clamp into the documented range instead of
+ // rescaling to whatever the data happens to span.
+ let normalized = (val as f32).clamp(0.0, 1.0);
let (r, g, b) = interpolate_color(&self.color_scale, normalized);
let x = col_idx as f32 * step;
@@ -179,3 +187,114 @@ impl Painter for Heatmap {
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rustmotion_core::traits::TimingConfig;
+
+ fn base_heatmap(data: Vec>) -> Heatmap {
+ Heatmap {
+ data,
+ color_scale: default_color_scale(),
+ cell_size: default_cell_size(),
+ cell_gap: default_cell_gap(),
+ cell_radius: default_cell_radius(),
+ animated: true,
+ animation_duration: 1.5,
+ timing: TimingConfig::default(),
+ style: CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+ }
+
+ fn cell_color(heatmap: &Heatmap, w: i32, h: i32, time: f64) -> (u8, u8, u8) {
+ let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ heatmap.paint(canvas, w as f32, h as f32, time);
+ }
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (1, 1),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut buf = [0u8; 4];
+ // Sample the middle of the top-left cell.
+ let x = (heatmap.cell_size / 2.0) as i32;
+ let y = (heatmap.cell_size / 2.0) as i32;
+ snapshot.read_pixels(
+ &info,
+ &mut buf,
+ 4,
+ skia_safe::IPoint::new(x, y),
+ skia_safe::image::CachingHint::Disallow,
+ );
+ (buf[0], buf[1], buf[2])
+ }
+
+ #[test]
+ fn a_uniformly_low_grid_is_not_identical_to_an_all_zero_grid() {
+ // #6's exact repro: `color_scale` is documented (SKILL.md) as an
+ // *absolute* 0.0-1.0 scale, but the painter renormalized min→max —
+ // so a grid of constant 5.0s (or any other constant) rendered
+ // pixel-for-pixel identical to a grid of constant 0.0s, both
+ // collapsing to the scale's first (lowest) color.
+ let uniform = base_heatmap(vec![vec![5.0, 5.0, 5.0], vec![5.0, 5.0, 5.0]]);
+ let zero = base_heatmap(vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]]);
+ let uniform_color = cell_color(&uniform, 200, 100, 10.0);
+ let zero_color = cell_color(&zero, 200, 100, 10.0);
+ assert_ne!(
+ uniform_color, zero_color,
+ "a grid of 5.0s must not render identically to a grid of 0.0s"
+ );
+ }
+
+ #[test]
+ fn absolute_values_are_not_renormalized_to_the_data_subrange() {
+ // A grid whose values happen to span [0.8, 1.0] must not stretch
+ // that subrange to fill the whole color scale — 0.8 reads as
+ // "mostly full", not as "the bottom of whatever this grid contains".
+ let high = base_heatmap(vec![vec![0.8, 0.9, 1.0]]);
+ let low = base_heatmap(vec![vec![0.0, 0.1, 0.2]]);
+ let high_first_cell = cell_color(&high, 200, 100, 10.0);
+ let low_first_cell = cell_color(&low, 200, 100, 10.0);
+ assert_ne!(
+ high_first_cell, low_first_cell,
+ "0.8 and 0.0 must not render as the same color"
+ );
+ }
+
+ #[test]
+ fn interpolate_color_at_the_top_of_the_scale_returns_the_last_color() {
+ // Surfaced while chasing #6: the clamped segment index was reused
+ // for `local_t` too, so t=1.0 exactly computed `local_t = 0.0` for
+ // the *clamped* (second-to-last) segment instead of `local_t = 1.0`
+ // — landing on the second-to-last color rather than the last
+ // (brightest) one.
+ let scale = default_color_scale();
+ let (r, g, b) = interpolate_color(&scale, 1.0);
+ let (er, eg, eb, _) = parse_hex_color(scale.last().unwrap());
+ assert_eq!(
+ (r, g, b),
+ (er, eg, eb),
+ "t=1.0 must resolve to the last color in the scale"
+ );
+ }
+
+ #[test]
+ fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
+ let mut heatmap = base_heatmap(vec![vec![1.0]]);
+ heatmap.animation_duration = 1.5;
+ heatmap.timing = TimingConfig {
+ start_at: Some(2.0),
+ end_at: None,
+ };
+ assert_eq!(heatmap.progress_at(2.0), 0.0);
+ assert!(heatmap.progress_at(2.75) < 1.0);
+ assert_eq!(heatmap.progress_at(3.5), 1.0);
+ }
+}
diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs
index 9e67e9b..5eb0215 100644
--- a/crates/rustmotion-components/src/intrinsic.rs
+++ b/crates/rustmotion-components/src/intrinsic.rs
@@ -24,7 +24,12 @@ use crate::gradient_text::GradientText;
use crate::kbd::Kbd;
use crate::text::Text;
-/// Cosmic-text–backed intrinsic measurer for [`Text`].
+/// Skia-backed intrinsic measurer for [`Text`] (audit #10: despite the name
+/// this module's doc header suggests, this uses `skia_safe::Font::
+/// measure_str` via `engine::renderer::text`'s fallback-aware helpers — the
+/// same primitives `Text::paint` draws with — not `engine::text::cosmic`,
+/// which has no callers on the real render path at all; see that module's
+/// doc comment).
pub struct TextIntrinsic {
content: String,
font_family: Option,
@@ -58,20 +63,38 @@ impl TextIntrinsic {
/// wrap:true unconditionally so their measured size still matches what
/// those painters actually draw.
pub fn from_parts(content: &str, style: &CssStyle, max_width: Option) -> Self {
- // No `LengthContext` is reachable here without changing this
- // constructor's signature — its only callers are `box_builder.rs`
- // and `rustmotion-cli/src/commands/geometry.rs`, both outside this
+ // No *real* `LengthContext` (real viewport, real parent width) is
+ // reachable here without changing this constructor's signature —
+ // its only callers are `box_builder.rs` and
+ // `rustmotion-cli/src/commands/geometry.rs`, both outside this
// workstream's scope (box_builder.rs is a sibling's live file this
// wave; the geometry validator re-measures via this exact type and
// must keep agreeing with it byte-for-byte, so changing what it
- // needs to pass in is not a call to make unilaterally here). So
- // `font_size`/`line_height` stay on the context-free accessors
- // (issue #125 §2's `vw`/`vh`/`rem`/`%` gap is not closed for this
- // constructor) — only `letter_spacing` below, which is used
- // exclusively by the wrap fix in `measure()`, no signature change
- // needed for it.
+ // needs to pass in is not a call to make unilaterally here).
+ //
+ // But `letter-spacing`'s and `line-height`'s `em`/`%` resolve
+ // against this element's *own* font-size (not the parent's, not the
+ // viewport) — CSS spec, also documented on
+ // `CssStyle::letter_spacing_px_ctx`/`line_height_for_ctx` — and that
+ // own font-size is already known right here, with zero signature
+ // change needed. Building a `LengthContext` carrying just that
+ // resolved `font_size` (defaults for everything else) and using the
+ // `_ctx` resolvers closes the measure-vs-paint divergence for `em`/
+ // `%` specifically (`Text`/`Caption`'s painters already resolve
+ // these two properties with the real `PaintCtx`'s viewport, but
+ // `em`/`%` on them don't read the viewport at all, so the two agree
+ // regardless of what viewport this default carries). `vw`/`vh`/
+ // `rem` on `letter-spacing`/`line-height` remain unresolved against
+ // the *real* viewport here (they fall back to this struct's default
+ // 1920×1080/16px root) — closing that fully needs the real
+ // `VideoConfig` plumbed through `box_builder.rs`/`geometry.rs`,
+ // still out of scope for the reasons above.
let font_size = style.font_size_px_or(48.0);
- let line_height_resolved = style.line_height_for(font_size);
+ let own_ctx = rustmotion_core::css::units::LengthContext {
+ font_size,
+ ..rustmotion_core::css::units::LengthContext::default()
+ };
+ let line_height_resolved = style.line_height_for_ctx(font_size, &own_ctx);
Self {
content: content.to_string(),
font_family: style.font_family.clone(),
@@ -79,7 +102,7 @@ impl TextIntrinsic {
line_height_resolved,
weight: weight_to_u16(style.font_weight.as_ref()),
italic: matches!(style.font_style, Some(CssFontStyle::Italic)),
- letter_spacing: style.letter_spacing_px(),
+ letter_spacing: style.letter_spacing_px_ctx(&own_ctx),
max_width,
wrap: true,
}
@@ -404,8 +427,8 @@ fn _line_height_unused(_: Option<&LineHeight>) {}
// ─────────────────────────────────────────────────────────────────────────────
use crate::terminal::{
- Terminal, CHROME_HEIGHT, FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT,
- PADDING as TERM_PADDING,
+ resolve_typeface as resolve_terminal_typeface, Terminal, CHROME_HEIGHT,
+ FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT, PADDING as TERM_PADDING,
};
/// Intrinsic measurer for [`Terminal`].
@@ -445,8 +468,10 @@ impl TerminalIntrinsic {
}
fn measure_max_width(t: &Terminal, font_size: f32) -> f32 {
- let font_style = skia_safe::FontStyle::normal();
- let Ok(typeface) = typeface_with_fallback("SF Mono", font_style) else {
+ // Same resolver the painter calls — see `terminal::resolve_typeface`.
+ // Measuring with one face and painting with another is how text ends up
+ // overflowing a box the geometry pass has already approved.
+ let Some(typeface) = resolve_terminal_typeface(&t.style) else {
// Font unavailable (CI without fonts); return 0 — the layout will
// be width-unconstrained and the container drives the size.
return 0.0;
@@ -1091,4 +1116,147 @@ mod tests {
w
);
}
+
+ // ─── #2 / #5: em/% typography resolve against own font-size, not 0 ────
+
+ fn text_with_style(content: &str, style: CssStyle) -> Text {
+ Text {
+ content: content.into(),
+ max_width: None,
+ timing: Default::default(),
+ style,
+ timeline: Vec::new(),
+ stagger: None,
+ text_shadow: None,
+ stroke: None,
+ text_background: None,
+ }
+ }
+
+ #[test]
+ fn line_height_percent_no_longer_collapses_the_box_to_zero_height() {
+ // #2 reproduction: `line-height: "150%"` went through the
+ // context-free `line_height_for`, which cannot resolve `%` and
+ // silently fell back to 0 — the intrinsic then reported a
+ // `line_count * 0.0 = 0` height, so `paint_pass.rs`'s `if height <=
+ // 0.0 { return }` guard skipped painting the node (and its
+ // subtree) entirely, even though `validate` reported success.
+ use rustmotion_core::css::units::LengthPercentage;
+ let text = text_with_style(
+ "VISIBLE?",
+ CssStyle {
+ font_size: Some(Length::Px(60.0)),
+ line_height: Some(LineHeight::Length(LengthPercentage::String("150%".into()))),
+ ..Default::default()
+ },
+ );
+ let m = TextIntrinsic::from_text(&text);
+ let (_w, h) = m.measure(
+ (None, None),
+ (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
+ );
+ assert!(
+ (h - 90.0).abs() < 0.5,
+ "line-height: 150% of a 60px font-size must resolve to 90px (own font-size, per \
+ CSS), got {h}"
+ );
+ }
+
+ #[test]
+ fn line_height_em_no_longer_collapses_the_box_to_zero_height() {
+ use rustmotion_core::css::units::LengthPercentage;
+ let text = text_with_style(
+ "VISIBLE?",
+ CssStyle {
+ font_size: Some(Length::Px(60.0)),
+ line_height: Some(LineHeight::Length(LengthPercentage::String("1.5em".into()))),
+ ..Default::default()
+ },
+ );
+ let m = TextIntrinsic::from_text(&text);
+ let (_w, h) = m.measure(
+ (None, None),
+ (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
+ );
+ assert!(
+ (h - 90.0).abs() < 0.5,
+ "line-height: 1.5em of a 60px font-size must resolve to 90px, got {h}"
+ );
+ // Sanity: matches the already-correct unitless-number form exactly,
+ // proving em and the bare-number multiplier agree.
+ let numeric = text_with_style(
+ "VISIBLE?",
+ CssStyle {
+ font_size: Some(Length::Px(60.0)),
+ line_height: Some(LineHeight::Number(1.5)),
+ ..Default::default()
+ },
+ );
+ let (_w, h_numeric) = TextIntrinsic::from_text(&numeric).measure(
+ (None, None),
+ (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
+ );
+ assert_eq!(h, h_numeric);
+ }
+
+ #[test]
+ fn letter_spacing_em_matches_the_equivalent_px_measurement() {
+ // #5 reproduction: `letter-spacing: "1.2em"` at font-size 200
+ // (=240px) went through the context-free `letter_spacing_px`, which
+ // returns 0 for `em` — the intrinsic reserved a box as if tracking
+ // were 0 while `Text::paint` (which already uses the `_ctx`
+ // resolver) painted with the real 240px tracking, so `validate`'s
+ // `unwrappable_text_overflow`/viewport checks (which re-measure via
+ // this same intrinsic) never saw the real, wider painted width.
+ let em_style = CssStyle {
+ font_size: Some(Length::Px(200.0)),
+ letter_spacing: Some(Length::String("1.2em".into())),
+ white_space: Some(WhiteSpace::Nowrap),
+ ..Default::default()
+ };
+ let px_style = CssStyle {
+ font_size: Some(Length::Px(200.0)),
+ letter_spacing: Some(Length::Px(240.0)),
+ white_space: Some(WhiteSpace::Nowrap),
+ ..Default::default()
+ };
+ let w_em = TextIntrinsic::from_text(&text_with_style("TRACKING", em_style))
+ .measure(
+ (None, None),
+ (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
+ )
+ .0;
+ let w_px = TextIntrinsic::from_text(&text_with_style("TRACKING", px_style))
+ .measure(
+ (None, None),
+ (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
+ )
+ .0;
+ assert!(
+ (w_em - w_px).abs() < 1.0,
+ "letter-spacing: 1.2em (font-size 200) must measure the same as the equivalent \
+ 240px value: em={w_em}, px={w_px}"
+ );
+ // And it must differ from the old (broken) zero-tracking width —
+ // otherwise this test would pass vacuously even if em still
+ // resolved to 0.
+ let w_zero_tracking = TextIntrinsic::from_text(&text_with_style(
+ "TRACKING",
+ CssStyle {
+ font_size: Some(Length::Px(200.0)),
+ white_space: Some(WhiteSpace::Nowrap),
+ ..Default::default()
+ },
+ ))
+ .measure(
+ (None, None),
+ (AvailableSpace::MaxContent, AvailableSpace::MaxContent),
+ )
+ .0;
+ assert!(
+ w_em > w_zero_tracking + 100.0,
+ "em tracking must measurably widen the line versus zero tracking: em={w_em}, \
+ zero={w_zero_tracking}"
+ );
+ }
}
diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs
index 3b0b023..773bbb8 100644
--- a/crates/rustmotion-components/src/legacy_dispatch.rs
+++ b/crates/rustmotion-components/src/legacy_dispatch.rs
@@ -119,8 +119,41 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
return;
};
+ // The `Painter` contract (traits/painter.rs, rules/paint-context.md)
+ // promises the canvas is already translated to the CONTENT-box
+ // origin, with `layout` describing the content box — padding
+ // reserved by taffy is consumed here, not left for the painter to
+ // rediscover. `Codeblock` is a deliberate, documented exception: it
+ // reads `style.padding` itself (`codeblock/render.rs` computes
+ // `code_x = x + pad_left + gutter_width` from the layout origin it
+ // receives) and paints its own background/border directly from the
+ // BORDER-box origin. Honoring the general contract for it too would
+ // double-apply padding — content shifted twice, background rect
+ // shrunk incorrectly — so it keeps receiving the untranslated
+ // border-box origin and dimensions, exactly as before this fix.
+ let is_self_padding = matches!(child.component, Component::Codeblock(_));
+
canvas.save();
- canvas.translate((layout.x, layout.y));
+ let local = if is_self_padding {
+ canvas.translate((layout.x, layout.y));
+ BoxLayout {
+ x: 0.0,
+ y: 0.0,
+ width: layout.width,
+ height: layout.height,
+ ..Default::default()
+ }
+ } else {
+ let (cx, cy, cw, ch) = layout.content_box();
+ canvas.translate((cx, cy));
+ BoxLayout {
+ x: 0.0,
+ y: 0.0,
+ width: cw,
+ height: ch,
+ ..Default::default()
+ }
+ };
let paint_ctx = PaintCtx {
time: local_time,
@@ -131,13 +164,6 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
video_height: frame.video_height,
stagger_offset: stagger_delay,
};
- let local = BoxLayout {
- x: 0.0,
- y: 0.0,
- width: layout.width,
- height: layout.height,
- ..Default::default()
- };
painter.paint_content(canvas, &local, &props, &paint_ctx);
canvas.restore();
@@ -193,6 +219,83 @@ mod tests {
}
}
+ #[test]
+ fn leaf_painter_content_is_inset_by_padding() {
+ // A 100x80 red shape at (0,0) with `padding: 20`. The Painter
+ // contract (traits/painter.rs, rules/paint-context.md) promises the
+ // canvas is already translated to the CONTENT-box origin — so the
+ // shape's own fill (which just paints (0,0)..(layout.width,
+ // layout.height)) should only cover the 60x40 content box (20,20)
+ // to (80,60), leaving the 20px padding ring showing the (empty/
+ // background) canvas underneath. Bug: the dispatcher translated to
+ // the BORDER-box origin and handed the painter the full border-box
+ // dimensions, so the fill ignored padding entirely and covered the
+ // whole (0,0)-(100,80) box.
+ use rustmotion_core::css::style::Edges;
+
+ let mut scene = vec![shape_child(100.0, 80.0, 0.0, 0.0)];
+ if let Component::Shape(s) = &mut scene[0].component {
+ s.style.padding = Some(Edges::Uniform(CLP::Px(20.0)));
+ }
+ let built = build_scene(&scene, (200.0, 200.0));
+ let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default());
+
+ let mut surface =
+ skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface");
+ let canvas = surface.canvas();
+ let dispatcher = LegacyPaintDispatcher::new(&built.components);
+ let frame = PaintFrame {
+ time: 0.0,
+ frame_index: 0,
+ fps: 30,
+ video_width: 200,
+ video_height: 200,
+ scene_duration: 1.0,
+ camera: None,
+ };
+ rustmotion_core::engine::paint_pass::paint_tree(
+ canvas,
+ &built.root,
+ &layout,
+ &frame,
+ &dispatcher,
+ );
+
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (1, 1),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let read = |x: i32, y: i32| -> [u8; 4] {
+ let mut buf = [0u8; 4];
+ assert!(snapshot.read_pixels(
+ &info,
+ &mut buf,
+ 4,
+ skia_safe::IPoint::new(x, y),
+ skia_safe::image::CachingHint::Disallow,
+ ));
+ buf
+ };
+
+ // Inside the padding ring (5,5): must NOT be red after the fix.
+ let padding_zone = read(5, 5);
+ assert!(
+ !(padding_zone[0] > 200 && padding_zone[1] < 50 && padding_zone[2] < 50),
+ "padding ring must not be painted by the leaf's own fill, got {:?}",
+ padding_zone
+ );
+ // Deep inside the content box (50,40): must be red either way.
+ let content_zone = read(50, 40);
+ assert!(
+ content_zone[0] > 200 && content_zone[1] < 50 && content_zone[2] < 50,
+ "content box must still be painted red, got {:?}",
+ content_zone
+ );
+ }
+
#[test]
fn dispatch_runs_paint_content_on_leaf() {
let scene = vec![shape_child(50.0, 30.0, 10.0, 20.0)];
diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs
index b053588..20e8092 100644
--- a/crates/rustmotion-components/src/lib.rs
+++ b/crates/rustmotion-components/src/lib.rs
@@ -126,19 +126,174 @@ pub use waveform::Waveform;
// --- Position mode ---
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+/// Constat #8: `PositionMode::Named(String)` accepts any string, but
+/// [`ChildComponent::absolute_position`] only ever treats the literal
+/// `"absolute"` specially — every other value (including the CSS-legitimate
+/// `"relative"`/`"static"`, which an LLM reasoning in CSS terms naturally
+/// reaches for) silently drops `x`/`y`: the component is taken out of flow
+/// (`is_flow()` is false for any `Some(position)`) but never receives an
+/// absolute offset either, since only `"absolute"` is matched. `x`/`y` are
+/// top-level sibling fields on `ChildComponent`, not on `PositionMode`
+/// itself, so this can't detect *whether* they were actually set — only
+/// that, if they were, they are about to be silently ignored. `"absolute"`
+/// stays completely silent (the common, correct case); anything else warns.
+pub fn is_recognized_position_name(s: &str) -> bool {
+ s == "absolute"
+}
+
+#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum PositionMode {
Absolute { x: f32, y: f32 },
Named(String),
}
+impl<'de> Deserialize<'de> for PositionMode {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum Raw {
+ Absolute { x: f32, y: f32 },
+ Named(String),
+ }
+ Ok(match Raw::deserialize(deserializer)? {
+ Raw::Absolute { x, y } => PositionMode::Absolute { x, y },
+ Raw::Named(s) => {
+ if !is_recognized_position_name(&s) && warn_once_for(&s) {
+ eprintln!(
+ "Warning: position: \"{s}\" is not \"absolute\" — this component-level \
+ `position` shorthand only honours the literal \"absolute\" (paired with \
+ `x`/`y`); any other value, including CSS-legitimate ones like \
+ \"relative\"/\"static\", is accepted but silently drops `x`/`y` instead \
+ of positioning the element (it still removes the component from flex \
+ flow). Use `style.position` for real CSS relative/static semantics."
+ );
+ }
+ PositionMode::Named(s)
+ }
+ })
+ }
+}
+
+/// True the first time this exact `position` value is seen, false afterwards.
+///
+/// `render_scene_frame` calls `prepare_scene` — and therefore re-runs this
+/// `Deserialize` over the whole scene tree — once **per frame**. An unguarded
+/// warning here would print the same line once per offending component per
+/// frame: over a thousand times on a 1200-frame render, drowning out anything
+/// else on stderr. Keyed by the value rather than a plain `Once` so a scenario
+/// with several distinct bad values still hears about each of them.
+fn warn_once_for(value: &str) -> bool {
+ use std::collections::HashSet;
+ use std::sync::{Mutex, OnceLock};
+ static SEEN: OnceLock>> = OnceLock::new();
+ SEEN.get_or_init(Default::default)
+ .lock()
+ .map(|mut seen| seen.insert(value.to_owned()))
+ .unwrap_or(false)
+}
+
impl Default for PositionMode {
fn default() -> Self {
Self::Absolute { x: 0.0, y: 0.0 }
}
}
+#[cfg(test)]
+mod position_mode_tests {
+ use super::*;
+
+ // ---- constat #8 (RED first) ----
+
+ #[test]
+ fn absolute_is_recognized() {
+ assert!(is_recognized_position_name("absolute"));
+ }
+
+ #[test]
+ fn relative_and_static_and_typos_are_not_recognized() {
+ for s in ["relative", "static", "fixed", "sticky", "Absolute", "abs"] {
+ assert!(
+ !is_recognized_position_name(s),
+ "'{s}' must not be treated as the recognised \"absolute\" value"
+ );
+ }
+ }
+
+ #[test]
+ fn absolute_object_form_still_carries_x_y() {
+ let json =
+ r#"{ "position": { "x": 10.0, "y": 20.0 }, "type": "shape", "shape": "circle" }"#;
+ let child: ChildComponent = serde_json::from_str(json).unwrap();
+ assert_eq!(child.absolute_position(), Some((10.0, 20.0)));
+ }
+
+ #[test]
+ fn absolute_string_form_with_sibling_x_y_still_carries_them() {
+ let json =
+ r#"{ "position": "absolute", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#;
+ let child: ChildComponent = serde_json::from_str(json).unwrap();
+ assert_eq!(child.absolute_position(), Some((5.0, 7.0)));
+ }
+
+ #[test]
+ fn relative_still_parses_but_drops_x_y_and_the_helper_flags_it() {
+ // The legitimate-CSS trap named in constat #8: an LLM writes
+ // `"position": "relative"` (valid CSS) with `x`/`y` alongside it,
+ // expecting a positioned element. The parse must not fail — this is
+ // legitimate JSON per the schema's own untagged catch-all — but the
+ // coordinates are provably dropped (`absolute_position()` is
+ // `None`), and `is_recognized_position_name` is the named,
+ // independently testable signal the warning path uses to detect
+ // this instead of staying silent.
+ let json =
+ r#"{ "position": "relative", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#;
+ let child: ChildComponent = serde_json::from_str(json).unwrap();
+ assert!(
+ !is_recognized_position_name("relative"),
+ "this is exactly the case the warning fires for"
+ );
+ assert_eq!(
+ child.absolute_position(),
+ None,
+ "x/y are indeed dropped for a non-\"absolute\" position — this is the silent \
+ behaviour being made loud, not a new regression"
+ );
+ // The component is still taken out of flow, same as before.
+ assert!(!child.is_flow());
+ }
+
+ /// `prepare_scene` re-runs this `Deserialize` over the whole scene tree
+ /// once per frame, so the warning must be deduplicated or a 1200-frame
+ /// render prints it 1200 times. Distinct values still each get a line.
+ #[test]
+ fn the_warning_fires_once_per_distinct_value_not_once_per_frame() {
+ let value = "position-value-used-only-by-this-test";
+ assert!(warn_once_for(value), "first sighting must warn");
+ for _ in 0..1000 {
+ assert!(
+ !warn_once_for(value),
+ "re-parsing the same value must stay silent"
+ );
+ }
+ assert!(
+ warn_once_for("a-different-position-value-for-this-test"),
+ "a different bad value must still get its own warning"
+ );
+ }
+
+ #[test]
+ fn no_position_set_is_a_normal_flow_child() {
+ let json = r#"{ "type": "shape", "shape": "circle" }"#;
+ let child: ChildComponent = serde_json::from_str(json).unwrap();
+ assert!(child.is_flow());
+ assert_eq!(child.absolute_position(), None);
+ }
+}
+
// --- Child wrapper ---
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
diff --git a/crates/rustmotion-components/src/progress.rs b/crates/rustmotion-components/src/progress.rs
index 3a0f0d5..0c0b358 100644
--- a/crates/rustmotion-components/src/progress.rs
+++ b/crates/rustmotion-components/src/progress.rs
@@ -75,10 +75,10 @@ rustmotion_core::impl_traits!(Progress {
});
impl Progress {
- fn paint(&self, canvas: &Canvas) -> Result<()> {
+ fn paint(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
match self.variant {
- ProgressVariant::Linear => self.render_linear(canvas),
- ProgressVariant::Circular => self.render_circular(canvas),
+ ProgressVariant::Linear => self.render_linear(canvas, w, h),
+ ProgressVariant::Circular => self.render_circular(canvas, w, h),
}
}
}
@@ -87,18 +87,24 @@ impl Painter for Progress {
fn paint_content(
&self,
canvas: &Canvas,
- _layout: &BoxLayout,
+ layout: &BoxLayout,
_props: &AnimatedProperties,
_ctx: &PaintCtx,
) {
- let _ = self.paint(canvas);
+ // `self.width`/`self.height` only seed the *intrinsic* size in
+ // `box_builder` (promoted to CSS when `style.width`/`style.height`
+ // are absent) — the box taffy actually assigns can differ whenever
+ // an author sets `style.width`/`style.height` or a flex-grow
+ // idiom directly, which `html-css-mental-model.md` recommends.
+ // Painting at `self.width`/`self.height` regardless left the fill
+ // sized to whichever one happened to be smaller, filling only part
+ // of its own box (or overflowing it) instead of the box.
+ let _ = self.paint(canvas, layout.width, layout.height);
}
}
impl Progress {
- fn render_linear(&self, canvas: &Canvas) -> Result<()> {
- let w = self.width;
- let h = self.height;
+ fn render_linear(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
let radius = self.border_radius;
let progress = self.progress.clamp(0.0, 1.0) as f32;
@@ -129,14 +135,12 @@ impl Progress {
Ok(())
}
- fn render_circular(&self, canvas: &Canvas) -> Result<()> {
- let w = self.width;
- let h = self.height;
+ fn render_circular(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
let progress = self.progress.clamp(0.0, 1.0) as f32;
let cx = w / 2.0;
let cy = h / 2.0;
- let radius = cx.min(cy) - self.track_width / 2.0 - 2.0;
+ let radius = (cx.min(cy) - self.track_width / 2.0 - 2.0).max(0.0);
let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0);
// Track
@@ -192,3 +196,142 @@ impl Progress {
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn base_progress(variant: ProgressVariant) -> Progress {
+ Progress {
+ progress: 0.5,
+ variant,
+ width: default_progress_width(),
+ height: default_progress_height(),
+ background_color: default_progress_bg(),
+ fill_color: default_progress_fill(),
+ border_radius: 0.0,
+ track_width: default_track_width(),
+ show_value: false,
+ timing: TimingConfig::default(),
+ style: CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+ }
+
+ fn base_ctx() -> PaintCtx {
+ PaintCtx {
+ time: 0.0,
+ scene_duration: 2.0,
+ frame_index: 0,
+ fps: 30,
+ video_width: 900,
+ video_height: 200,
+ stagger_offset: 0.0,
+ }
+ }
+
+ fn ink_bounds(
+ surface: &mut skia_safe::Surface,
+ w: i32,
+ h: i32,
+ ) -> Option<(i32, i32, i32, i32)> {
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (w, h),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut buf = vec![0u8; (w * h * 4) as usize];
+ snapshot.read_pixels(
+ &info,
+ &mut buf,
+ (w * 4) as usize,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ );
+ let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
+ for y in 0..h {
+ for x in 0..w {
+ if buf[((y * w + x) * 4 + 3) as usize] > 0 {
+ minx = minx.min(x);
+ maxx = maxx.max(x);
+ miny = miny.min(y);
+ maxy = maxy.max(y);
+ }
+ }
+ }
+ (minx <= maxx).then_some((minx, maxx, miny, maxy))
+ }
+
+ #[test]
+ fn linear_progress_fills_the_layout_box_not_its_own_width_height() {
+ // #4's exact repro: `render_linear` always drew at `self.width` x
+ // `self.height` (defaults 300x20) regardless of the box taffy
+ // actually assigned it. `box_builder` only promotes `c.width`/
+ // `c.height` to CSS when `style.width`/`style.height` are absent —
+ // so a `progress` sized via `style.width: 800` (the project's
+ // CSS-first idiom) painted a 300px-wide bar sitting inside an
+ // 800px-wide box, filling only 37% of it at `progress: 0.5`.
+ let progress = base_progress(ProgressVariant::Linear);
+ const BOX_W: f32 = 800.0;
+ const BOX_H: f32 = 24.0;
+ let layout = BoxLayout {
+ width: BOX_W,
+ height: BOX_H,
+ ..Default::default()
+ };
+ let ctx = base_ctx();
+ let props = AnimatedProperties::default();
+
+ let mut surface =
+ skia_safe::surfaces::raster_n32_premul((900, 200)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ progress.paint_content(canvas, &layout, &props, &ctx);
+ }
+ let (_minx, maxx, _miny, _maxy) =
+ ink_bounds(&mut surface, 900, 200).expect("progress must paint something");
+ // At progress 0.5 the fill should reach roughly the middle of the
+ // 800px box (~400px), not the middle of the component's own
+ // `width` field (300px -> 150px).
+ assert!(
+ maxx as f32 > BOX_W * 0.4,
+ "fill did not scale to the box's own width: max ink x = {maxx}, box width = {BOX_W}"
+ );
+ }
+
+ #[test]
+ fn circular_progress_fits_the_layout_box_not_its_own_width_height() {
+ let progress = base_progress(ProgressVariant::Circular);
+ const BOX_W: f32 = 60.0;
+ const BOX_H: f32 = 60.0;
+ let layout = BoxLayout {
+ width: BOX_W,
+ height: BOX_H,
+ ..Default::default()
+ };
+ let ctx = base_ctx();
+ let props = AnimatedProperties::default();
+
+ let mut surface =
+ skia_safe::surfaces::raster_n32_premul((300, 300)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ progress.paint_content(canvas, &layout, &props, &ctx);
+ }
+ let (minx, maxx, miny, maxy) =
+ ink_bounds(&mut surface, 300, 300).expect("progress must paint something");
+ // The ring must be centered on the 60x60 box's own center (30, 30),
+ // not on the component's own `width`/`height` fields' center
+ // (150, 10 for the 300x20 defaults) — the un-fixed painter puts the
+ // whole ring outside a small box entirely.
+ let center_x = (minx + maxx) as f32 / 2.0;
+ let center_y = (miny + maxy) as f32 / 2.0;
+ assert!(
+ (center_x - BOX_W / 2.0).abs() < 5.0 && (center_y - BOX_H / 2.0).abs() < 5.0,
+ "ring is not centered on the {BOX_W}x{BOX_H} box: center=({center_x}, {center_y})"
+ );
+ }
+}
diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs
index 5e58a8d..df6ffc1 100644
--- a/crates/rustmotion-components/src/shape.rs
+++ b/crates/rustmotion-components/src/shape.rs
@@ -61,7 +61,15 @@ impl Painter for Shape {
.iter()
.map(|c| color4f_from_hex(c))
.collect();
- let stops: Option> = gradient.stops.clone();
+ // skia asserts `pos.len() == colors.len()` inside the gradient
+ // shader — a mismatch aborts the process instead of erroring.
+ // Nothing upstream enforces the pairing, so drop stops we
+ // cannot honour and let skia distribute the colours evenly.
+ let stops: Option> = gradient
+ .stops
+ .as_ref()
+ .filter(|s| s.len() == colors.len())
+ .cloned();
let mut paint = Paint::default();
paint.set_anti_alias(true);
diff --git a/crates/rustmotion-components/src/sparkline.rs b/crates/rustmotion-components/src/sparkline.rs
index a8946d4..e5fde0f 100644
--- a/crates/rustmotion-components/src/sparkline.rs
+++ b/crates/rustmotion-components/src/sparkline.rs
@@ -60,12 +60,45 @@ rustmotion_core::impl_traits!(Sparkline {
Styled => style,
});
+/// `(min, max, normalize)` for a min-max scaled series, matching
+/// `chart::line::series_scale`'s flat-series handling: a constant series is
+/// centred (`0.5`) instead of collapsing to the bottom edge. Dividing by a
+/// `max(0.001)` floor mapped a constant series to 0, so a flat series read
+/// as "all zero" instead of "constant at some value". Duplicated locally —
+/// `chart::line::series_scale` is `pub(super)` to the `chart` module, not
+/// reachable from here.
+fn series_scale(values: impl Iterator
- + Clone) -> (f64, f64, impl Fn(f64) -> f32) {
+ let min_val = values.clone().fold(f64::INFINITY, f64::min);
+ let max_val = values.fold(f64::NEG_INFINITY, f64::max);
+ let (min_val, max_val) = if min_val.is_finite() && max_val.is_finite() {
+ (min_val, max_val)
+ } else {
+ (0.0, 0.0)
+ };
+ let span = max_val - min_val;
+ let flat = span.abs() < f64::EPSILON;
+ let range = if flat { 1.0 } else { span };
+ (min_val, max_val, move |v: f64| {
+ if flat {
+ 0.5
+ } else {
+ ((v - min_val) / range) as f32
+ }
+ })
+}
+
impl Sparkline {
fn progress_at(&self, time: f64) -> f32 {
if !self.animated {
return 1.0;
}
- let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
+ // Ramp measured from `start_at`, not from scene time zero — matches
+ // `Counter::ramp_progress`. A sparkline delayed with `start_at` used
+ // to read raw scene time, so it was already fully revealed on the
+ // very first frame it became visible.
+ let start = self.timing.start_at.unwrap_or(0.0);
+ let elapsed = (time - start).max(0.0);
+ let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}
@@ -79,9 +112,7 @@ impl Sparkline {
let progress = self.progress_at(time);
- let max_val = self.data.iter().fold(f64::MIN, |a, &b| a.max(b));
- let min_val = self.data.iter().fold(f64::MAX, |a, &b| a.min(b));
- let range = (max_val - min_val).max(0.001);
+ let (_, _, norm) = series_scale(self.data.iter().copied());
let pad = self.stroke_width;
@@ -90,7 +121,7 @@ impl Sparkline {
for (i, &val) in self.data.iter().enumerate() {
let x = pad + (i as f32 / (n - 1) as f32) * (w - pad * 2.0);
- let y = pad + (h - pad * 2.0) - ((val - min_val) / range) as f32 * (h - pad * 2.0);
+ let y = pad + (h - pad * 2.0) - norm(val) * (h - pad * 2.0);
if i == 0 {
line_path.move_to((x, y));
@@ -166,3 +197,101 @@ impl Painter for Sparkline {
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rustmotion_core::traits::TimingConfig;
+
+ fn base_sparkline(data: Vec) -> Sparkline {
+ Sparkline {
+ data,
+ color: default_color(),
+ fill: false,
+ fill_opacity: default_fill_opacity(),
+ stroke_width: default_stroke_width(),
+ animated: true,
+ animation_duration: 1.0,
+ timing: TimingConfig::default(),
+ style: CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+ }
+
+ fn ink_bounds(
+ surface: &mut skia_safe::Surface,
+ w: i32,
+ h: i32,
+ ) -> Option<(i32, i32, i32, i32)> {
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (w, h),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut buf = vec![0u8; (w * h * 4) as usize];
+ snapshot.read_pixels(
+ &info,
+ &mut buf,
+ (w * 4) as usize,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ );
+ let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
+ for y in 0..h {
+ for x in 0..w {
+ if buf[((y * w + x) * 4 + 3) as usize] > 0 {
+ minx = minx.min(x);
+ maxx = maxx.max(x);
+ miny = miny.min(y);
+ maxy = maxy.max(y);
+ }
+ }
+ }
+ (minx <= maxx).then_some((minx, maxx, miny, maxy))
+ }
+
+ #[test]
+ fn a_flat_series_is_centered_not_pinned_to_the_bottom_edge() {
+ // #7's exact repro: with every value equal, `(val - min_val)` is
+ // 0 for every point, so the line was drawn on the bottom edge
+ // (`h - pad`) — reading as "a series of zeroes" instead of "a
+ // constant series at some value". `chart::line::series_scale`
+ // was fixed to center a flat series (0.5) for exactly this reason.
+ const H: i32 = 40;
+ let flat = base_sparkline(vec![7.0, 7.0, 7.0, 7.0, 7.0]);
+ let mut surface = skia_safe::surfaces::raster_n32_premul((120, H)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ flat.paint(canvas, 120.0, H as f32, 10.0);
+ }
+ let (_minx, _maxx, miny, maxy) =
+ ink_bounds(&mut surface, 120, H).expect("flat sparkline must still paint a line");
+ let mid = (miny + maxy) as f32 / 2.0;
+ let bottom_edge = H as f32 - flat.stroke_width;
+ assert!(
+ (mid - H as f32 / 2.0).abs() < 6.0,
+ "flat series line should sit near vertical center (y~{}), got y=[{miny}..{maxy}]",
+ H / 2
+ );
+ assert!(
+ (bottom_edge - maxy as f32).abs() > 6.0,
+ "flat series line must not be pinned to the bottom edge: y=[{miny}..{maxy}], bottom={bottom_edge}"
+ );
+ }
+
+ #[test]
+ fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
+ let mut sparkline = base_sparkline(vec![1.0, 2.0, 3.0]);
+ sparkline.animation_duration = 1.5;
+ sparkline.timing = TimingConfig {
+ start_at: Some(2.0),
+ end_at: None,
+ };
+ assert_eq!(sparkline.progress_at(2.0), 0.0);
+ assert!(sparkline.progress_at(2.75) < 1.0);
+ assert_eq!(sparkline.progress_at(3.5), 1.0);
+ }
+}
diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs
index e1b3b0d..740ed5d 100644
--- a/crates/rustmotion-components/src/stat.rs
+++ b/crates/rustmotion-components/src/stat.rs
@@ -323,7 +323,12 @@ impl Stat {
let max_v = self.sparkline_data.iter().fold(f64::MIN, |a, &b| a.max(b));
let min_v = self.sparkline_data.iter().fold(f64::MAX, |a, &b| a.min(b));
- let range = (max_v - min_v).max(0.001);
+ // A flat series has no span. Flooring the divisor instead normalises
+ // every point to 0, which glues the line to the bottom edge and reads
+ // as "collapsed to zero" rather than "unchanged" — centre it instead,
+ // matching `chart::line`'s handling of the same case.
+ let span = max_v - min_v;
+ let flat = span.abs() < f64::EPSILON;
let n = self.sparkline_data.len();
let spark_color = self.sparkline_color.as_deref().unwrap_or("#3B82F6");
@@ -333,7 +338,8 @@ impl Stat {
for (i, &val) in self.sparkline_data.iter().enumerate() {
let x = pad + (i as f32 / (n - 1) as f32) * spark_w;
- let y = spark_y + spark_h - ((val - min_v) / range) as f32 * spark_h;
+ let norm = if flat { 0.5 } else { (val - min_v) / span };
+ let y = spark_y + spark_h - norm as f32 * spark_h;
if i == 0 {
line_path.move_to((x, y));
diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs
index 806eb99..0c0bfab 100644
--- a/crates/rustmotion-components/src/table.rs
+++ b/crates/rustmotion-components/src/table.rs
@@ -146,7 +146,14 @@ impl Table {
let text_color = self.style.color_str_or("#FFFFFF");
let header_text_color = self.header_text_color.as_deref().unwrap_or("#FFFFFF");
let default_row_colors = vec!["#1F2937".to_string(), "#111827".to_string()];
- let row_colors = self.row_colors.as_ref().unwrap_or(&default_row_colors);
+ // `"row_colors": []` deserializes to Some(vec![]), not None — a generator
+ // writes it to mean "no striping". Row painting indexes this slice, so an
+ // empty one has to fall back rather than reach the painter.
+ let row_colors = self
+ .row_colors
+ .as_ref()
+ .filter(|c| !c.is_empty())
+ .unwrap_or(&default_row_colors);
// Resolve fonts before the optional clip below so an early return on
// font failure keeps canvas save/restore balanced.
diff --git a/crates/rustmotion-components/src/tag_cloud.rs b/crates/rustmotion-components/src/tag_cloud.rs
index eedad5a..93fdf21 100644
--- a/crates/rustmotion-components/src/tag_cloud.rs
+++ b/crates/rustmotion-components/src/tag_cloud.rs
@@ -79,11 +79,12 @@ impl TagCloud {
1.0 - (1.0 - p).powi(3)
}
+ /// Never empty: callers index into it with `%`, and `"colors": []` — which a
+ /// generator emits to mean "no custom palette" — otherwise divides by zero.
fn palette(&self) -> Vec<&str> {
- if let Some(colors) = &self.colors {
- colors.iter().map(|s| s.as_str()).collect()
- } else {
- DEFAULT_PALETTE.to_vec()
+ match &self.colors {
+ Some(colors) if !colors.is_empty() => colors.iter().map(|s| s.as_str()).collect(),
+ _ => DEFAULT_PALETTE.to_vec(),
}
}
diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs
index 668e176..0e1d8ce 100644
--- a/crates/rustmotion-components/src/terminal.rs
+++ b/crates/rustmotion-components/src/terminal.rs
@@ -6,7 +6,8 @@ use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::{ease, AnimatedProperties};
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::{
- draw_text_with_fallback, emoji_typeface, paint_from_hex, typeface_with_fallback,
+ draw_text_with_fallback, emoji_typeface, paint_from_hex, resolve_custom_typeface,
+ typeface_with_fallback,
};
use rustmotion_core::schema::{CodeblockReveal, RevealMode, TimelineStep};
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
@@ -130,10 +131,24 @@ pub(crate) const FONT_SIZE: f32 = 14.0;
pub(crate) const LINE_HEIGHT: f32 = 22.0;
pub(crate) const PADDING: f32 = 16.0;
+/// The typeface both the painter and the intrinsic measurement resolve.
+///
+/// These must never diverge. The measurement reserves the box the painter then
+/// fills, so a different face on either side produces text that overflows a box
+/// the geometry validator has already declared safe — the exact failure mode the
+/// audit found across the text components. Both sides used to hardcode
+/// `"SF Mono"` independently, which agreed only by coincidence and ignored an
+/// explicit `font-family` outright, custom or not.
+pub(crate) fn resolve_typeface(style: &CssStyle) -> Option {
+ let font_style = skia_safe::FontStyle::normal();
+ let family = style.font_family_or("SF Mono");
+ resolve_custom_typeface(family, font_style)
+ .or_else(|| typeface_with_fallback(family, font_style).ok())
+}
+
impl Terminal {
fn make_font(&self) -> Option {
- let font_style = skia_safe::FontStyle::normal();
- let typeface = typeface_with_fallback("SF Mono", font_style).ok()?;
+ let typeface = resolve_typeface(&self.style)?;
let size = self.style.font_size_px_or(FONT_SIZE);
Some(skia_safe::Font::from_typeface(typeface, size))
}
diff --git a/crates/rustmotion-components/src/treemap.rs b/crates/rustmotion-components/src/treemap.rs
index b29195d..fb7872b 100644
--- a/crates/rustmotion-components/src/treemap.rs
+++ b/crates/rustmotion-components/src/treemap.rs
@@ -113,7 +113,13 @@ impl Treemap {
if !self.animated {
return 1.0;
}
- let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
+ // Ramp measured from `start_at`, not from scene time zero — matches
+ // `Counter::ramp_progress`. A treemap delayed with `start_at` used
+ // to read raw scene time, so it was already fully scaled in on the
+ // very first frame it became visible.
+ let start = self.timing.start_at.unwrap_or(0.0);
+ let elapsed = (time - start).max(0.0);
+ let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}
@@ -182,11 +188,35 @@ impl Treemap {
// Labels
if self.show_labels || self.show_values {
- if scaled_rect.width() < 30.0 || scaled_rect.height() < 20.0 {
+ let mut text_parts: Vec = vec![];
+ if self.show_labels {
+ if let Some(label) = &item.label {
+ text_parts.push(label.clone());
+ }
+ }
+ if self.show_values {
+ text_parts.push(format!("{}", item.value));
+ }
+
+ if text_parts.is_empty() {
continue;
}
let font_size = (scaled_rect.width() * 0.12).clamp(10.0, 24.0);
+ // `draw_text_with_fallback` builds a single-line `TextBlob`
+ // (renderer/text.rs) — joining label and value with "\n"
+ // never produced a line break, it fed the blob a literal
+ // control glyph. Each part now gets its own baseline, and
+ // the space each line needs (`20.0` per line, same floor
+ // the old single-line check used) is checked before
+ // drawing instead of after.
+ let line_height = font_size * 1.2;
+ if scaled_rect.width() < 30.0
+ || scaled_rect.height() < 20.0 * text_parts.len() as f32
+ {
+ continue;
+ }
+
let font = skia_safe::Font::from_typeface(&typeface, font_size);
let emoji_font =
emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
@@ -194,35 +224,29 @@ impl Treemap {
let mut text_paint = paint_from_hex("#FFFFFF");
text_paint.set_anti_alias(true);
- let mut text_parts: Vec = vec![];
- if self.show_labels {
- if let Some(label) = &item.label {
- text_parts.push(label.clone());
- }
- }
- if self.show_values {
- text_parts.push(format!("{}", item.value));
- }
-
- let text = text_parts.join("\n");
- let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
let (_, metrics) = font.metrics();
-
- let text_x = scaled_rect.left + (scaled_rect.width() - text_w) / 2.0;
- let text_y = scaled_rect.top
- + scaled_rect.height() / 2.0
- + (-metrics.ascent - metrics.descent) / 2.0;
-
- draw_text_with_fallback(
- canvas,
- &text,
- &font,
- &emoji_font,
- 0.0,
- text_x,
- text_y,
- &text_paint,
- );
+ let ascent = -metrics.ascent;
+ let descent = metrics.descent;
+ let block_h = line_height * text_parts.len() as f32;
+ let block_top = scaled_rect.top + scaled_rect.height() / 2.0 - block_h / 2.0;
+
+ for (li, part) in text_parts.iter().enumerate() {
+ let part_w = measure_text_with_fallback(part, &font, &emoji_font, 0.0);
+ let part_x = scaled_rect.left + (scaled_rect.width() - part_w) / 2.0;
+ let line_center_y = block_top + (li as f32 + 0.5) * line_height;
+ let part_y = line_center_y + (ascent - descent) / 2.0;
+
+ draw_text_with_fallback(
+ canvas,
+ part,
+ &font,
+ &emoji_font,
+ 0.0,
+ part_x,
+ part_y,
+ &text_paint,
+ );
+ }
}
}
}
@@ -239,3 +263,169 @@ impl Painter for Treemap {
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rustmotion_core::traits::TimingConfig;
+
+ fn base_treemap(data: Vec) -> Treemap {
+ Treemap {
+ data,
+ gap: default_gap(),
+ border_radius: default_border_radius(),
+ show_labels: default_show_labels(),
+ show_values: false,
+ animated: true,
+ animation_duration: 1.0,
+ timing: TimingConfig::default(),
+ style: CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+ }
+
+ fn read_rgba(surface: &mut skia_safe::Surface, w: i32, h: i32) -> Vec {
+ let snapshot = surface.image_snapshot();
+ let info = skia_safe::ImageInfo::new(
+ (w, h),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ let mut buf = vec![0u8; (w * h * 4) as usize];
+ snapshot.read_pixels(
+ &info,
+ &mut buf,
+ (w * 4) as usize,
+ skia_safe::IPoint::new(0, 0),
+ skia_safe::image::CachingHint::Disallow,
+ );
+ buf
+ }
+
+ /// A pixel is "text ink" if it's opaque-ish and near-white — the fixed
+ /// `#FFFFFF` label/value color, distinct from the cell's own colored
+ /// (never white) `DEFAULT_PALETTE` background fill that would otherwise
+ /// dominate a naive alpha-only scan.
+ fn is_text_ink(buf: &[u8], w: i32, x: i32, y: i32) -> bool {
+ let idx = ((y * w + x) * 4) as usize;
+ let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]);
+ a > 40 && r > 200 && g > 200 && b > 200
+ }
+
+ /// Contiguous vertical bands (start_y, end_y) of text ink, merging rows
+ /// separated by a 1px anti-aliasing gap but splitting on anything
+ /// wider — used to tell "two stacked text lines" apart from "one line
+ /// of text".
+ fn row_bands(buf: &[u8], w: i32, h: i32) -> Vec<(i32, i32)> {
+ let mut bands: Vec<(i32, i32)> = vec![];
+ for y in 0..h {
+ let has_ink = (0..w).any(|x| is_text_ink(buf, w, x, y));
+ if has_ink {
+ match bands.last_mut() {
+ Some((_, end)) if y <= *end + 1 => *end = y,
+ _ => bands.push((y, y)),
+ }
+ }
+ }
+ bands
+ }
+
+ fn row_ink_x_range(buf: &[u8], w: i32, y0: i32, y1: i32) -> (i32, i32) {
+ let (mut minx, mut maxx) = (i32::MAX, i32::MIN);
+ for y in y0..=y1 {
+ for x in 0..w {
+ if is_text_ink(buf, w, x, y) {
+ minx = minx.min(x);
+ maxx = maxx.max(x);
+ }
+ }
+ }
+ (minx, maxx)
+ }
+
+ #[test]
+ fn label_and_value_render_on_two_separate_centered_lines() {
+ // #8's exact repro: `text_parts.join("\n")` fed a single-line
+ // `TextBlob` a literal "\n" glyph — the label and value landed side
+ // by side on the same baseline instead of stacked, and the whole
+ // (wrongly wide) string was centered as one block, decentering the
+ // label itself.
+ const W: i32 = 300;
+ const H: i32 = 200;
+ let mut treemap = base_treemap(vec![TreemapItem {
+ label: Some("Alpha".to_string()),
+ value: 50.0,
+ color: None,
+ }]);
+ treemap.show_labels = true;
+ treemap.show_values = true;
+ treemap.animated = false;
+
+ let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ treemap.paint(canvas, W as f32, H as f32, 0.0);
+ }
+ let buf = read_rgba(&mut surface, W, H);
+ let bands = row_bands(&buf, W, H);
+ assert_eq!(
+ bands.len(),
+ 2,
+ "label and value must render as two stacked lines, got bands={bands:?}"
+ );
+ for (y0, y1) in bands {
+ let (minx, maxx) = row_ink_x_range(&buf, W, y0, y1);
+ let center = (minx + maxx) as f32 / 2.0;
+ assert!(
+ (center - W as f32 / 2.0).abs() < 12.0,
+ "line y=[{y0}..{y1}] is not centered on the box: ink x center = {center}"
+ );
+ }
+ }
+
+ #[test]
+ fn a_single_label_still_renders_as_one_centered_line() {
+ const W: i32 = 300;
+ const H: i32 = 200;
+ let mut treemap = base_treemap(vec![TreemapItem {
+ label: Some("Alpha".to_string()),
+ value: 50.0,
+ color: None,
+ }]);
+ treemap.show_labels = true;
+ treemap.show_values = false;
+ treemap.animated = false;
+
+ let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
+ {
+ let canvas = surface.canvas();
+ treemap.paint(canvas, W as f32, H as f32, 0.0);
+ }
+ let buf = read_rgba(&mut surface, W, H);
+ let bands = row_bands(&buf, W, H);
+ assert_eq!(
+ bands.len(),
+ 1,
+ "a single label is one line, got bands={bands:?}"
+ );
+ }
+
+ #[test]
+ fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
+ let mut treemap = base_treemap(vec![TreemapItem {
+ label: None,
+ value: 1.0,
+ color: None,
+ }]);
+ treemap.animation_duration = 1.5;
+ treemap.timing = TimingConfig {
+ start_at: Some(2.0),
+ end_at: None,
+ };
+ assert_eq!(treemap.progress_at(2.0), 0.0);
+ assert!(treemap.progress_at(2.75) < 1.0);
+ assert_eq!(treemap.progress_at(3.5), 1.0);
+ }
+}
diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs
index 6b5a680..44183f4 100644
--- a/crates/rustmotion-components/tests/caption_presets.rs
+++ b/crates/rustmotion-components/tests/caption_presets.rs
@@ -19,10 +19,16 @@ const H: u32 = 300;
/// Renders a single caption component (absolutely positioned at y=150 so the
/// baseline-anchored glyphs are fully on-canvas) and returns the RGBA buffer.
fn render_caption(json: serde_json::Value, time: f64) -> Vec {
+ render_caption_at(json, time, 150.0)
+}
+
+/// Same as [`render_caption`] but with an explicit vertical position, for
+/// tests whose caption spans more lines than fit below the default y=150.
+fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec {
let component: Component = serde_json::from_value(json).expect("deserialize caption");
let child = ChildComponent {
component,
- position: Some(PositionMode::Absolute { x: 0.0, y: 150.0 }),
+ position: Some(PositionMode::Absolute { x: 0.0, y }),
x: None,
y: None,
z_index: None,
@@ -193,3 +199,65 @@ fn karaoke_pop_highlights_active_word_with_pill() {
"inactive words not visible: {inactive} white pixels"
);
}
+
+/// Audit finding #1: a caption with no `max_width` set, given a box
+/// narrower than its unwrapped content via `style.width` (mirrors a caption
+/// placed inside a card, the documented use case), must wrap to fit that
+/// box — not paint one wide line that bleeds out of it. Routed through the
+/// real pipeline so `CaptionIntrinsic` (the box taffy reserves) and
+/// `Caption::paint` (what actually gets drawn) are exercised together: this
+/// is exactly the measure-vs-paint pairing the geometry validator depends
+/// on to catch overflow, and before the fix the two disagreed silently.
+#[test]
+fn wraps_within_its_layout_box_when_max_width_is_unset() {
+ let json = serde_json::json!({
+ "type": "caption",
+ "mode": "highlight",
+ "words": [
+ { "text": "the", "start": 0.0, "end": 100.0 },
+ { "text": "quick", "start": 0.0, "end": 100.0 },
+ { "text": "brown", "start": 0.0, "end": 100.0 },
+ { "text": "fox", "start": 0.0, "end": 100.0 },
+ { "text": "jumps", "start": 0.0, "end": 100.0 },
+ { "text": "over", "start": 0.0, "end": 100.0 },
+ { "text": "the", "start": 0.0, "end": 100.0 },
+ { "text": "lazy", "start": 0.0, "end": 100.0 },
+ { "text": "dog", "start": 0.0, "end": 100.0 }
+ ],
+ // No `max_width` — the box comes entirely from `style.width` below,
+ // mirroring a caption inside a fixed-width card.
+ "style": { "width": "150px", "font-size": 24, "color": "#FFFFFF" }
+ });
+ let buf = render_caption_at(json, 0.5, 20.0);
+ let (minx, maxx, miny, maxy) = ink_bounds(&buf).expect("caption must paint something");
+
+ assert!(
+ maxx - minx < 200,
+ "must wrap to roughly the 150px box width, got ink width {}",
+ maxx - minx
+ );
+ assert!(
+ maxy - miny > 60,
+ "must spread across multiple lines (9 words don't fit 150px on one line at 30px \
+ font-size), got ink height {}",
+ maxy - miny
+ );
+}
+
+/// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent pixel
+/// in an RGBA8888 `W`x`H` buffer, or `None` if nothing was painted.
+fn ink_bounds(buf: &[u8]) -> Option<(i32, i32, i32, i32)> {
+ let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
+ for y in 0..H as i32 {
+ for x in 0..W as i32 {
+ let idx = ((y * W as i32 + x) * 4 + 3) as usize;
+ if buf[idx] > 0 {
+ minx = minx.min(x);
+ maxx = maxx.max(x);
+ miny = miny.min(y);
+ maxy = maxy.max(y);
+ }
+ }
+ }
+ (minx <= maxx).then_some((minx, maxx, miny, maxy))
+}
diff --git a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs
new file mode 100644
index 0000000..80bcabe
--- /dev/null
+++ b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs
@@ -0,0 +1,141 @@
+//! Pixel test for codeblock `auto_scroll` during a typewriter reveal
+//! (audit finding #4).
+//!
+//! Routes a `codeblock` through the real pipeline (box_builder, run_layout,
+//! paint_tree) exactly like `caption_presets.rs` does, and counts painted
+//! (non-background) pixels at several points during a typewriter reveal.
+//! Before the fix, `scroll_offset` was computed from the *full* code's
+//! natural height regardless of how many lines the reveal had actually
+//! painted, so the box stayed empty for the majority of the reveal — the
+//! newly-revealed lines were translated above the clip and invisible until
+//! the reveal caught up with that constant offset.
+
+use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx};
+use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher;
+use rustmotion_components::{ChildComponent, Component, PositionMode};
+use rustmotion_core::css::taffy_bridge::ConversionContext;
+use rustmotion_core::engine::layout_pass::run_layout;
+use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame};
+
+const W: u32 = 1000;
+const H: u32 = 600;
+const SCENE_DURATION: f64 = 4.5;
+
+fn codeblock_json() -> serde_json::Value {
+ let lines: Vec = (0..40).map(|i| format!("let line_{i} = {i};")).collect();
+ let code = lines.join("\n");
+ serde_json::json!({
+ "type": "codeblock",
+ "code": code,
+ "language": "rust",
+ "auto_scroll": true,
+ "reveal": { "mode": "typewriter", "start": 0.0, "duration": 4.0 },
+ "style": { "width": "900px", "height": "300px", "font-size": 20 }
+ })
+}
+
+/// Count pixels whose color differs measurably from the codeblock's own
+/// dark background (`#2b303b` when unset) — i.e. actual glyph ink, not just
+/// "anything non-transparent" (the background rect itself is opaque and
+/// covers the whole box).
+fn text_ink_pixels(buf: &[u8]) -> usize {
+ // Background is #2b303b ~ (43, 48, 59). Count pixels that deviate from
+ // that by a wide margin in any channel — syntect's theme colors are all
+ // much brighter than the near-black background.
+ buf.chunks_exact(4)
+ .filter(|p| {
+ let (r, g, b, a) = (p[0] as i32, p[1] as i32, p[2] as i32, p[3] as i32);
+ a > 200 && ((r - 43).abs() > 40 || (g - 48).abs() > 40 || (b - 59).abs() > 40)
+ })
+ .count()
+}
+
+fn render_codeblock_at(time: f64) -> Vec {
+ let component: Component = serde_json::from_value(codeblock_json()).expect("deserialize");
+ let child = ChildComponent {
+ component,
+ position: Some(PositionMode::Absolute { x: 50.0, y: 150.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ };
+ let children = vec![child];
+
+ let mut surface =
+ skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).expect("raster surface");
+ let canvas = surface.canvas();
+ canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0));
+
+ let built = build_scene_with_anim(
+ &children,
+ (W as f32, H as f32),
+ BuildAnimationCtx {
+ time,
+ scene_duration: SCENE_DURATION,
+ fps: 30,
+ },
+ );
+ let layout = run_layout(
+ &built.root,
+ (W as f32, H as f32),
+ &ConversionContext::default(),
+ );
+ let dispatcher = LegacyPaintDispatcher::for_scene(&built);
+ let frame = PaintFrame {
+ time,
+ frame_index: (time * 30.0) as u32,
+ fps: 30,
+ video_width: W,
+ video_height: H,
+ scene_duration: SCENE_DURATION,
+ camera: None,
+ };
+ paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
+
+ let row_bytes = W as usize * 4;
+ let mut pixels = vec![0u8; row_bytes * H as usize];
+ let info = skia_safe::ImageInfo::new(
+ (W as i32, H as i32),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Premul,
+ None,
+ );
+ surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
+ pixels
+}
+
+#[test]
+fn early_reveal_shows_text_not_an_empty_box() {
+ // At t=0.5s (12.5% into a 4s typewriter reveal, well past the first
+ // line), some text must already be visible — before the fix, this
+ // stayed at 0 ink pixels until ~75% of the reveal had elapsed.
+ let ink = text_ink_pixels(&render_codeblock_at(0.5));
+ assert!(
+ ink > 20,
+ "expected visible text ink early in the reveal (t=0.5s), got {ink} ink pixels"
+ );
+}
+
+#[test]
+fn mid_reveal_shows_text_not_an_empty_box() {
+ // t=2.0s: 50% into the reveal — well within the range the audit
+ // measured as a completely empty box (0 ink pixels at t=0.5/1.0/2.0).
+ let ink = text_ink_pixels(&render_codeblock_at(2.0));
+ assert!(
+ ink > 20,
+ "expected visible text ink mid-reveal (t=2.0s), got {ink} ink pixels"
+ );
+}
+
+#[test]
+fn ink_grows_monotonically_enough_across_the_reveal() {
+ // Sanity: as more of the reveal completes, more text should be on
+ // screen (loosely monotonic — auto_scroll keeps only the visible
+ // window, so it won't be strictly increasing forever, but the box must
+ // never regress to empty once text has started appearing).
+ let t_early = text_ink_pixels(&render_codeblock_at(0.5));
+ let t_late = text_ink_pixels(&render_codeblock_at(3.9));
+ assert!(t_early > 0, "must have visible ink at t=0.5s, got 0");
+ assert!(t_late > 0, "must have visible ink at t=3.9s, got 0");
+}
diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs
new file mode 100644
index 0000000..8b609f3
--- /dev/null
+++ b/crates/rustmotion-components/tests/degenerate_inputs.rs
@@ -0,0 +1,185 @@
+//! A painter must never panic — nor hang — on input the schema accepts.
+//!
+//! `rustmotion validate` is the documented gate before delivery, and it answers
+//! "Valid scenario" for every component below. Each one was observed aborting or
+//! wedging the renderer mid-frame, which kills the whole encode: the scenarios
+//! here are the reproductions, verbatim, kept as a regression floor.
+//!
+//! Every case routes through the real pipeline — serde, box_builder, run_layout,
+//! paint_tree — so a fix that only guards the painter while leaving the component
+//! undeserialisable would still fail.
+
+use std::sync::mpsc;
+use std::time::Duration;
+
+use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx};
+use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher;
+use rustmotion_components::{ChildComponent, Component, PositionMode};
+use rustmotion_core::css::taffy_bridge::ConversionContext;
+use rustmotion_core::engine::layout_pass::run_layout;
+use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame};
+
+const W: u32 = 400;
+const H: u32 = 300;
+
+/// Deserialize one component and paint it at `time`. Panics propagate — that is
+/// the point of the test.
+fn paint(json: serde_json::Value, time: f64) {
+ let component: Component = serde_json::from_value(json).expect("component is schema-valid");
+ let children = vec![ChildComponent {
+ component,
+ position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }),
+ x: None,
+ y: None,
+ z_index: None,
+ bleed: false,
+ }];
+
+ let mut surface =
+ skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).expect("raster surface");
+ let canvas = surface.canvas();
+ canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0));
+
+ let built = build_scene_with_anim(
+ &children,
+ (W as f32, H as f32),
+ BuildAnimationCtx {
+ time,
+ scene_duration: 2.0,
+ fps: 30,
+ },
+ );
+ let layout = run_layout(
+ &built.root,
+ (W as f32, H as f32),
+ &ConversionContext::default(),
+ );
+ let dispatcher = LegacyPaintDispatcher::for_scene(&built);
+ let frame = PaintFrame {
+ time,
+ frame_index: (time * 30.0) as u32,
+ fps: 30,
+ video_width: W,
+ video_height: H,
+ scene_duration: 2.0,
+ camera: None,
+ };
+ paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
+}
+
+/// Paint on a worker so a runaway loop fails the test instead of wedging the
+/// suite. `dot_spacing: 0` used to spin for billions of iterations; a plain
+/// `paint()` call would hang CI rather than report.
+fn paint_within(json: serde_json::Value, time: f64, budget: Duration, what: &str) {
+ let (tx, rx) = mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ paint(json, time);
+ let _ = tx.send(());
+ });
+ match rx.recv_timeout(budget) {
+ Ok(()) => worker.join().expect(what),
+ Err(_) => panic!("{what}: still painting after {budget:?} — runaway loop"),
+ }
+}
+
+#[test]
+fn shape_survives_a_gradient_whose_stops_do_not_match_its_colors() {
+ // skia asserts pos.len() == colors.len() inside the gradient shader, so a
+ // mismatched `stops` aborted the process rather than returning an error.
+ for gradient_type in ["linear", "radial"] {
+ paint(
+ serde_json::json!({
+ "type": "shape",
+ "shape": "rect",
+ "style": { "width": 200, "height": 100 },
+ "fill": {
+ "type": gradient_type,
+ "colors": ["#FF0000", "#0000FF"],
+ "stops": [0.0, 0.5, 1.0]
+ }
+ }),
+ 0.5,
+ );
+ }
+}
+
+#[test]
+fn table_survives_an_empty_row_colors_list() {
+ // `row_colors: []` deserializes to Some(vec![]), so the default palette was
+ // never substituted and the modulo guard still indexed an empty slice.
+ paint(
+ serde_json::json!({
+ "type": "table",
+ "headers": ["A", "B"],
+ "rows": [["1", "2"], ["3", "4"]],
+ "row_colors": [],
+ "style": { "width": 300, "height": 200 }
+ }),
+ 0.5,
+ );
+}
+
+#[test]
+fn tag_cloud_survives_an_empty_colors_list() {
+ // palette() returned the caller's empty vec, and the painter took
+ // `index % palette.len()` on it.
+ paint(
+ serde_json::json!({
+ "type": "tag_cloud",
+ "tags": [{ "text": "rust", "weight": 3 }, { "text": "skia", "weight": 1 }],
+ "colors": [],
+ "style": { "width": 300, "height": 200 }
+ }),
+ 0.5,
+ );
+}
+
+#[test]
+fn dot_map_terminates_on_a_zero_dot_spacing() {
+ // (w - 0) / 0 is +inf, and `inf as u32` saturates to u32::MAX in Rust, so the
+ // nested loop was scheduled for ~1.8e19 iterations. The geometry pass catches
+ // dot_spacing: 0.01 but not 0.
+ paint_within(
+ serde_json::json!({
+ "type": "dot_map",
+ "points": [{ "lat": 48.8, "lng": 2.3 }],
+ "dot_spacing": 0,
+ "style": { "width": 300, "height": 150 }
+ }),
+ 0.5,
+ Duration::from_secs(10),
+ "dot_map with dot_spacing: 0",
+ );
+}
+
+#[test]
+fn codeblock_diff_survives_multibyte_text() {
+ // The edit script counts bytes while the reveal interpolates a fraction of
+ // that count, so mid-animation offsets landed inside a multi-byte character
+ // and `replace_range` aborted with "not a char boundary".
+ for (from, to) in [
+ ("let a = 1;", "let café = «héllo→»;"),
+ ("let x = 1;", "let y = \"éàü\";"),
+ ("a", "日本語のテキスト"),
+ ("ok", "🎬 clap"),
+ ] {
+ // Sweep the reveal: the panic only fires on the frames where progress
+ // lands part-way through a glyph.
+ for step in 0..=20 {
+ paint(
+ serde_json::json!({
+ "type": "codeblock",
+ "code": from,
+ "language": "rust",
+ "diff": true,
+ "states": [
+ { "code": from, "at": 0.0 },
+ { "code": to, "at": 0.4, "cursor": { "enabled": true } }
+ ],
+ "style": { "width": 380, "height": 120 }
+ }),
+ f64::from(step) * 0.1,
+ );
+ }
+ }
+}
diff --git a/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs b/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs
new file mode 100644
index 0000000..3a2547e
--- /dev/null
+++ b/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs
@@ -0,0 +1,111 @@
+//! Regression test — audit round 4, lot LAYOUT, constat 5.
+//!
+//! `.claude/skills/rustmotion/rules/html-css-mental-model.md` — a rules file
+//! read by the LLMs that generate rustmotion scenarios — documented
+//! `"margin-top"` / `"margin-left"` as valid per-child JSON keys, and
+//! `"padding": [40, 60]` as a valid CSS-shorthand-style array. Neither is:
+//! `CssStyle` only exposes `margin: Option` / `padding: Option`
+//! (`#[serde(deny_unknown_fields)]`), and `Edges` is `Uniform(LengthPercentage)`
+//! or a `{top, right, bottom, left}` object — never a per-key kebab-case field,
+//! never an array. Following the old doc verbatim makes the whole component
+//! fail typed deserialization, so it silently disappears from the render
+//! instead of being spaced as intended.
+//!
+//! These tests pin both failure modes (so a future change can't quietly
+//! re-introduce them) and confirm the corrected syntax the doc now teaches
+//! actually round-trips.
+
+use rustmotion_components::Component;
+
+fn component_json_parses(json: serde_json::Value) -> bool {
+ serde_json::from_value::(json).is_ok()
+}
+
+#[test]
+fn old_doc_margin_top_shorthand_fails_to_deserialize() {
+ // What the doc used to teach (html-css-mental-model.md, old line 87):
+ // `"margin-top": 16` as a direct style key.
+ let json = serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": { "margin-top": 16 }
+ });
+ let err = serde_json::from_value::(json)
+ .expect_err("margin-top must not deserialize — printed below for the audit red-phase log");
+ eprintln!("captured deserialize error (constat 5 red phase): {err}");
+ assert!(
+ err.to_string().contains("margin-top") || err.to_string().contains("unknown field"),
+ "expected an unknown-field error mentioning the rejected key, got: {err}"
+ );
+}
+
+#[test]
+fn old_doc_margin_left_auto_shorthand_fails_to_deserialize() {
+ // What the doc used to teach (html-css-mental-model.md, old line 90):
+ // `"margin-left": "auto"` as a direct style key.
+ let json = serde_json::json!({
+ "type": "badge",
+ "text": "NEW",
+ "style": { "margin-left": "auto" }
+ });
+ assert!(
+ !component_json_parses(json),
+ "`margin-left` is not a CssStyle field — same failure mode as margin-top"
+ );
+}
+
+#[test]
+fn old_doc_padding_array_shorthand_fails_to_deserialize() {
+ // What the doc used to teach (html-css-mental-model.md, old line 177):
+ // `"padding": [40, 60]`, a CSS `padding: 40px 60px` -style array shorthand
+ // `Edges` does not implement.
+ let json = serde_json::json!({
+ "type": "card",
+ "style": { "padding": [40, 60] },
+ "children": []
+ });
+ assert!(
+ !component_json_parses(json),
+ "`padding` only accepts a uniform scalar or a {{top,right,bottom,left}} \
+ object, never an array — the old doc's example should fail"
+ );
+}
+
+#[test]
+fn corrected_margin_object_syntax_round_trips() {
+ let json = serde_json::json!({
+ "type": "text",
+ "content": "hi",
+ "style": { "margin": { "top": 16 } }
+ });
+ assert!(
+ component_json_parses(json),
+ "the doc's corrected `\"margin\": {{\"top\": 16}}` syntax must actually parse"
+ );
+}
+
+#[test]
+fn corrected_margin_left_auto_object_syntax_round_trips() {
+ let json = serde_json::json!({
+ "type": "badge",
+ "text": "NEW",
+ "style": { "margin": { "left": "auto" } }
+ });
+ assert!(
+ component_json_parses(json),
+ "the doc's corrected `\"margin\": {{\"left\": \"auto\"}}` syntax must actually parse"
+ );
+}
+
+#[test]
+fn corrected_padding_object_syntax_round_trips() {
+ let json = serde_json::json!({
+ "type": "card",
+ "style": { "padding": { "top": 40, "bottom": 40, "left": 60, "right": 60 } },
+ "children": []
+ });
+ assert!(
+ component_json_parses(json),
+ "the doc's corrected per-side `padding` object syntax must actually parse"
+ );
+}
diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs
index ad44f35..a37ef79 100644
--- a/crates/rustmotion-core/src/css/style.rs
+++ b/crates/rustmotion-core/src/css/style.rs
@@ -486,12 +486,21 @@ pub enum Visibility {
}
/// `width: ` / `width: auto` / `width: 50%` / `width: max-content` / etc.
+///
+/// Constat #6: `#[serde(untagged)]` tries variants in declaration order and
+/// keeps the first that succeeds. `Length(LengthPercentage)` has its own
+/// `String` catch-all variant that accepts *any* string — so with `Keyword`
+/// declared after `Length` (as this used to be), `"max-content"` matched
+/// `Length(String("max-content"))` before `Keyword` was ever tried:
+/// `max-content`/`min-content`/`fit-content` were unreachable, dead schema.
+/// `Keyword` must come before the `Length` catch-all; `Auto` before either
+/// is fine since it needs an exact `"auto"` match nothing else claims first.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Size {
Auto(AutoKw),
- Length(LengthPercentage),
Keyword(SizeKeyword),
+ Length(LengthPercentage),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -509,8 +518,19 @@ pub enum SizeKeyword {
}
/// Edge values for `margin` / `padding`. Either uniform or per-side.
+///
+/// Constat #2: `CssStyle` itself has `deny_unknown_fields`, which gives the
+/// impression that any bad key under `style` is rejected — but one level
+/// down, `Sides`'s four fields are all `#[serde(default)]` with no
+/// `deny_unknown_fields` of its own. Since this is an untagged enum, a
+/// well-meaning but unsupported shape like `{"horizontal": 20}` (the exact
+/// form the LAYOUT `margin-left` rule teaches LLMs to reach for) fails to
+/// match `Uniform` (not a scalar) and then matches `Sides` anyway — every
+/// side defaults to 0, no error. `deny_unknown_fields` here closes that: an
+/// object that isn't a recognised `{top,right,bottom,left}` shape now fails
+/// to match either variant, and the untagged enum reports it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
-#[serde(untagged)]
+#[serde(untagged, deny_unknown_fields)]
pub enum Edges {
Uniform(LengthPercentage),
Sides {
@@ -579,18 +599,35 @@ pub enum BorderStyle {
}
/// Border-radius: uniform or per-corner.
+///
+/// Constat #1: every other composite in this file is kebab-case on the wire
+/// (`box-shadow` -> `offset-x`/`offset-y`, `transform-origin` -> `x`/`y`,
+/// etc. — see `rules/component-field-placement.md`). `Corners` used to be
+/// the sole snake_case outlier (`top_left`/...), with no `deny_unknown_fields`
+/// and every field defaulted — so the kebab form a CSS-literate author (or
+/// LLM) naturally writes matched *zero* declared fields, and being an
+/// untagged enum, serde didn't complain: it just produced `Corners` with
+/// every corner at 0px, silently. `rename_all = "kebab-case"` makes kebab
+/// the canonical wire form (matching every neighbour); `alias` keeps the
+/// original snake_case working for any scenario already written that way;
+/// `deny_unknown_fields` turns any other spelling (a genuine typo) into a
+/// named parse error instead of a third silent zero.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
-#[serde(untagged)]
+#[serde(untagged, deny_unknown_fields)]
pub enum BorderRadius {
Uniform(LengthPercentage),
Corners {
- #[serde(default)]
+ #[serde(default, alias = "top_left")]
+ #[serde(rename = "top-left")]
top_left: LengthPercentage,
- #[serde(default)]
+ #[serde(default, alias = "top_right")]
+ #[serde(rename = "top-right")]
top_right: LengthPercentage,
- #[serde(default)]
+ #[serde(default, alias = "bottom_right")]
+ #[serde(rename = "bottom-right")]
bottom_right: LengthPercentage,
- #[serde(default)]
+ #[serde(default, alias = "bottom_left")]
+ #[serde(rename = "bottom-left")]
bottom_left: LengthPercentage,
},
}
@@ -786,12 +823,20 @@ pub enum FontStyle {
}
/// `line-height: 1.5` (number) or `line-height: 24px` (length).
+///
+/// Same class of bug as constat #6 on [`Size`], found while auditing this
+/// file for other untagged enums with a catch-all before a specific variant:
+/// `Length(LengthPercentage)`'s `String` fallback accepts any string, so
+/// with `Keyword` declared after it, `"normal"` matched
+/// `Length(String("normal"))` — which then resolves through
+/// `Length::px()`/`.parse()` as an unparseable length, falling back to 0 —
+/// instead of `Keyword(LineHeightKw::Normal)`. `Keyword` now comes first.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum LineHeight {
Number(f32),
- Length(LengthPercentage),
Keyword(LineHeightKw),
+ Length(LengthPercentage),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -1497,4 +1542,177 @@ mod tests {
);
assert_eq!(line_height, 300.0 * 0.85);
}
+
+ // ---- constat #1: border-radius per-corner kebab-case (RED first) ----
+
+ #[test]
+ fn border_radius_corners_accepts_kebab_case() {
+ // This is the shape every sibling composite in this file uses
+ // (box-shadow -> offset-x/offset-y, transform-origin -> x/y, etc.)
+ // and the shape `rules/component-field-placement.md` teaches. Before
+ // the fix, `BorderRadius::Corners`'s fields are literally
+ // `top_left`/`top_right`/... with no kebab alias, so this kebab
+ // object fails to match `Corners` (unknown fields) and, being all
+ // `#[serde(default)]`, matches it anyway with every corner at 0 —
+ // the untagged enum never reports an error, it just silently
+ // produces radius 0.
+ let json = r#"{ "border-radius": { "top-left": "12px", "top-right": "12px", "bottom-right": "4px", "bottom-left": "4px" } }"#;
+ let s: CssStyle = serde_json::from_str(json).unwrap();
+ match s.border_radius {
+ Some(BorderRadius::Corners {
+ top_left,
+ top_right,
+ bottom_right,
+ bottom_left,
+ }) => {
+ assert_eq!(top_left.px(), 12.0, "top-left must be honoured, not 0");
+ assert_eq!(top_right.px(), 12.0);
+ assert_eq!(bottom_right.px(), 4.0);
+ assert_eq!(bottom_left.px(), 4.0);
+ }
+ other => panic!("expected Corners, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn border_radius_corners_still_accepts_legacy_snake_case() {
+ // Back-compat: any scenario already written with the old
+ // snake_case field names must keep working identically.
+ let json = r#"{ "border-radius": { "top_left": "8px", "top_right": "8px", "bottom_right": "8px", "bottom_left": "8px" } }"#;
+ let s: CssStyle = serde_json::from_str(json).unwrap();
+ assert_eq!(s.border_radius_px(), Some(8.0));
+ }
+
+ #[test]
+ fn border_radius_corners_typo_is_a_named_error_not_a_silent_zero() {
+ // A misspelled key must not silently resolve to Corners{0,0,0,0} —
+ // it must be reported.
+ let json = r#"{ "border-radius": { "topleft": "12px" } }"#;
+ let err = serde_json::from_str::(json).expect_err("typo must be rejected");
+ let msg = err.to_string();
+ assert!(
+ msg.contains("topleft")
+ || msg.contains("border-radius")
+ || msg.contains("BorderRadius"),
+ "error must name the offending input, got: {msg}"
+ );
+ }
+
+ // ---- constat #2: `Edges` (padding/margin) rejects unknown shapes (RED first) ----
+
+ #[test]
+ fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() {
+ // `rules/margin-left-hack.md`-adjacent trap: an LLM reasoning in CSS
+ // terms writes `{"horizontal": 20}` instead of the supported
+ // `{"top":.., "right":.., "bottom":.., "left":..}` shape. Before the
+ // fix, `Edges::Sides`'s four fields are all `#[serde(default)]` with
+ // no `deny_unknown_fields`, so this object matches `Sides` anyway
+ // with every side at 0 — silent, wrong padding instead of an error.
+ let json = r#"{ "padding": { "horizontal": 20 } }"#;
+ let err = serde_json::from_str::(json)
+ .expect_err("an unrecognised padding shape must be rejected, not silently zeroed");
+ let msg = err.to_string();
+ assert!(
+ msg.contains("horizontal") || msg.contains("padding") || msg.contains("Edges"),
+ "error must name the offending input, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn edges_still_accepts_valid_per_side_object() {
+ let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
+ let s: CssStyle = serde_json::from_str(json).unwrap();
+ assert_eq!(s.padding_px(), (10.0, 20.0, 10.0, 20.0));
+ }
+
+ #[test]
+ fn edges_still_accepts_uniform_scalar() {
+ let json = r#"{ "padding": "24px" }"#;
+ let s: CssStyle = serde_json::from_str(json).unwrap();
+ assert_eq!(s.padding_px(), (24.0, 24.0, 24.0, 24.0));
+ }
+
+ // ---- constat #6: `Size` untagged variant order (RED first) ----
+
+ #[test]
+ fn size_keyword_max_content_is_reachable() {
+ // `Size` is `#[serde(untagged)]`: Auto, Length, Keyword in that
+ // declared order (before the fix). `Length(LengthPercentage)`'s
+ // `String` fallback variant accepts *any* string, so it is tried
+ // (and succeeds) before `Keyword` is ever reached — `max-content` /
+ // `min-content` / `fit-content` are dead schema. After the fix,
+ // `Keyword` must be tried before the `Length` catch-all.
+ for (kw, expected) in [
+ ("max-content", SizeKeyword::MaxContent),
+ ("min-content", SizeKeyword::MinContent),
+ ("fit-content", SizeKeyword::FitContent),
+ ] {
+ let json = format!(r#"{{ "width": "{kw}" }}"#);
+ let s: CssStyle = serde_json::from_str(&json).unwrap();
+ assert_eq!(
+ s.width,
+ Some(Size::Keyword(expected)),
+ "width: \"{kw}\" must resolve to Size::Keyword, not Size::Length(String(..))"
+ );
+ }
+ }
+
+ #[test]
+ fn size_length_and_auto_are_unaffected_by_the_reorder() {
+ let s: CssStyle = serde_json::from_str(r#"{ "width": "200px" }"#).unwrap();
+ assert!(matches!(s.width, Some(Size::Length(_))));
+ let s: CssStyle = serde_json::from_str(r#"{ "width": "50%" }"#).unwrap();
+ assert!(matches!(s.width, Some(Size::Length(_))));
+ let s: CssStyle = serde_json::from_str(r#"{ "width": "auto" }"#).unwrap();
+ assert!(matches!(s.width, Some(Size::Auto(_))));
+ let s: CssStyle = serde_json::from_str(r#"{ "width": 200 }"#).unwrap();
+ assert!(matches!(s.width, Some(Size::Length(_))));
+ }
+
+ // ---- extra: `LineHeight` has the same catch-all-before-specific shape
+ // as constat #6's `Size`, found while auditing this file for the same
+ // bug class. Fixed alongside it (see the doc comment on `LineHeight`).
+
+ #[test]
+ fn line_height_keyword_normal_is_reachable() {
+ let s: CssStyle = serde_json::from_str(r#"{ "line-height": "normal" }"#).unwrap();
+ assert_eq!(
+ s.line_height,
+ Some(LineHeight::Keyword(LineHeightKw::Normal)),
+ "line-height: \"normal\" must resolve to Keyword, not Length(String(\"normal\"))"
+ );
+ }
+
+ #[test]
+ fn line_height_number_and_length_are_unaffected_by_the_reorder() {
+ let s: CssStyle = serde_json::from_str(r#"{ "line-height": 1.5 }"#).unwrap();
+ assert!(matches!(s.line_height, Some(LineHeight::Number(_))));
+ let s: CssStyle = serde_json::from_str(r#"{ "line-height": "24px" }"#).unwrap();
+ assert!(matches!(s.line_height, Some(LineHeight::Length(_))));
+ }
+
+ // ---- border-radius: kebab-case is the canonical wire form on output ----
+
+ #[test]
+ fn border_radius_corners_serializes_as_kebab_case() {
+ let s = CssStyle {
+ border_radius: Some(BorderRadius::Corners {
+ top_left: LengthPercentage::Px(1.0),
+ top_right: LengthPercentage::Px(2.0),
+ bottom_right: LengthPercentage::Px(3.0),
+ bottom_left: LengthPercentage::Px(4.0),
+ }),
+ ..Default::default()
+ };
+ let json = serde_json::to_value(&s).unwrap();
+ let br = &json["border-radius"];
+ assert_eq!(br["top-left"], serde_json::json!(1.0));
+ assert_eq!(br["top-right"], serde_json::json!(2.0));
+ assert_eq!(br["bottom-right"], serde_json::json!(3.0));
+ assert_eq!(br["bottom-left"], serde_json::json!(4.0));
+ assert!(
+ br.get("top_left").is_none(),
+ "must not emit the legacy snake_case key any more"
+ );
+ }
}
diff --git a/crates/rustmotion-core/src/css/taffy_bridge.rs b/crates/rustmotion-core/src/css/taffy_bridge.rs
index c7a27b9..9376ebb 100644
--- a/crates/rustmotion-core/src/css/taffy_bridge.rs
+++ b/crates/rustmotion-core/src/css/taffy_bridge.rs
@@ -9,9 +9,9 @@
use taffy::prelude as tf;
use super::style::{
- AlignContent, AlignItems, AlignSelf, CssStyle, Display, Edges, FlexDirection, FlexWrap, Gap,
- GridAutoFlow, GridLine, GridLineEnd, GridTrack, GridTrackKeyword, JustifyContent, Overflow,
- Position, Size,
+ AlignContent, AlignItems, AlignSelf, BoxSizing, CssStyle, Display, Edges, FlexDirection,
+ FlexWrap, Gap, GridAutoFlow, GridLine, GridLineEnd, GridTrack, GridTrackKeyword,
+ JustifyContent, JustifyItems, JustifySelf, Overflow, Position, Size,
};
use super::units::{LengthContext, LengthPercentage, ParsedLength};
@@ -65,6 +65,18 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style {
};
style.aspect_ratio = css.aspect_ratio;
+ // `box-sizing` (round 4 audit, lot LAYOUT, constat 3): taffy supports it
+ // natively (`Style::box_sizing`, default `BorderBox`) — schema-valid but
+ // untranslated before this fix, so `content-box` was silently ignored
+ // and every sized box behaved as `border-box` regardless of what the
+ // author declared.
+ if let Some(bs) = css.box_sizing {
+ style.box_sizing = match bs {
+ BoxSizing::ContentBox => tf::BoxSizing::ContentBox,
+ BoxSizing::BorderBox => tf::BoxSizing::BorderBox,
+ };
+ }
+
// Margin / padding / border (border WIDTH only — border style/color are paint props)
style.margin = edges_to_rect_lpa(css.margin.as_ref(), ctx);
style.padding = edges_to_rect_lp(css.padding.as_ref(), ctx);
@@ -114,6 +126,29 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style {
if let Some(basis) = css.flex_basis.as_ref() {
style.flex_basis = size_to_dim(Some(basis), ctx);
}
+ // `order` (round 4 audit, lot LAYOUT, constat 3): schema-valid but has no
+ // taffy equivalent — taffy has no flex/grid item-reordering primitive
+ // (its internal `order` on `Layout` is source order, assigned during
+ // layout, not settable via `Style`). Translating is not possible, so —
+ // per the same "fail loud instead of a silent no-op" contract this
+ // module's `Length`/`LengthPercentage` parsing already uses (see
+ // `units.rs`'s `px_or_warn` / `parse_length_or_warn`) — warn instead of
+ // dropping it without a trace. Reorder the JSON `children` array itself
+ // to get the equivalent effect.
+ // Emitted at most once per process: `to_taffy_style` runs per node per
+ // layout pass, and layout runs per frame — an unguarded `eprintln!` here
+ // would print the same line a thousand times over a single render and
+ // slow it down while doing so.
+ if css.order.is_some() {
+ static WARNED_ORDER: std::sync::Once = std::sync::Once::new();
+ WARNED_ORDER.call_once(|| {
+ eprintln!(
+ "Warning: `order` is not supported by the layout engine (no flex/grid item \
+ reordering primitive) — it is ignored. Reorder the component's JSON `children` \
+ array instead to change paint/layout order."
+ );
+ });
+ }
// Gap
if let Some(gap) = css.gap.as_ref() {
@@ -154,6 +189,17 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style {
if let Some(gr) = css.grid_row.as_ref() {
style.grid_row = grid_placement_line(gr);
}
+ // `justify-items` / `justify-self` (round 4 audit, lot LAYOUT, constat 3):
+ // taffy supports both natively for grid children, reusing the same
+ // `AlignItems`/`AlignSelf` types as `align-items`/`align-self` (the
+ // block-axis equivalents) — same untranslated-but-schema-valid gap as
+ // `box-sizing` above.
+ if let Some(ji) = css.justify_items {
+ style.justify_items = Some(justify_items_to_taffy(ji));
+ }
+ if let Some(js) = css.justify_self {
+ style.justify_self = justify_self_to_taffy(js);
+ }
// Overflow
if let Some(o) = css.overflow {
@@ -191,6 +237,33 @@ fn align_self_to_taffy(a: AlignSelf) -> Option {
})
}
+fn justify_items_to_taffy(j: JustifyItems) -> tf::AlignItems {
+ match j {
+ JustifyItems::Stretch => tf::AlignItems::Stretch,
+ JustifyItems::Start => tf::AlignItems::Start,
+ JustifyItems::End => tf::AlignItems::End,
+ JustifyItems::Center => tf::AlignItems::Center,
+ // `legacy` (old CSS2-era grid keyword, only meaningful combined with
+ // `left`/`right`/`center` which this schema doesn't expose) has no
+ // taffy analog; `Start` is the closest normal-flow behaviour and
+ // matches this bridge's own `Auto`-ish fallbacks elsewhere.
+ JustifyItems::Legacy => tf::AlignItems::Start,
+ }
+}
+
+fn justify_self_to_taffy(j: JustifySelf) -> Option {
+ Some(match j {
+ // `auto` computes to the parent's `justify-items` — `None` is
+ // exactly how this bridge already models `align-self: auto`
+ // inheriting `align-items` above.
+ JustifySelf::Auto => return None,
+ JustifySelf::Stretch => tf::AlignSelf::Stretch,
+ JustifySelf::Start => tf::AlignSelf::Start,
+ JustifySelf::End => tf::AlignSelf::End,
+ JustifySelf::Center => tf::AlignSelf::Center,
+ })
+}
+
fn align_content_to_taffy(a: AlignContent) -> tf::AlignContent {
match a {
AlignContent::Stretch => tf::AlignContent::Stretch,
@@ -724,4 +797,58 @@ mod tests {
"each 1fr column should be ~300px wide, got {w1}"
);
}
+
+ // ── Round 4 audit, lot LAYOUT, constat 3: box-sizing / justify-items /
+ // justify-self are schema-valid but were never translated to taffy. ────
+
+ #[test]
+ fn box_sizing_content_box_is_translated() {
+ let css = CssStyle {
+ box_sizing: Some(BoxSizing::ContentBox),
+ ..Default::default()
+ };
+ let s = to_taffy_style(&css, &ctx());
+ assert_eq!(s.box_sizing, tf::BoxSizing::ContentBox);
+ }
+
+ #[test]
+ fn box_sizing_defaults_to_border_box() {
+ let css = CssStyle::default();
+ let s = to_taffy_style(&css, &ctx());
+ assert_eq!(s.box_sizing, tf::BoxSizing::BorderBox);
+ }
+
+ #[test]
+ fn justify_items_is_translated_for_grid_children() {
+ let css = CssStyle {
+ display: Some(Display::Grid),
+ justify_items: Some(JustifyItems::Center),
+ ..Default::default()
+ };
+ let s = to_taffy_style(&css, &ctx());
+ assert_eq!(s.justify_items, Some(tf::AlignItems::Center));
+ }
+
+ #[test]
+ fn justify_self_is_translated() {
+ let css = CssStyle {
+ justify_self: Some(JustifySelf::End),
+ ..Default::default()
+ };
+ let s = to_taffy_style(&css, &ctx());
+ assert_eq!(s.justify_self, Some(tf::AlignSelf::End));
+ }
+
+ #[test]
+ fn justify_self_auto_falls_back_to_parent_justify_items() {
+ // `auto` computes to the parent's `justify-items` — taffy models
+ // this the same way `align-self: auto` models inheriting
+ // `align-items`: `None`, not an explicit `Start`.
+ let css = CssStyle {
+ justify_self: Some(JustifySelf::Auto),
+ ..Default::default()
+ };
+ let s = to_taffy_style(&css, &ctx());
+ assert_eq!(s.justify_self, None);
+ }
}
diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs
index ecba7cf..fb07e20 100644
--- a/crates/rustmotion-core/src/engine/animator.rs
+++ b/crates/rustmotion-core/src/engine/animator.rs
@@ -43,8 +43,23 @@ pub struct ResolvedCharAnimation {
/// Extracted and categorized animation effects from an AnimationEffect slice.
pub struct ExtractedEffects<'a> {
pub presets: Vec<(AnimationPreset, PresetConfig)>,
- pub keyframes: Vec<&'a Animation>,
- pub owned_keyframes: Vec,
+ /// Every `keyframes`/`tilt_in` effect's animations, in the order their
+ /// source effects appear in `style.animation` (constat #5: this used to
+ /// be split into two buckets — routed purely by whether the effect's
+ /// `delay` happened to be nonzero — resolved and merged separately,
+ /// which made "sum vs last-wins" on a shared property depend on that
+ /// unrelated field. Now there is one bucket, resolved in one
+ /// `resolve_animations` call, so the composition rule is always
+ /// "last effect in the array wins on a shared property" — a CSS-cascade
+ /// rule, independent of `delay`).
+ pub keyframe_animations: Vec,
+ /// True when any contributing `keyframes`/`tilt_in` effect requested
+ /// `"loop": true` (constat #7). Applied uniformly to the whole
+ /// `keyframe_animations` bucket — see the doc comment on
+ /// `resolve_props_for_effects` for the same caveat presets already have
+ /// (multiple effects with different loop settings on the same property
+ /// is an unsupported edge case, not new to this fix).
+ pub keyframes_loop: bool,
pub wiggles: Vec<&'a WiggleConfig>,
pub orbits: Vec<&'a OrbitConfig>,
pub glow: Option<&'a GlowConfig>,
@@ -73,8 +88,8 @@ pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> {
pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
let mut result = ExtractedEffects {
presets: Vec::new(),
- keyframes: Vec::new(),
- owned_keyframes: Vec::new(),
+ keyframe_animations: Vec::new(),
+ keyframes_loop: false,
wiggles: Vec::new(),
orbits: Vec::new(),
glow: None,
@@ -122,24 +137,23 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
result.orbits.push(config);
}
AnimationEffect::Keyframes(config) => {
- if config.delay.abs() > 1e-9 {
- // Keyframe times are absolute scene seconds; the
- // config-level delay shifts them (it used to be
- // silently ignored, breaking timeline/stagger shifts
- // on keyframes effects).
- result
- .owned_keyframes
- .extend(config.keyframes.iter().map(|anim| {
- let mut a = anim.clone();
- for kf in &mut a.keyframes {
- kf.time += config.delay;
- }
- a
- }));
- } else {
- for kf in &config.keyframes {
- result.keyframes.push(kf);
- }
+ // Keyframe times are absolute scene seconds; the
+ // config-level delay shifts them (applied unconditionally
+ // — a no-op when `delay == 0` — so every `keyframes`
+ // effect lands in the same bucket regardless of its
+ // delay; see the `ExtractedEffects::keyframe_animations`
+ // doc comment for why that used to matter).
+ result
+ .keyframe_animations
+ .extend(config.keyframes.iter().map(|anim| {
+ let mut a = anim.clone();
+ for kf in &mut a.keyframes {
+ kf.time += config.delay;
+ }
+ a
+ }));
+ if config.repeat {
+ result.keyframes_loop = true;
}
}
AnimationEffect::TiltIn(config) => {
@@ -149,7 +163,7 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
let ry = config.rotate_y.unwrap_or(-15.0);
let persp = config.perspective.unwrap_or(1000.0);
let sc = config.scale_from.unwrap_or(0.9);
- result.owned_keyframes.extend([
+ result.keyframe_animations.extend([
kf_anim(
"opacity",
delay,
@@ -163,6 +177,9 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
kf_anim("perspective", delay, persp, end, persp, EasingType::Linear),
kf_anim("scale", delay, sc, end, 1.0, EasingType::EaseOutCubic),
]);
+ if config.repeat {
+ result.keyframes_loop = true;
+ }
}
AnimationEffect::MotionBlur(config) => {
result.motion_blur = Some(config.intensity);
@@ -322,10 +339,22 @@ fn ease_in_out_cubic(t: f64) -> f64 {
/// Solve spring animation at time t (seconds).
/// Returns a value between 0.0 and 1.0 representing progress.
+///
+/// Constat #6: `SpringConfig` accepts any `f64` (it's schema-level, not
+/// range-checked at parse time), and `rustmotion validate` used to check
+/// nothing about it either. `mass <= 0` or `stiffness <= 0` fed straight into
+/// `sqrt`/division below produced NaN (sqrt of a negative/undefined ratio,
+/// or division by zero), and negative `damping` flipped the decay
+/// exponent's sign so the "settling" oscillation diverged to +-infinity
+/// instead. Either poisons every transform/opacity value downstream once it
+/// merges into `AnimatedProperties`. `validate_schema.rs` now rejects these
+/// combinations as errors (belt), and this floor keeps the solver itself
+/// finite and bounded even if an out-of-band caller skips validation
+/// (suspenders) — see `spring_robustness_tests` below.
pub fn spring_value(t: f64, config: &SpringConfig) -> f64 {
- let damping = config.damping;
- let stiffness = config.stiffness;
- let mass = config.mass;
+ let damping = config.damping.max(0.0);
+ let stiffness = config.stiffness.max(1e-6);
+ let mass = config.mass.max(1e-6);
let omega = (stiffness / mass).sqrt();
let zeta = damping / (2.0 * (stiffness * mass).sqrt());
@@ -534,15 +563,27 @@ pub fn resolve_props_for_effects(
let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration);
props.merge(&p);
}
- // owned_keyframes (generated by TiltIn etc.) are merged first so that explicit
- // user keyframes take priority via the multiplicative merge() semantics.
- if !extracted.owned_keyframes.is_empty() {
- let kp = resolve_animations(&extracted.owned_keyframes, None, None, time, scene_duration);
- props.merge(&kp);
- }
- if !extracted.keyframes.is_empty() {
- let kf: Vec = extracted.keyframes.iter().copied().cloned().collect();
- let kp = resolve_animations(&kf, None, None, time, scene_duration);
+ // Every `keyframes`/`tilt_in` effect is resolved together in one call
+ // (constat #5): within a single `resolve_animations` call, multiple
+ // `Animation`s targeting the same property are applied in list order via
+ // `apply_property` (assignment, not addition), so the *last* effect in
+ // `style.animation` wins on a shared property — deterministic, and
+ // independent of any effect's `delay`. `keyframes_loop` (constat #7)
+ // carries `"loop": true` from any contributing effect into the solver,
+ // which `resolve_animations` used to never see (it was always called
+ // with `preset_config = None`, i.e. `repeat = false`).
+ if !extracted.keyframe_animations.is_empty() {
+ let loop_cfg = PresetConfig {
+ repeat: extracted.keyframes_loop,
+ ..Default::default()
+ };
+ let kp = resolve_animations(
+ &extracted.keyframe_animations,
+ None,
+ Some(&loop_cfg),
+ time,
+ scene_duration,
+ );
props.merge(&kp);
}
if !extracted.wiggles.is_empty() {
@@ -1283,16 +1324,34 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim_loop("scale", 0.95, 1.05)],
- AnimationPreset::Float => vec![kf_anim_3kf(
+ // `delay`/`duration` used to be decorative here: the keyframes were
+ // pinned to literal times 0.0/0.25/0.5/1.0 regardless of what the
+ // scenario authored (constat #2), so every pulsing/floating/shaking/
+ // spinning element in a scene shared one hardcoded 1-second cycle
+ // starting at t=0. `delay` now shifts the cycle's start and
+ // `duration` sets its length, exactly like every other preset.
+ AnimationPreset::Pulse => vec![kf_anim_3kf_over(
+ "scale",
+ delay,
+ end,
+ 0.95,
+ 1.05,
+ 0.95,
+ EasingType::EaseInOut,
+ )],
+ AnimationPreset::Float => vec![kf_anim_3kf_over(
"position.y",
+ delay,
+ end,
0.0,
-10.0,
0.0,
EasingType::EaseInOut,
)],
- AnimationPreset::Shake => vec![kf_anim_4kf(
+ AnimationPreset::Shake => vec![kf_anim_4kf_over(
"position.x",
+ delay,
+ end,
0.0,
10.0,
-10.0,
@@ -1301,9 +1360,9 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim(
"rotation",
+ delay,
0.0,
- 0.0,
- 1.0,
+ end,
360.0,
EasingType::Linear,
)],
@@ -1393,10 +1452,41 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec Animation {
- Animation {
- property: property.to_string(),
- keyframes: vec![kf(0.0, v0), kf(0.5, v1), kf(1.0, v2)],
- easing,
- spring: None,
- }
-}
-
-fn kf_anim_4kf(
+/// Four-keyframe oscillation (quarter/half/end split) laid out over an
+/// explicit `start..end` window — the `shake` counterpart to
+/// `kf_anim_3kf_over`.
+#[allow(clippy::too_many_arguments)]
+fn kf_anim_4kf_over(
property: &str,
+ start: f64,
+ end: f64,
v0: f64,
v1: f64,
v2: f64,
v3: f64,
easing: EasingType,
) -> Animation {
+ let quarter = (end - start) / 4.0;
Animation {
property: property.to_string(),
- keyframes: vec![kf(0.0, v0), kf(0.25, v1), kf(0.5, v2), kf(1.0, v3)],
+ keyframes: vec![
+ kf(start, v0),
+ kf(start + quarter, v1),
+ kf(start + quarter * 2.0, v2),
+ kf(end, v3),
+ ],
easing,
spring: None,
}
}
-fn kf_anim_loop(property: &str, min: f64, max: f64) -> Animation {
- Animation {
- property: property.to_string(),
- keyframes: vec![kf(0.0, min), kf(0.5, max), kf(1.0, min)],
- easing: EasingType::EaseInOut,
- spring: None,
- }
-}
-
#[cfg(test)]
mod spring_preset_tests {
//! TDD tests for issue #88: spring easing on any preset via
@@ -1793,3 +1877,386 @@ mod glow_tests {
);
}
}
+
+#[cfg(test)]
+mod float3d_amplitude_tests {
+ //! Constat #1: `PresetConfig::amplitude` is read by `expand_preset_inner`
+ //! (`config.amplitude.unwrap_or(12.0)`) but `AnimationTiming::to_preset_config`
+ //! used to hardcode `amplitude: None`, so any author-supplied amplitude on
+ //! a `float_3d` effect never reached the solver — every element bobbed by
+ //! the same hardcoded 12px regardless of what was authored.
+ use super::*;
+ use crate::schema::AnimationEffect;
+
+ /// Peak absolute `translate_y` reached while sampling densely across one
+ /// cycle — proxy for the oscillation's amplitude actually resolved.
+ fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 {
+ let mut peak = 0.0f64;
+ let steps = 200;
+ for i in 0..=steps {
+ let t = window * i as f64 / steps as f64;
+ let y = resolve_props_for_effects(effects, t, window + 1.0).translate_y as f64;
+ if y.abs() > peak.abs() {
+ peak = y;
+ }
+ }
+ peak
+ }
+
+ #[test]
+ fn author_supplied_amplitude_reaches_the_solver() {
+ // Parsed from raw JSON, not built in Rust — proves the value survives
+ // serde all the way to the resolver, not merely that the struct has a
+ // field for it.
+ let default_fx: AnimationEffect =
+ serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap();
+ let big_fx: AnimationEffect =
+ serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0, "amplitude": 60 }"#)
+ .unwrap();
+
+ let default_peak = peak_translate_y(&[default_fx], 1.0);
+ let big_peak = peak_translate_y(&[big_fx], 1.0);
+
+ assert!(
+ (default_peak.abs() - 12.0).abs() < 0.5,
+ "default float_3d amplitude must stay ~12px, got {default_peak}"
+ );
+ assert!(
+ big_peak.abs() > 50.0,
+ "amplitude=60 must reach the solver (peak translate_y near 60px), got {big_peak} \
+ (default was {default_peak})"
+ );
+ }
+}
+
+#[cfg(test)]
+mod continuous_preset_timing_tests {
+ //! Constat #2: `pulse` / `float` / `shake` / `spin` used to fabricate
+ //! keyframes at literal times 0.0/0.25/0.5/1.0, ignoring `config.delay`
+ //! and `config.duration` entirely — every element sharing one of these
+ //! presets moved in lockstep on a fixed 1-second cycle no matter what the
+ //! scenario authored.
+ use super::*;
+ use crate::schema::AnimationEffect;
+
+ fn timing(delay: f64, duration: f64) -> AnimationTimingFixture {
+ AnimationTimingFixture { delay, duration }
+ }
+
+ /// Minimal JSON round-trip helper — keeps every case going through serde,
+ /// like the author's JSON would.
+ struct AnimationTimingFixture {
+ delay: f64,
+ duration: f64,
+ }
+
+ impl AnimationTimingFixture {
+ fn json(&self, name: &str) -> String {
+ format!(
+ r#"{{ "name": "{}", "delay": {}, "duration": {} }}"#,
+ name, self.delay, self.duration
+ )
+ }
+ }
+
+ #[test]
+ fn pulse_honours_delay_and_duration() {
+ let t = timing(1.0, 2.0);
+ let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap();
+ // Before its delay, the cycle has not started: the resolver clamps to
+ // the first keyframe's value (the 0.95 trough) at every pre-delay
+ // instant — it must be identical at two different pre-delay times,
+ // not moving. Before the fix, delay/duration were ignored and the
+ // preset ran its own literal 0..1s cycle regardless, so t=0.1 and
+ // t=0.9 fell in different oscillation phases and disagreed.
+ let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64;
+ let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64;
+ assert!(
+ (early - late).abs() < 1e-6,
+ "pulse must be frozen before its delay=1.0 (not yet oscillating): \
+ t=0.1 -> {early}, t=0.9 -> {late}"
+ );
+ assert!(
+ (early - 0.95).abs() < 0.01,
+ "pulse before its delay must clamp to the first keyframe (0.95), got {early}"
+ );
+ // At the midpoint of its cycle (delay + duration/2 = 2.0): near the peak (1.05).
+ let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64;
+ assert!(
+ mid > 1.03,
+ "pulse at t=2.0 (cycle midpoint) must be near peak scale ~1.05, got {mid}"
+ );
+ }
+
+ #[test]
+ fn float_honours_delay_and_duration() {
+ let t = timing(1.0, 2.0);
+ let fx: AnimationEffect = serde_json::from_str(&t.json("float")).unwrap();
+ let before =
+ resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_y as f64;
+ assert!(
+ before.abs() < 0.1,
+ "float at t=0.5 (before delay=1.0) must be at rest y=0, got {before}"
+ );
+ let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).translate_y as f64;
+ assert!(
+ mid < -8.0,
+ "float at t=2.0 (cycle midpoint) must be near peak y=-10, got {mid}"
+ );
+ }
+
+ #[test]
+ fn shake_honours_delay_and_duration() {
+ let t = timing(1.0, 2.0);
+ let fx: AnimationEffect = serde_json::from_str(&t.json("shake")).unwrap();
+ let before =
+ resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_x as f64;
+ assert!(
+ before.abs() < 0.1,
+ "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}"
+ );
+ // Quarter point of the cycle (delay + duration/4 = 1.5): near +10 peak.
+ let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64;
+ assert!(
+ quarter > 8.0,
+ "shake at t=1.5 (cycle quarter) must be near peak x=+10, got {quarter}"
+ );
+ }
+
+ #[test]
+ fn spin_honours_delay_and_duration() {
+ let t = timing(1.0, 2.0);
+ let fx: AnimationEffect = serde_json::from_str(&t.json("spin")).unwrap();
+ let before =
+ resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).rotation as f64;
+ assert!(
+ before.abs() < 0.1,
+ "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}"
+ );
+ // Halfway through its own cycle (delay + duration/2 = 2.0): ~180deg.
+ let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64;
+ assert!(
+ (mid - 180.0).abs() < 5.0,
+ "spin at t=2.0 (cycle midpoint) must be near 180deg, got {mid}"
+ );
+ }
+}
+
+#[cfg(test)]
+mod keyframes_composition_tests {
+ //! Constat #5: two `keyframes` effects targeting the same property used
+ //! to be routed into one of two buckets purely by whether `delay != 0`
+ //! (`owned_keyframes` vs `keyframes` in `extract_effects`), each bucket
+ //! resolved by its own `resolve_animations` call and combined via
+ //! `AnimatedProperties::merge` — which *sums* additive properties like
+ //! `translate_x` across buckets, while two effects landing in the *same*
+ //! bucket instead overwrite (last one in the list wins, since
+ //! `apply_property` assigns rather than adds). So the composition rule
+ //! depended entirely on an incidental field (`delay`) with no relation to
+ //! authoring intent.
+ //!
+ //! Chosen semantic: every `keyframes`/`tilt_in` effect is resolved
+ //! together in one `resolve_animations` call, in the order the effects
+ //! appear in `style.animation` — like a CSS cascade, the *last* effect
+ //! in the array wins on a shared property. This is deterministic and
+ //! independent of `delay`.
+ use super::*;
+ use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
+
+ /// A `keyframes` effect with one property ramping `0 -> value` over
+ /// `[0, 1]` (pre-shift), then shifted by `delay`.
+ fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect {
+ AnimationEffect::Keyframes(KeyframesConfig {
+ keyframes: vec![Animation {
+ property: property.to_string(),
+ keyframes: vec![
+ Keyframe {
+ time: 0.0,
+ value: KeyframeValue::Number(0.0),
+ easing: None,
+ },
+ Keyframe {
+ time: 1.0,
+ value: KeyframeValue::Number(value),
+ easing: None,
+ },
+ ],
+ easing: EasingType::Linear,
+ spring: None,
+ }],
+ delay,
+ duration: 0.8,
+ repeat: false,
+ })
+ }
+
+ #[test]
+ fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() {
+ // Case 1: A (delay=0) declared first, B (delay=0.5) declared second.
+ let a1 = ramp("translate_x", 100.0, 0.0);
+ let b1 = ramp("translate_x", 40.0, 0.5);
+ let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64;
+ let b1_alone = resolve_props_for_effects(&[b1], 1.0, 5.0).translate_x as f64;
+ assert!(
+ (combined_1 - b1_alone).abs() < 1e-4,
+ "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}"
+ );
+
+ // Case 2: swap which one carries the delay, keep declaration order
+ // (A first, B second) — the outcome must be identical in shape: B
+ // (still last) wins alone, this time using B's own (now delay=0)
+ // timing.
+ let a2 = ramp("translate_x", 100.0, 0.5);
+ let b2 = ramp("translate_x", 40.0, 0.0);
+ let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64;
+ let b2_alone = resolve_props_for_effects(&[b2], 1.0, 5.0).translate_x as f64;
+ assert!(
+ (combined_2 - b2_alone).abs() < 1e-4,
+ "B (declared last) must alone determine translate_x at t=1.0 even with delay swapped: \
+ combined={combined_2}, B-alone={b2_alone}"
+ );
+
+ // The two cases must NOT collapse to the same number (sanity check
+ // that this test isn't vacuous — B's own resolved value genuinely
+ // differs between the two delay assignments).
+ assert!(
+ (combined_1 - combined_2).abs() > 1.0,
+ "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}"
+ );
+ }
+}
+
+#[cfg(test)]
+mod keyframes_loop_tests {
+ //! Constat #7: `"loop": true` on a `keyframes` effect or on `tilt_in`
+ //! never reached the solver. `resolve_props_for_effects` always called
+ //! `resolve_animations(&kfs, None, None, ...)` for both keyframe buckets
+ //! — passing `preset_config = None` means `resolve_animations` falls back
+ //! to `PresetConfig::default()`, whose `repeat` is `false`, so
+ //! `loop_time` was never invoked no matter what `KeyframesConfig::repeat`
+ //! / `TiltInConfig::repeat` said.
+ use super::*;
+ use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
+
+ #[test]
+ fn keyframes_loop_true_wraps_time_past_the_last_keyframe() {
+ let looping = AnimationEffect::Keyframes(KeyframesConfig {
+ keyframes: vec![Animation {
+ property: "opacity".to_string(),
+ keyframes: vec![
+ Keyframe {
+ time: 0.0,
+ value: KeyframeValue::Number(0.0),
+ easing: None,
+ },
+ Keyframe {
+ time: 1.0,
+ value: KeyframeValue::Number(1.0),
+ easing: None,
+ },
+ ],
+ easing: EasingType::Linear,
+ spring: None,
+ }],
+ delay: 0.0,
+ duration: 0.8,
+ repeat: true,
+ });
+ // t=2.5 is past the keyframe's own last time (1.0). Without looping,
+ // the resolver clamps to the last keyframe's value (1.0) forever.
+ // With looping (start=0, end=1, duration=1), t=2.5 wraps to 0.5 ->
+ // opacity should be ~0.5, not 1.0.
+ let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64;
+ assert!(
+ (opacity - 0.5).abs() < 0.05,
+ "looping keyframes at t=2.5 must wrap to local t=0.5 (opacity ~0.5), got {opacity}"
+ );
+ }
+
+ #[test]
+ fn tilt_in_loop_true_keeps_tilting_past_its_settle_time() {
+ let looping_tilt: AnimationEffect = serde_json::from_str(
+ r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4, "loop": true }"#,
+ )
+ .unwrap();
+ let settled: AnimationEffect =
+ serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#)
+ .unwrap();
+
+ // Well past the settle time (0.4s): without loop, scale is pinned at
+ // the final resting value (1.0). With loop (cycle 0..0.4), t=1.0
+ // wraps to local t=0.2 (t=1.0 % 0.4 = 0.2), mid-tilt, scale != 1.0.
+ let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64;
+ let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64;
+
+ assert!(
+ (settled_scale - 1.0).abs() < 1e-3,
+ "non-looping tilt_in at t=1.0 (past settle) must be resting at scale 1.0, got {settled_scale}"
+ );
+ assert!(
+ (looping_scale - 1.0).abs() > 0.01,
+ "looping tilt_in at t=1.0 must still be mid-cycle (scale != 1.0 rest), got {looping_scale}"
+ );
+ }
+}
+
+#[cfg(test)]
+mod spring_robustness_tests {
+ //! Constat #6: `spring_value` fed `mass`/`stiffness`/`damping` straight
+ //! into `sqrt`/division with no floor, so `mass <= 0` or `stiffness <= 0`
+ //! produced NaN (division by zero or sqrt of a negative number), and
+ //! negative `damping` flipped the decay exponent's sign, diverging to
+ //! +-infinity instead of settling. A NaN/inf progress value then flows
+ //! into transform math (translate/scale) and contaminates the whole
+ //! subtree it touches.
+ use super::*;
+
+ #[test]
+ fn zero_mass_does_not_produce_nan() {
+ let config = SpringConfig {
+ damping: 10.0,
+ stiffness: 100.0,
+ mass: 0.0,
+ };
+ for i in 0..=20 {
+ let t = i as f64 * 0.25;
+ let v = spring_value(t, &config);
+ assert!(
+ v.is_finite(),
+ "spring_value(t={t}) with mass=0 must be finite, got {v}"
+ );
+ }
+ }
+
+ #[test]
+ fn zero_stiffness_does_not_produce_nan() {
+ let config = SpringConfig {
+ damping: 10.0,
+ stiffness: 0.0,
+ mass: 1.0,
+ };
+ for i in 0..=20 {
+ let t = i as f64 * 0.25;
+ let v = spring_value(t, &config);
+ assert!(
+ v.is_finite(),
+ "spring_value(t={t}) with stiffness=0 must be finite, got {v}"
+ );
+ }
+ }
+
+ #[test]
+ fn negative_damping_stays_bounded_instead_of_diverging() {
+ let config = SpringConfig {
+ damping: -20.0,
+ stiffness: 100.0,
+ mass: 1.0,
+ };
+ let v_at_5s = spring_value(5.0, &config);
+ assert!(
+ v_at_5s.is_finite() && v_at_5s.abs() < 100.0,
+ "spring_value(t=5.0) with damping=-20 must stay bounded (finite and reasonably \
+ small), got {v_at_5s} — negative damping must not diverge to +-infinity"
+ );
+ }
+}
diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs
index 3b129e0..f9d454e 100644
--- a/crates/rustmotion-core/src/engine/paint_pass.rs
+++ b/crates/rustmotion-core/src/engine/paint_pass.rs
@@ -220,9 +220,24 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
viewport_width: ctx.viewport_size.0,
viewport_height: ctx.viewport_size.1,
parent_size: box_layout.width.max(box_layout.height),
- font_size: 16.0,
+ font_size: node.css.font_size_px_or(16.0),
root_font_size: 16.0,
};
+ // Per-axis contexts for `transform`'s translate percentages: CSS
+ // resolves a `translate`/`translate3d` x-component percentage against
+ // the box's own WIDTH and the y-component against its own HEIGHT — never
+ // `max(width, height)` on both axes (that's only correct for square
+ // boxes). Mirrors what `resolve_origin` already does for
+ // `transform-origin` below. `z`/`perspective()` keep the general
+ // (shared) context — CSS has no per-axis convention for them.
+ let length_ctx_x = LengthContext {
+ parent_size: box_layout.width,
+ ..length_ctx
+ };
+ let length_ctx_y = LengthContext {
+ parent_size: box_layout.height,
+ ..length_ctx
+ };
canvas.save();
@@ -259,13 +274,18 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
.perspective
.as_ref()
.map(|l| l.resolve(&length_ctx).max(1.0));
+ let axes = TransformAxes {
+ x: length_ctx_x,
+ y: length_ctx_y,
+ general: length_ctx,
+ };
apply_transform(
canvas,
transform_list,
perspective_d,
transform_pivot,
perspective_pivot,
- &length_ctx,
+ &axes,
);
}
@@ -293,8 +313,50 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
});
}
- // 3. opacity / filter layer — one shared layer carries both the group
- // alpha and the CSS `filter` chain (applies to the node and its subtree).
+ // 3. backdrop-filter: filter what is *already painted behind this node*
+ // (earlier siblings, ancestor backgrounds), clipped to its own (rounded)
+ // border-box — the glassmorphism pattern. This must run BEFORE this
+ // node's own opacity/filter layer (step 4) opens: if it ran after (as it
+ // used to), the backdrop's `SaveLayerRec::backdrop()` would sample the
+ // freshly-opened, still-empty opacity layer instead of the real scene
+ // beneath it, making the blur a total no-op the instant `opacity < 1.0`
+ // or a `filter` is also present on the same node — exactly the
+ // glassmorphism + fade_in combination rules/glassmorphism.md recommends.
+ // Self-contained bracket (save/clip/layer/restore/restore): the panel is
+ // baked directly onto the canvas below, so this node's own opacity later
+ // fades its own background/border/content on top of it without
+ // re-fading the panel itself (avoiding a second, unrelated ordering
+ // hazard: a shared clip+layer would also have to stay open across
+ // background/border painting, reintroducing the overflow/shadow bug
+ // fixed below for those steps too).
+ if let Some(filters) = node.css.backdrop_filter.as_deref() {
+ if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) {
+ let radius = node
+ .css
+ .border_radius
+ .as_ref()
+ .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
+ .unwrap_or([0.0; 4]);
+ canvas.save();
+ canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true);
+ let rec = SaveLayerRec::default().backdrop(&backdrop);
+ canvas.save_layer(&rec);
+ canvas.restore();
+ canvas.restore();
+ }
+ }
+
+ // 4. opacity / filter layer — one shared layer carries both the group
+ // alpha and the CSS `filter` chain (applies to the node and its
+ // subtree). Bounded to the node's own box (padded by the filter chain's
+ // blur/drop-shadow bleed so those still bleed past the edge, unclipped):
+ // an unbounded `SaveLayerRec` sizes the layer against the current clip —
+ // usually the whole viewport — so every faded/filtered node allocates
+ // and composites a full-frame layer regardless of how small it is
+ // (measured on this repo's release binary, 1080x1920/60 frames, 30 small
+ // `opacity: 0.5` shapes, `--threads 1`: ~42-60s wall time unbounded vs.
+ // ~0.5s bounded — roughly two orders of magnitude, not a rounding
+ // error; cost scales with viewport area, not node size).
let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
let content_filter = node
.css
@@ -309,30 +371,33 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
if let Some(filter) = content_filter {
paint.set_image_filter(filter);
}
- let rec = SaveLayerRec::default().paint(&paint);
+ let bleed = node
+ .css
+ .filter
+ .as_deref()
+ .map(|list| filter_bleed(list, &length_ctx))
+ .unwrap_or(0.0);
+ let bounds = Rect::from_xywh(
+ box_layout.x - bleed,
+ box_layout.y - bleed,
+ box_layout.width + bleed * 2.0,
+ box_layout.height + bleed * 2.0,
+ );
+ let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds);
canvas.save_layer(&rec);
true
} else {
false
};
- // 4. clip overflow:hidden / clip
- let overflow = node.css.overflow.unwrap_or(Overflow::Visible);
- if matches!(
- overflow,
- Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto
- ) {
- let radius = node
- .css
- .border_radius
- .as_ref()
- .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
- .unwrap_or([0.0; 4]);
- let rrect = padding_rrect(box_layout, radius);
- canvas.clip_rrect(rrect, ClipOp::Intersect, true);
- }
-
- // 5. outset box-shadow
+ // 5. outset box-shadow, 6. background, 7. border — the box's own
+ // decorations. Painted BEFORE any overflow clip (step 8): CSS `overflow`
+ // clips a box's *descendants*, never the box's own border-box
+ // decorations (an outset box-shadow exists precisely outside the
+ // border-box; background/border are already shaped by border-radius on
+ // their own and gain nothing from an extra clip). They still sit inside
+ // the opacity/filter layer above so a faded node fades its whole
+ // appearance uniformly, background included.
if let Some(shadows) = node.css.box_shadow.as_ref() {
for shadow in shadows {
if shadow.inset.unwrap_or(false) {
@@ -341,41 +406,40 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false);
}
}
-
- // 5.5 backdrop-filter: filter what is already painted behind this node,
- // clipped to its (rounded) border-box, before its own background goes on
- // top — the glassmorphism pattern.
- if let Some(filters) = node.css.backdrop_filter.as_deref() {
- if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) {
- let radius = node
- .css
- .border_radius
- .as_ref()
- .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
- .unwrap_or([0.0; 4]);
- canvas.save();
- canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true);
- let rec = SaveLayerRec::default().backdrop(&backdrop);
- canvas.save_layer(&rec);
- canvas.restore();
- canvas.restore();
- }
- }
-
- // 6. background
if let Some(bg) = node.css.background.as_ref() {
paint_background(canvas, box_layout, &node.css, bg, &length_ctx);
}
-
- // 7. border — `gradient-border` replaces the standard border when present
- // (a box has one border, not two stacked ones).
+ // `gradient-border` replaces the standard border when present (a box
+ // has one border, not two stacked ones).
if let Some(gb) = node.css.gradient_border.as_ref() {
paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx);
} else if let Some(border) = node.css.border.as_ref() {
paint_border(canvas, box_layout, &node.css, border, &length_ctx);
}
- // 8. component-specific content (Ghost is painted identically to Component;
+ // 8. clip overflow:hidden / clip — scoped to this node's own content and
+ // its children only (see step 5-7's comment for why the box's own
+ // decorations must stay outside this clip).
+ let overflow = node.css.overflow.unwrap_or(Overflow::Visible);
+ let opened_overflow_clip = if matches!(
+ overflow,
+ Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto
+ ) {
+ let radius = node
+ .css
+ .border_radius
+ .as_ref()
+ .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
+ .unwrap_or([0.0; 4]);
+ let rrect = padding_rrect(box_layout, radius);
+ canvas.save();
+ canvas.clip_rrect(rrect, ClipOp::Intersect, true);
+ true
+ } else {
+ false
+ };
+
+ // 9. component-specific content (Ghost is painted identically to Component;
// the only difference is that Ghost is excluded from the hit-map above).
let payload_opt = match &node.kind {
BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p),
@@ -386,13 +450,17 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
.dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame);
}
- // 9. children (z-index ordered, then source order)
+ // 10. children (z-index ordered, then source order)
let mut indices: Vec = (0..node.children.len()).collect();
indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0));
for &i in &indices {
paint_node(canvas, &node.children[i], ctx, tree_depth + 1);
}
+ if opened_overflow_clip {
+ canvas.restore();
+ }
+
// inset shadows (after children so they overlay content)
if let Some(shadows) = node.css.box_shadow.as_ref() {
for shadow in shadows {
@@ -408,6 +476,40 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
canvas.restore();
}
+/// Conservative outward bleed (px) a `filter` chain can paint beyond the
+/// node's own box — used to size the opacity/filter layer's `SaveLayerRec`
+/// bounds generously enough that `blur`/`drop-shadow` never get clipped at
+/// the box edge (see the perf fix in step 4 above: an unbounded layer costs
+/// ~5.9x render time, but a *too-tight* one would silently clip filter
+/// bleed, trading a perf bug for a correctness one). `1.5x` the nominal
+/// radius covers the visible falloff of `image_filters::blur`'s Gaussian
+/// (sigma = radius/2, and ~3*sigma is the point the kernel is visually
+/// negligible).
+fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 {
+ use crate::css::style::FilterFn;
+ let mut bleed = 0.0f32;
+ for f in list {
+ let b = match f {
+ FilterFn::Blur { radius } => radius.resolve(ctx).max(0.0) * 1.5,
+ FilterFn::DropShadow {
+ offset_x,
+ offset_y,
+ blur,
+ ..
+ } => {
+ let blur_bleed = blur
+ .as_ref()
+ .map(|b| b.resolve(ctx).max(0.0) * 1.5)
+ .unwrap_or(0.0);
+ offset_x.resolve(ctx).abs().max(offset_y.resolve(ctx).abs()) + blur_bleed
+ }
+ _ => 0.0,
+ };
+ bleed = bleed.max(b);
+ }
+ bleed
+}
+
// ---- CSS filters ----
/// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter`
@@ -684,6 +786,19 @@ fn has_3d_transform(list: &[TransformFn]) -> bool {
})
}
+/// Per-axis length-resolution contexts for `transform`. CSS resolves a
+/// `translate`/`translate3d` percentage's x-component against the box's own
+/// width and its y-component against its own height — never `max(width,
+/// height)` on both axes (see `apply_transform`/`transform_to_m44`).
+/// `z`/`perspective()` have no established per-axis CSS convention, so they
+/// keep the general (pre-existing) context.
+#[derive(Clone, Copy)]
+struct TransformAxes {
+ x: LengthContext,
+ y: LengthContext,
+ general: LengthContext,
+}
+
/// Apply CSS transform + perspective to the canvas.
///
/// # Parameters
@@ -700,7 +815,7 @@ fn apply_transform(
perspective_d: Option,
transform_pivot: (f32, f32),
perspective_pivot: (f32, f32),
- ctx: &LengthContext,
+ axes: &TransformAxes,
) {
// Detect whether perspective and transform pivots differ.
let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001
@@ -713,16 +828,16 @@ fn apply_transform(
for tr in list {
match tr {
TransformFn::Translate { x, y } => {
- canvas.translate(Point::new(x.resolve(ctx), y.resolve(ctx)));
+ canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
}
TransformFn::TranslateX { x } => {
- canvas.translate(Point::new(x.resolve(ctx), 0.0));
+ canvas.translate(Point::new(x.resolve(&axes.x), 0.0));
}
TransformFn::TranslateY { y } => {
- canvas.translate(Point::new(0.0, y.resolve(ctx)));
+ canvas.translate(Point::new(0.0, y.resolve(&axes.y)));
}
TransformFn::Translate3d { x, y, .. } => {
- canvas.translate(Point::new(x.resolve(ctx), y.resolve(ctx)));
+ canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
}
TransformFn::Scale { x, y } => {
canvas.scale((*x, *y));
@@ -765,7 +880,7 @@ fn apply_transform(
m.pre_concat(&css_perspective_m44(d));
}
for tr in list {
- m.pre_concat(&transform_to_m44(tr, ctx));
+ m.pre_concat(&transform_to_m44(tr, axes));
}
m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0));
canvas.concat_44(&m);
@@ -789,7 +904,7 @@ fn apply_transform(
// Inner transform bracket (transform-origin).
m.pre_concat(&M44::translate(tp.0, tp.1, 0.0));
for tr in list {
- m.pre_concat(&transform_to_m44(tr, ctx));
+ m.pre_concat(&transform_to_m44(tr, axes));
}
m.pre_concat(&M44::translate(-tp.0, -tp.1, 0.0));
@@ -820,15 +935,19 @@ fn css_perspective_m44(d: f32) -> M44 {
])
}
-fn transform_to_m44(tr: &TransformFn, ctx: &LengthContext) -> M44 {
+fn transform_to_m44(tr: &TransformFn, axes: &TransformAxes) -> M44 {
match tr {
- TransformFn::Translate { x, y } => M44::translate(x.resolve(ctx), y.resolve(ctx), 0.0),
- TransformFn::TranslateX { x } => M44::translate(x.resolve(ctx), 0.0, 0.0),
- TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(ctx), 0.0),
- TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(ctx)),
- TransformFn::Translate3d { x, y, z } => {
- M44::translate(x.resolve(ctx), y.resolve(ctx), z.resolve(ctx))
+ TransformFn::Translate { x, y } => {
+ M44::translate(x.resolve(&axes.x), y.resolve(&axes.y), 0.0)
}
+ TransformFn::TranslateX { x } => M44::translate(x.resolve(&axes.x), 0.0, 0.0),
+ TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(&axes.y), 0.0),
+ TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(&axes.general)),
+ TransformFn::Translate3d { x, y, z } => M44::translate(
+ x.resolve(&axes.x),
+ y.resolve(&axes.y),
+ z.resolve(&axes.general),
+ ),
TransformFn::Scale { x, y } => M44::scale(*x, *y, 1.0),
TransformFn::ScaleX { x } => M44::scale(*x, 1.0, 1.0),
TransformFn::ScaleY { y } => M44::scale(1.0, *y, 1.0),
@@ -896,7 +1015,9 @@ fn transform_to_m44(tr: &TransformFn, ctx: &LengthContext) -> M44 {
0.0,
1.0,
]),
- TransformFn::Perspective { length } => css_perspective_m44(length.resolve(ctx).max(1.0)),
+ TransformFn::Perspective { length } => {
+ css_perspective_m44(length.resolve(&axes.general).max(1.0))
+ }
TransformFn::Matrix { values: v } => M44::row_major(&[
v[0], v[2], 0.0, v[4], v[1], v[3], 0.0, v[5], 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]),
@@ -1519,6 +1640,113 @@ mod hit_tests {
);
}
+ #[test]
+ fn backdrop_filter_survives_sibling_opacity_below_one() {
+ // Same scene as `backdrop_filter_blurs_content_behind`, but the panel
+ // also carries `opacity: 0.99` — a visually-imperceptible change that
+ // must NOT disable the blur. Bug: the opacity/filter SaveLayerRec
+ // (paint_pass step 3) used to open BEFORE the backdrop-filter's own
+ // save_layer(backdrop) (old step 5.5), so the backdrop sampled the
+ // freshly-opened, still-empty opacity layer instead of the real
+ // scene beneath it — a total no-op. `opacity` alone (no
+ // `backdrop_filter`) is not the trigger; only nodes that combine
+ // both are affected, which is exactly the documented glassmorphism
+ // template (glassmorphism.md pairs `backdrop-filter` with a
+ // `fade_in`/`fade_in_up` entrance animation that drives `opacity`).
+ use crate::css::style::{Background, Color as CssColor, FilterFn};
+ use crate::css::units::Length;
+
+ let black_top = BoxNode {
+ id: 0,
+ kind: BoxKind::Container,
+ css: CssStyle {
+ position: Some(Position::Absolute),
+ left: Some(CLP::Px(0.0)),
+ top: Some(CLP::Px(0.0)),
+ width: Some(CSize::Length(CLP::Px(200.0))),
+ height: Some(CSize::Length(CLP::Px(100.0))),
+ background: Some(Background::Color(CssColor::String("#000000".into()))),
+ ..Default::default()
+ },
+ children: vec![],
+ intrinsic: None,
+ source_path: None,
+ window: None,
+ };
+ let panel = BoxNode {
+ id: 0,
+ kind: BoxKind::Container,
+ css: CssStyle {
+ position: Some(Position::Absolute),
+ left: Some(CLP::Px(50.0)),
+ top: Some(CLP::Px(50.0)),
+ width: Some(CSize::Length(CLP::Px(100.0))),
+ height: Some(CSize::Length(CLP::Px(100.0))),
+ backdrop_filter: Some(vec![FilterFn::Blur {
+ radius: Length::Px(10.0),
+ }]),
+ opacity: Some(0.99),
+ ..Default::default()
+ },
+ children: vec![],
+ intrinsic: None,
+ source_path: None,
+ window: None,
+ };
+ let mut root = BoxNode {
+ id: 0,
+ kind: BoxKind::Container,
+ css: CssStyle {
+ display: Some(Display::Flex),
+ width: Some(CSize::Length(CLP::Px(200.0))),
+ height: Some(CSize::Length(CLP::Px(200.0))),
+ background: Some(Background::Color(CssColor::String("#ffffff".into()))),
+ ..Default::default()
+ },
+ children: vec![black_top, panel],
+ intrinsic: None,
+ source_path: None,
+ window: None,
+ };
+ root.assign_ids(0);
+
+ let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
+ let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
+ paint_tree(
+ surface.canvas(),
+ &root,
+ &layout,
+ &test_frame(200, 200),
+ &NoopDispatcher,
+ );
+
+ let info = skia_safe::ImageInfo::new(
+ (200, 200),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Unpremul,
+ None,
+ );
+ let mut buf = vec![0u8; 200 * 200 * 4];
+ assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
+ let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
+
+ // Outside the panel: hard edge preserved.
+ assert!(red(10, 97) < 10, "outside/above must stay black");
+ assert!(red(10, 103) > 245, "outside/below must stay white");
+ // Inside the panel: boundary must still smear into greys — the bug
+ // makes this a hard 0/255 edge identical to the outside columns.
+ let above = red(100, 97);
+ let below = red(100, 103);
+ assert!(
+ above > 30,
+ "backdrop not blurred above boundary with opacity:0.99 (r={above})"
+ );
+ assert!(
+ below < 225,
+ "backdrop not blurred below boundary with opacity:0.99 (r={below})"
+ );
+ }
+
#[test]
fn hitmap_reflects_node_transform() {
use crate::css::style::TransformFn;
@@ -1970,6 +2198,54 @@ mod transform_origin_tests {
"expected some red pixels with distinct origins"
);
}
+
+ // ---- Test 8: translate percentages resolve per-axis, not max(w,h) ----
+
+ #[test]
+ fn translate_percent_resolves_against_own_axis_not_max_dimension() {
+ // A 200x100 red box at (0,0), `transform: translate(50%, 50%)`.
+ // CSS resolves a translate-x percentage against the box's own WIDTH
+ // and translate-y against its own HEIGHT — never `max(width,
+ // height)` on both axes (only correct for square boxes). Expected:
+ // x shifts by 100 (50% of 200) -> [100,299]; y shifts by 50 (50% of
+ // 100) -> [50,149]. The bug instead resolved y against max(200,100)
+ // = 200, doubling the vertical shift to +100 -> [100,199].
+ let mut n = red_box(Position::Absolute, 0.0, 0.0, 200.0, 100.0);
+ n.css.transform = Some(vec![TransformFn::Translate {
+ x: CLP::String("50%".into()),
+ y: CLP::String("50%".into()),
+ }]);
+ let mut root = root_node(400.0, 400.0, vec![n]);
+ let buf = render_pixels(&mut root, 400, 400);
+
+ let mut min_x = u32::MAX;
+ let mut max_x = 0u32;
+ let mut min_y = u32::MAX;
+ let mut max_y = 0u32;
+ for y in 0..400u32 {
+ for x in 0..400u32 {
+ let i = ((y * 400 + x) * 4) as usize;
+ if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
+ min_x = min_x.min(x);
+ max_x = max_x.max(x);
+ min_y = min_y.min(y);
+ max_y = max_y.max(y);
+ }
+ }
+ }
+ assert_ne!(min_x, u32::MAX, "expected some red pixels");
+
+ assert!((min_x as i32 - 100).abs() <= 2, "min_x={min_x}");
+ assert!((max_x as i32 - 299).abs() <= 2, "max_x={max_x}");
+ assert!(
+ (min_y as i32 - 50).abs() <= 2,
+ "min_y={min_y} (expected ~50; the max(w,h) bug would give ~100)"
+ );
+ assert!(
+ (max_y as i32 - 149).abs() <= 2,
+ "max_y={max_y} (expected ~149; the max(w,h) bug would give ~199)"
+ );
+ }
}
#[cfg(test)]
@@ -2395,6 +2671,229 @@ mod glassmorphism_tests {
}
}
+#[cfg(test)]
+mod paint_order_tests {
+ //! TDD tests for two paint-pass audit findings:
+ //! - overflow:hidden must never clip a node's OWN outset box-shadow
+ //! (CSS clips descendants, never the box's own decorations).
+ //! - the opacity/filter SaveLayerRec must be bounded to the node's box
+ //! (+ filter bleed), not left to size against the ambient clip
+ //! (usually the whole viewport) — without clipping visible blur.
+
+ use super::*;
+
+ use crate::css::style::{
+ Background, BoxShadow, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection,
+ Overflow, Position, Size as CSize,
+ };
+ use crate::css::taffy_bridge::ConversionContext;
+ use crate::css::units::{Length, LengthPercentage as CLP};
+ use crate::engine::box_tree::{BoxKind, BoxNode};
+ use crate::engine::layout_pass::run_layout;
+
+ fn test_frame(w: u32, h: u32) -> PaintFrame {
+ PaintFrame {
+ time: 0.0,
+ frame_index: 0,
+ fps: 30,
+ video_width: w,
+ video_height: h,
+ scene_duration: 1.0,
+ camera: None,
+ }
+ }
+
+ fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> 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();
+ paint_tree(
+ surface.canvas(),
+ root,
+ &layout,
+ &test_frame(w, h),
+ &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 root_node(w: f32, h: f32, background: &str, children: Vec) -> BoxNode {
+ BoxNode {
+ id: 0,
+ kind: BoxKind::Container,
+ css: CssStyle {
+ display: Some(Display::Flex),
+ flex_direction: Some(FlexDirection::Column),
+ width: Some(CSize::Length(CLP::Px(w))),
+ height: Some(CSize::Length(CLP::Px(h))),
+ background: Some(Background::Color(CssColor::String(background.to_string()))),
+ ..Default::default()
+ },
+ children,
+ intrinsic: None,
+ source_path: None,
+ window: None,
+ }
+ }
+
+ /// Count of "probe" red pixels (spread-only, blur:0, so a hard-edged
+ /// halo) in a rectangular region — used to compare before/after pixel
+ /// counts for the paint-order fix.
+ fn count_red_in(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
+ let mut n = 0;
+ for y in y0..y1 {
+ for x in x0..x1 {
+ let i = ((y * w + x) * 4) as usize;
+ if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
+ n += 1;
+ }
+ }
+ }
+ n
+ }
+
+ fn card_with_shadow(overflow_hidden: bool) -> BoxNode {
+ let mut css = CssStyle {
+ position: Some(Position::Absolute),
+ left: Some(CLP::Px(50.0)),
+ top: Some(CLP::Px(50.0)),
+ width: Some(CSize::Length(CLP::Px(100.0))),
+ height: Some(CSize::Length(CLP::Px(100.0))),
+ background: Some(Background::Color(CssColor::String("#ffffff".into()))),
+ box_shadow: Some(vec![BoxShadow {
+ offset_x: Length::Px(0.0),
+ offset_y: Length::Px(0.0),
+ blur: None,
+ spread: Some(Length::Px(20.0)),
+ color: Some(CssColor::String("#ff0000".into())),
+ inset: None,
+ }]),
+ ..Default::default()
+ };
+ if overflow_hidden {
+ css.overflow = Some(Overflow::Hidden);
+ }
+ BoxNode {
+ id: 0,
+ kind: BoxKind::Container,
+ css,
+ children: vec![],
+ intrinsic: None,
+ source_path: None,
+ window: None,
+ }
+ }
+
+ #[test]
+ fn overflow_hidden_does_not_clip_own_outset_box_shadow() {
+ // 100x100 white card at (50,50) on a 200x200 black canvas, outset
+ // box-shadow (red, spread 20, blur 0 -> hard-edged halo rect from
+ // (30,30) to (170,170)). Probe points (100,45) and (100,155) sit in
+ // the halo band above/below the card, outside its own border-box.
+ let without = {
+ let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(false)]);
+ render_pixels(&mut root, 200, 200)
+ };
+ let with_hidden = {
+ let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(true)]);
+ render_pixels(&mut root, 200, 200)
+ };
+
+ let probe = |buf: &[u8], x: usize, y: usize| -> (u8, u8, u8) {
+ let i = (y * 200 + x) * 4;
+ (buf[i], buf[i + 1], buf[i + 2])
+ };
+
+ let above_plain = probe(&without, 100, 45);
+ let below_plain = probe(&without, 100, 155);
+ assert!(
+ above_plain.0 > 200 && above_plain.1 < 50,
+ "sanity: shadow halo must be visible without overflow, got {above_plain:?}"
+ );
+ assert!(
+ below_plain.0 > 200 && below_plain.1 < 50,
+ "sanity: shadow halo must be visible without overflow, got {below_plain:?}"
+ );
+
+ let above_hidden = probe(&with_hidden, 100, 45);
+ let below_hidden = probe(&with_hidden, 100, 155);
+ assert!(
+ above_hidden.0 > 200 && above_hidden.1 < 50,
+ "overflow:hidden must not erase the node's own outset shadow, got {above_hidden:?}"
+ );
+ assert!(
+ below_hidden.0 > 200 && below_hidden.1 < 50,
+ "overflow:hidden must not erase the node's own outset shadow, got {below_hidden:?}"
+ );
+
+ // Probe-pixel count over the full halo band, before/after overflow.
+ let halo_count_plain = count_red_in(&without, 200, 25, 25, 175, 175);
+ let halo_count_hidden = count_red_in(&with_hidden, 200, 25, 25, 175, 175);
+ assert_eq!(
+ halo_count_plain, halo_count_hidden,
+ "halo pixel count must be identical with/without overflow:hidden \
+ (plain={halo_count_plain}, hidden={halo_count_hidden})"
+ );
+ }
+
+ #[test]
+ fn filter_layer_bounds_do_not_clip_blur_bleed() {
+ // A 60x60 opaque red square with `opacity: 0.999` (forces the
+ // SaveLayerRec open) AND `filter: blur(24px)` on a 300x300 black
+ // canvas. Bounding the layer to the node's box (issue #4 fix) must
+ // still leave room for the blur to bleed outward — if the bounds
+ // were the bare box rect, Skia would hard-clip the blurred fringe
+ // at the box edge, and the region just outside the box would stay
+ // pure black instead of picking up a soft red glow.
+ let n = BoxNode {
+ id: 0,
+ kind: BoxKind::Container,
+ css: CssStyle {
+ position: Some(Position::Absolute),
+ left: Some(CLP::Px(120.0)),
+ top: Some(CLP::Px(120.0)),
+ width: Some(CSize::Length(CLP::Px(60.0))),
+ height: Some(CSize::Length(CLP::Px(60.0))),
+ background: Some(Background::Color(CssColor::String("#ff0000".into()))),
+ opacity: Some(0.999),
+ filter: Some(vec![FilterFn::Blur {
+ radius: Length::Px(24.0),
+ }]),
+ ..Default::default()
+ },
+ children: vec![],
+ intrinsic: None,
+ source_path: None,
+ window: None,
+ };
+ let mut root = root_node(300.0, 300.0, "#000000", vec![n]);
+ let buf = render_pixels(&mut root, 300, 300);
+
+ let probe = |x: usize, y: usize| -> u8 {
+ let i = (y * 300 + x) * 4;
+ buf[i]
+ };
+ // 8px outside the left edge of the box (box left edge = x=120),
+ // vertically centered (y=150): must show blur bleed (red > black).
+ let bled = probe(112, 150);
+ assert!(
+ bled > 15,
+ "blur must bleed past the box edge under bounded SaveLayerRec, got r={bled}"
+ );
+ // Far outside any plausible bleed radius: must stay black.
+ let far = probe(20, 20);
+ assert_eq!(far, 0, "far corner must stay untouched, got r={far}");
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs
index 9388ab3..879d86c 100644
--- a/crates/rustmotion-core/src/engine/renderer/fonts.rs
+++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs
@@ -13,57 +13,131 @@ use super::google_fonts::{font_cache_dir, resolve_google_font};
thread_local! {
static THREAD_FONT_MGR: FontMgr = FontMgr::default();
// Per-thread cache of Typefaces built from the global custom-font bytes,
- // so each render thread builds each custom face at most once.
- static CUSTOM_TYPEFACES: RefCell> = RefCell::new(HashMap::new());
+ // keyed by (family, weight, italic) — the exact variant `custom_typeface`
+ // picked — so each render thread builds each custom face at most once.
+ static CUSTOM_TYPEFACES: RefCell> =
+ RefCell::new(HashMap::new());
}
pub fn font_mgr() -> FontMgr {
THREAD_FONT_MGR.with(|mgr| mgr.clone())
}
+/// One registered custom-font file: its raw bytes plus the `(weight,
+/// italic)` style Skia parsed out of the file itself when it was
+/// registered — the ground truth for what that file actually renders as,
+/// independent of which nominal weight the caller happened to request it
+/// under.
+#[derive(Clone)]
+struct CustomFontVariant {
+ data: Vec,
+ weight: i32,
+ italic: bool,
+}
+
/// Global registry of custom/Google-font bytes, keyed by family name. Filled
/// once by [`load_custom_fonts`] on the main thread; read by every render
-/// thread through [`custom_typeface`]. A family maps to the first file
-/// registered for it (one weight per custom family via this path — sufficient
-/// for accent display faces; multi-weight custom families are future work).
-fn custom_font_registry() -> &'static Mutex>> {
- static REG: OnceLock>>> = OnceLock::new();
+/// thread through [`custom_typeface`]. Each family holds every distinct
+/// `(weight, italic)` variant registered for it — e.g. a Google Fonts
+/// declaration with `weights: [400, 700]` registers two variants — so
+/// [`custom_typeface`] can pick whichever is the closest match to what a
+/// paint call asks for, instead of always returning the first file that
+/// happened to register (the previous behaviour: every variant after the
+/// first was invisible, and every weight/style request resolved to
+/// whichever one file won the race).
+fn custom_font_registry() -> &'static Mutex>> {
+ static REG: OnceLock>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(HashMap::new()))
}
-/// Store a custom font's bytes under `family` (first registration wins).
-pub fn register_custom_font_bytes(family: &str, data: Vec) {
+/// Register a custom font's bytes under `family`, tagged with the `(weight,
+/// italic)` style Skia reports for the parsed file. A no-op if that exact
+/// `(family, weight, italic)` combination is already registered.
+pub fn register_custom_font_variant(family: &str, data: Vec, weight: i32, italic: bool) {
let mut reg = custom_font_registry()
.lock()
.unwrap_or_else(|e| e.into_inner());
- reg.entry(family.to_string()).or_insert(data);
+ let variants = reg.entry(family.to_string()).or_default();
+ if !variants
+ .iter()
+ .any(|v| v.weight == weight && v.italic == italic)
+ {
+ variants.push(CustomFontVariant {
+ data,
+ weight,
+ italic,
+ });
+ }
}
-/// The raw bytes registered for `family`, if any (test/introspection helper).
-pub fn custom_font_bytes(family: &str) -> Option> {
- custom_font_registry()
+/// The raw bytes registered for `family`'s closest `(weight, italic)` match,
+/// if any variant is registered under that family (test/introspection
+/// helper).
+#[cfg(test)]
+fn custom_font_bytes(family: &str, weight: i32, italic: bool) -> Option> {
+ let reg = custom_font_registry()
.lock()
- .unwrap_or_else(|e| e.into_inner())
- .get(family)
- .cloned()
+ .unwrap_or_else(|e| e.into_inner());
+ let variants = reg.get(family)?;
+ closest_variant(variants, weight, italic).map(|v| v.data.clone())
+}
+
+/// Pick the registered variant closest to `(weight, italic)`: exact
+/// italic-ness match preferred, then the smallest weight distance — the
+/// same nearest-match spirit as CSS font matching (`font-weight`/
+/// `font-style` never fail to resolve to *something*, they resolve to the
+/// closest available face).
+fn closest_variant(
+ variants: &[CustomFontVariant],
+ weight: i32,
+ italic: bool,
+) -> Option<&CustomFontVariant> {
+ variants.iter().min_by_key(|v| {
+ let italic_penalty = if v.italic == italic { 0 } else { 1_000_000 };
+ italic_penalty + (v.weight - weight).abs()
+ })
}
-/// Resolve a registered custom font to a Typeface, building it from the global
-/// bytes on first use per thread and caching it thereafter. `None` when no
-/// custom font is registered under `family`.
-fn custom_typeface(family: &str) -> Option {
+/// Resolve a registered custom font to a Typeface for the requested `style`,
+/// building it from the global bytes on first use per thread and caching it
+/// thereafter. `None` when no custom font is registered under `family`.
+fn custom_typeface(family: &str, style: FontStyle) -> Option {
+ let weight = *style.weight();
+ let italic = style.slant() != skia_safe::font_style::Slant::Upright;
+ let cache_key = (family.to_string(), weight, italic);
CUSTOM_TYPEFACES.with(|cache| {
- if let Some(tf) = cache.borrow().get(family) {
+ if let Some(tf) = cache.borrow().get(&cache_key) {
return Some(tf.clone());
}
- let data = custom_font_bytes(family)?;
+ let data = {
+ let reg = custom_font_registry()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let variants = reg.get(family)?;
+ closest_variant(variants, weight, italic)?.data.clone()
+ };
let sk_data = skia_safe::Data::new_copy(&data);
let tf = font_mgr().new_from_data(&sk_data, None)?;
- cache.borrow_mut().insert(family.to_string(), tf.clone());
+ cache.borrow_mut().insert(cache_key, tf.clone());
Some(tf)
})
}
+/// Look up only the custom/Google-font registry for `family` at the
+/// requested `style`, without falling through to any system font. Exposed
+/// for callers (e.g. `codeblock`/`terminal`'s monospace font resolver) that
+/// need to check "did the scenario declare a custom font for this family"
+/// *before* trying their own family-specific system fallback chain — unlike
+/// [`typeface_with_fallback`], which interleaves a single system-family
+/// lookup between the custom check and its own generic Helvetica/Arial
+/// catch-all, an order that doesn't suit every caller (see issue: codeblock/
+/// terminal's hardcoded monospace fallback list was never reached because
+/// `typeface_with_fallback`'s own system lookup already matched a decoy
+/// system family, e.g. "JetBrains Mono").
+pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option {
+ custom_typeface(family, style)
+}
+
/// Validate a `FontEntry` and resolve it to a list of TTF file paths.
///
/// - Local entry (`path` set, `source` absent): returns `[path]` as-is.
@@ -132,20 +206,28 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path)
match std::fs::read(path) {
Ok(data) => {
let sk_data = skia_safe::Data::new_copy(&data);
- if font_mgr.new_from_data(&sk_data, None).is_none() {
+ let Some(tf) = font_mgr.new_from_data(&sk_data, None) else {
eprintln!(
"Warning: failed to register custom font '{}' from '{}'",
family,
path.display()
);
return;
- }
+ };
// Skia's default FontMgr can build a Typeface from `new_from_data`
// but never exposes it to `match_family_style` (name lookup only
// sees installed system fonts). So keep the raw bytes in a global
// registry; `typeface_with_fallback` builds and caches a Typeface
- // from them per thread, ahead of the system match.
- register_custom_font_bytes(family, data);
+ // from them per thread, ahead of the system match. Tag the
+ // variant with the (weight, italic) Skia parsed out of the file
+ // itself — the ground truth for what it actually renders as —
+ // so a family with several registered weights (e.g. Google
+ // Fonts `weights: [400, 700]`) exposes every one of them instead
+ // of only whichever file happened to register first.
+ let parsed_style = tf.font_style();
+ let weight = *parsed_style.weight();
+ let italic = parsed_style.slant() != skia_safe::font_style::Slant::Upright;
+ register_custom_font_variant(family, data, weight, italic);
}
Err(e) => {
eprintln!(
@@ -166,8 +248,10 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path)
pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result {
// Custom/Google fonts declared in the scenario win over system fonts:
// they are not visible to `match_family_style`, so resolve them from the
- // registry first.
- if let Some(t) = custom_typeface(family) {
+ // registry first — matched against the requested `style` so a family
+ // registered with several weights picks the right one instead of
+ // whichever file happened to register first (#6).
+ if let Some(t) = custom_typeface(family, style) {
return Ok(t);
}
let fm = font_mgr();
@@ -200,6 +284,42 @@ pub fn emoji_typeface() -> Option {
EMOJI_TF.with(|tf| tf.clone())
}
+/// Resolve a system fallback typeface that actually contains a glyph for
+/// `c`, for when `primary_family`'s own face doesn't cover it (audit #3:
+/// CJK/Arabic/Devanagari/other scripts rendered as `.notdef` tofu when only
+/// a Latin `font-family` was requested, because neither measurement nor
+/// painting ever looked past the single requested typeface). This is
+/// Skia's font-fallback-by-character API — the same mechanism a browser
+/// uses to substitute, say, a CJK font for Chinese text embedded in an
+/// otherwise-Latin paragraph, instead of leaving `.notdef` tofu. Memoized
+/// per thread (keyed on the inputs that actually affect the OS's fallback
+/// decision) since callers may probe this once per uncovered code point
+/// during run segmentation. Returns `None` if no installed font covers `c`
+/// either — the caller falls back to the originally requested (tofu-
+/// producing) font, exactly the pre-fix behaviour, not worse.
+pub fn fallback_typeface_for_char(
+ primary_family: &str,
+ style: FontStyle,
+ c: char,
+) -> Option {
+ thread_local! {
+ static FALLBACK_CACHE: RefCell>> =
+ RefCell::new(HashMap::new());
+ }
+ let weight = *style.weight();
+ let italic = style.slant() != skia_safe::font_style::Slant::Upright;
+ let key = (primary_family.to_string(), weight, italic, c as u32);
+ FALLBACK_CACHE.with(|cache| {
+ if let Some(hit) = cache.borrow().get(&key) {
+ return hit.clone();
+ }
+ let resolved =
+ font_mgr().match_family_style_character(primary_family, style, &[], c as i32);
+ cache.borrow_mut().insert(key, resolved.clone());
+ resolved
+ })
+}
+
// ─── Unit tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -251,15 +371,60 @@ mod tests {
}
#[test]
- fn custom_font_registry_stores_first_and_serves_bytes() {
- register_custom_font_bytes("RmProbeRegistryFamily", vec![1, 2, 3]);
- // First registration wins (a later weight must not clobber it).
- register_custom_font_bytes("RmProbeRegistryFamily", vec![9, 9]);
+ fn custom_font_registry_stores_distinct_weights_and_serves_bytes() {
+ register_custom_font_variant("RmProbeRegistryFamily", vec![1, 2, 3], 400, false);
+ // A *different* (weight, italic) is a genuinely new variant — not a
+ // clobber of the first (the old `family`-only-keyed `or_insert`
+ // registry made every registration after the first invisible; #6).
+ register_custom_font_variant("RmProbeRegistryFamily", vec![9, 9], 700, false);
assert_eq!(
- custom_font_bytes("RmProbeRegistryFamily"),
+ custom_font_bytes("RmProbeRegistryFamily", 400, false),
Some(vec![1, 2, 3])
);
- assert!(custom_font_bytes("RmProbeUnregistered").is_none());
+ assert_eq!(
+ custom_font_bytes("RmProbeRegistryFamily", 700, false),
+ Some(vec![9, 9])
+ );
+ assert!(custom_font_bytes("RmProbeUnregistered", 400, false).is_none());
+ }
+
+ #[test]
+ fn registering_the_same_weight_twice_keeps_the_first() {
+ register_custom_font_variant("RmProbeDupeFamily", vec![1, 2, 3], 400, false);
+ register_custom_font_variant("RmProbeDupeFamily", vec![9, 9], 400, false);
+ assert_eq!(
+ custom_font_bytes("RmProbeDupeFamily", 400, false),
+ Some(vec![1, 2, 3]),
+ "re-registering the same (weight, italic) must not clobber the first file"
+ );
+ }
+
+ #[test]
+ fn custom_typeface_lookup_picks_the_closest_registered_weight() {
+ // Pure selection-logic reproduction of #6's fix mechanism,
+ // independent of any font actually installed on the host: three
+ // variants registered under one family; a lookup for an
+ // intermediate weight must pick the *closest* one, not always the
+ // first registered — the defect the audit measured (bold and
+ // normal always resolving to the same file).
+ register_custom_font_variant("RmProbeClosestFamily", vec![1], 400, false);
+ register_custom_font_variant("RmProbeClosestFamily", vec![2], 700, false);
+ register_custom_font_variant("RmProbeClosestFamily", vec![3], 900, false);
+
+ let reg = custom_font_registry()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let variants = reg.get("RmProbeClosestFamily").expect("registered above");
+ assert_eq!(
+ closest_variant(variants, 650, false).unwrap().weight,
+ 700,
+ "650 should resolve to the nearest registered weight, 700"
+ );
+ assert_eq!(
+ closest_variant(variants, 100, false).unwrap().weight,
+ 400,
+ "100 should resolve to the nearest registered weight, 400"
+ );
}
/// The bug this fix targets: a registered custom family must resolve to the
@@ -275,7 +440,17 @@ mod tests {
let Ok(bytes) = std::fs::read(&path) else {
return; // cold font cache → skip (render QA covers it)
};
- register_custom_font_bytes("Anton", bytes);
+ let fm = font_mgr();
+ let parsed = fm
+ .new_from_data(&skia_safe::Data::new_copy(&bytes), None)
+ .expect("cached TTF must parse");
+ let style = parsed.font_style();
+ register_custom_font_variant(
+ "Anton",
+ bytes,
+ *style.weight(),
+ style.slant() != skia_safe::font_style::Slant::Upright,
+ );
let tf = typeface_with_fallback("Anton", FontStyle::normal()).unwrap();
assert_eq!(
tf.family_name(),
@@ -284,6 +459,56 @@ mod tests {
);
}
+ /// End-to-end reproduction of #6: a family registered with two distinct
+ /// weights (mirrors `fonts: [{"family":"Inter","source":"google",
+ /// "weights":[400,700]}]`) must resolve *different* typefaces for
+ /// `font-weight: normal` vs `font-weight: bold`. Before the fix,
+ /// `custom_typeface` ignored `style` entirely and `register_custom_
+ /// font_bytes` kept only the first-registered file, so the audit's two
+ /// rendered PNGs (bold vs normal) came out byte-for-byte identical.
+ /// Skips on a cold font cache (no network access in CI) — the render QA
+ /// in `examples/` is the visual counterpart.
+ #[test]
+ fn family_with_two_registered_weights_resolves_distinct_typefaces() {
+ let cache_dir = format!(
+ "{}/.cache/rustmotion/fonts",
+ std::env::var("HOME").unwrap_or_default()
+ );
+ let (Ok(normal_bytes), Ok(bold_bytes)) = (
+ std::fs::read(format!("{cache_dir}/inter-400.ttf")),
+ std::fs::read(format!("{cache_dir}/inter-700.ttf")),
+ ) else {
+ return; // cold font cache → skip (render QA covers it)
+ };
+
+ let fm = font_mgr();
+ let normal_parsed = fm
+ .new_from_data(&skia_safe::Data::new_copy(&normal_bytes), None)
+ .expect("cached TTF must parse");
+ let bold_parsed = fm
+ .new_from_data(&skia_safe::Data::new_copy(&bold_bytes), None)
+ .expect("cached TTF must parse");
+ let normal_weight = *normal_parsed.font_style().weight();
+ let bold_weight = *bold_parsed.font_style().weight();
+
+ register_custom_font_variant("RmProbeInterFamily", normal_bytes, normal_weight, false);
+ register_custom_font_variant("RmProbeInterFamily", bold_bytes, bold_weight, false);
+
+ let resolved_normal =
+ typeface_with_fallback("RmProbeInterFamily", FontStyle::normal()).unwrap();
+ let resolved_bold =
+ typeface_with_fallback("RmProbeInterFamily", FontStyle::bold()).unwrap();
+
+ assert_ne!(
+ *resolved_normal.font_style().weight(),
+ *resolved_bold.font_style().weight(),
+ "requesting normal vs bold on the same custom family must resolve different weights \
+ (both used to resolve to whichever file registered first)"
+ );
+ assert_eq!(*resolved_bold.font_style().weight(), bold_weight);
+ assert_eq!(*resolved_normal.font_style().weight(), normal_weight);
+ }
+
#[test]
fn neither_path_nor_source_is_error() {
let entry = neither_entry();
diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs
index 081be2b..d607dad 100644
--- a/crates/rustmotion-core/src/engine/renderer/text.rs
+++ b/crates/rustmotion-core/src/engine/renderer/text.rs
@@ -1,4 +1,4 @@
-use skia_safe::{Canvas, Font, Paint, Point, TextBlob};
+use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface};
// ─── Counter formatting ─────────────────────────────────────────────────────
@@ -120,8 +120,14 @@ pub fn make_text_blob_with_spacing(text: &str, font: &Font, spacing: f32) -> Opt
// ─── Emoji support ──────────────────────────────────────────────────────────
-/// Check if a character is an emoji or emoji-related codepoint.
-fn is_emoji(c: char) -> bool {
+/// Code points that render as emoji **by default**, in every context,
+/// regardless of any following variation selector — genuine pictograph
+/// blocks (Miscellaneous Symbols and Pictographs, Emoticons, Transport,
+/// Supplemental Symbols/Pictographs, flags, keycaps, ZWJ sequences...).
+/// Nothing in these ranges has a meaningful plain-text rendering, so there
+/// is no narrowing to do here — contrast with
+/// [`is_text_presentation_by_default`], which needs one (audit #8).
+fn is_emoji_presentation_default(c: char) -> bool {
let cp = c as u32;
matches!(cp,
// Miscellaneous Symbols and Pictographs (includes skin tone modifiers 1F3FB-1F3FF)
@@ -136,12 +142,6 @@ fn is_emoji(c: char) -> bool {
0x1FA00..=0x1FA6F |
// Symbols and Pictographs Extended-B
0x1FA70..=0x1FAFF |
- // Dingbats (includes ✂️..➰ and arrows/symbols)
- 0x2702..=0x27B0 |
- // Miscellaneous Symbols (includes ☀️..⛿)
- 0x2600..=0x26FF |
- // Variation Selectors (keep with preceding emoji)
- 0xFE00..=0xFE0F |
// Zero-Width Joiner
0x200D |
// Combining Enclosing Keycap
@@ -152,68 +152,247 @@ fn is_emoji(c: char) -> bool {
0xE0020..=0xE007F |
// Playing cards, mahjong
0x1F004 | 0x1F0CF |
- // Misc technical (⌚ ⌛ ⏩..⏳ ⏸..⏺)
- 0x231A..=0x231B |
+ // Misc technical, unconditionally emoji (⏩..⏳ ⏸..⏺)
0x23E9..=0x23F3 |
0x23F8..=0x23FA |
- // Arrows and geometric symbols used as emoji
+ // Arrows and geometric symbols, unconditionally emoji
+ 0x2B1B..=0x2B1C |
+ 0x2B50 | 0x2B55
+ )
+}
+
+/// Code points that are TEXT-presentation **by default** — a normal glyph
+/// in the primary font, honouring `style.color` — but that Unicode still
+/// marks `Emoji=Yes`: they render as color emoji only when the author
+/// explicitly opts in with a following U+FE0F variation selector (audit
+/// #8). Routing these unconditionally to the emoji font (the previous
+/// behaviour, lumped in with [`is_emoji_presentation_default`]) either
+/// painted a color bitmap that ignores `style.color` (✔, ©, ®, ™ — all
+/// covered by Apple Color Emoji) or a `.notdef` tofu square for code points
+/// the emoji font itself doesn't cover even though the primary font does
+/// (✓ U+2713 — absent from Apple Color Emoji even though U+2714 sits one
+/// code point over and is present).
+fn is_text_presentation_by_default(c: char) -> bool {
+ let cp = c as u32;
+ matches!(cp,
+ // Copyright, registered, trademark
+ 0x00A9 | 0x00AE | 0x2122 |
+ // Misc technical (⌚ ⌛)
+ 0x231A..=0x231B |
+ // Miscellaneous Symbols (☀️..⛿)
+ 0x2600..=0x26FF |
+ // Dingbats (✂️..➰ and arrows/symbols), includes ✓/✔ U+2713/2714
+ 0x2702..=0x27B0 |
+ // Arrows and geometric symbols used as emoji only with VS16
0x2934..=0x2935 |
0x25AA..=0x25AB |
0x25B6 | 0x25C0 |
0x25FB..=0x25FE |
- // Arrows
0x2B05..=0x2B07 |
- 0x2B1B..=0x2B1C |
- 0x2B50 | 0x2B55 |
// CJK symbols
0x3030 | 0x303D |
- 0x3297 | 0x3299 |
- // Copyright, registered, trademark
- 0x00A9 | 0x00AE | 0x2122
+ 0x3297 | 0x3299
)
}
-/// A segment of text that uses either the primary font or the emoji font.
-struct TextRun {
- start: usize, // byte offset
- end: usize, // byte offset
- is_emoji: bool,
+/// Variation Selector-16 — forces the *preceding* text-presentation-default
+/// code point (see [`is_text_presentation_by_default`]) into emoji
+/// presentation. Narrower than the old catch-all `0xFE00..=0xFE0F` range:
+/// VS-15 (U+FE0E, forces *text* presentation) and the rest of that block
+/// are not emoji-forcing.
+const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}';
+
+/// True if `c` should be painted/measured with the emoji font, given the
+/// character immediately following it (`next`). Needed because
+/// [`is_text_presentation_by_default`] code points only opt into emoji
+/// presentation when explicitly followed by U+FE0F — a bare lookup of `c`
+/// alone can't tell "©" (text) from "©️" (explicit emoji presentation)
+/// apart.
+fn char_wants_emoji_font(c: char, next: Option) -> bool {
+ if c == VARIATION_SELECTOR_EMOJI {
+ return true; // always grouped with whatever code point selected it
+ }
+ if is_emoji_presentation_default(c) {
+ return true;
+ }
+ if is_text_presentation_by_default(c) {
+ return next == Some(VARIATION_SELECTOR_EMOJI);
+ }
+ false
}
-/// Segment text into runs of emoji vs non-emoji characters.
-fn segment_text_runs(text: &str) -> Vec {
- let mut runs = Vec::new();
- let mut chars = text.char_indices().peekable();
+/// Which font a run of text should be painted/measured with.
+enum RunKind {
+ Primary,
+ Emoji,
+ /// #3: a system fallback typeface resolved for a code point the
+ /// primary font doesn't cover (e.g. CJK/Arabic/Devanagari when only a
+ /// Latin `font-family` was requested).
+ Fallback(Typeface),
+}
+
+/// True if `a` and `b` are the "same" run kind for the purpose of merging
+/// adjacent characters into one run. Two `Fallback` runs merge only if they
+/// resolved to the *same* typeface (compared by Skia's unique id) — two
+/// characters that both need fallback but belong to different scripts
+/// (e.g. mixed CJK + Arabic) must not merge into a single run painted with
+/// only one of the two fonts.
+fn same_run_kind(a: &RunKind, b: &RunKind) -> bool {
+ match (a, b) {
+ (RunKind::Primary, RunKind::Primary) => true,
+ (RunKind::Emoji, RunKind::Emoji) => true,
+ (RunKind::Fallback(ta), RunKind::Fallback(tb)) => ta.unique_id() == tb.unique_id(),
+ _ => false,
+ }
+}
- while let Some(&(start_byte, c)) = chars.peek() {
- let emoji = is_emoji(c);
+/// True if `font` has an actual glyph (not `.notdef`, glyph id 0) for every
+/// character in `text`. Used both to gate whether a code point needs a
+/// fallback lookup at all (#3), and as a coverage guard before actually
+/// using the emoji font for a run classified as emoji (#8): some code
+/// points Unicode marks emoji-capable are, on a given platform, absent
+/// from the color emoji font even though the primary font has a perfectly
+/// good text glyph for them (U+2713 on Apple Color Emoji).
+fn font_covers(font: &Font, text: &str) -> bool {
+ let glyphs = font.str_to_glyphs_vec(text);
+ !glyphs.is_empty() && glyphs.iter().all(|&g| g != 0)
+}
+
+/// Classify a single character's font choice: emoji presentation first
+/// (#8), then primary-font glyph coverage, then a system fallback typeface
+/// resolved via [`super::fallback_typeface_for_char`] for the primary
+/// font's own `(family, style)` (#3). Whitespace/control code points are
+/// always `Primary` (assumed universally present, or invisible) so the
+/// fallback lookup isn't triggered by the spaces between same-script words.
+fn classify_char(c: char, primary: &Font, next: Option) -> RunKind {
+ if char_wants_emoji_font(c, next) {
+ return RunKind::Emoji;
+ }
+ if c.is_whitespace() || (c as u32) < 0x20 {
+ return RunKind::Primary;
+ }
+ if primary.unichar_to_glyph(c as i32) != 0 {
+ return RunKind::Primary;
+ }
+ let primary_typeface = primary.typeface();
+ let style = primary_typeface.font_style();
+ let family = primary_typeface.family_name();
+ match super::fallback_typeface_for_char(&family, style, c) {
+ Some(tf) => RunKind::Fallback(tf),
+ // No installed font covers `c` either — same `.notdef` tofu as
+ // before this fix, not worse.
+ None => RunKind::Primary,
+ }
+}
+
+/// Segment text into runs, each tagged with which font should paint/measure
+/// it (see [`classify_char`]).
+fn segment_text_runs(text: &str, primary: &Font) -> Vec {
+ let chars: Vec<(usize, char)> = text.char_indices().collect();
+ let mut runs = Vec::new();
+ let mut i = 0;
+ while i < chars.len() {
+ let (start_byte, c) = chars[i];
+ let next = chars.get(i + 1).map(|&(_, ch)| ch);
+ let kind = classify_char(c, primary, next);
let mut end_byte = start_byte + c.len_utf8();
- chars.next();
+ i += 1;
- while let Some(&(_, next_c)) = chars.peek() {
- if is_emoji(next_c) != emoji {
+ while let Some(&(nb, nc)) = chars.get(i) {
+ let nnext = chars.get(i + 1).map(|&(_, ch)| ch);
+ let nkind = classify_char(nc, primary, nnext);
+ if !same_run_kind(&kind, &nkind) {
break;
}
- end_byte += next_c.len_utf8();
- chars.next();
+ end_byte = nb + nc.len_utf8();
+ i += 1;
}
runs.push(TextRun {
start: start_byte,
end: end_byte,
- is_emoji: emoji,
+ kind,
});
}
runs
}
-/// Check if text contains any emoji characters.
+/// A segment of text tagged with which font it should be painted/measured
+/// with (see [`RunKind`]).
+struct TextRun {
+ start: usize, // byte offset
+ end: usize, // byte offset
+ kind: RunKind,
+}
+
+/// True if `text` contains any character that wants emoji presentation
+/// (#8-aware: honours the VS16 opt-in for text-presentation-default code
+/// points, so plain "©"/"✓" no longer count as emoji here).
pub fn has_emoji(text: &str) -> bool {
- text.chars().any(is_emoji)
+ let chars: Vec = text.chars().collect();
+ chars
+ .iter()
+ .enumerate()
+ .any(|(i, &c)| char_wants_emoji_font(c, chars.get(i + 1).copied()))
}
-/// Draw a text line with emoji font fallback.
-/// If `emoji_font` is None, falls back to drawing everything with the primary font.
+/// True if `text` needs the full run-segmentation machinery in
+/// [`draw_text_with_fallback`]/[`measure_text_with_fallback`]: it contains
+/// emoji-presentation content (and an emoji font is actually available), or
+/// at least one non-whitespace/control code point the primary `font`
+/// doesn't cover (#3). When neither is true, every caller's existing
+/// single-font fast path is exactly correct and segmentation would be
+/// wasted work.
+fn needs_segmentation(text: &str, primary: &Font, emoji_font: &Option) -> bool {
+ if emoji_font.is_some() && has_emoji(text) {
+ return true;
+ }
+ text.chars().any(|c| {
+ // ASCII short-circuits before the `unichar_to_glyph` FFI call: every
+ // font this engine resolves covers printable ASCII, and callers
+ // that draw a lot of short spans per frame (codeblock's per-token
+ // syntax highlighting, in particular) call this once per span —
+ // skipping the Skia round-trip for the overwhelmingly common
+ // all-ASCII case keeps #3's fix from adding per-glyph FFI overhead
+ // to code that was never affected by the tofu/coverage bug it
+ // fixes.
+ !(c.is_ascii() || c.is_whitespace() || (c as u32) < 0x20)
+ && primary.unichar_to_glyph(c as i32) == 0
+ })
+}
+
+/// Resolve which `Font` a run should actually be painted/measured with,
+/// applying the emoji coverage guard (#8) and building a same-size `Font`
+/// from a resolved fallback [`Typeface`] (#3). Returns a borrow of one of
+/// the two inputs, or an owned font stored in `owned` to keep the borrow
+/// alive at the call site.
+fn resolve_run_font<'a>(
+ kind: &RunKind,
+ segment: &str,
+ primary: &'a Font,
+ emoji_font: &'a Option,
+ owned: &'a mut Option,
+) -> &'a Font {
+ match kind {
+ RunKind::Primary => primary,
+ RunKind::Emoji => match emoji_font {
+ Some(ef) if font_covers(ef, segment.trim_end_matches(VARIATION_SELECTOR_EMOJI)) => ef,
+ // Coverage guard (#8): the emoji font doesn't actually have
+ // this glyph (or isn't available at all) — fall back to the
+ // primary font rather than paint `.notdef`.
+ _ => primary,
+ },
+ RunKind::Fallback(tf) => {
+ *owned = Some(Font::from_typeface(tf.clone(), primary.size()));
+ owned.as_ref().unwrap()
+ }
+ }
+}
+
+/// Draw a text line with emoji-font and glyph-coverage fallback (#3, #8).
+/// If `emoji_font` is None, falls back to drawing everything with the
+/// primary font (plus any `#3` system fallback the primary font's coverage
+/// gap needs).
pub fn draw_text_with_fallback(
canvas: &Canvas,
text: &str,
@@ -224,8 +403,9 @@ pub fn draw_text_with_fallback(
y: f32,
paint: &Paint,
) {
- // Fast path: no emoji font or no emoji in text
- if emoji_font.is_none() || !has_emoji(text) {
+ // Fast path: nothing here needs emoji presentation or a glyph-coverage
+ // fallback — every earlier caller's exact previous behaviour.
+ if !needs_segmentation(text, font, emoji_font) {
if letter_spacing.abs() > 0.01 {
if let Some(blob) = make_text_blob_with_spacing(text, font, letter_spacing) {
canvas.draw_text_blob(&blob, (x, y), paint);
@@ -236,13 +416,13 @@ pub fn draw_text_with_fallback(
return;
}
- let emoji_font = emoji_font.as_ref().unwrap();
- let runs = segment_text_runs(text);
+ let runs = segment_text_runs(text, font);
let mut cursor_x = x;
for run in &runs {
let segment = &text[run.start..run.end];
- let f = if run.is_emoji { emoji_font } else { font };
+ let mut owned = None;
+ let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
if letter_spacing.abs() > 0.01 {
if let Some(blob) = make_text_blob_with_spacing(segment, f, letter_spacing) {
@@ -263,7 +443,9 @@ pub fn draw_text_with_fallback(
}
}
-/// Measure the width of a text line with emoji font fallback.
+/// Measure the width of a text line with emoji-font and glyph-coverage
+/// fallback (#3, #8) — mirrors [`draw_text_with_fallback`] run-for-run so
+/// the width this returns always matches what actually gets painted.
pub fn measure_text_with_fallback(
text: &str,
font: &Font,
@@ -271,7 +453,7 @@ pub fn measure_text_with_fallback(
letter_spacing: f32,
) -> f32 {
// Fast path
- if emoji_font.is_none() || !has_emoji(text) {
+ if !needs_segmentation(text, font, emoji_font) {
let (w, _) = font.measure_str(text, None);
let extra = if letter_spacing.abs() > 0.01 {
letter_spacing * (text.chars().count() as f32 - 1.0).max(0.0)
@@ -281,13 +463,13 @@ pub fn measure_text_with_fallback(
return w + extra;
}
- let emoji_font = emoji_font.as_ref().unwrap();
- let runs = segment_text_runs(text);
+ let runs = segment_text_runs(text, font);
let mut total_w = 0.0f32;
for run in &runs {
let segment = &text[run.start..run.end];
- let f = if run.is_emoji { emoji_font } else { font };
+ let mut owned = None;
+ let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned);
let (w, _) = f.measure_str(segment, None);
let extra = if letter_spacing.abs() > 0.01 {
letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0)
@@ -672,3 +854,350 @@ mod tracking_tests {
}
}
}
+
+// ─── Tests: #8 — emoji presentation must default to text, not tofu/color ──
+
+#[cfg(test)]
+mod emoji_presentation_tests {
+ use super::super::{emoji_typeface, typeface_with_fallback};
+ use super::*;
+
+ // These assertions are pure code-point classification — no font, no
+ // machine dependency — so they hold on every host.
+
+ #[test]
+ fn text_presentation_default_symbols_are_not_emoji_without_vs16() {
+ // #8's headline repro: ✓ (U+2713) and ✔ (U+2714) — both inside the
+ // old unconditional 0x2702..=0x27B0 dingbats range — must NOT be
+ // classified as emoji when they appear bare, since Unicode marks
+ // them text-presentation by default.
+ assert!(
+ !char_wants_emoji_font('\u{2713}', None),
+ "✓ bare must be text"
+ );
+ assert!(
+ !char_wants_emoji_font('\u{2714}', None),
+ "✔ bare must be text"
+ );
+ // Copyright/registered/trademark: also text-presentation by default.
+ assert!(
+ !char_wants_emoji_font('\u{00A9}', None),
+ "© bare must be text"
+ );
+ assert!(
+ !char_wants_emoji_font('\u{00AE}', None),
+ "® bare must be text"
+ );
+ assert!(
+ !char_wants_emoji_font('\u{2122}', None),
+ "™ bare must be text"
+ );
+ }
+
+ #[test]
+ fn text_presentation_default_symbols_opt_into_emoji_with_vs16() {
+ // Explicit author intent (U+FE0F immediately after) must still be
+ // honoured — this is what "presentation *by default*" means.
+ assert!(char_wants_emoji_font('\u{2713}', Some('\u{FE0F}')));
+ assert!(char_wants_emoji_font('\u{00A9}', Some('\u{FE0F}')));
+ }
+
+ #[test]
+ fn genuine_pictographs_are_always_emoji_regardless_of_vs16() {
+ // Regression guard: the narrowing must not touch the actual emoji
+ // blocks (grinning face, etc.) — these have no meaningful text
+ // rendering at all.
+ assert!(
+ char_wants_emoji_font('\u{1F600}', None),
+ "😀 must stay emoji"
+ );
+ assert!(char_wants_emoji_font('\u{1F600}', Some('\u{FE0F}')));
+ }
+
+ #[test]
+ fn variation_selector_16_itself_is_always_emoji() {
+ // So it merges into whatever run selected it, rather than becoming
+ // its own (invisible, harmless either way) primary-font run.
+ assert!(char_wants_emoji_font('\u{FE0F}', None));
+ }
+
+ #[test]
+ fn variation_selector_15_does_not_force_emoji() {
+ // VS-15 (U+FE0E) forces *text* presentation — must not be conflated
+ // with VS-16 the way the old catch-all 0xFE00..=0xFE0F range did.
+ assert!(!char_wants_emoji_font('\u{2713}', Some('\u{FE0E}')));
+ }
+
+ #[test]
+ fn has_emoji_reflects_the_narrowed_classification() {
+ assert!(!has_emoji("2713:\u{2713} copyright:\u{00A9}"));
+ assert!(has_emoji("checked \u{2713}\u{FE0F}"));
+ assert!(has_emoji("grinning \u{1F600}"));
+ }
+
+ // ---- render-level reproduction: color and coverage, no emoji font needed ----
+
+ fn helvetica_font(size: f32) -> Font {
+ let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal())
+ .expect("host must have a fallback typeface");
+ Font::from_typeface(typeface, size)
+ }
+
+ /// Renders `text` at `size` in white and returns `(ink_pixel_count,
+ /// mean_r, mean_g, mean_b)` over every non-transparent pixel.
+ fn render_and_sample(text: &str, size: f32) -> (usize, f64, f64, f64) {
+ use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo};
+ const W: i32 = 200;
+ const H: i32 = 200;
+ let font = helvetica_font(size);
+ let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, size));
+ let mut surface = surfaces::raster_n32_premul((W, H)).unwrap();
+ let canvas = surface.canvas();
+ canvas.clear(Color::BLACK);
+ let mut paint = Paint::default();
+ paint.set_color(Color::WHITE);
+ paint.set_anti_alias(true);
+ let (_, metrics) = font.metrics();
+ let ascent = -metrics.ascent;
+ draw_text_with_fallback(
+ canvas,
+ text,
+ &font,
+ &emoji_font,
+ 0.0,
+ 10.0,
+ ascent + 10.0,
+ &paint,
+ );
+
+ let info = ImageInfo::new((W, H), ColorType::RGBA8888, 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));
+
+ let (mut n, mut sr, mut sg, mut sb) = (0usize, 0f64, 0f64, 0f64);
+ for px in buf.chunks_exact(4) {
+ if px[3] > 40 {
+ n += 1;
+ sr += px[0] as f64;
+ sg += px[1] as f64;
+ sb += px[2] as f64;
+ }
+ }
+ if n == 0 {
+ (0, 0.0, 0.0, 0.0)
+ } else {
+ (n, sr / n as f64, sg / n as f64, sb / n as f64)
+ }
+ }
+
+ #[test]
+ fn bare_check_mark_paints_requested_white_not_tofu_or_color_bitmap() {
+ // #8 render-level reproduction, environment-independent: whether or
+ // not an emoji font is installed on this host is irrelevant here —
+ // with the fix, U+2713 alone is classified as *text*, so
+ // `draw_text_with_fallback` never even looks at `emoji_font` for
+ // it. Guard on the primary font actually covering the glyph so
+ // this doesn't fail on some exotic host where even Helvetica lacks
+ // it (the audit's own finding: Helvetica/Menlo DO have it, only
+ // Apple Color Emoji doesn't).
+ let font = helvetica_font(64.0);
+ if !font_covers(&font, "\u{2713}") {
+ eprintln!("skip: primary font doesn't cover U+2713 on this host");
+ return;
+ }
+ let (ink, r, g, b) = render_and_sample("\u{2713}", 64.0);
+ assert!(ink > 20, "expected visible ink for ✓, got {ink} pixels");
+ // White request -> painted channels should be bright and roughly
+ // neutral (not a dark/colored emoji-bitmap tint).
+ assert!(
+ r > 150.0 && g > 150.0 && b > 150.0,
+ "✓ should paint near-white, got mean rgb=({r:.0},{g:.0},{b:.0})"
+ );
+ }
+}
+
+// ─── Tests: #3 — glyph fallback for scripts the primary font doesn't cover ─
+
+#[cfg(test)]
+mod glyph_fallback_tests {
+ use super::super::{fallback_typeface_for_char, typeface_with_fallback};
+ use super::*;
+
+ fn helvetica_font(size: f32) -> Font {
+ let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal())
+ .expect("host must have a fallback typeface");
+ Font::from_typeface(typeface, size)
+ }
+
+ #[test]
+ fn font_covers_detects_missing_glyphs_deterministically() {
+ // The detection mechanism itself, independent of whatever fallback
+ // font may or may not be installed: Helvetica (guaranteed present
+ // by `typeface_with_fallback`'s own contract) covers plain ASCII
+ // and does not cover CJK.
+ let font = helvetica_font(32.0);
+ assert!(font_covers(&font, "ABC"), "Helvetica must cover ASCII");
+ assert!(
+ !font_covers(&font, "\u{4F60}\u{597D}"), // 你好
+ "Helvetica must not cover CJK"
+ );
+ }
+
+ #[test]
+ fn classify_char_stays_primary_for_a_codepoint_no_font_covers() {
+ // A Private Use Area code point is uncovered by the primary font
+ // (nothing standard assigns glyphs there); it may or may not be
+ // "covered" by *some* installed font's own PUA convention (icon
+ // fonts, vendor glyphs) depending on the host, so skip gracefully
+ // rather than assume every machine agrees — the point of this test
+ // is that `classify_char` doesn't crash/loop when nothing covers a
+ // code point, falling back to `Primary` (the pre-fix, tofu-
+ // producing behaviour) rather than panicking.
+ let font = helvetica_font(32.0);
+ let pua = '\u{E000}';
+ assert!(
+ !font_covers(&font, &pua.to_string()),
+ "test setup: PUA code point must be uncovered by the primary font"
+ );
+ let style = font.typeface().font_style();
+ let family = font.typeface().family_name();
+ if fallback_typeface_for_char(&family, style, pua).is_some() {
+ eprintln!(
+ "skip: host has some font claiming to cover U+E000 (PUA) — can't exercise the \
+ 'nothing covers it anywhere' branch on this host"
+ );
+ return;
+ }
+ let kind = classify_char(pua, &font, None);
+ assert!(
+ matches!(kind, RunKind::Primary),
+ "an uncoverable-anywhere code point must resolve to Primary, not panic"
+ );
+ }
+
+ #[test]
+ fn classify_char_resolves_a_fallback_when_the_host_has_one() {
+ // Environment-dependent by nature (issue: CJK coverage depends on
+ // installed fonts) — skip gracefully, per this workstream's brief,
+ // rather than asserting a specific glyph renders. When a fallback
+ // *is* available (true on stock macOS/Windows/most Linux desktops
+ // via Noto/PingFang/MS-Gothic-class fonts), `classify_char` itself
+ // — not just the underlying `fallback_typeface_for_char` primitive
+ // — must actually route to it, and the resolved typeface must
+ // cover the character that triggered the lookup.
+ let font = helvetica_font(32.0);
+ let style = font.typeface().font_style();
+ let family = font.typeface().family_name();
+ if fallback_typeface_for_char(&family, style, '\u{4F60}').is_none() {
+ eprintln!("skip: no CJK-capable font installed on this host");
+ return;
+ }
+ let kind = classify_char('\u{4F60}', &font, None);
+ let RunKind::Fallback(fallback) = kind else {
+ panic!(
+ "classify_char must resolve a Fallback run for an uncovered CJK code point when \
+ the host has a capable font"
+ );
+ };
+ let fallback_font = Font::from_typeface(fallback, 32.0);
+ assert!(
+ font_covers(&fallback_font, "\u{4F60}"),
+ "resolved fallback typeface must actually cover the code point that triggered it"
+ );
+ }
+
+ #[test]
+ fn measure_and_paint_agree_on_cjk_width_when_fallback_is_available() {
+ // Measure-vs-paint parity (this workstream's core mandate):
+ // `measure_text_with_fallback` must report the width that actually
+ // gets painted. Skips gracefully (see above) when the host has no
+ // CJK-capable font at all — on such a host both measure and paint
+ // agree on the pre-fix degraded behaviour (0-width primary-font
+ // tofu), which is a separate, already-covered case.
+ let font = helvetica_font(48.0);
+ let text = "\u{4F60}\u{597D}"; // 你好
+ if !text.chars().any(|c| {
+ fallback_typeface_for_char("Helvetica", font.typeface().font_style(), c).is_some()
+ }) {
+ eprintln!("skip: no CJK-capable font installed on this host");
+ return;
+ }
+
+ // Directly prove segmentation actually routes through the fallback
+ // mechanism (not just that `fallback_typeface_for_char` the
+ // primitive would resolve *something* if called) — this is what
+ // distinguishes "the fix is wired up" from "the fix exists but
+ // nothing calls it".
+ let runs = segment_text_runs(text, &font);
+ assert!(
+ runs.iter().any(|r| matches!(r.kind, RunKind::Fallback(_))),
+ "expected at least one Fallback run when segmenting CJK text with a fallback font \
+ available, got kinds: {:?}",
+ runs.iter()
+ .map(|r| match &r.kind {
+ RunKind::Primary => "Primary",
+ RunKind::Emoji => "Emoji",
+ RunKind::Fallback(_) => "Fallback",
+ })
+ .collect::>()
+ );
+
+ let measured_w = measure_text_with_fallback(text, &font, &None, 0.0);
+ // Not tofu-narrow: with a real CJK fallback, two full-width
+ // ideographs at 48px measure well beyond a couple of `.notdef` box
+ // glyphs' worth of width.
+ assert!(
+ measured_w > 30.0,
+ "expected a real CJK measurement, got suspiciously narrow {measured_w}"
+ );
+
+ use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo};
+ const W: i32 = 400;
+ const H: i32 = 200;
+ let mut surface = surfaces::raster_n32_premul((W, H)).unwrap();
+ let canvas = surface.canvas();
+ canvas.clear(Color::BLACK);
+ let mut paint = Paint::default();
+ paint.set_color(Color::WHITE);
+ paint.set_anti_alias(true);
+ let (_, metrics) = font.metrics();
+ let ascent = -metrics.ascent;
+ draw_text_with_fallback(canvas, text, &font, &None, 0.0, 10.0, ascent + 10.0, &paint);
+
+ // Ink detection: the canvas is cleared to opaque black (alpha=255
+ // everywhere), so — unlike a transparent-cleared surface — alpha
+ // can't distinguish text from background here. White text against
+ // black shows up as a bright *red* (or green/blue) channel instead
+ // (same technique `tracking_tests::render_and_measure_ink_centre_x`
+ // above uses).
+ let info = ImageInfo::new((W, H), ColorType::RGBA8888, 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));
+ let mut min_x: Option = None;
+ let mut max_x: Option = None;
+ for y in 0..H {
+ for x in 0..W {
+ let idx = ((y * W + x) * 4) as usize;
+ if buf[idx] > 40 {
+ min_x = Some(min_x.map_or(x, |m| m.min(x)));
+ max_x = Some(max_x.map_or(x, |m| m.max(x)));
+ }
+ }
+ }
+ let (min_x, max_x) = (
+ min_x.expect("must paint something"),
+ max_x.expect("must paint something"),
+ );
+ let painted_width = (max_x - min_x) as f32;
+
+ // Loose tolerance: ink-bbox width vs advance width can differ from
+ // glyph side-bearings/overhang even for a well-behaved font — this
+ // is not the #125-style "wrap/paint disagreement" defect (a wild,
+ // multiple-hundred-px miscentre), just normal glyph-metric slack.
+ assert!(
+ (painted_width - measured_w).abs() < measured_w * 0.5 + 20.0,
+ "measured width {measured_w} should roughly match the painted ink width \
+ {painted_width} (min_x={min_x}, max_x={max_x})"
+ );
+ }
+}
diff --git a/crates/rustmotion-core/src/engine/text/cosmic.rs b/crates/rustmotion-core/src/engine/text/cosmic.rs
index 2f52815..5af0656 100644
--- a/crates/rustmotion-core/src/engine/text/cosmic.rs
+++ b/crates/rustmotion-core/src/engine/text/cosmic.rs
@@ -9,6 +9,23 @@
//! Shaping & line-breaking are delegated to cosmic-text. Paint is done by
//! rasterizing each glyph to an alpha mask, tinting it with the requested
//! color, and blitting it as a small `Image` into Skia.
+//!
+//! **Not currently wired into the real render path (audit #10).** Every
+//! component's actual measure/paint goes through `skia_safe::Font::
+//! measure_str` / `TextBlob::new` in `engine::renderer::text` +
+//! `rustmotion-components::intrinsic::TextIntrinsic`, not through this
+//! module — `measure_text`/`paint_text` below have no callers outside their
+//! own tests (`grep -rn "engine::text\|text::cosmic" crates/` confirms
+//! this). If you're chasing a text overflow/measure-vs-paint bug, look in
+//! `engine::renderer::text.rs` and `rustmotion-components::intrinsic`
+//! instead — the shaping/bidi/glyph-fallback behaviour cosmic-text would
+//! provide here is not what actually renders today. Kept building (and the
+//! `cosmic-text` dependency kept) as a candidate landing spot for a future
+//! real shaping engine; not deleted unilaterally by this fix since that
+//! call — wire it in for real vs. remove the module and its dependency —
+//! is bigger than any single finding in this pass. See
+//! `rustmotion-components::intrinsic` module doc for the other side of this
+//! (it also used to claim a cosmic-text backing it doesn't have).
use std::sync::{Mutex, OnceLock};
diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs
index cec5fd0..faa4ca7 100644
--- a/crates/rustmotion-core/src/engine/transition.rs
+++ b/crates/rustmotion-core/src/engine/transition.rs
@@ -560,11 +560,21 @@ mod camera_pan_tests {
for (progress, expected) in [(0.0, [255u8, 0, 0]), (1.0, [0, 0, 255])] {
let out = camera_pan_transition(
- &bg, &bg, &fg_a, &fg_b, w, h, progress, 8.0, 0.0,
- &EasingType::Linear, PanBackground::Static,
+ &bg,
+ &bg,
+ &fg_a,
+ &fg_b,
+ w,
+ h,
+ progress,
+ 8.0,
+ 0.0,
+ &EasingType::Linear,
+ PanBackground::Static,
);
assert_eq!(
- &out[0..3], &expected,
+ &out[0..3],
+ &expected,
"at progress {progress} the adjacent scene must render untouched",
);
}
@@ -580,14 +590,26 @@ mod camera_pan_tests {
let fg_b = solid(w, h, 0, 0, 255, 255);
let out = camera_pan_transition(
- &bg, &bg, &fg_a, &fg_b, w, h, 0.5, 8.0, 0.0,
- &EasingType::Linear, PanBackground::Static,
+ &bg,
+ &bg,
+ &fg_a,
+ &fg_b,
+ w,
+ h,
+ 0.5,
+ 8.0,
+ 0.0,
+ &EasingType::Linear,
+ PanBackground::Static,
);
// Left half carries the outgoing plane, right half the incoming one.
let left_red = out[0];
let right_blue = out[((w - 1) * 4 + 2) as usize];
assert!(left_red > 128, "outgoing plane faded too far: {left_red}");
- assert!(right_blue > 128, "incoming plane still too faint: {right_blue}");
+ assert!(
+ right_blue > 128,
+ "incoming plane still too faint: {right_blue}"
+ );
}
fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec {
diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs
index 55e4a3f..02da951 100644
--- a/crates/rustmotion-core/src/error.rs
+++ b/crates/rustmotion-core/src/error.rs
@@ -161,8 +161,14 @@ pub enum RustmotionError {
#[error("Failed to open FFmpeg stdin pipe")]
FfmpegPipe,
- #[error("Failed to write to FFmpeg pipe: {reason}")]
- FfmpegWrite { reason: String },
+ // A broken pipe here nearly always means ffmpeg already died on its own
+ // arguments, so the useful diagnostic is ffmpeg's stderr rather than our
+ // write error. Carry it in the error so it survives `--quiet`.
+ #[error("Failed to write to FFmpeg pipe: {reason}{}", .stderr.as_ref().map(|s| format!("\nffmpeg reported:\n{}", s)).unwrap_or_default())]
+ FfmpegWrite {
+ reason: String,
+ stderr: Option,
+ },
#[error("Failed to wait for FFmpeg: {reason}")]
FfmpegWait { reason: String },
diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs
index 85260ab..ebd90d9 100644
--- a/crates/rustmotion-core/src/schema/animation.rs
+++ b/crates/rustmotion-core/src/schema/animation.rs
@@ -1,7 +1,14 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+// `deny_unknown_fields` (reliquat of wave-A's constat, PR #158): closes the
+// last gap in `style.animation[*]` typo detection. Wave A covered the nine
+// effect-config structs in `schema/video.rs`; the types *inside* a
+// `keyframes[*]` entry (this struct and `Keyframe` below) were left
+// uncovered — a typo'd key here (e.g. `duratoin`) used to be silently
+// dropped instead of reported, same as every other struct this wave closed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
pub struct Animation {
pub property: String,
pub keyframes: Vec,
@@ -12,6 +19,7 @@ pub struct Animation {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(deny_unknown_fields)]
pub struct Keyframe {
pub time: f64,
pub value: KeyframeValue,
@@ -194,3 +202,63 @@ impl Default for PresetConfig {
fn default_preset_duration() -> f64 {
0.8
}
+
+#[cfg(test)]
+mod deny_unknown_fields_tests {
+ use super::*;
+ use serde_json::json;
+
+ // ---- reliquat of the wave-A fix (PR #158): `deny_unknown_fields` was
+ // added to the nine effect-config structs in `schema/video.rs`, but the
+ // types *inside* a `keyframes[*]` entry — `Animation` and `Keyframe`,
+ // both in this file — were left uncovered. A typo'd key inside one of
+ // these (e.g. `duratoin` on an `Animation`, or a per-keyframe field
+ // typo) used to be silently ignored instead of reported. This is the
+ // one change this workstream is authorized to make in this file. ----
+
+ #[test]
+ fn animation_rejects_unknown_fields() {
+ let json = json!({
+ "property": "opacity",
+ "keyframes": [{ "time": 0.0, "value": 1.0 }],
+ "easing": "ease_out",
+ "duratoin": 5.0
+ });
+ let err = serde_json::from_value::(json)
+ .expect_err("a typo'd field on Animation must be rejected, not silently ignored");
+ assert!(err.to_string().contains("duratoin"), "got: {err}");
+ }
+
+ #[test]
+ fn keyframe_rejects_unknown_fields() {
+ let json = json!({ "time": 0.0, "value": 1.0, "eaisng": "linear" });
+ let err = serde_json::from_value::(json)
+ .expect_err("a typo'd field on Keyframe must be rejected, not silently ignored");
+ assert!(err.to_string().contains("eaisng"), "got: {err}");
+ }
+
+ #[test]
+ fn animation_still_accepts_every_known_field() {
+ let json = json!({
+ "property": "opacity",
+ "keyframes": [
+ { "time": 0.0, "value": 0.0, "easing": "linear" },
+ { "time": 1.0, "value": 1.0 }
+ ],
+ "easing": "ease_out",
+ "spring": { "damping": 10.0, "stiffness": 100.0, "mass": 1.0 }
+ });
+ let a: Animation = serde_json::from_value(json).unwrap();
+ assert_eq!(a.property, "opacity");
+ assert_eq!(a.keyframes.len(), 2);
+ assert!(a.spring.is_some());
+ }
+
+ #[test]
+ fn keyframe_still_accepts_every_known_field() {
+ let json = json!({ "time": 0.5, "value": 10.0, "easing": "ease_in" });
+ let k: Keyframe = serde_json::from_value(json).unwrap();
+ assert_eq!(k.time, 0.5);
+ assert!(k.easing.is_some());
+ }
+}
diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs
index 464d7ea..bfba1cb 100644
--- a/crates/rustmotion-core/src/schema/background.rs
+++ b/crates/rustmotion-core/src/schema/background.rs
@@ -159,6 +159,18 @@ impl Serialize for AnimatedBackground {
}
}
+/// Every preset name the engine actually recognises. A `preset` value
+/// outside this list — including the empty string produced when the key is
+/// missing entirely — is rejected below instead of silently becoming
+/// `gradient_shift` (constat #3, sink 1).
+const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
+ "gradient_shift",
+ "grid_dots",
+ "concentric_circles",
+ "halo",
+ "heropattern",
+];
+
impl<'de> Deserialize<'de> for AnimatedBackground {
fn deserialize>(deserializer: D) -> Result {
let map: serde_json::Map =
@@ -167,48 +179,32 @@ impl<'de> Deserialize<'de> for AnimatedBackground {
// Common fields
let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
- let direction: Option = map
- .get("direction")
- .and_then(|v| serde_json::from_value(v.clone()).ok());
+ // Constat #3 (related sink, fixed alongside): a mistyped `direction`
+ // used to be swallowed by `.ok()` into a silent `None` — same class
+ // as the preset/zones/colors sinks below, just on a smaller field.
+ let direction: Option = match map.get("direction") {
+ Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| {
+ serde::de::Error::custom(format!("animated-background.direction: {e}"))
+ })?),
+ None => None,
+ };
let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or("");
+ if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) {
+ return Err(serde::de::Error::custom(format!(
+ "unknown animated-background preset '{preset_str}': expected one of {}",
+ KNOWN_BACKGROUND_PRESETS.join(", ")
+ )));
+ }
// Detect new vs legacy format: new format has a sub-object keyed by preset name
- let is_new_format =
- !preset_str.is_empty() && map.get(preset_str).is_some_and(|v| v.is_object());
+ let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object());
let (preset, speed) = if is_new_format {
// New format: config in sub-object
let sub = map.get(preset_str).unwrap().clone();
let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
- let preset = match preset_str {
- "grid_dots" => {
- let cfg: GridDotsConfig =
- serde_json::from_value(sub).map_err(serde::de::Error::custom)?;
- BackgroundPreset::GridDots(cfg)
- }
- "concentric_circles" => {
- let cfg: ConcentricCirclesConfig =
- serde_json::from_value(sub).map_err(serde::de::Error::custom)?;
- BackgroundPreset::ConcentricCircles(cfg)
- }
- "halo" => {
- let cfg: HaloConfig =
- serde_json::from_value(sub).map_err(serde::de::Error::custom)?;
- BackgroundPreset::Halo(cfg)
- }
- "heropattern" => {
- let cfg: HeropatternConfig =
- serde_json::from_value(sub).map_err(serde::de::Error::custom)?;
- BackgroundPreset::Heropattern(cfg)
- }
- _ => {
- // gradient_shift or unknown → gradient_shift
- let cfg: GradientShiftConfig =
- serde_json::from_value(sub).map_err(serde::de::Error::custom)?;
- BackgroundPreset::GradientShift(cfg)
- }
- };
+ let preset = deserialize_preset_config::(preset_str, sub)?;
(preset, speed)
} else {
// Legacy flat format
@@ -257,26 +253,74 @@ impl<'de> Deserialize<'de> for AnimatedBackground {
})
}
"halo" => {
- let zones: Vec = map
- .get("zones")
- .and_then(|v| serde_json::from_value(v.clone()).ok())
- .unwrap_or_default();
- BackgroundPreset::Halo(HaloConfig { zones })
+ // Constat #3, sink 2: was `.ok().unwrap_or_default()` —
+ // a malformed (or entirely missing) `zones` silently
+ // became an empty halo instead of erroring. Route
+ // through the same validated-struct path as the
+ // new-format branch: `HaloConfig::zones` is required
+ // (no `#[serde(default)]`), so a missing/malformed value
+ // now produces a real "missing/invalid field zones"
+ // error instead.
+ let mut obj = serde_json::Map::new();
+ if let Some(z) = map.get("zones") {
+ obj.insert("zones".to_string(), z.clone());
+ }
+ let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj))
+ .map_err(|e| {
+ serde::de::Error::custom(format!("animated-background.zones: {e}"))
+ })?;
+ BackgroundPreset::Halo(cfg)
}
- _ => {
- // Default: gradient_shift
- let colors: Vec = map
- .get("colors")
- .and_then(|v| serde_json::from_value(v.clone()).ok())
- .unwrap_or_default();
- let gradient_type: GradientType = map
- .get("gradient_type")
- .and_then(|v| serde_json::from_value(v.clone()).ok())
- .unwrap_or_else(default_bg_type);
- BackgroundPreset::GradientShift(GradientShiftConfig {
- colors,
- gradient_type,
- })
+ "heropattern" => {
+ // Constat #3 (related sink, fixed alongside): the legacy
+ // branch never had an arm for `heropattern` at all, so a
+ // *correctly spelled* `"preset": "heropattern"` written
+ // in the legacy flat form (no `heropattern: {...}`
+ // sub-object) fell through the old `_ =>` wildcard and
+ // silently became `gradient_shift` with `colors: []`.
+ let mut obj = serde_json::Map::new();
+ for key in ["pattern", "color", "opacity", "scale"] {
+ if let Some(v) = map.get(key) {
+ obj.insert(key.to_string(), v.clone());
+ }
+ }
+ let cfg: HeropatternConfig =
+ serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
+ serde::de::Error::custom(format!(
+ "animated-background.heropattern: {e}"
+ ))
+ })?;
+ BackgroundPreset::Heropattern(cfg)
+ }
+ "gradient_shift" => {
+ // Constat #3, sink 3: `colors`/`gradient_type` were each
+ // parsed with `.ok().unwrap_or_default()` /
+ // `.ok().unwrap_or_else(default_bg_type)` — so even with
+ // `preset` spelled *correctly*, a missing or malformed
+ // `colors` silently produced `colors: []`, i.e. a fully
+ // empty gradient that paints black with no diagnostic at
+ // all — the exact worst-case symptom the audit names.
+ let mut obj = serde_json::Map::new();
+ if let Some(c) = map.get("colors") {
+ obj.insert("colors".to_string(), c.clone());
+ }
+ if let Some(g) = map.get("gradient_type") {
+ obj.insert("gradient_type".to_string(), g.clone());
+ }
+ let cfg: GradientShiftConfig =
+ serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
+ serde::de::Error::custom(format!(
+ "animated-background.colors/gradient_type: {e}"
+ ))
+ })?;
+ BackgroundPreset::GradientShift(cfg)
+ }
+ // Unreachable: `preset_str` was already checked against
+ // `KNOWN_BACKGROUND_PRESETS` above.
+ other => {
+ return Err(serde::de::Error::custom(format!(
+ "internal error: unhandled animated-background preset '{other}'"
+ )))
}
};
(preset, legacy_speed)
@@ -305,6 +349,36 @@ impl<'de> Deserialize<'de> for AnimatedBackground {
}
}
+/// Deserialize the preset-specific config object for the "new" nested
+/// format (`{"preset": "halo", "halo": {...}}`) — shared by
+/// `AnimatedBackground::deserialize` and available for reuse. `preset_str`
+/// must already be one of [`KNOWN_BACKGROUND_PRESETS`].
+fn deserialize_preset_config(
+ preset_str: &str,
+ sub: serde_json::Value,
+) -> Result {
+ match preset_str {
+ "grid_dots" => Ok(BackgroundPreset::GridDots(
+ serde_json::from_value(sub).map_err(E::custom)?,
+ )),
+ "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles(
+ serde_json::from_value(sub).map_err(E::custom)?,
+ )),
+ "halo" => Ok(BackgroundPreset::Halo(
+ serde_json::from_value(sub).map_err(E::custom)?,
+ )),
+ "heropattern" => Ok(BackgroundPreset::Heropattern(
+ serde_json::from_value(sub).map_err(E::custom)?,
+ )),
+ "gradient_shift" => Ok(BackgroundPreset::GradientShift(
+ serde_json::from_value(sub).map_err(E::custom)?,
+ )),
+ other => Err(E::custom(format!(
+ "internal error: unhandled animated-background preset '{other}'"
+ ))),
+ }
+}
+
impl JsonSchema for AnimatedBackground {
fn schema_name() -> String {
"AnimatedBackground".to_string()
@@ -401,6 +475,49 @@ pub struct BackgroundEntry {
pub overrides: serde_json::Map,
}
+/// Constat #5: no derived `JsonSchema` here (the `#[serde(flatten)]` map
+/// makes a fully-accurate derive impossible anyway — the point of `flatten`
+/// is "any other keys"), which is exactly why `Scene`/`View` reached for
+/// `#[schemars(skip)]` on `background` in the first place: skip was the
+/// only option with no `JsonSchema` impl to call. But `Scene`/`View` are
+/// also `deny_unknown_fields` (schemars emits `additionalProperties: false`
+/// for that), so skipping `background` didn't just leave it undocumented —
+/// it made the *exported schema* declare invalid any scenario that actually
+/// sets `scene.background` / `view.background`, which is most of them. This
+/// manual impl describes the real accepted shape (`$ref` + `transition` +
+/// "anything else", matching the `flatten`) so `background` can be a real
+/// declared property instead.
+impl JsonSchema for BackgroundEntry {
+ fn schema_name() -> String {
+ "BackgroundEntry".to_string()
+ }
+
+ fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+ use schemars::schema::*;
+
+ let mut props = schemars::Map::new();
+ props.insert("$ref".to_string(), gen.subschema_for::