← back to Dw Discovery
initial scaffold: DW discovery (Boost-SD-replica faceted layer)
078f4b5fb400d199a24a1de0214f3f63dd77a894 · 2026-06-24 07:15:44 -0700 · Steve
Files touched
A .gitignoreA README.mdA data/products.jsonA dw-discovery.jsA index.html
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 & 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 →