Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .claude/skills/apply-annotations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ description: Apply pending studio annotations from a Rustmotion scenario — for

# Apply Annotations

Use this when a Rustmotion scenario has an `annotations` array (created in the studio's "Leave a comment for the agent" box) to apply.
Use this when a Rustmotion scenario has annotations (created in the studio's "Leave a comment for the agent" box) to apply.

## Where annotations live

- **JSON scenarios** (`foo.json`): in the scenario file's top-level `annotations` array.
- **HTML scenarios** (`foo.html` / `foo.htm`): in a sidecar file next to the source — `foo.annotations.json` — holding `{"annotations": [...]}` with exactly the same annotation object format. The HTML file itself never contains annotations. Always check for the sidecar when the source is HTML.

## Process

1. Read the scenario file's `annotations` array. For each entry with `status: "open"`:
1. Read the `annotations` array — from the scenario JSON, or from `<stem>.annotations.json` for HTML sources. For each entry with `status: "open"`:
- `target.pointer` is an RFC 6901 JSON Pointer to the element (e.g. `/scenes/2/children/5`).
- `note` is the change request.
- `frame` / `view` / `scene` give the moment in the video; `target.kind` is the component type (`text`, `card`, …).
2. For each open annotation, **apply the requested change** by editing the element at `target.pointer` — usually a property under its `style` object — interpreting `note`. Make the smallest edit that satisfies the note.
2. For each open annotation, **apply the requested change** by editing the element at `target.pointer` — usually a property under its `style` object — interpreting `note`. Make the smallest edit that satisfies the note. For HTML sources, the pointer addresses the **transpiled** scenario structure; apply the change in the HTML source (inline `style` attribute of the corresponding element).
3. After each edit, run `rustmotion validate -f <file>` (schema + geometry). Both passes must succeed. If geometry fails (e.g. `unwrappable_text_overflow`), adjust (keep `wrap: true`, lower a `font-size`, …) and re-validate.
4. Set the annotation's `status` to `"resolved"` (do not delete it — the studio panel and `validate --fix` can strip resolved ones later).
4. Set the annotation's `status` to `"resolved"` **in the same place you found it** — the scenario JSON, or the `<stem>.annotations.json` sidecar for HTML sources (do not delete it — the studio panel and `validate --fix` can strip resolved ones later).
5. Report a short summary: which annotations were applied and what changed.

## Rules
Expand Down
10 changes: 8 additions & 2 deletions crates/rustmotion-cli/src/commands/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,15 @@ pub fn load(source: ValidationSource<'_>) -> Result<LoadedScenario> {
source: e,
})?;
// HTML input is transpiled to the scenario JSON first, then validated
// through the identical JSON pipeline below.
// through the identical JSON pipeline below. Sidecar annotations
// are merged so validate/render see the same scenario as the
// studio and `load_input`.
let s = if rustmotion::loader::is_html_path(path) {
let value = rustmotion::loader::html_to_scenario_json(&s)?;
let mut value = rustmotion::loader::html_to_scenario_json(&s)?;
let annotations = rustmotion::loader::load_html_annotations_sidecar(path)?;
if !annotations.is_empty() {
value["annotations"] = serde_json::Value::Array(annotations);
}
serde_json::to_string(&value).map_err(RustmotionError::from)?
} else {
s
Expand Down
104 changes: 71 additions & 33 deletions crates/rustmotion-studio/src/editor/annotations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ use crate::{
button::{Button, ButtonSize, ButtonVariant},
textarea::{Textarea, TextareaVariant},
},
scenario::{append_annotation, remove_annotation, Shared},
scenario::{
append_annotation, append_sidecar_annotation, remove_annotation, remove_sidecar_annotation,
Shared,
},
};

/// Write `content` to `path`, returning a user-facing error string on failure.
Expand Down Expand Up @@ -90,31 +93,49 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
"view": view, "scene": scene,
"target": { "pointer": pointer, "kind": kind }
});
let updated = append_annotation(raw, ann);
// Annotations live in the scenario JSON; HTML sources have no place to
// store them, so skip the write rather than overwrite the HTML.
if let Some(path) = path {
if !rustmotion::loader::is_html_path(&path) {
match serde_json::to_string_pretty(&updated) {
Ok(t) => {
let write_result = write_file(&path, &t);
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
match write_result {
Ok(()) => {
m.write_error = None;
note.set(String::new());
}
Err(e) => {
m.write_error = Some(e);
m.generation = m.generation.wrapping_add(1);
}
let Some(path) = path else {
return;
};
if rustmotion::loader::is_html_path(&path) {
// HTML sources: annotations live in the `<stem>.annotations.json`
// sidecar; the HTML file itself is never modified.
let result = append_sidecar_annotation(&path, ann.clone());
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
match result {
Ok(()) => {
m.write_error = None;
// The watcher doesn't observe the sidecar, so reflect
// the change in the in-memory raw directly.
m.raw = append_annotation(std::mem::take(&mut m.raw), ann);
m.generation = m.generation.wrapping_add(1);
note.set(String::new());
}
Err(e) => {
m.write_error = Some(e);
m.generation = m.generation.wrapping_add(1);
}
}
} else {
let updated = append_annotation(raw, ann);
match serde_json::to_string_pretty(&updated) {
Ok(t) => {
let write_result = write_file(&path, &t);
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
match write_result {
Ok(()) => {
m.write_error = None;
note.set(String::new());
}
Err(e) => {
m.write_error = Some(e);
m.generation = m.generation.wrapping_add(1);
}
}
Err(e) => {
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
m.write_error = Some(format!("json: {e}"));
m.generation = m.generation.wrapping_add(1);
}
}
Err(e) => {
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
m.write_error = Some(format!("json: {e}"));
m.generation = m.generation.wrapping_add(1);
}
}
}
Expand Down Expand Up @@ -142,24 +163,41 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
}
}

/// Remove an annotation by id from the scenario file (the watcher reloads).
/// Remove an annotation by id. JSON sources rewrite the scenario file (the
/// watcher reloads); HTML sources rewrite the annotations sidecar (deleted
/// when it becomes empty) and update the in-memory raw directly.
fn delete_annotation(shared: &Shared, id: &str) {
let (path, raw) = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
(m.path.clone(), m.raw.clone())
};
let updated = remove_annotation(raw, id);
if let Some(path) = path {
if !rustmotion::loader::is_html_path(&path) {
let write_result = serde_json::to_string_pretty(&updated)
.map_err(|e| format!("json: {e}"))
.and_then(|t| write_file(&path, &t));
if let Err(e) = write_result {
let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
let Some(path) = path else {
return;
};
if rustmotion::loader::is_html_path(&path) {
let result = remove_sidecar_annotation(&path, id);
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
match result {
Ok(()) => {
m.write_error = None;
m.raw = remove_annotation(std::mem::take(&mut m.raw), id);
m.generation = m.generation.wrapping_add(1);
}
Err(e) => {
m.write_error = Some(e);
m.generation = m.generation.wrapping_add(1);
}
}
} else {
let updated = remove_annotation(raw, id);
let write_result = serde_json::to_string_pretty(&updated)
.map_err(|e| format!("json: {e}"))
.and_then(|t| write_file(&path, &t));
if let Err(e) = write_result {
let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
m.write_error = Some(e);
m.generation = m.generation.wrapping_add(1);
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/rustmotion-studio/src/scenario/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@

mod edit;
mod model;
mod sidecar;

pub use edit::{
append_annotation, list_annotations, read_field, read_style_object, remove_annotation,
set_field, set_style,
};
pub use model::{empty_scenario, Shared, StudioModel};
pub use sidecar::{append_sidecar_annotation, remove_sidecar_annotation};

/// Which top-level view is shown (library home vs. the editor).
#[derive(Clone, Copy, PartialEq)]
Expand Down
12 changes: 9 additions & 3 deletions crates/rustmotion-studio/src/scenario/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ impl StudioModel {
error: Option<String>,
path: Option<std::path::PathBuf>,
) -> Self {
// For HTML sources the raw JSON is the transpiled scenario, so the
// inspector can read/resolve element properties by pointer.
// For HTML sources the raw JSON is the transpiled scenario, plus the
// annotations sidecar merged in so `list_annotations` and the comments
// panel work unchanged; the inspector reads element props by pointer.
let raw = path
.as_ref()
.and_then(|p| {
let s = std::fs::read_to_string(p).ok()?;
if rustmotion::loader::is_html_path(p) {
rustmotion::loader::html_to_scenario_json(&s).ok()
let raw = rustmotion::loader::html_to_scenario_json(&s).ok()?;
// A corrupt sidecar already failed the scenario load in the
// loader (error banner); a read failure here can only be a
// race, so fall back to the bare transpile.
let annotations = super::sidecar::read_sidecar(p).unwrap_or_default();
Some(super::sidecar::merge_annotations(raw, annotations))
} else {
serde_json::from_str(&s).ok()
}
Expand Down
Loading
Loading