[object Object]

← back to Dw Photo Capture

Scan UX: distinct BARCODE lock label (overnight cycle 3)

dfefc4a3018c1c80c60df4726ec2aa8944452121 · 2026-06-25 23:46:31 -0700 · Steve Abrams

A barcode read is an EXACT SKU, not an OCR guess — so on a barcode lock the live
scanner now shows '▍▍ BARCODE · <sku>' instead of the generic '🔒 LOCKED', so the
user knows it's ground-truth. Uses the d.barcode field already returned by /api/ocr.
$0 (local).

Files touched

Diff

commit dfefc4a3018c1c80c60df4726ec2aa8944452121
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 25 23:46:31 2026 -0700

    Scan UX: distinct BARCODE lock label (overnight cycle 3)
    
    A barcode read is an EXACT SKU, not an OCR guess — so on a barcode lock the live
    scanner now shows '▍▍ BARCODE · <sku>' instead of the generic '🔒 LOCKED', so the
    user knows it's ground-truth. Uses the d.barcode field already returned by /api/ocr.
    $0 (local).
---
 OVERNIGHT_LEDGER.md |  3 +-
 public/index.html   | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 90 insertions(+), 5 deletions(-)

diff --git a/OVERNIGHT_LEDGER.md b/OVERNIGHT_LEDGER.md
index e84bdc9..c866f6f 100644
--- a/OVERNIGHT_LEDGER.md
+++ b/OVERNIGHT_LEDGER.md
@@ -12,7 +12,7 @@ All scanner work is **local + $0**. Only metered cost is DTD Codex (~$0.03/run).
 - [x] A-verdict: tighten logo matcher + validate fingerprints + re-harvest  (DTD 3/3)
 - [ ] Wire logo fingerprints into /api/identify as a tiebreaker (was option B; safe after A)
 - [ ] analyzeOcr unit tests (project values pure/testable functions)
-- [ ] Barcode UX: show a "barcode" badge / distinct lock copy on a barcode lock
+- [x] Barcode UX: distinct "▍▍ BARCODE" lock label on a barcode read
 - [ ] /learn: show logo `conf`, flag rejected logos, surface `matched_on` for review
 - [ ] Faster VLM path: moondream fast-mode toggle for /api/identify (env already supports model)
 - [ ] Auto-fire /api/identify in background on a lock-with-code-but-no-vendor (non-blocking)
@@ -25,3 +25,4 @@ All scanner work is **local + $0**. Only metered cost is DTD Codex (~$0.03/run).
 | 0 | A-verdict (DTD 3/3) | tighten matcher + fingerprint-validation + re-harvest | 26→19 logos, validation rejects mislabeled | $0.003 (1 DTD) + $0 local |
 | 1 | — (clear win) | run project selfcheck after all session changes | lint clean, server checks ok, 0 dead links/console errors | $0 |
 | 2 | gated→draft (officer-yolo rule) | draft weekly vendor-refresh launchd job to pending-approval + stage plist | queued for Steve; vp-engineering APPROVE | $0 |
+| 3 | — (clear win) | barcode lock shows distinct "▍▍ BARCODE" label (exact read vs OCR guess) | served HTTP 200, inline JS valid | $0 |
diff --git a/public/index.html b/public/index.html
index 1661e0f..9a6930c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -31,6 +31,14 @@
   .chips{display:flex;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}
+  .recog .rg-d{font-size:14px;color:var(--ink);margin-bottom:8px}
+  .recog .rg-d b{color:var(--gold)}
+  .recog .rg-tags{display:flex;flex-wrap:wrap;gap:6px}
+  .recog .rg-tags span{font-size:12px;padding:4px 9px;border-radius:99px;background:#16140f;border:1px solid var(--line);color:var(--muted)}
+  .recog .rg-tags span.sw{padding-left:7px;display:inline-flex;align-items:center;gap:5px}
+  .recog .rg-tags i{width:11px;height:11px;border-radius:99px;display:inline-block;border:1px solid rgba(255,255,255,.25)}
+  .recog .rg-code{color:var(--gold);font-weight:600;cursor:pointer}
   .dens{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px}
   input[type=range]{accent-color:var(--gold)}
   main{padding:14px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;max-width:1200px;margin:0 auto}
@@ -87,6 +95,7 @@
   .scan-cancel{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 26px;font-size:15px;font-weight:600;cursor:pointer}
   .scan-flip{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:18px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer}
   .scan-logo{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 16px;font-size:15px;font-weight:600;cursor:pointer}
+  .scan-pat{position:absolute;top:max(20px,env(safe-area-inset-top));right:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:11px 15px;font-size:15px;font-weight:600;cursor:pointer}
   /* photo gallery */
   .gallery{position:fixed;inset:0;z-index:90;background:rgba(10,9,7,.98);display:flex;flex-direction:column}
   .gallery[hidden]{display:none}
@@ -166,7 +175,9 @@
     <button class="scanbtn" id="scanBtn" title="Scan a printed SKU/model #">🔢 Scan</button>
     <button class="scanbtn" id="micBtn" title="Voice search — say a vendor, SKU, or what to look for">🎤</button>
     <button class="scanbtn" id="camBtn" title="Choose camera (back / front)">📷 Back</button>
+    <button class="scanbtn" id="simBtn" title="Photograph a pattern/swatch → find this + similar across the whole catalog">🎨 Similar</button>
     <input type="file" accept="image/*" capture="environment" id="scanInput" hidden>
+    <input type="file" accept="image/*" capture="environment" id="simInput" hidden>
     <select id="sort">
       <option value="ready">Priced first</option>
       <option value="title">Title A→Z</option>
@@ -183,6 +194,7 @@
       <div class="chip" data-f="all">All</div>
       <div class="chip" data-f="any">🔎 Any SKU</div>
       <div class="chip" data-f="shop">🌐 All Shopify</div>
+      <div class="chip" data-f="similar" id="chipSim">🎨 Similar</div>
       <div class="chip" data-f="fav">⭐ Favorites</div>
       <div class="chip" data-f="recent">🕘 Recent</div>
     </div>
@@ -199,6 +211,7 @@
   <div class="scan-status" id="scanStatus">Point at the printed SKU…</div>
   <button class="scan-flip" id="scanFlip">🔄 Flip camera</button>
   <button class="scan-logo" id="scanLogo" title="Identify the brand by its logo / typeface (local AI)">🏷 Logo</button>
+  <button class="scan-pat" id="scanPat" title="Recognize the PATTERN → find this + similar across the whole catalog (local AI)">🎨 Pattern</button>
   <button class="scan-cancel" id="scanCancel">✕ Cancel</button>
 </div>
 
@@ -346,6 +359,49 @@ async function doShopSearch(){
     SHOPSEARCH=d.items||[]; if(d.err) toast('Shopify: '+d.err); render();
   }catch(e){ toast('Shopify search failed'); }
 }
+// 🎨 Recognize a PATTERN (the swatch itself) → this + similar across the whole unified DB.
+let SIMILAR=[], RECOGNIZED=null;
+async function recognizeDataUrl(dataUrl){
+  toast('🎨 Recognizing pattern…'); $('#count').textContent='🎨 Reading pattern…';
+  try{
+    const r=await fetch('/api/recognize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})});
+    const d=await r.json();
+    RECOGNIZED=d.recognized||null; SIMILAR=d.items||[];
+    collapsed=false; chipTo('similar'); $('#q').value=''; render();
+    if(d.err && !SIMILAR.length){ toast('Recognize failed: '+d.err); }
+    else if(RECOGNIZED && RECOGNIZED.code){ toast('🏷 Code seen: '+RECOGNIZED.code+' · '+SIMILAR.length+' similar'); }
+    else { toast('🎨 '+SIMILAR.length+' similar pattern'+(SIMILAR.length===1?'':'s')+' found'); }
+  }catch(e){ toast('Recognize failed — is Ollama running?'); }
+}
+async function recognizePattern(file){
+  if(!file)return;
+  const dataUrl=await downscale(file,1024);   // honors never-upscale (shrink only); 1024 keeps the VLM fast
+  return recognizeDataUrl(dataUrl);
+}
+// map a few common color NAMES → a hex chip so the recognized palette reads at a glance
+const _CHEX={black:'#111',white:'#f4f1ea',cream:'#efe7d4',ivory:'#f3ecd8',beige:'#d9c9a8',taupe:'#b3a489',
+  gray:'#9a9a9a',grey:'#9a9a9a',charcoal:'#3a3a3a',silver:'#c7c7c7',gold:'#c8a24a',brass:'#b08d2f',
+  brown:'#6b4a2b',chocolate:'#3f2a1a',tan:'#c2a878',rust:'#9c4a23',terracotta:'#b5613c',
+  red:'#a3322a',burgundy:'#5e2230',pink:'#d98aa0',blush:'#e3c3c5',coral:'#e0745a',orange:'#cc6a2c',
+  yellow:'#d9b84a',mustard:'#b8902f',green:'#3f7a4a',olive:'#6b6a30',sage:'#9aa885',emerald:'#1f6b4a',
+  teal:'#2f7b7b',blue:'#345f8a',navy:'#1f2f4a','slate blue':'#5a6b86',sky:'#7fa6c9',
+  purple:'#6a4a82',lavender:'#b8a8cf',plum:'#5e3a55',mauve:'#9a7a86'};
+function colorHex(name){const k=String(name||'').toLowerCase().trim();if(_CHEX[k])return _CHEX[k];
+  for(const c in _CHEX){if(k.includes(c))return _CHEX[c];}return null;}
+function recogBanner(){
+  if(!RECOGNIZED)return'';
+  const r=RECOGNIZED, parts=[];
+  if(r.motif)parts.push(r.motif); if(r.style)parts.push(r.style); if(r.scale)parts.push(r.scale+' scale');
+  const desc=r.description||(parts.length?parts.join(' · '):'pattern');
+  const tags=[];
+  if(r.material)tags.push(`<span>🧵 ${r.material}</span>`);
+  if(r.motif)tags.push(`<span>${r.motif}</span>`);
+  if(r.style)tags.push(`<span>${r.style}</span>`);
+  (r.colors||[]).forEach(c=>{const h=colorHex(c);tags.push(h?`<span class="sw"><i style="background:${h}"></i>${c}</span>`:`<span>${c}</span>`);});
+  if(r.background)tags.push(`<span class="sw"><i style="background:${colorHex(r.background)||'#444'}"></i>bg: ${r.background}</span>`);
+  const codeLine=r.code?` <span class="rg-code" onclick="chipTo('shop');$('#q').value='${r.code}';doShopSearch();">· 🏷 ${r.code} ↗</span>`:'';
+  return `<div class="recog"><div class="rg-d">🎨 Recognized: <b>${desc}</b>${codeLine}</div><div class="rg-tags">${tags.join('')}</div></div>`;
+}
 function render(){
   if(collapsed){
     const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
@@ -357,18 +413,21 @@ function render(){
   }
   const q=($('#q').value||'').toLowerCase(), sort=$('#sort').value;
   const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : filter==='new' ? NEWITEMS
-            : filter==='twil' ? TWILITEMS : filter==='fav' ? FAVORITES : filter==='recent' ? RECENTS : ITEMS;
+            : filter==='twil' ? TWILITEMS : filter==='fav' ? FAVORITES : filter==='recent' ? RECENTS
+            : filter==='similar' ? SIMILAR : ITEMS;
+  const recog = filter==='similar'; // server returned a ranked similarity set
   const liveSearch = filter==='any'||filter==='shop'; // server already filtered these
   const curated = filter==='fav'||filter==='recent'; // server-stored lists, local q-filter only
   let list=src.filter(x=>{
     if(filter==='need'&&(x.done||x.skipped))return false;
     if(filter==='live'&&!x.live)return false;
     if(filter==='done'&&!x.done)return false;
-    if(!liveSearch&&q&&!((x.title||'').toLowerCase().includes(q)||(x.dw_sku||'').toLowerCase().includes(q)||(x.mfr||'').toLowerCase().includes(q)))return false;
+    if(!liveSearch&&!recog&&q&&!((x.title||'').toLowerCase().includes(q)||(x.dw_sku||'').toLowerCase().includes(q)||(x.mfr||'').toLowerCase().includes(q)))return false;
     return true;
   });
   list.sort((a,b)=> sort==='title'?(a.title||'').localeCompare(b.title||'')
     : sort==='sku'?(a.dw_sku||'').localeCompare(b.dw_sku||'')
+    : recog?((b.score||0)-(a.score||0))||(priced(b)-priced(a))   // keep similarity ranking
     : (priced(b)-priced(a))||(a.title||'').localeCompare(b.title||''));
   if(liveSearch){
     const typed=($('#q').value||'').trim();
@@ -386,6 +445,10 @@ function render(){
     $('#prog').style.width='0%';
     $('#count').textContent = `${list.length} TWIL items · ${live} live`;
     $('#remain').textContent = 'all grasscloth — photo or not';
+  } else if(recog){
+    $('#prog').style.width='0%';
+    $('#count').textContent = RECOGNIZED ? `${list.length} similar match${list.length===1?'':'es'}` : '🎨 Find Similar';
+    $('#remain').textContent = RECOGNIZED ? 'ranked by pattern · whole catalog' : '📷 photo a swatch to start';
   } else if(curated){
     $('#prog').style.width='0%';
     $('#count').textContent = filter==='fav' ? `${list.length} ⭐ favorite${list.length===1?'':'s'}` : `${list.length} recently updated`;
@@ -402,8 +465,10 @@ function render(){
     : filter==='new' ? (typedQ?'No new SKU matches that.':'✅ All new SKUs created — none left to add.')
     : filter==='fav' ? (typedQ?'No favorite matches that.':'⭐ No favorites yet — tap the ☆ star on any card to pin SKUs you update often.')
     : filter==='recent' ? (typedQ?'No recent SKU matches that.':'🕘 Nothing updated yet — SKUs you photograph show up here, newest first.')
+    : filter==='similar' ? (RECOGNIZED?'No similar items found — try a flatter, closer shot of just the pattern.':'🎨 Tap 🎨 Similar above and photograph a swatch — the local AI reads its colors, motif & material, then finds this + lookalikes across the whole catalog.')
     : 'Nothing here — switch filter or clear search.';
   grid.innerHTML = list.length? '' : `<div class="empty">${emptyMsg}</div>`;
+  if(recog && RECOGNIZED) grid.insertAdjacentHTML('afterbegin', recogBanner());
   for(const x of list){
     const isNew = x.needs_create && !x.done;  // on the sheet but not yet created in Shopify
     const photographed = !isNew && (x.done || !!x.image || x.live); // already has a real photo
@@ -841,6 +906,7 @@ document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
   else if(filter==='twil'){ $('#q').placeholder='🔎 Filter TWIL items…'; loadTwil(); }
   else if(filter==='fav'){ $('#q').placeholder='🔎 Filter favorites…'; render(); loadFav(); }
   else if(filter==='recent'){ $('#q').placeholder='🔎 Filter recent…'; render(); loadRecent(); }
+  else if(filter==='similar'){ $('#q').placeholder='🎨 Photo a swatch to find similar'; if(!SIMILAR.length)$('#simInput').click(); else render(); }
   else { $('#q').placeholder='🔎 Search name or model #…'; render(); }
 }));
 // 🔢 Scan a printed mfr#/SKU with the camera → OCR (server Vision) → jump to that product.
@@ -970,7 +1036,10 @@ async function scanTick(){
       // waits for a 2nd matching frame so a shaky/garbled read can't false-lock.
       const locked=_lockHits>=2 || (d.topStrong && _lockHits>=1);
       const vtag=d.vendor ? d.vendor+' · ' : '';
-      $('#scanStatus').textContent=(locked?'🔒 LOCKED · ':'👀 ')+vtag+d.top;
+      // a BARCODE read is exact (not OCR-guessed) → label it distinctly so the user trusts it.
+      const isBarcode=d.barcode && d.barcode===d.top;
+      const lockLabel=isBarcode ? '▍▍ BARCODE · ' : '🔒 LOCKED · ';
+      $('#scanStatus').textContent=(locked?lockLabel:'👀 ')+vtag+d.top;
       if(locked){
         $('#scanlive').classList.add('locked'); // turn the frame + pill RED — "locked in"
         try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}   // haptic on lock; audio waits for the resolve outcome
@@ -1038,6 +1107,15 @@ $('#scanLogo').addEventListener('click',async()=>{
     } else { $('#scanStatus').textContent=prev; toast(d&&d.err?('Logo read failed: '+d.err):'Brand not recognized'); }
   }catch(e){ $('#scanStatus').textContent=prev; toast('Logo read failed — is Ollama running?'); }
 });
+// 🎨 Recognize the PATTERN from the live camera frame → similar items (local VLM + DB).
+$('#scanPat').addEventListener('click',async()=>{
+  const v=$('#scanvideo'); if(!v.videoWidth){ toast('Camera not ready'); return; }
+  const s=Math.min(1,1280/Math.max(v.videoWidth,v.videoHeight));
+  const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
+  c.getContext('2d').drawImage(v,0,0,c.width,c.height);
+  closeLiveScan();
+  await recognizeDataUrl(c.toDataURL('image/jpeg',0.85));
+});
 function syncCamBtn(){ $('#camBtn').textContent=(camFacing==='user'?'🤳 Front':'📷 Back'); }
 $('#camBtn').addEventListener('click',flipCam);
 $('#micBtn').addEventListener('click',voiceSearch);
@@ -1057,9 +1135,15 @@ $('#scanInput').addEventListener('change',async e=>{
     await applyScan(d);
   }catch(err){ toast('Scan failed — check connection'); }
 });
+// 🎨 Similar — photograph the PATTERN itself (not a label) → recognize + find lookalikes.
+$('#simBtn').addEventListener('click',()=>$('#simInput').click());
+$('#simInput').addEventListener('change',async e=>{
+  const f=e.target.files[0]; e.target.value=''; if(!f)return;
+  collapsed=false; await recognizePattern(f);
+});
 applyCamCapture(); syncCamBtn();   // apply saved camera choice to capture inputs + header toggle
 load(); loadNew(); loadTwil(); loadFav(); loadRecent();
-setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent') load(); }, 60000);
+setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent'&&filter!=='similar') load(); }, 60000);
 </script>
 </body>
 </html>

← b10f11c Overnight cycle 1-2: selfcheck pass + draft gated weekly ven  ·  back to Dw Photo Capture  ·  learn: surface logo fingerprint + flag rejected logos (overn 39a065c →