← 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 →