Skip to content

fix: allow camera roll in bridge photo picker - #88

Merged
Amplify-Logic merged 1 commit into
mainfrom
fm/fm-bridge-picker-library-fix-p5
Aug 19, 2026
Merged

fix: allow camera roll in bridge photo picker#88
Amplify-Logic merged 1 commit into
mainfrom
fm/fm-bridge-picker-library-fix-p5

Conversation

@Amplify-Logic

Copy link
Copy Markdown
Owner

Intent

Bridge photo picker must offer the camera roll, not just the camera.

Captain field report (2026-08-19, on the now-working bridge): tapping "send a photo" forces the live camera; he cannot pick existing screenshots or camera-roll photos. Cause: the file input in bin/fm-bridge-view.py carries capture="environment", which on iOS Safari suppresses the Photo Library / Choose File options.

Surgical change: remove the capture attribute from that input and keep the accept list. On iOS this makes the tap offer Photo Library, Take Photo, and Choose File - camera still available, library now too. Adjust any test or docs line that mentions the capture behavior (docs/bridge-view.md).

Scope is exactly this attribute and its documentation.

What Changed

  • Remove the photo input’s capture attribute so iOS offers the Photo Library alongside camera and file options.
  • Preserve the existing accepted image-type list and document the updated picker behavior.
  • Update bridge view coverage to verify the accept list remains intact and capture is absent.

Risk Assessment

✅ Low: The surgical change exactly removes the capture attribute while preserving the image accept list, with aligned documentation and regression assertions; no material issues were found.

Testing

Inspected the complete three-file scope, ran the focused bridge suite successfully, then exercised login and authenticated page delivery end-to-end and rendered the served HTML at a mobile viewport; runtime inspection confirmed the image accept list remains while both capture attribute and property are absent. Reviewer-visible rendered HTML is attached; a PNG was attempted, but the browser interface reported a path without producing the file, so the portable rendered HTML is the visual evidence artifact.

Evidence: Authenticated mobile bridge picker surface
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>STARSHIP</title>
<style nonce="OZBSBGrFxitd2Tfp5hvDHA">
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
  background: #101418;
  color: #f2f4f3;
  line-height: 1.45;
  padding: max(1rem, env(safe-area-inset-top)) 1.1rem 2rem;
}
main { max-width: 40rem; margin: 0 auto; }
h1 { font-size: 0.8rem; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 0.4rem; color: #9aa7a0; }
h2 { font-size: 0.78rem; letter-spacing: 0.12em; text-transform: uppercase; margin: 1.4rem 0 0.5rem; color: #c5d0c8; }
.meta { color: #c5d0c8; font-size: 0.95rem; }
.warn { color: #e6c07b; font-size: 0.92rem; margin: 0.4rem 0 0; }
ul { list-style: none; padding: 0; margin: 0; }
li { padding: 0.55rem 0; border-bottom: 1px solid #2a3330; font-size: 1.05rem; }
.dot { display: inline-block; width: 0.65rem; height: 0.65rem; border-radius: 50%; margin-right: 0.55rem; background: #6ea8fe; }
.dot.needs { background: #c084fc; }
.dot.under { background: #6ea8fe; }
.dot.wait { background: #fbbf24; }
.dot.ready { background: #34d399; }
.dot.failed { background: #f87171; }
.dot.stuck { background: #fb923c; }
.more, .empty, .incomplete { color: #9aa7a0; font-size: 0.92rem; margin: 0.4rem 0 0; }
a { color: #9cdcfe; }
input, button {
  font: inherit; width: 100%; min-height: 2.75rem; border-radius: 0.5rem;
  border: 1px solid #3b4742; padding: 0.6rem 0.8rem;
}
input { background: #1b2220; color: inherit; margin: 0.8rem 0; }
button { background: #d7e0d8; color: #101418; font-weight: 600; }
.note { color: #9aa7a0; font-size: 0.9rem; }
.ok { color: #34d399; font-size: 0.95rem; margin: 0.4rem 0 0; }
#photo-form { margin: 1.1rem 0 0.4rem; }
#photo-form label { display: block; margin-bottom: 0.35rem; }
#stale {
  display: none; position: fixed; inset: 0; background: #101418;
  color: #f2f4f3; align-items: center; justify-content: center;
  text-align: center; padding: 2rem; font-size: 1.4rem; z-index: 9;
}
#stale.on { display: flex; }
header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
form.logout { margin: 0; width: auto; }
form.logout button { width: auto; min-height: 2rem; padding: 0.3rem 0.7rem; background: transparent; color: #c5d0c8; border-color: #3b4742; }
</style>
<script nonce="OZBSBGrFxitd2Tfp5hvDHA">
const STALE_MS = 90 * 1000;
const REFRESH_MS = 30 * 1000;
let lastSuccess = Date.now();
function esc(value) {
  return String(value).replace(/[&<>"']/g, function(ch) {
    return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]);
  });
}
function setStale(on) {
  const el = document.getElementById('stale');
  if (!el) return;
  el.classList.toggle('on', on);
}
function dotClass(name) {
  if (name === 'Needs you') return 'needs';
  if (name === 'Under way') return 'under';
  if (name === 'Waiting') return 'wait';
  if (name === 'Ready') return 'ready';
  if (name === 'Failed') return 'failed';
  if (name === 'Stuck') return 'stuck';
  return 'under';
}
function renderBucket(id, bucket, emptyText) {
  const root = document.getElementById(id);
  if (!root || !bucket) return;
  const items = bucket.items || [];
  const lines = items.map(function(item) {
    const title = esc(item.title || '');
    const url = item.url || '';
    const safeUrl = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+$/.test(url) ? url : '';
    const label = safeUrl
      ? '<a href="' + safeUrl + '" rel="noreferrer">' + title + '</a>'
      : title;
    return '<li><span class="dot ' + dotClass(item.dot) + '"></span>' + label + '</li>';
  });
  let extra = '';
  if (bucket.more > 0) extra += '<p class="more">' + bucket.more + ' more waiting</p>';
  if (bucket.incomplete) extra += '<p class="incomplete">This list may be incomplete.</p>';
  if (!items.length) {
    extra = '<p class="' + (bucket.incomplete ? 'incomplete' : 'empty') + '">' +
      (bucket.incomplete ? 'This list may be incomplete.' : emptyText) + '</p>' +
      (bucket.more > 0 ? '<p class="more">' + bucket.more + ' more waiting</p>' : '');
  }
  root.innerHTML = (lines.length ? '<ul>' + lines.join('') + '</ul>' : '') + extra;
}
function apply(data) {
  lastSuccess = Date.now();
  setStale(false);
  const age = document.getElementById('observed');
  if (age) age.textContent = 'Observed just now';
  const desk = document.getElementById('desk');
  if (desk) desk.textContent = 'Desk reachable';
  const mail = document.getElementById('mailbox');
  if (mail) mail.textContent = data.mailbox_listener ? 'Mailbox on' : 'Mailbox listener off';
  renderBucket('needs', data.needs_you, 'Nothing needs you right now.');
  renderBucket('underway', data.under_way, 'Nothing is under way.');
  renderBucket('finished', data.just_finished, 'No recent completions.');
  renderBucket('waiting', data.waiting, 'Nothing is waiting.');
  const count = document.getElementById('photo-count');
  if (count) count.textContent = 'Photos received today: ' + (data.photos_today || 0);
}
async function sendPhoto(ev) {
  ev.preventDefault();
  const input = document.getElementById('photo');
  const status = document.getElementById('photo-result');
  if (!input || !status) return;
  if (!input.files || !input.files.length) {
    status.className = 'warn';
    status.textContent = 'Choose a photo first.';
    return;
  }
  status.className = 'note';
  status.textContent = 'Sending photo…';
  try {
    const body = new FormData();
    body.append('photo', input.files[0]);
    const res = await fetch('/upload', {
      method: 'POST',
      body: body,
      credentials: 'same-origin',
      cache: 'no-store'
    });
    const payload = await res.json().catch(function() { return {}; });
    if (!res.ok) {
      status.className = 'warn';
      status.textContent = payload.error || ('Send failed (' + res.status + ')');
      return;
    }
    status.className = 'ok';
    status.textContent = 'Photo received.';
    input.value = '';
    const count = document.getElementById('photo-count');
    if (count && payload.received_today != null) {
      count.textContent = 'Photos received today: ' + payload.received_today;
    }
  } catch (err) {
    status.className = 'warn';
    status.textContent = 'Send failed.';
  }
}
function tickObserved() {
  const age = document.getElementById('observed');
  if (!age) return;
  const seconds = Math.max(0, Math.round((Date.now() - lastSuccess) / 1000));
  age.textContent = 'Observed ' + seconds + ' seconds ago';
  if (Date.now() - lastSuccess > STALE_MS) setStale(true);
}
async function refresh() {
  try {
    const ctl = new AbortController();
    const timer = setTimeout(function() { ctl.abort(); }, 10000);
    const res = await fetch('/api/observation', { credentials: 'same-origin', cache: 'no-store', signal: ctl.signal });
    clearTimeout(timer);
    if (!res.ok) throw new Error('status ' + res.status);
    apply(await res.json());
  } catch (err) {
    tickObserved();
  }
}
document.addEventListener('DOMContentLoaded', function() {
  refresh();
  setInterval(refresh, REFRESH_MS);
  setInterval(tickObserved, 1000);
  document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); });
  window.addEventListener('pageshow', function() { refresh(); });
  const form = document.getElementById('photo-form');
  if (form) form.addEventListener('submit', sendPhoto);
});
</script>
</head>
<body>
<div id="stale">Cannot reach the desk.</div>
<main>
<header>
  <h1>Starship</h1>
  <form class="logout" method="post" action="/logout"><button type="submit">Log out</button></form>
</header>
<p class="meta"><span id="desk">Desk reachable</span> · <span id="mailbox">Mailbox…</span></p>
<p class="meta" id="observed">Observed just now</p>
<p class="warn">Summary only. Do not approve from this page.</p>
<h2>Send a photo</h2>
<form id="photo-form" method="post" action="/upload" enctype="multipart/form-data">
  <label for="photo">Photo</label>
  <input id="photo" name="photo" type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,image/*">
  <button type="submit">Send photo</button>
</form>
<p class="meta" id="photo-count">Photos received today: 0</p>
<p id="photo-result" class="note"></p>
<h2>Needs you</h2>
<div id="needs"><p class="empty">Loading…</p></div>
<h2>Under way</h2>
<div id="underway"><p class="empty">Loading…</p></div>
<h2>Just finished</h2>
<div id="finished"><p class="empty">Loading…</p></div>
<h2>Waiting in the wings</h2>
<div id="waiting"><p class="empty">Loading…</p></div>
</main>
</body>
</html>
Evidence: Rendered picker runtime properties
{"type":"file","accept":"image/jpeg,image/png,image/webp,image/heic,image/heif,image/*","captureAttribute":null,"captureProperty":null}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • git diff --unified=80 b66ccbbe83467d321dc8562668ecadc1a3d056ee..be36d2ebde8784bae734ad59a7136a34eab2c995 -- bin/fm-bridge-view.py docs/bridge-view.md tests/fm-bridge-view.test.sh
  • tests/fm-bridge-view.test.sh
  • Started bin/fm-bridge-view.sh serve --host localtest.me --port 18766 with an isolated evidence home, authenticated through /login, and fetched the resulting bridge page through /
  • Rendered bridge-picker.html at a 390×844 mobile viewport and inspected #photo: type=file, full image accept list retained, captureAttribute=null, and captureProperty=null
  • git status --short and git rev-parse HEAD confirmed a clean worktree at target commit be36d2ebde8784bae734ad59a7136a34eab2c995
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

iOS Safari treats capture=environment as camera-only, hiding Photo Library.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Amplify-Logic
Amplify-Logic merged commit 3358767 into main Aug 19, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant