← back to Dw Theme Hamburger
snippets/color-palette.liquid
698 lines
{% comment %}
color-palette — "Colors In This Pattern" swatch spectrum.
Shows the pattern's real colors AND interpolated in-between colors, laid out as
uniform swatches centered in even rows. The 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"): every real base color is kept,
and the remaining budget of interpolated dots is spread EVENLY across the gaps
(never truncating one end of the spectrum). The slider runs MIN_DOTS(6) →
MAX_DOTS(64) with DEFAULT 20 (the former hard cap became the default value),
re-renders live on drag with NO page reload, and persists to localStorage under
'dw_palette_dot_count' (a palette-specific key, distinct from the product-grid
density slider). (was Steve 2026-07-09c: a fixed 20-dot cap, MAX_SWATCHES=20.)
FIX PASS (Steve 2026-07-09, contrarian FIX-FIRST): (1) the slider 'input' handler
DEBOUNCES the expensive paintSpectrum() re-render ~80ms so a continuous drag doesn't
thrash the whole dot spectrum every tick — the numeric "Colors shown" output and the
minus/plus buttons still update IMMEDIATELY for snappy feedback; persistence stays on
'change' (drag-release). (2) When a pattern has only ONE dominant base color there is
genuinely nothing to interpolate, so instead of silently HIDING the control (a dead
end that leaves the user wondering), it stays VISIBLE but DISABLED with a title/aria
hint ("Only one dominant color in this pattern").
DOT CLICK TARGET (Steve 2026-07-09b): "bring up an index of many images, not
just that exact hex. tolerance 10% for color to pull more." A swatch click now
opens an IN-PAGE expanding grid of MANY products whose dominant color is within
a perceptual 10% tolerance (CIELAB ΔE76) of the clicked hex — CATALOG-WIDE, not
one collection. The grid is served by the color-index endpoint
POST https://photo.designerwallcoverings.com/apps/color-index {hex, k}
(dwphoto → data/color-index.json, one dominant color per ACTIVE product with
precomputed LAB). Cards link to /products/<handle> (the roll variant, never the
$4.25 Sample). The family-collection href stays as the plain href so
middle-click / new-tab / crawlers still get a valid destination, and is the
graceful fallback if the endpoint is unreachable.
(was: Steve 2026-07-09a, a click deep-linked to the ONE nearest product+variant.)
Data tiers (mirrors color-dots.liquid, kept in sync):
Tier 1 custom.color_dots_json / custom.color_details (enriched JSON: [{hex,name,pct}])
Tier 2 custom.primary_color_hex + dwc.ai_generated_colors (name list → hex map)
Tier 3 custom.color_hex (single)
Tier 4 Canvas pixel extraction from the product image (always yields real % — reliable fallback)
{% endcomment %}
{% comment %} Read the authoritative enriched field FIRST (color_details); legacy
color_dots_json is only a fallback so it can never shadow fresh enrichment. {% endcomment %}
{% assign color_dots_json_val = product.metafields.custom.color_dots_json.value %}
{% assign color_details_val = product.metafields.custom.color_details.value %}
{% if color_details_val != blank %}
{% assign color_details_raw = color_details_val | json %}
{% elsif color_dots_json_val != blank %}
{% assign color_details_raw = color_dots_json_val | json %}
{% else %}
{% assign color_details_raw = blank %}
{% endif %}
{% assign color_hex = product.metafields.custom.color_hex.value %}
{% assign primary_hex = product.metafields.custom.primary_color_hex.value %}
{% assign ai_colors_raw = product.metafields.dwc.ai_generated_colors.value | json %}
{% assign color_name = product.metafields.custom.color_name.value %}
<div class="dw-color-palette" data-dw-color-palette
{% if color_details_raw != blank and color_details_raw != 'null' %}data-colors='{{ color_details_raw | escape }}'{% endif %}
{% if primary_hex != blank %}data-primary-hex="{{ primary_hex }}"{% endif %}
{% if ai_colors_raw != blank and ai_colors_raw != 'null' %}data-ai-colors='{{ ai_colors_raw }}'{% endif %}
{% if color_name != blank %}data-color-name="{{ color_name }}"{% endif %}
{% if color_hex != blank %}data-color-hex="{{ color_hex }}"{% endif %}
{% if product.featured_image %}data-product-image="{{ product.featured_image | img_url: '400x400' }}"{% endif %}
data-product-style="{{ product.metafields.custom.style.value | default: product.type | escape }}"
>
<p class="dw-color-palette__title">Colors In This Pattern</p>
{%- comment -%} DENSITY SLIDER (Steve 2026-07-09d): expand/contract the number of
dots shown. Range MIN_DOTS(6) → MAX_DOTS(64), default 20; live re-render on drag,
value persisted to localStorage 'dw_palette_dot_count'. Hidden until the palette
actually has base colors to interpolate (JS unhides it). {%- endcomment -%}
<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>
{%- comment -%} In-page color-index grid the dot brings up (Steve 2026-07-09b).
Hidden until a swatch is clicked; then filled with the catalog-wide products
within 10% (CIELAB ΔE76) of the clicked hex. {%- endcomment -%}
<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();
// If every card's image fails to load, fall back to the empty state so the
// drawer never sits with a bare/blank grid.
function _ciEmptyIfBare(){
if(grid && !grid.querySelector('.dw-color-index__card')){
grid.innerHTML='';
var e=document.createElement('div'); e.className='dw-color-index__empty';
e.textContent='No close matches for this color yet.';
grid.appendChild(e);
}
}
res.forEach(function(p){
if(!p || !p.handle || !p.image) return; // no handle or no image → never render a card
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.decoding='async';
// Belt to the build-time active+image filter: if the URL is dead (stale CDN /
// archived product that slipped through), drop the WHOLE card so a broken-image
// icon never renders to a shopper.
img.onerror=function(){ a.remove(); _ciEmptyIfBare(); };
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>
{%- comment -%} "More Wallcoverings Like This" (the #dw-similar CLIP row) moved 2026-07-13
out of the palette column to the PDP bottom — now rendered by sections/product.liquid
via {% render 'more-wallcoverings-like-this' %}, directly above Recently Viewed. {%- endcomment -%}