diff --git a/crates/browser/src/cdp.rs b/crates/browser/src/cdp.rs index c7132b382..66e82a3e9 100644 --- a/crates/browser/src/cdp.rs +++ b/crates/browser/src/cdp.rs @@ -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 { diff --git a/crates/cli/src/component_capture.rs b/crates/cli/src/component_capture.rs index aaf9413bc..6a4d90b9f 100644 --- a/crates/cli/src/component_capture.rs +++ b/crates/cli/src/component_capture.rs @@ -60,6 +60,7 @@ fn render_page( width: u32, height: u32, box_: &Value, + isolation: Option<&Value>, ) -> Result<(Vec, 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>, ) -> Result { + 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> = 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#"
"#; + 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()}; diff --git a/crates/cli/src/component_isolation.js b/crates/cli/src/component_isolation.js new file mode 100644 index 000000000..f5bea8dc6 --- /dev/null +++ b/crates/cli/src/component_isolation.js @@ -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 } }; +} diff --git a/crates/context/assets/component-review.js b/crates/context/assets/component-review.js index 0599ec457..bf1a00ce0 100644 --- a/crates/context/assets/component-review.js +++ b/crates/context/assets/component-review.js @@ -1,4 +1,4 @@ -(()=>{function ki(p,o){let a=o?.changes[p],r=o?.feedback?.[p],n=r?.decision??(o?.submitted?o.draft.decisions[p]:void 0),c=a?.kind==="unchanged"&&(a.carried??(o?.submitted&&n?.action==="approve"))===!0;return{change:a,prior:n,feedbackRound:r?.round??o?.packet.round,carried:c,label:a?.kind==="added"?"New component":a?.kind==="changed"?"Review again":c?"Approval kept":n?.action==="revise"?"Changes still requested":"Awaiting review"}}function O(p,o,a){let r=o.decisions[p.id],n=r?.revision===p.revision?r:void 0,c=ki(p.id,a);if(n?.action==="approve")return{kind:"approved",label:c.carried?"Approval kept":"Approved",priority:3};if(n?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:c.change?.kind==="changed"?"Review again":c.change?.kind==="added"?"New · review needed":"Not reviewed",priority:c.change?.kind==="changed"||c.change?.kind==="added"?0:1}}function po(p){return{packetRevision:p.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function Vo(p){return Object.values(p).every(Number.isFinite)&&p.x>=0&&p.y>=0&&p.w>0&&p.h>0&&p.x+p.w<=1.00001&&p.y+p.h<=1.00001}function li(p,o){let a=p.components.map((f)=>o.decisions[f.id]?.revision===f.revision?o.decisions[f.id]:void 0),r=a.filter((f)=>f?.action==="approve").length,n=a.filter((f)=>f?.action==="revise").length,c=a.length-r-n,g=n>0||o.missing.length>0;return{approved:r,revisions:n,pending:c,hasFeedback:g,canSubmit:o.packetRevision===p.revision&&o.missing.every((f)=>f.name.trim()&&Vo(f.box))&&(g||!c&&o.inventoryConfirmed)}}function ao(p,o){let a={...o.decisions};for(let r of p.components)if(!a[r.id]||a[r.id].revision!==r.revision)a[r.id]={revision:r.revision,action:"approve",feedback:"",split:!1};return{...o,decisions:a}}function xo(p,o){if(!li(p,o).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:p.id,...structuredClone(o)}}function ji(p){let o=p.preview.kind==="page"||p.preview.sourceKind==="page",a=p.preview.sourceKind==="page";return{code:o,captured:a,label:o?p.medium.match(/html|css|svg/i)?p.medium:"HTML / CSS / SVG":"Raster",caption:o?a?"Rendered component":"Live component":"Produced asset",fileLabel:a?"Open captured preview":"Open source image"}}function to(p,o,a){let r=p.components.findIndex((n)=>n.id===a);for(let n=1;n<=p.components.length;n++){let c=p.components[(r+n)%p.components.length];if(O(c,o).kind==="pending")return c.id}}function Yi(p,o,a){return a==="all"||O(p,o).kind==="pending"===(a==="pending")}function _i(p,o,a,r,n){let c=n==="fit"?Math.min(a/p,r/o):n;return{scale:c,width:p*c,height:o*c}}function Li(p,o,a,r){if(a<=0||r<=a)return 0;return Math.max(0,Math.min(1,((p-o)/a-0.08)/0.84))*(r-a)}var lo=` +(()=>{function ki(p,o){let a=o?.changes[p],r=o?.feedback?.[p],n=r?.decision??(o?.submitted?o.draft.decisions[p]:void 0),c=a?.kind==="unchanged"&&(a.carried??(o?.submitted&&n?.action==="approve"))===!0;return{change:a,prior:n,feedbackRound:r?.round??o?.packet.round,carried:c,label:a?.kind==="added"?"New component":a?.kind==="changed"?"Review again":c?"Approval kept":n?.action==="revise"?"Changes still requested":"Awaiting review"}}function D(p,o,a){let r=o.decisions[p.id],n=r?.revision===p.revision?r:void 0,c=ki(p.id,a);if(n?.action==="approve")return{kind:"approved",label:c.carried?"Approval kept":"Approved",priority:3};if(n?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:c.change?.kind==="changed"?"Review again":c.change?.kind==="added"?"New · review needed":"Not reviewed",priority:c.change?.kind==="changed"||c.change?.kind==="added"?0:1}}function ao(p){return{packetRevision:p.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function Eo(p){return Object.values(p).every(Number.isFinite)&&p.x>=0&&p.y>=0&&p.w>0&&p.h>0&&p.x+p.w<=1.00001&&p.y+p.h<=1.00001}function li(p,o){let a=p.components.map((f)=>o.decisions[f.id]?.revision===f.revision?o.decisions[f.id]:void 0),r=a.filter((f)=>f?.action==="approve").length,n=a.filter((f)=>f?.action==="revise").length,c=a.length-r-n,g=n>0||o.missing.length>0;return{approved:r,revisions:n,pending:c,hasFeedback:g,canSubmit:o.packetRevision===p.revision&&o.missing.every((f)=>f.name.trim()&&Eo(f.box))&&(g||!c&&o.inventoryConfirmed)}}function xo(p,o){let a={...o.decisions};for(let r of p.components)if(!a[r.id]||a[r.id].revision!==r.revision)a[r.id]={revision:r.revision,action:"approve",feedback:"",split:!1};return{...o,decisions:a}}function to(p,o){if(!li(p,o).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:p.id,...structuredClone(o)}}function ji(p){let o=p.preview.kind==="page"||p.preview.sourceKind==="page",a=p.preview.sourceKind==="page";return{code:o,captured:a,label:o?p.medium.match(/html|css|svg/i)?p.medium:"HTML / CSS / SVG":"Raster",caption:o?a?p.preview.isolation?"Component only":"Region capture":"Live component":"Produced asset",fileLabel:a?"Open captured preview":"Open source image"}}function lo(p,o,a){let r=p.components.findIndex((n)=>n.id===a);for(let n=1;n<=p.components.length;n++){let c=p.components[(r+n)%p.components.length];if(D(c,o).kind==="pending")return c.id}}function Xi(p,o,a){return a==="all"||D(p,o).kind==="pending"===(a==="pending")}function Qi(p,o,a,r,n){let c=n==="fit"?Math.min(a/p,r/o):n;return{scale:c,width:p*c,height:o*c}}function Wi(p,o,a,r){if(a<=0||r<=a)return 0;return Math.max(0,Math.min(1,((p-o)/a-0.08)/0.84))*(r-a)}var ho=` :host{height:var(--component-review-height,100dvh);min-height:0;overflow:hidden} .review{height:100%;max-width:none;min-height:0;padding:0;display:flex;flex-direction:column;overflow:hidden;background:var(--color-bg,#fafafa)} .review>header{flex-shrink:0;padding:16px 24px;margin:0;border-bottom:1px solid var(--line);gap:16px}.review>header>div{min-width:0}.review h1{font-size:30px;line-height:1}.review>header p{font-size:12px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.review .badge{font-size:11px} @@ -54,7 +54,7 @@ .review>.inventory-section{height:160px;padding:6px 14px}.inventory-section>.section-head{flex-direction:row;flex-wrap:nowrap;align-items:center;gap:6px;min-height:34px;margin-bottom:4px}.inventory-section h2{display:none}.inventory-filters{flex:1;width:auto;min-width:0;padding:2px}.inventory-filters button{padding:4px 6px;font-size:10px;min-height:28px}.inventory-filters b{margin-left:3px}.tray-actions #show-all{display:none}.tray-actions #toggle-tray{padding:6px;min-width:32px;min-height:32px}.tray-actions #toggle-tray span{display:none}.tray-actions svg{width:16px;height:16px}.review>.inventory-section.tray-collapsed{height:46px;padding:6px 14px}.review>.inventory-section.tray-expanded{height:160px}.inventory .item{flex-basis:130px;grid-template-rows:auto 1fr auto;padding:6px}.inventory .item-thumb{display:none}.inventory.all{display:flex;overflow-x:auto;overflow-y:hidden}.inventory .item strong{font-size:11px;min-height:22px}.inventory .state{font-size:10px}.inventory .item-number{font-size:10px;min-height:14px}.inventory-empty{padding:8px 0;font-size:12px} .review>footer{padding:8px 14px calc(8px + env(safe-area-inset-bottom));gap:8px;flex-direction:column;align-items:stretch}.review>footer>div:first-child{gap:10px;justify-content:space-between}.review>footer #approve-rest{font-size:10px;min-height:32px;max-width:47%;padding:5px 8px}.review>footer .check{font-size:10px;max-width:48%;gap:4px}.review>footer .check input{width:14px;height:14px}.review>footer .submit-area{justify-content:space-between;gap:10px}.review>footer .submit-area p{font-size:10px;max-width:22ch}.review>footer .primary{min-height:34px;font-size:12px;padding:6px 10px} } -`;var ho=` +`;var go=` :host{display:block;color:var(--color-text,#292929);font:14px/1.45 var(--font-sans,Arial,sans-serif);--line:var(--color-border,#ddd);--paper:var(--color-panel,#fff);--muted:var(--color-muted,#666);--teal:var(--color-patina,#28625e);--warn:var(--color-warn,#8a5b30);--selection:#43897f} *{box-sizing:border-box}h1,h2,p,figure{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit;padding:8px 12px;min-height:36px}button:hover{border-color:var(--teal);color:var(--teal)}button:disabled{cursor:default;opacity:.45}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--teal);outline-offset:3px}button[aria-pressed=true]{box-shadow:inset 0 0 0 1px var(--teal)}input[type=checkbox]{accent-color:var(--teal);width:16px;height:16px;flex-shrink:0}textarea,input:not([type=checkbox]){width:100%;background:var(--paper);color:inherit;border:1px solid #999;border-radius:4px;padding:9px 10px}textarea{resize:vertical;min-height:80px}::selection{background:#c7ddd8}a{color:var(--teal)} .review{max-width:1600px;margin:auto;padding:24px 28px 0}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:16px}h1{font:400 40px/1.05 var(--font-display,Arial,sans-serif);letter-spacing:-.02em}header p{margin-top:8px;font-size:15px}header p span,.medium{color:var(--muted)}.badge{border:1px solid var(--line);padding:5px 10px;font-size:12px;white-space:nowrap}.preview-note{color:var(--muted);font-size:12px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px} @@ -89,7 +89,7 @@ @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}} .item-medium{margin-left:auto;font-weight:400;display:flex;align-items:center;gap:4px;font-size:10px;color:var(--muted);min-height:16px}.item-medium .utility-icon{width:14px;height:14px;flex-shrink:0}.material>.utility-icon{width:18px;height:18px;align-self:center;color:var(--teal)} .feedback-actions{display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap}.feedback-actions .primary{margin-left:auto;display:inline-flex;align-items:center;gap:10px}.feedback-actions svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5}.feedback-hint,.shortcut-hint{font-size:12px;color:var(--muted)}.feedback-hint{margin-top:6px}.shortcut-hint{flex-basis:100%;text-align:right}.record-verdict{display:flex;flex-wrap:wrap;align-items:baseline;gap:5px 12px;font-size:13px}.record-verdict span{font-size:11px;color:var(--muted)}.review>.record-footer{font-size:12px;color:var(--muted);flex-wrap:wrap;gap:6px 16px}.saved-feedback{font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0}.decision-notice{display:flex;align-items:center;gap:12px;font-size:12px;margin-bottom:10px}.decision-notice>span{flex:1;overflow-wrap:anywhere}.decision-notice button{color:var(--teal);flex-shrink:0}.review-summary{padding:4px 2px}.completion-mark{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;background:#e6efe8;color:#28625e;float:left;margin:0 10px 8px 0}.completion-mark svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.6}.completion-link{display:inline-flex;align-items:center;gap:8px;color:var(--teal);border:0;background:transparent;padding:0;font-size:12px;text-align:left}.completion-link svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;flex-shrink:0}.queue-complete #approve-rest{display:none}.review-summary h2{font-size:22px;line-height:32px}.review-summary p{clear:both;margin-top:8px;color:var(--muted)}.summary-decisions{margin-top:24px;display:grid}.summary-decisions button{display:grid;grid-template-columns:1fr auto;gap:6px 16px;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:12px 0;background:transparent}.summary-decisions strong{font-size:13px;font-weight:500}.summary-decisions span{font-size:12px;color:var(--teal)}.summary-decisions small{grid-column:1/-1;font-size:12px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere} -`+lo+` +`+ho+` .review-form:has(#feedback-form){max-height:75%;min-height:0;flex-shrink:1;display:flex;flex-direction:column;overflow:hidden;padding-top:8px} #feedback-form{display:flex;flex-direction:column;flex:1;min-height:0} /* Leave room inside the scrollport for the 2px focus ring and 3px offset. */ @@ -123,47 +123,47 @@ .comparison-panel .overlay-control svg{width:18px;height:18px;stroke-width:1.6} .comparison-actions .source-link{color:var(--muted)} @media(forced-colors:active){.zoom-control select{appearance:auto;padding:6px 8px}.zoom-control>.utility-icon{display:none}} -`;var Eo={chevronDown:'',code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function J(p){return``}var l=(p)=>p.replace(/[&<>"']/g,(o)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]),ii=(p)=>`${p*100}%`,oi=(p)=>{let o=new URL(p,location.href);if(!["http:","https:"].includes(o.protocol))throw Error("Unsupported preview URL");return l(o.href)};function go(p,o,a){let r=p.attachShadow({mode:"open"}),n=structuredClone(a.initialDraft??po(o)),c=()=>[...o.components].sort((s,w)=>O(s,n,a.history).priority-O(w,n,a.history).priority),g=c().find((s)=>O(s,n,a.history).kind!=="approved")?.id??o.components[0]?.id,f=li(o,n).pending?"pending":"reviewed",G=!1,T=!1,u=a.completed??!1,qi="",N={},y=!!a.completed||!li(o,n).pending,U=null,fo=/Mac|iPhone|iPad/.test(navigator.platform)?"⌘Enter":"Ctrl+Enter",R=!1,K=!1,D=!1,V=!0,pi=!1,M="comp",Ti=M,W="fit",di="checker",X=!1,ei="isolated",Mi,Xi="fit",Z=null,A=null,ai=null,hi='',fi='',Qi=(s)=>`left:${ii(s.x)};top:${ii(s.y)};width:${ii(s.w)};height:${ii(s.h)}`;function xi(s){r.getElementById(s)?.focus({preventScroll:!0})}function so(s){let w=to(o,n,s);if(y=!w,w)g=w;M="component",X=!1,R=!1,W="fit",ei="isolated",f=y?"reviewed":"pending",d(),xi(y?"review-summary":N[g]?"feedback":"approve")}function Hi(s){if(T||u||X)return;let w=o.components.find((Vi)=>Vi.id===g);if(!w)return;let Q=n.decisions[w.id],gi=Q?.revision===w.revision?Q:void 0;U={id:w.id,name:w.name,action:s,previous:Q?{...Q}:void 0};let si=N[w.id]??gi;if(n.decisions[w.id]={revision:w.revision,action:s,feedback:s==="revise"?si?.feedback??"":"",split:s==="revise"&&(si?.split??!1)},delete N[w.id],pi)V=!0,pi=!1;so(w.id)}function co(){if(T||u||X)return;let s=o.components.find((gi)=>gi.id===g);if(!s)return;let w=n.decisions[s.id],Q=w?.revision===s.revision?w:void 0;if(N[s.id]??={feedback:Q?.feedback??"",split:Q?.split??!1},V&&(r.querySelector(".workbench")?.clientHeight??0)<420)pi=!0,V=!1;y=!1,d(),xi("feedback")}function Ui(s){let w=`missing-${crypto.randomUUID()}`;n.missing.push({id:w,name:"Missing component",feedback:"",box:s}),n.inventoryConfirmed=!1,y=!1,g=w,M="component",G=!1,Z=null,A=null,d(),r.querySelector("#missing-name")?.focus()}function d(){ai?.disconnect();let s=r.activeElement,w=s?.id,Q=s?.dataset.select,gi=window.scrollX,si=window.scrollY,Vi=r.querySelector(".inventory")?.scrollLeft??0,ci=Mi===g,wo=M==="component"&&(!ci||Ti!==M);Ti=M;let mo=ci?r.querySelector(".inspection-content")?.scrollTop??0:0,bo=ci&&(r.querySelector(".changed-files")?.open??!1),vo=r.querySelector(".comparison-slot")?.clientHeight??200,Wi=r.querySelector(".pan-viewport"),Zi=Mi===g&&Xi===W,uo=Zi?Wi?.scrollLeft??0:0,yo=Zi?Wi?.scrollTop??0:0;Mi=g,Xi=W;let h=o.components.find((i)=>i.id===g),k=n.missing.find((i)=>i.id===g),Ai=h?.box??k?.box,Bi=h?o.components.indexOf(h)+1:o.components.length+n.missing.findIndex((i)=>i.id===g)+1,Ii=h?n.decisions[h.id]:void 0,B=Ii?.revision===h?.revision?Ii:void 0,Ei=h?N[h.id]:void 0,S=Object.keys(N).length>0,zo=h?!o.components.some((i)=>i.id!==h.id&&O(i,n).kind==="pending"):!1,Ji=U&&!u?`
${l(U.name)} ${U.action==="approve"?"approved":"flagged for repair"}.
`:"",m=li(o,n),H=a.history,$=h?ki(h.id,H):void 0,ri=H?.packet.components.find((i)=>i.id===h?.id),I=X&&!!ri,x=I?ri:h,Y=I?H.packet:o,Oi=Object.values(H?.changes??{}),Ri=Oi.filter((i)=>i.kind==="changed").length,Di=Oi.filter((i)=>i.kind==="added").length,Ni=o.components.filter((i)=>O(i,n,H).kind==="approved"&&ki(i.id,H).carried).length,wi=(i)=>{let e=O(i,n,H);return u&&e.kind==="feedback"?{...e,label:"Changes requested"}:e},ko=m.approved+m.revisions+n.missing.length,Si=(f==="pending"?c():o.components).filter((i)=>Yi(i,n,f)),jo=[m.revisions+n.missing.length?`${m.revisions+n.missing.length} feedback ready`:"",Ni?`${Ni} ${Ni===1?"approval":"approvals"} kept`:"",Ri?`${Ri} changed`:"",Di?`${Di} added`:"",H?.removed.length?`${H.removed.length} removed`:""].filter(Boolean).join(" · "),qo=qi||(S?"Save or cancel your open feedback before sending.":u?a.preview?"Preview submitted. No run changed.":"Review submitted.":m.hasFeedback?"Ready to send for corrections.":m.pending?`${m.pending} left to review`:!n.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),C=x?ji(x):null,Ci=x?.preview.kind==="image"&&!C?.code,F=!!(x?.context&&ei==="context"),Fi=x&&(F?x.context?.kind!=="image":x.preview.kind==="page"),Pi=F&&x?.context?x.context.url:x?.preview.url,Mo=C?.code?`${C.label} · ${C.captured?"captured from code":"live preview"}`:x?.material?`${x.material.alpha==="transparent"?"Transparent":x.material.alpha==="opaque"?"Opaque":"Transparency unverified"} ${x.material.format}`:"Raster · transparency unverified";r.innerHTML=`
+`;var Jo={chevronDown:'',code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function J(p){return``}var l=(p)=>p.replace(/[&<>"']/g,(o)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]),ii=(p)=>`${p*100}%`,oi=(p)=>{let o=new URL(p,location.href);if(!["http:","https:"].includes(o.protocol))throw Error("Unsupported preview URL");return l(o.href)};function fo(p,o,a){let r=p.attachShadow({mode:"open"}),n=structuredClone(a.initialDraft??ao(o)),c=()=>[...o.components].sort((s,w)=>D(s,n,a.history).priority-D(w,n,a.history).priority),g=c().find((s)=>D(s,n,a.history).kind!=="approved")?.id??o.components[0]?.id,f=li(o,n).pending?"pending":"reviewed",K=!1,Q=!1,u=a.completed??!1,qi="",N={},y=!!a.completed||!li(o,n).pending,T=null,so=/Mac|iPhone|iPad/.test(navigator.platform)?"⌘Enter":"Ctrl+Enter",S=!1,Y=!1,F=!1,V=!0,pi=!1,M="comp",Zi=M,A="fit",di="checker",W=!1,ei="isolated",Mi,Ui="fit",B=null,I=null,ai=null,hi='',fi='',Ti=(s)=>`left:${ii(s.x)};top:${ii(s.y)};width:${ii(s.w)};height:${ii(s.h)}`;function xi(s){r.getElementById(s)?.focus({preventScroll:!0})}function co(s){let w=lo(o,n,s);if(y=!w,w)g=w;M="component",W=!1,S=!1,A="fit",ei="isolated",f=y?"reviewed":"pending",d(),xi(y?"review-summary":N[g]?"feedback":"approve")}function Hi(s){if(Q||u||W)return;let w=o.components.find((Vi)=>Vi.id===g);if(!w)return;let Z=n.decisions[w.id],gi=Z?.revision===w.revision?Z:void 0;T={id:w.id,name:w.name,action:s,previous:Z?{...Z}:void 0};let si=N[w.id]??gi;if(n.decisions[w.id]={revision:w.revision,action:s,feedback:s==="revise"?si?.feedback??"":"",split:s==="revise"&&(si?.split??!1)},delete N[w.id],pi)V=!0,pi=!1;co(w.id)}function wo(){if(Q||u||W)return;let s=o.components.find((gi)=>gi.id===g);if(!s)return;let w=n.decisions[s.id],Z=w?.revision===s.revision?w:void 0;if(N[s.id]??={feedback:Z?.feedback??"",split:Z?.split??!1},V&&(r.querySelector(".workbench")?.clientHeight??0)<420)pi=!0,V=!1;y=!1,d(),xi("feedback")}function Ai(s){let w=`missing-${crypto.randomUUID()}`;n.missing.push({id:w,name:"Missing component",feedback:"",box:s}),n.inventoryConfirmed=!1,y=!1,g=w,M="component",K=!1,B=null,I=null,d(),r.querySelector("#missing-name")?.focus()}function d(){ai?.disconnect();let s=r.activeElement,w=s?.id,Z=s?.dataset.select,gi=window.scrollX,si=window.scrollY,Vi=r.querySelector(".inventory")?.scrollLeft??0,ci=Mi===g,mo=M==="component"&&(!ci||Zi!==M);Zi=M;let bo=ci?r.querySelector(".inspection-content")?.scrollTop??0:0,vo=ci&&(r.querySelector(".changed-files")?.open??!1),uo=r.querySelector(".comparison-slot")?.clientHeight??200,Bi=r.querySelector(".pan-viewport"),Ii=Mi===g&&Ui===A,yo=Ii?Bi?.scrollLeft??0:0,zo=Ii?Bi?.scrollTop??0:0;Mi=g,Ui=A;let h=o.components.find((i)=>i.id===g),k=n.missing.find((i)=>i.id===g),Oi=h?.box??k?.box,Ri=h?o.components.indexOf(h)+1:o.components.length+n.missing.findIndex((i)=>i.id===g)+1,Di=h?n.decisions[h.id]:void 0,O=Di?.revision===h?.revision?Di:void 0,Ei=h?N[h.id]:void 0,C=Object.keys(N).length>0,ko=h?!o.components.some((i)=>i.id!==h.id&&D(i,n).kind==="pending"):!1,Ji=T&&!u?`
${l(T.name)} ${T.action==="approve"?"approved":"flagged for repair"}.
`:"",m=li(o,n),H=a.history,$=h?ki(h.id,H):void 0,ri=H?.packet.components.find((i)=>i.id===h?.id),R=W&&!!ri,x=R?ri:h,G=R?H.packet:o,Si=Object.values(H?.changes??{}),Fi=Si.filter((i)=>i.kind==="changed").length,Ci=Si.filter((i)=>i.kind==="added").length,Ni=o.components.filter((i)=>D(i,n,H).kind==="approved"&&ki(i.id,H).carried).length,wi=(i)=>{let e=D(i,n,H);return u&&e.kind==="feedback"?{...e,label:"Changes requested"}:e},jo=m.approved+m.revisions+n.missing.length,Pi=(f==="pending"?c():o.components).filter((i)=>Xi(i,n,f)),qo=[m.revisions+n.missing.length?`${m.revisions+n.missing.length} feedback ready`:"",Ni?`${Ni} ${Ni===1?"approval":"approvals"} kept`:"",Fi?`${Fi} changed`:"",Ci?`${Ci} added`:"",H?.removed.length?`${H.removed.length} removed`:""].filter(Boolean).join(" · "),Mo=qi||(C?"Save or cancel your open feedback before sending.":u?a.preview?"Preview submitted. No run changed.":"Review submitted.":m.hasFeedback?"Ready to send for corrections.":m.pending?`${m.pending} left to review`:!n.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),U=x?ji(x):null,$i=x?.preview.kind==="image"&&!U?.code,Gi=$i||x?.material?.alpha==="transparent",_=!!(x?.context&&ei==="context"),io=x&&(_?x.context?.kind!=="image":x.preview.kind==="page"),Ki=_&&x?.context?x.context.url:x?.preview.url,Ho=U?.code?`${U.label} · ${U.captured?"captured from code":"live preview"}`:x?.material?`${x.material.alpha==="transparent"?"Transparent":x.material.alpha==="opaque"?"Opaque":"Transparency unverified"} ${x.material.format}`:"Raster · transparency unverified";r.innerHTML=`

${u?"Review record.":"Review the components."}

${l(o.title)} · Round ${o.round}

${u?'Submitted · read-only':a.preview?'Interactive preview':""}
${a.preview?'

Historical hotel artwork for testing this interface. Decisions stay in this preview; no run is changed.

':""} - ${H?`

${m.pending} ${m.pending===1?"component":"components"} to review${jo}

${m.pending?``:""}${H.removed.length?`
Removed from the map

${H.removed.map((i)=>l(i.name)).join(" · ")}. Confirm these omissions are intentional before accepting the map.

`:""}
`:""} -
+ ${H?`

${m.pending} ${m.pending===1?"component":"components"} to review${qo}

${m.pending?``:""}${H.removed.length?`
Removed from the map

${H.removed.map((i)=>l(i.name)).join(" · ")}. Confirm these omissions are intentional before accepting the map.

`:""}
`:""} +
-

Approved comp

${!u?``:""}
-
+

Approved comp

${!u?``:""}
+
Approved composition for ${l(o.title)} - ${Ai&&!y?`
`:""} + ${Oi&&!y?`
`:""} ${o.components.map((i,e)=>{let t=wi(i);return``}).join("")} ${n.missing.map((i,e)=>``).join("")}
# To review${fi} ${u?"Changes requested":"Feedback ready"}${hi} Approved
- ${G?'
Draw around the missing piece.
':""} + ${K?'
Draw around the missing piece.
':""}
-

${y?"Review summary":`${Bi} ${l(h?.name??k?.name??"Component")}`}

- ${y?`

${u?"Review sent.":"All components reviewed."}

${m.approved} approved · ${m.revisions} flagged for repair${n.missing.length?` · ${n.missing.length} missing`:""}

${u?"Your decisions are saved.":m.hasFeedback?"Send your feedback to start the next repair round.":"Confirm nothing is missing, then approve and continue."}

${o.components.map((i)=>{let e=n.decisions[i.id],t=wi(i);return``}).join("")}${n.missing.map((i)=>``).join("")}
${Ji?`
${Ji}
`:""}`:h?`
${J(C.code?"code":"image")}${l(Mo)}${x?.material?`${x.material.width} × ${x.material.height} px`:""}
+

${y?"Review summary":`${Ri} ${l(h?.name??k?.name??"Component")}`}

+ ${y?`

${u?"Review sent.":"All components reviewed."}

${m.approved} approved · ${m.revisions} flagged for repair${n.missing.length?` · ${n.missing.length} missing`:""}

${u?"Your decisions are saved.":m.hasFeedback?"Send your feedback to start the next repair round.":"Confirm nothing is missing, then approve and continue."}

${o.components.map((i)=>{let e=n.decisions[i.id],t=wi(i);return``}).join("")}${n.missing.map((i)=>``).join("")}
${Ji?`
${Ji}
`:""}`:h?`
${J(U.code?"code":"image")}${l(Ho)}${x?.material?`${x.material.width} × ${x.material.height} px`:""}
${H?`
${$?.prior?.action==="revise"?`

Previous feedback Round ${$.feedbackRound}

Needs work

${l($.prior.feedback||"No written feedback was supplied.")}
${$.prior.split?"

Requested: split into separately reviewable components.

":""}
`:$?.carried?'

Unchanged · approval kept

':""} - ${$?.change?.kind==="changed"?`
${$.change.files.length?`${$.change.files.length} changed ${$.change.files.length===1?"file":"files"}`:$.change.reasons.includes("region")?"Region changed":ri?.note!==h.note?"Description changed · files unchanged":"Component definition changed · files unchanged"}${$.change.files.length?`
    ${$.change.files.map((i)=>`
  • ${l(i)}
  • `).join("")}
`:""}${ri&&ri.note!==h.note?`
Previous description
${l(ri.note)}
Current description
${l(h.note)}
`:""}
`:""} + ${$?.change?.kind==="changed"?`
${$.change.files.length?`${$.change.files.length} changed ${$.change.files.length===1?"file":"files"}`:$.change.reasons.includes("region")?"Region changed":ri?.note!==h.note?"Description changed · files unchanged":"Component definition changed · files unchanged"}${$.change.files.length?`
    ${$.change.files.map((i)=>`
  • ${l(i)}
  • `).join("")}
`:""}${ri&&ri.note!==h.note?`
Previous description
${l(ri.note)}
Current description
${l(h.note)}
`:""}
`:""}
`:""} - ${ri?`
`:""} -

${l(x.name)}

${x?.preview.kind==="image"?`${J("external")}`:""}
+ ${ri?`
`:""} +

${l(x.name)}

${x?.preview.kind==="image"?`${J("external")}`:""}
-
${I?`Comp · Round ${H.packet.round}`:"In the comp"}
Reference region for ${l(x.name)}
-
${I?`Previous · Round ${H.packet.round}`:H?`Current · Round ${o.round}`:F?"In the page":C.caption}
${!Fi?`Produced ${l(x.name)}`:``}${R?`Reference overlay`:""}
+
${R?`Comp · Round ${H.packet.round}`:"In the comp"}
Reference region for ${l(x.name)}
+
${R?`Previous · Round ${H.packet.round}`:_?"In context":H?`${U.caption} · Round ${o.round}`:U.caption}
${!io?`Produced ${l(x.name)}`:``}${S?`Reference overlay`:""}
- ${Ci?`
${x.context?`
`:""}
`:""} -

${l(x?.context?.layering??"Layer placement not recorded.")}

+ ${Gi||x.context?`
${x.context?`
`:""}${Gi?`
`:""}
`:""} +
${G.stage==="components"&&U?.captured&&!x?.preview.isolation?'

Legacy region capture · may include overlapping components.

':""}${x?.context?.layering&&($i||_)?`

${l(x.context.layering)}

`:""}

${l(x.note)}

-
${Ji}${I?'

Viewing the previous round. Return to Current to make a decision.

':""}${u?`
${B?.action==="approve"?"Approved":B?.action==="revise"?"Changes requested":"Not reviewed"}Submitted in round ${o.round} · read-only
`:`
Your review Round ${o.round}${I?"

Return to Current to review this round.

":""}
${B?``:""}
`} - ${Ei?`
`:B?.action==="revise"?`

${l(B.feedback||"No note — agent will diagnose.")}

`:""} +
${Ji}${R?'

Viewing the previous round. Return to Current to make a decision.

':""}${u?`
${O?.action==="approve"?"Approved":O?.action==="revise"?"Changes requested":"Not reviewed"}Submitted in round ${o.round} · read-only
`:`
Your review Round ${o.round}${R?"

Return to Current to review this round.

":""}
${O?``:""}
`} + ${Ei?`
`:O?.action==="revise"?`

${l(O.feedback||"No note — agent will diagnose.")}

`:""}
`:k?`

This piece will be added to the unresolved inventory.

${["x","y","w","h"].map((i)=>``).join("")}
`:"

No components supplied.

"}
-

Components

-
${Si.map((i)=>{let e=o.components.indexOf(i),t=wi(i);return``}).join("")}${(f==="pending"?[]:n.missing).map((i,e)=>``).join("")}${!Si.length&&(f==="pending"||!n.missing.length)?`

${f==="pending"?"Nothing left to review. Your decisions are ready.":"No components reviewed yet."}

`:""}
- ${u?`
Round ${o.round} submitted · read-only${m.approved} approved · ${m.revisions} changes requested
`:`
${!m.pending&&!S?``:""}

${l(qo)}

`} -
`;let E=r.querySelector("#comparison-dialog"),_=r.querySelector(".comparison-panel"),ni=r.querySelector(".comparison-slot");if(K&&_&&ni)ni.style.height=`${vo}px`,E.append(_),E.showModal();if(I)r.querySelectorAll(".decisions button,#feedback,#split,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#submit,#inventory-confirm").forEach((i)=>i.disabled=!0);if(r.querySelector(".inspection-content").scrollTop=mo,u||T)r.querySelectorAll(".decisions button,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#mark,#inventory-confirm,#missing-name,#missing-feedback,#feedback,#split,#remove-missing,[data-coordinate]").forEach((i)=>i.disabled=!0);if(r.querySelector(".inventory").scrollLeft=Vi,!ci&&V)Array.from(r.querySelectorAll(".inventory [data-select]")).find((i)=>i.dataset.select===g)?.scrollIntoView({block:"nearest",inline:"nearest"});if(w)r.getElementById(w)?.focus({preventScroll:!0});else if(Q)Array.from(r.querySelectorAll(".item[data-select]")).find((i)=>i.dataset.select===Q)?.focus({preventScroll:!0});let v=(i,e)=>r.querySelector(`#${i}`)?.addEventListener("click",e);r.querySelectorAll("[data-select]").forEach((i)=>i.onclick=()=>{if(G)return;y=!1,g=i.dataset.select,M="component",R=!1,W="fit",ei="isolated",X=!1,d()}),v("previous-round",()=>{X=!0,d()}),v("current-round",()=>{X=!1,d()}),v("review-changes",()=>{let i=c().filter((z)=>wi(z).kind==="pending"),e=i.findIndex((z)=>z.id===g),t=i[(e+1)%i.length];if(t)y=!1,g=t.id,M="component",f="pending",X=!1,W="fit",R=!1,ei="isolated",d()}),r.querySelectorAll("[data-filter]").forEach((i)=>i.onclick=()=>{f=i.dataset.filter,y=!m.pending&&f==="pending";let e=c().filter((z)=>Yi(z,n,f));if(!(f!=="pending"&&n.missing.some((z)=>z.id===g))&&!e.some((z)=>z.id===g)&&e.length)g=e[0].id,M="component",X=!1,W="fit",R=!1,ei="isolated";d()}),v("show-summary",()=>{y=!0,M="component",f="reviewed",d(),xi("review-summary")}),v("approve",()=>Hi("approve")),v("revise",co),v("clear",()=>{if(h)delete n.decisions[h.id],delete N[h.id];U=null,y=!1,f="pending",d()}),v("cancel-feedback",()=>{if(h)delete N[h.id];if(pi)V=!0,pi=!1;d(),xi("revise")}),r.querySelector("#feedback-form")?.addEventListener("submit",(i)=>{i.preventDefault(),Hi("revise")}),r.querySelector("#feedback")?.addEventListener("keydown",(i)=>{let e=i;if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)&&!e.isComposing)e.preventDefault(),e.stopPropagation(),Hi("revise")}),v("undo-decision",()=>{if(!U||T||u)return;let i=U;if(i.previous)n.decisions[i.id]=i.previous;else delete n.decisions[i.id];delete N[i.id],g=i.id,y=!1,X=!1,M="component",f="all",U=null,d(),xi("approve")}),v("overlay",()=>{R=!R,d()}),v("isolated",()=>{ei="isolated",d()}),v("context",()=>{ei="context",d()}),r.querySelector("#zoom")?.addEventListener("change",(i)=>{let e=i.target.value;W=e==="fit"?"fit":Number(e),d()}),v("background-checker",()=>{di="checker",d()}),v("background-page",()=>{di="page",d()}),v("show-all",()=>{D=!D,V=!0,d()}),v("toggle-tray",()=>{pi=!1,V=!V,d()}),v("show-comp",()=>{M="comp",d()}),v("show-component",()=>{M="component",d()}),v("mark",()=>{G=!G,d()}),v("add-box",()=>Ui({x:0.35,y:0.35,w:0.2,h:0.2})),v("remove-missing",()=>{n.missing=n.missing.filter((i)=>i.id!==g),g=o.components[0]?.id,d()}),v("approve-rest",()=>{if(S)return;n=ao(o,n),U=null,y=!0,f="reviewed",M="component",d(),xi("review-summary")}),r.querySelector("#inventory-confirm")?.addEventListener("change",(i)=>{n.inventoryConfirmed=i.target.checked,d()}),r.querySelector("#feedback")?.addEventListener("input",(i)=>{if(h&&N[h.id])N[h.id].feedback=i.target.value}),r.querySelector("#split")?.addEventListener("change",(i)=>{if(h&&N[h.id])N[h.id].split=i.target.checked}),r.querySelector("#missing-name")?.addEventListener("input",(i)=>{if(k)k.name=i.target.value;let e=r.querySelector("#submit");if(e)e.disabled=S||T||u||!li(o,n).canSubmit}),r.querySelector("#missing-feedback")?.addEventListener("input",(i)=>{if(k)k.feedback=i.target.value}),r.querySelectorAll("[data-coordinate]").forEach((i)=>i.addEventListener("change",()=>{if(!k)return;let e=i.dataset.coordinate,t=Number(i.value)/100;if(Number.isFinite(t))k.box[e]=Math.max(e==="w"||e==="h"?0.001:0,Math.min(1,t));k.box.w=Math.min(k.box.w,1-k.box.x),k.box.h=Math.min(k.box.h,1-k.box.y),d()})),v("submit",async()=>{if(S||T||u)return;T=!0,qi="",d();try{await a.onSubmit(xo(o,n)),u=!0,y=!0,G=!1,f="reviewed"}catch(i){qi=i instanceof Error?i.message:"Could not save. Try again."}finally{T=!1,d()}});let P=r.querySelector(".map");function io(i){let e=P.getBoundingClientRect();return{x:Math.max(0,Math.min(1,(i.clientX-e.left)/e.width)),y:Math.max(0,Math.min(1,(i.clientY-e.top)/e.height))}}P.addEventListener("pointerdown",(i)=>{if(!G)return;Z=io(i),P.setPointerCapture(i.pointerId),i.preventDefault()}),P.addEventListener("pointermove",(i)=>{if(!Z)return;let e=io(i);A={x:Math.min(Z.x,e.x),y:Math.min(Z.y,e.y),w:Math.abs(e.x-Z.x),h:Math.abs(e.y-Z.y)};let t=r.querySelector(".draw-box");t.hidden=!1,t.style.cssText=Qi(A)}),P.addEventListener("pointerup",()=>{if(A&&A.w>0.01&&A.h>0.01)Ui(A);else Z=null,A=null}),P.addEventListener("pointercancel",()=>{Z=null,A=null,d()});let oo=r.querySelector(".output"),mi=r.querySelector("iframe"),eo=r.querySelector(".workbench"),ti=r.querySelector(".inspection-content"),$i=r.querySelector(".inspector");function Gi(){if(!ti||!$i)return;$i.dataset.scrollAbove=String(ti.scrollTop>1),$i.dataset.scrollBelow=String(ti.scrollHeight-ti.clientHeight-ti.scrollTop>1)}ti?.addEventListener("scroll",Gi,{passive:!0});function bi(){let i=r.querySelector(".map-space");if(i&&i.clientWidth&&i.clientHeight){let b=_i(o.comp.width,o.comp.height,Math.max(1,i.clientWidth-32),Math.max(1,i.clientHeight-32),"fit");P.style.width=`${b.width}px`,P.style.height=`${b.height}px`}let e=r.querySelector(".inspection-content"),t=Array.from(r.querySelectorAll(".pan-viewport"));if(e?.clientHeight){let b=K?Math.max(100,E.clientHeight-(_?.querySelector(".compare-toolbar")?.clientHeight??0)-(_?.querySelector(".expanded-title")?.clientHeight??0)-(_?.querySelector(".view-controls")?.clientHeight??0)-112):Math.min(248,Math.max(100,e.clientHeight-40));t.forEach((j)=>j.style.height=`${b}px`)}if(x&&t.length){let b=_i(x.box.w*Y.comp.width,x.box.h*Y.comp.height,Math.min(...t.map((j)=>j.clientWidth)),Math.min(...t.map((j)=>j.clientHeight)),W);r.querySelectorAll(".crop-stage").forEach((j)=>{j.style.width=`${b.width}px`,j.style.height=`${b.height}px`})}if(t.forEach((b)=>{let j=b.scrollWidth>b.clientWidth+1||b.scrollHeight>b.clientHeight+1;b.classList.toggle("pannable",j),b.title=j?"Move your pointer to pan. You can also scroll, swipe, or use arrow keys.":""}),oo&&mi&&x){let b=oo.clientWidth/(x.box.w*Y.comp.width);mi.style.transform=`scale(${b})`,mi.style.left=`${-x.box.x*Y.comp.width*b}px`,mi.style.top=`${-x.box.y*Y.comp.height*b}px`}let z=eo.getBoundingClientRect(),L=r.querySelector(".region"),ui=r.querySelector(".number"),yi=r.querySelector(".connector path");if(L&&ui&&yi){let b=L.getBoundingClientRect(),j=ui.getBoundingClientRect(),q=b.right-z.left,zi=b.top+b.height/2-z.top,no=j.left-z.left-8,Ho=j.top+j.height/2-z.top;yi.setAttribute("d",`M ${q} ${zi} H ${no-14} V ${Ho} H ${no}`)}}function Ki(i){if(!_||!ni||i===K)return;let e=r.querySelector("#expand-comparison"),t=(K?E:_).getBoundingClientRect(),z=window.matchMedia("(prefers-reduced-motion: reduce)").matches,L=_.querySelector(".pan-viewport"),ui=L?L.scrollLeft/Math.max(1,L.scrollWidth-L.clientWidth):0,yi=L?L.scrollTop/Math.max(1,L.scrollHeight-L.clientHeight):0,b=()=>_.querySelectorAll(".pan-viewport").forEach((q)=>{q.scrollLeft=ui*Math.max(0,q.scrollWidth-q.clientWidth),q.scrollTop=yi*Math.max(0,q.scrollHeight-q.clientHeight)}),j=()=>{ni.append(_),E.close(),ni.style.height="",K=!1,e.innerHTML=J("expand"),e.setAttribute("aria-label","Enlarge comparison"),e.title="Enlarge comparison",e.setAttribute("aria-expanded","false"),bi(),b(),e.focus({preventScroll:!0})};if(i){ni.style.height=`${t.height}px`,E.append(_),E.showModal(),K=!0,e.innerHTML=J("compact"),e.setAttribute("aria-label","Restore comparison"),e.title="Restore comparison (Esc)",e.setAttribute("aria-expanded","true"),bi(),b(),e.focus({preventScroll:!0});let q=E.getBoundingClientRect();if(!z)E.animate([{transform:`translate(${t.x-q.x}px,${t.y-q.y}px) scale(${t.width/q.width},${t.height/q.height})`,opacity:0.6},{transform:"none",opacity:1}],{duration:240,easing:"cubic-bezier(.2,.8,.2,1)"})}else{if(e.disabled)return;let q=ni.getBoundingClientRect();if(z){j();return}e.disabled=!0;let zi=E.animate([{transform:"none",opacity:1},{transform:`translate(${q.x-t.x}px,${q.y-t.y}px) scale(${q.width/t.width},${q.height/t.height})`,opacity:0.6}],{duration:200,easing:"cubic-bezier(.4,0,.2,1)",fill:"forwards"});zi.finished.then(()=>{if(E.isConnected)zi.cancel(),e.disabled=!1,j()}).catch(()=>{})}}v("expand-comparison",()=>Ki(!K)),E.addEventListener("cancel",(i)=>{i.preventDefault(),i.stopPropagation(),Ki(!1)}),E.addEventListener("keydown",(i)=>{if(i.key==="Escape")i.preventDefault(),i.stopPropagation(),Ki(!1)}),ai=new ResizeObserver(()=>{bi(),Gi()}),ai.observe(eo),ai.observe(E);let ro=r.querySelector(".inspection-content");if(ro)ai.observe(ro);bi();let vi=Array.from(r.querySelectorAll(".pan-viewport"));if(vi.forEach((i)=>{i.scrollLeft=uo,i.scrollTop=yo}),vi.forEach((i)=>{i.querySelectorAll("img").forEach((e)=>e.draggable=!1),i.addEventListener("pointermove",(e)=>{if(e.pointerType!=="mouse"||e.buttons||!i.classList.contains("pannable"))return;let t=i.getBoundingClientRect();i.scrollLeft=Li(e.clientX,t.left,i.clientWidth,i.scrollWidth),i.scrollTop=Li(e.clientY,t.top,i.clientHeight,i.scrollHeight)}),i.addEventListener("keydown",(e)=>{let z={ArrowLeft:[-48,0],ArrowRight:[48,0],ArrowUp:[0,-48],ArrowDown:[0,48]}[e.key];if(!z)return;e.preventDefault(),e.stopPropagation(),i.scrollLeft+=z[0],i.scrollTop+=z[1]})}),vi.forEach((i)=>i.addEventListener("scroll",()=>{for(let e of vi)if(e!==i){if(e.scrollLeft!==i.scrollLeft)e.scrollLeft=i.scrollLeft;if(e.scrollTop!==i.scrollTop)e.scrollTop=i.scrollTop}})),wo&&window.matchMedia("(max-width:800px)").matches){let i=r.querySelector(".inspection-content"),e=r.querySelector(".compare");if(i&&e)i.scrollTop+=e.getBoundingClientRect().top-i.getBoundingClientRect().top}if(Gi(),window.scrollTo(gi,si),!u)a.onDraftChange?.(structuredClone(n))}return d(),{destroy(){ai?.disconnect(),r.replaceChildren()},getDraft(){return structuredClone(n)}}}async function Jo(){let p=document.getElementById("review"),o=await fetch("/packet",{cache:"no-store"});if(!o.ok)throw Error("The review packet could not be loaded. Reload to retry.");let a=await o.json();if(a.sourceStatus){let r=document.createElement("p");r.textContent=a.sourceStatus,p.before(r)}go(p,a.packet,{initialDraft:a.draft,history:a.history,completed:!!a.receipt,onSubmit:async(r)=>{let n=await fetch("/decision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),c=await n.json();if(!n.ok)throw Error(c.error??"The review could not be saved. Try again.")}})}Jo().catch((p)=>{let o=document.createElement("p");o.textContent=p instanceof Error?p.message:String(p),document.getElementById("review")?.replaceChildren(o)});})(); +

Components

+
${Pi.map((i)=>{let e=o.components.indexOf(i),t=wi(i);return``}).join("")}${(f==="pending"?[]:n.missing).map((i,e)=>``).join("")}${!Pi.length&&(f==="pending"||!n.missing.length)?`

${f==="pending"?"Nothing left to review. Your decisions are ready.":"No components reviewed yet."}

`:""}
+ ${u?`
Round ${o.round} submitted · read-only${m.approved} approved · ${m.revisions} changes requested
`:`
${!m.pending&&!C?``:""}

${l(Mo)}

`} + `;let E=r.querySelector("#comparison-dialog"),L=r.querySelector(".comparison-panel"),ni=r.querySelector(".comparison-slot");if(Y&&L&&ni)ni.style.height=`${uo}px`,E.append(L),E.showModal();if(R)r.querySelectorAll(".decisions button,#feedback,#split,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#submit,#inventory-confirm").forEach((i)=>i.disabled=!0);if(r.querySelector(".inspection-content").scrollTop=bo,u||Q)r.querySelectorAll(".decisions button,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#mark,#inventory-confirm,#missing-name,#missing-feedback,#feedback,#split,#remove-missing,[data-coordinate]").forEach((i)=>i.disabled=!0);if(r.querySelector(".inventory").scrollLeft=Vi,!ci&&V)Array.from(r.querySelectorAll(".inventory [data-select]")).find((i)=>i.dataset.select===g)?.scrollIntoView({block:"nearest",inline:"nearest"});if(w)r.getElementById(w)?.focus({preventScroll:!0});else if(Z)Array.from(r.querySelectorAll(".item[data-select]")).find((i)=>i.dataset.select===Z)?.focus({preventScroll:!0});let v=(i,e)=>r.querySelector(`#${i}`)?.addEventListener("click",e);r.querySelectorAll("[data-select]").forEach((i)=>i.onclick=()=>{if(K)return;y=!1,g=i.dataset.select,M="component",S=!1,A="fit",ei="isolated",W=!1,d()}),v("previous-round",()=>{W=!0,d()}),v("current-round",()=>{W=!1,d()}),v("review-changes",()=>{let i=c().filter((z)=>wi(z).kind==="pending"),e=i.findIndex((z)=>z.id===g),t=i[(e+1)%i.length];if(t)y=!1,g=t.id,M="component",f="pending",W=!1,A="fit",S=!1,ei="isolated",d()}),r.querySelectorAll("[data-filter]").forEach((i)=>i.onclick=()=>{f=i.dataset.filter,y=!m.pending&&f==="pending";let e=c().filter((z)=>Xi(z,n,f));if(!(f!=="pending"&&n.missing.some((z)=>z.id===g))&&!e.some((z)=>z.id===g)&&e.length)g=e[0].id,M="component",W=!1,A="fit",S=!1,ei="isolated";d()}),v("show-summary",()=>{y=!0,M="component",f="reviewed",d(),xi("review-summary")}),v("approve",()=>Hi("approve")),v("revise",wo),v("clear",()=>{if(h)delete n.decisions[h.id],delete N[h.id];T=null,y=!1,f="pending",d()}),v("cancel-feedback",()=>{if(h)delete N[h.id];if(pi)V=!0,pi=!1;d(),xi("revise")}),r.querySelector("#feedback-form")?.addEventListener("submit",(i)=>{i.preventDefault(),Hi("revise")}),r.querySelector("#feedback")?.addEventListener("keydown",(i)=>{let e=i;if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)&&!e.isComposing)e.preventDefault(),e.stopPropagation(),Hi("revise")}),v("undo-decision",()=>{if(!T||Q||u)return;let i=T;if(i.previous)n.decisions[i.id]=i.previous;else delete n.decisions[i.id];delete N[i.id],g=i.id,y=!1,W=!1,M="component",f="all",T=null,d(),xi("approve")}),v("overlay",()=>{S=!S,d()}),v("isolated",()=>{ei="isolated",d()}),v("context",()=>{ei="context",d()}),r.querySelector("#zoom")?.addEventListener("change",(i)=>{let e=i.target.value;A=e==="fit"?"fit":Number(e),d()}),v("background-checker",()=>{di="checker",d()}),v("background-page",()=>{di="page",d()}),v("show-all",()=>{F=!F,V=!0,d()}),v("toggle-tray",()=>{pi=!1,V=!V,d()}),v("show-comp",()=>{M="comp",d()}),v("show-component",()=>{M="component",d()}),v("mark",()=>{K=!K,d()}),v("add-box",()=>Ai({x:0.35,y:0.35,w:0.2,h:0.2})),v("remove-missing",()=>{n.missing=n.missing.filter((i)=>i.id!==g),g=o.components[0]?.id,d()}),v("approve-rest",()=>{if(C)return;n=xo(o,n),T=null,y=!0,f="reviewed",M="component",d(),xi("review-summary")}),r.querySelector("#inventory-confirm")?.addEventListener("change",(i)=>{n.inventoryConfirmed=i.target.checked,d()}),r.querySelector("#feedback")?.addEventListener("input",(i)=>{if(h&&N[h.id])N[h.id].feedback=i.target.value}),r.querySelector("#split")?.addEventListener("change",(i)=>{if(h&&N[h.id])N[h.id].split=i.target.checked}),r.querySelector("#missing-name")?.addEventListener("input",(i)=>{if(k)k.name=i.target.value;let e=r.querySelector("#submit");if(e)e.disabled=C||Q||u||!li(o,n).canSubmit}),r.querySelector("#missing-feedback")?.addEventListener("input",(i)=>{if(k)k.feedback=i.target.value}),r.querySelectorAll("[data-coordinate]").forEach((i)=>i.addEventListener("change",()=>{if(!k)return;let e=i.dataset.coordinate,t=Number(i.value)/100;if(Number.isFinite(t))k.box[e]=Math.max(e==="w"||e==="h"?0.001:0,Math.min(1,t));k.box.w=Math.min(k.box.w,1-k.box.x),k.box.h=Math.min(k.box.h,1-k.box.y),d()})),v("submit",async()=>{if(C||Q||u)return;Q=!0,qi="",d();try{await a.onSubmit(to(o,n)),u=!0,y=!0,K=!1,f="reviewed"}catch(i){qi=i instanceof Error?i.message:"Could not save. Try again."}finally{Q=!1,d()}});let P=r.querySelector(".map");function oo(i){let e=P.getBoundingClientRect();return{x:Math.max(0,Math.min(1,(i.clientX-e.left)/e.width)),y:Math.max(0,Math.min(1,(i.clientY-e.top)/e.height))}}P.addEventListener("pointerdown",(i)=>{if(!K)return;B=oo(i),P.setPointerCapture(i.pointerId),i.preventDefault()}),P.addEventListener("pointermove",(i)=>{if(!B)return;let e=oo(i);I={x:Math.min(B.x,e.x),y:Math.min(B.y,e.y),w:Math.abs(e.x-B.x),h:Math.abs(e.y-B.y)};let t=r.querySelector(".draw-box");t.hidden=!1,t.style.cssText=Ti(I)}),P.addEventListener("pointerup",()=>{if(I&&I.w>0.01&&I.h>0.01)Ai(I);else B=null,I=null}),P.addEventListener("pointercancel",()=>{B=null,I=null,d()});let eo=r.querySelector(".output"),mi=r.querySelector("iframe"),ro=r.querySelector(".workbench"),ti=r.querySelector(".inspection-content"),Yi=r.querySelector(".inspector");function _i(){if(!ti||!Yi)return;Yi.dataset.scrollAbove=String(ti.scrollTop>1),Yi.dataset.scrollBelow=String(ti.scrollHeight-ti.clientHeight-ti.scrollTop>1)}ti?.addEventListener("scroll",_i,{passive:!0});function bi(){let i=r.querySelector(".map-space");if(i&&i.clientWidth&&i.clientHeight){let b=Qi(o.comp.width,o.comp.height,Math.max(1,i.clientWidth-32),Math.max(1,i.clientHeight-32),"fit");P.style.width=`${b.width}px`,P.style.height=`${b.height}px`}let e=r.querySelector(".inspection-content"),t=Array.from(r.querySelectorAll(".pan-viewport"));if(e?.clientHeight){let b=Y?Math.max(100,E.clientHeight-(L?.querySelector(".compare-toolbar")?.clientHeight??0)-(L?.querySelector(".expanded-title")?.clientHeight??0)-(L?.querySelector(".view-controls")?.clientHeight??0)-112):Math.min(248,Math.max(100,e.clientHeight-40));t.forEach((j)=>j.style.height=`${b}px`)}if(x&&t.length){let b=Qi(x.box.w*G.comp.width,x.box.h*G.comp.height,Math.min(...t.map((j)=>j.clientWidth)),Math.min(...t.map((j)=>j.clientHeight)),A);r.querySelectorAll(".crop-stage").forEach((j)=>{j.style.width=`${b.width}px`,j.style.height=`${b.height}px`})}if(t.forEach((b)=>{let j=b.scrollWidth>b.clientWidth+1||b.scrollHeight>b.clientHeight+1;b.classList.toggle("pannable",j),b.title=j?"Move your pointer to pan. You can also scroll, swipe, or use arrow keys.":""}),eo&&mi&&x){let b=eo.clientWidth/(x.box.w*G.comp.width);mi.style.transform=`scale(${b})`,mi.style.left=`${-x.box.x*G.comp.width*b}px`,mi.style.top=`${-x.box.y*G.comp.height*b}px`}let z=ro.getBoundingClientRect(),X=r.querySelector(".region"),ui=r.querySelector(".number"),yi=r.querySelector(".connector path");if(X&&ui&&yi){let b=X.getBoundingClientRect(),j=ui.getBoundingClientRect(),q=b.right-z.left,zi=b.top+b.height/2-z.top,po=j.left-z.left-8,Vo=j.top+j.height/2-z.top;yi.setAttribute("d",`M ${q} ${zi} H ${po-14} V ${Vo} H ${po}`)}}function Li(i){if(!L||!ni||i===Y)return;let e=r.querySelector("#expand-comparison"),t=(Y?E:L).getBoundingClientRect(),z=window.matchMedia("(prefers-reduced-motion: reduce)").matches,X=L.querySelector(".pan-viewport"),ui=X?X.scrollLeft/Math.max(1,X.scrollWidth-X.clientWidth):0,yi=X?X.scrollTop/Math.max(1,X.scrollHeight-X.clientHeight):0,b=()=>L.querySelectorAll(".pan-viewport").forEach((q)=>{q.scrollLeft=ui*Math.max(0,q.scrollWidth-q.clientWidth),q.scrollTop=yi*Math.max(0,q.scrollHeight-q.clientHeight)}),j=()=>{ni.append(L),E.close(),ni.style.height="",Y=!1,e.innerHTML=J("expand"),e.setAttribute("aria-label","Enlarge comparison"),e.title="Enlarge comparison",e.setAttribute("aria-expanded","false"),bi(),b(),e.focus({preventScroll:!0})};if(i){ni.style.height=`${t.height}px`,E.append(L),E.showModal(),Y=!0,e.innerHTML=J("compact"),e.setAttribute("aria-label","Restore comparison"),e.title="Restore comparison (Esc)",e.setAttribute("aria-expanded","true"),bi(),b(),e.focus({preventScroll:!0});let q=E.getBoundingClientRect();if(!z)E.animate([{transform:`translate(${t.x-q.x}px,${t.y-q.y}px) scale(${t.width/q.width},${t.height/q.height})`,opacity:0.6},{transform:"none",opacity:1}],{duration:240,easing:"cubic-bezier(.2,.8,.2,1)"})}else{if(e.disabled)return;let q=ni.getBoundingClientRect();if(z){j();return}e.disabled=!0;let zi=E.animate([{transform:"none",opacity:1},{transform:`translate(${q.x-t.x}px,${q.y-t.y}px) scale(${q.width/t.width},${q.height/t.height})`,opacity:0.6}],{duration:200,easing:"cubic-bezier(.4,0,.2,1)",fill:"forwards"});zi.finished.then(()=>{if(E.isConnected)zi.cancel(),e.disabled=!1,j()}).catch(()=>{})}}v("expand-comparison",()=>Li(!Y)),E.addEventListener("cancel",(i)=>{i.preventDefault(),i.stopPropagation(),Li(!1)}),E.addEventListener("keydown",(i)=>{if(i.key==="Escape")i.preventDefault(),i.stopPropagation(),Li(!1)}),ai=new ResizeObserver(()=>{bi(),_i()}),ai.observe(ro),ai.observe(E);let no=r.querySelector(".inspection-content");if(no)ai.observe(no);bi();let vi=Array.from(r.querySelectorAll(".pan-viewport"));if(vi.forEach((i)=>{i.scrollLeft=yo,i.scrollTop=zo}),vi.forEach((i)=>{i.querySelectorAll("img").forEach((e)=>e.draggable=!1),i.addEventListener("pointermove",(e)=>{if(e.pointerType!=="mouse"||e.buttons||!i.classList.contains("pannable"))return;let t=i.getBoundingClientRect();i.scrollLeft=Wi(e.clientX,t.left,i.clientWidth,i.scrollWidth),i.scrollTop=Wi(e.clientY,t.top,i.clientHeight,i.scrollHeight)}),i.addEventListener("keydown",(e)=>{let z={ArrowLeft:[-48,0],ArrowRight:[48,0],ArrowUp:[0,-48],ArrowDown:[0,48]}[e.key];if(!z)return;e.preventDefault(),e.stopPropagation(),i.scrollLeft+=z[0],i.scrollTop+=z[1]})}),vi.forEach((i)=>i.addEventListener("scroll",()=>{for(let e of vi)if(e!==i){if(e.scrollLeft!==i.scrollLeft)e.scrollLeft=i.scrollLeft;if(e.scrollTop!==i.scrollTop)e.scrollTop=i.scrollTop}})),mo&&window.matchMedia("(max-width:800px)").matches){let i=r.querySelector(".inspection-content"),e=r.querySelector(".compare");if(i&&e)i.scrollTop+=e.getBoundingClientRect().top-i.getBoundingClientRect().top}if(_i(),window.scrollTo(gi,si),!u)a.onDraftChange?.(structuredClone(n))}return d(),{destroy(){ai?.disconnect(),r.replaceChildren()},getDraft(){return structuredClone(n)}}}async function No(){let p=document.getElementById("review"),o=await fetch("/packet",{cache:"no-store"});if(!o.ok)throw Error("The review packet could not be loaded. Reload to retry.");let a=await o.json();if(a.sourceStatus){let r=document.createElement("p");r.textContent=a.sourceStatus,p.before(r)}fo(p,a.packet,{initialDraft:a.draft,history:a.history,completed:!!a.receipt,onSubmit:async(r)=>{let n=await fetch("/decision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),c=await n.json();if(!n.ok)throw Error(c.error??"The review could not be saved. Try again.")}})}No().catch((p)=>{let o=document.createElement("p");o.textContent=p instanceof Error?p.message:String(p),document.getElementById("review")?.replaceChildren(o)});})(); diff --git a/crates/context/src/component_review/manifest.rs b/crates/context/src/component_review/manifest.rs index b82731e2b..fc5329e5c 100644 --- a/crates/context/src/component_review/manifest.rs +++ b/crates/context/src/component_review/manifest.rs @@ -85,8 +85,11 @@ fn view( pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap>), 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> = 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 Result<(Value, BTreeMap 256 * 1024 * 1024 { diff --git a/crates/context/src/component_review/tests.rs b/crates/context/src/component_review/tests.rs index dbf4b0bd8..3a22ce86c 100644 --- a/crates/context/src/component_review/tests.rs +++ b/crates/context/src/component_review/tests.rs @@ -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()); +} diff --git a/skill/reference/component-review.md b/skill/reference/component-review.md index d410e0ecd..853bfff3a 100644 --- a/skill/reference/component-review.md +++ b/skill/reference/component-review.md @@ -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 ` 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 diff --git a/ui/component-review/model.test.ts b/ui/component-review/model.test.ts index 5e7b6c619..1d9b4f39a 100644 --- a/ui/component-review/model.test.ts +++ b/ui/component-review/model.test.ts @@ -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'); }); diff --git a/ui/component-review/model.ts b/ui/component-review/model.ts index a3760c6de..2919ece81 100644 --- a/ui/component-review/model.ts +++ b/ui/component-review/model.ts @@ -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', }; } diff --git a/ui/component-review/review.ts b/ui/component-review/review.ts index d5705a2d4..dec005eba 100644 --- a/ui/component-review/review.ts +++ b/ui/component-review/review.ts @@ -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'?`
${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'}${repair.change.files.length?`
    ${repair.change.files.map(path=>`
  • ${esc(path)}
  • `).join('')}
`:''}${priorComponent&&priorComponent.note!==c.note?`
Previous description
${esc(priorComponent.note)}
Current description
${esc(c.note)}
`:''}
`:''} `:''} ${priorComponent?`
`:''} -

${esc(v!.name)}

${v?.preview.kind==='image'?`${icon('external')}`:''}
+

${esc(v!.name)}

${v?.preview.kind==='image'?`${icon('external')}`:''}
${viewingPrevious ? `Comp · Round ${history!.packet.round}` : 'In the comp'}
Reference region for ${esc(v!.name)}
-
${viewingPrevious ? `Previous · Round ${history!.packet.round}` : history ? `Current · Round ${packet.round}` : useContext ? 'In the page' : presentation!.caption}
${!useFrame ? `Produced ${esc(v!.name)}` : ``}${overlay ? `Reference overlay` : ''}
+
${viewingPrevious ? `Previous · Round ${history!.packet.round}` : useContext ? 'In context' : history ? `${presentation!.caption} · Round ${packet.round}` : presentation!.caption}
${!useFrame ? `Produced ${esc(v!.name)}` : ``}${overlay ? `Reference overlay` : ''}
- ${isRaster ? `
${v!.context ? `
` : ''}
` : ''} -

${esc(v?.context?.layering ?? 'Layer placement not recorded.')}

+ ${hasTransparency || v!.context ? `
${v!.context ? `
` : ''}${hasTransparency?`
`:''}
` : ''} +
${vp.stage==='components'&&presentation?.captured&&!v?.preview.isolation?'

Legacy region capture · may include overlapping components.

':''}${v?.context?.layering&&(isRaster||useContext)?`

${esc(v.context.layering)}

`:''}

${esc(v!.note)}

${notice}${viewingPrevious?'

Viewing the previous round. Return to Current to make a decision.

':''}${submitted?`
${d?.action==='approve'?'Approved':d?.action==='revise'?'Changes requested':'Not reviewed'}Submitted in round ${packet.round} · read-only
`:`
Your review Round ${packet.round}${viewingPrevious?'

Return to Current to review this round.

':''}
${d ? `` : ''}
`} ${edit ? `
` : d?.action==='revise' ? `

${esc(d.feedback || 'No note — agent will diagnose.')}

` : ''}