← back to Dw Photo Capture
TK-12162 Instant Film concept: simple-instant.html (Polaroid-style single-photo capture, animation decoupled from real /api/extract upload)
a262d3e4ed86897978442446f804998e3eb42ca3 · 2026-09-24 13:12:08 -0700 · Steve Abrams
Warm cream/paper theme with DW gold as accent only (deliberate departure from
the dark power-tool theme) — framed viewfinder with film-border + rounded
corners, shutter-flash, "developing" reveal on a fixed 1.25s cosmetic timer
that is fully decoupled from the real network pipeline: the /api/extract
upload fires the instant a frame is captured, before the flash/develop
animation even starts, and Save's enabled state is gated on that real
response (or a manual field edit) — never on the animation clock. Single
photo per session; front-loaded vendor + mfr# fields (both server-required)
auto-fill from OCR extraction with a manual-override always available.
Verified end-to-end locally via Playwright (fake camera device): camera live
in <400ms, extract() dispatched ~56ms after shutter tap, develop reveal fires
on schedule regardless of the real Gemini round-trip running 1.9-2.1s (longer
than the animation) — proving the UI never blocks on the real pipeline. Found
+ fixed a real bug in testing: the shutter button never re-armed after Retake.
Server.js route addition (server.js's own concurrent history under TK-12090
picked this up) is additive-only (mirrors the existing /batch pattern) and
does not touch index.html/cam.html/batch.html/desk.html or any core route.
Local exploration only — never linked from nav, never deployed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Files touched
A public/simple-instant.html
Diff
commit a262d3e4ed86897978442446f804998e3eb42ca3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 13:12:08 2026 -0700
TK-12162 Instant Film concept: simple-instant.html (Polaroid-style single-photo capture, animation decoupled from real /api/extract upload)
Warm cream/paper theme with DW gold as accent only (deliberate departure from
the dark power-tool theme) — framed viewfinder with film-border + rounded
corners, shutter-flash, "developing" reveal on a fixed 1.25s cosmetic timer
that is fully decoupled from the real network pipeline: the /api/extract
upload fires the instant a frame is captured, before the flash/develop
animation even starts, and Save's enabled state is gated on that real
response (or a manual field edit) — never on the animation clock. Single
photo per session; front-loaded vendor + mfr# fields (both server-required)
auto-fill from OCR extraction with a manual-override always available.
Verified end-to-end locally via Playwright (fake camera device): camera live
in <400ms, extract() dispatched ~56ms after shutter tap, develop reveal fires
on schedule regardless of the real Gemini round-trip running 1.9-2.1s (longer
than the animation) — proving the UI never blocks on the real pipeline. Found
+ fixed a real bug in testing: the shutter button never re-armed after Retake.
Server.js route addition (server.js's own concurrent history under TK-12090
picked this up) is additive-only (mirrors the existing /batch pattern) and
does not touch index.html/cam.html/batch.html/desk.html or any core route.
Local exploration only — never linked from nav, never deployed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
public/simple-instant.html | 414 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 414 insertions(+)
diff --git a/public/simple-instant.html b/public/simple-instant.html
new file mode 100644
index 0000000..e13f286
--- /dev/null
+++ b/public/simple-instant.html
@@ -0,0 +1,414 @@
+<!doctype html>
+<html lang="en">
+<head>
+<script>
+// Credential-safe fetch guard (fleet drop-in) — see index.html for the full rationale.
+// Ref: creds-in-url-fetch-guard-fleet-pattern.
+(function () {
+ var _fetch = window.fetch.bind(window);
+ var cleanBase = function () { return location.origin + location.pathname; };
+ window.fetch = function (input, init) {
+ try {
+ if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
+ input = new URL(input, cleanBase()).href;
+ } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
+ input = new Request(new URL(input.url, cleanBase()).href, input);
+ }
+ } catch (_) { /* fall through to native */ }
+ return _fetch(input, init);
+ };
+})();
+</script>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<meta name="apple-mobile-web-app-title" content="DW Instant">
+<meta name="mobile-web-app-capable" content="yes">
+<meta name="theme-color" content="#efe2c4">
+<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
+<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
+<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
+<link rel="manifest" href="/manifest.webmanifest">
+<title>DW Instant Film</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&display=swap" rel="stylesheet">
+<style>
+ :root{
+ --paper:#f7efdd; --paper-deep:#efe1bf; --white:#fffdf6;
+ --ink:#382d1e; --muted:#8d7c5c; --line:#e3d3ab;
+ --gold:#c8a24a; --gold-deep:#a67f2e; --gold-soft:#e7c878;
+ --shadow:0 20px 44px -14px rgba(56,45,30,.38), 0 4px 12px rgba(56,45,30,.16);
+ --radius:26px;
+ --flash-ms:150ms; --develop-ms:1100ms;
+ }
+ @media (prefers-color-scheme:dark){
+ :root:not([data-theme="light"]){
+ --paper:#241d12; --paper-deep:#1a1509; --white:#f7efdd;
+ --ink:#f2e8d3; --muted:#b7a37a; --line:#4a3d24;
+ }
+ }
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
+ html,body{height:100%}
+ body{
+ margin:0; min-height:100vh; min-height:100dvh;
+ background:
+ radial-gradient(1200px 800px at 15% -10%, var(--paper-deep), transparent 60%),
+ radial-gradient(1000px 700px at 110% 110%, var(--paper-deep), transparent 55%),
+ var(--paper);
+ color:var(--ink);
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
+ display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
+ padding:max(18px,env(safe-area-inset-top)) 18px max(24px,env(safe-area-inset-bottom));
+ overflow-x:hidden;
+ }
+ .brand{display:flex;align-items:baseline;gap:8px;margin:2px 130px 4px 0;user-select:none;max-width:100%;overflow:hidden}
+ .brand b{font-size:clamp(11px,3.6vw,15px);letter-spacing:.1em;color:var(--ink);white-space:nowrap}
+ .brand b .short{display:none}
+ .brand .script{font-family:'Caveat',cursive;font-size:22px;color:var(--gold-deep);font-weight:700;flex:none}
+ @media (max-width:400px){
+ .brand{ margin-right:112px; }
+ .brand b .full{display:none} .brand b .short{display:inline}
+ }
+ .sesschip{
+ position:fixed; top:max(14px,env(safe-area-inset-top)); right:14px; z-index:5;
+ background:var(--white); border:1px solid var(--line); color:var(--muted);
+ font-size:12px; font-weight:600; padding:6px 11px; border-radius:99px; box-shadow:var(--shadow);
+ }
+ .sesschip b{color:var(--gold-deep)}
+
+ /* ===== VIEWFINDER (idle / live camera) ===== */
+ .stage{ width:min(94vw,480px); display:flex; flex-direction:column; align-items:center; gap:22px; margin-top:6px; }
+ /* short-viewport / landscape iPad — the 3:4 frame at full width would clip the shutter below the
+ fold, so cap the frame's width (hence height, via aspect-ratio) to what the viewport can hold. */
+ @media (max-height:840px) and (min-width:640px){
+ .stage,.card-stage{ width:min(90vw,230px); }
+ .brand{ margin-bottom:0; }
+ .field{ margin-bottom:6px; }
+ .field input{ padding:8px 10px; font-size:14px; }
+ .chin{ padding:8px 4px 10px; }
+ .chin-row{ min-height:20px; font-size:18px; }
+ .save-row{ padding-bottom:8px; }
+ body{ justify-content:center; padding-top:max(10px,env(safe-area-inset-top)); }
+ .stage{ gap:14px; }
+ .card-stage{ gap:10px; }
+ }
+ .viewfinder{
+ position:relative; width:100%; aspect-ratio:3/4; border-radius:var(--radius);
+ background:var(--white); padding:14px 14px 34px;
+ box-shadow:var(--shadow); border:1px solid var(--line);
+ }
+ .vf-window{ position:relative; width:100%; height:100%; border-radius:16px; overflow:hidden; background:#111; }
+ .vf-window video{ width:100%; height:100%; object-fit:cover; display:block; transform:scaleX(1); }
+ .vf-corner{ position:absolute; width:22px; height:22px; border-color:rgba(255,255,255,.55); pointer-events:none; }
+ .vf-corner.tl{ top:8px; left:8px; border-top:2px solid; border-left:2px solid; border-radius:4px 0 0 0; }
+ .vf-corner.tr{ top:8px; right:8px; border-top:2px solid; border-right:2px solid; border-radius:0 4px 0 0; }
+ .vf-corner.bl{ bottom:8px; left:8px; border-bottom:2px solid; border-left:2px solid; border-radius:0 0 0 4px; }
+ .vf-corner.br{ bottom:8px; right:8px; border-bottom:2px solid; border-right:2px solid; border-radius:0 0 4px 0; }
+ .flip-btn{
+ position:absolute; top:22px; right:22px; z-index:3; width:38px;height:38px;border-radius:50%;
+ background:rgba(20,16,10,.42); color:#fff; border:1px solid rgba(255,255,255,.35);
+ font-size:17px; display:flex;align-items:center;justify-content:center; cursor:pointer;
+ }
+ .vf-hint{
+ position:absolute; left:0; right:0; bottom:10px; text-align:center; font-size:12px;
+ color:rgba(255,255,255,.85); text-shadow:0 1px 3px rgba(0,0,0,.6); pointer-events:none; letter-spacing:.02em;
+ }
+
+ .shutter{
+ width:78px; height:78px; border-radius:50%; border:none; cursor:pointer; padding:0;
+ background:radial-gradient(circle at 34% 30%, var(--gold-soft), var(--gold) 55%, var(--gold-deep) 100%);
+ box-shadow:0 10px 22px -6px rgba(166,127,46,.55), inset 0 0 0 5px var(--white), inset 0 0 0 7px var(--gold);
+ display:flex; align-items:center; justify-content:center; transition:transform .12s ease;
+ }
+ .shutter:active{ transform:scale(.9); }
+ .shutter .dot{ width:16px;height:16px;border-radius:50%;background:var(--white); box-shadow:0 0 0 2px rgba(0,0,0,.06) inset; }
+ .shutter[disabled]{ opacity:.5; pointer-events:none; }
+
+ /* ===== FLASH ===== */
+ .flash-overlay{ position:fixed; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:40; }
+ .flash-overlay.go{ animation:flashpop var(--flash-ms) ease-out; }
+ @keyframes flashpop{ 0%{opacity:0} 18%{opacity:.92} 100%{opacity:0} }
+
+ /* ===== INSTANT CARD (developing / result) ===== */
+ .card-stage{ width:min(94vw,480px); display:flex; flex-direction:column; align-items:center; gap:18px; }
+ .instant-card{
+ width:100%; background:var(--white); border-radius:var(--radius); box-shadow:var(--shadow);
+ padding:14px 14px 0; border:1px solid var(--line);
+ animation:cardpop .38s cubic-bezier(.2,.9,.3,1.2);
+ }
+ @keyframes cardpop{ from{ transform:scale(.92) translateY(10px); opacity:0 } to{ transform:none; opacity:1 } }
+ .photo-well{ position:relative; width:100%; aspect-ratio:3/4; border-radius:16px; overflow:hidden; background:#1c1712; }
+ .photo-well img{
+ width:100%; height:100%; object-fit:cover; display:block;
+ filter:blur(16px) saturate(.35) brightness(1.18); opacity:.82;
+ transition:filter var(--develop-ms) cubic-bezier(.16,.7,.24,1), opacity var(--develop-ms) ease-out;
+ }
+ .photo-well img.developed{ filter:blur(0) saturate(1) brightness(1); opacity:1; }
+ .sheen{
+ position:absolute; inset:0; pointer-events:none;
+ background:linear-gradient(115deg, transparent 35%, rgba(255,255,255,.55) 48%, transparent 62%);
+ transform:translateX(-120%); opacity:0;
+ }
+ .sheen.go{ animation:sheensweep var(--develop-ms) ease-out forwards; }
+ @keyframes sheensweep{ 0%{transform:translateX(-120%);opacity:.9} 70%{opacity:.5} 100%{transform:translateX(120%);opacity:0} }
+
+ .chin{ padding:16px 6px 18px; text-align:center; }
+ .chin-row{ font-family:'Caveat',cursive; font-size:22px; color:var(--muted); display:flex; align-items:center; justify-content:center; gap:8px; min-height:30px; }
+ .dot-flicker{ width:8px;height:8px;border-radius:50%;background:var(--gold); animation:flicker 1.1s ease-in-out infinite; }
+ @keyframes flicker{ 0%,100%{opacity:.25} 50%{opacity:1} }
+
+ .chin-fields{ text-align:left; }
+ .field{ margin-bottom:10px; }
+ .field label{ display:block; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); margin-bottom:4px; }
+ .field input{
+ width:100%; font-size:16px; padding:11px 12px; border-radius:10px; border:1.5px solid var(--line);
+ background:var(--paper); color:var(--ink); font-family:inherit;
+ }
+ .field input:focus{ outline:none; border-color:var(--gold); background:var(--white); }
+ .field.optional input{ font-size:14px; padding:9px 11px; }
+ .tiny-meta{ font-size:11px; color:var(--muted); text-align:center; margin:2px 0 10px; min-height:14px; }
+ .tiny-meta b{ color:var(--gold-deep) }
+
+ .save-row{ display:flex; gap:10px; padding-bottom:16px; }
+ .btn{ flex:1; border:none; border-radius:14px; padding:15px 10px; font-size:15px; font-weight:700; cursor:pointer; text-align:center; font-family:inherit; }
+ .btn-outline{ background:var(--white); color:var(--ink); border:1.5px solid var(--line); flex:0.85; }
+ .btn-outline:active{ background:var(--paper); }
+ .btn-gold{ background:linear-gradient(180deg, var(--gold-soft), var(--gold)); color:#332506; flex:1.3; box-shadow:0 8px 18px -8px rgba(166,127,46,.6); transition:opacity .15s,transform .12s; }
+ .btn-gold:active:not([disabled]){ transform:scale(.98); }
+ .btn-gold[disabled]{ opacity:.5; cursor:default; box-shadow:none; }
+
+ .toast{
+ position:fixed; left:50%; bottom:max(22px,env(safe-area-inset-bottom)); transform:translateX(-50%) translateY(8px);
+ background:#241d12; color:#f7efdd; padding:12px 20px; border-radius:14px; font-size:14px; font-weight:600;
+ box-shadow:var(--shadow); z-index:50; opacity:0; pointer-events:none; transition:opacity .25s, transform .25s;
+ }
+ .toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }
+
+ [hidden]{ display:none !important; }
+</style>
+</head>
+<body>
+
+ <div class="brand"><b><span class="full">DESIGNER WALLCOVERINGS</span><span class="short">DW</span></b><span class="script">Instant</span></div>
+ <div class="sesschip">📸 <b id="sessCount">0</b> saved this session</div>
+
+ <!-- STATE: live viewfinder -->
+ <div class="stage" id="stage">
+ <div class="viewfinder">
+ <div class="vf-window">
+ <video id="cam" autoplay playsinline muted></video>
+ <div class="vf-corner tl"></div><div class="vf-corner tr"></div>
+ <div class="vf-corner bl"></div><div class="vf-corner br"></div>
+ <button class="flip-btn" id="flipBtn" title="Flip camera" aria-label="Flip camera">⟲</button>
+ <div class="vf-hint" id="vfHint">frame the label & tap to shoot</div>
+ </div>
+ </div>
+ <button class="shutter" id="shutterBtn" aria-label="Take photo"><span class="dot"></span></button>
+ </div>
+
+ <!-- STATE: developing / result card -->
+ <div class="card-stage" id="cardStage" hidden>
+ <div class="instant-card">
+ <div class="photo-well">
+ <img id="shotImg" alt="Captured sample">
+ <div class="sheen" id="sheen"></div>
+ </div>
+ <div class="chin">
+ <div class="chin-row" id="developingRow"><span class="dot-flicker"></span> developing…</div>
+ <div class="chin-fields" id="chinFields" hidden>
+ <div class="field">
+ <label for="fVendor">Vendor</label>
+ <input id="fVendor" list="vendorList" placeholder="e.g. Kravet" autocomplete="off">
+ <datalist id="vendorList"></datalist>
+ </div>
+ <div class="field">
+ <label for="fMfr">Manufacturer # / SKU</label>
+ <input id="fMfr" placeholder="e.g. 34567-1116" autocomplete="off">
+ </div>
+ <div class="field optional">
+ <label for="fPattern">Pattern / Color <span style="text-transform:none;font-weight:400">(optional)</span></label>
+ <input id="fPattern" placeholder="e.g. Bermuda — Sand" autocomplete="off">
+ </div>
+ <div class="tiny-meta" id="tinyMeta"> </div>
+ <div class="save-row">
+ <button class="btn btn-outline" id="retakeBtn">↺ Retake</button>
+ <button class="btn btn-gold" id="saveBtn" disabled>⏳ Reading label…</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="flash-overlay" id="flashOverlay"></div>
+ <div class="toast" id="toast"></div>
+
+<script>
+(function(){
+ var $ = function(id){ return document.getElementById(id); };
+ var video = $('cam'), canvas = document.createElement('canvas');
+ var stage = $('stage'), cardStage = $('cardStage');
+ var shutterBtn = $('shutterBtn'), flipBtn = $('flipBtn');
+ var flashOverlay = $('flashOverlay'), sheen = $('sheen');
+ var shotImg = $('shotImg'), developingRow = $('developingRow'), chinFields = $('chinFields');
+ var fVendor = $('fVendor'), fMfr = $('fMfr'), fPattern = $('fPattern');
+ var tinyMeta = $('tinyMeta'), saveBtn = $('saveBtn'), retakeBtn = $('retakeBtn');
+ var toastEl = $('toast'), sessCountEl = $('sessCount'), vendorList = $('vendorList');
+
+ var FLASH_MS = 150, DEVELOP_MS = 1100; // purely cosmetic — never gates Save
+ var stream = null, facing = 'environment';
+ var busy = false, capturedDataUrl = null, extractDone = false, extractAbort = null;
+ var vendorFromExtract = false, sessCount = 0;
+
+ function toast(msg, ms){
+ toastEl.textContent = msg; toastEl.classList.add('show');
+ clearTimeout(toast._t); toast._t = setTimeout(function(){ toastEl.classList.remove('show'); }, ms || 2600);
+ }
+
+ // ---- camera: start immediately on load, never torn down between shots (retake is instant). ----
+ async function startCamera(f){
+ if (!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
+ $('vfHint').textContent = 'needs Safari/Chrome over HTTPS (or localhost)'; return;
+ }
+ try{
+ if (stream) stream.getTracks().forEach(function(t){ t.stop(); });
+ stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:{ ideal:f }, width:{ ideal:1920 }, height:{ ideal:1440 } }, audio:false });
+ }catch(e){
+ try{ stream = await navigator.mediaDevices.getUserMedia({ video:true, audio:false }); }
+ catch(e2){ toast('Camera unavailable — check permissions.'); return; }
+ }
+ video.srcObject = stream;
+ try{ await video.play(); }catch(e){}
+ shutterBtn.disabled = false;
+ }
+ flipBtn.addEventListener('click', function(){
+ facing = facing === 'environment' ? 'user' : 'environment';
+ startCamera(facing);
+ });
+
+ // ---- vendor autocomplete: loaded in the background, never blocks camera/shutter. ----
+ fetch('/api/vendors-registry').then(function(r){ return r.json(); }).then(function(d){
+ var seen = {};
+ (d.vendors || []).forEach(function(v){
+ var name = v.vendor; if (!name || seen[name]) return; seen[name] = 1;
+ var o = document.createElement('option'); o.value = name; vendorList.appendChild(o);
+ });
+ }).catch(function(){});
+
+ function fmt(ms){ return ms < 1000 ? Math.round(ms) + 'ms' : (ms/1000).toFixed(1) + 's'; }
+
+ function updateSaveEnabled(){
+ var ok = fVendor.value.trim() && fMfr.value.trim();
+ saveBtn.disabled = !ok;
+ if (ok) saveBtn.textContent = '💾 Save';
+ else saveBtn.textContent = extractDone ? '✍️ Vendor + Mfr# needed' : '⏳ Reading label…';
+ }
+ fVendor.addEventListener('input', updateSaveEnabled);
+ fMfr.addEventListener('input', updateSaveEnabled);
+
+ function resetToLive(){
+ cardStage.hidden = true; stage.hidden = false;
+ shotImg.className = ''; shotImg.src = '';
+ developingRow.hidden = false; chinFields.hidden = true;
+ fVendor.value = ''; fMfr.value = ''; fPattern.value = '';
+ tinyMeta.innerHTML = ' ';
+ capturedDataUrl = null; extractDone = false; vendorFromExtract = false; busy = false;
+ saveBtn.disabled = true; saveBtn.textContent = '⏳ Reading label…';
+ shutterBtn.disabled = !stream; // re-arm the shutter — the camera stream is still live, no re-init needed
+ }
+
+ async function doCapture(){
+ if (busy || !stream) return;
+ busy = true; shutterBtn.disabled = true;
+
+ var vw = video.videoWidth || 1280, vh = video.videoHeight || 960;
+ var maxDim = 1600, scale = Math.min(1, maxDim / Math.max(vw, vh));
+ canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
+ canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
+ capturedDataUrl = canvas.toDataURL('image/jpeg', 0.86);
+
+ // ── SPEED: fire the REAL network call the instant we have pixels — before any
+ // flash/develop UI even starts. The cosmetic animation below runs on its own
+ // fixed clock; Save's enabled state is gated on this promise settling, never
+ // on the animation timers further down. TK-12162 "Instant Film" speed rule.
+ var t0 = performance.now();
+ extractAbort = new AbortController();
+ var extractPromise = fetch('/api/extract', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ dataUrl: capturedDataUrl }), signal: extractAbort.signal
+ }).then(function(r){ return r.json(); }).catch(function(e){
+ return { ok:false, err: e.name === 'AbortError' ? 'aborted' : (e.message || 'network') };
+ });
+
+ // ---- cosmetic: shutter-flash → develop reveal. Runs in parallel, blocks nothing. ----
+ flashOverlay.classList.remove('go'); void flashOverlay.offsetWidth; flashOverlay.classList.add('go');
+ setTimeout(function(){
+ stage.hidden = true; cardStage.hidden = false;
+ shotImg.src = capturedDataUrl; shotImg.className = '';
+ developingRow.hidden = false; chinFields.hidden = true;
+ sheen.classList.remove('go'); void sheen.offsetWidth; sheen.classList.add('go');
+ requestAnimationFrame(function(){ requestAnimationFrame(function(){ shotImg.classList.add('developed'); }); });
+ setTimeout(revealResult, DEVELOP_MS);
+ }, FLASH_MS);
+
+ extractPromise.then(function(data){
+ extractDone = true;
+ var elapsed = performance.now() - t0;
+ var fields = (data && data.fields) || {};
+ if (!fVendor.value.trim() && (data.vendor_matched || fields.vendor)){
+ fVendor.value = data.vendor_matched || fields.vendor; vendorFromExtract = true;
+ }
+ if (!fMfr.value.trim() && fields.mfr_sku) fMfr.value = fields.mfr_sku;
+ if (!fPattern.value.trim() && (fields.pattern_name || fields.color)){
+ fPattern.value = [fields.pattern_name, fields.color].filter(Boolean).join(' — ');
+ }
+ if (data && data.err) tinyMeta.innerHTML = '🔎 label read in <b>' + fmt(elapsed) + '</b> · no fields found, enter by hand';
+ else if (data && data.ok === false) tinyMeta.innerHTML = '🔎 checked in <b>' + fmt(elapsed) + '</b> · enter details by hand';
+ else tinyMeta.innerHTML = '🔎 label read in <b>' + fmt(elapsed) + '</b>';
+ updateSaveEnabled();
+ });
+ }
+
+ function revealResult(){
+ developingRow.hidden = true; chinFields.hidden = false;
+ updateSaveEnabled();
+ if (!extractDone) tinyMeta.innerHTML = '🔎 still reading the label…';
+ fVendor.focus({ preventScroll:true });
+ }
+
+ shutterBtn.addEventListener('click', doCapture);
+
+ retakeBtn.addEventListener('click', function(){
+ if (extractAbort) try{ extractAbort.abort(); }catch(e){}
+ resetToLive();
+ // camera stream was never stopped — viewfinder is live again with zero re-init latency.
+ });
+
+ saveBtn.addEventListener('click', async function(){
+ if (saveBtn.disabled) return;
+ saveBtn.disabled = true; var prevLabel = saveBtn.textContent; saveBtn.textContent = 'Saving…';
+ var payload = {
+ vendor: fVendor.value.trim(), mfr: fMfr.value.trim(),
+ name: fPattern.value.split('—')[0].trim(), color: (fPattern.value.split('—')[1] || '').trim(),
+ price:'', width:'', repeat:'', substrate:'', how_sold:'', notes:'',
+ dataUrl: capturedDataUrl, photos:[capturedDataUrl],
+ require_two:true, front_present:true, back_present:false,
+ id_source: vendorFromExtract ? 'ocr' : 'manual', commit:true
+ };
+ try{
+ var r = await (await fetch('/api/create-item', {
+ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
+ })).json();
+ if (r.duplicate){ toast('⚠ ' + (r.err || 'already in catalog')); saveBtn.disabled = false; saveBtn.textContent = prevLabel; return; }
+ if (!r.ok){ toast('✗ ' + (r.err || 'save failed')); saveBtn.disabled = false; saveBtn.textContent = prevLabel; return; }
+ sessCount++; sessCountEl.textContent = sessCount;
+ toast('✅ Saved ' + (r.dw_sku || 'item'));
+ setTimeout(resetToLive, 550);
+ }catch(e){ toast('✗ network error'); saveBtn.disabled = false; saveBtn.textContent = prevLabel; }
+ });
+
+ startCamera(facing);
+})();
+</script>
+</body>
+</html>
← 247c9f9 TK-12090: report provider=null when the vision call fails
·
back to Dw Photo Capture
·
auto-data-snapshot: 2026-09-24T13:20:22 (1 data files) — dat a7bae5f →