← 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
A public/simple-probooth.htmlA scripts/probooth-figma-build.js
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 →