[object Object]

← back to Dw Photo Capture

TK-12090/TK-12162: fix numeric-SKU .trim() crash + de-obfuscate deploy verifier

03f509f2d48df6d76d9dd9eeec3366d0bd5e492e · 2026-09-24 13:31:39 -0700 · Steve Abrams

- simple-minimal.html, simple-wizard.html: fields.mfr_sku from Gemini's JSON
  can come back as a number, not a string; (fields.mfr_sku || '').trim()
  throws TypeError in that case, silently breaking the capture flow. Coerce
  to string first (matches the safe pattern already used in
  simple-probooth.html's fillIfEmpty).
- simple-minimal.html: surface the server's read-error message on the
  manual-entry fallback card instead of a generic string.
- STEVE-PASTE-tk12090-deploy2.sh: replace the base64-encoded inline Python
  verifier with a checked-in, readable script (scripts/verify-vision.py),
  piped over ssh stdin. The script now refuses to run with blank
  AUTH_USER/AUTH_PASS instead of silently sending an empty Basic Auth header.

Reviewed and code-reviewed; withdrew two other findings (restart-verification
alarm was moot — set -e + && already aborts on a dead /healthz; the 200ms
'timeout' in the Python verifier is actually 200 seconds, not milliseconds).

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

Files touched

Diff

commit 03f509f2d48df6d76d9dd9eeec3366d0bd5e492e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 13:31:39 2026 -0700

    TK-12090/TK-12162: fix numeric-SKU .trim() crash + de-obfuscate deploy verifier
    
    - simple-minimal.html, simple-wizard.html: fields.mfr_sku from Gemini's JSON
      can come back as a number, not a string; (fields.mfr_sku || '').trim()
      throws TypeError in that case, silently breaking the capture flow. Coerce
      to string first (matches the safe pattern already used in
      simple-probooth.html's fillIfEmpty).
    - simple-minimal.html: surface the server's read-error message on the
      manual-entry fallback card instead of a generic string.
    - STEVE-PASTE-tk12090-deploy2.sh: replace the base64-encoded inline Python
      verifier with a checked-in, readable script (scripts/verify-vision.py),
      piped over ssh stdin. The script now refuses to run with blank
      AUTH_USER/AUTH_PASS instead of silently sending an empty Basic Auth header.
    
    Reviewed and code-reviewed; withdrew two other findings (restart-verification
    alarm was moot — set -e + && already aborts on a dead /healthz; the 200ms
    'timeout' in the Python verifier is actually 200 seconds, not milliseconds).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD
---
 public/simple-minimal.html             | 421 ++++++++++++++++++++++++++
 public/simple-wizard.html              | 537 +++++++++++++++++++++++++++++++++
 scripts/STEVE-PASTE-tk12090-deploy2.sh |  10 +
 scripts/STEVE-PASTE-vision-step3.sh    |  16 +
 scripts/verify-vision.py               |  35 +++
 5 files changed, 1019 insertions(+)

diff --git a/public/simple-minimal.html b/public/simple-minimal.html
new file mode 100644
index 0000000..defb99c
--- /dev/null
+++ b/public/simple-minimal.html
@@ -0,0 +1,421 @@
+<!doctype html>
+<html lang="en">
+<head>
+<script>
+// Credential-safe fetch guard (fleet drop-in, mandatory on every Basic-Auth-gated DW page) — if this
+// page is opened with credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
+// https://user:pass@host/…), the browser poisons document.baseURI, so a relative fetch('/api/…')
+// throws "Request cannot be constructed from a URL that includes credentials" and callers silently
+// fall to an empty state. Resolve every non-absolute request URL against the credential-free
+// location instead. Placed first so it wraps window.fetch before any app script runs.
+// Ref: creds-in-url-fetch-guard-fleet-pattern.
+(function () {
+  var _fetch = window.fetch.bind(window);
+  var cleanBase = function () { return location.origin + location.pathname; };
+  window.fetch = function (input, init) {
+    try {
+      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
+        input = new URL(input, cleanBase()).href;
+      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
+        input = new Request(new URL(input.url, cleanBase()).href, input);
+      }
+    } catch (_) { /* fall through to native */ }
+    return _fetch(input, init);
+  };
+})();
+</script>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<meta name="apple-mobile-web-app-title" content="DW Photo">
+<meta name="mobile-web-app-capable" content="yes">
+<meta name="theme-color" content="#0f0e0c">
+<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
+<title>DW Photo — Minimal Showroom</title>
+<style>
+  :root{ --bg:#0f0e0c; --panel:#1b1710; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184;
+         --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
+  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
+  html,body{height:100%;overscroll-behavior:none}
+  body{margin:0;background:#000;color:var(--ink);font:16px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif;
+    -webkit-text-size-adjust:100%;user-select:none}
+
+  /* ============ STAGE — full-bleed camera / captured photo, zero chrome ============ */
+  #stage{position:fixed;inset:0;background:#000;overflow:hidden}
+  #cam{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000}
+  #cam.mirror{transform:scaleX(-1)}
+  #shot{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;opacity:0;pointer-events:none;transition:opacity .15s}
+  #shot.on{opacity:1}
+  #flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:40}
+  #flash.on{opacity:.9;transition:opacity .35s ease-out}
+
+  /* Vignette so the shutter/gear always read regardless of what the camera sees */
+  #stage::before{content:'';position:absolute;left:0;right:0;bottom:0;height:34%;z-index:5;pointer-events:none;
+    background:linear-gradient(180deg,transparent,rgba(0,0,0,.55) 70%)}
+  #stage::after{content:'';position:absolute;left:0;right:0;top:0;height:14%;z-index:5;pointer-events:none;
+    background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
+
+  /* ============ GEAR — the ONE tucked-away affordance, top-right corner ============ */
+  #gear{position:absolute;top:calc(env(safe-area-inset-top,0px) + 14px);right:calc(env(safe-area-inset-right,0px) + 14px);
+    z-index:30;width:44px;height:44px;border-radius:50%;border:1px solid rgba(255,255,255,.22);
+    background:rgba(15,14,12,.5);backdrop-filter:blur(6px);color:rgba(255,255,255,.75);font-size:20px;
+    display:flex;align-items:center;justify-content:center;cursor:pointer}
+  #gear:active{background:rgba(15,14,12,.75)}
+
+  #drawer{position:absolute;top:calc(env(safe-area-inset-top,0px) + 64px);right:calc(env(safe-area-inset-right,0px) + 14px);
+    z-index:30;width:min(78vw,300px);background:var(--panel);border:1px solid var(--line);border-radius:16px;
+    box-shadow:0 10px 34px rgba(0,0,0,.6);padding:14px;display:none;gap:12px;flex-direction:column}
+  #drawer.open{display:flex}
+  #drawer .d-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
+  #drawer .d-lbl{font-size:13px;color:var(--muted);font-weight:600}
+  #drawer button.d-btn{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:9px;
+    padding:9px 12px;font-size:13px;font-weight:600;cursor:pointer}
+  #drawer button.d-btn.on{border-color:var(--gold);color:var(--gold)}
+  #drawer .d-hd{font:800 13px/1 "SF Pro Display",sans-serif;color:var(--gold);letter-spacing:.03em;text-transform:uppercase;margin-bottom:2px}
+  /* toggle switch */
+  .sw{position:relative;width:44px;height:26px;border-radius:99px;background:#3a352c;border:1px solid var(--line);cursor:pointer;flex:0 0 auto}
+  .sw i{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:var(--muted);transition:left .15s,background .15s}
+  .sw.on{background:#4a3e22}
+  .sw.on i{left:20px;background:var(--gold)}
+
+  /* ============ SHUTTER — the whole interaction, bottom-center, thumb-reachable ============ */
+  #shutterWrap{position:absolute;left:0;right:0;bottom:0;z-index:20;display:flex;justify-content:center;
+    padding-bottom:calc(max(20px,env(safe-area-inset-bottom)) + 22px)}
+  #shutter{width:132px;height:132px;border-radius:50%;border:6px solid var(--gold);background:var(--gold);
+    box-shadow:0 0 0 4px rgba(0,0,0,.4),0 6px 24px rgba(0,0,0,.5);cursor:pointer;flex:0 0 auto;
+    transition:transform .08s ease}
+  #shutter:active{transform:scale(.93)}
+  #shutter:disabled{background:#3a3428;border-color:#3a3428;opacity:.7}
+  #shutter[hidden]{display:none}
+
+  /* ============ PROCESSING — pulsing gold status, no other chrome ============ */
+  #procWrap{position:absolute;left:0;right:0;bottom:0;z-index:25;display:none;flex-direction:column;
+    align-items:center;gap:14px;padding-bottom:calc(max(20px,env(safe-area-inset-bottom)) + 40px)}
+  #procWrap.on{display:flex}
+  .pulse-dot{width:14px;height:14px;border-radius:50%;background:var(--gold);box-shadow:0 0 16px 4px rgba(200,162,74,.7);
+    animation:pulse 1.1s ease-in-out infinite}
+  @keyframes pulse{0%,100%{transform:scale(.8);opacity:.55}50%{transform:scale(1.15);opacity:1}}
+  #procMsg{color:#f0e2b8;font:700 16px/1 -apple-system,sans-serif;text-shadow:0 1px 3px #000;letter-spacing:.01em}
+  #procTime{color:var(--muted);font:600 12px/1 ui-monospace,Menlo,monospace}
+  #procTime[hidden]{display:none}
+
+  /* ============ RESULT — one card, one action ============ */
+  #resultWrap{position:absolute;inset:0;z-index:26;display:none;align-items:flex-end;justify-content:center;
+    padding:0 16px calc(max(20px,env(safe-area-inset-bottom)) + 22px)}
+  #resultWrap.on{display:flex}
+  .rcard{width:100%;max-width:520px;background:var(--panel);border:1px solid var(--line);border-radius:20px;
+    padding:20px;box-shadow:0 14px 40px rgba(0,0,0,.6);display:flex;flex-direction:column;gap:14px;
+    animation:riseIn .22s ease-out}
+  @keyframes riseIn{from{transform:translateY(18px);opacity:0}to{transform:translateY(0);opacity:1}}
+  .rcard.ok .rc-check{color:var(--green)}
+  .rcard.err .rc-check{color:var(--red)}
+  .rc-check{font-size:34px;line-height:1}
+  .rc-title{font:800 19px/1.25 "SF Pro Display",sans-serif;color:var(--ink)}
+  .rc-sub{font:600 13px/1.4 ui-monospace,Menlo,monospace;color:var(--gold)}
+  .rc-detail{font-size:13px;color:var(--muted)}
+  .rc-time{font:600 11px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
+  .rc-time[hidden]{display:none}
+  .rc-again{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:16px;
+    font-size:16px;font-weight:800;cursor:pointer;min-height:56px}
+  .rc-manual{display:none;flex-direction:column;gap:10px}
+  .rc-manual.on{display:flex}
+  .rc-manual select,.rc-manual input{width:100%;background:#16140f;border:1px solid var(--line);color:var(--ink);
+    border-radius:10px;padding:13px 12px;font-size:15px;min-height:48px}
+  .rc-manual select.need,.rc-manual input.need{border-color:var(--red);box-shadow:0 0 0 3px rgba(192,86,63,.3)}
+  .rc-save{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:13px;padding:14px;
+    font-size:15px;font-weight:700;cursor:pointer;min-height:50px}
+
+  /* ============ PERMISSION / ERROR full-screen fallback ============ */
+  #permWrap{position:fixed;inset:0;z-index:60;background:var(--bg);display:none;flex-direction:column;
+    align-items:center;justify-content:center;gap:16px;text-align:center;padding:24px}
+  #permWrap.on{display:flex}
+  #permWrap h1{font:800 20px/1.3 "SF Pro Display",sans-serif;color:var(--gold);margin:0}
+  #permWrap p{color:var(--muted);margin:0;max-width:320px;font-size:14px}
+  #permRetry{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:14px 26px;
+    font-size:15px;font-weight:800;cursor:pointer;min-height:52px}
+</style>
+</head>
+<body>
+
+<div id="stage">
+  <video id="cam" autoplay playsinline muted></video>
+  <img id="shot" alt="">
+  <div id="flash"></div>
+</div>
+
+<button id="gear" aria-label="Settings" title="Settings">⚙</button>
+<div id="drawer">
+  <div class="d-hd">Camera</div>
+  <div class="d-row"><span class="d-lbl">Flip camera</span><button class="d-btn" id="btnFlip">↺ Flip</button></div>
+  <div class="d-hd" style="margin-top:6px">Behavior</div>
+  <div class="d-row"><span class="d-lbl">Auto-create on read</span><div class="sw on" id="swAuto"><i></i></div></div>
+  <div class="d-row"><span class="d-lbl">Show timing</span><div class="sw" id="swTiming"><i></i></div></div>
+  <div class="d-hd" style="margin-top:6px">Vendor override</div>
+  <select id="vendorOverride"><option value="">Auto-detect from label</option></select>
+</div>
+
+<div id="shutterWrap"><button id="shutter" aria-label="Capture"></button></div>
+
+<div id="procWrap">
+  <div class="pulse-dot"></div>
+  <div id="procMsg">Reading label…</div>
+  <div id="procTime" hidden></div>
+</div>
+
+<div id="resultWrap">
+  <div class="rcard" id="rcard">
+    <div class="rc-check" id="rcIcon">✓</div>
+    <div class="rc-title" id="rcTitle">Created</div>
+    <div class="rc-sub" id="rcSku"></div>
+    <div class="rc-detail" id="rcDetail"></div>
+    <div class="rc-time" id="rcTime" hidden></div>
+    <div class="rc-manual" id="rcManual">
+      <select id="mVendor"><option value="">Pick vendor…</option></select>
+      <input id="mMfr" type="text" placeholder="Manufacturer # / SKU" autocapitalize="characters">
+      <button class="rc-save" id="rcSave">Save</button>
+    </div>
+    <button class="rc-again" id="rcAgain">Take Another</button>
+  </div>
+</div>
+
+<div id="permWrap">
+  <h1>Camera access needed</h1>
+  <p id="permMsg">Allow camera access to start photographing samples.</p>
+  <button id="permRetry">Try Again</button>
+</div>
+
+<script>
+(function(){
+  'use strict';
+
+  // ── DOM ──────────────────────────────────────────────────────────────────
+  var $ = function(id){ return document.getElementById(id); };
+  var camEl = $('cam'), shotEl = $('shot'), flashEl = $('flash');
+  var gearBtn = $('gear'), drawer = $('drawer');
+  var shutterWrap = $('shutterWrap'), shutterBtn = $('shutter');
+  var procWrap = $('procWrap'), procMsg = $('procMsg'), procTime = $('procTime');
+  var resultWrap = $('resultWrap'), rcard = $('rcard'), rcIcon = $('rcIcon'), rcTitle = $('rcTitle'),
+      rcSku = $('rcSku'), rcDetail = $('rcDetail'), rcTime = $('rcTime'), rcAgain = $('rcAgain');
+  var rcManual = $('rcManual'), mVendor = $('mVendor'), mMfr = $('mMfr'), rcSave = $('rcSave');
+  var permWrap = $('permWrap'), permMsg = $('permMsg'), permRetry = $('permRetry');
+  var vendorOverrideSel = $('vendorOverride');
+  var swAuto = $('swAuto'), swTiming = $('swTiming');
+
+  // ── state ────────────────────────────────────────────────────────────────
+  var STATE = 'camera';          // camera | processing | result
+  var stream = null, facing = 'environment';
+  var AUTO_CREATE = true, SHOW_TIMING = false;
+  var VENDORS = [];              // warmed speculatively, used only for the manual fallback
+  var capturedDataUrl = null;
+  var lastExtractFields = null;
+
+  // ── SPEED: warm the vendor registry in the background the moment the page loads.
+  // This is pure overlapped/speculative work — it costs nothing on the critical capture
+  // path (nothing waits on it) and means that IF extraction fails to resolve a vendor,
+  // the manual-fallback dropdown is already populated with zero added latency instead of
+  // firing a fresh GET at that moment. ──────────────────────────────────────────────────
+  function warmVendors(){
+    fetch('/api/vendors-registry').then(function(r){ return r.json(); }).then(function(d){
+      VENDORS = (d && d.vendors) || [];
+      var fill = function(sel, withAuto){
+        var html = withAuto ? '<option value="">Auto-detect from label</option>' : '<option value="">Pick vendor…</option>';
+        html += VENDORS.map(function(v){
+          var name = v.vendor || v.real_vendor || '';
+          return name ? '<option value="'+esc(name)+'">'+esc(name)+'</option>' : '';
+        }).join('');
+        sel.innerHTML = html;
+      };
+      fill(vendorOverrideSel, true);
+      fill(mVendor, false);
+    }).catch(function(){ /* non-fatal — manual fallback still works via free-text mfr */ });
+  }
+  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
+
+  // ── camera ───────────────────────────────────────────────────────────────
+  function startCamera(){
+    permWrap.classList.remove('on');
+    if (stream) { stream.getTracks().forEach(function(t){ t.stop(); }); stream = null; }
+    var constraints = { audio:false, video:{ facingMode:{ ideal:facing }, width:{ideal:1920}, height:{ideal:1080} } };
+    navigator.mediaDevices.getUserMedia(constraints).then(function(s){
+      stream = s; camEl.srcObject = s; camEl.classList.toggle('mirror', facing==='user');
+    }).catch(function(e){
+      // fall back to ANY camera before giving up entirely
+      navigator.mediaDevices.getUserMedia({ audio:false, video:true }).then(function(s){
+        stream = s; camEl.srcObject = s;
+      }).catch(function(e2){
+        permMsg.textContent = (e2 && e2.name==='NotAllowedError')
+          ? 'Camera permission was denied. Enable it in Settings and try again.'
+          : 'Could not start the camera: ' + (e2 && e2.message || 'unknown error');
+        permWrap.classList.add('on');
+      });
+    });
+  }
+  permRetry.addEventListener('click', startCamera);
+
+  // ── gear drawer ──────────────────────────────────────────────────────────
+  gearBtn.addEventListener('click', function(){ drawer.classList.toggle('open'); });
+  document.addEventListener('click', function(e){
+    if (drawer.classList.contains('open') && !drawer.contains(e.target) && e.target!==gearBtn) drawer.classList.remove('open');
+  });
+  $('btnFlip').addEventListener('click', function(){ facing = facing==='environment' ? 'user' : 'environment'; startCamera(); });
+  swAuto.addEventListener('click', function(){ AUTO_CREATE = !AUTO_CREATE; swAuto.classList.toggle('on', AUTO_CREATE); });
+  swTiming.addEventListener('click', function(){ SHOW_TIMING = !SHOW_TIMING; swTiming.classList.toggle('on', SHOW_TIMING);
+    procTime.hidden = !SHOW_TIMING; rcTime.hidden = !SHOW_TIMING; });
+
+  // ── state transitions ────────────────────────────────────────────────────
+  function toCamera(){
+    STATE = 'camera';
+    shotEl.classList.remove('on');
+    shutterWrap.style.display = ''; shutterBtn.hidden = false; shutterBtn.disabled = false;
+    procWrap.classList.remove('on');
+    resultWrap.classList.remove('on');
+    rcManual.classList.remove('on');
+    capturedDataUrl = null; lastExtractFields = null;
+  }
+  function toProcessing(msg){
+    STATE = 'processing';
+    shutterBtn.hidden = true;
+    procMsg.textContent = msg || 'Reading label…';
+    procWrap.classList.add('on');
+  }
+  function toResult(){
+    STATE = 'result';
+    procWrap.classList.remove('on');
+    resultWrap.classList.add('on');
+  }
+
+  // ── capture: SPEED — the frame is drawn + shown INSTANTLY (pure client-side canvas work,
+  // no network dependency), so the user sees "shot taken" with zero perceived latency. The
+  // live <video> stream is left running underneath (not stopped/renegotiated), so "Take
+  // Another" needs no getUserMedia round trip either — just hide the frozen frame. ────────
+  var shotCanvas = document.createElement('canvas');
+  function captureFrame(){
+    var w = camEl.videoWidth || 1280, h = camEl.videoHeight || 960;
+    shotCanvas.width = w; shotCanvas.height = h;
+    var ctx = shotCanvas.getContext('2d');
+    if (facing === 'user') { ctx.translate(w,0); ctx.scale(-1,1); }
+    ctx.drawImage(camEl, 0, 0, w, h);
+    return shotCanvas.toDataURL('image/jpeg', 0.85);
+  }
+
+  var tStart = 0;
+  function fmtMs(ms){ return ms < 1000 ? Math.round(ms)+'ms' : (ms/1000).toFixed(1)+'s'; }
+
+  shutterBtn.addEventListener('click', function(){
+    if (STATE !== 'camera') return;
+    tStart = performance.now();
+    flashEl.classList.remove('on'); void flashEl.offsetWidth; flashEl.classList.add('on');
+    capturedDataUrl = captureFrame();
+    shotEl.src = capturedDataUrl;
+    shotEl.classList.add('on');                       // instant visual feedback — no network wait
+    toProcessing('Reading label…');
+    runPipeline(capturedDataUrl);
+  });
+
+  // ── SPEED: the extract → create-item pipeline. /api/extract must return the vendor + mfr#
+  // before /api/create-item can be called (create-item hard-requires both), so — with the two
+  // endpoints as they exist today — these two calls cannot be parallelized from the client;
+  // extract's ~2.5–3s Gemini-vision round trip is the dominant, unavoidable cost of the FIRST
+  // exchange. What IS eliminated here vs. the full power-tool flow: the separate dry-run
+  // /api/create-item PREVIEW call (~290ms measured) that a review-UI would need — this page has
+  // no review step, so on a successful read it goes straight from extract's response into the
+  // REAL (committing) create-item call, cutting 3 sequential awaited round trips down to 2. ──
+  function runPipeline(dataUrl){
+    fetch('/api/extract', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ dataUrl: dataUrl }) })
+      .then(function(r){ return r.json(); })
+      .then(function(r){
+        var f = (r && r.fields) || {};
+        lastExtractFields = f;
+        var vendor = vendorOverrideSel.value || r.vendor_matched || '';
+        var mfr = String(f.mfr_sku == null ? '' : f.mfr_sku).trim();   // Gemini JSON may type a numeric SKU as a number
+        if (vendor && mfr && AUTO_CREATE) {
+          procMsg.textContent = 'Creating ' + vendor + ' item…';
+          createItem(dataUrl, vendor, mfr, f);
+        } else if (vendor && mfr && !AUTO_CREATE) {
+          showResult(true, { preview:true, vendor:vendor, mfr:mfr, fields:f });
+        } else {
+          showNeedsInfo(f, dataUrl, r && r.ok === false ? r.err : null);
+        }
+      })
+      .catch(function(e){
+        showResult(false, { err: 'Could not read the label — ' + (e && e.message || 'network error') });
+      });
+  }
+
+  function createItem(dataUrl, vendor, mfr, fields){
+    var payload = {
+      mfr: mfr, vendor: vendor, color: fields.color || '', material: fields.material || '',
+      collection: fields.collection || '', width: fields.width || '', roll_length: fields.roll_length || '',
+      repeat: fields.repeat || '', pattern_match: fields.pattern_match || '', substrate: fields.substrate || '',
+      how_sold: fields.how_sold || '', price: fields.price || '', price_code: fields.price_code || '',
+      photos: [dataUrl], back_present: false, commit: true
+    };
+    fetch('/api/create-item', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) })
+      .then(function(r){ return r.json(); })
+      .then(function(r){
+        if (r.duplicate) { showResult(false, { err: r.err, duplicate:true, mfr:mfr }); return; }
+        if (!r.ok) { showResult(false, { err: r.err || 'Create failed', mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); return; }
+        showResult(true, { dw_sku: r.dw_sku, title: r.title, vendor:vendor });
+      })
+      .catch(function(e){ showResult(false, { err: 'Network error creating item: ' + (e && e.message || ''), mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); });
+  }
+
+  function showNeedsInfo(fields, dataUrl, readErr){
+    var elapsed = performance.now() - tStart;
+    toResult();
+    rcard.className = 'rcard err';
+    rcIcon.textContent = '🏷';
+    rcTitle.textContent = "Couldn't auto-read the SKU";
+    rcSku.textContent = '';
+    rcDetail.textContent = (readErr ? 'Label read failed (' + readErr + '). ' : '') + 'Pick the vendor and type the mfr# to save this item.';
+    rcTime.hidden = !SHOW_TIMING; rcTime.textContent = 'read: ' + fmtMs(elapsed);
+    rcManual.classList.add('on');
+    mMfr.value = fields && fields.mfr_sku != null ? String(fields.mfr_sku) : '';
+    rcSave.onclick = function(){
+      var v = mVendor.value.trim(), m = mMfr.value.trim();
+      mVendor.classList.toggle('need', !v); mMfr.classList.toggle('need', !m);
+      if (!v || !m) return;
+      rcSave.disabled = true; rcSave.textContent = 'Saving…';
+      createItem(dataUrl, v, m, fields || {});
+    };
+  }
+
+  function showResult(ok, info){
+    var elapsed = performance.now() - tStart;
+    toResult();
+    rcManual.classList.remove('on');
+    rcSave.disabled = false; rcSave.textContent = 'Save';
+    rcTime.hidden = !SHOW_TIMING; rcTime.textContent = 'total: ' + fmtMs(elapsed);
+    procTime.hidden = !SHOW_TIMING;
+    if (ok && info.dw_sku) {
+      rcard.className = 'rcard ok';
+      rcIcon.textContent = '✓';
+      rcTitle.textContent = info.title || info.dw_sku;
+      rcSku.textContent = 'DW# ' + info.dw_sku + (info.vendor ? '  ·  ' + info.vendor : '');
+      rcDetail.textContent = 'Draft created — not live yet.';
+    } else if (ok && info.preview) {
+      rcard.className = 'rcard ok';
+      rcIcon.textContent = '✓';
+      rcTitle.textContent = 'Read OK (preview mode)';
+      rcSku.textContent = info.mfr + '  ·  ' + info.vendor;
+      rcDetail.textContent = 'Auto-create is off — nothing was saved.';
+    } else {
+      rcard.className = 'rcard err';
+      rcIcon.textContent = '✗';
+      rcTitle.textContent = info.duplicate ? 'Already in the catalog' : "Couldn't save";
+      rcSku.textContent = info.mfr || '';
+      rcDetail.textContent = info.err || 'Unknown error.';
+    }
+  }
+
+  rcAgain.addEventListener('click', toCamera);
+
+  // ── boot ─────────────────────────────────────────────────────────────────
+  warmVendors();
+  startCamera();
+})();
+</script>
+</body>
+</html>
diff --git a/public/simple-wizard.html b/public/simple-wizard.html
new file mode 100644
index 0000000..b04561f
--- /dev/null
+++ b/public/simple-wizard.html
@@ -0,0 +1,537 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>DW Sample Capture — Guided</title>
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<meta name="apple-mobile-web-app-title" content="DW Capture">
+<link rel="apple-touch-icon" href="/icon-180.png">
+<link rel="icon" href="/icon-192.png">
+<style>
+  /* ============================================================================================
+     GUIDED WIZARD — TK-12162 concept-guided-wizard
+     One focused job at a time. Numbered steps, one primary action per screen, large touch targets
+     (min 64px), large text (18px+ body / 24px+ headings), high contrast. "TSA PreCheck kiosk"
+     clarity, not "pro camera app" density. Built for a first-time / less-tech-savvy showroom guest.
+     ============================================================================================ */
+  :root{
+    --bg:#0b1220; --panel:#101a2c; --panel2:#16233a; --line:#26374f;
+    --fg:#f4f8fb; --dim:#9fb2c8; --accent:#22c3a6; --accent-d:#159a83;
+    --warn:#e0a53a; --danger:#e0563a; --radius:22px;
+    --safe-top:env(safe-area-inset-top,0px); --safe-bottom:env(safe-area-inset-bottom,0px);
+  }
+  *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
+  html,body{height:100%;}
+  body{
+    margin:0; background:var(--bg); color:var(--fg);
+    font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,Helvetica,Arial,sans-serif;
+    font-size:18px; line-height:1.4; overscroll-behavior:none; user-select:none;
+    display:flex; flex-direction:column; height:100dvh; width:100vw; overflow:hidden;
+  }
+  h1,h2,h3{margin:0; font-weight:800; letter-spacing:-0.01em;}
+  /* ---- progress rail (always visible, top) ---- */
+  #progress{
+    display:flex; align-items:center; justify-content:center; gap:10px;
+    padding:calc(14px + var(--safe-top)) 16px 14px;
+    background:var(--panel); border-bottom:1px solid var(--line); flex:0 0 auto; z-index:20;
+  }
+  .step-dot{
+    display:flex; align-items:center; gap:8px; opacity:.45; transition:opacity .25s;
+  }
+  .step-dot.active{opacity:1;}
+  .step-dot .num{
+    width:34px; height:34px; border-radius:50%; background:var(--panel2); border:2px solid var(--line);
+    display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:var(--dim);
+    transition:all .25s;
+  }
+  .step-dot.active .num{ background:var(--accent); border-color:var(--accent); color:#04231d; }
+  .step-dot.done .num{ background:var(--accent-d); border-color:var(--accent-d); color:#fff; }
+  .step-dot .lbl{ font-size:13px; font-weight:700; color:var(--dim); display:none; }
+  .step-dot.active .lbl{ color:var(--fg); }
+  @media (min-width:700px){ .step-dot .lbl{ display:inline; } }
+  .step-line{ width:22px; height:2px; background:var(--line); flex:0 0 auto; }
+  @media (min-width:700px){ .step-line{ width:40px; } }
+
+  /* ---- screens ---- */
+  #stage{ position:relative; flex:1 1 auto; min-height:0; }
+  .screen{
+    position:absolute; inset:0; display:none; flex-direction:column;
+    padding:24px 20px calc(24px + var(--safe-bottom)); text-align:center;
+  }
+  .screen.on{ display:flex; }
+  .screen h2{ font-size:26px; margin-bottom:8px; }
+  .screen .sub{ font-size:18px; color:var(--dim); margin-bottom:18px; max-width:560px; align-self:center; }
+
+  /* ---- camera stage ---- */
+  .cam-wrap{
+    position:relative; flex:1 1 auto; min-height:0; border-radius:var(--radius); overflow:hidden;
+    background:#000; margin:0 auto 18px; width:100%; max-width:680px;
+  }
+  #video{ width:100%; height:100%; object-fit:cover; display:block; background:#000; }
+  #snapCanvas{ display:none; }
+  .frame-guide{
+    position:absolute; inset:8%; border-radius:18px; pointer-events:none;
+    box-shadow:0 0 0 2000px rgba(0,0,0,.38);
+  }
+  .frame-guide .corner{ position:absolute; width:38px; height:38px; border:5px solid var(--accent); }
+  .frame-guide .tl{ top:-3px; left:-3px; border-right:none; border-bottom:none; border-top-left-radius:14px; }
+  .frame-guide .tr{ top:-3px; right:-3px; border-left:none; border-bottom:none; border-top-right-radius:14px; }
+  .frame-guide .bl{ bottom:-3px; left:-3px; border-right:none; border-top:none; border-bottom-left-radius:14px; }
+  .frame-guide .br{ bottom:-3px; right:-3px; border-left:none; border-top:none; border-bottom-right-radius:14px; }
+  .cam-hint{
+    position:absolute; left:0; right:0; bottom:14px; text-align:center;
+    font-size:16px; font-weight:700; color:#fff; text-shadow:0 1px 6px rgba(0,0,0,.7);
+  }
+  .countdown-num{
+    position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
+    font-size:140px; font-weight:900; color:#fff; text-shadow:0 4px 24px rgba(0,0,0,.6);
+    opacity:0; transform:scale(.7); transition:none;
+  }
+  .countdown-num.show{ animation:pop .78s ease-out; }
+  @keyframes pop{ 0%{opacity:0; transform:scale(.5);} 25%{opacity:1; transform:scale(1.08);} 70%{opacity:1; transform:scale(1);} 100%{opacity:0; transform:scale(.9);} }
+  .flash{ position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; }
+  .flash.go{ animation:flashfade .35s ease-out; }
+  @keyframes flashfade{ 0%{opacity:.9;} 100%{opacity:0;} }
+
+  /* ---- review photo ---- */
+  .review-wrap{
+    position:relative; flex:1 1 auto; min-height:0; border-radius:var(--radius); overflow:hidden;
+    background:#000; margin:0 auto 18px; width:100%; max-width:680px;
+    display:flex; align-items:center; justify-content:center;
+  }
+  #reviewImg{ width:100%; height:100%; object-fit:contain; display:block; }
+  .analyzing-pill{
+    position:absolute; top:14px; left:50%; transform:translateX(-50%);
+    background:rgba(11,18,32,.82); border:1px solid var(--line); border-radius:999px;
+    padding:8px 16px; font-size:14px; font-weight:700; color:var(--accent);
+    display:flex; align-items:center; gap:8px; opacity:0; transition:opacity .2s;
+  }
+  .analyzing-pill.show{ opacity:1; }
+  .dot-spin{ width:9px; height:9px; border-radius:50%; background:var(--accent); animation:blink 1s infinite ease-in-out; }
+  @keyframes blink{ 0%,100%{opacity:.25;} 50%{opacity:1;} }
+
+  /* ---- buttons ---- */
+  .actions{ display:flex; gap:14px; justify-content:center; flex-wrap:wrap; flex:0 0 auto; }
+  .actions.stack{ flex-direction:column; align-items:stretch; max-width:520px; margin:0 auto; width:100%; }
+  button{
+    font-family:inherit; border:none; border-radius:18px; cursor:pointer;
+    font-size:20px; font-weight:800; padding:22px 30px; min-height:70px; min-width:180px;
+    display:flex; align-items:center; justify-content:center; gap:10px;
+    transition:transform .08s ease, filter .15s ease; -webkit-user-select:none;
+  }
+  button:active{ transform:scale(.97); }
+  button:disabled{ opacity:.5; cursor:default; }
+  .btn-primary{ background:var(--accent); color:#04231d; box-shadow:0 8px 24px rgba(34,195,166,.28); }
+  .btn-primary:active{ filter:brightness(.95); }
+  .btn-secondary{ background:var(--panel2); color:var(--fg); border:2px solid var(--line); }
+  .btn-danger{ background:transparent; color:var(--dim); border:2px solid var(--line); font-size:17px; padding:16px 24px; min-height:56px; }
+  .btn-big{ font-size:24px; padding:28px 40px; min-height:84px; width:100%; max-width:420px; align-self:center; }
+
+  /* ---- done screen ---- */
+  .checkmark{
+    width:120px; height:120px; border-radius:50%; background:var(--accent); margin:0 auto 22px;
+    display:flex; align-items:center; justify-content:center; box-shadow:0 10px 30px rgba(34,195,166,.35);
+  }
+  .checkmark svg{ width:62px; height:62px; }
+  .done-card{
+    background:var(--panel2); border:1px solid var(--line); border-radius:18px; padding:20px 22px;
+    max-width:480px; margin:0 auto 22px; text-align:left; align-self:center; width:100%;
+  }
+  .done-card .row{ display:flex; justify-content:space-between; gap:14px; padding:8px 0; font-size:17px; }
+  .done-card .row + .row{ border-top:1px solid var(--line); }
+  .done-card .k{ color:var(--dim); font-weight:600; }
+  .done-card .v{ font-weight:800; text-align:right; }
+
+  /* ---- error / camera denied ---- */
+  .err-box{ background:rgba(224,86,58,.12); border:1px solid var(--danger); border-radius:16px; padding:18px 20px; max-width:520px; margin:0 auto 18px; align-self:center; }
+
+  /* ---- perf debug HUD (hidden unless ?debug=1) ---- */
+  #hud{
+    position:fixed; right:8px; bottom:calc(8px + var(--safe-bottom)); z-index:999;
+    background:rgba(0,0,0,.82); color:#7CFC9C; font:11px/1.5 ui-monospace,Menlo,monospace;
+    padding:8px 10px; border-radius:10px; max-width:280px; display:none; white-space:pre;
+    pointer-events:none; /* debug overlay must never steal taps from the real UI beneath it */
+  }
+  #hud.show{ display:block; }
+</style>
+</head>
+<body>
+
+  <div id="progress">
+    <div class="step-dot" data-step="1"><div class="num">1</div><div class="lbl">Position</div></div>
+    <div class="step-line"></div>
+    <div class="step-dot" data-step="2"><div class="num">2</div><div class="lbl">Capture</div></div>
+    <div class="step-line"></div>
+    <div class="step-dot" data-step="3"><div class="num">3</div><div class="lbl">Review</div></div>
+    <div class="step-line"></div>
+    <div class="step-dot" data-step="4"><div class="num">4</div><div class="lbl">Done</div></div>
+  </div>
+
+  <div id="stage">
+
+    <!-- STEP 1 — POSITION -->
+    <section class="screen" id="screen-1">
+      <h2>Position the sample</h2>
+      <div class="sub">Lay the sample flat and fill the frame. Good light helps.</div>
+      <div class="cam-wrap">
+        <video id="video" playsinline muted autoplay></video>
+        <div class="frame-guide">
+          <div class="corner tl"></div><div class="corner tr"></div>
+          <div class="corner bl"></div><div class="corner br"></div>
+        </div>
+        <div class="cam-hint" id="camHint">Starting camera…</div>
+      </div>
+      <div class="actions">
+        <button class="btn-primary btn-big" id="btnReady" disabled>I'm Ready →</button>
+      </div>
+    </section>
+
+    <!-- STEP 2 — CAPTURE -->
+    <section class="screen" id="screen-2">
+      <h2>Hold still…</h2>
+      <div class="sub">Capturing in a moment.</div>
+      <div class="cam-wrap" id="captureWrap">
+        <!-- video2 shares the same live stream, cloned in via JS -->
+        <video id="video2" playsinline muted autoplay></video>
+        <div class="frame-guide">
+          <div class="corner tl"></div><div class="corner tr"></div>
+          <div class="corner bl"></div><div class="corner br"></div>
+        </div>
+        <div class="countdown-num" id="countdownNum"></div>
+        <div class="flash" id="flashEl"></div>
+      </div>
+      <canvas id="snapCanvas"></canvas>
+    </section>
+
+    <!-- STEP 3 — REVIEW -->
+    <section class="screen" id="screen-3">
+      <h2>How does it look?</h2>
+      <div class="sub">Make sure the sample is clear and in focus.</div>
+      <div class="review-wrap">
+        <img id="reviewImg" alt="Captured sample photo">
+        <div class="analyzing-pill" id="analyzingPill"><span class="dot-spin"></span> Reading label…</div>
+      </div>
+      <div class="actions">
+        <button class="btn-secondary" id="btnRetake">↺ Retake</button>
+        <button class="btn-primary" id="btnUse">✓ Use This Photo</button>
+      </div>
+    </section>
+
+    <!-- STEP 4 — DONE -->
+    <section class="screen" id="screen-4">
+      <div style="flex:1 1 auto; display:flex; flex-direction:column; justify-content:center;">
+        <div class="checkmark" id="doneIcon">
+          <svg viewBox="0 0 24 24" fill="none"><path d="M4 12.5L9.5 18L20 6" stroke="#04231d" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
+        </div>
+        <h2 id="doneTitle">Sample Captured!</h2>
+        <div class="sub" id="doneSub">Nice work — this sample is on its way into the catalog.</div>
+        <div class="done-card" id="doneCard" style="display:none"></div>
+        <div class="err-box" id="errCard" style="display:none"></div>
+      </div>
+      <div class="actions">
+        <button class="btn-primary btn-big" id="btnAgain">➕ Add Another Sample</button>
+      </div>
+    </section>
+
+  </div>
+
+  <div id="hud"></div>
+
+<script>
+(function(){
+  'use strict';
+  const $ = id => document.getElementById(id);
+  const DEBUG = /[?&]debug=1/.test(location.search);
+  if (DEBUG) $('hud').classList.add('show');
+
+  // ---------------------------------------------------------------------------------------------
+  // TIMING LEDGER — every measured instant, for the speed writeup. Logged to console always;
+  // rendered in the corner HUD only with ?debug=1 so the guest-facing screen stays clean.
+  // ---------------------------------------------------------------------------------------------
+  const T = {};
+  function mark(k){ T[k] = performance.now(); renderHud(); return T[k]; }
+  function ms(a,b){ return (T[b]!=null && T[a]!=null) ? Math.round(T[b]-T[a]) : null; }
+  function renderHud(){
+    if(!DEBUG) return;
+    const lines = [
+      'STEP TIMING (ms)',
+      't_position_enter → t_capture_tap : ' + (ms('positionEnter','captureTap') ?? '—'),
+      't_capture_tap → t_frame_grabbed  : ' + (ms('captureTap','frameGrabbed') ?? '—'),
+      't_frame_grabbed → extract_fired  : ' + (ms('frameGrabbed','extractFired') ?? '—'),
+      'extract in-flight (spec.)        : ' + (ms('extractFired','extractResolved') ?? '(pending)'),
+      't_confirm_tap → extract_ready    : ' + (ms('confirmTap','extractResolved') ?? '0 (already done)'),
+      't_confirm_tap → create_fired     : ' + (ms('confirmTap','createFired') ?? '—'),
+      'create-item round trip           : ' + (ms('createFired','createResolved') ?? '(pending)'),
+      '── TOTAL confirm → done ──        : ' + (ms('confirmTap','doneShown') ?? '—'),
+    ];
+    $('hud').textContent = lines.join('\n');
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // STATE
+  // ---------------------------------------------------------------------------------------------
+  let stream = null;
+  let capturedDataUrl = null;
+  let extractPromise = null;     // speculative — fired the instant the frame is grabbed, step 2→3
+  let extractResult = null;
+  let currentStep = 1;
+
+  function goStep(n){
+    currentStep = n;
+    document.querySelectorAll('.screen').forEach(s => s.classList.remove('on'));
+    $('screen-' + n).classList.add('on');
+    document.querySelectorAll('.step-dot').forEach(d => {
+      const step = +d.dataset.step;
+      d.classList.toggle('active', step === n);
+      d.classList.toggle('done', step < n);
+    });
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // CAMERA — bounded acquire with a timeout race (a hung getUserMedia/play() must never freeze the
+  // wizard on a first-time guest's device). Mirrors the robustness pattern already proven in
+  // public/index.html's two-shot live cam (TK-12124/TK-12127), trimmed to what this flow needs.
+  // ---------------------------------------------------------------------------------------------
+  async function acquireCamera(){
+    if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
+      throw new Error('This device/browser can\'t access the camera. Try Safari on iPhone/iPad or Chrome on Android.');
+    }
+    const constraints = [
+      { video:{ facingMode:{ ideal:'environment' }, width:{ ideal:1920 }, height:{ ideal:1440 } }, audio:false },
+      { video:{ facingMode:{ ideal:'environment' } }, audio:false },
+      { video:true, audio:false }
+    ];
+    let lastErr;
+    for(const c of constraints){
+      try{
+        const timeout = new Promise((_,rej) => setTimeout(() => rej(new Error('timeout')), 8000));
+        const s = await Promise.race([navigator.mediaDevices.getUserMedia(c), timeout]);
+        return s;
+      }catch(e){ lastErr = e; }
+    }
+    throw lastErr || new Error('camera unavailable');
+  }
+
+  async function startCamera(){
+    $('camHint').textContent = 'Starting camera…';
+    try{
+      stream = await acquireCamera();
+      const v1 = $('video'); v1.srcObject = stream;
+      await v1.play().catch(()=>{});
+      $('camHint').textContent = 'Fill the frame with the sample';
+      $('btnReady').disabled = false;
+
+      // ---- JEV-STYLE PREFETCH / WARM-UP -----------------------------------------------------
+      // This is the "position" step's dwell time — the guest is adjusting the sample, which is
+      // exactly the idle window JEV spends on speculative work instead of waiting for the next
+      // user action. We fire a cheap same-origin GET now so the keep-alive connection this page
+      // will reuse for the real POSTs (/api/extract, /api/create-item) is already established —
+      // no fresh TCP/TLS handshake sitting in the critical path after the guest taps "Use".
+      fetch('/healthz', { keepalive:true }).catch(()=>{});
+      mark('positionEnter');
+    }catch(e){
+      $('camHint').textContent = '';
+      showCameraError(e.message || String(e));
+    }
+  }
+
+  function showCameraError(msg){
+    const s = $('screen-1');
+    if($('camErrBox')) return;
+    const box = document.createElement('div');
+    box.id = 'camErrBox'; box.className = 'err-box';
+    box.innerHTML = '<b>Camera not available</b><br><span style="color:var(--dim)">' + msg.replace(/</g,'&lt;') + '</span>';
+    s.insertBefore(box, s.querySelector('.actions'));
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // STEP 1 → 2 : "I'm Ready"
+  // ---------------------------------------------------------------------------------------------
+  $('btnReady').addEventListener('click', () => {
+    mark('captureTap');
+    // hand the SAME live stream to the capture screen's video element — no re-acquire, no flicker.
+    const v2 = $('video2'); v2.srcObject = stream; v2.play().catch(()=>{});
+    goStep(2);
+    runCountdown();
+  });
+
+  // ---------------------------------------------------------------------------------------------
+  // STEP 2 — countdown then auto-capture (confirm tap already happened; countdown just holds the
+  // sample steady for a sharp shot — combines "clear confirm tap" + "countdown" from the brief).
+  // ---------------------------------------------------------------------------------------------
+  function runCountdown(){
+    const el = $('countdownNum');
+    const seq = ['3','2','1','📸'];
+    let i = 0;
+    (function tick(){
+      el.textContent = seq[i];
+      el.classList.remove('show'); void el.offsetWidth; el.classList.add('show');
+      if(seq[i] === '📸'){
+        setTimeout(() => { grabFrame(); }, 260);
+        return;
+      }
+      i++;
+      setTimeout(tick, 700);
+    })();
+  }
+
+  function grabFrame(){
+    $('flashEl').classList.add('go');
+    const v = $('video2');
+    const canvas = $('snapCanvas');
+    // downscale to a sane max edge — keeps the base64 payload small so the (already-fast) round
+    // trips to /api/extract and /api/create-item aren't padded by an unnecessarily huge JPEG.
+    const MAXW = 1600;
+    const vw = v.videoWidth || 1280, vh = v.videoHeight || 960;
+    const scale = Math.min(1, MAXW / Math.max(vw, vh));
+    canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
+    canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
+    capturedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
+    mark('frameGrabbed');
+
+    $('reviewImg').src = capturedDataUrl;
+    goStep(3);
+
+    // ---- SPECULATIVE EXTRACTION (the core JEV-style optimization for this concept) -----------
+    // Fire /api/extract THE INSTANT we have pixels — while the guest is still looking at the
+    // review screen deciding whether to keep the shot — instead of waiting for the "Use This
+    // Photo" tap. A person spends ~1-3s glancing at a photo before confirming; Gemini Vision OCR
+    // typically resolves in a similar window. By the time they tap "Use", extraction is usually
+    // already done, so only ONE round trip (/api/create-item) remains on the critical path
+    // instead of two SEQUENTIAL ones. This mirrors Jev's "one request per decision, no idle
+    // network gaps" — we just move the decision point earlier because we can predict it.
+    $('analyzingPill').classList.add('show');
+    mark('extractFired');
+    extractPromise = fetch('/api/extract', {
+      method:'POST', headers:{ 'Content-Type':'application/json' },
+      body: JSON.stringify({ dataUrl: capturedDataUrl })
+    }).then(r => r.json()).then(j => {
+      mark('extractResolved');
+      extractResult = j;
+      $('analyzingPill').classList.remove('show');
+      return j;
+    }).catch(e => {
+      mark('extractResolved');
+      extractResult = { ok:false, err: e.message };
+      $('analyzingPill').classList.remove('show');
+      return extractResult;
+    });
+  }
+
+  // ---------------------------------------------------------------------------------------------
+  // STEP 3 — Retake / Use This Photo
+  // ---------------------------------------------------------------------------------------------
+  $('btnRetake').addEventListener('click', () => {
+    extractPromise = null; extractResult = null; capturedDataUrl = null;
+    goStep(2);
+    runCountdown();
+  });
+
+  $('btnUse').addEventListener('click', async () => {
+    mark('confirmTap');
+    $('btnUse').disabled = true; $('btnRetake').disabled = true;
+    await finishCapture();
+  });
+
+  // ---------------------------------------------------------------------------------------------
+  // STEP 3 → 4 : await the (likely-already-resolved) speculative extract, then the ONE remaining
+  // round trip — /api/create-item — using the exact contract public/index.html's "add new item"
+  // flow uses (mfr + vendor required server-side; front photo satisfies the photo gate; mfr from
+  // OCR satisfies the identity gate when there's no separate back-label shot).
+  // ---------------------------------------------------------------------------------------------
+  async function finishCapture(){
+    // Await the speculative extract IF it's still in flight (a fast/impatient tap can beat the
+    // network back) — this is the only place the wizard ever waits on it; most of the time it's
+    // already resolved from the review-screen dwell and this await returns on the same tick.
+    if(extractPromise){ extractResult = await extractPromise; }
+    const fields = (extractResult && extractResult.fields) || {};
+    const vendor = extractResult && (extractResult.vendor_matched || fields.vendor) || '';
+    const mfr = String(fields.mfr_sku == null ? '' : fields.mfr_sku).trim();   // Gemini JSON may type a numeric SKU as a number
+
+    if(!mfr || !vendor){
+      // Honest graceful degradation — the label didn't read clearly enough to auto-identify.
+      // A guided kiosk never shows a raw error; it explains plainly and hands off to staff.
+      showDone({
+        ok:false,
+        title: 'Photo saved',
+        sub: "We couldn't automatically read the label on this one — a team member will finish adding it.",
+      });
+      return;
+    }
+
+    mark('createFired');
+    let r;
+    try{
+      r = await fetch('/api/create-item', {
+        method:'POST', headers:{ 'Content-Type':'application/json' },
+        body: JSON.stringify({
+          dataUrl: capturedDataUrl,
+          photos: [capturedDataUrl],
+          mfr, vendor, vid: '',
+          name: fields.pattern_name || '', color: fields.color || '',
+          material: fields.material || 'Wallcovering',
+          collection: fields.collection || '', width: fields.width || '',
+          roll_length: fields.roll_length || '', repeat: fields.repeat || '',
+          pattern_match: fields.pattern_match || '', substrate: fields.substrate || '',
+          how_sold: fields.how_sold || '', price: fields.price || '', price_code: fields.price_code || '',
+          id_source: 'guided-wizard-ocr', commit: true
+        })
+      }).then(res => res.json());
+    }catch(e){
+      r = { ok:false, err: e.message };
+    }
+    mark('createResolved');
+
+    if(r && r.ok){
+      showDone({ ok:true, dw_sku: r.dw_sku, vendor, title: 'Sample Captured!', sub: 'This sample is now a draft in the catalog.' });
+    } else if(r && r.duplicate){
+      showDone({ ok:true, dw_sku: (r.preview && r.preview.dw_sku) || '', vendor, title: 'Already in the catalog', sub: 'This exact sample is already on file — nothing more to do here.' });
+    } else {
+      showDone({ ok:false, title: 'Photo saved', sub: 'The catalog step needs a hand from staff for this one (' + ((r && r.err) || 'unknown issue') + ').' });
+    }
+  }
+
+  function showDone(info){
+    mark('doneShown');
+    $('doneTitle').textContent = info.title;
+    $('doneSub').textContent = info.sub;
+    $('doneIcon').style.background = info.ok ? 'var(--accent)' : 'var(--warn)';
+    if(info.ok && info.dw_sku){
+      $('doneCard').style.display = 'block';
+      $('doneCard').innerHTML =
+        '<div class="row"><span class="k">DW SKU</span><span class="v">' + esc(info.dw_sku) + '</span></div>' +
+        '<div class="row"><span class="k">Vendor</span><span class="v">' + esc(info.vendor || '—') + '</span></div>';
+      $('errCard').style.display = 'none';
+    } else {
+      $('doneCard').style.display = 'none';
+    }
+    goStep(4);
+    if(DEBUG) console.log('[guided-wizard] timing ledger', JSON.parse(JSON.stringify(T)));
+  }
+
+  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+  // ---------------------------------------------------------------------------------------------
+  // "Add Another Sample" — full reset back to step 1, camera re-acquired fresh.
+  // ---------------------------------------------------------------------------------------------
+  $('btnAgain').addEventListener('click', () => {
+    capturedDataUrl = null; extractPromise = null; extractResult = null;
+    $('btnUse').disabled = false; $('btnRetake').disabled = false;
+    for(const k of Object.keys(T)) delete T[k];
+    goStep(1);
+    if(!stream || !stream.active){ startCamera(); }
+    else {
+      $('video').srcObject = stream;
+      mark('positionEnter');
+    }
+  });
+
+  // boot
+  goStep(1);
+  startCamera();
+})();
+</script>
+</body>
+</html>
diff --git a/scripts/STEVE-PASTE-tk12090-deploy2.sh b/scripts/STEVE-PASTE-tk12090-deploy2.sh
new file mode 100755
index 0000000..98fc910
--- /dev/null
+++ b/scripts/STEVE-PASTE-tk12090-deploy2.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# TK-12090 deploy 2: server.js (247c9f9 provider=null on failure) + _shared exo-vision.mjs
+# (06dacfd key redaction), with timestamped backups, then one verify call printing the error.
+# Undo: mv each .pre-tk12090b-*.bak back + pm2 restart 226.
+# Verifier source: scripts/verify-vision.py (piped over ssh stdin; it refuses blank VU/VP).
+set -e
+scp ~/Projects/dw-photo-capture/server.js root@45.61.58.125:/root/public-projects/dwphoto/server.js.tk12090-new
+scp ~/Projects/_shared/lib/exo-vision.mjs root@45.61.58.125:/root/public-projects/_shared/lib/exo-vision.mjs.tk12090-new
+ssh root@45.61.58.125 'TS=$(date -u +%Y%m%dT%H%M%SZ); D=/root/public-projects; cp $D/dwphoto/server.js $D/dwphoto/server.js.pre-tk12090b-$TS.bak && cp $D/_shared/lib/exo-vision.mjs $D/_shared/lib/exo-vision.mjs.pre-tk12090b-$TS.bak && mv $D/dwphoto/server.js.tk12090-new $D/dwphoto/server.js && mv $D/_shared/lib/exo-vision.mjs.tk12090-new $D/_shared/lib/exo-vision.mjs && pm2 restart 226 >/dev/null && sleep 3 && printf "healthz=" && curl -s -m 5 http://127.0.0.1:9912/healthz && printf "\nbackups=*.pre-tk12090b-%s.bak\n" "$TS"'
+ssh root@45.61.58.125 'PID=$(ss -lptnH "sport = :9912" 2>/dev/null | grep -oP "pid=\K[0-9]+" | head -1); [ -z "$PID" ] && { echo "ERR: no PID on :9912"; exit 1; }; export VU=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_USER= | cut -d= -f2-); export VP=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_PASS= | cut -d= -f2-); python3 -' < ~/Projects/dw-photo-capture/scripts/verify-vision.py
diff --git a/scripts/STEVE-PASTE-vision-step3.sh b/scripts/STEVE-PASTE-vision-step3.sh
new file mode 100755
index 0000000..304d721
--- /dev/null
+++ b/scripts/STEVE-PASTE-vision-step3.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# TK-12090 vision-url memo — verification step 3 (Steve runs this himself).
+# Drives ONE real vision call through prod dwphoto and prints which provider served it.
+# visualMatch:false => no Gemini visual-match spend. Read-only against a scratch image.
+ssh root@45.61.58.125 'PID=$(pm2 pid 226); export VU=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_USER= | cut -d= -f2-); export VP=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_PASS= | cut -d= -f2-); python3 -c "
+from PIL import Image,ImageDraw
+import base64,io,json,urllib.request,os
+im=Image.new(\"RGB\",(384,384),(198,178,142)); d=ImageDraw.Draw(im)
+[d.line([(0,y),(384,y)],fill=(150,130,96),width=6) for y in range(0,384,24)]
+[d.line([(x,0),(x,384)],fill=(170,150,112),width=3) for x in range(0,384,24)]
+b=io.BytesIO(); im.save(b,\"JPEG\",quality=88)
+c=base64.b64encode((os.environ[\"VU\"]+\":\"+os.environ[\"VP\"]).encode()).decode()
+p=json.dumps({\"dataUrl\":\"data:image/jpeg;base64,\"+base64.b64encode(b.getvalue()).decode(),\"visualMatch\":False}).encode()
+r=json.loads(urllib.request.urlopen(urllib.request.Request(\"http://127.0.0.1:9912/api/recognize\",data=p,headers={\"Content-Type\":\"application/json\",\"Authorization\":\"Basic \"+c}),timeout=200).read())
+print(\"PROVIDER:\",r.get(\"provider\"),\" (expect exo, not gemini)\"); print(\"cost_usd:\",r.get(\"cost_usd\"),\" (expect 0)\"); print(\"ok:\",r.get(\"ok\"))
+"'
diff --git a/scripts/verify-vision.py b/scripts/verify-vision.py
new file mode 100644
index 0000000..5cb08d0
--- /dev/null
+++ b/scripts/verify-vision.py
@@ -0,0 +1,35 @@
+# TK-12090 prod vision verifier — piped over ssh stdin by STEVE-PASTE-tk12090-deploy2.sh
+# (runs ON Kamatera). Sends one synthetic grasscloth-ish JPEG to /api/recognize with
+# visualMatch:false (no Gemini visual-match spend) and prints which engine/provider served it.
+# Needs VU / VP (the dwphoto Basic-Auth pair) in the environment.
+from PIL import Image, ImageDraw
+import base64, io, json, os, re, sys, urllib.request
+
+vu, vp = os.environ.get("VU", ""), os.environ.get("VP", "")
+if not vu or not vp:
+    sys.exit("ERR: VU/VP (AUTH_USER/AUTH_PASS) empty — refusing to call with blank Basic Auth")
+
+im = Image.new("RGB", (384, 384), (198, 178, 142))
+d = ImageDraw.Draw(im)
+for y in range(0, 384, 24):
+    d.line([(0, y), (384, y)], fill=(150, 130, 96), width=6)
+for x in range(0, 384, 24):
+    d.line([(x, 0), (x, 384)], fill=(170, 150, 112), width=3)
+b = io.BytesIO()
+im.save(b, "JPEG", quality=88)
+
+auth = base64.b64encode(f"{vu}:{vp}".encode()).decode()
+payload = json.dumps({
+    "dataUrl": "data:image/jpeg;base64," + base64.b64encode(b.getvalue()).decode(),
+    "visualMatch": False,
+}).encode()
+req = urllib.request.Request(
+    "http://127.0.0.1:9912/api/recognize",
+    data=payload,
+    headers={"Content-Type": "application/json", "Authorization": "Basic " + auth},
+)
+r = json.loads(urllib.request.urlopen(req, timeout=200).read())  # seconds; vision can be slow
+
+err = re.sub(r"(key=)[^&\s\"]+", r"\1REDACTED", str(r.get("err") or ""))[:220]
+print("ENGINE:", r.get("engine"), " PROVIDER:", r.get("provider"), " cost_usd:", r.get("cost_usd"), " ok:", r.get("ok"))
+print("MOTIF:", (r.get("recognized") or {}).get("motif"), " ERR:", err or "-")

← a7bae5f auto-data-snapshot: 2026-09-24T13:20:22 (1 data files) — dat  ·  back to Dw Photo Capture  ·  TK-12162: guided wizard never strands a guest on an unexpect 10d3e40 →