← back to Dw Photo Capture
TK-12090/TK-12162: fix numeric-SKU .trim() crash + de-obfuscate deploy verifier
03f509f2d48df6d76d9dd9eeec3366d0bd5e492e · 2026-09-24 13:31:39 -0700 · Steve Abrams
- simple-minimal.html, simple-wizard.html: fields.mfr_sku from Gemini's JSON
can come back as a number, not a string; (fields.mfr_sku || '').trim()
throws TypeError in that case, silently breaking the capture flow. Coerce
to string first (matches the safe pattern already used in
simple-probooth.html's fillIfEmpty).
- simple-minimal.html: surface the server's read-error message on the
manual-entry fallback card instead of a generic string.
- STEVE-PASTE-tk12090-deploy2.sh: replace the base64-encoded inline Python
verifier with a checked-in, readable script (scripts/verify-vision.py),
piped over ssh stdin. The script now refuses to run with blank
AUTH_USER/AUTH_PASS instead of silently sending an empty Basic Auth header.
Reviewed and code-reviewed; withdrew two other findings (restart-verification
alarm was moot — set -e + && already aborts on a dead /healthz; the 200ms
'timeout' in the Python verifier is actually 200 seconds, not milliseconds).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD
Files touched
A public/simple-minimal.htmlA public/simple-wizard.htmlA scripts/STEVE-PASTE-tk12090-deploy2.shA scripts/STEVE-PASTE-vision-step3.shA scripts/verify-vision.py
Diff
commit 03f509f2d48df6d76d9dd9eeec3366d0bd5e492e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 13:31:39 2026 -0700
TK-12090/TK-12162: fix numeric-SKU .trim() crash + de-obfuscate deploy verifier
- simple-minimal.html, simple-wizard.html: fields.mfr_sku from Gemini's JSON
can come back as a number, not a string; (fields.mfr_sku || '').trim()
throws TypeError in that case, silently breaking the capture flow. Coerce
to string first (matches the safe pattern already used in
simple-probooth.html's fillIfEmpty).
- simple-minimal.html: surface the server's read-error message on the
manual-entry fallback card instead of a generic string.
- STEVE-PASTE-tk12090-deploy2.sh: replace the base64-encoded inline Python
verifier with a checked-in, readable script (scripts/verify-vision.py),
piped over ssh stdin. The script now refuses to run with blank
AUTH_USER/AUTH_PASS instead of silently sending an empty Basic Auth header.
Reviewed and code-reviewed; withdrew two other findings (restart-verification
alarm was moot — set -e + && already aborts on a dead /healthz; the 200ms
'timeout' in the Python verifier is actually 200 seconds, not milliseconds).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD
---
public/simple-minimal.html | 421 ++++++++++++++++++++++++++
public/simple-wizard.html | 537 +++++++++++++++++++++++++++++++++
scripts/STEVE-PASTE-tk12090-deploy2.sh | 10 +
scripts/STEVE-PASTE-vision-step3.sh | 16 +
scripts/verify-vision.py | 35 +++
5 files changed, 1019 insertions(+)
diff --git a/public/simple-minimal.html b/public/simple-minimal.html
new file mode 100644
index 0000000..defb99c
--- /dev/null
+++ b/public/simple-minimal.html
@@ -0,0 +1,421 @@
+<!doctype html>
+<html lang="en">
+<head>
+<script>
+// Credential-safe fetch guard (fleet drop-in, mandatory on every Basic-Auth-gated DW page) — 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 a 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. 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 charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<meta name="apple-mobile-web-app-title" content="DW Photo">
+<meta name="mobile-web-app-capable" content="yes">
+<meta name="theme-color" content="#0f0e0c">
+<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
+<title>DW Photo — Minimal Showroom</title>
+<style>
+ :root{ --bg:#0f0e0c; --panel:#1b1710; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184;
+ --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
+ html,body{height:100%;overscroll-behavior:none}
+ body{margin:0;background:#000;color:var(--ink);font:16px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif;
+ -webkit-text-size-adjust:100%;user-select:none}
+
+ /* ============ STAGE — full-bleed camera / captured photo, zero chrome ============ */
+ #stage{position:fixed;inset:0;background:#000;overflow:hidden}
+ #cam{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000}
+ #cam.mirror{transform:scaleX(-1)}
+ #shot{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;opacity:0;pointer-events:none;transition:opacity .15s}
+ #shot.on{opacity:1}
+ #flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:40}
+ #flash.on{opacity:.9;transition:opacity .35s ease-out}
+
+ /* Vignette so the shutter/gear always read regardless of what the camera sees */
+ #stage::before{content:'';position:absolute;left:0;right:0;bottom:0;height:34%;z-index:5;pointer-events:none;
+ background:linear-gradient(180deg,transparent,rgba(0,0,0,.55) 70%)}
+ #stage::after{content:'';position:absolute;left:0;right:0;top:0;height:14%;z-index:5;pointer-events:none;
+ background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
+
+ /* ============ GEAR — the ONE tucked-away affordance, top-right corner ============ */
+ #gear{position:absolute;top:calc(env(safe-area-inset-top,0px) + 14px);right:calc(env(safe-area-inset-right,0px) + 14px);
+ z-index:30;width:44px;height:44px;border-radius:50%;border:1px solid rgba(255,255,255,.22);
+ background:rgba(15,14,12,.5);backdrop-filter:blur(6px);color:rgba(255,255,255,.75);font-size:20px;
+ display:flex;align-items:center;justify-content:center;cursor:pointer}
+ #gear:active{background:rgba(15,14,12,.75)}
+
+ #drawer{position:absolute;top:calc(env(safe-area-inset-top,0px) + 64px);right:calc(env(safe-area-inset-right,0px) + 14px);
+ z-index:30;width:min(78vw,300px);background:var(--panel);border:1px solid var(--line);border-radius:16px;
+ box-shadow:0 10px 34px rgba(0,0,0,.6);padding:14px;display:none;gap:12px;flex-direction:column}
+ #drawer.open{display:flex}
+ #drawer .d-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
+ #drawer .d-lbl{font-size:13px;color:var(--muted);font-weight:600}
+ #drawer button.d-btn{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:9px;
+ padding:9px 12px;font-size:13px;font-weight:600;cursor:pointer}
+ #drawer button.d-btn.on{border-color:var(--gold);color:var(--gold)}
+ #drawer .d-hd{font:800 13px/1 "SF Pro Display",sans-serif;color:var(--gold);letter-spacing:.03em;text-transform:uppercase;margin-bottom:2px}
+ /* toggle switch */
+ .sw{position:relative;width:44px;height:26px;border-radius:99px;background:#3a352c;border:1px solid var(--line);cursor:pointer;flex:0 0 auto}
+ .sw i{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:var(--muted);transition:left .15s,background .15s}
+ .sw.on{background:#4a3e22}
+ .sw.on i{left:20px;background:var(--gold)}
+
+ /* ============ SHUTTER — the whole interaction, bottom-center, thumb-reachable ============ */
+ #shutterWrap{position:absolute;left:0;right:0;bottom:0;z-index:20;display:flex;justify-content:center;
+ padding-bottom:calc(max(20px,env(safe-area-inset-bottom)) + 22px)}
+ #shutter{width:132px;height:132px;border-radius:50%;border:6px solid var(--gold);background:var(--gold);
+ box-shadow:0 0 0 4px rgba(0,0,0,.4),0 6px 24px rgba(0,0,0,.5);cursor:pointer;flex:0 0 auto;
+ transition:transform .08s ease}
+ #shutter:active{transform:scale(.93)}
+ #shutter:disabled{background:#3a3428;border-color:#3a3428;opacity:.7}
+ #shutter[hidden]{display:none}
+
+ /* ============ PROCESSING — pulsing gold status, no other chrome ============ */
+ #procWrap{position:absolute;left:0;right:0;bottom:0;z-index:25;display:none;flex-direction:column;
+ align-items:center;gap:14px;padding-bottom:calc(max(20px,env(safe-area-inset-bottom)) + 40px)}
+ #procWrap.on{display:flex}
+ .pulse-dot{width:14px;height:14px;border-radius:50%;background:var(--gold);box-shadow:0 0 16px 4px rgba(200,162,74,.7);
+ animation:pulse 1.1s ease-in-out infinite}
+ @keyframes pulse{0%,100%{transform:scale(.8);opacity:.55}50%{transform:scale(1.15);opacity:1}}
+ #procMsg{color:#f0e2b8;font:700 16px/1 -apple-system,sans-serif;text-shadow:0 1px 3px #000;letter-spacing:.01em}
+ #procTime{color:var(--muted);font:600 12px/1 ui-monospace,Menlo,monospace}
+ #procTime[hidden]{display:none}
+
+ /* ============ RESULT — one card, one action ============ */
+ #resultWrap{position:absolute;inset:0;z-index:26;display:none;align-items:flex-end;justify-content:center;
+ padding:0 16px calc(max(20px,env(safe-area-inset-bottom)) + 22px)}
+ #resultWrap.on{display:flex}
+ .rcard{width:100%;max-width:520px;background:var(--panel);border:1px solid var(--line);border-radius:20px;
+ padding:20px;box-shadow:0 14px 40px rgba(0,0,0,.6);display:flex;flex-direction:column;gap:14px;
+ animation:riseIn .22s ease-out}
+ @keyframes riseIn{from{transform:translateY(18px);opacity:0}to{transform:translateY(0);opacity:1}}
+ .rcard.ok .rc-check{color:var(--green)}
+ .rcard.err .rc-check{color:var(--red)}
+ .rc-check{font-size:34px;line-height:1}
+ .rc-title{font:800 19px/1.25 "SF Pro Display",sans-serif;color:var(--ink)}
+ .rc-sub{font:600 13px/1.4 ui-monospace,Menlo,monospace;color:var(--gold)}
+ .rc-detail{font-size:13px;color:var(--muted)}
+ .rc-time{font:600 11px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
+ .rc-time[hidden]{display:none}
+ .rc-again{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:16px;
+ font-size:16px;font-weight:800;cursor:pointer;min-height:56px}
+ .rc-manual{display:none;flex-direction:column;gap:10px}
+ .rc-manual.on{display:flex}
+ .rc-manual select,.rc-manual input{width:100%;background:#16140f;border:1px solid var(--line);color:var(--ink);
+ border-radius:10px;padding:13px 12px;font-size:15px;min-height:48px}
+ .rc-manual select.need,.rc-manual input.need{border-color:var(--red);box-shadow:0 0 0 3px rgba(192,86,63,.3)}
+ .rc-save{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:13px;padding:14px;
+ font-size:15px;font-weight:700;cursor:pointer;min-height:50px}
+
+ /* ============ PERMISSION / ERROR full-screen fallback ============ */
+ #permWrap{position:fixed;inset:0;z-index:60;background:var(--bg);display:none;flex-direction:column;
+ align-items:center;justify-content:center;gap:16px;text-align:center;padding:24px}
+ #permWrap.on{display:flex}
+ #permWrap h1{font:800 20px/1.3 "SF Pro Display",sans-serif;color:var(--gold);margin:0}
+ #permWrap p{color:var(--muted);margin:0;max-width:320px;font-size:14px}
+ #permRetry{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:14px 26px;
+ font-size:15px;font-weight:800;cursor:pointer;min-height:52px}
+</style>
+</head>
+<body>
+
+<div id="stage">
+ <video id="cam" autoplay playsinline muted></video>
+ <img id="shot" alt="">
+ <div id="flash"></div>
+</div>
+
+<button id="gear" aria-label="Settings" title="Settings">⚙</button>
+<div id="drawer">
+ <div class="d-hd">Camera</div>
+ <div class="d-row"><span class="d-lbl">Flip camera</span><button class="d-btn" id="btnFlip">↺ Flip</button></div>
+ <div class="d-hd" style="margin-top:6px">Behavior</div>
+ <div class="d-row"><span class="d-lbl">Auto-create on read</span><div class="sw on" id="swAuto"><i></i></div></div>
+ <div class="d-row"><span class="d-lbl">Show timing</span><div class="sw" id="swTiming"><i></i></div></div>
+ <div class="d-hd" style="margin-top:6px">Vendor override</div>
+ <select id="vendorOverride"><option value="">Auto-detect from label</option></select>
+</div>
+
+<div id="shutterWrap"><button id="shutter" aria-label="Capture"></button></div>
+
+<div id="procWrap">
+ <div class="pulse-dot"></div>
+ <div id="procMsg">Reading label…</div>
+ <div id="procTime" hidden></div>
+</div>
+
+<div id="resultWrap">
+ <div class="rcard" id="rcard">
+ <div class="rc-check" id="rcIcon">✓</div>
+ <div class="rc-title" id="rcTitle">Created</div>
+ <div class="rc-sub" id="rcSku"></div>
+ <div class="rc-detail" id="rcDetail"></div>
+ <div class="rc-time" id="rcTime" hidden></div>
+ <div class="rc-manual" id="rcManual">
+ <select id="mVendor"><option value="">Pick vendor…</option></select>
+ <input id="mMfr" type="text" placeholder="Manufacturer # / SKU" autocapitalize="characters">
+ <button class="rc-save" id="rcSave">Save</button>
+ </div>
+ <button class="rc-again" id="rcAgain">Take Another</button>
+ </div>
+</div>
+
+<div id="permWrap">
+ <h1>Camera access needed</h1>
+ <p id="permMsg">Allow camera access to start photographing samples.</p>
+ <button id="permRetry">Try Again</button>
+</div>
+
+<script>
+(function(){
+ 'use strict';
+
+ // ── DOM ──────────────────────────────────────────────────────────────────
+ var $ = function(id){ return document.getElementById(id); };
+ var camEl = $('cam'), shotEl = $('shot'), flashEl = $('flash');
+ var gearBtn = $('gear'), drawer = $('drawer');
+ var shutterWrap = $('shutterWrap'), shutterBtn = $('shutter');
+ var procWrap = $('procWrap'), procMsg = $('procMsg'), procTime = $('procTime');
+ var resultWrap = $('resultWrap'), rcard = $('rcard'), rcIcon = $('rcIcon'), rcTitle = $('rcTitle'),
+ rcSku = $('rcSku'), rcDetail = $('rcDetail'), rcTime = $('rcTime'), rcAgain = $('rcAgain');
+ var rcManual = $('rcManual'), mVendor = $('mVendor'), mMfr = $('mMfr'), rcSave = $('rcSave');
+ var permWrap = $('permWrap'), permMsg = $('permMsg'), permRetry = $('permRetry');
+ var vendorOverrideSel = $('vendorOverride');
+ var swAuto = $('swAuto'), swTiming = $('swTiming');
+
+ // ── state ────────────────────────────────────────────────────────────────
+ var STATE = 'camera'; // camera | processing | result
+ var stream = null, facing = 'environment';
+ var AUTO_CREATE = true, SHOW_TIMING = false;
+ var VENDORS = []; // warmed speculatively, used only for the manual fallback
+ var capturedDataUrl = null;
+ var lastExtractFields = null;
+
+ // ── SPEED: warm the vendor registry in the background the moment the page loads.
+ // This is pure overlapped/speculative work — it costs nothing on the critical capture
+ // path (nothing waits on it) and means that IF extraction fails to resolve a vendor,
+ // the manual-fallback dropdown is already populated with zero added latency instead of
+ // firing a fresh GET at that moment. ──────────────────────────────────────────────────
+ function warmVendors(){
+ fetch('/api/vendors-registry').then(function(r){ return r.json(); }).then(function(d){
+ VENDORS = (d && d.vendors) || [];
+ var fill = function(sel, withAuto){
+ var html = withAuto ? '<option value="">Auto-detect from label</option>' : '<option value="">Pick vendor…</option>';
+ html += VENDORS.map(function(v){
+ var name = v.vendor || v.real_vendor || '';
+ return name ? '<option value="'+esc(name)+'">'+esc(name)+'</option>' : '';
+ }).join('');
+ sel.innerHTML = html;
+ };
+ fill(vendorOverrideSel, true);
+ fill(mVendor, false);
+ }).catch(function(){ /* non-fatal — manual fallback still works via free-text mfr */ });
+ }
+ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
+
+ // ── camera ───────────────────────────────────────────────────────────────
+ function startCamera(){
+ permWrap.classList.remove('on');
+ if (stream) { stream.getTracks().forEach(function(t){ t.stop(); }); stream = null; }
+ var constraints = { audio:false, video:{ facingMode:{ ideal:facing }, width:{ideal:1920}, height:{ideal:1080} } };
+ navigator.mediaDevices.getUserMedia(constraints).then(function(s){
+ stream = s; camEl.srcObject = s; camEl.classList.toggle('mirror', facing==='user');
+ }).catch(function(e){
+ // fall back to ANY camera before giving up entirely
+ navigator.mediaDevices.getUserMedia({ audio:false, video:true }).then(function(s){
+ stream = s; camEl.srcObject = s;
+ }).catch(function(e2){
+ permMsg.textContent = (e2 && e2.name==='NotAllowedError')
+ ? 'Camera permission was denied. Enable it in Settings and try again.'
+ : 'Could not start the camera: ' + (e2 && e2.message || 'unknown error');
+ permWrap.classList.add('on');
+ });
+ });
+ }
+ permRetry.addEventListener('click', startCamera);
+
+ // ── gear drawer ──────────────────────────────────────────────────────────
+ gearBtn.addEventListener('click', function(){ drawer.classList.toggle('open'); });
+ document.addEventListener('click', function(e){
+ if (drawer.classList.contains('open') && !drawer.contains(e.target) && e.target!==gearBtn) drawer.classList.remove('open');
+ });
+ $('btnFlip').addEventListener('click', function(){ facing = facing==='environment' ? 'user' : 'environment'; startCamera(); });
+ swAuto.addEventListener('click', function(){ AUTO_CREATE = !AUTO_CREATE; swAuto.classList.toggle('on', AUTO_CREATE); });
+ swTiming.addEventListener('click', function(){ SHOW_TIMING = !SHOW_TIMING; swTiming.classList.toggle('on', SHOW_TIMING);
+ procTime.hidden = !SHOW_TIMING; rcTime.hidden = !SHOW_TIMING; });
+
+ // ── state transitions ────────────────────────────────────────────────────
+ function toCamera(){
+ STATE = 'camera';
+ shotEl.classList.remove('on');
+ shutterWrap.style.display = ''; shutterBtn.hidden = false; shutterBtn.disabled = false;
+ procWrap.classList.remove('on');
+ resultWrap.classList.remove('on');
+ rcManual.classList.remove('on');
+ capturedDataUrl = null; lastExtractFields = null;
+ }
+ function toProcessing(msg){
+ STATE = 'processing';
+ shutterBtn.hidden = true;
+ procMsg.textContent = msg || 'Reading label…';
+ procWrap.classList.add('on');
+ }
+ function toResult(){
+ STATE = 'result';
+ procWrap.classList.remove('on');
+ resultWrap.classList.add('on');
+ }
+
+ // ── capture: SPEED — the frame is drawn + shown INSTANTLY (pure client-side canvas work,
+ // no network dependency), so the user sees "shot taken" with zero perceived latency. The
+ // live <video> stream is left running underneath (not stopped/renegotiated), so "Take
+ // Another" needs no getUserMedia round trip either — just hide the frozen frame. ────────
+ var shotCanvas = document.createElement('canvas');
+ function captureFrame(){
+ var w = camEl.videoWidth || 1280, h = camEl.videoHeight || 960;
+ shotCanvas.width = w; shotCanvas.height = h;
+ var ctx = shotCanvas.getContext('2d');
+ if (facing === 'user') { ctx.translate(w,0); ctx.scale(-1,1); }
+ ctx.drawImage(camEl, 0, 0, w, h);
+ return shotCanvas.toDataURL('image/jpeg', 0.85);
+ }
+
+ var tStart = 0;
+ function fmtMs(ms){ return ms < 1000 ? Math.round(ms)+'ms' : (ms/1000).toFixed(1)+'s'; }
+
+ shutterBtn.addEventListener('click', function(){
+ if (STATE !== 'camera') return;
+ tStart = performance.now();
+ flashEl.classList.remove('on'); void flashEl.offsetWidth; flashEl.classList.add('on');
+ capturedDataUrl = captureFrame();
+ shotEl.src = capturedDataUrl;
+ shotEl.classList.add('on'); // instant visual feedback — no network wait
+ toProcessing('Reading label…');
+ runPipeline(capturedDataUrl);
+ });
+
+ // ── SPEED: the extract → create-item pipeline. /api/extract must return the vendor + mfr#
+ // before /api/create-item can be called (create-item hard-requires both), so — with the two
+ // endpoints as they exist today — these two calls cannot be parallelized from the client;
+ // extract's ~2.5–3s Gemini-vision round trip is the dominant, unavoidable cost of the FIRST
+ // exchange. What IS eliminated here vs. the full power-tool flow: the separate dry-run
+ // /api/create-item PREVIEW call (~290ms measured) that a review-UI would need — this page has
+ // no review step, so on a successful read it goes straight from extract's response into the
+ // REAL (committing) create-item call, cutting 3 sequential awaited round trips down to 2. ──
+ function runPipeline(dataUrl){
+ fetch('/api/extract', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ dataUrl: dataUrl }) })
+ .then(function(r){ return r.json(); })
+ .then(function(r){
+ var f = (r && r.fields) || {};
+ lastExtractFields = f;
+ var vendor = vendorOverrideSel.value || r.vendor_matched || '';
+ var mfr = String(f.mfr_sku == null ? '' : f.mfr_sku).trim(); // Gemini JSON may type a numeric SKU as a number
+ if (vendor && mfr && AUTO_CREATE) {
+ procMsg.textContent = 'Creating ' + vendor + ' item…';
+ createItem(dataUrl, vendor, mfr, f);
+ } else if (vendor && mfr && !AUTO_CREATE) {
+ showResult(true, { preview:true, vendor:vendor, mfr:mfr, fields:f });
+ } else {
+ showNeedsInfo(f, dataUrl, r && r.ok === false ? r.err : null);
+ }
+ })
+ .catch(function(e){
+ showResult(false, { err: 'Could not read the label — ' + (e && e.message || 'network error') });
+ });
+ }
+
+ function createItem(dataUrl, vendor, mfr, fields){
+ var payload = {
+ mfr: mfr, vendor: vendor, color: fields.color || '', material: fields.material || '',
+ collection: fields.collection || '', width: fields.width || '', roll_length: fields.roll_length || '',
+ repeat: fields.repeat || '', pattern_match: fields.pattern_match || '', substrate: fields.substrate || '',
+ how_sold: fields.how_sold || '', price: fields.price || '', price_code: fields.price_code || '',
+ photos: [dataUrl], back_present: false, commit: true
+ };
+ fetch('/api/create-item', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) })
+ .then(function(r){ return r.json(); })
+ .then(function(r){
+ if (r.duplicate) { showResult(false, { err: r.err, duplicate:true, mfr:mfr }); return; }
+ if (!r.ok) { showResult(false, { err: r.err || 'Create failed', mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); return; }
+ showResult(true, { dw_sku: r.dw_sku, title: r.title, vendor:vendor });
+ })
+ .catch(function(e){ showResult(false, { err: 'Network error creating item: ' + (e && e.message || ''), mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); });
+ }
+
+ function showNeedsInfo(fields, dataUrl, readErr){
+ var elapsed = performance.now() - tStart;
+ toResult();
+ rcard.className = 'rcard err';
+ rcIcon.textContent = '🏷';
+ rcTitle.textContent = "Couldn't auto-read the SKU";
+ rcSku.textContent = '';
+ rcDetail.textContent = (readErr ? 'Label read failed (' + readErr + '). ' : '') + 'Pick the vendor and type the mfr# to save this item.';
+ rcTime.hidden = !SHOW_TIMING; rcTime.textContent = 'read: ' + fmtMs(elapsed);
+ rcManual.classList.add('on');
+ mMfr.value = fields && fields.mfr_sku != null ? String(fields.mfr_sku) : '';
+ rcSave.onclick = function(){
+ var v = mVendor.value.trim(), m = mMfr.value.trim();
+ mVendor.classList.toggle('need', !v); mMfr.classList.toggle('need', !m);
+ if (!v || !m) return;
+ rcSave.disabled = true; rcSave.textContent = 'Saving…';
+ createItem(dataUrl, v, m, fields || {});
+ };
+ }
+
+ function showResult(ok, info){
+ var elapsed = performance.now() - tStart;
+ toResult();
+ rcManual.classList.remove('on');
+ rcSave.disabled = false; rcSave.textContent = 'Save';
+ rcTime.hidden = !SHOW_TIMING; rcTime.textContent = 'total: ' + fmtMs(elapsed);
+ procTime.hidden = !SHOW_TIMING;
+ if (ok && info.dw_sku) {
+ rcard.className = 'rcard ok';
+ rcIcon.textContent = '✓';
+ rcTitle.textContent = info.title || info.dw_sku;
+ rcSku.textContent = 'DW# ' + info.dw_sku + (info.vendor ? ' · ' + info.vendor : '');
+ rcDetail.textContent = 'Draft created — not live yet.';
+ } else if (ok && info.preview) {
+ rcard.className = 'rcard ok';
+ rcIcon.textContent = '✓';
+ rcTitle.textContent = 'Read OK (preview mode)';
+ rcSku.textContent = info.mfr + ' · ' + info.vendor;
+ rcDetail.textContent = 'Auto-create is off — nothing was saved.';
+ } else {
+ rcard.className = 'rcard err';
+ rcIcon.textContent = '✗';
+ rcTitle.textContent = info.duplicate ? 'Already in the catalog' : "Couldn't save";
+ rcSku.textContent = info.mfr || '';
+ rcDetail.textContent = info.err || 'Unknown error.';
+ }
+ }
+
+ rcAgain.addEventListener('click', toCamera);
+
+ // ── boot ─────────────────────────────────────────────────────────────────
+ warmVendors();
+ startCamera();
+})();
+</script>
+</body>
+</html>
diff --git a/public/simple-wizard.html b/public/simple-wizard.html
new file mode 100644
index 0000000..b04561f
--- /dev/null
+++ b/public/simple-wizard.html
@@ -0,0 +1,537 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>DW Sample Capture — Guided</title>
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<meta name="apple-mobile-web-app-title" content="DW Capture">
+<link rel="apple-touch-icon" href="/icon-180.png">
+<link rel="icon" href="/icon-192.png">
+<style>
+ /* ============================================================================================
+ GUIDED WIZARD — TK-12162 concept-guided-wizard
+ One focused job at a time. Numbered steps, one primary action per screen, large touch targets
+ (min 64px), large text (18px+ body / 24px+ headings), high contrast. "TSA PreCheck kiosk"
+ clarity, not "pro camera app" density. Built for a first-time / less-tech-savvy showroom guest.
+ ============================================================================================ */
+ :root{
+ --bg:#0b1220; --panel:#101a2c; --panel2:#16233a; --line:#26374f;
+ --fg:#f4f8fb; --dim:#9fb2c8; --accent:#22c3a6; --accent-d:#159a83;
+ --warn:#e0a53a; --danger:#e0563a; --radius:22px;
+ --safe-top:env(safe-area-inset-top,0px); --safe-bottom:env(safe-area-inset-bottom,0px);
+ }
+ *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
+ html,body{height:100%;}
+ body{
+ margin:0; background:var(--bg); color:var(--fg);
+ font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,Helvetica,Arial,sans-serif;
+ font-size:18px; line-height:1.4; overscroll-behavior:none; user-select:none;
+ display:flex; flex-direction:column; height:100dvh; width:100vw; overflow:hidden;
+ }
+ h1,h2,h3{margin:0; font-weight:800; letter-spacing:-0.01em;}
+ /* ---- progress rail (always visible, top) ---- */
+ #progress{
+ display:flex; align-items:center; justify-content:center; gap:10px;
+ padding:calc(14px + var(--safe-top)) 16px 14px;
+ background:var(--panel); border-bottom:1px solid var(--line); flex:0 0 auto; z-index:20;
+ }
+ .step-dot{
+ display:flex; align-items:center; gap:8px; opacity:.45; transition:opacity .25s;
+ }
+ .step-dot.active{opacity:1;}
+ .step-dot .num{
+ width:34px; height:34px; border-radius:50%; background:var(--panel2); border:2px solid var(--line);
+ display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:var(--dim);
+ transition:all .25s;
+ }
+ .step-dot.active .num{ background:var(--accent); border-color:var(--accent); color:#04231d; }
+ .step-dot.done .num{ background:var(--accent-d); border-color:var(--accent-d); color:#fff; }
+ .step-dot .lbl{ font-size:13px; font-weight:700; color:var(--dim); display:none; }
+ .step-dot.active .lbl{ color:var(--fg); }
+ @media (min-width:700px){ .step-dot .lbl{ display:inline; } }
+ .step-line{ width:22px; height:2px; background:var(--line); flex:0 0 auto; }
+ @media (min-width:700px){ .step-line{ width:40px; } }
+
+ /* ---- screens ---- */
+ #stage{ position:relative; flex:1 1 auto; min-height:0; }
+ .screen{
+ position:absolute; inset:0; display:none; flex-direction:column;
+ padding:24px 20px calc(24px + var(--safe-bottom)); text-align:center;
+ }
+ .screen.on{ display:flex; }
+ .screen h2{ font-size:26px; margin-bottom:8px; }
+ .screen .sub{ font-size:18px; color:var(--dim); margin-bottom:18px; max-width:560px; align-self:center; }
+
+ /* ---- camera stage ---- */
+ .cam-wrap{
+ position:relative; flex:1 1 auto; min-height:0; border-radius:var(--radius); overflow:hidden;
+ background:#000; margin:0 auto 18px; width:100%; max-width:680px;
+ }
+ #video{ width:100%; height:100%; object-fit:cover; display:block; background:#000; }
+ #snapCanvas{ display:none; }
+ .frame-guide{
+ position:absolute; inset:8%; border-radius:18px; pointer-events:none;
+ box-shadow:0 0 0 2000px rgba(0,0,0,.38);
+ }
+ .frame-guide .corner{ position:absolute; width:38px; height:38px; border:5px solid var(--accent); }
+ .frame-guide .tl{ top:-3px; left:-3px; border-right:none; border-bottom:none; border-top-left-radius:14px; }
+ .frame-guide .tr{ top:-3px; right:-3px; border-left:none; border-bottom:none; border-top-right-radius:14px; }
+ .frame-guide .bl{ bottom:-3px; left:-3px; border-right:none; border-top:none; border-bottom-left-radius:14px; }
+ .frame-guide .br{ bottom:-3px; right:-3px; border-left:none; border-top:none; border-bottom-right-radius:14px; }
+ .cam-hint{
+ position:absolute; left:0; right:0; bottom:14px; text-align:center;
+ font-size:16px; font-weight:700; color:#fff; text-shadow:0 1px 6px rgba(0,0,0,.7);
+ }
+ .countdown-num{
+ position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
+ font-size:140px; font-weight:900; color:#fff; text-shadow:0 4px 24px rgba(0,0,0,.6);
+ opacity:0; transform:scale(.7); transition:none;
+ }
+ .countdown-num.show{ animation:pop .78s ease-out; }
+ @keyframes pop{ 0%{opacity:0; transform:scale(.5);} 25%{opacity:1; transform:scale(1.08);} 70%{opacity:1; transform:scale(1);} 100%{opacity:0; transform:scale(.9);} }
+ .flash{ position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; }
+ .flash.go{ animation:flashfade .35s ease-out; }
+ @keyframes flashfade{ 0%{opacity:.9;} 100%{opacity:0;} }
+
+ /* ---- review photo ---- */
+ .review-wrap{
+ position:relative; flex:1 1 auto; min-height:0; border-radius:var(--radius); overflow:hidden;
+ background:#000; margin:0 auto 18px; width:100%; max-width:680px;
+ display:flex; align-items:center; justify-content:center;
+ }
+ #reviewImg{ width:100%; height:100%; object-fit:contain; display:block; }
+ .analyzing-pill{
+ position:absolute; top:14px; left:50%; transform:translateX(-50%);
+ background:rgba(11,18,32,.82); border:1px solid var(--line); border-radius:999px;
+ padding:8px 16px; font-size:14px; font-weight:700; color:var(--accent);
+ display:flex; align-items:center; gap:8px; opacity:0; transition:opacity .2s;
+ }
+ .analyzing-pill.show{ opacity:1; }
+ .dot-spin{ width:9px; height:9px; border-radius:50%; background:var(--accent); animation:blink 1s infinite ease-in-out; }
+ @keyframes blink{ 0%,100%{opacity:.25;} 50%{opacity:1;} }
+
+ /* ---- buttons ---- */
+ .actions{ display:flex; gap:14px; justify-content:center; flex-wrap:wrap; flex:0 0 auto; }
+ .actions.stack{ flex-direction:column; align-items:stretch; max-width:520px; margin:0 auto; width:100%; }
+ button{
+ font-family:inherit; border:none; border-radius:18px; cursor:pointer;
+ font-size:20px; font-weight:800; padding:22px 30px; min-height:70px; min-width:180px;
+ display:flex; align-items:center; justify-content:center; gap:10px;
+ transition:transform .08s ease, filter .15s ease; -webkit-user-select:none;
+ }
+ button:active{ transform:scale(.97); }
+ button:disabled{ opacity:.5; cursor:default; }
+ .btn-primary{ background:var(--accent); color:#04231d; box-shadow:0 8px 24px rgba(34,195,166,.28); }
+ .btn-primary:active{ filter:brightness(.95); }
+ .btn-secondary{ background:var(--panel2); color:var(--fg); border:2px solid var(--line); }
+ .btn-danger{ background:transparent; color:var(--dim); border:2px solid var(--line); font-size:17px; padding:16px 24px; min-height:56px; }
+ .btn-big{ font-size:24px; padding:28px 40px; min-height:84px; width:100%; max-width:420px; align-self:center; }
+
+ /* ---- done screen ---- */
+ .checkmark{
+ width:120px; height:120px; border-radius:50%; background:var(--accent); margin:0 auto 22px;
+ display:flex; align-items:center; justify-content:center; box-shadow:0 10px 30px rgba(34,195,166,.35);
+ }
+ .checkmark svg{ width:62px; height:62px; }
+ .done-card{
+ background:var(--panel2); border:1px solid var(--line); border-radius:18px; padding:20px 22px;
+ max-width:480px; margin:0 auto 22px; text-align:left; align-self:center; width:100%;
+ }
+ .done-card .row{ display:flex; justify-content:space-between; gap:14px; padding:8px 0; font-size:17px; }
+ .done-card .row + .row{ border-top:1px solid var(--line); }
+ .done-card .k{ color:var(--dim); font-weight:600; }
+ .done-card .v{ font-weight:800; text-align:right; }
+
+ /* ---- error / camera denied ---- */
+ .err-box{ background:rgba(224,86,58,.12); border:1px solid var(--danger); border-radius:16px; padding:18px 20px; max-width:520px; margin:0 auto 18px; align-self:center; }
+
+ /* ---- perf debug HUD (hidden unless ?debug=1) ---- */
+ #hud{
+ position:fixed; right:8px; bottom:calc(8px + var(--safe-bottom)); z-index:999;
+ background:rgba(0,0,0,.82); color:#7CFC9C; font:11px/1.5 ui-monospace,Menlo,monospace;
+ padding:8px 10px; border-radius:10px; max-width:280px; display:none; white-space:pre;
+ pointer-events:none; /* debug overlay must never steal taps from the real UI beneath it */
+ }
+ #hud.show{ display:block; }
+</style>
+</head>
+<body>
+
+ <div id="progress">
+ <div class="step-dot" data-step="1"><div class="num">1</div><div class="lbl">Position</div></div>
+ <div class="step-line"></div>
+ <div class="step-dot" data-step="2"><div class="num">2</div><div class="lbl">Capture</div></div>
+ <div class="step-line"></div>
+ <div class="step-dot" data-step="3"><div class="num">3</div><div class="lbl">Review</div></div>
+ <div class="step-line"></div>
+ <div class="step-dot" data-step="4"><div class="num">4</div><div class="lbl">Done</div></div>
+ </div>
+
+ <div id="stage">
+
+ <!-- STEP 1 — POSITION -->
+ <section class="screen" id="screen-1">
+ <h2>Position the sample</h2>
+ <div class="sub">Lay the sample flat and fill the frame. Good light helps.</div>
+ <div class="cam-wrap">
+ <video id="video" playsinline muted autoplay></video>
+ <div class="frame-guide">
+ <div class="corner tl"></div><div class="corner tr"></div>
+ <div class="corner bl"></div><div class="corner br"></div>
+ </div>
+ <div class="cam-hint" id="camHint">Starting camera…</div>
+ </div>
+ <div class="actions">
+ <button class="btn-primary btn-big" id="btnReady" disabled>I'm Ready →</button>
+ </div>
+ </section>
+
+ <!-- STEP 2 — CAPTURE -->
+ <section class="screen" id="screen-2">
+ <h2>Hold still…</h2>
+ <div class="sub">Capturing in a moment.</div>
+ <div class="cam-wrap" id="captureWrap">
+ <!-- video2 shares the same live stream, cloned in via JS -->
+ <video id="video2" playsinline muted autoplay></video>
+ <div class="frame-guide">
+ <div class="corner tl"></div><div class="corner tr"></div>
+ <div class="corner bl"></div><div class="corner br"></div>
+ </div>
+ <div class="countdown-num" id="countdownNum"></div>
+ <div class="flash" id="flashEl"></div>
+ </div>
+ <canvas id="snapCanvas"></canvas>
+ </section>
+
+ <!-- STEP 3 — REVIEW -->
+ <section class="screen" id="screen-3">
+ <h2>How does it look?</h2>
+ <div class="sub">Make sure the sample is clear and in focus.</div>
+ <div class="review-wrap">
+ <img id="reviewImg" alt="Captured sample photo">
+ <div class="analyzing-pill" id="analyzingPill"><span class="dot-spin"></span> Reading label…</div>
+ </div>
+ <div class="actions">
+ <button class="btn-secondary" id="btnRetake">↺ Retake</button>
+ <button class="btn-primary" id="btnUse">✓ Use This Photo</button>
+ </div>
+ </section>
+
+ <!-- STEP 4 — DONE -->
+ <section class="screen" id="screen-4">
+ <div style="flex:1 1 auto; display:flex; flex-direction:column; justify-content:center;">
+ <div class="checkmark" id="doneIcon">
+ <svg viewBox="0 0 24 24" fill="none"><path d="M4 12.5L9.5 18L20 6" stroke="#04231d" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
+ </div>
+ <h2 id="doneTitle">Sample Captured!</h2>
+ <div class="sub" id="doneSub">Nice work — this sample is on its way into the catalog.</div>
+ <div class="done-card" id="doneCard" style="display:none"></div>
+ <div class="err-box" id="errCard" style="display:none"></div>
+ </div>
+ <div class="actions">
+ <button class="btn-primary btn-big" id="btnAgain">➕ Add Another Sample</button>
+ </div>
+ </section>
+
+ </div>
+
+ <div id="hud"></div>
+
+<script>
+(function(){
+ 'use strict';
+ const $ = id => document.getElementById(id);
+ const DEBUG = /[?&]debug=1/.test(location.search);
+ if (DEBUG) $('hud').classList.add('show');
+
+ // ---------------------------------------------------------------------------------------------
+ // TIMING LEDGER — every measured instant, for the speed writeup. Logged to console always;
+ // rendered in the corner HUD only with ?debug=1 so the guest-facing screen stays clean.
+ // ---------------------------------------------------------------------------------------------
+ const T = {};
+ function mark(k){ T[k] = performance.now(); renderHud(); return T[k]; }
+ function ms(a,b){ return (T[b]!=null && T[a]!=null) ? Math.round(T[b]-T[a]) : null; }
+ function renderHud(){
+ if(!DEBUG) return;
+ const lines = [
+ 'STEP TIMING (ms)',
+ 't_position_enter → t_capture_tap : ' + (ms('positionEnter','captureTap') ?? '—'),
+ 't_capture_tap → t_frame_grabbed : ' + (ms('captureTap','frameGrabbed') ?? '—'),
+ 't_frame_grabbed → extract_fired : ' + (ms('frameGrabbed','extractFired') ?? '—'),
+ 'extract in-flight (spec.) : ' + (ms('extractFired','extractResolved') ?? '(pending)'),
+ 't_confirm_tap → extract_ready : ' + (ms('confirmTap','extractResolved') ?? '0 (already done)'),
+ 't_confirm_tap → create_fired : ' + (ms('confirmTap','createFired') ?? '—'),
+ 'create-item round trip : ' + (ms('createFired','createResolved') ?? '(pending)'),
+ '── TOTAL confirm → done ── : ' + (ms('confirmTap','doneShown') ?? '—'),
+ ];
+ $('hud').textContent = lines.join('\n');
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // STATE
+ // ---------------------------------------------------------------------------------------------
+ let stream = null;
+ let capturedDataUrl = null;
+ let extractPromise = null; // speculative — fired the instant the frame is grabbed, step 2→3
+ let extractResult = null;
+ let currentStep = 1;
+
+ function goStep(n){
+ currentStep = n;
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('on'));
+ $('screen-' + n).classList.add('on');
+ document.querySelectorAll('.step-dot').forEach(d => {
+ const step = +d.dataset.step;
+ d.classList.toggle('active', step === n);
+ d.classList.toggle('done', step < n);
+ });
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // CAMERA — bounded acquire with a timeout race (a hung getUserMedia/play() must never freeze the
+ // wizard on a first-time guest's device). Mirrors the robustness pattern already proven in
+ // public/index.html's two-shot live cam (TK-12124/TK-12127), trimmed to what this flow needs.
+ // ---------------------------------------------------------------------------------------------
+ async function acquireCamera(){
+ if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
+ throw new Error('This device/browser can\'t access the camera. Try Safari on iPhone/iPad or Chrome on Android.');
+ }
+ const constraints = [
+ { video:{ facingMode:{ ideal:'environment' }, width:{ ideal:1920 }, height:{ ideal:1440 } }, audio:false },
+ { video:{ facingMode:{ ideal:'environment' } }, audio:false },
+ { video:true, audio:false }
+ ];
+ let lastErr;
+ for(const c of constraints){
+ try{
+ const timeout = new Promise((_,rej) => setTimeout(() => rej(new Error('timeout')), 8000));
+ const s = await Promise.race([navigator.mediaDevices.getUserMedia(c), timeout]);
+ return s;
+ }catch(e){ lastErr = e; }
+ }
+ throw lastErr || new Error('camera unavailable');
+ }
+
+ async function startCamera(){
+ $('camHint').textContent = 'Starting camera…';
+ try{
+ stream = await acquireCamera();
+ const v1 = $('video'); v1.srcObject = stream;
+ await v1.play().catch(()=>{});
+ $('camHint').textContent = 'Fill the frame with the sample';
+ $('btnReady').disabled = false;
+
+ // ---- JEV-STYLE PREFETCH / WARM-UP -----------------------------------------------------
+ // This is the "position" step's dwell time — the guest is adjusting the sample, which is
+ // exactly the idle window JEV spends on speculative work instead of waiting for the next
+ // user action. We fire a cheap same-origin GET now so the keep-alive connection this page
+ // will reuse for the real POSTs (/api/extract, /api/create-item) is already established —
+ // no fresh TCP/TLS handshake sitting in the critical path after the guest taps "Use".
+ fetch('/healthz', { keepalive:true }).catch(()=>{});
+ mark('positionEnter');
+ }catch(e){
+ $('camHint').textContent = '';
+ showCameraError(e.message || String(e));
+ }
+ }
+
+ function showCameraError(msg){
+ const s = $('screen-1');
+ if($('camErrBox')) return;
+ const box = document.createElement('div');
+ box.id = 'camErrBox'; box.className = 'err-box';
+ box.innerHTML = '<b>Camera not available</b><br><span style="color:var(--dim)">' + msg.replace(/</g,'<') + '</span>';
+ s.insertBefore(box, s.querySelector('.actions'));
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // STEP 1 → 2 : "I'm Ready"
+ // ---------------------------------------------------------------------------------------------
+ $('btnReady').addEventListener('click', () => {
+ mark('captureTap');
+ // hand the SAME live stream to the capture screen's video element — no re-acquire, no flicker.
+ const v2 = $('video2'); v2.srcObject = stream; v2.play().catch(()=>{});
+ goStep(2);
+ runCountdown();
+ });
+
+ // ---------------------------------------------------------------------------------------------
+ // STEP 2 — countdown then auto-capture (confirm tap already happened; countdown just holds the
+ // sample steady for a sharp shot — combines "clear confirm tap" + "countdown" from the brief).
+ // ---------------------------------------------------------------------------------------------
+ function runCountdown(){
+ const el = $('countdownNum');
+ const seq = ['3','2','1','📸'];
+ let i = 0;
+ (function tick(){
+ el.textContent = seq[i];
+ el.classList.remove('show'); void el.offsetWidth; el.classList.add('show');
+ if(seq[i] === '📸'){
+ setTimeout(() => { grabFrame(); }, 260);
+ return;
+ }
+ i++;
+ setTimeout(tick, 700);
+ })();
+ }
+
+ function grabFrame(){
+ $('flashEl').classList.add('go');
+ const v = $('video2');
+ const canvas = $('snapCanvas');
+ // downscale to a sane max edge — keeps the base64 payload small so the (already-fast) round
+ // trips to /api/extract and /api/create-item aren't padded by an unnecessarily huge JPEG.
+ const MAXW = 1600;
+ const vw = v.videoWidth || 1280, vh = v.videoHeight || 960;
+ const scale = Math.min(1, MAXW / Math.max(vw, vh));
+ canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
+ canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
+ capturedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
+ mark('frameGrabbed');
+
+ $('reviewImg').src = capturedDataUrl;
+ goStep(3);
+
+ // ---- SPECULATIVE EXTRACTION (the core JEV-style optimization for this concept) -----------
+ // Fire /api/extract THE INSTANT we have pixels — while the guest is still looking at the
+ // review screen deciding whether to keep the shot — instead of waiting for the "Use This
+ // Photo" tap. A person spends ~1-3s glancing at a photo before confirming; Gemini Vision OCR
+ // typically resolves in a similar window. By the time they tap "Use", extraction is usually
+ // already done, so only ONE round trip (/api/create-item) remains on the critical path
+ // instead of two SEQUENTIAL ones. This mirrors Jev's "one request per decision, no idle
+ // network gaps" — we just move the decision point earlier because we can predict it.
+ $('analyzingPill').classList.add('show');
+ mark('extractFired');
+ extractPromise = fetch('/api/extract', {
+ method:'POST', headers:{ 'Content-Type':'application/json' },
+ body: JSON.stringify({ dataUrl: capturedDataUrl })
+ }).then(r => r.json()).then(j => {
+ mark('extractResolved');
+ extractResult = j;
+ $('analyzingPill').classList.remove('show');
+ return j;
+ }).catch(e => {
+ mark('extractResolved');
+ extractResult = { ok:false, err: e.message };
+ $('analyzingPill').classList.remove('show');
+ return extractResult;
+ });
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // STEP 3 — Retake / Use This Photo
+ // ---------------------------------------------------------------------------------------------
+ $('btnRetake').addEventListener('click', () => {
+ extractPromise = null; extractResult = null; capturedDataUrl = null;
+ goStep(2);
+ runCountdown();
+ });
+
+ $('btnUse').addEventListener('click', async () => {
+ mark('confirmTap');
+ $('btnUse').disabled = true; $('btnRetake').disabled = true;
+ await finishCapture();
+ });
+
+ // ---------------------------------------------------------------------------------------------
+ // STEP 3 → 4 : await the (likely-already-resolved) speculative extract, then the ONE remaining
+ // round trip — /api/create-item — using the exact contract public/index.html's "add new item"
+ // flow uses (mfr + vendor required server-side; front photo satisfies the photo gate; mfr from
+ // OCR satisfies the identity gate when there's no separate back-label shot).
+ // ---------------------------------------------------------------------------------------------
+ async function finishCapture(){
+ // Await the speculative extract IF it's still in flight (a fast/impatient tap can beat the
+ // network back) — this is the only place the wizard ever waits on it; most of the time it's
+ // already resolved from the review-screen dwell and this await returns on the same tick.
+ if(extractPromise){ extractResult = await extractPromise; }
+ const fields = (extractResult && extractResult.fields) || {};
+ const vendor = extractResult && (extractResult.vendor_matched || fields.vendor) || '';
+ const mfr = String(fields.mfr_sku == null ? '' : fields.mfr_sku).trim(); // Gemini JSON may type a numeric SKU as a number
+
+ if(!mfr || !vendor){
+ // Honest graceful degradation — the label didn't read clearly enough to auto-identify.
+ // A guided kiosk never shows a raw error; it explains plainly and hands off to staff.
+ showDone({
+ ok:false,
+ title: 'Photo saved',
+ sub: "We couldn't automatically read the label on this one — a team member will finish adding it.",
+ });
+ return;
+ }
+
+ mark('createFired');
+ let r;
+ try{
+ r = await fetch('/api/create-item', {
+ method:'POST', headers:{ 'Content-Type':'application/json' },
+ body: JSON.stringify({
+ dataUrl: capturedDataUrl,
+ photos: [capturedDataUrl],
+ mfr, vendor, vid: '',
+ name: fields.pattern_name || '', color: fields.color || '',
+ material: fields.material || 'Wallcovering',
+ collection: fields.collection || '', width: fields.width || '',
+ roll_length: fields.roll_length || '', repeat: fields.repeat || '',
+ pattern_match: fields.pattern_match || '', substrate: fields.substrate || '',
+ how_sold: fields.how_sold || '', price: fields.price || '', price_code: fields.price_code || '',
+ id_source: 'guided-wizard-ocr', commit: true
+ })
+ }).then(res => res.json());
+ }catch(e){
+ r = { ok:false, err: e.message };
+ }
+ mark('createResolved');
+
+ if(r && r.ok){
+ showDone({ ok:true, dw_sku: r.dw_sku, vendor, title: 'Sample Captured!', sub: 'This sample is now a draft in the catalog.' });
+ } else if(r && r.duplicate){
+ showDone({ ok:true, dw_sku: (r.preview && r.preview.dw_sku) || '', vendor, title: 'Already in the catalog', sub: 'This exact sample is already on file — nothing more to do here.' });
+ } else {
+ showDone({ ok:false, title: 'Photo saved', sub: 'The catalog step needs a hand from staff for this one (' + ((r && r.err) || 'unknown issue') + ').' });
+ }
+ }
+
+ function showDone(info){
+ mark('doneShown');
+ $('doneTitle').textContent = info.title;
+ $('doneSub').textContent = info.sub;
+ $('doneIcon').style.background = info.ok ? 'var(--accent)' : 'var(--warn)';
+ if(info.ok && info.dw_sku){
+ $('doneCard').style.display = 'block';
+ $('doneCard').innerHTML =
+ '<div class="row"><span class="k">DW SKU</span><span class="v">' + esc(info.dw_sku) + '</span></div>' +
+ '<div class="row"><span class="k">Vendor</span><span class="v">' + esc(info.vendor || '—') + '</span></div>';
+ $('errCard').style.display = 'none';
+ } else {
+ $('doneCard').style.display = 'none';
+ }
+ goStep(4);
+ if(DEBUG) console.log('[guided-wizard] timing ledger', JSON.parse(JSON.stringify(T)));
+ }
+
+ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+ // ---------------------------------------------------------------------------------------------
+ // "Add Another Sample" — full reset back to step 1, camera re-acquired fresh.
+ // ---------------------------------------------------------------------------------------------
+ $('btnAgain').addEventListener('click', () => {
+ capturedDataUrl = null; extractPromise = null; extractResult = null;
+ $('btnUse').disabled = false; $('btnRetake').disabled = false;
+ for(const k of Object.keys(T)) delete T[k];
+ goStep(1);
+ if(!stream || !stream.active){ startCamera(); }
+ else {
+ $('video').srcObject = stream;
+ mark('positionEnter');
+ }
+ });
+
+ // boot
+ goStep(1);
+ startCamera();
+})();
+</script>
+</body>
+</html>
diff --git a/scripts/STEVE-PASTE-tk12090-deploy2.sh b/scripts/STEVE-PASTE-tk12090-deploy2.sh
new file mode 100755
index 0000000..98fc910
--- /dev/null
+++ b/scripts/STEVE-PASTE-tk12090-deploy2.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# TK-12090 deploy 2: server.js (247c9f9 provider=null on failure) + _shared exo-vision.mjs
+# (06dacfd key redaction), with timestamped backups, then one verify call printing the error.
+# Undo: mv each .pre-tk12090b-*.bak back + pm2 restart 226.
+# Verifier source: scripts/verify-vision.py (piped over ssh stdin; it refuses blank VU/VP).
+set -e
+scp ~/Projects/dw-photo-capture/server.js root@45.61.58.125:/root/public-projects/dwphoto/server.js.tk12090-new
+scp ~/Projects/_shared/lib/exo-vision.mjs root@45.61.58.125:/root/public-projects/_shared/lib/exo-vision.mjs.tk12090-new
+ssh root@45.61.58.125 'TS=$(date -u +%Y%m%dT%H%M%SZ); D=/root/public-projects; cp $D/dwphoto/server.js $D/dwphoto/server.js.pre-tk12090b-$TS.bak && cp $D/_shared/lib/exo-vision.mjs $D/_shared/lib/exo-vision.mjs.pre-tk12090b-$TS.bak && mv $D/dwphoto/server.js.tk12090-new $D/dwphoto/server.js && mv $D/_shared/lib/exo-vision.mjs.tk12090-new $D/_shared/lib/exo-vision.mjs && pm2 restart 226 >/dev/null && sleep 3 && printf "healthz=" && curl -s -m 5 http://127.0.0.1:9912/healthz && printf "\nbackups=*.pre-tk12090b-%s.bak\n" "$TS"'
+ssh root@45.61.58.125 'PID=$(ss -lptnH "sport = :9912" 2>/dev/null | grep -oP "pid=\K[0-9]+" | head -1); [ -z "$PID" ] && { echo "ERR: no PID on :9912"; exit 1; }; export VU=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_USER= | cut -d= -f2-); export VP=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_PASS= | cut -d= -f2-); python3 -' < ~/Projects/dw-photo-capture/scripts/verify-vision.py
diff --git a/scripts/STEVE-PASTE-vision-step3.sh b/scripts/STEVE-PASTE-vision-step3.sh
new file mode 100755
index 0000000..304d721
--- /dev/null
+++ b/scripts/STEVE-PASTE-vision-step3.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# TK-12090 vision-url memo — verification step 3 (Steve runs this himself).
+# Drives ONE real vision call through prod dwphoto and prints which provider served it.
+# visualMatch:false => no Gemini visual-match spend. Read-only against a scratch image.
+ssh root@45.61.58.125 'PID=$(pm2 pid 226); export VU=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_USER= | cut -d= -f2-); export VP=$(tr "\0" "\n" </proc/$PID/environ | grep -m1 ^AUTH_PASS= | cut -d= -f2-); python3 -c "
+from PIL import Image,ImageDraw
+import base64,io,json,urllib.request,os
+im=Image.new(\"RGB\",(384,384),(198,178,142)); d=ImageDraw.Draw(im)
+[d.line([(0,y),(384,y)],fill=(150,130,96),width=6) for y in range(0,384,24)]
+[d.line([(x,0),(x,384)],fill=(170,150,112),width=3) for x in range(0,384,24)]
+b=io.BytesIO(); im.save(b,\"JPEG\",quality=88)
+c=base64.b64encode((os.environ[\"VU\"]+\":\"+os.environ[\"VP\"]).encode()).decode()
+p=json.dumps({\"dataUrl\":\"data:image/jpeg;base64,\"+base64.b64encode(b.getvalue()).decode(),\"visualMatch\":False}).encode()
+r=json.loads(urllib.request.urlopen(urllib.request.Request(\"http://127.0.0.1:9912/api/recognize\",data=p,headers={\"Content-Type\":\"application/json\",\"Authorization\":\"Basic \"+c}),timeout=200).read())
+print(\"PROVIDER:\",r.get(\"provider\"),\" (expect exo, not gemini)\"); print(\"cost_usd:\",r.get(\"cost_usd\"),\" (expect 0)\"); print(\"ok:\",r.get(\"ok\"))
+"'
diff --git a/scripts/verify-vision.py b/scripts/verify-vision.py
new file mode 100644
index 0000000..5cb08d0
--- /dev/null
+++ b/scripts/verify-vision.py
@@ -0,0 +1,35 @@
+# TK-12090 prod vision verifier — piped over ssh stdin by STEVE-PASTE-tk12090-deploy2.sh
+# (runs ON Kamatera). Sends one synthetic grasscloth-ish JPEG to /api/recognize with
+# visualMatch:false (no Gemini visual-match spend) and prints which engine/provider served it.
+# Needs VU / VP (the dwphoto Basic-Auth pair) in the environment.
+from PIL import Image, ImageDraw
+import base64, io, json, os, re, sys, urllib.request
+
+vu, vp = os.environ.get("VU", ""), os.environ.get("VP", "")
+if not vu or not vp:
+ sys.exit("ERR: VU/VP (AUTH_USER/AUTH_PASS) empty — refusing to call with blank Basic Auth")
+
+im = Image.new("RGB", (384, 384), (198, 178, 142))
+d = ImageDraw.Draw(im)
+for y in range(0, 384, 24):
+ d.line([(0, y), (384, y)], fill=(150, 130, 96), width=6)
+for x in range(0, 384, 24):
+ d.line([(x, 0), (x, 384)], fill=(170, 150, 112), width=3)
+b = io.BytesIO()
+im.save(b, "JPEG", quality=88)
+
+auth = base64.b64encode(f"{vu}:{vp}".encode()).decode()
+payload = json.dumps({
+ "dataUrl": "data:image/jpeg;base64," + base64.b64encode(b.getvalue()).decode(),
+ "visualMatch": False,
+}).encode()
+req = urllib.request.Request(
+ "http://127.0.0.1:9912/api/recognize",
+ data=payload,
+ headers={"Content-Type": "application/json", "Authorization": "Basic " + auth},
+)
+r = json.loads(urllib.request.urlopen(req, timeout=200).read()) # seconds; vision can be slow
+
+err = re.sub(r"(key=)[^&\s\"]+", r"\1REDACTED", str(r.get("err") or ""))[:220]
+print("ENGINE:", r.get("engine"), " PROVIDER:", r.get("provider"), " cost_usd:", r.get("cost_usd"), " ok:", r.get("ok"))
+print("MOTIF:", (r.get("recognized") or {}).get("motif"), " ERR:", err or "-")
← a7bae5f auto-data-snapshot: 2026-09-24T13:20:22 (1 data files) — dat
·
back to Dw Photo Capture
·
TK-12162: guided wizard never strands a guest on an unexpect 10d3e40 →