← back to Dw Photo Capture

public/simple-probooth.html

536 lines

<!doctype html>
<!--
  ═══════════════════════════════════════════════════════════════════════════════════════════
  PRO BOOTH — simplified capture for DW staff who want SOME control, not the full toolkit.
  TK-12162 concept page (1 of 4 explored directions). LOCAL-ONLY until Steve picks a direction.

  What it is:
    · One focused job: photograph a wallcovering sample → Shopify DRAFT. Nothing else.
    · Exactly THREE big always-visible sliders — Exposure · Warmth · Saturation — mapped onto
      the EXISTING shared CapturePipeline engine (/js/capture-pipeline.js, TK-12115). Same
      math, same WYSIWYG guarantee, none of the 11-slider panel.
    · No vendor picker, no OCR/extraction UI, no multi-shot batch mode. Vendor/mfr#/name/color
      arrive by background auto-fill (server /api/extract) into four plain text fields.

  SPEED ARCHITECTURE (the Jev principle — one decisive network exchange, minimum round trips):
    1. The adjustment math bakes EXACTLY ONCE per capture, at shutter time, full-res
       (CapturePipeline.apply with capture:true). The result screen shows the already-baked
       JPEG — nothing is re-processed after capture. (The ~80ms live preview necessarily runs
       the same apply() per frame at ≤640px — that is what makes it WYSIWYG — but post-shutter
       there is zero further pixel work.)
    2. The FIRST network call (/api/extract with the baked JPEG) fires SPECULATIVELY inside
       the shutter handler, in the same tick the bake finishes — BEFORE the confirm/retake
       screen appears. Retake aborts it via AbortController (cancel-if-retake pattern:
       ATTEMPTED AND SHIPPED — extract is idempotent + read-only, so speculation is free).
    3. Save Draft is a SINGLE /api/create-item {commit:true} — Pro Booth deliberately skips
       the desk tool's extra dry-run "preview" round trip (index.html does preview→commit =
       2 sequential create-item calls). Safe because the server result is a DRAFT (never
       auto-published) and the server dedups on mfr# either way.
    4. /api/create-item itself could NOT be fired speculatively at shutter: it requires
       mfr+vendor, which don't exist until extract returns or the operator types them. The
       speculative slot therefore goes to extract — the only call that's ready at shutter.

  HARD RULES preserved from the existing pipeline (see /js/capture-pipeline.js header):
    · manual canvas pixel math only — NO WebGL, NO ctx.filter, NO CSS filters (ctx.filter
      doesn't survive toDataURL() and is unreliable on iOS Safari).
    · preview == capture (same apply(), same tune).
  ═══════════════════════════════════════════════════════════════════════════════════════════
-->
<html lang="en">
<head>
<meta charset="utf-8">
<script>
// Credential-safe fetch guard (fleet drop-in) — 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 any
// relative or root-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 of baseURI. 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 name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#111008">
<title>DW Pro Booth</title>
<style>
  :root{
    --bg:#111008; --panel:#1b1912; --line:#2c2920; --ink:#f2ede1; --dim:#9a917d;
    --gold:#c8a24a; --gold2:#e2bd66; --ok:#5fbf7a; --err:#e0483a;
    --rail:132px;
  }
  *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
  html,body{height:100%;background:var(--bg);color:var(--ink);
    font:15px/1.45 -apple-system,'SF Pro Text','Helvetica Neue',Helvetica,Arial,sans-serif;
    overscroll-behavior:none}
  body{position:fixed;inset:0;overflow:hidden;
    padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
  .screen{position:absolute;inset:0;display:flex;flex-direction:column}
  [hidden]{display:none!important}
  button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}

  /* ── top bar (shared) ── */
  .bar{display:flex;align-items:center;gap:10px;padding:12px 16px;flex:0 0 auto}
  .brand{font-size:12px;letter-spacing:.34em;color:var(--gold);font-weight:600;text-transform:uppercase;white-space:nowrap}
  .brand b{color:var(--ink);font-weight:600}
  .bar .sp{flex:1}
  .chip{border:1px solid var(--line);border-radius:999px;padding:7px 14px;font-size:12.5px;color:var(--dim);background:rgba(0,0,0,.35)}
  .chip:active{color:var(--ink);border-color:var(--gold)}
  .bar .chip{white-space:nowrap}

  /* ── LIVE ── */
  #live{background:#000}
  .stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden}
  #pv{max-width:100%;max-height:100%;display:block;background:#000}
  #vid{display:none}
  .stage .hint{position:absolute;left:0;right:0;bottom:10px;text-align:center;font-size:12px;color:rgba(242,237,225,.75);
    text-shadow:0 1px 4px rgba(0,0,0,.8);pointer-events:none}
  #camMsg{position:absolute;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;
    text-align:center;padding:28px;color:var(--dim);font-size:14px}
  #camMsg button{border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:12px 22px;font-size:15px}

  /* ── controls: the 3 sliders + shutter ── */
  .controls{flex:0 0 auto;background:linear-gradient(0deg,#15130c 82%,rgba(21,19,12,0));padding:6px 16px 14px;
    display:flex;flex-direction:column;gap:2px}
  .sl{display:grid;grid-template-columns:96px 1fr 52px;align-items:center;gap:12px;padding:7px 0}
  .sl label{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);font-weight:600}
  .sl output{font-size:14px;color:var(--gold2);text-align:right;font-variant-numeric:tabular-nums}
  input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:44px;background:transparent;touch-action:none}
  input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;
    background:linear-gradient(90deg,var(--line) 0 49.5%,var(--gold) 49.5% 50.5%,var(--line) 50.5% 100%)}
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:30px;height:30px;border-radius:50%;
    background:radial-gradient(circle at 34% 30%,#f0d489,var(--gold) 62%);border:2px solid #171509;margin-top:-13px;
    box-shadow:0 2px 8px rgba(0,0,0,.55)}
  input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:var(--line)}
  input[type=range]::-moz-range-thumb{width:30px;height:30px;border-radius:50%;background:var(--gold);border:2px solid #171509}
  .shutter-row{display:flex;align-items:center;justify-content:center;gap:26px;padding-top:8px}
  #shutter{width:78px;height:78px;border-radius:50%;border:4px solid var(--ink);position:relative;flex:0 0 auto}
  #shutter::after{content:'';position:absolute;inset:6px;border-radius:50%;background:var(--ink);transition:transform .08s}
  #shutter:active::after{transform:scale(.86)}
  #shutter:disabled{opacity:.35}
  .side-slot{width:78px;display:flex;justify-content:center}
  #fullResBtn{white-space:nowrap}

  /* iPad / landscape: sliders become a calm right rail, shutter below them */
  @media (orientation:landscape) and (min-width:700px){
    #live{flex-direction:row}
    #live .col-main{flex:1;display:flex;flex-direction:column;min-width:0}
    .controls{flex:0 0 236px;background:var(--panel);border-left:1px solid var(--line);
      padding:18px 20px calc(18px + env(safe-area-inset-bottom));justify-content:center;gap:6px}
    .sl{grid-template-columns:1fr 52px;grid-template-rows:auto auto;padding:10px 0}
    .sl label{grid-column:1}
    .sl output{grid-column:2}
    .sl input{grid-column:1 / span 2;grid-row:2}
    .shutter-row{padding-top:22px}
  }

  /* ── RESULT ── */
  #result{background:var(--bg)}
  .r-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;padding:0 16px 16px;overflow:auto}
  .r-photo{flex:0 0 auto;display:flex;justify-content:center}
  .r-photo img{max-width:100%;max-height:44vh;border-radius:12px;border:1px solid var(--line)}
  .f-strip{display:grid;grid-template-columns:1fr 1fr;gap:10px}
  .f{display:flex;flex-direction:column;gap:5px}
  .f span{font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--dim);font-weight:600}
  .f input{background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink);
    font:inherit;font-size:16px;padding:12px 12px;min-height:46px;width:100%}
  .f input:focus{outline:none;border-color:var(--gold)}
  .f input.need{border-color:var(--err)}
  #xstat{font-size:12.5px;color:var(--dim);min-height:18px}
  #xstat.on{color:var(--gold2)}
  .r-actions{display:flex;gap:12px;padding-top:2px}
  .btn{flex:1;border-radius:12px;padding:16px;font-size:16px;font-weight:600;text-align:center;min-height:54px}
  .btn.ghost{border:1px solid var(--line);color:var(--dim)}
  .btn.ghost:active{color:var(--ink)}
  .btn.gold{background:var(--gold);color:#171308}
  .btn.gold:active{background:var(--gold2)}
  .btn:disabled{opacity:.4}
  #timing{font-size:11px;color:#6f684f;font-variant-numeric:tabular-nums;text-align:center;padding-bottom:4px}
  @media (orientation:landscape) and (min-width:700px){
    .r-body{flex-direction:row;align-items:stretch;gap:22px;padding:0 22px 18px}
    .r-photo{flex:1.2;align-items:center}
    .r-photo img{max-height:100%}
    .r-form{flex:1;display:flex;flex-direction:column;gap:14px;justify-content:center;max-width:430px}
  }

  /* ── SAVED ── */
  #saved{align-items:center;justify-content:center;gap:16px;text-align:center;padding:24px}
  #saved .big{font-size:52px}
  #saved .sku{font-size:26px;font-weight:700;color:var(--gold2);letter-spacing:.04em}
  #saved .sub{color:var(--dim);font-size:13.5px;max-width:340px}
  #saved .btn{flex:0 0 auto;min-width:240px}
  #sessN{font-size:12px;color:var(--dim)}

  /* toast */
  #toast{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom));transform:translate(-50%,20px);
    background:#232015;border:1px solid var(--line);color:var(--ink);border-radius:12px;
    padding:12px 18px;font-size:14px;opacity:0;pointer-events:none;transition:.22s;z-index:99;max-width:86vw;text-align:center}
  #toast.on{opacity:1;transform:translate(-50%,0)}
  .flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:50}
  .flash.on{opacity:.85;transition:none}
</style>
</head>
<body>

<!-- ════════ SCREEN 1 · LIVE ════════ -->
<div class="screen" id="live">
  <div class="col-main">
    <div class="bar">
      <div class="brand">DW <b>Pro Booth</b></div>
      <div class="sp"></div>
      <button class="chip" id="resetBtn" title="Reset adjustments">↺ Reset</button>
      <button class="chip" id="homeBtn" title="Back to full app">✕</button>
    </div>
    <div class="stage">
      <video id="vid" autoplay playsinline muted></video>
      <canvas id="pv"></canvas>
      <div class="flash" id="flash"></div>
      <div class="hint" id="liveHint">Frame the sample · adjust · shoot</div>
      <div id="camMsg" hidden>
        <div id="camMsgTxt">Camera unavailable.</div>
        <button id="snapFallback">📷 Use the snap camera</button>
        <input type="file" id="fileInput" accept="image/*" capture="environment" hidden>
      </div>
    </div>
  </div>
  <div class="controls">
    <div class="sl">
      <label for="sExposure">Exposure</label>
      <input type="range" id="sExposure" min="-100" max="100" step="1" value="0">
      <output id="oExposure">0</output>
    </div>
    <div class="sl">
      <label for="sWarmth">Warmth</label>
      <input type="range" id="sWarmth" min="-100" max="100" step="1" value="0">
      <output id="oWarmth">0</output>
    </div>
    <div class="sl">
      <label for="sSaturation">Saturation</label>
      <input type="range" id="sSaturation" min="-100" max="100" step="1" value="0">
      <output id="oSaturation">0</output>
    </div>
    <div class="shutter-row">
      <div class="side-slot"><button class="chip" id="fullResBtn">📷 Full-res</button></div>
      <button id="shutter" aria-label="Take photo" disabled></button>
      <div class="side-slot"><span id="sessN"></span></div>
    </div>
  </div>
</div>

<!-- ════════ SCREEN 2 · RESULT ════════ -->
<div class="screen" id="result" hidden>
  <div class="bar">
    <div class="brand">DW <b>Pro Booth</b></div>
    <div class="sp"></div>
    <div class="chip" style="border-color:transparent">baked · full res</div>
  </div>
  <div class="r-body">
    <div class="r-photo"><img id="shotImg" alt="Captured sample"></div>
    <div class="r-form">
      <div id="xstat">Reading the label…</div>
      <div class="f-strip">
        <div class="f"><span>Vendor</span><input id="fVendor" autocomplete="off" placeholder="required"></div>
        <div class="f"><span>Mfr # / SKU</span><input id="fMfr" autocomplete="off" placeholder="required"></div>
        <div class="f"><span>Pattern name</span><input id="fName" autocomplete="off" placeholder="optional"></div>
        <div class="f"><span>Color</span><input id="fColor" autocomplete="off" placeholder="optional"></div>
      </div>
      <div class="r-actions">
        <button class="btn ghost" id="retakeBtn">↩ Retake</button>
        <button class="btn gold" id="saveBtn">Save draft</button>
      </div>
      <div id="timing"></div>
    </div>
  </div>
</div>

<!-- ════════ SCREEN 3 · SAVED ════════ -->
<div class="screen" id="saved" hidden>
  <div class="big">✓</div>
  <div class="sku" id="savedSku">DW—</div>
  <div class="sub" id="savedDwUni" hidden></div>
  <div class="sub">Saved as a Shopify <b>draft</b> — never auto-published. Adjustments and camera stay warm for the next sample.</div>
  <button class="btn gold" id="nextBtn">Next sample</button>
  <div id="sessN2"></div>
</div>

<div id="toast"></div>

<script src="/js/capture-pipeline.js"></script>
<script src="/js/acquire-camera.js"></script>
<script src="/js/field-clean.js"></script>
<script src="/js/native-photo.js"></script>
<script src="/js/adjust-panel.js"></script>
<script>
'use strict';
/* Pro Booth client — see the header comment for the speed architecture. */
const $ = s => document.querySelector(s);
let toastT;
function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('on'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('on'),2600); }

/* ── the trimmed tune: 3 controls over the FULL shared engine (everything else stays 0) ──
   Exposure -> tune.exposure · Warmth -> tune.temp · Saturation -> tune.saturation.
   Persisted under its own key so Pro Booth never clobbers the desk tool's dwTsTune. */
const TUNE_KEY='dwProBoothTune';
let tune = CapturePipeline.loadTune(TUNE_KEY);
const SLIDERS=[['sExposure','exposure','oExposure'],['sWarmth','temp','oWarmth'],['sSaturation','saturation','oSaturation']];
function syncSliders(){ for(const [id,key,out] of SLIDERS){ $('#'+id).value=tune[key]; $('#'+out).textContent=tune[key]; } }
function bindSliders(){
  for(const [id,key,out] of SLIDERS){
    const el=$('#'+id);
    el.addEventListener('input',()=>{
      tune[key]=parseInt(el.value,10)||0;
      $('#'+out).textContent=tune[key];
      CapturePipeline.saveTune(TUNE_KEY,tune); panel.sync();
      hwNudge();
    });
  }
  $('#resetBtn').addEventListener('click',()=>{
    tune=CapturePipeline.defaultTune(); CapturePipeline.saveTune(TUNE_KEY,tune);
    syncSliders(); panel.sync(); hwNudge(); toast('Adjustments reset');
  });
}
/* best-effort hardware assist (never authoritative — the software bake owns WYSIWYG) */
let hwT=null;
function hwNudge(){ clearTimeout(hwT); hwT=setTimeout(()=>{ try{ const tr=acq.getTrack(); if(tr) CapturePipeline.Hardware.apply(tr,tune); }catch(e){} },250); }
/* all 11 adjustments: the panel edits the SAME tune object the 3 big sliders, preview and bake use */
const panel = AdjustPanel.create({ getTune:()=>tune, toggleText:'🎚 All 11', toggleParent: document.querySelector('#live .bar'), toggleClass:'chip',
  onChange:()=>{ CapturePipeline.saveTune(TUNE_KEY,tune); syncSliders(); hwNudge(); } });
$('#resetBtn').before(panel.toggle);

/* ── camera (shared hardened acquirer — bounded getUserMedia + play, generation-guarded) ── */
const acq = AcquireCamera.createCameraAcquirer();
let live=false, pvTimer=null, wake=null, fallbackShot=null;
async function startCam(){
  live=false; $('#shutter').disabled=true; $('#camMsg').hidden=true;
  if(!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
    return camDead('Live camera needs HTTPS (Safari on iPad/iPhone).');
  }
  try{
    await acq.acquire(
      [ {video:{facingMode:{ideal:'environment'},width:{ideal:4096},height:{ideal:3072}},audio:false},
        {video:{facingMode:{ideal:'environment'}},audio:false},
        {video:true,audio:false} ],
      { videoEl: $('#vid'), onDead: ()=>camDead('Camera stopped — tap to restart.') });
  }catch(e){
    return camDead(e && e.message==='camera-timeout' ? 'Camera didn’t respond — tap to retry, or use the snap camera.' : 'Camera blocked — allow camera access, or use the snap camera.');
  }
  live=true; $('#shutter').disabled=false;
  if(pvTimer) clearInterval(pvTimer);
  pvTimer=setInterval(previewTick,80);
  hwNudge();
  try{ if('wakeLock' in navigator) wake=await navigator.wakeLock.request('screen'); }catch(e){}
}
function camDead(msg){
  live=false; $('#shutter').disabled=true;
  $('#camMsgTxt').textContent=msg; $('#camMsg').hidden=false;
}
$('#camMsg').addEventListener('click',e=>{ if(e.target.id!=='snapFallback') startCam(); });
$('#snapFallback').addEventListener('click',()=>$('#fileInput').click());
/* snap-camera fallback: the file still goes through the SAME one-time bake (sliders apply) */
$('#fileInput').addEventListener('change',async ev=>{
  const f=ev.target.files && ev.target.files[0]; if(!f) return;
  const url=await new Promise(res=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.readAsDataURL(f); });
  const img=await new Promise(res=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=url; });
  if(!img) return toast('Could not read that photo');
  const baked=bakeFrom(img);
  if(baked) shutterLand(baked);
  ev.target.value='';
});

/* ── live WYSIWYG preview — same apply(), ≤640px, capture:false (per the shared-engine contract) ── */
function previewTick(){
  if(!live || !$('#result').hidden || !$('#saved').hidden) return;
  const v=$('#vid'), w=v.videoWidth, h=v.videoHeight; if(!w||!h) return;
  const scale=Math.min(1,640/Math.max(w,h)), pw=Math.round(w*scale), ph=Math.round(h*scale);
  const pv=$('#pv'); if(pv.width!==pw)pv.width=pw; if(pv.height!==ph)pv.height=ph;
  const x=pv.getContext('2d',{willReadFrequently:true});
  try{
    CapturePipeline.drawSource(x,v,pw,ph,tune.straighten);
    CapturePipeline.apply(x,pw,ph,tune,{capture:false});
  }catch(e){}
}

/* ── THE BAKE — exactly once per capture, full res, at shutter time ── */
const MAX_EDGE=2000;   // same 25MB-body-cap guard as the desk tool
function bakeFrom(source, maxEdge){
  const sw=source.videoWidth||source.naturalWidth||source.width;
  const sh=source.videoHeight||source.naturalHeight||source.height;
  if(!sw||!sh) return null;
  let w=sw,h=sh; const m=Math.max(w,h);
  const cap=maxEdge||MAX_EDGE;
  if(m>cap){ const k=cap/m; w=Math.round(w*k); h=Math.round(h*k); }
  const c=document.createElement('canvas'); c.width=w; c.height=h;
  const ctx=c.getContext('2d',{willReadFrequently:true});
  const t0=performance.now();
  try{
    CapturePipeline.drawSource(ctx,source,w,h,tune.straighten);
    CapturePipeline.apply(ctx,w,h,tune,{capture:true});   // ← the ONE full-res bake
  }catch(e){ return null; }
  const url=c.toDataURL('image/jpeg',0.92);
  perf.bakeMs=Math.round(performance.now()-t0);
  perf.px=w+'×'+h;
  return url;
}

/* ── perf instrumentation (shown small on the result screen; logged to console) ── */
let perf={};

/* ── speculative extract: fired in the SAME handler tick as the bake, aborted on retake ── */
let xCtl=null, xPromise=null, shotUrl=null;
function fireExtract(url){
  xCtl=new AbortController();
  perf.extractStartMs=Math.round(performance.now()-perf.shutterT0);
  const p=fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({dataUrl:url}),signal:xCtl.signal})
    .then(r=>r.json())
    .then(r=>{ perf.extractMs=Math.round(performance.now()-perf.shutterT0-perf.extractStartMs); return r; })
    .catch(e=>({ok:false,aborted:e&&e.name==='AbortError',err:String(e&&e.message||e)}));
  xPromise=p;
  p.then(r=>{
    if(xPromise!==p || $('#result').hidden) return;      // stale (retaken) — ignore
    const st=$('#xstat');
    if(r.aborted) return;
    if(!r || !r.ok || !r.fields){ st.textContent='Label read unavailable — type the vendor + mfr#.'; st.classList.remove('on'); showTiming(); return; }
    const f=r.fields;
    fillIfEmpty('fVendor', r.vendor_matched || f.vendor);
    fillIfEmpty('fMfr',    f.mfr_sku);
    fillIfEmpty('fName',   f.pattern_name);
    fillIfEmpty('fColor',  f.color);
    extracted=f;
    st.textContent='✓ Label read — check the fields, then Save.'; st.classList.add('on');
    showTiming();
    if(panel.autoSave() && r.vendor_registered===true && $('#fVendor').value.trim() && $('#fMfr').value.trim() && !$('#saveBtn').disabled){
      st.textContent='✓ Label read — saving draft…'; $('#saveBtn').click();
    }
  });
}
let extracted={};
function fillIfEmpty(id,v){ const el=$('#'+id); const c=cleanField(v); if(el && !el.value.trim() && c) el.value=c; }
function showTiming(){
  const t=[];
  if(perf.bakeMs!=null) t.push('bake '+perf.bakeMs+'ms @ '+perf.px);
  if(perf.extractStartMs!=null) t.push('upload started +'+perf.extractStartMs+'ms after shutter');
  if(perf.extractMs!=null) t.push('extract '+perf.extractMs+'ms');
  if(perf.saveMs!=null) t.push('save '+perf.saveMs+'ms');
  $('#timing').textContent=t.join(' · ');
  if(t.length) console.log('[probooth perf]',t.join(' · '));
}

/* ── full-res: the phone's own Camera app photo, baked ONCE with the same 3 sliders at up to
      NativePhoto.FULL_EDGE; the unbaked 1600px copy feeds the label read (OCR ignores tone). ── */
$('#fullResBtn').addEventListener('click',()=>{
  NativePhoto.pick().then(p=>{
    if(!p) return;
    const img=new Image();
    img.onload=()=>{
      perf={shutterT0:performance.now()};
      const baked=bakeFrom(img, NativePhoto.FULL_EDGE);
      if(baked) shutterLand(baked, p.small); else toast('Could not process that photo');
    };
    img.onerror=()=>toast('Could not read that photo');
    img.src=p.full;
  });
});

/* ── shutter ── */
$('#shutter').addEventListener('click',()=>{
  if(!live) return;
  perf={shutterT0:performance.now()};
  const fl=$('#flash'); fl.classList.add('on'); setTimeout(()=>fl.classList.remove('on'),120);
  if(navigator.vibrate) try{ navigator.vibrate(30); }catch(e){}
  const url=bakeFrom($('#vid'));
  if(!url) return toast('Capture failed — try again');
  shutterLand(url);
});
function shutterLand(url, extractUrl){
  shotUrl=url; extracted={};
  if(!perf.shutterT0) perf={shutterT0:performance.now()};
  fireExtract(extractUrl||url);                      // ← network starts NOW, before the result screen paints
  $('#shotImg').src=url;
  ['fVendor','fMfr','fName','fColor'].forEach(id=>{ const el=$('#'+id); el.value=''; el.classList.remove('need'); });
  $('#xstat').textContent='Reading the label…'; $('#xstat').classList.remove('on');
  $('#timing').textContent=''; showTiming();
  $('#saveBtn').disabled=false;
  $('#live').hidden=true; $('#result').hidden=false;
}

/* ── retake: cancel the speculative upload, straight back to live ── */
$('#retakeBtn').addEventListener('click',()=>{
  if(xCtl){ try{ xCtl.abort(); }catch(e){} xCtl=null; }
  xPromise=null; shotUrl=null;
  $('#result').hidden=true; $('#live').hidden=false;
  if(!live) startCam();
});

/* ── save: ONE decisive commit call (no dry-run preview round trip) ── */
let sess=0;
$('#saveBtn').addEventListener('click',async ()=>{
  const vendor=$('#fVendor').value.trim(), mfr=$('#fMfr').value.trim();
  $('#fVendor').classList.toggle('need',!vendor); $('#fMfr').classList.toggle('need',!mfr);
  if(!vendor||!mfr){ toast('Vendor + mfr# are required'); return; }
  const b=$('#saveBtn'); b.disabled=true; $('#xstat').textContent='Saving draft…'; $('#xstat').classList.add('on');
  const t0=performance.now();
  const payload=Object.assign({}, extracted, {
    vendor, mfr,
    name:$('#fName').value.trim(), color:$('#fColor').value.trim(),
    dataUrl:shotUrl, photos:[shotUrl],
    require_two:true, front_present:true, back_present:false,
    id_source: extracted && extracted.mfr_sku ? 'ocr' : 'manual',
    notes:'Captured via Pro Booth',
    commit:true
  });
  let r;
  try{ r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})).json(); }
  catch(e){ r={ok:false,err:'network — '+(e&&e.message||e)}; }
  perf.saveMs=Math.round(performance.now()-t0); showTiming();
  if(r && r.duplicate){ $('#xstat').textContent='⚠ '+(r.err||'Already in the catalog')+' — use the full app’s Update SKU to add media.'; $('#xstat').classList.remove('on'); b.disabled=false; return; }
  if(!r || !r.ok){ $('#xstat').textContent='✗ '+((r&&r.err)||'save failed'); $('#xstat').classList.remove('on'); b.disabled=false; return; }
  sess++;
  $('#savedSku').textContent=r.dw_sku||'saved';
  // 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 shown independently.
  const dwuEl=$('#savedDwUni');
  if(r.dw_unified){ dwuEl.hidden=false; dwuEl.style.color=r.dw_unified.committed?'var(--ok)':'var(--err)';
    dwuEl.textContent=r.dw_unified.committed?'✓ saved to dw_unified':'⚠ dw_unified write failed'; }
  else dwuEl.hidden=true;
  $('#sessN').textContent=sess+' ✓'; $('#sessN2').textContent=sess+' saved this session';
  $('#result').hidden=true; $('#saved').hidden=false;
  toast('✅ Draft created'+(r.dw_sku?': '+r.dw_sku:''));
});

/* ── next: adjustments + camera stay warm ── */
$('#nextBtn').addEventListener('click',()=>{
  shotUrl=null; extracted={}; perf={};
  $('#saved').hidden=true; $('#live').hidden=false;
  if(!live) startCam();
});
$('#homeBtn').addEventListener('click',()=>{ location.href='/app'; });

/* iOS backgrounding pauses the stream — re-assert on return */
document.addEventListener('visibilitychange',()=>{
  if(document.visibilityState==='visible' && !$('#live').hidden && acq.getStream()){
    $('#vid').play().catch(()=>{});
    try{ if('wakeLock' in navigator) navigator.wakeLock.request('screen').then(w=>wake=w).catch(()=>{}); }catch(e){}
  }
});

/* boot */
syncSliders(); bindSliders(); startCam();
</script>
</body>
</html>