[object Object]

← back to Designer Wallcoverings

fix(sync): use global fetch in sync-shopify-products — daily mirror sync dead since 07-07

5718e91a713adc85bd1d6b30b9e0865cc4eca9b1 · 2026-07-09 15:55:22 -0700 · Steve

node-fetch v3 is ESM-only; require() on Node 26 returned a namespace object, not a
function → 'fetch is not a function' crashed the 6am launchd sync every day from
2026-07-07, freezing the local dw_unified.shopify_products mirror at 07-06. Internal
viewers (new.designerwallcoverings.com etc.) read the mirror, so they showed pre-fix
'Wallcoverings' titles while LIVE Shopify was already correct. Node 18+ has global fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 5718e91a713adc85bd1d6b30b9e0865cc4eca9b1
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 15:55:22 2026 -0700

    fix(sync): use global fetch in sync-shopify-products — daily mirror sync dead since 07-07
    
    node-fetch v3 is ESM-only; require() on Node 26 returned a namespace object, not a
    function → 'fetch is not a function' crashed the 6am launchd sync every day from
    2026-07-07, freezing the local dw_unified.shopify_products mirror at 07-06. Internal
    viewers (new.designerwallcoverings.com etc.) read the mirror, so they showed pre-fix
    'Wallcoverings' titles while LIVE Shopify was already correct. Node 18+ has global fetch.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/wallquest-refresh/cp-index-clip.cjs        |  15 +
 scripts/wallquest-refresh/cp-refine-capture.cjs    | 116 ++++
 .../cp-refine-shots/before-01-rest.png             | Bin 0 -> 21691 bytes
 .../cp-refine-shots/before-02-low.png              | Bin 0 -> 19097 bytes
 .../cp-refine-shots/before-03-high.png             | Bin 0 -> 29386 bytes
 .../cp-refine-shots/before-04-index-open.png       | Bin 0 -> 4322886 bytes
 .../cp-refine-shots/before-04b-index-only.png      | Bin 0 -> 3448249 bytes
 shopify/scripts/sync-shopify-products.js           |   6 +-
 .../snippets/color-palette.liquid                  | 675 +++++++++++++++++++++
 shopify/verify-density-slider-live.mjs             | 129 ++++
 10 files changed, 940 insertions(+), 1 deletion(-)

diff --git a/scripts/wallquest-refresh/cp-index-clip.cjs b/scripts/wallquest-refresh/cp-index-clip.cjs
new file mode 100644
index 00000000..2267c589
--- /dev/null
+++ b/scripts/wallquest-refresh/cp-index-clip.cjs
@@ -0,0 +1,15 @@
+const puppeteer = require('/Users/macstudio3/Projects/Designer-Wallcoverings/scripts/wallquest-refresh/node_modules/puppeteer');
+const OUT='/Users/macstudio3/Projects/Designer-Wallcoverings/scripts/wallquest-refresh/cp-refine-shots';
+(async()=>{
+  const b=await puppeteer.launch({headless:'new',args:['--no-sandbox']});
+  const p=await b.newPage();
+  await p.setViewport({width:820,height:1400,deviceScaleFactor:2});
+  await p.goto('https://www.designerwallcoverings.com/products/crayford-paisley-porcelain',{waitUntil:'networkidle2',timeout:60000});
+  await p.waitForSelector('[data-dw-color-palette]',{timeout:20000}).catch(()=>{});
+  await new Promise(r=>setTimeout(r,4500));
+  await p.evaluate(()=>{const b=document.querySelector('[data-dw-color-palette-cards] .dw-swatch--base');if(b)b.click();});
+  await new Promise(r=>setTimeout(r,4000));
+  const ci=await p.$('[data-dw-color-index]');
+  if(ci){await p.evaluate(()=>document.querySelector('[data-dw-color-index]').scrollIntoView({block:'start'}));await new Promise(r=>setTimeout(r,500));await ci.screenshot({path:OUT+'/before-04b-index-only.png'});}
+  await b.close();console.log('done');
+})();
diff --git a/scripts/wallquest-refresh/cp-refine-capture.cjs b/scripts/wallquest-refresh/cp-refine-capture.cjs
new file mode 100644
index 00000000..6b9715fe
--- /dev/null
+++ b/scripts/wallquest-refresh/cp-refine-capture.cjs
@@ -0,0 +1,116 @@
+// Headless capture of the live "Colors In This Pattern" palette + slider + color-index grid.
+// $0 local — uses the shopify dir's puppeteer.
+const path = require('path');
+const puppeteer = require('/Users/macstudio3/Projects/Designer-Wallcoverings/scripts/wallquest-refresh/node_modules/puppeteer');
+
+const URL = process.argv[2] || 'https://www.designerwallcoverings.com/products/crayford-paisley-porcelain';
+const OUT = process.argv[3] || '/Users/macstudio3/Projects/Designer-Wallcoverings/scripts/wallquest-refresh/cp-refine-shots';
+const TAG = process.argv[4] || 'before';
+
+(async () => {
+  const browser = await puppeteer.launch({
+    headless: 'new',
+    args: ['--no-sandbox', '--disable-setuid-sandbox', '--window-size=1200,2400'],
+  });
+  const page = await browser.newPage();
+  await page.setViewport({ width: 1200, height: 2000, deviceScaleFactor: 2 });
+  page.on('console', m => { const t = m.text(); if (/error|fail/i.test(t)) console.log('  [pageconsole]', t.slice(0,160)); });
+
+  console.log('navigating', URL);
+  await page.goto(URL, { waitUntil: 'networkidle2', timeout: 60000 });
+
+  // Wait for palette to render its dots
+  await page.waitForSelector('[data-dw-color-palette]', { timeout: 20000 }).catch(()=>{});
+  // give canvas extraction + slider wire-up time
+  await new Promise(r => setTimeout(r, 4500));
+
+  const palSel = '[data-dw-color-palette]';
+  const el = await page.$(palSel);
+  if (!el) { console.log('NO PALETTE FOUND'); await browser.close(); process.exit(2); }
+
+  // scroll palette into view
+  await page.evaluate(s => document.querySelector(s).scrollIntoView({block:'center'}), palSel);
+  await new Promise(r => setTimeout(r, 600));
+
+  // Report the state
+  const state = await page.evaluate(() => {
+    const root = document.querySelector('[data-dw-color-palette]');
+    const cards = root.querySelector('[data-dw-color-palette-cards]');
+    const range = root.querySelector('[data-dw-cp-range]');
+    const dens = root.querySelector('[data-dw-palette-density]');
+    return {
+      dotCount: cards ? cards.children.length : 0,
+      baseDots: cards ? cards.querySelectorAll('.dw-swatch--base').length : 0,
+      sliderVal: range ? range.value : null,
+      sliderMin: range ? range.min : null,
+      sliderMax: range ? range.max : null,
+      densityHidden: dens ? dens.hasAttribute('hidden') : null,
+      densitySingle: dens ? dens.classList.contains('is-single') : null,
+    };
+  });
+  console.log('state @rest:', JSON.stringify(state));
+
+  // (i) palette + slider at rest
+  await el.screenshot({ path: path.join(OUT, `${TAG}-01-rest.png`) });
+
+  // (ii-low) drag slider to low (min)
+  await page.evaluate(() => {
+    const r = document.querySelector('[data-dw-cp-range]');
+    if (r && !r.disabled) {
+      r.value = r.min;
+      r.dispatchEvent(new Event('input', {bubbles:true}));
+      r.dispatchEvent(new Event('change', {bubbles:true}));
+    }
+  });
+  await new Promise(r => setTimeout(r, 400));
+  await el.screenshot({ path: path.join(OUT, `${TAG}-02-low.png`) });
+  const lowN = await page.evaluate(() => document.querySelector('[data-dw-color-palette-cards]').children.length);
+
+  // (ii-high) drag slider to high (max)
+  await page.evaluate(() => {
+    const r = document.querySelector('[data-dw-cp-range]');
+    if (r && !r.disabled) {
+      r.value = r.max;
+      r.dispatchEvent(new Event('input', {bubbles:true}));
+      r.dispatchEvent(new Event('change', {bubbles:true}));
+    }
+  });
+  await new Promise(r => setTimeout(r, 400));
+  await el.screenshot({ path: path.join(OUT, `${TAG}-03-high.png`) });
+  const highN = await page.evaluate(() => document.querySelector('[data-dw-color-palette-cards]').children.length);
+  console.log('low dots:', lowN, 'high dots:', highN);
+
+  // (iii) click a base dot → color-index grid opens
+  const clicked = await page.evaluate(() => {
+    const b = document.querySelector('[data-dw-color-palette-cards] .dw-swatch--base') ||
+              document.querySelector('[data-dw-color-palette-cards] .dw-swatch');
+    if (b) { b.click(); return true; }
+    return false;
+  });
+  console.log('clicked base dot:', clicked);
+  // wait for the index grid to fill from the endpoint
+  await new Promise(r => setTimeout(r, 4000));
+  const ci = await page.evaluate(() => {
+    const c = document.querySelector('[data-dw-color-index]');
+    if (!c) return { present:false };
+    const grid = c.querySelector('[data-dw-ci-grid]');
+    return {
+      present: true,
+      hidden: c.hasAttribute('hidden'),
+      cards: grid ? grid.querySelectorAll('.dw-color-index__card').length : 0,
+      loading: grid ? !!grid.querySelector('.dw-color-index__loading') : false,
+      empty: grid ? !!grid.querySelector('.dw-color-index__empty') : false,
+    };
+  });
+  console.log('color-index state:', JSON.stringify(ci));
+  // screenshot the palette + index region (bigger clip)
+  const palBox = await page.evaluate(() => {
+    const c = document.querySelector('[data-dw-color-palette]');
+    const r = c.getBoundingClientRect();
+    return { x: Math.max(0, r.left), y: Math.max(0, window.scrollY + r.top), w: r.width, h: r.height };
+  });
+  await el.screenshot({ path: path.join(OUT, `${TAG}-04-index-open.png`) });
+
+  await browser.close();
+  console.log('DONE — shots in', OUT);
+})().catch(e => { console.error('CAPTURE ERR', e.message); process.exit(1); });
diff --git a/scripts/wallquest-refresh/cp-refine-shots/before-01-rest.png b/scripts/wallquest-refresh/cp-refine-shots/before-01-rest.png
new file mode 100644
index 00000000..3677b42f
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/before-01-rest.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/before-02-low.png b/scripts/wallquest-refresh/cp-refine-shots/before-02-low.png
new file mode 100644
index 00000000..60d99068
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/before-02-low.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/before-03-high.png b/scripts/wallquest-refresh/cp-refine-shots/before-03-high.png
new file mode 100644
index 00000000..3427d412
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/before-03-high.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/before-04-index-open.png b/scripts/wallquest-refresh/cp-refine-shots/before-04-index-open.png
new file mode 100644
index 00000000..8ae71ebe
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/before-04-index-open.png differ
diff --git a/scripts/wallquest-refresh/cp-refine-shots/before-04b-index-only.png b/scripts/wallquest-refresh/cp-refine-shots/before-04b-index-only.png
new file mode 100644
index 00000000..0b9f06a5
Binary files /dev/null and b/scripts/wallquest-refresh/cp-refine-shots/before-04b-index-only.png differ
diff --git a/shopify/scripts/sync-shopify-products.js b/shopify/scripts/sync-shopify-products.js
index d72a8389..a09a95c8 100644
--- a/shopify/scripts/sync-shopify-products.js
+++ b/shopify/scripts/sync-shopify-products.js
@@ -14,7 +14,11 @@
  */
 
 const { Pool } = require('pg');
-const fetch = require('node-fetch');
+// Node 18+ (box is on v26) ships a global fetch. The old `require('node-fetch')`
+// shim broke on the Node-26 upgrade — node-fetch v3 is ESM-only, so require()
+// returned a namespace object, not a function → "fetch is not a function" crashed
+// the daily 6am sync silently from 2026-07-07 (mirror froze at 07-06). Use global fetch.
+const fetch = globalThis.fetch;
 
 const pool = new Pool({
   connectionString: (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified')
diff --git a/shopify/theme-refine-colorpalette-20260709/snippets/color-palette.liquid b/shopify/theme-refine-colorpalette-20260709/snippets/color-palette.liquid
new file mode 100644
index 00000000..6525af08
--- /dev/null
+++ b/shopify/theme-refine-colorpalette-20260709/snippets/color-palette.liquid
@@ -0,0 +1,675 @@
+{% 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">&minus;</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">&times;</button>
+    </div>
+    <div class="dw-color-index__grid" data-dw-ci-grid aria-live="polite"></div>
+  </div>
+</div>
+
+<style>
+  .dw-color-palette{padding:16px 0 10px;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 12px;letter-spacing:-0.01em;}
+  /* ── DENSITY SLIDER (expand/contract dot count) ── */
+  .dw-color-palette__density{display:flex;align-items:center;justify-content:center;gap:8px;max-width:660px;margin:0 auto 14px;}
+  .dw-color-palette__density[hidden]{display:none;}
+  .dw-cp-dense__wrap{display:inline-flex;align-items:center;gap:8px;}
+  .dw-cp-dense__lbl{font-family:Lora,serif;font-size:11px;color:#8a8577;letter-spacing:.02em;text-transform:uppercase;white-space:nowrap;}
+  .dw-cp-dense__range{-webkit-appearance:none;appearance:none;width:150px;max-width:38vw;height:3px;border-radius:3px;background:#e0ded9;outline:none;cursor:pointer;margin:0;}
+  .dw-cp-dense__range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#3D4246;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.18);cursor:pointer;}
+  .dw-cp-dense__range::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#3D4246;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.18);cursor:pointer;}
+  .dw-cp-dense__out{font-family:Lora,serif;font-size:12px;font-weight:600;color:#3D4246;min-width:18px;text-align:right;font-variant-numeric:tabular-nums;}
+  .dw-cp-dense__step{font-family:Lora,serif;font-size:16px;line-height:1;color:#6b6b6b;background:none;border:1px solid #e0ded9;border-radius:50%;width:24px;height:24px;padding:0;cursor:pointer;flex:0 0 auto;}
+  .dw-cp-dense__step:hover{color:#000;border-color:#b8b3a8;}
+  /* 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:2px;max-width:660px;margin:0 auto;}
+  .dw-swatch{display:block;width:18px;height:18px;border-radius:3px;text-decoration:none;box-sizing:border-box;border:1px solid rgba(0,0,0,.06);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,.22);z-index:3;border-color:rgba(0,0,0,.25);}
+  /* the pattern's REAL colors get a ring + a touch more size so they stand out
+     from the interpolated in-between steps, while rows stay even. */
+  .dw-swatch--base{width:22px;height:22px;border-radius:4px;border:1px solid #fff;box-shadow:0 0 0 1.5px #3D4246,0 1px 3px rgba(0,0,0,.14);}
+  .dw-swatch--base:hover{transform:scale(1.4);}
+  .dw-color-palette__bar{display:flex;height:4px;border-radius:3px;overflow:hidden;max-width:660px;margin:14px 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 14px;max-width:660px;margin:12px auto 0;}
+  .dw-color-palette__legend a,.dw-color-palette__legend span{display:inline-flex;align-items:center;gap:5px;font-family:Lora,serif;font-size:11px;color:#3D4246;text-decoration:none;text-transform:capitalize;}
+  .dw-color-palette__legend i{width:11px;height:11px;border-radius:2px;box-shadow:0 0 0 1px rgba(0,0,0,.12);}
+  .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 ── */
+  .dw-color-index{max-width:660px;margin:16px auto 4px;text-align:left;}
+  .dw-color-index[hidden]{display:none;}
+  .dw-color-index__head{display:flex;align-items:center;gap:9px;margin:0 0 12px;}
+  .dw-color-index__swatch{width:20px;height:20px;border-radius:4px;flex:0 0 auto;box-shadow:0 0 0 1px rgba(0,0,0,.14);}
+  .dw-color-index__label{font-family:Lora,serif;font-size:13px;color:#3D4246;flex:1 1 auto;}
+  .dw-color-index__label b{font-weight:600;}
+  .dw-color-index__close{flex:0 0 auto;background:none;border:1px solid #e0ded9;border-radius:50%;width:24px;height:24px;line-height:1;font-size:16px;color:#6b6b6b;cursor:pointer;padding:0;}
+  .dw-color-index__close:hover{color:#000;border-color:#b8b3a8;}
+  .dw-color-index__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;}
+  .dw-color-index__card{display:block;text-decoration:none;color:inherit;}
+  .dw-color-index__card img{width:100%;aspect-ratio:1;object-fit:cover;border:1px solid #e5e5e5;border-radius:3px;background:#f4f2ee;}
+  .dw-color-index__cap{display:block;font-family:Lora,serif;font-size:11px;color:#3D4246;margin-top:5px;line-height:1.25;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;text-transform:capitalize;}
+  .dw-color-index__cap i{display:block;font-style:normal;color:#8a8577;font-size:10px;text-transform:none;}
+  .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.hidden=true; } }
+    if(CI){
+      var _cx=ciEl('[data-dw-ci-close]');
+      if(_cx) _cx.addEventListener('click', 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;
+      CI.scrollIntoView({behavior:'smooth', block:'nearest'});
+      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('');
+    }
+    /* 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();
+      for(var i=0;i<list.length;i++){
+        frag.appendChild(makeSwatch(list[i].hex,{base:true,name:list[i].name,pct:list[i].pct}));
+        if(i<list.length-1){
+          var steps = stepsPerGap[i] || 0;
+          for(var k=1;k<=steps;k++){
+            var t=k/(steps+1);
+            frag.appendChild(makeSwatch(mix(list[i].hex,list[i+1].hex,t),{base:false}));
+          }
+        }
+      }
+      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;
+      }
+      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 -%} CLIP "More like this" — PDP color-dot CLIP visual-similarity row.
+  Calls the SAME-BEHAVIOR public proxy /apps/similar (dwphoto → loopback CLIP :9914),
+  which returns the visually-nearest patterns to THIS product, softly re-ranked by the
+  same hex→color-family bucket + style overlap the swatches use. Additive: the swatch
+  spectrum + its color-family links above are unchanged. dw_sku / hex / style come from
+  the product's REAL fields. (Steve "ship the CLIP dots" 2026-07-08) {%- endcomment -%}
+{%- assign _dw_sku = product.selected_or_first_available_variant.sku | default: product.variants.first.sku | remove: '-Sample' -%}
+{%- assign _dw_hex = product.metafields.custom.primary_color_hex.value | default: product.metafields.custom.color_hex.value | default: product.metafields.dwc.dominant_hex.value -%}
+{%- assign _dw_style = product.metafields.custom.style.value | default: product.metafields.global.Style.value | default: product.type -%}
+{%- if _dw_sku != blank -%}
+<div id="dw-similar" class="dw-similar"
+     data-dw-sku="{{ _dw_sku | escape }}"
+     data-hex="{{ _dw_hex | escape }}"
+     data-style="{{ _dw_style | escape }}"
+     data-endpoint="https://photo.designerwallcoverings.com/apps/similar">
+  <h3 class="dw-similar__h">More Wallcoverings Like This</h3>
+  <div class="dw-similar__grid" aria-live="polite"></div>
+</div>
+<script>
+(function(){
+  var root=document.getElementById('dw-similar'); if(!root) return;
+  var sku=(root.dataset.dwSku||'').trim().replace(/-Sample$/i,'');  // base SKU keys the CLIP index; the Sample variant SKU (usually first-available) has no embedding
+  if(!sku){ root.style.display='none'; return; }
+  var ep=root.dataset.endpoint||'https://photo.designerwallcoverings.com/apps/similar';
+  fetch(ep,{method:'POST',mode:'cors',headers:{'Content-Type':'application/json'},
+    body:JSON.stringify({dw_sku:sku, hex:root.dataset.hex||'', style:root.dataset.style||'', k:8})})
+   .then(function(r){return r.ok?r.json():{results:[]};})
+   .then(function(d){
+     var g=root.querySelector('.dw-similar__grid');
+     if(!d.results||!d.results.length){ root.style.display='none'; return; }
+     d.results.forEach(function(p){
+       var a=document.createElement('a'); a.className='dw-similar__card'; a.rel='nofollow';
+       // real PDP link when we have a storefront handle; else fall back to a SKU search (never a name-search)
+       a.href = p.handle ? ('/products/'+encodeURIComponent(p.handle))
+                         : ('/search?q='+encodeURIComponent(p.dw_sku||'')+'&type=product');
+       var img=document.createElement('img'); img.loading='lazy'; img.src=p.image||''; img.alt=p.pattern||'';
+       var cap=document.createElement('span'); cap.className='dw-similar__cap'; cap.textContent=p.pattern||'';
+       a.appendChild(img); a.appendChild(cap); g.appendChild(a);
+     });
+   }).catch(function(){ root.style.display='none'; });
+})();
+</script>
+<style>
+.dw-similar{margin:20px auto 8px;max-width:660px;text-align:center}
+.dw-similar__h{font-family:Lora,serif;font-size:15px;font-weight:500;color:#3D4246;letter-spacing:-0.01em;margin:0 0 12px}
+.dw-similar__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}
+.dw-similar__card{display:block;text-decoration:none;color:inherit}
+.dw-similar__card img{width:100%;aspect-ratio:1;object-fit:cover;border:1px solid #e5e5e5;border-radius:3px}
+.dw-similar__cap{display:block;font-family:Lora,serif;font-size:11px;color:#3D4246;margin-top:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-transform:capitalize}
+.dw-similar:empty{display:none}
+</style>
+{%- endif -%}
diff --git a/shopify/verify-density-slider-live.mjs b/shopify/verify-density-slider-live.mjs
new file mode 100644
index 00000000..7304e6bf
--- /dev/null
+++ b/shopify/verify-density-slider-live.mjs
@@ -0,0 +1,129 @@
+import pw from '/Users/macstudio3/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/node_modules/playwright/index.js';
+const { chromium } = pw;
+
+const URL = 'https://www.designerwallcoverings.com/products/crayford-paisley-porcelain';
+const out = {};
+const errs = [];
+
+const CHROME = '/Users/macstudio3/Library/Caches/ms-playwright/chromium-1228/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
+const browser = await chromium.launch({ headless: true, executablePath: CHROME });
+const ctx = await browser.newContext({ userAgent: 'Mozilla/5.0 (Macintosh) DW-verify' });
+const page = await ctx.newPage();
+page.on('console', m => { if (m.type() === 'error') errs.push('console: ' + m.text()); });
+page.on('pageerror', e => errs.push('pageerror: ' + e.message));
+
+await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
+// let the palette JS run
+await page.waitForTimeout(4000);
+
+// Locate the range slider
+const range = page.locator('[data-dw-cp-range]').first();
+const rangeCount = await range.count();
+out.slider_present = rangeCount > 0;
+
+// dot-count helper: count rendered dots inside the palette
+async function dotCount() {
+  return await page.evaluate(() => {
+    const r = document.querySelector('[data-dw-cp-range]');
+    if (!r) return -1;
+    // find nearest palette container ancestor
+    let cont = r.closest('[class*="palette"], [class*="colors"], section, div');
+    // walk up a few levels to find a container that holds the dot swatches
+    const countDots = (root) => {
+      const sels = ['[data-dw-cp-dot]', '.dw-cp-dot', '[class*="cp-dot"]', '[class*="palette-dot"]', '[class*="swatch"]'];
+      for (const s of sels) { const n = root.querySelectorAll(s); if (n.length) return n.length; }
+      return 0;
+    };
+    let best = 0, node = cont;
+    for (let i = 0; i < 6 && node; i++) { best = Math.max(best, countDots(node)); node = node.parentElement; }
+    // also try document-wide by the specific dot marker
+    return best || countDots(document);
+  });
+}
+
+if (out.slider_present) {
+  // attributes
+  out.range_min = await range.getAttribute('min');
+  out.range_max = await range.getAttribute('max');
+  out.range_default_value = await range.getAttribute('value') || await range.inputValue();
+
+  out.dots_default = await dotCount();
+
+  // Drag to LOW: set value min, dispatch input+change, wait for debounce
+  async function setRange(v) {
+    await range.evaluate((el, val) => {
+      el.value = String(val);
+      el.dispatchEvent(new Event('input', { bubbles: true }));
+      el.dispatchEvent(new Event('change', { bubbles: true }));
+    }, v);
+    await page.waitForTimeout(400); // > 80ms debounce
+  }
+  const min = parseInt(out.range_min || '6', 10);
+  const max = parseInt(out.range_max || '64', 10);
+  await setRange(min);
+  out.dots_low = await dotCount();
+  out.dots_low_target = min;
+  await setRange(max);
+  out.dots_high = await dotCount();
+  out.dots_high_target = max;
+  // return to a mid value for the click test
+  await setRange(20);
+  await page.waitForTimeout(300);
+}
+
+// Click a dot → open the color-index grid; count cards
+out.color_index = {};
+try {
+  const dotSel = await page.evaluate(() => {
+    const sels = ['[data-dw-cp-dot]', '.dw-cp-dot', '[class*="cp-dot"]', '[class*="palette-dot"]', '[class*="swatch"]'];
+    for (const s of sels) { if (document.querySelector(s)) return s; }
+    return null;
+  });
+  out.color_index.dot_selector = dotSel;
+  if (dotSel) {
+    const dot = page.locator(dotSel).first();
+    // capture the /apps/color-index XHR
+    const respP = page.waitForResponse(r => r.url().includes('/apps/color-index'), { timeout: 20000 }).catch(() => null);
+    await dot.click({ timeout: 10000 });
+    const resp = await respP;
+    out.color_index.xhr_status = resp ? resp.status() : 'no-xhr';
+    await page.waitForTimeout(3000);
+    // count product cards in the opened index grid
+    out.color_index.cards = await page.evaluate(() => {
+      const sels = ['[class*="color-index"] a[href*="/products/"]', '[class*="cp-index"] a[href*="/products/"]',
+                    '[data-dw-cp-index] a[href*="/products/"]', '[class*="index-grid"] a[href*="/products/"]'];
+      for (const s of sels) { const n = document.querySelectorAll(s); if (n.length) return n.length; }
+      // fallback: any newly-visible product-card links within a palette-index region
+      const region = document.querySelector('[class*="color-index"], [class*="cp-index"], [data-dw-cp-index], [class*="index"]');
+      return region ? region.querySelectorAll('a[href*="/products/"]').length : 0;
+    });
+    out.color_index.sample_hrefs = await page.evaluate(() => {
+      const region = document.querySelector('[class*="color-index"], [class*="cp-index"], [data-dw-cp-index], [class*="index"]');
+      const links = region ? [...region.querySelectorAll('a[href*="/products/"]')] : [];
+      return links.slice(0, 3).map(a => a.getAttribute('href'));
+    });
+  }
+} catch (e) { out.color_index.error = e.message; }
+
+// Non-regression: CLIP strip + family-collection fallback href
+out.nonregression = await page.evaluate(() => {
+  const html = document.documentElement.innerHTML;
+  // CLIP "More Wallcoverings Like This" strip
+  const clipRegion = [...document.querySelectorAll('*')].find(el =>
+    /more wallcoverings like this/i.test(el.textContent || '') && el.children.length < 40);
+  const clipHeadingPresent = /more wallcoverings like this/i.test(html);
+  const clipCards = document.querySelectorAll('[class*="clip"] a[href*="/products/"], [class*="similar"] a[href*="/products/"], [class*="like-this"] a[href*="/products/"]').length;
+  // family-collection fallback href
+  const famLinks = [...document.querySelectorAll('a[href*="/collections/"]')]
+    .map(a => a.getAttribute('href')).filter(Boolean);
+  return {
+    clip_heading_present: clipHeadingPresent,
+    clip_cards: clipCards,
+    family_collection_href_present: famLinks.length > 0,
+    family_collection_sample: famLinks.slice(0, 2),
+  };
+});
+
+out.console_errors = errs;
+console.log(JSON.stringify(out, null, 2));
+await browser.close();

← 7905bd62 auto-save: 2026-07-09T15:41:06 (5 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  Refine PDP color-palette: fix invisible density slider, dedu 7fa268bf →