diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 298350d..61d669e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -654,8 +654,14 @@ textarea { resize: vertical; min-height: 120px; } .issue-filing-review-summary > div { display:grid; gap:4px; padding:10px; border:1px solid #2a496e; border-radius:10px; min-width:0; } .issue-filing-review-summary dt { color:#94a3b8; font-size:12px; font-weight:700; text-transform:uppercase; } .issue-filing-review-summary dd { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } -.issue-filing-review ol { display:grid; gap:8px; margin:0; padding-left:24px; overflow-wrap:anywhere; } -.issue-filing-review ol:empty::after { content:'No screenshots attached.'; display:list-item; color:#94a3b8; } +.issue-filing-review ol { display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr)); gap:8px; margin:0; padding:0; list-style:none; overflow-wrap:anywhere; } +.issue-filing-evidence-selector { min-height:44px; width:100%; padding:4px; border:2px solid #31577f; border-radius:10px; background:#08111f; } +.issue-filing-evidence-selector[aria-pressed="true"] { border-color:#93c5fd; box-shadow:0 0 0 2px rgba(96,165,250,.35); } +.issue-filing-evidence-selector img { display:block; width:100%; height:64px; object-fit:cover; border-radius:6px; } +.issue-filing-evidence-preview { display:grid; gap:8px; margin:10px 0 0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#020617; } +.issue-filing-evidence-preview[hidden] { display:none; } +.issue-filing-evidence-preview > img { display:block; width:100%; max-height:42dvh; object-fit:contain; border-radius:8px; background:#000; } +.issue-filing-evidence-preview figcaption { display:grid; gap:3px; min-width:0; overflow-wrap:anywhere; } .issue-filing-review-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .issue-filing-review-actions button { min-height:44px; width:100%; } @media(max-width:320px) { .issue-filing-review-panel { padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); } } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 37f0d23..c6e2fc9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -557,6 +557,12 @@ confirmButton: qs('#confirm-issue-filing'), backButton: qs('#back-to-issue-edit'), evidenceList: qs('#issue-filing-review-evidence'), + evidenceEmpty: qs('#issue-filing-review-evidence-empty'), + evidencePreview: qs('#issue-filing-review-evidence-preview'), + evidenceImage: qs('#issue-filing-review-evidence-image'), + evidencePosition: qs('#issue-filing-review-evidence-position'), + evidenceFilename: qs('#issue-filing-review-evidence-filename'), + evidenceNote: qs('#issue-filing-review-evidence-note'), repository: qs('#issue-filing-review-repository'), intent: qs('#issue-filing-review-intent'), title: qs('#issue-filing-review-title'), @@ -564,6 +570,8 @@ metadata: qs('#issue-filing-review-metadata'), status: qs('#issue-filing-review-status'), document, + createObjectURL: blob => URL.createObjectURL(blob), + revokeObjectURL: url => URL.revokeObjectURL(url), onConfirm: admitReviewedIssue, }); const authoredOutbox = createAuthoredOutbox({ diff --git a/frontend/index.html b/frontend/index.html index 04993b7..85efcdc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -866,7 +866,16 @@

Evidence in filing order

+
    +
    diff --git a/frontend/issue-filing-review.js b/frontend/issue-filing-review.js index 891f943..a48657b 100644 --- a/frontend/issue-filing-review.js +++ b/frontend/issue-filing-review.js @@ -20,6 +20,68 @@ let reviewed = null; let trigger = null; let busy = false; + let evidenceItems = []; + const generatedUrls = new Set(); + + function releaseEvidenceUrls() { + generatedUrls.forEach(url => options.revokeObjectURL?.(url)); + generatedUrls.clear(); + evidenceItems = []; + } + + function evidenceUrl(attachment) { + if (attachment?.blob) { + const url = options.createObjectURL(attachment.blob); + generatedUrls.add(url); + return url; + } + if (attachment?.data) { + return 'data:' + String(attachment.contentType || 'application/octet-stream') + + ';base64,' + String(attachment.data); + } + return ''; + } + + function selectEvidence(index) { + const selected = evidenceItems[index]; + if (!selected) return; + evidenceItems.forEach((item, position) => + item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false')); + const filename = selected.attachment.filename || 'Screenshot'; + options.evidencePreview.hidden = false; + options.evidenceImage.src = selected.url; + options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename; + options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length; + options.evidenceFilename.textContent = filename; + options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.'; + } + + function renderVisualEvidence(attachments) { + releaseEvidenceUrls(); + options.evidenceEmpty.hidden = attachments.length > 0; + options.evidencePreview.hidden = true; + options.evidenceImage.src = ''; + evidenceItems = attachments.map((attachment, index) => { + const url = evidenceUrl(attachment); + const item = options.document.createElement('li'); + const button = options.document.createElement('button'); + const image = options.document.createElement('img'); + const filename = attachment.filename || 'Screenshot'; + button.setAttribute('type', 'button'); + button.className = 'issue-filing-evidence-selector'; + button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename); + button.setAttribute('aria-pressed', 'false'); + image.src = url; + image.alt = ''; + button.appendChild(image); + item.appendChild(button); + return { attachment, button, item, url }; + }); + evidenceItems.forEach((entry, index) => + entry.button.addEventListener('click', () => selectEvidence(index))); + options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item)); + if (evidenceItems.length) selectEvidence(0); + } function close() { options.sheet.hidden = true; @@ -27,6 +89,7 @@ trigger = null; reviewed = null; busy = false; + releaseEvidenceUrls(); options.confirmButton.disabled = false; previousTrigger?.focus(); } @@ -47,6 +110,10 @@ 'Assigned to you', ].join(' ยท '); const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean); + if (options.evidencePreview) { + renderVisualEvidence(attachments); + return; + } options.evidenceList.replaceChildren(...attachments.map((attachment, index) => { const item = options.document.createElement('li'); item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') + diff --git a/tests/test_issue_filing_review.py b/tests/test_issue_filing_review.py index 7f6702d..d65cb22 100644 --- a/tests/test_issue_filing_review.py +++ b/tests/test_issue_filing_review.py @@ -99,6 +99,122 @@ process.stdout.write(JSON.stringify({{hidden:sheet.hidden,focused:trigger.focuse assert run_node(script) == {"hidden": True, "focused": True, "title": "Original"} +def test_review_renders_exact_evidence_bytes_and_selects_the_preview(): + script = f""" +const createReview = require({json.dumps(str(MODULE))}); +function target(tag='div') {{ + const listeners={{}}, attributes={{}}; + return {{tag,hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'', + addEventListener:(name,fn)=>listeners[name]=fn, + dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},currentTarget:this,...event}}), + replaceChildren(...items){{this.children=items;}}, appendChild(item){{this.children.push(item);}}, + setAttribute(name,value){{attributes[name]=String(value);}}, getAttribute:name=>attributes[name], + focus(){{this.focused=true;}}}}; +}} +const documentRef={{createElement:tag=>target(tag),addEventListener:()=>{{}}}}; +const evidence=target(), preview=target(), image=target('img'); +const position=target(), filename=target(), note=target(), empty=target(); +const created=[]; +const review=createReview({{ + sheet:target(),confirmButton:target(),backButton:target(),evidenceList:evidence, + evidencePreview:preview,evidenceImage:image,evidencePosition:position, + evidenceFilename:filename,evidenceNote:note,evidenceEmpty:empty, + repository:target(),intent:target(),title:target(),body:target(),metadata:target(), + document:documentRef,createObjectURL:blob=>{{const url='blob:review-'+blob.size;created.push(url);return url;}}, + revokeObjectURL:()=>{{}},onConfirm:async()=>{{}}, +}}); +review.open({{draft:{{repository:'o/r',title:'Privacy check',attachments:[ + {{filename:'redacted.png',contentType:'image/png',blob:new Blob(['flattened-redaction']),note:'Secrets hidden'}}, + {{filename:'legacy.webp',contentType:'image/webp',data:'bGVnYWN5',note:'Expected result'}} +]}},intent:'create-and-assign'}},target()); +const firstButton=evidence.children[0].children[0]; +const secondButton=evidence.children[1].children[0]; +secondButton.dispatch('click'); +process.stdout.write(JSON.stringify({{ + created,thumbnailSources:[firstButton.children[0].src,secondButton.children[0].src], + labels:[firstButton.getAttribute('aria-label'),secondButton.getAttribute('aria-label')], + selected:[firstButton.getAttribute('aria-pressed'),secondButton.getAttribute('aria-pressed')], + preview:{{hidden:preview.hidden,src:image.src,alt:image.alt,position:position.textContent, + filename:filename.textContent,note:note.textContent}},emptyHidden:empty.hidden, +}})); +""" + + result = run_node(script) + + assert result == { + "created": ["blob:review-19"], + "thumbnailSources": [ + "blob:review-19", + "data:image/webp;base64,bGVnYWN5", + ], + "labels": [ + "Preview evidence 1 of 2: redacted.png", + "Preview evidence 2 of 2: legacy.webp", + ], + "selected": ["false", "true"], + "preview": { + "hidden": False, + "src": "data:image/webp;base64,bGVnYWN5", + "alt": "Evidence 2 of 2: legacy.webp", + "position": "Evidence 2 of 2", + "filename": "legacy.webp", + "note": "Expected result", + }, + "emptyHidden": True, + } + + +def test_failed_filing_keeps_preview_and_success_releases_blob_url_once(): + script = f""" +const createReview = require({json.dumps(str(MODULE))}); +function target() {{ + const listeners={{}}; + return {{hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'', + addEventListener:(name,fn)=>listeners[name]=fn, + dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},...event}}), + replaceChildren(...items){{this.children=items;}},appendChild(item){{this.children.push(item);}}, + setAttribute(){{}},focus(){{this.focused=true;}}}}; +}} +const documentRef={{createElement:()=>target(),addEventListener:()=>{{}}}}; +const sheet=target(), confirm=target(), trigger=target(), image=target(), preview=target(); +const revoked=[]; +let attempts=0; +const review=createReview({{ + sheet,confirmButton:confirm,backButton:target(),evidenceList:target(),evidencePreview:preview, + evidenceImage:image,evidencePosition:target(),evidenceFilename:target(),evidenceNote:target(),evidenceEmpty:target(), + repository:target(),intent:target(),title:target(),body:target(),metadata:target(),status:target(), + document:documentRef,createObjectURL:blob=>'blob:attempt-'+blob.size, + revokeObjectURL:url=>revoked.push(url), + onConfirm:async()=>{{attempts+=1;if(attempts===1)throw new Error('Network unavailable');}}, +}}); +review.open({{draft:{{repository:'o/r',title:'Verify',attachment:{{filename:'one.png',blob:new Blob(['one'])}}}},intent:'create-and-assign'}},trigger); +confirm.dispatch('click'); +setImmediate(()=>{{ + const afterFailure={{hidden:sheet.hidden,previewHidden:preview.hidden,src:image.src,disabled:confirm.disabled,revoked:[...revoked]}}; + confirm.dispatch('click'); + setImmediate(()=>process.stdout.write(JSON.stringify({{ + attempts,afterFailure,afterSuccess:{{hidden:sheet.hidden,triggerFocused:trigger.focused||false,revoked}} + }}))); +}}); +""" + + assert run_node(script) == { + "attempts": 2, + "afterFailure": { + "hidden": False, + "previewHidden": False, + "src": "blob:attempt-3", + "disabled": False, + "revoked": [], + }, + "afterSuccess": { + "hidden": True, + "triggerFocused": True, + "revoked": ["blob:attempt-3"], + }, + } + + def test_create_actions_review_the_complete_payload_before_durable_admission(): root = Path(__file__).parents[1] html = (root / "frontend" / "index.html").read_text() @@ -112,6 +228,12 @@ def test_create_actions_review_the_complete_payload_before_durable_admission(): assert 'id="issue-filing-review-title"' in html assert 'id="issue-filing-review-body"' in html assert 'id="issue-filing-review-evidence"' in html + assert 'id="issue-filing-review-evidence-empty"' in html + assert 'id="issue-filing-review-evidence-preview"' in html + assert 'id="issue-filing-review-evidence-image"' in html + assert 'id="issue-filing-review-evidence-position"' in html + assert 'id="issue-filing-review-evidence-filename"' in html + assert 'id="issue-filing-review-evidence-note"' in html assert 'id="back-to-issue-edit"' in html assert 'id="confirm-issue-filing"' in html assert '' in html @@ -122,6 +244,10 @@ def test_create_actions_review_the_complete_payload_before_durable_admission(): assert "filingReview.open" in submit_handler assert "issueOutbox.enqueueDurably" not in submit_handler assert "async function admitReviewedIssue" in dashboard + assert "evidencePreview: qs('#issue-filing-review-evidence-preview')" in dashboard + assert "evidenceImage: qs('#issue-filing-review-evidence-image')" in dashboard + assert "createObjectURL: blob => URL.createObjectURL(blob)" in dashboard + assert "revokeObjectURL: url => URL.revokeObjectURL(url)" in dashboard assert "issueOutbox.enqueueDurably" in dashboard.split( "async function admitReviewedIssue", 1 )[1].split("qs('#create-issue-form').addEventListener", 1)[0] @@ -130,6 +256,9 @@ def test_create_actions_review_the_complete_payload_before_durable_admission(): assert "max-height:100dvh" in css assert "overflow-x:hidden" in css assert "env(safe-area-inset-bottom)" in css + assert ".issue-filing-evidence-preview" in css + assert ".issue-filing-evidence-selector" in css + assert "min-height:44px" in css.split(".issue-filing-evidence-selector", 1)[1] assert '"static/issue-filing-review.js"' in bundle