← 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
M OVERNIGHT_LEDGER.mdM public/index.html
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 →