[object Object]

← back to Dw Photo Capture

TK-12228: every captured photo, front and back, displays in the app

d1a0b78338a0680442100694bb2cb11f2e1e6a61 · 2026-09-25 09:38:47 -0700 · Steve Abrams

- index.html: front/back tiles show the captured photo immediately; Preview shows
  both sides (and counts them, was "0 photo(s)"); Created + Update results show the
  server-stored copies; tap to enlarge; links to the new Capture History.
- server.js: create-item/photo/photos persist every photo under photos/ with its side
  in the filename (create-item now also on a failed create) and index one record per
  item in data/captures.jsonl; responses return the stored URLs. New /captures page +
  /api/captures merge that log with batch manifests and legacy vendor-log uploads.
- captures.html: history grid, front+back per card (missing side shown as a gap),
  date+time chip, sort + density slider persisted in localStorage, lightbox.
- batch.html: FRONT (PSku) + BACK (Info) thumbs side by side, front shown on capture,
  no more 8s blob revoke. cam.html: last remote shot shown on the phone.
- --test seam (DWP_TEST_DIR): rebased data/photos, Shopify/dw_unified/FM writes and
  paid OCR stubbed; scripts/e2e-front-back-display.cjs drives it (19 checks).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P

Files touched

Diff

commit d1a0b78338a0680442100694bb2cb11f2e1e6a61
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 09:38:47 2026 -0700

    TK-12228: every captured photo, front and back, displays in the app
    
    - index.html: front/back tiles show the captured photo immediately; Preview shows
      both sides (and counts them, was "0 photo(s)"); Created + Update results show the
      server-stored copies; tap to enlarge; links to the new Capture History.
    - server.js: create-item/photo/photos persist every photo under photos/ with its side
      in the filename (create-item now also on a failed create) and index one record per
      item in data/captures.jsonl; responses return the stored URLs. New /captures page +
      /api/captures merge that log with batch manifests and legacy vendor-log uploads.
    - captures.html: history grid, front+back per card (missing side shown as a gap),
      date+time chip, sort + density slider persisted in localStorage, lightbox.
    - batch.html: FRONT (PSku) + BACK (Info) thumbs side by side, front shown on capture,
      no more 8s blob revoke. cam.html: last remote shot shown on the phone.
    - --test seam (DWP_TEST_DIR): rebased data/photos, Shopify/dw_unified/FM writes and
      paid OCR stubbed; scripts/e2e-front-back-display.cjs drives it (19 checks).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P
---
 .gitignore                         |   1 +
 public/batch.html                  |  31 +++++-
 public/cam.html                    |  14 +++
 public/captures.html               | 114 +++++++++++++++++++
 public/index.html                  |  60 ++++++++--
 scripts/e2e-front-back-display.cjs | 139 +++++++++++++++++++++++
 server.js                          | 222 ++++++++++++++++++++++++++++++++-----
 7 files changed, 544 insertions(+), 37 deletions(-)

diff --git a/.gitignore b/.gitignore
index 2a31434..7a25395 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ data/vendor-photos.backfilled
 
 # agent git worktrees (TK-12205) — never track or deploy
 .claude/worktrees/
+data/captures.jsonl
diff --git a/public/batch.html b/public/batch.html
index a1cb348..262762d 100644
--- a/public/batch.html
+++ b/public/batch.html
@@ -89,6 +89,14 @@
   .shutter{width:78px;height:78px;border-radius:50%;border:4px solid var(--gold);background:var(--gold);cursor:pointer;flex:0 0 auto}
   .shutter:disabled{background:#3a3428;border-color:#3a3428}
   .thumb{width:56px;height:56px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f}
+  /* TK-12228: the last sample's FRONT (PSku) and BACK (Info) photos, side by side, tap to enlarge */
+  .thumbs{display:flex;gap:6px;flex:0 0 auto}
+  .thumbs figure{margin:0;position:relative;cursor:zoom-in}
+  .thumbs figure[hidden]{display:none}
+  .thumbs figcaption{position:absolute;left:3px;bottom:3px;font:800 9px/1 -apple-system,sans-serif;letter-spacing:.04em;background:#000b;color:var(--ink);padding:2px 4px;border-radius:4px}
+  #bLb{position:absolute;inset:0;z-index:70;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
+  #bLb[hidden]{display:none}
+  #bLb img{max-width:100%;max-height:80%;object-fit:contain;border-radius:8px}
   .state{font:800 20px/1.2 "SF Pro Display",sans-serif;letter-spacing:.01em;min-height:26px;text-align:center;padding:0 10px}
   .state.ready{color:var(--green)} .state.wait{color:var(--muted)} .state.retake{color:var(--red)}
   /* ── big RETAKE / re-cal overlay ── */
@@ -241,7 +249,10 @@
         <button class="btn ghost" id="detBtn" title="Show the technical details" style="padding:12px 14px">ⓘ</button>
         <label class="chip" style="gap:6px;font-size:12px"><input type="checkbox" id="autoFire" style="width:auto"> Auto-snap</label>
         <button class="shutter" id="shutter" title="Take photo"></button>
-        <img class="thumb" id="thumb" alt="last photo" hidden>
+        <div class="thumbs">
+          <figure id="thumbFWrap" hidden><img class="thumb" id="thumb" alt="last FRONT photo"><figcaption>FRONT</figcaption></figure>
+          <figure id="thumbBWrap" hidden><img class="thumb" id="thumbB" alt="last BACK photo"><figcaption>BACK</figcaption></figure>
+        </div>
       </div>
       <div class="qmeta" id="queueTxt"></div>
     </div>
@@ -252,6 +263,7 @@
   <div class="big recal" id="bigRecal" hidden><h2>CHECK COLOR</h2><p>The light or stand moved, so colors won't match. Let's re-shoot the gray card — it only takes a second.</p><small id="recalMeta"></small><button class="btn primary" id="recalGo">Re-shoot gray card</button></div>
   <div class="overlay" id="gate"><h2>Turn on the camera</h2><p>Tap the button, then put your first sample on the stand.</p><button class="btn primary" id="startCam">📷 Turn on camera</button></div>
   <div class="toast" id="toast"></div>
+  <div id="bLb" hidden><b id="bLbCap" style="letter-spacing:.05em"></b><img id="bLbImg" alt="captured photo"><button class="btn ghost" id="bLbClose">✕ Close</button> <a href="/captures" style="color:var(--gold);font-size:13px">All captures →</a></div>
 </div>
 
 <script>
@@ -702,9 +714,22 @@ async function saveShot(photo, sku, m){
     original:photo.original, master:photo.master, web:photo.web,
     meta:{ via:m.via||'manual', at:new Date().toISOString(), skuConf, side:m.side||null, single:!!m.single, ...metaFields() } });
   window._lastSku=sku;
-  const url=URL.createObjectURL(photo.web); const th=$('#thumb'); th.src=url; th.hidden=false; setTimeout(()=>URL.revokeObjectURL(url),8000);
+  showThumb(m.side==='info'?'back':'front', photo.web);
   drainQueue();
 }
+// TK-12228: keep the last sample's FRONT (PSku) + BACK (Info) photos on screen. The object URL lives until
+// that slot is replaced (the old 8s revoke could blank a thumb before it was opened full-size).
+const _thumbUrl={front:null,back:null};
+function showThumb(side, blob){
+  const wrap=$(side==='back'?'#thumbBWrap':'#thumbFWrap'), img=$(side==='back'?'#thumbB':'#thumb'); if(!wrap||!img||!blob) return;
+  if(_thumbUrl[side]) URL.revokeObjectURL(_thumbUrl[side]);
+  _thumbUrl[side]=URL.createObjectURL(blob); img.src=_thumbUrl[side]; wrap.hidden=false;
+}
+function clearThumb(side){ const wrap=$(side==='back'?'#thumbBWrap':'#thumbFWrap'); if(wrap) wrap.hidden=true;
+  if(_thumbUrl[side]){ URL.revokeObjectURL(_thumbUrl[side]); _thumbUrl[side]=null; } const img=$(side==='back'?'#thumbB':'#thumb'); if(img) img.removeAttribute('src'); }
+['thumbFWrap','thumbBWrap'].forEach(id=>{ const w=document.getElementById(id); if(w) w.addEventListener('click',()=>{ const im=w.querySelector('img');
+  if(!im||!im.src) return; $('#bLbImg').src=im.src; $('#bLbCap').textContent=id==='thumbBWrap'?'BACK · label':'FRONT · pattern'; $('#bLb').hidden=false; }); });
+$('#bLbClose').addEventListener('click',()=>{ $('#bLb').hidden=true; });
 // ── CAPTURE: PSku photo first; if the code is already on it → one entry, else request the Info photo ──
 async function capture(via, fails){
   if(busy) return;
@@ -723,10 +748,12 @@ async function capture(via, fails){
       if(code){                                    // info-on-front → ONE entry, no second photo
         fillFields(info.code?info:{code, confidence:skuConf});
         session.n=(session.n||0)+1; saveJSON(LS_SESSION,session); $('#hN').textContent=session.n;
+        clearThumb('back');                                // single-photo sample: no back to show
         await saveShot(photo, code, { via, side:null, single:true, _seq:session.n });
         toast('✓ '+code+' saved — all fields filled'); lastFireAt=Date.now(); resetForNextSample();
       } else {                                     // pattern-only front → hold it, request the Info photo
         pendingFront=photo; phase='info'; setSideBadge();
+        showThumb('front', photo.web); clearThumb('back');   // TK-12228: the FRONT shows the moment it's taken
         setState('wait','Flip the sample — put the LABEL in the box, then take the INFO photo');
         toast('Now the Info photo (the label side)');
       }
diff --git a/public/cam.html b/public/cam.html
index 199d9f2..a91d140 100644
--- a/public/cam.html
+++ b/public/cam.html
@@ -64,6 +64,13 @@
   .target{font-size:13px;color:var(--muted);text-align:center;min-height:18px}
   .target b{color:var(--gold);font:600 13px/1 ui-monospace,Menlo,monospace}
   .hint{font-size:12px;color:var(--muted);text-align:center;max-width:340px}
+  /* TK-12228: the last shot, as stored on the server, shown on the phone too (tap to enlarge) */
+  .lastshot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
+  .lastshot[hidden]{display:none}
+  .lastshot img{width:64px;height:64px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f;cursor:zoom-in}
+  #camLb{position:fixed;inset:0;z-index:70;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:16px}
+  #camLb[hidden]{display:none}
+  #camLb img{max-width:100%;max-height:82vh;object-fit:contain;border-radius:8px}
   .btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:15px 22px;font-weight:700;font-size:16px;min-height:54px;display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}
   .btn.primary{background:var(--gold);color:#1b1407;border-color:var(--gold)}
   .overlay{position:fixed;inset:0;z-index:50;background:#0f0e0cf2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;text-align:center}
@@ -107,7 +114,9 @@
   <div class="foot">
     <div class="target" id="target">Desktop not connected yet</div>
     <div class="hint" id="hint">Keep this page open and your phone awake. The desktop will trigger the shot.</div>
+    <div class="lastshot" id="lastShot" hidden><img id="lastShotImg" alt="last shot"><span id="lastShotTxt"></span></div>
   </div>
+  <div id="camLb" hidden><img id="camLbImg" alt="last shot full size"><button class="btn" id="camLbClose">✕ Close</button></div>
 
   <!-- camera permission / wake gate -->
   <div class="overlay" id="gate">
@@ -393,8 +402,13 @@ async function onShoot(target){
     dw_sku: target.dw_sku||null, product_id: res&&res.product_id||target.product_id||null
   }, res||{}))}).catch(()=>{});
   toast(res&&res.ok ? (res.live?'🟢 LIVE · sent to desktop':'✓ Uploaded · sent to desktop') : ('Upload error'+(res&&res.err?': '+res.err:'')));
+  showLastShot(res&&res.ok&&res.photo ? res.photo : dataUrl, res&&res.ok ? (target.dw_sku||'saved') : 'not uploaded — local only');
   busy=false;
 }
+// TK-12228: show the shot on the phone — the server-stored copy when the upload worked, else the local capture.
+function showLastShot(src, label){ if(!src) return; $('#lastShotImg').src=src; $('#lastShotTxt').textContent='Last shot · '+label; $('#lastShot').hidden=false; }
+$('#lastShotImg').addEventListener('click',()=>{ $('#camLbImg').src=$('#lastShotImg').src; $('#camLb').hidden=false; });
+$('#camLbClose').addEventListener('click',()=>{ $('#camLb').hidden=true; });
 
 // ── SSE + status ──
 let es=null;
diff --git a/public/captures.html b/public/captures.html
new file mode 100644
index 0000000..adf1974
--- /dev/null
+++ b/public/captures.html
@@ -0,0 +1,114 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Capture History · DW</title>
+<!-- TK-12228 — Steve: "every photo, front and back, must show display on the app."
+     One card per captured item with its FRONT and BACK photos side by side (a missing side is shown
+     as a visible gap, never silently dropped). Sort + density slider persist in localStorage. -->
+<style>
+  :root{--bg:#0f0e0c;--card:#1b1916;--ink:#f3efe7;--muted:#9a9184;--line:#2e2a25;--gold:#c8a24a;--warn:#e6a23a;--cardw:260px}
+  *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.4 -apple-system,system-ui,sans-serif}
+  header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px}
+  h1{margin:0 0 10px;font-size:18px} h1 a{color:var(--muted);font-size:13px;text-decoration:none;margin-left:8px}
+  .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+  select,input[type=search]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:14px}
+  .dens{color:var(--muted);font-size:13px;display:flex;align-items:center;gap:6px}
+  .stat{color:var(--muted);font-size:13px} .stat b{color:var(--ink)} .stat .w{color:var(--warn)}
+  main{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardw),1fr));gap:12px;padding:14px 16px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
+  .card.miss{border-color:#6b551f}
+  .pair{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#000}
+  .pair figure{margin:0;position:relative;cursor:zoom-in}
+  .pair img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:#000}
+  .pair figcaption{position:absolute;left:5px;bottom:5px;font:800 10px/1 -apple-system,sans-serif;letter-spacing:.05em;text-transform:uppercase;background:#000b;padding:3px 5px;border-radius:4px}
+  .pair .gap{cursor:default;aspect-ratio:1;display:flex;align-items:center;justify-content:center;text-align:center;color:var(--warn);font-size:12px;padding:8px;background:#16120a}
+  .extras{display:flex;gap:2px;background:#000}
+  .extras figure{margin:0;flex:1;cursor:zoom-in} .extras img{width:100%;height:56px;object-fit:cover;display:block}
+  .meta{padding:8px 10px;font-size:12px;display:grid;gap:3px}
+  .sku{font-weight:600;font-size:13px}
+  .sub{color:var(--muted)}
+  .when{color:var(--gold)}
+  .bad{color:var(--warn)}
+  .empty{padding:40px 16px;color:var(--muted);text-align:center;grid-column:1/-1}
+  #lb{position:fixed;inset:0;z-index:50;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
+  #lb[hidden]{display:none}
+  #lb img{max-width:100%;max-height:84vh;object-fit:contain;border-radius:8px}
+  #lb .cap{font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:14px}
+  #lb button{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:9px 16px;font-size:14px;cursor:pointer}
+</style></head>
+<body>
+<header>
+  <h1>Capture History <span id="count" style="color:var(--muted);font-weight:400;font-size:14px"></span><a href="/">← capture</a><a href="/vendor-photos">vendor photos</a></h1>
+  <div class="controls">
+    <input type="search" id="q" placeholder="Search SKU, vendor, source">
+    <select id="sort" aria-label="Sort">
+      <option value="newest">Newest</option>
+      <option value="oldest">Oldest</option>
+      <option value="missing">Missing a side first</option>
+      <option value="sku">SKU A→Z</option>
+      <option value="vendor">Vendor A→Z</option>
+      <option value="source">Source</option>
+    </select>
+    <label class="dens">Size <input type="range" id="dens" min="160" max="520" step="20" value="260" aria-label="Card size"></label>
+    <span class="stat" id="stat"></span>
+  </div>
+</header>
+<main id="grid"></main>
+<div id="lb" hidden><div class="cap" id="lbCap"></div><img id="lbImg" alt="captured photo full size"><button id="lbClose">✕ Close</button></div>
+<script>
+const $ = s => document.querySelector(s);
+const ls = { get(k,d){ try{ return localStorage.getItem(k) ?? d; }catch(e){ return d; } }, set(k,v){ try{ localStorage.setItem(k,v); }catch(e){} } };
+const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const fmtDate = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+const LBL = { front:'Front', back:'Back', extra:'Extra', photo:'Photo' };
+let all = [], sort = ls.get('cap.sort','newest');
+$('#sort').value = sort; $('#dens').value = ls.get('cap.dens','260');
+document.documentElement.style.setProperty('--cardw', $('#dens').value + 'px');
+const side = (c, s) => c.photos.find(p => p.side === s);
+const missing = c => !side(c,'front') || !side(c,'back');
+function fig(p, cls){ const lbl = LBL[p.side] || p.side;
+  return `<figure class="${cls||''}" data-src="${esc(p.url)}" data-cap="${esc(lbl)}"><img loading="lazy" src="${esc(p.url)}" alt="${esc(lbl)} photo" data-side="${esc(p.side)}">${cls==='x'?'':`<figcaption${p.inferred?' title="side inferred from the filename (captured before side-tagging)"':''}>${esc(lbl)}${p.inferred?'*':''}</figcaption>`}</figure>`; }
+function card(c){
+  // single-shot surfaces (remote cam / legacy single photo) have no front/back split — show what exists.
+  const f = side(c,'front'), b = side(c,'back');
+  const loose = c.photos.filter(p => p.side === 'photo');
+  let pair;
+  if (!f && !b && loose.length) pair = `<div class="pair" style="grid-template-columns:1fr">${fig(loose[0])}</div>`;
+  else pair = `<div class="pair">${f ? fig(f) : '<div class="gap">⚠ no FRONT photo</div>'}${b ? fig(b) : '<div class="gap">⚠ no BACK photo</div>'}</div>`;
+  const rest = c.photos.filter(p => p !== f && p !== b && !(p === loose[0] && !f && !b));
+  const extras = rest.length ? `<div class="extras">${rest.map(p => fig(p,'x')).join('')}</div>` : '';
+  const status = c.ok === false ? `<div class="bad">✗ create failed${c.err ? ': ' + esc(c.err) : ''}</div>` : '';
+  return `<div class="card${(f||b) && missing(c) ? ' miss' : ''}" data-id="${esc(c.id)}">${pair}${extras}
+    <div class="meta"><div class="sku">${esc(c.dw_sku || '—')}${c.title ? ' <span class="sub">· ' + esc(c.title) + '</span>' : ''}</div>
+    <div class="sub">${esc(c.vendor || 'no vendor')} · ${esc(c.source || '')} · ${c.photos.length} photo${c.photos.length === 1 ? '' : 's'}</div>
+    ${status}<div class="when" title="${esc(c.at)}">🕓 ${esc(fmtDate(c.at))}</div></div></div>`;
+}
+function render(){
+  const q = $('#q').value.trim().toLowerCase();
+  let rows = all.filter(c => !q || [c.dw_sku, c.vendor, c.source, c.title, c.mfr].some(v => String(v || '').toLowerCase().includes(q)));
+  const newest = (a,b) => String(b.at).localeCompare(String(a.at));
+  const by = { newest, oldest:(a,b)=>String(a.at).localeCompare(String(b.at)),
+    missing:(a,b)=>(missing(b)-missing(a)) || newest(a,b),
+    sku:(a,b)=>String(a.dw_sku||'').localeCompare(String(b.dw_sku||'')) || newest(a,b),
+    vendor:(a,b)=>String(a.vendor||'').localeCompare(String(b.vendor||'')) || newest(a,b),
+    source:(a,b)=>String(a.source||'').localeCompare(String(b.source||'')) || newest(a,b) };
+  rows.sort(by[sort] || newest);
+  $('#count').textContent = rows.length + ' of ' + all.length;
+  $('#grid').innerHTML = rows.length ? rows.map(card).join('') : '<div class="empty">No captures yet — shoot a front + back and it appears here.</div>';
+}
+$('#grid').addEventListener('click', e => { const f = e.target.closest('figure[data-src]'); if (!f) return;
+  $('#lbImg').src = f.dataset.src; $('#lbCap').textContent = f.dataset.cap || ''; $('#lb').hidden = false; });
+const closeLb = () => { $('#lb').hidden = true; $('#lbImg').removeAttribute('src'); };
+$('#lbClose').addEventListener('click', closeLb);
+$('#lb').addEventListener('click', e => { if (e.target.id === 'lb') closeLb(); });
+document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLb(); });
+$('#sort').addEventListener('change', e => { sort = e.target.value; ls.set('cap.sort', sort); render(); });
+$('#dens').addEventListener('input', e => { document.documentElement.style.setProperty('--cardw', e.target.value + 'px'); ls.set('cap.dens', e.target.value); });
+$('#q').addEventListener('input', render);
+fetch(location.origin + '/api/captures?limit=2000').then(r => r.json()).then(d => {
+  all = d.captures || [];
+  $('#stat').innerHTML = `<b>${d.total}</b> items` + (d.missing_back ? ` · <span class="w">${d.missing_back} without a back photo</span>` : '');
+  render();
+}).catch(() => { $('#grid').innerHTML = '<div class="empty">Could not load captures.</div>'; });
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
index 4daaa60..963b2aa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -337,6 +337,23 @@
   .shot-btn.need{border-color:#b8902f}
   .shot-btn .shot-num{position:absolute;top:6px;left:9px;width:20px;height:20px;border-radius:50%;background:#2e2a25;color:#d8cba6;font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center}
   .shot-btn.have .shot-num{background:#3f7d4f;color:#fff}
+  /* TK-12228: the captured photo is shown IN its tile the moment it lands (front AND back) */
+  .shot-btn .shot-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:12px;opacity:.9}
+  .shot-btn.have{overflow:hidden}
+  .shot-btn.have .shot-ico{display:none}
+  .shot-btn.have b,.shot-btn.have small,.shot-btn.have .shot-ico{position:relative;z-index:1;text-shadow:0 1px 3px #000,0 0 8px #000}
+  .shot-btn.have .shot-num,.shot-btn.have .shot-ck::after{z-index:2}
+  /* both-sides photo strip used by the Preview + Created + Update result views */
+  .ph-pair{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin:10px 0 4px}
+  .ph-pair figure{margin:0;background:#0f0e0b;border:1px solid var(--line);border-radius:10px;overflow:hidden;cursor:zoom-in}
+  .ph-pair img{display:block;width:100%;aspect-ratio:1;object-fit:cover;background:#000}
+  .ph-pair figcaption{font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:5px 8px;color:#c8ba97}
+  .ph-pair figure.miss{cursor:default;display:flex;align-items:center;justify-content:center;min-height:120px;color:#e6a23a;font-size:12px;text-align:center;padding:8px}
+  #photoLb{position:fixed;inset:0;z-index:9999;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
+  #photoLb[hidden]{display:none}
+  #photoLb img{max-width:100%;max-height:84vh;object-fit:contain;border-radius:8px}
+  #photoLb .lb-cap{color:#eadfc6;font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
+  #photoLb button{background:#16140f;color:#eadfc6;border:1px solid var(--line);border-radius:10px;padding:10px 18px;font-size:15px}
   .shot-status{font-size:13px;margin:2px 0 8px;min-height:16px;color:#c8ba97}
   .shot-status.block{color:#e6a23a}
   /* OCR candidate picker — tap the correct detected SKU */
@@ -470,6 +487,7 @@
       <a href="/simple-probooth">Pro Booth</a>
       <a href="/simple-wizard">Guided Wizard</a>
       <a href="/vendor-photos">Vendor Photos</a>
+      <a href="/captures">Capture History</a>
     </nav>
   </div>
 </div>
@@ -521,6 +539,7 @@
   <a href="/simple-probooth">Pro Booth</a>
   <a href="/simple-wizard">Guided Wizard</a>
   <a href="/vendor-photos">Vendor Photos</a>
+  <a href="/captures">Capture History</a>
 </nav>
 <style>
   .other-apps{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;
@@ -671,8 +690,8 @@
     <!-- TWO-PHOTO gate: FRONT (pattern face) + BACK (label/SKU, OCR'd). Create is blocked until
          FRONT exists AND (BACK exists OR the mfr#/details are entered manually). -->
     <div class="two-shot">
-      <button class="shot-btn" id="frontBtn"><span class="shot-num">1</span><span class="shot-ico">📷</span><b>Front</b><small>pattern face</small><i class="shot-ck" id="frontCk"></i></button>
-      <button class="shot-btn" id="backBtn"><span class="shot-num">2</span><span class="shot-ico">🏷</span><b>Back</b><small>label / SKU</small><i class="shot-ck" id="backCk"></i></button>
+      <button class="shot-btn" id="frontBtn"><img class="shot-img" id="frontImg" alt="Front photo" hidden><span class="shot-num">1</span><span class="shot-ico">📷</span><b>Front</b><small>pattern face</small><i class="shot-ck" id="frontCk"></i></button>
+      <button class="shot-btn" id="backBtn"><img class="shot-img" id="backImg" alt="Back photo" hidden><span class="shot-num">2</span><span class="shot-ico">🏷</span><b>Back</b><small>label / SKU</small><i class="shot-ck" id="backCk"></i></button>
     </div>
     <div class="shot-status" id="shotStatus"></div>
     <!-- OCR fallback: when the mfr# can't be auto-determined, offer detected code candidates to tap -->
@@ -706,6 +725,9 @@
   </div>
 </div>
 
+<!-- TK-12228: tap any captured photo (front/back) to see it full-size -->
+<div id="photoLb" hidden><div class="lb-cap" id="photoLbCap"></div><img id="photoLbImg" alt="captured photo"><button id="photoLbClose">✕ Close</button></div>
+
 <!-- Measure Depth: a credit card (3.375") in frame sets the px→inch scale, then size the box to the goods -->
 <div id="measureModal" hidden>
   <video id="mVideo" autoplay playsinline muted webkit-playsinline></video>
@@ -2117,7 +2139,25 @@ function twoPhotoGate(){ const mfr=curMfr();
 }
 function renderShotTiles(){
   $('#frontBtn').classList.toggle('have',!!_frontPhoto); $('#frontBtn').classList.toggle('need',!_frontPhoto);
-  $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_backPhoto); }
+  $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_backPhoto);
+  // TK-12228: show the actual captured photo inside its tile (immediate preview of FRONT and BACK)
+  [['#frontImg',_frontPhoto],['#backImg',_backPhoto]].forEach(([id,src])=>{ const im=$(id); if(!im) return;
+    if(src){ if(im.getAttribute('src')!==src) im.src=src; im.hidden=false; } else { im.removeAttribute('src'); im.hidden=true; } }); }
+// ── TK-12228: both-sides photo strip for the Preview / Created / Update result views + tap-to-enlarge ──
+const SIDE_LBL={front:'Front · pattern',back:'Back · label',extra:'Extra',photo:'Photo'};
+function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
+// list: [{side,url}] — FRONT and BACK always get a slot; a missing side is shown as a visible gap, never silently dropped.
+function photoPairHtml(list,opts){ opts=opts||{}; list=(list||[]).filter(x=>x&&x.url);
+  const has=s=>list.some(x=>x.side===s);
+  const cells=list.map(x=>`<figure data-src="${escAttr(x.url)}" data-cap="${escAttr(SIDE_LBL[x.side]||x.side)}"><img src="${escAttr(x.url)}" alt="${escAttr(SIDE_LBL[x.side]||x.side)} photo" data-side="${escAttr(x.side)}"><figcaption>${SIDE_LBL[x.side]||x.side}</figcaption></figure>`);
+  if(opts.requireTwo){ if(!has('front')) cells.unshift('<figure class="miss">⚠ no FRONT photo</figure>'); if(!has('back')) cells.splice(1,0,'<figure class="miss">⚠ no BACK photo</figure>'); }
+  return `<div class="ph-pair" data-set="${escAttr(opts.set||'')}">${cells.join('')}</div>`; }
+function localPhotoList(){ return [{side:'front',url:_frontPhoto},{side:'back',url:_backPhoto}].filter(x=>x.url)
+  .concat(_media.filter(m=>m.type==='photo').map(m=>({side:'extra',url:m.dataUrl}))); }
+function openPhotoLb(src,cap){ $('#photoLbImg').src=src; $('#photoLbCap').textContent=cap||''; $('#photoLb').hidden=false; }
+document.addEventListener('click',ev=>{ const f=ev.target.closest&&ev.target.closest('.ph-pair figure[data-src]'); if(!f) return; ev.preventDefault(); openPhotoLb(f.dataset.src,f.dataset.cap); });
+$('#photoLbClose').addEventListener('click',()=>{ $('#photoLb').hidden=true; $('#photoLbImg').removeAttribute('src'); });
+$('#photoLb').addEventListener('click',ev=>{ if(ev.target.id==='photoLb'){ $('#photoLb').hidden=true; $('#photoLbImg').removeAttribute('src'); } });
 function renderShots(){ renderShotTiles();
   if(_autoBusy) return;                                             // mid-extract: autoExtract owns the status line
   if($('#candPick') && $('#candPick').innerHTML) return;            // candidate picker owns the status line
@@ -2589,12 +2629,13 @@ async function addPreview(){ if(_addMode==='update') return addUpdate();
   const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)})).json();
   if(r.duplicate){ $('#addNote').innerHTML='⚠ '+r.err+' — tap <b>⌂ → Update SKU</b> to add media to it live.'; return; }
   if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); return; }
-  const pv=r.preview, c=mediaCounts(); $('#addNote').textContent='Preview — nothing created yet (all 3 systems).';
+  const pv=r.preview, c=mediaCounts(), nAll=addPhotosList().length; $('#addNote').textContent='Preview — nothing created yet (all 3 systems).';
   const fm=pv.filemaker||{}, esc=s=>String(s==null?'':s).replace(/</g,'&lt;');
   const fmRows=Object.entries(fm.fieldData||{}).map(([k,v])=>`<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('');
   const flagsHtml=(pv.flags&&pv.flags.length)?`<div class="pv-flags">⚠ ${pv.flags.map(esc).join('<br>⚠ ')}</div>`:'';
   $('#addResult').innerHTML=`
-    <div class="fb-hit"><div><b>${esc(pv.title)}</b><br><small><b>DW# ${esc(pv.dw_sku)}</b>${pv.provisional_sku?' <span style="color:#e0a53a">(PROVISIONAL)</span>':''} · ${c.p} photo(s) · ${c.v} video(s)</small></div></div>
+    <div class="fb-hit"><div><b>${esc(pv.title)}</b><br><small><b>DW# ${esc(pv.dw_sku)}</b>${pv.provisional_sku?' <span style="color:#e0a53a">(PROVISIONAL)</span>':''} · ${nAll} photo(s) · ${c.v} video(s)</small></div></div>
+    ${photoPairHtml(localPhotoList(),{requireTwo:true,set:'preview'})}
     ${flagsHtml}
     <div class="pv-sys"><div class="pv-h">🛍 Shopify draft</div><small>${esc(pv.shopify.vendor)} · ${esc(pv.shopify.product_type)} · <b>DRAFT</b><br>Roll <b>${esc(pv.shopify.variants[0].sku)}</b> @ ${esc(pv.shopify.variants[0].price)} · Sample <b>${esc(pv.shopify.variants[1].sku)}</b> @ $4.25</small></div>
     <div class="pv-sys"><div class="pv-h">🗄 dw_unified · new_items_staging</div><small>dw_sku <b>${esc(pv.staging.dw_sku)}</b> · vid ${esc(pv.staging.vid||'—')} · ${esc(pv.staging.pattern_name||'—')} / ${esc(pv.staging.color||'—')}</small></div>
@@ -2614,7 +2655,9 @@ async function addCommit(){ const g=twoPhotoGate(); if(!g.ok){ $('#addNote').tex
   const baseMsg=`✅ Created ${r.dw_sku} — ${nPh>=2?'front+back':nPh+' photo(s)'} saved${vmsg}`;
   $('#addNote').innerHTML=`${baseMsg} <small>— DRAFT, not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
   // CONTINUE-ABLE: a prominent Next button clears the item but keeps vendor + calibration + white-balance
-  $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${r.dw_sku}</b> <small>created (draft) · ${_sessCount} this session</small></div></div><button class="samp-print" id="addNext" style="margin-top:12px">➕ Next item (keep vendor)</button>`;
+  // TK-12228: show the photos AS STORED on the server (front + back) — proves they persisted + are served back.
+  const saved=(r.captured&&r.captured.photos&&r.captured.photos.length)?r.captured.photos:localPhotoList();
+  $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${r.dw_sku}</b> <small>created (draft) · ${_sessCount} this session</small></div></div>${photoPairHtml(saved,{requireTwo:true,set:'created'})}<div class="samp-meta" style="margin-top:6px"><a href="/captures" style="color:var(--gold)">View all captures →</a></div><button class="samp-print" id="addNext" style="margin-top:12px">➕ Next item (keep vendor)</button>`;
   $('#addNext').addEventListener('click',nextItem);
   if(vup.ok && r.product_id) pollVideoStatus(r.product_id, baseMsg, vup.ok);
 }
@@ -2677,7 +2720,10 @@ async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
     $('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; return; }
   const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
   $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
-  if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
+  let savedPh=[];
+  if(photos.length){ const sides=localPhotoList().map(x=>x.side);
+    const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos,sides})})).json(); added=r.added||0; savedPh=r.photos||[]; }
+  $('#addResult').innerHTML=savedPh.length?photoPairHtml(savedPh,{set:'updated'}):'';   // TK-12228: show what was stored
   const hadVideos=addVideosList().length;
   const vup=hadVideos ? await uploadVideos(_updatePid,_updateSku) : {ok:0,failed:0};
   const baseMsg=`✅ LIVE: ${added} photo(s) + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:'')+` → ${_updateSku}`;
diff --git a/scripts/e2e-front-back-display.cjs b/scripts/e2e-front-back-display.cjs
new file mode 100644
index 0000000..1a6c0fb
--- /dev/null
+++ b/scripts/e2e-front-back-display.cjs
@@ -0,0 +1,139 @@
+// Usage (TK-12228): start a test server, then run this against it:
+//   mkdir -p /tmp/dwp-e2e && PORT=9987 DWP_TEST_DIR=/tmp/dwp-e2e node server.js --test &
+//   TESTROOT=/tmp/dwp-e2e OUT=/tmp/dwp-e2e/shots BASE=http://127.0.0.1:9987 node scripts/e2e-front-back-display.cjs
+//   (PW=<path to a playwright package> if not the default; exit 0 = all pass, 1 = a check failed, 2 = crash)
+// TK-12228 E2E: every captured photo (front + back) displays — preview, result, history, batch, remote cam.
+// Drives the REAL app (server started with --test: Shopify/dw_unified/FM writes + paid OCR stubbed).
+const { chromium } = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
+const fs = require('fs'), path = require('path');
+const BASE = process.env.BASE || 'http://127.0.0.1:9987';
+const OUT = process.env.OUT || path.join(process.env.TESTROOT || '.', 'shots');
+const TESTROOT = process.env.TESTROOT; if (!TESTROOT) { console.error('TESTROOT (= the server DWP_TEST_DIR) required'); process.exit(2); }
+fs.mkdirSync(OUT, { recursive: true });
+const AUTH = { username: 'admin', password: 'DW2024!' };
+const results = []; let failed = 0;
+function check(name, cond, detail) { results.push({ name, ok: !!cond, detail }); if (!cond) failed++; console.log((cond ? 'PASS ' : 'FAIL ') + name + (detail ? ' — ' + JSON.stringify(detail) : '')); }
+// every <img> matched must be fully loaded with real pixels
+async function imgs(page, sel) {
+  await page.waitForFunction(s => { const l = [...document.querySelectorAll(s)]; return l.length && l.every(i => i.complete); }, sel, { timeout: 15000 }).catch(() => {});
+  return page.$$eval(sel, l => l.map(i => ({ side: i.dataset.side || i.alt, w: i.naturalWidth, h: i.naturalHeight, src: (i.currentSrc || i.src).slice(0, 80) })));
+}
+const allLoaded = l => l.length > 0 && l.every(x => x.w > 0 && x.h > 0);
+
+(async () => {
+  const browser = await chromium.launch({ headless: true, args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] });
+  const ctx = await browser.newContext({ httpCredentials: AUTH, permissions: ['camera', 'microphone'], viewport: { width: 430, height: 932 } });
+  const page = await ctx.newPage();
+  page.on('pageerror', e => console.log('[pageerror]', e.message));
+
+  // ── 1. index.html ADD flow: live two-shot camera → FRONT then BACK ──
+  await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+  await page.click('#homeScreen [data-act="add"]');
+  await page.waitForSelector('#twoShotCam:not([hidden])', { timeout: 15000 });
+  await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 20000 });
+  await page.waitForTimeout(600);
+  await page.click('#tsShutterBtn');                                   // FRONT
+  await page.waitForFunction(() => document.querySelector('#tsStepName').textContent === 'BACK', null, { timeout: 15000 });
+  await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 15000 });
+  const tsThumb = await imgs(page, '#tsThumb');
+  check('live cam: FRONT thumbnail shows while shooting BACK', allLoaded(tsThumb), tsThumb);
+  await page.screenshot({ path: path.join(OUT, '01-livecam-front-thumb.png') });
+  await page.click('#tsShutterBtn');                                   // BACK
+  await page.waitForSelector('#twoShotCam', { state: 'hidden', timeout: 15000 });
+  await page.waitForSelector('#frontImg:not([hidden])'); await page.waitForSelector('#backImg:not([hidden])');
+  const tiles = await imgs(page, '#frontImg, #backImg');
+  check('immediate preview: FRONT + BACK tiles both render', tiles.length === 2 && allLoaded(tiles), tiles);
+  await page.locator('.two-shot').screenshot({ path: path.join(OUT, '02-tiles-front-back.png') });
+
+  // vendor + mfr (OCR is stubbed → manual entry path)
+  await page.waitForFunction(() => document.querySelectorAll('#addVendor option').length > 1, null, { timeout: 10000 });
+  await page.selectOption('#addVendor', 'Test Vendor');
+  await page.fill('#addMfr', 'TK12228A');
+  await page.click('#addPreview');
+  await page.waitForSelector('.ph-pair[data-set="preview"] img', { timeout: 15000 });
+  const pv = await imgs(page, '.ph-pair[data-set="preview"] img');
+  check('Preview view: FRONT + BACK render', pv.map(x => x.side).join(',') === 'front,back' && allLoaded(pv), pv);
+  const pvCount = await page.$eval('#addResult .fb-hit small', e => e.textContent);
+  check('Preview counts the front+back photos (was "0 photo(s)")', /2 photo\(s\)/.test(pvCount), pvCount);
+  await page.locator('#addResult').screenshot({ path: path.join(OUT, '03-preview-front-back.png') });
+
+  await page.click('#addCommit');
+  await page.waitForSelector('.ph-pair[data-set="created"] img', { timeout: 20000 });
+  const cr = await imgs(page, '.ph-pair[data-set="created"] img');
+  check('Created result: FRONT + BACK render from server /photos/ URLs', cr.map(x => x.side).join(',') === 'front,back' && allLoaded(cr) && cr.every(x => x.src.startsWith(BASE + '/photos/')), cr);
+  await page.locator('#addResult').screenshot({ path: path.join(OUT, '04-created-front-back.png') });
+  await page.click('.ph-pair[data-set="created"] figure:nth-child(2)');
+  await page.waitForSelector('#photoLb:not([hidden])');
+  const lb = await imgs(page, '#photoLbImg');
+  check('Created result: tap BACK → full-size lightbox', allLoaded(lb) && /back/i.test(await page.$eval('#photoLbCap', e => e.textContent)), lb);
+  await page.screenshot({ path: path.join(OUT, '05-lightbox-back.png') });
+  await page.click('#photoLbClose');
+
+  // files really persisted on disk
+  const disk = fs.readdirSync(path.join(TESTROOT, 'photos')).filter(f => /^TEST-TK12228A-(front|back)-/.test(f));
+  check('both photos persisted under photos/ with side in filename', disk.some(f => /-front-/.test(f)) && disk.some(f => /-back-/.test(f)), disk);
+
+  // ── 2. Update mode (/api/photos) + batch (/api/batch-shot) + legacy vendor-log via API, then history ──
+  const jpgFront = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; const g = c.getContext('2d'); g.fillStyle = '#3a7'; g.fillRect(0, 0, 320, 240); g.fillStyle = '#fff'; g.font = '40px sans-serif'; g.fillText('FRONT', 90, 130); return c.toDataURL('image/jpeg', 0.9); });
+  const jpgBack = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; const g = c.getContext('2d'); g.fillStyle = '#a53'; g.fillRect(0, 0, 320, 240); g.fillStyle = '#fff'; g.font = '40px sans-serif'; g.fillText('BACK', 100, 130); return c.toDataURL('image/jpeg', 0.9); });
+  const post = (u, b) => page.evaluate(async ([u, b]) => (await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) })).json(), [u, b]);
+  const up = await post('/api/photos', { dw_sku: 'UPD-777', product_id: 123, dataUrls: [jpgFront, jpgBack], sides: ['front', 'back'] });
+  check('/api/photos returns both stored photos with sides', up.photos && up.photos.map(p => p.side).join(',') === 'front,back', up);
+  const b1 = await post('/api/batch-shot', { sessionId: 'e2e-sess', sku: 'BAT-1', seq: 1, vendor: 'Test Vendor', web: jpgFront, meta: { side: 'psku' } });
+  const b2 = await post('/api/batch-shot', { sessionId: 'e2e-sess', sku: 'BAT-1', seq: 1, vendor: 'Test Vendor', web: jpgBack, meta: { side: 'info' } });
+  check('/api/batch-shot accepted psku + info', b1.ok && b2.ok, { b1: b1.paths, b2: b2.paths });
+  // legacy: a pre-TK-12228 create-item (vendor-log only, -0/-1 tags) must still show front + back in history
+  const vdir = path.join(TESTROOT, 'photos', 'vendors', 'Legacy Co'); fs.mkdirSync(vdir, { recursive: true });
+  const t0 = Date.now() - 86400000;
+  [['0', jpgFront], ['1', jpgBack]].forEach(([tag, d]) => fs.writeFileSync(path.join(vdir, `LEG-5-${t0}-${tag}.jpg`), Buffer.from(d.split(',')[1], 'base64')));
+  fs.appendFileSync(path.join(TESTROOT, 'data', 'vendor-photos.jsonl'), [0, 1].map(tag => JSON.stringify({ at: new Date(t0 + tag).toISOString(), vendor: 'Legacy Co', dw_sku: 'LEG-5', source: 'create-item', path: `/photos/vendors/Legacy%20Co/LEG-5-${t0}-${tag}.jpg` })).join('\n') + '\n');
+
+  // ── 3. /captures history grid ──
+  await page.goto(BASE + '/captures', { waitUntil: 'domcontentloaded' });
+  await page.waitForSelector('.card');
+  const cards = await page.$$eval('.card', l => l.map(c => ({ sku: c.querySelector('.sku').textContent.split(' ')[0], sides: [...c.querySelectorAll('.pair img')].map(i => i.dataset.side), when: c.querySelector('.when').textContent, title: c.querySelector('.when').title })));
+  const want = ['TEST-TK12228A', 'UPD-777', 'BAT-1', 'LEG-5'];
+  for (const sku of want) { const c = cards.find(x => x.sku === sku); check(`history card ${sku} shows front+back`, c && c.sides.join(',') === 'front,back', c); }
+  const hist = await imgs(page, '.card .pair img');
+  check('history: every front/back thumbnail has pixels (naturalWidth>0)', hist.length >= 8 && allLoaded(hist), { n: hist.length, bad: hist.filter(x => !x.w) });
+  const c0 = cards[0];
+  check('history card shows created date+time chip with ISO title', /\d{4}/.test(c0.when) && /\d:\d\d/.test(c0.when) && /T.*Z$/.test(c0.title), c0);
+  await page.screenshot({ path: path.join(OUT, '06-history-grid.png'), fullPage: true });
+  // sort + density persist across reload
+  await page.selectOption('#sort', 'sku'); await page.fill('#dens', '200'); await page.dispatchEvent('#dens', 'input');
+  await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.card');
+  const persisted = await page.evaluate(() => ({ sort: document.querySelector('#sort').value, dens: document.querySelector('#dens').value, first: document.querySelector('.card .sku').textContent }));
+  check('history sort + density persist in localStorage', persisted.sort === 'sku' && persisted.dens === '200', persisted);
+  await page.click('.card .pair figure[data-side], .card .pair figure');
+  await page.waitForSelector('#lb:not([hidden])');
+  check('history: click thumbnail → enlarge', allLoaded(await imgs(page, '#lbImg')));
+  await page.screenshot({ path: path.join(OUT, '07-history-lightbox.png') });
+
+  // ── 4. batch.html thumbs (unit-level: drive the page's own showThumb with real JPEG blobs) ──
+  await page.goto(BASE + '/batch', { waitUntil: 'domcontentloaded' });
+  await page.evaluate(async ([f, b]) => {
+    const blob = async d => (await fetch(d)).blob();
+    document.getElementById('vShoot').hidden = false;
+    showThumb('front', await blob(f)); showThumb('back', await blob(b));
+  }, [jpgFront, jpgBack]);
+  const bt = await imgs(page, '#thumb, #thumbB');
+  check('batch.html: last sample FRONT + BACK thumbs both render', bt.length === 2 && allLoaded(bt), bt);
+  await page.locator('.thumbs').screenshot({ path: path.join(OUT, '08-batch-thumbs.png') });
+
+  // ── 5. cam.html remote shutter: shot shows on the phone from the server-stored copy ──
+  const cam = await ctx.newPage();
+  cam.on('pageerror', e => console.log('[cam pageerror]', e.message));
+  await cam.goto(BASE + '/cam', { waitUntil: 'domcontentloaded' });
+  await cam.click('#gBtn').catch(() => {});
+  for (let i = 0; i < 40; i++) { const st = await cam.evaluate(async () => (await fetch('/api/pair/status')).json()); if (st.cam_connected && st.cam_live) break; await cam.waitForTimeout(500); }
+  const sh = await post('/api/pair/shoot', {});
+  await cam.waitForSelector('#lastShot:not([hidden])', { timeout: 20000 });
+  const cl = await imgs(cam, '#lastShotImg');
+  check('cam.html: remote shot displays on the phone (server /photos/ copy)', sh.ok && allLoaded(cl) && cl[0].src.includes('/photos/'), { sh, cl });
+  await cam.screenshot({ path: path.join(OUT, '09-cam-lastshot.png') });
+
+  fs.writeFileSync(path.join(OUT, 'results.json'), JSON.stringify({ failed, results }, null, 2));
+  console.log(`\n${results.length - failed}/${results.length} passed`);
+  await browser.close();
+  process.exit(failed ? 1 : 0);
+})().catch(e => { console.error('E2E CRASH', e); process.exit(2); });
diff --git a/server.js b/server.js
index 7555e10..cd6bfb6 100644
--- a/server.js
+++ b/server.js
@@ -30,6 +30,16 @@ process.on('uncaughtException', (err) => {
   try { console.error('[uncaughtException]', (err && err.stack) || err); } catch (e) {}
 });
 
+// ── Test seam (TK-12228) ─────────────────────────────────────────────────────
+// `node server.js --test` (DWP_TEST_DIR required) rebases data/ + photos/ into a scratch dir and
+// STUBS every Shopify write (shopifyReq) + the whole createNewItem commit (Shopify/dw_unified/FM),
+// and skips the catalog/FileMaker boot fetches. Lets a headless browser exercise the real capture
+// → persist → display path end to end with zero external writes. pm2/launchd NEVER pass --test.
+const TEST_MODE = process.argv.includes('--test');
+const TEST_DIR = TEST_MODE ? String(process.env.DWP_TEST_DIR || '') : '';
+if (TEST_MODE && !TEST_DIR) { console.error('--test requires DWP_TEST_DIR (scratch dir for data/ + photos/)'); process.exit(2); }
+if (TEST_MODE) console.log('[TEST MODE] data+photos under', TEST_DIR, '— Shopify/dw_unified/FileMaker writes STUBBED');
+
 // FileMaker Cloud client (sample-request lookup + sticker-print flag). Loaded guarded so a missing
 // dep / unset creds degrade gracefully instead of crashing the scanner.
 let FM = null;
@@ -53,7 +63,7 @@ const VSEARCH_URL = process.env.VSEARCH_URL || 'http://127.0.0.1:9914';   // CLI
 // for a "samples coming in" queue. Refreshed at boot + every 8 min + on a stale read.
 let _incomingCache = { at: 0, samples: [] }, _incomingLoading = false;
 async function refreshIncoming() {
-  if (_incomingLoading || !FM_ENABLED()) return;
+  if (_incomingLoading || !FM_ENABLED() || TEST_MODE) return;
   _incomingLoading = true;
   try {
     const floor = new Date(Date.now() - 120 * 864e5).toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' });
@@ -89,8 +99,8 @@ setTimeout(refreshIncoming, 9000);                 // warm the cache shortly aft
 setInterval(refreshIncoming, 8 * 60 * 1000);       // keep it fresh
 
 const ROOT = __dirname;
-const DATA = path.join(ROOT, 'data');
-const PHOTOS = path.join(ROOT, 'photos');
+const DATA = TEST_MODE ? path.join(TEST_DIR, 'data') : path.join(ROOT, 'data');
+const PHOTOS = TEST_MODE ? path.join(TEST_DIR, 'photos') : path.join(ROOT, 'photos');
 const QUEUE_FILE = path.join(DATA, 'queue.json');
 const PROGRESS_FILE = path.join(DATA, 'progress.json');
 const PORT = process.env.PORT || 9890;
@@ -115,6 +125,7 @@ try {
   }
 } catch (e) {}
 if (!TOKEN) TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
+if (TEST_MODE) TOKEN = 'TEST-STUB';   // never a real token in test mode — shopifyReq below is stubbed
 
 fs.mkdirSync(PHOTOS, { recursive: true });
 
@@ -175,15 +186,103 @@ function archiveVendorPhoto(srcFile, { vendor, dw_sku, source, at, name } = {})
     return rec;
   } catch (e) { console.error('[vendor-photos] archive failed', srcFile, e.message); return null; }
 }
-// Write a base64 JPEG straight into the vendor archive (for photos that were never saved locally,
-// e.g. the create-item photos that only went to Shopify).
-function archiveVendorB64(b64, { vendor, dw_sku, source, tag }) {
+// (archiveVendorB64 retired TK-12228: create-item photos now go through savePhotoB64 → archiveVendorPhoto
+//  so each file carries its front/back side and is indexed in data/captures.jsonl.)
+// ── Capture history (TK-12228 — Steve: "every photo, front and back, must show display on the app") ──
+// Every capture that reaches the server is persisted under photos/ with its SIDE in the filename and
+// indexed in data/captures.jsonl as ONE record per item (front + back + extras together), so the
+// result view and the /captures history grid can show BOTH sides. Batch-mode shots are read from
+// their existing per-session manifests; captures from before this log existed are reconstructed from
+// the vendor-photo archive log (sides inferred from the -0/-1 filename tag).
+const CAPTURE_LOG = path.join(DATA, 'captures.jsonl');
+const SIDES = ['front', 'back', 'extra', 'photo'];
+// sides[] for an ordered upload list [front?, back?, ...extras] given which of front/back are present.
+function sidesFor(n, frontPresent, backPresent) {
+  const out = []; let i = 0;
+  if (frontPresent !== false && i < n) out[i++] = 'front';
+  if (backPresent && i < n) out[i++] = 'back';
+  while (i < n) out[i++] = 'extra';
+  return out;
+}
+const cleanSide = s => (SIDES.includes(s) ? s : 'photo');
+// Write one base64 JPEG under photos/ as <SKU>-<side>-<ts>-<i>.jpg → its /photos/ URL (null on failure).
+function savePhotoB64(b64, dw_sku, side, i) {
   try {
-    const safe = String(dw_sku || 'nosku').replace(/[^A-Za-z0-9._-]/g, '');
-    const f = path.join(PHOTOS, `${safe}-${Date.now()}-${tag || 0}.jpg`);
-    fs.writeFileSync(f, Buffer.from(b64, 'base64'));
-    return archiveVendorPhoto(f, { vendor, dw_sku, source });
-  } catch (e) { console.error('[vendor-photos] b64 archive failed', e.message); return null; }
+    const buf = Buffer.from(String(b64 || ''), 'base64');
+    if (buf.length < 200) return null;
+    const safe = String(dw_sku || 'nosku').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 80) || 'nosku';
+    const fname = `${safe}-${cleanSide(side)}-${Date.now()}-${i || 0}.jpg`;
+    fs.writeFileSync(path.join(PHOTOS, fname), buf);
+    return { file: path.join(PHOTOS, fname), url: `/photos/${fname}` };
+  } catch (e) { console.error('[captures] save failed', e.message); return null; }
+}
+function recordCapture(rec) {
+  try {
+    const at = new Date().toISOString();
+    const full = Object.assign({ id: at.replace(/[:.]/g, '-') + '-' + Math.random().toString(36).slice(2, 7), at }, rec);
+    full.photos = (full.photos || []).filter(p => p && p.url).map(p => ({ side: cleanSide(p.side), url: p.url }));
+    fs.appendFileSync(CAPTURE_LOG, JSON.stringify(full) + '\n');
+    return full;
+  } catch (e) { console.error('[captures] log failed', e.message); return null; }
+}
+const readJsonl = f => { const out = []; try { for (const l of fs.readFileSync(f, 'utf8').split('\n')) { if (!l) continue; try { out.push(JSON.parse(l)); } catch (e) {} } } catch (e) {} return out; };
+// Merge the three sources into one newest-first list of { id, at, source, dw_sku, vendor, photos[] }.
+function listCaptures() {
+  const items = [], seen = new Set();
+  const base = u => { try { return decodeURIComponent(String(u).split('/').pop()); } catch (e) { return String(u).split('/').pop(); } };
+  for (const r of readJsonl(CAPTURE_LOG)) {
+    if (!r || !Array.isArray(r.photos)) continue;
+    r.photos.forEach(p => seen.add(base(p.url)));
+    items.push(Object.assign({ source: 'capture' }, r));
+  }
+  // batch manifests: one record per (session, sku, seq); psku/front → front, info/back → back
+  const mdir = path.join(DATA, 'batch-sessions');
+  try {
+    for (const m of fs.readdirSync(mdir)) {
+      if (!/\.jsonl$/.test(m)) continue;
+      const groups = new Map();
+      for (const r of readJsonl(path.join(mdir, m))) {
+        const key = `${r.sessionId}|${r.sku}|${r.seq == null ? '' : r.seq}`;
+        let g = groups.get(key);
+        if (!g) { g = { id: 'batch:' + key, at: r.at, source: 'batch', session: r.sessionId, dw_sku: r.sku, vendor: r.vendor || null, bySide: {} }; groups.set(key, g); }
+        if (r.at && r.at < g.at) g.at = r.at;
+        const ms = r.meta && r.meta.side;
+        const side = ms === 'info' || ms === 'back' ? 'back' : 'front';   // psku/front/unsided single → front
+        const pth = r.paths || {};
+        const url = pth.web || pth.master || pth.original;
+        if (url) g.bySide[side] = url;
+      }
+      for (const g of groups.values()) {
+        g.photos = ['front', 'back'].filter(s => g.bySide[s]).map(s => ({ side: s, url: g.bySide[s] }));
+        delete g.bySide; if (g.photos.length) items.push(g);
+      }
+    }
+  } catch (e) { /* no batch sessions yet */ }
+  // legacy uploads (before captures.jsonl): the vendor-photo archive log, grouped per SKU+source+~2min
+  const legacy = [];
+  const vseen = new Set();
+  for (const r of readJsonl(VENDOR_LOG)) {
+    if (!r || !r.path || vseen.has(r.path)) continue; vseen.add(r.path);
+    if (/batch/.test(r.source || '')) continue;                   // batch shots come from their manifests
+    const b = base(r.path);
+    if (seen.has(b) || /^(scan-|_ocr-)/.test(b)) continue;
+    legacy.push(r);
+  }
+  legacy.sort((a, b) => String(a.at).localeCompare(String(b.at)));
+  let cur = null;
+  for (const r of legacy) {
+    const t = Date.parse(r.at) || 0;
+    if (!cur || cur.dw_sku !== (r.dw_sku || null) || cur._src !== r.source || t - cur._t > 120000) {
+      cur = { id: 'legacy:' + r.path, at: r.at, source: 'legacy-' + (r.source || 'upload'), _src: r.source, _t: t, dw_sku: r.dw_sku || null, vendor: r.vendor || null, photos: [] };
+      items.push(cur);
+    }
+    const tag = /-\d{10,}-(\d+)\.jpe?g$/i.exec(base(r.path));
+    const side = tag ? (tag[1] === '0' ? 'front' : tag[1] === '1' ? 'back' : 'extra') : 'photo';
+    cur.photos.push({ side, url: r.path, inferred: true });
+  }
+  for (const it of items) { delete it._src; delete it._t; }
+  items.sort((a, b) => String(b.at).localeCompare(String(a.at)));
+  return items;
 }
 // One-time backfill of photos saved before the archive existed (flat photos/<SKU>-*.jpg and
 // batch/<session>/). Runs once the catalog is loaded; marker file makes it idempotent.
@@ -265,6 +364,10 @@ function cardRecord(dw_sku, meta, extra) {
 
 function shopifyReq(method, path, payload) {
   return new Promise((resolve) => {
+    if (TEST_MODE) {   // TK-12228 test seam: no network. Image POSTs "succeed" so attach paths run; all else is inert.
+      if (method === 'POST' && /\/images\.json$/.test(path)) return resolve({ status: 201, body: { image: { id: Date.now() } }, raw: '' });
+      return resolve({ status: 0, body: null, err: 'test-stub' });
+    }
     if (!TOKEN) return resolve({ status: 0, body: null, err: 'no token' });
     const data = payload ? JSON.stringify(payload) : null;
     const req = https.request({
@@ -1065,6 +1168,26 @@ const appHandler = (req, res) => {
 
   if (u.pathname === '/healthz') return send(res, 200, { ok: true });
 
+  // TK-12228 test seam: under --test, the PAID / external identification endpoints (Gemini/GCV OCR,
+  // CLIP, FileMaker, dw_unified vendor registry) answer with deterministic empty results, so a headless
+  // browser can drive the whole front+back capture flow at $0 with no external reads or writes.
+  if (TEST_MODE && req.method !== 'OPTIONS') {
+    const STUB = {
+      '/api/extract': { ok: true, fields: {}, test_stub: true },
+      '/api/ocr': { ok: true, candidates: [], top: null, topStrong: false, test_stub: true },
+      '/api/identify': { ok: true, found: false, candidates: [], test_stub: true },
+      '/api/identify-multi': { ok: true, found: false, matches: [], results: [], test_stub: true },
+      '/api/recognize': { ok: true, found: false, matches: [], test_stub: true },
+      '/api/learn': { ok: true, test_stub: true },
+      '/api/ask': { ok: true, answer: '(test mode)', test_stub: true },
+      '/api/incoming-samples': { ok: true, samples: [], test_stub: true },
+      '/api/vendors-registry': { ok: true, count: 2, test_stub: true, vendors: [
+        { vendor: 'Test Vendor', real_vendor: 'Test Vendor', vid: 'TST', sku_prefix: 'TST-', sku_range_start: 1000, fm_vid: '999', private_label: false },
+        { vendor: 'Fentucci', real_vendor: 'Fentucci', vid: 'TWIL', sku_prefix: 'GRS-', sku_range_start: 26000, fm_vid: '1', private_label: true }] },
+    };
+    if (STUB[u.pathname]) { req.resume(); return send(res, 200, STUB[u.pathname]); }
+  }
+
   // ── Public marketing assets (email images) — TIGHTLY bounded static serve ──
   // No auth (allowed in the PUBLIC gate above). NOT a generic file server:
   //  · Fixed dir public-marketing/ — never user-controlled.
@@ -1429,6 +1552,19 @@ const appHandler = (req, res) => {
     return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
   }
 
+  // ── Capture history (TK-12228): every item's FRONT + BACK photos, newest first (behind Basic-auth).
+  if (u.pathname === '/captures') {
+    const f = path.join(ROOT, 'public/captures.html');
+    if (!fs.existsSync(f)) return send(res, 404, { err: 'captures.html missing' });
+    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
+  }
+  if (u.pathname === '/api/captures' && req.method === 'GET') {
+    const limit = Math.min(2000, Math.max(1, parseInt(u.searchParams.get('limit') || '500', 10) || 500));
+    const all = listCaptures();
+    const missingBack = all.filter(c => !c.photos.some(p => p.side === 'back')).length;
+    return send(res, 200, { ok: true, total: all.length, missing_back: missingBack, count: Math.min(limit, all.length), captures: all.slice(0, limit) });
+  }
+
   // ── Per-vendor photo archive viewer (behind the global Basic-auth wall).
   if (u.pathname === '/vendor-photos') {
     const f = path.join(ROOT, 'public/vendor-photos.html');
@@ -1877,12 +2013,24 @@ const appHandler = (req, res) => {
       p._photos64 = photos64;
       const b64 = photos64[0] || null;
       try {
-        const out = await createNewItem(p, b64, p.commit !== true);
-        if (p.commit === true && out && out.ok) {
+        const out = TEST_MODE ? testStubCreate(p, p.commit !== true) : await createNewItem(p, b64, p.commit !== true);
+        if (p.commit === true && out) {
+          // TK-12228: persist EVERY committed photo (front + back + extras) with its side, even when the
+          // create failed — so nothing captured is ever lost/undisplayable — and return the served URLs
+          // so the result view shows the photos the server actually stored.
           const sku = out.dw_sku || (out.preview && out.preview.dw_sku) || p.mfr;
           const vend = (out.preview && out.preview.vendor) || out.vendor || p.vendor;
-          (p._photos64 && p._photos64.length ? p._photos64 : (b64 ? [b64] : []))
-            .forEach((ph, i) => archiveVendorB64(ph, { vendor: vend, dw_sku: sku, source: 'create-item', tag: i }));
+          const list = p._photos64 && p._photos64.length ? p._photos64 : (b64 ? [b64] : []);
+          const sides = sidesFor(list.length, p.front_present, p.back_present);
+          const photos = [];
+          list.forEach((ph, i) => {
+            const sv = savePhotoB64(ph, sku, sides[i], i); if (!sv) return;
+            archiveVendorPhoto(sv.file, { vendor: vend, dw_sku: sku, source: 'create-item' });
+            photos.push({ side: sides[i], url: sv.url });
+          });
+          const rec = recordCapture({ source: 'create-item', dw_sku: sku || null, vendor: vend || null, mfr: p.mfr || null,
+            product_id: out.product_id || null, title: out.title || null, ok: !!out.ok, err: out.ok ? null : (out.err || null), photos });
+          out.captured = { id: rec && rec.id, photos };
         }
         send(res, 200, out);
       }
@@ -2422,12 +2570,12 @@ load();
       const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
       if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
       const b64 = await normB64(dataUrl.replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
-      const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
-      const fname = `${safe}-${Date.now()}.jpg`;
-      fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
-      archiveVendorPhoto(path.join(PHOTOS, fname), { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photo' });
+      const side = cleanSide(p.side || (meta && meta.side));
+      const sv = savePhotoB64(b64, dw_sku, side, 0);
+      if (!sv) return send(res, 500, { ok: false, err: 'could not save photo' });
+      archiveVendorPhoto(sv.file, { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photo' });
       const entry = progress[dw_sku] || {};
-      entry.done = true; entry.skipped = false; entry.photo = `/photos/${fname}`; entry.ts = new Date().toISOString();
+      entry.done = true; entry.skipped = false; entry.photo = sv.url; entry.ts = new Date().toISOString();
       entry.live = false; entry.live_reason = null; entry.created = false;
       const sheet = SHEET_BY_GRS[String(dw_sku).toUpperCase()];
       const existingPid = product_id || (CATALOG.find(x => (x.dw_sku || '').toUpperCase() === String(dw_sku).toUpperCase()) || {}).product_id;
@@ -2455,7 +2603,9 @@ load();
         status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts
       });
       saveRecents();
-      return send(res, 200, { ok: true, dw_sku, photo: entry.photo, created: entry.created, shopify_pushed: entry.shopify_pushed,
+      recordCapture({ source: 'photo', dw_sku, vendor: p.vendor || (meta && meta.vendor) || null, product_id: entry.product_id || existingPid || null,
+        title: (meta && meta.title) || null, ok: true, shopify_pushed: !!entry.shopify_pushed, photos: [{ side, url: sv.url }] });
+      return send(res, 200, { ok: true, dw_sku, photo: entry.photo, photos: [{ side, url: sv.url }], created: entry.created, shopify_pushed: entry.shopify_pushed,
         push_err: entry.push_err, live: entry.live, live_reason: entry.live_reason });
     });
     return;
@@ -2473,13 +2623,13 @@ load();
       if (!dw_sku || !product_id || !Array.isArray(dataUrls) || !dataUrls.length)
         return send(res, 400, { err: 'dw_sku + product_id + dataUrls[] required' });
       const urls = dataUrls.slice(0, 12); // cap a single batch
-      let added = 0; const errors = []; let lastPhoto = null;
+      const sides = Array.isArray(p.sides) ? p.sides.map(cleanSide) : urls.map(() => 'photo');
+      let added = 0; const errors = []; let lastPhoto = null; const saved = [];
       for (let i = 0; i < urls.length; i++) {
         const b64 = await normB64(String(urls[i]).replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
-        const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
-        const fname = `${safe}-${Date.now()}-${i}.jpg`;
-        try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`;
-              archiveVendorPhoto(path.join(PHOTOS, fname), { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photos' }); } catch (e) {}
+        const sv = savePhotoB64(b64, dw_sku, sides[i] || 'photo', i);
+        if (sv) { lastPhoto = sv.url; saved.push({ side: sides[i] || 'photo', url: sv.url });
+          archiveVendorPhoto(sv.file, { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photos' }); }
         const r = await shopifyAppendImage(product_id, dw_sku, b64, `${Date.now()}-${i}`);
         if (r.ok) added++; else errors.push(r.err);
       }
@@ -2492,7 +2642,9 @@ load();
       recents[dw_sku] = cardRecord(dw_sku, meta, { product_id, image: entry.photo,
         status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts });
       saveRecents();
-      return send(res, 200, { ok: added > 0, added, total: urls.length, errors, live: entry.live, live_reason: entry.live_reason });
+      recordCapture({ source: 'update', dw_sku, vendor: p.vendor || (meta && meta.vendor) || null, product_id,
+        title: (meta && meta.title) || null, ok: added > 0, err: errors[0] || null, photos: saved });
+      return send(res, 200, { ok: added > 0, added, total: urls.length, errors, photos: saved, live: entry.live, live_reason: entry.live_reason });
     });
     return;
   }
@@ -2844,6 +2996,19 @@ function buildFmMaster(p, mint, vreg) {
 // Create a NEW item (arbitrary vendor) as a Shopify DRAFT + a dw_unified staging row + a REAL
 // FileMaker WALLPAPER master. Draft-only, never auto-published (going live is a separate gated step).
 // dryRun (default) returns a full 3-system preview and writes nothing.
+// TK-12228 test seam: stands in for createNewItem under --test ONLY (no Shopify / dw_unified / FileMaker).
+function testStubCreate(p, dryRun) {
+  const mfr = String(p.mfr || '').trim(), vendor = String(p.vendor || '').trim();
+  if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required — pick a vendor first' };
+  const dw = 'TEST-' + mfr.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
+  const preview = { title: `${p.name || mfr} | ${vendor}`, dw_sku: dw, mfr, vendor, vid: p.vid || null, color: p.color || '', price: null, status: 'draft',
+    photos: Array.isArray(p._photos64) ? p._photos64.length : 0, provisional_sku: false, duplicate_of: null,
+    shopify: { vendor, product_type: 'Wallcovering', status: 'draft', variants: [{ option1: 'Roll', sku: dw, price: '(quote)' }, { option1: 'Sample', sku: dw + '-Sample', price: '4.25' }] },
+    staging: { table: 'new_items_staging', dw_sku: dw, mfr_sku: mfr, vendor, vid: '', pattern_name: p.name || '', color: p.color || '', price: null, specs: {} },
+    filemaker: { db: 'TEST', layout: 'TEST', fieldData: {}, auto_calc: {} }, flags: ['TEST MODE — nothing written'] };
+  if (dryRun) return { ok: true, dryRun: true, preview };
+  return { ok: true, test_stub: true, product_id: null, dw_sku: dw, title: preview.title, status: 'draft', preview };
+}
 async function createNewItem(p, b64, dryRun) {
   const mfr = String(p.mfr || '').trim(); const vendor = String(p.vendor || '').trim();
   if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required — pick a vendor first' };
@@ -3448,8 +3613,9 @@ server.on('error', (e) => {
   console.error('[server.on error]', (e && e.stack) || e);
 });
 server.listen(PORT, '0.0.0.0', () => {
-  console.log(`DW Photo Capture on http://0.0.0.0:${PORT}  (Shopify push: ${TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
+  console.log(`DW Photo Capture on http://0.0.0.0:${PORT}  (Shopify push: ${TEST_MODE ? 'STUBBED (--test)' : TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
   rebuildIndex();              // sheet-only items searchable immediately
+  if (TEST_MODE) return;       // TK-12228: no catalog fetch / multi-hundred-MB cache write in test mode
   buildCatalog();              // load the WHOLE dw_unified catalog from the all-dw feed
   // keep the browsable catalog fresh (feed refreshes its own snapshot every 10 min)
   setInterval(() => buildCatalog().catch(() => {}), 15 * 60 * 1000);

← e82585f deploy: exclude .claude/ from rsync so agent worktree logs o  ·  back to Dw Photo Capture  ·  5x sweep 1: favicon on captures/batch/cam, consistent missin 15beb44 →