Capture camera-first photo evidence from mobile New issue #938
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user