[object Object]

← back to Dw Collections Viewer

Hero resolution probe + HD/MED/SD badges + upscale to largest variant

dd68026a60790d0ae8ffd0c5f7a9c656cbda59cc · 2026-05-06 17:56:42 -0700 · SteveStudio2

Steve flagged: many hero images look blurry full-page because the source is
a small Shopify variant (e.g. 480x480). Two fixes:

1. largestShopifyImage(url) — strips _NxN size suffix from any
   cdn.shopify.com / designerwallcoverings.com URL so the master is fetched
   for both card thumbnails AND drag-drop hero pins.

2. Resolution probe + badges — every product card now shows a HD/MED/SD
   badge in its top-right after the natural image dimensions are probed
   client-side (concurrency 6, sessionStorage cached). Tier rules:
     ≥ 1500 px largest side → HD (green)
     ≥ 800  px              → MED (gold)
     <  800  px             → SD (red)
   SD cards fade to 55% opacity to push you toward HD picks.

3. Hero panel banner — when a site is selected, a banner above the assigned-
   collections section reports whether the current hero is HD/MED/SD with
   the actual dimensions; SD shows a 'WILL LOOK BLURRY' warning.

4. 'HD only' filter checkbox in the products bar — hides all non-HD cards.

5. Products-bar count line now shows '23 HD · 12 MED · 4 SD · 8 probing'.

Cork live site already pushed with the upscale logic; 45 other sites
re-patched locally pending deploy.

Files touched

Diff

commit dd68026a60790d0ae8ffd0c5f7a9c656cbda59cc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 17:56:42 2026 -0700

    Hero resolution probe + HD/MED/SD badges + upscale to largest variant
    
    Steve flagged: many hero images look blurry full-page because the source is
    a small Shopify variant (e.g. 480x480). Two fixes:
    
    1. largestShopifyImage(url) — strips _NxN size suffix from any
       cdn.shopify.com / designerwallcoverings.com URL so the master is fetched
       for both card thumbnails AND drag-drop hero pins.
    
    2. Resolution probe + badges — every product card now shows a HD/MED/SD
       badge in its top-right after the natural image dimensions are probed
       client-side (concurrency 6, sessionStorage cached). Tier rules:
         ≥ 1500 px largest side → HD (green)
         ≥ 800  px              → MED (gold)
         <  800  px             → SD (red)
       SD cards fade to 55% opacity to push you toward HD picks.
    
    3. Hero panel banner — when a site is selected, a banner above the assigned-
       collections section reports whether the current hero is HD/MED/SD with
       the actual dimensions; SD shows a 'WILL LOOK BLURRY' warning.
    
    4. 'HD only' filter checkbox in the products bar — hides all non-HD cards.
    
    5. Products-bar count line now shows '23 HD · 12 MED · 4 SD · 8 probing'.
    
    Cork live site already pushed with the upscale logic; 45 other sites
    re-patched locally pending deploy.
---
 public/index.html | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 158 insertions(+), 6 deletions(-)

diff --git a/public/index.html b/public/index.html
index 702ccda..1175aad 100644
--- a/public/index.html
+++ b/public/index.html
@@ -148,6 +148,27 @@ aside { border-left: 1px solid var(--rule); background: var(--bg2);
   border: 1px solid var(--gold); background: rgba(12,10,8,0.78); color: var(--gold);
   font-size: 11px; line-height: 14px; text-align: center; font-weight: 700 }
 .products-slider .pcard.selected .check { background: var(--gold); color: var(--bg) }
+/* Resolution badge — shown on each product card after image probe */
+.products-slider .pcard .res-badge {
+  position: absolute; top: 4px; right: 4px;
+  font: 600 9px/1.4 ui-monospace, "SF Mono", monospace;
+  letter-spacing: 0.06em; padding: 2px 5px; border-radius: 2px;
+  background: rgba(12,10,8,0.88); pointer-events: none; backdrop-filter: blur(2px);
+}
+.products-slider .pcard .res-badge.hd      { color: var(--green, #6b8e6f); border: 1px solid var(--green, #6b8e6f) }
+.products-slider .pcard .res-badge.med     { color: var(--gold);          border: 1px solid var(--gold) }
+.products-slider .pcard .res-badge.sd      { color: var(--red);           border: 1px solid var(--red) }
+.products-slider .pcard .res-badge.probing { color: var(--faint);         border: 1px solid var(--rule) }
+.products-slider .pcard.low-res { opacity: 0.55 }
+/* Hero resolution warning on detail panel */
+.detail .hero-res {
+  margin: -12px 0 16px; padding: 8px 12px;
+  font: 600 10px/1.4 ui-monospace, monospace; letter-spacing: 0.10em; text-transform: uppercase;
+  border-left: 3px solid var(--gold);
+}
+.detail .hero-res.hd  { color: var(--green); border-color: var(--green); background: rgba(107,142,111,0.06) }
+.detail .hero-res.med { color: var(--gold);  background: rgba(201,182,135,0.06) }
+.detail .hero-res.sd  { color: var(--red);   border-color: var(--red); background: rgba(198,108,77,0.08) }
 .products-slider .pcard.dragging { opacity: 0.45; outline: 1px dashed var(--gold) }
 .products-slider .pcard[draggable="true"] { cursor: grab }
 .products-slider .pcard[draggable="true"]:active { cursor: grabbing }
@@ -476,6 +497,63 @@ async function fetchProducts(slug) {
 // Sort + sizing state — persisted in localStorage so it survives reloads.
 let PRODUCTS_SORT  = localStorage.getItem('dwc_sort') || 'newest';
 let PRODUCTS_SIZE  = parseInt(localStorage.getItem('dwc_size') || '140', 10);
+let HD_ONLY        = localStorage.getItem('dwc_hdonly') === '1';
+function setHdOnly(v) { HD_ONLY = !!v; localStorage.setItem('dwc_hdonly', v ? '1' : '0'); renderProductsSlider(); }
+
+// Image-resolution cache: sku → { w, h, tier }. Persists in sessionStorage.
+const IMG_DIMS = (() => {
+  try { return new Map(Object.entries(JSON.parse(sessionStorage.getItem('dwc_dims') || '{}'))); }
+  catch { return new Map(); }
+})();
+function saveDims() {
+  try { sessionStorage.setItem('dwc_dims', JSON.stringify(Object.fromEntries(IMG_DIMS))); } catch {}
+}
+function tierFor(w, h) {
+  const m = Math.max(w || 0, h || 0);
+  if (m >= 1500) return 'hd';
+  if (m >= 800)  return 'med';
+  return 'sd';
+}
+function tierLabel(t, w, h) {
+  if (t === 'hd')  return `HD ${w}×${h}`;
+  if (t === 'med') return `MED ${w}×${h}`;
+  if (t === 'sd')  return `SD ${w}×${h}`;
+  return '…';
+}
+
+// Upgrade Shopify CDN URL to its largest available variant.
+// Strips any `_NxN` size suffix so the master is requested.
+function largestShopifyImage(url) {
+  if (!url || typeof url !== 'string') return url;
+  if (!/cdn\.shopify\.com|designerwallcoverings\.com/.test(url)) return url;
+  // Strip _200x200, _480x480, _800x, etc. before extension
+  return url.replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4');
+}
+
+// Concurrent-limited image dimension probe
+const _probeQueue = []; let _probeInFlight = 0;
+function probeImage(url, sku, onDone) {
+  if (IMG_DIMS.has(sku)) { onDone(IMG_DIMS.get(sku)); return; }
+  _probeQueue.push({ url, sku, onDone });
+  drainProbe();
+}
+function drainProbe() {
+  while (_probeInFlight < 6 && _probeQueue.length) {
+    const { url, sku, onDone } = _probeQueue.shift();
+    _probeInFlight++;
+    const im = new Image();
+    im.onload = () => {
+      const w = im.naturalWidth, h = im.naturalHeight;
+      const rec = { w, h, tier: tierFor(w, h) };
+      IMG_DIMS.set(sku, rec);
+      saveDims();
+      _probeInFlight--; drainProbe();
+      onDone(rec);
+    };
+    im.onerror = () => { _probeInFlight--; drainProbe(); onDone({ w: 0, h: 0, tier: 'sd' }); };
+    im.src = largestShopifyImage(url);
+  }
+}
 
 // Color buckets for sort=color. Tags include literal color names like "Dark Green",
 // "Beige", "Olive". We rank by where they fall on a hue cycle so similar colors
@@ -522,30 +600,54 @@ function renderProductsSlider() {
     return;
   }
   const sorted = sortProducts(PRODUCTS, PRODUCTS_SORT);
-  const cards = sorted.map(p => {
+  // Optional filter — show only HD-grade source images
+  const filtered = HD_ONLY ? sorted.filter(p => {
+    const sku = String(p.sku || p.handle || '');
+    const d = IMG_DIMS.get(sku);
+    return d && d.tier === 'hd';
+  }) : sorted;
+  const cards = filtered.map(p => {
     const sku = String(p.sku || p.handle || '');
     const sel = PRODUCT_SELECTED.has(sku) ? ' selected' : '';
-    const img = p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title || '')}" loading="lazy" onerror="this.style.opacity=0.15">` : '';
+    const dim = IMG_DIMS.get(sku);
+    const tier = dim ? dim.tier : null;
+    const lowResCls = tier === 'sd' ? ' low-res' : '';
+    // Use the largest Shopify variant for the card thumbnail too — sharper preview
+    const bigUrl = largestShopifyImage(p.image_url || '');
+    const img = bigUrl ? `<img src="${esc(bigUrl)}" alt="${esc(p.title || '')}" loading="lazy" data-probe-sku="${esc(sku)}" onerror="this.style.opacity=0.15">` : '';
+    const badge = tier
+      ? `<span class="res-badge ${tier}">${esc(tierLabel(tier, dim.w, dim.h))}</span>`
+      : `<span class="res-badge probing" data-probe-badge="${esc(sku)}">…</span>`;
     const tipLines = [p.title || sku];
     if (p.sku) tipLines.push(`SKU: ${p.sku}`);
+    if (dim) tipLines.push(`Image: ${dim.w}×${dim.h} (${dim.tier.toUpperCase()})`);
     if ((p.tags || []).length) tipLines.push(`Tags: ${(p.tags || []).slice(0, 6).join(', ')}`);
-    tipLines.push('Drag to cover image to set as hero');
+    tipLines.push('Drag to cover image to set as hero · pick HD for cover');
     const ts = p.published_at || p.created_at;
     if (ts) {
       const d = new Date(ts);
       if (!isNaN(d)) tipLines.push(`Published: ${d.toISOString().slice(0,16).replace('T', ' ')} UTC`);
     }
-    return `<div class="pcard${sel}" draggable="true" data-sku="${esc(sku)}" data-image="${esc(p.image_url || '')}" data-title="${esc(p.title || '')}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(tipLines.join('\n'))}">
+    return `<div class="pcard${sel}${lowResCls}" draggable="true" data-sku="${esc(sku)}" data-image="${esc(bigUrl || '')}" data-title="${esc(p.title || '')}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(tipLines.join('\n'))}">
       <div class="check">${PRODUCT_SELECTED.has(sku) ? '✓' : ''}</div>
+      ${badge}
       <div class="img">${img}</div>
       <div class="label">${esc(p.title || sku)}</div>
     </div>`;
   }).join('');
   const selCount = PRODUCT_SELECTED.size;
   const sortLabel = { newest:'newest first', sku:'SKU A→Z', color:'by color', style:'by style' }[PRODUCTS_SORT] || PRODUCTS_SORT;
+  // Count of HD/MED/SD already known so the bar shows progress
+  const dimCount = sorted.reduce((acc, p) => {
+    const sku = String(p.sku || p.handle || '');
+    const d = IMG_DIMS.get(sku);
+    if (!d) acc.unknown++;
+    else acc[d.tier]++;
+    return acc;
+  }, { hd: 0, med: 0, sd: 0, unknown: 0 });
   el.innerHTML = `
     <div class="products-bar">
-      <div class="count">${PRODUCTS.length} products${selCount ? ` · ${selCount} selected` : ''} · ${sortLabel}</div>
+      <div class="count">${PRODUCTS.length} products${selCount ? ` · ${selCount} selected` : ''} · ${sortLabel} · <span style="color:var(--green)">${dimCount.hd} HD</span> · <span style="color:var(--gold)">${dimCount.med} MED</span> · <span style="color:var(--red)">${dimCount.sd} SD</span>${dimCount.unknown ? ` · <span style="color:var(--faint)">${dimCount.unknown} probing</span>` : ''}</div>
       <div class="actions-mini">
         <label class="pill-label">Sort:
           <select onchange="setSort(this.value)">
@@ -558,6 +660,9 @@ function renderProductsSlider() {
         <label class="pill-label">Size:
           <input type="range" min="80" max="260" step="10" value="${PRODUCTS_SIZE}" oninput="setGridSize(this.value)" title="Grid card width" />
         </label>
+        <label class="pill-label" title="Show only products whose source image is ≥1500px (HD enough for full-page hero)">
+          <input type="checkbox" ${HD_ONLY ? 'checked' : ''} onchange="setHdOnly(this.checked)" /> HD only
+        </label>
         ${selCount ? `<button onclick="clearProductSelection()">Clear (${selCount})</button>` : ''}
         ${selCount ? `<button onclick="copySelectedSkus()">Copy SKUs</button>` : ''}
         ${selCount ? `<button onclick="removeSelectedFromList()">Remove (${selCount})</button>` : ''}
@@ -567,6 +672,22 @@ function renderProductsSlider() {
     <div class="row" style="grid-template-columns:repeat(auto-fill,minmax(${PRODUCTS_SIZE}px,1fr))">${cards}</div>
   `;
   wireProductDrag();
+  // Kick off concurrent dimension probes for any cards whose dims aren't cached.
+  for (const p of filtered) {
+    const sku = String(p.sku || p.handle || '');
+    if (IMG_DIMS.has(sku)) continue;
+    const url = largestShopifyImage(p.image_url || '');
+    if (!url) continue;
+    probeImage(url, sku, rec => {
+      const badge = document.querySelector(`[data-probe-badge="${CSS.escape(sku)}"]`);
+      if (badge) {
+        badge.className = 'res-badge ' + rec.tier;
+        badge.textContent = tierLabel(rec.tier, rec.w, rec.h);
+      }
+      const card = document.querySelector(`.pcard[data-sku="${CSS.escape(sku)}"]`);
+      if (card && rec.tier === 'sd') card.classList.add('low-res');
+    });
+  }
 }
 
 function setSort(v) {
@@ -660,9 +781,13 @@ async function removeSelectedFromList() {
 function renderDetail() {
   const s = SITES[CURRENT];
   if (!s) return;
-  const coverUrl = s.cover_image_url || (s.hero && s.hero.image_url) || null;
+  const rawCoverUrl = s.cover_image_url || (s.hero && s.hero.image_url) || null;
+  const coverUrl = rawCoverUrl ? largestShopifyImage(rawCoverUrl) : null;
   const coverTitle = s.cover_image_url ? 'custom cover' : (s.hero && s.hero.title) || '';
   const isEmpty = !coverUrl;
+  // Hero resolution warning — probe in background and inject a banner.
+  const heroSku = (s.hero && s.hero.sku) || 'site-cover-' + CURRENT;
+  const heroDim = IMG_DIMS.get(heroSku);
   const hint = `<div class="drop-hint">
         <div class="icon">⤓</div>
         <div>${isEmpty ? 'Drop image · or click to choose' : 'Drop · or click to replace'}</div>
@@ -693,10 +818,24 @@ function renderDetail() {
       <button class="add" onclick="addColl('${esc(c.handle)}')">+ add</button>
     </div>`).join('') || '<div style="padding:18px;color:var(--faint);text-align:center;font-size:11px;letter-spacing:0.18em;text-transform:uppercase">no matches</div>';
 
+  // Hero resolution banner: text varies based on probed dimensions
+  let heroResBanner = '';
+  if (coverUrl) {
+    if (heroDim) {
+      const t = heroDim.tier;
+      const msg = t === 'hd'  ? `✓ Hero is HD (${heroDim.w}×${heroDim.h}) — looks sharp full-page`
+                : t === 'med' ? `△ Hero is MED (${heroDim.w}×${heroDim.h}) — may look soft on large screens; pick HD if available`
+                              : `✗ Hero is SD (${heroDim.w}×${heroDim.h}) — will look BLURRY on full-page hero. Drag a HD-tagged product onto the cover.`;
+      heroResBanner = `<div class="hero-res ${t}">${esc(msg)}</div>`;
+    } else {
+      heroResBanner = `<div class="hero-res med" id="heroResBanner">… probing hero resolution</div>`;
+    }
+  }
   document.getElementById('detail').innerHTML = `
     <h2>${esc(CURRENT)}</h2>
     <div class="domain">${esc(s.domain)} · ${s.product_count} products live</div>
     <div class="hero-large${isEmpty ? ' empty-zone' : ''}">${heroImg}</div>
+    ${heroResBanner}
     ${heroTitle}
 
     <div class="section">
@@ -786,6 +925,19 @@ function renderDetail() {
   `;
   // Hydrate the products slider after the detail HTML is in the DOM.
   renderProductsSlider();
+  // Probe hero resolution if not already cached, then update the banner in place.
+  if (coverUrl && !heroDim) {
+    probeImage(coverUrl, heroSku, rec => {
+      const banner = document.getElementById('heroResBanner');
+      if (!banner) return;
+      const t = rec.tier;
+      const msg = t === 'hd'  ? `✓ Hero is HD (${rec.w}×${rec.h}) — looks sharp full-page`
+                : t === 'med' ? `△ Hero is MED (${rec.w}×${rec.h}) — may look soft on large screens; pick HD if available`
+                              : `✗ Hero is SD (${rec.w}×${rec.h}) — will look BLURRY on full-page hero. Drag a HD-tagged product onto the cover.`;
+      banner.className = 'hero-res ' + t;
+      banner.textContent = msg;
+    });
+  }
 }
 
 function filterColls(v) { COLL_FILTER = v; renderDetail(); }

← 71f7337 save+deploy: sort selector (textures/newest/title/sku/priceU  ·  back to Dw Collections Viewer  ·  save+deploy: persist chosen sort to per-site rules; UI pre-p a0cea68 →