# ADR-144 Offer Camera, Gallery and ImgBind from one shared image picker

## Status

Accepted, 2026-08-25.

## Status History

```yaml
status_history:
  - date: 2026-08-25
    status: Proposed
    changed_by: hkl
    reason: "Current Image sources - Camera and Gallary. Pls add images from ImgBind also. Pls make it a standard option for all PWA demanding image."
  - date: 2026-08-25
    status: Accepted
    changed_by: hkl
    reason: Shared module built, RentVeh converted as the reference, ten PWAs repointed the same day
```

## Context

A photo shared into ImageBinding could already reach a PWA — but only through that
PWA's **"Incoming media" tray**, a separate panel on its home screen (ADR-131, B6/B8).
At the moment a field user is actually filling a form and the app asks for a photo,
the tray is somewhere else. So the photo gets **taken again**, and the shared copy is
the one that goes stale.

Behind that sits a three-year pattern of the same bug in different clothes, all in
RentVeh alone:

| date | symptom | cause |
|---|---|---|
| 2026-07-31 | Android offered only the camera | `capture="environment"` on `#odo-file` |
| 2026-08-24 | gallery worked but was invisible | one 📷 button, relying on the OS chooser |
| 2026-08-25 | ImgBind unreachable while filling a form | it lived in a tray, not in the picker |

Each was fixed locally in one PWA. Measured across the platform on 2026-08-25, the
same three shapes were still scattered: **`dispatch` and `finance/custodian` carried
`capture="environment"` under labels that read "Camera or gallery — tap to choose
either" and "📷 Take Photo / Choose Image"** — text the attribute makes false on
Android; `counters` derived its list from its own rows; `vehicle` had eight hand-rolled
📷/🖼 pairs. Twelve PWAs, no two the same, and a fourth source would have meant twelve
more edits.

## Decision

Ship **`/shared/imgsource.js`** as the single image-source picker for every 360LM PWA
that asks for a photo. It offers exactly three sources — **📷 Camera · 🖼 Gallery or
files · 🔗 ImgBind** — and owns the file inputs itself, so no PWA can reintroduce a
lone `capture` input.

Two entry points, because the PWAs are not uniform and pretending otherwise is how
handlers get broken:

- **`pick(opts) → {blob, mime, name, source, itemId, caption} | null`** — full sheet,
  returns bytes. For a PWA being converted properly. RentVeh uses this.
- **`attachTo(inputId, opts)`** — picks, then delivers the chosen bytes **into that
  PWA's existing `<input type=file>`** as a real `File` via `DataTransfer` and fires a
  real `change`. The page's own `onchange` runs untouched. `imgbindOnly:true` skips
  the sheet for PWAs that already show their own 📷/🖼 buttons and only need the third
  source added beside them. This is what made a ten-PWA rollout safe in one pass.

This is a deliberate, narrow exception to **ADR-013 (single-file PWA)**, on the same
grounds as `/shared/tour.js`: three sources × twelve PWAs is not something to
copy-paste, because a fourth source would have to be copy-pasted again.

**Decision Maker:** hkl

## Implementation Notes

- **Module:** `/var/www/360lm/shared/imgsource.js` (`LM360ImageSource`, `version` field).
  Loaded with `<script src="/shared/imgsource.js"></script>` immediately before each
  PWA's own inline `<script>`.
- **Auth:** the lightweight `Hub <base64({empId,loginAt})>` header that
  `/hub-media-proxy/sign` accepts, read from `localStorage['lm360-session']` (ADR-112).
- **Data:** `media.items` over PostgREST with `Accept-Profile: media`. **Two groups, and
  the distinction matters:** `status=eq.routed & target_pwa=eq.<pwa>` = someone
  deliberately sent this here (labelled *sent here*); everything else recent = the album.
- 🚨 **`deleted_at=is.null` is mandatory.** On dev **14 of 17** `media.items` rows are
  soft-deleted (D0). Without the filter the picker offers deleted media.
- 🚨 **`markConsumed` fires only when `status==='routed' && target_pwa===<this pwa>`.**
  Consuming an album item would silently take it from whoever else is about to use it —
  a cross-PWA data loss that testing in one app would never surface.
- **OCR:** an ImgBind photo goes through the *same* path as a camera photo, so
  ADR-062's prefill gate still applies — the reading is prefilled and editable, never
  authoritative. Verified: an album photo produced `✓ AI read 98,474 KM`.
- **Cache:** every converted PWA had its SW/`CACHE_VER` bumped. Where a SW pre-caches
  `/shared/*` (rentveh, lenovo) the module was added to its `ASSETS` — **a stale cached
  module is worse than a stale page, because the page looks updated and behaves old.**
  `hr` was found with `sw.js` at `360hr-v9` and its page at `360hr-v8`; both were
  realigned to `v10`.

**Converted (all on dev; see Consequences on why that is the only claim available):**

| bucket | PWA | what changed | cache |
|---|---|---|---|
| reference | `rentveh` | fully converted to `pick()`; owns **no** file input any more | v23→v24 |
| pair + 🔗 | `vehicle` | 8 photo slots, one 🔗 each | v33→v34 |
| pair + 🔗 | `expense` | `#btn-imgbind` beside Camera/Gallery | v34→v35 |
| sheet + 🔗 | `btl`, `lenovo`, `activity` | third option in the existing photo-source sheet | btl v1→v2, lenovo v3→v4, act v35→v36 |
| **fixed a lie** | `dispatch` | `capture` removed from `#podFileInput`; 🔗 on POD + slips | v12→v13 |
| **fixed a lie** | `finance/custodian` | `capture` removed from **both** receipt inputs; 🔗 on create + edit | v44→v45 |
| added | `hr` | 🔗 on both certificate slots | v8/v9→v10 |
| loaded only | `sales` | module available; its inputs are document pickers, not photo capture | v23→v24 |

**Not converted, each for a reason:** `recce` — carries 287 lines of uncommitted work
from another session and its spec targets a URL that 404s, so there is **no regression
signal**; deferred deliberately. `production`, `tourexp` — live, but have no image input
to add a source to. `hub` — `#icon-file-input` is an admin icon uploader, not field
capture. `imgbind` — it is the source. `finance-v1` — not in `hub.pwa_registry`; legacy.

- **Find every wired site:** `grep -rln "shared/imgsource.js" /var/www/360lm --include=index.html`
- **Guard:** `tests/rentveh.spec.js` asserts the served HTML contains **no** `capture=`,
  that the module loads, that the sheet offers exactly `[camera, gallery, imgbind]`, and
  that the ImgBind branch either lists media with working thumbnails or says plainly
  that none is shared — never a blank box.

## Alternatives Considered

- **Add a 🔗 button to each PWA by hand.** Rejected: that is the status quo that produced
  twelve different pickers. The next source would cost twelve more edits.
- **Make the module return a synthetic input element** so call sites need no change at
  all. Rejected: RentVeh alone has three handler shapes (blob, input element,
  `input.files[0]`); a fake element would have to satisfy all of them. `attachTo` puts a
  **real** `File` in a **real** input instead, which no handler can tell apart.
- **Route everything through the existing tray instead.** Rejected: the tray is on the
  home screen; the need arises mid-form. It is kept — the two are complementary.
- **Show only media routed to this PWA.** Rejected: on dev that renders empty (0 routed
  of 17), and it makes the user go to ImageBinding to route before they can attach.
- **Bundle the module into each PWA at deploy time** to preserve ADR-013 literally.
  Rejected: no build step exists, and inventing one for this is disproportionate.

## Consequences

- A photo shared once into 360LM can be attached anywhere without being re-taken.
- **Two PWAs stopped lying.** `dispatch` and `finance/custodian` promised "camera or
  gallery" while forcing the camera. Custodian is the live payment app, so a receipt
  already in the gallery genuinely could not be attached until today.
- Every wired PWA now depends on `/shared/imgsource.js` being served. It is ~18 KB,
  same-origin, and the picker degrades to Camera/Gallery if `media.items` is unreachable
  — but a 404 on the module itself breaks the 🔗 buttons in ten apps at once.
- 🚨 **Every claim here is a DEV claim.** The `360lm-prod` web container does not exist
  (absent from `docker ps -a`; prod DB and API are alive, every prod PWA path 404s), so
  none of this could be verified in production even in principle.

## Revisit If

- **A PWA needs a source this does not offer** — a scanner, a Drive file, a WhatsApp
  pull. The sheet is a fixed three; adding a fourth means editing the module, which is
  the point, but the `opts` shape will need to grow a source allow-list.
- **`/hub-media-proxy/sign`'s `Hub <b64>` header is replaced by the ADR-105 signed JWT.**
  The module hardcodes the unsigned form. That header is unsigned today and is already
  recorded as a security note — when ADR-105 lands, this is one of the call sites.
- **`media.items` grows past a phone-friendly list.** The browser fetches 40 rows with no
  search or paging; at a few hundred shared photos a day that stops being usable.
- **A second consumer needs `markConsumed` semantics that differ** — today "routed to me"
  is the only thing consumed, and that rule lives in the module, not in the caller.
- **recce is finally converted** — it is the one live image-capturing PWA still outside
  the standard, and it should not stay outside once it has a working test signal.

## Does NOT govern

- **Who owns the OS share sheet** — that is ADR-131 (the Hub, not ImageBinding). This ADR
  is about the in-app picker, the other end of the same journey.
- **What happens to the bytes afterwards** — compression, cropping, the ADR-072 proof
  editor, OCR via ADR-062. The module hands over a Blob and stops.
- **The "Incoming media" tray.** Unchanged and still useful for triage; this adds a second
  route to the same media, it does not replace the first.
- **Whether a PWA should ask for a photo at all**, or whether one is mandatory.
