Clarify completed component reviews and isolate feedback styles

AI-assisted implementation with Codex, directed and visually reviewed by the maintainer.
This commit is contained in:
Paul Bakaus
2026-09-14 16:30:28 -07:00
parent ab24a6ddb4
commit 8d0a82da4a
6 changed files with 86 additions and 66 deletions
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -6,7 +6,7 @@ export const appLayout = `
.review>.preview-note{flex-shrink:0;margin:0;padding:8px 24px;font-size:11px}.review>.round-summary{flex-shrink:0;margin:0;padding:8px 24px;border-top:0;gap:6px 16px;max-height:120px;overflow:auto}.round-summary p{font-size:12px;gap:6px 14px}.round-summary button{font-size:12px;min-height:32px;padding:5px 10px}
.review>.workbench{flex:1;min-height:0;align-items:stretch;padding:20px 24px;gap:32px;overflow:hidden;grid-template-columns:minmax(0,1.12fr) minmax(0,1fr)}.reference{display:flex;flex-direction:column;min-height:0;width:100%;max-width:none;margin:0}.reference>.section-head{flex-shrink:0;margin-bottom:10px}.map-space{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;background:#eeefec;border:1px solid var(--line);overflow:hidden}.map{flex-shrink:0;max-width:100%;max-height:100%}.map-legend{flex-shrink:0;margin-top:9px;gap:6px 14px}.map-caption{flex-shrink:0;min-height:0;padding-top:6px;font-size:10px}.map-caption button{display:inline-block;margin:0 0 0 8px;min-height:28px;padding:3px 7px}
.workbench>.inspector{height:100%;min-height:0;padding:0;border:0}.inspector>.section-head{margin-bottom:10px;min-height:32px}.section-head h2{font-size:16px}.inspection-content{padding-bottom:8px}.review-form{max-height:55%;overflow-y:auto;scrollbar-width:thin;scrollbar-gutter:stable}.material{min-height:28px}.material strong{font-size:13px}.compare-toolbar{margin-bottom:12px}.previous-feedback{padding:10px 12px}.repair-context{margin-bottom:12px}.preview-round{margin-bottom:10px}.decision-title p{font-size:11px}.decisions>button{min-height:40px;font-size:13px}.decision-title strong{font-size:13px}.view-controls{margin-top:8px}.component-details{height:auto;max-height:80px}
.review>.inventory-section{height:236px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;margin:0;padding:10px 24px 8px;border-top:1px solid var(--line);background:var(--color-panel,#f5f5f2);overflow:hidden}.inventory-section>.section-head{margin:0 0 8px;min-height:36px;flex-shrink:0;gap:10px}.inventory-section h2{font-size:14px}.inventory{flex:1;min-height:0;align-items:stretch;margin:0;padding:3px 3px 7px;overflow-x:auto;overflow-y:hidden}.inventory.all{overflow:auto;align-items:start;grid-auto-rows:160px}.inventory .item{flex:0 0 136px;padding:8px;gap:3px;grid-template-rows:auto auto minmax(24px,1fr) auto;min-height:0}.inventory .item-thumb{height:46px;margin-bottom:3px}.inventory .thumb-crop{max-height:46px}.inventory .item-number{font-size:11px;min-height:16px;padding:0}.inventory .item strong{font-size:12px;min-height:26px;line-height:1.2}.inventory .state{font-size:11px;padding-top:4px}.tray-actions{display:flex;gap:6px;align-items:center}.tray-actions button{white-space:nowrap;font-size:11px;min-height:32px}.tray-actions #toggle-tray{display:flex;align-items:center;gap:6px}.tray-actions svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.review>.inventory-section.tray-expanded{height:min(42dvh,390px)}.review>.inventory-section.tray-collapsed{height:56px;padding-block:10px}.tray-collapsed .inventory{display:none}.tray-collapsed>.section-head{margin-bottom:0}
.review>.inventory-section{height:236px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;margin:0;padding:10px 24px 8px;border-top:1px solid var(--line);background:var(--color-panel,#f5f5f2);overflow:hidden}.inventory-section>.section-head{margin:0 0 8px;min-height:36px;flex-shrink:0;gap:10px}.inventory-section h2{font-size:14px}.inventory{flex:1;min-height:0;align-items:stretch;margin:0;padding:3px 3px 7px;overflow-x:auto;overflow-y:hidden}.inventory.all{overflow:auto;align-items:start;grid-auto-rows:160px}.inventory .item{flex:0 0 136px;padding:8px;gap:3px;grid-template-rows:auto auto minmax(24px,1fr) auto;min-height:0}.inventory .item-thumb{height:46px;margin-bottom:3px}.inventory .thumb-crop{max-height:46px}.inventory .item-number{font-size:11px;min-height:16px;padding:0}.inventory .item strong{font-size:12px;min-height:26px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.inventory .state{font-size:11px;padding-top:4px}.tray-actions{display:flex;gap:6px;align-items:center}.tray-actions button{white-space:nowrap;font-size:11px;min-height:32px}.tray-actions #toggle-tray{display:flex;align-items:center;gap:6px}.tray-actions svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.review>.inventory-section.tray-expanded{height:min(42dvh,390px)}.review>.inventory-section.tray-collapsed{height:56px;padding-block:10px}.tray-collapsed .inventory{display:none}.tray-collapsed>.section-head{margin-bottom:0}
.review>footer{flex-shrink:0;margin:0;padding:12px 24px;gap:16px;border-top:1px solid var(--line);background:var(--paper);align-items:center}.review>footer>div:first-child{display:flex;align-items:center;gap:14px;min-width:0}.review>footer .check{margin:0;max-width:240px;font-size:11px}.review>footer #approve-rest{min-height:36px;font-size:12px;max-width:210px;padding:7px 10px}.review>footer .submit-area{gap:12px}.review>footer .submit-area p{font-size:11px;max-width:24ch}.review>footer .primary{min-height:40px;font-size:13px}.mobile-panes{display:none}
/* Depth describes the shell: recessed work area, raised inspector, anchored docks. */
.review{--workspace:#e3e6e2;--canvas:#d5dad5;--dock:#f6f7f4;--surface:#fff;background:var(--workspace)}
@@ -28,7 +28,7 @@ export const appLayout = `
.review>.inventory-section{position:relative;z-index:8;background:var(--dock);border-top-color:#b9c3b8;box-shadow:0 -3px 5px #2433280c,0 -10px 24px #24332814}
.inventory-section>.section-head{border-bottom:1px solid #d9dfd5;padding-bottom:8px;margin-bottom:8px}
.tray-collapsed>.section-head{border:0;padding-bottom:0;margin-bottom:0}
.inventory{background:#e9ece5;border-radius:4px;padding:7px 7px 9px}
.inventory{background:transparent;border-radius:0;padding:3px 3px 7px}
.inventory .item{background:#fafbf8}
.inventory .item.active{background:#fff}
.review>footer{position:relative;z-index:9;background:var(--surface);border-top-color:#ccd4c7;box-shadow:0 -2px 5px #2433280b}
@@ -51,7 +51,7 @@ export const appLayout = `
@media(max-width:800px){
.review>header{padding:12px 14px;align-items:center}.review h1{font-size:25px}.review>header p{max-width:68vw;font-size:11px}.review .badge{display:none}.review>.preview-note{padding:6px 14px}.review>.round-summary{padding:6px 14px;max-height:76px;gap:4px 8px}.round-summary p{max-width:calc(100% - 44px);gap:4px 10px;font-size:11px}.round-summary p>span{font-size:10px}.round-summary button{font-size:11px}
.mobile-panes{display:flex;flex-shrink:0;gap:4px;padding:7px 14px;border-bottom:1px solid var(--line);background:var(--paper)}.mobile-panes button{flex:1;font-size:12px;min-height:32px;padding:5px;border-color:transparent;background:transparent}.mobile-panes button[aria-pressed=true]{background:#e8efec;color:var(--teal);box-shadow:none;border-color:#bdd0c8}
.review>.workbench{display:block;padding:12px 14px;min-height:0;overflow:hidden}.workbench[data-mobile-pane=component]>.reference,.workbench[data-mobile-pane=comp]>.inspector{display:none}.reference{height:100%;max-width:none}.connector{display:none}.inspector{height:100%;border:0}.map-legend{gap:6px 12px;font-size:10px}.map-caption{font-size:9px}.reference .section-head button{min-height:30px;padding:4px 8px}.reference .section-head h2{font-size:14px}.inspector>.section-head{min-height:26px;margin-bottom:0;padding:9px 12px}.inspection-content{padding:10px 8px 10px 12px}.inspector>.review-form{padding:8px 8px 8px 12px}.number{width:23px;height:23px;font-size:11px}.section-head h2{font-size:14px}.review-form{padding-top:8px}.decision-title strong{font-size:12px}.decision-title p{font-size:10px}.decisions>button{min-height:36px}.review-form .feedback{font-size:12px}.review-form .feedback textarea{min-height:60px}.compare{max-width:none}
.review>.workbench{display:block;padding:12px 14px;min-height:0;overflow:hidden}.workbench[data-mobile-pane=component]>.reference,.workbench[data-mobile-pane=comp]>.inspector{display:none}.reference{height:100%;max-width:none}.connector{display:none}.inspector{height:100%;border:0}.map-legend{gap:6px 12px;font-size:10px}.map-caption{font-size:9px}.reference .section-head button{min-height:30px;padding:4px 8px}.reference .section-head h2{font-size:14px}.inspector>.section-head{min-height:26px;margin-bottom:0;padding:9px 12px}.inspection-content{padding:10px 8px 10px 12px}.inspector>.review-form{padding:8px 8px 8px 12px}.number{width:23px;height:23px;font-size:11px}.section-head h2{font-size:14px}.review-form{padding-top:8px}.decision-title strong{font-size:12px}.decision-title p{font-size:10px}.decisions>button{min-height:36px}.review-form .feedback-field{font-size:12px}.review-form .feedback-field textarea{min-height:60px}.compare{max-width:none}
.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}
}
+12
View File
@@ -60,3 +60,15 @@ test('review progression skips decisions, wraps, and revisits stale components',
expect(nextUnreviewed(packet,draft,second.id)).toBe(first.id);
expect(nextUnreviewed({...packet,components:[]},draft)).toBeUndefined();
});
test('reviewed queue includes feedback, pending queue includes stale decisions', async () => {
const { inReviewQueue } = await import('./model');
const draft=approveRemaining(packet,newDraft(packet));
draft.decisions.art.action='revise';
expect(packet.components.filter(c=>inReviewQueue(c,draft,'pending'))).toHaveLength(0);
expect(packet.components.filter(c=>inReviewQueue(c,draft,'reviewed'))).toHaveLength(2);
expect(summarize(packet,draft).pending).toBe(0);
draft.decisions.art.revision='old';
expect(packet.components.filter(c=>inReviewQueue(c,draft,'pending')).map(c=>c.id)).toEqual(['art']);
expect(packet.components.filter(c=>inReviewQueue(c,draft,'reviewed')).map(c=>c.id)).toEqual(['control']);
});
+6
View File
@@ -90,3 +90,9 @@ export function nextUnreviewed(packet: ReviewPacket, draft: Draft, after?: strin
if (componentState(component, draft).kind === 'pending') return component.id;
}
}
export type InventoryFilter = 'pending' | 'reviewed' | 'all';
/** Repair requests are completed review decisions, not work left for the reviewer. */
export function inReviewQueue(component: Component, draft: Draft, filter: InventoryFilter) {
return filter === 'all' || (componentState(component, draft).kind === 'pending') === (filter === 'pending');
}
+24 -22
View File
@@ -1,4 +1,4 @@
import { componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model';
import { inReviewQueue, type InventoryFilter, componentPresentation, nextUnreviewed, approveRemaining, componentState, repairStatus, newDraft, submission, summarize, type Box, type Decision, type Draft, type ReviewPacket, type ReviewHistory } from './model';
import { comparisonSize, hoverPan } from './viewport';
import { styles } from './styles';
import { icon } from './icons';
@@ -12,19 +12,19 @@ const url = (s: string) => {
return esc(parsed.href);
};
export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, options: {
preview?: boolean; history?: ReviewHistory | null; initialDraft?: Draft; completed?: boolean; onSubmit: (value: ReturnType<typeof submission>) => Promise<void>;
preview?: boolean; history?: ReviewHistory | null; initialDraft?: Draft; completed?: boolean; onDraftChange?: (draft: Draft) => void; onSubmit: (value: ReturnType<typeof submission>) => Promise<void>;
}) {
const root = host.attachShadow({mode: 'open'});
let draft = structuredClone(options.initialDraft ?? newDraft(packet));
const orderedComponents = () => [...packet.components].sort((a,b)=>componentState(a,draft,options.history).priority-componentState(b,draft,options.history).priority);
let selected = orderedComponents().find(c=>componentState(c,draft,options.history).kind!=='approved')?.id ?? packet.components[0]?.id;
let inventoryFilter: 'attention' | 'approved' | 'all' = options.completed ? 'all' : 'attention';
let inventoryFilter: InventoryFilter = summarize(packet,draft).pending ? 'pending' : 'reviewed';
let marking = false;
let sending = false;
let submitted = options.completed ?? false;
let error = '';
const edits: Record<string, {feedback: string; split: boolean}> = {};
let finished = false;
let finished = !summarize(packet,draft).pending;
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;
@@ -54,7 +54,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
finished = !next;
if (next) selected = next;
mobilePane='component'; previousRound=false; overlay=false; zoom='fit'; outputMode='isolated';
if (inventoryFilter==='approved') inventoryFilter='attention';
inventoryFilter = finished ? 'reviewed' : 'pending';
render();
focusReview(finished ? 'review-summary' : edits[selected!] ? 'feedback' : 'approve');
}
@@ -130,8 +130,8 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
const addedCount = changes.filter(change=>change.kind==='added').length;
const carriedCount = packet.components.filter(item=>componentState(item,draft,history).kind==='approved'&&repairStatus(item.id,history).carried).length;
const stateFor = (item: typeof packet.components[number]) => componentState(item,draft,history);
const attentionCount = packet.components.length-stats.approved+draft.missing.length;
const shownComponents = (inventoryFilter==='attention'?orderedComponents():packet.components).filter(item=>inventoryFilter==='all'||(stateFor(item).kind==='approved')===(inventoryFilter==='approved'));
const reviewedCount = stats.approved+stats.revisions+draft.missing.length;
const shownComponents = (inventoryFilter==='pending'?orderedComponents():packet.components).filter(item=>inReviewQueue(item,draft,inventoryFilter));
const summaryDetails = [
stats.revisions+draft.missing.length ? `${stats.revisions+draft.missing.length} feedback ready` : '',
carriedCount ? `${carriedCount} ${carriedCount===1?'approval':'approvals'} kept` : '',
@@ -157,8 +157,8 @@ 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} ${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="${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 ${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('')}
${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('')}
<div class="draw-box" hidden></div>
</div></div>
@@ -167,7 +167,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
</section>
<section class="inspector" aria-label="Selected component">
<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="Component comparison" tabindex="0">
${finished ? `<section class="review-summary" id="review-summary" tabindex="-1"><h2>Components reviewed.</h2><p>${stats.approved} approved · ${stats.revisions} flagged for repair${draft.missing.length ? ` · ${draft.missing.length} missing` : ''}</p><p>Check your decisions below. ${stats.hasFeedback ? 'Send feedback when youre ready.' : 'Confirm nothing is missing, then approve and continue.'}</p><div class="summary-decisions">${packet.components.map(item=>{const decision=draft.decisions[item.id];return `<button data-select="${esc(item.id)}"><strong>${esc(item.name)}</strong><span>${decision?.action==='revise' ? '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><div class="review-form">${notice}</div>` : c ? `<div class="material">${icon(presentation!.code ? 'code' : 'image')}<strong>${esc(materialLabel)}</strong><span>${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}</span></div>
${finished ? `<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 ? `<div class="material">${icon(presentation!.code ? 'code' : 'image')}<strong>${esc(materialLabel)}</strong><span>${v?.material ? `${v.material.width} × ${v.material.height} px` : ''}</span></div>
${history ? `<div class="repair-context">
${repair?.prior?.action==='revise'?`<section class="previous-feedback" aria-label="Previous feedback"><h3>Previous feedback <span>Round ${repair.feedbackRound}</span></h3><p class="previous-verdict">Needs work</p><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>`:''}
@@ -182,13 +182,13 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
</div></div><div class="component-details"><p class="layering">${esc(v?.context?.layering ?? 'Layer placement not recorded.')}</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>':''}<div class="decisions" role="group" aria-label="Decision for ${esc(c.name)}"><div class="decision-title"><strong>Your review <span>Round ${packet.round}</span></strong>${viewingPrevious?'<p>Return to Current to review this round.</p>':''}</div><button id="approve" class="decision-approve ${d?.action === 'approve' ? 'approved' : ''}" aria-pressed="${d?.action === 'approve'}">Looks good</button><button id="revise" class="decision-revise ${d?.action === 'revise' ? 'revise' : ''}" aria-pressed="${d?.action === 'revise'}">Needs work</button>${d ? `<button id="clear" class="quiet icon-button" aria-label="Clear decision" title="Clear decision">${icon('undo')}</button>` : ''}</div>
${edit ? `<form id="feedback-form"><div class="feedback-fields"><label class="feedback">What needs to change?<textarea id="feedback" aria-describedby="feedback-hint">${esc(edit.feedback)}</textarea></label><p id="feedback-hint" class="feedback-hint">Optional — leave blank for the agent to diagnose.</p><label class="check"><input id="split" type="checkbox" ${edit.split ? 'checked' : ''}> Split into separately reviewable components</label></div><div class="feedback-actions"><button id="cancel-feedback" type="button" class="quiet">Cancel</button><button id="save-feedback" type="submit" class="primary">${isLast?'Save & finish review':'Save & next'} <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6"/></svg></button><span class="shortcut-hint">${shortcutLabel}</span></div></form>` : d?.action==='revise' ? `<p class="saved-feedback">${esc(d.feedback || 'No note — agent will diagnose.')}</p>` : ''}
</div>` : missing ? `<p>This piece will be added to the unresolved inventory.</p><label class="feedback">Name<input id="missing-name" value="${esc(missing.name)}"></label><label class="feedback">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>'}
${edit ? `<form id="feedback-form"><div class="feedback-fields"><label class="feedback-field">What needs to change?<textarea id="feedback" aria-describedby="feedback-hint">${esc(edit.feedback)}</textarea></label><p id="feedback-hint" class="feedback-hint">Optional — leave blank for the agent to diagnose.</p><label class="check"><input id="split" type="checkbox" ${edit.split ? 'checked' : ''}> Split into separately reviewable components</label></div><div class="feedback-actions"><button id="cancel-feedback" type="button" class="quiet">Cancel</button><button id="save-feedback" type="submit" class="primary">${isLast?'Save & finish review':'Save & next'} <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6"/></svg></button><span class="shortcut-hint">${shortcutLabel}</span></div></form>` : d?.action==='revise' ? `<p class="saved-feedback">${esc(d.feedback || 'No note — agent will diagnose.')}</p>` : ''}
</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>
<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="attention" aria-pressed="${inventoryFilter==='attention'}">Needs attention <b>${attentionCount}</b></button><button data-filter="approved" aria-pressed="${inventoryFilter==='approved'}">Approved <b>${stats.approved}</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} ${selected === item.id ? 'active' : ''}" data-select="${esc(item.id)}" aria-pressed="${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==='approved'?[]: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==='approved'||!draft.missing.length)?`<p class="inventory-empty">${inventoryFilter==='attention'?'Every component is approved. Confirm the map is complete, then continue.':'No components approved yet.'}</p>`:''}</div></section>
<footer><div><button id="approve-rest" ${!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…':stats.hasFeedback?'Send feedback':'Approve & continue'}</button></div></footer>
<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>
<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="Enlarged component comparison"></dialog>`;
const comparisonDialog=root.querySelector<HTMLDialogElement>('#comparison-dialog')!;
const comparisonPanel=root.querySelector<HTMLElement>('.comparison-panel');
@@ -208,16 +208,17 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
root.querySelectorAll<HTMLElement>('[data-select]').forEach(el => el.onclick = () => {if(marking)return; finished=false; selected=el.dataset.select!; mobilePane='component'; overlay=false; zoom='fit'; outputMode='isolated'; previousRound=false; render();});
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='attention';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){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=()=>{
finished=false;inventoryFilter=button.dataset.filter as typeof inventoryFilter;
const matches=orderedComponents().filter(item=>inventoryFilter==='all'||(stateFor(item).kind==='approved')===(inventoryFilter==='approved'));
const selectedMissing=inventoryFilter!=='approved'&&draft.missing.some(item=>item.id===selected);
inventoryFilter=button.dataset.filter as InventoryFilter;finished=!stats.pending&&inventoryFilter==='pending';
const matches=orderedComponents().filter(item=>inReviewQueue(item,draft,inventoryFilter));
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';}
render();
});
on('show-summary',()=>{finished=true;mobilePane='component';inventoryFilter='reviewed';render();focusReview('review-summary');});
on('approve',()=>updateDecision('approve')); on('revise',beginFeedback);
on('clear',()=>{if(c){delete draft.decisions[c.id];delete edits[c.id];}lastDecision=null; render();});
on('clear',()=>{if(c){delete draft.decisions[c.id];delete edits[c.id];}lastDecision=null;finished=false;inventoryFilter='pending';render();});
on('cancel-feedback',()=>{if(c)delete edits[c.id];if(restoreTrayAfterFeedback){trayOpen=true;restoreTrayAfterFeedback=false;}render();focusReview('revise');});
root.querySelector('#feedback-form')?.addEventListener('submit',e=>{e.preventDefault();updateDecision('revise');});
root.querySelector('#feedback')?.addEventListener('keydown',e=>{
@@ -242,7 +243,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
on('show-component',()=>{mobilePane='component';render();});
on('mark',()=>{marking=!marking;render();}); on('add-box',()=>addMissing({x:.35,y:.35,w:.2,h:.2}));
on('remove-missing',()=>{draft.missing=draft.missing.filter(m=>m.id!==selected);selected=packet.components[0]?.id;render();});
on('approve-rest',()=>{if(uncommitted)return;draft=approveRemaining(packet,draft);lastDecision=null;finished=true;mobilePane='component';render();focusReview('review-summary');});
on('approve-rest',()=>{if(uncommitted)return;draft=approveRemaining(packet,draft);lastDecision=null;finished=true;inventoryFilter='reviewed';mobilePane='component';render();focusReview('review-summary');});
root.querySelector('#inventory-confirm')?.addEventListener('change',e=>{draft.inventoryConfirmed=(e.target as HTMLInputElement).checked;render();});
root.querySelector('#feedback')?.addEventListener('input',e=>{if(c&&edits[c.id])edits[c.id].feedback=(e.target as HTMLTextAreaElement).value;});
root.querySelector('#split')?.addEventListener('change',e=>{if(c&&edits[c.id])edits[c.id].split=(e.target as HTMLInputElement).checked;});
@@ -254,7 +255,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
if(Number.isFinite(next)) missing.box[k]=Math.max(k==='w'||k==='h'?.001:0, Math.min(1,next));
missing.box.w=Math.min(missing.box.w,1-missing.box.x);missing.box.h=Math.min(missing.box.h,1-missing.box.y);render();
}));
on('submit',async()=>{if(uncommitted||sending||submitted)return;sending=true;error='';render();try{await options.onSubmit(submission(packet,draft));submitted=true;}catch(e){error=e instanceof Error?e.message:'Could not save. Try again.';}finally{sending=false;render();}});
on('submit',async()=>{if(uncommitted||sending||submitted)return;sending=true;error='';render();try{await options.onSubmit(submission(packet,draft));submitted=true;finished=true;inventoryFilter='reviewed';}catch(e){error=e instanceof Error?e.message:'Could not save. Try again.';}finally{sending=false;render();}});
const map = root.querySelector<HTMLElement>('.map')!;
function point(e: PointerEvent) {const r=map.getBoundingClientRect();return {x:Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),y:Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))};}
map.addEventListener('pointerdown',e=>{if(!marking)return;drag=point(e);map.setPointerCapture(e.pointerId);e.preventDefault();});
@@ -345,6 +346,7 @@ export function mountComponentReview(host: HTMLElement, packet: ReviewPacket, op
if(focusMobileComparison&&window.matchMedia('(max-width:800px)').matches){const body=root.querySelector<HTMLElement>('.inspection-content');const comparison=root.querySelector<HTMLElement>('.compare');if(body&&comparison)body.scrollTop+=comparison.getBoundingClientRect().top-body.getBoundingClientRect().top;}
updateScrollEdges();
window.scrollTo(scrollX,scrollY);
if(!submitted) options.onDraftChange?.(structuredClone(draft));
}
render();
return {destroy(){resize?.disconnect();root.replaceChildren();},getDraft():Draft{return structuredClone(draft);}};
+2 -2
View File
@@ -4,7 +4,7 @@ export const styles = `
*{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}
.connector{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible}.connector path{fill:none;stroke:var(--selection);stroke-width:2}.workbench{align-items:start;display:grid;grid-template-columns:minmax(0,1.18fr) minmax(0,1fr);gap:40px;position:relative}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;min-height:36px}.section-head h2{font-size:17px;font-weight:500;line-height:1.2}.section-head>span,.section-head h2>span:not(.number){font-size:12px;color:var(--muted)}.section-head button{font-size:12px}.number{display:inline-flex;align-items:center;justify-content:center;width:27px;height:27px;border:1px solid var(--teal);color:var(--teal);margin-right:7px;font:12px var(--font-mono,monospace)}
.map{position:relative;background:#eaeaea;isolation:isolate}.comp{width:100%;height:100%;display:block;user-select:none}.map.marking{touch-action:none;cursor:crosshair}.map.marking .pin{pointer-events:none;opacity:.25}.pin{position:absolute;transform:translate(-50%,-50%);padding:0;min-height:25px;width:25px;height:25px;border-radius:50%;border:1px solid #fff;background:#fff;color:#292929;font:11px var(--font-mono,monospace);box-shadow:0 1px 4px #0006;z-index:2}.pin.selected{background:var(--teal);color:#fff;border-color:var(--teal);box-shadow:none;outline:none;z-index:3}.pin:focus-visible{outline:2px solid white;outline-offset:3px}.region,.draw-box{position:absolute;pointer-events:none;outline:2px solid var(--selection);z-index:1}.draw-box{background:#28625e33;z-index:4}.map-caption{font-size:12px;color:var(--muted);padding-top:12px;min-height:40px}.map-caption button{display:block;margin-top:10px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px}.compare figure{min-width:0}.compare figcaption{height:24px;min-height:0;font-size:12px;margin-bottom:9px}.compare figcaption span{display:block;color:var(--muted);font-size:11px}.crop-stage{position:relative;overflow:hidden;background:var(--comp-background,#eee);min-width:0}.crop-image{position:absolute;max-width:none;height:auto}.asset{display:block;width:100%;height:100%;object-fit:cover}.crop-stage iframe{position:absolute;max-width:none;border:0;transform-origin:top left;pointer-events:none}.overlay-image{opacity:.5;pointer-events:none}.component-note{font-size:12px;line-height:1.5;color:var(--muted);margin:6px 0 0}.decisions{display:flex;gap:10px;padding:10px 0;background:var(--paper);position:sticky;bottom:0;z-index:6}.decisions>button{flex:1;min-height:48px;font-weight:600;font-size:15px;border-color:var(--teal)}.decision-approve{background:var(--teal);color:white}.decision-approve:hover{background:#224e4b;color:white}.decision-revise{color:var(--teal);background:var(--paper)}.decisions>.quiet{flex:0;border:0;background:transparent;font-size:12px}.decisions .approved{color:var(--teal);background:#e9f0ed;border-color:var(--teal)}.decisions .revise{color:var(--warn);background:#f7f0e6;border-color:var(--warn)}.feedback{display:block;margin-top:16px;font-size:13px}.feedback>span{float:right;color:var(--muted);font-size:12px}.feedback textarea,.feedback input{display:block;margin-top:7px}.check{display:flex;align-items:center;gap:7px;font-size:12px;line-height:1.5;margin-top:12px}.coordinates{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:16px 0}.coordinates label{font-size:12px}.coordinates input{margin-top:5px}
.map{position:relative;background:#eaeaea;isolation:isolate}.comp{width:100%;height:100%;display:block;user-select:none}.map.marking{touch-action:none;cursor:crosshair}.map.marking .pin{pointer-events:none;opacity:.25}.pin{position:absolute;transform:translate(-50%,-50%);padding:0;min-height:25px;width:25px;height:25px;border-radius:50%;border:1px solid #fff;background:#fff;color:#292929;font:11px var(--font-mono,monospace);box-shadow:0 1px 4px #0006;z-index:2}.pin.selected{background:var(--teal);color:#fff;border-color:var(--teal);box-shadow:none;outline:none;z-index:3}.pin:focus-visible{outline:2px solid white;outline-offset:3px}.region,.draw-box{position:absolute;pointer-events:none;outline:2px solid var(--selection);z-index:1}.draw-box{background:#28625e33;z-index:4}.map-caption{font-size:12px;color:var(--muted);padding-top:12px;min-height:40px}.map-caption button{display:block;margin-top:10px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px}.compare figure{min-width:0}.compare figcaption{height:24px;min-height:0;font-size:12px;margin-bottom:9px}.compare figcaption span{display:block;color:var(--muted);font-size:11px}.crop-stage{position:relative;overflow:hidden;background:var(--comp-background,#eee);min-width:0}.crop-image{position:absolute;max-width:none;height:auto}.asset{display:block;width:100%;height:100%;object-fit:cover}.crop-stage iframe{position:absolute;max-width:none;border:0;transform-origin:top left;pointer-events:none}.overlay-image{opacity:.5;pointer-events:none}.component-note{font-size:12px;line-height:1.5;color:var(--muted);margin:6px 0 0}.decisions{display:flex;gap:10px;padding:10px 0;background:var(--paper);position:sticky;bottom:0;z-index:6}.decisions>button{flex:1;min-height:48px;font-weight:600;font-size:15px;border-color:var(--teal)}.decision-approve{background:var(--teal);color:white}.decision-approve:hover{background:#224e4b;color:white}.decision-revise{color:var(--teal);background:var(--paper)}.decisions>.quiet{flex:0;border:0;background:transparent;font-size:12px}.decisions .approved{color:var(--teal);background:#e9f0ed;border-color:var(--teal)}.decisions .revise{color:var(--warn);background:#f7f0e6;border-color:var(--warn)}.feedback-field{display:block;margin-top:16px;font-size:13px}.feedback-field>span{float:right;color:var(--muted);font-size:12px}.feedback-field textarea,.feedback-field input{display:block;margin-top:7px}.check{display:flex;align-items:center;gap:7px;font-size:12px;line-height:1.5;margin-top:12px}.coordinates{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:16px 0}.coordinates label{font-size:12px}.coordinates input{margin-top:5px}
.inventory-section{margin-top:28px;border-top:1px solid var(--line);padding-top:16px}.inventory-section .section-head{margin-bottom:10px}.inventory{display:flex;gap:8px;overflow-x:auto;padding:3px 3px 14px;scrollbar-color:#a6bcb8 #eee;scrollbar-width:thin}.item{position:relative;flex:0 0 134px;display:grid;grid-template-columns:20px 1fr;column-gap:8px;row-gap:3px;padding:10px;text-align:left;background:transparent}.item strong{grid-column:2;font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item.active{background:var(--paper);border-color:var(--teal)}.thumb-crop{position:relative;display:block;overflow:hidden}.item-thumb{display:flex;align-items:center;justify-content:center;grid-column:1/-1;position:relative;overflow:hidden;width:100%;height:76px;background:var(--comp-background,#eee);margin-bottom:7px}.item-thumb img{width:100%;height:100%;object-fit:contain}.item-thumb img[style]{height:auto}.inventory.all{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));overflow:visible}.item-number{grid-row:2/4;font:12px var(--font-mono,monospace);color:var(--muted);padding-top:2px}.state{grid-column:2;font-size:11px;color:var(--muted)}.state.approve{color:var(--teal)}.state.revise{color:var(--warn)}footer{display:flex;justify-content:space-between;gap:24px;padding:20px 0 24px;border-top:1px solid var(--line);margin-top:8px}.submit-area{display:flex;align-items:center;gap:20px}.submit-area p{font-size:12px;color:var(--muted);max-width:28ch}.primary{min-height:46px;font-weight:600;background:var(--teal);border-color:var(--teal);color:white;white-space:nowrap}.primary:hover{color:white;background:#224e4b}.primary:disabled{opacity:.45}.inspector>p{margin:12px 0}
@media(min-width:1300px){.workbench{gap:56px}.review{padding-top:32px}.component-note{max-width:62ch}}
@media(max-width:800px){.connector{display:none}.review{padding:20px 16px 0}.workbench{grid-template-columns:1fr;gap:24px}.reference{max-width:640px;margin:auto;width:100%}.inspector{border-top:1px solid var(--line);padding-top:16px}.compare{max-width:640px}.inventory-section .section-head{align-items:flex-start;flex-direction:column;gap:4px}footer{flex-direction:column}.submit-area{justify-content:space-between}.badge{font-size:11px}.section-head{gap:8px}h1{font-size:34px}header{align-items:flex-start}.section-head h2{font-size:16px}}
@@ -33,7 +33,7 @@ export const styles = `
@media(max-width:800px){.inventory-section .section-head{gap:10px}.inventory-filters{width:100%}.inventory-filters button{padding-inline:7px;font-size:11px;flex:1}.inventory-filters b{margin-left:3px}}
@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}
.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:4px 2px}.completion-mark{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;background:#e6efe8;color:#28625e;float:left;margin:0 10px 8px 0}.completion-mark svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.6}.completion-link{display:inline-flex;align-items:center;gap:8px;color:var(--teal);border:0;background:transparent;padding:0;font-size:12px;text-align:left}.completion-link svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;flex-shrink:0}.queue-complete #approve-rest{display:none}.review-summary h2{font-size:22px;line-height:32px}.review-summary p{clear:both;margin-top:8px;color:var(--muted)}.summary-decisions{margin-top:24px;display:grid}.summary-decisions button{display:grid;grid-template-columns:1fr auto;gap:6px 16px;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:12px 0;background:transparent}.summary-decisions strong{font-size:13px;font-weight:500}.summary-decisions span{font-size:12px;color:var(--teal)}.summary-decisions small{grid-column:1/-1;font-size:12px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere}
` + appLayout + `
.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}