From eff0d814c96ba3c30976ff239a0f2a9e3287bdb5 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 14 Sep 2026 15:59:25 -0700 Subject: [PATCH] Add synchronized hover panning and expandable component comparison --- crates/context/assets/component-review.js | 79 +++++++++++++---------- ui/component-review/review.ts | 72 +++++++++++++++++++-- ui/component-review/styles.ts | 11 ++++ ui/component-review/viewport.test.ts | 12 +++- ui/component-review/viewport.ts | 7 ++ 5 files changed, 140 insertions(+), 41 deletions(-) diff --git a/crates/context/assets/component-review.js b/crates/context/assets/component-review.js index 49ac0b57b..07a199f8e 100644 --- a/crates/context/assets/component-review.js +++ b/crates/context/assets/component-review.js @@ -1,4 +1,4 @@ -(()=>{function di(p,e){let a=e?.changes[p],o=e?.feedback?.[p],n=o?.decision??(e?.submitted?e.draft.decisions[p]:void 0),c=a?.kind==="unchanged"&&(a.carried??(e?.submitted&&n?.action==="approve"))===!0;return{change:a,prior:n,feedbackRound:o?.round??e?.packet.round,carried:c,label:a?.kind==="added"?"New component":a?.kind==="changed"?"Review again":c?"Approval kept":n?.action==="revise"?"Changes still requested":"Awaiting review"}}function B(p,e,a){let o=e.decisions[p.id],n=o?.revision===p.revision?o:void 0,c=di(p.id,a);if(n?.action==="approve")return{kind:"approved",label:c.carried?"Approval kept":"Approved",priority:3};if(n?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:c.change?.kind==="changed"?"Review again":c.change?.kind==="added"?"New · review needed":"Not reviewed",priority:c.change?.kind==="changed"||c.change?.kind==="added"?0:1}}function Si(p){return{packetRevision:p.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function ue(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 hi(p,e){let a=p.components.map((g)=>e.decisions[g.id]?.revision===g.revision?e.decisions[g.id]:void 0),o=a.filter((g)=>g?.action==="approve").length,n=a.filter((g)=>g?.action==="revise").length,c=a.length-o-n,d=n>0||e.missing.length>0;return{approved:o,revisions:n,pending:c,hasFeedback:d,canSubmit:e.packetRevision===p.revision&&e.missing.every((g)=>g.name.trim()&&ue(g.box))&&(d||!c&&e.inventoryConfirmed)}}function Ci(p,e){let a={...e.decisions};for(let o of p.components)if(!a[o.id]||a[o.id].revision!==o.revision)a[o.id]={revision:o.revision,action:"approve",feedback:"",split:!1};return{...e,decisions:a}}function Fi(p,e){if(!hi(p,e).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:p.id,...structuredClone(e)}}function gi(p){let e=p.preview.kind==="page"||p.preview.sourceKind==="page",a=p.preview.sourceKind==="page";return{code:e,captured:a,label:e?p.medium.match(/html|css|svg/i)?p.medium:"HTML / CSS / SVG":"Raster",caption:e?a?"Rendered component":"Live component":"Produced asset",fileLabel:a?"Open captured preview":"Open source image"}}function Pi(p,e,a){let o=p.components.findIndex((n)=>n.id===a);for(let n=1;n<=p.components.length;n++){let c=p.components[(o+n)%p.components.length];if(B(c,e).kind==="pending")return c.id}}function ki(p,e,a,o,n){let c=n==="fit"?Math.min(a/p,o/e):n;return{scale:c,width:p*c,height:e*c}}var ie=` +(()=>{function yi(p,o){let a=o?.changes[p],r=o?.feedback?.[p],n=r?.decision??(o?.submitted?o.draft.decisions[p]:void 0),c=a?.kind==="unchanged"&&(a.carried??(o?.submitted&&n?.action==="approve"))===!0;return{change:a,prior:n,feedbackRound:r?.round??o?.packet.round,carried:c,label:a?.kind==="added"?"New component":a?.kind==="changed"?"Review again":c?"Approval kept":n?.action==="revise"?"Changes still requested":"Awaiting review"}}function F(p,o,a){let r=o.decisions[p.id],n=r?.revision===p.revision?r:void 0,c=yi(p.id,a);if(n?.action==="approve")return{kind:"approved",label:c.carried?"Approval kept":"Approved",priority:3};if(n?.action==="revise")return{kind:"feedback",label:"Feedback ready",priority:2};return{kind:"pending",label:c.change?.kind==="changed"?"Review again":c.change?.kind==="added"?"New · review needed":"Not reviewed",priority:c.change?.kind==="changed"||c.change?.kind==="added"?0:1}}function no(p){return{packetRevision:p.revision,decisions:{},missing:[],inventoryConfirmed:!1}}function Ho(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 zi(p,o){let a=p.components.map((f)=>o.decisions[f.id]?.revision===f.revision?o.decisions[f.id]:void 0),r=a.filter((f)=>f?.action==="approve").length,n=a.filter((f)=>f?.action==="revise").length,c=a.length-r-n,h=n>0||o.missing.length>0;return{approved:r,revisions:n,pending:c,hasFeedback:h,canSubmit:o.packetRevision===p.revision&&o.missing.every((f)=>f.name.trim()&&Ho(f.box))&&(h||!c&&o.inventoryConfirmed)}}function po(p,o){let a={...o.decisions};for(let r of p.components)if(!a[r.id]||a[r.id].revision!==r.revision)a[r.id]={revision:r.revision,action:"approve",feedback:"",split:!1};return{...o,decisions:a}}function ao(p,o){if(!zi(p,o).canSubmit)throw Error("Review is incomplete or stale");return{schemaVersion:1,requestId:p.id,...structuredClone(o)}}function ki(p){let o=p.preview.kind==="page"||p.preview.sourceKind==="page",a=p.preview.sourceKind==="page";return{code:o,captured:a,label:o?p.medium.match(/html|css|svg/i)?p.medium:"HTML / CSS / SVG":"Raster",caption:o?a?"Rendered component":"Live component":"Produced asset",fileLabel:a?"Open captured preview":"Open source image"}}function to(p,o,a){let r=p.components.findIndex((n)=>n.id===a);for(let n=1;n<=p.components.length;n++){let c=p.components[(r+n)%p.components.length];if(F(c,o).kind==="pending")return c.id}}function Ki(p,o,a,r,n){let c=n==="fit"?Math.min(a/p,r/o):n;return{scale:c,width:p*c,height:o*c}}function Qi(p,o,a,r){if(a<=0||r<=a)return 0;return Math.max(0,Math.min(1,((p-o)/a-0.08)/0.84))*(r-a)}var xo=` :host{height:var(--component-review-height,100dvh);min-height:0;overflow:hidden} .review{height:100%;max-width:none;min-height:0;padding:0;display:flex;flex-direction:column;overflow:hidden;background:var(--color-bg,#fafafa)} .review>header{flex-shrink:0;padding:16px 24px;margin:0;border-bottom:1px solid var(--line);gap:16px}.review>header>div{min-width:0}.review h1{font-size:30px;line-height:1}.review>header p{font-size:12px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.review .badge{font-size:11px} @@ -54,7 +54,7 @@ .review>.inventory-section{height:160px;padding:6px 14px}.inventory-section>.section-head{flex-direction:row;flex-wrap:nowrap;align-items:center;gap:6px;min-height:34px;margin-bottom:4px}.inventory-section h2{display:none}.inventory-filters{flex:1;width:auto;min-width:0;padding:2px}.inventory-filters button{padding:4px 6px;font-size:10px;min-height:28px}.inventory-filters b{margin-left:3px}.tray-actions #show-all{display:none}.tray-actions #toggle-tray{padding:6px;min-width:32px;min-height:32px}.tray-actions #toggle-tray span{display:none}.tray-actions svg{width:16px;height:16px}.review>.inventory-section.tray-collapsed{height:46px;padding:6px 14px}.review>.inventory-section.tray-expanded{height:160px}.inventory .item{flex-basis:130px;grid-template-rows:auto 1fr auto;padding:6px}.inventory .item-thumb{display:none}.inventory.all{display:flex;overflow-x:auto;overflow-y:hidden}.inventory .item strong{font-size:11px;min-height:22px}.inventory .state{font-size:10px}.inventory .item-number{font-size:10px;min-height:14px}.inventory-empty{padding:8px 0;font-size:12px} .review>footer{padding:8px 14px calc(8px + env(safe-area-inset-bottom));gap:8px;flex-direction:column;align-items:stretch}.review>footer>div:first-child{gap:10px;justify-content:space-between}.review>footer #approve-rest{font-size:10px;min-height:32px;max-width:47%;padding:5px 8px}.review>footer .check{font-size:10px;max-width:48%;gap:4px}.review>footer .check input{width:14px;height:14px}.review>footer .submit-area{justify-content:space-between;gap:10px}.review>footer .submit-area p{font-size:10px;max-width:22ch}.review>footer .primary{min-height:34px;font-size:12px;padding:6px 10px} } -`;var ee=` +`;var lo=` :host{display:block;color:var(--color-text,#292929);font:14px/1.45 var(--font-sans,Arial,sans-serif);--line:var(--color-border,#ddd);--paper:var(--color-panel,#fff);--muted:var(--color-muted,#666);--teal:var(--color-patina,#28625e);--warn:var(--color-warn,#8a5b30);--selection:#43897f} *{box-sizing:border-box}h1,h2,p,figure{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:4px;background:var(--paper);color:inherit;padding:8px 12px;min-height:36px}button:hover{border-color:var(--teal);color:var(--teal)}button:disabled{cursor:default;opacity:.45}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--teal);outline-offset:3px}button[aria-pressed=true]{box-shadow:inset 0 0 0 1px var(--teal)}input[type=checkbox]{accent-color:var(--teal);width:16px;height:16px;flex-shrink:0}textarea,input:not([type=checkbox]){width:100%;background:var(--paper);color:inherit;border:1px solid #999;border-radius:4px;padding:9px 10px}textarea{resize:vertical;min-height:80px}::selection{background:#c7ddd8}a{color:var(--teal)} .review{max-width:1600px;margin:auto;padding:24px 28px 0}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:16px}h1{font:400 40px/1.05 var(--font-display,Arial,sans-serif);letter-spacing:-.02em}header p{margin-top:8px;font-size:15px}header p span,.medium{color:var(--muted)}.badge{border:1px solid var(--line);padding:5px 10px;font-size:12px;white-space:nowrap}.preview-note{color:var(--muted);font-size:12px;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px} @@ -89,7 +89,7 @@ @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}} .item-medium{margin-left:auto;font-weight:400;display:flex;align-items:center;gap:4px;font-size:10px;color:var(--muted);min-height:16px}.item-medium .utility-icon{width:14px;height:14px;flex-shrink:0}.material>.utility-icon{width:18px;height:18px;align-self:center;color:var(--teal)} .feedback-actions{display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap}.feedback-actions .primary{margin-left:auto;display:inline-flex;align-items:center;gap:10px}.feedback-actions svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5}.feedback-hint,.shortcut-hint{font-size:12px;color:var(--muted)}.feedback-hint{margin-top:6px}.shortcut-hint{flex-basis:100%;text-align:right}.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:16px 8px}.review-summary h2{font-size:24px}.review-summary p{margin-top:12px;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} -`+ie+` +`+xo+` .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. */ @@ -100,47 +100,58 @@ .feedback-actions .shortcut-hint{flex-basis:auto;order:-1;margin-right:auto} .feedback-actions .primary{margin-left:0} @media(max-height:800px),(max-width:800px){.feedback-actions .shortcut-hint{display:none}.feedback-actions .primary{margin-left:auto}.review-form .feedback textarea{min-height:56px}.review-form:has(#feedback-form) .decision-notice{margin-bottom:4px}} -`;var ve={code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function K(p){return``}var x=(p)=>p.replace(/[&<>"']/g,(e)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e]),D=(p)=>`${p*100}%`,O=(p)=>{let e=new URL(p,location.href);if(!["http:","https:"].includes(e.protocol))throw Error("Unsupported preview URL");return x(e.href)};function oe(p,e,a){let o=p.attachShadow({mode:"open"}),n=structuredClone(a.initialDraft??Si(e)),c=()=>[...e.components].sort((s,b)=>B(s,n,a.history).priority-B(b,n,a.history).priority),d=c().find((s)=>B(s,n,a.history).kind!=="approved")?.id??e.components[0]?.id,g=a.completed?"all":"attention",V=!1,J=!1,Q=a.completed??!1,si="",H={},M=!1,W=null,ne=/Mac|iPhone|iPad/.test(navigator.platform)?"⌘Enter":"Ctrl+Enter",Y=!1,U=!1,k=!0,F=!1,y="comp",ji=y,_="fit",ei="checker",N=!1,R="isolated",fi,Hi="fit",L=null,T=null,oi=null,ci='',ai='',Mi=(s)=>`left:${D(s.x)};top:${D(s.y)};width:${D(s.w)};height:${D(s.h)}`;function ni(s){o.getElementById(s)?.focus({preventScroll:!0})}function re(s){let b=Pi(e,n,s);if(M=!b,b)d=b;if(y="component",N=!1,Y=!1,_="fit",R="isolated",g==="approved")g="attention";h(),ni(M?"review-summary":H[d]?"feedback":"approve")}function bi(s){if(J||Q||N)return;let b=e.components.find((mi)=>mi.id===d);if(!b)return;let $=n.decisions[b.id],ri=$?.revision===b.revision?$:void 0;W={id:b.id,name:b.name,action:s,previous:$?{...$}:void 0};let ti=H[b.id]??ri;if(n.decisions[b.id]={revision:b.revision,action:s,feedback:s==="revise"?ti?.feedback??"":"",split:s==="revise"&&(ti?.split??!1)},delete H[b.id],F)k=!0,F=!1;re(b.id)}function pe(){if(J||Q||N)return;let s=e.components.find((ri)=>ri.id===d);if(!s)return;let b=n.decisions[s.id],$=b?.revision===s.revision?b:void 0;if(H[s.id]??={feedback:$?.feedback??"",split:$?.split??!1},k&&(o.querySelector(".workbench")?.clientHeight??0)<420)F=!0,k=!1;M=!1,h(),ni("feedback")}function qi(s){let b=`missing-${crypto.randomUUID()}`;n.missing.push({id:b,name:"Missing component",feedback:"",box:s}),n.inventoryConfirmed=!1,M=!1,d=b,y="component",V=!1,L=null,T=null,h(),o.querySelector("#missing-name")?.focus()}function h(){oi?.disconnect();let s=o.activeElement,b=s?.id,$=s?.dataset.select,ri=window.scrollX,ti=window.scrollY,mi=o.querySelector(".inventory")?.scrollLeft??0,xi=fi===d,ae=y==="component"&&(!xi||ji!==y);ji=y;let te=xi?o.querySelector(".inspection-content")?.scrollTop??0:0,xe=xi&&(o.querySelector(".changed-files")?.open??!1),Ei=o.querySelector(".pan-viewport"),Vi=fi===d&&Hi===_,le=Vi?Ei?.scrollLeft??0:0,de=Vi?Ei?.scrollTop??0:0;fi=d,Hi=_;let l=e.components.find((i)=>i.id===d),w=n.missing.find((i)=>i.id===d),Ji=l?.box??w?.box,Ni=l?e.components.indexOf(l)+1:e.components.length+n.missing.findIndex((i)=>i.id===d)+1,$i=l?n.decisions[l.id]:void 0,S=$i?.revision===l?.revision?$i:void 0,wi=l?H[l.id]:void 0,P=Object.keys(H).length>0,he=l?!e.components.some((i)=>i.id!==l.id&&B(i,n).kind==="pending"):!1,Gi=W?`
${x(W.name)} ${W.action==="approve"?"approved":"flagged for repair"}.
`:"",u=hi(e,n),v=a.history,q=l?di(l.id,v):void 0,C=v?.packet.components.find((i)=>i.id===l?.id),X=N&&!!C,t=X?C:l,E=X?v.packet:e,Ki=Object.values(v?.changes??{}),Qi=Ki.filter((i)=>i.kind==="changed").length,Wi=Ki.filter((i)=>i.kind==="added").length,ui=e.components.filter((i)=>B(i,n,v).kind==="approved"&&di(i.id,v).carried).length,pi=(i)=>B(i,n,v),ge=e.components.length-u.approved+n.missing.length,_i=(g==="attention"?c():e.components).filter((i)=>g==="all"||pi(i).kind==="approved"===(g==="approved")),se=[u.revisions+n.missing.length?`${u.revisions+n.missing.length} feedback ready`:"",ui?`${ui} ${ui===1?"approval":"approvals"} kept`:"",Qi?`${Qi} changed`:"",Wi?`${Wi} added`:"",v?.removed.length?`${v.removed.length} removed`:""].filter(Boolean).join(" · "),fe=si||(P?"Save or cancel your open feedback before sending.":Q?a.preview?"Preview submitted. No run changed.":"Review submitted.":u.hasFeedback?"Ready to send for corrections.":u.pending?`${u.pending} left to review`:!n.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),Z=t?gi(t):null,Li=t?.preview.kind==="image"&&!Z?.code,I=!!(t?.context&&R==="context"),Ti=t&&(I?t.context?.kind!=="image":t.preview.kind==="page"),Xi=I&&t?.context?t.context.url:t?.preview.url,ce=Z?.code?`${Z.label} · ${Z.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";if(o.innerHTML=`
-

Review the components.

${x(e.title)} · Round ${e.round}

${a.preview?'Interactive preview':""}
+ +.comparison-panel{min-width:0}.comparison-panel .expanded-title{display:none}.comparison-panel .compare-toolbar{justify-content:flex-start}.comparison-panel .overlay-control{margin-left:auto}.comparison-panel #expand-comparison{flex-shrink:0}.pan-viewport.pannable{cursor:move}.pan-viewport img{user-select:none;-webkit-user-drag:none} +#comparison-dialog{position:fixed;inset:16px;width:calc(100vw - 32px);height:calc(100dvh - 32px);max-width:none;max-height:none;margin:0;padding:24px;background:var(--paper);color:inherit;border:1px solid var(--line);border-radius:8px;box-shadow:0 20px 60px #14292340;overflow:hidden;transform-origin:top left} +#comparison-dialog::backdrop{background:#172821a6} +#comparison-dialog .comparison-panel{height:100%;display:flex;flex-direction:column;gap:12px} +#comparison-dialog .expanded-title{display:block;font-size:20px;line-height:1.3;margin:0;font-weight:500} +#comparison-dialog .compare-toolbar{flex-shrink:0;margin:0} +#comparison-dialog .compare{flex:1;min-height:0;max-width:none;grid-template-columns:minmax(0,1fr) minmax(0,1fr)} +#comparison-dialog .compare figure{display:flex;flex-direction:column;min-height:0}#comparison-dialog .pan-viewport{flex:1;min-height:0}#comparison-dialog .compare figcaption{flex-shrink:0} +#comparison-dialog .view-controls{flex-shrink:0;margin:0} +@media(max-width:600px){#comparison-dialog{inset:6px;width:calc(100vw - 12px);height:calc(100dvh - 12px);padding:14px}.comparison-panel .compare-toolbar{gap:6px}.comparison-panel .overlay-control{padding-inline:8px;font-size:12px}#comparison-dialog .compare{gap:8px}#comparison-dialog .compare figcaption{font-size:11px}} +`;var Vo={code:'',image:'',expand:'',compact:'',hideTray:'',showTray:'',next:'',mark:'',close:'',undo:'',external:'',zoom:''};function E(p){return``}var x=(p)=>p.replace(/[&<>"']/g,(o)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[o]),C=(p)=>`${p*100}%`,P=(p)=>{let o=new URL(p,location.href);if(!["http:","https:"].includes(o.protocol))throw Error("Unsupported preview URL");return x(o.href)};function ho(p,o,a){let r=p.attachShadow({mode:"open"}),n=structuredClone(a.initialDraft??no(o)),c=()=>[...o.components].sort((s,w)=>F(s,n,a.history).priority-F(w,n,a.history).priority),h=c().find((s)=>F(s,n,a.history).kind!=="approved")?.id??o.components[0]?.id,f=a.completed?"all":"attention",_=!1,L=!1,Y=a.completed??!1,ji="",V={},J=!1,U=null,go=/Mac|iPhone|iPad/.test(navigator.platform)?"⌘Enter":"Ctrl+Enter",B=!1,$=!1,O=!1,M=!0,ni=!1,q="comp",_i=q,W="fit",xi="checker",T=!1,ii="isolated",qi,Li="fit",Z=null,I=null,pi=null,Mi='',gi='',Ti=(s)=>`left:${C(s.x)};top:${C(s.y)};width:${C(s.w)};height:${C(s.h)}`;function li(s){r.getElementById(s)?.focus({preventScroll:!0})}function fo(s){let w=to(o,n,s);if(J=!w,w)h=w;if(q="component",T=!1,B=!1,W="fit",ii="isolated",f==="approved")f="attention";g(),li(J?"review-summary":V[h]?"feedback":"approve")}function Hi(s){if(L||Y||T)return;let w=o.components.find((Vi)=>Vi.id===h);if(!w)return;let X=n.decisions[w.id],di=X?.revision===w.revision?X:void 0;U={id:w.id,name:w.name,action:s,previous:X?{...X}:void 0};let fi=V[w.id]??di;if(n.decisions[w.id]={revision:w.revision,action:s,feedback:s==="revise"?fi?.feedback??"":"",split:s==="revise"&&(fi?.split??!1)},delete V[w.id],ni)M=!0,ni=!1;fo(w.id)}function so(){if(L||Y||T)return;let s=o.components.find((di)=>di.id===h);if(!s)return;let w=n.decisions[s.id],X=w?.revision===s.revision?w:void 0;if(V[s.id]??={feedback:X?.feedback??"",split:X?.split??!1},M&&(r.querySelector(".workbench")?.clientHeight??0)<420)ni=!0,M=!1;J=!1,g(),li("feedback")}function Xi(s){let w=`missing-${crypto.randomUUID()}`;n.missing.push({id:w,name:"Missing component",feedback:"",box:s}),n.inventoryConfirmed=!1,J=!1,h=w,q="component",_=!1,Z=null,I=null,g(),r.querySelector("#missing-name")?.focus()}function g(){pi?.disconnect();let s=r.activeElement,w=s?.id,X=s?.dataset.select,di=window.scrollX,fi=window.scrollY,Vi=r.querySelector(".inventory")?.scrollLeft??0,si=qi===h,co=q==="component"&&(!si||_i!==q);_i=q;let wo=si?r.querySelector(".inspection-content")?.scrollTop??0:0,mo=si&&(r.querySelector(".changed-files")?.open??!1),bo=r.querySelector(".comparison-slot")?.clientHeight??200,Yi=r.querySelector(".pan-viewport"),Ui=qi===h&&Li===W,uo=Ui?Yi?.scrollLeft??0:0,vo=Ui?Yi?.scrollTop??0:0;qi=h,Li=W;let d=o.components.find((i)=>i.id===h),v=n.missing.find((i)=>i.id===h),Wi=d?.box??v?.box,Zi=d?o.components.indexOf(d)+1:o.components.length+n.missing.findIndex((i)=>i.id===h)+1,Ii=d?n.decisions[d.id]:void 0,oi=Ii?.revision===d?.revision?Ii:void 0,Ei=d?V[d.id]:void 0,ai=Object.keys(V).length>0,yo=d?!o.components.some((i)=>i.id!==d.id&&F(i,n).kind==="pending"):!1,Ai=U?`
${x(U.name)} ${U.action==="approve"?"approved":"flagged for repair"}.
`:"",y=zi(o,n),j=a.history,N=d?yi(d.id,j):void 0,ei=j?.packet.components.find((i)=>i.id===d?.id),A=T&&!!ei,t=A?ei:d,G=A?j.packet:o,Bi=Object.values(j?.changes??{}),Oi=Bi.filter((i)=>i.kind==="changed").length,Ri=Bi.filter((i)=>i.kind==="added").length,Ji=o.components.filter((i)=>F(i,n,j).kind==="approved"&&yi(i.id,j).carried).length,hi=(i)=>F(i,n,j),zo=o.components.length-y.approved+n.missing.length,Di=(f==="attention"?c():o.components).filter((i)=>f==="all"||hi(i).kind==="approved"===(f==="approved")),ko=[y.revisions+n.missing.length?`${y.revisions+n.missing.length} feedback ready`:"",Ji?`${Ji} ${Ji===1?"approval":"approvals"} kept`:"",Oi?`${Oi} changed`:"",Ri?`${Ri} added`:"",j?.removed.length?`${j.removed.length} removed`:""].filter(Boolean).join(" · "),jo=ji||(ai?"Save or cancel your open feedback before sending.":Y?a.preview?"Preview submitted. No run changed.":"Review submitted.":y.hasFeedback?"Ready to send for corrections.":y.pending?`${y.pending} left to review`:!n.inventoryConfirmed?"Confirm the map is complete.":"Ready to continue."),R=t?ki(t):null,Si=t?.preview.kind==="image"&&!R?.code,D=!!(t?.context&&ii==="context"),Fi=t&&(D?t.context?.kind!=="image":t.preview.kind==="page"),Ci=D&&t?.context?t.context.url:t?.preview.url,qo=R?.code?`${R.label} · ${R.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";r.innerHTML=`
+

Review the components.

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

${a.preview?'Interactive preview':""}
${a.preview?'

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

':""} - ${v?`

${u.pending} ${u.pending===1?"component":"components"} to review${se}

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

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

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

${y.pending} ${y.pending===1?"component":"components"} to review${ko}

${y.pending?``:""}${j.removed.length?`
Removed from the map

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

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

Approved comp

-
- Approved composition for ${x(e.title)} - ${Ji&&!M?`
`:""} - ${e.components.map((i,r)=>{let f=pi(i);return``}).join("")} - ${n.missing.map((i,r)=>``).join("")} +

Approved comp

+
+ Approved composition for ${x(o.title)} + ${Wi&&!J?`
`:""} + ${o.components.map((i,e)=>{let l=hi(i);return``}).join("")} + ${n.missing.map((i,e)=>``).join("")}
-
# To review${ai} Feedback ready${ci} Approved
- ${V?'
Draw around the missing piece.
':""} +
# To review${gi} Feedback ready${Mi} Approved
+ ${_?'
Draw around the missing piece.
':""}
-

${M?"Review summary":`${Ni} ${x(l?.name??w?.name??"Component")}`}

- ${M?`

Components reviewed.

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

Check your decisions below. ${u.hasFeedback?"Send feedback when you’re ready.":"Confirm nothing is missing, then approve and continue."}

${e.components.map((i)=>{let r=n.decisions[i.id];return``}).join("")}${n.missing.map((i)=>``).join("")}
${Gi}
`:l?`
${K(Z.code?"code":"image")}${x(ce)}${t?.material?`${t.material.width} × ${t.material.height} px`:""}${t?.preview.kind==="image"?`${K("external")}`:""}
- ${v?`
- ${q?.prior?.action==="revise"?`

Previous feedback Round ${q.feedbackRound}

Needs work

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

Requested: split into separately reviewable components.

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

Unchanged · approval kept

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

${J?"Review summary":`${Zi} ${x(d?.name??v?.name??"Component")}`}

+ ${J?`

Components reviewed.

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

Check your decisions below. ${y.hasFeedback?"Send feedback when you’re ready.":"Confirm nothing is missing, then approve and continue."}

${o.components.map((i)=>{let e=n.decisions[i.id];return``}).join("")}${n.missing.map((i)=>``).join("")}
${Ai}
`:d?`
${E(R.code?"code":"image")}${x(qo)}${t?.material?`${t.material.width} × ${t.material.height} px`:""}${t?.preview.kind==="image"?`${E("external")}`:""}
+ ${j?`
+ ${N?.prior?.action==="revise"?`

Previous feedback Round ${N.feedbackRound}

Needs work

${x(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":ei?.note!==d.note?"Description changed · files unchanged":"Component definition changed · files unchanged"}${N.change.files.length?`
    ${N.change.files.map((i)=>`
  • ${x(i)}
  • `).join("")}
`:""}${ei&&ei.note!==d.note?`
Previous description
${x(ei.note)}
Current description
${x(d.note)}
`:""}
`:""}
`:""} - ${C?`
`:""} -
+ ${ei?`
`:""} +

${x(t.name)}

-
${X?`Comp · Round ${v.packet.round}`:"In the comp"}
Reference region for ${x(t.name)}
-
${X?`Previous · Round ${v.packet.round}`:v?`Current · Round ${e.round}`:I?"In the page":Z.caption}
${!Ti?`Produced ${x(t.name)}`:``}${Y?`Reference overlay`:""}
+
${A?`Comp · Round ${j.packet.round}`:"In the comp"}
Reference region for ${x(t.name)}
+
${A?`Previous · Round ${j.packet.round}`:j?`Current · Round ${o.round}`:D?"In the page":R.caption}
${!Fi?`Produced ${x(t.name)}`:``}${B?`Reference overlay`:""}
- ${Li?`
${t.context?`
`:""}
`:""} -

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

+ ${Si?`
${t.context?`
`:""}
`:""} +

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

${x(t.note)}

-
${Gi}${X?'

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

':""}
Your review Round ${e.round}${X?"

Return to Current to review this round.

":""}
${S?``:""}
- ${wi?`
`:S?.action==="revise"?`

${x(S.feedback||"No note — agent will diagnose.")}

`:""} -
`:w?`

This piece will be added to the unresolved inventory.

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

No components supplied.

"} +
${Ai}${A?'

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

':""}
Your review Round ${o.round}${A?"

Return to Current to review this round.

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

${x(oi.feedback||"No note — agent will diagnose.")}

`:""} +
`:v?`

This piece will be added to the unresolved inventory.

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

No components supplied.

"}
-

Components

-
${_i.map((i)=>{let r=e.components.indexOf(i),f=pi(i);return``}).join("")}${(g==="approved"?[]:n.missing).map((i,r)=>``).join("")}${!_i.length&&(g==="approved"||!n.missing.length)?`

${g==="attention"?"Every component is approved. Confirm the map is complete, then continue.":"No components approved yet."}

`:""}
-

${x(fe)}

-
`,X)o.querySelectorAll(".decisions button,#feedback,#split,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#submit,#inventory-confirm").forEach((i)=>i.disabled=!0);if(o.querySelector(".inspection-content").scrollTop=te,Q||J)o.querySelectorAll(".decisions button,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#mark,#inventory-confirm,#missing-name,#missing-feedback,#feedback,#split,#remove-missing,[data-coordinate]").forEach((i)=>i.disabled=!0);if(o.querySelector(".inventory").scrollLeft=mi,!xi&&k)Array.from(o.querySelectorAll(".inventory [data-select]")).find((i)=>i.dataset.select===d)?.scrollIntoView({block:"nearest",inline:"nearest"});if(b)o.getElementById(b)?.focus({preventScroll:!0});else if($)Array.from(o.querySelectorAll(".item[data-select]")).find((i)=>i.dataset.select===$)?.focus({preventScroll:!0});let m=(i,r)=>o.querySelector(`#${i}`)?.addEventListener("click",r);o.querySelectorAll("[data-select]").forEach((i)=>i.onclick=()=>{if(V)return;M=!1,d=i.dataset.select,y="component",Y=!1,_="fit",R="isolated",N=!1,h()}),m("previous-round",()=>{N=!0,h()}),m("current-round",()=>{N=!1,h()}),m("review-changes",()=>{let i=c().filter((j)=>pi(j).kind==="pending"),r=i.findIndex((j)=>j.id===d),f=i[(r+1)%i.length];if(f)M=!1,d=f.id,y="component",g="attention",N=!1,_="fit",Y=!1,R="isolated",h()}),o.querySelectorAll("[data-filter]").forEach((i)=>i.onclick=()=>{M=!1,g=i.dataset.filter;let r=c().filter((j)=>g==="all"||pi(j).kind==="approved"===(g==="approved"));if(!(g!=="approved"&&n.missing.some((j)=>j.id===d))&&!r.some((j)=>j.id===d)&&r.length)d=r[0].id,y="component",N=!1,_="fit",Y=!1,R="isolated";h()}),m("approve",()=>bi("approve")),m("revise",pe),m("clear",()=>{if(l)delete n.decisions[l.id],delete H[l.id];W=null,h()}),m("cancel-feedback",()=>{if(l)delete H[l.id];if(F)k=!0,F=!1;h(),ni("revise")}),o.querySelector("#feedback-form")?.addEventListener("submit",(i)=>{i.preventDefault(),bi("revise")}),o.querySelector("#feedback")?.addEventListener("keydown",(i)=>{let r=i;if(r.key==="Enter"&&(r.metaKey||r.ctrlKey)&&!r.isComposing)r.preventDefault(),r.stopPropagation(),bi("revise")}),m("undo-decision",()=>{if(!W||J||Q)return;let i=W;if(i.previous)n.decisions[i.id]=i.previous;else delete n.decisions[i.id];delete H[i.id],d=i.id,M=!1,N=!1,y="component",g="all",W=null,h(),ni("approve")}),m("overlay",()=>{Y=!Y,h()}),m("isolated",()=>{R="isolated",h()}),m("context",()=>{R="context",h()}),o.querySelector("#zoom")?.addEventListener("change",(i)=>{let r=i.target.value;_=r==="fit"?"fit":Number(r),h()}),m("background-checker",()=>{ei="checker",h()}),m("background-page",()=>{ei="page",h()}),m("show-all",()=>{U=!U,k=!0,h()}),m("toggle-tray",()=>{F=!1,k=!k,h()}),m("show-comp",()=>{y="comp",h()}),m("show-component",()=>{y="component",h()}),m("mark",()=>{V=!V,h()}),m("add-box",()=>qi({x:0.35,y:0.35,w:0.2,h:0.2})),m("remove-missing",()=>{n.missing=n.missing.filter((i)=>i.id!==d),d=e.components[0]?.id,h()}),m("approve-rest",()=>{if(P)return;n=Ci(e,n),W=null,M=!0,y="component",h(),ni("review-summary")}),o.querySelector("#inventory-confirm")?.addEventListener("change",(i)=>{n.inventoryConfirmed=i.target.checked,h()}),o.querySelector("#feedback")?.addEventListener("input",(i)=>{if(l&&H[l.id])H[l.id].feedback=i.target.value}),o.querySelector("#split")?.addEventListener("change",(i)=>{if(l&&H[l.id])H[l.id].split=i.target.checked}),o.querySelector("#missing-name")?.addEventListener("input",(i)=>{if(w)w.name=i.target.value;let r=o.querySelector("#submit");if(r)r.disabled=P||J||Q||!hi(e,n).canSubmit}),o.querySelector("#missing-feedback")?.addEventListener("input",(i)=>{if(w)w.feedback=i.target.value}),o.querySelectorAll("[data-coordinate]").forEach((i)=>i.addEventListener("change",()=>{if(!w)return;let r=i.dataset.coordinate,f=Number(i.value)/100;if(Number.isFinite(f))w.box[r]=Math.max(r==="w"||r==="h"?0.001:0,Math.min(1,f));w.box.w=Math.min(w.box.w,1-w.box.x),w.box.h=Math.min(w.box.h,1-w.box.y),h()})),m("submit",async()=>{if(P||J||Q)return;J=!0,si="",h();try{await a.onSubmit(Fi(e,n)),Q=!0}catch(i){si=i instanceof Error?i.message:"Could not save. Try again."}finally{J=!1,h()}});let A=o.querySelector(".map");function Yi(i){let r=A.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))}}A.addEventListener("pointerdown",(i)=>{if(!V)return;L=Yi(i),A.setPointerCapture(i.pointerId),i.preventDefault()}),A.addEventListener("pointermove",(i)=>{if(!L)return;let r=Yi(i);T={x:Math.min(L.x,r.x),y:Math.min(L.y,r.y),w:Math.abs(r.x-L.x),h:Math.abs(r.y-L.y)};let f=o.querySelector(".draw-box");f.hidden=!1,f.style.cssText=Mi(T)}),A.addEventListener("pointerup",()=>{if(T&&T.w>0.01&&T.h>0.01)qi(T);else L=null,T=null}),A.addEventListener("pointercancel",()=>{L=null,T=null,h()});let Ui=o.querySelector(".output"),li=o.querySelector("iframe"),Zi=o.querySelector(".workbench"),ii=o.querySelector(".inspection-content"),vi=o.querySelector(".inspector");function yi(){if(!ii||!vi)return;vi.dataset.scrollAbove=String(ii.scrollTop>1),vi.dataset.scrollBelow=String(ii.scrollHeight-ii.clientHeight-ii.scrollTop>1)}ii?.addEventListener("scroll",yi,{passive:!0});function Ii(){let i=o.querySelector(".map-space");if(i&&i.clientWidth&&i.clientHeight){let z=ki(e.comp.width,e.comp.height,Math.max(1,i.clientWidth-32),Math.max(1,i.clientHeight-32),"fit");A.style.width=`${z.width}px`,A.style.height=`${z.height}px`}let r=o.querySelector(".inspection-content"),f=Array.from(o.querySelectorAll(".pan-viewport"));if(r?.clientHeight)f.forEach((z)=>z.style.height=`${Math.min(248,Math.max(100,r.clientHeight-40))}px`);if(t&&f.length){let z=ki(t.box.w*E.comp.width,t.box.h*E.comp.height,Math.min(...f.map((G)=>G.clientWidth)),Math.min(...f.map((G)=>G.clientHeight)),_);o.querySelectorAll(".crop-stage").forEach((G)=>{G.style.width=`${z.width}px`,G.style.height=`${z.height}px`})}if(Ui&&li&&t){let z=Ui.clientWidth/(t.box.w*E.comp.width);li.style.transform=`scale(${z})`,li.style.left=`${-t.box.x*E.comp.width*z}px`,li.style.top=`${-t.box.y*E.comp.height*z}px`}let j=Zi.getBoundingClientRect(),Bi=o.querySelector(".region"),Di=o.querySelector(".number"),Oi=o.querySelector(".connector path");if(Bi&&Di&&Oi){let z=Bi.getBoundingClientRect(),G=Di.getBoundingClientRect(),be=z.right-j.left,me=z.top+z.height/2-j.top,Ri=G.left-j.left-8,we=G.top+G.height/2-j.top;Oi.setAttribute("d",`M ${be} ${me} H ${Ri-14} V ${we} H ${Ri}`)}}oi=new ResizeObserver(()=>{Ii(),yi()}),oi.observe(Zi);let Ai=o.querySelector(".inspection-content");if(Ai)oi.observe(Ai);Ii();let zi=Array.from(o.querySelectorAll(".pan-viewport"));if(zi.forEach((i)=>{i.scrollLeft=le,i.scrollTop=de}),zi.forEach((i)=>i.addEventListener("scroll",()=>{for(let r of zi)if(r!==i){if(r.scrollLeft!==i.scrollLeft)r.scrollLeft=i.scrollLeft;if(r.scrollTop!==i.scrollTop)r.scrollTop=i.scrollTop}})),ae&&window.matchMedia("(max-width:800px)").matches){let i=o.querySelector(".inspection-content"),r=o.querySelector(".compare");if(i&&r)i.scrollTop+=r.getBoundingClientRect().top-i.getBoundingClientRect().top}yi(),window.scrollTo(ri,ti)}return h(),{destroy(){oi?.disconnect(),o.replaceChildren()},getDraft(){return structuredClone(n)}}}async function ye(){let p=document.getElementById("review"),e=await fetch("/packet",{cache:"no-store"});if(!e.ok)throw Error("The review packet could not be loaded. Reload to retry.");let a=await e.json();if(a.sourceStatus){let o=document.createElement("p");o.textContent=a.sourceStatus,p.before(o)}oe(p,a.packet,{initialDraft:a.draft,history:a.history,completed:!!a.receipt,onSubmit:async(o)=>{let n=await fetch("/decision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)}),c=await n.json();if(!n.ok)throw Error(c.error??"The review could not be saved. Try again.")}})}ye().catch((p)=>{let e=document.createElement("p");e.textContent=p instanceof Error?p.message:String(p),document.getElementById("review")?.replaceChildren(e)});})(); +

Components

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

${f==="attention"?"Every component is approved. Confirm the map is complete, then continue.":"No components approved yet."}

`:""}
+

${x(jo)}

+ `;let H=r.querySelector("#comparison-dialog"),K=r.querySelector(".comparison-panel"),ri=r.querySelector(".comparison-slot");if($&&K&&ri)ri.style.height=`${bo}px`,H.append(K),H.showModal();if(A)r.querySelectorAll(".decisions button,#feedback,#split,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#submit,#inventory-confirm").forEach((i)=>i.disabled=!0);if(r.querySelector(".inspection-content").scrollTop=wo,Y||L)r.querySelectorAll(".decisions button,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#mark,#inventory-confirm,#missing-name,#missing-feedback,#feedback,#split,#remove-missing,[data-coordinate]").forEach((i)=>i.disabled=!0);if(r.querySelector(".inventory").scrollLeft=Vi,!si&&M)Array.from(r.querySelectorAll(".inventory [data-select]")).find((i)=>i.dataset.select===h)?.scrollIntoView({block:"nearest",inline:"nearest"});if(w)r.getElementById(w)?.focus({preventScroll:!0});else if(X)Array.from(r.querySelectorAll(".item[data-select]")).find((i)=>i.dataset.select===X)?.focus({preventScroll:!0});let b=(i,e)=>r.querySelector(`#${i}`)?.addEventListener("click",e);r.querySelectorAll("[data-select]").forEach((i)=>i.onclick=()=>{if(_)return;J=!1,h=i.dataset.select,q="component",B=!1,W="fit",ii="isolated",T=!1,g()}),b("previous-round",()=>{T=!0,g()}),b("current-round",()=>{T=!1,g()}),b("review-changes",()=>{let i=c().filter((u)=>hi(u).kind==="pending"),e=i.findIndex((u)=>u.id===h),l=i[(e+1)%i.length];if(l)J=!1,h=l.id,q="component",f="attention",T=!1,W="fit",B=!1,ii="isolated",g()}),r.querySelectorAll("[data-filter]").forEach((i)=>i.onclick=()=>{J=!1,f=i.dataset.filter;let e=c().filter((u)=>f==="all"||hi(u).kind==="approved"===(f==="approved"));if(!(f!=="approved"&&n.missing.some((u)=>u.id===h))&&!e.some((u)=>u.id===h)&&e.length)h=e[0].id,q="component",T=!1,W="fit",B=!1,ii="isolated";g()}),b("approve",()=>Hi("approve")),b("revise",so),b("clear",()=>{if(d)delete n.decisions[d.id],delete V[d.id];U=null,g()}),b("cancel-feedback",()=>{if(d)delete V[d.id];if(ni)M=!0,ni=!1;g(),li("revise")}),r.querySelector("#feedback-form")?.addEventListener("submit",(i)=>{i.preventDefault(),Hi("revise")}),r.querySelector("#feedback")?.addEventListener("keydown",(i)=>{let e=i;if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)&&!e.isComposing)e.preventDefault(),e.stopPropagation(),Hi("revise")}),b("undo-decision",()=>{if(!U||L||Y)return;let i=U;if(i.previous)n.decisions[i.id]=i.previous;else delete n.decisions[i.id];delete V[i.id],h=i.id,J=!1,T=!1,q="component",f="all",U=null,g(),li("approve")}),b("overlay",()=>{B=!B,g()}),b("isolated",()=>{ii="isolated",g()}),b("context",()=>{ii="context",g()}),r.querySelector("#zoom")?.addEventListener("change",(i)=>{let e=i.target.value;W=e==="fit"?"fit":Number(e),g()}),b("background-checker",()=>{xi="checker",g()}),b("background-page",()=>{xi="page",g()}),b("show-all",()=>{O=!O,M=!0,g()}),b("toggle-tray",()=>{ni=!1,M=!M,g()}),b("show-comp",()=>{q="comp",g()}),b("show-component",()=>{q="component",g()}),b("mark",()=>{_=!_,g()}),b("add-box",()=>Xi({x:0.35,y:0.35,w:0.2,h:0.2})),b("remove-missing",()=>{n.missing=n.missing.filter((i)=>i.id!==h),h=o.components[0]?.id,g()}),b("approve-rest",()=>{if(ai)return;n=po(o,n),U=null,J=!0,q="component",g(),li("review-summary")}),r.querySelector("#inventory-confirm")?.addEventListener("change",(i)=>{n.inventoryConfirmed=i.target.checked,g()}),r.querySelector("#feedback")?.addEventListener("input",(i)=>{if(d&&V[d.id])V[d.id].feedback=i.target.value}),r.querySelector("#split")?.addEventListener("change",(i)=>{if(d&&V[d.id])V[d.id].split=i.target.checked}),r.querySelector("#missing-name")?.addEventListener("input",(i)=>{if(v)v.name=i.target.value;let e=r.querySelector("#submit");if(e)e.disabled=ai||L||Y||!zi(o,n).canSubmit}),r.querySelector("#missing-feedback")?.addEventListener("input",(i)=>{if(v)v.feedback=i.target.value}),r.querySelectorAll("[data-coordinate]").forEach((i)=>i.addEventListener("change",()=>{if(!v)return;let e=i.dataset.coordinate,l=Number(i.value)/100;if(Number.isFinite(l))v.box[e]=Math.max(e==="w"||e==="h"?0.001:0,Math.min(1,l));v.box.w=Math.min(v.box.w,1-v.box.x),v.box.h=Math.min(v.box.h,1-v.box.y),g()})),b("submit",async()=>{if(ai||L||Y)return;L=!0,ji="",g();try{await a.onSubmit(ao(o,n)),Y=!0}catch(i){ji=i instanceof Error?i.message:"Could not save. Try again."}finally{L=!1,g()}});let S=r.querySelector(".map");function Pi(i){let e=S.getBoundingClientRect();return{x:Math.max(0,Math.min(1,(i.clientX-e.left)/e.width)),y:Math.max(0,Math.min(1,(i.clientY-e.top)/e.height))}}S.addEventListener("pointerdown",(i)=>{if(!_)return;Z=Pi(i),S.setPointerCapture(i.pointerId),i.preventDefault()}),S.addEventListener("pointermove",(i)=>{if(!Z)return;let e=Pi(i);I={x:Math.min(Z.x,e.x),y:Math.min(Z.y,e.y),w:Math.abs(e.x-Z.x),h:Math.abs(e.y-Z.y)};let l=r.querySelector(".draw-box");l.hidden=!1,l.style.cssText=Ti(I)}),S.addEventListener("pointerup",()=>{if(I&&I.w>0.01&&I.h>0.01)Xi(I);else Z=null,I=null}),S.addEventListener("pointercancel",()=>{Z=null,I=null,g()});let io=r.querySelector(".output"),ci=r.querySelector("iframe"),oo=r.querySelector(".workbench"),ti=r.querySelector(".inspection-content"),Ni=r.querySelector(".inspector");function $i(){if(!ti||!Ni)return;Ni.dataset.scrollAbove=String(ti.scrollTop>1),Ni.dataset.scrollBelow=String(ti.scrollHeight-ti.clientHeight-ti.scrollTop>1)}ti?.addEventListener("scroll",$i,{passive:!0});function wi(){let i=r.querySelector(".map-space");if(i&&i.clientWidth&&i.clientHeight){let m=Ki(o.comp.width,o.comp.height,Math.max(1,i.clientWidth-32),Math.max(1,i.clientHeight-32),"fit");S.style.width=`${m.width}px`,S.style.height=`${m.height}px`}let e=r.querySelector(".inspection-content"),l=Array.from(r.querySelectorAll(".pan-viewport"));if(e?.clientHeight){let m=$?Math.max(100,H.clientHeight-(K?.querySelector(".compare-toolbar")?.clientHeight??0)-(K?.querySelector(".expanded-title")?.clientHeight??0)-(K?.querySelector(".view-controls")?.clientHeight??0)-112):Math.min(248,Math.max(100,e.clientHeight-40));l.forEach((z)=>z.style.height=`${m}px`)}if(t&&l.length){let m=Ki(t.box.w*G.comp.width,t.box.h*G.comp.height,Math.min(...l.map((z)=>z.clientWidth)),Math.min(...l.map((z)=>z.clientHeight)),W);r.querySelectorAll(".crop-stage").forEach((z)=>{z.style.width=`${m.width}px`,z.style.height=`${m.height}px`})}if(l.forEach((m)=>{let z=m.scrollWidth>m.clientWidth+1||m.scrollHeight>m.clientHeight+1;m.classList.toggle("pannable",z),m.title=z?"Move your pointer to pan. You can also scroll, swipe, or use arrow keys.":""}),io&&ci&&t){let m=io.clientWidth/(t.box.w*G.comp.width);ci.style.transform=`scale(${m})`,ci.style.left=`${-t.box.x*G.comp.width*m}px`,ci.style.top=`${-t.box.y*G.comp.height*m}px`}let u=oo.getBoundingClientRect(),Q=r.querySelector(".region"),bi=r.querySelector(".number"),ui=r.querySelector(".connector path");if(Q&&bi&&ui){let m=Q.getBoundingClientRect(),z=bi.getBoundingClientRect(),k=m.right-u.left,vi=m.top+m.height/2-u.top,ro=z.left-u.left-8,Mo=z.top+z.height/2-u.top;ui.setAttribute("d",`M ${k} ${vi} H ${ro-14} V ${Mo} H ${ro}`)}}function Gi(i){if(!K||!ri||i===$)return;let e=r.querySelector("#expand-comparison"),l=($?H:K).getBoundingClientRect(),u=window.matchMedia("(prefers-reduced-motion: reduce)").matches,Q=K.querySelector(".pan-viewport"),bi=Q?Q.scrollLeft/Math.max(1,Q.scrollWidth-Q.clientWidth):0,ui=Q?Q.scrollTop/Math.max(1,Q.scrollHeight-Q.clientHeight):0,m=()=>K.querySelectorAll(".pan-viewport").forEach((k)=>{k.scrollLeft=bi*Math.max(0,k.scrollWidth-k.clientWidth),k.scrollTop=ui*Math.max(0,k.scrollHeight-k.clientHeight)}),z=()=>{ri.append(K),H.close(),ri.style.height="",$=!1,e.innerHTML=E("expand"),e.setAttribute("aria-label","Enlarge comparison"),e.title="Enlarge comparison",e.setAttribute("aria-expanded","false"),wi(),m(),e.focus({preventScroll:!0})};if(i){ri.style.height=`${l.height}px`,H.append(K),H.showModal(),$=!0,e.innerHTML=E("compact"),e.setAttribute("aria-label","Restore comparison"),e.title="Restore comparison (Esc)",e.setAttribute("aria-expanded","true"),wi(),m(),e.focus({preventScroll:!0});let k=H.getBoundingClientRect();if(!u)H.animate([{transform:`translate(${l.x-k.x}px,${l.y-k.y}px) scale(${l.width/k.width},${l.height/k.height})`,opacity:0.6},{transform:"none",opacity:1}],{duration:240,easing:"cubic-bezier(.2,.8,.2,1)"})}else{if(e.disabled)return;let k=ri.getBoundingClientRect();if(u){z();return}e.disabled=!0;let vi=H.animate([{transform:"none",opacity:1},{transform:`translate(${k.x-l.x}px,${k.y-l.y}px) scale(${k.width/l.width},${k.height/l.height})`,opacity:0.6}],{duration:200,easing:"cubic-bezier(.4,0,.2,1)",fill:"forwards"});vi.finished.then(()=>{if(H.isConnected)vi.cancel(),e.disabled=!1,z()}).catch(()=>{})}}b("expand-comparison",()=>Gi(!$)),H.addEventListener("cancel",(i)=>{i.preventDefault(),i.stopPropagation(),Gi(!1)}),H.addEventListener("keydown",(i)=>{if(i.key==="Escape")i.preventDefault(),i.stopPropagation(),Gi(!1)}),pi=new ResizeObserver(()=>{wi(),$i()}),pi.observe(oo),pi.observe(H);let eo=r.querySelector(".inspection-content");if(eo)pi.observe(eo);wi();let mi=Array.from(r.querySelectorAll(".pan-viewport"));if(mi.forEach((i)=>{i.scrollLeft=uo,i.scrollTop=vo}),mi.forEach((i)=>{i.querySelectorAll("img").forEach((e)=>e.draggable=!1),i.addEventListener("pointermove",(e)=>{if(e.pointerType!=="mouse"||e.buttons||!i.classList.contains("pannable"))return;let l=i.getBoundingClientRect();i.scrollLeft=Qi(e.clientX,l.left,i.clientWidth,i.scrollWidth),i.scrollTop=Qi(e.clientY,l.top,i.clientHeight,i.scrollHeight)}),i.addEventListener("keydown",(e)=>{let u={ArrowLeft:[-48,0],ArrowRight:[48,0],ArrowUp:[0,-48],ArrowDown:[0,48]}[e.key];if(!u)return;e.preventDefault(),e.stopPropagation(),i.scrollLeft+=u[0],i.scrollTop+=u[1]})}),mi.forEach((i)=>i.addEventListener("scroll",()=>{for(let e of mi)if(e!==i){if(e.scrollLeft!==i.scrollLeft)e.scrollLeft=i.scrollLeft;if(e.scrollTop!==i.scrollTop)e.scrollTop=i.scrollTop}})),co&&window.matchMedia("(max-width:800px)").matches){let i=r.querySelector(".inspection-content"),e=r.querySelector(".compare");if(i&&e)i.scrollTop+=e.getBoundingClientRect().top-i.getBoundingClientRect().top}$i(),window.scrollTo(di,fi)}return g(),{destroy(){pi?.disconnect(),r.replaceChildren()},getDraft(){return structuredClone(n)}}}async function Eo(){let p=document.getElementById("review"),o=await fetch("/packet",{cache:"no-store"});if(!o.ok)throw Error("The review packet could not be loaded. Reload to retry.");let a=await o.json();if(a.sourceStatus){let r=document.createElement("p");r.textContent=a.sourceStatus,p.before(r)}ho(p,a.packet,{initialDraft:a.draft,history:a.history,completed:!!a.receipt,onSubmit:async(r)=>{let n=await fetch("/decision",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),c=await n.json();if(!n.ok)throw Error(c.error??"The review could not be saved. Try again.")}})}Eo().catch((p)=>{let o=document.createElement("p");o.textContent=p instanceof Error?p.message:String(p),document.getElementById("review")?.replaceChildren(o)});})(); diff --git a/ui/component-review/review.ts b/ui/component-review/review.ts index 1bbcc79b8..b7f607091 100644 --- a/ui/component-review/review.ts +++ b/ui/component-review/review.ts @@ -1,5 +1,5 @@ import { componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model'; -import { comparisonSize } from './viewport'; +import { comparisonSize, hoverPan } from './viewport'; import { styles } from './styles'; import { icon } from './icons'; @@ -28,6 +28,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op let lastDecision: {id: string; name: string; action: 'approve' | 'revise'; previous?: Decision} | null = null; const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘Enter' : 'Ctrl+Enter'; let overlay = false; + let expandedComparison = false; let showAll = false; let trayOpen = true; let restoreTrayAfterFeedback = false; @@ -102,6 +103,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op renderedMobilePane=mobilePane; const inspectorTop=keepInspector?(root.querySelector('.inspection-content')?.scrollTop??0):0; const filesOpen=keepInspector&&(root.querySelector('.changed-files')?.open??false); + const oldComparisonHeight=root.querySelector('.comparison-slot')?.clientHeight ?? 200; const oldPane=root.querySelector('.pan-viewport'); const retainPan=renderedSelection===selected&&renderedZoom===zoom; const panLeft=retainPan?(oldPane?.scrollLeft??0):0, panTop=retainPan?(oldPane?.scrollTop??0):0; @@ -171,13 +173,13 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op ${repair?.change?.kind==='changed'?`
${repair.change.files.length?`${repair.change.files.length} changed ${repair.change.files.length===1?'file':'files'}`:repair.change.reasons.includes('region')?'Region changed':priorComponent?.note!==c.note?'Description changed · files unchanged':'Component definition changed · files unchanged'}${repair.change.files.length?`
    ${repair.change.files.map(path=>`
  • ${esc(path)}
  • `).join('')}
`:''}${priorComponent&&priorComponent.note!==c.note?`
Previous description
${esc(priorComponent.note)}
Current description
${esc(c.note)}
`:''}
`:''} `:''} ${priorComponent?`
`:''} -
+

${esc(v!.name)}

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

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

+

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

${esc(v!.note)}

${notice}${viewingPrevious?'

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

':''}
Your review Round ${packet.round}${viewingPrevious?'

Return to Current to review this round.

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

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

` : ''} @@ -187,7 +189,14 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op

Components

${shownComponents.map(item=>{const i=packet.components.indexOf(item);const state=stateFor(item); return ``}).join('')}${(inventoryFilter==='approved'?[]:draft.missing).map((m,i)=>``).join('')}${!shownComponents.length&&(inventoryFilter==='approved'||!draft.missing.length)?`

${inventoryFilter==='attention'?'Every component is approved. Confirm the map is complete, then continue.':'No components approved yet.'}

`:''}

${esc(statusMessage)}

- `; + `; + const comparisonDialog=root.querySelector('#comparison-dialog')!; + const comparisonPanel=root.querySelector('.comparison-panel'); + const comparisonSlot=root.querySelector('.comparison-slot'); + if(expandedComparison && comparisonPanel && comparisonSlot){ + comparisonSlot.style.height=`${oldComparisonHeight}px`; + comparisonDialog.append(comparisonPanel);comparisonDialog.showModal(); + } if(viewingPrevious)root.querySelectorAll('.decisions button,#feedback,#split,#save-feedback,#cancel-feedback,#undo-decision,#approve-rest,#submit,#inventory-confirm').forEach(el=>el.disabled=true); root.querySelector('.inspection-content')!.scrollTop=inspectorTop; if(submitted||sending)root.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(el=>el.disabled=true); @@ -271,16 +280,67 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op } const content=root.querySelector('.inspection-content'); const panes=Array.from(root.querySelectorAll('.pan-viewport')); - if(content?.clientHeight)panes.forEach(p=>p.style.height=`${Math.min(248,Math.max(100,content.clientHeight-40))}px`); + if(content?.clientHeight){ + const height=expandedComparison + ? Math.max(100,comparisonDialog.clientHeight-(comparisonPanel?.querySelector('.compare-toolbar')?.clientHeight??0)-(comparisonPanel?.querySelector('.expanded-title')?.clientHeight??0)-(comparisonPanel?.querySelector('.view-controls')?.clientHeight??0)-112) + : Math.min(248,Math.max(100,content.clientHeight-40)); + panes.forEach(p=>p.style.height=`${height}px`); + } if(v&&panes.length){const size=comparisonSize(v.box.w*vp.comp.width,v.box.h*vp.comp.height,Math.min(...panes.map(p=>p.clientWidth)),Math.min(...panes.map(p=>p.clientHeight)),zoom);root.querySelectorAll('.crop-stage').forEach(el=>{el.style.width=`${size.width}px`;el.style.height=`${size.height}px`;});} + panes.forEach(p=>{const pannable=p.scrollWidth>p.clientWidth+1||p.scrollHeight>p.clientHeight+1;p.classList.toggle('pannable',pannable);p.title=pannable?'Move your pointer to pan. You can also scroll, swipe, or use arrow keys.':'';}); if(stage&&frame&&v){const s=stage.clientWidth/(v.box.w*vp.comp.width);frame.style.transform=`scale(${s})`;frame.style.left=`${-v.box.x*vp.comp.width*s}px`;frame.style.top=`${-v.box.y*vp.comp.height*s}px`;} const bounds=workbench.getBoundingClientRect();const region=root.querySelector('.region');const end=root.querySelector('.number'); const path=root.querySelector('.connector path'); if(region&&end&&path){const a=region.getBoundingClientRect(),b=end.getBoundingClientRect();const x1=a.right-bounds.left,y1=a.top+a.height/2-bounds.top,x2=b.left-bounds.left-8,y2=b.top+b.height/2-bounds.top;path.setAttribute('d',`M ${x1} ${y1} H ${x2-14} V ${y2} H ${x2}`);} } - resize=new ResizeObserver(()=>{resizePreview();updateScrollEdges();});resize.observe(workbench);const content=root.querySelector('.inspection-content');if(content)resize.observe(content);resizePreview(); + function setComparisonExpanded(open: boolean) { + if(!comparisonPanel||!comparisonSlot||open===expandedComparison)return; + const button=root.querySelector('#expand-comparison')!; + const before=(expandedComparison?comparisonDialog:comparisonPanel).getBoundingClientRect(); + const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const currentPane=comparisonPanel.querySelector('.pan-viewport'); + const px=currentPane ? currentPane.scrollLeft/Math.max(1,currentPane.scrollWidth-currentPane.clientWidth) : 0; + const py=currentPane ? currentPane.scrollTop/Math.max(1,currentPane.scrollHeight-currentPane.clientHeight) : 0; + const restorePan=()=>comparisonPanel.querySelectorAll('.pan-viewport').forEach(p=>{p.scrollLeft=px*Math.max(0,p.scrollWidth-p.clientWidth);p.scrollTop=py*Math.max(0,p.scrollHeight-p.clientHeight);}); + const restore=()=>{ + comparisonSlot.append(comparisonPanel);comparisonDialog.close();comparisonSlot.style.height=''; + expandedComparison=false;button.innerHTML=icon('expand');button.setAttribute('aria-label','Enlarge comparison');button.title='Enlarge comparison';button.setAttribute('aria-expanded','false'); + resizePreview();restorePan();button.focus({preventScroll:true}); + }; + if(open){ + comparisonSlot.style.height=`${before.height}px`;comparisonDialog.append(comparisonPanel);comparisonDialog.showModal();expandedComparison=true; + button.innerHTML=icon('compact');button.setAttribute('aria-label','Restore comparison');button.title='Restore comparison (Esc)';button.setAttribute('aria-expanded','true'); + resizePreview();restorePan();button.focus({preventScroll:true}); + const after=comparisonDialog.getBoundingClientRect(); + if(!reduced)comparisonDialog.animate([{transform:`translate(${before.x-after.x}px,${before.y-after.y}px) scale(${before.width/after.width},${before.height/after.height})`,opacity:.6},{transform:'none',opacity:1}],{duration:240,easing:'cubic-bezier(.2,.8,.2,1)'}); + }else{ + if(button.disabled)return; + const target=comparisonSlot.getBoundingClientRect(); + if(reduced){restore();return;} + button.disabled=true; + const animation=comparisonDialog.animate([{transform:'none',opacity:1},{transform:`translate(${target.x-before.x}px,${target.y-before.y}px) scale(${target.width/before.width},${target.height/before.height})`,opacity:.6}],{duration:200,easing:'cubic-bezier(.4,0,.2,1)',fill:'forwards'}); + animation.finished.then(()=>{if(comparisonDialog.isConnected){animation.cancel();button.disabled=false;restore();}}).catch(()=>{}); + } + } + on('expand-comparison',()=>setComparisonExpanded(!expandedComparison)); + comparisonDialog.addEventListener('cancel',e=>{e.preventDefault();e.stopPropagation();setComparisonExpanded(false);}); + comparisonDialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();setComparisonExpanded(false);}}); + resize=new ResizeObserver(()=>{resizePreview();updateScrollEdges();});resize.observe(workbench);resize.observe(comparisonDialog);const content=root.querySelector('.inspection-content');if(content)resize.observe(content);resizePreview(); const panes=Array.from(root.querySelectorAll('.pan-viewport')); panes.forEach(pane=>{pane.scrollLeft=panLeft;pane.scrollTop=panTop;}); + panes.forEach(pane=>{ + pane.querySelectorAll('img').forEach(img=>img.draggable=false); + pane.addEventListener('pointermove',e=>{ + if(e.pointerType!=='mouse'||e.buttons||!pane.classList.contains('pannable'))return; + const rect=pane.getBoundingClientRect(); + pane.scrollLeft=hoverPan(e.clientX,rect.left,pane.clientWidth,pane.scrollWidth); + pane.scrollTop=hoverPan(e.clientY,rect.top,pane.clientHeight,pane.scrollHeight); + }); + pane.addEventListener('keydown',e=>{ + const delta:Record={ArrowLeft:[-48,0],ArrowRight:[48,0],ArrowUp:[0,-48],ArrowDown:[0,48]}; + const move=delta[e.key];if(!move)return;e.preventDefault();e.stopPropagation();pane.scrollLeft+=move[0];pane.scrollTop+=move[1]; + }); + }); panes.forEach(pane=>pane.addEventListener('scroll',()=>{for(const other of panes)if(other!==pane){if(other.scrollLeft!==pane.scrollLeft)other.scrollLeft=pane.scrollLeft;if(other.scrollTop!==pane.scrollTop)other.scrollTop=pane.scrollTop;}})); if(focusMobileComparison&&window.matchMedia('(max-width:800px)').matches){const body=root.querySelector('.inspection-content');const comparison=root.querySelector('.compare');if(body&&comparison)body.scrollTop+=comparison.getBoundingClientRect().top-body.getBoundingClientRect().top;} updateScrollEdges(); diff --git a/ui/component-review/styles.ts b/ui/component-review/styles.ts index baf929728..cb1ef08a4 100644 --- a/ui/component-review/styles.ts +++ b/ui/component-review/styles.ts @@ -45,4 +45,15 @@ export const styles = ` .feedback-actions .shortcut-hint{flex-basis:auto;order:-1;margin-right:auto} .feedback-actions .primary{margin-left:0} @media(max-height:800px),(max-width:800px){.feedback-actions .shortcut-hint{display:none}.feedback-actions .primary{margin-left:auto}.review-form .feedback textarea{min-height:56px}.review-form:has(#feedback-form) .decision-notice{margin-bottom:4px}} + +.comparison-panel{min-width:0}.comparison-panel .expanded-title{display:none}.comparison-panel .compare-toolbar{justify-content:flex-start}.comparison-panel .overlay-control{margin-left:auto}.comparison-panel #expand-comparison{flex-shrink:0}.pan-viewport.pannable{cursor:move}.pan-viewport img{user-select:none;-webkit-user-drag:none} +#comparison-dialog{position:fixed;inset:16px;width:calc(100vw - 32px);height:calc(100dvh - 32px);max-width:none;max-height:none;margin:0;padding:24px;background:var(--paper);color:inherit;border:1px solid var(--line);border-radius:8px;box-shadow:0 20px 60px #14292340;overflow:hidden;transform-origin:top left} +#comparison-dialog::backdrop{background:#172821a6} +#comparison-dialog .comparison-panel{height:100%;display:flex;flex-direction:column;gap:12px} +#comparison-dialog .expanded-title{display:block;font-size:20px;line-height:1.3;margin:0;font-weight:500} +#comparison-dialog .compare-toolbar{flex-shrink:0;margin:0} +#comparison-dialog .compare{flex:1;min-height:0;max-width:none;grid-template-columns:minmax(0,1fr) minmax(0,1fr)} +#comparison-dialog .compare figure{display:flex;flex-direction:column;min-height:0}#comparison-dialog .pan-viewport{flex:1;min-height:0}#comparison-dialog .compare figcaption{flex-shrink:0} +#comparison-dialog .view-controls{flex-shrink:0;margin:0} +@media(max-width:600px){#comparison-dialog{inset:6px;width:calc(100vw - 12px);height:calc(100dvh - 12px);padding:14px}.comparison-panel .compare-toolbar{gap:6px}.comparison-panel .overlay-control{padding-inline:8px;font-size:12px}#comparison-dialog .compare{gap:8px}#comparison-dialog .compare figcaption{font-size:11px}} `; diff --git a/ui/component-review/viewport.test.ts b/ui/component-review/viewport.test.ts index 40a7402f6..b9ed6a113 100644 --- a/ui/component-review/viewport.test.ts +++ b/ui/component-review/viewport.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { comparisonSize } from './viewport'; +import { comparisonSize, hoverPan } from './viewport'; test('portrait regions fit the available height without distorting the reference', () => { const size = comparisonSize(768, 922, 210, 248, 'fit'); @@ -11,3 +11,13 @@ test('zoom uses comp pixels, including tiny texture regions and thin controls', expect(comparisonSize(154, 102, 210, 248, 4)).toEqual({scale:4,width:616,height:408}); expect(comparisonSize(1440, 4, 210, 248, 1)).toEqual({scale:1,width:1440,height:4}); }); + +test('hover panning reaches both edges, synchronizes midpoint, and ignores fitted images', () => { + expect(hoverPan(10,10,200,600)).toBe(0); + expect(hoverPan(110,10,200,600)).toBeCloseTo(200); + expect(hoverPan(210,10,200,600)).toBe(400); + expect(hoverPan(-30,10,200,600)).toBe(0); + expect(hoverPan(300,10,200,600)).toBe(400); + expect(hoverPan(110,10,200,150)).toBe(0); + expect(hoverPan(110,10,0,600)).toBe(0); +}); diff --git a/ui/component-review/viewport.ts b/ui/component-review/viewport.ts index dd1f4db37..463c7aa14 100644 --- a/ui/component-review/viewport.ts +++ b/ui/component-review/viewport.ts @@ -3,3 +3,10 @@ export function comparisonSize(width: number, height: number, availableWidth: nu const scale = zoom === 'fit' ? Math.min(availableWidth / width, availableHeight / height) : zoom; return { scale, width: width * scale, height: height * scale }; } + +/** Map pointer position to the full scroll range, reaching both edges. */ +export function hoverPan(position: number, start: number, viewport: number, content: number): number { + if (viewport <= 0 || content <= viewport) return 0; + const ratio = Math.max(0, Math.min(1, ((position - start) / viewport - .08) / .84)); + return ratio * (content - viewport); +}