[object Object]

← back to Dw Photo Capture

TK-12162 Instant Film concept: simple-instant.html (Polaroid-style single-photo capture, animation decoupled from real /api/extract upload)

a262d3e4ed86897978442446f804998e3eb42ca3 · 2026-09-24 13:12:08 -0700 · Steve Abrams

Warm cream/paper theme with DW gold as accent only (deliberate departure from
the dark power-tool theme) — framed viewfinder with film-border + rounded
corners, shutter-flash, "developing" reveal on a fixed 1.25s cosmetic timer
that is fully decoupled from the real network pipeline: the /api/extract
upload fires the instant a frame is captured, before the flash/develop
animation even starts, and Save's enabled state is gated on that real
response (or a manual field edit) — never on the animation clock. Single
photo per session; front-loaded vendor + mfr# fields (both server-required)
auto-fill from OCR extraction with a manual-override always available.

Verified end-to-end locally via Playwright (fake camera device): camera live
in <400ms, extract() dispatched ~56ms after shutter tap, develop reveal fires
on schedule regardless of the real Gemini round-trip running 1.9-2.1s (longer
than the animation) — proving the UI never blocks on the real pipeline. Found
+ fixed a real bug in testing: the shutter button never re-armed after Retake.
Server.js route addition (server.js's own concurrent history under TK-12090
picked this up) is additive-only (mirrors the existing /batch pattern) and
does not touch index.html/cam.html/batch.html/desk.html or any core route.
Local exploration only — never linked from nav, never deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files touched

Diff

commit a262d3e4ed86897978442446f804998e3eb42ca3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 13:12:08 2026 -0700

    TK-12162 Instant Film concept: simple-instant.html (Polaroid-style single-photo capture, animation decoupled from real /api/extract upload)
    
    Warm cream/paper theme with DW gold as accent only (deliberate departure from
    the dark power-tool theme) — framed viewfinder with film-border + rounded
    corners, shutter-flash, "developing" reveal on a fixed 1.25s cosmetic timer
    that is fully decoupled from the real network pipeline: the /api/extract
    upload fires the instant a frame is captured, before the flash/develop
    animation even starts, and Save's enabled state is gated on that real
    response (or a manual field edit) — never on the animation clock. Single
    photo per session; front-loaded vendor + mfr# fields (both server-required)
    auto-fill from OCR extraction with a manual-override always available.
    
    Verified end-to-end locally via Playwright (fake camera device): camera live
    in <400ms, extract() dispatched ~56ms after shutter tap, develop reveal fires
    on schedule regardless of the real Gemini round-trip running 1.9-2.1s (longer
    than the animation) — proving the UI never blocks on the real pipeline. Found
    + fixed a real bug in testing: the shutter button never re-armed after Retake.
    Server.js route addition (server.js's own concurrent history under TK-12090
    picked this up) is additive-only (mirrors the existing /batch pattern) and
    does not touch index.html/cam.html/batch.html/desk.html or any core route.
    Local exploration only — never linked from nav, never deployed.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 public/simple-instant.html | 414 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 414 insertions(+)

diff --git a/public/simple-instant.html b/public/simple-instant.html
new file mode 100644
index 0000000..e13f286
--- /dev/null
+++ b/public/simple-instant.html
@@ -0,0 +1,414 @@
+<!doctype html>
+<html lang="en">
+<head>
+<script>
+// Credential-safe fetch guard (fleet drop-in) — see index.html for the full rationale.
+// 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 Instant">
+<meta name="mobile-web-app-capable" content="yes">
+<meta name="theme-color" content="#efe2c4">
+<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
+<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
+<link rel="manifest" href="/manifest.webmanifest">
+<title>DW Instant Film</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&display=swap" rel="stylesheet">
+<style>
+  :root{
+    --paper:#f7efdd; --paper-deep:#efe1bf; --white:#fffdf6;
+    --ink:#382d1e; --muted:#8d7c5c; --line:#e3d3ab;
+    --gold:#c8a24a; --gold-deep:#a67f2e; --gold-soft:#e7c878;
+    --shadow:0 20px 44px -14px rgba(56,45,30,.38), 0 4px 12px rgba(56,45,30,.16);
+    --radius:26px;
+    --flash-ms:150ms; --develop-ms:1100ms;
+  }
+  @media (prefers-color-scheme:dark){
+    :root:not([data-theme="light"]){
+      --paper:#241d12; --paper-deep:#1a1509; --white:#f7efdd;
+      --ink:#f2e8d3; --muted:#b7a37a; --line:#4a3d24;
+    }
+  }
+  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
+  html,body{height:100%}
+  body{
+    margin:0; min-height:100vh; min-height:100dvh;
+    background:
+      radial-gradient(1200px 800px at 15% -10%, var(--paper-deep), transparent 60%),
+      radial-gradient(1000px 700px at 110% 110%, var(--paper-deep), transparent 55%),
+      var(--paper);
+    color:var(--ink);
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
+    display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
+    padding:max(18px,env(safe-area-inset-top)) 18px max(24px,env(safe-area-inset-bottom));
+    overflow-x:hidden;
+  }
+  .brand{display:flex;align-items:baseline;gap:8px;margin:2px 130px 4px 0;user-select:none;max-width:100%;overflow:hidden}
+  .brand b{font-size:clamp(11px,3.6vw,15px);letter-spacing:.1em;color:var(--ink);white-space:nowrap}
+  .brand b .short{display:none}
+  .brand .script{font-family:'Caveat',cursive;font-size:22px;color:var(--gold-deep);font-weight:700;flex:none}
+  @media (max-width:400px){
+    .brand{ margin-right:112px; }
+    .brand b .full{display:none} .brand b .short{display:inline}
+  }
+  .sesschip{
+    position:fixed; top:max(14px,env(safe-area-inset-top)); right:14px; z-index:5;
+    background:var(--white); border:1px solid var(--line); color:var(--muted);
+    font-size:12px; font-weight:600; padding:6px 11px; border-radius:99px; box-shadow:var(--shadow);
+  }
+  .sesschip b{color:var(--gold-deep)}
+
+  /* ===== VIEWFINDER (idle / live camera) ===== */
+  .stage{ width:min(94vw,480px); display:flex; flex-direction:column; align-items:center; gap:22px; margin-top:6px; }
+  /* short-viewport / landscape iPad — the 3:4 frame at full width would clip the shutter below the
+     fold, so cap the frame's width (hence height, via aspect-ratio) to what the viewport can hold. */
+  @media (max-height:840px) and (min-width:640px){
+    .stage,.card-stage{ width:min(90vw,230px); }
+    .brand{ margin-bottom:0; }
+    .field{ margin-bottom:6px; }
+    .field input{ padding:8px 10px; font-size:14px; }
+    .chin{ padding:8px 4px 10px; }
+    .chin-row{ min-height:20px; font-size:18px; }
+    .save-row{ padding-bottom:8px; }
+    body{ justify-content:center; padding-top:max(10px,env(safe-area-inset-top)); }
+    .stage{ gap:14px; }
+    .card-stage{ gap:10px; }
+  }
+  .viewfinder{
+    position:relative; width:100%; aspect-ratio:3/4; border-radius:var(--radius);
+    background:var(--white); padding:14px 14px 34px;
+    box-shadow:var(--shadow); border:1px solid var(--line);
+  }
+  .vf-window{ position:relative; width:100%; height:100%; border-radius:16px; overflow:hidden; background:#111; }
+  .vf-window video{ width:100%; height:100%; object-fit:cover; display:block; transform:scaleX(1); }
+  .vf-corner{ position:absolute; width:22px; height:22px; border-color:rgba(255,255,255,.55); pointer-events:none; }
+  .vf-corner.tl{ top:8px; left:8px; border-top:2px solid; border-left:2px solid; border-radius:4px 0 0 0; }
+  .vf-corner.tr{ top:8px; right:8px; border-top:2px solid; border-right:2px solid; border-radius:0 4px 0 0; }
+  .vf-corner.bl{ bottom:8px; left:8px; border-bottom:2px solid; border-left:2px solid; border-radius:0 0 0 4px; }
+  .vf-corner.br{ bottom:8px; right:8px; border-bottom:2px solid; border-right:2px solid; border-radius:0 0 4px 0; }
+  .flip-btn{
+    position:absolute; top:22px; right:22px; z-index:3; width:38px;height:38px;border-radius:50%;
+    background:rgba(20,16,10,.42); color:#fff; border:1px solid rgba(255,255,255,.35);
+    font-size:17px; display:flex;align-items:center;justify-content:center; cursor:pointer;
+  }
+  .vf-hint{
+    position:absolute; left:0; right:0; bottom:10px; text-align:center; font-size:12px;
+    color:rgba(255,255,255,.85); text-shadow:0 1px 3px rgba(0,0,0,.6); pointer-events:none; letter-spacing:.02em;
+  }
+
+  .shutter{
+    width:78px; height:78px; border-radius:50%; border:none; cursor:pointer; padding:0;
+    background:radial-gradient(circle at 34% 30%, var(--gold-soft), var(--gold) 55%, var(--gold-deep) 100%);
+    box-shadow:0 10px 22px -6px rgba(166,127,46,.55), inset 0 0 0 5px var(--white), inset 0 0 0 7px var(--gold);
+    display:flex; align-items:center; justify-content:center; transition:transform .12s ease;
+  }
+  .shutter:active{ transform:scale(.9); }
+  .shutter .dot{ width:16px;height:16px;border-radius:50%;background:var(--white); box-shadow:0 0 0 2px rgba(0,0,0,.06) inset; }
+  .shutter[disabled]{ opacity:.5; pointer-events:none; }
+
+  /* ===== FLASH ===== */
+  .flash-overlay{ position:fixed; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:40; }
+  .flash-overlay.go{ animation:flashpop var(--flash-ms) ease-out; }
+  @keyframes flashpop{ 0%{opacity:0} 18%{opacity:.92} 100%{opacity:0} }
+
+  /* ===== INSTANT CARD (developing / result) ===== */
+  .card-stage{ width:min(94vw,480px); display:flex; flex-direction:column; align-items:center; gap:18px; }
+  .instant-card{
+    width:100%; background:var(--white); border-radius:var(--radius); box-shadow:var(--shadow);
+    padding:14px 14px 0; border:1px solid var(--line);
+    animation:cardpop .38s cubic-bezier(.2,.9,.3,1.2);
+  }
+  @keyframes cardpop{ from{ transform:scale(.92) translateY(10px); opacity:0 } to{ transform:none; opacity:1 } }
+  .photo-well{ position:relative; width:100%; aspect-ratio:3/4; border-radius:16px; overflow:hidden; background:#1c1712; }
+  .photo-well img{
+    width:100%; height:100%; object-fit:cover; display:block;
+    filter:blur(16px) saturate(.35) brightness(1.18); opacity:.82;
+    transition:filter var(--develop-ms) cubic-bezier(.16,.7,.24,1), opacity var(--develop-ms) ease-out;
+  }
+  .photo-well img.developed{ filter:blur(0) saturate(1) brightness(1); opacity:1; }
+  .sheen{
+    position:absolute; inset:0; pointer-events:none;
+    background:linear-gradient(115deg, transparent 35%, rgba(255,255,255,.55) 48%, transparent 62%);
+    transform:translateX(-120%); opacity:0;
+  }
+  .sheen.go{ animation:sheensweep var(--develop-ms) ease-out forwards; }
+  @keyframes sheensweep{ 0%{transform:translateX(-120%);opacity:.9} 70%{opacity:.5} 100%{transform:translateX(120%);opacity:0} }
+
+  .chin{ padding:16px 6px 18px; text-align:center; }
+  .chin-row{ font-family:'Caveat',cursive; font-size:22px; color:var(--muted); display:flex; align-items:center; justify-content:center; gap:8px; min-height:30px; }
+  .dot-flicker{ width:8px;height:8px;border-radius:50%;background:var(--gold); animation:flicker 1.1s ease-in-out infinite; }
+  @keyframes flicker{ 0%,100%{opacity:.25} 50%{opacity:1} }
+
+  .chin-fields{ text-align:left; }
+  .field{ margin-bottom:10px; }
+  .field label{ display:block; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); margin-bottom:4px; }
+  .field input{
+    width:100%; font-size:16px; padding:11px 12px; border-radius:10px; border:1.5px solid var(--line);
+    background:var(--paper); color:var(--ink); font-family:inherit;
+  }
+  .field input:focus{ outline:none; border-color:var(--gold); background:var(--white); }
+  .field.optional input{ font-size:14px; padding:9px 11px; }
+  .tiny-meta{ font-size:11px; color:var(--muted); text-align:center; margin:2px 0 10px; min-height:14px; }
+  .tiny-meta b{ color:var(--gold-deep) }
+
+  .save-row{ display:flex; gap:10px; padding-bottom:16px; }
+  .btn{ flex:1; border:none; border-radius:14px; padding:15px 10px; font-size:15px; font-weight:700; cursor:pointer; text-align:center; font-family:inherit; }
+  .btn-outline{ background:var(--white); color:var(--ink); border:1.5px solid var(--line); flex:0.85; }
+  .btn-outline:active{ background:var(--paper); }
+  .btn-gold{ background:linear-gradient(180deg, var(--gold-soft), var(--gold)); color:#332506; flex:1.3; box-shadow:0 8px 18px -8px rgba(166,127,46,.6); transition:opacity .15s,transform .12s; }
+  .btn-gold:active:not([disabled]){ transform:scale(.98); }
+  .btn-gold[disabled]{ opacity:.5; cursor:default; box-shadow:none; }
+
+  .toast{
+    position:fixed; left:50%; bottom:max(22px,env(safe-area-inset-bottom)); transform:translateX(-50%) translateY(8px);
+    background:#241d12; color:#f7efdd; padding:12px 20px; border-radius:14px; font-size:14px; font-weight:600;
+    box-shadow:var(--shadow); z-index:50; opacity:0; pointer-events:none; transition:opacity .25s, transform .25s;
+  }
+  .toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }
+
+  [hidden]{ display:none !important; }
+</style>
+</head>
+<body>
+
+  <div class="brand"><b><span class="full">DESIGNER WALLCOVERINGS</span><span class="short">DW</span></b><span class="script">Instant</span></div>
+  <div class="sesschip">📸 <b id="sessCount">0</b> saved this session</div>
+
+  <!-- STATE: live viewfinder -->
+  <div class="stage" id="stage">
+    <div class="viewfinder">
+      <div class="vf-window">
+        <video id="cam" autoplay playsinline muted></video>
+        <div class="vf-corner tl"></div><div class="vf-corner tr"></div>
+        <div class="vf-corner bl"></div><div class="vf-corner br"></div>
+        <button class="flip-btn" id="flipBtn" title="Flip camera" aria-label="Flip camera">⟲</button>
+        <div class="vf-hint" id="vfHint">frame the label &amp; tap to shoot</div>
+      </div>
+    </div>
+    <button class="shutter" id="shutterBtn" aria-label="Take photo"><span class="dot"></span></button>
+  </div>
+
+  <!-- STATE: developing / result card -->
+  <div class="card-stage" id="cardStage" hidden>
+    <div class="instant-card">
+      <div class="photo-well">
+        <img id="shotImg" alt="Captured sample">
+        <div class="sheen" id="sheen"></div>
+      </div>
+      <div class="chin">
+        <div class="chin-row" id="developingRow"><span class="dot-flicker"></span> developing…</div>
+        <div class="chin-fields" id="chinFields" hidden>
+          <div class="field">
+            <label for="fVendor">Vendor</label>
+            <input id="fVendor" list="vendorList" placeholder="e.g. Kravet" autocomplete="off">
+            <datalist id="vendorList"></datalist>
+          </div>
+          <div class="field">
+            <label for="fMfr">Manufacturer # / SKU</label>
+            <input id="fMfr" placeholder="e.g. 34567-1116" autocomplete="off">
+          </div>
+          <div class="field optional">
+            <label for="fPattern">Pattern / Color <span style="text-transform:none;font-weight:400">(optional)</span></label>
+            <input id="fPattern" placeholder="e.g. Bermuda — Sand" autocomplete="off">
+          </div>
+          <div class="tiny-meta" id="tinyMeta">&nbsp;</div>
+          <div class="save-row">
+            <button class="btn btn-outline" id="retakeBtn">↺ Retake</button>
+            <button class="btn btn-gold" id="saveBtn" disabled>⏳ Reading label…</button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="flash-overlay" id="flashOverlay"></div>
+  <div class="toast" id="toast"></div>
+
+<script>
+(function(){
+  var $ = function(id){ return document.getElementById(id); };
+  var video = $('cam'), canvas = document.createElement('canvas');
+  var stage = $('stage'), cardStage = $('cardStage');
+  var shutterBtn = $('shutterBtn'), flipBtn = $('flipBtn');
+  var flashOverlay = $('flashOverlay'), sheen = $('sheen');
+  var shotImg = $('shotImg'), developingRow = $('developingRow'), chinFields = $('chinFields');
+  var fVendor = $('fVendor'), fMfr = $('fMfr'), fPattern = $('fPattern');
+  var tinyMeta = $('tinyMeta'), saveBtn = $('saveBtn'), retakeBtn = $('retakeBtn');
+  var toastEl = $('toast'), sessCountEl = $('sessCount'), vendorList = $('vendorList');
+
+  var FLASH_MS = 150, DEVELOP_MS = 1100;  // purely cosmetic — never gates Save
+  var stream = null, facing = 'environment';
+  var busy = false, capturedDataUrl = null, extractDone = false, extractAbort = null;
+  var vendorFromExtract = false, sessCount = 0;
+
+  function toast(msg, ms){
+    toastEl.textContent = msg; toastEl.classList.add('show');
+    clearTimeout(toast._t); toast._t = setTimeout(function(){ toastEl.classList.remove('show'); }, ms || 2600);
+  }
+
+  // ---- camera: start immediately on load, never torn down between shots (retake is instant). ----
+  async function startCamera(f){
+    if (!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
+      $('vfHint').textContent = 'needs Safari/Chrome over HTTPS (or localhost)'; return;
+    }
+    try{
+      if (stream) stream.getTracks().forEach(function(t){ t.stop(); });
+      stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:{ ideal:f }, width:{ ideal:1920 }, height:{ ideal:1440 } }, audio:false });
+    }catch(e){
+      try{ stream = await navigator.mediaDevices.getUserMedia({ video:true, audio:false }); }
+      catch(e2){ toast('Camera unavailable — check permissions.'); return; }
+    }
+    video.srcObject = stream;
+    try{ await video.play(); }catch(e){}
+    shutterBtn.disabled = false;
+  }
+  flipBtn.addEventListener('click', function(){
+    facing = facing === 'environment' ? 'user' : 'environment';
+    startCamera(facing);
+  });
+
+  // ---- vendor autocomplete: loaded in the background, never blocks camera/shutter. ----
+  fetch('/api/vendors-registry').then(function(r){ return r.json(); }).then(function(d){
+    var seen = {};
+    (d.vendors || []).forEach(function(v){
+      var name = v.vendor; if (!name || seen[name]) return; seen[name] = 1;
+      var o = document.createElement('option'); o.value = name; vendorList.appendChild(o);
+    });
+  }).catch(function(){});
+
+  function fmt(ms){ return ms < 1000 ? Math.round(ms) + 'ms' : (ms/1000).toFixed(1) + 's'; }
+
+  function updateSaveEnabled(){
+    var ok = fVendor.value.trim() && fMfr.value.trim();
+    saveBtn.disabled = !ok;
+    if (ok) saveBtn.textContent = '💾 Save';
+    else saveBtn.textContent = extractDone ? '✍️ Vendor + Mfr# needed' : '⏳ Reading label…';
+  }
+  fVendor.addEventListener('input', updateSaveEnabled);
+  fMfr.addEventListener('input', updateSaveEnabled);
+
+  function resetToLive(){
+    cardStage.hidden = true; stage.hidden = false;
+    shotImg.className = ''; shotImg.src = '';
+    developingRow.hidden = false; chinFields.hidden = true;
+    fVendor.value = ''; fMfr.value = ''; fPattern.value = '';
+    tinyMeta.innerHTML = '&nbsp;';
+    capturedDataUrl = null; extractDone = false; vendorFromExtract = false; busy = false;
+    saveBtn.disabled = true; saveBtn.textContent = '⏳ Reading label…';
+    shutterBtn.disabled = !stream;   // re-arm the shutter — the camera stream is still live, no re-init needed
+  }
+
+  async function doCapture(){
+    if (busy || !stream) return;
+    busy = true; shutterBtn.disabled = true;
+
+    var vw = video.videoWidth || 1280, vh = video.videoHeight || 960;
+    var maxDim = 1600, scale = Math.min(1, maxDim / Math.max(vw, vh));
+    canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
+    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
+    capturedDataUrl = canvas.toDataURL('image/jpeg', 0.86);
+
+    // ── SPEED: fire the REAL network call the instant we have pixels — before any
+    // flash/develop UI even starts. The cosmetic animation below runs on its own
+    // fixed clock; Save's enabled state is gated on this promise settling, never
+    // on the animation timers further down. TK-12162 "Instant Film" speed rule.
+    var t0 = performance.now();
+    extractAbort = new AbortController();
+    var extractPromise = fetch('/api/extract', {
+      method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ dataUrl: capturedDataUrl }), signal: extractAbort.signal
+    }).then(function(r){ return r.json(); }).catch(function(e){
+      return { ok:false, err: e.name === 'AbortError' ? 'aborted' : (e.message || 'network') };
+    });
+
+    // ---- cosmetic: shutter-flash → develop reveal. Runs in parallel, blocks nothing. ----
+    flashOverlay.classList.remove('go'); void flashOverlay.offsetWidth; flashOverlay.classList.add('go');
+    setTimeout(function(){
+      stage.hidden = true; cardStage.hidden = false;
+      shotImg.src = capturedDataUrl; shotImg.className = '';
+      developingRow.hidden = false; chinFields.hidden = true;
+      sheen.classList.remove('go'); void sheen.offsetWidth; sheen.classList.add('go');
+      requestAnimationFrame(function(){ requestAnimationFrame(function(){ shotImg.classList.add('developed'); }); });
+      setTimeout(revealResult, DEVELOP_MS);
+    }, FLASH_MS);
+
+    extractPromise.then(function(data){
+      extractDone = true;
+      var elapsed = performance.now() - t0;
+      var fields = (data && data.fields) || {};
+      if (!fVendor.value.trim() && (data.vendor_matched || fields.vendor)){
+        fVendor.value = data.vendor_matched || fields.vendor; vendorFromExtract = true;
+      }
+      if (!fMfr.value.trim() && fields.mfr_sku) fMfr.value = fields.mfr_sku;
+      if (!fPattern.value.trim() && (fields.pattern_name || fields.color)){
+        fPattern.value = [fields.pattern_name, fields.color].filter(Boolean).join(' — ');
+      }
+      if (data && data.err) tinyMeta.innerHTML = '🔎 label read in <b>' + fmt(elapsed) + '</b> · no fields found, enter by hand';
+      else if (data && data.ok === false) tinyMeta.innerHTML = '🔎 checked in <b>' + fmt(elapsed) + '</b> · enter details by hand';
+      else tinyMeta.innerHTML = '🔎 label read in <b>' + fmt(elapsed) + '</b>';
+      updateSaveEnabled();
+    });
+  }
+
+  function revealResult(){
+    developingRow.hidden = true; chinFields.hidden = false;
+    updateSaveEnabled();
+    if (!extractDone) tinyMeta.innerHTML = '🔎 still reading the label…';
+    fVendor.focus({ preventScroll:true });
+  }
+
+  shutterBtn.addEventListener('click', doCapture);
+
+  retakeBtn.addEventListener('click', function(){
+    if (extractAbort) try{ extractAbort.abort(); }catch(e){}
+    resetToLive();
+    // camera stream was never stopped — viewfinder is live again with zero re-init latency.
+  });
+
+  saveBtn.addEventListener('click', async function(){
+    if (saveBtn.disabled) return;
+    saveBtn.disabled = true; var prevLabel = saveBtn.textContent; saveBtn.textContent = 'Saving…';
+    var payload = {
+      vendor: fVendor.value.trim(), mfr: fMfr.value.trim(),
+      name: fPattern.value.split('—')[0].trim(), color: (fPattern.value.split('—')[1] || '').trim(),
+      price:'', width:'', repeat:'', substrate:'', how_sold:'', notes:'',
+      dataUrl: capturedDataUrl, photos:[capturedDataUrl],
+      require_two:true, front_present:true, back_present:false,
+      id_source: vendorFromExtract ? 'ocr' : 'manual', commit:true
+    };
+    try{
+      var r = await (await fetch('/api/create-item', {
+        method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
+      })).json();
+      if (r.duplicate){ toast('⚠ ' + (r.err || 'already in catalog')); saveBtn.disabled = false; saveBtn.textContent = prevLabel; return; }
+      if (!r.ok){ toast('✗ ' + (r.err || 'save failed')); saveBtn.disabled = false; saveBtn.textContent = prevLabel; return; }
+      sessCount++; sessCountEl.textContent = sessCount;
+      toast('✅ Saved ' + (r.dw_sku || 'item'));
+      setTimeout(resetToLive, 550);
+    }catch(e){ toast('✗ network error'); saveBtn.disabled = false; saveBtn.textContent = prevLabel; }
+  });
+
+  startCamera(facing);
+})();
+</script>
+</body>
+</html>

← 247c9f9 TK-12090: report provider=null when the vision call fails  ·  back to Dw Photo Capture  ·  auto-data-snapshot: 2026-09-24T13:20:22 (1 data files) — dat a7bae5f →