[object Object]

← back to Trending Dw

server+frontend: realImage support (signal default sort, meta coverage, Shopify staging guard) + UX overhaul (error/retry states, skeletons, provenance pill, OURS inset, date+time chip, alt text, 36-card first paint, detail modal w/ THEIRS-OURS)

b3b4e49557efa53009c9bc070b686c5f3bd3819c · 2026-07-15 10:59:12 -0700 · Steve Abrams

Files touched

Diff

commit b3b4e49557efa53009c9bc070b686c5f3bd3819c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 15 10:59:12 2026 -0700

    server+frontend: realImage support (signal default sort, meta coverage, Shopify staging guard) + UX overhaul (error/retry states, skeletons, provenance pill, OURS inset, date+time chip, alt text, 36-card first paint, detail modal w/ THEIRS-OURS)
---
 public/index.html | 127 +++++++++++++++++++++++++++++++++++++++++++-----------
 server.js         |  12 ++++--
 2 files changed, 111 insertions(+), 28 deletions(-)

diff --git a/public/index.html b/public/index.html
index 3f9855f..bb8e4af 100644
--- a/public/index.html
+++ b/public/index.html
@@ -62,6 +62,20 @@
   .swatch .pending .sub{ font-size:9px; opacity:.8; letter-spacing:.02em; }
   .spotted{ font-size:10px; color:var(--muted); display:flex; align-items:center; gap:4px; font-variant-numeric:tabular-nums; }
   .cov.gap{ background:var(--gap); color:#fff; } .cov.covered{ background:rgba(255,255,255,.82); color:var(--accent); }
+  /* real-product hero: fades in on load; shimmer overlay while fetching */
+  .swatch img.hero{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity .25s; }
+  .swatch img.hero.ld{ opacity:1; }
+  .swatch .shimmer{ position:absolute; inset:0; background:linear-gradient(100deg,rgba(255,255,255,0) 30%,rgba(255,255,255,.35) 50%,rgba(255,255,255,0) 70%); background-size:200% 100%; animation:shim 1.1s linear infinite; pointer-events:none; }
+  @keyframes shim{ 0%{ background-position:180% 0; } 100%{ background-position:-80% 0; } }
+  .swatch .prov{ position:absolute; bottom:8px; right:9px; background:rgba(0,0,0,.62); color:#fff; font-size:9.5px; letter-spacing:.06em; font-weight:700; padding:3px 8px; border-radius:20px; }
+  /* our-own coverage design, inset over the competitor's real product image */
+  .swatch .oursthumb{ position:absolute; bottom:8px; left:9px; width:58px; text-align:center; }
+  .swatch .oursthumb img{ width:58px; height:58px; object-fit:cover; border-radius:8px; border:2px solid rgba(255,255,255,.95); box-shadow:0 2px 8px rgba(0,0,0,.35); display:block; }
+  .swatch .oursthumb .lb{ font-size:8px; font-weight:800; letter-spacing:.12em; color:#fff; background:rgba(0,0,0,.55); border-radius:6px; padding:1px 0; margin-top:3px; }
+  .errbar{ background:#fdf2f1; border:1px solid #ecc8c4; color:#8f2b23; font-size:13px; padding:12px 14px; border-radius:10px; display:flex; align-items:center; gap:12px; margin:10px 0; }
+  .errbar button{ border:1px solid #b6392f; background:#b6392f; color:#fff; border-radius:8px; padding:6px 12px; cursor:pointer; font-size:12px; }
+  .empty{ color:var(--muted); font-size:13px; padding:34px 10px; text-align:center; grid-column:1/-1; }
+  .empty a{ color:var(--gap); cursor:pointer; text-decoration:underline; }
   .body{ padding:11px 12px 13px; display:flex; flex-direction:column; gap:6px; flex:1; }
   .title{ font-size:14px; font-weight:700; line-height:1.25; }
   .company{ font-size:12px; } .company b{ color:var(--accent); }
@@ -108,8 +122,8 @@
       <button class="queuebtn" id="queueBtn" onclick="openQueue()">🛍 Shopify queue <b id="qn">0</b></button>
       <div><label>Sort</label>
         <select id="sort">
-          <option value="have">Imaged first</option>
           <option value="signal">Bestseller signal</option>
+          <option value="have">Imaged first</option>
           <option value="gap">Gaps first</option>
           <option value="newest">Newest</option>
           <option value="titleAZ">Title A–Z</option>
@@ -120,6 +134,7 @@
       <div class="dens"><label>Density</label><input type="range" id="dens" min="2" max="6" step="1" value="4"/></div>
     </div>
     <div class="chipsbar" id="chipsbar"></div>
+    <div id="errbar"></div>
     <div class="grid" id="grid"></div>
     <div id="lanes"></div>
     <div id="sentinel"></div>
@@ -128,10 +143,15 @@
 </div>
 <script>
 const store = k => { try{ return JSON.parse(localStorage.getItem(k)); }catch(e){ return null; } };
+// one-time migration: 'have' (Imaged first) was the default before real product images
+// existed; now nearly everything is imaged, so 'signal' is the meaningful default.
+const savedSort = (()=>{ let s=localStorage.getItem('tdw_sort');
+  if(s==='have'&&!localStorage.getItem('tdw_sort_mig')){ s='signal'; localStorage.setItem('tdw_sort','signal'); localStorage.setItem('tdw_sort_mig','1'); }
+  return s||'signal'; })();
 const state = {
   filters: store('tdw_filters2') || {},          // {dim: [values]}
-  q:'', sort: localStorage.getItem('tdw_sort')||'have', view: localStorage.getItem('tdw_view')||'grid',
-  page:1, size:12, loading:false, done:false
+  q:'', sort: savedSort, view: localStorage.getItem('tdw_view')||'grid',
+  page:1, size:36, loading:false, done:false
 };
 const DIMS = [['ourCoverage','Coverage'],['marketplace','Marketplace'],['company','Company'],['style','Style'],['color','Color'],['priceBand','Price']];
 const has = (dim,val) => (state.filters[dim]||[]).includes(val);
@@ -139,7 +159,9 @@ function toggle(dim,val){ const a = state.filters[dim]||[]; state.filters[dim] =
 function save(){ localStorage.setItem('tdw_filters2', JSON.stringify(state.filters)); localStorage.setItem('tdw_sort', state.sort); localStorage.setItem('tdw_view', state.view); }
 
 async function loadFacets(){
-  const f = await (await fetch('/api/facets')).json();
+  let f;
+  try{ const r = await fetch('/api/facets'); if(!r.ok) throw new Error('HTTP '+r.status); f = await r.json(); }
+  catch(e){ showError('Couldn’t load facets ('+e.message+').'); return; }
   const el = document.getElementById('facets'); el.innerHTML='';
   for (const [key,label] of DIMS){
     const rows=(f[key]||[]); if(!rows.length) continue;
@@ -175,45 +197,87 @@ async function loadMeta(){
     el.className = 'fresh fresh-'+tier;
     const age = d==null ? '—' : d===0 ? 'today' : d===1 ? '1 day ago' : d+' days ago';
     const asOf = m.asOf ? fmtSpotted(m.asOf) : '—';
-    el.innerHTML = '<span class="dot"></span><span class="lbl">as of <b>'+esc(asOf)+'</b> · <span class="age">'+esc(age)+'</span> · <b>'+(m.count||0)+'</b> signals'+(m.imageGap?' · <b>'+m.imageGap+'</b> img-gap':'')+'</span>';
+    el.innerHTML = '<span class="dot"></span><span class="lbl">as of <b>'+esc(asOf)+'</b> · <span class="age">'+esc(age)+'</span> · <b>'+(m.count||0)+'</b> signals'+(m.withRealImage?' · <b>'+m.withRealImage+'</b> real imgs':'')+(m.realImageGap?' · <b>'+m.realImageGap+'</b> img-gap':'')+'</span>';
     el.title = 'Data as of '+asOf+' ('+age+'). A true trend refresh is metered & gated — this board reflects the last approved research pull.';
   }catch(e){}
 }
 function shade(h,p){ const n=parseInt(h.slice(1),16); let r=(n>>16)+p,g=((n>>8)&255)+p,b=(n&255)+p; r=Math.max(0,Math.min(255,r));g=Math.max(0,Math.min(255,g));b=Math.max(0,Math.min(255,b)); return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); }
+function fmtWhen(iso){ try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return iso; } }
 function card(it){
   const c=document.createElement('div'); c.className='card';
   const grad='linear-gradient(135deg,'+it.dominantHex+' 0%,'+shade(it.dominantHex,-18)+' 100%)';
-  const pending='<div class="pending"><div class="ico">🎨</div><div class="txt">Our-lane image pending</div><div class="sub">'+(it.ourCoverage==='gap'?'GAP — awaiting generation':'match in progress')+'</div></div>';
-  c.innerHTML='<div class="swatch" style="position:relative;overflow:hidden;background:'+grad+'">'+(it.image?'<img src="'+esc(it.image)+'" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">':pending)
+  const pending='<div class="pending"><div class="ico">🎨</div><div class="txt">Product image pending</div><div class="sub">'+(it.ourCoverage==='gap'?'GAP — real image unresolved':'match in progress')+'</div></div>';
+  // hero = the REAL marketplace product image when we have one; else our own coverage design
+  const hero = it.realImage || it.image;
+  const prov = it.realImage ? '<div class="prov" title="Real product image — '+esc(it.realImageSource||it.marketplace)+(it.realImageFetchedAt?' · fetched '+esc(fmtWhen(it.realImageFetchedAt)):'')+'">▣ '+esc(it.marketplace)+'</div>' : '';
+  const oursInset = (it.realImage && it.image && it.isOurOriginal)
+    ? '<div class="oursthumb" title="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'"><img src="'+esc(it.image)+'" alt="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'" loading="lazy"><div class="lb">OURS</div></div>' : '';
+  const oursPill = (!it.realImage && it.isOurOriginal && it.imageDesign)
+    ? '<div class="ours" title="Our own design: '+esc(it.imageDesign)+'">◆ our lane: '+esc(it.imageDesign)+'</div>' : '';
+  c.innerHTML='<div class="swatch" style="position:relative;overflow:hidden;background:'+grad+'">'
+    +(hero?'<img class="hero" src="'+esc(hero)+'" alt="'+esc(it.title)+' — '+esc(it.marketplace)+'" loading="lazy"><div class="shimmer"></div>':pending)
     +(it.isNew?'<div class="ribbon">NEW</div>':'')
     +'<div class="cov '+it.ourCoverage+'">'+(it.ourCoverage==='gap'?'🎯 GAP':'✓ HAVE')+'</div>'
-    +(it.signalRank?'<div class="rank">▲ '+it.signalRank+'</div>':'')
-    +(it.isOurOriginal&&it.imageDesign?'<div class="ours" title="Our own design: '+esc(it.imageDesign)+'">◆ our lane: '+esc(it.imageDesign)+'</div>':'')+'</div>'
+    +(it.signalRank?'<div class="rank"'+(prov?' style="bottom:34px"':'')+'>▲ '+it.signalRank+'</div>':'')
+    +prov+oursInset+oursPill+'</div>'
     +'<div class="body"><div class="title">'+esc(it.title)+'</div>'
     +'<div class="company">by <b>'+esc(it.company)+'</b></div>'
     +'<div class="chips"><span class="chip mk">'+esc(it.marketplace)+'</span><span class="chip">'+esc(it.style)+'</span><span class="chip">'+esc(it.color)+'</span><span class="chip">'+esc(it.priceBand)+'</span></div>'
-    +(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+'</div>':'')
+    +(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+(it.realImageFetchedAt?' · real image fetched '+esc(it.realImageFetchedAt):'')+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+(it.realImageFetchedAt?' · 📷 '+esc(fmtWhen(it.realImageFetchedAt)):'')+'</div>':'')
     +'<div class="signal"><span class="dot">●</span> '+esc(it.signal)+'</div>'
     +'<div class="tier">DW tier → '+esc(it.dwTier)+(it.url?' · <a class="src" href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div>'
     +(it.isOurOriginal?'<button class="shopbtn" data-id="'+esc(it.id)+'">+ Put on Shopify</button>':'')
     +'</div>';
-  c.style.cursor="pointer"; c.title="Match against our catalog"; c.addEventListener("click",function(e){ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it); });
+  c.style.cursor="pointer"; c.title="Open detail + match against our catalog"; c.addEventListener("click",function(e){ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it); });
   var sb=c.querySelector(".shopbtn"); if(sb){ sb.addEventListener("click",function(e){ e.stopPropagation(); putOnShopify(sb); }); }
-  // image-404 fallback attached in JS (closure over `pending`) — no inline-onerror string
-  // escaping, so the double-quoted class= attrs inside `pending` can't truncate the handler.
-  var im=c.querySelector(".swatch img");
-  if(im){ im.addEventListener("error",function(){ var sw=this.closest(".swatch"); if(sw&&!sw.querySelector(".pending")) sw.insertAdjacentHTML("beforeend",pending); this.remove(); }); }
+  // hero load/error handling attached in JS (closure over `it` + `pending`) — shimmer clears on
+  // load; a failed REAL image falls back to our design; a failed our-design falls to the tile.
+  var im=c.querySelector(".swatch img.hero");
+  if(im){
+    var clear=function(sw){ var sh=sw&&sw.querySelector(".shimmer"); if(sh) sh.remove(); };
+    im.addEventListener("load",function(){ this.classList.add("ld"); clear(this.closest(".swatch")); });
+    im.addEventListener("error",function(){
+      var sw=this.closest(".swatch"); clear(sw);
+      if(it.realImage && this.getAttribute("src")===it.realImage && it.image){
+        var pv=sw.querySelector(".prov"); if(pv) pv.remove();          // no longer showing the real product
+        var ot=sw.querySelector(".oursthumb"); if(ot) ot.remove();     // hero IS ours now — no duplicate inset
+        this.setAttribute("src", it.image); return;
+      }
+      if(sw&&!sw.querySelector(".pending")) sw.insertAdjacentHTML("beforeend",pending);
+      this.remove();
+    });
+    if(im.complete&&im.naturalWidth>0){ im.classList.add("ld"); clear(c.querySelector(".swatch")); }
+  }
   return c;
 }
+// a failed fetch used to leave state.loading=true forever — the board sat on "Loading…"
+// with no way out. Every load path now surfaces an error bar with a Retry.
+function showError(msg){ document.getElementById('errbar').innerHTML='<div class="errbar"><span>⚠ '+esc(msg)+'</span><button onclick="retryAll()">Retry</button></div>'; }
+function clearError(){ document.getElementById('errbar').innerHTML=''; }
+function retryAll(){ clearError(); loadFacets(); loadMeta(); refresh(); }
+function clearAllFilters(){ state.filters={}; state.q=''; document.getElementById('search').value=''; save(); loadFacets(); chips(); refresh(); }
 async function fetchPage(){
   if(state.loading||state.done||state.view!=='grid') return;
   state.loading=true; document.getElementById('loading').style.display='block';
-  const d=await (await fetch('/api/items?'+qs({page:state.page,size:state.size}))).json();
-  document.getElementById('cnt').textContent=d.total;
-  document.getElementById('gapcnt').textContent=d.gapCount?('🎯 '+d.gapCount+' gaps'):'';
-  d.items.forEach(it=>document.getElementById('grid').appendChild(card(it)));
-  if(state.page*state.size>=d.total) state.done=true;
-  state.page++; state.loading=false; document.getElementById('loading').style.display='none';
+  try{
+    const r=await fetch('/api/items?'+qs({page:state.page,size:state.size}));
+    if(!r.ok) throw new Error('HTTP '+r.status);
+    const d=await r.json();
+    clearError();
+    document.getElementById('cnt').textContent=d.total;
+    document.getElementById('gapcnt').textContent=d.gapCount?('🎯 '+d.gapCount+' gaps'):'';
+    d.items.forEach(it=>document.getElementById('grid').appendChild(card(it)));
+    if(state.page===1&&d.total===0){
+      document.getElementById('grid').innerHTML='<div class="empty">No items match these filters — <a onclick="clearAllFilters()">clear all filters</a></div>';
+      state.done=true;
+    }
+    if(state.page*state.size>=d.total) state.done=true;
+    state.page++;
+  }catch(e){
+    showError('Couldn’t load items ('+e.message+'). The server may be restarting.');
+  }finally{
+    state.loading=false; document.getElementById('loading').style.display='none';
+  }
   // If the loaded cards didn't fill the viewport, the IntersectionObserver won't
   // re-fire (it only fires on an intersection *transition*) and the board stalls on
   // page 1. Keep loading while the sentinel is still within (viewport + 400px).
@@ -300,10 +364,23 @@ function openMatchup(it){
     document.body.appendChild(m);
   }
   var q=(it.style+' '+(it.color==='Multi'?'':it.color)).trim();
-  document.getElementById('tmbox').innerHTML='<div style="padding:20px 24px;border-bottom:1px solid #eee;display:flex;gap:16px;align-items:center">'
-    +(it.image?'<img src="'+it.image+'" style="width:84px;height:84px;object-fit:cover;border-radius:10px">':'')
-    +'<div><div style="font-size:18px;font-weight:600">'+esc(it.title)+'</div>'
-    +'<div style="color:#777;font-size:13px;margin-top:4px">'+esc(it.marketplace)+' · '+esc(it.style)+' · '+esc(it.color)+(it.url?' · <a href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div></div>'
+  var hero=it.realImage||it.image;
+  var heroImg=hero?'<img src="'+esc(hero)+'" alt="'+esc(it.title)+'" style="width:340px;max-width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:12px;display:block">':'';
+  var theirs=hero?'<div style="flex:0 0 auto">'+(it.realImageUrl?'<a href="'+esc(it.realImageUrl)+'" target="_blank" rel="noopener noreferrer" title="Open the product page">'+heroImg+'</a>':heroImg)
+    +(it.realImage?'<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:#555;margin-top:5px">THEIRS — '+esc(it.realImageSource||it.marketplace)+(it.realImageFetchedAt?' · <span title="'+esc(it.realImageFetchedAt)+'">🕓 '+esc(fmtWhen(it.realImageFetchedAt))+'</span>':'')+'</div>':'')+'</div>':'';
+  var ours=(it.realImage&&it.image&&it.isOurOriginal)
+    ?'<div style="flex:0 0 auto;text-align:center"><img src="'+esc(it.image)+'" alt="Our design" style="width:150px;aspect-ratio:1;object-fit:cover;border-radius:10px;border:1px solid #eee;display:block">'
+      +'<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:#1f7a4d;margin-top:5px;max-width:150px">OURS'+(it.imageDesign?' — '+esc(it.imageDesign):'')+'</div></div>':'';
+  document.getElementById('tmbox').innerHTML='<div style="padding:20px 24px;border-bottom:1px solid #eee;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap">'
+    +theirs+ours
+    +'<div style="flex:1;min-width:230px"><div style="font-size:19px;font-weight:700;line-height:1.25">'+esc(it.title)+'</div>'
+    +'<div style="color:#555;font-size:13px;margin-top:5px">by <b>'+esc(it.company)+'</b> · '+esc(it.marketplace)+'</div>'
+    +'<div style="color:#777;font-size:12.5px;margin-top:6px">'+esc(it.style)+' · '+esc(it.color)+' · '+esc(it.priceBand)+(it.signalRank?' · ▲ '+it.signalRank:'')+'</div>'
+    +(it.signal?'<div style="font-size:12.5px;color:#2f5d3a;margin-top:7px">● '+esc(it.signal)+'</div>':'')
+    +'<div style="font-size:12px;margin-top:9px">'+(it.url?'<a href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">trend source ↗</a>':'')
+    +(it.realImageUrl&&it.realImageUrl!==it.url?(it.url?' · ':'')+'<a href="'+esc(it.realImageUrl)+'" target="_blank" rel="noopener noreferrer">product page ↗</a>':'')+'</div>'
+    +(it.ourCoverage==='gap'&&it.genBrief?'<div style="font-size:12px;background:#fdf2f1;border:1px dashed #ecc8c4;color:#8f2b23;padding:9px 11px;border-radius:8px;margin-top:11px"><b>🎯 GAP brief:</b> '+esc(it.genBrief)+'</div>':'')
+    +'<div style="font-size:11px;color:#999;margin-top:10px">DW tier → '+esc(it.dwTier||'')+'</div></div>'
     +'<button onclick="document.getElementById(\'tmodal\').style.display=\'none\'" style="margin-left:auto;border:0;background:#f2efe9;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:15px">✕</button></div>'
     +'<div style="padding:16px 24px"><div style="font-weight:600;margin-bottom:10px">🎯 Matched in our catalog <span id="tmc" style="color:#888;font-weight:400;font-size:13px"></span></div><div id="tmgrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px">Loading…</div></div>';
   document.getElementById('tmodal').style.display='flex';
diff --git a/server.js b/server.js
index 16e8121..e328fbb 100644
--- a/server.js
+++ b/server.js
@@ -55,8 +55,11 @@ function loadMeta(){
       }
     }
     const withImage = items.filter(i=>i.image).length;
+    const withRealImage = items.filter(i=>i.realImage).length;
     return { generatedAt, asOf, staleDays, count: items.length, imagesAttachedAt: d.imagesAttachedAt || null,
-             withImage, imageGap: items.length - withImage, source: d.source || null };
+             withImage, imageGap: items.length - withImage,
+             withRealImage, realImageGap: items.length - withRealImage, realImagesAttachedAt: d.realImagesAttachedAt || null,
+             source: d.source || null };
   } catch(e){ return { generatedAt:null, asOf:null, staleDays:null, count:0 }; }
 }
 
@@ -106,8 +109,11 @@ function nextSku(q){
   return DW_SERIES + '-' + String(max+1).padStart(4,'0');
 }
 // stage a product for one trending item's OUR-OWN design (idempotent per design)
+// INVARIANT: only `image` (our own design) is ever staged. `realImage` is a competitor's
+// marketplace image (internal intel only) and must never become a DW product image.
 function stageForItem(it, nowIso){
   if (!it || !it.image || !it.isOurOriginal) return { error: 'no our-own design to list (GAP lanes need generation first)' };
+  if (String(it.image).startsWith('/assets/real/')) return { error: 'refusing to stage: image is a competitor marketplace photo (internal intel only)' };
   const q = loadQueue();
   const designKey = it.imageDesign || it.image;
   const existing = q.find(p => p.designKey === designKey);
@@ -179,9 +185,9 @@ const server = http.createServer((req,res)=>{
 
   if (u.pathname === '/api/items'){
     let items = applyFilters(loadItems(), u.searchParams);
-    const sort = u.searchParams.get('sort') || 'have';
+    const sort = u.searchParams.get('sort') || 'signal';
     const cmp = {
-      have: (a,b)=> ((b.image?1:0)-(a.image?1:0)) || (b.signalRank||0)-(a.signalRank||0),
+      have: (a,b)=> (((b.realImage||b.image)?1:0)-((a.realImage||a.image)?1:0)) || (b.signalRank||0)-(a.signalRank||0),
       signal: (a,b)=> (b.signalRank||0)-(a.signalRank||0),
       gap: (a,b)=> (a.ourCoverage===b.ourCoverage?0:(a.ourCoverage==='gap'?-1:1)) || (b.signalRank||0)-(a.signalRank||0),
       titleAZ: (a,b)=> (a.title||'').localeCompare(b.title||''),

← c44ad99 real-image pipeline scaffold: plan/download/qc/attach script  ·  back to Trending Dw  ·  auto-save: 2026-07-15T11:06:03 (3 files) — data/real-images/ 8ddd3d1 →