← back to Designer Wallcoverings
auto-save: 2026-07-09T16:11:12 (9 files) — pending-approval/boost-filter-consolidation-2026-06-25 vendor-scrapers/china-seas-refresh scripts/wallquest-refresh/cp-harness.html scripts/wallquest-refresh/cp-refine-shots/after-01-rest.png scripts/wallquest-refresh/cp-refine-shots/after-02-low.png
ed16c8e231ab7a897af81b927850d001711a9076 · 2026-07-09 16:11:16 -0700 · Steve Abrams
Files touched
A scripts/wallquest-refresh/cp-harness.htmlA scripts/wallquest-refresh/cp-refine-shots/after-01-rest.pngA scripts/wallquest-refresh/cp-refine-shots/after-02-low.pngA scripts/wallquest-refresh/cp-refine-shots/after-03-high.pngA scripts/wallquest-refresh/cp-refine-shots/after-04-index-open.pngA scripts/wallquest-refresh/cp-refine-shots/cmp-high.pngA scripts/wallquest-refresh/cp-refine-shots/cmp-rest.png
Diff
commit ed16c8e231ab7a897af81b927850d001711a9076
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 16:11:16 2026 -0700
auto-save: 2026-07-09T16:11:12 (9 files) — pending-approval/boost-filter-consolidation-2026-06-25 vendor-scrapers/china-seas-refresh scripts/wallquest-refresh/cp-harness.html scripts/wallquest-refresh/cp-refine-shots/after-01-rest.png scripts/wallquest-refresh/cp-refine-shots/after-02-low.png
---
scripts/wallquest-refresh/cp-harness.html | 617 +++++++++++++++++++++
.../cp-refine-shots/after-01-rest.png | Bin 0 -> 37592 bytes
.../cp-refine-shots/after-02-low.png | Bin 0 -> 33049 bytes
.../cp-refine-shots/after-03-high.png | Bin 0 -> 41256 bytes
.../cp-refine-shots/after-04-index-open.png | Bin 0 -> 4331378 bytes
.../wallquest-refresh/cp-refine-shots/cmp-high.png | Bin 0 -> 73012 bytes
.../wallquest-refresh/cp-refine-shots/cmp-rest.png | Bin 0 -> 61275 bytes
7 files changed, 617 insertions(+)
diff --git a/scripts/wallquest-refresh/cp-harness.html b/scripts/wallquest-refresh/cp-harness.html
new file mode 100644
index 00000000..4f9c3328
--- /dev/null
+++ b/scripts/wallquest-refresh/cp-harness.html
@@ -0,0 +1,617 @@
+<!doctype html><html><head><meta charset="utf-8">
+<style>body{font-family:Lora,serif;background:#fff;margin:0;padding:24px;max-width:760px;margin:0 auto;}</style>
+<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet"></head>
+<body><div class="dw-color-palette" data-dw-color-palette
+ data-colors='[{"name":"Gray","hex":"#838c99","pct":40},{"name":"Zinc","hex":"#a2aaae","pct":37},{"name":"Cloud","hex":"#c1c7c4","pct":14},{"name":"Slate","hex":"#697285","pct":9}]'
+ data-color-hex="#4682B4"
+ data-product-image="https://www.designerwallcoverings.com/cdn/shop/products/ScreenShot2022-11-04at2.11.01PM_400x400.png?v=1747254096"
+ data-product-style="Wallcovering">
+ <p class="dw-color-palette__title">Colors In This Pattern</p>
+
+
+ <div class="dw-color-palette__density" data-dw-palette-density hidden>
+ <button type="button" class="dw-cp-dense__step" data-dw-cp-minus aria-label="Show fewer colors">−</button>
+ <label class="dw-cp-dense__wrap">
+ <span class="dw-cp-dense__lbl">Colors shown</span>
+ <input type="range" class="dw-cp-dense__range" data-dw-cp-range
+ min="6" max="64" step="1" value="20"
+ aria-label="Number of colors shown in the palette">
+ <output class="dw-cp-dense__out" data-dw-cp-out>20</output>
+ </label>
+ <button type="button" class="dw-cp-dense__step" data-dw-cp-plus aria-label="Show more colors">+</button>
+ </div>
+
+ <div class="dw-color-palette__cards" data-dw-color-palette-cards aria-live="polite"></div>
+
+
+ <div class="dw-color-index" data-dw-color-index hidden
+ data-endpoint="https://photo.designerwallcoverings.com/apps/color-index">
+ <div class="dw-color-index__head">
+ <span class="dw-color-index__swatch" data-dw-ci-swatch></span>
+ <span class="dw-color-index__label" data-dw-ci-label aria-live="polite"></span>
+ <button type="button" class="dw-color-index__close" data-dw-ci-close aria-label="Close color index">×</button>
+ </div>
+ <div class="dw-color-index__grid" data-dw-ci-grid aria-live="polite"></div>
+ </div>
+</div>
+
+<style>
+ .dw-color-palette{padding:18px 0 12px;text-align:center;min-height:10px;}
+ .dw-color-palette__title{font-family:Lora,serif;font-size:15px;font-weight:500;color:#3D4246;margin:0 0 4px;letter-spacing:.01em;}
+ /* ── DENSITY SLIDER (expand/contract dot count) — refined to read as a quiet
+ caption UNDER the title, not a utilitarian widget row (refine 2026-07-09). ── */
+ .dw-color-palette__density{display:flex;align-items:center;justify-content:center;gap:10px;max-width:660px;margin:0 auto 15px;}
+ .dw-color-palette__density[hidden]{display:none;}
+ .dw-cp-dense__wrap{display:inline-flex;align-items:center;gap:9px;}
+ .dw-cp-dense__lbl{font-family:Lora,serif;font-size:10px;color:#8a8577;letter-spacing:.14em;text-transform:uppercase;white-space:nowrap;}
+ .dw-cp-dense__range{-webkit-appearance:none;appearance:none;width:150px;max-width:38vw;height:2px;border-radius:2px;background:linear-gradient(#d8d4cc,#d8d4cc) no-repeat;background-size:100% 2px;background-position:0 center;outline:none;cursor:pointer;margin:0;}
+ .dw-cp-dense__range:focus-visible{outline:2px solid #b8b3a8;outline-offset:4px;border-radius:3px;}
+ .dw-cp-dense__range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#3D4246;border:2px solid #fff;box-shadow:0 0 0 1px rgba(61,66,70,.35),0 1px 2px rgba(0,0,0,.12);cursor:pointer;transition:transform .1s ease;}
+ .dw-cp-dense__range::-webkit-slider-thumb:hover{transform:scale(1.15);}
+ .dw-cp-dense__range::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#3D4246;border:2px solid #fff;box-shadow:0 0 0 1px rgba(61,66,70,.35),0 1px 2px rgba(0,0,0,.12);cursor:pointer;}
+ .dw-cp-dense__out{font-family:Lora,serif;font-size:11px;font-weight:600;color:#3D4246;min-width:16px;text-align:right;font-variant-numeric:tabular-nums;letter-spacing:.01em;}
+ .dw-cp-dense__step{font-family:Lora,serif;font-size:15px;line-height:1;color:#8a8577;background:none;border:1px solid #e0ded9;border-radius:50%;width:22px;height:22px;padding:0;cursor:pointer;flex:0 0 auto;transition:color .12s ease,border-color .12s ease;}
+ .dw-cp-dense__step:hover{color:#3D4246;border-color:#b8b3a8;}
+ .dw-cp-dense__step:focus-visible{outline:2px solid #b8b3a8;outline-offset:2px;}
+ /* single-dominant-color state: control stays visible but reads as intentionally
+ inert (Steve 2026-07-09 fix #2) — dimmed, non-interactive, hinted via title/aria. */
+ .dw-color-palette__density.is-single{opacity:.55;}
+ .dw-color-palette__density.is-single .dw-cp-dense__range,
+ .dw-color-palette__density.is-single .dw-cp-dense__step{cursor:not-allowed;}
+ .dw-cp-dense__range:disabled{cursor:not-allowed;}
+ .dw-cp-dense__step:disabled{cursor:not-allowed;color:#c4c0b6;border-color:#ece9e3;}
+ .dw-cp-dense__step:disabled:hover{color:#c4c0b6;border-color:#ece9e3;}
+ @media (max-width:600px){
+ .dw-cp-dense__lbl{display:none;}
+ .dw-cp-dense__range{width:120px;}
+ }
+ /* Centered EVEN ROWS of uniform swatches: flex-wrap + justify-content:center
+ centers every row, including a partial last row. (Steve 2026-07-08) */
+ .dw-color-palette__cards{display:flex;flex-wrap:wrap;justify-content:center;align-content:center;gap:3px;max-width:660px;margin:0 auto;}
+ /* Swatches read as cut material tiles: faint inner shadow + slightly larger radius,
+ hairline edge (not a heavy border). (refine 2026-07-09) */
+ .dw-swatch{display:block;width:18px;height:18px;border-radius:4px;text-decoration:none;box-sizing:border-box;box-shadow:inset 0 0 0 1px rgba(0,0,0,.05),inset 0 -3px 5px rgba(0,0,0,.045);transition:transform .12s ease,box-shadow .12s ease,z-index .12s;position:relative;}
+ a.dw-swatch{cursor:pointer;}
+ .dw-swatch:hover{transform:scale(1.55);box-shadow:0 3px 10px rgba(0,0,0,.20),inset 0 0 0 1px rgba(0,0,0,.10);z-index:3;}
+ /* the pattern's REAL colors read as anchors: a refined HAIRLINE ink ring + a
+ slightly larger tile — no longer a heavy dark hoop that orphans them at high
+ dot counts. (refine 2026-07-09) */
+ .dw-swatch--base{width:22px;height:22px;border-radius:5px;box-shadow:0 0 0 1px #fff,0 0 0 1.5px rgba(61,66,70,.55),0 1px 3px rgba(0,0,0,.12),inset 0 -3px 6px rgba(0,0,0,.05);}
+ .dw-swatch--base:hover{transform:scale(1.4);box-shadow:0 0 0 1px #fff,0 0 0 1.5px rgba(61,66,70,.85),0 3px 10px rgba(0,0,0,.20);}
+ .dw-color-palette__bar{display:flex;height:4px;border-radius:3px;overflow:hidden;max-width:660px;margin:16px auto 0;box-shadow:inset 0 0 0 1px rgba(0,0,0,.04);}
+ .dw-color-palette__bar span{display:block;height:100%;}
+ .dw-color-palette__legend{display:flex;flex-wrap:wrap;justify-content:center;gap:6px 16px;max-width:660px;margin:13px auto 0;}
+ .dw-color-palette__legend a,.dw-color-palette__legend span{display:inline-flex;align-items:center;gap:6px;font-family:Lora,serif;font-size:11px;color:#3D4246;text-decoration:none;text-transform:capitalize;letter-spacing:.01em;}
+ .dw-color-palette__legend i{width:11px;height:11px;border-radius:3px;box-shadow:0 0 0 1px rgba(0,0,0,.12),inset 0 -2px 3px rgba(0,0,0,.05);}
+ .dw-color-palette__legend a:hover{color:#000;text-decoration:underline;}
+ .dw-color-palette:empty{display:none;}
+ @media (max-width:600px){
+ .dw-color-palette__cards{gap:2px;max-width:100%;}
+ .dw-swatch{width:15px;height:15px;}
+ .dw-swatch--base{width:19px;height:19px;}
+ }
+ /* ── In-page color-index grid the dot brings up — refined to read as a curated
+ "shop this colorway" drawer: hairline-ruled header, Lora title at the palette
+ scale, soft reveal, even gutters, hairline media. (refine 2026-07-09) ── */
+ .dw-color-index{max-width:660px;margin:20px auto 6px;text-align:left;border-top:1px solid #ece9e3;padding-top:16px;}
+ .dw-color-index[hidden]{display:none;}
+ @keyframes dwCiReveal{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
+ .dw-color-index.is-open{animation:dwCiReveal .28s ease both;}
+ .dw-color-index__head{display:flex;align-items:center;gap:10px;margin:0 0 14px;}
+ .dw-color-index__swatch{width:22px;height:22px;border-radius:5px;flex:0 0 auto;box-shadow:0 0 0 1px rgba(0,0,0,.14),inset 0 -3px 5px rgba(0,0,0,.06);}
+ .dw-color-index__label{font-family:Lora,serif;font-size:14px;color:#3D4246;flex:1 1 auto;letter-spacing:.01em;}
+ .dw-color-index__label b{font-weight:600;text-transform:capitalize;}
+ .dw-color-index__close{flex:0 0 auto;background:none;border:1px solid #e0ded9;border-radius:50%;width:26px;height:26px;line-height:1;font-size:15px;color:#8a8577;cursor:pointer;padding:0;transition:color .12s ease,border-color .12s ease;}
+ .dw-color-index__close:hover{color:#3D4246;border-color:#b8b3a8;}
+ .dw-color-index__close:focus-visible{outline:2px solid #b8b3a8;outline-offset:2px;}
+ .dw-color-index__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));gap:16px 14px;}
+ .dw-color-index__card{display:block;text-decoration:none;color:inherit;}
+ .dw-color-index__card img{width:100%;aspect-ratio:1;object-fit:cover;box-shadow:0 0 0 1px #ece9e3;border-radius:3px;background:#f4f2ee;transition:box-shadow .14s ease;}
+ .dw-color-index__card:hover img{box-shadow:0 0 0 1px #b8b3a8,0 3px 12px rgba(0,0,0,.10);}
+ .dw-color-index__cap{display:block;font-family:Lora,serif;font-size:11px;color:#3D4246;margin-top:7px;line-height:1.3;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;text-transform:capitalize;}
+ .dw-color-index__card:hover .dw-color-index__cap{text-decoration:underline;text-decoration-color:#d8d4cc;}
+ .dw-color-index__cap i{display:block;font-style:normal;color:#8a8577;font-size:10px;text-transform:none;margin-top:1px;letter-spacing:.02em;}
+ .dw-color-index__empty,.dw-color-index__loading{font-family:Lora,serif;font-size:12px;color:#8a8577;padding:8px 0;}
+ @media (max-width:600px){
+ .dw-color-index__grid{grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px;}
+ }
+</style>
+
+<script>
+(function(){
+ function init(){
+ var root = document.querySelector('[data-dw-color-palette]');
+ if(!root || root.dataset.rendered) return;
+ var cards = root.querySelector('[data-dw-color-palette-cards]');
+ if(!cards) return;
+ root.dataset.rendered = '1';
+
+ /* name -> hex fallback map (kept in sync with color-dots.liquid) */
+ var N = {
+ 'red':'#DC2626','crimson':'#DC143C','burgundy':'#800020','wine':'#722F37','maroon':'#800000',
+ 'blue':'#2563EB','navy':'#1B2A4A','cobalt':'#0047AB','indigo':'#3F51B5','sky blue':'#87CEEB','steel blue':'#4682B4','royal blue':'#4169E1','blue frost':'#C4D3E0','deep indigo':'#1B2A4A','dark navy':'#1B2A4A','midnight':'#191970',
+ 'green':'#16A34A','sage':'#87AE73','olive':'#808000','forest green':'#228B22','emerald':'#50C878','mint':'#98FF98','moss':'#8A9A5B','sea glass':'#C6D8CE',
+ 'gold':'#D4AF37','golden':'#FFD700','amber':'#FFBF00',
+ 'yellow':'#EAB308','mustard':'#FFDB58',
+ 'orange':'#EA580C','rust':'#B7410E','terracotta':'#E2725B','melon':'#FDBCB4','soft melon':'#F6C9A8',
+ 'pink':'#EC4899','blush':'#DE5D83','rose':'#FF007F','salmon':'#FA8072','fuchsia':'#FF00FF',
+ 'purple':'#9333EA','violet':'#7C3AED','plum':'#8E4585','lavender':'#E6E6FA','lilac':'#C8A2C8','mauve':'#E0B0FF',
+ 'black':'#1A1A1A','charcoal':'#36454F','ebony':'#555D50','onyx':'#353839',
+ 'white':'#FAFAFA','ivory':'#FFFFF0','cream':'#FFFDD0','pearl':'#F0EAD6','off-white':'#FAF9F6','eggshell':'#F0EAD6',
+ 'gray':'#6B7280','grey':'#6B7280','silver':'#C0C0C0','slate':'#708090','metallic silver':'#BFC1C2',
+ 'brown':'#92400E','chocolate':'#7B3F00','espresso':'#3C1414','walnut':'#5C3A1D','mocha':'#967969','sienna':'#A0522D',
+ 'beige':'#F5F5DC','tan':'#D2B48C','taupe':'#483C32','sand':'#C2B280','khaki':'#F0E68C','camel':'#C19A6B','oatmeal':'#B8AB8C','linen':'#FAF0E6',
+ 'coral':'#FF6F61','peach':'#FFCBA4',
+ 'teal':'#0D9488','aqua':'#00FFFF','turquoise':'#40E0D0','cyan':'#06B6D4',
+ 'metallic':'#AAA9AD','copper':'#B87333','bronze':'#CD7F32'
+ };
+
+ function pctOf(c){ var p = (c.pct!=null?c.pct:(c.percentage!=null?c.percentage:null)); if(p==null) return null; p=parseFloat(p); return isNaN(p)?null:Math.round(p); }
+
+ /* ── HEX → color-family COLLECTION (handles verified live 2026-07-08) ──────
+ Derive the family from the REAL measured hex and link to that family's
+ collection. blue-wallpaper & grey-silver corrected from the old dead
+ handles (blue-wallpaper-collection / grey-wallcovering, which 404'd). */
+ var FAM_COLL = {
+ red:'red-wallpaper-collection', orange:'orange', yellow:'yellow-wallpaper-collection',
+ gold:'gold-wallpaper', green:'green-wallpaper-collection', teal:'turquoise',
+ blue:'blue-wallpaper', purple:'purple', pink:'pink-wallcovering-2',
+ brown:'brown-wallpaper-collection', beige:'beige-cream-wallpaper-collection',
+ black:'black', white:'white-wallpaper-collection', gray:'grey-silver'
+ };
+ function hexToRgb(h){
+ h=(h||'').replace('#','');
+ if(h.length===3) h=h.split('').map(function(x){return x+x;}).join('');
+ if(h.length<6) return null;
+ var r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
+ return (isNaN(r)||isNaN(g)||isNaN(b))?null:[r,g,b];
+ }
+ function familyFromHex(hex){
+ var rgb=hexToRgb(hex); if(!rgb) return null;
+ var r=rgb[0]/255,g=rgb[1]/255,b=rgb[2]/255;
+ var mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn;
+ var l=(mx+mn)/2, s=d===0?0:d/(1-Math.abs(2*l-1));
+ var hdeg=0;
+ if(d!==0){
+ if(mx===r) hdeg=60*(((g-b)/d)%6);
+ else if(mx===g) hdeg=60*(((b-r)/d)+2);
+ else hdeg=60*(((r-g)/d)+4);
+ }
+ if(hdeg<0) hdeg+=360;
+ if(s<0.18){
+ if(l>0.86) return 'white';
+ if(l<0.18) return 'black';
+ if((hdeg>=20 && hdeg<=70) && l>0.30) return 'beige';
+ return 'gray';
+ }
+ if(l>0.80 && (hdeg>=20 && hdeg<70)) return 'beige';
+ if(l<0.40 && s<0.75 && ((hdeg>=10 && hdeg<70) || hdeg>=345)) return 'brown';
+ if(l<0.16) return 'black';
+ if(hdeg<15||hdeg>=345) return 'red';
+ if(hdeg<40){ return (s<0.45 && l>0.55) ? 'beige' : 'orange'; }
+ if(hdeg<55){ return (s<0.45) ? 'beige' : (hdeg<50 ? 'gold' : 'yellow'); }
+ if(hdeg<65) return (l<0.45) ? 'gold' : 'yellow';
+ if(hdeg<170) return 'green';
+ if(hdeg<200) return 'teal';
+ if(hdeg<255) return 'blue';
+ if(hdeg<290) return 'purple';
+ return 'pink';
+ }
+ var PRODUCT_STYLE = (root.dataset.productStyle||'').trim();
+ /* The color-FAMILY collection handle for a hex (used both as the search space
+ for the nearest-specific-product resolver AND as the graceful fallback). */
+ function familyHandle(hex){
+ var fam=familyFromHex(hex); if(!fam) return null;
+ return FAM_COLL[fam] || null;
+ }
+ /* Fallback collection URL — only used if we can't resolve a single specific
+ product of that hue (edge case). (Steve 2026-07-09: DEFAULT must be a
+ specific product+variant, this is the graceful floor.) */
+ function familyHref(hex){
+ var handle=familyHandle(hex); if(!handle) return null;
+ var url='/collections/'+handle+'?sort_by=created-descending';
+ if(PRODUCT_STYLE){ url+='&filter.p.m.custom.style='+encodeURIComponent(PRODUCT_STYLE); }
+ return url;
+ }
+
+ /* ── hex → IN-PAGE INDEX of MANY products within 10% color tolerance ───────
+ Steve 2026-07-09b: "bring up an index of many images, not just that exact
+ hex. tolerance 10% for color to pull more." A dot is a palette-extracted
+ hue; clicking it opens the in-page grid (below the palette) of products
+ whose dominant color is within a perceptual 10% tolerance (CIELAB ΔE76) of
+ the clicked hex, drawn CATALOG-WIDE from the color-index endpoint. Results
+ are cached per hex so re-clicking a swatch is instant. */
+ var CI = root.querySelector('[data-dw-color-index]');
+ var CI_ENDPOINT = (CI && CI.dataset.endpoint) || 'https://photo.designerwallcoverings.com/apps/color-index';
+ var CI_K = 36; // cards to show (endpoint caps at 60)
+ var _ciCache = {}; // hex -> Promise<{results,total,tolerance_pct}>
+ function fetchColorIndex(hex){
+ if(_ciCache[hex]) return _ciCache[hex];
+ _ciCache[hex]=fetch(CI_ENDPOINT,{
+ method:'POST', mode:'cors', headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({hex:hex, k:CI_K})
+ }).then(function(r){ return r.ok?r.json():{results:[]}; })
+ .catch(function(){ return {results:[], _err:true}; });
+ return _ciCache[hex];
+ }
+ function ciEl(sel){ return CI ? CI.querySelector(sel) : null; }
+ function closeIndex(){ if(CI){ CI.classList.remove('is-open'); CI.hidden=true; } }
+ if(CI){
+ var _cx=ciEl('[data-dw-ci-close]');
+ if(_cx) _cx.addEventListener('click', closeIndex);
+ // Esc closes the drawer when it (or something inside it) is focused (a11y).
+ CI.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ closeIndex(); } });
+ }
+ /* Open the in-page index for a clicked hex. */
+ function openIndex(hex, name){
+ if(!CI) return;
+ var sw=ciEl('[data-dw-ci-swatch]'), lbl=ciEl('[data-dw-ci-label]'), grid=ciEl('[data-dw-ci-grid]');
+ var fam=familyFromHex(hex)||'';
+ if(sw) sw.style.background=hex;
+ if(lbl) lbl.innerHTML='Wallcoverings in this color — <b>'+(name||fam||hex)+'</b>';
+ if(grid) grid.innerHTML='<div class="dw-color-index__loading">Finding matches…</div>';
+ CI.hidden=false;
+ // retrigger the soft reveal even on a re-click (refine 2026-07-09)
+ CI.classList.remove('is-open'); void CI.offsetWidth; CI.classList.add('is-open');
+ CI.scrollIntoView({behavior:'smooth', block:'nearest'});
+ var _cl=ciEl('[data-dw-ci-close]'); if(_cl){ try{ _cl.focus({preventScroll:true}); }catch(e){} }
+ fetchColorIndex(hex).then(function(d){
+ if(!grid) return;
+ var res=(d&&d.results)||[];
+ if(!res.length){
+ grid.innerHTML='';
+ var empty=document.createElement('div'); empty.className='dw-color-index__empty';
+ empty.textContent = d && d._err
+ ? 'Could not load matches right now — try another color.'
+ : 'No close matches for this color yet.';
+ grid.appendChild(empty);
+ return;
+ }
+ grid.innerHTML='';
+ var frag=document.createDocumentFragment();
+ res.forEach(function(p){
+ if(!p || !p.handle) return;
+ var a=document.createElement('a'); a.className='dw-color-index__card'; a.rel='nofollow';
+ // link straight to the PDP handle — the roll/first-available variant,
+ // never the $4.25 Sample (Sample is never the default variant).
+ a.href='/products/'+encodeURIComponent(p.handle);
+ var img=document.createElement('img'); img.loading='lazy';
+ img.src=p.image||''; img.alt=p.title||'';
+ var cap=document.createElement('span'); cap.className='dw-color-index__cap';
+ cap.textContent=(p.title||'').replace(/\s*\|\s*.*$/,''); // pattern part
+ if(p.vendor){ var v=document.createElement('i'); v.textContent=p.vendor; cap.appendChild(v); }
+ a.appendChild(img); a.appendChild(cap); frag.appendChild(a);
+ });
+ grid.appendChild(frag);
+ });
+ }
+ /* Click handler shared by every swatch: open the in-page index for that hue.
+ Modified clicks (new tab) fall through to the plain family-collection href. */
+ function onSwatchClick(hex, ev, name){
+ ev.preventDefault();
+ openIndex(hex, name);
+ }
+
+ /* ── color math: normalize + interpolate ── */
+ function normHex(hex){
+ var rgb=hexToRgb(hex); if(!rgb) return null;
+ return '#'+rgb.map(function(x){return x.toString(16).padStart(2,'0');}).join('');
+ }
+ function mix(a,b,t){
+ var ca=hexToRgb(a),cb=hexToRgb(b); if(!ca||!cb) return a;
+ var r=Math.round(ca[0]+(cb[0]-ca[0])*t),g=Math.round(ca[1]+(cb[1]-ca[1])*t),bl=Math.round(ca[2]+(cb[2]-ca[2])*t);
+ return '#'+[r,g,bl].map(function(x){return x.toString(16).padStart(2,'0');}).join('');
+ }
+ /* ── perceptual ΔE (CIELAB, ΔE76) for VISUAL dedup of adjacent interpolated dots.
+ On a low-base, low-chroma pattern (e.g. an all-grey paisley) cranking the slider
+ high produces long runs of visually-identical adjacent swatches — meaningless
+ and it cheapens the curated feel. We skip an INTERPOLATED dot when it is within
+ ΔE < DEDUP_DE of the previously-placed swatch. Base (real) colors are NEVER
+ deduped — they're the pattern's actual colors. (refine 2026-07-09) */
+ function _rgb2lab(rgb){
+ var r=rgb[0]/255,g=rgb[1]/255,b=rgb[2]/255;
+ r=r>0.04045?Math.pow((r+0.055)/1.055,2.4):r/12.92;
+ g=g>0.04045?Math.pow((g+0.055)/1.055,2.4):g/12.92;
+ b=b>0.04045?Math.pow((b+0.055)/1.055,2.4):b/12.92;
+ var x=(r*0.4124+g*0.3576+b*0.1805)/0.95047,
+ y=(r*0.2126+g*0.7152+b*0.0722)/1.0,
+ z=(r*0.0193+g*0.1192+b*0.9505)/1.08883;
+ var f=function(t){return t>0.008856?Math.pow(t,1/3):(7.787*t)+16/116;};
+ x=f(x);y=f(y);z=f(z);
+ return [(116*y)-16, 500*(x-y), 200*(y-z)];
+ }
+ function deltaE(hexA,hexB){
+ var a=hexToRgb(hexA),b=hexToRgb(hexB); if(!a||!b) return 999;
+ var la=_rgb2lab(a),lb=_rgb2lab(b);
+ return Math.sqrt(Math.pow(la[0]-lb[0],2)+Math.pow(la[1]-lb[1],2)+Math.pow(la[2]-lb[2],2));
+ }
+ var DEDUP_DE = 2.3; // ~1 JND: below this, two swatches are indistinguishable
+ /* TOTAL rendered dots are driven by a user DENSITY SLIDER (Steve 2026-07-09d:
+ "allow the user to grid slide the amount of dots that they can see..
+ expand.. contract"). The slider value is the target TOTAL dot count. We keep
+ EVERY real base color, then spend whatever budget is left on interpolated
+ in-between dots, distributed EVENLY across the gaps (never truncating one end
+ of the spectrum). See buildSteps. The former hard cap of 20 is now the slider
+ DEFAULT; the range is MIN_DOTS..MAX_DOTS, persisted to localStorage. */
+ var MIN_DOTS = 6, MAX_DOTS = 64, DEFAULT_DOTS = 20; /* slider bounds + default */
+ var LS_KEY = 'dw_palette_dot_count'; /* palette-specific persistence key */
+ function readDotCount(){
+ var v = DEFAULT_DOTS;
+ try { var s = localStorage.getItem(LS_KEY); if(s!=null){ var n=parseInt(s,10); if(!isNaN(n)) v=n; } } catch(e){}
+ if(v<MIN_DOTS) v=MIN_DOTS; if(v>MAX_DOTS) v=MAX_DOTS;
+ return v;
+ }
+ function writeDotCount(v){ try { localStorage.setItem(LS_KEY, String(v)); } catch(e){} }
+ var dotTarget = readDotCount(); /* current TOTAL-dot target (base + interpolated) */
+
+ /* Given the base-color count, return the number of interpolated dots to place
+ in EACH of the (n-1) gaps, so that base + sum(steps) <= `total` and the
+ leftover is spread across the earliest gaps (keeps the spectrum balanced,
+ left-to-right, rather than starving the tail). `total` = current slider value. */
+ function buildSteps(n, total){
+ var gaps = n - 1;
+ if(gaps <= 0) return [];
+ var budget = total - n; // interpolated dots we can afford
+ if(budget < 0) budget = 0;
+ var per = Math.floor(budget / gaps);
+ var extra = budget - per * gaps; // spread +1 across the first `extra` gaps
+ var out = [];
+ for(var g=0; g<gaps; g++){ out.push(per + (g < extra ? 1 : 0)); }
+ return out;
+ }
+
+ /* Build one swatch. Left-click opens the IN-PAGE index of many products
+ within 10% of that hue (onSwatchClick → openIndex); the href is the family
+ collection so middle-click / new-tab / crawlers still get a valid
+ destination. (Steve 2026-07-09b: "bring up an index of many images.") */
+ function makeSwatch(hex, opts){
+ opts=opts||{};
+ var href=familyHref(hex);
+ var el=document.createElement(href?'a':'span');
+ el.className='dw-swatch'+(opts.base?' dw-swatch--base':'');
+ el.style.background=hex;
+ if(href){
+ el.href=href;
+ el.addEventListener('click', (function(nm){ return function(ev){
+ // let modified clicks (new tab / new window) use the plain-collection href
+ if(ev.metaKey||ev.ctrlKey||ev.shiftKey||ev.altKey||ev.button!==0) return;
+ onSwatchClick(hex, ev, nm);
+ }; })(opts.name));
+ }
+ var fam=familyFromHex(hex);
+ if(opts.base){
+ var lbl=(opts.name?opts.name:(fam||''));
+ el.title='Shop this '+(fam||'')+' color'+(lbl&&lbl!==fam?' — '+lbl:'')+' ('+hex+(opts.pct!=null?', '+opts.pct+'% here':'')+')';
+ } else {
+ el.title='Shop this '+(fam||'')+' color ('+hex+')';
+ }
+ return el;
+ }
+
+ /* Resolved base-color state, cached once so the density slider can re-render
+ the spectrum WITHOUT re-fetching / re-extracting the product image. */
+ var _baseList = null; // [{hex,name,pct}] real base colors (deduped, sorted)
+ var _hasPct = false;
+ var _legEl = null, _barEl = null; // legend + proportion-bar elements (built once)
+
+ /* Rebuild ONLY the dot spectrum (cards) from the cached base list at the current
+ `dotTarget`. Called on first render and on every slider change. Every real base
+ color is kept; interpolated dots fill the remaining budget, evenly across gaps.
+ Legend + proportion bar reflect the real base colors and are built once (they
+ don't change with the dot count). */
+ function paintSpectrum(){
+ if(!_baseList || !_baseList.length) return;
+ var list = _baseList;
+ var stepsPerGap = buildSteps(list.length, dotTarget);
+ var frag=document.createDocumentFragment();
+ var _lastHex=null; // last hex actually appended, for adjacent visual dedup
+ for(var i=0;i<list.length;i++){
+ // BASE colors are always shown (real pattern colors, never deduped).
+ frag.appendChild(makeSwatch(list[i].hex,{base:true,name:list[i].name,pct:list[i].pct}));
+ _lastHex=list[i].hex;
+ if(i<list.length-1){
+ var steps = stepsPerGap[i] || 0;
+ for(var k=1;k<=steps;k++){
+ var t=k/(steps+1);
+ var ih=mix(list[i].hex,list[i+1].hex,t);
+ // skip an interpolated dot that's visually identical to the previous one
+ // (dedup near-duplicates on low-chroma / low-base palettes). We still
+ // guarantee the NEXT base color renders because bases bypass this check.
+ if(_lastHex && deltaE(ih,_lastHex) < DEDUP_DE) continue;
+ frag.appendChild(makeSwatch(ih,{base:false}));
+ _lastHex=ih;
+ }
+ }
+ }
+ cards.innerHTML=''; // clear prior dots (re-render), keep legend/bar intact
+ cards.appendChild(frag);
+ }
+
+ /* Wire the density slider once. Clamps the slider max to the number of dots that
+ are actually meaningful for this palette's base count (a 2-base palette can't
+ usefully show 64 distinct dots — but we keep at least the base count visible).
+ Persists to localStorage; live re-renders on input. */
+ var _paintTimer = null; // debounce handle for paintSpectrum (closure var)
+ var PAINT_DEBOUNCE_MS = 80; // ~80ms: coalesces continuous drag ticks into one re-render
+ function wireDensity(){
+ var wrap = root.querySelector('[data-dw-palette-density]');
+ if(!wrap) return;
+ var range = wrap.querySelector('[data-dw-cp-range]');
+ var out = wrap.querySelector('[data-dw-cp-out]');
+ var minus = wrap.querySelector('[data-dw-cp-minus]');
+ var plus = wrap.querySelector('[data-dw-cp-plus]');
+ if(!range) return;
+ // A single base color has nothing to interpolate — there's nothing to slide.
+ // Don't silently vanish the control (that leaves the user wondering); keep it
+ // VISIBLE but DISABLED with a hint (Steve 2026-07-09 fix #2). The slider stays
+ // pinned at the base count, and a title/aria hint explains why.
+ if(_baseList.length < 2){
+ wrap.hidden = false;
+ wrap.classList.add('is-single');
+ var hint = 'Only one dominant color in this pattern — nothing to expand';
+ wrap.setAttribute('title', hint);
+ range.disabled = true;
+ range.setAttribute('aria-label', hint);
+ var single = Math.max(MIN_DOTS, _baseList.length);
+ range.min = String(single); range.max = String(single); range.value = String(single);
+ if(out) out.textContent = String(_baseList.length);
+ if(minus) minus.disabled = true;
+ if(plus) plus.disabled = true;
+ return;
+ }
+ // Multi-color path: REVEAL the control (refine 2026-07-09 — critical live bug:
+ // the density slider markup ships with the `hidden` attr and this branch never
+ // cleared it, so the whole expand/contract control was invisible on every
+ // normal >1-color product). Unhide it here.
+ wrap.hidden = false;
+ wrap.classList.remove('is-single');
+ wrap.removeAttribute('title');
+ range.disabled = false;
+ if(minus) minus.disabled = false;
+ if(plus) plus.disabled = false;
+ // clamp current target into [MIN_DOTS, MAX_DOTS]
+ if(dotTarget < MIN_DOTS) dotTarget = MIN_DOTS;
+ if(dotTarget > MAX_DOTS) dotTarget = MAX_DOTS;
+ range.min = String(MIN_DOTS); range.max = String(MAX_DOTS);
+ range.value = String(dotTarget);
+ range.setAttribute('aria-label', 'Number of colors shown in the palette');
+ if(out) out.textContent = String(dotTarget);
+ // apply(v, persist, immediate):
+ // - the numeric output + slider position update IMMEDIATELY (snappy feedback);
+ // - the expensive paintSpectrum() DOM re-render is DEBOUNCED ~80ms during drag
+ // so a continuous drag doesn't thrash the whole dot spectrum every tick.
+ // - −/+ buttons and drag-release pass immediate=true (one discrete change → paint now).
+ function apply(v, persist, immediate){
+ v = parseInt(v,10); if(isNaN(v)) v = DEFAULT_DOTS;
+ if(v < MIN_DOTS) v = MIN_DOTS; if(v > MAX_DOTS) v = MAX_DOTS;
+ dotTarget = v;
+ range.value = String(v);
+ if(out) out.textContent = String(v); // IMMEDIATE — always snappy
+ if(persist) writeDotCount(v);
+ if(immediate){
+ if(_paintTimer){ clearTimeout(_paintTimer); _paintTimer = null; }
+ paintSpectrum();
+ } else {
+ if(_paintTimer) clearTimeout(_paintTimer);
+ _paintTimer = setTimeout(function(){ _paintTimer = null; paintSpectrum(); }, PAINT_DEBOUNCE_MS);
+ }
+ }
+ range.addEventListener('input', function(){ apply(range.value, false, false); }); // drag → debounced paint
+ range.addEventListener('change', function(){ apply(range.value, true, true); }); // release → persist + paint now
+ if(minus) minus.addEventListener('click', function(){ apply(dotTarget-1, true, true); });
+ if(plus) plus.addEventListener('click', function(){ apply(dotTarget+1, true, true); });
+ }
+
+ function render(colors){
+ if(!colors || !colors.length) return false;
+ /* de-dupe by hex/name, keep order; resolve each to a real hex */
+ var seen={}, list=[];
+ colors.forEach(function(c){
+ if(!c) return;
+ var hex=normHex(c.hex) || normHex(N[(c.name||'').toLowerCase()]);
+ if(!hex) return;
+ if(seen[hex]) return; seen[hex]=1;
+ list.push({hex:hex, name:c.name||'', pct:pctOf(c)});
+ });
+ if(!list.length) return false;
+ var hasPct = list.some(function(c){return c.pct!=null;});
+ if(hasPct) list.sort(function(a,b){return (b.pct||0)-(a.pct||0);});
+ /* Keep the real base colors (still capped at ≤8 dominant hues — bases are the
+ pattern's actual colors; interpolation between them fills the slider budget). */
+ list = list.slice(0, 8);
+
+ /* Cache the resolved base list + wire the slider, then paint the spectrum at
+ the current (persisted) dot target. Slider re-renders call paintSpectrum
+ directly — no image re-extraction. (Steve 2026-07-09d) */
+ _baseList = list; _hasPct = hasPct;
+ wireDensity();
+ paintSpectrum();
+
+ /* named legend for the real colors (so names/% stay available, rows stay even).
+ Built ONCE — independent of the dot count. */
+ var named=list.filter(function(c){return c.name;});
+ if(named.length){
+ var leg=document.createElement('div'); leg.className='dw-color-palette__legend';
+ named.forEach(function(c){
+ var href=familyHref(c.hex);
+ var it=document.createElement(href?'a':'span');
+ if(href){
+ it.href=href;
+ (function(hx, nm){ it.addEventListener('click', function(ev){
+ if(ev.metaKey||ev.ctrlKey||ev.shiftKey||ev.altKey||ev.button!==0) return;
+ onSwatchClick(hx, ev, nm);
+ }); })(c.hex, c.name);
+ }
+ it.innerHTML='<i style="background:'+c.hex+'"></i>'+c.name+(c.pct!=null?' <b style="font-weight:600;color:#8a8577">'+c.pct+'%</b>':'');
+ leg.appendChild(it);
+ });
+ root.appendChild(leg); _legEl=leg;
+ }
+
+ /* proportion bar when we have percentages (reflects the real base colors). */
+ if(hasPct){
+ var bar=document.createElement('div'); bar.className='dw-color-palette__bar';
+ var tot=list.reduce(function(s,c){return s+(c.pct||0);},0)||1;
+ list.forEach(function(c){ var sp=document.createElement('span'); sp.style.background=c.hex; sp.style.flex=(c.pct||0)/tot; bar.appendChild(sp); });
+ root.appendChild(bar); _barEl=bar;
+ }
+ return true;
+ }
+
+ /* Metafield fallbacks — used ONLY if canvas extraction yields nothing. */
+ function renderMeta(){
+ var ph=root.dataset.primaryHex, aiRaw=root.dataset.aiColors, sh=root.dataset.colorHex, list2=[];
+ if(ph) list2.push({hex:ph, name:root.dataset.colorName||'Primary'});
+ try { JSON.parse((aiRaw||'[]').replace(/=>/g,':')).forEach(function(nm){ var h=N[(''+nm).toLowerCase()]; if(h) list2.push({hex:h,name:nm}); }); } catch(e){}
+ if(list2.length && render(list2)) return true;
+ if(sh) return render([{hex:sh, name:root.dataset.colorName||'Color'}]);
+ return false;
+ }
+
+ /* ── Tier 1: color_details / color_dots_json metafield (BEST — local-vision enriched) ── */
+ var raw = root.dataset.colors;
+ if(raw){
+ try { if(render(JSON.parse(raw.replace(/=>/g,':')))) return; } catch(e){}
+ }
+
+ /* ── Tier 2: Canvas extraction from the product image (multi-color + %), meta as fallback ── */
+ var REF={'red':[190,55,55],'crimson':[176,40,60],'navy':[30,45,80],'blue':[70,110,175],'slate blue':[95,120,150],'powder blue':[170,196,214],'green':[70,130,80],'sage':[150,168,138],'celadon':[176,192,168],'eucalyptus':[130,146,128],'moss':[120,138,90],'olive':[124,124,70],'gold':[200,168,90],'ochre':[190,150,80],'yellow':[224,186,90],'orange':[220,120,70],'terracotta':[196,110,85],'coral':[224,140,120],'pink':[220,150,165],'blush':[224,190,190],'purple':[140,100,160],'lavender':[190,180,205],'black':[40,40,40],'charcoal':[70,74,78],'white':[248,246,240],'ivory':[240,236,224],'cream':[236,228,206],'eggshell':[224,216,196],'gray':[140,140,142],'greige':[176,168,154],'silver':[196,196,198],'brown':[130,95,70],'walnut':[110,80,55],'taupe':[150,138,120],'beige':[214,202,178],'tan':[196,176,140],'sand':[200,184,150],'stone':[186,180,166],'teal':[70,140,140],'turquoise':[110,180,175]};
+ function closestName(r,g,b){var best='',bd=1e9;for(var n in REF){var c=REF[n],d=Math.pow(r-c[0],2)+Math.pow(g-c[1],2)+Math.pow(b-c[2],2);if(d<bd){bd=d;best=n;}}return best;}
+ function hx(r,g,b){return '#'+[r,g,b].map(function(x){return x.toString(16).padStart(2,'0');}).join('');}
+ function fromCanvas(url){
+ var img=new Image(); img.crossOrigin='anonymous';
+ img.onload=function(){
+ try{
+ var sz=120,cv=document.createElement('canvas');cv.width=sz;cv.height=sz;
+ var ctx=cv.getContext('2d');ctx.drawImage(img,0,0,sz,sz);
+ var px=ctx.getImageData(0,0,sz,sz).data,bk={};
+ for(var i=0;i<px.length;i+=4){var kr=Math.round(px[i]/22)*22,kg=Math.round(px[i+1]/22)*22,kb=Math.round(px[i+2]/22)*22,k=kr+','+kg+','+kb;if(!bk[k])bk[k]={r:0,g:0,b:0,n:0};bk[k].r+=px[i];bk[k].g+=px[i+1];bk[k].b+=px[i+2];bk[k].n++;}
+ var sorted=Object.keys(bk).map(function(k){return bk[k];}).sort(function(a,b){return b.n-a.n;});
+ var tot=sz*sz,out=[],seen={};
+ for(var j=0;j<sorted.length && out.length<8;j++){var s=sorted[j],ar=Math.round(s.r/s.n),ag=Math.round(s.g/s.n),ab=Math.round(s.b/s.n),nm=closestName(ar,ag,ab),pct=Math.round(s.n/tot*100);if(seen[nm]||pct<2)continue;seen[nm]=1;out.push({name:nm,hex:hx(ar,ag,ab),pct:pct});}
+ if(!render(out.slice(0,6))) renderMeta();
+ }catch(e){ renderMeta(); }
+ };
+ img.onerror=function(){ renderMeta(); };
+ img.src=url;
+ }
+ function imgUrl(){
+ var u=root.dataset.productImage;
+ if(u&&u.length>5) return u.indexOf('//')===0?'https:'+u:u;
+ var sels=['.product-gallery--viewport img[src]','.product-gallery img[src]','[data-product-gallery] img[src]','.media-gallery__slide img[src]'];
+ for(var i=0;i<sels.length;i++){var el=document.querySelector(sels[i]);if(el&&el.src)return el.src;}
+ return null;
+ }
+ var u=imgUrl();
+ if(u){ fromCanvas(u); }
+ else {
+ var obs=new MutationObserver(function(m,o){var v=imgUrl();if(v){o.disconnect();fromCanvas(v);}});
+ obs.observe(document.body,{childList:true,subtree:true});
+ setTimeout(function(){ obs.disconnect(); if(!cards.children.length) renderMeta(); },5000);
+ }
+ }
+ if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',init);}else{init();}
+})();
+</script>
+
+</body></html>
\ No newline at end of file
diff --git a/scripts/wallquest-refresh/cp-refine-shots/after-01-rest.png b/scripts/wallquest-refresh/cp-refine-shots/after-01-rest.png
new file mode 100644
index 00000000..519e58a0
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/after-01-rest.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/after-02-low.png b/scripts/wallquest-refresh/cp-refine-shots/after-02-low.png
new file mode 100644
index 00000000..dec09751
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/after-02-low.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/after-03-high.png b/scripts/wallquest-refresh/cp-refine-shots/after-03-high.png
new file mode 100644
index 00000000..7f298057
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/after-03-high.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/after-04-index-open.png b/scripts/wallquest-refresh/cp-refine-shots/after-04-index-open.png
new file mode 100644
index 00000000..2a3351af
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/after-04-index-open.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/cmp-high.png b/scripts/wallquest-refresh/cp-refine-shots/cmp-high.png
new file mode 100644
index 00000000..244f9e01
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/cmp-high.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/cmp-rest.png b/scripts/wallquest-refresh/cp-refine-shots/cmp-rest.png
new file mode 100644
index 00000000..ba7d37d7
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/cmp-rest.png differ
← 7fa268bf Refine PDP color-palette: fix invisible density slider, dedu
·
back to Designer Wallcoverings
·
Add roll-on-fabric detector (bucket1 genuine-fabric-on-roll 0f3780cd →