← back to Dw Photo Capture
Photo capture: linear 2-photo flow, Measure Depth, White Balance, colour hue, continue-able batch, mic-every-field
14ba745ecc9778058774f686c9c2704d8ef92450 · 2026-09-18 14:36:50 -0700 · steve
- LINEAR capture: Photo 1 (front) → Photo 2 (back) → THEN auto-determine mfr info
(back = primary OCR source, front secondary). Removed the confusing 'front read the
code, back optional' mid-capture branch. Editable review of mfr/name/colour/specs.
- mfr# fallback priority: auto-determine → tap-to-SELECT from detected OCR candidates
(/api/ocr candidates) → manual free-text. Never creates without an mfr#.
- Measure Depth: renamed from 'calibration/measure'; iOS Safari hardening (explicit
inline video.play, rAF-deferred ppi read so 0-width can't NaN the scale, guarded save,
clearer 'Set scale (card)' copy).
- White Balance: tap a white/grey point in the live camera → per-device colour-cast
correction (persisted in localStorage), applied to every captured swatch; gray-world
auto fallback; clear.
- Colour hue: client-side (/bin/zsh) dominant colour of the white-balanced front centre
(skips glare/shadow) → chip + HEX + nearest DW colourway name → auto-fills colour field.
- Continue-able batch: after create, '➕ Next item' clears the item + jumps to Photo 1
but KEEPS sticky vendor + Measure-Depth scale + White-Balance; running session count.
- Mic on every editable field via one dictateInto() helper (Web Speech, /bin/zsh); added
editable Width/Repeat/Content/How-sold/Notes fields; feature-detect HIDES mics when
the device lacks Web Speech (native keyboard mic is the fallback) — no dead controls.
- Fix: .chips filter row flex-wrap (was overflowing → body horizontal scroll at 390px).
Verified headless Chrome: linear gate progression, WB neutralises a warm cast
(r×0.88/b×1.18), colour names map, mic-hide fallback (10/10 hidden w/o Web Speech),
no body h-scroll @390px, 0 page errors; dry-run new specs → FM Width/Content/Sold Per + notes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCq3132eTmJESAKqJBM8FN
Files touched
Diff
commit 14ba745ecc9778058774f686c9c2704d8ef92450
Author: steve <steve@designerwallcoverings.com>
Date: Fri Sep 18 14:36:50 2026 -0700
Photo capture: linear 2-photo flow, Measure Depth, White Balance, colour hue, continue-able batch, mic-every-field
- LINEAR capture: Photo 1 (front) → Photo 2 (back) → THEN auto-determine mfr info
(back = primary OCR source, front secondary). Removed the confusing 'front read the
code, back optional' mid-capture branch. Editable review of mfr/name/colour/specs.
- mfr# fallback priority: auto-determine → tap-to-SELECT from detected OCR candidates
(/api/ocr candidates) → manual free-text. Never creates without an mfr#.
- Measure Depth: renamed from 'calibration/measure'; iOS Safari hardening (explicit
inline video.play, rAF-deferred ppi read so 0-width can't NaN the scale, guarded save,
clearer 'Set scale (card)' copy).
- White Balance: tap a white/grey point in the live camera → per-device colour-cast
correction (persisted in localStorage), applied to every captured swatch; gray-world
auto fallback; clear.
- Colour hue: client-side (/bin/zsh) dominant colour of the white-balanced front centre
(skips glare/shadow) → chip + HEX + nearest DW colourway name → auto-fills colour field.
- Continue-able batch: after create, '➕ Next item' clears the item + jumps to Photo 1
but KEEPS sticky vendor + Measure-Depth scale + White-Balance; running session count.
- Mic on every editable field via one dictateInto() helper (Web Speech, /bin/zsh); added
editable Width/Repeat/Content/How-sold/Notes fields; feature-detect HIDES mics when
the device lacks Web Speech (native keyboard mic is the fallback) — no dead controls.
- Fix: .chips filter row flex-wrap (was overflowing → body horizontal scroll at 390px).
Verified headless Chrome: linear gate progression, WB neutralises a warm cast
(r×0.88/b×1.18), colour names map, mic-hide fallback (10/10 hidden w/o Web Speech),
no body h-scroll @390px, 0 page errors; dry-run new specs → FM Width/Content/Sold Per + notes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCq3132eTmJESAKqJBM8FN
---
public/index.html | 299 +++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 251 insertions(+), 48 deletions(-)
diff --git a/public/index.html b/public/index.html
index 8d93e95..653dedc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -54,7 +54,7 @@
input[type=search]{flex:1;min-width:140px}
.scanbtn{position:relative;overflow:hidden;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:9px 12px;font-size:14px;font-weight:600;white-space:nowrap;cursor:pointer}
.scanbtn input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
- .chips{display:flex;gap:6px}
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{padding:7px 13px;border-radius:99px;border:1px solid var(--line);background:#16140f;color:var(--muted);font-size:13px;cursor:pointer}
.chip.on{background:var(--gold);color:#1b1407;border-color:var(--gold);font-weight:600}
.recog{grid-column:1/-1;background:var(--card);border:1px solid var(--gold);border-radius:14px;padding:12px 14px;margin-bottom:4px}
@@ -267,8 +267,38 @@
.shot-btn.have{border-style:solid;border-color:#3f7d4f;background:#10160f}
.shot-btn.have .shot-ck::after{content:'✓';position:absolute;top:6px;right:9px;color:#5cc177;font-size:16px;font-weight:800}
.shot-btn.need{border-color:#b8902f}
+ .shot-btn .shot-num{position:absolute;top:6px;left:9px;width:20px;height:20px;border-radius:50%;background:#2e2a25;color:#d8cba6;font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center}
+ .shot-btn.have .shot-num{background:#3f7d4f;color:#fff}
.shot-status{font-size:13px;margin:2px 0 8px;min-height:16px;color:#c8ba97}
.shot-status.block{color:#e6a23a}
+ /* OCR candidate picker — tap the correct detected SKU */
+ .cand-pick{margin:2px 0 10px}
+ .cand-pick .cp-lbl{font-size:12.5px;color:#e6a23a;font-weight:700;margin-bottom:6px}
+ .cand-pick .cp-chips{display:flex;flex-wrap:wrap;gap:8px}
+ .cand-pick .cp-chip{background:#1b1812;border:2px solid var(--gold);color:var(--ink);border-radius:10px;padding:12px 16px;font-size:17px;font-weight:600;cursor:pointer;min-height:48px}
+ .cand-pick .cp-chip:active{background:#2a2418}
+ .cand-pick .cp-manual{background:#0f0e0b;border:1px dashed var(--line);color:#9a917d;border-radius:10px;padding:12px 16px;font-size:14px;cursor:pointer}
+ /* auto-detected swatch colour readout */
+ .color-read{display:none;align-items:center;gap:10px;margin:2px 0 10px;padding:9px 11px;background:#0f0e0b;border:1px solid var(--line);border-radius:10px}
+ .color-read[hidden]{display:none}.color-read.show{display:flex}
+ .color-read .cr-sw{width:38px;height:38px;border-radius:8px;border:1px solid rgba(255,255,255,.2);flex:0 0 auto}
+ .color-read .cr-name{font-size:15px;font-weight:700;color:#e8ddc2}
+ .color-read .cr-hex{font-size:12px;color:#9a917d;font-family:ui-monospace,Menlo,monospace}
+ .color-read .cr-use{margin-left:auto;background:#1b1812;border:1px solid var(--gold);color:var(--ink);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
+ .cam-tools{display:flex;gap:8px}.cam-tools .add-voice{flex:1}
+ .sess-count{font-size:12px;font-weight:700;color:#0b0a07;background:var(--gold);border-radius:999px;padding:2px 9px;vertical-align:middle}
+ .sess-count[hidden]{display:none}
+ /* mic recording pulse */
+ .mic-btn.on{color:#fff;background:var(--red)!important;animation:micpulse .9s ease-in-out infinite}
+ @keyframes micpulse{0%,100%{box-shadow:0 0 0 0 rgba(192,86,63,.6)}50%{box-shadow:0 0 0 7px rgba(192,86,63,0)}}
+ .mic-btn.mic-off{display:none}
+ /* White Balance modal */
+ #wbModal{position:fixed;inset:0;z-index:400;background:#000}
+ #wbModal[hidden]{display:none}
+ #wbVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
+ .wb-reticle{position:absolute;top:50%;left:50%;width:64px;height:64px;transform:translate(-50%,-50%);border:3px solid #fff;border-radius:50%;box-shadow:0 0 0 2px rgba(0,0,0,.5),0 0 12px rgba(0,0,0,.6);pointer-events:none}
+ .wb-readout{position:absolute;bottom:max(96px,20%);left:50%;transform:translateX(-50%);max-width:86%;text-align:center;background:rgba(8,7,6,.82);color:#fff;font-size:15px;font-weight:600;padding:11px 16px;border-radius:14px;border:1px solid rgba(255,255,255,.2)}
+ #wbAuto{background:#c8a24a;color:#221a06}#wbClear{background:#333;color:#fff}#wbClose{background:#2bd06a;color:#032}
.media-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:10px}
.media-grid:empty{display:none}
.media-cell{position:relative;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;overflow:hidden;border:1px solid var(--line)}
@@ -531,7 +561,7 @@
<div class="samp" id="addModal" hidden>
<div class="samp-card">
<button class="samp-x" id="addClose">✕</button>
- <div class="samp-pat" id="addTitle" style="margin-bottom:4px">Add new item</div>
+ <div class="samp-pat" id="addTitle" style="margin-bottom:4px">Add new item <span id="sessCount" class="sess-count" hidden></span></div>
<div class="media-sub" id="addTarget"></div>
<!-- VENDOR FIRST: pick the vendor before anything else — it drives the DW#, Shopify vendor + FM vid -->
<div class="xl-vendor-wrap" id="xlVendorWrap">
@@ -542,10 +572,14 @@
<!-- TWO-PHOTO gate: FRONT (pattern face) + BACK (label/SKU, OCR'd). Create is blocked until
FRONT exists AND (BACK exists OR the mfr#/details are entered manually). -->
<div class="two-shot">
- <button class="shot-btn" id="frontBtn"><span class="shot-ico">📷</span><b>Front</b><small>pattern face</small><i class="shot-ck" id="frontCk"></i></button>
- <button class="shot-btn" id="backBtn"><span class="shot-ico">🏷</span><b>Back</b><small>label / SKU</small><i class="shot-ck" id="backCk"></i></button>
+ <button class="shot-btn" id="frontBtn"><span class="shot-num">1</span><span class="shot-ico">📷</span><b>Front</b><small>pattern face</small><i class="shot-ck" id="frontCk"></i></button>
+ <button class="shot-btn" id="backBtn"><span class="shot-num">2</span><span class="shot-ico">🏷</span><b>Back</b><small>label / SKU</small><i class="shot-ck" id="backCk"></i></button>
</div>
<div class="shot-status" id="shotStatus"></div>
+ <!-- OCR fallback: when the mfr# can't be auto-determined, offer detected code candidates to tap -->
+ <div class="cand-pick" id="candPick" hidden></div>
+ <!-- auto-detected swatch colour (from the white-balanced front photo): chip + HEX + nearest name -->
+ <div class="color-read" id="colorRead" hidden></div>
<div class="media-grid" id="addMedia"></div>
<div class="media-add">
<button class="media-btn" id="addPhotoBtn">➕ More photo <span id="cPhoto">0</span></button>
@@ -555,11 +589,17 @@
<input type="file" accept="image/*" capture="environment" id="backInput" hidden>
<input type="file" accept="image/*" capture="environment" id="addPhotoInput" hidden>
<input type="file" accept="video/*" capture="environment" id="addVideoInput" hidden>
- <button class="add-voice" id="addMeasure">📐 Measure real size (place a card by the goods)</button>
+ <div class="cam-tools">
+ <button class="add-voice" id="addWB">⚪ White Balance</button>
+ <button class="add-voice" id="addMeasure">📐 Measure Depth</button>
+ </div>
<button class="add-voice" id="addVoice">🎤 Voice-fill everything that's missing</button>
<div class="add-field"><label>Mfr # / SKU</label><div class="in-mic"><input id="addMfr" autocapitalize="characters" placeholder="e.g. TR2581"><button class="mic-btn" data-mic="addMfr" data-kind="sku" title="Speak the SKU / mfr number">🎤</button></div></div>
<div class="add-row"><div class="add-field"><label>VID</label><div class="in-mic"><input id="addVid" placeholder="THI"><button class="mic-btn" data-mic="addVid" data-kind="text" title="Speak the VID">🎤</button></div></div><div class="add-field"><label>Color</label><div class="in-mic"><input id="addColor"><button class="mic-btn" data-mic="addColor" data-kind="text" title="Speak the color">🎤</button></div></div></div>
<div class="add-row"><div class="add-field"><label>Pattern name</label><div class="in-mic"><input id="addName"><button class="mic-btn" data-mic="addName" data-kind="text" title="Speak the pattern name">🎤</button></div></div><div class="add-field"><label>Price</label><div class="in-mic"><input id="addPrice" type="number" inputmode="decimal"><button class="mic-btn" data-mic="addPrice" data-kind="num" title="Speak the price">🎤</button></div></div></div>
+ <div class="add-row"><div class="add-field"><label>Width</label><div class="in-mic"><input id="addWidth" placeholder="e.g. 27 in"><button class="mic-btn" data-mic="addWidth" data-kind="text" title="Speak the width">🎤</button></div></div><div class="add-field"><label>Repeat</label><div class="in-mic"><input id="addRepeat" placeholder="e.g. 25.2 in"><button class="mic-btn" data-mic="addRepeat" data-kind="text" title="Speak the repeat">🎤</button></div></div></div>
+ <div class="add-row"><div class="add-field"><label>Content</label><div class="in-mic"><input id="addContent" placeholder="e.g. non-woven"><button class="mic-btn" data-mic="addContent" data-kind="text" title="Speak the content">🎤</button></div></div><div class="add-field"><label>How sold</label><div class="in-mic"><input id="addHowSold" placeholder="e.g. per yard"><button class="mic-btn" data-mic="addHowSold" data-kind="text" title="Speak how it's sold">🎤</button></div></div></div>
+ <div class="add-field"><label>Notes</label><div class="in-mic"><input id="addNotes" placeholder="anything else"><button class="mic-btn" data-mic="addNotes" data-kind="text" title="Speak a note">🎤</button></div></div>
<div id="addSpecs"></div>
<button class="samp-print" id="addPreview">👁 Preview</button>
<div class="samp-note" id="addNote"></div>
@@ -567,19 +607,32 @@
</div>
</div>
-<!-- Live on-camera dimension measure: a credit card (3.375") in frame calibrates real inches -->
+<!-- Measure Depth: a credit card (3.375") in frame sets the px→inch scale, then size the box to the goods -->
<div id="measureModal" hidden>
- <video id="mVideo" autoplay playsinline muted></video>
+ <video id="mVideo" autoplay playsinline muted webkit-playsinline></video>
<div class="mbox mcard" id="mCard"><span class="mlbl">💳 card</span><i class="mrz"></i></div>
<div class="mbox mgoods" id="mSample"><span class="mlbl">📦 goods</span><i class="mrz"></i></div>
- <div class="mreadout" id="mReadout">Size 💳 to a credit card, 📦 to the goods</div>
+ <div class="mreadout" id="mReadout">Measure Depth — set the scale with a credit card, then size the box to the goods</div>
<div class="mbar">
- <button id="mSaveCal" hidden>✓ Save calibration</button>
+ <button id="mSaveCal" hidden>✓ Set scale</button>
<button id="mUse" hidden>✓ Use W×L</button>
- <button id="mRecal" hidden>↺ Recalibrate</button>
+ <button id="mRecal" hidden>↺ Set scale (card)</button>
<button id="mClose">✕ Close</button>
</div>
</div>
+
+<!-- White Balance: tap a white/grey area in the live frame to set the white point; correction is
+ applied to captured swatches so colour renders true, and persisted per-device in localStorage. -->
+<div id="wbModal" hidden>
+ <video id="wbVideo" autoplay playsinline muted webkit-playsinline></video>
+ <div class="wb-reticle" id="wbReticle"></div>
+ <div class="wb-readout" id="wbReadout">Tap a WHITE or GREY area (or hold a white card) to set the white point.</div>
+ <div class="mbar">
+ <button id="wbAuto">✨ Auto (gray-world)</button>
+ <button id="wbClear" hidden>↺ Clear</button>
+ <button id="wbClose">✕ Done</button>
+ </div>
+</div>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -1782,40 +1835,158 @@ function renderMedia(){ const c=mediaCounts();
? `<div class="media-cell"><video src="${m.dataUrl}" muted playsinline preload="metadata"></video><span class="badge">🎥</span><button class="rm" data-rm="${i}">✕</button></div>`
: `<div class="media-cell" style="background-image:url('${m.dataUrl}')"><button class="rm" data-rm="${i}">✕</button></div>`).join('');
$('#addMedia').querySelectorAll('.rm').forEach(b=>b.addEventListener('click',()=>{ _media.splice(+b.dataset.rm,1); renderMedia(); })); }
-function addResetMedia(){ _media=[]; _addPhoto=null; _frontPhoto=null; _backPhoto=null; _frontHasCode=false; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; renderMedia(); renderShots(); }
+function addResetMedia(){ _media=[]; _addPhoto=null; _frontPhoto=null; _backPhoto=null; _idSource=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; if(typeof clearCandidates==='function')clearCandidates(); renderMedia(); renderShots(); }
function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again or use a JPEG'); return; }
_media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
- if(!_extracted){ _extracted=true; addExtract(); } // first photo = the scan → read specs
+ if(!_extracted && _addMode==='update'){ _extracted=true; addExtract(); } // UPDATE mode: first extra photo = label scan (ADD mode uses the 2-photo autoExtract)
} else { if(c.v>=MAX_VIDEOS){ toast('Max '+MAX_VIDEOS+' videos'); return; }
if(file.size>150*1024*1024){ toast('Video too big (>150MB)'); return; }
const dataUrl=await fileToDataUrl(file); _media.push({type:'video',dataUrl,name:file.name||'clip.mp4'}); renderMedia(); } }
// ── IDENTITY gate: a new SKU must have an mfr# from one of {front-OCR, back-OCR, manual} ──────────
-// FRONT (pattern face) is the featured image and is OCR'd first. If the front already carries the
-// code, the BACK photo is OPTIONAL; otherwise a BACK label photo (OCR'd) OR manual mfr# is required.
-let _frontPhoto=null, _backPhoto=null, _frontHasCode=false;
+// ── LINEAR two-photo flow: shoot Photo 1 (front) → Photo 2 (back) → THEN determine the mfr info ──
+// Both photos are always taken, in order — no mid-capture branching. Only AFTER both are captured
+// do we extract: the BACK label is the PRIMARY source for the mfr#/specs, the FRONT is secondary.
+// Then a review/edit step. If the mfr# can't be auto-determined we present detected OCR code
+// CANDIDATES to tap; only if there are none do we fall back to manual free-text. Never no mfr#.
+let _frontPhoto=null, _backPhoto=null, _autoBusy=false, _idSource=null;
async function shotAdd(side,file){ if(!file)return;
- const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again'); return; }
- if(side==='front'){ _frontPhoto=url; _addPhoto=url; renderShots(); await addExtract(url,'front'); } // priority 1: front-OCR
- else { _backPhoto=url; renderShots(); await addExtract(url,'back'); } // priority 2: back-OCR
- renderShots(); addDedupCheck&&addDedupCheck(); }
+ let url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again'); return; }
+ url=await applyWB(url); // true colour: apply the per-device white-balance correction to the swatch
+ if(side==='front'){ _frontPhoto=url; _addPhoto=url; detectAndShowColor(); } // hue readout off the white-balanced front
+ else { _backPhoto=url; if(!_addPhoto)_addPhoto=url; }
+ renderShots();
+ if(_addMode==='update'){ if(!_extracted){ _extracted=true; await addExtract(url); } return; } // UPDATE mode keeps its label-resolve
+ if(_frontPhoto && _backPhoto) await autoExtract(); // ADD mode: determine only after BOTH
+}
// Ordered photo list the backend receives: FRONT (pos1) then BACK (pos2) then any extras.
function addPhotosList(){ return [_frontPhoto,_backPhoto].filter(Boolean).concat(_media.filter(m=>m.type==='photo').map(m=>m.dataUrl)); }
-// Create is allowed only with FRONT photo + an mfr# identity (front-OCR | back-OCR | manual).
-function twoPhotoGate(){ const mfr=($('#addMfr').value||'').trim();
- if(!_frontPhoto) return { ok:false, msg:'📷 Take the FRONT (pattern) photo to continue.' };
- if(!mfr) return { ok:false, msg:'🏷 No code read from the front — take the BACK (label) photo, or type the mfr#/SKU.' };
- const src = _frontHasCode ? 'front photo — back optional' : (_backPhoto ? 'back label' : 'manual entry');
- return { ok:true, msg:'✓ mfr# identity from '+src };
-}
-function renderShots(){
- const mfr=($('#addMfr').value||'').trim();
+// STEP 3+4: after both photos, determine the mfr# — auto (read) → else OCR candidates to select → else manual.
+async function autoExtract(){ if(_autoBusy)return; _autoBusy=true; const mySession=_addSession; clearCandidates(); _idSource=null;
+ try{
+ setStatus('🔍 Reading the label — determining the mfr info from the 2 photos…',false);
+ await extractInto(_backPhoto); // PRIMARY: the back label
+ if(_addSession!==mySession) return;
+ if(!curMfr()){ await extractInto(_frontPhoto); if(_addSession!==mySession) return; } // SECONDARY: the front
+ if(curMfr()){ _idSource='auto'; setShotReview('✓ mfr info read from the 2 photos — review & edit below'); return; }
+ // no confident mfr# → gather detected OCR code candidates (back first, then front)
+ let oc=await ocrCandidates(_backPhoto); if(_addSession!==mySession) return;
+ if(!(oc.candidates&&oc.candidates.length)){ oc=await ocrCandidates(_frontPhoto); if(_addSession!==mySession) return; }
+ if(oc.topStrong && oc.top){ $('#addMfr').value=oc.top; _idSource='auto'; setShotReview('✓ Read a SKU off the label — review & edit below'); return; }
+ if(oc.candidates && oc.candidates.length){ renderCandidates(oc.candidates); return; } // step 4a: user SELECTS a candidate
+ promptManual(); // step 4b: manual free-text
+ } finally { _autoBusy=false; renderShotTiles(); }
+}
+function curMfr(){ return ($('#addMfr').value||'').trim(); }
+function setStatus(msg,block){ const s=$('#shotStatus'); if(s){ s.textContent=msg; s.classList.toggle('block',!!block); } }
+function setShotReview(msg){ setStatus(msg,false); addDedupCheck&&addDedupCheck(); }
+async function ocrCandidates(dataUrl){ try{ const r=await(await fetch('/api/ocr',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})})).json(); return { candidates:(r&&r.candidates)||[], top:(r&&r.top)||null, topStrong:!!(r&&r.topStrong) }; }catch(e){ return {candidates:[],top:null,topStrong:false}; } }
+function clearCandidates(){ const c=$('#candPick'); if(c){ c.hidden=true; c.innerHTML=''; } }
+function renderCandidates(list){ const c=$('#candPick'); if(!c)return; const uniq=[...new Set(list.map(x=>String(x).toUpperCase()))].slice(0,8);
+ c.innerHTML='<div class="cp-lbl">🏷 Couldn’t auto-pick the SKU — tap the correct one:</div><div class="cp-chips">'
+ +uniq.map(x=>`<button class="cp-chip" data-code="${x}">${x}</button>`).join('')
+ +'<button class="cp-manual" id="candManual">✎ none — type it</button></div>';
+ c.hidden=false;
+ c.querySelectorAll('.cp-chip').forEach(b=>b.addEventListener('click',()=>{ $('#addMfr').value=b.dataset.code; _idSource='select'; clearCandidates(); $('#addMfr').classList.remove('need'); setShotReview('✓ SKU selected — review & edit below'); }));
+ $('#candManual').addEventListener('click',()=>{ clearCandidates(); promptManual(); });
+ setStatus('🏷 Tap the correct SKU above, or type it below.',true); }
+function promptManual(){ clearCandidates(); _idSource='manual'; $('#addMfr').classList.add('need'); setStatus('🏷 Couldn’t read the mfr# — type the mfr#/SKU below.',true); try{ $('#addMfr').focus(); }catch(e){} }
+// Lean extract-and-fill (no prompt/dedup/update side-effects — the flow orchestrates those).
+async function extractInto(dataUrl){ if(!dataUrl)return; const mySession=_addSession;
+ try{ const r=await(await fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})})).json();
+ if(_addSession!==mySession) return; const f=(r&&r.fields)||{}; _addExtracted=Object.assign({},_addExtracted,f);
+ const setIf=(id,v)=>{ if(v&&$('#'+id)&&!$('#'+id).value) $('#'+id).value=v; };
+ setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.color); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
+ setIf('addWidth',f.width); setIf('addRepeat',f.repeat); setIf('addContent',f.substrate); setIf('addHowSold',f.how_sold); // editable spec fields
+ if(r&&r.vendor_matched){ await loadVendors(); const opt=[...$('#addVendor').options].find(o=>o.value.toLowerCase()===String(r.vendor_matched).toLowerCase()); if(opt){ setStickyVendor(opt.value,true); } }
+ const specs=[['Collection',f.collection],['Roll length',f.roll_length],['Match',f.pattern_match],['Material',f.material],['Price code',f.price_code]].filter(x=>x[1]);
+ $('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">More captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
+ }catch(e){ /* leave fields as-is */ } }
+// Gate: BOTH photos required, then an mfr# (auto-determined, selected, or typed). Never create without it.
+function twoPhotoGate(){ const mfr=curMfr();
+ if(!_frontPhoto) return { ok:false, msg:'📷 Take Photo 1 — the FRONT (pattern) face.' };
+ if(!_backPhoto) return { ok:false, msg:'🏷 Take Photo 2 — the BACK (label / SKU).' };
+ if(!mfr) return { ok:false, msg:'🏷 Pick or type the mfr#/SKU to continue.' };
+ return { ok:true, msg:'✓ 2 photos + mfr# '+mfr+' — review & Preview' };
+}
+function renderShotTiles(){
$('#frontBtn').classList.toggle('have',!!_frontPhoto); $('#frontBtn').classList.toggle('need',!_frontPhoto);
- // back tile only "needs" attention when there's no identity yet (front had no code AND no manual mfr)
- $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_frontHasCode && !_backPhoto && !mfr);
- const g=twoPhotoGate(); const s=$('#shotStatus'); if(s){ s.textContent=g.msg; s.classList.toggle('block',!g.ok); } }
+ $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_backPhoto); }
+function renderShots(){ renderShotTiles();
+ if(_autoBusy) return; // mid-extract: autoExtract owns the status line
+ if($('#candPick') && $('#candPick').innerHTML) return; // candidate picker owns the status line
+ const s=$('#shotStatus'); if(!s) return;
+ if(!_frontPhoto){ setStatus('📷 Step 1 — take the FRONT (pattern) photo.',false); }
+ else if(!_backPhoto){ setStatus('🏷 Step 2 — now take the BACK (label) photo.',false); }
+ else { const g=twoPhotoGate(); setStatus(g.msg,!g.ok); } }
+
+// ── WHITE BALANCE: tap a white/grey point in the live frame → per-device colour-cast correction,
+// applied to every captured swatch so colour renders true. Persisted in localStorage; gray-world fallback. ──
+function wbGet(){ try{ return JSON.parse(localStorage.getItem('dwWhiteBalance')||'null'); }catch(e){ return null; } }
+function wbSet(o){ try{ o?localStorage.setItem('dwWhiteBalance',JSON.stringify(o)):localStorage.removeItem('dwWhiteBalance'); }catch(e){} }
+const _wbClamp=x=>Math.max(0.5,Math.min(2.5,x));
+function gainsFromRef(ref){ if(!ref)return null; const avg=(ref.r+ref.g+ref.b)/3; if(avg<12)return null; // too dark to trust
+ return { rGain:_wbClamp(avg/(ref.r||1)), gGain:_wbClamp(avg/(ref.g||1)), bGain:_wbClamp(avg/(ref.b||1)) }; }
+function applyWB(dataUrl){ return new Promise(res=>{ const g=wbGet(); if(!g)return res(dataUrl);
+ const img=new Image(); img.onload=()=>{ try{ const c=document.createElement('canvas'); c.width=img.naturalWidth; c.height=img.naturalHeight;
+ const ctx=c.getContext('2d'); ctx.drawImage(img,0,0); const im=ctx.getImageData(0,0,c.width,c.height), d=im.data;
+ for(let i=0;i<d.length;i+=4){ d[i]=Math.min(255,d[i]*g.rGain); d[i+1]=Math.min(255,d[i+1]*g.gGain); d[i+2]=Math.min(255,d[i+2]*g.bGain); }
+ ctx.putImageData(im,0,0); res(c.toDataURL('image/jpeg',0.9)); }catch(e){ res(dataUrl); } };
+ img.onerror=()=>res(dataUrl); img.src=dataUrl; }); }
+// gray-world: assume the average of the scene is neutral → gains that make the channel means equal
+function grayWorldFromVideo(v){ if(!v||!v.videoWidth)return null; const c=document.createElement('canvas');
+ const s=Math.min(1,300/Math.max(v.videoWidth,v.videoHeight)); c.width=v.videoWidth*s|0; c.height=v.videoHeight*s|0;
+ const ctx=c.getContext('2d'); ctx.drawImage(v,0,0,c.width,c.height); const d=ctx.getImageData(0,0,c.width,c.height).data;
+ let r=0,g=0,b=0,n=0; for(let i=0;i<d.length;i+=4){ r+=d[i];g+=d[i+1];b+=d[i+2];n++; } return gainsFromRef({r:r/n,g:g/n,b:b/n}); }
+let _wbStream=null;
+async function openWB(){
+ if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){ toast('Live camera needs Safari on iPhone/iPad.'); return; }
+ try{ _wbStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}}}); }
+ catch(e){ toast('Camera blocked — allow camera access, or open in Safari.'); return; }
+ const v=$('#wbVideo'); v.srcObject=_wbStream; v.setAttribute('playsinline',''); v.muted=true;
+ $('#wbModal').hidden=false; document.body.style.overflow='hidden'; try{ await v.play(); }catch(e){}
+ $('#wbClear').hidden=!wbGet();
+ $('#wbReadout').innerHTML = wbGet()? '✓ White balance is SET — tap a white/grey area to re-set, or ↺ Clear.' : 'Tap a WHITE or GREY area (or hold a white card) to set the white point.'; }
+function closeWB(){ if(_wbStream){ _wbStream.getTracks().forEach(t=>t.stop()); _wbStream=null; } $('#wbModal').hidden=true; document.body.style.overflow=''; }
+function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)return null; const rect=v.getBoundingClientRect();
+ const scale=Math.max(rect.width/v.videoWidth, rect.height/v.videoHeight); // object-fit:cover
+ const offX=(rect.width-v.videoWidth*scale)/2, offY=(rect.height-v.videoHeight*scale)/2;
+ const px=Math.round((clientX-rect.left-offX)/scale), py=Math.round((clientY-rect.top-offY)/scale);
+ const c=document.createElement('canvas'); c.width=v.videoWidth; c.height=v.videoHeight; c.getContext('2d').drawImage(v,0,0);
+ const R=14, x0=Math.max(0,px-R), y0=Math.max(0,py-R), w=Math.max(1,Math.min(v.videoWidth-x0,R*2)), h=Math.max(1,Math.min(v.videoHeight-y0,R*2));
+ const d=c.getContext('2d').getImageData(x0,y0,w,h).data; let r=0,g=0,b=0,n=0;
+ for(let i=0;i<d.length;i+=4){ r+=d[i];g+=d[i+1];b+=d[i+2];n++; } return {r:r/n,g:g/n,b:b/n}; }
+
+// ── COLOUR HUE: client-side dominant colour of the swatch centre → chip + HEX + nearest DW name ──
+const COLOR_LEX=[['Alabaster',237,234,224],['Oatmeal',214,205,187],['Greige',186,175,158],['Linen',232,223,205],['Ivory',245,240,225],['Cream',245,238,215],['Taupe',150,135,118],['Mushroom',160,150,138],['Sand',209,190,158],['Camel',175,140,95],['Almond',224,205,175],['Putty',188,178,160],['Beige',220,205,180],['Stone',180,172,160],['Wheat',225,205,150],['Charcoal',54,54,56],['Slate',96,105,115],['Graphite',70,72,76],['Onyx',30,30,32],['Black',20,20,20],['White',245,245,245],['Dove Grey',176,176,172],['Silver',190,190,190],['Pewter',132,134,130],['Ash',140,140,138],['Fog',200,200,198],['Celadon',172,196,168],['Sage',158,170,140],['Moss',110,120,80],['Olive',120,115,70],['Fern',90,120,80],['Forest',40,70,50],['Emerald',30,110,80],['Teal',40,110,110],['Aqua',120,190,190],['Sky Blue',150,190,220],['Powder Blue',180,205,225],['Cornflower',110,140,200],['Denim',70,100,140],['Navy',36,52,86],['Indigo',50,60,110],['Cobalt',40,80,160],['Periwinkle',150,150,210],['Blush',230,200,195],['Rose',210,150,150],['Dusty Rose',190,140,140],['Terracotta',195,110,80],['Rust',160,80,50],['Brick',150,70,60],['Clay',180,120,95],['Coral',235,140,120],['Mustard',200,160,60],['Ochre',190,140,70],['Gold',200,168,90],['Butter',240,225,150],['Lavender',190,175,210],['Plum',110,70,100],['Aubergine',80,50,70],['Mauve',175,140,160],['Chocolate',80,55,40],['Espresso',60,45,38],['Walnut',110,80,55],['Cognac',150,95,55],['Tan',195,160,120],['Burgundy',110,40,50],['Wine',95,40,55],['Cranberry',150,45,60],['Red',180,50,50]];
+function rgbHex(r,g,b){ const h=x=>('0'+Math.round(x).toString(16)).slice(-2); return '#'+h(r)+h(g)+h(b); }
+function nearestColorName(r,g,b){ let best=null,bd=1e9; for(const c of COLOR_LEX){ const dr=r-c[1],dg=g-c[2],db=b-c[3]; const d=dr*dr*0.3+dg*dg*0.59+db*db*0.11; if(d<bd){bd=d;best=c[0];} } return best; }
+function detectSwatchColor(dataUrl){ return new Promise(res=>{ const img=new Image(); img.onload=()=>{ try{ const S=200,c=document.createElement('canvas'); c.width=S;c.height=S; const ctx=c.getContext('2d');
+ const m=Math.min(img.naturalWidth,img.naturalHeight), sx=(img.naturalWidth-m)/2, sy=(img.naturalHeight-m)/2; ctx.drawImage(img,sx,sy,m,m,0,0,S,S);
+ const x0=S*0.25|0, w=S*0.5|0; const d=ctx.getImageData(x0,x0,w,w).data; const rs=[],gs=[],bs=[];
+ for(let i=0;i<d.length;i+=4){ const r=d[i],g=d[i+1],b=d[i+2],mx=Math.max(r,g,b),mn=Math.min(r,g,b); if(mx>245||mn<8)continue; rs.push(r);gs.push(g);bs.push(b); } // skip glare + shadow
+ if(!rs.length)return res(null); const med=a=>{ a.sort((x,y)=>x-y); return a[a.length>>1]; }; const r=med(rs),g=med(gs),b=med(bs);
+ res({r,g,b,hex:rgbHex(r,g,b),name:nearestColorName(r,g,b)}); }catch(e){ res(null); } };
+ img.onerror=()=>res(null); img.src=dataUrl; }); }
+async function detectAndShowColor(){ if(!_frontPhoto)return; const col=await detectSwatchColor(_frontPhoto); const el=$('#colorRead'); if(!el)return;
+ if(!col){ el.hidden=true; el.classList.remove('show'); return; }
+ el.hidden=false; el.classList.add('show');
+ el.innerHTML=`<span class="cr-sw" style="background:${col.hex}"></span><span><span class="cr-name">${col.name}</span> <span class="cr-hex">${col.hex}</span></span><button class="cr-use" id="crUse">Use “${col.name}”</button>`;
+ const use=$('#crUse'); if(use) use.addEventListener('click',()=>{ $('#addColor').value=col.name; toast('Colour set: '+col.name); });
+ if(!$('#addColor').value.trim()) $('#addColor').value=col.name; // auto-fill only when empty (never clobber a typed/OCR'd colour)
+}
+// ── CONTINUE-ABLE batch: after a create, clear the per-item state but KEEP vendor + calibration + WB ──
+let _sessCount=0;
+function updateSessCount(){ const c=$('#sessCount'); if(!c)return; if(_sessCount>0){ c.hidden=false; c.textContent=_sessCount+' this session'; } else { c.hidden=true; } }
+function nextItem(){ addResetMedia();
+ ['addMfr','addName','addColor','addPrice','addWidth','addRepeat','addContent','addHowSold','addNotes'].forEach(id=>{ const el=$('#'+id); if(el){ el.value=''; el.classList.remove('need'); } });
+ const cr=$('#colorRead'); if(cr){ cr.hidden=true; cr.classList.remove('show'); cr.innerHTML=''; }
+ clearCandidates(); $('#addNote').textContent=''; $('#addResult').innerHTML='';
+ renderShots(); toast('Ready for the next item — vendor + calibration kept.');
+ try{ $('#frontInput').click(); }catch(e){} // jump straight to Photo 1
+}
let _vreg=[];
// One <option> set, applied to BOTH the sticky camera bar and the (mirrored) modal picker.
function vendorOptionsHtml(placeholder){ return `<option value="">${placeholder}</option>`+_vreg.map(v=>
@@ -1853,7 +2024,8 @@ function onStickyPick(){ const s=$('#stickyVendor'); setStickyVendor(s.value, tr
// Modal picker changed (rare — a one-off override) → also update the sticky selection.
function onVendorPick(){ const val=$('#addVendor').value; setStickyVendor(val, true); }
function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _addSession++; addResetMedia();
- ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
+ ['addMfr','addVid','addName','addColor','addPrice','addWidth','addRepeat','addContent','addHowSold','addNotes'].forEach(id=>{ const el=$('#'+id); if(el)el.value=''; }); $('#addNote').textContent=''; $('#addResult').innerHTML='';
+ const cr=$('#colorRead'); if(cr){ cr.hidden=true; cr.classList.remove('show'); cr.innerHTML=''; } _sessCount=0; updateSessCount();
$('#addTitle').textContent = _addMode==='update' ? 'Update SKU on Shopify' : 'Add new item';
$('#addTarget').textContent = _addMode==='update' ? 'Shoot the label to find the SKU, then add photos + videos.' : '';
$('#addPreview').textContent = _addMode==='update' ? '⬆ Push media to SKU (LIVE)' : '👁 Preview';
@@ -1869,7 +2041,10 @@ function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _add
if(opts.camera) setTimeout(()=>$('#addPhotoInput').click(),250); // onload → go straight to camera
}
function addVideosList(){ return _media.filter(m=>m.type==='video'); }
-function addPayload(commit){ return Object.assign({}, _addExtracted, { mfr:$('#addMfr').value.trim(), vendor:$('#addVendor').value, vid:$('#addVid').value.trim(), name:$('#addName').value.trim(), color:$('#addColor').value.trim(), price:$('#addPrice').value.trim(), dataUrl:_addPhoto, photos:addPhotosList(), require_two:true, front_present:!!_frontPhoto, back_present:!!_backPhoto, id_source:(_frontHasCode?'front':(_backPhoto?'back':'manual')), commit:!!commit }); }
+function addPayload(commit){ const v=id=>{ const el=$('#'+id); return el?el.value.trim():''; };
+ return Object.assign({}, _addExtracted, { mfr:v('addMfr'), vendor:$('#addVendor').value, vid:v('addVid'), name:v('addName'), color:v('addColor'), price:v('addPrice'),
+ width:v('addWidth')||_addExtracted.width||'', repeat:v('addRepeat')||_addExtracted.repeat||'', substrate:v('addContent')||_addExtracted.substrate||'', how_sold:v('addHowSold')||_addExtracted.how_sold||'', notes:v('addNotes'),
+ dataUrl:_addPhoto, photos:addPhotosList(), require_two:true, front_present:!!_frontPhoto, back_present:!!_backPhoto, id_source:(_idSource||'manual'), commit:!!commit }); }
async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0,failed=0;
for(let i=0;i<vids.length;i++){ $('#addNote').textContent=`🎥 Uploading video ${i+1}/${vids.length}…`;
try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; else failed++; }catch(e){ failed++; } }
@@ -1914,8 +2089,13 @@ async function addCommit(){ const g=twoPhotoGate(); if(!g.ok){ $('#addNote').tex
if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
const hadVideos=addVideosList().length; let vmsg='', vup={ok:0,failed:0};
if(hadVideos && r.product_id){ vup=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:''); }
- const baseMsg=`✅ Created DRAFT · ${r.dw_sku}${vmsg}`;
- $('#addNote').innerHTML=`${baseMsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
+ _sessCount++; updateSessCount();
+ const nPh=addPhotosList().length;
+ const baseMsg=`✅ Created ${r.dw_sku} — ${nPh>=2?'front+back':nPh+' photo(s)'} saved${vmsg}`;
+ $('#addNote').innerHTML=`${baseMsg} <small>— DRAFT, not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
+ // CONTINUE-ABLE: a prominent Next button clears the item but keeps vendor + calibration + white-balance
+ $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${r.dw_sku}</b> <small>created (draft) · ${_sessCount} this session</small></div></div><button class="samp-print" id="addNext" style="margin-top:12px">➕ Next item (keep vendor)</button>`;
+ $('#addNext').addEventListener('click',nextItem);
if(vup.ok && r.product_id) pollVideoStatus(r.product_id, baseMsg, vup.ok);
}
// UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
@@ -1994,7 +2174,10 @@ $('#backBtn').addEventListener('click',()=>$('#backInput').click());
$('#frontInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; shotAdd('front',f); });
$('#backInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; shotAdd('back',f); });
// Typing the mfr# manually is a valid identity source → re-evaluate the gate live.
-$('#addMfr').addEventListener('input',()=>renderShots());
+$('#addMfr').addEventListener('input',()=>{ const mfr=curMfr();
+ if(mfr){ _idSource=_idSource||'manual'; clearCandidates(); $('#addMfr').classList.remove('need');
+ if(_frontPhoto&&_backPhoto){ setStatus('✓ mfr# '+mfr+' — review & Preview',false); return; } }
+ renderShots(); });
$('#addPhotoBtn').addEventListener('click',()=>$('#addPhotoInput').click());
$('#addVideoBtn').addEventListener('click',()=>$('#addVideoInput').click());
$('#addPhotoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('photo',f)); });
@@ -2008,9 +2191,7 @@ async function addExtract(dataUrl,side){ const src=dataUrl||_addPhoto; if(!src)r
if(_addSession!==mySession) return; // modal was closed/reopened mid-request — discard this stale result
const f=r.fields||{}; _addExtracted=Object.assign({},_addExtracted,f); const setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.color); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
- // priority-1 identity: the FRONT photo already carried the code → back photo becomes optional
- if(side==='front' && !hadMfr && ($('#addMfr').value||'').trim()) _frontHasCode=true;
- renderShots();
+ renderShotTiles();
if(r.vendor_matched){ await loadVendors(); const opt=[...$('#addVendor').options].find(o=>o.value.toLowerCase()===r.vendor_matched.toLowerCase()); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; } }
const specs=[['Material',f.material],['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate / contents',f.substrate],['How sold',f.how_sold],['Price code',f.price_code]].filter(x=>x[1]);
$('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">Captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
@@ -2049,11 +2230,12 @@ function mSetMode(mode){ _mMode=mode;
$('#mRecal').hidden = mode==='calibrate';
mRecompute(); }
function mRecompute(){
- if(_mMode==='calibrate'){ _ppi=$('#mCard').offsetWidth/CARD_IN;
- $('#mReadout').innerHTML=`One-time setup: size 💳 to a real credit card, then Save. <small>(${_ppi.toFixed(0)} px/in)</small>`; return; }
- const s=$('#mSample'); if(!_ppi){ $('#mReadout').textContent='Tap ↺ Recalibrate first.'; return; }
+ if(_mMode==='calibrate'){ const w=$('#mCard').offsetWidth;
+ if(w>0) _ppi=w/CARD_IN; // guard: a 0-width read (layout not flushed) must NOT zero the scale → NaN dead-end
+ $('#mReadout').innerHTML=`Set scale: size 💳 to a real credit card, then tap <b>Set scale</b>. <small>(${_ppi>0?_ppi.toFixed(0)+' px/in':'…'})</small>`; return; }
+ const s=$('#mSample'); if(!_ppi){ $('#mReadout').innerHTML='Set the scale first — tap <b>↺ Set scale (card)</b>.'; return; }
_measW=s.offsetWidth/_ppi; _measL=s.offsetHeight/_ppi;
- $('#mReadout').innerHTML=`W <b>${_measW.toFixed(1)}"</b> · L <b>${_measL.toFixed(1)}"</b> <small>· saved calibration · hold at your usual distance</small>`; }
+ $('#mReadout').innerHTML=`W <b>${_measW.toFixed(1)}"</b> · L <b>${_measL.toFixed(1)}"</b> <small>· saved scale · hold at your usual distance</small>`; }
function mMakeBox(el){ let mode=null,sx,sy,ox,oy,ow,oh; const rz=el.querySelector('.mrz');
const start=(e,m)=>{ mode=m; sx=e.clientX; sy=e.clientY; ox=el.offsetLeft; oy=el.offsetTop; ow=el.offsetWidth; oh=el.offsetHeight; (m==='resize'?rz:el).setPointerCapture(e.pointerId); e.preventDefault(); e.stopPropagation(); };
const move=e=>{ if(!mode)return; const dx=e.clientX-sx,dy=e.clientY-sy;
@@ -2072,22 +2254,37 @@ async function openMeasure(){
// LOCK ZOOM to minimum so the focal length / field-of-view stays constant (scale depends on it)
try{ const tr=_mStream.getVideoTracks()[0], caps=tr.getCapabilities?tr.getCapabilities():{};
if(caps.zoom) await tr.applyConstraints({advanced:[{zoom:caps.zoom.min||1}]}); }catch(e){}
- $('#mVideo').srcObject=_mStream; $('#measureModal').hidden=false; document.body.style.overflow='hidden';
+ const v=$('#mVideo'); v.srcObject=_mStream; v.setAttribute('playsinline',''); v.muted=true;
+ $('#measureModal').hidden=false; document.body.style.overflow='hidden';
+ try{ await v.play(); }catch(e){} // iOS Safari: autoplay attr isn't always enough — explicitly play inline
Object.assign($('#mCard').style,{left:'26px',top:'96px',width:'135px',height:'85px'});
Object.assign($('#mSample').style,{left:'56px',top:'200px',width:'210px',height:'270px'});
const cal=calGet();
- // reuse the saved calibration only if the viewport matches — ppi is in CSS px, so a different
+ // reuse the saved scale only if the viewport matches — ppi is in CSS px, so a different
// screen width (rotation / different device) would make the stored ppi wrong.
- if(cal&&cal.ppi&&(!cal.sw||Math.abs(cal.sw-window.innerWidth)<=2)){ _ppi=cal.ppi; mSetMode('measure'); }
- else { if(cal&&cal.ppi) toast('Screen changed — quick one-time recalibrate'); mSetMode('calibrate'); } } // first time on this device → calibrate
+ const applyMode=()=>{ if(cal&&cal.ppi&&(!cal.sw||Math.abs(cal.sw-window.innerWidth)<=2)){ _ppi=cal.ppi; mSetMode('measure'); }
+ else { if(cal&&cal.ppi) toast('Screen changed — quick one-time set-scale'); mSetMode('calibrate'); } };
+ // defer to the next frame so the just-unhidden #mCard has a real (non-zero) offsetWidth before ppi is read
+ requestAnimationFrame(()=>requestAnimationFrame(applyMode)); }
function closeMeasure(){ if(_mStream){ _mStream.getTracks().forEach(t=>t.stop()); _mStream=null; } $('#measureModal').hidden=true; document.body.style.overflow=''; }
+// White Balance wiring: tap the video to set the white point; auto = gray-world; clear = remove.
+$('#addWB').addEventListener('click',openWB);
+$('#wbClose').addEventListener('click',closeWB);
+$('#wbVideo').addEventListener('pointerdown',ev=>{ ev.preventDefault(); const ref=wbSampleAt(ev.clientX,ev.clientY); const g=gainsFromRef(ref);
+ if(!g){ $('#wbReadout').textContent='Too dark there — tap a brighter white/grey area.'; return; }
+ wbSet(g); $('#wbClear').hidden=false; $('#wbReadout').innerHTML=`✓ White point set (R×${g.rGain.toFixed(2)} G×${g.gGain.toFixed(2)} B×${g.bGain.toFixed(2)}) — new photos render true. Tap again to re-set.`;
+ toast('✓ White balance set'); });
+$('#wbAuto').addEventListener('click',()=>{ const g=grayWorldFromVideo($('#wbVideo')); if(!g){ toast('Point at the scene, then tap Auto.'); return; }
+ wbSet(g); $('#wbClear').hidden=false; $('#wbReadout').innerHTML='✨ Auto (gray-world) white balance set — new photos render true.'; toast('✨ Auto white balance set'); });
+$('#wbClear').addEventListener('click',()=>{ wbSet(null); $('#wbClear').hidden=true; $('#wbReadout').textContent='White balance cleared. Tap a white/grey area to set it again.'; toast('White balance cleared'); });
mMakeBox($('#mCard')); mMakeBox($('#mSample'));
$('#addMeasure').addEventListener('click',openMeasure);
$('#mClose').addEventListener('click',closeMeasure);
$('#mRecal').addEventListener('click',()=>mSetMode('calibrate'));
$('#mSaveCal').addEventListener('click',()=>{ mRecompute();
if(_ppi>0){ calSet({ppi:_ppi, sw:window.innerWidth, ts:Date.now()});
- toast('✓ Calibrated ('+_ppi.toFixed(0)+' px/in) — no card needed next time'); mSetMode('measure'); } });
+ toast('✓ Scale set ('+_ppi.toFixed(0)+' px/in) — no card needed next time'); mSetMode('measure'); }
+ else { toast('Size the box to a credit card first, then Set scale.'); } });
$('#mUse').addEventListener('click',()=>{ const w=_measW.toFixed(1)+'"',l=_measL.toFixed(1)+'"';
_addExtracted.width=w; _addExtracted.roll_length=l;
$('#addSpecs').insertAdjacentHTML('afterbegin',`<div class="add-spec"><span>📐 Measured</span><b>${w} × ${l}</b></div>`);
@@ -2118,6 +2315,12 @@ function dictateInto(fieldId, kind, btn){
try{ r.start(); }catch(e){ off(); }
}
document.querySelectorAll('#addModal .mic-btn').forEach(b=>b.addEventListener('click',ev=>{ ev.preventDefault(); dictateInto(b.dataset.mic, b.dataset.kind, b); }));
+// FEATURE-DETECT: if the device has no Web Speech API, HIDE every mic button (the native keyboard
+// mic is the fallback) rather than showing a dead control. Recent iOS/iPadOS Safari 14.5+ supports it.
+if(!(window.SpeechRecognition||window.webkitSpeechRecognition)){
+ document.querySelectorAll('.mic-btn').forEach(b=>b.classList.add('mic-off'));
+ console.log('Web Speech API unavailable — mic buttons hidden (native keyboard mic still works).');
+}
// ── onload chooser: Add New SKU · Update SKU · Check Sample In ──
function routeHome(act){ $('#homeScreen').hidden=true;
← a138b39 Batch OCR: load GEMINI_API_KEY from secrets + default to gem
·
back to Dw Photo Capture
·
auto-data-snapshot: 2026-09-18T14:54:24 (2 data files) — dat 40f89a1 →