[object Object]

← back to Dw Vendor Microsites

build zinc_textile (Zinc Textile) -> BUILT, port 10091, 100 skus, img 1.0

860ba1a81e8b3679918d6dd547c9dcc231a37a27 · 2026-06-30 15:05:22 -0700 · Steve

Files touched

Diff

commit 860ba1a81e8b3679918d6dd547c9dcc231a37a27
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 15:05:22 2026 -0700

    build zinc_textile (Zinc Textile) -> BUILT, port 10091, 100 skus, img 1.0
---
 manifest.json                                   |  10 +-
 vendors/zinc_textile/viewer/fieldmap.json       |   6 +
 vendors/zinc_textile/viewer/public/buybutton.js |  70 ++++
 vendors/zinc_textile/viewer/public/index.html   | 414 +++++++++++++++++++++++
 vendors/zinc_textile/viewer/server.js           | 417 ++++++++++++++++++++++++
 5 files changed, 914 insertions(+), 3 deletions(-)

diff --git a/manifest.json b/manifest.json
index feb2199..11855e5 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2032,9 +2032,13 @@
       "table_count": 100,
       "samples_only": true,
       "status": "OK",
-      "build_status": "PENDING",
+      "build_status": "BUILT",
       "deploy_status": "PENDING",
-      "wave": 17
+      "wave": 17,
+      "port": 10091,
+      "sku_count": 100,
+      "image_health": 1.0,
+      "image_status": "HEALTHY"
     },
     "arteriors": {
       "vendor_code": "arteriors",
@@ -2241,7 +2245,7 @@
       "wave": 16
     }
   },
-  "updated_at": "2026-06-30T15:05:18.682001",
+  "updated_at": "2026-06-30T15:05:22.057607",
   "stats": {
     "total_vendors": 118,
     "buildable": 117,
diff --git a/vendors/zinc_textile/viewer/fieldmap.json b/vendors/zinc_textile/viewer/fieldmap.json
new file mode 100644
index 0000000..6ae5422
--- /dev/null
+++ b/vendors/zinc_textile/viewer/fieldmap.json
@@ -0,0 +1,6 @@
+{
+  "title": "Zinc Textile",
+  "samples_only": true,
+  "vendor_code": "zinc_textile",
+  "subdomain": "zinc-textile"
+}
diff --git a/vendors/zinc_textile/viewer/public/buybutton.js b/vendors/zinc_textile/viewer/public/buybutton.js
new file mode 100644
index 0000000..f479e9a
--- /dev/null
+++ b/vendors/zinc_textile/viewer/public/buybutton.js
@@ -0,0 +1,70 @@
+/* Shopify sample checkout for the DW vendor microsites.
+ *
+ * These vendor lines are OFF Shopify (no per-product Shopify variant), so "Request
+ * Sample" cannot add a per-product variant. Instead it uses ONE generic "Memo Sample"
+ * Shopify product/variant and adds it to a real Shopify cart once per requested swatch,
+ * carrying each swatch's pattern/color/SKU/vendor as line-item custom properties, then
+ * opens Shopify's secure hosted checkout. The order line-items tell fulfillment which
+ * swatches to pull.
+ *
+ * Config is injected by the server at /config.js as window.DWV_CFG = {
+ *   domain: '<store>.myshopify.com', storefrontToken: '<token>', sampleVariantId: '<gid or numeric>'
+ * }. Until storefrontToken + sampleVariantId are set, this falls back to the no-payment
+ * JSONL sample-request endpoint (POST /api/sample-request) so the button always works.
+ */
+(function () {
+  var CFG = window.DWV_CFG || {};
+  var ready = CFG.storefrontToken && CFG.sampleVariantId && CFG.domain;
+  var client = null, checkoutId = null;
+
+  function loadSDK(cb) {
+    if (window.ShopifyBuy && window.ShopifyBuy.buildClient) return cb();
+    var s = document.createElement('script');
+    s.src = 'https://sdks.shopifycdn.com/js-buy-sdk/latest/index.umd.min.js';
+    s.onload = cb; s.onerror = function () { ready = false; cb(); };
+    document.head.appendChild(s);
+  }
+
+  function ensureClient(cb) {
+    if (client) return cb();
+    loadSDK(function () {
+      if (!window.ShopifyBuy || !ready) { ready = false; return cb(); }
+      client = window.ShopifyBuy.buildClient({ domain: CFG.domain, storefrontAccessToken: CFG.storefrontToken });
+      client.checkout.create().then(function (co) { checkoutId = co.id; cb(); }).catch(function () { ready = false; cb(); });
+    });
+  }
+
+  // items: [{sku, mfr_sku, pattern_name, color_name, line}]  -> Shopify checkout
+  function shopifyCheckout(items, contact) {
+    return new Promise(function (resolve, reject) {
+      ensureClient(function () {
+        if (!ready || !client || !checkoutId) return reject(new Error('shopify-unavailable'));
+        var lines = items.slice(0, 250).map(function (it) {
+          return {
+            variantId: CFG.sampleVariantId,
+            quantity: 1,
+            customAttributes: [
+              { key: 'Pattern', value: String(it.pattern_name || it.sku || '') },
+              { key: 'Color', value: String(it.color_name || '') },
+              { key: 'SKU', value: String(it.mfr_sku || it.sku || '') },
+              { key: 'Line', value: String(it.line || '') }
+            ]
+          };
+        });
+        var attrs = [];
+        if (contact && contact.name) attrs.push({ key: 'Requested by', value: contact.name });
+        var p = client.checkout.addLineItems(checkoutId, lines);
+        p.then(function (co) {
+          if (attrs.length) return client.checkout.updateAttributes(checkoutId, { customAttributes: attrs }).then(function () { return co; });
+          return co;
+        }).then(function (co) { window.location.href = co.webUrl; resolve(co); }).catch(reject);
+      });
+    });
+  }
+
+  // Expose for the viewer's sample modal. Returns true if Shopify path is wired.
+  window.DWV_SAMPLE = {
+    available: function () { return !!ready; },
+    checkout: shopifyCheckout
+  };
+})();
diff --git a/vendors/zinc_textile/viewer/public/index.html b/vendors/zinc_textile/viewer/public/index.html
new file mode 100644
index 0000000..ce0d82b
--- /dev/null
+++ b/vendors/zinc_textile/viewer/public/index.html
@@ -0,0 +1,414 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Trade Lines</title>
+<style>
+  :root{
+    --cols: 5;            /* density-driven grid columns */
+    --ink:#1c1a17; --muted:#6f6a62; --line:#e7e2da; --bg:#faf8f4; --card:#fff;
+    --accent:#7a6a52; --accent2:#3c5a4a;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0}
+  body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
+  a{color:inherit}
+  header.top{padding:22px 24px 12px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30}
+  .brand{font-size:24px;letter-spacing:.04em;font-weight:600}
+  .brand small{display:block;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin-top:3px}
+  .count{color:var(--muted);font-size:13px;margin-left:auto}
+  .toprow{display:flex;align-items:flex-end;gap:18px;flex-wrap:wrap}
+  /* controls bar */
+  .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:73px;z-index:20}
+  .controls label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:flex;flex-direction:column;gap:4px}
+  select,input[type=search],input[type=text],input[type=email],textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
+  input[type=range]{width:120px}
+  .grow{flex:1;min-width:180px}
+  .density{display:flex;flex-direction:column;gap:4px}
+  .cartbtn{margin-left:auto;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer;letter-spacing:.02em}
+  .cartbtn .n{display:inline-block;background:var(--accent2);border-radius:20px;padding:1px 8px;margin-left:6px;font-size:12px}
+  /* active-filter chips bar */
+  .activebar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 24px;border-bottom:1px solid var(--line);background:#fcfbf8}
+  .activebar.empty{display:none}
+  .achip{display:inline-flex;align-items:center;gap:6px;background:var(--ink);color:#fff;border-radius:20px;padding:4px 6px 4px 11px;font-size:12.5px;line-height:1}
+  .achip .k{opacity:.6;text-transform:uppercase;letter-spacing:.06em;font-size:10px}
+  .achip .x{border:none;background:rgba(255,255,255,.2);color:#fff;border-radius:50%;width:18px;height:18px;cursor:pointer;font-size:13px;line-height:1;padding:0}
+  .clearall{margin-left:auto;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 12px;cursor:pointer;font-size:12.5px}
+  /* layout */
+  .wrap{display:flex;align-items:flex-start}
+  aside{width:248px;flex:0 0 248px;padding:18px 16px 60px;border-right:1px solid var(--line);position:sticky;top:135px;max-height:calc(100vh - 135px);overflow:auto}
+  aside h3{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:18px 0 8px}
+  aside h3:first-child{margin-top:0}
+  /* facet CHIPS (toggle, multi-select) */
+  .chips{display:flex;flex-wrap:wrap;gap:6px}
+  .chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:18px;padding:4px 10px;font-size:12.5px;cursor:pointer;user-select:none}
+  .chip .cc{color:var(--muted);font-size:10.5px}
+  .chip.on{background:var(--ink);color:#fff;border-color:var(--ink)}
+  .chip.on .cc{color:rgba(255,255,255,.7)}
+  .chip.zero{opacity:.38;cursor:default;pointer-events:none}
+  .chip .swatch{width:11px;height:11px;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}
+  .facet-search{width:100%;margin-bottom:8px;padding:5px 8px;font-size:13px}
+  .facet-scroll{max-height:230px;overflow:auto;padding-right:4px}
+  .clearbtn{margin-top:14px;width:100%;padding:8px;border:1px solid var(--line);background:#fff;border-radius:7px;cursor:pointer;font-size:13px}
+  main{flex:1;padding:18px 24px 80px;min-width:0}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:14px}
+  /* MINIMAL image-forward cards: image + hex dot(s) only; detail on hover/focus */
+  .card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+  .card .imgwrap{aspect-ratio:1/1;background:#f1ede6;overflow:hidden;position:relative}
+  .card img{width:100%;height:100%;object-fit:cover;display:block}
+  .card .dots{position:absolute;left:8px;bottom:8px;display:flex;gap:4px;z-index:2}
+  .card .dot{width:14px;height:14px;border-radius:50%;border:1px solid rgba(255,255,255,.85);box-shadow:0 0 0 1px rgba(0,0,0,.18)}
+  /* reveal overlay (hover or .open via click) */
+  .reveal{position:absolute;inset:auto 0 0 0;background:linear-gradient(to top,rgba(20,18,15,.92),rgba(20,18,15,.78) 60%,rgba(20,18,15,0));color:#fff;padding:30px 11px 11px;transform:translateY(101%);transition:transform .18s ease;display:flex;flex-direction:column;gap:3px}
+  .card:hover .reveal,.card.open .reveal,.card:focus-within .reveal{transform:translateY(0)}
+  .reveal .ptitle{font-weight:600;font-size:13.5px;line-height:1.25}
+  .reveal .rmeta{font-size:11.5px;color:rgba(255,255,255,.82)}
+  .reveal .badge{display:inline-block;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;background:rgba(255,255,255,.16);border-radius:20px;padding:2px 7px;align-self:flex-start;margin-bottom:1px}
+  .reqbtn{margin-top:6px;border:1px solid #fff;background:transparent;color:#fff;border-radius:7px;padding:6px;font-size:12px;font-weight:600;cursor:pointer}
+  .reqbtn.in{background:var(--accent2);border-color:var(--accent2)}
+  #sentinel{height:60px}
+  .status{text-align:center;color:var(--muted);padding:24px;font-size:13px}
+  /* modal */
+  .overlay{position:fixed;inset:0;background:rgba(28,26,23,.45);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow:auto}
+  .overlay.open{display:flex}
+  .modal{background:#fff;border-radius:12px;max-width:560px;width:100%;padding:22px 24px 26px}
+  .modal h2{margin:0 0 4px;font-size:20px}
+  .modal p.sub{margin:0 0 16px;color:var(--muted);font-size:13px}
+  .cartlist{max-height:230px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:16px}
+  .citem{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px}
+  .citem:last-child{border-bottom:none}
+  .citem img{width:42px;height:42px;object-fit:cover;border-radius:6px;background:#eee}
+  .citem .x{margin-left:auto;border:none;background:none;color:var(--muted);cursor:pointer;font-size:18px}
+  .field{display:flex;flex-direction:column;gap:4px;margin-bottom:11px}
+  .field label{font-size:12px;color:var(--muted)}
+  .row2{display:flex;gap:11px}.row2 .field{flex:1}
+  .submit{width:100%;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:12px;font-weight:600;font-size:15px;cursor:pointer;margin-top:4px}
+  .submit:disabled{opacity:.5;cursor:default}
+  .closex{float:right;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
+  .ok{background:#eef5ef;border:1px solid #cfe3d4;color:#2c5d3c;padding:12px;border-radius:8px;font-size:14px}
+  @media(max-width:820px){aside{display:none}.controls{top:auto;position:static}}
+</style>
+</head>
+<body>
+<header class="top">
+  <div class="toprow">
+    <div class="brand" id="brand">Trade Lines<small>Designer Wallcoverings</small></div>
+    <div class="count" id="count">loading…</div>
+  </div>
+</header>
+
+<div class="controls">
+  <label class="grow">Search
+    <input type="search" id="q" placeholder="pattern, color, SKU, collection…" autocomplete="off">
+  </label>
+  <label>Sort
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="family">Color Family</option>
+      <option value="style">Style</option>
+      <option value="pattern">Pattern A→Z</option>
+      <option value="color_name">Color name A→Z</option>
+      <option value="collection">Collection A→Z</option>
+      <option value="line">Line</option>
+      <option value="product_type">Product Type</option>
+      <option value="material">Material</option>
+      <option value="light">Light → Dark</option>
+      <option value="dark">Dark → Light</option>
+      <option value="hue">Color Wheel (hue)</option>
+      <option value="sku">SKU A→Z</option>
+    </select>
+  </label>
+  <label class="density">Density
+    <input type="range" id="density" min="2" max="8" step="1" value="5">
+  </label>
+  <button class="cartbtn" id="cartbtn">Request Samples<span class="n" id="cartn">0</span></button>
+</div>
+
+<!-- active-filter chips -->
+<div class="activebar empty" id="activebar"></div>
+
+<div class="wrap">
+  <aside id="facets"></aside>
+  <main>
+    <div class="grid" id="grid"></div>
+    <div id="sentinel"></div>
+    <div class="status" id="status"></div>
+  </main>
+</div>
+
+<!-- sample request modal -->
+<div class="overlay" id="overlay">
+  <div class="modal">
+    <button class="closex" id="closex">&times;</button>
+    <h2>Request Samples</h2>
+    <p class="sub">No payment — we’ll mail trade samples of the items below.</p>
+    <div id="formArea">
+      <div class="cartlist" id="cartlist"></div>
+      <form id="reqform">
+        <div class="row2">
+          <div class="field"><label>Name *</label><input type="text" name="name" required></div>
+          <div class="field"><label>Email *</label><input type="email" name="email" required></div>
+        </div>
+        <div class="row2">
+          <div class="field"><label>Company / Studio</label><input type="text" name="company"></div>
+          <div class="field"><label>Phone</label><input type="text" name="phone"></div>
+        </div>
+        <div class="field"><label>Shipping address</label><textarea name="address" rows="2"></textarea></div>
+        <div class="field"><label>Notes</label><textarea name="notes" rows="2" placeholder="Optional"></textarea></div>
+        <button class="submit" id="submitbtn" type="submit">Submit sample request</button>
+      </form>
+    </div>
+    <div id="doneArea" style="display:none"></div>
+  </div>
+</div>
+
+<script>
+// per-vendor localStorage namespace (set after /api/config so two microsites don't collide)
+let NS = 'dwvm';
+const LS = {
+  get:(k,d)=>{try{const v=localStorage.getItem(NS+'.'+k);return v==null?d:JSON.parse(v);}catch{return d;}},
+  set:(k,v)=>{try{localStorage.setItem(NS+'.'+k,JSON.stringify(v));}catch{}}
+};
+const $ = (s)=>document.querySelector(s);
+const grid=$('#grid'), statusEl=$('#status'), countEl=$('#count');
+
+// approximate swatch color per family (for the chip dot)
+const FAMILY_SWATCH = {
+  'Red':'#c0392b','Orange/Brown':'#b5651d','Yellow':'#e1c340','Green':'#4a7c3f','Teal':'#2a9d8f',
+  'Blue':'#3a6ea5','Purple':'#7e57c2','Pink':'#e08aa8','Neutral/Beige':'#d8cbb3','Black':'#222',
+  'Grey':'#9a9a9a','White':'#f4f4f0'
+};
+// pretty labels for the active-chip key
+const FACET_LABEL = {family:'Color',style:'Style',material_bucket:'Material',enriched:'Status',line:'Line',product_type:'Type',collection:'Collection'};
+
+// ---- persisted UI state ----
+let sort, density, cart, active;
+let offset=0, total=0, loading=false, done=false;
+const LIMIT=60;
+
+function qs(){
+  const p=new URLSearchParams();
+  p.set('sort',sort); p.set('offset',offset); p.set('limit',LIMIT);
+  const q=$('#q').value.trim(); if(q) p.set('q',q);
+  for(const k of Object.keys(active)){ if(active[k] && active[k].length) p.set(k, active[k].join('|')); }
+  return p.toString();
+}
+function reset(){ offset=0; total=0; done=false; grid.innerHTML=''; statusEl.textContent=''; renderActive(); load(); }
+
+async function load(){
+  if(loading||done) return;
+  loading=true; statusEl.textContent='loading…';
+  try{
+    const r=await fetch('/api/skus?'+qs());
+    const d=await r.json();
+    total=d.total;
+    for(const row of d.rows) grid.appendChild(card(row));
+    offset += d.rows.length;
+    if(offset>=total || d.rows.length===0) done=true;
+    countEl.textContent = total.toLocaleString()+' of '+d.all.toLocaleString()+' products';
+    statusEl.textContent = done ? (total? 'End of results — '+total.toLocaleString()+' shown' : 'No products match your filters.') : '';
+  }catch(e){ statusEl.textContent='Error loading products.'; }
+  loading=false;
+}
+
+function inCart(sku){ return cart.some(c=>c.sku===sku); }
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// MINIMAL card: image + hex dot(s) only; everything else in hover/click reveal
+function card(row){
+  const el=document.createElement('div'); el.className='card'; el.tabIndex=0;
+  const img = row.image ? `<img loading="lazy" src="${row.image}" alt="${esc(row.pattern_name)}" onerror="this.style.opacity=.25">` : '';
+  let dots='';
+  if(row.color_hex) dots += `<span class="dot" style="background:${row.color_hex}" title="${row.color_hex}"></span>`;
+  if(!row.color_hex && row.family) dots += `<span class="dot" style="background:${FAMILY_SWATCH[row.family]||'#bbb'}" title="${esc(row.family)}"></span>`;
+  const lineBadge = row.line ? `<span class="badge">${esc(row.line)}</span>` : '';
+  el.innerHTML = `
+    <div class="imgwrap">
+      ${img}
+      <div class="dots">${dots}</div>
+      <div class="reveal">
+        ${lineBadge}
+        <div class="ptitle">${esc(row.pattern_name)}</div>
+        <div class="rmeta">${esc(row.color_name||'—')}${row.family?' · '+esc(row.family):''}</div>
+        ${row.collection?`<div class="rmeta">${esc(row.collection)}</div>`:''}
+        <button class="reqbtn ${inCart(row.sku)?'in':''}" data-sku="${esc(row.sku)}">${inCart(row.sku)?'✓ In sample request':'Request Sample'}</button>
+      </div>
+    </div>`;
+  // click anywhere on card (not the button) toggles the reveal open (touch/click reveal)
+  el.addEventListener('click',(e)=>{ if(e.target.closest('.reqbtn')) return; el.classList.toggle('open'); });
+  el.querySelector('.reqbtn').addEventListener('click',(e)=>{e.stopPropagation();toggleCart(row, e.currentTarget);});
+  return el;
+}
+
+// ---- cart ----
+function toggleCart(row, btn){
+  if(inCart(row.sku)){ cart=cart.filter(c=>c.sku!==row.sku); }
+  else cart.push({sku:row.sku,mfr_sku:row.mfr_sku,pattern_name:row.pattern_name,color_name:row.color_name,line:row.line,image:row.image});
+  LS.set('cart',cart);
+  if(btn){ btn.classList.toggle('in',inCart(row.sku)); btn.textContent=inCart(row.sku)?'✓ In sample request':'Request Sample'; }
+  $('#cartn').textContent=cart.length;
+}
+
+// ---- active-filter chip bar (removable) ----
+function renderActive(){
+  const bar=$('#activebar'); bar.innerHTML='';
+  let any=false;
+  for(const k of Object.keys(active)){
+    for(const v of (active[k]||[])){
+      any=true;
+      const c=document.createElement('span'); c.className='achip';
+      const label = k==='enriched' ? (v==='enriched'?'Enriched':'Unenriched') : v;
+      c.innerHTML=`<span class="k">${esc(FACET_LABEL[k]||k)}</span><span>${esc(label)}</span><button class="x" title="remove">&times;</button>`;
+      c.querySelector('.x').addEventListener('click',()=>{ active[k]=active[k].filter(x=>x!==v); if(!active[k].length) delete active[k]; LS.set('filters',active); loadFacets(); reset(); });
+      bar.appendChild(c);
+    }
+  }
+  if(any){
+    const clr=document.createElement('button'); clr.className='clearall'; clr.textContent='Clear all';
+    clr.addEventListener('click',()=>{active={};LS.set('filters',active);loadFacets();reset();});
+    bar.appendChild(clr);
+    bar.classList.remove('empty');
+  } else bar.classList.add('empty');
+}
+
+function toggleFacet(key,value){
+  active[key]=active[key]||[];
+  if(active[key].includes(value)) active[key]=active[key].filter(v=>v!==value);
+  else active[key].push(value);
+  if(!active[key].length) delete active[key];
+  LS.set('filters',active); loadFacets(); reset();
+}
+
+// ---- facets as CHIPS ----
+async function loadFacets(){
+  const r=await fetch('/api/facets'); const d=await r.json();
+  const f=d.facets;
+  const meta=[
+    ['family','Color Family',f.family,false,true],
+    ['style','Style',f.style,false,false],
+    ['material_bucket','Material',f.material_bucket,false,false],
+    ['enriched','Enrichment',f.enriched,false,false],
+    ['line','Line',f.line,false,false],
+    ['product_type','Product Type',f.product_type,false,false],
+    ['collection','Collection',f.collection,true,false],
+  ];
+  const root=$('#facets'); root.innerHTML='';
+  for(const [key,title,vals,searchable,swatch] of meta){
+    if(!vals || !vals.length) continue;
+    const h=document.createElement('h3'); h.textContent=title; root.appendChild(h);
+    let search=null;
+    if(searchable){
+      search=document.createElement('input'); search.type='text'; search.className='facet-search'; search.placeholder='Filter '+title.toLowerCase()+'…';
+      root.appendChild(search);
+    }
+    const box=document.createElement('div'); box.className=searchable?'chips facet-scroll':'chips';
+    for(const {value,count} of vals){
+      const on=(active[key]||[]).includes(value);
+      const chip=document.createElement('span');
+      chip.className='chip'+(on?' on':'')+(count===0&&!on?' zero':'');
+      chip.dataset.v=value.toLowerCase();
+      const label = key==='enriched' ? (value==='enriched'?'Enriched':'Unenriched') : value;
+      const sw = swatch ? `<span class="swatch" style="background:${FAMILY_SWATCH[value]||'#bbb'}"></span>` : '';
+      chip.innerHTML=`${sw}<span>${esc(label)}</span><span class="cc">${count}</span>`;
+      if(!(count===0&&!on)) chip.addEventListener('click',()=>toggleFacet(key,value));
+      box.appendChild(chip);
+    }
+    root.appendChild(box);
+    if(search) search.addEventListener('input',()=>{const t=search.value.toLowerCase();box.querySelectorAll('.chip').forEach(l=>l.style.display=l.dataset.v.includes(t)?'':'none');});
+  }
+  const clear=document.createElement('button'); clear.className='clearbtn'; clear.textContent='Clear all filters';
+  clear.addEventListener('click',()=>{active={};LS.set('filters',active);loadFacets();reset();});
+  root.appendChild(clear);
+}
+
+// ---- controls ----
+$('#sort').addEventListener('change',e=>{sort=e.target.value;LS.set('sort',sort);reset();});
+$('#density').addEventListener('input',e=>{density=+e.target.value;LS.set('density',density);document.documentElement.style.setProperty('--cols',density);});
+let qt; $('#q').addEventListener('input',()=>{clearTimeout(qt);qt=setTimeout(reset,250);});
+
+// ---- infinite scroll ----
+new IntersectionObserver((ents)=>{ if(ents[0].isIntersecting) load(); },{rootMargin:'600px'}).observe($('#sentinel'));
+
+// ---- sample modal ----
+const overlay=$('#overlay');
+function openCart(){
+  renderCartList();
+  $('#formArea').style.display = cart.length? '' : 'none';
+  $('#doneArea').style.display='none'; $('#doneArea').innerHTML='';
+  overlay.classList.add('open');
+}
+function closeCart(){ overlay.classList.remove('open'); }
+$('#cartbtn').addEventListener('click',openCart);
+$('#closex').addEventListener('click',closeCart);
+overlay.addEventListener('click',e=>{if(e.target===overlay)closeCart();});
+
+function renderCartList(){
+  const list=$('#cartlist');
+  if(!cart.length){ list.innerHTML='<div class="status">No items yet. Click “Request Sample” on a product.</div>'; return; }
+  list.innerHTML='';
+  cart.forEach(it=>{
+    const row=document.createElement('div'); row.className='citem';
+    row.innerHTML=`${it.image?`<img src="${it.image}">`:''}
+      <div><strong>${esc(it.pattern_name||it.sku)}</strong><br><span style="color:#6f6a62">${esc(it.color_name||'')} ${it.line?'· '+esc(it.line):''}</span></div>
+      <button class="x" title="remove">&times;</button>`;
+    row.querySelector('.x').addEventListener('click',()=>{cart=cart.filter(c=>c.sku!==it.sku);LS.set('cart',cart);$('#cartn').textContent=cart.length;document.querySelectorAll('.reqbtn').forEach(b=>{if(b.dataset.sku===it.sku){b.classList.remove('in');b.textContent='Request Sample';}});openCart();});
+    list.appendChild(row);
+  });
+}
+
+$('#reqform').addEventListener('submit',async e=>{
+  e.preventDefault();
+  if(!cart.length) return;
+  const fd=new FormData(e.target);
+  const payload={items:cart};
+  for(const [k,v] of fd.entries()) payload[k]=v;
+  const btn=$('#submitbtn'); btn.disabled=true; btn.textContent='Submitting…';
+  // Shopify checkout path (real sample order) when wired; else no-payment request below.
+  if(window.DWV_SAMPLE && window.DWV_SAMPLE.available()){
+    try{ btn.textContent='Opening secure checkout…'; await window.DWV_SAMPLE.checkout(cart,{name:payload.name,email:payload.email}); return; }
+    catch(err){ /* fall through to the no-payment request flow */ }
+  }
+  try{
+    const r=await fetch('/api/sample-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+    const d=await r.json();
+    if(d.ok){
+      cart=[]; LS.set('cart',cart); $('#cartn').textContent=0;
+      document.querySelectorAll('.reqbtn.in').forEach(b=>{b.classList.remove('in');b.textContent='Request Sample';});
+      $('#formArea').style.display='none';
+      $('#doneArea').style.display='';
+      $('#doneArea').innerHTML=`<div class="ok">Thank you, ${esc(payload.name)} — your request for ${d.count} sample${d.count===1?'':'s'} was received. We’ll be in touch at ${esc(payload.email)}.</div>`;
+    } else { btn.disabled=false; btn.textContent='Submit sample request'; alert(d.error||'Could not submit.'); }
+  }catch(err){ btn.disabled=false; btn.textContent='Submit sample request'; alert('Network error.'); }
+});
+
+// ---- boot: read /api/config for title + per-vendor namespace, then hydrate state ----
+(async function boot(){
+  try{
+    const cfg=await (await fetch('/api/config')).json();
+    if(cfg.title){
+      document.title = cfg.title;
+      $('#brand').innerHTML = esc(cfg.title)+'<small>Designer Wallcoverings</small>';
+      NS = 'dwvm.'+cfg.title.replace(/[^a-z0-9]+/gi,'_').toLowerCase();
+    }
+    window.DWV_CFG = cfg.shopify || {};
+    if(window.DWV_CFG.storefrontToken && window.DWV_CFG.sampleVariantId){
+      await new Promise(res=>{const s=document.createElement('script');s.src='/buybutton.js';s.onload=res;s.onerror=res;document.head.appendChild(s);});
+    }
+  }catch{}
+  sort = LS.get('sort','newest');
+  density = LS.get('density',5);
+  cart = LS.get('cart',[]);
+  active = LS.get('filters',{});
+  $('#sort').value=sort;
+  $('#density').value=density;
+  document.documentElement.style.setProperty('--cols',density);
+  $('#cartn').textContent=cart.length;
+  renderActive();
+  loadFacets();
+  load();
+})();
+</script>
+</body>
+</html>
diff --git a/vendors/zinc_textile/viewer/server.js b/vendors/zinc_textile/viewer/server.js
new file mode 100644
index 0000000..9904dbf
--- /dev/null
+++ b/vendors/zinc_textile/viewer/server.js
@@ -0,0 +1,417 @@
+#!/usr/bin/env node
+/**
+ * DW Vendor Microsite — generalized catalog viewer (LOCAL, read-only browse + sample-order).
+ *
+ * Generalized from ~/Projects/cowtan-lines/viewer/server.js. The engine is
+ * vendor-agnostic: buildRows / sorters / facets / the /api/sample-request flow are
+ * uniform across every vendor's catalog schema (the staging JSONL is already
+ * normalized to the canonical cowtan shape by lib/export-catalog.sh).
+ *
+ * What is vendor-specific (read from argv / env / fieldmap.json):
+ *   - JSONL source path   : argv[3] || env VENDOR_JSONL || ../staging/cowtan.jsonl
+ *   - banner / page title : env VENDOR_TITLE || fieldmap.title || 'Vendor Trade Lines'
+ *   - samples-only policy  : fieldmap.samples_only (no prices/checkout when true)
+ *
+ * These lines are intentionally OFF Shopify — the ONLY purchase action is a no-payment
+ * sample request. No prices are sold, no Shopify links, no checkout.
+ *
+ * Zero dependencies. Images are live vendor CDN URLs, used as-is (never upscaled).
+ *   node server.js [port] [jsonl] [fieldmap]
+ *     port      default 0 = OS-assigned free port
+ *     jsonl     default env VENDOR_JSONL || ../staging/cowtan.jsonl
+ *     fieldmap  default env VENDOR_FIELDMAP || ./fieldmap.json (optional)
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '0', 10);
+const DIR = path.join(__dirname, '..', '..', '..', 'staging'); // repo/vendors/<v>/viewer -> repo/staging
+const SRC = process.argv[3] || process.env.VENDOR_JSONL || path.join(__dirname, '..', 'staging', 'cowtan.jsonl');
+const FIELDMAP_PATH = process.argv[4] || process.env.VENDOR_FIELDMAP || path.join(__dirname, 'fieldmap.json');
+const PUBLIC = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data');
+const REQUESTS = path.join(DATA, 'sample-requests.jsonl');
+
+let FIELDMAP = {};
+try { if (fs.existsSync(FIELDMAP_PATH)) FIELDMAP = JSON.parse(fs.readFileSync(FIELDMAP_PATH, 'utf8')); } catch { FIELDMAP = {}; }
+const TITLE = process.env.VENDOR_TITLE || FIELDMAP.title || 'Vendor Trade Lines';
+const SAMPLES_ONLY = FIELDMAP.samples_only !== false; // default true (samples-only / no checkout)
+
+const readJsonl = (f) => (fs.existsSync(f)
+  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+  : []);
+
+const num = (v) => { if (v == null) return null; const m = String(v).match(/[\d.]+/); return m ? parseFloat(m[0]) : null; };
+
+// perceived luminance 0..255 from #rrggbb
+function lum(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+  const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
+  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+// hue 0..360 from #rrggbb (for color-wheel sort)
+function hue(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+  let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  if (d === 0) return -1; // grayscale -> sort to end
+  let h;
+  if (mx === r) h = ((g - b) / d) % 6;
+  else if (mx === g) h = (b - r) / d + 2;
+  else h = (r - g) / d + 4;
+  h *= 60; if (h < 0) h += 360;
+  return h;
+}
+
+// ---- ENHANCED bucketing (Steve's spec) ------------------------------------
+// Full HSL from #rrggbb -> {h:0..360, s:0..1, l:0..1} or null.
+function hsl(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+  let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+  if ([r, g, b].some((v) => Number.isNaN(v))) return null;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  const l = (mx + mn) / 2;
+  let s = 0, h = 0;
+  if (d !== 0) {
+    s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+    if (mx === r) h = ((g - b) / d) % 6;
+    else if (mx === g) h = (b - r) / d + 2;
+    else h = (r - g) / d + 4;
+    h *= 60; if (h < 0) h += 360;
+  }
+  return { h, s, l };
+}
+
+// Canonical color families (order = display order; 0-count still listed by facets()).
+const COLOR_FAMILIES = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];
+// ai_colors hint words -> family (fallback when hex absent/ambiguous).
+const AI_COLOR_TO_FAMILY = {
+  red: 'Red', crimson: 'Red', scarlet: 'Red', burgundy: 'Red', maroon: 'Red', wine: 'Red',
+  orange: 'Orange/Brown', terracotta: 'Orange/Brown', rust: 'Orange/Brown', brown: 'Orange/Brown', tan: 'Orange/Brown', camel: 'Orange/Brown', copper: 'Orange/Brown', bronze: 'Orange/Brown', amber: 'Orange/Brown', coffee: 'Orange/Brown', chocolate: 'Orange/Brown',
+  yellow: 'Yellow', gold: 'Yellow', mustard: 'Yellow', ochre: 'Yellow',
+  green: 'Green', olive: 'Green', sage: 'Green', lime: 'Green', emerald: 'Green', forest: 'Green', moss: 'Green',
+  teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
+  blue: 'Blue', navy: 'Blue', indigo: 'Blue', cobalt: 'Blue', azure: 'Blue', denim: 'Blue',
+  purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
+  pink: 'Pink', rose: 'Pink', blush: 'Pink', fuchsia: 'Pink', magenta: 'Pink', coral: 'Pink', salmon: 'Pink',
+  beige: 'Neutral/Beige', cream: 'Neutral/Beige', ivory: 'Neutral/Beige', taupe: 'Neutral/Beige', sand: 'Neutral/Beige', linen: 'Neutral/Beige', natural: 'Neutral/Beige', oatmeal: 'Neutral/Beige', greige: 'Neutral/Beige', stone: 'Neutral/Beige',
+  black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
+  grey: 'Grey', gray: 'Grey', silver: 'Grey', slate: 'Grey', pewter: 'Grey', smoke: 'Grey',
+  white: 'White', chalk: 'White', alabaster: 'White', snow: 'White',
+};
+// hue-degrees -> chromatic family bucket.
+function familyFromHue(h) {
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) return 'Orange/Brown';
+  if (h < 70) return 'Yellow';
+  if (h < 160) return 'Green';
+  if (h < 195) return 'Teal';
+  if (h < 255) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+  return 'Red';
+}
+// Derive the color family for a row. hex first (HSL), ai_colors as fallback hint.
+function colorFamily(hex, aiColors) {
+  const c = hsl(hex);
+  if (c) {
+    // low-saturation -> achromatic by luminance
+    if (c.s < 0.12) {
+      if (c.l < 0.18) return 'Black';
+      if (c.l > 0.88) return 'White';
+      if (c.l > 0.70) return 'Neutral/Beige';
+      return 'Grey';
+    }
+    // low-ish sat + warm hue + mid/high lum -> beige rather than a vivid family
+    if (c.s < 0.22 && c.l > 0.62 && (c.h < 70 || c.h >= 330)) return 'Neutral/Beige';
+    return familyFromHue(c.h);
+  }
+  // hex missing/invalid -> ai_colors hint (first matchable word)
+  if (Array.isArray(aiColors)) {
+    for (const w of aiColors) {
+      const key = String(w || '').trim().toLowerCase();
+      if (AI_COLOR_TO_FAMILY[key]) return AI_COLOR_TO_FAMILY[key];
+      // partial: "dark green" -> green
+      for (const word of key.split(/[^a-z]+/)) if (AI_COLOR_TO_FAMILY[word]) return AI_COLOR_TO_FAMILY[word];
+    }
+  }
+  return null;
+}
+
+// Canonical styles (display order). Derived from ai_styles + ai_patterns keywords.
+const STYLE_BUCKETS = ['Floral', 'Botanical', 'Damask', 'Toile', 'Stripe', 'Geometric', 'Plaid/Check', 'Trellis', 'Animal/Fauna', 'Scenic/Mural', 'Abstract', 'Texture/Plain', 'Metallic', 'Traditional', 'Modern'];
+const STYLE_KEYWORDS = [
+  ['Floral', ['floral', 'flower', 'rose', 'peony', 'bloom', 'blossom', 'chintz']],
+  ['Botanical', ['botanical', 'leaf', 'leaves', 'fern', 'palm', 'foliage', 'tree', 'vine', 'garden', 'jungle', 'tropical']],
+  ['Damask', ['damask', 'medallion', 'ogee']],
+  ['Toile', ['toile']],
+  ['Stripe', ['stripe', 'striped', 'pinstripe', 'ticking']],
+  ['Plaid/Check', ['plaid', 'check', 'tartan', 'gingham', 'houndstooth', 'windowpane']],
+  ['Trellis', ['trellis', 'lattice', 'fretwork']],
+  ['Geometric', ['geometric', 'chevron', 'herringbone', 'diamond', 'hexagon', 'grid', 'dot', 'polka', 'moroccan', 'fret', 'greek key', 'op art']],
+  ['Animal/Fauna', ['animal', 'bird', 'butterfly', 'fauna', 'leopard', 'zebra', 'tiger', 'peacock', 'fish', 'insect', 'skin', 'hide']],
+  ['Scenic/Mural', ['scenic', 'mural', 'landscape', 'panoramic', 'chinoiserie', 'view', 'cityscape', 'map']],
+  ['Metallic', ['metallic', 'foil', 'mica', 'gilt', 'gilded', 'shimmer', 'glitter']],
+  ['Texture/Plain', ['texture', 'textured', 'plain', 'solid', 'grasscloth', 'linen', 'woven', 'weave', 'sisal', 'cork', 'faux', 'concrete', 'stucco', 'plaster', 'leather', 'suede', 'tweed']],
+  ['Abstract', ['abstract', 'organic', 'watercolor', 'marble', 'painterly', 'brushstroke', 'ombre', 'tie dye', 'splatter']],
+  ['Traditional', ['traditional', 'classic', 'heritage', 'colonial', 'victorian']],
+  ['Modern', ['modern', 'contemporary', 'minimal', 'mid-century', 'midcentury', 'scandinavian']],
+];
+// First matching style bucket from the row's ai_styles + ai_patterns words.
+function styleBucket(aiStyles, aiPatterns) {
+  const words = [];
+  for (const arr of [aiStyles, aiPatterns]) if (Array.isArray(arr)) for (const w of arr) words.push(String(w || '').toLowerCase());
+  const blob = ' ' + words.join(' ') + ' ';
+  for (const [bucket, kws] of STYLE_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
+  return null;
+}
+
+// Material buckets from material / product_type strings.
+const MATERIAL_BUCKETS = ['Non-woven', 'Paper', 'Vinyl', 'Grasscloth', 'Fabric/Textile', 'Cork', 'Mylar/Metallic', 'Leather/Suede', 'Mural', 'Other'];
+const MATERIAL_KEYWORDS = [
+  ['Non-woven', ['non-woven', 'nonwoven', 'non woven']],
+  ['Grasscloth', ['grasscloth', 'grass cloth', 'sisal', 'jute', 'hemp', 'raffia', 'arrowroot', 'seagrass']],
+  ['Vinyl', ['vinyl', 'pvc', 'type ii', 'type 2', 'commercial']],
+  ['Cork', ['cork']],
+  ['Mylar/Metallic', ['mylar', 'metallic', 'foil', 'mica']],
+  ['Leather/Suede', ['leather', 'suede', 'ultrasuede', 'hide']],
+  ['Mural', ['mural', 'panel', 'panoramic']],
+  ['Fabric/Textile', ['fabric', 'textile', 'linen', 'silk', 'cotton', 'wool', 'velvet', 'woven', 'weave']],
+  ['Paper', ['paper', 'wallpaper', 'wallcovering', 'pulp']],
+];
+function materialBucket(material, ptype) {
+  const blob = ' ' + (String(material || '') + ' ' + String(ptype || '')).toLowerCase() + ' ';
+  if (blob.trim() === '') return null;
+  for (const [bucket, kws] of MATERIAL_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
+  return null;
+}
+
+function buildRows() {
+  const rows = [];
+  let i = 0;
+  for (const r of readJsonl(SRC)) {
+    let images = [];
+    if (Array.isArray(r.all_images)) images = r.all_images;
+    else if (typeof r.all_images === 'string' && r.all_images.trim()) { try { images = JSON.parse(r.all_images); } catch { images = []; } }
+    if (!images.length && r.image_url) images = [r.image_url];
+    const ptype = (r.product_type || '').replace(/^./, (c) => c.toUpperCase()); // normalize casing
+    const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
+    const aiColors = parseArr(r.ai_colors), aiStyles = parseArr(r.ai_styles), aiPatterns = parseArr(r.ai_patterns);
+    const enriched = (aiColors.length > 0 || aiStyles.length > 0); // ai_colors OR ai_styles non-empty
+    rows.push({
+      idx: i++,
+      sku: r.dw_sku || r.mfr_sku || String(i),
+      mfr_sku: r.mfr_sku || null,
+      pattern_name: r.pattern_name || r.mfr_sku || '(untitled)',
+      color_name: r.color_name || null,
+      collection: r.collection || null,
+      product_type: ptype || null,
+      material: r.material || null,
+      width: r.width || null,
+      repeat_v: r.repeat_v || null,
+      repeat_h: r.repeat_h || null,
+      color_primary: r.color_primary || null,
+      color_hex: r.color_hex || null,
+      ai_colors: aiColors,
+      ai_styles: aiStyles,
+      ai_patterns: aiPatterns,
+      repeat_classification: r.repeat_classification || null,
+      finish: r.finish || null,
+      application: r.application || null,
+      coverage: r.coverage || null,
+      fire_rating: r.fire_rating || null,
+      line: r.line || r.material || null,
+      image: r.image_url || images[0] || null,
+      images,
+      image_count: images.length,
+      // ENHANCED buckets (server-side)
+      family: colorFamily(r.color_hex, aiColors),
+      style: styleBucket(aiStyles, aiPatterns),
+      material_bucket: materialBucket(r.material, ptype),
+      enriched,
+      _lum: lum(r.color_hex),
+      _hue: hue(r.color_hex),
+    });
+  }
+  return rows;
+}
+
+let ROWS = buildRows();
+fs.watchFile(SRC, { interval: 5000 }, () => { ROWS = buildRows(); });
+
+const cmpStr = (a, b) => String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, { sensitivity: 'base' });
+const nullsLast = (v) => (v == null ? Infinity : v);
+
+const sorters = {
+  newest: null, // natural staged order
+  pattern: (a, b) => cmpStr(a.pattern_name, b.pattern_name),
+  color_name: (a, b) => cmpStr(a.color_name, b.color_name),
+  collection: (a, b) => cmpStr(a.collection, b.collection) || cmpStr(a.pattern_name, b.pattern_name),
+  line: (a, b) => cmpStr(a.line, b.line) || cmpStr(a.pattern_name, b.pattern_name),
+  product_type: (a, b) => cmpStr(a.product_type, b.product_type) || cmpStr(a.pattern_name, b.pattern_name),
+  material: (a, b) => cmpStr(a.material, b.material) || cmpStr(a.pattern_name, b.pattern_name),
+  // light -> dark: brightest first; missing-hex always sort last
+  light: (a, b) => { const la = a._lum == null ? -1 : a._lum, lb = b._lum == null ? -1 : b._lum; return lb - la; },
+  // dark -> light: darkest first; missing-hex always sort last
+  dark: (a, b) => { const la = a._lum == null ? 999 : a._lum, lb = b._lum == null ? 999 : b._lum; return la - lb; },
+  hue: (a, b) => {
+    // grayscale (-1) and null sort to the very end
+    const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue;
+    const hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
+    return ha - hb;
+  },
+  sku: (a, b) => cmpStr(a.mfr_sku || a.sku, b.mfr_sku || b.sku),
+  // group by canonical color family (display order), nulls last, then by hue within
+  family: (a, b) => {
+    const ia = a.family == null ? 999 : COLOR_FAMILIES.indexOf(a.family);
+    const ib = b.family == null ? 999 : COLOR_FAMILIES.indexOf(b.family);
+    if (ia !== ib) return ia - ib;
+    const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue, hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
+    return ha - hb;
+  },
+  // group by canonical style (display order), nulls last, then pattern name
+  style: (a, b) => {
+    const ia = a.style == null ? 999 : STYLE_BUCKETS.indexOf(a.style);
+    const ib = b.style == null ? 999 : STYLE_BUCKETS.indexOf(b.style);
+    return ia - ib || cmpStr(a.pattern_name, b.pattern_name);
+  },
+};
+
+function facets() {
+  // ENHANCED facets: color-family / style / material (bucketed) REPLACE raw color_primary
+  // as the color facet. Plus enriched, line, product_type, collection.
+  const fam = {}, sty = {}, mat = {};
+  const f = { line: {}, product_type: {}, collection: {} };
+  let enrichedN = 0, unenrichedN = 0;
+  for (const r of ROWS) {
+    const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
+    bump(fam, r.family);
+    bump(sty, r.style);
+    bump(mat, r.material_bucket);
+    bump(f.line, r.line);
+    bump(f.product_type, r.product_type);
+    bump(f.collection, r.collection);
+    if (r.enriched) enrichedN++; else unenrichedN++;
+  }
+  // canonical-ordered facets: every value listed even at count 0 (UI greys 0s)
+  const ordered = (canon, counts) => canon.map((value) => ({ value, count: counts[value] || 0 }));
+  // count-desc for free-form facets; collection alpha (searchable)
+  const byCount = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
+  const byAlpha = (counts) => Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
+  return {
+    family: ordered(COLOR_FAMILIES, fam),
+    style: ordered(STYLE_BUCKETS, sty),
+    material_bucket: ordered(MATERIAL_BUCKETS, mat),
+    enriched: [
+      { value: 'enriched', count: enrichedN },
+      { value: 'unenriched', count: unenrichedN },
+    ],
+    line: byCount(f.line),
+    product_type: byCount(f.product_type),
+    collection: byAlpha(f.collection),
+  };
+}
+
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+const server = http.createServer((req, res) => {
+  const u = new URL(req.url, 'http://localhost');
+
+  if (u.pathname === '/api/config') {
+    return json(res, 200, { title: TITLE, samples_only: SAMPLES_ONLY, total: ROWS.length,
+      shopify: {
+        domain: process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
+        storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN || '',
+        sampleVariantId: process.env.SHOPIFY_SAMPLE_VARIANT_ID || ''
+      } });
+  }
+
+  if (u.pathname === '/api/facets') {
+    return json(res, 200, { total: ROWS.length, facets: facets() });
+  }
+
+  if (u.pathname === '/api/skus') {
+    const q = (u.searchParams.get('q') || '').trim().toLowerCase();
+    const sort = u.searchParams.get('sort') || 'newest';
+    const offset = parseInt(u.searchParams.get('offset') || '0', 10);
+    const limit = Math.min(parseInt(u.searchParams.get('limit') || '60', 10), 200);
+    // multi-select facets: pipe-separated ('|'). Buckets + free-form + enriched.
+    const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
+    const fFam = sel('family'), fStyle = sel('style'), fMat = sel('material_bucket');
+    const fEnr = sel('enriched'); // 'enriched' | 'unenriched' (multi tolerated -> OR)
+    const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');
+
+    let rows = ROWS;
+    const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
+    const enrMatch = (r) => {
+      if (!fEnr.length) return true;
+      const wantE = fEnr.includes('enriched'), wantU = fEnr.includes('unenriched');
+      return (wantE && r.enriched) || (wantU && !r.enriched);
+    };
+    rows = rows.filter((r) =>
+      inAny(fFam, r.family) && inAny(fStyle, r.style) && inAny(fMat, r.material_bucket) &&
+      inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) && enrMatch(r));
+    if (q) {
+      rows = rows.filter((r) => (
+        (r.pattern_name || '') + ' ' + (r.color_name || '') + ' ' + (r.mfr_sku || '') + ' ' +
+        (r.sku || '') + ' ' + (r.collection || '')).toLowerCase().includes(q));
+    }
+    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+    const page = rows.slice(offset, offset + limit);
+    return json(res, 200, { total: rows.length, all: ROWS.length, offset, limit, rows: page });
+  }
+
+  if (u.pathname === '/api/sample-request' && req.method === 'POST') {
+    let body = '';
+    req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
+    req.on('end', () => {
+      let p; try { p = JSON.parse(body || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
+      const name = (p.name || '').toString().trim();
+      const email = (p.email || '').toString().trim();
+      const items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
+      if (!name || !email || !/.+@.+\..+/.test(email) || !items.length) {
+        return json(res, 400, { ok: false, error: 'name, valid email, and at least one item are required' });
+      }
+      const rec = {
+        ts: new Date().toISOString(),
+        vendor: TITLE,
+        name, email,
+        company: (p.company || '').toString().trim() || null,
+        phone: (p.phone || '').toString().trim() || null,
+        address: (p.address || '').toString().trim() || null,
+        notes: (p.notes || '').toString().trim() || null,
+        items: items.map((it) => ({
+          sku: (it.sku || '').toString(),
+          mfr_sku: (it.mfr_sku || '').toString() || null,
+          pattern_name: (it.pattern_name || '').toString() || null,
+          color_name: (it.color_name || '').toString() || null,
+          line: (it.line || '').toString() || null,
+        })),
+        ua: (req.headers['user-agent'] || '').slice(0, 200),
+      };
+      try {
+        fs.mkdirSync(DATA, { recursive: true });
+        fs.appendFileSync(REQUESTS, JSON.stringify(rec) + '\n');
+      } catch (e) {
+        return json(res, 500, { ok: false, error: 'could not save request' });
+      }
+      return json(res, 200, { ok: true, count: rec.items.length });
+    });
+    return;
+  }
+
+  // static
+  let pth = u.pathname === '/' ? '/index.html' : u.pathname;
+  const file = path.join(PUBLIC, path.normalize(pth).replace(/^(\.\.[/\\])+/, ''));
+  if (!file.startsWith(PUBLIC) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end('not found'); }
+  const ext = path.extname(file);
+  const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }[ext] || 'text/plain';
+  res.writeHead(200, { 'Content-Type': mime });
+  res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+  const addr = server.address();
+  console.log(`${TITLE} viewer -> http://localhost:${addr.port}  (${ROWS.length} products${SAMPLES_ONLY ? ', samples-only' : ''})`);
+});

← f52a652 build zoffany (Zoffany) -> BUILT, port 10090, 384 skus, img  ·  back to Dw Vendor Microsites  ·  build atomic50 (Atomic 50 Ceilings) -> BUILT, port 10092, 59 0d12c7a →