← back to Dw Photo Capture

public/simple-instant.html

454 lines

<!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; }
  .fullres-btn{ margin-top:-8px; border:1px solid var(--line); background:var(--white); color:var(--gold-deep);
    font:600 13px/1 -apple-system,system-ui,sans-serif; padding:9px 16px; border-radius:99px; cursor:pointer; }

  /* ===== 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>
    <button class="fullres-btn" id="fullResBtn">📷 Full-res photo</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 src="/js/field-clean.js"></script>
<script src="/js/native-photo.js"></script>
<script src="/js/capture-pipeline.js"></script>
<script src="/js/adjust-panel.js"></script>
<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;

  // Pre-shot adjustments (all 11) + auto-save once the label read fills vendor + mfr#.
  var panel = AdjustPanel.create({ key:'dwSimpleTune', toggleParent: $('fullResBtn').parentNode, toggleClass:'fullres-btn' });
  panel.attachPreview(video);
  var autoFired = false, vendorRegistered = false;
  function maybeAutoSave(){
    // registry vendors only — an unmatched label vendor would mint a PROV- sku, so a human confirms it
    if (autoFired || !panel.autoSave() || !extractDone || !vendorRegistered || chinFields.hidden || saveBtn.disabled) return;
    if (!fVendor.value.trim() || !fMfr.value.trim()) return;
    autoFired = true; saveBtn.click();
  }

  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:4096 }, height:{ ideal:3072 } }, 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(native){
    if (busy || (!stream && !native)) return;
    busy = true; shutterBtn.disabled = true; autoFired = false; vendorRegistered = false;

    if (native) {
      capturedDataUrl = native.full;
    } else {

    var vw = video.videoWidth || 1280, vh = video.videoHeight || 960;
    var maxDim = 2000, scale = Math.min(1, maxDim / Math.max(vw, vh));
    canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
    capturedDataUrl = panel.bake(video, maxDim);   // pre-shot adjustments baked in
    }

    // ── 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: (native && native.small) || 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;
      vendorRegistered = !!(data && data.vendor_registered === 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 != null) fMfr.value = cleanField(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();
      maybeAutoSave();
    });
  }

  function revealResult(){
    developingRow.hidden = true; chinFields.hidden = false;
    updateSaveEnabled();
    maybeAutoSave();
    if (!extractDone) tinyMeta.innerHTML = '🔎 still reading the label…';
    fVendor.focus({ preventScroll:true });
  }

  shutterBtn.addEventListener('click', function(){ doCapture(); });   // never pass the click event as `native`
  // Full-res: the phone's own Camera app (real sensor photo); a 1600px copy feeds the label read.
  $('fullResBtn').addEventListener('click', function(){
    if (busy) return;
    NativePhoto.pick().then(function(p){
      if (!p) return;
      panel.bakeDataUrl(p.full, NativePhoto.FULL_EDGE).then(function(full){ doCapture({ full: full, small: p.small }); });
    });
  });

  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;
      // dw_unified is a SEPARATE write from the Shopify draft (server.js createNewItem stages
      // into new_items_staging) — r.ok only means the Shopify draft succeeded, so it's called
      // out here rather than assumed.
      var dwuMsg = r.dw_unified
        ? (r.dw_unified.committed ? ' · ✓ dw_unified' : ' · ⚠ dw_unified failed')
        : '';
      toast('✅ Saved ' + (r.dw_sku || 'item') + dwuMsg, dwuMsg ? 3600 : undefined);
      setTimeout(resetToLive, 550);
    }catch(e){ toast('✗ network error'); saveBtn.disabled = false; saveBtn.textContent = prevLabel; }
  });

  startCamera(facing);
})();
</script>
</body>
</html>