Capture photo evidence in mobile conversation replies #942

Merged
timmy merged 1 commits from timmy/941-mobile-conversation-photo-capture into main 2026-08-16 05:37:39 +00:00
15 changed files with 65 additions and 32 deletions

View File

@ -528,6 +528,8 @@ textarea { resize: vertical; min-height: 120px; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.conversation-photo-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; min-width:0; width:100%; }
.conversation-photo-actions .issue-attachment-trigger { box-sizing:border-box; width:100%; min-width:0; text-align:center; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }

View File

@ -496,6 +496,7 @@
});
const issueAttachmentController = issueAttachment.mount({
input: qs('#issue-attachment'),
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
preview: qs('#issue-attachment-preview'),
image: qs('#issue-attachment-image'),
meta: qs('#issue-attachment-meta'),
@ -526,6 +527,7 @@
});
const pullAttachmentController = issueAttachment.mount({
input: qs('#pull-attachment'),
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
preview: qs('#pull-attachment-preview'),
image: qs('#pull-attachment-image'),
meta: qs('#pull-attachment-meta'),
@ -547,6 +549,7 @@
});
const updateReplyAttachmentController = issueAttachment.mount({
input: qs('#update-reply-attachment'),
inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')],
preview: qs('#update-reply-attachment-preview'),
image: qs('#update-reply-attachment-image'),
meta: qs('#update-reply-attachment-meta'),

View File

@ -661,9 +661,11 @@
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-issue-comment-photo">Take photo</label>
<input class="visually-hidden" id="take-issue-comment-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="issue-attachment">Choose existing</label>
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
<img id="issue-attachment-image" alt="Selected screenshot preview" />
@ -1050,9 +1052,11 @@
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-update-reply-photo">Take photo</label>
<input class="visually-hidden" id="take-update-reply-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Choose existing</label>
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
@ -1120,9 +1124,11 @@
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-pull-comment-photo">Take photo</label>
<input class="visually-hidden" id="take-pull-comment-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="pull-attachment">Choose existing</label>
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
<img id="pull-attachment-image" alt="Selected screenshot preview" />

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-v109';
const CACHE = 'stackchain-dashboard-shell-v110';
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-v109" in worker
assert "stackchain-dashboard-shell-v110" in worker

View File

@ -570,7 +570,7 @@ 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():
def test_mobile_new_issue_camera_and_library_render_as_one_photo_evidence_bundle():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
@ -585,6 +585,30 @@ def test_mobile_new_issue_camera_and_library_append_to_one_photo_evidence_bundle
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
assert "min-width:0" in controls
def test_mobile_conversation_composers_offer_camera_and_library_through_one_attachment_controller():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
composers = (
("take-issue-comment-photo", "issue-attachment", "issueAttachmentController"),
("take-pull-comment-photo", "pull-attachment", "pullAttachmentController"),
("take-update-reply-photo", "update-reply-attachment", "updateReplyAttachmentController"),
)
for camera_id, library_id, controller in composers:
assert f'id="{camera_id}" type="file" accept="image/*" capture="environment"' in html
assert f'for="{camera_id}">Take photo</label>' in html
assert f'for="{library_id}">Choose existing</label>' in html
mount = source.split(f"const {controller} = issueAttachment.mount({{", 1)[1].split("});", 1)[0]
assert f"inputs: [qs('#{camera_id}'), qs('#{library_id}')]" in mount
controls = css.split(".conversation-photo-actions", 1)[1].split("}", 1)[0]
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
assert "min-width:0" in controls
assert ".conversation-photo-actions .issue-attachment-trigger" in css
assert "width:100%" in css.split(".conversation-photo-actions .issue-attachment-trigger", 1)[1].split("}", 1)[0]
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
@ -593,7 +617,7 @@ class Element {{
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;
library.multiple=false;
const controller=attachment.mount({{
input:library,inputs:[camera,library],preview,image,meta,remove,status,
optimizeImage:async file=>file,
@ -612,9 +636,7 @@ const photo=name=>{{const blob=new Blob([name],{{type:'image/jpeg'}});blob.name=
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"
]
assert output["afterLibrary"]["name"] == "library-two.jpg"
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():

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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in worker
assert "stackchain-dashboard-shell-v110" 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-v109" in worker
assert "stackchain-dashboard-shell-v110" 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-v109" in worker
assert "stackchain-dashboard-shell-v110" 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-v109" in service_worker
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109" in source
assert "stackchain-dashboard-shell-v110" 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-v109';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v110';" 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-v109" in source
assert "stackchain-dashboard-shell-v110" in source
assert "BASE + 'static/today-sync.js'" in source