Skip to content

Commit 98f9fa4

Browse files
authored
feat(studio): annotations for HTML sources via sidecar file (#37)
Closes #16. Sidecar with identical annotation format, merged at every load path (studio, load_input, CLI validate), PR #30 write guarantees, skill updated.
1 parent ece0faa commit 98f9fa4

7 files changed

Lines changed: 404 additions & 51 deletions

File tree

.claude/skills/apply-annotations/SKILL.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@ description: Apply pending studio annotations from a Rustmotion scenario — for
55

66
# Apply Annotations
77

8-
Use this when a Rustmotion scenario has an `annotations` array (created in the studio's "Leave a comment for the agent" box) to apply.
8+
Use this when a Rustmotion scenario has annotations (created in the studio's "Leave a comment for the agent" box) to apply.
9+
10+
## Where annotations live
11+
12+
- **JSON scenarios** (`foo.json`): in the scenario file's top-level `annotations` array.
13+
- **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.
914

1015
## Process
1116

12-
1. Read the scenario file's `annotations` array. For each entry with `status: "open"`:
17+
1. Read the `annotations` array — from the scenario JSON, or from `<stem>.annotations.json` for HTML sources. For each entry with `status: "open"`:
1318
- `target.pointer` is an RFC 6901 JSON Pointer to the element (e.g. `/scenes/2/children/5`).
1419
- `note` is the change request.
1520
- `frame` / `view` / `scene` give the moment in the video; `target.kind` is the component type (`text`, `card`, …).
16-
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.
21+
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).
1722
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.
18-
4. Set the annotation's `status` to `"resolved"` (do not delete it — the studio panel and `validate --fix` can strip resolved ones later).
23+
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).
1924
5. Report a short summary: which annotations were applied and what changed.
2025

2126
## Rules

crates/rustmotion-cli/src/commands/validation.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,15 @@ pub fn load(source: ValidationSource<'_>) -> Result<LoadedScenario> {
9494
source: e,
9595
})?;
9696
// HTML input is transpiled to the scenario JSON first, then validated
97-
// through the identical JSON pipeline below.
97+
// through the identical JSON pipeline below. Sidecar annotations
98+
// are merged so validate/render see the same scenario as the
99+
// studio and `load_input`.
98100
let s = if rustmotion::loader::is_html_path(path) {
99-
let value = rustmotion::loader::html_to_scenario_json(&s)?;
101+
let mut value = rustmotion::loader::html_to_scenario_json(&s)?;
102+
let annotations = rustmotion::loader::load_html_annotations_sidecar(path)?;
103+
if !annotations.is_empty() {
104+
value["annotations"] = serde_json::Value::Array(annotations);
105+
}
100106
serde_json::to_string(&value).map_err(RustmotionError::from)?
101107
} else {
102108
s

crates/rustmotion-studio/src/editor/annotations.rs

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use crate::{
55
button::{Button, ButtonSize, ButtonVariant},
66
textarea::{Textarea, TextareaVariant},
77
},
8-
scenario::{append_annotation, remove_annotation, Shared},
8+
scenario::{
9+
append_annotation, append_sidecar_annotation, remove_annotation, remove_sidecar_annotation,
10+
Shared,
11+
},
912
};
1013

1114
/// Write `content` to `path`, returning a user-facing error string on failure.
@@ -90,31 +93,49 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
9093
"view": view, "scene": scene,
9194
"target": { "pointer": pointer, "kind": kind }
9295
});
93-
let updated = append_annotation(raw, ann);
94-
// Annotations live in the scenario JSON; HTML sources have no place to
95-
// store them, so skip the write rather than overwrite the HTML.
96-
if let Some(path) = path {
97-
if !rustmotion::loader::is_html_path(&path) {
98-
match serde_json::to_string_pretty(&updated) {
99-
Ok(t) => {
100-
let write_result = write_file(&path, &t);
101-
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
102-
match write_result {
103-
Ok(()) => {
104-
m.write_error = None;
105-
note.set(String::new());
106-
}
107-
Err(e) => {
108-
m.write_error = Some(e);
109-
m.generation = m.generation.wrapping_add(1);
110-
}
96+
let Some(path) = path else {
97+
return;
98+
};
99+
if rustmotion::loader::is_html_path(&path) {
100+
// HTML sources: annotations live in the `<stem>.annotations.json`
101+
// sidecar; the HTML file itself is never modified.
102+
let result = append_sidecar_annotation(&path, ann.clone());
103+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
104+
match result {
105+
Ok(()) => {
106+
m.write_error = None;
107+
// The watcher doesn't observe the sidecar, so reflect
108+
// the change in the in-memory raw directly.
109+
m.raw = append_annotation(std::mem::take(&mut m.raw), ann);
110+
m.generation = m.generation.wrapping_add(1);
111+
note.set(String::new());
112+
}
113+
Err(e) => {
114+
m.write_error = Some(e);
115+
m.generation = m.generation.wrapping_add(1);
116+
}
117+
}
118+
} else {
119+
let updated = append_annotation(raw, ann);
120+
match serde_json::to_string_pretty(&updated) {
121+
Ok(t) => {
122+
let write_result = write_file(&path, &t);
123+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
124+
match write_result {
125+
Ok(()) => {
126+
m.write_error = None;
127+
note.set(String::new());
128+
}
129+
Err(e) => {
130+
m.write_error = Some(e);
131+
m.generation = m.generation.wrapping_add(1);
111132
}
112133
}
113-
Err(e) => {
114-
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
115-
m.write_error = Some(format!("json: {e}"));
116-
m.generation = m.generation.wrapping_add(1);
117-
}
134+
}
135+
Err(e) => {
136+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
137+
m.write_error = Some(format!("json: {e}"));
138+
m.generation = m.generation.wrapping_add(1);
118139
}
119140
}
120141
}
@@ -142,24 +163,41 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
142163
}
143164
}
144165

145-
/// Remove an annotation by id from the scenario file (the watcher reloads).
166+
/// Remove an annotation by id. JSON sources rewrite the scenario file (the
167+
/// watcher reloads); HTML sources rewrite the annotations sidecar (deleted
168+
/// when it becomes empty) and update the in-memory raw directly.
146169
fn delete_annotation(shared: &Shared, id: &str) {
147170
let (path, raw) = {
148171
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
149172
(m.path.clone(), m.raw.clone())
150173
};
151-
let updated = remove_annotation(raw, id);
152-
if let Some(path) = path {
153-
if !rustmotion::loader::is_html_path(&path) {
154-
let write_result = serde_json::to_string_pretty(&updated)
155-
.map_err(|e| format!("json: {e}"))
156-
.and_then(|t| write_file(&path, &t));
157-
if let Err(e) = write_result {
158-
let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
174+
let Some(path) = path else {
175+
return;
176+
};
177+
if rustmotion::loader::is_html_path(&path) {
178+
let result = remove_sidecar_annotation(&path, id);
179+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
180+
match result {
181+
Ok(()) => {
182+
m.write_error = None;
183+
m.raw = remove_annotation(std::mem::take(&mut m.raw), id);
184+
m.generation = m.generation.wrapping_add(1);
185+
}
186+
Err(e) => {
159187
m.write_error = Some(e);
160188
m.generation = m.generation.wrapping_add(1);
161189
}
162190
}
191+
} else {
192+
let updated = remove_annotation(raw, id);
193+
let write_result = serde_json::to_string_pretty(&updated)
194+
.map_err(|e| format!("json: {e}"))
195+
.and_then(|t| write_file(&path, &t));
196+
if let Err(e) = write_result {
197+
let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
198+
m.write_error = Some(e);
199+
m.generation = m.generation.wrapping_add(1);
200+
}
163201
}
164202
}
165203

crates/rustmotion-studio/src/scenario/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
44
mod edit;
55
mod model;
6+
mod sidecar;
67

78
pub use edit::{
89
append_annotation, list_annotations, read_field, read_style_object, remove_annotation,
910
set_field, set_style,
1011
};
1112
pub use model::{empty_scenario, Shared, StudioModel};
13+
pub use sidecar::{append_sidecar_annotation, remove_sidecar_annotation};
1214

1315
/// Which top-level view is shown (library home vs. the editor).
1416
#[derive(Clone, Copy, PartialEq)]

crates/rustmotion-studio/src/scenario/model.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,20 @@ impl StudioModel {
3131
error: Option<String>,
3232
path: Option<std::path::PathBuf>,
3333
) -> Self {
34-
// For HTML sources the raw JSON is the transpiled scenario, so the
35-
// inspector can read/resolve element properties by pointer.
34+
// For HTML sources the raw JSON is the transpiled scenario, plus the
35+
// annotations sidecar merged in so `list_annotations` and the comments
36+
// panel work unchanged; the inspector reads element props by pointer.
3637
let raw = path
3738
.as_ref()
3839
.and_then(|p| {
3940
let s = std::fs::read_to_string(p).ok()?;
4041
if rustmotion::loader::is_html_path(p) {
41-
rustmotion::loader::html_to_scenario_json(&s).ok()
42+
let raw = rustmotion::loader::html_to_scenario_json(&s).ok()?;
43+
// A corrupt sidecar already failed the scenario load in the
44+
// loader (error banner); a read failure here can only be a
45+
// race, so fall back to the bare transpile.
46+
let annotations = super::sidecar::read_sidecar(p).unwrap_or_default();
47+
Some(super::sidecar::merge_annotations(raw, annotations))
4248
} else {
4349
serde_json::from_str(&s).ok()
4450
}

0 commit comments

Comments
 (0)