[object Object]

← back to All Designerwallcoverings

full-catalog browser: in-memory dw_unified snapshot (153k products), word-anchored search, 9 sortable fields, chip-toggle facets (vendor/type/color/style/material/price/status/image) w/ type-ahead + expanders, density + infinite scroll; vendor directory preserved at /vendors.html

0ff51f2c63b2a99eefc49709d30ab38e4fb13196 · 2026-07-02 16:09:31 -0700 · Steve Abrams

Files touched

Diff

commit 0ff51f2c63b2a99eefc49709d30ab38e4fb13196
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 16:09:31 2026 -0700

    full-catalog browser: in-memory dw_unified snapshot (153k products), word-anchored search, 9 sortable fields, chip-toggle facets (vendor/type/color/style/material/price/status/image) w/ type-ahead + expanders, density + infinite scroll; vendor directory preserved at /vendors.html
---
 package-lock.json | 174 +++++++++++++++++++++++
 package.json      |   9 +-
 public/index.html | 405 ++++++++++++++++++++++++++++++++++--------------------
 server.js         | 248 +++++++++++++++++++++++++++------
 4 files changed, 649 insertions(+), 187 deletions(-)

diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b2dfa99
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,174 @@
+{
+  "name": "all-designerwallcoverings",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "all-designerwallcoverings",
+      "version": "1.0.0",
+      "dependencies": {
+        "dotenv": "^17.4.2",
+        "pg": "^8.22.0"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "17.4.2",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+      "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
index e818dff..a28682c 100644
--- a/package.json
+++ b/package.json
@@ -3,5 +3,12 @@
   "version": "1.0.0",
   "description": "Front-facing vendor directory for all dw_unified vendors — all.designerwallcoverings.com",
   "main": "server.js",
-  "scripts": { "start": "node server.js", "build-data": "python3 scripts/build-data.py" }
+  "scripts": {
+    "start": "node server.js",
+    "build-data": "python3 scripts/build-data.py"
+  },
+  "dependencies": {
+    "dotenv": "^17.4.2",
+    "pg": "^8.22.0"
+  }
 }
diff --git a/public/index.html b/public/index.html
index c625495..c3a2523 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,176 +3,287 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>All Vendors — Designer Wallcoverings</title>
-<meta name="description" content="The complete vendor index at Designer Wallcoverings — every wallcovering and fabric house we carry, in one place.">
+<title>All Products — Designer Wallcoverings</title>
 <style>
-  :root { --cols: 4; --ink:#161412; --paper:#f7f5f1; --gold:#a08a5f; --line:#dedad2; }
-  * { box-sizing: border-box; margin: 0; }
-  body { background: var(--paper); color: var(--ink); font-family: "Optima", "Segoe UI", system-ui, sans-serif; }
-  a { color: inherit; text-decoration: none; }
-
-  header { display:flex; justify-content:space-between; align-items:center;
-    padding: 22px 34px; position: sticky; top:0; background: rgba(247,245,241,.92);
-    backdrop-filter: blur(8px); z-index: 20; border-bottom: 1px solid var(--line); }
-  .wordmark { letter-spacing: .22em; font-size: 15px; text-transform: uppercase; font-weight: 600; }
-  .wordmark small { display:block; letter-spacing:.34em; font-size:9px; color:var(--gold); margin-top:3px; }
-  .burger { cursor:pointer; background:none; border:none; padding:8px; }
-  .burger span { display:block; width:24px; height:1.5px; background:var(--ink); margin:5px 0; }
-  nav.menu { display:none; position:fixed; top:0; right:0; bottom:0; width:290px; background:#141210;
-    color:#eee9df; z-index:40; padding:80px 40px; flex-direction:column; gap:26px;
-    font-size:13px; letter-spacing:.18em; text-transform:uppercase; }
-  nav.menu.open { display:flex; }
-  nav.menu .close { position:absolute; top:24px; right:28px; font-size:26px; cursor:pointer; background:none; border:none; color:#eee9df; }
-  nav.menu a:hover { color: var(--gold); }
-
-  .hero { padding: 74px 34px 58px; text-align:center; background:#141210; color:#f2eee6; }
-  .hero h1 { font-family: Georgia, serif; font-weight: 400; font-size: clamp(30px, 5vw, 52px); letter-spacing:.04em; }
-  .hero p { margin-top:14px; color:#b6ad9c; font-size:15px; letter-spacing:.08em; }
-  .hero .stats { margin-top: 26px; display:flex; gap:44px; justify-content:center; font-size:12px; letter-spacing:.24em; text-transform:uppercase; color:var(--gold); }
-  .hero .stats b { display:block; font-size:26px; color:#f2eee6; font-family: Georgia, serif; font-weight:400; letter-spacing:.02em; }
-
-  .controls { display:flex; flex-wrap:wrap; gap:14px 22px; align-items:center;
-    padding: 20px 34px; border-bottom:1px solid var(--line); background:var(--paper);
-    position: sticky; top: 69px; z-index: 15; }
-  .controls input[type=search] { flex:1 1 220px; max-width: 340px; padding:10px 14px; border:1px solid var(--line);
-    background:#fff; font-size:14px; border-radius:2px; }
-  .controls select { padding:10px 12px; border:1px solid var(--line); background:#fff; font-size:13px; border-radius:2px; }
-  .seg { display:flex; border:1px solid var(--line); border-radius:2px; overflow:hidden; }
-  .seg button { padding:9px 16px; background:#fff; border:none; font-size:12px; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; color:#7d766a; }
-  .seg button.on { background:var(--ink); color:#f2eee6; }
-  .dens { display:flex; align-items:center; gap:10px; font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#7d766a; }
-  .dens input { accent-color: var(--gold); width: 130px; }
-
-  .grid { display:grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 26px; padding: 34px; }
-  .card { background:#fff; border:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column;
-    transition: box-shadow .25s, transform .25s; }
-  .card:hover { box-shadow: 0 14px 34px rgba(22,20,18,.14); transform: translateY(-3px); }
-  .collage { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; aspect-ratio: 4/3; gap:1px; background:var(--line); }
-  .collage.single { grid-template-columns:1fr; grid-template-rows:1fr; }
-  .collage img { width:100%; height:100%; object-fit:cover; display:block; }
-  .collage .ph { display:flex; align-items:center; justify-content:center; background:#efece6; color:#c6bfb1;
-    font-family:Georgia,serif; font-size:34px; }
-  .card .meta { padding: 16px 18px 18px; }
-  .card h3 { font-family: Georgia, serif; font-weight:400; font-size:17px; letter-spacing:.03em; }
-  .card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
-  .card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
-
-  .sentinel { height: 10px; }
-  footer { padding: 44px 34px 60px; text-align:center; font-size:11px; letter-spacing:.2em; text-transform:uppercase; color:#9a927f; border-top:1px solid var(--line); }
-  @media (max-width: 900px){ .grid{ --cols: 2 !important; } .controls{ top:63px } }
+  :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
+  * { box-sizing: border-box; }
+  body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+  header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; max-height:78vh; overflow:auto; }
+  h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
+  h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
+  h1 a { color:var(--mut); font-size:12px; font-weight:400; margin-left:14px; text-decoration:none; border-bottom:1px dotted var(--mut); }
+  .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
+  select, input[type=search], input[type=text] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
+  input[type=search] { min-width:260px; }
+  .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
+  details.facets { margin-top:12px; }
+  details.facets > summary { cursor:pointer; color:var(--mut); font-size:12px; letter-spacing:.04em; text-transform:uppercase; list-style:none; user-select:none; padding:2px 0; }
+  details.facets > summary::-webkit-details-marker { display:none; }
+  details.facets > summary::before { content:'▸ '; color:var(--accent); }
+  details.facets[open] > summary::before { content:'▾ '; }
+  summary b { color:var(--accent); }
+  .facetbody { display:flex; flex-direction:column; gap:9px; margin-top:9px; }
+  .frow { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
+  .flabel { color:var(--mut); font-size:11px; text-transform:uppercase; letter-spacing:.06em; min-width:62px; }
+  .chip { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:4px 10px; cursor:pointer; font-size:11.5px; color:var(--mut); }
+  .chip.on { background:var(--accent); color:#1a1407; border-color:var(--accent); font-weight:600; }
+  .chip .ct { opacity:.65; margin-left:4px; font-size:10px; }
+  .chip.zero { opacity:.3; }
+  .chip.allchip { font-weight:600; border-style:dashed; }
+  .chip.allchip.on { border-style:solid; }
+  .chip.more { border-style:dotted; color:var(--accent); }
+  .ffind { padding:3px 9px !important; font-size:11px !important; border-radius:20px !important; min-width:110px; }
+  .active { display:flex; flex-wrap:wrap; gap:6px; align-items:center; padding-top:2px; }
+  .afilter { background:#23211a; border:1px solid var(--accent); color:var(--txt); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
+  .afilter b { color:var(--accent); font-weight:600; }
+  .clearall { background:none; border:1px solid var(--line); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
+  .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
+  .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
+  .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
+  .badge.st-ARCHIVED { background:#5a3d3d; color:#e8c9c9; }
+  .badge.st-DRAFT { background:#3d4a5a; color:#c9dae8; }
+  .meta { padding:8px 10px; }
+  .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
+  .ttl { color:var(--txt); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
+  .vt { color:var(--mut); font-size:10.5px; margin-top:3px; }
+  .fac { margin-top:4px; font-size:10.5px; color:var(--accent); }
+  .priceline { margin-top:4px; }
+  .price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
+  #sentinel { height:1px; }
+  #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
 </style>
 </head>
 <body>
-
 <header>
-  <a class="wordmark" href="https://www.designerwallcoverings.com">Designer Wallcoverings<small>The Complete Vendor Index</small></a>
-  <button class="burger" aria-label="Menu" onclick="menu.classList.add('open')"><span></span><span></span><span></span></button>
+  <h1>All Products <small id="sub">Designer Wallcoverings — the whole catalog, one place</small><a href="/vendors.html">vendor directory →</a></h1>
+  <div class="controls">
+    <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
+    <label class="dens">Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="oldest">Oldest</option>
+        <option value="updated">Recently updated</option>
+        <option value="title">Title A→Z</option>
+        <option value="vendor">Vendor A→Z</option>
+        <option value="type">Type A→Z</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="pattern">Pattern A→Z</option>
+        <option value="color">Color (family)</option>
+        <option value="style">Style A→Z</option>
+        <option value="material">Material A→Z</option>
+        <option value="price_asc">Price ↑</option>
+        <option value="price_desc">Price ↓</option>
+        <option value="status">Status</option>
+      </select>
+    </label>
+    <label class="dens">Density
+      <input type="range" id="dens" min="3" max="10" value="6">
+    </label>
+  </div>
+
+  <details class="facets" id="facets">
+    <summary>Filters <b id="facetCount"></b></summary>
+    <div class="facetbody">
+      <div class="frow"><span class="flabel">Vendor</span><div class="frow" id="vendorRow"></div></div>
+      <div class="frow"><span class="flabel">Type</span><div class="frow" id="typeRow"></div></div>
+      <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
+      <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
+      <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
+      <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
+      <div class="frow"><span class="flabel">Status</span><div class="frow" id="statusRow"></div></div>
+      <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
+      <div class="active" id="activeRow"></div>
+    </div>
+  </details>
 </header>
+<div class="grid" id="grid"></div>
+<div id="sentinel"></div>
+<div id="status">loading…</div>
 
-<nav class="menu" id="menu">
-  <button class="close" aria-label="Close" onclick="menu.classList.remove('open')">&times;</button>
-  <a href="https://www.designerwallcoverings.com">Shop the Store</a>
-  <a href="https://www.designerwallcoverings.com/collections/trending-wallcoverings-collection-2026">New &amp; Trending</a>
-  <a href="https://www.designerwallcoverings.com/pages/contact-us">Contact a Designer</a>
-</nav>
-
-<section class="hero">
-  <h1>Every Line We Carry</h1>
-  <p>The world's wallcovering &amp; textile houses — indexed in one place.</p>
-  <div class="stats">
-    <span><b id="statVendors">—</b>Vendors</span>
-    <span><b id="statDesigns">—</b>Designs Live</span>
-  </div>
-</section>
+<script>
+const grid = document.getElementById('grid');
+const statusEl = document.getElementById('status');
+const subEl = document.getElementById('sub');
+const LIMIT = 120;
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
 
-<div class="controls">
-  <input type="search" id="q" placeholder="Search vendors…" aria-label="Search vendors">
-  <div class="seg" role="tablist">
-    <button id="fLive" class="on">Available Now</button>
-    <button id="fAll">All Vendors</button>
-  </div>
-  <select id="sort" aria-label="Sort">
-    <option value="designs">Most Designs</option>
-    <option value="az">Name A→Z</option>
-    <option value="za">Name Z→A</option>
-  </select>
-  <label class="dens">Density <input type="range" id="density" min="2" max="8" step="1" value="4"></label>
-</div>
+let state = {
+  sort: localStorage.getItem('all_sort') || 'newest',
+  q: '',
+  vendors: loadSet('all_vendors'), types: loadSet('all_types'), statuses: loadSet('all_statuses'),
+  colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
+  prices: loadSet('all_prices'), images: loadSet('all_images'),
+  offset: 0, total: 0, loading: false, done: false,
+};
+// per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
+const rowUI = { vendorRow: { expand: false, find: '' }, typeRow: { expand: false, find: '' } };
 
-<main class="grid" id="grid"></main>
-<div class="sentinel" id="sentinel"></div>
+const dens = document.getElementById('dens');
+dens.value = localStorage.getItem('all_cols') || 6;
+document.documentElement.style.setProperty('--cols', dens.value);
+dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('all_cols', dens.value); };
 
-<footer>Designer Wallcoverings &amp; Fabrics — To The Trade &amp; Beyond</footer>
+const facetsEl = document.getElementById('facets');
+facetsEl.open = localStorage.getItem('all_facets_open') !== '0';
+facetsEl.ontoggle = () => localStorage.setItem('all_facets_open', facetsEl.open ? '1' : '0');
 
-<script>
-const grid = document.getElementById('grid'), sentinel = document.getElementById('sentinel');
-const menu = document.getElementById('menu');
-const state = {
-  all: [], view: [], rendered: 0, BATCH: 40,
-  q: '', filter: localStorage.getItem('adw_filter') || 'live',
-  sort: localStorage.getItem('adw_sort') || 'designs',
-  cols: localStorage.getItem('adw_cols') || '4',
+document.getElementById('sort').value = state.sort;
+document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('all_sort', state.sort); reset(); };
+let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
+
+const SETS = {
+  vendors: 'all_vendors', types: 'all_types', statuses: 'all_statuses', colors: 'all_colors',
+  styles: 'all_styles', materials: 'all_materials', prices: 'all_prices', images: 'all_images',
 };
+function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+function facetParams(extra = {}) {
+  return new URLSearchParams({
+    sort: state.sort, q: state.q,
+    vendors: [...state.vendors].join(','), types: [...state.types].join(','),
+    statuses: [...state.statuses].join(','), colors: [...state.colors].join(','),
+    styles: [...state.styles].join(','), materials: [...state.materials].join(','),
+    prices: [...state.prices].join(','), images: [...state.images].join(','), ...extra,
+  });
+}
 
-document.documentElement.style.setProperty('--cols', state.cols);
-density.value = state.cols;
-sort.value = state.sort;
-setSeg();
+function reset() { grid.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
+function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
 
-function setSeg(){
-  fLive.classList.toggle('on', state.filter==='live');
-  fAll.classList.toggle('on', state.filter==='all');
+function allChip(set, label = 'All') {
+  const el = document.createElement('span');
+  el.className = 'chip allchip' + (set.size === 0 ? ' on' : '');
+  el.textContent = label;
+  el.title = set.size ? 'Clear this category' : 'Showing all';
+  el.onclick = () => { if (set.size) { set.clear(); applyFilterChange(); } };
+  return el;
 }
 
-fetch('/api/vendors').then(r=>r.json()).then(d=>{
-  state.all = d.vendors || [];
-  statVendors.textContent = state.all.length.toLocaleString();
-  statDesigns.textContent = state.all.reduce((s,v)=>s+(v.active_count||0),0).toLocaleString();
-  apply();
-});
-
-function apply(){
-  let v = state.all;
-  if (state.filter==='live') v = v.filter(x=>x.active_count>0);
-  if (state.q) { const q=state.q.toLowerCase(); v = v.filter(x=>x.name.toLowerCase().includes(q)); }
-  if (state.sort==='az') v=[...v].sort((a,b)=>a.name.localeCompare(b.name));
-  else if (state.sort==='za') v=[...v].sort((a,b)=>b.name.localeCompare(a.name));
-  else v=[...v].sort((a,b)=>b.active_count-a.active_count || a.name.localeCompare(b.name));
-  state.view = v; state.rendered = 0; grid.innerHTML=''; renderMore();
+// count-chip row. Big rows (vendor/type) get a type-ahead + top-N cap with "+N more" expander;
+// selected values ALWAYS render so an active filter can't hide off-screen.
+function chipRow(rowId, counts, set, opts = {}) {
+  const row = document.getElementById(rowId); row.innerHTML = '';
+  const ui = rowUI[rowId];
+  row.appendChild(allChip(set, opts.allLabel));
+  if (ui) {
+    const find = document.createElement('input');
+    find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
+    find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
+    row.appendChild(find);
+  }
+  let entries = Object.entries(counts || {});
+  entries = opts.order
+    ? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
+    : entries.sort((a, b) => b[1] - a[1]);
+  if (ui && ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
+  const CAP = 30;
+  let shown = entries, hidden = 0;
+  if (ui && !ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
+  // active selections must always be visible
+  const shownVals = new Set(shown.map(([v]) => v));
+  for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
+  if (!shown.length) { const s = document.createElement('span'); s.style.cssText = 'font-size:10px;color:#6a6a72'; s.textContent = 'none'; row.appendChild(s); return; }
+  shown.forEach(([val, ct]) => {
+    const el = document.createElement('span');
+    el.className = 'chip' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    el.innerHTML = `${val}<span class="ct">${ct.toLocaleString()}</span>`;
+    el.onclick = () => toggleSet(set, val);
+    row.appendChild(el);
+  });
+  if (hidden > 0) {
+    const el = document.createElement('span'); el.className = 'chip more'; el.textContent = `+${hidden.toLocaleString()} more`;
+    el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
+    row.appendChild(el);
+  } else if (ui && ui.expand && entries.length > CAP) {
+    const el = document.createElement('span'); el.className = 'chip more'; el.textContent = 'show less';
+    el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
+    row.appendChild(el);
+  }
 }
 
-function card(v){
-  const el = document.createElement('a');
-  el.className='card'; el.href=v.shop_url; el.target='_blank'; el.rel='noopener noreferrer';
-  const imgs=(v.images||[]).slice(0,4);
-  const cells = imgs.length
-    ? imgs.map(u=>`<img loading="lazy" src="${u.replace(/"/g,'')}" alt="${v.name} design">`).join('')
-    : `<div class="ph">${v.name.charAt(0)}</div>`;
-  el.innerHTML = `
-    <div class="collage${imgs.length<=1?' single':''}">${cells}</div>
-    <div class="meta">
-      <h3></h3>
-      <div class="sub">${v.active_count>0 ? v.active_count.toLocaleString()+' designs live' : 'By inquiry'}</div>
-      <div class="cta">Shop the Line →</div>
-    </div>`;
-  el.querySelector('h3').textContent = v.name;
-  return el;
+let LAST_FACETS = {};
+function renderFacets(f) {
+  LAST_FACETS = f;
+  chipRow('vendorRow', f.vendor, state.vendors);
+  chipRow('typeRow', f.type, state.types);
+  chipRow('colorRow', f.color, state.colors, { order: f.family_order });
+  chipRow('styleRow', f.style, state.styles);
+  chipRow('matRow', f.material, state.materials);
+  chipRow('priceRow', f.price_band, state.prices, { order: f.price_order });
+  chipRow('statusRow', f.status, state.statuses, { allLabel: 'Active (default)' });
+  chipRow('imgRow', f.image_state, state.images);
+  renderActive();
+}
+
+function renderActive() {
+  const row = document.getElementById('activeRow'); row.innerHTML = '';
+  const dims = [['Vendor', state.vendors], ['Type', state.types], ['Color', state.colors], ['Style', state.styles],
+    ['Material', state.materials], ['Price', state.prices], ['Status', state.statuses], ['Image', state.images]];
+  let n = 0;
+  for (const [label, set] of dims) {
+    set.forEach((v) => {
+      n++;
+      const el = document.createElement('span'); el.className = 'afilter';
+      el.innerHTML = `<b>${label}:</b> ${v} ✕`;
+      el.onclick = () => { set.delete(v); applyFilterChange(); };
+      row.appendChild(el);
+    });
+  }
+  document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
+  if (n) {
+    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+    c.onclick = () => { dims.forEach(([, s]) => s.clear()); applyFilterChange(); };
+    row.appendChild(c);
+  }
 }
 
-function renderMore(){
-  const next = state.view.slice(state.rendered, state.rendered + state.BATCH);
-  next.forEach(v=>grid.appendChild(card(v)));
-  state.rendered += next.length;
+async function refreshFacets() {
+  try {
+    const res = await fetch('/api/facets?' + facetParams());
+    renderFacets(await res.json());
+  } catch (e) { /* facets degrade silently — grid still works */ }
 }
 
-new IntersectionObserver(es=>{ if(es[0].isIntersecting) renderMore(); }, {rootMargin:'800px'}).observe(sentinel);
+function card(r) {
+  const el = document.createElement('div'); el.className = 'card';
+  const stBadge = (r.status && r.status !== 'ACTIVE') ? `<span class="badge st-${r.status}">${r.status}</span>` : '';
+  const thumb = r.image
+    ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}</div>`
+    : `<div class="thumb empty">${stBadge}no image</div>`;
+  const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
+  const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
+  const price = r.price ? `<div class="priceline"><span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span></div>` : '';
+  el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${fac ? `<div class="fac">${fac}</div>` : ''}${price}</div>`;
+  if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
+  return el;
+}
+
+async function load() {
+  if (state.loading || state.done) return;
+  state.loading = true;
+  try {
+    const res = await fetch('/api/products?' + facetParams({ offset: state.offset, limit: LIMIT }));
+    const data = await res.json();
+    state.total = data.total;
+    const frag = document.createDocumentFragment();
+    data.rows.forEach((r) => frag.appendChild(card(r)));
+    grid.appendChild(frag);
+    state.offset += data.rows.length;
+    state.done = state.offset >= data.total || data.rows.length === 0;
+    subEl.textContent = `${data.catalog.toLocaleString()} products in catalog · ${data.total.toLocaleString()} matching · showing ${state.offset.toLocaleString()}`;
+    statusEl.textContent = data.total === 0 ? '— no matching products —'
+      : (state.done ? `— end · ${state.total.toLocaleString()} matching —` : 'scroll for more…');
+  } catch (e) {
+    statusEl.textContent = 'Load error — check server';
+  } finally {
+    state.loading = false;
+  }
+}
 
-q.addEventListener('input', e=>{ state.q=e.target.value.trim(); apply(); });
-sort.addEventListener('change', e=>{ state.sort=e.target.value; localStorage.setItem('adw_sort',state.sort); apply(); });
-density.addEventListener('input', e=>{ state.cols=e.target.value; localStorage.setItem('adw_cols',state.cols);
-  document.documentElement.style.setProperty('--cols', state.cols); });
-fLive.onclick=()=>{ state.filter='live'; localStorage.setItem('adw_filter','live'); setSeg(); apply(); };
-fAll.onclick=()=>{ state.filter='all'; localStorage.setItem('adw_filter','all'); setSeg(); apply(); };
+new IntersectionObserver((es) => { if (es[0].isIntersecting) load(); }, { rootMargin: '600px' }).observe(document.getElementById('sentinel'));
+refreshFacets(); load();
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 830c400..7ac86e0 100644
--- a/server.js
+++ b/server.js
@@ -1,57 +1,227 @@
-// all.designerwallcoverings.com — front-facing vendor directory (all dw_unified vendor ids)
-// Zero-dependency node server: static public/ + /api/vendors from data/vendors.json
-const http = require("http");
-const fs = require("fs");
-const path = require("path");
+// all.designerwallcoverings.com — the WHOLE catalog, searchable in one place.
+// Pattern: japan-staging-viewer (in-memory rows + paginated /api/products +
+// drill-down /api/facets + chip-toggle filters + sort-any-field + infinite scroll).
+// Data: dw_unified.shopify_products loaded into RAM at boot, refreshed every 10 min —
+// the request path never touches PG, so search over the full catalog is instant and
+// the canonical DB needs no new indexes. Public surface: cost/net/wholesale never
+// leave the server; only retail price ships.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+try { require('dotenv').config(); } catch {}
 
 const PORT = process.env.PORT || 9958;
-const PUB = path.join(__dirname, "public");
-const DATA = path.join(__dirname, "data", "vendors.json");
+const PUB = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data', 'vendors.json'); // legacy vendor directory (kept at /vendors.html)
+const REFRESH_MS = 10 * 60 * 1000;
+const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
 
 const MIME = {
-  ".html": "text/html; charset=utf-8",
-  ".js": "text/javascript; charset=utf-8",
-  ".css": "text/css; charset=utf-8",
-  ".json": "application/json; charset=utf-8",
-  ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
-  ".ico": "image/x-icon", ".webp": "image/webp",
+  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
+  '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
+  '.ico': 'image/x-icon', '.webp': 'image/webp',
 };
 
+// ── tag lexicons → derived facets ────────────────────────────────────────────────
+// Tags are ", "-separated words like "Blue, Coastal, Grasscloth, color:Ecru, …".
+// Color families follow the interior-designer wheel order; style + material use the
+// working DW tag vocabulary. A tag must match exactly (case-insensitive).
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'Grey', 'White', 'Silver', 'Metallic', 'Multi'];
+const COLOR_TAGS = new Set(FAMILY_ORDER.concat(['Light Blue', 'Dark Blue', 'Navy', 'Light Gray', 'Dark Gray', 'Charcoal', 'Ivory', 'Ecru', 'Taupe', 'Tan', 'Rust', 'Burgundy', 'Coral', 'Lavender', 'Mint', 'Sage', 'Olive', 'Turquoise', 'Aqua', 'Bronze', 'Copper', 'Neutral']).map((s) => s.toLowerCase()));
+const STYLE_TAGS = new Set(['Traditional', 'Modern', 'Contemporary', 'Transitional', 'Floral', 'Damask', 'Geometric', 'Stripe', 'Stripes', 'Striped', 'Toile', 'Ikat', 'Chinoiserie', 'Coastal', 'Tropical', 'Botanical', 'Art Deco', 'Mid-Century', 'Minimalist', 'Rustic', 'Vintage', 'Abstract', 'Animal', 'Bird', 'Paisley', 'Plaid', 'Trellis', 'Lattice', 'Medallion', 'Ombre', 'Mural', 'Scenic', 'Texture', 'Textured', 'Solid', 'Novelty', 'Kids', 'Basketweave', 'Herringbone', 'Chevron', 'Moroccan', 'Asian', 'French', 'English', 'Farmhouse', 'Glam', 'Bohemian', 'Industrial'].map((s) => s.toLowerCase()));
+const MATERIAL_TAGS = new Set(['Grasscloth', 'Cork', 'Silk', 'Linen', 'Vinyl', 'Paper', 'Non-Woven', 'Nonwoven', 'Fabric', 'Textile', 'Sisal', 'Jute', 'Raffia', 'Hemp', 'Bamboo', 'Wood', 'Leather', 'Suede', 'Velvet', 'Flocked', 'Foil', 'Mylar', 'Mica', 'Glass Beads', 'Glass Beaded', 'Metal', 'Natural', 'Naturals', 'Paperweave', 'Arrowroot', 'Wool', 'Felt', 'Acoustic', 'Type II', 'Type I', 'Peel and Stick', 'Peel & Stick', 'Removable'].map((s) => s.toLowerCase()));
+
+const PRICE_BANDS = [
+  { key: '≤$50', test: (p) => p <= 50 },
+  { key: '$50–100', test: (p) => p > 50 && p <= 100 },
+  { key: '$100–200', test: (p) => p > 100 && p <= 200 },
+  { key: '$200–400', test: (p) => p > 200 && p <= 400 },
+  { key: '$400+', test: (p) => p > 400 },
+];
+const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
+const priceBand = (p) => { if (!p || p <= 0) return null; const b = PRICE_BANDS.find((x) => x.test(p)); return b ? b.key : null; };
+
+const titleCase = (s) => s.replace(/\w\S*/g, (t) => t[0].toUpperCase() + t.slice(1));
+
+// ── snapshot load ────────────────────────────────────────────────────────────────
+// Public-safe columns ONLY — cost_price / net_price / cost never selected.
+const SNAP_SQL = `
+  SELECT id, handle, title, vendor, product_type, dw_sku, variant_sku, sku, pattern_name,
+         tags, upper(coalesce(status,'')) AS status, image_url,
+         coalesce(price, min_variant_price) AS price,
+         online_store_published,
+         created_at_shopify, updated_at_shopify
+  FROM shopify_products
+  WHERE coalesce(status,'') <> 'DELETED_FROM_SHOPIFY'`;
+
+let ROWS = [];
+let LOADED_AT = null;
+let pool = null;
+
+function deriveRow(r) {
+  const tags = (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean);
+  const colors = [], styles = [], materials = [];
+  for (const t of tags) {
+    const lc = t.toLowerCase();
+    const bare = lc.startsWith('color:') ? lc.slice(6).trim() : lc;
+    if (COLOR_TAGS.has(bare)) colors.push(titleCase(bare));
+    if (STYLE_TAGS.has(lc)) styles.push(titleCase(lc));
+    if (MATERIAL_TAGS.has(lc)) materials.push(titleCase(lc));
+  }
+  const price = r.price != null ? parseFloat(r.price) : null;
+  return {
+    id: r.id,
+    sku: r.dw_sku || r.variant_sku || r.sku || null,
+    title: r.title || '',
+    vendor: r.vendor || null,
+    type: r.product_type || null,
+    pattern: r.pattern_name || null,
+    status: r.status || null,
+    image: r.image_url || null,
+    price: price && price > 0 ? price : null,
+    price_band: priceBand(price),
+    colors: [...new Set(colors)],
+    styles: [...new Set(styles)],
+    materials: [...new Set(materials)],
+    image_state: r.image_url ? 'Has image' : 'No image',
+    url: (r.online_store_published && r.handle) ? `https://designerwallcoverings.com/products/${r.handle}` : null,
+    created: r.created_at_shopify ? new Date(r.created_at_shopify).getTime() : 0,
+    updated: r.updated_at_shopify ? new Date(r.updated_at_shopify).getTime() : 0,
+    // one lowercase haystack per row so search is a single .includes() pass
+    hay: [r.dw_sku, r.variant_sku, r.sku, r.title, r.vendor, r.product_type, r.pattern_name, r.tags].filter(Boolean).join(' ').toLowerCase(),
+  };
+}
+
+async function loadSnapshot() {
+  if (!pool) pool = new Pool({ connectionString: DSN, max: 2 });
+  const t0 = Date.now();
+  const res = await pool.query(SNAP_SQL);
+  ROWS = res.rows.map(deriveRow);
+  LOADED_AT = new Date().toISOString();
+  console.log(`snapshot: ${ROWS.length.toLocaleString()} products in ${Date.now() - t0}ms`);
+}
+
+// ── filtering / sorting (japan-viewer pattern) ──────────────────────────────────
+function parseFilters(u) {
+  const csv = (k) => new Set((u.searchParams.get(k) || '').split(',').map((s) => s.trim()).filter(Boolean));
+  return {
+    q: (u.searchParams.get('q') || '').trim().toLowerCase(),
+    vendors: csv('vendors'), types: csv('types'), statuses: csv('statuses'),
+    colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
+    prices: csv('prices'), images: csv('images'),
+  };
+}
+// Default status scope = ACTIVE unless the user explicitly picked statuses.
+function applyFilters(rows, f, opts = {}) {
+  let out = rows;
+  if (opts.skip !== 'status') {
+    out = f.statuses.size ? out.filter((r) => f.statuses.has(r.status)) : out.filter((r) => r.status === 'ACTIVE');
+  }
+  if (f.q) {
+    // word-START match per term so "red" hits "Red"/"redwood" but not "weatheRED"/"bordeRED"
+    const terms = f.q.split(/\s+/).filter(Boolean)
+      .map((t) => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+    out = out.filter((r) => terms.every((re) => re.test(r.hay)));
+  }
+  if (opts.skip !== 'vendor' && f.vendors.size) out = out.filter((r) => r.vendor && f.vendors.has(r.vendor));
+  if (opts.skip !== 'type' && f.types.size) out = out.filter((r) => r.type && f.types.has(r.type));
+  if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => r.colors.some((c) => f.colors.has(c)));
+  if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => r.styles.some((c) => f.styles.has(c)));
+  if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => r.materials.some((c) => f.materials.has(c)));
+  if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => r.price_band && f.prices.has(r.price_band));
+  if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => f.images.has(r.image_state));
+  return out;
+}
+
+const str = (v) => String(v == null ? '￿' : v); // nulls sink to the end on A→Z sorts
+const sorters = {
+  newest: (a, b) => b.created - a.created,
+  oldest: (a, b) => a.created - b.created,
+  updated: (a, b) => b.updated - a.updated,
+  title: (a, b) => str(a.title).localeCompare(str(b.title)),
+  vendor: (a, b) => str(a.vendor).localeCompare(str(b.vendor)) || str(a.title).localeCompare(str(b.title)),
+  type: (a, b) => str(a.type).localeCompare(str(b.type)) || str(a.title).localeCompare(str(b.title)),
+  sku: (a, b) => str(a.sku).localeCompare(str(b.sku)),
+  pattern: (a, b) => str(a.pattern).localeCompare(str(b.pattern)),
+  status: (a, b) => str(a.status).localeCompare(str(b.status)),
+  price_asc: (a, b) => (a.price ?? 1e12) - (b.price ?? 1e12),
+  price_desc: (a, b) => (b.price ?? -1) - (a.price ?? -1),
+  color: (a, b) => { const i = (x) => { const c = x.colors[0]; const k = c ? FAMILY_ORDER.indexOf(c) : -1; return k < 0 ? 99 : k; }; return i(a) - i(b) || str(a.sku).localeCompare(str(b.sku)); },
+  style: (a, b) => str(a.styles[0]).localeCompare(str(b.styles[0])) || str(a.sku).localeCompare(str(b.sku)),
+  material: (a, b) => str(a.materials[0]).localeCompare(str(b.materials[0])) || str(a.sku).localeCompare(str(b.sku)),
+};
+
+const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
+const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of r[key]) m[v] = (m[v] || 0) + 1; } return m; };
+
 let vendorsCache = null, vendorsMtime = 0;
 function loadVendors() {
   const st = fs.statSync(DATA);
-  if (!vendorsCache || st.mtimeMs !== vendorsMtime) {
-    vendorsCache = fs.readFileSync(DATA);
-    vendorsMtime = st.mtimeMs;
-  }
+  if (!vendorsCache || st.mtimeMs !== vendorsMtime) { vendorsCache = fs.readFileSync(DATA); vendorsMtime = st.mtimeMs; }
   return vendorsCache;
 }
 
-http.createServer((req, res) => {
-  const url = new URL(req.url, "http://x");
-  const p = url.pathname;
+const server = http.createServer((req, res) => {
+  const u = new URL(req.url, `http://localhost:${PORT}`);
 
-  if (p === "/healthz") {
-    res.writeHead(200, { "Content-Type": "text/plain" });
-    return res.end("ok");
+  if (u.pathname === '/api/products') {
+    const f = parseFilters(u);
+    const sort = u.searchParams.get('sort') || 'newest';
+    const offset = parseInt(u.searchParams.get('offset') || '0', 10) || 0;
+    const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10) || 120, 500);
+    let rows = applyFilters(ROWS, f);
+    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({
+      total: rows.length, catalog: ROWS.length, loaded_at: LOADED_AT, offset, limit,
+      rows: rows.slice(offset, offset + limit).map(({ hay, created, updated, ...r }) => r),
+    }));
+    return;
   }
-  if (p === "/api/vendors") {
+
+  if (u.pathname === '/api/facets') {
+    const f = parseFilters(u);
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({
+      vendor: tally(applyFilters(ROWS, f, { skip: 'vendor' }), 'vendor'),
+      type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'type'),
+      status: tally(applyFilters(ROWS, f, { skip: 'status' }), 'status'),
+      color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),
+      style: tallyArr(applyFilters(ROWS, f, { skip: 'style' }), 'styles'),
+      material: tallyArr(applyFilters(ROWS, f, { skip: 'material' }), 'materials'),
+      price_band: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
+      image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+      price_order: PRICE_ORDER, family_order: FAMILY_ORDER,
+      total: applyFilters(ROWS, f).length,
+    }));
+    return;
+  }
+
+  if (u.pathname === '/api/vendors') { // legacy vendor directory API (vendors.html)
     try {
-      const body = loadVendors();
-      res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=300" });
-      return res.end(body);
-    } catch (e) {
-      res.writeHead(500, { "Content-Type": "application/json" });
-      return res.end('{"error":"data unavailable"}');
-    }
+      res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=300' });
+      return res.end(loadVendors());
+    } catch { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end('{"error":"data unavailable"}'); }
+  }
+
+  if (u.pathname === '/healthz') {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify({ ok: ROWS.length > 0, products: ROWS.length, loaded_at: LOADED_AT }));
   }
 
   // static, path-traversal-safe
-  let file = path.normalize(path.join(PUB, p === "/" ? "index.html" : p));
-  if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
-  fs.readFile(file, (err, buf) => {
-    if (err) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("Not found"); }
-    res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
-    res.end(buf);
-  });
-}).listen(PORT, () => console.log(`all-designerwallcoverings on :${PORT}`));
+  const p = u.pathname === '/' ? '/index.html' : u.pathname;
+  const file = path.join(PUB, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
+  if (!file.startsWith(PUB) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end('not found'); }
+  res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
+  res.end(fs.readFileSync(file));
+});
+
+loadSnapshot()
+  .then(() => {
+    server.listen(PORT, () => console.log(`all. catalog → http://127.0.0.1:${PORT}  (${ROWS.length.toLocaleString()} products)`));
+    setInterval(() => loadSnapshot().catch((e) => console.error('snapshot refresh failed:', e.message)), REFRESH_MS);
+  })
+  .catch((e) => { console.error('FATAL: initial snapshot load failed:', e.message); process.exit(1); });

← 455cb72 snapshot before full-catalog rebuild (vendor directory prese  ·  back to All Designerwallcoverings  ·  banned front-facing vendor scrub on the public snapshot (Bre c8c8cd7 →