[object Object]

← back to Dw Photo Capture

5x sweep 1: favicon on captures/batch/cam, consistent missing-side rule, 3-engine e2e + lazy-img scroll

15beb44520ccf35424d2c9252e21794d4db0eb1f · 2026-09-25 10:03:22 -0700 · Steve Abrams

- captures/batch/cam: declare <link rel=icon> (was /favicon.ico 404 console error; captures new, batch/cam pre-existing)
- /api/captures missing_back + captures.html missing()/sort/.miss: one rule — a front/back item lacking a side;
  single-shot photo-only items (remote cam) no longer inflate the count (was 5 vs real 1)
- e2e: ENGINE=chromium|webkit|firefox with per-engine fake camera; scroll lazy thumbs into view before asserting
- 5x/tools: console probe, history scroll check, index toolbar clickthrough, missing-side regression check

TK-12228

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

Files touched

Diff

commit 15beb44520ccf35424d2c9252e21794d4db0eb1f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 10:03:22 2026 -0700

    5x sweep 1: favicon on captures/batch/cam, consistent missing-side rule, 3-engine e2e + lazy-img scroll
    
    - captures/batch/cam: declare <link rel=icon> (was /favicon.ico 404 console error; captures new, batch/cam pre-existing)
    - /api/captures missing_back + captures.html missing()/sort/.miss: one rule — a front/back item lacking a side;
      single-shot photo-only items (remote cam) no longer inflate the count (was 5 vs real 1)
    - e2e: ENGINE=chromium|webkit|firefox with per-engine fake camera; scroll lazy thumbs into view before asserting
    - 5x/tools: console probe, history scroll check, index toolbar clickthrough, missing-side regression check
    
    TK-12228
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P
---
 5x/{REPORT.md => archive/REPORT-TK-11947.md} |  0
 5x/sweep-1.md                                | 48 ++++++++++++++++++++++++++++
 5x/tools/captures-missing-check.cjs          | 30 +++++++++++++++++
 5x/tools/console-probe.cjs                   | 32 +++++++++++++++++++
 5x/tools/history-scroll-check.cjs            | 18 +++++++++++
 5x/tools/index-toolbar-click.cjs             | 35 ++++++++++++++++++++
 public/batch.html                            |  1 +
 public/cam.html                              |  1 +
 public/captures.html                         |  6 ++--
 scripts/e2e-front-back-display.cjs           | 19 +++++++++--
 server.js                                    |  3 +-
 11 files changed, 187 insertions(+), 6 deletions(-)

diff --git a/5x/REPORT.md b/5x/archive/REPORT-TK-11947.md
similarity index 100%
rename from 5x/REPORT.md
rename to 5x/archive/REPORT-TK-11947.md
diff --git a/5x/sweep-1.md b/5x/sweep-1.md
new file mode 100644
index 0000000..2ed68f9
--- /dev/null
+++ b/5x/sweep-1.md
@@ -0,0 +1,48 @@
+# /5x sweep 1 — TK-12228 front+back display (target d1a0b78, test server :9987 `--test`)
+
+Server log confirmed `Shopify push: STUBBED (--test)`; data/photos rebased to /tmp/dwp-e2e.
+
+## Verify
+| check | result |
+|---|---|
+| E2E front/back journey — Chromium (fake media) | **19/19 PASS** |
+| E2E front/back journey — WebKit 26.5 (Playwright mock capture device) | 18/19 — history thumbnail check FAIL (4 lazy imgs naturalWidth 0) |
+| E2E front/back journey — Firefox 151 (media.navigator.streams.fake) | 18/19 — history thumbnail check FAIL (11 lazy imgs naturalWidth 0) |
+| /3x `/` | M1 M2 M3 B4 PASS · B5/B6 SKIP (runner's playwright 1.57 has no WebKit/Firefox build) · clickthrough 10 pass / 7 FAIL |
+| /3x `/captures` | M3 FAIL (1 console error) · clickthrough 7/0 |
+| /3x `/batch` | M3 FAIL (1 console error) · clickthrough 8/0 |
+| /3x `/cam` | M2 FAIL (no screenshot) · M3 FAIL (1 console error) · clickthrough 5/0 |
+| console/pageerror/broken-img probe, 3 engines × 4 pages | clean except Firefox doc 401 (normal Basic-auth challenge → retried 200; not a defect) |
+
+## Caught → diagnosis
+1. **M3 console error on /captures, /batch, /cam = `GET /favicon.ico 404`.** Those pages declare no `<link rel="icon">`
+   (index.html does), so every browser requests /favicon.ico. `/captures` is NEW in d1a0b78; `/batch` + `/cam` are
+   PRE-EXISTING at 126f917 (verified `git show 126f917:public/{batch,cam}.html` → 0 icon links). Small + in-scope → fixed.
+2. **WebKit/Firefox history thumbs naturalWidth==0 = test artifact, not an app defect.** captures.html uses
+   `loading="lazy"`; WebKit/Firefox have small lazy-load margins, so off-screen thumbs are never fetched until scrolled.
+   Proven with 5x/tools/history-scroll-check.cjs: after scrolling each into view, 21/21 load in all 3 engines.
+   Fix = the e2e helper now scrolls each matched img into view BEFORE the (unchanged, still all-must-load) assertion.
+3. **/3x clickthrough `/` buttons 5–11 timeout = harness artifact.** `#homeScreen` is a by-design full-screen launcher
+   overlay covering the toolbar, so Playwright's actionability wait times out. Replaced with a targeted clickthrough
+   (5x/tools/index-toolbar-click.cjs: dismiss via "skip →", click each launcher tile + toolbar button, assert 0
+   errors) → **33/33 PASS across Chromium/WebKit/Firefox**. Pre-existing overlay; not a defect.
+4. **/3x M2 on /cam = environment/harness.** `/cam` holds an SSE stream (`/cam/events`, pre-existing); Chrome's
+   `--virtual-time-budget` never elapses while a request is pending, so the CLI screenshot never fires. Not an app
+   defect; Playwright screenshots of /cam render fine (E2E shot 09-cam-lastshot.png).
+   (Also noted: the first test-server process died silently mid-sweep — no stack in the log — coinciding with an
+   aborted `timeout`-wrapped Chrome command in the same shell; re-run under a managed background task it stayed up
+   for the rest of the loop. Treated as harness, logged for Cody.)
+5. **B5/B6 SKIP** — the 3x runner resolves Designer-Wallcoverings' playwright 1.57 whose WebKit/Firefox builds
+   aren't installed. Covered instead by the 3-engine probe + WebKit/Firefox E2E journeys (playwright 1.61).
+6. **Code-review find (new code): "missing a side" rule inconsistent between server and UI.** `/api/captures`
+   `missing_back` counted single-shot items (remote-cam `photo`-only) as "without a back photo", and the UI's
+   "Missing a side first" sort ranked them first, while the card itself was NOT flagged `.miss`. Aligned both to one
+   rule: a front/back item lacking one side; photo-only single shots never count.
+
+## Fixes (commit below)
+- public/captures.html, batch.html, cam.html: `<link rel="icon" href="/icon-192.png">` (+ apple-touch-icon on captures).
+- public/captures.html: `missing()` = has front or back but not both; `.miss` flag uses it.
+- server.js `/api/captures`: `missing_back` = has FRONT and no BACK.
+- scripts/e2e-front-back-display.cjs: `ENGINE=chromium|webkit|firefox` (each with its own fake-camera mechanism);
+  `imgs()` scrolls lazy images into view before asserting.
+- 5x/tools/: console-probe.cjs, history-scroll-check.cjs, index-toolbar-click.cjs (sweep harness).
diff --git a/5x/tools/captures-missing-check.cjs b/5x/tools/captures-missing-check.cjs
new file mode 100644
index 0000000..742d5bb
--- /dev/null
+++ b/5x/tools/captures-missing-check.cjs
@@ -0,0 +1,30 @@
+// Regression check for sweep-1 fix #6: a FRONT-only item counts as missing_back and its card is .miss;
+// a single-shot 'photo' item (remote cam) is neither counted nor flagged. Runs in each engine.
+const pw = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
+const BASE = process.env.BASE || 'http://127.0.0.1:9987';
+(async () => { let bad = 0;
+  const b0 = await pw.chromium.launch(); const c0 = await b0.newContext({ httpCredentials: { username: 'admin', password: 'DW2024!' } }); const p0 = await c0.newPage();
+  await p0.goto(BASE + '/healthz');
+  const jpg = await p0.evaluate(() => { const c = document.createElement('canvas'); c.width = 200; c.height = 150; const g = c.getContext('2d'); g.fillStyle = '#48c'; g.fillRect(0, 0, 200, 150); return c.toDataURL('image/jpeg', .9); });
+  const post = (u, body) => p0.evaluate(async ([u, body]) => (await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(), [u, body]);
+  const before = await p0.evaluate(async () => (await fetch('/api/captures?limit=2000')).json());
+  const r1 = await post('/api/photo', { dw_sku: 'FRONTONLY-1', dataUrl: jpg, side: 'front' });
+  const r2 = await post('/api/photo', { dw_sku: 'SINGLESHOT-1', dataUrl: jpg });
+  const after = await p0.evaluate(async () => (await fetch('/api/captures?limit=2000')).json());
+  const d = after.missing_back - before.missing_back;
+  console.log(`${d === 1 ? 'PASS' : 'FAIL'} missing_back delta = ${d} (want 1: front-only counts, photo-only does not)`); if (d !== 1) bad++;
+  await b0.close();
+  for (const e of ['chromium', 'webkit', 'firefox']) {
+    const b = await pw[e].launch(); const ctx = await b.newContext({ httpCredentials: { username: 'admin', password: 'DW2024!' } }); const p = await ctx.newPage();
+    await p.goto(BASE + '/captures'); await p.waitForSelector('.card');
+    const st = await p.evaluate(() => { const card = sku => [...document.querySelectorAll('.card')].find(c => c.querySelector('.sku').textContent.startsWith(sku));
+      const f = card('FRONTONLY-1'), s = card('SINGLESHOT-1');
+      return { frontMiss: f && f.classList.contains('miss'), frontGap: f && /no BACK/.test(f.textContent), singleMiss: s && s.classList.contains('miss') }; });
+    await p.selectOption('#sort', 'missing'); await p.waitForTimeout(200);
+    const firstNonMissIdx = await p.evaluate(() => { const cs = [...document.querySelectorAll('.card')]; const i = cs.findIndex(c => !c.classList.contains('miss')); const j = cs.map(c => c.classList.contains('miss')).lastIndexOf(true); return { i, j }; });
+    const ok = st.frontMiss && st.frontGap && st.singleMiss === false && (firstNonMissIdx.j < firstNonMissIdx.i || firstNonMissIdx.j === -1);
+    console.log(`${ok ? 'PASS' : 'FAIL'} ${e}: ${JSON.stringify(st)} sort=missing groups flagged first ${JSON.stringify(firstNonMissIdx)}`); if (!ok) bad++;
+    await p.evaluate(() => localStorage.clear()); await b.close();
+  }
+  process.exit(bad ? 1 : 0);
+})();
diff --git a/5x/tools/console-probe.cjs b/5x/tools/console-probe.cjs
new file mode 100644
index 0000000..02c13dd
--- /dev/null
+++ b/5x/tools/console-probe.cjs
@@ -0,0 +1,32 @@
+// Load each page in chromium/webkit/firefox, report console errors, pageerrors, failed requests, broken imgs.
+const pwPath = process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright';
+const pw = require(pwPath);
+const BASE = process.env.BASE || 'http://127.0.0.1:9987';
+const PATHS = (process.env.PATHS || '/,/captures,/batch,/cam').split(',');
+const ENGINES = (process.env.ENGINES || 'chromium,webkit,firefox').split(',');
+(async () => {
+  const out = {}; let bad = 0;
+  for (const e of ENGINES) {
+    const b = await pw[e].launch();
+    for (const p of PATHS) {
+      const ctx = await b.newContext({ httpCredentials: { username: 'admin', password: 'DW2024!' } });
+      const page = await ctx.newPage();
+      const errs = [], reqf = [];
+      page.on('console', m => m.type() === 'error' && errs.push('console: ' + m.text() + ' @' + (m.location().url||'') + ':' + m.location().lineNumber));
+      page.on('pageerror', x => errs.push('pageerror: ' + x.message));
+      page.on('requestfailed', r => reqf.push(r.url() + ' ' + (r.failure() && r.failure().errorText)));
+      page.on('response', r => { if (r.status() >= 400) reqf.push(r.status() + ' ' + r.url()); });
+      await page.goto(BASE + p, { waitUntil: 'load', timeout: 20000 }).catch(x => errs.push('goto: ' + x.message));
+      await page.waitForTimeout(2500);
+      const imgs = await page.$$eval('img', l => l.filter(i => i.complete && i.getAttribute('src') && !i.closest('[hidden]') && i.naturalWidth === 0).map(i => i.src.slice(0, 100))).catch(() => []);
+      const r = { errs, reqf, brokenImgs: imgs };
+      out[e + ' ' + p] = r;
+      const n = errs.length + reqf.length + imgs.length; bad += n;
+      console.log(`${n ? 'BAD ' : 'OK  '} ${e.padEnd(8)} ${p.padEnd(10)} ${n ? JSON.stringify(r) : ''}`);
+      await ctx.close();
+    }
+    await b.close();
+  }
+  if (process.env.JSON) require('fs').writeFileSync(process.env.JSON, JSON.stringify(out, null, 2));
+  process.exit(bad ? 1 : 0);
+})();
diff --git a/5x/tools/history-scroll-check.cjs b/5x/tools/history-scroll-check.cjs
new file mode 100644
index 0000000..e869f9a
--- /dev/null
+++ b/5x/tools/history-scroll-check.cjs
@@ -0,0 +1,18 @@
+// Scroll every /captures thumbnail into view (lazy-loaded) in each engine, then assert naturalWidth>0.
+const pw = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
+const BASE = process.env.BASE || 'http://127.0.0.1:9987';
+(async () => { let bad = 0;
+  for (const e of (process.env.ENGINES || 'chromium,webkit,firefox').split(',')) {
+    const b = await pw[e].launch(); const ctx = await b.newContext({ httpCredentials: { username: 'admin', password: 'DW2024!' }, viewport: { width: 430, height: 932 } });
+    const p = await ctx.newPage(); const errs = []; p.on('pageerror', x => errs.push(x.message));
+    await p.goto(BASE + '/captures', { waitUntil: 'load' }); await p.waitForSelector('.card');
+    const n = await p.locator('.card .pair img').count();
+    for (let i = 0; i < n; i++) { await p.locator('.card .pair img').nth(i).scrollIntoViewIfNeeded(); }
+    await p.waitForFunction(() => [...document.querySelectorAll('.card .pair img')].every(i => i.complete), null, { timeout: 15000 }).catch(() => {});
+    const r = await p.$$eval('.card .pair img', l => l.map(i => ({ w: i.naturalWidth, src: i.currentSrc.slice(-50) })));
+    const broken = r.filter(x => !x.w); bad += broken.length + errs.length;
+    console.log(`${broken.length || errs.length ? 'FAIL' : 'PASS'} ${e}: ${r.length - broken.length}/${r.length} history thumbs loaded after scroll${broken.length ? ' broken=' + JSON.stringify(broken) : ''}${errs.length ? ' errs=' + JSON.stringify(errs) : ''}`);
+    await b.close();
+  }
+  process.exit(bad ? 1 : 0);
+})();
diff --git a/5x/tools/index-toolbar-click.cjs b/5x/tools/index-toolbar-click.cjs
new file mode 100644
index 0000000..fdb2f3a
--- /dev/null
+++ b/5x/tools/index-toolbar-click.cjs
@@ -0,0 +1,35 @@
+// index.html: the #homeScreen launcher is a modal overlay that (by design) covers the toolbar, so the
+// generic /3x clickthrough times out on every toolbar button. Here: for each engine, for each launcher
+// tile and each toolbar button — fresh load, dismiss launcher via "skip →" where needed, click, assert
+// no pageerror/console error, Escape, next.
+const pw = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
+const BASE = process.env.BASE || 'http://127.0.0.1:9987';
+const LAUNCH = { chromium: { args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] }, webkit: {}, firefox: { firefoxUserPrefs: { 'media.navigator.streams.fake': true, 'media.navigator.permission.disabled': true } } };
+(async () => { let fails = 0;
+  for (const e of (process.env.ENGINES || 'chromium,webkit,firefox').split(',')) {
+    const b = await pw[e].launch(LAUNCH[e]);
+    const ctx = await b.newContext(Object.assign({ httpCredentials: { username: 'admin', password: 'DW2024!' }, viewport: { width: 430, height: 932 } }, e === 'chromium' ? { permissions: ['camera', 'microphone'] } : {}));
+    const targets = [['#homeScreen [data-act="add"]', false], ['#homeScreen [data-act="update"]', false], ['#homeScreen [data-act="lookup"]', false], ['#homeScreen [data-act="check"]', false],
+      ['#homeBtn', true], ['#scanBtn', true], ['#micBtn', true], ['#camBtn', true], ['#simBtn', true], ['#fbBtn', true], ['#addBtn', true]];
+    for (const [sel, skipHome] of targets) {
+      const p = await ctx.newPage(); const errs = [];
+      p.on('pageerror', x => errs.push('pageerror: ' + x.message));
+      p.on('console', m => { if (m.type() === 'error' && !/favicon/.test(m.location().url || '')) errs.push('console: ' + m.text()); });
+      p.on('dialog', d => d.dismiss().catch(() => {}));
+      p.setDefaultTimeout(6000);
+      let res = 'PASS', why = '';
+      try {
+        await p.goto(BASE + '/', { waitUntil: 'load' });
+        if (skipHome) { const s = p.locator('#homeScreen button', { hasText: 'skip' }); if (await s.count()) await s.first().click(); }
+        if (!(await p.locator(sel).count())) { res = 'SKIP'; why = 'not present'; }
+        else { await p.locator(sel).first().click(); await p.waitForTimeout(1200); await p.keyboard.press('Escape'); await p.waitForTimeout(300); }
+      } catch (x) { res = 'FAIL'; why = x.message.split('\n')[0]; }
+      if (res === 'PASS' && errs.length) { res = 'FAIL'; why = JSON.stringify(errs); }
+      if (res === 'FAIL') fails++;
+      console.log(`[${res}] ${e.padEnd(8)} ${sel}${why ? ' — ' + why : ''}`);
+      await p.close();
+    }
+    await b.close();
+  }
+  console.log(`\nINDEX-TOOLBAR → ${fails} failed`); process.exit(fails ? 1 : 0);
+})();
diff --git a/public/batch.html b/public/batch.html
index 262762d..4be2d4e 100644
--- a/public/batch.html
+++ b/public/batch.html
@@ -33,6 +33,7 @@
 <meta name="apple-mobile-web-app-title" content="DW Batch">
 <meta name="theme-color" content="#0f0e0c">
 <link rel="apple-touch-icon" href="/apple-touch-icon.png">
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
 <title>DW Batch Shoot</title>
 <style>
   :root{ --ui-scale:1.35; /* "make all larger" — single dial for control/label size (1 = original). */
diff --git a/public/cam.html b/public/cam.html
index a91d140..90da533 100644
--- a/public/cam.html
+++ b/public/cam.html
@@ -33,6 +33,7 @@
 <meta name="apple-mobile-web-app-title" content="DW Cam">
 <meta name="theme-color" content="#0f0e0c">
 <link rel="apple-touch-icon" href="/apple-touch-icon.png">
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
 <title>DW Remote Cam</title>
 <style>
   :root{ --ui-scale:1.35; /* "make all larger" — single dial for control/label size (1 = original). */
diff --git a/public/captures.html b/public/captures.html
index adf1974..fb273a5 100644
--- a/public/captures.html
+++ b/public/captures.html
@@ -2,6 +2,7 @@
 <html lang="en"><head>
 <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
 <title>Capture History · DW</title>
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png"><link rel="apple-touch-icon" href="/apple-touch-icon.png">
 <!-- 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. -->
@@ -65,7 +66,8 @@ 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');
+// a FRONT/BACK item that lacks one side; single-shot surfaces (remote cam, side 'photo' only) never have a pair
+const missing = c => { const f = side(c,'front'), b = side(c,'back'); return !!(f || b) && !(f && b); };
 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){
@@ -78,7 +80,7 @@ function card(c){
   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}
+  return `<div class="card${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>`;
diff --git a/scripts/e2e-front-back-display.cjs b/scripts/e2e-front-back-display.cjs
index 1a6c0fb..9bac259 100644
--- a/scripts/e2e-front-back-display.cjs
+++ b/scripts/e2e-front-back-display.cjs
@@ -4,7 +4,16 @@
 //   (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 PWMOD = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
+// ENGINE=chromium (default) | webkit | firefox — each with its own fake-camera mechanism:
+//   chromium: --use-fake-device-for-media-stream; webkit: Playwright's built-in mock capture device;
+//   firefox: media.navigator.streams.fake pref. Only chromium understands the 'camera' permission.
+const ENGINE = process.env.ENGINE || 'chromium';
+const LAUNCH = {
+  chromium: { headless: true, args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] },
+  webkit: { headless: true },
+  firefox: { headless: true, firefoxUserPrefs: { 'media.navigator.streams.fake': true, 'media.navigator.permission.disabled': true } },
+}[ENGINE];
 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');
@@ -15,14 +24,18 @@ 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) {
+  // history thumbs are loading="lazy": WebKit/Firefox (small lazy margins) never fetch off-screen ones,
+  // so bring each into view first — the assertion below still requires EVERY one to have real pixels.
+  for (const h of await page.$$(sel)) await h.scrollIntoViewIfNeeded().catch(() => {});
   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 browser = await PWMOD[ENGINE].launch(LAUNCH);
+  const ctx = await browser.newContext(Object.assign({ httpCredentials: AUTH, viewport: { width: 430, height: 932 } }, ENGINE === 'chromium' ? { permissions: ['camera', 'microphone'] } : {}));
+  console.log('engine:', ENGINE, browser.version());
   const page = await ctx.newPage();
   page.on('pageerror', e => console.log('[pageerror]', e.message));
 
diff --git a/server.js b/server.js
index cd6bfb6..4abf2cb 100644
--- a/server.js
+++ b/server.js
@@ -1561,7 +1561,8 @@ const appHandler = (req, res) => {
   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;
+    // front/back items with no BACK (single-shot remote-cam/legacy 'photo'-only items have no pair to miss — same rule as captures.html)
+    const missingBack = all.filter(c => c.photos.some(p => p.side === 'front') && !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) });
   }
 

← d1a0b78 TK-12228: every captured photo, front and back, displays in  ·  back to Dw Photo Capture  ·  deploy: exclude tracked schu-publish-audit.jsonl (identical b49fdf9 →