diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index e89b25b..d1948d4 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -633,8 +633,19 @@ textarea { resize: vertical; min-height: 120px; }
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
-.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
+.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
+.issue-evidence-editor { position:fixed; inset:0; z-index:70; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
+.issue-evidence-editor[hidden] { display:none; }
+.issue-evidence-editor-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); background:#0b1526; border:1px solid #2a496e; border-radius:14px 14px 0 0; }
+.issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; }
+.issue-evidence-editor-panel h3 { margin:0 0 4px; }
+.issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; }
+.issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; }
+.issue-evidence-editor button { min-width:44px; min-height:44px; }
+.issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
+.issue-evidence-editor-apply { width:100%; }
+@media(max-width:320px) { .issue-evidence-editor-panel { padding:12px; } .issue-evidence-editor-tools { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index a5e1f39..6653518 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -487,6 +487,20 @@
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
+ editor: {
+ document,
+ edit: qs('#edit-create-issue-attachment'),
+ dialog: qs('#issue-evidence-editor'),
+ canvas: qs('#issue-evidence-editor-canvas'),
+ exportCanvas: qs('#issue-evidence-editor-export'),
+ crop: qs('#crop-issue-evidence'),
+ redact: qs('#redact-issue-evidence'),
+ undo: qs('#undo-issue-evidence-edit'),
+ reset: qs('#reset-issue-evidence-edit'),
+ cancel: qs('#cancel-issue-evidence-edit'),
+ apply: qs('#apply-issue-evidence-edit'),
+ status: qs('#issue-evidence-editor-status'),
+ },
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
diff --git a/frontend/index.html b/frontend/index.html
index f239176..653fafd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -769,6 +769,7 @@
+
@@ -826,6 +827,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
Drag over private information to hide it.
+
+
+
@@ -1177,6 +1196,7 @@
+
diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js
index 63e77ef..af00571 100644
--- a/frontend/issue-attachment.js
+++ b/frontend/issue-attachment.js
@@ -1,9 +1,10 @@
(function(root, factory) {
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
- const api = factory(review);
+ const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
+ const api = factory(review, editor);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
-})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview) {
+})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
@@ -103,6 +104,20 @@
return state();
}
+ function replace(index, file) {
+ const position = Number(index);
+ if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
+ if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
+ file.size <= 0 || file.size > MAX_BYTES) {
+ throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
+ }
+ selectionGeneration += 1;
+ selected[position] = {
+ ...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
+ };
+ return state();
+ }
+
function setNote(index, value) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
@@ -199,7 +214,7 @@
return text ? text + '\n\n' + evidence : evidence;
}
- return { select, restore, remove, move, setNote, note, clear, state, serialize, prepareComment };
+ return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
}
function mount(options) {
@@ -211,11 +226,14 @@
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
let previewUrl = '';
let selectionSequence = 0;
- const review = reviewEnabled ? issueEvidenceReview.create({ ...options, controller }) : null;
+ const review = reviewEnabled ? issueEvidenceReview.create({
+ ...options, edit:options.editor?.edit, controller,
+ }) : null;
function setBusy(value) {
options.input.disabled = Boolean(value);
options.remove.disabled = Boolean(value);
+ if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
review?.setBusy(value);
}
@@ -224,6 +242,7 @@
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
review?.clear();
+ if (options.editor?.edit) options.editor.edit.disabled = true;
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
@@ -237,6 +256,7 @@
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
+ if (options.editor?.edit) options.editor.edit.disabled = false;
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
@@ -339,6 +359,20 @@
return restored;
}
+ if (review && options.editor && issueEvidenceEditor) {
+ issueEvidenceEditor.mount({
+ ...options.editor,
+ controller,
+ getActiveIndex:review.activeIndex,
+ optimizeImage,
+ onApplied: async index => {
+ const value = await controller.serialize();
+ review.render(value, index);
+ options.status.textContent = 'Edited screenshot flattened and ready to file.';
+ },
+ });
+ options.editor.edit.disabled = !controller.state();
+ }
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
}
diff --git a/frontend/issue-evidence-editor.js b/frontend/issue-evidence-editor.js
new file mode 100644
index 0000000..1da9bc2
--- /dev/null
+++ b/frontend/issue-evidence-editor.js
@@ -0,0 +1,212 @@
+(function(root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.issueEvidenceEditor = api;
+})(typeof self !== 'undefined' ? self : this, function() {
+ 'use strict';
+
+ function boundedRect(value, bounds) {
+ const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
+ const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
+ const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
+ const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
+ return { x, y, width, height };
+ }
+
+ function createModel(size) {
+ const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
+ let crop = { ...full };
+ let redactions = [];
+ let history = [];
+
+ function remember() {
+ history.push({ crop:{...crop}, redactions:redactions.map(value => ({...value})) });
+ }
+ function setCrop(value) {
+ remember();
+ const next = boundedRect(value, crop);
+ crop = next;
+ redactions = redactions.filter(rectangle =>
+ rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
+ rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
+ ).map(rectangle => boundedRect(rectangle, crop));
+ return snapshot();
+ }
+ function addRedaction(value) {
+ remember();
+ redactions.push(boundedRect(value, crop));
+ return snapshot();
+ }
+ function undo() {
+ const previous = history.pop();
+ if (previous) {
+ crop = previous.crop;
+ redactions = previous.redactions;
+ }
+ return snapshot();
+ }
+ function reset() {
+ remember();
+ crop = { ...full };
+ redactions = [];
+ return snapshot();
+ }
+ function snapshot() {
+ return { crop:{...crop}, redactions:redactions.map(value => ({...value})) };
+ }
+ return { addRedaction, reset, setCrop, snapshot, undo };
+ }
+
+ function namedBlob(blob, name) {
+ if (typeof File === 'function') return new File([blob], name, {type:blob.type});
+ Object.defineProperty(blob, 'name', {value:name, configurable:true});
+ return blob;
+ }
+
+ function paint(options) {
+ const state = options.model.snapshot();
+ const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
+ const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
+ const width = Math.max(1, Math.round(state.crop.width * scale));
+ const height = Math.max(1, Math.round(state.crop.height * scale));
+ options.canvas.width = width;
+ options.canvas.height = height;
+ const context = options.canvas.getContext('2d');
+ if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
+ context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
+ context.fillStyle = '#000000';
+ state.redactions.forEach(rectangle => context.fillRect(
+ Math.round((rectangle.x - state.crop.x) * scale),
+ Math.round((rectangle.y - state.crop.y) * scale),
+ Math.round(rectangle.width * scale),
+ Math.round(rectangle.height * scale),
+ ));
+ return state;
+ }
+
+ async function flatten(options) {
+ paint(options);
+ const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
+ const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
+ if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
+ return namedBlob(blob, options.name || 'edited-screenshot.png');
+ }
+
+ function attachmentBlob(value) {
+ if (value.blob) return value.blob;
+ const binary = atob(String(value.data || ''));
+ return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
+ }
+
+ function mount(options) {
+ let source = null;
+ let model = null;
+ let mode = 'redact';
+ let start = null;
+ let selectedIndex = -1;
+ let previousFocus = null;
+
+ function status(message) { options.status.textContent = message; }
+ function setMode(value) {
+ mode = value;
+ options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
+ options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
+ status(value === 'crop' ? 'Drag around the part to keep.' : 'Drag over private information to hide it.');
+ }
+ function render() {
+ if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
+ }
+ function close() {
+ options.dialog.hidden = true;
+ if (source && typeof source.close === 'function') source.close();
+ source = null;
+ model = null;
+ start = null;
+ (previousFocus || options.edit).focus();
+ }
+ async function open() {
+ const value = await options.controller.serialize();
+ const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
+ selectedIndex = options.getActiveIndex();
+ const selected = values[selectedIndex];
+ if (!selected) return;
+ previousFocus = options.document.activeElement;
+ status('Opening screenshot editor…');
+ try {
+ const decode = options.decode || globalThis.createImageBitmap;
+ if (typeof decode !== 'function') throw new Error('decode');
+ source = await decode(attachmentBlob(selected));
+ model = createModel({width:source.width, height:source.height});
+ options.dialog.hidden = false;
+ render();
+ setMode('redact');
+ options.redact.focus();
+ } catch (_error) {
+ source = null;
+ status('This browser cannot edit the screenshot. Your original is unchanged.');
+ }
+ }
+ function point(event) {
+ const box = options.canvas.getBoundingClientRect();
+ const state = model.snapshot();
+ return {
+ x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
+ y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
+ };
+ }
+ options.canvas.addEventListener('pointerdown', event => {
+ if (!model) return;
+ start = point(event);
+ options.canvas.setPointerCapture?.(event.pointerId);
+ });
+ options.canvas.addEventListener('pointerup', event => {
+ if (!model || !start) return;
+ const end = point(event);
+ const rectangle = {
+ x:Math.min(start.x, end.x), y:Math.min(start.y, end.y),
+ width:Math.abs(end.x - start.x), height:Math.abs(end.y - start.y),
+ };
+ start = null;
+ if (rectangle.width < 3 || rectangle.height < 3) {
+ status('Drag a larger area on the screenshot.');
+ return;
+ }
+ if (mode === 'crop') model.setCrop(rectangle);
+ else model.addRedaction(rectangle);
+ render();
+ status(mode === 'crop' ? 'Crop applied. Undo or reset if needed.' : 'Private area hidden with an opaque redaction.');
+ });
+ options.edit.addEventListener('click', open);
+ options.crop.addEventListener('click', () => setMode('crop'));
+ options.redact.addEventListener('click', () => setMode('redact'));
+ options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
+ options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop and redactions reset.'); } });
+ options.cancel.addEventListener('click', close);
+ options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
+ options.apply.addEventListener('click', async () => {
+ if (!model || !source) return;
+ options.apply.disabled = true;
+ status('Applying flattened edit…');
+ try {
+ const current = await options.controller.serialize();
+ const values = Array.isArray(current) ? current : [current];
+ const selected = values[selectedIndex];
+ let derivative = await flatten({
+ source, model, canvas:options.exportCanvas, maxDimension:2048,
+ name:selected.filename, type:selected.contentType,
+ });
+ if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
+ options.controller.replace(selectedIndex, derivative);
+ await options.onApplied(selectedIndex);
+ close();
+ } catch (error) {
+ status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
+ } finally {
+ options.apply.disabled = false;
+ }
+ });
+ return { close, open };
+ }
+
+ return { createModel, flatten, mount, paint };
+});
diff --git a/frontend/issue-evidence-review.js b/frontend/issue-evidence-review.js
index 238c08a..71c3f25 100644
--- a/frontend/issue-evidence-review.js
+++ b/frontend/issue-evidence-review.js
@@ -69,6 +69,7 @@
options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1;
if (options.note) options.note.disabled = busy;
+ if (options.edit) options.edit.disabled = busy || !count();
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
}
@@ -93,6 +94,7 @@
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
options.preview.hidden = false;
+ if (options.edit) options.edit.disabled = busy;
Array.from(options.tray.children || []).forEach((button, index) =>
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0;
@@ -141,6 +143,7 @@
options.note.value = '';
options.note.disabled = true;
}
+ if (options.edit) options.edit.disabled = true;
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
}
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 7c8a74c..b96bad6 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -65,6 +65,7 @@ const SHELL = [
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-evidence-review.js',
+ BASE + 'static/issue-evidence-editor.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 3dfeb42..898fd0b 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -35,7 +35,8 @@ FEATURE_SOURCES = {
"static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
- "static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-attachment.js",
+ "static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
+ "static/issue-attachment.js",
),
}
CACHE_DECLARATION = re.compile(
diff --git a/tests/test_issue_evidence_editor.py b/tests/test_issue_evidence_editor.py
new file mode 100644
index 0000000..f373f89
--- /dev/null
+++ b/tests/test_issue_evidence_editor.py
@@ -0,0 +1,95 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+ROOT = Path(__file__).parents[1]
+EDITOR = ROOT / "frontend" / "issue-evidence-editor.js"
+ATTACHMENT = ROOT / "frontend" / "issue-attachment.js"
+INDEX = ROOT / "frontend" / "index.html"
+CSS = ROOT / "frontend" / "dashboard.css"
+SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
+
+
+def run_node(script: str) -> dict:
+ return json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+
+def test_editor_crops_redacts_undoes_and_flattens_derivative():
+ script = f"""
+const editor = require({json.dumps(str(EDITOR))});
+const model = editor.createModel({{width:400,height:300}});
+model.setCrop({{x:40,y:30,width:240,height:180}});
+model.addRedaction({{x:70,y:60,width:80,height:40}});
+model.addRedaction({{x:180,y:120,width:50,height:30}});
+model.undo();
+const calls=[];
+const canvas={{width:0,height:0,getContext:()=>({{
+ drawImage:(...args)=>calls.push(['draw',...args.slice(1)]),
+ fillRect:(...args)=>calls.push(['fill',...args]),
+ set fillStyle(value){{calls.push(['color',value]);}}
+}}),toBlob:(callback,type)=>callback(new Blob(['flattened'],{{type}}))}};
+(async()=>{{
+ const result=await editor.flatten({{source:{{}},model,canvas,name:'private.png',type:'image/png'}});
+ process.stdout.write(JSON.stringify({{snapshot:model.snapshot(),calls,width:canvas.width,height:canvas.height,
+ name:result.name,type:result.type,text:await result.text()}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ output = run_node(script)
+ assert output["snapshot"] == {
+ "crop": {"x": 40, "y": 30, "width": 240, "height": 180},
+ "redactions": [{"x": 70, "y": 60, "width": 80, "height": 40}],
+ }
+ assert output["width"] == 240
+ assert output["height"] == 180
+ assert output["calls"] == [
+ ["draw", 40, 30, 240, 180, 0, 0, 240, 180],
+ ["color", "#000000"],
+ ["fill", 30, 30, 80, 40],
+ ]
+ assert output["name"] == "private.png"
+ assert output["type"] == "image/png"
+ assert output["text"] == "flattened"
+
+
+def test_applying_edit_replaces_only_selected_blob_and_preserves_note_and_order():
+ script = f"""
+const attachment=require({json.dumps(str(ATTACHMENT))});
+let id=0;
+const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
+const controller=attachment.create({{maxFiles:5,createOperationId:()=>`edit-${{++id}}`,upload:async()=>({{markdown:'ok'}})}});
+['one.png','private.png','three.png'].forEach(name=>controller.select(image(name)));
+controller.setNote(1,'Token hidden here');
+controller.replace(1,image('private-edited.png'));
+(async()=>{{
+ const values=await controller.serialize();
+ process.stdout.write(JSON.stringify({{values:await Promise.all(values.map(async value=>({{name:value.filename,note:value.note||'',text:await value.blob.text()}}))),state:controller.state()}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ output = run_node(script)
+ assert output["values"] == [
+ {"name": "one.png", "note": "", "text": "one.png"},
+ {"name": "private-edited.png", "note": "Token hidden here", "text": "private-edited.png"},
+ {"name": "three.png", "note": "", "text": "three.png"},
+ ]
+ assert [item["uploaded"] for item in output["state"]] == [False, False, False]
+
+
+def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
+ html = INDEX.read_text()
+ css = CSS.read_text()
+ worker = SERVICE_WORKER.read_text()
+
+ assert 'id="edit-create-issue-attachment"' in html
+ assert 'id="issue-evidence-editor"' in html
+ assert 'role="dialog"' in html
+ assert 'aria-labelledby="issue-evidence-editor-heading"' in html
+ assert 'id="issue-evidence-editor-canvas"' in html
+ assert 'aria-pressed="true"' in html
+ assert "issue-evidence-editor.js" in worker
+ assert ".issue-evidence-editor" in css
+ assert "min-height:44px" in css
+ assert "max-width:100%" in css
+ assert "overflow-x:hidden" in css
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 2dd53db..f31397e 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -838,6 +838,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-evidence-review.js",
+ "/dashboard/static/issue-evidence-editor.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",