Fix: isolate review components from assembled context

AI-assisted implementation with Codex under maintainer direction.
This commit is contained in:
Paul Bakaus
2026-09-14 17:36:22 -07:00
parent c65febd291
commit 2b7527e2cd
10 changed files with 267 additions and 46 deletions
+6
View File
@@ -1396,6 +1396,12 @@ impl<'a> Page<'a> {
}
}
/// Preserve transparency when capturing isolated component paint.
pub fn set_transparent_background(&mut self) -> CdpResult<()> {
self.send("Emulation.setDefaultBackgroundColorOverride", json!({"color":{"r":0,"g":0,"b":0,"a":0}}))?;
Ok(())
}
/// Capture the current viewport without Chromium's beyond-viewport resize.
/// Use for observations that must not trigger responsive source selection.
pub fn screenshot_viewport(&mut self) -> CdpResult<String> {
+93 -4
View File
@@ -60,6 +60,7 @@ fn render_page(
width: u32,
height: u32,
box_: &Value,
isolation: Option<&Value>,
) -> Result<(Vec<u8>, Value), String> {
let server = snapshot.serve()?;
let url = server.entry_url();
@@ -80,6 +81,12 @@ fn render_page(
if(document.getAnimations().some(a=>a.playState==='running'))throw Error('Component is animated; provide its static review state.');
return {html:document.documentElement.outerHTML,svg:document.querySelectorAll('svg').length,images:document.images.length,controls:document.querySelectorAll('button,input,select,textarea,a[href]').length};
})()"#).map_err(|e|e.message)?;
let isolated = if let Some(targets) = isolation {
page.set_transparent_background().map_err(|e| e.message)?;
let script = format!("({})({})", include_str!("component_isolation.js"), targets);
Some(page.evaluate_value_in_world(&world, &script).map_err(|e| e.message)?)
} else { None };
let captured_dom = page.evaluate_value_in_world(&world, "document.documentElement.outerHTML").map_err(|e| e.message)?;
let coords = ["x", "y", "w", "h"].map(|k| box_[k].as_f64().unwrap());
let clip = [
coords[0] * width as f64,
@@ -87,6 +94,13 @@ fn render_page(
coords[2] * width as f64,
coords[3] * height as f64,
];
if let Some(isolated) = &isolated {
let b = &isolated["bounds"];
let (x, y, w, h) = (b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["width"].as_f64().unwrap(), b["height"].as_f64().unwrap());
if x >= clip[0] + clip[2] || y >= clip[1] + clip[3] || x + w <= clip[0] || y + h <= clip[1] {
return Err("component target does not intersect its measured box".into());
}
}
let first = page
.screenshot_viewport()
.map_err(|e| e.message)?;
@@ -144,14 +158,18 @@ fn render_page(
let unchanged = page
.evaluate_value_in_world(&world, "document.documentElement.outerHTML")
.map_err(|e| e.message)?;
if unchanged != dom["html"] {
if unchanged != captured_dom {
return Err("component document changed during capture".into());
}
let viewport_png = base64::engine::general_purpose::STANDARD
.decode(first)
.map_err(|e| e.to_string())?;
let png = crop_viewport(&viewport_png, width, height, clip)?;
let proof = json!({"kind":"static-code","entry":snapshot.entry(),"inputSnapshot":snapshot.digest(),"inputs":snapshot.manifest(),"observedDependencies":responses,"domSha256":hash(dom["html"].as_str().unwrap().as_bytes()),"screenshotSha256":hash(&png),"viewportScreenshotSha256":hash(&viewport_png),"cropMethod":"verified-viewport-pixels","viewport":{"width":width,"height":height,"dpr":1},"box":box_,"reducedMotion":true,"svgElements":dom["svg"],"rasterElements":dom["images"],"semanticControls":dom["controls"]});
let mut proof = json!({"kind":"static-code","entry":snapshot.entry(),"inputSnapshot":snapshot.digest(),"inputs":snapshot.manifest(),"observedDependencies":responses,"domSha256":hash(dom["html"].as_str().unwrap().as_bytes()),"screenshotSha256":hash(&png),"viewportScreenshotSha256":hash(&viewport_png),"cropMethod":"verified-viewport-pixels","viewport":{"width":width,"height":height,"dpr":1},"box":box_,"reducedMotion":true,"svgElements":dom["svg"],"rasterElements":dom["images"],"semanticControls":dom["controls"]});
if let Some(isolated) = isolated {
proof["isolation"] = isolated;
proof["capturedDomSha256"] = json!(hash(captured_dom.as_str().unwrap().as_bytes()));
}
Ok((png, proof))
})();
page.close();
@@ -163,6 +181,20 @@ impl ComponentCapturer for NativeComponentCapturer {
packet: &mut Value,
inputs: &BTreeMap<String, Vec<u8>>,
) -> Result<CapturedPreviews, String> {
let isolated = packet["schemaVersion"] == 2 && packet["stage"] == "components";
if packet["stage"] == "components" && !isolated {
return Err("New component captures require schemaVersion 2 with preview.selector for each code component. Existing review records remain readable.".into());
}
let mut targets: BTreeMap<String, Vec<Value>> = BTreeMap::new();
if isolated {
for c in packet["components"].as_array().ok_or("missing components")? {
let target = if c["preview"]["kind"] == "page" { Some(&c["preview"]) }
else if c["context"]["kind"] == "page" { Some(&c["context"]) } else { None };
if let Some(target) = target {
targets.entry(source(target)?.into()).or_default().push(json!({"id":c["id"],"selector":target["selector"]}));
}
}
}
let width = packet["comp"]["width"]
.as_u64()
.ok_or("missing comp width")? as u32;
@@ -195,6 +227,13 @@ impl ComponentCapturer for NativeComponentCapturer {
{
let id = c["id"].as_str().ok_or("missing component id")?.to_string();
let mut views = serde_json::Map::new();
// Context comes from the same frozen document, not a separately
// authored approximation. It is never a second approval item.
if isolated && c["preview"]["kind"] == "page" {
c["context"] = c["preview"].clone();
c["context"].as_object_mut().unwrap().remove("selector");
c["context"]["layering"] = json!("Context only. This decision applies to the isolated component; the assembled page is reviewed separately.");
}
for key in ["preview", "context"] {
if c.get(key).is_none() {
continue;
@@ -223,12 +262,15 @@ impl ComponentCapturer for NativeComponentCapturer {
);
}
let snapshot = Arc::new(HtmlSnapshot::from_pinned(path.clone(), selected)?);
let cache_key = format!("{}:{}", snapshot.digest(), c["box"]);
let isolation = if isolated && key == "preview" {
Some(json!({"id":id,"targets":targets.get(&path).ok_or("missing component targets")?}))
} else { None };
let cache_key = format!("{}:{}:{}", snapshot.digest(), c["box"], isolation.as_ref().unwrap_or(&Value::Null));
let (png, proof) = if let Some(saved) = cache.get(&cache_key) {
saved.clone()
} else {
let captured =
render_page(&mut browser, snapshot, width, height, &c["box"])
render_page(&mut browser, snapshot, width, height, &c["box"], isolation.as_ref())
.map_err(|e| format!("{id} {key}: {e}"))?;
cache.insert(cache_key, captured.clone());
captured
@@ -237,6 +279,9 @@ impl ComponentCapturer for NativeComponentCapturer {
c[key]["url"] = json!(format!("/files/{output}"));
c[key]["kind"] = json!("image");
c[key]["sourceKind"] = json!("page");
if let Some(isolation) = proof.get("isolation") {
c[key]["isolation"] = isolation.clone();
}
if key == "preview" {
c["material"] = material(&png, "Captured HTML / CSS / SVG")?;
}
@@ -260,6 +305,50 @@ impl ComponentCapturer for NativeComponentCapturer {
#[cfg(test)]
mod tests {
use super::*;
// Real browser regression: shared-document crops used to duplicate the
// headline in its background card. Run explicitly on a browser-equipped host.
#[test]
#[ignore = "requires Chromium"]
fn isolated_component_capture_keeps_layout_excludes_children_and_preserves_context() {
let comp = impeccable_comp::raster::Image { width:100,height:100,data:vec![255;100*100*4] };
let reference = impeccable_comp::png_io::encode_png(&comp,&[]).unwrap();
let html = br#"<!doctype html><style>
html,body{margin:0;background:purple} #surface{position:absolute;inset:0;background:yellow}
#headline{position:absolute;left:10px;top:10px;width:40px;height:40px;background:red}
#headline::before{content:'';position:absolute;left:0;top:0;width:5px;height:5px;background:cyan;visibility:visible}
#headline::after{content:'';position:absolute;left:5px;top:0;width:5px;height:5px;background:black;visibility:hidden}
#child{position:absolute;left:10px;top:10px;width:10px;height:10px;background:lime}
#sibling{position:absolute;left:70px;top:70px;width:10px;height:10px;background:blue}
</style><div id="surface"><div id="headline"><span id="child"></span></div></div><div id="sibling"></div>"#;
let inputs = BTreeMap::from([("comp.png".into(),reference),("kit.html".into(),html.to_vec())]);
let make = |id:&str| json!({"id":id,"box":{"x":0,"y":0,"w":1,"h":1},"preview":{"kind":"page","url":"/files/kit.html","selector":format!("#{id}")},"dependencies":[]});
let mut packet = json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":100,"height":100},"components":[make("surface"),make("headline"),make("child"),make("sibling")]});
let captured = NativeComponentCapturer.capture(&mut packet,&inputs).unwrap();
let pixels = |index:usize,view:&str,x:usize,y:usize| {
let path=packet["components"][index][view]["url"].as_str().unwrap().strip_prefix("/files/").unwrap();
let image=impeccable_comp::png_io::decode_png(&captured.files[path]).unwrap().image;
image.data[(y*100+x)*4..(y*100+x)*4+4].to_vec()
};
assert_eq!(pixels(0,"preview",12,12),[255,255,0,255]); // no foreground/pseudo
assert_eq!(pixels(0,"context",12,12),[0,255,255,255]); // real combined context
assert_eq!(pixels(1,"preview",16,12),[255,0,0,255]); // hidden pseudo stays hidden
assert_eq!(pixels(1,"preview",22,22),[255,0,0,255]); // independently owned child absent
assert_eq!(pixels(1,"preview",75,75)[3],0); // sibling and page canvas absent
assert_eq!(pixels(2,"preview",22,22),[0,255,0,255]);
assert_eq!(pixels(2,"preview",12,12)[3],0); // parent's paint absent
assert_eq!(packet["components"][1]["preview"]["isolation"]["excludedComponents"],json!(["child"]));
assert_eq!(packet["components"][1]["material"]["alpha"],"transparent");
let mut raster_child = json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":100,"height":100},"components":[make("headline"),make("child")]});
raster_child["components"][1]["preview"] = json!({"kind":"image","url":"/files/comp.png"});
raster_child["components"][1]["context"] = json!({"kind":"page","url":"/files/kit.html","selector":"#child"});
NativeComponentCapturer.capture(&mut raster_child,&inputs).unwrap();
assert_eq!(raster_child["components"][0]["preview"]["isolation"]["excludedComponents"],json!(["child"]));
for selector in ["body", "#missing", "div", "#surface"] {
let mut broken = json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":100,"height":100},"components":[make("surface"),make("headline")]});
broken["components"][1]["preview"]["selector"]=json!(selector);
assert!(NativeComponentCapturer.capture(&mut broken,&inputs).is_err(),"{selector}");
}
}
#[test]
fn component_edge_crops_round_endpoints_on_odd_viewports() {
let image = impeccable_comp::raster::Image {width:3,height:3,data:(0u8..36).collect()};
+59
View File
@@ -0,0 +1,59 @@
// Native capture adapter, evaluated in a CDP isolated world. Keep DOM/layout and
// authored styling; suppress paint belonging to other components. No page script
// or producer screenshot participates in this operation.
({ id, targets }) => {
const roots = targets.map(target => {
const matches = document.querySelectorAll(target.selector);
if (matches.length !== 1) throw Error(`${target.id}: selector must match exactly one element (got ${matches.length}).`);
const element = matches[0];
if (element === document.body || element === document.documentElement || !document.body.contains(element)) {
throw Error(`${target.id}: select a component element inside the body, not the whole document.`);
}
return { ...target, element };
});
if (new Set(roots.map(root => root.element)).size !== roots.length) throw Error('Components cannot own the same DOM element.');
const selected = roots.find(root => root.id === id);
if (!selected) throw Error('Missing component target.');
const rect = selected.element.getBoundingClientRect();
if (!rect.width || !rect.height || getComputedStyle(selected.element).visibility !== 'visible') throw Error(`${id}: component target is not visible.`);
const otherRoots = roots.filter(root => root !== selected && selected.element.contains(root.element));
const elements = [...document.querySelectorAll('body,body *')];
const visibility = elements.map(element => {
const owns = (element === selected.element || selected.element.contains(element))
&& !otherRoots.some(root => root.element === element || root.element.contains(element));
const computed = getComputedStyle(element);
return { owns, rect: element.getBoundingClientRect().toJSON(),
authored: owns ? Object.fromEntries([...computed].filter(key => key !== 'visibility').map(key => [key, computed.getPropertyValue(key)])) : {},
main: owns ? computed.visibility : 'hidden',
pseudo: ['::before','::after','::marker'].map(pseudo => owns ? getComputedStyle(element, pseudo).visibility : 'hidden') };
});
// Apply only after reading all original computed styles. Descendant components
// keep their layout space but cannot paint inside their parent's preview.
if (document.querySelector('[data-impeccable-capture]')) throw Error('Reserved capture attribute is already present.');
elements.forEach((element, index) => {
element.style.setProperty('visibility', visibility[index].main, 'important');
element.setAttribute('data-impeccable-capture', String(index));
});
const style = document.createElement('style');
// Explicit pseudo-element visibility can otherwise escape its hidden owner.
style.textContent = elements.map((_, index) => ['::before','::after','::marker'].map((pseudo, p) =>
`[data-impeccable-capture="${index}"]${pseudo}{visibility:${visibility[index].pseudo[p]}!important}`).join('')).join('');
document.head.append(style);
// A body's background can propagate to the viewport despite visibility:hidden.
// Neither document root is a component; leave the capture canvas transparent.
for (const root of [document.documentElement, document.body]) root.style.setProperty('background', 'transparent', 'important');
elements.forEach((element, index) => {
const before = visibility[index];
if (!before.owns) return;
const rect = element.getBoundingClientRect();
const computed = getComputedStyle(element);
if (['x','y','width','height'].some(key => Math.abs(before.rect[key] - rect[key]) > .01)
|| Object.entries(before.authored).some(([key, value]) => computed.getPropertyValue(key) !== value)) {
throw Error('Isolating the component changed its authored layout or styles. Use a dedicated static component document.');
}
});
const after = selected.element.getBoundingClientRect();
if (['x','y','width','height'].some(key => Math.abs(rect[key] - after[key]) > .01)) throw Error('Isolating the component changed its layout.');
return { method: 'dom-component-v1', selector: selected.selector, excludedComponents: otherRoots.map(root => root.id),
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
}
File diff suppressed because one or more lines are too long
@@ -85,8 +85,11 @@ fn view(
pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap<String, Vec<u8>>), String> {
let canonical = project.canonicalize().map_err(|e| e.to_string())?;
let project = canonical.as_path();
if input["schemaVersion"] != 1 {
return Err("manifest schemaVersion must be 1".into());
if !matches!(input["schemaVersion"].as_u64(), Some(1 | 2)) {
return Err("manifest schemaVersion must be 1 or 2".into());
}
if input["schemaVersion"] == 2 && !matches!(input["stage"].as_str(), Some("components" | "hero")) {
return Err("schemaVersion 2 requires stage components or hero".into());
}
let mut packet = input.clone();
for key in ["capture", "captureVerified"] {
@@ -126,6 +129,24 @@ pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap<String,
}
view(&mut packet["comp"], project, &mut files, &mut comp_files)?;
// Selector ownership is part of each shared document's input contract. A peer
// target changing must invalidate captures that previously excluded it.
let mut targets: BTreeMap<String, Vec<Value>> = BTreeMap::new();
if input["schemaVersion"] == 2 && input["stage"] == "components" {
for c in input["components"].as_array().ok_or("components must be an array")? {
if c["preview"]["kind"] != "page" && c["preview"].get("selector").is_some() {
return Err("only code previews can declare a selector; place raster DOM targets in context".into());
}
let target = if c["preview"]["kind"] == "page" { Some(&c["preview"]) }
else if c["context"]["kind"] == "page" { Some(&c["context"]) } else { None };
if let Some(target) = target {
let selector = string(target, "selector")?;
if selector.len() > 1024 { return Err("component selector too long".into()); }
let path = string(target, "path")?;
targets.entry(path.into()).or_default().push(json!({"id":c["id"],"selector":selector}));
}
}
}
let mut ids = BTreeSet::new();
let components = packet["components"]
.as_array_mut()
@@ -143,6 +164,7 @@ pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap<String,
if let Some(view) = c.get_mut(key).and_then(Value::as_object_mut) {
view.remove("sourceKind");
view.remove("capture");
view.remove("isolation");
}
}
let id = string(c, "id")?.to_string();
@@ -196,9 +218,13 @@ pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap<String,
}
}
c.as_object_mut().unwrap().remove("revision");
c["revision"] = json!(digest(
&serde_json::to_vec(&json!({"component":c,"files":used})).unwrap()
));
let mut identity = json!({"component":c,"files":used});
let target_path = if c["preview"]["kind"] == "page" { Some(preview_path.as_str()) }
else { c["context"]["url"].as_str().and_then(|url| url.strip_prefix("/files/")) };
if let Some(peers) = target_path.and_then(|path| targets.get(path)) {
identity["targets"] = json!(peers);
}
c["revision"] = json!(digest(&serde_json::to_vec(&identity).unwrap()));
}
let total: usize = files.values().map(Vec::len).sum();
if total > 256 * 1024 * 1024 {
@@ -489,3 +489,39 @@ fn measured_inventory_is_bound_without_repeated_author_dependencies() {
fs::write(path, br#"{"regions":[]}"#).unwrap();
assert!(store::sources_current(&state).is_err());
}
#[test]
fn isolated_components_require_targets_and_pin_their_ownership() {
let f = Fixture::new();
fs::create_dir_all(f.project.join(".impeccable/build")).unwrap();
fs::write(f.project.join(".impeccable/build/spec.json"), br#"{"regions":[{"id":"art","kind":"plate"},{"id":"control","kind":"control"}]}"#).unwrap();
let mut input = f.manifest();
input["schemaVersion"] = json!(2);
input["stage"] = json!("components");
assert!(manifest::freeze(&f.project, &input).unwrap_err().contains("selector"));
input["components"][1]["preview"]["selector"] = json!("button");
let (first, _) = manifest::freeze(&f.project, &input).unwrap();
input["components"][1]["preview"]["selector"] = json!("#cta");
let (changed, _) = manifest::freeze(&f.project, &input).unwrap();
assert_ne!(first["components"][1]["revision"], changed["components"][1]["revision"]);
assert_eq!(first["components"][0]["revision"], changed["components"][0]["revision"]);
input["components"][0]["preview"]["selector"] = json!("#fake-raster-target");
assert!(manifest::freeze(&f.project, &input).is_err());
}
#[test]
fn shared_target_changes_invalidate_other_isolated_components() {
let f = Fixture::new();
fs::create_dir_all(f.project.join(".impeccable/build")).unwrap();
fs::write(f.project.join(".impeccable/build/spec.json"), br#"{"regions":[{"id":"art","kind":"chrome"},{"id":"control","kind":"control"}]}"#).unwrap();
let mut input = f.manifest();
input["schemaVersion"] = json!(2); input["stage"] = json!("components");
input["components"][0]["preview"] = json!({"kind":"page","path":"control.html","selector":"#background"});
input["components"][1]["preview"]["selector"] = json!("button");
let (before,_) = manifest::freeze(&f.project,&input).unwrap();
input["components"][1]["preview"]["selector"] = json!("#cta");
let (after,_) = manifest::freeze(&f.project,&input).unwrap();
assert_ne!(before["components"][0]["revision"],after["components"][0]["revision"]);
input["stage"] = Value::Null;
assert!(manifest::freeze(&f.project,&input).is_err());
}
+8 -4
View File
@@ -10,7 +10,7 @@ Write `.impeccable/review/components.json` with this manifest format:
```json
{
"schemaVersion": 1,
"schemaVersion": 2,
"id": "components",
"title": "Component review",
"stage": "components",
@@ -31,14 +31,18 @@ Write `.impeccable/review/components.json` with this manifest format:
"medium": "html",
"box": {"x": 0.05, "y": 0.2, "w": 0.4, "h": 0.25},
"note": "Rendered semantic heading and its typography.",
"preview": {"kind": "page", "path": ".impeccable/review/components/headline.html"},
"preview": {"kind": "page", "path": ".impeccable/review/components/kit.html", "selector": "#headline"},
"dependencies": [".impeccable/build/spec.json", "assets/type.woff2"]
}
]
}
```
The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). A code preview is rendered at the comp viewport and cropped to that component's box, so place its content at those coordinates in the review document. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. The runtime also binds the measured spec for the component stage and checks its inventory. Local paths only. Static PNG, WebP and JPEG previews retain their original bytes and actual transparency; never draw a checkerboard into the asset.
The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (`x / width`, `y / height`, `w / width`, `h / height`). Each code preview requires a `selector` matching exactly one component element inside the document body. Shared kit documents are supported: the native capturer preserves layout and authored styles, hides other components, and crops to the measured box. A separately targeted child is excluded from its parent's isolated preview. Background fields therefore show their own paint, not the text and controls laid over them. Place components at the comp coordinates in the review document.
For a raster placed inside the kit, add `context: {"kind":"page","path":".impeccable/review/components/kit.html","selector":"#illustration"}` and declare that document's dependencies. This identifies its DOM placement so a containing code component excludes it too; the raster preview remains the original image bytes.
The runtime also captures an unmodified **In context** view from that same document. This assembled view is reference only, not another component to approve. Keep each review target independently meaningful; use context to show a group together rather than submitting the same content for approval as both a combined component and its children. The final assembled hero still has its own review checkpoint. Include every file the document uses in `dependencies`, including linked CSS, fonts and images. The runtime also binds the measured spec for the component stage and checks its inventory. Local paths only. Static PNG, WebP and JPEG previews retain their original bytes and actual transparency; never draw a checkerboard into the asset.
Native capture supports stable HTML/CSS and inline SVG. Supply a static review state for motion and keep the implementation's real inputs. A scripted, canvas or otherwise unsupported component is a blocker to report, not permission to substitute a raster or omit it.
@@ -48,7 +52,7 @@ If the harness exposes `component_review`, call it with `manifest_path` set to `
Otherwise run `{{scripts_path}}/impeccable component-review capture --manifest .impeccable/review/components.json`, then start `{{scripts_path}}/impeccable component-review serve --session <returned session>` in the background. Open the URL it prints in the available browser and wait for the user. Read the result with `{{scripts_path}}/impeccable component-review verify --manifest .impeccable/review/components.json`; pending, needs-work and stale input all refuse approval. Never submit the page or write a receipt on the user's behalf.
The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved.
The user can approve components, request changes, and mark missing regions. Act on their feedback without replacing it with your own favorable verdict. Keep component IDs stable, update the actual implementation and dependency list, and present another round. The UI carries only approvals whose component inputs have not changed. Selector ownership is an input too; changing a target invalidates affected captures. Do not ask the user to reapprove unchanged work. Continue only when the inventory is confirmed and all components are approved.
## Assemble and review
+1 -1
View File
@@ -42,7 +42,7 @@ test('captured code keeps its implementation identity without trusting medium as
const image = packet.components[0];
expect(componentPresentation({...image, medium:'SVG'}).code).toBe(false);
const captured = {...packet.components[1], preview:{kind:'image' as const, sourceKind:'page' as const, url:'/capture.png'}};
expect(componentPresentation(captured)).toMatchObject({code:true,captured:true,label:'HTML',caption:'Rendered component',fileLabel:'Open captured preview'});
expect(componentPresentation(captured)).toMatchObject({code:true,captured:true,label:'HTML',caption:'Region capture',fileLabel:'Open captured preview'});
expect(componentPresentation(packet.components[1]).caption).toBe('Live component');
});
+3 -3
View File
@@ -4,10 +4,10 @@ export type Component = {
material?: { format: string; width: number; height: number; alpha: 'transparent' | 'opaque' | 'unknown' };
context?: { kind?: 'image' | 'page'; sourceKind?: 'page'; url: string; layering: string };
thumbnail?: { url: string; box?: Box };
preview: { kind: 'image' | 'page'; sourceKind?: 'page'; url: string; position?: string };
preview: { kind: 'image' | 'page'; sourceKind?: 'page'; isolation?: { method: 'dom-component-v1'; selector: string; excludedComponents: string[] }; url: string; position?: string };
};
export type ReviewPacket = {
id: string; revision: string; title: string; round: number;
id: string; revision: string; title: string; round: number; stage?: 'components' | 'hero';
comp: { url: string; width: number; height: number; background?: string }; components: Component[];
};
export type Decision = { revision: string; action: 'approve' | 'revise'; feedback: string; split: boolean };
@@ -76,7 +76,7 @@ export function componentPresentation(component: Component) {
return {
code, captured,
label: code ? (component.medium.match(/html|css|svg/i) ? component.medium : 'HTML / CSS / SVG') : 'Raster',
caption: code ? (captured ? 'Rendered component' : 'Live component') : 'Produced asset',
caption: code ? (captured ? component.preview.isolation ? 'Component only' : 'Region capture' : 'Live component') : 'Produced asset',
fileLabel: captured ? 'Open captured preview' : 'Open source image',
};
}
+5 -4
View File
@@ -145,6 +145,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
const statusMessage = error || (uncommitted ? 'Save or cancel your open feedback before sending.' : submitted ? (options.preview ? 'Preview submitted. No run changed.' : 'Review submitted.') : stats.hasFeedback ? 'Ready to send for corrections.' : stats.pending ? `${stats.pending} left to review` : !draft.inventoryConfirmed ? 'Confirm the map is complete.' : 'Ready to continue.');
const presentation = v ? componentPresentation(v) : null;
const isRaster = v?.preview.kind === 'image' && !presentation?.code;
const hasTransparency = isRaster || v?.material?.alpha === 'transparent';
const useContext = !!(v?.context && outputMode === 'context');
const useFrame = v && (useContext ? v.context?.kind !== 'image' : v.preview.kind === 'page');
const sourceUrl = useContext && v?.context ? v.context.url : v?.preview.url;
@@ -176,13 +177,13 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
${repair?.change?.kind==='changed'?`<details class="changed-files" ${filesOpen?'open':''}><summary>${repair.change.files.length?`${repair.change.files.length} changed ${repair.change.files.length===1?'file':'files'}`:repair.change.reasons.includes('region')?'Region changed':priorComponent?.note!==c.note?'Description changed · files unchanged':'Component definition changed · files unchanged'}</summary>${repair.change.files.length?`<ul>${repair.change.files.map(path=>`<li>${esc(path)}</li>`).join('')}</ul>`:''}${priorComponent&&priorComponent.note!==c.note?`<dl class="description-diff"><dt>Previous description</dt><dd>${esc(priorComponent.note)}</dd><dt>Current description</dt><dd>${esc(c.note)}</dd></dl>`:''}</details>`:''}
</div>`:''}
${priorComponent?`<div class="preview-round"><div class="round-switch" role="group" aria-label="Preview version"><button id="current-round" aria-pressed="${!viewingPrevious}">Current · round ${packet.round}</button><button id="previous-round" aria-pressed="${viewingPrevious}">Previous · round ${history!.packet.round}</button></div></div>`:''}
<div class="comparison-slot"><div class="comparison-panel"><h2 class="expanded-title">${esc(v!.name)}</h2><div class="compare-toolbar"><label class="zoom-control" title="Comparison zoom · based on comp pixels">${icon('zoom')}<select id="zoom" aria-label="Comparison zoom">${[['fit','Fit'],['1','100%'],['2','200%'],['4','400%']].map(([value,label])=>`<option value="${value}" ${String(zoom)===value?'selected':''}>${label}</option>`).join('')}</select>${icon('chevronDown')}</label><button id="overlay" class="overlay-control" aria-label="Overlay comp" title="Overlay approved comp" aria-pressed="${overlay}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="10" height="10"/><rect x="7" y="7" width="10" height="10"/></svg>Overlay</button><div class="comparison-actions" role="group" aria-label="Comparison view actions"><button id="expand-comparison" class="icon-button" aria-label="${expandedComparison?'Restore comparison':'Enlarge comparison'}" title="${expandedComparison?'Restore comparison (Esc)':'Enlarge comparison'}" aria-expanded="${expandedComparison}">${icon(expandedComparison?'compact':'expand')}</button>${v?.preview.kind==='image'?`<a class="icon-button source-link" href="${url(v!.preview.url)}" target="_blank" rel="noopener" aria-label="${presentation!.fileLabel}" title="${presentation!.fileLabel}">${icon('external')}</a>`:''}</div></div>
<div class="comparison-slot"><div class="comparison-panel"><h2 class="expanded-title">${esc(v!.name)}</h2><div class="compare-toolbar"><label class="zoom-control" title="Comparison zoom · based on comp pixels">${icon('zoom')}<select id="zoom" aria-label="Comparison zoom">${[['fit','Fit'],['1','100%'],['2','200%'],['4','400%']].map(([value,label])=>`<option value="${value}" ${String(zoom)===value?'selected':''}>${label}</option>`).join('')}</select>${icon('chevronDown')}</label><button id="overlay" class="overlay-control" aria-label="Overlay comp" title="Overlay approved comp" aria-pressed="${overlay}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="10" height="10"/><rect x="7" y="7" width="10" height="10"/></svg>Overlay</button><div class="comparison-actions" role="group" aria-label="Comparison view actions"><button id="expand-comparison" class="icon-button" aria-label="${expandedComparison?'Restore comparison':'Enlarge comparison'}" title="${expandedComparison?'Restore comparison (Esc)':'Enlarge comparison'}" aria-expanded="${expandedComparison}">${icon(expandedComparison?'compact':'expand')}</button>${v?.preview.kind==='image'?`<a class="icon-button source-link" href="${url(sourceUrl!)}" target="_blank" rel="noopener" aria-label="${useContext?'Open context capture':presentation!.fileLabel}" title="${useContext?'Open context capture':presentation!.fileLabel}">${icon('external')}</a>`:''}</div></div>
<div class="compare">
<figure><figcaption>${viewingPrevious ? `Comp · Round ${history!.packet.round}` : 'In the comp'}</figcaption><div class="pan-viewport" aria-label="Reference comparison canvas" tabindex="0"><div class="crop-stage"><img class="crop-image" src="${url(vp.comp.url)}" alt="Reference region for ${esc(v!.name)}" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%"></div></div></figure>
<figure><figcaption>${viewingPrevious ? `Previous · Round ${history!.packet.round}` : history ? `Current · Round ${packet.round}` : useContext ? 'In the page' : presentation!.caption}</figcaption><div class="pan-viewport" aria-label="Produced comparison canvas" tabindex="0"><div class="output crop-stage ${isRaster&&!useContext&&!useFrame&&backdrop==='checker'?'checker':''}">${!useFrame ? `<img class="asset" src="${url(sourceUrl!)}" alt="Produced ${esc(v!.name)}" style="object-position:${esc(v!.preview.position ?? 'center')}">` : `<iframe aria-hidden="true" title="Rendered ${esc(v!.name)}" src="${url(sourceUrl!)}" sandbox="" tabindex="-1" width="${vp.comp.width}" height="${vp.comp.height}"></iframe>`}${overlay ? `<img class="crop-image overlay-image" src="${url(vp.comp.url)}" alt="Reference overlay" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%">` : ''}</div></div></figure>
<figure><figcaption>${viewingPrevious ? `Previous · Round ${history!.packet.round}` : useContext ? 'In context' : history ? `${presentation!.caption} · Round ${packet.round}` : presentation!.caption}</figcaption><div class="pan-viewport" aria-label="Produced comparison canvas" tabindex="0"><div class="output crop-stage ${hasTransparency&&!useContext&&!useFrame&&backdrop==='checker'?'checker':''}">${!useFrame ? `<img class="asset" src="${url(sourceUrl!)}" alt="Produced ${esc(v!.name)}" style="object-position:${esc(v!.preview.position ?? 'center')}">` : `<iframe aria-hidden="true" title="Rendered ${esc(v!.name)}" src="${url(sourceUrl!)}" sandbox="" tabindex="-1" width="${vp.comp.width}" height="${vp.comp.height}"></iframe>`}${overlay ? `<img class="crop-image overlay-image" src="${url(vp.comp.url)}" alt="Reference overlay" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%">` : ''}</div></div></figure>
</div>
${isRaster ? `<div class="view-controls">${v!.context ? `<div role="group" aria-label="Asset view"><button id="isolated" aria-pressed="${!useContext}">Asset only</button><button id="context" aria-pressed="${useContext}">In page</button></div>` : ''}<div class="background-options" role="group" aria-label="Asset preview background"><button id="background-checker" class="swatch-button" aria-label="Checkerboard background" title="Checkerboard background" aria-pressed="${backdrop==='checker'}" ${useContext?'disabled':''}><span class="background-swatch checker"></span></button><button id="background-page" class="swatch-button" aria-label="${vp.comp.background?'Page color':'Neutral'} background" title="${vp.comp.background?'Page color':'Neutral'} background" aria-pressed="${backdrop==='page'}" ${useContext?'disabled':''}><span class="background-swatch page-swatch"></span></button></div></div>` : ''}
</div></div><div class="component-details"><p class="layering">${esc(v?.context?.layering ?? 'Layer placement not recorded.')}</p>
${hasTransparency || v!.context ? `<div class="view-controls">${v!.context ? `<div role="group" aria-label="Component view"><button id="isolated" aria-pressed="${!useContext}">${isRaster?'Asset only':'Component only'}</button><button id="context" aria-pressed="${useContext}">In context</button></div>` : ''}${hasTransparency?`<div class="background-options" role="group" aria-label="Asset preview background"><button id="background-checker" class="swatch-button" aria-label="Checkerboard background" title="Checkerboard background" aria-pressed="${backdrop==='checker'}" ${useContext?'disabled':''}><span class="background-swatch checker"></span></button><button id="background-page" class="swatch-button" aria-label="${vp.comp.background?'Page color':'Neutral'} background" title="${vp.comp.background?'Page color':'Neutral'} background" aria-pressed="${backdrop==='page'}" ${useContext?'disabled':''}><span class="background-swatch page-swatch"></span></button></div>`:''}</div>` : ''}
</div></div><div class="component-details">${vp.stage==='components'&&presentation?.captured&&!v?.preview.isolation?'<p class="layering">Legacy region capture · may include overlapping components.</p>':''}${v?.context?.layering&&(isRaster||useContext)?`<p class="layering">${esc(v.context.layering)}</p>`:''}
<p class="component-note">${esc(v!.note)}</p>
</div></div><div class="review-form">${notice}${viewingPrevious?'<p class="previous-notice">Viewing the previous round. Return to Current to make a decision.</p>':''}${submitted?`<div class="record-verdict"><strong>${d?.action==='approve'?'Approved':d?.action==='revise'?'Changes requested':'Not reviewed'}</strong><span>Submitted in round ${packet.round} · read-only</span></div>`:`<div class="decisions" role="group" aria-label="Decision for ${esc(c.name)}"><div class="decision-title"><strong>Your review <span>Round ${packet.round}</span></strong>${viewingPrevious?'<p>Return to Current to review this round.</p>':''}</div><button id="approve" class="decision-approve ${d?.action === 'approve' ? 'approved' : ''}" aria-pressed="${d?.action === 'approve'}">Looks good</button><button id="revise" class="decision-revise ${d?.action === 'revise' ? 'revise' : ''}" aria-pressed="${d?.action === 'revise'}">Needs work</button>${d ? `<button id="clear" class="quiet icon-button" aria-label="Clear decision" title="Clear decision">${icon('undo')}</button>` : ''}</div>`}
${edit ? `<form id="feedback-form"><div class="feedback-fields"><label class="feedback-field">What needs to change?<textarea id="feedback" aria-describedby="feedback-hint">${esc(edit.feedback)}</textarea></label><p id="feedback-hint" class="feedback-hint">Optional — leave blank for the agent to diagnose.</p><label class="check"><input id="split" type="checkbox" ${edit.split ? 'checked' : ''}> Split into separately reviewable components</label></div><div class="feedback-actions"><button id="cancel-feedback" type="button" class="quiet">Cancel</button><button id="save-feedback" type="submit" class="primary">${isLast?'Save & finish review':'Save & next'} <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6"/></svg></button><span class="shortcut-hint">${shortcutLabel}</span></div></form>` : d?.action==='revise' ? `<p class="saved-feedback">${esc(d.feedback || 'No note — agent will diagnose.')}</p>` : ''}