From d1601745700667f62f5342d643bcde3051c78277 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Thu, 17 Sep 2026 12:18:03 -0700 Subject: [PATCH] Fix review crop integrity and support repeated component decisions --- crates/cli/src/component_capture.rs | 19 ++++- crates/cli/src/component_isolation.js | 25 ++++++- crates/comp-verbs/src/comp_spec.rs | 37 +++++++++- crates/context/assets/component-review.js | 74 ++++++++++--------- .../context/src/component_review/manifest.rs | 14 ++++ crates/context/src/component_review/tests.rs | 18 +++++ skill/reference/component-review.md | 10 ++- skill/reference/new-work.md | 2 +- ui/component-review/model.test.ts | 14 ++++ ui/component-review/model.ts | 12 +++ ui/component-review/review.ts | 25 ++++--- ui/component-review/styles.ts | 6 ++ ui/component-review/viewport.test.ts | 5 ++ ui/component-review/viewport.ts | 2 +- 14 files changed, 209 insertions(+), 54 deletions(-) diff --git a/crates/cli/src/component_capture.rs b/crates/cli/src/component_capture.rs index 82ad7a6b0..388b995bb 100644 --- a/crates/cli/src/component_capture.rs +++ b/crates/cli/src/component_capture.rs @@ -97,10 +97,10 @@ fn render_page( coords[3] * height as f64, ]; if let Some(isolated) = &isolated { - let b = &isolated["bounds"]; + let b = &isolated["paintBounds"]; 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()); + if x < clip[0] - 1. || y < clip[1] - 1. || x + w > clip[0] + clip[2] + 1. || y + h > clip[1] + clip[3] + 1. { + return Err(format!("{}: review crop clips component content. Measured crop [{:.1}, {:.1}, {:.1}, {:.1}], visible content [{x:.1}, {y:.1}, {w:.1}, {h:.1}]. Check the reference region and component layout before asking the user to review it.", isolated["selector"].as_str().unwrap_or("component"), clip[0], clip[1], clip[2], clip[3])); } } let first = page @@ -390,6 +390,19 @@ mod tests { assert_eq!(impeccable_comp::png_io::decode_png(&crop).unwrap().image.data,pixels.data); } #[test] + #[ignore = "requires Chromium"] + fn isolated_capture_refuses_overflow_clipped_by_review_box() { + let image=impeccable_comp::raster::create_image(200,100,[255,255,255,255]); + let reference=impeccable_comp::png_io::encode_png(&image,&[]).unwrap(); + let html=br#""#; + let inputs=BTreeMap::from([("comp.png".into(),reference),("kit.html".into(),html.to_vec())]); + let original=json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":200,"height":100},"components":[{"id":"nav","box":{"x":0,"y":0,"w":0.3,"h":0.4},"preview":{"kind":"page","url":"/files/kit.html","selector":"#nav"},"dependencies":[]}]}); + let error=NativeComponentCapturer.capture(&mut original.clone(),&inputs).err().unwrap(); + assert!(error.contains("review crop clips component content"),"{error}"); + let mut valid=original;valid["components"][0]["box"]["w"]=json!(0.75); + NativeComponentCapturer.capture(&mut valid,&inputs).unwrap(); + } + #[test] fn component_crops_copy_verified_pixels_without_resizing_or_synthetic_edges() { let image = impeccable_comp::raster::Image {width:3,height:2,data:(0u8..24).collect()}; let png = impeccable_comp::png_io::encode_png(&image, &[]).unwrap(); diff --git a/crates/cli/src/component_isolation.js b/crates/cli/src/component_isolation.js index f5bea8dc6..b45160ec4 100644 --- a/crates/cli/src/component_isolation.js +++ b/crates/cli/src/component_isolation.js @@ -27,6 +27,28 @@ main: owns ? computed.visibility : 'hidden', pseudo: ['::before','::after','::marker'].map(pseudo => owns ? getComputedStyle(element, pseudo).visibility : 'hidden') }; }); + // Record visible layout/text extents before isolation. A small wrapper can + // have overflowing children; its border box alone would certify a clipped crop. + let paint = {left:rect.left,top:rect.top,right:rect.right,bottom:rect.bottom}; + function include(bounds, element) { + let b = {left:bounds.left,top:bounds.top,right:bounds.right,bottom:bounds.bottom}; + for(let parent=element;parent;parent=parent.parentElement){ + const style=getComputedStyle(parent), clip=parent.getBoundingClientRect(); + if(/hidden|clip|scroll|auto/.test(style.overflowX)){b.left=Math.max(b.left,clip.left);b.right=Math.min(b.right,clip.right);} + if(/hidden|clip|scroll|auto/.test(style.overflowY)){b.top=Math.max(b.top,clip.top);b.bottom=Math.min(b.bottom,clip.bottom);} + } + if(b.right<=b.left||b.bottom<=b.top)return; + paint={left:Math.min(paint.left,b.left),top:Math.min(paint.top,b.top),right:Math.max(paint.right,b.right),bottom:Math.max(paint.bottom,b.bottom)}; + } + elements.forEach((element,index)=>{ + if(!visibility[index].owns || visibility[index].main!=='visible')return; + include(element.getBoundingClientRect(),element.parentElement); + for(const node of element.childNodes){ + if(node.nodeType!==Node.TEXT_NODE||!node.textContent.trim())continue; + const range=document.createRange();range.selectNodeContents(node); + for(const bounds of range.getClientRects())include(bounds,element); + } + }); // 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.'); @@ -55,5 +77,6 @@ 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 } }; + bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + paintBounds: {x:paint.left,y:paint.top,width:paint.right-paint.left,height:paint.bottom-paint.top} }; } diff --git a/crates/comp-verbs/src/comp_spec.rs b/crates/comp-verbs/src/comp_spec.rs index 0a4700471..b960dc717 100644 --- a/crates/comp-verbs/src/comp_spec.rs +++ b/crates/comp-verbs/src/comp_spec.rs @@ -339,6 +339,29 @@ pub fn snap_box_to_ink(comp: &Image, boxf: (f64, f64, f64, f64), ground: f64) -> )) } +/// Automatic narrowing must not discard separate words/lines from a compound +/// control. The original largest-cluster helper remains available to explicit +/// callers; region measurement uses this conservative wrapper. +fn snap_preserving_ink(comp: &Image, boxf: (f64, f64, f64, f64), ground: f64) -> Option<(f64, f64, f64, f64)> { + let snapped = snap_box_to_ink(comp, boxf, ground)?; + let original = r::clamp_rect(comp, boxf.0 * comp.width as f64, boxf.1 * comp.height as f64, + boxf.2 * comp.width as f64, boxf.3 * comp.height as f64); + let keep = r::clamp_rect(comp, snapped.0 * comp.width as f64, snapped.1 * comp.height as f64, + snapped.2 * comp.width as f64, snapped.3 * comp.height as f64); + let (mut total, mut lost) = (0u64, 0u64); + for y in original.y..original.y + original.h { + for x in original.x..original.x + original.w { + if (gray_no_alpha(&comp.data, (y * comp.width + x) * 4) - ground).abs() > 60. { + total += 1; + if x < keep.x || x >= keep.x + keep.w || y < keep.y || y >= keep.y + keep.h { lost += 1; } + } + } + } + // At most incidental noise may disappear. Preserve the supplied span when + // the algorithm cannot distinguish a second label from unrelated content. + (lost * 100 <= total * 5).then_some(snapped) +} + /// JS: uncoveredInkCells(comp, regions). fn uncovered_ink_cells(comp: &Image, regions: &[Value]) -> Vec { let grid = m::detail_grid(comp, 10, 10, 512); @@ -453,7 +476,7 @@ pub fn measure_regions(comp: &Image, regions_input: &Value, comp_path: &str) -> let grid_str = raw.get("grid").and_then(Value::as_str); let snap_not_false = raw.get("snap").and_then(Value::as_bool) != Some(false); if !has_box && grid_str.is_some() && (kind == "text" || kind == "control") && snap_not_false { - if let Some(snapped) = snap_box_to_ink(comp, boxf, page_ground) { + if let Some(snapped) = snap_preserving_ink(comp, boxf, page_ground) { cover_box = Some(boxf); boxf = snapped; } @@ -1039,6 +1062,18 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { mod reference_tests { use super::*; + #[test] + fn automatic_snap_preserves_separated_navigation_and_multiline_copy() { + let mut comp = r::create_image(300, 100, [255,255,255,255]); + r::fill_rect(&mut comp, 25., 30., 70., 12., [0.,0.,0.,255.]); + r::fill_rect(&mut comp, 185., 30., 45., 12., [0.,0.,0.,255.]); + assert!(snap_box_to_ink(&comp, (0.,0.,1.,1.), 255.).is_some()); + assert!(snap_preserving_ink(&comp, (0.,0.,1.,1.), 255.).is_none()); + let mut single = r::create_image(300, 100, [255,255,255,255]); + r::fill_rect(&mut single, 25., 30., 70., 12., [0.,0.,0.,255.]); + assert!(snap_preserving_ink(&single, (0.,0.,1.,1.), 255.).is_some()); + } + fn fixture() -> (Image, Value) { let mut comp = r::create_image(16, 16, [230, 220, 210, 255]); r::fill_rect(&mut comp, 4., 4., 8., 8., [30., 70., 110., 255.]); diff --git a/crates/context/assets/component-review.js b/crates/context/assets/component-review.js index 024aaa79d..273c12294 100644 --- a/crates/context/assets/component-review.js +++ b/crates/context/assets/component-review.js @@ -1,4 +1,4 @@ -(()=>{function $i(p,o){let x=o?.changes[p],e=o?.feedback?.[p],n=e?.decision??(o?.submitted?o.draft.decisions[p]:void 0),l=x?.kind==="unchanged"&&(x.carried??(o?.submitted&&n?.action==="approve"))===!0;return{change:x,prior:n,feedbackRound:e?.round??o?.packet.round,carried:l,label:x?.kind==="added"?"New component":x?.kind==="changed"?"Review again":l?"Approval kept":n?.action==="revise"?"Changes still requested":"Awaiting review"}}function C(p,o,x){let e=o.decisions[p.id],n=e?.revision===p.revision?e:void 0,l=$i(p.id,x);if(n?.action==="approve")return{kind:"approved",label:l.carried?"Approval kept":"Approved",priority:3};if(n?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:l.change?.kind==="changed"?"Review again":l.change?.kind==="added"?"New · review needed":"Not reviewed",priority:l.change?.kind==="changed"||l.change?.kind==="added"?0:1}}function bo(p){return{packetRevision:p.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function Zo(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 gi(p,o){let x=p.components.map((h)=>o.decisions[h.id]?.revision===h.revision?o.decisions[h.id]:void 0),e=x.filter((h)=>h?.action==="approve").length,n=x.filter((h)=>h?.action==="revise").length,l=x.length-e-n,ei=n>0||o.missing.length>0;return{approved:e,revisions:n,pending:l,hasFeedback:ei,canSubmit:o.packetRevision===p.revision&&o.missing.every((h)=>h.name.trim()&&Zo(h.box))&&(ei||!l&&o.inventoryConfirmed)}}function vo(p,o){let x={...o.decisions};for(let e of p.components)if(!x[e.id]||x[e.id].revision!==e.revision)x[e.id]={revision:e.revision,action:"approve",feedback:"",split:!1};return{...o,decisions:x}}function uo(p,o){if(!gi(p,o).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:p.id,...structuredClone(o)}}function Gi(p){let o=p.preview.kind==="page"||p.preview.sourceKind==="page",x=p.preview.sourceKind==="page";return{code:o,captured:x,label:o?p.medium.match(/html|css|svg/i)?p.medium:"HTML / CSS / SVG":"Raster",caption:o?x?p.preview.isolation?"Component only":"Region capture":"Live component":"Produced asset",fileLabel:x?"Open captured preview":"Open source image"}}function yo(p,o,x){let e=p.components.findIndex((n)=>n.id===x);for(let n=1;n<=p.components.length;n++){let l=p.components[(e+n)%p.components.length];if(C(l,o).kind==="pending")return l.id}}function Ai(p,o,x){return x==="all"||C(p,o).kind==="pending"===(x==="pending")}function Bi(p,o,x,e,n){let l=n==="fit"?Math.min(x/p,e/o):n;return{scale:l,width:p*l,height:o*l}}function Ii(p,o,x,e){if(x<=0||e<=x)return 0;return Math.max(0,Math.min(1,((p-o)/x-0.08)/0.84))*(e-x)}var zo=` +(()=>{function Li(p,o){let x=o?.changes[p],e=o?.feedback?.[p],n=e?.decision??(o?.submitted?o.draft.decisions[p]:void 0),t=x?.kind==="unchanged"&&(x.carried??(o?.submitted&&n?.action==="approve"))===!0;return{change:x,prior:n,feedbackRound:e?.round??o?.packet.round,carried:t,label:x?.kind==="added"?"New component":x?.kind==="changed"?"Review again":t?"Approval kept":n?.action==="revise"?"Changes still requested":"Awaiting review"}}function R(p,o,x){let e=o.decisions[p.id],n=e?.revision===p.revision?e:void 0,t=Li(p.id,x);if(n?.action==="approve")return{kind:"approved",label:t.carried?"Approval kept":"Approved",priority:3};if(n?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:t.change?.kind==="changed"?"Review again":t.change?.kind==="added"?"New · review needed":"Not reviewed",priority:t.change?.kind==="changed"||t.change?.kind==="added"?0:1}}function ko(p){return{packetRevision:p.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function Io(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 wi(p,o){let x=p.components.map((h)=>o.decisions[h.id]?.revision===h.revision?o.decisions[h.id]:void 0),e=x.filter((h)=>h?.action==="approve").length,n=x.filter((h)=>h?.action==="revise").length,t=x.length-e-n,ni=n>0||o.missing.length>0;return{approved:e,revisions:n,pending:t,hasFeedback:ni,canSubmit:o.packetRevision===p.revision&&o.missing.every((h)=>h.name.trim()&&Io(h.box))&&(ni||!t&&o.inventoryConfirmed)}}function jo(p,o){let x={...o.decisions};for(let e of p.components)if(!x[e.id]||x[e.id].revision!==e.revision)x[e.id]={revision:e.revision,action:"approve",feedback:"",split:!1};return{...o,decisions:x}}function qo(p,o){if(!wi(p,o).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:p.id,...structuredClone(o)}}function ci(p){let o=p.preview.kind==="page"||p.preview.sourceKind==="page",x=p.preview.sourceKind==="page";return{code:o,captured:x,label:o?p.medium.match(/html|css|svg/i)?p.medium:"HTML / CSS / SVG":"Raster",caption:o?x?p.preview.isolation?"Component only":"Region capture":"Live component":"Produced asset",fileLabel:x?"Open captured preview":"Open source image"}}function Mo(p,o,x){let e=p.components.findIndex((n)=>n.id===x);for(let n=1;n<=p.components.length;n++){let t=p.components[(e+n)%p.components.length];if(R(t,o).kind==="pending")return t.id}}function Ri(p,o,x){return x==="all"||R(p,o).kind==="pending"===(x==="pending")}function Di(p,o){if(!o.reviewGroup||!ci(o).code)return[o];return p.components.filter((x)=>x.reviewGroup===o.reviewGroup&&ci(x).code)}function Ni(p,o,x,e,n=[]){return(e?Di(p,x):[x]).filter((t)=>t.id===x.id||R(t,o).kind==="pending"&&!n.includes(t.id))}function Si(p,o,x,e,n){let t=n==="fit"?Math.min(1,x/p,e/o):n;return{scale:t,width:p*t,height:o*t}}function Fi(p,o,x,e){if(x<=0||e<=x)return 0;return Math.max(0,Math.min(1,((p-o)/x-0.08)/0.84))*(e-x)}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} @@ -53,7 +53,13 @@ .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 ko=` +`;var Vo=` +.review-peers{display:grid;gap:8px;padding:8px 0 12px;font-size:12px} +.review-peers strong span{font-weight:400}.review-peers>div{display:flex;flex-wrap:wrap;gap:4px} +.review-peers button{min-width:28px;min-height:28px;padding:3px;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit} +.review-peers button[aria-pressed="true"]{background:var(--teal);color:white} +.review-peers label{display:flex;align-items:center;gap:6px} + :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} @@ -88,7 +94,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} -`+zo+` +`+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. */ @@ -150,47 +156,47 @@ .assembled-review .decisions>button{flex:1;min-width:0} .assembled-review .page-review-status{text-align:left} } -`;var Uo={chevronDown:'',code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function K(p){return``}var d=(p)=>p.replace(/[&<>"']/g,(o)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]),oi=(p)=>`${p*100}%`,ri=(p)=>{let o=new URL(p,location.href);if(!["http:","https:"].includes(o.protocol))throw Error("Unsupported preview URL");return d(o.href)};function jo(p,o,x){let e=p.attachShadow({mode:"open"}),n=structuredClone(x.initialDraft??bo(o)),l=o.stage==="hero"&&o.components.length===1&&!n.missing.length,ei=()=>[...o.components].sort((f,m)=>C(f,n,x.history).priority-C(m,n,x.history).priority),h=ei().find((f)=>C(f,n,x.history).kind!=="approved")?.id??o.components[0]?.id,z=gi(o,n).pending?"pending":"reviewed",Y=!1,L=!1,v=x.completed??!1,bi="",J={},y=!!x.completed||!gi(o,n).pending,T=null,qo=/Mac|iPhone|iPad/.test(navigator.platform)?"⌘Enter":"Ctrl+Enter",P=!1,k=!1,vi=!1,Ki=null,ii=!1,$=!0,ti=!1,V="comp",Oi=V,O="fit",fi="checker",Q=!1,ni="isolated",Yi,Ri="fit",R=null,D=null,S=null,ci='',ui='',Di=(f)=>`left:${oi(f.x)};top:${oi(f.y)};width:${oi(f.w)};height:${oi(f.h)}`;function li(f){e.getElementById(f)?.focus({preventScroll:!0})}function Mo(f){let m=yo(o,n,f);if(y=!m,m)h=m;V="component",Q=!1,P=!1,O="fit",ni="isolated",z=y?"reviewed":"pending";let _=()=>{g(),li(y?"review-summary":J[h]?"feedback":"approve")};if(y&&k&&Ki)Ki(_);else _()}async function Si(){if(L||v||Q||Object.keys(J).length)return;L=!0,bi="",g();try{await x.onSubmit(uo(o,n)),v=!0,y=!0,Y=!1,z="reviewed"}catch(f){bi=f instanceof Error?f.message:"Could not save. Try again."}finally{L=!1,g()}}function _i(f){if(L||v||Q||vi)return;let m=o.components.find((Li)=>Li.id===h);if(!m)return;let _=n.decisions[m.id],si=_?.revision===m.revision?_:void 0;T={id:m.id,name:m.name,action:f,previous:_?{..._}:void 0};let yi=J[m.id]??si;if(n.decisions[m.id]={revision:m.revision,action:f,feedback:f==="revise"?yi?.feedback??"":"",split:f==="revise"&&(yi?.split??!1)},delete J[m.id],ti)$=!0,ti=!1;if(l)n.inventoryConfirmed=f==="approve",T=null,Si();else Mo(m.id)}function Ho(){if(L||v||Q||vi)return;let f=o.components.find((si)=>si.id===h);if(!f)return;let m=n.decisions[f.id],_=m?.revision===f.revision?m:void 0;if(J[f.id]??={feedback:_?.feedback??"",split:_?.split??!1},$&&(e.querySelector(".workbench")?.clientHeight??0)<420)ti=!0,$=!1;y=!1,g(),li("feedback")}function Fi(f){let m=`missing-${crypto.randomUUID()}`;n.missing.push({id:m,name:"Missing component",feedback:"",box:f}),n.inventoryConfirmed=!1,y=!1,h=m,V="component",Y=!1,R=null,D=null,g(),e.querySelector("#missing-name")?.focus()}function g(){if(S?.disconnect(),v||y&&!l)k=!1;let f=e.activeElement,m=f?.id,_=f?.dataset.select,si=window.scrollX,yi=window.scrollY,Li=e.querySelector(".inventory")?.scrollLeft??0,zi=Yi===h,Vo=V==="component"&&(!zi||Oi!==V);Oi=V;let Jo=zi?e.querySelector(".inspection-content")?.scrollTop??0:0,$o=zi&&(e.querySelector(".changed-files")?.open??!1),Go=e.querySelector(".comparison-slot")?.clientHeight??200,Ci=e.querySelector(".pan-viewport"),Pi=Yi===h&&Ri===O,Ko=Pi?Ci?.scrollLeft??0:0,Yo=Pi?Ci?.scrollTop??0:0;Yi=h,Ri=O;let c=o.components.find((i)=>i.id===h),j=n.missing.find((i)=>i.id===h),io=c?.box??j?.box,oo=c?o.components.indexOf(c)+1:o.components.length+n.missing.findIndex((i)=>i.id===h)+1,ro=c?n.decisions[c.id]:void 0,F=ro?.revision===c?.revision?ro:void 0,ki=c?J[c.id]:void 0,pi=Object.keys(J).length>0,_o=c?!o.components.some((i)=>i.id!==c.id&&C(i,n).kind==="pending"):!1,Ni=T&&!v?`
${d(T.name)} ${T.action==="approve"?"approved":"flagged for repair"}.
`:"",b=gi(o,n),q=x.history,N=c?$i(c.id,q):void 0,ai=q?.packet.components.find((i)=>i.id===c?.id),A=Q&&!!ai,t=A?ai:c,X=A?q.packet:o,eo=Object.values(q?.changes??{}),no=eo.filter((i)=>i.kind==="changed").length,po=eo.filter((i)=>i.kind==="added").length,Xi=o.components.filter((i)=>C(i,n,q).kind==="approved"&&$i(i.id,q).carried).length,ji=(i)=>{let r=C(i,n,q);return v&&r.kind==="feedback"?{...r,label:"Changes requested"}:r},Lo=b.approved+b.revisions+n.missing.length,ao=(z==="pending"?ei():o.components).filter((i)=>Ai(i,n,z)),No=[b.revisions+n.missing.length?`${b.revisions+n.missing.length} feedback ready`:"",Xi?`${Xi} ${Xi===1?"approval":"approvals"} kept`:"",no?`${no} changed`:"",po?`${po} added`:"",q?.removed.length?`${q.removed.length} removed`:""].filter(Boolean).join(" · "),Xo=bi||(pi?"Save or cancel your open feedback before sending.":v?x.preview?"Preview submitted. No run changed.":"Review submitted.":b.hasFeedback?"Ready to send for corrections.":b.pending?`${b.pending} left to review`:!n.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),B=t?Gi(t):null,Ei=t?.preview.kind==="image"&&!B?.code,Qi=Ei||t?.material?.alpha==="transparent",W=!!(t?.context&&ni==="context"),xo=t&&(W?t.context?.kind!=="image":t.preview.kind==="page"),Wi=W&&t?.context?t.context.url:t?.preview.url,Eo=B?.code?`${B.label} · ${B.captured?"captured from code":"live preview"}`:t?.material?`${t.material.alpha==="transparent"?"Transparent":t.material.alpha==="opaque"?"Opaque":"Transparency unverified"} ${t.material.format}`:"Raster · transparency unverified";e.innerHTML=`
-

${v?"Review record.":l?"Review the assembled page.":"Review the components."}

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

${v?'Submitted · read-only':x.preview?'Interactive preview':""}
+`;var Oo={chevronDown:'',code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function Y(p){return``}var l=(p)=>p.replace(/[&<>"']/g,(o)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]),ri=(p)=>`${p*100}%`,ei=(p)=>{let o=new URL(p,location.href);if(!["http:","https:"].includes(o.protocol))throw Error("Unsupported preview URL");return l(o.href)};function Jo(p,o,x){let e=p.attachShadow({mode:"open"}),n=structuredClone(x.initialDraft??ko(o)),t=o.stage==="hero"&&o.components.length===1&&!n.missing.length,ni=()=>[...o.components].sort((s,b)=>R(s,n,x.history).priority-R(b,n,x.history).priority),h=ni().find((s)=>R(s,n,x.history).kind!=="approved")?.id??o.components[0]?.id,z=wi(o,n).pending?"pending":"reviewed",_=!1,N=!1,m=x.completed??!1,ki="",V={},y=!!x.completed||!wi(o,n).pending,B=null,li=!1,$o=/Mac|iPhone|iPad/.test(navigator.platform)?"⌘Enter":"Ctrl+Enter",ii=!1,k=!1,ji=!1,Xi=null,oi=!1,$=!0,di=!1,J="comp",Ci=J,D="fit",mi="checker",W=!1,pi="isolated",Ei,Pi="fit",S=null,F=null,C=null,bi='',qi='',io=(s)=>`left:${ri(s.x)};top:${ri(s.y)};width:${ri(s.w)};height:${ri(s.h)}`;function hi(s){e.getElementById(s)?.focus({preventScroll:!0})}function Ko(s){let b=Mo(o,n,s);if(y=!b,b)h=b;J="component",W=!1,ii=!1,D="fit",pi="isolated",li=!1,z=y?"reviewed":"pending";let Z=()=>{f(),hi(y?"review-summary":V[h]?"feedback":"approve")};if(y&&k&&Xi)Xi(Z);else Z()}async function oo(){if(N||m||W||Object.keys(V).length)return;N=!0,ki="",f();try{await x.onSubmit(qo(o,n)),m=!0,y=!0,_=!1,z="reviewed"}catch(s){ki=s instanceof Error?s.message:"Could not save. Try again."}finally{N=!1,f()}}function Qi(s){if(N||m||W||ji)return;let b=o.components.find((L)=>L.id===h);if(!b)return;let Z=n.decisions[b.id],vi=Z?.revision===b.revision?Z:void 0,gi=Ni(o,n,b,li,Object.keys(V));B={id:b.id,name:gi.length>1?`${b.reviewGroup} · ${gi.length} instances`:b.name,action:s,previous:Object.fromEntries(gi.map((L)=>[L.id,n.decisions[L.id]?{...n.decisions[L.id]}:void 0]))};let Mi=V[b.id]??vi;for(let L of gi)n.decisions[L.id]={revision:L.revision,action:s,feedback:s==="revise"?Mi?.feedback??"":"",split:s==="revise"&&(Mi?.split??!1)};if(delete V[b.id],di)$=!0,di=!1;if(t)n.inventoryConfirmed=s==="approve",B=null,oo();else Ko(b.id)}function Yo(){if(N||m||W||ji)return;let s=o.components.find((vi)=>vi.id===h);if(!s)return;let b=n.decisions[s.id],Z=b?.revision===s.revision?b:void 0;if(V[s.id]??={feedback:Z?.feedback??"",split:Z?.split??!1},$&&(e.querySelector(".workbench")?.clientHeight??0)<420)di=!0,$=!1;y=!1,f(),hi("feedback")}function ro(s){let b=`missing-${crypto.randomUUID()}`;n.missing.push({id:b,name:"Missing component",feedback:"",box:s}),n.inventoryConfirmed=!1,y=!1,h=b,J="component",_=!1,S=null,F=null,f(),e.querySelector("#missing-name")?.focus()}function f(){if(C?.disconnect(),m||y&&!t)k=!1;let s=e.activeElement,b=s?.id,Z=s?.dataset.select,vi=window.scrollX,gi=window.scrollY,Mi=e.querySelector(".inventory")?.scrollLeft??0,L=Ei===h,_o=J==="component"&&(!L||Ci!==J);Ci=J;let Lo=L?e.querySelector(".inspection-content")?.scrollTop??0:0,No=L&&(e.querySelector(".changed-files")?.open??!1),Xo=e.querySelector(".comparison-slot")?.clientHeight??200,eo=e.querySelector(".pan-viewport"),no=Ei===h&&Pi===D,Eo=no?eo?.scrollLeft??0:0,Qo=no?eo?.scrollTop??0:0;Ei=h,Pi=D;let g=o.components.find((i)=>i.id===h),j=n.missing.find((i)=>i.id===h),po=g?.box??j?.box,ao=g?o.components.indexOf(g)+1:o.components.length+n.missing.findIndex((i)=>i.id===h)+1,xo=g?n.decisions[g.id]:void 0,P=xo?.revision===g?.revision?xo:void 0,Hi=g?V[g.id]:void 0,ai=Object.keys(V).length>0,Wi=g?Di(o,g):[],ui=g?Ni(o,n,g,li,Object.keys(V)):[],Wo=g?!o.components.some((i)=>!ui.some((r)=>r.id===i.id)&&R(i,n).kind==="pending"):!1,Zi=B&&!m?`
${l(B.name)} ${B.action==="approve"?"approved":"flagged for repair"}.
`:"",v=wi(o,n),q=x.history,X=g?Li(g.id,q):void 0,xi=q?.packet.components.find((i)=>i.id===g?.id),U=W&&!!xi,d=U?xi:g,E=U?q.packet:o,to=Object.values(q?.changes??{}),lo=to.filter((i)=>i.kind==="changed").length,ho=to.filter((i)=>i.kind==="added").length,Ui=o.components.filter((i)=>R(i,n,q).kind==="approved"&&Li(i.id,q).carried).length,Vi=(i)=>{let r=R(i,n,q);return m&&r.kind==="feedback"?{...r,label:"Changes requested"}:r},Zo=v.approved+v.revisions+n.missing.length,go=(z==="pending"?ni():o.components).filter((i)=>Ri(i,n,z)),Uo=[v.revisions+n.missing.length?`${v.revisions+n.missing.length} feedback ready`:"",Ui?`${Ui} ${Ui===1?"approval":"approvals"} kept`:"",lo?`${lo} changed`:"",ho?`${ho} added`:"",q?.removed.length?`${q.removed.length} removed`:""].filter(Boolean).join(" · "),Go=ki||(ai?"Save or cancel your open feedback before sending.":m?x.preview?"Preview submitted. No run changed.":"Review submitted.":v.hasFeedback?"Ready to send for corrections.":v.pending?`${v.pending} left to review`:!n.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),I=d?ci(d):null,Gi=d?.preview.kind==="image"&&!I?.code,Ti=Gi||d?.material?.alpha==="transparent",G=!!(d?.context&&pi==="context"),fo=d&&(G?d.context?.kind!=="image":d.preview.kind==="page"),Ai=G&&d?.context?d.context.url:d?.preview.url,To=I?.code?`${I.label} · ${I.captured?"captured from code":"live preview"}`:d?.material?`${d.material.alpha==="transparent"?"Transparent":d.material.alpha==="opaque"?"Opaque":"Transparency unverified"} ${d.material.format}`:"Raster · transparency unverified";e.innerHTML=`
+

${m?"Review record.":t?"Review the assembled page.":"Review the components."}

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

${m?'Submitted · read-only':x.preview?'Interactive preview':""}
${x.preview?'

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

':""} - ${q&&!l?`

${b.pending} ${b.pending===1?"component":"components"} to review${No}

${b.pending?``:""}${q.removed.length?`
Removed from the map

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

`:""}
`:""} - ${!l?`
`:""} -
${!l?` + ${q&&!t?`

${v.pending} ${v.pending===1?"component":"components"} to review${Uo}

${v.pending?``:""}${q.removed.length?`
Removed from the map

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

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

Approved comp

${!v?``:""}
-
- Approved composition for ${d(o.title)} - ${io&&!y?`
`:""} - ${o.components.map((i,r)=>{let a=ji(i);return``}).join("")} - ${n.missing.map((i,r)=>``).join("")} +

Approved comp

${!m?``:""}
+
+ Approved composition for ${l(o.title)} + ${po&&!y?`
`:""} + ${o.components.map((i,r)=>{let a=Vi(i);return``}).join("")} + ${n.missing.map((i,r)=>``).join("")}
-
# To review${ui} ${v?"Changes requested":"Feedback ready"}${ci} Approved
- ${Y?'
Draw around the missing piece.
':""} +
# To review${qi} ${m?"Changes requested":"Feedback ready"}${bi} Approved
+ ${_?'
Draw around the missing piece.
':""}
`:""} -
- ${!l?`

${y?"Review summary":`${oo} ${d(c?.name??j?.name??"Component")}`}

`:""}
- ${y&&!l?`

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

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

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

${o.components.map((i)=>{let r=n.decisions[i.id],a=ji(i);return``}).join("")}${n.missing.map((i)=>``).join("")}
${Ni?`
${Ni}
`:""}`:c?` +
+ ${!t?`

${y?"Review summary":`${ao} ${l(g?.name??j?.name??"Component")}`}

`:""}
+ ${y&&!t?`

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

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

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

${o.components.map((i)=>{let r=n.decisions[i.id],a=Vi(i);return``}).join("")}${n.missing.map((i)=>``).join("")}
${Zi?`
${Zi}
`:""}`:g?` ${q?`
- ${A&&N?.prior?.action==="revise"?`

Previous feedback · Round ${N.feedbackRound}

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

Requested: split into separately reviewable components.

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

Unchanged · approval kept

':""} - ${N?.change?.kind==="changed"?`
${N.change.files.length?`${N.change.files.length} changed ${N.change.files.length===1?"file":"files"}`:N.change.reasons.includes("region")?"Region changed":ai?.note!==c.note?"Description changed · files unchanged":"Component definition changed · files unchanged"}${N.change.files.length?`
    ${N.change.files.map((i)=>`
  • ${d(i)}
  • `).join("")}
`:""}${ai&&ai.note!==c.note?`
Previous description
${d(ai.note)}
Current description
${d(c.note)}
`:""}
`:""} + ${U&&X?.prior?.action==="revise"?`

Previous feedback · Round ${X.feedbackRound}

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

Requested: split into separately reviewable components.

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

Unchanged · approval kept

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

${d(t.name)}

${ai?`
`:""}
${t?.preview.kind==="image"?`${K("external")}`:""}
+

${l(d.name)}

${Wi.length>1?`
${l(g.reviewGroup)} · ${Wi.length} instances
${Wi.map((i)=>``).join("")}
`:""}
${xi?`
`:""}
${d?.preview.kind==="image"?`${Y("external")}`:""}
-
${A?`Comp · Round ${q.packet.round}`:l?"Approved comp":"In the comp"}
Reference region for ${d(t.name)}
-
${A?`Previous · Round ${q.packet.round}`:l?"Assembled page":W?"In context":q?`${B.caption} · Round ${o.round}`:B.caption}
${!xo?`Produced ${d(t.name)}`:``}${P?`Reference overlay`:""}
+
${U?`Comp · Round ${q.packet.round}`:t?"Approved comp":"In the comp"}
Reference region for ${l(d.name)}
+
${U?`Previous · Round ${q.packet.round}`:t?"Assembled page":G?"In context":q?`${I.caption} · Round ${o.round}`:I.caption}
${!fo?`Produced ${l(d.name)}`:``}${ii?`Reference overlay`:""}
- ${Qi||t.context?`
${t.context?`
`:""}${Qi?`
`:""}
`:""} -
${!l?`
${K(B.code?"code":"image")}${d(Eo)}${t?.material?`${t.material.width} × ${t.material.height} px`:""}
${X.stage==="components"&&B?.captured&&!t?.preview.isolation?'

Legacy region capture · may include overlapping components.

':""}${t?.context?.layering&&(Ei||W)?`

${d(t.context.layering)}

`:""} -

${d(t.note)}

-
`:""}
${Ni}${A?'

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

':""}${v?`
${F?.action==="approve"?"Approved":F?.action==="revise"?"Changes requested":"Not reviewed"}Submitted in round ${o.round} · read-only
`:`
${!l?`
Your review Round ${o.round}${A?"

Return to Current to review this round.

":""}
`:""}${F&&!l?``:""}
`} - ${ki?`
`:F?.action==="revise"?`

${d(F.feedback||"No note — agent will diagnose.")}

`:""} - ${l?`

${d(bi||(v?"Your decision is saved.":L?"Sending…":ki?"":"Approval confirms the composition and that nothing is missing."))}

`:""}
`:j?`

This piece will be added to the unresolved inventory.

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

No components supplied.

"} + ${Ti||d.context?`
${d.context?`
`:""}${Ti?`
`:""}
`:""} +
${!t?`
${Y(I.code?"code":"image")}${l(To)}${d?.material?`${d.material.width} × ${d.material.height} px`:""}
${E.stage==="components"&&I?.captured&&!d?.preview.isolation?'

Legacy region capture · may include overlapping components.

':""}${d?.context?.layering&&(Gi||G)?`

${l(d.context.layering)}

`:""} +

${l(d.note)}

+
`:""}
${Zi}${U?'

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

':""}${m?`
${P?.action==="approve"?"Approved":P?.action==="revise"?"Changes requested":"Not reviewed"}Submitted in round ${o.round} · read-only
`:`
${!t?`
Your review Round ${o.round}${U?"

Return to Current to review this round.

":""}
`:""}${P&&!t?``:""}
`} + ${Hi?`
`:P?.action==="revise"?`

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

`:""} + ${t?`

${l(ki||(m?"Your decision is saved.":N?"Sending…":Hi?"":"Approval confirms the composition and that nothing is missing."))}

`:""}
`:j?`

This piece will be added to the unresolved inventory.

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

No components supplied.

"}
- ${!l?`

Components

-
${ao.map((i)=>{let r=o.components.indexOf(i),a=ji(i);return``}).join("")}${(z==="pending"?[]:n.missing).map((i,r)=>``).join("")}${!ao.length&&(z==="pending"||!n.missing.length)?`

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

`:""}
`:""} - ${l?"":v?`
Round ${o.round} submitted · read-only${b.approved} approved · ${b.revisions} changes requested
`:`
${!b.pending&&!pi?``:""}

${d(Xo)}

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

Components

+
${go.map((i)=>{let r=o.components.indexOf(i),a=Vi(i);return``}).join("")}${(z==="pending"?[]:n.missing).map((i,r)=>``).join("")}${!go.length&&(z==="pending"||!n.missing.length)?`

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

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

${l(Go)}

`} + `;let K=e.querySelector("#comparison-dialog"),Q=e.querySelector(".comparison-panel"),ti=e.querySelector(".comparison-slot"),O=e.querySelector(".inspector > .review-form"),Ao=e.querySelector(".inspector"),so=()=>{if(K.append(Q),O)K.append(O)};if(k&&Q&&ti)ti.style.height=`${Xo}px`,so(),K.showModal();if(U)e.querySelectorAll(".decisions button,#feedback,#split,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#submit,#inventory-confirm").forEach((i)=>i.disabled=!0);if(e.querySelector(".inspection-content").scrollTop=Lo,m||N)e.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);let co=e.querySelector(".inventory");if(co)co.scrollLeft=Mi;if(!L&&$)Array.from(e.querySelectorAll(".inventory [data-select]")).find((i)=>i.dataset.select===h)?.scrollIntoView({block:"nearest",inline:"nearest"});if(b)e.getElementById(b)?.focus({preventScroll:!0});else if(Z)Array.from(e.querySelectorAll(".item[data-select]")).find((i)=>i.dataset.select===Z)?.focus({preventScroll:!0});let u=(i,r)=>e.querySelector(`#${i}`)?.addEventListener("click",r);function wo(i,r=!1){if(_)return;if(li=!1,y=!1,h=i,J="component",ii=!1,D="fit",pi="isolated",W=!1,f(),r)e.querySelector("#expand-comparison")?.click()}e.querySelectorAll("[data-select]").forEach((i)=>i.onclick=()=>wo(i.dataset.select,i.classList.contains("pin")&&o.components.some((r)=>r.id===i.dataset.select))),u("previous-round",()=>{W=!0,f()}),u("current-round",()=>{W=!1,f()}),u("review-changes",()=>{let i=ni().filter((c)=>Vi(c).kind==="pending"),r=i.findIndex((c)=>c.id===h),a=i[(r+1)%i.length];if(a)y=!1,h=a.id,J="component",z="pending",W=!1,D="fit",ii=!1,pi="isolated",f()}),e.querySelectorAll("[data-filter]").forEach((i)=>i.onclick=()=>{z=i.dataset.filter,y=!v.pending&&z==="pending";let r=ni().filter((c)=>Ri(c,n,z));if(!(z!=="pending"&&n.missing.some((c)=>c.id===h))&&!r.some((c)=>c.id===h)&&r.length)h=r[0].id,J="component",W=!1,D="fit",ii=!1,pi="isolated";f()}),u("show-summary",()=>{y=!0,J="component",z="reviewed",f(),hi("review-summary")}),u("approve",()=>Qi("approve")),u("revise",Yo),u("clear",()=>{if(g)delete n.decisions[g.id],delete V[g.id];B=null,y=!1,z="pending",f()}),u("cancel-feedback",()=>{if(g)delete V[g.id];if(di)$=!0,di=!1;f(),hi("revise")}),e.querySelector("#feedback-form")?.addEventListener("submit",(i)=>{i.preventDefault(),Qi("revise")}),e.querySelector("#feedback")?.addEventListener("keydown",(i)=>{let r=i;if(r.key==="Enter"&&(r.metaKey||r.ctrlKey)&&!r.isComposing)r.preventDefault(),r.stopPropagation(),Qi("revise")}),u("undo-decision",()=>{if(!B||N||m)return;let i=B;for(let[r,a]of Object.entries(i.previous))if(a)n.decisions[r]=a;else delete n.decisions[r];delete V[i.id],h=i.id,y=!1,W=!1,J="component",z="all",B=null,f(),hi("approve")}),e.querySelector("#apply-group")?.addEventListener("change",(i)=>{li=i.target.checked,f()}),u("overlay",()=>{ii=!ii,f()}),u("isolated",()=>{pi="isolated",f()}),u("context",()=>{pi="context",f()}),e.querySelector("#zoom")?.addEventListener("change",(i)=>{let r=i.target.value;D=r==="fit"?"fit":Number(r),f()}),u("background-checker",()=>{mi="checker",f()}),u("background-page",()=>{mi="page",f()}),u("show-all",()=>{oi=!oi,$=!0,f()}),u("toggle-tray",()=>{di=!1,$=!$,f()}),u("show-comp",()=>{J="comp",f()}),u("show-component",()=>{J="component",f()}),u("mark",()=>{_=!_,f()}),u("add-box",()=>ro({x:0.35,y:0.35,w:0.2,h:0.2})),u("remove-missing",()=>{n.missing=n.missing.filter((i)=>i.id!==h),h=o.components[0]?.id,f()}),u("approve-rest",()=>{if(ai)return;n=jo(o,n),B=null,y=!0,z="reviewed",J="component",f(),hi("review-summary")}),e.querySelector("#inventory-confirm")?.addEventListener("change",(i)=>{n.inventoryConfirmed=i.target.checked,f()}),e.querySelector("#feedback")?.addEventListener("input",(i)=>{if(g&&V[g.id])V[g.id].feedback=i.target.value}),e.querySelector("#split")?.addEventListener("change",(i)=>{if(g&&V[g.id])V[g.id].split=i.target.checked}),e.querySelector("#missing-name")?.addEventListener("input",(i)=>{if(j)j.name=i.target.value;let r=e.querySelector("#submit");if(r)r.disabled=ai||N||m||!wi(o,n).canSubmit}),e.querySelector("#missing-feedback")?.addEventListener("input",(i)=>{if(j)j.feedback=i.target.value}),e.querySelectorAll("[data-coordinate]").forEach((i)=>i.addEventListener("change",()=>{if(!j)return;let r=i.dataset.coordinate,a=Number(i.value)/100;if(Number.isFinite(a))j.box[r]=Math.max(r==="w"||r==="h"?0.001:0,Math.min(1,a));j.box.w=Math.min(j.box.w,1-j.box.x),j.box.h=Math.min(j.box.h,1-j.box.y),f()})),u("submit",()=>{oo()});let T=e.querySelector(".map");function Bi(i){let r=T.getBoundingClientRect();return{x:Math.max(0,Math.min(1,(i.clientX-r.left)/r.width)),y:Math.max(0,Math.min(1,(i.clientY-r.top)/r.height))}}function mo(i){let r=Bi(i);return o.components.filter(({box:a})=>r.x>=a.x&&r.x<=a.x+a.w&&r.y>=a.y&&r.y<=a.y+a.h).sort((a,c)=>a.box.w*a.box.h-c.box.w*c.box.h)[0]}T?.addEventListener("click",(i)=>{if(_||i.target.closest("[data-select]"))return;let r=mo(i);if(r)wo(r.id,!0)}),T?.addEventListener("pointermove",(i)=>{if(!_)T.style.cursor=mo(i)?"zoom-in":""}),T?.addEventListener("pointerdown",(i)=>{if(!_)return;S=Bi(i),T.setPointerCapture(i.pointerId),i.preventDefault()}),T?.addEventListener("pointermove",(i)=>{if(!S)return;let r=Bi(i);F={x:Math.min(S.x,r.x),y:Math.min(S.y,r.y),w:Math.abs(r.x-S.x),h:Math.abs(r.y-S.y)};let a=e.querySelector(".draw-box");a.hidden=!1,a.style.cssText=io(F)}),T?.addEventListener("pointerup",()=>{if(F&&F.w>0.01&&F.h>0.01)ro(F);else S=null,F=null}),T?.addEventListener("pointercancel",()=>{S=null,F=null,f()});let bo=e.querySelector(".output"),Ji=e.querySelector("iframe"),vo=e.querySelector(".workbench"),fi=e.querySelector(".inspection-content"),Ii=e.querySelector(".inspector");function Oi(){if(!fi||!Ii)return;Ii.dataset.scrollAbove=String(fi.scrollTop>1),Ii.dataset.scrollBelow=String(fi.scrollHeight-fi.clientHeight-fi.scrollTop>1)}fi?.addEventListener("scroll",Oi,{passive:!0});function $i(){let i=e.querySelector(".map-space");if(i&&i.clientWidth&&i.clientHeight){let w=Si(o.comp.width,o.comp.height,Math.max(1,i.clientWidth-32),Math.max(1,i.clientHeight-32),"fit");T.style.width=`${w.width}px`,T.style.height=`${w.height}px`}let r=e.querySelector(".inspection-content"),a=Array.from(e.querySelectorAll(".pan-viewport"));if(r?.clientHeight){let w=k?Math.max(100,K.clientHeight-(Q?.querySelector(".compare-toolbar")?.clientHeight??0)-(Q?.querySelector(".expanded-title")?.clientHeight??0)-(Q?.querySelector(".view-controls")?.clientHeight??0)-(O?.getBoundingClientRect().height??0)-124):Math.min(t?Number.POSITIVE_INFINITY:248,Math.max(100,r.clientHeight-((a[0]?.getBoundingClientRect().top??r.getBoundingClientRect().top)-r.getBoundingClientRect().top+r.scrollTop)-(Q?.querySelector(".view-controls")?.clientHeight??0)-12));a.forEach((M)=>M.style.height=`${w}px`)}if(d&&a.length){let w=Si(d.box.w*E.comp.width,d.box.h*E.comp.height,Math.min(...a.map((M)=>M.clientWidth)),Math.min(...a.map((M)=>M.clientHeight)),D);e.querySelectorAll(".crop-stage").forEach((M)=>{M.style.width=`${w.width}px`,M.style.height=`${w.height}px`})}if(a.forEach((w)=>{let M=w.scrollWidth>w.clientWidth+1||w.scrollHeight>w.clientHeight+1;w.classList.toggle("pannable",M),w.style.cursor=k?"":"zoom-in",w.setAttribute("role",k?"region":"button"),w.title=k?M?"Move your pointer to pan. You can also scroll, swipe, or use arrow keys.":"":"Click to enlarge comparison"}),bo&&Ji&&d){let w=bo.clientWidth/(d.box.w*E.comp.width);Ji.style.transform=`scale(${w})`,Ji.style.left=`${-d.box.x*E.comp.width*w}px`,Ji.style.top=`${-d.box.y*E.comp.height*w}px`}let c=vo.getBoundingClientRect(),yi=e.querySelector(".region"),A=e.querySelector(".number"),Yi=e.querySelector(".connector path");if(yi&&A&&Yi){let w=yi.getBoundingClientRect(),M=A.getBoundingClientRect(),_i=w.right-c.left,H=w.top+w.height/2-c.top,zi=M.left-c.left-8,Bo=M.top+M.height/2-c.top;Yi.setAttribute("d",`M ${_i} ${H} H ${zi-14} V ${Bo} H ${zi}`)}}function si(i,r){if(!Q||!ti||i===k)return;let a=e.querySelector("#expand-comparison"),c=(k?K:Q).getBoundingClientRect(),yi=window.matchMedia("(prefers-reduced-motion: reduce)").matches,A=Q.querySelector(".pan-viewport"),Yi=A?A.scrollLeft/Math.max(1,A.scrollWidth-A.clientWidth):0,w=A?A.scrollTop/Math.max(1,A.scrollHeight-A.clientHeight):0,M=()=>Q.querySelectorAll(".pan-viewport").forEach((H)=>{H.scrollLeft=Yi*Math.max(0,H.scrollWidth-H.clientWidth),H.scrollTop=w*Math.max(0,H.scrollHeight-H.clientHeight)}),_i=()=>{if(ti.append(Q),O)Ao.append(O),O.inert=!1;K.close(),ti.style.height="",ji=!1,k=!1,a.innerHTML=Y("expand"),a.setAttribute("aria-label","Enlarge comparison"),a.title="Enlarge comparison",a.setAttribute("aria-expanded","false"),$i(),M(),a.focus({preventScroll:!0}),r?.()};if(i){ti.style.height=`${c.height}px`,so(),K.showModal(),k=!0,a.innerHTML=Y("compact"),a.setAttribute("aria-label","Restore comparison"),a.title="Restore comparison (Esc)",a.setAttribute("aria-expanded","true"),$i(),M(),a.focus({preventScroll:!0});let H=K.getBoundingClientRect();if(!yi)K.animate([{transform:`translate(${c.x-H.x}px,${c.y-H.y}px) scale(${c.width/H.width},${c.height/H.height})`,opacity:0.6},{transform:"none",opacity:1}],{duration:240,easing:"cubic-bezier(.2,.8,.2,1)"})}else{if(a.disabled)return;let H=ti.getBoundingClientRect();if(yi){_i();return}if(a.disabled=!0,ji=!0,O)O.inert=!0;let zi=K.animate([{transform:"none",opacity:1},{transform:`translate(${H.x-c.x}px,${H.y-c.y}px) scale(${H.width/c.width},${H.height/c.height})`,opacity:0.6}],{duration:200,easing:"cubic-bezier(.4,0,.2,1)",fill:"forwards"});zi.finished.then(()=>{if(K.isConnected)zi.cancel(),a.disabled=!1,_i()}).catch(()=>{})}}if(Xi=(i)=>si(!1,i),u("expand-comparison",()=>si(!k)),K.addEventListener("cancel",(i)=>{i.preventDefault(),i.stopPropagation(),si(!1)}),K.addEventListener("keydown",(i)=>{if(i.key==="Escape")i.preventDefault(),i.stopPropagation(),si(!1)}),C=new ResizeObserver(()=>{$i(),Oi()}),C.observe(vo),C.observe(K),O)C.observe(O);let uo=e.querySelector(".inspection-content");if(uo)C.observe(uo);let yo=e.querySelector(".repair-context");if(yo)C.observe(yo);let zo=Q?.querySelector(".compare-toolbar");if(zo)C.observe(zo);$i();let Ki=Array.from(e.querySelectorAll(".pan-viewport"));if(Ki.forEach((i)=>{i.scrollLeft=Eo,i.scrollTop=Qo}),Ki.forEach((i)=>{i.querySelectorAll("img").forEach((r)=>r.draggable=!1),i.addEventListener("click",()=>{if(!k)si(!0)}),i.addEventListener("pointermove",(r)=>{if(r.pointerType!=="mouse"||r.buttons||!i.classList.contains("pannable"))return;let a=i.getBoundingClientRect();i.scrollLeft=Fi(r.clientX,a.left,i.clientWidth,i.scrollWidth),i.scrollTop=Fi(r.clientY,a.top,i.clientHeight,i.scrollHeight)}),i.addEventListener("keydown",(r)=>{if(!k&&(r.key==="Enter"||r.key===" ")){r.preventDefault(),r.stopPropagation(),si(!0);return}let c={ArrowLeft:[-48,0],ArrowRight:[48,0],ArrowUp:[0,-48],ArrowDown:[0,48]}[r.key];if(!c)return;r.preventDefault(),r.stopPropagation(),i.scrollLeft+=c[0],i.scrollTop+=c[1]})}),Ki.forEach((i)=>i.addEventListener("scroll",()=>{for(let r of Ki)if(r!==i){if(r.scrollLeft!==i.scrollLeft)r.scrollLeft=i.scrollLeft;if(r.scrollTop!==i.scrollTop)r.scrollTop=i.scrollTop}})),_o&&window.matchMedia("(max-width:800px)").matches){let i=e.querySelector(".inspection-content"),r=e.querySelector(".compare");if(i&&r)i.scrollTop+=r.getBoundingClientRect().top-i.getBoundingClientRect().top}if(Oi(),window.scrollTo(vi,gi),!m)x.onDraftChange?.(structuredClone(n))}return f(),{destroy(){C?.disconnect(),e.replaceChildren()},getDraft(){return structuredClone(n)}}}async function Ro(){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 x=await o.json();if(x.sourceStatus){let e=document.createElement("p");e.textContent=x.sourceStatus,p.before(e)}Jo(p,x.packet,{initialDraft:x.draft,history:x.history,completed:!!x.receipt,onSubmit:async(e)=>{let n=await fetch("/decision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)}),t=await n.json();if(!n.ok)throw Error(t.error??"The review could not be saved. Try again.")}})}Ro().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 fc5329e5c..4d140ab0b 100644 --- a/crates/context/src/component_review/manifest.rs +++ b/crates/context/src/component_review/manifest.rs @@ -147,6 +147,20 @@ pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap = BTreeMap::new(); + for c in input["components"].as_array().ok_or("components must be an array")? { + if let Some(group) = c.get("reviewGroup") { + let name = group.as_str().filter(|s| !s.trim().is_empty() && s.len() <= 120) + .ok_or("reviewGroup needs a nonempty name of at most 120 bytes")?; + if input["stage"] != "components" || c["preview"]["kind"] != "page" { + return Err("review groups are for repeated code components; raster assets remain individual".into()); + } + let path = string(&c["preview"], "path")?; + if groups.insert(name.into(), path.into()).is_some_and(|previous| previous != path) { + return Err("a review group must share one code document".into()); + } + } + } let mut ids = BTreeSet::new(); let components = packet["components"] .as_array_mut() diff --git a/crates/context/src/component_review/tests.rs b/crates/context/src/component_review/tests.rs index 36dbac04c..3ef5108ef 100644 --- a/crates/context/src/component_review/tests.rs +++ b/crates/context/src/component_review/tests.rs @@ -641,3 +641,21 @@ fn visual_approvals_survive_shared_source_edits_but_not_changed_scope_or_pixels( let changed = store::read(&dir.join("current.json")).unwrap(); assert!(changed["draft"]["decisions"]["control"].is_null()); } + +#[test] +fn review_groups_preserve_instances_and_require_a_shared_code_document() { + let f = Fixture::new(); + fs::create_dir_all(f.project.join(".impeccable/build")).unwrap(); + fs::write(f.project.join(".impeccable/build/spec.json"), r#"{"regions":[]}"#).unwrap(); + let mut input=f.manifest(); input["stage"]=json!("components"); + input["components"][1]["reviewGroup"]=json!("Labels"); + let mut peer=input["components"][1].clone(); peer["id"]=json!("peer"); + input["components"].as_array_mut().unwrap().push(peer); + let (packet, _) = manifest::freeze(&f.project, &input).unwrap(); + assert_eq!(packet["components"].as_array().unwrap().len(),3); + assert_eq!(packet["components"][2]["reviewGroup"],"Labels"); + let mut invalid=input.clone();invalid["components"][0]["reviewGroup"]=json!("Labels"); + assert!(manifest::freeze(&f.project,&invalid).unwrap_err().contains("raster assets remain individual")); + input["components"][2]["preview"]["path"]=json!("different.html"); + assert!(manifest::freeze(&f.project,&input).unwrap_err().contains("share one code document")); +} diff --git a/skill/reference/component-review.md b/skill/reference/component-review.md index 7568ffcb7..89f1b2792 100644 --- a/skill/reference/component-review.md +++ b/skill/reference/component-review.md @@ -6,6 +6,10 @@ Use this checkpoint on comp-led builds after producing the initial component kit Keep the measured spec's region IDs. Include every visible region: produced raster assets and working HTML/CSS/SVG for text, controls, patterns, decoration and layout elements. A region rendered in code needs an actual review document, not a promise to implement it later. Use semantic HTML for content and controls. Do not flatten the page or combine unrelated regions to avoid review. Report omitted regions so the user can mark what is missing. +For a repeated code pattern, give its instances the same `reviewGroup` name. Keep every instance and its region ID in the manifest, in the same kit document. The user can inspect instances and explicitly apply one decision to the unreviewed group. Unique raster assets still require their own review; grouping never removes inventory or gate checks. + +Before producing assets, inspect each reference crop against its named subject. Coarse grid cells and automatic ink snapping can include neighbors or omit parts of a compound element. Correct the measured region with an explicit normalized `box`; do not build to a known bad crop. Check the code preview contains the complete component before presenting it. The capture tool refuses content cut off by the review crop. + Write `.impeccable/review/components.json` with this manifest format: ```json @@ -23,7 +27,7 @@ Write `.impeccable/review/components.json` with this manifest format: "box": {"x": 0.5, "y": 0.2, "w": 0.45, "h": 0.7}, "note": "Produced cutout; positioned over the page ground.", "preview": {"kind": "image", "path": "assets/illustration.png"}, - "dependencies": [".impeccable/build/spec.json"] + "dependencies": [] }, { "id": "headline", @@ -32,13 +36,13 @@ Write `.impeccable/review/components.json` with this manifest format: "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/kit.html", "selector": "#headline"}, - "dependencies": [".impeccable/build/spec.json", "assets/type.woff2"] + "dependencies": ["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`). 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. +The coordinates above only illustrate the schema. Use the approved comp's actual pixel dimensions and each measured region's normalized bounds (the spec’s `box` is already normalized; divide only pixel coordinates by comp dimensions). 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. diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index ce3627a05..6c270b2f4 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -107,7 +107,7 @@ Then, in order, each closed by `{{scripts_path}}/impeccable build-phase advance` 0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened. The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet. -1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. +1. **spec.** Measure the comp: `impeccable comp-spec --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions narrow to an ink cluster only when it retains at least 95% of the span’s contrasting pixels; inspect the resulting crop, especially for compound controls and multiline text; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop ` writes the reference; save `impeccable comp-spec --plate-prompt --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt --prompt-file `. The API fallback is `impeccable generate-image --ref --prompt-file --out --size --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. After the initial assets exist, prepare the isolated code component previews and complete [component-review.md](component-review.md) before further gate-driven repair: the user reviews the whole component kit, including code, before page assembly. This checkpoint keeps the existing gates; it does not require passing them first. After the full page and responsive checks, use the assembled-hero checkpoint before the final response. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. 3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. diff --git a/ui/component-review/model.test.ts b/ui/component-review/model.test.ts index 1d9b4f39a..374b5a792 100644 --- a/ui/component-review/model.test.ts +++ b/ui/component-review/model.test.ts @@ -72,3 +72,17 @@ test('reviewed queue includes feedback, pending queue includes stale decisions', expect(packet.components.filter(c=>inReviewQueue(c,draft,'pending')).map(c=>c.id)).toEqual(['art']); expect(packet.components.filter(c=>inReviewQueue(c,draft,'reviewed')).map(c=>c.id)).toEqual(['control']); }); + +test('explicit pattern decisions preserve prior decisions, raster reviews and open edits', async () => { + const {reviewPeers,decisionTargets}=await import('./model'); + const pattern={...packet.components[1],reviewGroup:'Room labels'}; + const p={...packet,components:[{...packet.components[0],reviewGroup:'Room labels'},pattern, + ...['b','c','d'].map(id=>({...pattern,id,revision:id}))]}; + const draft=newDraft(p); + draft.decisions.b={revision:'b',action:'revise',feedback:'Keep this specific repair',split:false}; + expect(reviewPeers(p,pattern).map(c=>c.id)).toEqual(['control','b','c','d']); + expect(decisionTargets(p,draft,pattern,false).map(c=>c.id)).toEqual(['control']); + expect(decisionTargets(p,draft,pattern,true,['c']).map(c=>c.id)).toEqual(['control','d']); + expect(draft.decisions.b.feedback).toBe('Keep this specific repair'); + expect(reviewPeers(p,packet.components[0])).toEqual([packet.components[0]]); +}); diff --git a/ui/component-review/model.ts b/ui/component-review/model.ts index 2919ece81..0cdaed269 100644 --- a/ui/component-review/model.ts +++ b/ui/component-review/model.ts @@ -1,6 +1,7 @@ export type Box = { x: number; y: number; w: number; h: number }; export type Component = { id: string; revision: string; name: string; medium: string; note: string; box: Box; + reviewGroup?: string; 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 }; @@ -96,3 +97,14 @@ export type InventoryFilter = 'pending' | 'reviewed' | 'all'; export function inReviewQueue(component: Component, draft: Draft, filter: InventoryFilter) { return filter === 'all' || (componentState(component, draft).kind === 'pending') === (filter === 'pending'); } + +/** Grouping is authored explicitly, never guessed from names or visual similarity. + * Decisions remain per component; existing decisions and unsaved edits are excluded. */ +export function reviewPeers(packet: ReviewPacket, component: Component): Component[] { + if (!component.reviewGroup || !componentPresentation(component).code) return [component]; + return packet.components.filter(c => c.reviewGroup === component.reviewGroup && componentPresentation(c).code); +} +export function decisionTargets(packet: ReviewPacket, draft: Draft, selected: Component, grouped: boolean, editing: string[] = []) { + return (grouped ? reviewPeers(packet, selected) : [selected]).filter(c => c.id === selected.id || + (componentState(c, draft).kind === 'pending' && !editing.includes(c.id))); +} diff --git a/ui/component-review/review.ts b/ui/component-review/review.ts index 5e738cc3a..a81247abe 100644 --- a/ui/component-review/review.ts +++ b/ui/component-review/review.ts @@ -1,4 +1,4 @@ -import { inReviewQueue, type InventoryFilter, componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model'; +import { reviewPeers, decisionTargets, inReviewQueue, type InventoryFilter, componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model'; import { comparisonSize, hoverPan } from './viewport'; import { styles } from './styles'; import { icon } from './icons'; @@ -27,7 +27,8 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op let error = ''; const edits: Record = {}; let finished = !!options.completed || !summarize(packet,draft).pending; - let lastDecision: {id: string; name: string; action: 'approve' | 'revise'; previous?: Decision} | null = null; + let lastDecision: {id: string; name: string; action: 'approve' | 'revise'; previous: Record} | null = null; + let applyGroup = false; const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘Enter' : 'Ctrl+Enter'; let overlay = false; let expandedComparison = false; @@ -57,7 +58,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op const next = nextUnreviewed(packet, draft, after); finished = !next; if (next) selected = next; - mobilePane='component'; previousRound=false; overlay=false; zoom='fit'; outputMode='isolated'; + mobilePane='component'; previousRound=false; overlay=false; zoom='fit'; outputMode='isolated'; applyGroup=false; inventoryFilter = finished ? 'reviewed' : 'pending'; const showNext = () => { render(); @@ -81,9 +82,10 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op if (!c) return; const saved = draft.decisions[c.id]; const current = saved?.revision===c.revision ? saved : undefined; - lastDecision = {id:c.id, name:c.name, action, previous:saved ? {...saved} : undefined}; + const targets = decisionTargets(packet,draft,c,applyGroup,Object.keys(edits)); + lastDecision = {id:c.id, name:targets.length>1 ? `${c.reviewGroup} · ${targets.length} instances` : c.name, action, previous:Object.fromEntries(targets.map(t=>[t.id,draft.decisions[t.id] ? {...draft.decisions[t.id]} : undefined]))}; const note = edits[c.id] ?? current; - draft.decisions[c.id] = {revision:c.revision, action, feedback:action==='revise' ? note?.feedback ?? '' : '', split:action==='revise' && (note?.split ?? false)}; + for (const target of targets) draft.decisions[target.id] = {revision:target.revision, action, feedback:action==='revise' ? note?.feedback ?? '' : '', split:action==='revise' && (note?.split ?? false)}; delete edits[c.id]; if(restoreTrayAfterFeedback){trayOpen=true;restoreTrayAfterFeedback=false;} if (assembled) { @@ -139,7 +141,9 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op const d = savedDecision?.revision === c?.revision ? savedDecision : undefined; const edit = c ? edits[c.id] : undefined; const uncommitted = Object.keys(edits).length > 0; - const isLast = c ? !packet.components.some(item=>item.id!==c.id && componentState(item,draft).kind==='pending') : false; + const peers = c ? reviewPeers(packet,c) : []; + const targets = c ? decisionTargets(packet,draft,c,applyGroup,Object.keys(edits)) : []; + const isLast = c ? !packet.components.some(item=>!targets.some(t=>t.id===item.id) && componentState(item,draft).kind==='pending') : false; const notice = lastDecision && !submitted ? `
${esc(lastDecision.name)} ${lastDecision.action==='approve'?'approved':'flagged for repair'}.
` : ''; const stats = summarize(packet, draft); const history = options.history; @@ -200,7 +204,7 @@ 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)}
`:''}
`:''} `:''} -

${esc(v!.name)}

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

${esc(v!.name)}

${peers.length>1?`
${esc(c!.reviewGroup!)} · ${peers.length} instances
${peers.map(peer=>``).join('')}
`:''}
${priorComponent?`
`:''}
${v?.preview.kind==='image'?`${icon('external')}`:''}
${viewingPrevious ? `Comp · Round ${history!.packet.round}` : assembled ? 'Approved comp' : 'In the comp'}
Reference region for ${esc(v!.name)}
${viewingPrevious ? `Previous · Round ${history!.packet.round}` : assembled ? 'Assembled page' : useContext ? 'In context' : history ? `${presentation!.caption} · Round ${packet.round}` : presentation!.caption}
${!useFrame ? `Produced ${esc(v!.name)}` : ``}${overlay ? `Reference overlay` : ''}
@@ -208,7 +212,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op ${hasTransparency || v!.context ? `
${v!.context ? `
` : ''}${hasTransparency?`
`:''}
` : ''}
${!assembled?`
${icon(presentation!.code ? 'code' : 'image')}${esc(materialLabel)}${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}
${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
`:`
${!assembled?`
Your review Round ${packet.round}${viewingPrevious?'

Return to Current to review this round.

':''}
`:''}${d && !assembled ? `` : ''}
`} +
`:''}
${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
`:`
${!assembled?`
Your review Round ${packet.round}${viewingPrevious?'

Return to Current to review this round.

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

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

` : ''} ${assembled?`

${esc(error || (submitted?'Your decision is saved.':sending?'Sending…':edit?'':'Approval confirms the composition and that nothing is missing.'))}

`:''}
` : missing ? `

This piece will be added to the unresolved inventory.

${(['x','y','w','h'] as const).map(k=>``).join('')}
` : '

No components supplied.

'} @@ -241,7 +245,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op const on = (id:string, action:()=>void) => root.querySelector(`#${id}`)?.addEventListener('click', action); function selectComponent(id:string, enlarge=false) { if(marking)return; - finished=false; selected=id; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render(); + applyGroup=false; finished=false; selected=id; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render(); // Use the newly rendered control so map shortcuts share the toolbar's // animation, focus management, reduced-motion and dismissal behavior. if(enlarge)root.querySelector('#expand-comparison')?.click(); @@ -269,9 +273,10 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op on('undo-decision',()=>{ if(!lastDecision||sending||submitted)return; const previous=lastDecision; - if(previous.previous)draft.decisions[previous.id]=previous.previous;else delete draft.decisions[previous.id]; + for(const [id,decision] of Object.entries(previous.previous)){if(decision)draft.decisions[id]=decision;else delete draft.decisions[id];} delete edits[previous.id];selected=previous.id;finished=false;previousRound=false;mobilePane='component';inventoryFilter='all';lastDecision=null;render();focusReview('approve'); }); + root.querySelector('#apply-group')?.addEventListener('change',e=>{applyGroup=(e.target as HTMLInputElement).checked;render();}); on('overlay',()=>{overlay=!overlay; render();}); on('isolated',()=>{outputMode='isolated';render();}); on('context',()=>{outputMode='context';render();}); diff --git a/ui/component-review/styles.ts b/ui/component-review/styles.ts index 368308876..99a23d46d 100644 --- a/ui/component-review/styles.ts +++ b/ui/component-review/styles.ts @@ -1,5 +1,11 @@ import { appLayout } from './app-layout'; export const styles = ` +.review-peers{display:grid;gap:8px;padding:8px 0 12px;font-size:12px} +.review-peers strong span{font-weight:400}.review-peers>div{display:flex;flex-wrap:wrap;gap:4px} +.review-peers button{min-width:28px;min-height:28px;padding:3px;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit} +.review-peers button[aria-pressed="true"]{background:var(--teal);color:white} +.review-peers label{display:flex;align-items:center;gap:6px} + :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} diff --git a/ui/component-review/viewport.test.ts b/ui/component-review/viewport.test.ts index b9ed6a113..daa7749d0 100644 --- a/ui/component-review/viewport.test.ts +++ b/ui/component-review/viewport.test.ts @@ -21,3 +21,8 @@ test('hover panning reaches both edges, synchronizes midpoint, and ignores fitte expect(hoverPan(110,10,200,150)).toBe(0); expect(hoverPan(110,10,0,600)).toBe(0); }); + + test('fit never magnifies tiny crops; deliberate zoom still does', () => { + expect(comparisonSize(66,24,600,420,'fit')).toEqual({scale:1,width:66,height:24}); + expect(comparisonSize(66,24,600,420,2)).toEqual({scale:2,width:132,height:48}); +}); diff --git a/ui/component-review/viewport.ts b/ui/component-review/viewport.ts index 463c7aa14..9dda42b67 100644 --- a/ui/component-review/viewport.ts +++ b/ui/component-review/viewport.ts @@ -1,6 +1,6 @@ /** Both panes use comp pixels and the same scale; source-image resolution is separate. */ export function comparisonSize(width: number, height: number, availableWidth: number, availableHeight: number, zoom: 'fit' | number) { - const scale = zoom === 'fit' ? Math.min(availableWidth / width, availableHeight / height) : zoom; + const scale = zoom === 'fit' ? Math.min(1, availableWidth / width, availableHeight / height) : zoom; return { scale, width: width * scale, height: height * scale }; }