Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e442799
style(fmt): apply rustfmt across the workspace
LeadcodeDev Aug 8, 2026
2cd338a
fix(encode): order ffmpeg inputs ahead of output options (#143)
LeadcodeDev Aug 8, 2026
e6277d0
fix(components): stop five painters aborting on schema-valid input (#…
LeadcodeDev Aug 8, 2026
69ba2ab
fix(cli): stop the helper commands destroying files they do not own (…
LeadcodeDev Aug 8, 2026
025af5a
fix(cli): make batch validate like render and report what actually ha…
LeadcodeDev Aug 8, 2026
c73d9a9
fix(html): turn the dialect's silent losses into named errors (#149)
LeadcodeDev Aug 8, 2026
c229ec0
fix(dataviz): honour start_at, the layout box, and degenerate scales …
LeadcodeDev Aug 8, 2026
d174819
fix(encode): make every output format render the frames it claims (#151)
LeadcodeDev Aug 8, 2026
72289ca
fix(world): stop the camera teleporting and the frame budget losing f…
LeadcodeDev Aug 8, 2026
c056b41
fix(text): give the text engine glyph fallback, real units, and the f…
LeadcodeDev Aug 8, 2026
7b95a15
fix(paint): unbreak shadows, bound the opacity layer, make the bandin…
LeadcodeDev Aug 8, 2026
49ccc02
fix(layout): resolve viewport units against the real viewport and run…
LeadcodeDev Aug 9, 2026
e6decfb
fix(animation): make the animation knobs actually reach the solver (#…
LeadcodeDev Aug 9, 2026
4b49e83
fix(validate): make the geometry pass call the engine instead of reim…
LeadcodeDev Aug 9, 2026
4675780
fix(schema): turn the deserializer's silent sinks into named errors (…
LeadcodeDev Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .claude/skills/rustmotion/rules/html-css-mental-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Edges>` (`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<Edges>`) 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

```
Expand Down Expand Up @@ -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": [...] }
```

---
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/rustmotion/rules/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
128 changes: 128 additions & 0 deletions crates/rustmotion-cli/src/claude_md.rs
Original file line number Diff line number Diff line change
@@ -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 = "<!-- rustmotion:start -->";
pub const END: &str = "<!-- rustmotion:end -->";

/// 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<String> {
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"));
}
}
Loading
Loading