[object Object]

← back to Dw Photo Capture

shop-by-color: data-driven ΔE-collapsed color dots (real DB hexes, ≥20 patterns, qty-ordered)

e0020541019a94680e6bde1e27dd509d54045f1a · 2026-07-19 08:22:33 -0700 · Steve Abrams

- scripts/build-color-dots.cjs: greedy CIELAB ΔE76 clustering (DTD verdict A) of
  product_colors (live-truth gated) into visually-unique dots; keep clusters
  ≥20 patterns, order by summed count desc; nearest-Pantone naming + LAB-chroma
  family bucketing. 146 dots covering 41,907/43,284 products.
- server.js: public GET /apps/color-dots serving data/color-dots.json (hot-reload
  on mtime, same CORS posture as /apps/color-index).
- refresh-color-index.sh: build + rsync color-dots.json alongside the index.

Files touched

Diff

commit e0020541019a94680e6bde1e27dd509d54045f1a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 19 08:22:33 2026 -0700

    shop-by-color: data-driven ΔE-collapsed color dots (real DB hexes, ≥20 patterns, qty-ordered)
    
    - scripts/build-color-dots.cjs: greedy CIELAB ΔE76 clustering (DTD verdict A) of
      product_colors (live-truth gated) into visually-unique dots; keep clusters
      ≥20 patterns, order by summed count desc; nearest-Pantone naming + LAB-chroma
      family bucketing. 146 dots covering 41,907/43,284 products.
    - server.js: public GET /apps/color-dots serving data/color-dots.json (hot-reload
      on mtime, same CORS posture as /apps/color-index).
    - refresh-color-index.sh: build + rsync color-dots.json alongside the index.
---
 data/color-dots.json           |   1 +
 scripts/build-color-dots.cjs   | 177 +++++++++++++++++++++++++++++++++++++++++
 scripts/refresh-color-index.sh |   9 ++-
 server.js                      |  46 ++++++++++-
 4 files changed, 230 insertions(+), 3 deletions(-)

diff --git a/data/color-dots.json b/data/color-dots.json
new file mode 100644
index 0000000..4a37a20
--- /dev/null
+++ b/data/color-dots.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-19T15:18:03.892Z","merge_de":10,"min_patterns":20,"total_products":43284,"covered":41907,"count":146,"dots":[{"x":"#f2f2f2","n":18280,"name":"Soft White","family":"white","l":95.5,"a":0,"b":0,"members":1358},{"x":"#d9d2c4","n":5100,"name":"Greige","family":"grey","l":84.4,"a":0,"b":7.8,"members":2489},{"x":"#a9a9a9","n":1523,"name":"Ash Grey","family":"grey","l":69.2,"a":0,"b":0,"members":1238},{"x":"#f5f5dc","n":1237,"name":"Beige","family":"neutral","l":95.9,"a":-4.2,"b":12,"members":473},{"x":"#c1b39a","n":1158,"name":"Oatmeal","family":"neutral","l":73.5,"a":0.9,"b":14.6,"members":982},{"x":"#c7ced4","n":882,"name":"Silver Lining","family":"grey","l":82.4,"a":-1.2,"b":-3.8,"members":790},{"x":"#808080","n":834,"name":"Pewter","family":"grey","l":53.6,"a":0,"b":0,"members":605},{"x":"#a3957d","n":757,"name":"Warm Taupe","family":"neutral","l":62.3,"a":1.2,"b":14.6,"members":640},{"x":"#282828","n":569,"name":"Jet Black","family":"grey","l":16.1,"a":0,"b":0,"members":407},{"x":"#36454f","n":553,"name":"Slate","family":"blue","l":28.4,"a":-3.3,"b":-8,"members":291},{"x":"#736755","n":455,"name":"Bark","family":"brown","l":44.2,"a":1.6,"b":11.9,"members":438},{"x":"#4f4f4f","n":447,"name":"Charcoal","family":"grey","l":33.6,"a":0,"b":0,"members":414},{"x":"#d2b48c","n":419,"name":"Almond Buff","family":"orange","l":75,"a":5,"b":24.4,"members":254},{"x":"#ddd1ac","n":392,"name":"Chamois","family":"neutral","l":83.9,"a":-1.7,"b":19.9,"members":392},{"x":"#483c32","n":380,"name":"Espresso","family":"brown","l":26.3,"a":3.4,"b":8.1,"members":327},{"x":"#fffdd0","n":320,"name":"Beige","family":"neutral","l":98.5,"a":-6.5,"b":21.8,"members":9},{"x":"#000000","n":272,"name":"Obsidian","family":"black","l":0,"a":0,"b":0,"members":71},{"x":"#b0beaf","n":257,"name":"Dove Grey","family":"neutral","l":75.5,"a":-7.6,"b":5.9,"members":257},{"x":"#a38266","n":238,"name":"Cognac","family":"orange","l":56.8,"a":8.7,"b":20,"members":205},{"x":"#273752","n":200,"name":"Indigo","family":"blue","l":22.9,"a":2.1,"b":-18.4,"members":180},{"x":"#576a70","n":186,"name":"Gunmetal","family":"blue","l":43.5,"a":-5.6,"b":-5.7,"members":178},{"x":"#708090","n":185,"name":"Slate Blue","family":"blue","l":52.8,"a":-2.1,"b":-10.6,"members":172},{"x":"#add8e6","n":165,"name":"Powder Blue","family":"blue","l":83.8,"a":-10.9,"b":-11.5,"members":57},{"x":"#c19a6b","n":163,"name":"Camel","family":"orange","l":66.1,"a":8.4,"b":30.2,"members":153},{"x":"#97b5b5","n":163,"name":"Dove Grey","family":"neutral","l":71.5,"a":-10.2,"b":-3.4,"members":153},{"x":"#7a959a","n":157,"name":"Ultimate Gray","family":"teal","l":59.9,"a":-8.3,"b":-5.8,"members":157},{"x":"#808973","n":155,"name":"Eucalyptus","family":"neutral","l":55.8,"a":-7.5,"b":10.7,"members":155},{"x":"#bcb88a","n":153,"name":"Sand Dollar","family":"yellow","l":74.1,"a":-6,"b":23.9,"members":136},{"x":"#192335","n":151,"name":"Midnight Navy","family":"black","l":13.6,"a":1.6,"b":-13.1,"members":125},{"x":"#98a788","n":139,"name":"Fern","family":"neutral","l":66.6,"a":-11,"b":14.3,"members":128},{"x":"#b0c4de","n":134,"name":"Powder Blue","family":"blue","l":78.5,"a":-1.3,"b":-15.2,"members":68},{"x":"#98a1b2","n":127,"name":"Ultimate Gray","family":"blue","l":66,"a":0.4,"b":-9.9,"members":123},{"x":"#bb9e9a","n":126,"name":"Rose","family":"neutral","l":67.5,"a":10,"b":6.1,"members":125},{"x":"#64473a","n":126,"name":"Mocha","family":"brown","l":33,"a":10.5,"b":12.7,"members":126},{"x":"#fedfc3","n":118,"name":"Champagne","family":"neutral","l":90.7,"a":6.5,"b":17.7,"members":86},{"x":"#53645a","n":116,"name":"Gunmetal","family":"green","l":40.7,"a":-8.7,"b":3.5,"members":103},{"x":"#51576a","n":111,"name":"Slate","family":"blue","l":37.1,"a":2.3,"b":-11.7,"members":111},{"x":"#466987","n":110,"name":"Steel Blue","family":"blue","l":43,"a":-3.8,"b":-20.5,"members":101},{"x":"#805841","n":107,"name":"Chestnut","family":"brown","l":41.1,"a":13.6,"b":19.9,"members":99},{"x":"#947c79","n":107,"name":"Pewter","family":"red","l":54.1,"a":8.7,"b":5.1,"members":107},{"x":"#806b46","n":104,"name":"Olive Branch","family":"brown","l":46.4,"a":2.9,"b":23.8,"members":104},{"x":"#cee8e4","n":102,"name":"Soft White","family":"white","l":90.1,"a":-9.3,"b":-1,"members":102},{"x":"#91936b","n":100,"name":"Fern","family":"yellow","l":59.8,"a":-7.6,"b":21,"members":100},{"x":"#9f8050","n":93,"name":"Old Gold","family":"orange","l":55.5,"a":5.6,"b":30.5,"members":93},{"x":"#4169e1","n":92,"name":"Ultra Violet","family":"blue","l":47.8,"a":26.3,"b":-65.3,"members":1},{"x":"#84a294","n":92,"name":"Fern","family":"neutral","l":64.1,"a":-13.3,"b":3.8,"members":90},{"x":"#e2c487","n":91,"name":"Almond Buff","family":"gold","l":80.4,"a":2.3,"b":34.5,"members":90},{"x":"#e8bdb6","n":80,"name":"Rose","family":"neutral","l":80.1,"a":14.3,"b":9.3,"members":79},{"x":"#228b22","n":79,"name":"Forest","family":"green","l":50.6,"a":-49.6,"b":45,"members":5},{"x":"#4a543a","n":78,"name":"Hunter Green","family":"green","l":34.2,"a":-9.2,"b":13.9,"members":75},{"x":"#2a493d","n":77,"name":"Hunter Green","family":"green","l":28.4,"a":-14.7,"b":3.6,"members":75},{"x":"#527c7d","n":77,"name":"Mineral Blue","family":"teal","l":49.1,"a":-14.2,"b":-5.2,"members":77},{"x":"#a87840","n":75,"name":"Old Gold","family":"orange","l":54.2,"a":12.7,"b":37.7,"members":73},{"x":"#b74b3d","n":70,"name":"Terracotta","family":"red","l":46.2,"a":42.8,"b":30.4,"members":68},{"x":"#c48f45","n":67,"name":"Antique Gold","family":"orange","l":63.2,"a":12.4,"b":46.3,"members":64},{"x":"#877f4b","n":67,"name":"Old Gold","family":"gold","l":52.8,"a":-4.8,"b":29.3,"members":62},{"x":"#ad6155","n":67,"name":"Dusty Cedar","family":"red","l":49.6,"a":29.5,"b":20.3,"members":66},{"x":"#e2c16f","n":64,"name":"Ochre","family":"gold","l":79.3,"a":1.5,"b":45.3,"members":60},{"x":"#b39b5b","n":64,"name":"Ochre","family":"gold","l":64.8,"a":0.3,"b":36.6,"members":64},{"x":"#613528","n":63,"name":"Mocha","family":"red","l":27.4,"a":18.3,"b":16.7,"members":57},{"x":"#345a60","n":63,"name":"Gunmetal","family":"teal","l":35.8,"a":-11.7,"b":-7.7,"members":63},{"x":"#383b26","n":62,"name":"Espresso","family":"yellow","l":24,"a":-5.5,"b":12.6,"members":62},{"x":"#468499","n":59,"name":"Mineral Blue","family":"blue","l":52,"a":-13.8,"b":-17.4,"members":53},{"x":"#00466b","n":59,"name":"Classic Blue","family":"blue","l":28,"a":-4.1,"b":-26.8,"members":54},{"x":"#823333","n":59,"name":"Marsala","family":"red","l":32.6,"a":34.1,"b":17.5,"members":59},{"x":"#993333","n":58,"name":"Cranberry","family":"red","l":36.7,"a":42.5,"b":23.5,"members":56},{"x":"#4a2f31","n":58,"name":"Espresso","family":"red","l":22.7,"a":12.7,"b":3.8,"members":58},{"x":"#1f170e","n":55,"name":"Obsidian","family":"black","l":8.4,"a":2.4,"b":6.8,"members":55},{"x":"#008080","n":54,"name":"Teal","family":"teal","l":48.3,"a":-28.8,"b":-8.5,"members":16},{"x":"#b17958","n":54,"name":"Cognac","family":"orange","l":55.8,"a":18,"b":26.9,"members":54},{"x":"#6d6667","n":54,"name":"Gunmetal","family":"grey","l":43.8,"a":2.9,"b":0.4,"members":54},{"x":"#acc7a8","n":53,"name":"Sage","family":"neutral","l":77.5,"a":-15,"b":12.5,"members":45},{"x":"#607e6b","n":53,"name":"Eucalyptus","family":"green","l":50.1,"a":-14.9,"b":6.9,"members":51},{"x":"#926238","n":53,"name":"Chestnut","family":"orange","l":45.8,"a":14.9,"b":31.5,"members":53},{"x":"#a7d1c9","n":51,"name":"Silver Lining","family":"neutral","l":80.8,"a":-15.4,"b":-0.6,"members":48},{"x":"#9aa76f","n":51,"name":"Moss","family":"green","l":66.3,"a":-14.1,"b":27.5,"members":51},{"x":"#ffd700","n":49,"name":"Buttercup","family":"gold","l":86.9,"a":-1.9,"b":87.1,"members":1},{"x":"#696f45","n":49,"name":"Olive Branch","family":"yellow","l":45.4,"a":-9.7,"b":22.7,"members":49},{"x":"#f7e7b0","n":48,"name":"Champagne","family":"neutral","l":91.7,"a":-2.7,"b":28.8,"members":36},{"x":"#c9937b","n":47,"name":"Cognac","family":"orange","l":65.5,"a":17.2,"b":20.7,"members":46},{"x":"#83381c","n":46,"name":"Burnt Sienna","family":"orange","l":33.4,"a":30.5,"b":32.3,"members":44},{"x":"#d2b8c7","n":46,"name":"Silver Lining","family":"coral","l":77.3,"a":11.8,"b":-4.1,"members":44},{"x":"#7f4637","n":46,"name":"Marsala","family":"red","l":36.3,"a":22.8,"b":19.7,"members":46},{"x":"#003366","n":45,"name":"Classic Blue","family":"blue","l":21.3,"a":6.8,"b":-34.2,"members":37},{"x":"#415280","n":45,"name":"Classic Blue","family":"blue","l":35.4,"a":7,"b":-28,"members":44},{"x":"#d4a74a","n":44,"name":"Honey Gold","family":"gold","l":71,"a":6.6,"b":53,"members":40},{"x":"#5f9ea0","n":43,"name":"Mineral Blue","family":"teal","l":61.2,"a":-19.7,"b":-7.4,"members":40},{"x":"#fddedb","n":43,"name":"Ivory Cream","family":"white","l":90.9,"a":10.2,"b":5.4,"members":43},{"x":"#a47262","n":43,"name":"Cocoa Brown","family":"red","l":52.7,"a":17.7,"b":16.8,"members":43},{"x":"#5d815b","n":42,"name":"Willow Bough","family":"green","l":50.4,"a":-20.7,"b":16.6,"members":34},{"x":"#78b0cc","n":42,"name":"Airy Blue","family":"blue","l":69,"a":-11,"b":-20,"members":42},{"x":"#8b4513","n":41,"name":"Burnt Sienna","family":"orange","l":37.5,"a":26.4,"b":41,"members":14},{"x":"#8cb387","n":41,"name":"Sage","family":"green","l":69.2,"a":-21.8,"b":18.3,"members":27},{"x":"#7797b0","n":41,"name":"Slate Blue","family":"blue","l":60.9,"a":-5,"b":-16.6,"members":41},{"x":"#675234","n":41,"name":"Chestnut","family":"brown","l":36.3,"a":4.4,"b":20.9,"members":41},{"x":"#4682b4","n":40,"name":"Provence","family":"blue","l":52.5,"a":-4.1,"b":-32.2,"members":17},{"x":"#85605c","n":40,"name":"Cocoa Brown","family":"red","l":44.4,"a":14.5,"b":8.2,"members":39},{"x":"#6d535a","n":40,"name":"Mocha","family":"coral","l":38.2,"a":12,"b":0.1,"members":40},{"x":"#591818","n":39,"name":"Marsala","family":"red","l":19.4,"a":29.8,"b":16.9,"members":35},{"x":"#a66133","n":39,"name":"Burnt Sienna","family":"orange","l":48.1,"a":23.9,"b":37.5,"members":38},{"x":"#bdb76b","n":38,"name":"Ochre","family":"yellow","l":73.4,"a":-8.8,"b":39.3,"members":37},{"x":"#426244","n":38,"name":"Willow Bough","family":"green","l":38.4,"a":-18.5,"b":13.5,"members":38},{"x":"#ffbf00","n":36,"name":"Buttercup","family":"gold","l":81,"a":10.4,"b":83,"members":2},{"x":"#800020","n":35,"name":"Cranberry","family":"red","l":25.8,"a":48.9,"b":21.3,"members":28},{"x":"#b2993f","n":35,"name":"Antique Gold","family":"gold","l":63.8,"a":-1.3,"b":49.3,"members":34},{"x":"#9e813d","n":35,"name":"Old Gold","family":"gold","l":55.4,"a":2.9,"b":40.3,"members":34},{"x":"#de5d83","n":34,"name":"Cranberry","family":"coral","l":57.1,"a":53.8,"b":4.7,"members":4},{"x":"#b92a22","n":34,"name":"Racing Red","family":"red","l":41.4,"a":55.6,"b":40.3,"members":33},{"x":"#b3592c","n":34,"name":"Terracotta","family":"orange","l":48.2,"a":33.3,"b":41.7,"members":33},{"x":"#ff7f50","n":33,"name":"Living Coral","family":"orange","l":67.3,"a":45.4,"b":47.5,"members":5},{"x":"#779ecb","n":33,"name":"Serenity","family":"blue","l":63.9,"a":-1.7,"b":-27.3,"members":29},{"x":"#594659","n":33,"name":"Charcoal","family":"purple","l":32.3,"a":11.9,"b":-8.2,"members":30},{"x":"#0a3138","n":33,"name":"Slate","family":"teal","l":18.1,"a":-10.5,"b":-8.4,"members":33},{"x":"#19478f","n":32,"name":"Classic Blue","family":"blue","l":31.1,"a":12.5,"b":-44.2,"members":29},{"x":"#5e8b52","n":32,"name":"Willow Bough","family":"green","l":53.4,"a":-26.9,"b":25.7,"members":29},{"x":"#c67e53","n":32,"name":"Terracotta","family":"orange","l":59.5,"a":23.6,"b":34.8,"members":31},{"x":"#e07a5f","n":31,"name":"Terracotta","family":"red","l":62.2,"a":37,"b":32.3,"members":26},{"x":"#5e2e32","n":31,"name":"Mocha","family":"red","l":25.5,"a":22.2,"b":7.4,"members":31},{"x":"#ffb6c1","n":29,"name":"Blush","family":"red","l":81.1,"a":28,"b":5,"members":3},{"x":"#4a411f","n":29,"name":"Espresso","family":"gold","l":27.7,"a":-1.4,"b":22,"members":28},{"x":"#c7817d","n":29,"name":"Dusty Cedar","family":"red","l":61.1,"a":26.6,"b":13.4,"members":29},{"x":"#dc143c","n":28,"name":"Racing Red","family":"red","l":47,"a":70.9,"b":33.6,"members":5},{"x":"#04686b","n":28,"name":"Deep Lake","family":"teal","l":39.6,"a":-23.6,"b":-8.9,"members":26},{"x":"#a04f55","n":28,"name":"Marsala","family":"red","l":43.8,"a":34.1,"b":12.2,"members":27},{"x":"#918940","n":28,"name":"Moss","family":"gold","l":56.4,"a":-7.1,"b":39.6,"members":26},{"x":"#6b7ea2","n":28,"name":"Steel Blue","family":"blue","l":52.6,"a":2.1,"b":-21.5,"members":28},{"x":"#ba7335","n":26,"name":"Amberglow","family":"orange","l":55,"a":22.6,"b":44.8,"members":25},{"x":"#40171b","n":26,"name":"Espresso","family":"black","l":14.3,"a":20.4,"b":7.2,"members":26},{"x":"#d6a4ac","n":26,"name":"Blush","family":"neutral","l":72.1,"a":19.6,"b":3,"members":26},{"x":"#3b6a35","n":26,"name":"Willow Bough","family":"green","l":40.4,"a":-27.7,"b":24.7,"members":26},{"x":"#a493a7","n":25,"name":"Lavender","family":"purple","l":63,"a":10,"b":-8.2,"members":25},{"x":"#4f8a7d","n":25,"name":"Eucalyptus","family":"teal","l":53.3,"a":-22.5,"b":0.9,"members":25},{"x":"#004650","n":24,"name":"Slate","family":"teal","l":26.6,"a":-15.2,"b":-11.5,"members":20},{"x":"#e6b373","n":24,"name":"Ochre","family":"orange","l":76.3,"a":10.9,"b":39.6,"members":23},{"x":"#d0ddbf","n":24,"name":"Beige","family":"neutral","l":86.4,"a":-9.7,"b":13.3,"members":24},{"x":"#bfb751","n":23,"name":"Ochre","family":"yellow","l":73.2,"a":-10.3,"b":51.8,"members":23},{"x":"#e2b531","n":22,"name":"Honey Gold","family":"gold","l":75.7,"a":4,"b":68.2,"members":21},{"x":"#6b3b13","n":22,"name":"Chestnut","family":"orange","l":30.2,"a":17.8,"b":32.5,"members":22},{"x":"#8d261a","n":22,"name":"Cranberry","family":"red","l":32.1,"a":42.6,"b":32.7,"members":22},{"x":"#e7b19a","n":22,"name":"Apricot","family":"orange","l":76.5,"a":16.5,"b":19.4,"members":22},{"x":"#935235","n":22,"name":"Burnt Sienna","family":"orange","l":42,"a":24.3,"b":28.6,"members":22},{"x":"#556b2f","n":21,"name":"Willow Bough","family":"green","l":42.2,"a":-18.8,"b":30.6,"members":16},{"x":"#d35934","n":21,"name":"Terracotta","family":"red","l":53.2,"a":46,"b":44.5,"members":21},{"x":"#ff6347","n":20,"name":"Living Coral","family":"red","l":62.2,"a":57.9,"b":46.4,"members":7},{"x":"#cb6362","n":20,"name":"Dusty Cedar","family":"red","l":54.6,"a":41.1,"b":20.1,"members":20},{"x":"#7a8f5c","n":20,"name":"Moss","family":"green","l":56.6,"a":-16.8,"b":24.7,"members":20}]}
\ No newline at end of file
diff --git a/scripts/build-color-dots.cjs b/scripts/build-color-dots.cjs
new file mode 100644
index 0000000..4de7378
--- /dev/null
+++ b/scripts/build-color-dots.cjs
@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+/**
+ * build-color-dots.cjs — generate the data-driven "color dots" for the
+ * designerwallcoverings.com /pages/shop-by-color WHEEL (the Palette tab).
+ *
+ * Steve's rule (2026-07-19): the wheel must show ONLY colors that actually
+ * exist in the hex_unified product-colors DB, every dot must back >=20 real
+ * patterns, dots are ordered by most-used quantity, AND each dot must be a
+ * UNIQUE, visually-distinct color (no run of near-identical soft-whites).
+ *
+ * Method (DTD verdict A, 5/5, 2026-07-19): perceptual CIELAB ΔE76 clustering.
+ *   1. Gate to LIVE truth exactly like build-color-index.cjs (active +
+ *      online_store_published + has_product_variant), ONE row per product.
+ *   2. Aggregate to exact hexes with a count + mean LAB.
+ *   3. Greedy-merge hexes within ΔE76 <= DOT_MERGE_DE into the MOST-USED hex of
+ *      the cluster (seeds are visited most-used-first, so the representative is
+ *      always the biggest real hex). Sum the cluster's pattern counts.
+ *   4. Keep clusters whose SUMMED count >= DOT_MIN_PATTERNS. Order by count desc.
+ *   5. Name each dot by nearest (ΔE) luxury/Pantone lexicon entry, and bucket it
+ *      into a color family (for the wheel's family filter tabs).
+ *
+ * Output: data/color-dots.json → { generated_at, merge_de, min_patterns,
+ *   total_products, covered, count, dots:[ { x:"#rrggbb", n:count, name, family,
+ *   l,a,b, members } ] }   (served read-only by /apps/color-dots)
+ *
+ * Run where pg resolves (dwphoto's node_modules):
+ *   cd ~/Projects/dw-photo-capture && node scripts/build-color-dots.cjs
+ * $0 (local PG). Idempotent; safe to schedule alongside build-color-index.cjs.
+ */
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+const pool = new Pool(
+  process.env.DATABASE_URL
+    ? { connectionString: process.env.DATABASE_URL }
+    : { host: '/tmp', database: 'dw_unified' }
+);
+
+const OUT = path.join(__dirname, '..', 'data', 'color-dots.json');
+const DOT_MERGE_DE = Number(process.env.DOT_MERGE_DE || 10);    // ΔE76 merge radius
+const DOT_MIN_PATTERNS = Number(process.env.DOT_MIN_PATTERNS || 20); // Steve's floor
+
+// Compact luxury/Pantone lexicon (name + hex) → each dot gets the nearest name.
+// Mirrors the interior-designer colorway vocabulary used elsewhere in DW.
+const LEXICON = [
+  ['Soft White', '#f2f2f2'], ['Pure White', '#ffffff'], ['Ivory Cream', '#f5f0e0'],
+  ['Beige', '#f5f5dc'], ['Chamois', '#d9c9a5'], ['Greige', '#d9d2c4'], ['Almond Buff', '#d2b48c'],
+  ['Sand Dollar', '#d5c4a1'], ['Warm Taupe', '#af9483'], ['Oatmeal', '#c1b39a'],
+  ['Dove Grey', '#b5b5b5'], ['Silver Lining', '#c0c0c0'], ['Ultimate Gray', '#939597'],
+  ['Ash Grey', '#a9a9a9'], ['Pewter', '#848482'], ['Charcoal', '#4a4a4a'], ['Anthracite', '#383838'],
+  ['Jet Black', '#2c2c2c'], ['Obsidian', '#1c1c1c'], ['Slate', '#36454f'], ['Gunmetal', '#576a70'],
+  ['Midnight Navy', '#1b2a4a'], ['Classic Blue', '#0f4c81'], ['Indigo', '#273752'],
+  ['Provence', '#658dc6'], ['Serenity', '#92a8d1'], ['Powder Blue', '#b0c4de'],
+  ['Cerulean', '#9bb7d4'], ['Slate Blue', '#708090'], ['Steel Blue', '#466987'],
+  ['Airy Blue', '#92b6d5'], ['Tidewater', '#2a9d8f'], ['Mineral Blue', '#4e8b98'],
+  ['Deep Lake', '#186a62'], ['Aqua Haze', '#6ecfbd'], ['Teal', '#008080'], ['Eucalyptus', '#5f8575'],
+  ['Sage', '#9caf88'], ['Willow Bough', '#5a7247'], ['Moss', '#8a9a5b'], ['Olive Branch', '#6b6b47'],
+  ['Hunter Green', '#335648'], ['Forest', '#228b22'], ['Celadon', '#ace1af'], ['Fern', '#98a788'],
+  ['Ochre', '#c5a55a'], ['Honey Gold', '#d4a937'], ['Antique Gold', '#b8943e'], ['Old Gold', '#9f8050'],
+  ['Champagne', '#e8d5b5'], ['Buttercup', '#fad02e'], ['Mimosa', '#e8c840'], ['Lemon', '#f0e68c'],
+  ['Amberglow', '#d4763b'], ['Terracotta', '#c4633a'], ['Burnt Sienna', '#a0522d'],
+  ['Camel', '#c19a6b'], ['Cognac', '#a38266'], ['Chestnut', '#805841'],
+  ['Cocoa Brown', '#8b6c5c'], ['Espresso', '#483c32'], ['Mocha', '#64473a'], ['Bark', '#736755'],
+  ['Living Coral', '#ff6f61'], ['Apricot', '#f5b895'], ['Blush', '#de9da5'], ['Rose', '#bb9e9a'],
+  ['Dusty Cedar', '#ad655f'], ['Racing Red', '#b22234'], ['Cranberry', '#9b2335'],
+  ['Marsala', '#964f4c'], ['Ultra Violet', '#6a4c93'], ['Lavender', '#9b8bb4'], ['Plum', '#6c3461'],
+];
+
+function hexToRgb(h) { h = h.replace('#',''); return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)]; }
+function rgbToLab(r, g, b) {
+  let R=r/255, G=g/255, B=b/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;
+  let X=(R*0.4124+G*0.3576+B*0.1805)/0.95047, Y=(R*0.2126+G*0.7152+B*0.0722), Z=(R*0.0193+G*0.1192+B*0.9505)/1.08883;
+  const f=t=> t>0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
+  X=f(X); Y=f(Y); Z=f(Z);
+  return { l:116*Y-16, a:500*(X-Y), b:200*(Y-Z) };
+}
+const LEX = LEXICON.map(([name, hex]) => { const [r,g,b]=hexToRgb(hex); return { name, ...rgbToLab(r,g,b) }; });
+function nearestName(lab) {
+  let best='Neutral', bd=Infinity;
+  for (const e of LEX) { const dl=e.l-lab.l, da=e.a-lab.a, db=e.b-lab.b, d=dl*dl+da*da+db*db; if (d<bd){bd=d; best=e.name;} }
+  return best;
+}
+// Family bucket for the wheel filter tabs. Neutrals are gated by PERCEPTUAL
+// CIELAB chroma (√(a²+b²)) — HSL saturation is inflated for light colors, so a
+// pale beige/cream reads as "yellow" under HSL but as a near-neutral under LAB.
+// Chromatic hue buckets fall back to HSL hue (fine once neutrals are removed).
+function familyOf(hex, lab) {
+  const [r,g,b]=hexToRgb(hex); const R=r/255,G=g/255,B=b/255;
+  const mx=Math.max(R,G,B), mn=Math.min(R,G,B), l=(mx+mn)/2; let h=0, d=mx-mn;
+  if (d!==0){ if (mx===R) h=((G-B)/d+(G<B?6:0)); else if (mx===G) h=((B-R)/d+2); else h=((R-G)/d+4); h*=60; }
+  const C = Math.sqrt(lab.a*lab.a + lab.b*lab.b);   // LAB chroma
+  const L = lab.l;
+  if (L > 90 && C < 12) return 'white';
+  if (L < 16) return 'black';
+  if (C < 8) return 'grey';                          // truly achromatic
+  // Pale near-neutrals are creams/beiges/taupes — they lean WARM (LAB b>0). A
+  // pale COOL tint (powder blue, b<<0) keeps its hue instead of collapsing.
+  if (L > 90 && C < 30 && lab.b > -4) return 'neutral';
+  if (C < 20 && L >= 55 && lab.b > -4) return 'neutral';
+  if (C < 26 && h >= 15 && h <= 45 && L < 55) return 'brown';
+  if (h<15||h>=345) return 'red';
+  if (h<40) return 'orange';
+  if (h<55) return 'gold';
+  if (h<70) return 'yellow';
+  if (h<165) return 'green';
+  if (h<190) return 'teal';
+  if (h<255) return 'blue';
+  if (h<320) return 'purple';
+  return 'coral';
+}
+
+(async () => {
+  const t0 = Date.now();
+  const q = `
+    SELECT DISTINCT ON (pc.handle)
+      lower('#' || replace(pc.hex,'#','')) AS hex, pc.lab_l AS l, pc.lab_a AS a, pc.lab_b AS b
+    FROM product_colors pc
+    JOIN shopify_products sp ON sp.handle = pc.handle
+    WHERE lower(sp.status) = 'active'
+      AND sp.online_store_published = true
+      AND sp.has_product_variant IS TRUE
+      AND pc.lab_l IS NOT NULL AND pc.lab_a IS NOT NULL AND pc.lab_b IS NOT NULL
+      AND pc.handle IS NOT NULL AND pc.handle <> ''
+      AND pc.hex ~* '^#?[0-9a-f]{6}$'
+    ORDER BY pc.handle`;
+  const { rows } = await pool.query(q);
+  await pool.end();
+
+  // exact-hex aggregate: count + mean LAB
+  const m = new Map();
+  for (const r of rows) {
+    let e = m.get(r.hex);
+    if (!e) { e = { hex:r.hex, n:0, l:0,a:0,b:0 }; m.set(r.hex,e); }
+    e.n++; e.l+=r.l; e.a+=r.a; e.b+=r.b;
+  }
+  let hexes = [...m.values()].map(e => ({ hex:e.hex, n:e.n, l:e.l/e.n, a:e.a/e.n, b:e.b/e.n }));
+  hexes.sort((x,y)=> y.n - x.n);   // most-used first → reps are the biggest real hexes
+
+  // greedy ΔE76 clustering
+  const clusters = [];
+  for (const h of hexes) {
+    let best=null, bd=Infinity;
+    for (const c of clusters) {
+      const dl=c.l-h.l, da=c.a-h.a, db=c.b-h.b, d=Math.sqrt(dl*dl+da*da+db*db);
+      if (d<=DOT_MERGE_DE && d<bd) { bd=d; best=c; }
+    }
+    if (best) { best.n += h.n; best.members.push(h.hex); }
+    else clusters.push({ hex:h.hex, l:h.l,a:h.a,b:h.b, n:h.n, members:[h.hex] });
+  }
+
+  const dots = clusters
+    .filter(c => c.n >= DOT_MIN_PATTERNS)
+    .sort((x,y)=> y.n - x.n)
+    .map(c => ({
+      x: c.hex, n: c.n,
+      name: nearestName({ l:c.l, a:c.a, b:c.b }),
+      family: familyOf(c.hex, { l:c.l, a:c.a, b:c.b }),
+      l: Math.round(c.l*10)/10, a: Math.round(c.a*10)/10, b: Math.round(c.b*10)/10,
+      members: c.members.length
+    }));
+
+  const payload = {
+    generated_at: new Date().toISOString(),
+    merge_de: DOT_MERGE_DE, min_patterns: DOT_MIN_PATTERNS,
+    total_products: rows.length,
+    covered: dots.reduce((s,d)=>s+d.n,0),
+    count: dots.length,
+    dots
+  };
+  fs.writeFileSync(OUT, JSON.stringify(payload));
+  const kb = Math.round(fs.statSync(OUT).size / 1024);
+  console.log(`color-dots: ${dots.length} unique dots (ΔE${DOT_MERGE_DE}, >=${DOT_MIN_PATTERNS}) covering ${payload.covered}/${rows.length} → ${OUT} (${kb} KB) in ${Date.now()-t0}ms`);
+})().catch(e => { console.error('build-color-dots FAILED:', e.message); process.exit(1); });
diff --git a/scripts/refresh-color-index.sh b/scripts/refresh-color-index.sh
index cc8846c..bacc3f0 100755
--- a/scripts/refresh-color-index.sh
+++ b/scripts/refresh-color-index.sh
@@ -14,8 +14,13 @@ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
 echo "[$(date '+%F %T')] building color-index from shopify_products…"
 node scripts/build-color-index.cjs
 
-echo "[$(date '+%F %T')] shipping color-index.json → Kamatera (hot-reload on mtime)…"
+echo "[$(date '+%F %T')] building color-dots (ΔE-collapsed shop-by-color wheel swatches)…"
+node scripts/build-color-dots.cjs
+
+echo "[$(date '+%F %T')] shipping color-index.json + color-dots.json → Kamatera (hot-reload on mtime)…"
 rsync -az data/color-index.json \
   root@45.61.58.125:/root/public-projects/dwphoto/data/color-index.json
+rsync -az data/color-dots.json \
+  root@45.61.58.125:/root/public-projects/dwphoto/data/color-dots.json
 
-echo "[$(date '+%F %T')] color-index refreshed + shipped."
+echo "[$(date '+%F %T')] color-index + color-dots refreshed + shipped."
diff --git a/server.js b/server.js
index 0ea45e2..06839cd 100644
--- a/server.js
+++ b/server.js
@@ -381,6 +381,21 @@ function loadColorIndex() {
   _colorIndexMtime = st.mtimeMs;
   return _colorIndex;
 }
+// ── Color-DOTS (shop-by-color WHEEL swatch set) ────────────────────────────────
+// The wheel's clickable dots must be REAL DB colors only, each backing >=20
+// patterns, ordered by most-used qty, and each visually UNIQUE (ΔE-collapsed).
+// Built by scripts/build-color-dots.cjs → data/color-dots.json. Served read-only,
+// hot-reloaded on mtime like the color index. See dw-shop-by-color-wheel memory.
+const COLOR_DOTS_PATH = require('path').join(__dirname, 'data', 'color-dots.json');
+let _colorDots = null, _colorDotsMtime = 0;
+function loadColorDots() {
+  const fs = require('fs');
+  const st = fs.statSync(COLOR_DOTS_PATH); // throws if missing → handler 503s
+  if (_colorDots && st.mtimeMs === _colorDotsMtime) return _colorDots;
+  _colorDots = JSON.parse(fs.readFileSync(COLOR_DOTS_PATH, 'utf8'));
+  _colorDotsMtime = st.mtimeMs;
+  return _colorDots;
+}
 // sRGB hex → CIELAB (D65), matching how product_colors LAB was computed upstream.
 function hexToLab(hex) {
   hex = hex.replace('#', '');
@@ -901,7 +916,7 @@ function pipeUpstreamImage(src, res, headers) {
 
 const appHandler = (req, res) => {
   // public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
-  const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index', '/apps/color-widen'];
+  const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index', '/apps/color-widen', '/apps/color-dots'];
   const _p = req.url.split('?')[0];
   if (!PUBLIC.includes(_p) && !checkAuth(req)) {
     res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Photo Capture"' });
@@ -977,6 +992,35 @@ const appHandler = (req, res) => {
   //  · Output allowlist: only public catalog fields already visible on the store.
   //  · CORS: reflect an ALLOWED storefront origin only.
   //  · 10% tolerance is ONE named constant (COLOR_INDEX_TOLERANCE_PCT) → ΔE ceiling.
+  // ── Shop-by-Color WHEEL dot set (GET) ──
+  // The data-driven color dots for /pages/shop-by-color: real DB colors only,
+  // each >=20 patterns, ΔE-collapsed to be visually unique, ordered by qty.
+  // Read-only GET (no input), public, same CORS posture as the color routes.
+  if (u.pathname === '/apps/color-dots') {
+    const ALLOWED_ORIGINS = new Set([
+      'https://designerwallcoverings.com',
+      'https://www.designerwallcoverings.com',
+      'https://designer-laboratory-sandbox.myshopify.com'
+    ]);
+    const origin = req.headers.origin || '';
+    const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
+    const CORS = {
+      'Access-Control-Allow-Origin': corsOrigin,
+      'Vary': 'Origin',
+      'Access-Control-Allow-Methods': 'GET, OPTIONS',
+      'Access-Control-Allow-Headers': 'Content-Type',
+      'Cache-Control': 'public, max-age=3600'
+    };
+    if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
+    if (req.method !== 'GET') return send(res, 405, { err: 'GET required' }, CORS);
+    try {
+      const d = loadColorDots();
+      return send(res, 200, d, CORS);
+    } catch (e) {
+      return send(res, 503, { ok: false, err: 'color dots unavailable', dots: [] }, CORS);
+    }
+  }
+
   if (u.pathname === '/apps/color-index') {
     const ALLOWED_ORIGINS = new Set([
       'https://designerwallcoverings.com',

← a9c6a11 chore: version-up v1.5.0 (session close — shop-by-color prod  ·  back to Dw Photo Capture  ·  auto-save: 2026-07-20T03:59:45 (1 files) — data/color-dots.j 2a2122c →