Group repeated component reviews by default

This commit is contained in:
Paul Bakaus
2026-09-18 18:09:25 -07:00
parent 62e91731c0
commit 37754ce3e1
5 changed files with 118 additions and 31 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ 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 instances of the same component and role the same `reviewGroup` name. Group peers of the same kind, not a container with its contents or unrelated text roles. 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.
For a repeated code pattern, give instances of the same component and role the same `reviewGroup` name. Group peers of the same kind, not a container with its contents or unrelated text roles. Keep every instance and its region ID in the manifest, in the same kit document. The review opens with one item per group and shows its instances together. One explicit group decision applies to its unreviewed instances; the user can open an instance to leave an exception. Existing decisions are preserved. 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.
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { nextUnreviewed, approveRemaining, newDraft, submission, summarize, validBox, type ReviewPacket } from './model';
import { reviewUnits, decisionTargets, nextUnreviewed, approveRemaining, newDraft, submission, summarize, validBox, type ReviewPacket } from './model';
const packet: ReviewPacket = { id:'review-1', revision:'packet-1', title:'Test', round:1, comp:{url:'/comp.png',width:100,height:100}, components:[{id:'art',revision:'art-1',name:'Art',medium:'Raster',note:'',box:{x:0,y:0,w:1,h:1},preview:{kind:'image',url:'/art.png'}},{id:'control',revision:'control-1',name:'Button',medium:'HTML',note:'',box:{x:0,y:0,w:.1,h:.1},preview:{kind:'page',url:'/page.html'}}] };
describe('component review drafts',()=>{
test('bulk approval still requires explicit inventory confirmation',()=>{const draft=approveRemaining(packet,newDraft(packet));expect(summarize(packet,draft).canSubmit).toBe(false);draft.inventoryConfirmed=true;expect(submission(packet,draft).requestId).toBe(packet.id);});
@@ -86,3 +86,29 @@ test('explicit pattern decisions preserve prior decisions, raster reviews and op
expect(draft.decisions.b.feedback).toBe('Keep this specific repair');
expect(reviewPeers(p,packet.components[0])).toEqual([packet.components[0]]);
});
test('review units collapse explicit code groups while retaining individual raster assets',()=>{
const p:ReviewPacket={...packet,components:[...Array.from({length:14},(_,i)=>({...packet.components[1],id:`room-${i}`,reviewGroup:'room-name'})),{...packet.components[0],reviewGroup:'room-name'}]};
const draft=newDraft(p);const units=reviewUnits(p,draft);
expect(units).toHaveLength(2);expect(units[0].members).toHaveLength(14);expect(units[1].members).toHaveLength(1);
expect(units[0].label).toBe('Room name');expect(units[0].pending).toBe(14);
draft.decisions['room-0']={revision:'control-1',action:'revise',feedback:'Fix this one',split:false};
const group=reviewUnits(p,draft)[0];expect(group.id).toBe('room-0');expect(group.representative.id).toBe('room-1');
for(const c of decisionTargets(p,draft,group.representative,true))draft.decisions[c.id]={revision:c.revision,action:'approve',feedback:'',split:false};
expect(draft.decisions['room-0'].feedback).toBe('Fix this one');expect(reviewUnits(p,draft)[0]).toMatchObject({pending:0,kind:'feedback',stateLabel:'1 need work'});
expect(summarize(p,draft)).toMatchObject({approved:13,revisions:1,pending:1});
p.components[2].revision='changed';expect(reviewUnits(p,draft)[0]).toMatchObject({pending:1,kind:'pending'});
});
test('group approval cannot overwrite a reviewed representative',()=>{
const p=structuredClone(packet);
p.components=p.components.slice(0,1);
const original=p.components[0];
original.reviewGroup='repeated'; original.preview={kind:'page',url:'/component.html'};
p.components.push({...original,id:'second'});
const draft=newDraft(p);
draft.decisions[original.id]={revision:original.revision,action:'revise',feedback:'Preserve this exception',split:false};
expect(decisionTargets(p,draft,original,true).map(c=>c.id)).toEqual(['second']);
expect(decisionTargets(p,draft,original,false).map(c=>c.id)).toEqual([original.id]);
});
+27 -2
View File
@@ -105,6 +105,31 @@ export function reviewPeers(packet: ReviewPacket, component: Component): Compone
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)));
const peers = grouped ? reviewPeers(packet, selected) : [selected];
if (peers.length === 1) return [selected];
return peers.filter(c => componentState(c, draft).kind === 'pending' &&
(c.id === selected.id || !editing.includes(c.id)));
}
/** One visible review unit per explicitly authored code pattern. Receipts stay
* per instance, including revision checks and individual exceptions. */
export function reviewUnits(packet: ReviewPacket, draft: Draft, history?: ReviewHistory | null) {
const seen = new Set<string>();
return packet.components.flatMap(component => {
if (seen.has(component.id)) return [];
const members = reviewPeers(packet, component);
members.forEach(c => seen.add(c.id));
const pending = members.filter(c => componentState(c,draft,history).kind === 'pending');
const feedback = members.filter(c => componentState(c,draft,history).kind === 'feedback');
const representative = pending[0] ?? feedback[0] ?? component;
const kind = pending.length ? 'pending' as const : feedback.length ? 'feedback' as const : 'approved' as const;
return [{id:component.id, members, representative, pending:pending.length, kind,
label:members.length > 1 ? component.reviewGroup!.replace(/[-_]+/g,' ').replace(/^./,c=>c.toUpperCase()) : component.name,
box: {x:Math.min(...members.map(c=>c.box.x)),y:Math.min(...members.map(c=>c.box.y)),
w:Math.max(...members.map(c=>c.box.x+c.box.w))-Math.min(...members.map(c=>c.box.x)),
h:Math.max(...members.map(c=>c.box.y+c.box.h))-Math.min(...members.map(c=>c.box.y))},
stateLabel:pending.length ? (members.length>1 ? `${pending.length} to review` : componentState(representative,draft,history).label)
: feedback.length ? (members.length>1 ? `${feedback.length} ${feedback.length===1?'needs':'need'} work` : 'Feedback ready') : 'Approved'}];
});
}
+37 -27
View File
@@ -1,4 +1,4 @@
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 { reviewUnits, 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';
@@ -28,7 +28,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
const edits: Record<string, {feedback: string; split: boolean}> = {};
let finished = !!options.completed || !summarize(packet,draft).pending;
let lastDecision: {id: string; name: string; action: 'approve' | 'revise'; previous: Record<string, Decision | undefined>} | null = null;
let applyGroup = false;
let applyGroup = true;
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘Enter' : 'Ctrl+Enter';
let overlay = false;
let expandedComparison = false;
@@ -58,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'; applyGroup=false;
mobilePane='component'; previousRound=false; overlay=false; zoom='fit'; outputMode='isolated'; applyGroup=true;
inventoryFilter = finished ? 'reviewed' : 'pending';
const showNext = () => {
render();
@@ -79,7 +79,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
function updateDecision(action: 'approve' | 'revise') {
if (sending || submitted || previousRound || closingComparison) return;
const c = packet.components.find(c => c.id === selected);
if (!c) return;
if (!c || !decisionTargets(packet,draft,c,applyGroup,Object.keys(edits)).length) return;
const saved = draft.decisions[c.id];
const current = saved?.revision===c.revision ? saved : undefined;
const targets = decisionTargets(packet,draft,c,applyGroup,Object.keys(edits));
@@ -98,7 +98,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
function beginFeedback() {
if (sending || submitted || previousRound || closingComparison) return;
const c = packet.components.find(c=>c.id===selected);
if (!c) return;
if (!c || !decisionTargets(packet,draft,c,applyGroup,Object.keys(edits)).length) return;
const saved = draft.decisions[c.id];
const current = saved?.revision===c.revision ? saved : undefined;
edits[c.id] ??= {feedback:current?.feedback ?? '',split:current?.split ?? false};
@@ -135,8 +135,11 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
renderedSelection=selected;renderedZoom=zoom;
const c = packet.components.find(c => c.id === selected);
const missing = draft.missing.find(m => m.id === selected);
const box = c?.box ?? missing?.box;
const index = c ? packet.components.indexOf(c) + 1 : packet.components.length + draft.missing.findIndex(m => m.id === selected) + 1;
const units = reviewUnits(packet,draft,options.history);
const unit = units.find(u=>u.members.some(m=>m.id===selected));
const groupOverview = !!unit && unit.members.length>1 && applyGroup;
const box = groupOverview ? unit!.box : c?.box ?? missing?.box;
const index = unit ? units.indexOf(unit)+1 : units.length + draft.missing.findIndex(m => m.id === selected)+1;
const savedDecision = c ? draft.decisions[c.id] : undefined;
const d = savedDecision?.revision === c?.revision ? savedDecision : undefined;
const edit = c ? edits[c.id] : undefined;
@@ -160,8 +163,11 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
const state=componentState(item,draft,history);
return submitted&&state.kind==='feedback'?{...state,label:'Changes requested'}:state;
};
const reviewedCount = stats.approved+stats.revisions+draft.missing.length;
const shownComponents = (inventoryFilter==='pending'?orderedComponents():packet.components).filter(item=>inReviewQueue(item,draft,inventoryFilter));
const pendingUnits = units.filter(u=>u.kind==='pending').length;
const reviewedCount = units.length-pendingUnits+draft.missing.length;
const shownUnits=units.filter(u=>inventoryFilter==='all'||(u.kind==='pending')===(inventoryFilter==='pending'));
const shownComponents=shownUnits.map(u=>u.representative);
const displayName = (id:string) => units.find(u=>u.members.some(c=>c.id===id))?.label ?? id;
const summaryDetails = [
stats.revisions+draft.missing.length ? `${stats.revisions+draft.missing.length} feedback ready` : '',
carriedCount ? `${carriedCount} ${carriedCount===1?'approval':'approvals'} kept` : '',
@@ -169,7 +175,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
addedCount ? `${addedCount} added` : '',
history?.removed.length ? `${history.removed.length} removed` : '',
].filter(Boolean).join(' · ');
const statusMessage = error || (uncommitted ? 'Save or cancel your open feedback before sending.' : submitted ? (options.preview ? 'Preview submitted. No run changed.' : 'Review submitted.') : stats.hasFeedback ? 'Ready to send for corrections.' : stats.pending ? `${stats.pending} left to review` : !draft.inventoryConfirmed ? 'Confirm the map is complete.' : 'Ready to continue.');
const statusMessage = error || (uncommitted ? 'Save or cancel your open feedback before sending.' : submitted ? (options.preview ? 'Preview submitted. No run changed.' : 'Review submitted.') : stats.hasFeedback ? 'Ready to send for corrections.' : stats.pending ? `${pendingUnits} left to review` : !draft.inventoryConfirmed ? 'Confirm the map is complete.' : 'Ready to continue.');
const presentation = v ? componentPresentation(v) : null;
const isRaster = v?.preview.kind === 'image' && !presentation?.code;
const hasTransparency = isRaster || v?.material?.alpha === 'transparent';
@@ -180,7 +186,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
root.innerHTML = `<style>${styles}</style><section class="review ${assembled?'assembled-review':''}" aria-label="${assembled?'Assembled page review':'Component review'}" style="--comp-background:${/^#[0-9a-f]{6}$/i.test(packet.comp.background ?? '') ? packet.comp.background : '#eeeeee'}">
<header><div><h1>${submitted?'Review record.':assembled?'Review the assembled page.':'Review the components.'}</h1><p>${esc(packet.title)} <span>· Round ${packet.round}</span></p></div>${submitted ? '<span class="badge">Submitted · read-only</span>' : options.preview ? '<span class="badge">Interactive preview</span>' : ''}</header>
${options.preview ? '<p class="preview-note">Historical hotel artwork for testing this interface. Decisions stay in this preview; no run is changed.</p>' : ''}
${history && !assembled ? `<section class="round-summary" aria-label="Changes since previous round"><p><strong>${stats.pending} ${stats.pending===1?'component':'components'} to review</strong><span>${summaryDetails}</span></p>${stats.pending?`<button id="review-changes" class="icon-button" aria-label="Next to review" title="Next to review">${icon('next')}</button>`:''}${history.removed.length?`<details><summary>Removed from the map</summary><p>${history.removed.map(item=>esc(item.name)).join(' · ')}. Confirm these omissions are intentional before accepting the map.</p></details>`:''}</section>`:''}
${history && !assembled ? `<section class="round-summary" aria-label="Changes since previous round"><p><strong>${pendingUnits} ${pendingUnits===1?'item':'items'} to review</strong><span>${summaryDetails}</span></p>${stats.pending?`<button id="review-changes" class="icon-button" aria-label="Next to review" title="Next to review">${icon('next')}</button>`:''}${history.removed.length?`<details><summary>Removed from the map</summary><p>${history.removed.map(item=>esc(item.name)).join(' · ')}. Confirm these omissions are intentional before accepting the map.</p></details>`:''}</section>`:''}
${!assembled?`<div class="mobile-panes" role="group" aria-label="Inspection view"><button id="show-comp" aria-pressed="${mobilePane==='comp'}">Approved comp</button><button id="show-component" aria-pressed="${mobilePane==='component'}">Component ${index}</button></div>`:''}
<div class="workbench" data-mobile-pane="${mobilePane}">${!assembled?`<svg class="connector" aria-hidden="true"><path /></svg>
<section class="reference" aria-label="Approved composition">
@@ -188,8 +194,9 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
<div class="map-space"><div class="map ${marking ? 'marking' : ''}" style="aspect-ratio:${packet.comp.width}/${packet.comp.height}">
<img class="comp" src="${url(packet.comp.url)}" alt="Approved composition for ${esc(packet.title)}" draggable="false">
${box && !finished ? `<div class="region" style="${boxStyle(box)}"></div>` : ''}
${packet.components.map((item,i) => {const state=stateFor(item);return `<button class="pin ${state.kind} ${!finished && selected === item.id ? 'selected' : ''}" data-select="${esc(item.id)}" style="left:${pct(Math.min(.96, item.box.x+item.box.w/2))};top:${pct(Math.max(.035,item.box.y))}" aria-label="Inspect ${esc(item.name)}${esc(state.label)}" title="${i+1}. ${esc(item.name)} · ${esc(state.label)}" aria-pressed="${!finished && selected === item.id}">${state.kind==='approved'?checkIcon:state.kind==='feedback'?feedbackIcon:''}<span>${i+1}</span></button>`}).join('')}
${draft.missing.map((item,i)=>`<button class="pin feedback ${!finished&&selected===item.id?'selected':''}" data-select="${esc(item.id)}" style="left:${pct(item.box.x+item.box.w/2)};top:${pct(item.box.y)}" aria-label="Inspect missing ${esc(item.name)}" title="Missing: ${esc(item.name)}">${feedbackIcon}<span>${packet.components.length+i+1}</span></button>`).join('')}
${units.map((u,i) => {const item=u.representative;const state={kind:u.kind,label:u.stateLabel};const active=!finished&&unit?.id===u.id;return `<button class="pin ${state.kind} ${active?'selected':''}" data-select="${esc(item.id)}" style="left:${pct(Math.min(.96,u.box.x+u.box.w/2))};top:${pct(Math.max(.035,u.box.y))}" aria-label="Inspect ${esc(u.label)}${u.members.length>1?` · ${u.members.length} instances`:''} — ${esc(state.label)}" title="${i+1}. ${esc(u.label)} · ${esc(state.label)}" aria-pressed="${active}">${state.kind==='approved'?checkIcon:state.kind==='feedback'?feedbackIcon:''}<span>${i+1}</span>${u.members.length>1?`<small>×${u.members.length}</small>`:''}</button>`}).join('')}
${groupOverview&&!finished?unit!.members.map(m=>`<div class="region instance-region" style="${boxStyle(m.box)}"></div>`).join(''):''}
${draft.missing.map((item,i)=>`<button class="pin feedback ${!finished&&selected===item.id?'selected':''}" data-select="${esc(item.id)}" style="left:${pct(item.box.x+item.box.w/2)};top:${pct(item.box.y)}" aria-label="Inspect missing ${esc(item.name)}" title="Missing: ${esc(item.name)}">${feedbackIcon}<span>${units.length+i+1}</span></button>`).join('')}
<div class="draw-box" hidden></div>
</div></div>
@@ -197,28 +204,29 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
${marking ? '<div class="map-caption">Draw around the missing piece.<button id="add-box">Add an adjustable box</button></div>' : ''}
</section>`:''}
<section class="inspector" aria-label="${assembled?'Page comparison':'Selected component'}">
${!assembled?`<div class="section-head"><h2>${finished ? 'Review summary' : `<span class="number">${index}</span> ${esc(c?.name ?? missing?.name ?? 'Component')}`}</h2></div>`:''}<div class="inspection-content" role="region" aria-label="${assembled?'Page comparison':'Component comparison'}" tabindex="0">
${finished && !assembled ? `<section class="review-summary" id="review-summary" tabindex="-1"><div class="completion-mark" aria-hidden="true">${checkIcon}</div><h2>${submitted ? 'Review sent.' : 'All components reviewed.'}</h2><p>${stats.approved} approved · ${stats.revisions} flagged for repair${draft.missing.length ? ` · ${draft.missing.length} missing` : ''}</p><p>${submitted ? 'Your decisions are saved.' : stats.hasFeedback ? 'Send your feedback to start the next repair round.' : 'Confirm nothing is missing, then approve and continue.'}</p><div class="summary-decisions">${packet.components.map(item=>{const decision=draft.decisions[item.id];const state=stateFor(item);return `<button data-select="${esc(item.id)}"><strong>${esc(item.name)}</strong><span>${state.kind==='pending' ? 'Not reviewed' : state.kind==='feedback' ? 'Needs work' : 'Approved'}</span>${decision?.action==='revise' ? `<small>${esc(decision.feedback || 'No note — agent will diagnose.')}</small>` : ''}</button>`;}).join('')}${draft.missing.map(item=>`<button data-select="${esc(item.id)}"><strong>${esc(item.name)}</strong><span>Missing</span><small>${esc(item.feedback)}</small></button>`).join('')}</div></section></div>${notice?`<div class="review-form">${notice}</div>`:''}` : c ? `
${!assembled?`<div class="section-head"><h2>${finished ? 'Review summary' : `<span class="number">${index}</span> ${esc(groupOverview ? unit!.label : c?.name ?? missing?.name ?? 'Component')}`}</h2></div>`:''}<div class="inspection-content" role="region" aria-label="${assembled?'Page comparison':'Component comparison'}" tabindex="0">
${finished && !assembled ? `<section class="review-summary" id="review-summary" tabindex="-1"><div class="completion-mark" aria-hidden="true">${checkIcon}</div><h2>${submitted ? 'Review sent.' : 'All components reviewed.'}</h2><p>${stats.approved} approved · ${stats.revisions} flagged for repair${draft.missing.length ? ` · ${draft.missing.length} missing` : ''}</p><p>${submitted ? 'Your decisions are saved.' : stats.hasFeedback ? 'Send your feedback to start the next repair round.' : 'Confirm nothing is missing, then approve and continue.'}</p><div class="summary-decisions">${units.map(u=>{const item=u.representative;const decision=draft.decisions[item.id];const state=stateFor(item);return `<button data-select="${esc(item.id)}"><strong>${esc(u.label)}</strong><span>${u.kind==='pending' ? 'Not reviewed' : state.kind==='feedback' ? 'Needs work' : 'Approved'}</span>${decision?.action==='revise' ? `<small>${esc(decision.feedback || 'No note — agent will diagnose.')}</small>` : ''}</button>`;}).join('')}${draft.missing.map(item=>`<button data-select="${esc(item.id)}"><strong>${esc(item.name)}</strong><span>Missing</span><small>${esc(item.feedback)}</small></button>`).join('')}</div></section></div>${notice?`<div class="review-form">${notice}</div>`:''}` : c ? `
${history ? `<div class="repair-context">
${viewingPrevious&&repair?.prior?.action==='revise'?`<section class="previous-feedback" aria-label="Previous feedback"><h3>Previous feedback <span>· Round ${repair.feedbackRound}</span></h3><blockquote>${esc(repair.prior.feedback || 'No written feedback was supplied.')}</blockquote>${repair.prior.split?'<p>Requested: split into separately reviewable components.</p>':''}</section>`:repair?.carried?`<p class="kept-approval">Unchanged · approval kept</p>`:''}
${repair?.change?.kind==='changed'?`<details class="changed-files" ${filesOpen?'open':''}><summary>${repair.change.files.length?`${repair.change.files.length} changed ${repair.change.files.length===1?'file':'files'}`:repair.change.reasons.includes('region')?'Region changed':priorComponent?.note!==c.note?'Description changed · files unchanged':'Component definition changed · files unchanged'}</summary>${repair.change.files.length?`<ul>${repair.change.files.map(path=>`<li>${esc(path)}</li>`).join('')}</ul>`:''}${priorComponent&&priorComponent.note!==c.note?`<dl class="description-diff"><dt>Previous description</dt><dd>${esc(priorComponent.note)}</dd><dt>Current description</dt><dd>${esc(c.note)}</dd></dl>`:''}</details>`:''}
</div>`:''}
<div class="comparison-slot"><div class="comparison-panel"><h2 class="expanded-title">${esc(v!.name)}</h2>${peers.length>1?`<div class="review-peers"><strong>${esc(c!.reviewGroup!)} <span>· ${peers.length} instances</span></strong><div role="group" aria-label="Inspect matching components">${peers.map(peer=>`<button data-select="${esc(peer.id)}" aria-pressed="${peer.id===c!.id}" title="${esc(peer.name)}">${packet.components.indexOf(peer)+1}</button>`).join('')}</div><label><input id="apply-group" type="checkbox" ${applyGroup?'checked':''} ${submitted||viewingPrevious?'disabled':''}>Apply this decision to ${decisionTargets(packet,draft,c!,true,Object.keys(edits)).length} unreviewed instances</label></div>`:''}<div class="compare-toolbar">${priorComponent?`<div class="round-switch" role="group" aria-label="Preview version"><button id="current-round" aria-label="Current · round ${packet.round}" title="Current · round ${packet.round}" aria-pressed="${!viewingPrevious}">Current</button><button id="previous-round" aria-label="Previous · round ${history!.packet.round}" title="Previous · round ${history!.packet.round}" aria-pressed="${viewingPrevious}">Previous</button></div>`:''}<label class="zoom-control" title="Comparison zoom · based on comp pixels">${icon('zoom')}<select id="zoom" aria-label="Comparison zoom">${[['fit','Fit'],['1','100%'],['2','200%'],['4','400%']].map(([value,label])=>`<option value="${value}" ${String(zoom)===value?'selected':''}>${label}</option>`).join('')}</select>${icon('chevronDown')}</label><button id="overlay" class="overlay-control" aria-label="Overlay comp" title="Overlay approved comp" aria-pressed="${overlay}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="10" height="10"/><rect x="7" y="7" width="10" height="10"/></svg><span class="overlay-label">Overlay</span></button><div class="comparison-actions" role="group" aria-label="Comparison view actions"><button id="expand-comparison" class="icon-button" aria-label="${expandedComparison?'Restore comparison':'Enlarge comparison'}" title="${expandedComparison?'Restore comparison (Esc)':'Enlarge comparison'}" aria-expanded="${expandedComparison}">${icon(expandedComparison?'compact':'expand')}</button>${v?.preview.kind==='image'?`<a class="icon-button source-link" href="${url(sourceUrl!)}" target="_blank" rel="noopener" aria-label="${useContext?'Open context capture':presentation!.fileLabel}" title="${useContext?'Open context capture':presentation!.fileLabel}">${icon('external')}</a>`:''}</div></div>
<div class="comparison-slot"><div class="comparison-panel ${groupOverview?'group-overview':''}"><h2 class="expanded-title">${esc(groupOverview?unit!.label:v!.name)}</h2>${peers.length>1?`<div class="review-peers"><strong>${peers.length} instances</strong>${!groupOverview?'<button id="all-instances" class="quiet">All instances</button>':'<span class="group-hint">Select to inspect</span>'}</div>`:''}<div class="compare-toolbar">${priorComponent?`<div class="round-switch" role="group" aria-label="Preview version"><button id="current-round" aria-label="Current · round ${packet.round}" title="Current · round ${packet.round}" aria-pressed="${!viewingPrevious}">Current</button><button id="previous-round" aria-label="Previous · round ${history!.packet.round}" title="Previous · round ${history!.packet.round}" aria-pressed="${viewingPrevious}">Previous</button></div>`:''}<label class="zoom-control" title="Comparison zoom · based on comp pixels">${icon('zoom')}<select id="zoom" aria-label="Comparison zoom">${[['fit','Fit'],['1','100%'],['2','200%'],['4','400%']].map(([value,label])=>`<option value="${value}" ${String(zoom)===value?'selected':''}>${label}</option>`).join('')}</select>${icon('chevronDown')}</label><button id="overlay" class="overlay-control" aria-label="Overlay comp" title="Overlay approved comp" aria-pressed="${overlay}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="10" height="10"/><rect x="7" y="7" width="10" height="10"/></svg><span class="overlay-label">Overlay</span></button><div class="comparison-actions" role="group" aria-label="Comparison view actions"><button id="expand-comparison" class="icon-button" aria-label="${expandedComparison?'Restore comparison':'Enlarge comparison'}" title="${expandedComparison?'Restore comparison (Esc)':'Enlarge comparison'}" aria-expanded="${expandedComparison}">${icon(expandedComparison?'compact':'expand')}</button>${v?.preview.kind==='image'?`<a class="icon-button source-link" href="${url(sourceUrl!)}" target="_blank" rel="noopener" aria-label="${useContext?'Open context capture':presentation!.fileLabel}" title="${useContext?'Open context capture':presentation!.fileLabel}">${icon('external')}</a>`:''}</div></div>
${groupOverview?`<div class="instance-grid" aria-label="All instances of ${esc(unit!.label)}"><div class="instance-grid-labels"><span>In the comp</span><span>Produced</span></div>${unit!.members.map((m,n)=>{const st=stateFor(m);return `<button class="instance-row ${st.kind}" data-instance="${esc(m.id)}" aria-label="Inspect instance ${n+1}: ${esc(m.name)}${esc(st.label)}"><span class="instance-caption"><strong>${esc(m.name)}</strong><span>${esc(st.label)}</span></span><span class="instance-pair"><span class="instance-reference" style="width:min(100%,${m.box.w*packet.comp.width}px,${180*m.box.w*packet.comp.width/(m.box.h*packet.comp.height)}px);aspect-ratio:${m.box.w*packet.comp.width}/${m.box.h*packet.comp.height}"><img src="${url(packet.comp.url)}" alt="Comp: ${esc(m.name)}" loading="lazy" style="width:${100/m.box.w}%;left:${-100*m.box.x/m.box.w}%;top:${-100*m.box.y/m.box.h}%"></span><span class="instance-produced" style="width:min(100%,${m.box.w*packet.comp.width}px,${180*m.box.w*packet.comp.width/(m.box.h*packet.comp.height)}px);aspect-ratio:${m.box.w*packet.comp.width}/${m.box.h*packet.comp.height}">${m.preview.kind==='image'?`<img src="${url(m.preview.url)}" alt="Produced: ${esc(m.name)}" loading="lazy">`:m.thumbnail?`<img src="${url(m.thumbnail.url)}" alt="Preview: ${esc(m.name)}" loading="lazy">`:'Open live component'}</span></span></button>`;}).join('')}</div>`:''}
<div class="compare">
<figure><figcaption>${viewingPrevious ? `Comp · Round ${history!.packet.round}` : assembled ? 'Approved comp' : 'In the comp'}</figcaption><div class="pan-viewport" aria-label="Reference comparison canvas" tabindex="0"><div class="crop-stage"><img class="crop-image" src="${url(vp.comp.url)}" alt="Reference region for ${esc(v!.name)}" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%"></div></div></figure>
<figure><figcaption>${viewingPrevious ? `Previous · Round ${history!.packet.round}` : assembled ? 'Assembled page' : useContext ? 'In context' : history ? `${presentation!.caption} · Round ${packet.round}` : presentation!.caption}</figcaption><div class="pan-viewport" aria-label="Produced comparison canvas" tabindex="0"><div class="output crop-stage ${hasTransparency&&!useContext&&!useFrame&&backdrop==='checker'?'checker':''}">${!useFrame ? `<img class="asset" src="${url(sourceUrl!)}" alt="Produced ${esc(v!.name)}" style="object-position:${esc(v!.preview.position ?? 'center')}">` : `<iframe aria-hidden="true" title="Rendered ${esc(v!.name)}" src="${url(sourceUrl!)}" sandbox="" tabindex="-1" width="${vp.comp.width}" height="${vp.comp.height}"></iframe>`}${overlay ? `<img class="crop-image overlay-image" src="${url(vp.comp.url)}" alt="Reference overlay" style="width:${100/v!.box.w}%;left:${-100*v!.box.x/v!.box.w}%;top:${-100*v!.box.y/v!.box.h}%">` : ''}</div></div></figure>
</div>
${hasTransparency || v!.context ? `<div class="view-controls">${v!.context ? `<div role="group" aria-label="Component view"><button id="isolated" aria-pressed="${!useContext}">${isRaster?'Asset only':'Component only'}</button><button id="context" aria-pressed="${useContext}">In context</button></div>` : ''}${hasTransparency?`<div class="background-options" role="group" aria-label="Asset preview background"><button id="background-checker" class="swatch-button" aria-label="Checkerboard background" title="Checkerboard background" aria-pressed="${backdrop==='checker'}" ${useContext?'disabled':''}><span class="background-swatch checker"></span></button><button id="background-page" class="swatch-button" aria-label="${vp.comp.background?'Page color':'Neutral'} background" title="${vp.comp.background?'Page color':'Neutral'} background" aria-pressed="${backdrop==='page'}" ${useContext?'disabled':''}><span class="background-swatch page-swatch"></span></button></div>`:''}</div>` : ''}
</div></div>${!assembled?`<div class="component-details"><div class="material">${icon(presentation!.code ? 'code' : 'image')}<strong>${esc(materialLabel)}</strong><span>${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}</span></div>${vp.stage==='components'&&presentation?.captured&&!v?.preview.isolation?'<p class="layering">Legacy region capture · may include overlapping components.</p>':''}${v?.context?.layering&&(isRaster||useContext)?`<p class="layering">${esc(v.context.layering)}</p>`:''}
</div></div>${!assembled&&!groupOverview?`<div class="component-details"><div class="material">${icon(presentation!.code ? 'code' : 'image')}<strong>${esc(materialLabel)}</strong><span>${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}</span></div>${vp.stage==='components'&&presentation?.captured&&!v?.preview.isolation?'<p class="layering">Legacy region capture · may include overlapping components.</p>':''}${v?.context?.layering&&(isRaster||useContext)?`<p class="layering">${esc(v.context.layering)}</p>`:''}
<p class="component-note">${esc(v!.note)}</p>
</div>`:''}</div><div class="review-form">${notice}${viewingPrevious?'<p class="previous-notice">Viewing the previous round. Return to Current to make a decision.</p>':''}${submitted?`<div class="record-verdict"><strong>${d?.action==='approve'?'Approved':d?.action==='revise'?'Changes requested':'Not reviewed'}</strong><span>Submitted in round ${packet.round} · read-only</span></div>`:`<div class="decisions" role="group" aria-label="Decision for ${esc(c.name)}">${!assembled?`<div class="decision-title"><strong>Your review <span>Round ${packet.round}</span></strong>${viewingPrevious?'<p>Return to Current to review this round.</p>':''}</div>`:''}<button id="approve" class="decision-approve ${d?.action === 'approve' ? 'approved' : ''}" aria-pressed="${d?.action === 'approve'}">${assembled?(sending?'Sending…':'Approve & continue'):targets.length>1?`Approve ${targets.length} instances`:'Looks good'}</button><button id="revise" class="decision-revise ${d?.action === 'revise' ? 'revise' : ''}" aria-pressed="${d?.action === 'revise'}">${targets.length>1?`Revise ${targets.length} instances`:'Needs work'}</button>${d && !assembled ? `<button id="clear" class="quiet icon-button" aria-label="Clear decision" title="Clear decision">${icon('undo')}</button>` : ''}</div>`}
</div>`:''}</div><div class="review-form">${notice}${viewingPrevious?'<p class="previous-notice">Viewing the previous round. Return to Current to make a decision.</p>':''}${submitted?`<div class="record-verdict"><strong>${d?.action==='approve'?'Approved':d?.action==='revise'?'Changes requested':'Not reviewed'}</strong><span>Submitted in round ${packet.round} · read-only</span></div>`:`<div class="decisions" role="group" aria-label="Decision for ${esc(c.name)}">${!assembled?`<div class="decision-title"><strong>Your review <span>Round ${packet.round}</span></strong>${viewingPrevious?'<p>Return to Current to review this round.</p>':''}</div>`:''}<button id="approve" ${!targets.length?'disabled':''} class="decision-approve ${d?.action === 'approve' ? 'approved' : ''}" aria-pressed="${d?.action === 'approve'}">${assembled?(sending?'Sending…':'Approve & continue'):targets.length>1?`Approve ${targets.length} instances`:'Looks good'}</button><button id="revise" ${!targets.length?'disabled':''} class="decision-revise ${d?.action === 'revise' ? 'revise' : ''}" aria-pressed="${d?.action === 'revise'}">${targets.length>1?`Revise ${targets.length} instances`:'Needs work'}</button>${d && !assembled && !groupOverview ? `<button id="clear" class="quiet icon-button" aria-label="Clear decision" title="Clear decision">${icon('undo')}</button>` : ''}</div>`}
${edit ? `<form id="feedback-form"><div class="feedback-fields"><label class="feedback-field">What needs to change?<textarea id="feedback" aria-describedby="feedback-hint">${esc(edit.feedback)}</textarea></label><p id="feedback-hint" class="feedback-hint">Optional — leave blank for the agent to diagnose.</p>${!assembled?`<label class="check"><input id="split" type="checkbox" ${edit.split ? 'checked' : ''}> Split into separately reviewable components</label>`:''}</div><div class="feedback-actions"><button id="cancel-feedback" type="button" class="quiet">Cancel</button><button id="save-feedback" type="submit" class="primary">${assembled?'Send feedback':isLast?'Save & finish review':'Save & next'} <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6"/></svg></button><span class="shortcut-hint">${shortcutLabel}</span></div></form>` : d?.action==='revise' ? `<p class="saved-feedback">${esc(d.feedback || 'No note — agent will diagnose.')}</p>` : ''}
${assembled?`<p class="page-review-status" role="status">${esc(error || (submitted?'Your decision is saved.':sending?'Sending…':edit?'':'Approval confirms the composition and that nothing is missing.'))}</p>`:''}</div>` : missing ? `<p>This piece will be added to the unresolved inventory.</p><label class="feedback-field">Name<input id="missing-name" value="${esc(missing.name)}"></label><label class="feedback-field">What is missing?<textarea id="missing-feedback">${esc(missing.feedback)}</textarea></label><div class="coordinates">${(['x','y','w','h'] as const).map(k=>`<label>${{x:'Left',y:'Top',w:'Width',h:'Height'}[k]} %<input type="number" data-coordinate="${k}" value="${Math.round(missing.box[k]*1000)/10}" min="0" max="100" step="0.1"></label>`).join('')}</div><button id="remove-missing">Remove this mark</button></div>` : '<p>No components supplied.</p></div>'}
</section>
</div>
${!assembled?`<section class="inventory-section ${trayOpen?'':'tray-collapsed'} ${showAll&&trayOpen?'tray-expanded':''}" aria-label="Component inventory"><div class="section-head"><h2>Components</h2><div class="inventory-filters" role="group" aria-label="Filter components"><button data-filter="pending" aria-pressed="${inventoryFilter==='pending'}">To review <b>${stats.pending}</b></button><button data-filter="reviewed" aria-pressed="${inventoryFilter==='reviewed'}">Reviewed <b>${reviewedCount}</b></button><button data-filter="all" aria-pressed="${inventoryFilter==='all'}">All <b>${packet.components.length+draft.missing.length}</b></button></div><div class="tray-actions"><button id="show-all" class="icon-button" aria-pressed="${showAll}" aria-controls="component-tray" aria-label="${showAll?'Compact':'Expand'} tray" title="${showAll?'Compact':'Expand'} tray">${icon(showAll?'compact':'expand')}</button><button id="toggle-tray" class="icon-button" aria-expanded="${trayOpen}" aria-controls="component-tray" aria-label="${trayOpen?'Hide':'Show'} component tray" title="${trayOpen?'Hide':'Show'} component tray">${icon(trayOpen?'hideTray':'showTray')}</button></div></div>
<div id="component-tray" class="inventory ${showAll ? 'all' : ''}">${shownComponents.map(item=>{const i=packet.components.indexOf(item);const state=stateFor(item); return `<button class="item ${state.kind} ${!finished && selected === item.id ? 'active' : ''}" data-select="${esc(item.id)}" aria-pressed="${!finished && selected === item.id}">${item.thumbnail ? `<span class="item-thumb">${item.thumbnail.box ? `<span class="thumb-crop" style="width:min(100%,${76*item.thumbnail.box.w*packet.comp.width/(item.thumbnail.box.h*packet.comp.height)}px);aspect-ratio:${item.thumbnail.box.w*packet.comp.width}/${item.thumbnail.box.h*packet.comp.height}"><img alt="" loading="lazy" src="${url(item.thumbnail.url)}" style="position:absolute;width:${100/item.thumbnail.box.w}%;max-width:none;left:${-100*item.thumbnail.box.x/item.thumbnail.box.w}%;top:${-100*item.thumbnail.box.y/item.thumbnail.box.h}%;"></span>` : `<img alt="" loading="lazy" src="${url(item.thumbnail.url)}">`}</span>` : ''}<span class="item-number">${state.kind==='approved'?checkIcon:state.kind==='feedback'?feedbackIcon:''}${i+1}<span class="item-medium">${icon(componentPresentation(item).code ? 'code' : 'image')}${esc(componentPresentation(item).label)}</span></span><strong>${esc(item.name)}</strong><span class="state ${state.kind}">${esc(state.label)}</span></button>`}).join('')}${(inventoryFilter==='pending'?[]:draft.missing).map((m,i)=>`<button class="item feedback ${selected===m.id?'active':''}" data-select="${esc(m.id)}"><span class="item-number">${packet.components.length+i+1}</span><strong>${esc(m.name)}</strong><span class="state revise">Missing</span></button>`).join('')}${!shownComponents.length&&(inventoryFilter==='pending'||!draft.missing.length)?`<p class="inventory-empty">${inventoryFilter==='pending'?'Nothing left to review. Your decisions are ready.':'No components reviewed yet.'}</p>`:''}</div></section>`:''}
${!assembled?`<section class="inventory-section ${trayOpen?'':'tray-collapsed'} ${showAll&&trayOpen?'tray-expanded':''}" aria-label="Component inventory"><div class="section-head"><h2>Components</h2><div class="inventory-filters" role="group" aria-label="Filter components"><button data-filter="pending" aria-pressed="${inventoryFilter==='pending'}">To review <b>${pendingUnits}</b></button><button data-filter="reviewed" aria-pressed="${inventoryFilter==='reviewed'}">Reviewed <b>${reviewedCount}</b></button><button data-filter="all" aria-pressed="${inventoryFilter==='all'}">All <b>${units.length+draft.missing.length}</b></button></div><div class="tray-actions"><button id="show-all" class="icon-button" aria-pressed="${showAll}" aria-controls="component-tray" aria-label="${showAll?'Compact':'Expand'} tray" title="${showAll?'Compact':'Expand'} tray">${icon(showAll?'compact':'expand')}</button><button id="toggle-tray" class="icon-button" aria-expanded="${trayOpen}" aria-controls="component-tray" aria-label="${trayOpen?'Hide':'Show'} component tray" title="${trayOpen?'Hide':'Show'} component tray">${icon(trayOpen?'hideTray':'showTray')}</button></div></div>
<div id="component-tray" class="inventory ${showAll ? 'all' : ''}">${shownComponents.map(item=>{const u=units.find(u=>u.members.some(m=>m.id===item.id))!;const i=units.indexOf(u);const state={kind:u.kind,label:u.stateLabel}; return `<button class="item ${state.kind} ${!finished && unit?.id === u.id ? 'active' : ''}" data-select="${esc(item.id)}" aria-pressed="${!finished && unit?.id === u.id}">${item.thumbnail ? `<span class="item-thumb">${item.thumbnail.box ? `<span class="thumb-crop" style="width:min(100%,${76*item.thumbnail.box.w*packet.comp.width/(item.thumbnail.box.h*packet.comp.height)}px);aspect-ratio:${item.thumbnail.box.w*packet.comp.width}/${item.thumbnail.box.h*packet.comp.height}"><img alt="" loading="lazy" src="${url(item.thumbnail.url)}" style="position:absolute;width:${100/item.thumbnail.box.w}%;max-width:none;left:${-100*item.thumbnail.box.x/item.thumbnail.box.w}%;top:${-100*item.thumbnail.box.y/item.thumbnail.box.h}%;"></span>` : `<img alt="" loading="lazy" src="${url(item.thumbnail.url)}">`}</span>` : ''}<span class="item-number">${state.kind==='approved'?checkIcon:state.kind==='feedback'?feedbackIcon:''}${i+1}<span class="item-medium">${icon(componentPresentation(item).code ? 'code' : 'image')}${esc(componentPresentation(item).label)}</span></span><strong>${esc(displayName(item.id))}${u.members.length>1?` <small>×${u.members.length}</small>`:''}</strong><span class="state ${state.kind}">${esc(state.label)}</span></button>`}).join('')}${(inventoryFilter==='pending'?[]:draft.missing).map((m,i)=>`<button class="item feedback ${selected===m.id?'active':''}" data-select="${esc(m.id)}"><span class="item-number">${units.length+i+1}</span><strong>${esc(m.name)}</strong><span class="state revise">Missing</span></button>`).join('')}${!shownComponents.length&&(inventoryFilter==='pending'||!draft.missing.length)?`<p class="inventory-empty">${inventoryFilter==='pending'?'Nothing left to review. Your decisions are ready.':'No components reviewed yet.'}</p>`:''}</div></section>`:''}
${assembled?'':submitted?`<footer class="record-footer"><span>Round ${packet.round} submitted · read-only</span><span>${stats.approved} approved · ${stats.revisions} changes requested</span></footer>`:`<footer class="${!stats.pending&&!uncommitted?'queue-complete':''}"><div>${!stats.pending&&!uncommitted ? `<button id="show-summary" class="completion-link">${checkIcon}${submitted?'Review sent':'All components reviewed'}</button>` : ''}<button id="approve-rest" ${!stats.pending?'hidden':''} ${!stats.pending || uncommitted ? 'disabled' : ''}>Approve ${stats.approved || stats.revisions ? 'remaining' : 'all'}</button><label class="check"><input id="inventory-confirm" type="checkbox" ${draft.inventoryConfirmed?'checked':''}> Nothing missing from the comp</label></div><div class="submit-area"><p role="status">${esc(statusMessage)}</p><button id="submit" class="primary" ${!stats.canSubmit || uncommitted || sending || submitted?'disabled':''}>${sending?'Sending…':submitted?'Review sent':stats.hasFeedback?'Send feedback':'Approve & continue'}</button></div></footer>`}
</section><dialog id="comparison-dialog" aria-label="${assembled?'Enlarged page comparison':'Enlarged component comparison'}"></dialog>`;
const comparisonDialog=root.querySelector<HTMLDialogElement>('#comparison-dialog')!;
@@ -243,9 +251,10 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
if (focusId) root.getElementById(focusId)?.focus({preventScroll:true});
else if(focusSelection) Array.from(root.querySelectorAll<HTMLElement>('.item[data-select]')).find(el=>el.dataset.select===focusSelection)?.focus({preventScroll:true});
const on = (id:string, action:()=>void) => root.querySelector(`#${id}`)?.addEventListener('click', action);
function selectComponent(id:string, enlarge=false) {
function selectComponent(id:string, enlarge=false, individual=false) {
if(marking)return;
applyGroup=false; finished=false; selected=id; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render();
if(!individual)id=units.find(u=>u.members.some(m=>m.id===id))?.representative.id ?? id;
applyGroup=!individual; 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<HTMLButtonElement>('#expand-comparison')?.click();
@@ -253,12 +262,12 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
root.querySelectorAll<HTMLElement>('[data-select]').forEach(el => el.onclick = () => selectComponent(el.dataset.select!,el.classList.contains('pin')&&packet.components.some(item=>item.id===el.dataset.select)));
on('previous-round',()=>{previousRound=true;render();});
on('current-round',()=>{previousRound=false;render();});
on('review-changes',()=>{const pending=orderedComponents().filter(item=>stateFor(item).kind==='pending');const current=pending.findIndex(item=>item.id===selected);const next=pending[(current+1)%pending.length];if(next){finished=false;selected=next.id;mobilePane='component';inventoryFilter='pending';previousRound=false;zoom='fit';overlay=false;outputMode='isolated';render();}});
on('review-changes',()=>{const pending=orderedComponents().filter(item=>stateFor(item).kind==='pending');const current=pending.findIndex(item=>item.id===selected);const next=pending[(current+1)%pending.length];if(next){applyGroup=true;finished=false;selected=next.id;mobilePane='component';inventoryFilter='pending';previousRound=false;zoom='fit';overlay=false;outputMode='isolated';render();}});
root.querySelectorAll<HTMLButtonElement>('[data-filter]').forEach(button=>button.onclick=()=>{
inventoryFilter=button.dataset.filter as InventoryFilter;finished=!stats.pending&&inventoryFilter==='pending';
const matches=orderedComponents().filter(item=>inReviewQueue(item,draft,inventoryFilter));
const matches=units.filter(u=>inventoryFilter==='all'||(u.kind==='pending')===(inventoryFilter==='pending')).map(u=>u.representative);
const selectedMissing=inventoryFilter!=='pending'&&draft.missing.some(item=>item.id===selected);
if(!selectedMissing&&!matches.some(item=>item.id===selected)&&matches.length){selected=matches[0].id;mobilePane='component';previousRound=false;zoom='fit';overlay=false;outputMode='isolated';}
if(!selectedMissing&&!matches.some(item=>item.id===selected)&&matches.length){applyGroup=true;selected=matches[0].id;mobilePane='component';previousRound=false;zoom='fit';overlay=false;outputMode='isolated';}
render();
});
on('show-summary',()=>{finished=true;mobilePane='component';inventoryFilter='reviewed';render();focusReview('review-summary');});
@@ -276,7 +285,8 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
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('all-instances',()=>{applyGroup=true;selected=unit!.representative.id;previousRound=false;render();});
root.querySelectorAll<HTMLElement>('[data-instance]').forEach(el=>el.onclick=()=>selectComponent(el.dataset.instance!,false,true));
on('overlay',()=>{overlay=!overlay; render();});
on('isolated',()=>{outputMode='isolated';render();});
on('context',()=>{outputMode='context';render();});
@@ -346,7 +356,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
- (comparisonPanel?.querySelector('.view-controls')?.clientHeight ?? 0)-12));
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<HTMLElement>('.crop-stage').forEach(el=>{el.style.width=`${size.width}px`;el.style.height=`${size.height}px`;});}
if(v&&panes.length&&!groupOverview){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<HTMLElement>('.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.style.cursor=expandedComparison?'':'zoom-in';p.setAttribute('role',expandedComparison?'region':'button');p.title=expandedComparison?(pannable?'Move your pointer to pan. You can also scroll, swipe, or use arrow keys.':''):'Click to enlarge comparison';});
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<HTMLElement>('.region');const end=root.querySelector<HTMLElement>('.number');
+26
View File
@@ -102,4 +102,30 @@ export const styles = `
.assembled-review .decisions>button{flex:1;min-width:0}
.assembled-review .page-review-status{text-align:left}
}
/* Grouped review is the default; instances are an explicit drill-down. */
.pin small{font:600 10px/1 var(--font-body, sans-serif);margin-left:3px}.pin:has(small){width:auto;min-width:32px;padding:0 7px}
.instance-region{opacity:.55;border-width:1px;pointer-events:none}
.group-overview>.compare,.group-overview>.view-controls{display:none}
.group-overview{position:relative}.group-overview>.review-peers{padding:0 44px 4px 0;min-height:36px}.group-overview>.compare-toolbar{position:absolute;right:0;top:0;justify-content:flex-end;margin:0}
.group-overview>.compare-toolbar>:not(.comparison-actions),.group-overview .source-link{display:none}
.group-overview .comparison-actions{margin-left:auto}
.review-peers{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}
.group-hint{font-size:12px;color:var(--muted)}
.instance-grid{display:grid;gap:0;flex:none;grid-auto-rows:max-content}
.instance-grid-labels{display:grid;grid-template-columns:1fr 1fr;gap:16px;color:var(--muted);font-size:12px;padding:8px 0}
.instance-row{display:block;height:auto;width:100%;text-align:left;padding:12px 0;border:0;border-top:1px solid var(--line);border-radius:0;background:transparent;color:inherit}
.instance-row:hover{background:#eef3f1}
.instance-caption{display:flex;justify-content:space-between;align-items:baseline;gap:12px;font-size:12px;margin-bottom:8px}
.instance-caption>span{color:var(--muted);font-size:11px}
.instance-pair{display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:16px}
.instance-reference{justify-self:center;display:block;position:relative;overflow:hidden;max-height:180px;width:100%;background:var(--comp-background)}
.instance-reference img{position:absolute;max-width:none;height:auto}
.instance-produced{justify-self:center;display:flex;align-items:center;justify-content:center;min-height:36px;max-height:180px;overflow:hidden}
.instance-produced img{display:block;width:100%;height:100%;object-fit:contain}
.instance-row.approved{opacity:.65}.instance-row.feedback .instance-caption>span{color:var(--warn)}
.item strong small{font-size:11px;white-space:nowrap;color:var(--muted)}
#comparison-dialog .group-overview{overflow:auto;min-height:0}
#comparison-dialog .group-overview>.compare-toolbar{top:0}
#comparison-dialog .group-overview>.expanded-title{padding-right:44px}
`;