[object Object]

← back to Dw Photo Capture

TK-12162 Pro Booth concept: simple-probooth.html (3-slider capture, bake-once + speculative extract, single-commit save) + staged Figma build script

dc3cc712b3580f4eaf4b6d960cc621170ef23b86 · 2026-09-24 13:08:29 -0700 · Steve Abrams

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

Files touched

Diff

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

    TK-12162 Pro Booth concept: simple-probooth.html (3-slider capture, bake-once + speculative extract, single-commit save) + staged Figma build script
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 public/simple-probooth.html     | 475 ++++++++++++++++++++++++++++++++++++++++
 scripts/probooth-figma-build.js | 179 +++++++++++++++
 2 files changed, 654 insertions(+)

diff --git a/public/simple-probooth.html b/public/simple-probooth.html
new file mode 100644
index 0000000..592b72b
--- /dev/null
+++ b/public/simple-probooth.html
@@ -0,0 +1,475 @@
+<!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">
+<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)}
+
+  /* ── 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}
+
+  /* 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"></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">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>
+'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);
+      hwNudge();
+    });
+  }
+  $('#resetBtn').addEventListener('click',()=>{
+    tune=CapturePipeline.defaultTune(); CapturePipeline.saveTune(TUNE_KEY,tune);
+    syncSliders(); 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); }
+
+/* ── 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,0);
+    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){
+  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);
+  if(m>MAX_EDGE){ const k=MAX_EDGE/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,0);
+    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();
+  });
+}
+let extracted={};
+function fillIfEmpty(id,v){ const el=$('#'+id); if(el && !el.value.trim() && v) el.value=String(v).trim(); }
+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(' · '));
+}
+
+/* ── 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){
+  shotUrl=url; extracted={};
+  if(!perf.shutterT0) perf={shutterT0:performance.now()};
+  fireExtract(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';
+  $('#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='/'; });
+
+/* 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>
diff --git a/scripts/probooth-figma-build.js b/scripts/probooth-figma-build.js
new file mode 100644
index 0000000..ffcc849
--- /dev/null
+++ b/scripts/probooth-figma-build.js
@@ -0,0 +1,179 @@
+// use_figma build script — "DW Photo — Pro Booth (iPad/Phone)" (TK-12162).
+// Run in ONE use_figma call after create_new_file. Builds 6 frames on one page:
+// iPad landscape 1024×768 (Live / Result / Saved) + Phone 390×844 (Live / Result / Saved).
+(async () => {
+  await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
+  await figma.loadFontAsync({ family: 'Inter', style: 'Semi Bold' });
+  await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
+
+  const C = {
+    bg: { r: 0.067, g: 0.063, b: 0.031 },      // #111008
+    panel: { r: 0.106, g: 0.098, b: 0.071 },   // #1b1912
+    line: { r: 0.173, g: 0.161, b: 0.125 },    // #2c2920
+    ink: { r: 0.949, g: 0.929, b: 0.882 },     // #f2ede1
+    dim: { r: 0.604, g: 0.569, b: 0.490 },     // #9a917d
+    gold: { r: 0.784, g: 0.635, b: 0.290 },    // #c8a24a
+    gold2: { r: 0.886, g: 0.741, b: 0.400 },   // #e2bd66
+    black: { r: 0, g: 0, b: 0 },
+    darkBtn: { r: 0.090, g: 0.075, b: 0.031 }, // #171308
+    stage: { r: 0.16, g: 0.15, b: 0.13 },
+  };
+  const fill = c => [{ type: 'SOLID', color: c }];
+  function txt(parent, s, size, color, style, ls) {
+    const t = figma.createText();
+    t.fontName = { family: 'Inter', style: style || 'Regular' };
+    t.characters = s; t.fontSize = size; t.fills = fill(color);
+    if (ls) t.letterSpacing = { value: ls, unit: 'PERCENT' };
+    parent.appendChild(t); return t;
+  }
+  function rect(parent, w, h, c, r) {
+    const n = figma.createRectangle(); n.resize(w, h); n.fills = fill(c);
+    if (r) n.cornerRadius = r; parent.appendChild(n); return n;
+  }
+  function frameNode(name, w, h, x, y, c) {
+    const f = figma.createFrame(); f.name = name; f.resize(w, h); f.x = x; f.y = y;
+    f.fills = fill(c || C.bg); figma.currentPage.appendChild(f); return f;
+  }
+  // slider group: label row (label + value) over a track with a gold round thumb
+  function slider(parent, x, y, w, label, val, pct) {
+    const g = figma.createFrame(); g.name = 'Slider · ' + label;
+    g.resize(w, 56); g.x = x; g.y = y; g.fills = [];
+    parent.appendChild(g);
+    const l = txt(g, label.toUpperCase(), 11, C.dim, 'Semi Bold', 18); l.x = 0; l.y = 0;
+    const v = txt(g, String(val), 14, C.gold2, 'Regular'); v.x = w - 34; v.y = -2; v.textAlignHorizontal = 'RIGHT';
+    const track = rect(g, w, 4, C.line, 2); track.x = 0; track.y = 34;
+    const notch = rect(g, 2, 8, C.gold, 1); notch.x = w / 2 - 1; notch.y = 32;
+    const th = figma.createEllipse(); th.resize(30, 30);
+    th.fills = fill(C.gold); th.strokes = fill(C.darkBtn); th.strokeWeight = 2;
+    th.x = Math.round((w - 30) * pct); th.y = 21; th.effects = [{ type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.55 }, offset: { x: 0, y: 2 }, radius: 8, visible: true, blendMode: 'NORMAL' }];
+    g.appendChild(th);
+    return g;
+  }
+  function shutter(parent, cx, cy, d) {
+    d = d || 78;
+    const o = figma.createEllipse(); o.resize(d, d); o.x = cx - d / 2; o.y = cy - d / 2;
+    o.fills = []; o.strokes = fill(C.ink); o.strokeWeight = 4; parent.appendChild(o);
+    const i = figma.createEllipse(); i.resize(d - 16, d - 16); i.x = cx - (d - 16) / 2; i.y = cy - (d - 16) / 2;
+    i.fills = fill(C.ink); parent.appendChild(i);
+  }
+  function chip(parent, x, y, label, goldBorder) {
+    const g = figma.createFrame(); g.name = 'Chip'; g.layoutMode = 'HORIZONTAL';
+    g.paddingLeft = 14; g.paddingRight = 14; g.paddingTop = 7; g.paddingBottom = 7;
+    g.primaryAxisSizingMode = 'AUTO'; g.counterAxisSizingMode = 'AUTO';
+    g.cornerRadius = 999; g.fills = [{ type: 'SOLID', color: C.black, opacity: 0.35 }];
+    g.strokes = fill(goldBorder ? C.gold : C.line); g.strokeWeight = 1;
+    g.x = x; g.y = y; parent.appendChild(g);
+    txt(g, label, 12.5, C.dim, 'Regular');
+    return g;
+  }
+  function brand(parent, x, y) {
+    const t = txt(parent, 'DW  PRO BOOTH', 12, C.gold, 'Semi Bold', 34); t.x = x; t.y = y; return t;
+  }
+  function field(parent, x, y, w, label, value, placeholder) {
+    const l = txt(parent, label.toUpperCase(), 10.5, C.dim, 'Semi Bold', 20); l.x = x; l.y = y;
+    const box = rect(parent, w, 46, C.panel, 10); box.x = x; box.y = y + 18;
+    box.strokes = fill(C.line); box.strokeWeight = 1;
+    const v = txt(parent, value || placeholder || '', 16, value ? C.ink : C.dim, 'Regular');
+    v.x = x + 12; v.y = y + 31;
+  }
+  function button(parent, x, y, w, label, gold) {
+    const b = rect(parent, w, 54, gold ? C.gold : C.bg, 12); b.x = x; b.y = y;
+    if (!gold) { b.strokes = fill(C.line); b.strokeWeight = 1; b.fills = []; }
+    const t = txt(parent, label, 16, gold ? C.darkBtn : C.dim, 'Semi Bold');
+    t.resize(w, 20); t.textAlignHorizontal = 'CENTER'; t.x = x; t.y = y + 17;
+  }
+
+  figma.currentPage.name = 'Pro Booth · iPad + Phone';
+
+  /* ═══ iPad · LIVE (1024×768) ═══ */
+  let f = frameNode('iPad · 1 · Live', 1024, 768, 0, 0, C.black);
+  // stage
+  const stage = rect(f, 788, 768, C.stage); stage.x = 0; stage.y = 0;
+  const sampleHint = txt(f, 'live viewfinder — WYSIWYG preview (same bake as capture)', 12, C.dim, 'Regular');
+  sampleHint.resize(788, 16); sampleHint.textAlignHorizontal = 'CENTER'; sampleHint.x = 0; sampleHint.y = 730;
+  brand(f, 16, 14); chip(f, 806 - 196, 10, '↺ Reset'); chip(f, 806 - 106, 10, '✕');
+  // right rail
+  const rail = rect(f, 236, 768, C.panel); rail.x = 788; rail.y = 0;
+  const railLine = rect(f, 1, 768, C.line); railLine.x = 788; railLine.y = 0;
+  slider(f, 808, 170, 196, 'Exposure', '+12', 0.56);
+  slider(f, 808, 258, 196, 'Warmth', '-8', 0.46);
+  slider(f, 808, 346, 196, 'Saturation', '+6', 0.53);
+  shutter(f, 906, 520);
+  const sess = txt(f, '3 ✓', 12, C.dim, 'Regular'); sess.x = 960; sess.y = 514;
+
+  /* ═══ iPad · RESULT ═══ */
+  f = frameNode('iPad · 2 · Result', 1024, 768, 1104, 0);
+  brand(f, 16, 14);
+  const bkchip = chip(f, 850, 10, 'baked · full res'); bkchip.strokes = [];
+  const ph = rect(f, 520, 620, C.stage, 12); ph.x = 22; ph.y = 78; ph.strokes = fill(C.line); ph.strokeWeight = 1;
+  const phLbl = txt(f, 'captured JPEG — already baked, no re-processing', 12, C.dim, 'Regular');
+  phLbl.resize(520, 16); phLbl.textAlignHorizontal = 'CENTER'; phLbl.x = 22; phLbl.y = 380;
+  const fx = 586, fw = 196;
+  const st = txt(f, '✓ Label read — check the fields, then Save.', 12.5, C.gold2, 'Regular'); st.x = fx; st.y = 150;
+  field(f, fx, 186, fw, 'Vendor', 'Schumacher');
+  field(f, fx + fw + 14, 186, fw, 'Mfr # / SKU', '5015301');
+  field(f, fx, 268, fw, 'Pattern name', 'Torbay Handprint');
+  field(f, fx + fw + 14, 268, fw, 'Color', 'Aloe');
+  button(f, fx, 372, fw, '↩ Retake', false);
+  button(f, fx + fw + 14, 372, fw, 'Save draft', true);
+  const tm = txt(f, 'bake 60ms @ 2000×1125 · upload started +61ms after shutter · extract 808ms', 11, { r: 0.435, g: 0.408, b: 0.310 }, 'Regular');
+  tm.resize(fw * 2 + 14, 14); tm.textAlignHorizontal = 'CENTER'; tm.x = fx; tm.y = 446;
+
+  /* ═══ iPad · SAVED ═══ */
+  f = frameNode('iPad · 3 · Saved', 1024, 768, 2208, 0);
+  const ck = txt(f, '✓', 52, C.ink, 'Regular'); ck.resize(1024, 60); ck.textAlignHorizontal = 'CENTER'; ck.x = 0; ck.y = 250;
+  const sku = txt(f, 'DWSH-99001', 26, C.gold2, 'Bold', 4); sku.resize(1024, 34); sku.textAlignHorizontal = 'CENTER'; sku.x = 0; sku.y = 330;
+  const sub = txt(f, 'Saved as a Shopify draft — never auto-published.\nAdjustments and camera stay warm for the next sample.', 13.5, C.dim, 'Regular');
+  sub.resize(1024, 40); sub.textAlignHorizontal = 'CENTER'; sub.x = 0; sub.y = 380;
+  button(f, 392, 452, 240, 'Next sample', true);
+  const s2 = txt(f, '3 saved this session', 12, C.dim, 'Regular'); s2.resize(1024, 16); s2.textAlignHorizontal = 'CENTER'; s2.x = 0; s2.y = 522;
+
+  /* ═══ Phone · LIVE (390×844) ═══ */
+  f = frameNode('Phone · 1 · Live', 390, 844, 0, 880, C.black);
+  const pstage = rect(f, 390, 470, C.stage); pstage.x = 0; pstage.y = 0;
+  brand(f, 16, 14); chip(f, 250, 10, '↺'); chip(f, 316, 10, '✕');
+  const ph2 = txt(f, 'Frame the sample · adjust · shoot', 12, C.dim, 'Regular');
+  ph2.resize(390, 16); ph2.textAlignHorizontal = 'CENTER'; ph2.x = 0; ph2.y = 440;
+  const ctl = rect(f, 390, 374, { r: 0.082, g: 0.075, b: 0.047 }); ctl.x = 0; ctl.y = 470;
+  // phone sliders: label 96 + track + value, horizontal rows
+  function phoneSlider(y, label, val, pct) {
+    const l = txt(f, label.toUpperCase(), 11, C.dim, 'Semi Bold', 18); l.x = 16; l.y = y + 6;
+    const v = txt(f, String(val), 14, C.gold2, 'Regular'); v.x = 344; v.y = y + 4;
+    const track = rect(f, 210, 4, C.line, 2); track.x = 118; track.y = y + 12;
+    const notch = rect(f, 2, 8, C.gold, 1); notch.x = 222; notch.y = y + 10;
+    const th = figma.createEllipse(); th.resize(30, 30); th.fills = fill(C.gold);
+    th.strokes = fill(C.darkBtn); th.strokeWeight = 2;
+    th.x = 118 + Math.round(180 * pct); th.y = y - 1; f.appendChild(th);
+  }
+  phoneSlider(500, 'Exposure', '+12', 0.56);
+  phoneSlider(558, 'Warmth', '-8', 0.46);
+  phoneSlider(616, 'Saturation', '+6', 0.53);
+  shutter(f, 195, 730);
+  const psess = txt(f, '3 ✓', 12, C.dim, 'Regular'); psess.x = 285, psess.y = 724;
+
+  /* ═══ Phone · RESULT ═══ */
+  f = frameNode('Phone · 2 · Result', 390, 844, 470, 880);
+  brand(f, 16, 14);
+  const pph = rect(f, 358, 300, C.stage, 12); pph.x = 16; pph.y = 60; pph.strokes = fill(C.line); pph.strokeWeight = 1;
+  const pst = txt(f, '✓ Label read — check the fields, then Save.', 12.5, C.gold2, 'Regular'); pst.x = 16; pst.y = 376;
+  const pfw = 172;
+  field(f, 16, 402, pfw, 'Vendor', 'Schumacher');
+  field(f, 202, 402, pfw, 'Mfr # / SKU', '5015301');
+  field(f, 16, 484, pfw, 'Pattern name', 'Torbay Handprint');
+  field(f, 202, 484, pfw, 'Color', 'Aloe');
+  button(f, 16, 588, pfw, '↩ Retake', false);
+  button(f, 202, 588, pfw, 'Save draft', true);
+  const ptm = txt(f, 'bake 60ms · upload +61ms after shutter · extract 808ms', 11, { r: 0.435, g: 0.408, b: 0.310 }, 'Regular');
+  ptm.resize(358, 14); ptm.textAlignHorizontal = 'CENTER'; ptm.x = 16; ptm.y = 660;
+
+  /* ═══ Phone · SAVED ═══ */
+  f = frameNode('Phone · 3 · Saved', 390, 844, 940, 880);
+  const pck = txt(f, '✓', 52, C.ink, 'Regular'); pck.resize(390, 60); pck.textAlignHorizontal = 'CENTER'; pck.x = 0; pck.y = 280;
+  const psku = txt(f, 'DWSH-99001', 26, C.gold2, 'Bold', 4); psku.resize(390, 34); psku.textAlignHorizontal = 'CENTER'; psku.x = 0; psku.y = 360;
+  const psub = txt(f, 'Saved as a Shopify draft — never auto-published.\nAdjustments and camera stay warm.', 13.5, C.dim, 'Regular');
+  psub.resize(390, 40); psub.textAlignHorizontal = 'CENTER'; psub.x = 0; psub.y = 410;
+  button(f, 75, 486, 240, 'Next sample', true);
+
+  figma.viewport.scrollAndZoomIntoView(figma.currentPage.children);
+  return 'built ' + figma.currentPage.children.length + ' frames';
+})()

← 8452fcc TK-12090: decouple exo VLM ring from HAS_MACVISION, make it  ·  back to Dw Photo Capture  ·  TK-12090: report provider=null when the vision call fails 247c9f9 →