[object Object]

← back to Momentum Landing

initial momentum-landing internal viewer (Hollywood line, dw_sku on card)

c7fea0d303f91caeca2688651e7435a1332c828e · 2026-07-08 15:12:47 -0700 · Steve Abrams

Copied+adapted from astek-landing. Basic-auth (admin:DW2024!) internal viewer
over dw_unified.momentum_colorways (10,985 rows, Wallcovering+Acoustic) private-
labeled as Hollywood Wallcoverings. Momentum/Versa scrubbed from public strings.
Left-panel field tables (Category/Collection/Pattern/Color/Product Line/Width/
Status/Price/Color family), dw_sku teal chip + created-date+time chip on every
card, density-driven type scale, no <a>-wrapped cards, location.origin fetches.
13/13 E2E checks pass in real WebKit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c7fea0d303f91caeca2688651e7435a1332c828e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 8 15:12:47 2026 -0700

    initial momentum-landing internal viewer (Hollywood line, dw_sku on card)
    
    Copied+adapted from astek-landing. Basic-auth (admin:DW2024!) internal viewer
    over dw_unified.momentum_colorways (10,985 rows, Wallcovering+Acoustic) private-
    labeled as Hollywood Wallcoverings. Momentum/Versa scrubbed from public strings.
    Left-panel field tables (Category/Collection/Pattern/Color/Product Line/Width/
    Status/Price/Color family), dw_sku teal chip + created-date+time chip on every
    card, density-driven type scale, no <a>-wrapped cards, location.origin fetches.
    13/13 E2E checks pass in real WebKit.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                     |  13 ++
 docs/proof/webkit-grid.png     | Bin 0 -> 325371 bytes
 lib/vendor-requests.js         | 222 ++++++++++++++++++++
 package.json                   |  15 ++
 public/curate.html             | 160 +++++++++++++++
 public/index.html              | 446 +++++++++++++++++++++++++++++++++++++++++
 public/product.html            | 238 ++++++++++++++++++++++
 public/styles.css              | 286 ++++++++++++++++++++++++++
 scripts/build-products-json.js | 206 +++++++++++++++++++
 scripts/e2e-verify.js          |  95 +++++++++
 server.js                      | 162 +++++++++++++++
 11 files changed, 1843 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7d34bd8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/products.json
+data/inquiries.jsonl
+data/selection.json
+node_modules
diff --git a/docs/proof/webkit-grid.png b/docs/proof/webkit-grid.png
new file mode 100644
index 0000000..f4843cc
Binary files /dev/null and b/docs/proof/webkit-grid.png differ
diff --git a/lib/vendor-requests.js b/lib/vendor-requests.js
new file mode 100644
index 0000000..cdf81fe
--- /dev/null
+++ b/lib/vendor-requests.js
@@ -0,0 +1,222 @@
+/* Internal purchasing requests — shared module for every *.designerwallcoverings.com
+   internal vendor PDP (astek, reidwitlin, quadrille, sangetsu, …).
+
+   POST /api/request           → create REQ-YYMMDD-<id> in dw_unified.vendor_requests;
+                                 stock/price requests AUTO-EMAIL the vendor via
+                                 george-gmail (:9850) when a vendor email is on file.
+   POST /api/request/preview   → compose the exact email (no insert, no send).
+   GET  /api/requests          → recent requests for this vendor (internal queue view).
+
+   Hard rules baked in:
+   - NEVER include a client name — emails reference our account number + REQ only.
+   - Vendor emails go out ONLY on an explicit user click behind Basic Auth; the
+     george external-send guard token is passed because that click IS the approval.
+   - No vendor email on file → request is still logged (status no_email) and the
+     caller is told to phone the vendor instead. */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { Pool } = require('pg');
+
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const FROM = 'steve@designerwallcoverings.com';
+
+function envFrom(file, key) {
+  try { const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].replace(/^["']|["']$/g, '').trim() : ''; }
+  catch { return ''; }
+}
+// Resolve a value from the first source that has it: an explicit process env var,
+// then each candidate .env file. Makes the lib portable across Mac (~/Projects/...)
+// and Kamatera, where george lives at ~/DW-Agents/gmail-agent — the old single
+// hard-coded path returned '' there, so the send token was empty and george
+// rejected every request (status email_failed).
+function firstEnv(key, files) {
+  if (process.env[key]) return process.env[key];
+  for (const f of files) { const v = envFrom(f, key); if (v) return v; }
+  return '';
+}
+const HOME = os.homedir();
+const GEORGE_ENVS = [
+  path.join(HOME, 'Projects/george-gmail/.env'),  // Mac dev
+  path.join(HOME, 'DW-Agents/gmail-agent/.env'),   // Kamatera prod
+];
+const SEND_TOKEN = firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS);
+const PGPASS = firstEnv('DW_ADMIN_DB_PASSWORD', [path.join(HOME, 'Projects/secrets-manager/.env')]);
+// george basic auth — mirror george's own resolution: GEORGE_BASIC_AUTH from its
+// .env when set, else the historical fallback (admin + empty password).
+const GEORGE_AUTH = firstEnv('GEORGE_BASIC_AUTH', GEORGE_ENVS) || 'admin:';
+
+let pool = null;
+function db() {
+  if (!pool) pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PGPASS, max: 3 });
+  return pool;
+}
+
+// Self-bootstrap the table so a fresh DB (Kamatera, mac2, a new sister site)
+// never throws `relation "vendor_requests" does not exist` on the first click.
+// Idempotent (IF NOT EXISTS) and memoized — the DDL runs at most once per process.
+let schemaReady = null;
+function ensureSchema() {
+  if (!schemaReady) {
+    schemaReady = db().query(`
+      CREATE TABLE IF NOT EXISTS vendor_requests (
+        id                bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+        req_no            text,
+        req_type          text,
+        vendor_code       text,
+        vendor_name       text,
+        vendor_email      text,
+        account_number    text,
+        dw_sku            text,
+        mfr_sku           text,
+        product_title     text,
+        qty               text,
+        note              text,
+        requested_by      text,
+        status            text,
+        email_subject     text,
+        email_body        text,
+        email_message_id  text,
+        created_at        timestamptz NOT NULL DEFAULT now(),
+        sent_at           timestamptz
+      );
+      CREATE INDEX IF NOT EXISTS vendor_requests_vendor_code_id_idx
+        ON vendor_requests (vendor_code, id DESC);
+    `).catch(e => { schemaReady = null; console.error('[vendor-requests] ensureSchema failed:', e.message); throw e; });
+  }
+  return schemaReady;
+}
+
+const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
+
+// ---- email composition — polite, account-number-only, client-anonymous ----
+// vendor.buy_from = distributor that carries the line (e.g. Artmura via NewWall):
+// the email greets the distributor; the line name stays in the subject/item.
+function composeEmail(type, vendor, item, reqNo) {
+  const who = vendor.buy_from || vendor.name;
+  const acct = vendor.account_number || null;
+  const ask = type === 'stock'
+    ? 'Could you please check current stock availability on the following item?'
+    : 'Could you please confirm our current net / wholesale cost (and any applicable discount) on the following item?';
+  // HARD RULE (Steve 2026-07-02): outbound emails NEVER mention our internal
+  // (DW) SKU numbers — manufacturer SKU + REQ number only.
+  const subjectHead = type === 'stock' ? 'Stock check' : 'Pricing request';
+  const subjectSku = item.mfr_sku ? ` (${item.mfr_sku})` : '';
+  const subject = `${subjectHead} — ${item.title || item.mfr_sku || 'item inquiry'}${subjectSku}${acct ? ` — Acct #${acct}` : ''} — ${reqNo}`;
+  const rows = [
+    ['Pattern', item.title], ['Item / SKU', item.mfr_sku],
+    ['Quantity', item.qty || (type === 'stock' ? 'to be confirmed' : null)],
+    ['Note', item.note],
+  ].filter(r => r[1]);
+  const body = [
+    `<p>Hello ${esc(who)} team,</p>`,
+    `<p>${ask}</p>`,
+    `<p>${rows.map(r => `&nbsp;&nbsp;&nbsp;&nbsp;<b>${esc(r[0])}:</b> ${esc(r[1])}`).join('<br>')}</p>`,
+    `<p>This is for Designer Wallcoverings${acct ? `, account #${esc(acct)}` : ''}. Please reference <b>${reqNo}</b> in your reply.</p>`,
+    `<p>Thank you very much — we appreciate your help!</p>`,
+    `<p>Warm regards,<br><br>Designer Wallcoverings — Purchasing<br>${FROM}${acct ? `<br>Account #${esc(acct)}` : ''} · Ref ${reqNo}</p>`,
+  ].join('\n');
+  return { subject, body };
+}
+
+function georgeSend({ to, subject, body }) {
+  return new Promise((resolve) => {
+    const payload = JSON.stringify({ account: 'steve-office', from: FROM, to, subject, body, source: 'vendor-pdp' });
+    const u = new URL(GEORGE + '/api/send');
+    const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'X-Send-Approval': SEND_TOKEN,
+        'Authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64') } }, res => {
+      let d = ''; res.on('data', c => d += c);
+      res.on('end', () => { try { resolve({ status: res.statusCode, ...JSON.parse(d) }); } catch { resolve({ status: res.statusCode, error: d.slice(0, 300) }); } });
+    });
+    req.on('error', e => resolve({ status: 0, error: e.message }));
+    req.setTimeout(15000, () => { req.destroy(); resolve({ status: 0, error: 'george timeout' }); });
+    req.end(payload);
+  });
+}
+
+function reqNoFor(id, createdAt) {
+  const d = new Date(createdAt);
+  const ymd = d.getFullYear().toString().slice(2) + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
+  return `REQ-${ymd}-${id}`;
+}
+
+function mountVendorRequests(app, { vendorCode, getVendor, dataDir }) {
+  // warm the schema on mount so the table exists before the first request lands
+  ensureSchema().catch(() => {});
+
+  // exact email preview — same composer the send path uses; nothing persisted
+  app.post('/api/request/preview', (req, res) => {
+    const { type, dw_sku, mfr_sku, title, qty, note } = req.body || {};
+    if (!['stock', 'price'].includes(type)) return res.status(400).json({ ok: false, error: 'preview is for stock/price only' });
+    const vendor = getVendor() || {};
+    const { subject, body } = composeEmail(type, vendor, { dw_sku, mfr_sku, title, qty, note }, 'REQ-(assigned on send)');
+    res.json({ ok: true, to: vendor.email || null, from: FROM, subject, body, no_email: !vendor.email, phone: vendor.phone || null });
+  });
+
+  app.post('/api/request', async (req, res) => {
+    const { dw_sku, mfr_sku, title, qty, note, name } = req.body || {};
+    const type = ['memo', 'stock', 'price'].includes(req.body && req.body.type) ? req.body.type : 'memo';
+    if (!dw_sku && !mfr_sku) return res.status(400).json({ ok: false, error: 'sku required' });
+    const vendor = getVendor() || {};
+    try {
+      await ensureSchema(); // guarantees the table on a cold process (no more "relation does not exist")
+      const ins = await db().query(
+        `INSERT INTO vendor_requests (req_type, vendor_code, vendor_name, vendor_email, account_number,
+           dw_sku, mfr_sku, product_title, qty, note, requested_by, status)
+         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'open') RETURNING id, created_at`,
+        [type, vendorCode, vendor.name || null, vendor.email || null, vendor.account_number || null,
+         dw_sku || null, mfr_sku || null, title || null, qty || null, note || null, name || FROM]);
+      const { id, created_at } = ins.rows[0];
+      const reqNo = reqNoFor(id, created_at);
+
+      let status = 'open', emailed = false, message, mail = null;
+      if (type === 'memo') {
+        message = `${reqNo} logged — purchasing will order the memo sample.`;
+      } else if (!vendor.email) {
+        status = 'no_email';
+        message = `${reqNo} logged. No vendor email on file — call ${vendor.buy_from || vendor.name || 'the vendor'}${vendor.phone ? ' at ' + vendor.phone : ''}.`;
+      } else {
+        mail = composeEmail(type, vendor, { dw_sku, mfr_sku, title, qty, note }, reqNo);
+        const to = process.env.REQUEST_EMAIL_OVERRIDE || vendor.email; // override = smoke-test only
+        const sent = await georgeSend({ to, ...mail });
+        if (sent.success) {
+          status = 'emailed'; emailed = true;
+          message = `${reqNo} sent to ${vendor.buy_from || vendor.name} (${to}).`;
+        } else {
+          status = 'email_failed';
+          message = `${reqNo} logged, but the email failed (${sent.error || 'status ' + sent.status}) — call ${vendor.phone || 'the vendor'}.`;
+        }
+        await db().query(
+          `UPDATE vendor_requests SET req_no=$1, status=$2, email_subject=$3, email_body=$4,
+             email_message_id=$5, sent_at=CASE WHEN $2='emailed' THEN now() ELSE NULL END WHERE id=$6`,
+          [reqNo, status, mail.subject, mail.body, sent.messageId || null, id]);
+      }
+      if (type === 'memo' || status === 'no_email') {
+        await db().query(`UPDATE vendor_requests SET req_no=$1, status=$2 WHERE id=$3`, [reqNo, status, id]);
+      }
+      // local trail, same file the old inquiry endpoint used
+      try {
+        fs.appendFileSync(path.join(dataDir, 'inquiries.jsonl'),
+          JSON.stringify({ at: new Date().toISOString(), req_no: reqNo, type, status, sku: dw_sku || mfr_sku, qty: qty || '', note: note || '' }) + '\n');
+      } catch {}
+      res.json({ ok: true, req_no: reqNo, status, emailed, message });
+    } catch (e) {
+      console.error('[vendor-requests]', e.message);
+      res.status(500).json({ ok: false, error: 'request failed: ' + e.message });
+    }
+  });
+
+  app.get('/api/requests', async (_req, res) => {
+    try {
+      await ensureSchema();
+      const { rows } = await db().query(
+        `SELECT req_no, req_type, status, dw_sku, mfr_sku, product_title, qty, created_at, sent_at
+         FROM vendor_requests WHERE vendor_code=$1 ORDER BY id DESC LIMIT 50`, [vendorCode]);
+      res.json({ requests: rows });
+    } catch (e) { res.status(500).json({ error: e.message }); }
+  });
+}
+
+module.exports = { mountVendorRequests, composeEmail };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2d7b4b4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "momentum-landing",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Internal line viewer for Hollywood Wallcoverings (Momentum private label; INTERNAL dw_unified.momentum_colorways — not on Shopify)",
+  "scripts": {
+    "build-products": "node scripts/build-products-json.js",
+    "start": "node server.js"
+  },
+  "dependencies": {
+    "compression": "^1.8.1",
+    "express": "^4.19.2",
+    "pg": "^8.11.5"
+  }
+}
diff --git a/public/curate.html b/public/curate.html
new file mode 100644
index 0000000..af007f7
--- /dev/null
+++ b/public/curate.html
@@ -0,0 +1,160 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Select · Hollywood Wallcoverings (internal)</title>
+<style>
+:root{--cols:8;--bg:#0c0d12;--panel:#15171f;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;--new:#f59e0b}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
+ border-bottom:1px solid var(--line);padding:10px 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+h1{font-size:14px;margin:0;font-weight:700}
+h1 .pl{color:var(--acc)}
+.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px;font-variant-numeric:tabular-nums}
+.muted{color:var(--mut)}
+select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12.5px}
+input[type=range]{accent-color:var(--acc);width:150px}
+.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 11px;cursor:pointer;font-size:12.5px;text-decoration:none}
+.btn:hover{border-color:var(--acc)}
+.btn.on{outline:1px solid var(--acc);color:var(--acc)}
+.ctl{display:flex;align-items:center;gap:6px}
+/* texture-only grid — the image IS the tile */
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:6px;padding:8px 10px 70px}
+.tile{position:relative;aspect-ratio:1/1;background:#0a0b0f;border:1px solid var(--line);border-radius:2px;overflow:hidden;cursor:pointer;user-select:none}
+.tile img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
+.tile:hover{border-color:#414a5e}
+.tile.sel{border-color:var(--acc);box-shadow:inset 0 0 0 2px var(--acc)}
+.tile.sel::after{content:'✓';position:absolute;top:5px;right:5px;width:20px;height:20px;border-radius:50%;
+ background:var(--acc);color:#04251e;font-weight:800;font-size:13px;display:flex;align-items:center;justify-content:center}
+.tile .tip{position:absolute;left:0;right:0;bottom:0;background:#000b;color:#cbd5e1;font-size:10px;padding:2px 6px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;transition:opacity .12s}
+.tile:hover .tip{opacity:1}
+#more{display:block;margin:8px auto 70px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
+#empty{padding:60px;text-align:center;color:var(--mut)}
+/* selection tray */
+#tray{position:fixed;left:0;right:0;bottom:0;z-index:20;display:flex;gap:10px;align-items:center;flex-wrap:wrap;
+ background:#0e0f15f2;border-top:1px solid var(--line);padding:9px 14px;backdrop-filter:blur(8px)}
+#tray .cnt{font-weight:700;color:var(--acc);font-variant-numeric:tabular-nums}
+#save{font-size:11px;color:var(--mut)}
+</style></head>
+<body>
+<header>
+  <h1>Hollywood Wallcoverings · <span class="pl">Select</span></h1>
+  <span class="pill" id="total">…</span>
+  <span class="muted" id="meta"></span>
+  <div class="ctl"><input type="text" id="q" placeholder="search pattern / dw sku / color…"></div>
+  <div class="ctl"><select id="book"><option value="">All categories</option></select></div>
+  <div class="ctl"><select id="color"><option value="">All colors</option></select></div>
+  <div class="ctl"><label class="muted">Grid</label>
+    <input type="range" id="density" min="1" max="20" step="1"><span class="muted" id="denv"></span></div>
+  <a class="btn" href="/">← Catalog</a>
+</header>
+<div class="grid" id="grid"></div>
+<button id="more" style="display:none">Load more</button>
+<div id="empty" style="display:none">No products match.</div>
+<div id="tray">
+  <span><span class="cnt" id="selcount">0</span> selected</span>
+  <button class="btn" id="selvis">☑ Select visible</button>
+  <button class="btn" id="unselvis">☐ Unselect visible</button>
+  <button class="btn" id="showsel">Show selected only</button>
+  <button class="btn" id="clear">✕ Clear all</button>
+  <button class="btn" id="csv">⬇ Export CSV</button>
+  <span id="save" class="muted"></span>
+  <span class="muted" style="margin-left:auto">click = select · shift-click = range · ⌘-click = open product</span>
+</div>
+<script>
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], VIEW=[], shown=0, lastIdx=null, showSelOnly=false;
+const PAGE=400;
+const SEL=new Set();          // dw_sku set — the Phillipe Romano candidate list
+const BY_SKU={};              // dw_sku -> product
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+const dens=localStorage.getItem('hw.cur.density')||'8';
+$('#density').value=dens; $('#denv').textContent=dens;
+document.documentElement.style.setProperty('--cols',dens);
+$('#density').oninput=()=>{const v=$('#density').value;document.documentElement.style.setProperty('--cols',v);
+ $('#denv').textContent=v;localStorage.setItem('hw.cur.density',v);};
+
+function tile(p,idx){
+  const on=SEL.has(p.dw_sku);
+  return `<div class="tile${on?' sel':''}" data-sku="${esc(p.dw_sku)}" data-idx="${idx}" data-href="/product/${encodeURIComponent(p.handle)}">
+    <img loading="lazy" src="${esc(p.swatch)}" alt="${esc(p.display_name||p.sku||'')}">
+    <span class="tip">${esc(p.series||p.display_name||'')}${p.color?' · '+esc(p.color):''} · ${esc(p.sku||'')}</span>
+  </div>`;
+}
+grid.addEventListener('click',e=>{
+  const t=e.target.closest('.tile'); if(!t)return;
+  if(e.metaKey||e.ctrlKey){window.open(t.dataset.href,'_blank');return;}
+  const idx=Number(t.dataset.idx);
+  if(e.shiftKey&&lastIdx!=null){
+    const [a,b]=[Math.min(lastIdx,idx),Math.max(lastIdx,idx)];
+    const turnOn=!SEL.has(t.dataset.sku);
+    for(let i=a;i<=b;i++){const p=VIEW[i];if(!p)continue;turnOn?SEL.add(p.dw_sku):SEL.delete(p.dw_sku);}
+    repaint();
+  }else{
+    SEL.has(t.dataset.sku)?SEL.delete(t.dataset.sku):SEL.add(t.dataset.sku);
+    t.classList.toggle('sel',SEL.has(t.dataset.sku));
+  }
+  lastIdx=idx; syncCount(); saveSoon();
+});
+function repaint(){document.querySelectorAll('.tile').forEach(t=>t.classList.toggle('sel',SEL.has(t.dataset.sku)));}
+function syncCount(){$('#selcount').textContent=SEL.size.toLocaleString();}
+let st;
+function saveSoon(){clearTimeout(st);st=setTimeout(saveNow,700);}
+async function saveNow(){
+  try{
+    const r=await fetch(location.origin+'/api/selection',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({skus:[...SEL]})}).then(r=>r.json());
+    $('#save').textContent=r.ok?('saved '+new Date().toLocaleTimeString()):'save failed';
+  }catch{$('#save').textContent='save failed';}
+}
+function applyFilters(){
+  const bk=$('#book').value, cl=$('#color').value, q=$('#q').value.trim().toLowerCase();
+  VIEW=ALL.filter(p=>
+    (!showSelOnly||SEL.has(p.dw_sku))&&
+    (!bk||p.book===bk)&&(!cl||p.color_bucket===cl)&&
+    (!q||[p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book].some(v=>v&&String(v).toLowerCase().includes(q))));
+  shown=0; lastIdx=null; grid.innerHTML=''; renderMore();
+  $('#total').textContent=VIEW.length.toLocaleString()+' designs';
+  $('#empty').style.display=VIEW.length?'none':'block';
+}
+function renderMore(){
+  const slice=VIEW.slice(shown,shown+PAGE);
+  grid.insertAdjacentHTML('beforeend',slice.map((p,i)=>tile(p,shown+i)).join(''));
+  shown+=slice.length;
+  $('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
+  $('#more').style.display=shown<VIEW.length?'block':'none';
+}
+$('#more').onclick=renderMore;
+addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
+$('#book').onchange=applyFilters; $('#color').onchange=applyFilters;
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
+$('#selvis').onclick=()=>{VIEW.slice(0,shown).forEach(p=>SEL.add(p.dw_sku));repaint();syncCount();saveSoon();};
+$('#unselvis').onclick=()=>{VIEW.slice(0,shown).forEach(p=>SEL.delete(p.dw_sku));repaint();syncCount();saveSoon();};
+$('#showsel').onclick=()=>{showSelOnly=!showSelOnly;$('#showsel').classList.toggle('on',showSelOnly);applyFilters();};
+$('#clear').onclick=()=>{if(!SEL.size||confirm('Clear all '+SEL.size+' selected?')){SEL.clear();repaint();syncCount();saveSoon();applyFilters();}};
+$('#csv').onclick=()=>{
+  const rows=[['dw_sku','vendor_sku','pattern','color','book','handle','swatch_url']];
+  for(const s of SEL){const p=BY_SKU[s];if(!p)continue;
+    rows.push([p.dw_sku,p.sku,p.series||p.display_name,p.color||'',p.book||'',p.handle,p.swatch||'']);}
+  const csv=rows.map(r=>r.map(v=>'"'+String(v??'').replace(/"/g,'""')+'"').join(',')).join('\n');
+  const a=document.createElement('a');
+  a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
+  a.download='hollywood-wallcoverings-candidates-'+new Date().toISOString().slice(0,10)+'.csv';
+  a.click();
+};
+async function load(){
+  const [pd,fc,sel]=await Promise.all([
+    fetch(location.origin+'/api/products').then(r=>r.json()),
+    fetch(location.origin+'/api/facets').then(r=>r.json()),
+    fetch(location.origin+'/api/selection').then(r=>r.json())]);
+  ALL=pd.products; ALL.forEach(p=>BY_SKU[p.dw_sku]=p);
+  (sel.skus||[]).forEach(s=>SEL.add(s));
+  $('#book').innerHTML='<option value="">All categories</option>'+(fc.category||fc.books||[]).map(([b,n])=>`<option value="${esc(b)}">${esc(b)} (${n.toLocaleString()})</option>`).join('');
+  const colors={}; ALL.forEach(p=>{if(p.color_bucket)colors[p.color_bucket]=(colors[p.color_bucket]||0)+1;});
+  $('#color').innerHTML='<option value="">All colors</option>'+Object.entries(colors).sort((a,b)=>b[1]-a[1]).map(([c,n])=>`<option value="${esc(c)}">${esc(c)} (${n.toLocaleString()})</option>`).join('');
+  syncCount(); applyFilters();
+}
+load();
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..ced41c2
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,446 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Hollywood Wallcoverings · Internal Line · dw_unified</title>
+<style>
+:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+ --new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--ship:#60a5fa}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
+ border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
+.internal-tag{background:var(--arch);color:#2a0808;font-weight:700;border-radius:5px;padding:2px 8px;font-size:10px;letter-spacing:.5px}
+.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
+.muted{color:var(--mut)}
+select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
+.ctl{display:flex;align-items:center;gap:7px}
+input[type=range]{accent-color:var(--acc)}
+.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
+.btn:hover{border-color:var(--acc)}
+/* ── left panel: filters + field tables, ALL collapsed on load ── */
+#layout{display:flex;align-items:flex-start}
+aside{position:sticky;width:230px;min-width:230px;max-height:calc(100vh - 52px);overflow:auto;
+ padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
+aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
+aside details{border-bottom:1px solid var(--line)}
+aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
+ font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
+aside summary::-webkit-details-marker{display:none}
+aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
+aside details[open] summary::before{transform:rotate(90deg)}
+aside summary:hover{color:var(--ink)}
+aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
+ max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.fbody{padding:2px 0 8px}
+.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
+ cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
+.frow:hover{background:var(--panel);color:var(--ink)}
+.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
+.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
+.frow .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0008;display:inline-block;flex:none}
+.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
+.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
+#clearf{display:none;margin-top:10px;width:100%}
+main{flex:1;min-width:0}
+/* mobile: hamburger + full-screen filter drawer */
+#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
+#fclose{display:none}
+@media(max-width:880px){
+ #layout{display:block}
+ header{padding:8px 10px;gap:8px}
+ #burger{display:inline-flex;align-items:center;gap:6px}
+ .ctl:has(#density){display:none}
+ aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
+  overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
+ aside.open-mobile{display:block}
+ #fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
+ .grid:not(.list){grid-template-columns:repeat(2,1fr)}
+}
+/* ── grid — tight, 1pt stroke edges; text scales with density via --fs ── */
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
+.card:hover{border-color:#414a5e}
+.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
+.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center;display:block}
+.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
+.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
+.statusbadge{position:absolute;top:6px;left:6px;z-index:2;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px}
+.statusbadge.new{background:var(--new);color:#1a1205}
+.statusbadge.closeout{background:var(--arch);color:#2a0808}
+.catbadge{position:absolute;top:6px;right:6px;z-index:2;background:#000a;color:#cbd5e1;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px}
+.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
+.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px}
+.dwsku{display:inline-block;font-family:ui-monospace,Menlo,monospace;font-size:calc(9.5px*var(--fs));color:var(--acc);
+ background:#0a1f1b;border:1px solid #123c34;border-radius:4px;padding:1px 6px;letter-spacing:.3px;align-self:flex-start}
+.when{font-size:calc(9.5px*var(--fs));color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+/* field visibility toggles (grid view only) */
+body.f-off-vend .grid:not(.list) .vend{display:none}
+body.f-off-ttl .grid:not(.list) .ttl{display:none}
+body.f-off-dwsku .grid:not(.list) .dwsku{display:none}
+body.f-off-when .grid:not(.list) .when{display:none}
+body.f-off-chips .grid:not(.list) .chips{display:none}
+body.imgonly .grid:not(.list) .card .b{display:none}
+/* ── chips (minimal) ── */
+.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
+.chip{font-size:calc(10px*var(--fs));font-weight:600;border-radius:999px;padding:1px calc(8px*var(--fs));letter-spacing:.2px;white-space:nowrap;border:1px solid transparent}
+.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
+.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
+.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
+.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
+/* ── collapsed detail drawer ── */
+.info{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
+.card.info-open .info{display:flex}
+.kv{display:grid;grid-template-columns:88px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
+.kv .k{color:var(--mut)}
+.kv .v{text-align:left;word-break:break-word;font-weight:500}
+.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
+.lnk:hover{background:var(--panel)}
+.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
+.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
+.act:hover{border-color:var(--acc)}
+#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
+#empty{padding:60px;text-align:center;color:var(--mut)}
+/* ── List view ── */
+:root{--listcols:42px 108px minmax(130px,1.4fr) minmax(90px,1fr) 96px minmax(105px,1fr) 96px 70px 84px minmax(120px,1fr)}
+.grid.list{grid-template-columns:1fr;gap:4px;padding:4px 12px 60px}
+.grid.list .card{display:grid;align-items:center;border-radius:2px;grid-template-columns:var(--listcols);column-gap:12px;min-height:52px;padding:0 12px 0 8px}
+.grid.list .card>.b{display:contents}
+.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
+.grid.list .imgwrap.noimg::after{font-size:8px}
+.grid.list .statusbadge,.grid.list .catbadge{display:none}
+.grid.list .vend{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
+.grid.list .dwsku{font-size:10px;padding:1px 5px}
+.grid.list .when{font-size:10.5px}
+.grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
+.grid.list .chip{font-size:10px;padding:1px 8px}
+.grid.list .chips .chip.toggle{display:none}
+.grid.list .lc{display:block;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .info{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
+.lc{display:none}
+/* sortable column header */
+#listhead{display:none;position:sticky;z-index:8;background:#0e0f15;border-bottom:1px solid var(--line);
+ grid-template-columns:var(--listcols);column-gap:12px;padding:6px 24px 6px 20px;align-items:center}
+body.listmode #listhead{display:grid}
+#listhead .lh{font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;
+ cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none}
+#listhead .lh:hover{color:var(--ink)}
+#listhead .lh.on{color:var(--acc)}
+/* ── corner refresh countdown pill ── */
+.hw-refresh-pill{position:fixed;right:14px;bottom:14px;z-index:25;display:flex;align-items:center;gap:7px;
+ background:#11131bF2;border:1px solid var(--line);border-radius:999px;padding:6px 13px;font-size:11.5px;
+ color:var(--mut);backdrop-filter:blur(8px);cursor:default;user-select:none}
+.hw-refresh-pill .rp-dot{width:8px;height:8px;border-radius:50%;background:var(--pub)}
+.hw-refresh-pill.rp-live .rp-dot{background:var(--new);animation:rppulse 1s ease-in-out infinite}
+@keyframes rppulse{0%,100%{opacity:1}50%{opacity:.25}}
+</style></head>
+<body>
+<header>
+  <h1>Hollywood Wallcoverings</h1>
+  <span class="internal-tag" title="Login-gated internal tool — not customer-facing">INTERNAL</span>
+  <button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
+  <span class="pill" id="total">…</span>
+  <span class="muted" id="meta"></span>
+  <div class="ctl"><label class="muted">Sort</label>
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="color">Color</option>
+      <option value="pattern">Pattern A→Z</option>
+      <option value="collection">Collection</option>
+      <option value="sku">DW SKU A→Z</option>
+      <option value="title">Title A→Z</option>
+      <option value="price_asc">Price ↑</option>
+      <option value="price_desc">Price ↓</option>
+    </select></div>
+  <div class="ctl"><label class="muted">Density</label>
+    <input type="range" id="density" min="3" max="20" step="1"></div>
+  <button class="btn" id="view">▤ List view</button>
+  <a class="btn" href="/curate" title="bulk-select candidates for a private-label push">✓ Select</a>
+</header>
+<div id="layout">
+<aside aria-label="filters">
+  <button id="fclose" type="button">✕ Done — show products</button>
+  <input type="text" id="q" placeholder="Search pattern / dw sku / color…">
+  <input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the line">
+  <datalist id="patternlist"></datalist>
+  <details id="fieldsbox"><summary>Card fields</summary>
+    <div class="fbody">
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Category · pattern</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Title / color</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="dwsku" checked>DW SKU chip</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="when" checked>Created date · time</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
+    </div>
+  </details>
+  <div id="facetbox"><!-- one collapsed <details> table per data field --></div>
+  <button class="btn" id="clearf">✕ Clear filters</button>
+</aside>
+<main>
+  <div id="listhead" aria-label="sortable columns"></div>
+  <div class="grid" id="grid"></div>
+  <button id="more" style="display:none">Load more</button>
+  <div id="empty" style="display:none">No products match.</div>
+</main>
+</div>
+<script>
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], FACETS=null, VIEW=[], shown=0;
+const PAGE=120;
+const NS='hw';   // localStorage namespace (Hollywood)
+const BUCKET_HEX={white:'#f4f4f2',grey:'#9aa0aa',black:'#1c1e24',pink:'#f3a8c0',red:'#d0453e',orange:'#e08a3c',
+ brown:'#8a6242',gold:'#c9a75a',green:'#5f8f5c',teal:'#4bb3a6',blue:'#4f7fb5',purple:'#8a6fb5'};
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+// created date + time chip (admin standing rule) — local tz, full ISO in title=
+function fmtWhen(iso){
+  if(!iso)return '';
+  try{
+    const d=new Date(iso);
+    if(isNaN(d))return '';
+    return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+  }catch(e){return '';}
+}
+// ── every data field is a left-panel table: collapsed on load, click value = filter ──
+const FIELDS=[
+ ['category','Category'],['collection','Collection'],['series','Pattern'],['color','Color'],
+ ['product_line','Product Line'],['width','Width'],['status','Status'],['price_group','Price'],
+ ['color_bucket','Color family'],
+];
+const FIL={};            // field -> active value ('' = off)
+FIELDS.forEach(([k])=>FIL[k]='');
+let FCOUNTS={};          // field -> [[value,count]…] computed once from ALL
+const FCAP=40;           // rows shown per table (top-by-count)
+const PRICE_GROUP_ORDER=['Under $25','$25 - $50','$50 - $75','$75 - $100','$100 - $150','$150 - $250','$250+'];
+const BUCKET_ORDER=['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
+// persisted prefs (sort + density persist in localStorage)
+$('#sort').value=localStorage.getItem(NS+'.sort')||'newest';
+// ── density also drives type scale (formula matches astek reference exactly) ──
+function setDensity(v){
+  v=Number(v);
+  const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
+  document.documentElement.style.setProperty('--cols',v);
+  document.documentElement.style.setProperty('--fs',fs.toFixed(3));
+}
+const dens=localStorage.getItem(NS+'.density')||'6';
+$('#density').value=dens; setDensity(dens);
+$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem(NS+'.density',$('#density').value);};
+// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
+const asideEl=document.querySelector('aside');
+$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
+$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
+window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
+  if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
+});
+// ── card-field visibility toggles (image-only when all off) ──
+const FV_KEYS=['vend','ttl','dwsku','when','chips'];
+const FVIS=Object.assign({vend:true,ttl:true,dwsku:true,when:true,chips:true},JSON.parse(localStorage.getItem(NS+'.fieldvis')||'{}'));
+function applyFieldVis(){
+  for(const k of FV_KEYS)document.body.classList.toggle('f-off-'+k,!FVIS[k]);
+  document.body.classList.toggle('imgonly',FV_KEYS.every(k=>!FVIS[k]));
+  document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
+  localStorage.setItem(NS+'.fieldvis',JSON.stringify(FVIS));
+}
+document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
+  cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
+});
+applyFieldVis();
+// NOTE: no <a> wrapper around the card — navigation is a delegated click.
+function card(p){
+  const title=esc(p.display_name||p.series||p.dw_sku||'(untitled)');
+  const badge = p.is_closeout?`<span class="statusbadge closeout">CLOSEOUT</span>`
+    : (p.is_new?`<span class="statusbadge new">NEW</span>`:'');
+  const catbadge = p.category?`<span class="catbadge">${esc(p.category)}</span>`:'';
+  const img=p.swatch
+    ?`<div class="imgwrap">${badge}${catbadge}<img loading="lazy" src="${esc(p.swatch)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
+    :`<div class="imgwrap noimg">${badge}${catbadge}</div>`;
+  const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+  const dot=BUCKET_HEX[p.color_bucket]||'#666';
+  const whenTxt=fmtWhen(p.created_at);
+  return `<div class="card" data-href="/product/${encodeURIComponent(p.handle)}" data-handle="${esc(p.handle)}">
+    ${img}<div class="b">
+    <span class="vend">${esc(p.category||'Wallcovering')}${p.series?(' · '+esc(p.series)):''}</span>
+    <div class="ttl">${title}</div>
+    <span class="dwsku" title="Designer Wallcoverings SKU">${esc((p.dw_sku||'').toUpperCase())}</span>
+    ${whenTxt?`<span class="when" title="${esc(p.created_at||'')}">🕓 ${esc(whenTxt)}</span>`:''}
+    <div class="chips">
+      ${p.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_bucket)}</span>`:''}
+      ${p.price!=null?`<span class="chip cl">$${Number(p.price).toFixed(2)}</span>`:''}
+      <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
+    </div>
+    <span class="lc" title="Color">${esc(p.color||'')}</span>
+    <span class="lc" title="Family">${esc(p.color_bucket||'')}</span>
+    <span class="lc" title="Collection">${esc(p.collection||'')}</span>
+    <span class="lc" title="Category">${esc(p.category||'')}</span>
+    <span class="lc" title="Width">${esc(p.width||'')}</span>
+    <span class="lc" title="Price">${p.price!=null?('$'+Number(p.price).toFixed(2)):''}</span>
+    <span class="lc" title="Created">${esc(whenTxt)}</span>
+    </div>
+    <div class="info" onclick="event.stopPropagation()">
+      ${kv('DW SKU',p.dw_sku)}${kv('Mfr SKU',p.momentum_sku)}${kv('Pattern',p.series)}${kv('Color',p.color)}
+      ${kv('Category',p.category)}${kv('Collection',p.collection)}${kv('Product Line',p.product_line)}
+      ${kv('Width',p.width)}${kv('Price',p.price!=null?('$'+Number(p.price).toFixed(2)):null)}
+      ${kv('Status',p.status)}${kv('Created',fmtWhen(p.created_at))}
+      <div class="acts">
+        ${p.dw_sku?`<button class="act" data-copy="${esc(p.dw_sku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
+        ${p.momentum_sku?`<button class="act" data-copy="${esc(p.momentum_sku)}" onclick="copyTxt(this)" title="copy mfr SKU">📋 Mfr</button>`:''}
+        <a class="lnk" href="/product/${encodeURIComponent(p.handle)}">open ↗</a>
+      </div>
+    </div>
+  </div>`;
+}
+// delegated card navigation — anything not interactive opens the PDP
+grid.addEventListener('click',e=>{
+  if(e.target.closest('.chip.toggle,.info,a,button'))return;
+  const c=e.target.closest('.card[data-href]');
+  if(c)location.href=c.dataset.href;
+});
+function copyTxt(btn){
+  const t=btn.dataset.copy||'';
+  navigator.clipboard.writeText(t).then(()=>{
+    const o=btn.textContent; btn.textContent='✓ copied';
+    setTimeout(()=>{btn.textContent=o;},1200);
+  });
+}
+window.copyTxt=copyTxt;
+function applyFilters(){
+  const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
+  VIEW=ALL.filter(p=>{
+    for(const [k] of FIELDS){ if(FIL[k]&&String(p[k]??'')!==FIL[k])return false; }
+    if(pat&&!String(p.series||p.display_name||'').toLowerCase().includes(pat))return false;
+    if(q&&![p.display_name,p.series,p.dw_sku,p.momentum_sku,p.color,p.collection,p.category].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
+    return true;
+  });
+  const s=$('#sort').value, cmp={
+    newest:(a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||''))||String(a.dw_sku||'').localeCompare(String(b.dw_sku||'')),
+    color:(a,b)=>(a.hue??999)-(b.hue??999)||(a.val??0)-(b.val??0),
+    pattern:(a,b)=>String(a.series||a.display_name||'').localeCompare(String(b.series||b.display_name||'')),
+    collection:(a,b)=>String(a.collection||'').localeCompare(String(b.collection||''))||String(a.series||'').localeCompare(String(b.series||'')),
+    sku:(a,b)=>String(a.dw_sku||a.handle).localeCompare(String(b.dw_sku||b.handle),undefined,{numeric:true}),
+    title:(a,b)=>String(a.display_name||'').localeCompare(String(b.display_name||'')),
+    price_asc:(a,b)=>(a.price==null?Infinity:a.price)-(b.price==null?Infinity:b.price),
+    price_desc:(a,b)=>(b.price==null?-Infinity:b.price)-(a.price==null?-Infinity:a.price),
+  }[s];
+  if(COLSORT.key){
+    const k=COLSORT.key, dir=COLSORT.dir;
+    VIEW=[...VIEW].sort((a,b)=>{
+      const av=a[k], bv=b[k];
+      if(av==null||av==='')return 1; if(bv==null||bv==='')return -1;
+      if(k==='price')return dir*((av)-(bv));
+      return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
+    });
+  }else if(cmp)VIEW=[...VIEW].sort(cmp);
+  shown=0; grid.innerHTML=''; renderMore();
+  $('#total').textContent=VIEW.length.toLocaleString()+' colorways';
+  $('#empty').style.display=VIEW.length?'none':'block';
+  renderSide();
+}
+function renderMore(){
+  const slice=VIEW.slice(shown,shown+PAGE);
+  grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
+  shown+=slice.length;
+  $('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
+  $('#more').style.display=shown<VIEW.length?'block':'none';
+}
+function buildFacetCache(){
+  FCOUNTS={};
+  for(const [k] of FIELDS){
+    const m={};
+    for(const p of ALL){const v=p[k];if(v==null||v==='')continue;m[v]=(m[v]||0)+1;}
+    let entries=Object.entries(m).sort((a,b)=>b[1]-a[1]);
+    if(k==='price_group')entries=PRICE_GROUP_ORDER.filter(g=>m[g]).map(g=>[g,m[g]]);
+    if(k==='color_bucket')entries=BUCKET_ORDER.filter(b=>m[b]).map(b=>[b,m[b]]);
+    FCOUNTS[k]=entries;
+  }
+  const series=(FCOUNTS.series||[]).map(([v])=>v).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
+  $('#patternlist').innerHTML=series.map(s=>`<option value="${esc(s)}">`).join('');
+}
+// left panel — one collapsed <details> table per field; open state survives re-render
+function renderSide(){
+  const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
+  $('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
+    const rows=(FCOUNTS[k]||[]).slice(0,FCAP).map(([v,n])=>{
+      const dot=k==='color_bucket'?`<span class="dot" style="background:${BUCKET_HEX[v]||'#666'}"></span>`:'';
+      return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
+        <span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`;
+    }).join('');
+    const more=(FCOUNTS[k]||[]).length>FCAP?`<div class="fmore">+ ${(FCOUNTS[k].length-FCAP).toLocaleString()} more — use search</div>`:'';
+    const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
+    return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
+  }).join('');
+  $('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value)?'block':'none';
+}
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
+window.pickF=pickF;
+$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
+$('#sort').onchange=()=>{localStorage.setItem(NS+'.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
+let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
+$('#more').onclick=renderMore;
+// ── sortable column header (list view) ──
+const LIST_COLS=[[null,''],['dw_sku','DW SKU'],['display_name','Pattern / Color'],['color','Color'],['color_bucket','Family'],
+ ['collection','Collection'],['category','Category'],['width','Width'],['price','Price'],['created_at','Created']];
+const COLSORT={key:null,dir:1};
+function renderListHead(){
+  $('#listhead').innerHTML=LIST_COLS.map(([k,lbl])=>k
+    ?`<span class="lh${COLSORT.key===k?' on':''}" onclick="colSort('${k}')">${lbl}${COLSORT.key===k?(COLSORT.dir>0?' ▲':' ▼'):''}</span>`
+    :'<span></span>').join('');
+  syncSticky();
+}
+function colSort(k){
+  if(COLSORT.key===k)COLSORT.dir=-COLSORT.dir; else{COLSORT.key=k;COLSORT.dir=1;}
+  renderListHead(); applyFilters();
+}
+window.colSort=colSort;
+function syncSticky(){
+  const h=document.querySelector('header').offsetHeight;
+  $('#listhead').style.top=h+'px';
+  document.querySelector('aside').style.top=h+'px';
+  document.querySelector('aside').style.maxHeight=`calc(100vh - ${h}px)`;
+}
+addEventListener('resize',syncSticky);
+function applyView(){const list=localStorage.getItem(NS+'.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listmode',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();}
+$('#view').onclick=()=>{localStorage.setItem(NS+'.view',localStorage.getItem(NS+'.view')==='list'?'grid':'list');applyView();};
+applyView();
+addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
+async function loadAll(){
+  const [pd,fc]=await Promise.all([
+    fetch(location.origin+'/api/products').then(r=>r.json()),
+    fetch(location.origin+'/api/facets').then(r=>r.json())]);
+  ALL=pd.products; FACETS=fc;
+  buildFacetCache(); applyFilters();
+}
+loadAll();
+
+/* ── Corner refresh countdown ── */
+(function(){
+  var meta={lastRefresh:null,intervalSec:900}, target=0, waiting=false;
+  var pill=document.createElement('div');
+  pill.className='hw-refresh-pill';
+  pill.title='Hollywood Wallcoverings data auto-refreshes from dw_unified';
+  pill.innerHTML='<span class="rp-dot"></span><span class="rp-txt">syncing…</span>';
+  document.body.appendChild(pill);
+  var txt=pill.querySelector('.rp-txt');
+  function fmt(s){s=Math.max(0,Math.round(s));var m=Math.floor(s/60);return m+':'+String(s%60).padStart(2,'0');}
+  async function syncMeta(){
+    try{
+      var m=await fetch(location.origin+'/api/meta').then(r=>r.json());
+      var advanced = meta.lastRefresh && m.lastRefresh!==meta.lastRefresh;
+      meta=m;
+      var t=new Date(m.lastRefresh).getTime()+m.intervalSec*1000;
+      target = (t<=Date.now()) ? Date.now()+m.intervalSec*1000 : t;
+      if(advanced) loadAll();
+      return advanced;
+    }catch(e){ return false; }
+  }
+  async function tick(){
+    var rem=(target-Date.now())/1000;
+    if(rem>0){ txt.textContent='Next refresh in '+fmt(rem); pill.classList.remove('rp-live'); }
+    else if(!waiting){ txt.textContent='Updating…'; pill.classList.add('rp-live'); waiting=true;
+      var adv=await syncMeta(); if(!adv) target=Date.now()+7000; waiting=false; }
+  }
+  syncMeta().then(function(){ tick(); setInterval(tick,1000); });
+})();
+</script>
+</body></html>
diff --git a/public/product.html b/public/product.html
new file mode 100644
index 0000000..d9ec64d
--- /dev/null
+++ b/public/product.html
@@ -0,0 +1,238 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Designer Wallcoverings</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+
+<a class="corner ul wordmark" href="https://www.designerwallcoverings.com">Designer Wallcoverings</a>
+<nav class="corner ur">
+  <a href="/">The Collection</a>
+  <a href="https://www.designerwallcoverings.com/collections/all" target="_blank" rel="noopener noreferrer">All Wallcoverings</a>
+  <span class="internal-pill" title="Login-gated internal page — not customer-facing">INTERNAL</span>
+</nav>
+
+<main class="pdp" id="pdp"><div class="crumbs">Loading…</div></main>
+
+<a class="shopbar" href="/">← Back to the Hollywood Wallcoverings collection at <b>Designer Wallcoverings</b></a>
+
+<!-- INTERNAL REQUEST MODAL — memo / stock / price -->
+<div class="modal-back" id="inqBack">
+  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="inqTitle">
+    <button class="mclose" id="inqClose" aria-label="Close">×</button>
+    <div class="eyebrow">Internal — Designer Wallcoverings</div>
+    <h3 id="inqTitle">Request a Memo Sample</h3>
+    <div class="msub" id="inqSub">Logged for purchasing follow-up.</div>
+    <input type="hidden" id="inqType" value="memo">
+    <div id="inqQtyWrap" hidden>
+      <label for="inqQty">Quantity (optional)</label>
+      <input id="inqQty" placeholder="e.g. 30 yards">
+    </div>
+    <label for="inqNote">Note (optional) — <b>the vendor sees this: never include a client name</b></label>
+    <textarea id="inqNote" placeholder="Sidemark by account #, timeline…"></textarea>
+    <div class="preview" id="inqPrev" hidden></div>
+    <div class="msg" id="inqMsg"></div>
+    <div class="mrow"><button class="cta" id="inqSend" style="margin-top:0">Request Memo</button></div>
+  </div>
+</div>
+
+<script>
+const handle=decodeURIComponent(location.pathname.split('/').pop());
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+function specRow(k,v){return v?`<div class="row"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';}
+
+let VM={}; // vendorMeta from /api/config — phone, account #, discount, pricing model
+const money=n=>n==null?null:'$'+Number(n).toFixed(2);
+
+// Internal pricing — retail / designer net / our cost, per the line's pricing model.
+// 'map' lines (Kravet family): retail = MAP = whls cost × 1.5; our cost = whls.
+// Everything else: retail = cost/0.65/0.85, net = cost/0.65, our cost = cost minus
+// the line's vendor_discount_pct when one is on file. Explicit list price wins for retail.
+function priceTiers(p){
+  const cost=p.cost!=null?Number(p.cost):null, list=p.price!=null?Number(p.price):null;
+  const disc=VM.discount_pct!=null?Number(VM.discount_pct):null;
+  let retail=null, net=null, ourCost=null;
+  if(VM.pricing_model==='map' && cost!=null){ retail=cost*1.5; net=cost*1.5; ourCost=cost; }
+  else{
+    ourCost = cost!=null ? (disc!=null? cost*(1-disc/100) : cost) : null;
+    net     = cost!=null ? cost/0.65 : (list!=null? list*0.85 : null);
+    retail  = list!=null ? list : (cost!=null? cost/0.65/0.85 : null);
+  }
+  return {retail, net, ourCost};
+}
+function priceBox(p){
+  const t=priceTiers(p);
+  const unit=VM.pricing_unit?('/'+String(VM.pricing_unit).replace(/^per\s*/i,'').replace('yard','yd').replace('roll','roll')):'';
+  const row=(k,v)=>`<div class="row"><span class="k">${k}</span><span class="v">${v!=null?esc(money(v)+unit):'—'}</span></div>`;
+  const empty=t.retail==null&&t.net==null&&t.ourCost==null;
+  return `<div class="pricebox">
+    ${row('Retail',t.retail)}
+    ${row('Designer Net',t.net)}
+    ${row('Our Cost'+(VM.discount_pct!=null?' (after '+VM.discount_pct+'% discount)':''),t.ourCost)}
+    ${p.price_code?`<div class="row"><span class="k">Vendor Price Code</span><span class="v">${esc(p.price_code)}</span></div>`:''}
+    ${empty?'<div class="pnote">No pricing on file for this line — use <b>Get Price</b> below.</div>':''}
+  </div>`;
+}
+
+(async()=>{
+  const [cfg,r]=await Promise.all([fetch(location.origin+'/api/config').then(x=>x.json()).catch(()=>({})), fetch(location.origin+'/api/product/'+encodeURIComponent(handle))]);
+  const LINE = cfg.line || cfg.vendor || 'Hollywood Wallcoverings';
+  VM = cfg.vendorMeta || {};
+  if(cfg.slug){ document.body.dataset.line = cfg.slug; }
+  document.querySelector('.corner.ul').setAttribute('href', cfg.houseUrl || 'https://www.designerwallcoverings.com');
+  if(!r.ok){document.getElementById('pdp').innerHTML='<div class="crumbs"><a href="/">← Back</a></div><h1 style="font-family:var(--serif)">Not found</h1>';return;}
+  const p=await r.json();
+  document.title=`${p.display_name||p.color||p.title} · ${LINE} | Designer Wallcoverings`;
+  const imgs=(p.images&&p.images.length)?p.images:[p.swatch].filter(Boolean);
+  const main=imgs[0]||'';
+  const label=`${p.display_eyebrow||''} ${p.display_name||p.color||''}`.trim();
+
+  document.getElementById('pdp').innerHTML=`
+    <div class="crumbs"><a href="https://www.designerwallcoverings.com">Designer Wallcoverings</a> &nbsp;/&nbsp; <a href="/">${esc(LINE)}</a> &nbsp;/&nbsp; <a href="/#catalog">${esc(p.display_eyebrow||p.series||'')}</a> &nbsp;/&nbsp; ${esc(p.display_name||p.color||p.title)}</div>
+    <div class="pdp-top">
+      <div class="gallery">
+        <div class="main" id="main" style="background-image:url('${main}')"></div>
+        ${imgs.length>1?`<div class="thumbs">${imgs.map((s,i)=>`<div class="t ${i===0?'on':''}" data-s="${s}" style="background-image:url('${s}')"></div>`).join('')}</div>`:''}
+      </div>
+      <div class="rail">
+        <div class="ser">${esc(p.display_eyebrow||p.book||LINE)}</div>
+        <h1>${esc(p.display_name||p.color||p.title)}</h1>
+        <div class="colr">${esc(VM.name||LINE)} · ${esc(p.sku||'')}${p.material?' · '+esc(p.material):''}</div>
+        ${priceBox(p)}
+        <div class="act3">
+          <button class="cta" data-act="memo">Request Memo</button>
+          <button class="cta" data-act="stock">Check Stock</button>
+          <button class="cta" data-act="price">Get Price</button>
+        </div>
+        ${!VM.email?`<div class="noemail">✉️ No vendor email on file — stock &amp; price checks are logged internally; confirm by phone${VM.phone?': '+esc(VM.phone):''}.</div>`:''}
+        ${VM.phone?`<a class="cta ghost call" href="tel:+1${(VM.phone||'').replace(/[^0-9]/g,'')}">📞 Call ${esc(VM.buy_from||VM.name||LINE)} — ${esc(VM.phone)}${VM.account_number?' · Acct # '+esc(VM.account_number):''}</a>`:''}
+        <div class="spec">
+          ${specRow('Vendor',VM.name||LINE)}
+          ${specRow('Buy From / Distributor',VM.buy_from)}
+          ${specRow('Vendor SKU / Model #',p.sku)}
+          ${specRow('DW SKU',p.dw_sku)}
+          ${specRow('Our Account #',VM.account_number)}
+          ${specRow('Vendor Phone',VM.phone)}
+          ${specRow('Pattern',p.series)}
+          ${specRow('Colorway',p.color)}
+          ${specRow('Width',p.width)}
+          ${specRow('Length',p.length)}
+          ${specRow('Repeat',p.repeat)}
+          ${specRow('Material',p.material)}
+          ${specRow('Match',p.match)}
+          ${specRow('Format',p.book)}
+        </div>
+      </div>
+    </div>
+    <div class="story">
+      <div><h3>The story</h3><div>${p.body_html?('<p>'+esc(p.body_html.slice(0,700))+(p.body_html.length>700?'…':'')+'</p>'):('<p>'+esc(p.title)+' from the '+esc(LINE)+' collection.</p>')}</div></div>
+      <div><h3>Specifications</h3><p>${esc([p.width,p.repeat&&('repeat '+p.repeat),p.material].filter(Boolean).join(' · ')||'Digitally printed, made to order.')}</p></div>
+      <div><h3>Ordering (internal)</h3><p>Order direct from ${esc(VM.name||LINE)}${VM.phone?' at '+esc(VM.phone):''}${VM.account_number?', our account # '+esc(VM.account_number):''}. Use the buttons above to log a memo, stock, or price request for purchasing.</p></div>
+    </div>
+    <section class="pairs" id="pairs" hidden>
+      <h3>Pairs well with</h3>
+      <div class="pair-grid" id="pairGrid"></div>
+    </section>`;
+
+  // 3 internal actions → shared modal (internal; no checkout, no external store)
+  ITEM={dw_sku:p.dw_sku, mfr_sku:p.sku, title:(label||p.title||'').trim()};
+  document.querySelectorAll('.act3 .cta').forEach(b=>{
+    b.onclick=()=>openInquiry(b.dataset.act);
+  });
+
+  // JSON-LD Product schema (no price — trade line; canonical → this page)
+  const ld={ "@context":"https://schema.org","@type":"Product",name:p.title,sku:p.sku,
+    brand:{"@type":"Brand",name:LINE},category:"Wallcoverings",image:imgs,
+    description:(p.body_html||'').replace(/<[^>]+>/g,'').slice(0,300) };
+  const s=document.createElement('script');s.type='application/ld+json';s.textContent=JSON.stringify(ld);document.head.appendChild(s);
+  // canonical → this internal page (Hollywood Wallcoverings is not on Shopify, so no external canonical)
+  const c=document.createElement('link');c.rel='canonical';c.href=location.origin+location.pathname;document.head.appendChild(c);
+
+  // pairs well with
+  try{
+    const pr=await (await fetch(location.origin+'/api/pairs/'+encodeURIComponent(handle))).json();
+    if(pr.pairs&&pr.pairs.length){
+      document.getElementById('pairGrid').innerHTML=pr.pairs.map(q=>`
+        <a class="pcard" href="/product/${encodeURIComponent(q.handle)}">
+          <img loading="lazy" decoding="async" alt="${esc(q.title)}" src="${q.swatch||q.room||''}">
+          <div class="pc-b"><span class="pc-s">${esc(q.series||'')}</span><span class="pc-n">${esc(q.color||q.title)}</span></div>
+        </a>`).join('');
+      document.getElementById('pairs').hidden=false;
+    }
+  }catch(e){}
+
+  document.querySelectorAll('.thumbs .t').forEach(t=>t.onclick=()=>{
+    document.getElementById('main').style.backgroundImage=`url('${t.dataset.s}')`;
+    document.querySelectorAll('.thumbs .t').forEach(x=>x.classList.remove('on'));t.classList.add('on');
+  });
+})();
+
+// ---- internal request modal (memo / stock / price) ----
+// stock/price = ACTIVE requests: previews the exact vendor email, then sends via
+// purchasing (george) and returns an internal REQ number. memo = internal log.
+let ITEM={};
+const ACT={
+  memo:{title:'Request a Memo Sample',btn:'Log Memo Request',sub:s=>`Log a memo request for ${s} — purchasing orders the sample.`},
+  stock:{title:'Check Stock',btn:'Send Stock Check',sub:s=>`Emails the vendor a stock check for ${s} (our account # only — no client info). You get a REQ number.`},
+  price:{title:'Get Price',btn:'Send Price Request',sub:s=>`Emails the vendor for our current cost on ${s} (our account # only — no client info). You get a REQ number.`},
+};
+const $=id=>document.getElementById(id);
+function reqPayload(){
+  const type=$('inqType').value;
+  return {type, dw_sku:ITEM.dw_sku, mfr_sku:ITEM.mfr_sku, title:ITEM.title,
+          qty:$('inqQty').value.trim()||null, note:$('inqNote').value.trim()||null};
+}
+let prevTimer=null;
+async function refreshPreview(){
+  const type=$('inqType').value;
+  if(type==='memo'){$('inqPrev').hidden=true;return;}
+  try{
+    const r=await fetch(location.origin+'/api/request/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(reqPayload())});
+    const j=await r.json();
+    if(!j.ok)return;
+    $('inqPrev').hidden=false;
+    $('inqPrev').innerHTML = j.no_email
+      ? `<div class="pv-warn">✉️ No vendor email on file — this logs an internal REQ; confirm by phone${j.phone?': '+esc(j.phone):''}.</div>`
+      : `<div class="pv-h">Email that will be sent</div>
+         <div class="pv-meta"><b>To:</b> ${esc(j.to)} &nbsp; <b>From:</b> ${esc(j.from)}</div>
+         <div class="pv-meta"><b>Subject:</b> ${esc(j.subject)}</div>
+         <div class="pv-body">${j.body}</div>`;
+  }catch(e){}
+}
+function openInquiry(type){
+  const a=ACT[type]||ACT.memo;
+  $('inqType').value=ACT[type]?type:'memo';
+  $('inqTitle').textContent=a.title;
+  $('inqSend').textContent=a.btn;
+  $('inqSub').textContent=a.sub(ITEM.title||ITEM.mfr_sku||'this item');
+  $('inqQtyWrap').hidden = type==='memo';
+  $('inqQty').value=''; $('inqNote').value='';
+  $('inqPrev').hidden=true;
+  const m=$('inqMsg'); m.textContent=''; m.className='msg';
+  $('inqBack').classList.add('on');
+  refreshPreview();
+}
+['inqQty','inqNote'].forEach(id=>$(id).addEventListener('input',()=>{clearTimeout(prevTimer);prevTimer=setTimeout(refreshPreview,500);}));
+$('inqClose').onclick=()=>$('inqBack').classList.remove('on');
+$('inqBack').addEventListener('click',e=>{if(e.target.id==='inqBack')$('inqBack').classList.remove('on');});
+$('inqSend').onclick=async()=>{
+  const m=$('inqMsg');
+  $('inqSend').disabled=true;
+  try{
+    const r=await fetch(location.origin+'/api/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(reqPayload())});
+    const j=await r.json();
+    if(j.ok){m.textContent=j.message;m.className='msg ok';setTimeout(()=>$('inqBack').classList.remove('on'),3200);}
+    else{m.textContent=j.error||'Something went wrong.';m.className='msg err';}
+  }catch(e){m.textContent='Network error — request not sent.';m.className='msg err';}
+  $('inqSend').disabled=false;
+};
+
+</script>
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..76f90df
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,286 @@
+/* Designer Wallcoverings vendor-landing — editorial luxury house chrome over a featured line.
+   Warm neutrals, Lora serif display (DW house font), clean specs. */
+:root{
+  --bg:#f6f2ec;            /* warm cream */
+  --paper:#fffdf9;
+  --ink:#211d18;          /* near-black warm */
+  --ink-soft:#6b6258;
+  --line:#e3dccf;
+  --taupe:#b7a890;
+  --accent:#8c7a5f;       /* champagne-taupe */
+  --gold:#a98c54;
+  --cols:4;
+  --maxw:1320px;
+  --serif:"Lora",Georgia,serif;
+  --sans:"Inter","Helvetica Neue",Arial,sans-serif;
+}
+*{box-sizing:border-box}
+html{scroll-behavior:smooth}
+body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);
+  -webkit-font-smoothing:antialiased;font-size:15px;line-height:1.55}
+a{color:inherit;text-decoration:none}
+img{display:block;max-width:100%}
+
+/* ---------- top corners (standing rules: logo UL, nav UR) ---------- */
+.corner{position:fixed;top:0;z-index:40;padding:22px 26px;display:flex;align-items:center;gap:18px}
+.corner.ul{left:0}
+.corner.ur{right:0;gap:22px}
+.wordmark{font-family:var(--serif);font-weight:600;letter-spacing:.2em;font-size:16px;
+  text-transform:uppercase;color:var(--ink);white-space:nowrap}
+.on-hero .wordmark{text-shadow:0 1px 18px rgba(0,0,0,.55),0 0 2px rgba(0,0,0,.4)}
+.on-hero.ur a{text-shadow:0 1px 14px rgba(0,0,0,.5)}
+.wordmark .dot{color:var(--gold)}
+.corner.ur a{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-soft);
+  transition:color .2s}
+.corner.ur a:hover{color:var(--ink)}
+.on-hero .wordmark,.on-hero.ur a{color:#fff}
+.on-hero.ur a{color:rgba(255,255,255,.85)}
+
+/* ---------- hero ---------- */
+.hero{position:relative;height:100vh;min-height:620px;overflow:hidden;background:#1a1612}
+.hero .slide{position:absolute;inset:0;background-size:cover;background-position:center;
+  opacity:0;transition:opacity 1.4s ease;transform:scale(1.04)}
+.hero .slide.on{opacity:1}
+/* layered scrim — top band (wordmark+nav), center vignette (headline), bottom (CTA);
+   tuned so a WHITE wordmark clears APCA Lc>=60 on light Italian room shots */
+.hero::after{content:"";position:absolute;inset:0;
+  background:
+    radial-gradient(ellipse 72% 52% at 50% 56%, rgba(18,14,10,.52) 0%, rgba(18,14,10,0) 72%),
+    linear-gradient(180deg, rgba(18,14,10,.64) 0%, rgba(18,14,10,.30) 30%, rgba(18,14,10,.26) 58%, rgba(18,14,10,.68) 100%)}
+.hero-inner{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;
+  align-items:center;justify-content:center;text-align:center;color:#fff;padding:0 24px}
+.hero-eyebrow{font-size:11px;letter-spacing:.4em;text-transform:uppercase;opacity:.92;margin-bottom:20px;
+  padding-bottom:14px;border-bottom:1px solid rgba(255,255,255,.4)}
+.hero-kicker{font-size:10.5px;letter-spacing:.4em;text-transform:uppercase;opacity:.8;margin-top:18px}
+.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(52px,9vw,118px);line-height:.96;
+  margin:0;letter-spacing:.02em;text-shadow:0 2px 30px rgba(0,0,0,.45)}
+.hero .sub{font-family:var(--serif);font-style:italic;font-size:clamp(18px,2.4vw,27px);
+  margin-top:14px;opacity:.94;text-shadow:0 1px 16px rgba(0,0,0,.5)}
+.enter{position:absolute;bottom:42px;left:50%;transform:translateX(-50%);z-index:6;
+  border:1px solid rgba(255,255,255,.55);color:#fff;border-radius:40px;
+  padding:13px 34px;font-size:11px;letter-spacing:.26em;text-transform:uppercase;
+  background:rgba(20,16,12,.18);backdrop-filter:blur(3px);transition:.25s}
+.enter:hover{background:#fff;color:var(--ink);border-color:#fff}
+
+/* ---------- collections strip ---------- */
+.books{max-width:var(--maxw);margin:0 auto;padding:64px 26px 8px;text-align:center}
+.books .eyebrow{font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-soft)}
+.books h2{font-family:var(--serif);font-weight:500;font-size:38px;margin:8px 0 26px}
+.chips{display:flex;flex-wrap:wrap;gap:10px;justify-content:center}
+.chip{border:1px solid var(--line);background:var(--paper);border-radius:30px;
+  padding:9px 18px;font-size:12px;letter-spacing:.04em;cursor:pointer;transition:.2s;color:var(--ink-soft)}
+.chip:hover{border-color:var(--taupe);color:var(--ink)}
+.chip.active{background:var(--ink);color:#fff;border-color:var(--ink)}
+.chip .n{opacity:.55;margin-left:6px;font-variant-numeric:tabular-nums}
+
+/* ---------- controls (sort + density — standing rule) ---------- */
+.controls{position:sticky;top:0;z-index:45;background:rgba(246,242,236,.94);
+  backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
+.controls-in{max-width:var(--maxw);margin:0 auto;padding:14px 26px;display:flex;
+  align-items:center;gap:20px;flex-wrap:wrap}
+.bar-wm{display:none;font-family:var(--serif);font-weight:600;letter-spacing:.28em;
+  text-transform:uppercase;font-size:16px;color:var(--ink)}
+.bar-wm .dot{color:var(--gold)}
+body.scrolled .bar-wm{display:block}
+body.scrolled .corner{opacity:0;pointer-events:none}
+.corner{transition:opacity .3s}
+.count{font-size:12px;letter-spacing:.04em;color:var(--ink-soft);margin-right:auto}
+.count b{color:var(--ink);font-weight:600}
+.ctl{display:flex;align-items:center;gap:9px}
+.ctl label{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft)}
+.ctl select{font-family:var(--sans);font-size:13px;border:1px solid var(--line);background:var(--paper);
+  color:var(--ink);border-radius:8px;padding:7px 30px 7px 11px;cursor:pointer;
+  appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238c7a5f'/%3E%3C/svg%3E");
+  background-repeat:no-repeat;background-position:right 11px center}
+.ctl input[type=range]{width:120px;accent-color:var(--accent)}
+
+/* ---------- color filter bar ---------- */
+.colorbar{max-width:var(--maxw);margin:0 auto;padding:16px 26px 0;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
+.cdot{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:var(--paper);
+  border-radius:30px;padding:6px 13px 6px 8px;font-size:11px;letter-spacing:.04em;text-transform:capitalize;
+  color:var(--ink-soft);cursor:pointer;transition:.18s}
+.cdot span{width:14px;height:14px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}
+.cdot.all{padding:6px 14px}
+.cdot:hover{border-color:var(--taupe);color:var(--ink)}
+.cdot.active{background:var(--ink);color:#fff;border-color:var(--ink)}
+
+/* ---------- grid ---------- */
+.grid-wrap{max-width:var(--maxw);margin:0 auto;padding:26px 26px 80px}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:22px}
+.card{background:var(--paper);border:1px solid var(--line);border-radius:4px;overflow:hidden;
+  cursor:pointer;transition:transform .25s ease,box-shadow .25s ease;display:flex;flex-direction:column}
+.card:hover{transform:translateY(-3px);box-shadow:0 16px 40px -22px rgba(33,29,24,.45)}
+.card .ph{aspect-ratio:1/1;width:100%;object-fit:cover;background:var(--paper);display:block;transition:transform .6s cubic-bezier(.2,.7,.2,1)}
+.card:hover .ph{transform:scale(1.055)}
+.card .body{padding:13px 14px 15px}
+.card .ser{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold)}
+.card .nm{font-family:var(--serif);font-size:20px;line-height:1.1;margin:3px 0 2px}
+.card .meta{display:flex;justify-content:space-between;align-items:baseline;margin-top:7px}
+.card .col{font-size:12px;color:var(--ink-soft);display:inline-flex;align-items:center;gap:6px}
+.card .col .dot{width:9px;height:9px;border-radius:50%;border:1px solid rgba(0,0,0,.12);flex:0 0 auto}
+.card .pr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--gold);transition:.2s}
+.card:hover .pr{color:var(--accent)}
+
+/* ---------- footer ---------- */
+footer{border-top:1px solid var(--line);background:var(--paper)}
+.foot-in{max-width:var(--maxw);margin:0 auto;padding:46px 26px;display:flex;
+  justify-content:space-between;gap:24px;flex-wrap:wrap;font-size:12px;color:var(--ink-soft)}
+.foot-in .wm{font-family:var(--serif);letter-spacing:.3em;text-transform:uppercase;color:var(--ink);font-size:16px}
+.gate{font-size:11px;color:var(--ink-soft);max-width:420px;line-height:1.5}
+
+/* ---------- PDP (Editorial Magazine / Figma V1) ---------- */
+.pdp{max-width:1280px;margin:0 auto;padding:104px 26px 90px}
+.crumbs{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:22px}
+.crumbs a:hover{color:var(--ink)}
+.pdp-top{display:grid;grid-template-columns:1.55fr 1fr;gap:48px;align-items:start}
+.gallery .main{aspect-ratio:4/3;background:#efe9df center/cover;border:1px solid var(--line);border-radius:4px}
+.thumbs{display:flex;gap:10px;margin-top:12px}
+.thumbs .t{width:76px;height:76px;background:#efe9df center/cover;border:1px solid var(--line);
+  border-radius:3px;cursor:pointer;opacity:.7;transition:.2s}
+.thumbs .t.on,.thumbs .t:hover{opacity:1;border-color:var(--taupe)}
+.rail .ser{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--gold)}
+.rail h1{font-family:var(--serif);font-weight:500;font-size:46px;line-height:1.02;margin:8px 0 4px}
+.rail .colr{font-size:14px;color:var(--ink-soft);margin-bottom:22px}
+.price{font-family:var(--serif);font-size:34px}
+.price small{font-family:var(--sans);font-size:12px;letter-spacing:.06em;color:var(--ink-soft)}
+.cta{display:block;width:100%;text-align:center;margin-top:20px;padding:15px;border-radius:40px;
+  background:var(--ink);color:#fff;font-size:11px;letter-spacing:.22em;text-transform:uppercase;
+  cursor:pointer;border:0;transition:.2s}
+.cta:hover{background:var(--accent)}
+.cta.ghost{background:transparent;color:var(--ink);border:1px solid var(--line);margin-top:10px}
+.cta.ghost:hover{border-color:var(--ink)}
+/* social share — shares the real DW product page */
+.share{display:flex;align-items:center;gap:10px;margin-top:22px;flex-wrap:wrap}
+.share-lbl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-soft);margin-right:2px}
+.sbtn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;
+  border:1px solid var(--line);background:var(--paper);color:var(--ink-soft);cursor:pointer;transition:.2s;padding:0}
+.sbtn:hover{color:#fff;transform:translateY(-2px)}
+.sbtn.pin:hover{background:#e60023;border-color:#e60023}
+.sbtn.fb:hover{background:#1877f2;border-color:#1877f2}
+.sbtn.x:hover{background:#000;border-color:#000}
+.sbtn.mail:hover{background:var(--accent);border-color:var(--accent)}
+.sbtn.copy:hover{background:var(--ink);border-color:var(--ink)}
+.sbtn.copy.done{background:#3f8a5d;border-color:#3f8a5d;color:#fff}
+
+.spec{margin-top:30px;border-top:1px solid var(--line)}
+.spec .row{display:flex;justify-content:space-between;padding:11px 0;border-bottom:1px solid var(--line);font-size:13px}
+.spec .row .k{color:var(--ink-soft);letter-spacing:.04em}
+.spec .row .v{color:var(--ink);text-align:right}
+.story{display:grid;grid-template-columns:repeat(3,1fr);gap:36px;margin-top:64px;
+  padding-top:40px;border-top:1px solid var(--line)}
+.story h3{font-family:var(--serif);font-weight:500;font-size:22px;margin:0 0 12px}
+.story p{font-size:13.5px;color:var(--ink-soft);margin:0}
+.pairs{margin-top:60px;padding-top:40px;border-top:1px solid var(--line)}
+.pairs h3{font-family:var(--serif);font-weight:500;font-size:24px;margin:0 0 22px}
+.pair-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:18px}
+.pcard{display:block}
+.pcard img{aspect-ratio:1/1;width:100%;object-fit:cover;border:1px solid var(--line);border-radius:3px;transition:.25s}
+.pcard:hover img{transform:translateY(-3px);box-shadow:0 14px 32px -20px rgba(33,29,24,.5)}
+.pc-b{padding:9px 2px}
+.pc-s{display:block;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold)}
+.pc-n{display:block;font-family:var(--serif);font-size:16px;line-height:1.1;margin-top:2px}
+@media(max-width:1080px){.pair-grid{grid-template-columns:repeat(3,1fr)}}
+@media(max-width:560px){.pair-grid{grid-template-columns:repeat(2,1fr)}}
+.shopbar{position:fixed;bottom:0;left:0;right:0;z-index:50;background:#211d18;color:#f6f2ec;
+  font-size:12px;letter-spacing:.06em;text-align:center;padding:11px 14px;display:block;
+  text-decoration:none;transition:background .2s}
+.shopbar:hover{background:#2f2920}
+.shopbar b{color:#caa45f;font-weight:600}
+
+@media(max-width:1080px){:root{--cols:3}.pdp-top{grid-template-columns:1fr;gap:30px}.story{grid-template-columns:1fr;gap:26px}}
+@media(max-width:720px){:root{--cols:2}.hero h1{font-size:64px}.grid{gap:14px}}
+
+/* ============================================================
+   FallingStar Studio — hand-painted artist treatment
+   (graphic-designer direction, 2026-06-17). Scoped to the line;
+   Artmura / Thibaut / Schumacher are unaffected.
+   ============================================================ */
+/* monograph register — lighter display weights read as a gallery, not a catalog */
+body[data-line="fallingstar"] .books h2,
+body[data-line="fallingstar"] .card .nm,
+body[data-line="fallingstar"] .rail h1{font-weight:400;letter-spacing:.005em}
+
+/* move 1 — matted artwork: thin ink frame insets the image like framed art */
+body[data-line="fallingstar"] .card{border-color:var(--taupe);border-radius:3px}
+body[data-line="fallingstar"] .card .ph{margin:8px 8px 0;width:calc(100% - 16px);
+  border:1px solid rgba(34,31,24,.28);border-radius:2px}
+body[data-line="fallingstar"] .card .ser{font-style:italic;text-transform:none;letter-spacing:.04em;font-size:11px}
+
+/* move 2 — postage / wax-stamp collection chips */
+body[data-line="fallingstar"] .chip{border-radius:3px;font-family:var(--serif);
+  letter-spacing:.02em;background:var(--paper);box-shadow:inset 0 0 0 2px var(--paper),0 0 0 1px var(--line)}
+body[data-line="fallingstar"] .chip.active{background:var(--accent);color:#fff;border-color:var(--accent);
+  box-shadow:inset 0 0 0 2px var(--accent),0 0 0 1px var(--accent)}
+
+/* move 3 — brushstroke rule under the collections heading */
+body[data-line="fallingstar"] .books h2{position:relative}
+body[data-line="fallingstar"] .books h2::after{content:"";display:block;width:72px;height:9px;margin:16px auto 0;
+  background:var(--accent);opacity:.9;
+  -webkit-mask:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 9'><path d='M1 5 C12 2,22 7,33 4 C45 1,56 8,71 4 L70 6 C56 9,45 4,33 7 C22 9,12 4,2 7 Z'/></svg>") center/contain no-repeat;
+          mask:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 9'><path d='M1 5 C12 2,22 7,33 4 C45 1,56 8,71 4 L70 6 C56 9,45 4,33 7 C22 9,12 4,2 7 Z'/></svg>") center/contain no-repeat;
+  transform:rotate(-.6deg)}
+
+/* ABOUT — brand story + "in collaboration with Designer Wallcoverings" (standing rule) */
+.about{max-width:760px;margin:0 auto;padding:56px 26px 8px;text-align:center}
+.about .eyebrow{font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-soft)}
+.about h2{font-family:var(--serif);font-weight:500;font-size:32px;margin:8px 0 22px}
+.about-body p{font-size:16px;line-height:1.85;color:var(--ink-soft);margin:0 0 18px;text-align:left}
+.about .dw-collab{font-family:var(--serif);font-style:italic;font-size:16px;color:var(--ink);
+  margin:26px 0 0;padding-top:22px;border-top:1px solid var(--line)}
+
+/* ---------- memo-sample / inquiry modal (internal CTA — no checkout) ---------- */
+.modal-back{position:fixed;inset:0;z-index:80;background:rgba(20,16,12,.55);
+  display:none;align-items:center;justify-content:center;padding:20px}
+.modal-back.on{display:flex}
+.modal{background:var(--paper);border:1px solid var(--line);border-radius:6px;max-width:440px;width:100%;
+  padding:30px 28px;box-shadow:0 30px 80px -30px rgba(33,29,24,.6)}
+.modal .eyebrow{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--gold)}
+.modal h3{font-family:var(--serif);font-weight:500;font-size:26px;margin:6px 0 4px}
+.modal .msub{font-size:13px;color:var(--ink-soft);margin-bottom:18px}
+.modal label{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-soft);margin:14px 0 5px}
+.modal input,.modal textarea{width:100%;border:1px solid var(--line);background:#fff;border-radius:7px;
+  padding:11px 12px;font-family:var(--sans);font-size:14px;color:var(--ink)}
+.modal textarea{min-height:74px;resize:vertical}
+.modal .mrow{display:flex;gap:12px;margin-top:20px}
+.modal .msg{font-size:13px;margin-top:14px;min-height:18px}
+.modal .msg.ok{color:#3f8a5d}.modal .msg.err{color:#a23b2e}
+.mclose{float:right;border:0;background:none;font-size:22px;line-height:1;color:var(--ink-soft);cursor:pointer;margin:-6px -4px 0 0}
+
+/* ── Corner refresh countdown pill ── */
+.astek-refresh-pill{
+  position:fixed; right:16px; bottom:16px; z-index:9999;
+  display:flex; align-items:center; gap:8px;
+  padding:8px 14px; border-radius:999px;
+  background:rgba(20,20,22,.82); color:#f4f1ea;
+  font:500 12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
+  letter-spacing:.02em; backdrop-filter:blur(8px);
+  box-shadow:0 4px 18px rgba(0,0,0,.28); border:1px solid rgba(255,255,255,.12);
+  user-select:none; pointer-events:auto;
+}
+.astek-refresh-pill .rp-dot{
+  width:7px; height:7px; border-radius:50%; background:#8bbf9f; flex:0 0 auto;
+  box-shadow:0 0 0 0 rgba(139,191,159,.6); animation:rpPulse 2.4s ease-out infinite;
+}
+.astek-refresh-pill.rp-live .rp-dot{ background:#e6b566; animation-duration:.9s; }
+@keyframes rpPulse{ 0%{box-shadow:0 0 0 0 rgba(139,191,159,.55)} 70%{box-shadow:0 0 0 8px rgba(139,191,159,0)} 100%{box-shadow:0 0 0 0 rgba(139,191,159,0)} }
+@media (max-width:640px){ .astek-refresh-pill{ right:10px; bottom:10px; padding:6px 11px; font-size:11px; } }
+
+/* ── internal PDP — pricing panel + 3-action row + INTERNAL pill ── */
+.internal-pill{font-size:10px;letter-spacing:.18em;font-weight:600;color:#fff;background:#b3261e;
+  padding:4px 10px;border-radius:20px;align-self:center}
+.pricebox{margin-top:18px;border:1px solid var(--line);border-radius:10px;padding:4px 14px;background:rgba(0,0,0,.02)}
+.pricebox .row{display:flex;justify-content:space-between;padding:9px 0;border-bottom:1px solid var(--line);font-size:13px}
+.pricebox .row:last-child{border-bottom:0}
+.pricebox .row .k{color:var(--ink-soft);letter-spacing:.04em}
+.pricebox .row .v{color:var(--ink);font-weight:600}
+.pricebox .pnote{padding:9px 0;font-size:12.5px;color:var(--ink-soft)}
+.act3{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:16px}
+.act3 .cta{margin-top:0;padding:13px 4px;font-size:13px;white-space:nowrap}
+.cta.call{font-size:13.5px}
+.noemail{margin-top:10px;font-size:12.5px;color:#8a5a00;background:#fdf6e3;border:1px solid #eadfc0;border-radius:8px;padding:9px 12px}
+.preview{margin-top:14px;border:1px solid var(--line);border-radius:10px;padding:12px 14px;background:rgba(0,0,0,.02);font-size:12.5px;max-height:260px;overflow:auto}
+.preview .pv-h{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold);margin-bottom:8px}
+.preview .pv-meta{color:var(--ink-soft);margin-bottom:4px}
+.preview .pv-body{margin-top:10px;border-top:1px solid var(--line);padding-top:8px;color:var(--ink)}
+.preview .pv-body p{margin:0 0 8px}
+.preview .pv-warn{color:#8a5a00}
diff --git a/scripts/build-products-json.js b/scripts/build-products-json.js
new file mode 100644
index 0000000..4cf1c56
--- /dev/null
+++ b/scripts/build-products-json.js
@@ -0,0 +1,206 @@
+#!/usr/bin/env node
+/**
+ * Build data/products.json from the internal dw_unified momentum_colorways table.
+ * The ENTIRE table IS the Hollywood Wallcoverings line (Momentum→Hollywood private
+ * label — 10,985 rows, Wallcovering + Acoustic). These are NOT on Shopify; this
+ * internal viewer serves its OWN grid. $0 (local PG read).
+ *
+ * PUBLIC-facing private-label brand = "Hollywood Wallcoverings". The real upstream
+ * vendor ("Momentum" / "Versa" / "Versa Designed Surfaces") must NEVER appear on
+ * any customer-facing surface. This is an INTERNAL gated tool, so we DO show the
+ * momentum_sku on the card (like astek shows the real vendor sku) — it stays behind
+ * basic-auth and never leaks to the public.
+ */
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+const OUT = path.join(__dirname, '..', 'data', 'products.json');
+const PW = (() => {
+  try {
+    const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
+    const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m);
+    if (m) return m[1].replace(/^["']|["']$/g, '').trim();
+  } catch { /* fall through */ }
+  return process.env.PGPASSWORD || 'DW2024SecurePass';
+})();
+const pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW });
+
+// Map an ai_color_name / color_name -> canonical color bucket (drives color filter + color sort).
+const BUCKET = {
+  white: 'white', ivory: 'white', cream: 'white', beige: 'white', alabaster: 'white', oatmeal: 'white', linen: 'white', snow: 'white', pearl: 'white',
+  grey: 'grey', gray: 'grey', silver: 'grey', neutral: 'grey', taupe: 'grey', greige: 'grey', stone: 'grey', slate: 'grey', pewter: 'grey', dove: 'grey', ash: 'grey', fog: 'grey',
+  black: 'black', charcoal: 'black', ebony: 'black', onyx: 'black', noir: 'black',
+  red: 'red', burgundy: 'red', crimson: 'red', scarlet: 'red', ruby: 'red', wine: 'red', brick: 'red',
+  orange: 'orange', rust: 'orange', copper: 'orange', terracotta: 'orange', apricot: 'orange', amber: 'orange', tangerine: 'orange',
+  brown: 'brown', tan: 'brown', bronze: 'brown', chocolate: 'brown', mocha: 'brown', coffee: 'brown', walnut: 'brown', cocoa: 'brown', khaki: 'brown', camel: 'brown', harvest: 'brown',
+  gold: 'gold', yellow: 'gold', mustard: 'gold', honey: 'gold', wheat: 'gold', flax: 'gold', maize: 'gold',
+  green: 'green', olive: 'green', sage: 'green', emerald: 'green', moss: 'green', fern: 'green', forest: 'green', celadon: 'green', mint: 'green',
+  teal: 'teal', turquoise: 'teal', aqua: 'teal', seafoam: 'teal', peacock: 'teal',
+  blue: 'blue', navy: 'blue', indigo: 'blue', cobalt: 'blue', denim: 'blue', sky: 'blue', azure: 'blue', ocean: 'blue', marine: 'blue',
+  purple: 'purple', violet: 'purple', lavender: 'purple', plum: 'purple', mauve: 'purple', lilac: 'purple', aubergine: 'purple', eggplant: 'purple',
+  pink: 'pink', rose: 'pink', blush: 'pink', magenta: 'pink', coral: 'pink', fuchsia: 'pink', salmon: 'pink',
+};
+const BUCKET_ORDER = ['white', 'grey', 'black', 'pink', 'red', 'orange', 'brown', 'gold', 'green', 'teal', 'blue', 'purple'];
+const BUCKET_HUE = { red: 0, orange: 30, gold: 50, green: 120, teal: 175, blue: 215, purple: 280, pink: 330, brown: 25, white: null, grey: null, black: null };
+
+// banned-word scrub — applies to EVERY string that could surface on a public deploy.
+// Strips the real upstream vendor names (Momentum / Versa) plus the wallpaper→wallcovering rule.
+const clean = s => (s == null ? s : String(s)
+  .replace(/\bVersa\s+Designed\s+Surfaces\b/gi, 'Hollywood Wallcoverings')
+  .replace(/\bMomentum\s+Textiles?(?:\s*&?\s*Walls?)?\b/gi, 'Hollywood Wallcoverings')
+  .replace(/\bMomentum\b/gi, 'Hollywood Wallcoverings')
+  .replace(/\bVersa\b/gi, 'Hollywood')
+  .replace(/\bWallpapers\b/gi, 'Wallcoverings')
+  .replace(/\bWallpaper\b/gi, 'Wallcovering'));
+
+const slug = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+
+function bucketFor(...names) {
+  for (const name of names) {
+    if (!name) continue;
+    const words = String(name).toLowerCase().split(/[^a-z]+/).filter(Boolean);
+    for (const w of words) if (BUCKET[w]) return BUCKET[w];
+  }
+  return null;
+}
+
+// bucket list_price into a coarse price group for the left-panel facet table
+function priceGroup(v) {
+  if (v == null) return null;
+  v = Number(v);
+  if (!(v > 0)) return null;
+  if (v < 25) return 'Under $25';
+  if (v < 50) return '$25 - $50';
+  if (v < 75) return '$50 - $75';
+  if (v < 100) return '$75 - $100';
+  if (v < 150) return '$100 - $150';
+  if (v < 250) return '$150 - $250';
+  return '$250+';
+}
+// fixed order so the price-group facet reads low→high, not alphabetical
+const PRICE_GROUP_ORDER = ['Under $25', '$25 - $50', '$50 - $75', '$75 - $100', '$100 - $150', '$150 - $250', '$250+'];
+
+async function main() {
+  const { rows } = await pool.query(`
+    SELECT dw_sku, momentum_sku, pattern_name, color_name, ai_color_name, ai_color_hex,
+           category, pl_city_name, product_line, width, list_price,
+           is_new, is_closeout, image_url, created_at
+    FROM momentum_colorways
+    ORDER BY pl_city_name, pattern_name, color_name, dw_sku
+  `);
+
+  // Vendor ops meta for the internal PDP (Call Vendor / Our Account # / pricing basis).
+  // Look the line up in vendor_registry / fmpro under its REAL vendor name (internal only).
+  const VENDOR_CODE_REG = 'momentum';
+  const FM_VENDOR_NAME = 'Momentum';
+  const vr = (await pool.query(
+    `SELECT vendor_name, vendor_discount_pct, pricing_unit, pricing_model, pricing_notes, sample_price
+     FROM vendor_registry WHERE vendor_code = $1`, [VENDOR_CODE_REG])).rows[0] || {};
+  const fm = (await pool.query(
+    `SELECT phone, email_1, account_num FROM fmpro
+     WHERE vendor_name = $1 AND account_num IS NOT NULL AND phone IS NOT NULL LIMIT 1`,
+    [FM_VENDOR_NAME])).rows[0] || {};
+  const vendorMeta = {
+    name: 'Hollywood Wallcoverings',                 // public-safe display name
+    phone: fm.phone || null,
+    email: fm.email_1 || null,
+    account_number: fm.account_num || null,
+    discount_pct: vr.vendor_discount_pct != null ? Number(vr.vendor_discount_pct) : null,
+    pricing_unit: vr.pricing_unit || null,
+    pricing_model: vr.pricing_model || null,
+    pricing_notes: vr.pricing_notes || null,
+    sample_price: vr.sample_price != null ? Number(vr.sample_price) : null,
+  };
+
+  const products = [];
+  let dropped = 0;
+  for (const r of rows) {
+    const img = r.image_url || null;
+    if (!img) { dropped++; continue; }                      // no image → not shown on the grid
+    const dwsku = r.dw_sku || '';
+    const displayColor = r.ai_color_name || r.color_name || null;   // prefer AI color name
+    const bucket = bucketFor(r.ai_color_name, r.color_name);
+    const status = r.is_closeout ? 'Closeout' : (r.is_new ? 'New' : null);
+    const price = r.list_price != null && Number(r.list_price) > 0 ? Number(r.list_price) : null;
+    products.push({
+      handle: slug(dwsku),                                  // e.g. dwhd-508083
+      dw_sku: r.dw_sku,
+      momentum_sku: r.momentum_sku || null,                 // internal only (behind basic-auth)
+      sku: r.momentum_sku || null,                          // alias for generic card machinery
+      title: clean([r.pattern_name, displayColor].filter(Boolean).join(' - ')) || dwsku,
+      display_eyebrow: clean(r.pattern_name || ''),
+      display_name: clean(displayColor || r.pattern_name || dwsku),
+      pattern_name: clean(r.pattern_name) || null,
+      series: clean(r.pattern_name) || null,                // pattern = "series" for pairing/sort
+      color_name: clean(r.color_name) || null,
+      ai_color_name: clean(r.ai_color_name) || null,
+      color: clean(displayColor) || null,
+      category: r.category || null,                          // Wallcovering | Acoustic
+      collection: clean(r.pl_city_name) || null,             // pl_city_name = the collection
+      book: r.category || 'Wallcovering',                    // category drives the "book" chip
+      product_line: r.product_line || null,
+      style: null, styles: [],
+      color_bucket: bucket,
+      hue: bucket ? BUCKET_HUE[bucket] : null,
+      hex: r.ai_color_hex || null,
+      sat: null, val: bucket === 'white' ? 1 : bucket === 'black' ? 0 : 0.5,
+      width: r.width ? String(r.width) : null,
+      is_new: !!r.is_new,
+      is_closeout: !!r.is_closeout,
+      status,
+      price,
+      price_group: priceGroup(price),
+      swatch: img,
+      room: img,
+      images: [img],
+      created_at: r.created_at || null,
+      published_at: r.created_at || null,
+      inquiry_sku: r.dw_sku,
+    });
+  }
+
+  // Facet counts for the left-panel tables. FIELDS list mirrors the frontend.
+  const FACET_FIELDS = ['category', 'collection', 'series', 'color', 'product_line', 'width', 'status', 'price_group', 'color_bucket'];
+  const facets = {};
+  for (const f of FACET_FIELDS) facets[f] = {};
+  for (const p of products) {
+    for (const f of FACET_FIELDS) {
+      const v = p[f];
+      if (v == null || v === '') continue;
+      facets[f][v] = (facets[f][v] || 0) + 1;
+    }
+  }
+  const orderedColors = BUCKET_ORDER.filter(b => facets.color_bucket[b]).map(b => [b, facets.color_bucket[b]]);
+  const orderedPrice = PRICE_GROUP_ORDER.filter(g => facets.price_group[g]).map(g => [g, facets.price_group[g]]);
+
+  const snapshot = {
+    built_at: new Date().toISOString(),
+    source: 'dw_unified.momentum_colorways (INTERNAL - Hollywood Wallcoverings private label, not on Shopify)',
+    count: products.length,
+    dropped_no_image: dropped,
+    vendor: vendorMeta,
+    facets: {
+      total: products.length,
+      category: Object.entries(facets.category).sort((a, b) => b[1] - a[1]),
+      collection: Object.entries(facets.collection).sort((a, b) => b[1] - a[1]),
+      series: Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, 400),
+      color: Object.entries(facets.color).sort((a, b) => b[1] - a[1]).slice(0, 400),
+      product_line: Object.entries(facets.product_line).sort((a, b) => b[1] - a[1]),
+      width: Object.entries(facets.width).sort((a, b) => b[1] - a[1]),
+      status: Object.entries(facets.status).sort((a, b) => b[1] - a[1]),
+      price_group: orderedPrice,
+      colors: orderedColors,          // legacy alias
+      color_bucket: orderedColors,
+    },
+    products,
+  };
+  fs.writeFileSync(OUT, JSON.stringify(snapshot));
+  console.log(`products.json -> ${OUT}`);
+  console.log(`  products: ${products.length} | dropped (no image): ${dropped}`);
+  console.log(`  categories: ${snapshot.facets.category.map(c => c[0] + ':' + c[1]).join(', ')}`);
+  console.log(`  color buckets: ${orderedColors.map(c => c[0] + ':' + c[1]).join(', ')}`);
+  console.log(`  collections: ${snapshot.facets.collection.length} | patterns: ${snapshot.facets.series.length}`);
+  await pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/e2e-verify.js b/scripts/e2e-verify.js
new file mode 100644
index 0000000..d17ca5a
--- /dev/null
+++ b/scripts/e2e-verify.js
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+/* Structural E2E + real-WebKit verification of the Hollywood Wallcoverings viewer.
+ * Usage: E2E_URL="http://admin:DW2024!@127.0.0.1:PORT/" node scripts/e2e-verify.js */
+const path = require('path');
+const URL = process.env.E2E_URL;
+if (!URL) { console.error('set E2E_URL'); process.exit(2); }
+const PROOF = path.join(__dirname, '..', 'docs', 'proof');
+const results = [];
+function check(name, ok, detail) { results.push({ name, ok, detail }); console.log((ok ? 'PASS' : 'FAIL') + '  ' + name + (detail ? '  — ' + detail : '')); }
+
+(async () => {
+  const { chromium, webkit } = require('playwright');
+
+  // ── 1. Structural E2E (chromium) ──────────────────────────────
+  {
+    const b = await chromium.launch();
+    const pg = await b.newPage();
+    await pg.goto(URL, { waitUntil: 'networkidle', timeout: 60000 });
+    await pg.waitForSelector('.card', { timeout: 30000 });
+    const m = await pg.evaluate(() => ({
+      gridChildren: document.getElementById('grid').children.length,
+      cards: document.querySelectorAll('.card').length,
+      emptyAnchorGridChildren: [...document.getElementById('grid').children].filter(c => c.tagName === 'A' && !c.textContent.trim()).length,
+      anchorWrappedCards: document.querySelectorAll('a > .card, a.card').length,
+    }));
+    check('[chromium] #grid.children === .card count', m.gridChildren === m.cards, `grid=${m.gridChildren} cards=${m.cards}`);
+    check('[chromium] zero empty <a> grid children', m.emptyAnchorGridChildren === 0, `empty-a=${m.emptyAnchorGridChildren}`);
+    check('[chromium] no <a>-wrapped cards', m.anchorWrappedCards === 0, `wrapped=${m.anchorWrappedCards}`);
+    await b.close();
+  }
+
+  // ── 2. Real WebKit, creds in URL ──────────────────────────────
+  {
+    const b = await webkit.launch();
+    const pg = await b.newPage();
+    await pg.goto(URL, { waitUntil: 'networkidle', timeout: 60000 });
+    await pg.waitForSelector('.card', { timeout: 30000 });
+
+    const cards = await pg.locator('.card').count();
+    check('[webkit] grid renders cards>0', cards > 0, `cards=${cards}`);
+
+    // dw_sku chip present on cards (standing rule)
+    const dwChips = await pg.locator('.card .dwsku').count();
+    check('[webkit] dw_sku chip on cards', dwChips > 0, `dwsku chips=${dwChips}`);
+    const dwText = await pg.locator('.card .dwsku').first().textContent();
+    check('[webkit] dw_sku chip has DWHD text', /DWHD-\d+/.test(dwText || ''), `text="${dwText}"`);
+
+    // created-date chip present with ISO title (standing rule)
+    const whenChip = pg.locator('.card .when').first();
+    const whenCount = await pg.locator('.card .when').count();
+    const whenTitle = whenCount ? await whenChip.getAttribute('title') : '';
+    check('[webkit] created date+time chip present', whenCount > 0, `when chips=${whenCount}`);
+    check('[webkit] created chip has ISO in title=', /\d{4}-\d{2}-\d{2}T/.test(whenTitle || ''), `title="${whenTitle}"`);
+
+    // panel field-tables present + collapsed on load
+    const detailsTotal = await pg.locator('#facetbox details').count();
+    const detailsOpen = await pg.locator('#facetbox details[open]').count();
+    check('[webkit] panel field-tables present', detailsTotal >= 8, `details=${detailsTotal}`);
+    check('[webkit] all field-tables collapsed on load', detailsOpen === 0, `open=${detailsOpen}`);
+
+    // a value-click filters (open Category table, click first value, count drops)
+    const before = await pg.locator('.card').count();
+    await pg.locator('#facetbox details[data-f="category"] summary').click();
+    await pg.waitForTimeout(200);
+    const firstVal = await pg.locator('#facetbox details[data-f="category"] .frow').first().textContent();
+    await pg.locator('#facetbox details[data-f="category"] .frow').first().click();
+    await pg.waitForTimeout(400);
+    const totalTxt = await pg.locator('#total').textContent();
+    const activeRow = await pg.locator('#facetbox details[data-f="category"] .frow.active').count();
+    check('[webkit] value-click filters (active row set)', activeRow === 1, `active=${activeRow}, total="${totalTxt}", clicked="${(firstVal||'').trim()}"`);
+
+    // density changes --fs
+    const fsBefore = await pg.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim());
+    await pg.evaluate(() => { const d = document.getElementById('density'); d.value = 14; d.dispatchEvent(new Event('input')); });
+    await pg.waitForTimeout(150);
+    const fsAfter = await pg.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim());
+    const colsAfter = await pg.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--cols').trim());
+    check('[webkit] density changes --fs', fsBefore !== fsAfter, `--fs ${fsBefore} -> ${fsAfter}, --cols=${colsAfter}`);
+
+    // a card click sets navigation (data-href present + resolves to /product/)
+    // clear filter first for a clean card
+    await pg.locator('#clearf').click().catch(() => {});
+    await pg.waitForTimeout(300);
+    const href = await pg.locator('.card[data-href]').first().getAttribute('data-href');
+    check('[webkit] card has data-href navigation', /^\/product\//.test(href || ''), `data-href="${href}"`);
+
+    await pg.screenshot({ path: path.join(PROOF, 'webkit-grid.png'), fullPage: false });
+    // screenshot with a filter+details open state too
+    await b.close();
+  }
+
+  const failed = results.filter(r => !r.ok);
+  console.log(`\n${results.length - failed.length}/${results.length} checks passed.`);
+  process.exit(failed.length ? 1 : 0);
+})().catch(e => { console.error('E2E ERROR:', e); process.exit(3); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..14dfbfb
--- /dev/null
+++ b/server.js
@@ -0,0 +1,162 @@
+/* Hollywood Wallcoverings — INTERNAL line viewer, Designer Wallcoverings house brand.
+   INTERNAL DATA ONLY: catalog is dw_unified.momentum_colorways (NOT on Shopify).
+   The public-facing private-label brand is "Hollywood Wallcoverings"; the real
+   upstream vendor (Momentum / Versa) must NEVER surface (behind basic-auth here).
+   Self-contained: reads data/products.json. Links resolve to this site's OWN
+   internal PDPs and a memo-sample/inquiry CTA — never Shopify, never momentumco.com. */
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 0; // 0 = OS-assigned free port
+const DATA = path.join(__dirname, 'data', 'products.json');
+
+const app = express();
+app.use(require('compression')());
+
+// Unauthenticated health probe (deploy smoke test + uptime monitors) — BEFORE the auth gate.
+app.get('/healthz', (_req, res) => res.json({ ok: true, products: SNAP.products.length, dataMtime: LAST_REFRESH }));
+
+// HTTP Basic Auth — internal-only gate (username + password). Override via
+// BASIC_AUTH="user:pass"; defaults to the DW standard admin / DW2024!.
+const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
+app.use((req, res, next) => {
+  const hdr = req.headers.authorization || '';
+  const [scheme, b64] = hdr.split(' ');
+  if (scheme === 'Basic' && b64) {
+    const [u, p] = Buffer.from(b64, 'base64').toString('utf8').split(':');
+    if (u === AUTH_USER && p === AUTH_PASS) return next();
+  }
+  res.set('WWW-Authenticate', 'Basic realm="Hollywood Wallcoverings - Designer Wallcoverings (internal)"');
+  return res.status(401).send('Authentication required.');
+});
+
+app.use(express.json({ limit: '2mb' }));
+
+let SNAP = { products: [], facets: { total: 0, books: [], series: [], colors: [] } };
+let LIGHT = []; // grid-index payload — heavy PDP-only fields stripped
+let LAST_REFRESH = new Date().toISOString();
+const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 900); // 15 min, matches the cron
+function load() {
+  SNAP = JSON.parse(fs.readFileSync(DATA, 'utf8'));
+  // the index grid never renders body_html / images[] / eyebrow — keep those PDP-only
+  LIGHT = SNAP.products.map(({ body_html, images, display_eyebrow, published_at, ...p }) => p);
+  try { LAST_REFRESH = fs.statSync(DATA).mtime.toISOString(); } catch { LAST_REFRESH = new Date().toISOString(); }
+  console.log(`[hollywood] loaded ${SNAP.products.length} products (data mtime ${LAST_REFRESH})`);
+}
+load();
+// Hot-reload when the refresh cron rewrites data/products.json — no restart needed.
+fs.watchFile(DATA, { interval: 5000 }, (cur, prev) => {
+  if (cur.mtimeMs !== prev.mtimeMs) { try { load(); } catch (e) { console.error('[hollywood] reload failed:', e.message); } }
+});
+
+// House identity — Designer Wallcoverings is the brand; Hollywood Wallcoverings is the line.
+const CONFIG = {
+  house: 'Designer Wallcoverings',
+  houseUrl: 'https://www.designerwallcoverings.com',
+  nav: [
+    { label: 'The Collection', href: '#collections' },
+    { label: 'Designer Wallcoverings', href: 'https://www.designerwallcoverings.com' },
+  ],
+  vendor: 'Hollywood Wallcoverings',
+  line: 'Hollywood Wallcoverings',
+  wordmark: 'Designer Wallcoverings',
+  eyebrow: 'A Designer Wallcoverings Collection',
+  kicker: 'Contract Wallcoverings & Acoustic',
+  tagline: 'Contract wallcoverings and acoustic surfaces — the Hollywood Wallcoverings line, curated by Designer Wallcoverings.',
+  booksHeading: 'The Hollywood Wallcoverings Collection',
+  title: 'Hollywood Wallcoverings — Internal Line',
+  metaDescription: 'The Hollywood Wallcoverings line at Designer Wallcoverings (internal). To the trade — order a memo sample before specifying.',
+  slug: 'hollywood',
+  palette: null,
+  about: {
+    paragraphs: [
+      'Hollywood Wallcoverings is Designer Wallcoverings\' contract wallcovering and acoustic line — durable, specification-grade surfaces spanning Type II vinyls, textural weaves, and acoustic panels, organized into California-coast collections and made for both commercial and hospitality interiors.',
+      'The collection spans traditional and contemporary wallcoverings alongside an acoustic surface program — colorways developed for demanding contract specification.',
+    ],
+    collab: 'The Hollywood Wallcoverings line, presented by Designer Wallcoverings.',
+  },
+};
+
+// vendorMeta (phone / our account # / discount / pricing model) rides in from
+// data/products.json — the 15-min refresh keeps it current from PG.
+app.get('/api/config', (_req, res) => res.json({ ...CONFIG, vendorMeta: SNAP.vendor || null }));
+// Refresh metadata — drives the corner countdown pill on the landing.
+app.get('/api/meta', (_req, res) => res.json({
+  lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
+  count: SNAP.products.length, now: new Date().toISOString(),
+}));
+app.get('/api/products', (_req, res) => res.json({ count: LIGHT.length, products: LIGHT }));
+app.get('/api/facets', (_req, res) => res.json(SNAP.facets));
+
+app.get('/api/product/:handle', (req, res) => {
+  const p = SNAP.products.find(x => x.handle === req.params.handle);
+  if (!p) return res.status(404).json({ error: 'not found' });
+  res.json(p);
+});
+
+// "Pairs well with" — same/adjacent color family, different pattern (contrast of scale). Up to 6.
+app.get('/api/pairs/:handle', (req, res) => {
+  const p = SNAP.products.find(x => x.handle === req.params.handle);
+  if (!p) return res.status(404).json({ error: 'not found' });
+  const hueDist = (a, b) => { if (a == null || b == null) return 180; const d = Math.abs(a - b) % 360; return d > 180 ? 360 - d : d; };
+  const scored = SNAP.products.filter(x => x.handle !== p.handle && x.series !== p.series).map(x => {
+    let s = 0;
+    if (x.color_bucket && x.color_bucket === p.color_bucket) s += 40;
+    s += Math.max(0, 30 - hueDist(x.hue, p.hue) / 2);
+    s += 20; // always a different pattern (filtered above) — reward scale contrast
+    if (x.book && x.book === p.book) s += 8;
+    return { x, s };
+  }).sort((a, b) => b.s - a.s);
+  // de-dup by series so pairs aren't 6 colorways of one pattern
+  const seen = new Set(), out = [];
+  for (const o of scored) { if (seen.has(o.x.series)) continue; seen.add(o.x.series); out.push(o.x); if (out.length === 6) break; }
+  res.json({ pairs: out });
+});
+
+// Internal purchasing requests — Request Memo / Check Stock / Get Price.
+// REQ-numbered in dw_unified.vendor_requests; stock/price auto-email the vendor
+// via george-gmail when an email is on file (account # only, never a client name).
+const { mountVendorRequests } = require('./lib/vendor-requests');
+mountVendorRequests(app, { vendorCode: 'momentum', getVendor: () => SNAP.vendor || {}, dataDir: path.join(__dirname, 'data') });
+
+// Legacy inquiry endpoint — kept for anything still posting here; logs only.
+const ACTION_MSG = {
+  memo: 'Memo request logged — purchasing will order the sample.',
+  stock: 'Stock check logged — purchasing will confirm availability with the vendor.',
+  price: 'Price request logged — purchasing will confirm current pricing with the vendor.',
+};
+app.post('/api/inquiry', (req, res) => {
+  const { sku, name, email, note } = req.body || {};
+  const type = ['memo', 'stock', 'price'].includes(req.body && req.body.type) ? req.body.type : 'memo';
+  if (!sku || !email) return res.status(400).json({ ok: false, error: 'sku and email required' });
+  const rec = { at: new Date().toISOString(), type, sku, name: name || '', email, note: note || '', ip: req.ip };
+  try {
+    fs.appendFileSync(path.join(__dirname, 'data', 'inquiries.jsonl'), JSON.stringify(rec) + '\n');
+  } catch (e) { return res.status(500).json({ ok: false, error: 'log failed' }); }
+  res.json({ ok: true, message: ACTION_MSG[type] });
+});
+
+// ── Private-label curation (Astek → Phillipe Romano) ─────────────────────
+// Selection is the FIRST step only — picks persist server-side so the later
+// (Steve-gated) private-label push has a durable list. NO Shopify writes here.
+const SEL = path.join(__dirname, 'data', 'selection.json');
+app.get('/api/selection', (_req, res) => {
+  try { res.json(JSON.parse(fs.readFileSync(SEL, 'utf8'))); }
+  catch { res.json({ updated_at: null, count: 0, skus: [] }); }
+});
+app.post('/api/selection', (req, res) => {
+  const skus = Array.isArray(req.body?.skus) ? req.body.skus.filter(s => typeof s === 'string').slice(0, 20000) : null;
+  if (!skus) return res.status(400).json({ ok: false, error: 'skus[] required' });
+  const doc = { updated_at: new Date().toISOString(), label: 'Phillipe Romano candidates', count: skus.length, skus };
+  fs.writeFileSync(SEL, JSON.stringify(doc, null, 1));
+  res.json({ ok: true, count: skus.length });
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+app.get('/product/:handle', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'product.html')));
+app.get('/curate', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'curate.html')));
+
+const server = app.listen(PORT, () => {
+  console.log(`Hollywood Wallcoverings (internal) → http://127.0.0.1:${server.address().port}`);
+});

(oldest)  ·  back to Momentum Landing  ·  rename orphaned refresh-pill css class astek->hw a21f169 →