diff --git a/crates/context/src/component_review/tests.rs b/crates/context/src/component_review/tests.rs index 3ef5108ef..2cc7b7fd3 100644 --- a/crates/context/src/component_review/tests.rs +++ b/crates/context/src/component_review/tests.rs @@ -599,6 +599,24 @@ fn visual_approvals_survive_shared_source_edits_but_not_changed_scope_or_pixels( before["packet"]["revision"].as_str().unwrap() ))) .unwrap(); + // Component decisions approve the isolated preview, not its contextual + // surroundings or the dependency closure of a shared document. + let mut component_previous = previous.clone(); + component_previous["packet"]["stage"] = json!("components"); + let mut component_current = after.clone(); + component_current["packet"]["stage"] = json!("components"); + component_current["packet"]["components"][1]["dependencies"] = json!(["shared.css", "unrelated.png"]); + component_current["packet"]["components"][1]["context"] = json!({"kind":"image","url":"/files/unrelated-context.png"}); + component_current["capture"]["components"][1]["views"]["preview"]["rasterElements"] = json!(12); + assert_eq!(super::visual_approval::carry(&component_previous, &mut component_current, &dir.join("blobs")), 1); + assert_eq!(component_current["draft"]["decisions"]["control"]["action"], "approve"); + assert!(component_current["receipt"].is_null()); + for field in ["box", "medium", "note", "preview"] { + let mut changed = component_current.clone(); + changed["draft"]["decisions"].as_object_mut().unwrap().remove("control"); + changed["packet"]["components"][1][field] = json!("changed"); + assert_eq!(super::visual_approval::carry(&component_previous, &mut changed, &dir.join("blobs")), 0, "component scope: {field}"); + } for field in ["box", "medium", "note", "context"] { let mut changed = after.clone(); changed["packet"]["components"][1][field] = json!("changed"); diff --git a/crates/context/src/component_review/visual_approval.rs b/crates/context/src/component_review/visual_approval.rs index 48719d39a..3e6bdb86e 100644 --- a/crates/context/src/component_review/visual_approval.rs +++ b/crates/context/src/component_review/visual_approval.rs @@ -14,9 +14,20 @@ fn identity(state: &Value, component: &Value, blobs: &Path) -> Option { let prefix = format!("/files/{}/", state["packet"]["revision"].as_str()?); let mut definition = component.clone(); definition.as_object_mut()?.remove("revision"); - // Dependency paths remain part of scope; their bytes are checked by sources_current. + let component_stage = state["packet"]["stage"] == "components"; + if component_stage { + // The checkpoint approves the isolated component. Context is explicitly + // reference-only; shared document dependencies and thumbnails are not + // additional things the user approves. Source integrity still binds the + // full frozen closure and is checked separately by sources_current. + for key in ["dependencies", "context", "thumbnail"] { + definition.as_object_mut()?.remove(key); + } + } let mut views = serde_json::Map::new(); - for key in ["preview", "context", "thumbnail", "comp"] { + let keys: &[&str] = if component_stage { &["preview", "comp"] } + else { &["preview", "context", "thumbnail", "comp"] }; + for &key in keys { let view = if key == "comp" { &state["packet"]["comp"] } else { @@ -60,6 +71,9 @@ fn identity(state: &Value, component: &Value, blobs: &Path) -> Option { "rasterElements", "semanticControls", ] { + // These counts describe the entire shared document, including + // components outside this decision's isolated preview. + if component_stage && matches!(field, "svgElements" | "rasterElements" | "semanticControls") { continue; } scoped[field] = proof[field].clone(); } }