Merge pull request 'Capture camera-first photo evidence from mobile New issue' (#938) from timmy/937-camera-first-photo-evidence into main
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 6s

Merge pull request #938 from timmy/937-camera-first-photo-evidence
This commit is contained in:
timmy 2026-08-16 04:33:45 +00:00
commit 8a85b344f8
16 changed files with 95 additions and 37 deletions

View File

@ -684,6 +684,8 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
.photo-evidence-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; min-width:0; }
.photo-evidence-actions .issue-attachment-trigger { box-sizing:border-box; width:100%; min-width:0; text-align:center; }
#create-issue-attachment-preview { grid-template-columns:1fr; }
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }

View File

@ -564,6 +564,7 @@
});
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')],
preview: qs('#create-issue-attachment-preview'),
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),

View File

@ -842,15 +842,17 @@
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
<strong id="create-issue-attachment-label">Photo evidence <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls photo-evidence-actions">
<label class="issue-attachment-trigger" for="take-create-issue-photo">Take photo</label>
<input class="visually-hidden" id="take-create-issue-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="create-issue-attachment">Choose existing</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Photo evidence" hidden></div>
<label class="issue-evidence-note" for="create-issue-evidence-note">
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>

View File

@ -220,6 +220,8 @@
}
function mount(options) {
const inputs = Array.from(new Set((options.inputs || [options.input]).filter(Boolean)));
if (!inputs.includes(options.input)) inputs.push(options.input);
const controller = create({
...options, inspectPixels:true,
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
@ -234,7 +236,7 @@
}) : null;
function setBusy(value) {
options.input.disabled = Boolean(value);
inputs.forEach(input => { input.disabled = Boolean(value); });
options.remove.disabled = Boolean(value);
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
review?.setBusy(value);
@ -248,8 +250,10 @@
if (options.editor?.edit) options.editor.edit.disabled = true;
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
options.input.disabled = false;
inputs.forEach(input => {
input.value = '';
input.disabled = false;
});
clearSelection();
}
@ -264,15 +268,16 @@
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const files = Array.from(event.target.files || []);
function selectFromInput(event) {
const sourceInput = event.target;
const files = Array.from(sourceInput.files || []);
const sequence = ++selectionSequence;
if (!files.length) return;
let first;
try { first = controller.select(files[0]); }
catch (error) {
options.status.textContent = error.message;
options.input.value = '';
sourceInput.value = '';
return;
}
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
@ -282,14 +287,14 @@
let optimized = Boolean(first && typeof first.then === 'function');
if (optimized) {
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
inputs.forEach(input => { input.disabled = true; });
}
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
const result = controller.select(file);
if (result && typeof result.then === 'function') {
optimized = true;
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
inputs.forEach(input => { input.disabled = true; });
await result;
}
}), Promise.resolve(first));
@ -309,12 +314,13 @@
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
options.input.value = '';
sourceInput.value = '';
}
}).finally(() => {
if (sequence === selectionSequence) options.input.disabled = false;
if (sequence === selectionSequence) inputs.forEach(input => { input.disabled = false; });
});
});
}
inputs.forEach(input => input.addEventListener('change', selectFromInput));
options.remove.addEventListener('click', () => {
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v108';
const CACHE = 'stackchain-dashboard-shell-v109';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v109" in worker

View File

@ -543,7 +543,7 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'id="remove-create-issue-attachment"' in html
assert 'id="create-issue-evidence-tray"' in html
assert 'role="toolbar"' in html
assert 'aria-label="Evidence screenshots"' in html
assert 'aria-label="Photo evidence"' in html
assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' in html
assert 'id="create-issue-evidence-note-label"' in html
@ -570,6 +570,53 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert "overflow-x:hidden" in css
def test_mobile_new_issue_camera_and_library_append_to_one_photo_evidence_bundle():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="take-create-issue-photo"' in html
assert 'accept="image/*" capture="environment"' in html
assert 'for="take-create-issue-photo">Take photo</label>' in html
assert 'for="create-issue-attachment">Choose existing</label>' in html
assert 'id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
assert "inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')]" in source
controls = css.split(".photo-evidence-actions", 1)[1].split("}", 1)[0]
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
assert "min-width:0" in controls
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const camera=new Element(),library=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
library.multiple=true;
const controller=attachment.mount({{
input:library,inputs:[camera,library],preview,image,meta,remove,status,
optimizeImage:async file=>file,
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
const photo=name=>{{const blob=new Blob([name],{{type:'image/jpeg'}});blob.name=name;return blob;}};
;(async()=>{{
camera.files=[photo('camera-one.jpg')]; await camera.dispatch('change');
const afterCamera=controller.state();
camera.files=[]; await camera.dispatch('change');
const afterCancel=controller.state();
library.files=[photo('library-two.jpg')]; await library.dispatch('change');
process.stdout.write(JSON.stringify({{afterCamera,afterCancel,afterLibrary:controller.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["afterCamera"]["name"] == "camera-one.jpg"
assert output["afterCancel"] == output["afterCamera"]
assert [item["name"] for item in output["afterLibrary"]] == [
"camera-one.jpg", "library-two.jpg"
]
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
source = DASHBOARD.read_text()
handler = re.search(

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v109" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v109" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v109" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v108" in service_worker
assert "stackchain-dashboard-shell-v109" in service_worker

View File

@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -155,7 +155,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -164,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -179,7 +179,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -187,7 +187,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -197,14 +197,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -213,21 +213,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -893,7 +893,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v108';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v109';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v109" in source
assert "BASE + 'static/today-sync.js'" in source