[object Object]

← back to Dw Discovery

initial scaffold: DW discovery (Boost-SD-replica faceted layer)

078f4b5fb400d199a24a1de0214f3f63dd77a894 · 2026-06-24 07:15:44 -0700 · Steve

Files touched

Diff

commit 078f4b5fb400d199a24a1de0214f3f63dd77a894
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 07:15:44 2026 -0700

    initial scaffold: DW discovery (Boost-SD-replica faceted layer)
---
 .gitignore         |   8 +
 README.md          |  15 ++
 data/products.json | 722 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 dw-discovery.js    | 304 ++++++++++++++++++++++
 index.html         | 104 ++++++++
 5 files changed, 1153 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..51ffc0c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+# dw-discovery
+
+DW-owned faceted search, merchandising & display layer — replicates Boost AI Search
+& Discovery as features we control. Zero dependencies, drop into any DW grid:
+
+```html
+<script src="dw-discovery.js"></script>
+<script>new DWDiscovery({ mount:'#grid', products });</script>
+```
+
+Features: filter trees · merchandising (pin/boost/bury) · filter layout (sidebar↔top)
+· display settings (density + card fields) · analytics insights · merge-values
+canonicalization · custom dual-handle range sliders · color swatches.
+
+State persists to localStorage. `data/products.json` is DW-shaped sample data.
diff --git a/data/products.json b/data/products.json
new file mode 100644
index 0000000..207fe73
--- /dev/null
+++ b/data/products.json
@@ -0,0 +1,722 @@
+[
+{
+"id": 1,
+"sku": "DW-1001",
+"title": "Damask Grasscloth Celadon",
+"vendor": "Thibaut",
+"category": "Wallcovering",
+"subtype": "Grasscloth",
+"style": "Damask",
+"colorName": "Celadon",
+"colorBucket": "Green",
+"hex": "#9bb6a1",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 98,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 3
+},
+{
+"id": 2,
+"sku": "DW-1002",
+"title": "Stripe Silk Oatmeal",
+"vendor": "Thibaut",
+"category": "Wallcovering",
+"subtype": "Silk",
+"style": "Stripe",
+"colorName": "Oatmeal",
+"colorBucket": "Neutral",
+"hex": "#d9cdb8",
+"width_in": 36,
+"repeat_in": 12,
+"price": 120,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 6
+},
+{
+"id": 3,
+"sku": "DW-1003",
+"title": "Modern Linen Weave Terracotta",
+"vendor": "Thibaut",
+"category": "Wallcovering",
+"subtype": "Linen Weave",
+"style": "Modern",
+"colorName": "Terracotta",
+"colorBucket": "Warm",
+"hex": "#b5683f",
+"width_in": 52,
+"repeat_in": 21,
+"price": 142,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 9
+},
+{
+"id": 4,
+"sku": "DW-1004",
+"title": "Geometric Vinyl Charcoal",
+"vendor": "Thibaut",
+"category": "Wallcovering",
+"subtype": "Vinyl",
+"style": "Geometric",
+"colorName": "Charcoal",
+"colorBucket": "Dark",
+"hex": "#33353a",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 164,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 12
+},
+{
+"id": 5,
+"sku": "DW-1005",
+"title": "Texture Linen Crimson",
+"vendor": "Schumacher",
+"category": "Fabric",
+"subtype": "Linen",
+"style": "Texture",
+"colorName": "Crimson",
+"colorBucket": "Red",
+"hex": "#8e2a33",
+"width_in": 18,
+"repeat_in": 0,
+"price": 170,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 15
+},
+{
+"id": 6,
+"sku": "DW-1006",
+"title": "Floral Cotton Print Indigo",
+"vendor": "Schumacher",
+"category": "Fabric",
+"subtype": "Cotton Print",
+"style": "Floral",
+"colorName": "Indigo",
+"colorBucket": "Blue",
+"hex": "#34406b",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 192,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 18
+},
+{
+"id": 7,
+"sku": "DW-1007",
+"title": "Chinoiserie Sheer Forest",
+"vendor": "Schumacher",
+"category": "Fabric",
+"subtype": "Sheer",
+"style": "Chinoiserie",
+"colorName": "Forest",
+"colorBucket": "Green",
+"hex": "#2e4d3a",
+"width_in": 36,
+"repeat_in": 12,
+"price": 214,
+"inStock": true,
+"featured": true,
+"createdDaysAgo": 21
+},
+{
+"id": 8,
+"sku": "DW-1008",
+"title": "Traditional Wool Blend Alabaster",
+"vendor": "Schumacher",
+"category": "Fabric",
+"subtype": "Wool Blend",
+"style": "Traditional",
+"colorName": "Alabaster",
+"colorBucket": "Neutral",
+"hex": "#ece6da",
+"width_in": 52,
+"repeat_in": 21,
+"price": 236,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 24
+},
+{
+"id": 9,
+"sku": "DW-1009",
+"title": "Damask Linen Weave Ochre",
+"vendor": "Cole & Son",
+"category": "Wallcovering",
+"subtype": "Linen Weave",
+"style": "Damask",
+"colorName": "Ochre",
+"colorBucket": "Warm",
+"hex": "#c79a3c",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 76,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 27
+},
+{
+"id": 10,
+"sku": "DW-1010",
+"title": "Stripe Vinyl Espresso",
+"vendor": "Cole & Son",
+"category": "Wallcovering",
+"subtype": "Vinyl",
+"style": "Stripe",
+"colorName": "Espresso",
+"colorBucket": "Dark",
+"hex": "#3a2c26",
+"width_in": 18,
+"repeat_in": 0,
+"price": 98,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 30
+},
+{
+"id": 11,
+"sku": "DW-1011",
+"title": "Modern Mural Brick",
+"vendor": "Cole & Son",
+"category": "Wallcovering",
+"subtype": "Mural",
+"style": "Modern",
+"colorName": "Brick",
+"colorBucket": "Red",
+"hex": "#9c4a3c",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 120,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 33
+},
+{
+"id": 12,
+"sku": "DW-1012",
+"title": "Geometric Flock Sky",
+"vendor": "Cole & Son",
+"category": "Wallcovering",
+"subtype": "Flock",
+"style": "Geometric",
+"colorName": "Sky",
+"colorBucket": "Blue",
+"hex": "#7fa6cf",
+"width_in": 36,
+"repeat_in": 12,
+"price": 142,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 36
+},
+{
+"id": 13,
+"sku": "DW-1013",
+"title": "Texture Sheer Sage",
+"vendor": "Kravet",
+"category": "Fabric",
+"subtype": "Sheer",
+"style": "Texture",
+"colorName": "Sage",
+"colorBucket": "Green",
+"hex": "#8a9a7b",
+"width_in": 52,
+"repeat_in": 21,
+"price": 148,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 39
+},
+{
+"id": 14,
+"sku": "DW-1014",
+"title": "Floral Wool Blend Greige",
+"vendor": "Kravet",
+"category": "Fabric",
+"subtype": "Wool Blend",
+"style": "Floral",
+"colorName": "Greige",
+"colorBucket": "Neutral",
+"hex": "#b8ada0",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 170,
+"inStock": true,
+"featured": true,
+"createdDaysAgo": 42
+},
+{
+"id": 15,
+"sku": "DW-1015",
+"title": "Chinoiserie Embroidery Blush",
+"vendor": "Kravet",
+"category": "Fabric",
+"subtype": "Embroidery",
+"style": "Chinoiserie",
+"colorName": "Blush",
+"colorBucket": "Warm",
+"hex": "#e0b3ac",
+"width_in": 18,
+"repeat_in": 0,
+"price": 192,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 45
+},
+{
+"id": 16,
+"sku": "DW-1016",
+"title": "Traditional Velvet Onyx",
+"vendor": "Kravet",
+"category": "Fabric",
+"subtype": "Velvet",
+"style": "Traditional",
+"colorName": "Onyx",
+"colorBucket": "Dark",
+"hex": "#1c1c1e",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 214,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 48
+},
+{
+"id": 17,
+"sku": "DW-1017",
+"title": "Damask Mural Crimson",
+"vendor": "Pierre Frey",
+"category": "Wallcovering",
+"subtype": "Mural",
+"style": "Damask",
+"colorName": "Crimson",
+"colorBucket": "Red",
+"hex": "#8e2a33",
+"width_in": 36,
+"repeat_in": 12,
+"price": 252,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 51
+},
+{
+"id": 18,
+"sku": "DW-1018",
+"title": "Stripe Flock Navy",
+"vendor": "Pierre Frey",
+"category": "Wallcovering",
+"subtype": "Flock",
+"style": "Stripe",
+"colorName": "Navy",
+"colorBucket": "Blue",
+"hex": "#1f2d4d",
+"width_in": 52,
+"repeat_in": 21,
+"price": 76,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 54
+},
+{
+"id": 19,
+"sku": "DW-1019",
+"title": "Modern Grasscloth Celadon",
+"vendor": "Pierre Frey",
+"category": "Wallcovering",
+"subtype": "Grasscloth",
+"style": "Modern",
+"colorName": "Celadon",
+"colorBucket": "Green",
+"hex": "#9bb6a1",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 98,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 57
+},
+{
+"id": 20,
+"sku": "DW-1020",
+"title": "Geometric Silk Oatmeal",
+"vendor": "Pierre Frey",
+"category": "Wallcovering",
+"subtype": "Silk",
+"style": "Geometric",
+"colorName": "Oatmeal",
+"colorBucket": "Neutral",
+"hex": "#d9cdb8",
+"width_in": 18,
+"repeat_in": 0,
+"price": 120,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 60
+},
+{
+"id": 21,
+"sku": "DW-1021",
+"title": "Texture Embroidery Terracotta",
+"vendor": "Designers Guild",
+"category": "Fabric",
+"subtype": "Embroidery",
+"style": "Texture",
+"colorName": "Terracotta",
+"colorBucket": "Warm",
+"hex": "#b5683f",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 126,
+"inStock": true,
+"featured": true,
+"createdDaysAgo": 63
+},
+{
+"id": 22,
+"sku": "DW-1022",
+"title": "Floral Velvet Charcoal",
+"vendor": "Designers Guild",
+"category": "Fabric",
+"subtype": "Velvet",
+"style": "Floral",
+"colorName": "Charcoal",
+"colorBucket": "Dark",
+"hex": "#33353a",
+"width_in": 36,
+"repeat_in": 12,
+"price": 148,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 66
+},
+{
+"id": 23,
+"sku": "DW-1023",
+"title": "Chinoiserie Linen Brick",
+"vendor": "Designers Guild",
+"category": "Fabric",
+"subtype": "Linen",
+"style": "Chinoiserie",
+"colorName": "Brick",
+"colorBucket": "Red",
+"hex": "#9c4a3c",
+"width_in": 52,
+"repeat_in": 21,
+"price": 170,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 69
+},
+{
+"id": 24,
+"sku": "DW-1024",
+"title": "Traditional Cotton Print Indigo",
+"vendor": "Designers Guild",
+"category": "Fabric",
+"subtype": "Cotton Print",
+"style": "Traditional",
+"colorName": "Indigo",
+"colorBucket": "Blue",
+"hex": "#34406b",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 192,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 72
+},
+{
+"id": 25,
+"sku": "DW-1025",
+"title": "Damask Grasscloth Forest",
+"vendor": "Romo",
+"category": "Wallcovering",
+"subtype": "Grasscloth",
+"style": "Damask",
+"colorName": "Forest",
+"colorBucket": "Green",
+"hex": "#2e4d3a",
+"width_in": 18,
+"repeat_in": 0,
+"price": 230,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 75
+},
+{
+"id": 26,
+"sku": "DW-1026",
+"title": "Stripe Silk Alabaster",
+"vendor": "Romo",
+"category": "Wallcovering",
+"subtype": "Silk",
+"style": "Stripe",
+"colorName": "Alabaster",
+"colorBucket": "Neutral",
+"hex": "#ece6da",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 252,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 78
+},
+{
+"id": 27,
+"sku": "DW-1027",
+"title": "Modern Linen Weave Ochre",
+"vendor": "Romo",
+"category": "Wallcovering",
+"subtype": "Linen Weave",
+"style": "Modern",
+"colorName": "Ochre",
+"colorBucket": "Warm",
+"hex": "#c79a3c",
+"width_in": 36,
+"repeat_in": 12,
+"price": 76,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 81
+},
+{
+"id": 28,
+"sku": "DW-1028",
+"title": "Geometric Vinyl Espresso",
+"vendor": "Romo",
+"category": "Wallcovering",
+"subtype": "Vinyl",
+"style": "Geometric",
+"colorName": "Espresso",
+"colorBucket": "Dark",
+"hex": "#3a2c26",
+"width_in": 52,
+"repeat_in": 21,
+"price": 98,
+"inStock": false,
+"featured": true,
+"createdDaysAgo": 84
+},
+{
+"id": 29,
+"sku": "DW-1029",
+"title": "Texture Linen Crimson",
+"vendor": "Osborne & Little",
+"category": "Fabric",
+"subtype": "Linen",
+"style": "Texture",
+"colorName": "Crimson",
+"colorBucket": "Red",
+"hex": "#8e2a33",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 104,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 87
+},
+{
+"id": 30,
+"sku": "DW-1030",
+"title": "Floral Cotton Print Sky",
+"vendor": "Osborne & Little",
+"category": "Fabric",
+"subtype": "Cotton Print",
+"style": "Floral",
+"colorName": "Sky",
+"colorBucket": "Blue",
+"hex": "#7fa6cf",
+"width_in": 18,
+"repeat_in": 0,
+"price": 126,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 0
+},
+{
+"id": 31,
+"sku": "DW-1031",
+"title": "Chinoiserie Sheer Sage",
+"vendor": "Osborne & Little",
+"category": "Fabric",
+"subtype": "Sheer",
+"style": "Chinoiserie",
+"colorName": "Sage",
+"colorBucket": "Green",
+"hex": "#8a9a7b",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 148,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 3
+},
+{
+"id": 32,
+"sku": "DW-1032",
+"title": "Traditional Wool Blend Greige",
+"vendor": "Osborne & Little",
+"category": "Fabric",
+"subtype": "Wool Blend",
+"style": "Traditional",
+"colorName": "Greige",
+"colorBucket": "Neutral",
+"hex": "#b8ada0",
+"width_in": 36,
+"repeat_in": 12,
+"price": 170,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 6
+},
+{
+"id": 33,
+"sku": "DW-1033",
+"title": "Damask Linen Weave Blush",
+"vendor": "Zoffany",
+"category": "Wallcovering",
+"subtype": "Linen Weave",
+"style": "Damask",
+"colorName": "Blush",
+"colorBucket": "Warm",
+"hex": "#e0b3ac",
+"width_in": 52,
+"repeat_in": 21,
+"price": 208,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 9
+},
+{
+"id": 34,
+"sku": "DW-1034",
+"title": "Stripe Vinyl Onyx",
+"vendor": "Zoffany",
+"category": "Wallcovering",
+"subtype": "Vinyl",
+"style": "Stripe",
+"colorName": "Onyx",
+"colorBucket": "Dark",
+"hex": "#1c1c1e",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 230,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 12
+},
+{
+"id": 35,
+"sku": "DW-1035",
+"title": "Modern Mural Brick",
+"vendor": "Zoffany",
+"category": "Wallcovering",
+"subtype": "Mural",
+"style": "Modern",
+"colorName": "Brick",
+"colorBucket": "Red",
+"hex": "#9c4a3c",
+"width_in": 18,
+"repeat_in": 0,
+"price": 252,
+"inStock": true,
+"featured": true,
+"createdDaysAgo": 15
+},
+{
+"id": 36,
+"sku": "DW-1036",
+"title": "Geometric Flock Navy",
+"vendor": "Zoffany",
+"category": "Wallcovering",
+"subtype": "Flock",
+"style": "Geometric",
+"colorName": "Navy",
+"colorBucket": "Blue",
+"hex": "#1f2d4d",
+"width_in": 27,
+"repeat_in": 6.5,
+"price": 76,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 18
+},
+{
+"id": 37,
+"sku": "DW-1037",
+"title": "Texture Sheer Celadon",
+"vendor": "Harlequin",
+"category": "Fabric",
+"subtype": "Sheer",
+"style": "Texture",
+"colorName": "Celadon",
+"colorBucket": "Green",
+"hex": "#9bb6a1",
+"width_in": 36,
+"repeat_in": 12,
+"price": 82,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 21
+},
+{
+"id": 38,
+"sku": "DW-1038",
+"title": "Floral Wool Blend Oatmeal",
+"vendor": "Harlequin",
+"category": "Fabric",
+"subtype": "Wool Blend",
+"style": "Floral",
+"colorName": "Oatmeal",
+"colorBucket": "Neutral",
+"hex": "#d9cdb8",
+"width_in": 52,
+"repeat_in": 21,
+"price": 104,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 24
+},
+{
+"id": 39,
+"sku": "DW-1039",
+"title": "Chinoiserie Embroidery Terracotta",
+"vendor": "Harlequin",
+"category": "Fabric",
+"subtype": "Embroidery",
+"style": "Chinoiserie",
+"colorName": "Terracotta",
+"colorBucket": "Warm",
+"hex": "#b5683f",
+"width_in": 54,
+"repeat_in": 25.25,
+"price": 126,
+"inStock": true,
+"featured": false,
+"createdDaysAgo": 27
+},
+{
+"id": 40,
+"sku": "DW-1040",
+"title": "Traditional Velvet Charcoal",
+"vendor": "Harlequin",
+"category": "Fabric",
+"subtype": "Velvet",
+"style": "Traditional",
+"colorName": "Charcoal",
+"colorBucket": "Dark",
+"hex": "#33353a",
+"width_in": 18,
+"repeat_in": 0,
+"price": 148,
+"inStock": false,
+"featured": false,
+"createdDaysAgo": 30
+}
+]
\ No newline at end of file
diff --git a/dw-discovery.js b/dw-discovery.js
new file mode 100644
index 0000000..0411ba5
--- /dev/null
+++ b/dw-discovery.js
@@ -0,0 +1,304 @@
+/* dw-discovery.js — DW-owned faceted search & merchandising layer.
+ * Replicates Boost AI Search & Discovery as features we control. Zero dependencies.
+ * Drop into any DW grid: new DWDiscovery({ mount, products, config }).
+ *
+ * Feature map (Boost SD -> here):
+ *   Filter trees ........ nested, collapsible, multi-select facets w/ live counts
+ *   Merchandising ....... pin / boost / bury rules (featured-first, in-stock-first)
+ *   Filter layout ....... sidebar <-> top-bar, persisted
+ *   Display settings .... density slider + card-field toggles
+ *   Analytics ........... facet-usage counts + zero-result log (Insights panel)
+ *   Merge values ........ canonicalize raw facet values via a rules map
+ *   Range sliders ....... dual-handle Price / Width / Repeat
+ *   Swatches ............ color facet as dominant-hex chips
+ */
+(function (global) {
+  'use strict';
+  const LS = (k, d) => { try { return JSON.parse(localStorage.getItem('dwd:' + k)) ?? d; } catch { return d; } };
+  const SS = (k, v) => { try { localStorage.setItem('dwd:' + k, JSON.stringify(v)); } catch {} };
+  const el = (t, c, h) => { const e = document.createElement(t); if (c) e.className = c; if (h != null) e.innerHTML = h; return e; };
+
+  // ---- TOOLS · Merge values: canonicalize raw facet values into one bucket ----
+  const DEFAULT_MERGE = {
+    colorBucket: { 'Indigo': 'Blue', 'Sky': 'Blue', 'Navy': 'Blue' }, // alias examples
+    vendor: { 'Cole and Son': 'Cole & Son', 'Osborne and Little': 'Osborne & Little' }
+  };
+
+  function DWDiscovery(opts) {
+    const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
+    const products = opts.products.slice();
+    const cfg = Object.assign({
+      merge: DEFAULT_MERGE,
+      // Filter trees: parent facet -> { field, children:[{field}] }
+      tree: [
+        { label: 'Catalog', field: 'category', children: [{ label: 'Type', field: 'subtype' }] },
+        { label: 'Style', field: 'style' },
+        { label: 'Vendor', field: 'vendor' }
+      ],
+      ranges: [
+        { label: 'Price', field: 'price', prefix: '$', step: 1 },
+        { label: 'Width', field: 'width_in', suffix: '"', step: 1 },
+        { label: 'Repeat', field: 'repeat_in', suffix: '"', step: 0.5 }
+      ],
+      swatchField: 'colorBucket'
+    }, opts.config || {});
+
+    const merge = (field, val) => (cfg.merge[field] && cfg.merge[field][val]) || val;
+
+    // persisted UI state
+    const state = {
+      facets: LS('facets', {}),            // {field: [values]}
+      ranges: LS('ranges', {}),            // {field: [min,max]}
+      swatches: LS('swatches', []),
+      sort: LS('sort', 'featured'),
+      cols: LS('cols', 4),
+      layout: LS('layout', 'sidebar'),     // Filter layout
+      cardFields: LS('cardFields', { price: true, vendor: true, swatch: true }),
+      merchandising: LS('merch', { featuredFirst: true, inStockFirst: true }),
+      query: ''
+    };
+    // INSIGHTS · Analytics
+    const analytics = LS('analytics', { facetClicks: {}, zeroResults: [], applies: 0 });
+    const track = (field, value) => {
+      const k = field + ':' + value;
+      analytics.facetClicks[k] = (analytics.facetClicks[k] || 0) + 1;
+      analytics.applies++; SS('analytics', analytics);
+    };
+
+    // ---------- core filtering ----------
+    function bounds(field) {
+      let lo = Infinity, hi = -Infinity;
+      products.forEach(p => { const v = +p[field]; if (v < lo) lo = v; if (v > hi) hi = v; });
+      return [Math.floor(lo), Math.ceil(hi)];
+    }
+    function passes(p) {
+      if (state.query) {
+        const q = state.query.toLowerCase();
+        if (!(p.title + ' ' + p.vendor + ' ' + p.colorName).toLowerCase().includes(q)) return false;
+      }
+      for (const f in state.facets) {
+        const sel = state.facets[f]; if (!sel.length) continue;
+        if (!sel.includes(merge(f, p[f]))) return false;
+      }
+      for (const f in state.ranges) {
+        const [mn, mx] = state.ranges[f]; const v = +p[f];
+        if (v < mn || v > mx) return false;
+      }
+      if (state.swatches.length && !state.swatches.includes(merge(cfg.swatchField, p[cfg.swatchField]))) return false;
+      return true;
+    }
+    function result() {
+      let r = products.filter(passes);
+      // MERCHANDISING + sort
+      const s = state.sort;
+      r.sort((a, b) => {
+        if (state.merchandising.featuredFirst && a.featured !== b.featured) return a.featured ? -1 : 1;
+        if (state.merchandising.inStockFirst && a.inStock !== b.inStock) return a.inStock ? -1 : 1;
+        if (s === 'priceUp') return a.price - b.price;
+        if (s === 'priceDown') return b.price - a.price;
+        if (s === 'title') return a.title.localeCompare(b.title);
+        if (s === 'newest') return a.createdDaysAgo - b.createdDaysAgo;
+        return 0;
+      });
+      return r;
+    }
+    // facet counts honor all OTHER active filters (true faceted behavior)
+    function countsFor(field) {
+      const tmp = JSON.parse(JSON.stringify(state.facets)); delete tmp[field];
+      const sub = products.filter(p => {
+        for (const f in tmp) { if (tmp[f].length && !tmp[f].includes(merge(f, p[f]))) return false; }
+        return true;
+      });
+      const m = {};
+      sub.forEach(p => { const v = merge(field, p[field]); m[v] = (m[v] || 0) + 1; });
+      return m;
+    }
+
+    // ---------- rendering ----------
+    mount.classList.add('dwd');
+    mount.dataset.layout = state.layout;
+    const aside = el('aside', 'dwd-panel');
+    const main = el('section', 'dwd-main');
+    mount.append(aside, main);
+
+    function persist() {
+      SS('facets', state.facets); SS('ranges', state.ranges); SS('swatches', state.swatches);
+      SS('sort', state.sort); SS('cols', state.cols); SS('layout', state.layout);
+      SS('cardFields', state.cardFields); SS('merch', state.merchandising);
+    }
+
+    function renderTree() {
+      const wrap = el('div', 'dwd-sec');
+      wrap.append(el('h4', null, 'Filters'));
+      const walk = (node, depth) => {
+        const counts = countsFor(node.field);
+        const vals = Object.keys(counts).sort();
+        const grp = el('div', 'dwd-grp');
+        const head = el('button', 'dwd-grp-h', `<span>${node.label}</span><span class="dwd-caret">▾</span>`);
+        const body = el('div', 'dwd-grp-b');
+        const open = LS('open:' + node.field, depth === 0); body.style.display = open ? '' : 'none';
+        head.onclick = () => { const v = body.style.display === 'none'; body.style.display = v ? '' : 'none'; SS('open:' + node.field, v); };
+        grp.append(head, body);
+        vals.forEach(v => {
+          const sel = (state.facets[node.field] || []).includes(v);
+          const row = el('label', 'dwd-opt' + (sel ? ' on' : ''),
+            `<input type="checkbox" ${sel ? 'checked' : ''}><span>${v}</span><b>${counts[v]}</b>`);
+          row.querySelector('input').onchange = e => {
+            const arr = state.facets[node.field] || (state.facets[node.field] = []);
+            if (e.target.checked) { arr.push(v); track(node.field, v); } else arr.splice(arr.indexOf(v), 1);
+            persist(); render();
+          };
+          body.append(row);
+        });
+        if (node.children) node.children.forEach(c => body.append(walk(c, depth + 1)));
+        return grp;
+      };
+      cfg.tree.forEach(n => wrap.append(walk(n, 0)));
+      return wrap;
+    }
+
+    // TOOLS · Swatches
+    function renderSwatches() {
+      const wrap = el('div', 'dwd-sec'); wrap.append(el('h4', null, 'Color'));
+      const counts = countsFor(cfg.swatchField);
+      const hexByBucket = {};
+      products.forEach(p => { hexByBucket[merge(cfg.swatchField, p[cfg.swatchField])] = p.hex; });
+      const row = el('div', 'dwd-swatches');
+      Object.keys(counts).sort().forEach(b => {
+        const on = state.swatches.includes(b);
+        const c = el('button', 'dwd-sw' + (on ? ' on' : ''),
+          `<i style="background:${hexByBucket[b]}"></i><span>${b}</span><b>${counts[b]}</b>`);
+        c.onclick = () => {
+          const i = state.swatches.indexOf(b);
+          if (i < 0) { state.swatches.push(b); track(cfg.swatchField, b); } else state.swatches.splice(i, 1);
+          persist(); render();
+        };
+        row.append(c);
+      });
+      wrap.append(row); return wrap;
+    }
+
+    // TOOLS · Custom range sliders (dual handle, zero-dep)
+    function renderRanges() {
+      const wrap = el('div', 'dwd-sec'); wrap.append(el('h4', null, 'Ranges'));
+      cfg.ranges.forEach(r => {
+        const [lo, hi] = bounds(r.field);
+        const cur = state.ranges[r.field] || [lo, hi];
+        const box = el('div', 'dwd-range');
+        const fmt = v => (r.prefix || '') + v + (r.suffix || '');
+        box.innerHTML = `<div class="dwd-range-h"><span>${r.label}</span><em>${fmt(cur[0])} – ${fmt(cur[1])}</em></div>
+          <div class="dwd-dual"><input type="range" min="${lo}" max="${hi}" step="${r.step}" value="${cur[0]}">
+          <input type="range" min="${lo}" max="${hi}" step="${r.step}" value="${cur[1]}"></div>`;
+        const [a, b] = box.querySelectorAll('input');
+        const lab = box.querySelector('em');
+        const sync = () => {
+          let mn = +a.value, mx = +b.value; if (mn > mx) [mn, mx] = [mx, mn];
+          state.ranges[r.field] = [mn, mx]; lab.textContent = `${fmt(mn)} – ${fmt(mx)}`;
+        };
+        const commit = () => { sync(); track('range', r.field); persist(); render(); };
+        a.oninput = b.oninput = sync; a.onchange = b.onchange = commit;
+        wrap.append(box);
+      });
+      return wrap;
+    }
+
+    function renderControls() {
+      const bar = el('div', 'dwd-controls');
+      // search
+      const q = el('input', 'dwd-search'); q.placeholder = 'Search patterns…'; q.value = state.query;
+      q.oninput = () => { state.query = q.value; render(); };
+      // sort
+      const sort = el('select', 'dwd-sel');
+      [['featured', 'Featured'], ['newest', 'Newest'], ['priceUp', 'Price ↑'], ['priceDown', 'Price ↓'], ['title', 'Title A→Z']]
+        .forEach(([v, l]) => { const o = el('option', null, l); o.value = v; if (v === state.sort) o.selected = true; sort.append(o); });
+      sort.onchange = () => { state.sort = sort.value; persist(); render(); };
+      // density (Display settings)
+      const dens = el('input'); dens.type = 'range'; dens.min = 2; dens.max = 6; dens.value = state.cols; dens.className = 'dwd-dens';
+      dens.oninput = () => { state.cols = +dens.value; main.querySelector('.dwd-grid').style.setProperty('--cols', state.cols); persist(); };
+      // layout toggle (Filter layout)
+      const lay = el('button', 'dwd-btn', state.layout === 'sidebar' ? '▤ Sidebar' : '▥ Top bar');
+      lay.onclick = () => { state.layout = state.layout === 'sidebar' ? 'top' : 'sidebar'; mount.dataset.layout = state.layout; lay.textContent = state.layout === 'sidebar' ? '▤ Sidebar' : '▥ Top bar'; persist(); };
+      // card fields (Display settings)
+      const fldWrap = el('div', 'dwd-fields');
+      Object.keys(state.cardFields).forEach(k => {
+        const b = el('button', 'dwd-chip' + (state.cardFields[k] ? ' on' : ''), k);
+        b.onclick = () => { state.cardFields[k] = !state.cardFields[k]; b.classList.toggle('on'); persist(); render(); };
+        fldWrap.append(b);
+      });
+      bar.append(q, sort, el('span', 'dwd-lab', 'Density'), dens, lay, fldWrap);
+      return bar;
+    }
+
+    // INSIGHTS · Analytics panel
+    function renderInsights() {
+      const top = Object.entries(analytics.facetClicks).sort((a, b) => b[1] - a[1]).slice(0, 6);
+      const z = analytics.zeroResults.slice(-5).reverse();
+      const box = el('details', 'dwd-insights');
+      box.innerHTML = `<summary>Insights · ${analytics.applies} filter actions</summary>
+        <div class="dwd-ins-cols">
+          <div><h5>Top facets</h5>${top.length ? top.map(([k, n]) => `<div class="dwd-ins-row"><span>${k}</span><b>${n}</b></div>`).join('') : '<em>none yet</em>'}</div>
+          <div><h5>Zero-result searches</h5>${z.length ? z.map(s => `<div class="dwd-ins-row"><span>“${s}”</span></div>`).join('') : '<em>none yet</em>'}</div>
+        </div>`;
+      return box;
+    }
+
+    function renderMerch() {
+      const wrap = el('div', 'dwd-sec'); wrap.append(el('h4', null, 'Merchandising'));
+      [['featuredFirst', 'Featured first'], ['inStockFirst', 'In-stock first']].forEach(([k, l]) => {
+        const b = el('label', 'dwd-opt' + (state.merchandising[k] ? ' on' : ''),
+          `<input type="checkbox" ${state.merchandising[k] ? 'checked' : ''}><span>${l}</span>`);
+        b.querySelector('input').onchange = e => { state.merchandising[k] = e.target.checked; persist(); render(); };
+        wrap.append(b);
+      });
+      return wrap;
+    }
+
+    function card(p) {
+      const cf = state.cardFields;
+      const sw = cf.swatch ? `<i class="dwd-card-sw" style="background:${p.hex}" title="${p.colorName}"></i>` : '';
+      const badges = (p.featured ? '<span class="dwd-badge feat">Featured</span>' : '') + (!p.inStock ? '<span class="dwd-badge oos">Made to order</span>' : '');
+      return el('article', 'dwd-card', `
+        <div class="dwd-thumb" style="--c:${p.hex}">${sw}${badges}</div>
+        <div class="dwd-meta">
+          <div class="dwd-title">${p.title}</div>
+          ${cf.vendor ? `<div class="dwd-vendor">${p.vendor}</div>` : ''}
+          <div class="dwd-row2">
+            ${cf.price ? `<span class="dwd-price">$${p.price.toFixed(2)}</span>` : '<span></span>'}
+            <span class="dwd-spec">${p.width_in}" · rpt ${p.repeat_in}"</span>
+          </div>
+        </div>`);
+    }
+
+    function activeChips() {
+      const chips = el('div', 'dwd-active');
+      const add = (label, clear) => { const c = el('button', 'dwd-tag', `${label} ✕`); c.onclick = clear; chips.append(c); };
+      for (const f in state.facets) state.facets[f].slice().forEach(v => add(v, () => { state.facets[f].splice(state.facets[f].indexOf(v), 1); persist(); render(); }));
+      state.swatches.slice().forEach(v => add('● ' + v, () => { state.swatches.splice(state.swatches.indexOf(v), 1); persist(); render(); }));
+      for (const f in state.ranges) add(f, () => { delete state.ranges[f]; persist(); render(); });
+      if (chips.children.length) { const c = el('button', 'dwd-tag clear', 'Clear all'); c.onclick = () => { state.facets = {}; state.ranges = {}; state.swatches = []; persist(); render(); }; chips.append(c); }
+      return chips;
+    }
+
+    function render() {
+      const r = result();
+      if (!r.length && (state.query || Object.keys(state.facets).length)) {
+        if (state.query) { analytics.zeroResults.push(state.query); SS('analytics', analytics); }
+      }
+      aside.innerHTML = ''; main.innerHTML = '';
+      aside.append(renderTree(), renderSwatches(), renderRanges(), renderMerch());
+      const head = el('div', 'dwd-head');
+      head.append(renderControls());
+      const count = el('div', 'dwd-count', `<strong>${r.length}</strong> of ${products.length} products`);
+      main.append(head, count, activeChips(), renderInsights());
+      const grid = el('div', 'dwd-grid'); grid.style.setProperty('--cols', state.cols);
+      if (!r.length) grid.append(el('div', 'dwd-empty', 'No products match — try removing a filter.'));
+      r.forEach(p => grid.append(card(p)));
+      main.append(grid);
+    }
+
+    render();
+    return { state, render, analytics };
+  }
+
+  global.DWDiscovery = DWDiscovery;
+})(window);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..1d751f2
--- /dev/null
+++ b/index.html
@@ -0,0 +1,104 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>DW Discovery — faceted search & merchandising</title>
+<style>
+  :root{ --ink:#1c1c1e; --mut:#6b6b70; --line:#e6e2da; --bg:#faf8f4; --accent:#2e4d3a; --card:#fff; }
+  *{box-sizing:border-box}
+  body{margin:0;font:15px/1.5 "Helvetica Neue",Arial,sans-serif;color:var(--ink);background:var(--bg)}
+  header.site{padding:22px 28px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:16px}
+  header.site .logo{font:600 22px/1 "Times New Roman",serif;letter-spacing:.14em;text-transform:uppercase}
+  header.site .tag{color:var(--mut);font-style:italic;font-size:13px}
+  .crumb{padding:14px 28px;color:var(--mut);font-size:13px;letter-spacing:.04em;text-transform:uppercase}
+
+  /* layout */
+  .dwd{display:grid;gap:24px;padding:0 28px 60px}
+  .dwd[data-layout=sidebar]{grid-template-columns:248px 1fr}
+  .dwd[data-layout=top]{grid-template-columns:1fr}
+  .dwd[data-layout=top] .dwd-panel{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;order:-1}
+  .dwd-panel{align-self:start;position:sticky;top:14px}
+  @media(max-width:820px){ .dwd[data-layout=sidebar]{grid-template-columns:1fr} .dwd-panel{position:static} }
+
+  .dwd-sec{margin-bottom:22px}
+  .dwd-sec h4{margin:0 0 8px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--mut)}
+  .dwd-grp{border-top:1px solid var(--line)}
+  .dwd-grp-h{width:100%;display:flex;justify-content:space-between;align-items:center;background:none;border:0;padding:9px 0;cursor:pointer;font-weight:600;font-size:13px;color:var(--ink)}
+  .dwd-caret{color:var(--mut)}
+  .dwd-grp-b{padding:2px 0 8px 4px}
+  .dwd-opt{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:13px;cursor:pointer;color:#39393d}
+  .dwd-opt b{margin-left:auto;color:var(--mut);font-weight:500;font-size:12px}
+  .dwd-opt.on{color:var(--accent);font-weight:600}
+  .dwd-opt input{accent-color:var(--accent)}
+  .dwd-grp .dwd-grp{margin-left:10px;border-top:1px dashed var(--line)}
+
+  .dwd-swatches{display:flex;flex-wrap:wrap;gap:6px}
+  .dwd-sw{display:flex;align-items:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:20px;padding:4px 9px 4px 5px;cursor:pointer;font-size:12px}
+  .dwd-sw i{width:15px;height:15px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)}
+  .dwd-sw b{color:var(--mut);font-weight:500}
+  .dwd-sw.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
+
+  .dwd-range{margin:10px 0 16px}
+  .dwd-range-h{display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px}
+  .dwd-range-h em{color:var(--accent);font-style:normal;font-weight:600}
+  .dwd-dual{position:relative;height:24px}
+  .dwd-dual input{position:absolute;width:100%;margin:0;-webkit-appearance:none;appearance:none;background:none;pointer-events:none;top:8px}
+  .dwd-dual input::-webkit-slider-thumb{-webkit-appearance:none;pointer-events:all;width:15px;height:15px;border-radius:50%;background:var(--accent);border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:pointer}
+  .dwd-dual::before{content:"";position:absolute;left:0;right:0;top:14px;height:3px;background:var(--line);border-radius:2px}
+
+  .dwd-head{display:flex;flex-direction:column;gap:10px;margin-bottom:6px}
+  .dwd-controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
+  .dwd-search{flex:1;min-width:180px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;font-size:14px}
+  .dwd-sel{padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:13px}
+  .dwd-lab{font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.08em}
+  .dwd-dens{width:96px;accent-color:var(--accent)}
+  .dwd-btn{padding:8px 12px;border:1px solid var(--line);background:#fff;border-radius:6px;cursor:pointer;font-size:13px}
+  .dwd-fields{display:flex;gap:5px;margin-left:auto}
+  .dwd-chip{border:1px solid var(--line);background:#fff;border-radius:5px;padding:6px 9px;font-size:11px;text-transform:capitalize;cursor:pointer;color:var(--mut)}
+  .dwd-chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
+
+  .dwd-count{font-size:13px;color:var(--mut)} .dwd-count strong{color:var(--ink)}
+  .dwd-active{display:flex;flex-wrap:wrap;gap:6px}
+  .dwd-tag{border:1px solid var(--accent);color:var(--accent);background:#fff;border-radius:16px;padding:3px 10px;font-size:12px;cursor:pointer}
+  .dwd-tag.clear{border-color:var(--mut);color:var(--mut)}
+
+  .dwd-insights{margin:6px 0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:6px 12px;font-size:13px}
+  .dwd-insights summary{cursor:pointer;color:var(--accent);font-weight:600}
+  .dwd-ins-cols{display:grid;grid-template-columns:1fr 1fr;gap:18px;padding:10px 2px}
+  .dwd-ins-cols h5{margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--mut)}
+  .dwd-ins-row{display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px dotted var(--line)}
+  .dwd-ins-row b{color:var(--accent)}
+
+  .dwd-grid{display:grid;grid-template-columns:repeat(var(--cols,4),1fr);gap:18px;margin-top:14px}
+  .dwd-card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;transition:.15s}
+  .dwd-card:hover{box-shadow:0 6px 20px rgba(0,0,0,.08);transform:translateY(-2px)}
+  .dwd-thumb{aspect-ratio:4/5;background:
+     repeating-linear-gradient(45deg,rgba(255,255,255,.35) 0 8px,transparent 8px 16px),var(--c);position:relative}
+  .dwd-card-sw{position:absolute;left:10px;bottom:10px;width:22px;height:22px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.3)}
+  .dwd-badge{position:absolute;top:10px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:3px 7px;border-radius:4px;color:#fff}
+  .dwd-badge.feat{left:10px;background:var(--accent)} .dwd-badge.oos{right:10px;background:#9a7b3c}
+  .dwd-meta{padding:11px 13px 14px}
+  .dwd-title{font-weight:600;font-size:14px;line-height:1.25}
+  .dwd-vendor{color:var(--mut);font-size:12px;margin-top:2px}
+  .dwd-row2{display:flex;justify-content:space-between;align-items:center;margin-top:8px}
+  .dwd-price{font-weight:600} .dwd-spec{color:var(--mut);font-size:11px}
+  .dwd-empty{grid-column:1/-1;text-align:center;color:var(--mut);padding:60px 0}
+</style>
+</head>
+<body>
+  <header class="site">
+    <span class="logo">Designer Wallcoverings</span>
+    <span class="tag">Discovery — faceted search &amp; merchandising</span>
+  </header>
+  <div class="crumb">Home / Collections / All Patterns</div>
+  <div id="grid"></div>
+
+  <script src="dw-discovery.js"></script>
+  <script>
+    fetch('data/products.json').then(r => r.json()).then(products => {
+      window.dwd = new DWDiscovery({ mount: '#grid', products });
+    });
+  </script>
+</body>
+</html>

(oldest)  ·  back to Dw Discovery  ·  add verified preview screenshot (40→20→3→2 filter chain conf 4a2f88a →