[object Object]

← back to Fabricut Landing

Fabricut microsite (fabricut-landing): DW-branded self-contained viewer over fabricut_catalog (1325 products, per-yard, real-hex color sort)

7c24d19ef40f2e0f49b247fdf104c62d166765e1 · 2026-08-20 10:31:21 -0700 · Steve

Files touched

Diff

commit 7c24d19ef40f2e0f49b247fdf104c62d166765e1
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 10:31:21 2026 -0700

    Fabricut microsite (fabricut-landing): DW-branded self-contained viewer over fabricut_catalog (1325 products, per-yard, real-hex color sort)
---
 .deploy.conf                                  |    6 +
 .gitignore                                    |    8 +
 _sangetsu_spec.mjs                            |   74 ++
 _verifylive.mjs                               |   20 +
 docs/proof/grid.png                           |  Bin 0 -> 1996677 bytes
 docs/proof/home.png                           |  Bin 0 -> 1912695 bytes
 docs/proof/pdp-memo-modal.png                 |  Bin 0 -> 585041 bytes
 kravet_fe_full.mjs                            |   81 ++
 launchd/com.steve.astek-data-refresh.plist    |   17 +
 launchd/com.steve.astek-monthly-refresh.plist |   23 +
 lib/vendor-requests.js                        |  222 +++++
 node_modules                                  |    1 +
 package-lock.json                             | 1069 +++++++++++++++++++++++++
 package.json                                  |   17 +
 public/curate.html                            |  162 ++++
 public/drill.js                               |   36 +
 public/index.html                             |  489 +++++++++++
 public/nav-agent/nav-agent.css                |  133 +++
 public/nav-agent/nav-agent.js                 |  620 ++++++++++++++
 public/product.html                           |  242 ++++++
 public/styles.css                             |  289 +++++++
 scripts/build-fabricut.js                     |  167 ++++
 scripts/build-products-json.js                |  194 +++++
 scripts/monthly-refresh.sh                    |   41 +
 scripts/scrape-astek.js                       |  277 +++++++
 server.js                                     |  150 ++++
 26 files changed, 4338 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..bf44b14
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,6 @@
+# fabricut-landing deploy config (GATED — do not run without Steve's approval)
+PROJECT_NAME="fabricut-landing"
+DEPLOY_PATH="/var/www/fabricut.designerwallcoverings.com"
+DOMAIN="fabricut.designerwallcoverings.com"
+HEALTH_URL="http://localhost:PORT/healthz"
+BUILD_CMD="MODE=public node scripts/build-fabricut.js"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1e23b4a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+data/products.json
+data/inquiries.jsonl
+data/selection.json
diff --git a/_sangetsu_spec.mjs b/_sangetsu_spec.mjs
new file mode 100644
index 0000000..3e85af6
--- /dev/null
+++ b/_sangetsu_spec.mjs
@@ -0,0 +1,74 @@
+// sangetsu-spec-scrape.mjs — headless-render each Sangetsu product page and
+// parse its SPECIFICATIONS block (the store-API description LACKS this; the
+// rendered page HAS it, e.g. "Dimension Per Unit: 54\" x 30 yards | Backing:
+// Vinyl Fabric Back | Origin: USA"). Recovers width + backing/origin/repeat/match
+// for patterns that expose a specs section (spec-less "coming soon" books yield
+// nothing — reported, never fabricated).
+//
+// FREE (local chromium). Reads pattern list from sangetsu-staging.jsonl, writes
+// staging/sangetsu-page-specs.jsonl keyed by pattern. Resumable: skips patterns
+// already present in the out file.
+import { chromium } from 'playwright';
+import fs from 'fs';
+
+const DIR = '/Users/macstudio3/Projects/Designer-Wallcoverings/onboarding/sangetsu-lilycolor/staging';
+const IN = `${DIR}/sangetsu-staging.jsonl`;
+const OUT = `${DIR}/sangetsu-page-specs.jsonl`;
+
+const pats = fs.readFileSync(IN, 'utf8').trim().split('\n').map(JSON.parse);
+const done = new Set(
+  fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).pattern) : []
+);
+
+function parseWidth(seg) {
+  if (!seg) return '';
+  let m;
+  // "Width 52" / 132 cm" -> prefer cm
+  m = seg.match(/width\s*:?\s*\d{1,3}\s*["”″]\s*\/\s*(\d{2,3})\s*cm/i); if (m) return `${m[1]} cm`;
+  // "width 0.91 m" / "x width 0.91 m" -> cm
+  m = seg.match(/width\s*:?\s*(\d(?:\.\d+)?)\s*m\b/i);                 if (m) return `${Math.round(parseFloat(m[1]) * 100)} cm`;
+  // "Width: 132 cm" / "Width 132 cm"
+  m = seg.match(/width\s*:?\s*(\d{2,3})\s*cm/i);                       if (m) return `${m[1]} cm`;
+  // "Dimension Per Unit: 54" x 30 yards" -> inches
+  m = seg.match(/(\d{2,3})\s*["”″]\s*x\s*\d/i);                        if (m) return `${m[1]} Inches`;
+  // "Width 52"" (inch only)
+  m = seg.match(/width\s*:?\s*(\d{2,3})\s*["”″]/i);                    if (m) return `${m[1]} Inches`;
+  return '';
+}
+function field(txt, label) {
+  const re = new RegExp(label + '\\s*:?\\s*([^\\n|]{1,40}?)(?=\\s{2,}|$|[A-Z][a-z]+\\s+[A-Z]|Width|Match|Backing|Weight|Fire|Meets|Custom|CARE|Type|Countries)', 'i');
+  const m = txt.match(re); return m ? m[1].trim().replace(/[:.\s]+$/, '') : '';
+}
+
+const b = await chromium.launch({ headless: true });
+const stream = fs.createWriteStream(OUT, { flags: 'a' });
+let hit = 0, miss = 0, i = 0;
+for (const p of pats) {
+  i++;
+  if (done.has(p.pattern)) continue;
+  const pg = await b.newPage();
+  let rec = { pattern: p.pattern, width: '', backing: '', origin: '', repeat: '', match: '', has_spec: false };
+  try {
+    await pg.goto(p.source_url, { waitUntil: 'domcontentloaded', timeout: 40000 });
+    await pg.waitForTimeout(900);
+    const body = (await pg.innerText('body').catch(() => '')) || '';
+    const si = body.search(/SPECIFICATIONS/i);
+    if (si >= 0) {
+      const seg = body.slice(si, si + 500).replace(/\s+/g, ' ');
+      rec.has_spec = true;
+      rec.width = parseWidth(seg);
+      rec.backing = field(seg, 'Backing');
+      rec.origin = field(seg, 'Origin');
+      rec.repeat = field(seg, 'Repeat');
+      rec.match = field(seg, 'Match');
+      rec.spec_raw = seg.slice(0, 240);
+    }
+    if (rec.width) hit++; else miss++;
+  } catch (e) { miss++; rec.err = e.message.slice(0, 40); }
+  stream.write(JSON.stringify(rec) + '\n');
+  await pg.close();
+  if (i % 50 === 0) console.log(`[${i}/${pats.length}] width_hit=${hit} miss=${miss}`);
+}
+stream.end();
+await b.close();
+console.log(JSON.stringify({ patterns: pats.length, width_recovered: hit, no_width: miss }));
diff --git a/_verifylive.mjs b/_verifylive.mjs
new file mode 100644
index 0000000..368fe0a
--- /dev/null
+++ b/_verifylive.mjs
@@ -0,0 +1,20 @@
+import { chromium } from 'playwright';
+const url='https://www.designerwallcoverings.com/products/dwkk-gc7c45cad?_='+Math.floor(Math.random()*1e6);
+const b=await chromium.launch();
+const pg=await b.newPage({viewport:{width:1280,height:2400},deviceScaleFactor:1});
+await pg.goto(url,{waitUntil:'networkidle',timeout:45000}).catch(()=>{});
+await pg.waitForTimeout(3500);
+const r=await pg.evaluate(()=>{
+  var sim=document.getElementById('dw-similar');
+  var out={sim:!!sim};
+  if(sim){ var rr=sim.getBoundingClientRect(); out.simW=Math.round(rr.width); out.simLeft=Math.round(rr.left);
+    out.simParent=(sim.parentElement.className||'').toString().replace(/\s+/g,' ').trim().slice(0,40);
+    var g=sim.querySelector('.dw-similar__grid'); out.cols=g?getComputedStyle(g).gridTemplateColumns.split(' ').length:0; out.cards=g?g.children.length:0; }
+  var smp=document.querySelector('.dw-price-buy-row2 .dl-sample-btn');
+  var pdf=document.querySelector('.dw-price-buy-row2 .spec-sheet-btn');
+  if(smp&&pdf){ out.sampleW=Math.round(smp.getBoundingClientRect().width); out.pdfW=Math.round(pdf.getBoundingClientRect().width);
+    out.pdfText=(pdf.textContent||'').trim(); }
+  return out;
+});
+console.log(JSON.stringify(r,null,2));
+await b.close();
diff --git a/docs/proof/grid.png b/docs/proof/grid.png
new file mode 100644
index 0000000..52db822
Binary files /dev/null and b/docs/proof/grid.png differ
diff --git a/docs/proof/home.png b/docs/proof/home.png
new file mode 100644
index 0000000..cfcb7d6
Binary files /dev/null and b/docs/proof/home.png differ
diff --git a/docs/proof/pdp-memo-modal.png b/docs/proof/pdp-memo-modal.png
new file mode 100644
index 0000000..50cde1f
Binary files /dev/null and b/docs/proof/pdp-memo-modal.png differ
diff --git a/kravet_fe_full.mjs b/kravet_fe_full.mjs
new file mode 100644
index 0000000..b7fa6b5
--- /dev/null
+++ b/kravet_fe_full.mjs
@@ -0,0 +1,81 @@
+// Kravet front-end full run — render each SKU, attach extra brandfolder gallery images to Shopify (base64).
+// Resumable + crash-safe: only marks a SKU done after a completed render; relaunches browser on crash.
+import { chromium } from 'playwright';
+import fs from 'fs';
+const DIR='/Users/macstudio3/kravet-feed-pull/fe-run';
+const LEDGER=DIR+'/done.txt', LOG=DIR+'/progress.log';
+const env=Object.fromEntries(fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').filter(l=>l.includes('=')).map(l=>{const i=l.indexOf('=');return[l.slice(0,i).trim(),l.slice(i+1).trim()];}));
+const DOMAIN=(env.SHOPIFY_STORE_DOMAIN||env.SHOPIFY_STORE).replace(/^https?:\/\//,'').replace(/\/$/,'');
+const API=`https://${DOMAIN}/admin/api/2024-10`;
+const H={'X-Shopify-Access-Token':env.SHOPIFY_ADMIN_TOKEN,'Content-Type':'application/json'};
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const log=m=>{const s=`[${new Date().toISOString()}] ${m}`; console.log(s); try{fs.appendFileSync(LOG,s+'\n');}catch{}};
+
+const done=new Set(fs.existsSync(LEDGER)?fs.readFileSync(LEDGER,'utf8').trim().split('\n').filter(Boolean):[]);
+const rows=fs.readFileSync(DIR+'/skus.txt','utf8').trim().split('\n').map(l=>l.split('|')).filter(r=>!done.has(r[0]));
+const urls=new Set(fs.readFileSync(DIR+'/urls.txt','utf8').trim().split('\n').map(u=>u.replace('https://www.kravet.com/','')));
+function resolve(mfr){const base=mfr.toLowerCase().replace(/\.0$/,'');
+  for(const c of [base.replace(/[./]/g,'-'), base.split(/[./]/).slice(0,2).join('-')]) if(urls.has(c)) return c;
+  const pref=base.replace(/[./]/g,'-').slice(0,10); for(const u of urls) if(u.startsWith(pref)) return u; return null;}
+const keyOf=mfr=>mfr.toLowerCase().replace(/\.0$/,'').replace(/[./]/g,'_');
+function mine(f,key){f=f.toLowerCase(); if(!f.startsWith(key))return false; const nx=f[key.length]; if(nx!=='.'&&nx!=='_')return false; if(f.includes('_thumb'))return false; return true;}
+const isExtra=(f,key)=>f.toLowerCase().startsWith(key+'_');
+async function curImgs(pid){try{const r=await fetch(`${API}/products/${pid}/images.json?fields=id,src`,{headers:H});return r.ok?((await r.json()).images||[]):[];}catch{return [];}}
+async function addB64(pid,src,fnameBase){
+  let buf,ct; for(let t=0;t<3;t++){try{const ir=await fetch(src,{headers:{'User-Agent':'Mozilla/5.0'},signal:AbortSignal.timeout(25000)});if(!ir.ok)throw new Error('dl'+ir.status);ct=ir.headers.get('content-type')||'';buf=Buffer.from(await ir.arrayBuffer());break;}catch(e){if(t===2)return{ok:false};await sleep(600);}}
+  if(!buf||buf.length<500)return{ok:false};
+  const ext=/png/i.test(ct)?'png':/webp/i.test(ct)?'webp':'jpg';
+  const filename=fnameBase.replace(/\.(auto|jpe?g|png|webp)$/i,'')+'.'+ext;
+  for(let t=0;t<4;t++){try{const r=await fetch(`${API}/products/${pid}/images.json`,{method:'POST',headers:H,body:JSON.stringify({image:{attachment:buf.toString('base64'),filename}}),signal:AbortSignal.timeout(45000)});
+    if(r.ok)return{ok:true}; if(r.status===429||r.status>=500){await sleep(1500*(t+1));continue;} return{ok:false};}catch(e){if(t===3)return{ok:false}; await sleep(1500*(t+1));}}
+  return{ok:false};
+}
+
+const CONC=6; let n=0,withExtra=0,added=0,prods=0,fail=0,rerr=0; let idx=0; const t0=Date.now();
+let browser=await chromium.launch({headless:true});
+async function freshCtx(){
+  if(!browser.isConnected()){ log('browser died — relaunching'); browser=await chromium.launch({headless:true}); }
+  const ctx=await browser.newContext();
+  await ctx.route('**/*',route=>{
+    const req=route.request(); const u=req.url();
+    if(/^https:\/\/cdn\.brandfolder\.io/.test(u)){const fn=u.split('?')[0].split('/').pop(); if(/\.(auto|jpe?g|png|webp)$/i.test(fn)&&ctx.__cur) ctx.__cur[fn]=u.split('?')[0]; return route.abort();}
+    const rt=req.resourceType(); if(rt==='image'||rt==='media'||rt==='font'||rt==='stylesheet') return route.abort();
+    return route.continue().catch(()=>{});
+  });
+  return ctx;
+}
+async function worker(){
+  let ctx=await freshCtx();
+  while(idx<rows.length){
+    const i=idx++; const [dwsku,pid,mfr]=rows[i];
+    const slug=resolve(mfr);
+    if(!slug){ fs.appendFileSync(LEDGER,dwsku+'\n'); n++; tick(); continue; }
+    let rendered=false, extras=[];
+    try{
+      const urlByFile={}; ctx.__cur=urlByFile;
+      const p=await ctx.newPage();
+      try{
+        await p.goto('https://www.kravet.com/'+slug,{waitUntil:'domcontentloaded',timeout:20000}).catch(()=>{});
+        await p.waitForTimeout(2800);
+        rendered=true;
+        const key=keyOf(mfr);
+        extras=Object.keys(urlByFile).filter(f=>mine(f,key)&&isExtra(f,key));
+      } finally { await p.close().catch(()=>{}); }
+    }catch(e){ rerr++; if(rerr%50===1) log(`render err (${rerr}) ${mfr}: ${String(e.message).slice(0,60)}`);
+      try{await ctx.close().catch(()=>{});}catch{} ctx=await freshCtx(); continue; /* do NOT mark done -> retried next run */ }
+    if(rendered && extras.length){ withExtra++;
+      const cur=await curImgs(pid); const have=new Set(cur.map(im=>(im.src||'').split('/').pop().split('?')[0].toLowerCase().replace(/(_[0-9a-f-]{8,})?\.(jpe?g|png|webp|auto)$/,'')));
+      const todo=extras.filter(f=>!have.has(f.toLowerCase().replace(/\.(auto|jpe?g|png|webp)$/,'')));
+      const results=await Promise.all(todo.map(f=>addB64(pid,ctx.__cur[f],f)));  // parallel per-product transfers
+      const a=results.filter(r=>r.ok).length; added+=a; fail+=results.length-a;
+      if(a>0)prods++;
+    }
+    fs.appendFileSync(LEDGER,dwsku+'\n'); n++; tick();
+  }
+  await ctx.close().catch(()=>{});
+}
+function tick(){ if(n%100===0){const rate=n/((Date.now()-t0)/1000); const rem=Math.round((rows.length-n)/Math.max(rate,0.01)/60); log(`${n}/${rows.length} | extras:${withExtra} prods:${prods} imgs:${added} fail:${fail} rerr:${rerr} | ~${rem}min left`);} }
+log(`START full FE run: ${rows.length} to process (${done.size} already done)  CONC=${CONC}`);
+await Promise.all(Array.from({length:CONC},()=>worker()));
+await browser.close().catch(()=>{});
+log(`FULL FE RUN DONE. processed:${n} with-extras:${withExtra} products-attached:${prods} images-added:${added} fail:${fail} render-errs:${rerr}`);
diff --git a/launchd/com.steve.astek-data-refresh.plist b/launchd/com.steve.astek-data-refresh.plist
new file mode 100644
index 0000000..dae3868
--- /dev/null
+++ b/launchd/com.steve.astek-data-refresh.plist
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.astek-data-refresh</string>
+  <key>ProgramArguments</key><array><string>/opt/homebrew/bin/node</string><string>/Users/macstudio3/Projects/astek-landing/scripts/build-products-json.js</string></array>
+  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/astek-landing/scripts</string>
+  <key>StartCalendarInterval</key><array>
+    <dict><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Minute</key><integer>15</integer></dict>
+    <dict><key>Minute</key><integer>30</integer></dict>
+    <dict><key>Minute</key><integer>45</integer></dict>
+  </array>
+  <key>RunAtLoad</key><true/>
+  <key>StandardOutPath</key><string>/tmp/astek-data-refresh.log</string>
+  <key>StandardErrorPath</key><string>/tmp/astek-data-refresh.log</string>
+  <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/usr/bin:/bin</string></dict>
+</dict></plist>
diff --git a/launchd/com.steve.astek-monthly-refresh.plist b/launchd/com.steve.astek-monthly-refresh.plist
new file mode 100644
index 0000000..284b4da
--- /dev/null
+++ b/launchd/com.steve.astek-monthly-refresh.plist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.astek-monthly-refresh</string>
+  <key>ProgramArguments</key>
+    <array>
+      <string>/bin/bash</string>
+      <string>/Users/macstudio3/Projects/astek-landing/scripts/monthly-refresh.sh</string>
+    </array>
+  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/astek-landing</string>
+  <!-- 1st of every month, 04:30 local -->
+  <key>StartCalendarInterval</key>
+    <dict>
+      <key>Day</key><integer>1</integer>
+      <key>Hour</key><integer>4</integer>
+      <key>Minute</key><integer>30</integer>
+    </dict>
+  <key>RunAtLoad</key><false/>
+  <key>StandardOutPath</key><string>/tmp/astek-monthly-refresh.log</string>
+  <key>StandardErrorPath</key><string>/tmp/astek-monthly-refresh.log</string>
+  <key>EnvironmentVariables</key>
+    <dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
+</dict></plist>
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/node_modules b/node_modules
new file mode 120000
index 0000000..58145b4
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/Users/macstudio3/Projects/astek-landing/node_modules
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1511e8b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1069 @@
+{
+  "name": "astek-landing",
+  "version": "0.3.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "astek-landing",
+      "version": "0.3.0",
+      "dependencies": {
+        "compression": "^1.8.1",
+        "express": "^4.19.2",
+        "pg": "^8.11.5",
+        "playwright": "^1.61.1"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/compressible": {
+      "version": "2.0.18",
+      "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+      "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": ">= 1.43.0 < 2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/compression": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+      "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "compressible": "~2.0.18",
+        "debug": "2.6.9",
+        "negotiator": "~0.6.4",
+        "on-headers": "~1.1.0",
+        "safe-buffer": "5.2.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/compression/node_modules/negotiator": {
+      "version": "0.6.4",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+      "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/on-headers": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+      "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+      "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.61.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+      "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7ec4e4e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "astek-landing",
+  "version": "0.3.0",
+  "private": true,
+  "description": "Editorial vendor landing for Astek (INTERNAL dw_unified data — not on Shopify)",
+  "scripts": {
+    "scrape": "node scripts/scrape-astek.js",
+    "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",
+    "playwright": "^1.61.1"
+  }
+}
diff --git a/public/curate.html b/public/curate.html
new file mode 100644
index 0000000..7bee9ce
--- /dev/null
+++ b/public/curate.html
@@ -0,0 +1,162 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Select · Fabricut → Phillipe Romano</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>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+</head>
+<body>
+<header>
+  <h1>Fabricut → <span class="pl">Phillipe Romano</span> · Select</h1>
+  <span class="pill" id="total">…</span>
+  <span class="muted" id="meta"></span>
+  <div class="ctl"><input type="text" id="q" placeholder="search pattern / sku / color…"></div>
+  <div class="ctl"><select id="book"><option value="">All books</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('fabricut.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('fabricut.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='phillipe-romano-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 books</option>'+(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>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
+</body></html>
diff --git a/public/drill.js b/public/drill.js
new file mode 100644
index 0000000..e63cdd3
--- /dev/null
+++ b/public/drill.js
@@ -0,0 +1,36 @@
+// ── href-to-deeper-data primitives — SINGLE SOURCE OF TRUTH (HARD rule, 2026-07-31) ──
+// Every displayed data point must be an href to its deeper (filtered) data. These are the
+// reusable atoms; a viewer's index.html loads this file, then defines FIELDS, FIL, $, esc,
+// and applyFilters (which these reference at call time via the shared classic-script global
+// scope). Canonical copy: ~/Projects/_shared/web/drill.js → each viewer's public/drill.js.
+// Memory: all-data-points-href-to-deeper-data. Reference viewers: astek-landing, schumacher-internal.
+
+// Build the URL for a given filter set — any count/atom becomes a real, shareable link.
+function qstr(fil, q, pat) {
+  const u = new URLSearchParams();
+  for (const [k] of FIELDS) { if (fil[k]) u.set(k, fil[k]); }
+  if (q) u.set('q', q); if (pat) u.set('pat', pat);
+  const s = u.toString(); return s ? ('?' + s) : location.pathname;
+}
+// Read the URL's filter state back into FIL + the search inputs (deep-link / back-button).
+function readURL() {
+  const u = new URLSearchParams(location.search);
+  for (const [k] of FIELDS) FIL[k] = u.get(k) || '';
+  $('#q').value = u.get('q') || ''; $('#pattern').value = u.get('pat') || '';
+}
+// Reflect the current filter state into the URL (push = new history entry, else replace).
+function writeURL(push) {
+  const url = qstr(FIL, $('#q').value.trim(), $('#pattern').value.trim());
+  history[push ? 'pushState' : 'replaceState']({}, '', url);
+}
+// drill(field,value,label,cls) → an <a> whose href IS the filtered view for that value.
+// cmd/ctrl/middle-click opens it in a new tab; plain click filters in place (delegated listener).
+function drill(field, val, label, cls) {
+  if (val == null || val === '') return '';
+  const href = qstr({ ...FIL, [field]: String(val) }, $('#q').value.trim(), $('#pattern').value.trim());
+  return `<a class="drill ${cls || ''}" href="${href}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label != null ? esc(label) : esc(val)}</a>`;
+}
+// Atom drill target: SET the filter to this value (drill in), vs pickF which TOGGLES.
+function setF(k, v) { if (FIL[k] === String(v)) return; FIL[k] = String(v); applyFilters('push'); }
+
+window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.drill = drill; window.setF = setF;
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5858e0b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,489 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Fabricut · Catalog · 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}
+.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 (sidebar no longer stacks above the grid) */
+#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} /* grid is fixed 2-up on phones — slider is noise */
+ /* top:0!important beats the inline style.top the desktop sticky-layout JS sets */
+ 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 img.roomimg{position:absolute;inset:0;opacity:0;transition:opacity .18s ease}
+.imgwrap:hover img.roomimg{opacity:1}
+.roombadge{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}
+.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}
+.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}
+.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
+/* field visibility toggles (grid view only — list view columns stay intact) */
+body.f-off-vend .grid:not(.list) .vend{display:none}
+body.f-off-ttl .grid:not(.list) .ttl{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)}
+/* pattern thumbnail sitting on the chip row + Japan-origin match chip */
+.chipthumb{width:calc(22px*var(--fs));height:calc(22px*var(--fs));object-fit:cover;border-radius:4px;border:1px solid var(--line);flex:0 0 auto}
+.chip.jp{display:inline-flex;align-items:center;gap:5px;border-color:var(--acc);color:var(--ink);background:color-mix(in srgb,var(--acc) 12%,transparent);text-decoration:none;cursor:pointer}
+.chip.jp:hover{border-color:var(--acc);filter:brightness(1.15)}
+.chip.jp.poss{border-style:dashed;color:var(--mut);background:transparent}
+.chip.jp .jt{width:calc(16px*var(--fs));height:calc(16px*var(--fs));object-fit:cover;border-radius:3px;border:1px solid #0003;flex:0 0 auto}
+.grid.list .chipthumb{width:18px;height:18px}
+.grid.list .chip.jp .jt{width:14px;height:14px}
+/* ── collapsed detail drawer (direct child of .card so list-mode span works) ── */
+.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)}
+/* ── drill atom: every data point is an href to its filtered view (deeper data) ── */
+.drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted transparent}
+.drill:hover{color:var(--acc);border-bottom-color:var(--acc)}
+aside a.frow{text-decoration:none} /* facet rows are real hrefs but keep the row look */
+.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
+#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);background:#1b1e24;color:var(--ink,#e8e6e1);border:1px solid var(--line,#2a2d33);border-radius:8px;padding:10px 16px;font-size:13px;box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:60}
+#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+#toast.ok{border-color:#2a6a4a}#toast.err{border-color:#7a2a2a}
+.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 ── aligned CSS-grid rows (.b uses display:contents so its
+   children become columns of the card grid). Fixed type sizes (1 column —
+   density/font scaling is a grid-view concern). */
+:root{--listcols:42px 105px minmax(130px,1.4fr) minmax(90px,1fr) 92px minmax(105px,1fr) 112px minmax(90px,1fr) 64px 64px 84px 92px}
+.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 .roombadge{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 .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 .lsku,.grid.list .ldw{display:block;font-family:ui-monospace,Menlo,monospace;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}
+.lsku,.ldw,.lc{display:none}
+/* sortable column header — same template as rows, sticky under the main 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 ── */
+.fabricut-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}
+.fabricut-refresh-pill .rp-dot{width:8px;height:8px;border-radius:50%;background:var(--pub)}
+.fabricut-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>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+</head>
+<body>
+<header>
+  <h1>Fabricut · Catalog</h1>
+  <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="style">Style</option>
+      <option value="pattern">Pattern A→Z</option>
+      <option value="sku">SKU A→Z</option>
+      <option value="title">Title A→Z</option>
+      <option value="book">Book</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 the Phillipe Romano private label">✓ Select for PR</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 / sku / color…">
+  <input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the catalog">
+  <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>Book · series</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="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 src="/drill.js"></script>
+<script>
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], FACETS=null, VIEW=[], shown=0;
+const PAGE=120;
+const BUCKET_HEX={white:'#f4f4f2',grey:'#9aa0aa',black:'#1c1e24',pink:'#f3a8c0',red:'#d0453e',orange:'#e08a3c',
+ brown:'#8a6242',gold:'#c9a75a',green:'#5f8f5c',blue:'#4f7fb5',purple:'#8a6fb5'};
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+// href-to-deeper-data primitives (qstr / readURL / writeURL / drill / setF) load from /drill.js
+// ── every data field is a left-panel table: collapsed on load, click value = filter ──
+const FIELDS=[
+ ['book','Books'],['style','Styles'],['color_bucket','Colors'],['series','Patterns'],['color','Colorways'],
+ ['material','Material'],['width','Width'],['length','Length'],['repeat','Repeat'],['match','Match'],
+];
+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)
+// persisted prefs (CLAUDE.md: sort + density persist in localStorage)
+$('#sort').value=localStorage.getItem('fabricut.sort')||'newest';
+// ── density also drives type scale: fewer/larger cards → bigger type, 20/row → smallest ──
+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('fabricut.density')||'6';
+$('#density').value=dens; setDensity(dens);
+$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('fabricut.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 FVIS=JSON.parse(localStorage.getItem('fabricut.fieldvis')||'{"vend":true,"ttl":true,"chips":true}');
+function applyFieldVis(){
+  for(const k of ['vend','ttl','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
+  document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.chips);
+  document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
+  localStorage.setItem('fabricut.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 — nesting anchors splits the DOM (adoption
+// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
+function card(p){
+  const title=esc(p.display_name||p.series||p.sku||'(untitled)')+(p.color&&p.color!==p.display_name?(', '+esc(p.color)):'');
+  const hasRoom=p.room&&p.room!==p.swatch;
+  const img=p.swatch
+    ?`<div class="imgwrap"><img loading="lazy" src="${esc(p.swatch)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')">`+
+      (hasRoom?`<img loading="lazy" class="roomimg" src="${esc(p.room)}" alt=""><span class="roombadge">ROOM</span>`:'')+`</div>`
+    :`<div class="imgwrap noimg"></div>`;
+  const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+  // kvd = a kv row whose value drills to its filtered view (facet fields only)
+  const kvd=(k,field,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${drill(field,v,v)}</span></div>`:'';
+  const dot=BUCKET_HEX[p.color_bucket]||'#666';
+  return `<div class="card" data-href="/product/${encodeURIComponent(p.handle)}" data-handle="${esc(p.handle)}" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.sku||'')}" data-title="${esc(p.display_name||p.series||p.dw_sku||'')}">
+    ${img}<div class="b">
+    <span class="vend">${drill('book',p.book||'Fabricut',p.book||'Fabricut')}${p.series&&p.series!==p.display_name?(' · '+drill('series',p.series,p.series)):''}</span>
+    <div class="ttl">${title}</div>
+    <span class="lc" title="Color">${drill('color',p.color,p.color)}</span>
+    <div class="chips">
+      ${p.swatch?`<img class="chipthumb" loading="lazy" src="${esc(p.swatch)}" alt="" title="${esc(p.series||p.display_name||'')}" onerror="this.style.display='none'">`:''}
+      ${p.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${drill('color_bucket',p.color_bucket,p.color_bucket)}</span>`:''}
+      ${p.japan_sku?`<a class="chip jp ${p.japan_conf==='possible'?'poss':'hi'}" href="${esc(p.japan_img_url||('https://japan.designerwallcoverings.com/?q='+encodeURIComponent(p.japan_sku)))}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="Open the exact ${esc(p.japan_vendor)} ${esc(p.japan_sku)} image on japan.designerwallcoverings.com (visual match — ${esc(p.japan_conf)})">${p.japan_thumb?`<img class="jt" loading="lazy" src="${esc(p.japan_thumb)}" alt="" onerror="this.style.display='none'">`:''}≈ ${esc(p.japan_vendor)} ${esc(p.japan_sku)} ↗</a>`:''}
+      <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
+    </div>
+    <span class="lsku" title="Vendor SKU">${esc(p.sku||'')}</span>
+    <span class="ldw" title="DW SKU">${esc(p.dw_sku||'')}</span>
+    <span class="lc" title="Material">${drill('material',p.material,p.material)}</span>
+    <span class="lc" title="Width">${drill('width',p.width,p.width)}</span>
+    <span class="lc" title="Length">${drill('length',p.length,p.length)}</span>
+    <span class="lc" title="Repeat">${drill('repeat',p.repeat,p.repeat)}</span>
+    <span class="lc" title="Match">${drill('match',p.match,p.match)}</span>
+    </div>
+    <div class="info" onclick="event.stopPropagation()">
+      ${kv('DW SKU',p.dw_sku)}${kv('Vendor SKU',p.sku)}${kvd('Series','series',p.series)}${kvd('Book','book',p.book)}
+      ${p.japan_sku?`<div class="kv"><span class="k">Japan origin</span><span class="v" title="visual image match — ${esc(p.japan_conf)}">${esc(p.japan_vendor)} ${esc(p.japan_sku)} <em style="opacity:.55;font-style:normal">(${esc(p.japan_conf)})</em></span></div>`:''}
+      ${kvd('Material','material',p.material)}${kvd('Width','width',p.width)}${kvd('Repeat','repeat',p.repeat)}${kvd('Match','match',p.match)}
+      <div class="acts">
+        <button class="act reqact" data-act="memo" title="Log a memo-sample request">Memo</button>
+        <button class="act reqact" data-act="stock" title="Email the vendor a stock check">Stock</button>
+        <button class="act reqact" data-act="price" title="Email the vendor for current price">Price</button>
+        ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
+        ${p.dw_sku?`<button class="act" data-copy="${esc(p.dw_sku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
+        <a class="lnk" href="/product/${encodeURIComponent(p.handle)}">open ↗</a>
+      </div>
+    </div>
+  </div>`;
+}
+// delegated card navigation — anything not interactive opens the PDP.
+// (.drill atoms are <a>, so this handler's `a` skip lets them fall through to the drill handler.)
+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;
+});
+// atom drill — plain click filters the grid to that value; cmd/ctrl/shift/middle-click
+// keeps the real href (opens the filtered view in a new tab). Every datum → deeper data.
+grid.addEventListener('click',e=>{
+  const d=e.target.closest('a.drill'); if(!d)return;
+  if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1)return;
+  e.preventDefault(); e.stopPropagation();
+  setF(d.dataset.filter,d.dataset.v);
+});
+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;
+// Purchasing actions on each chip: Memo / Stock / Price → /api/request
+grid.addEventListener('click',async e=>{
+  const btn=e.target.closest('.reqact'); if(!btn)return;
+  e.stopPropagation();
+  const card=btn.closest('.card'), type=btn.dataset.act, old=btn.textContent;
+  btn.disabled=true; btn.textContent='…';
+  try{
+    const r=await fetch(location.origin+'/api/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type,dw_sku:card.dataset.sku,mfr_sku:card.dataset.mfr,title:card.dataset.title})}).then(r=>r.json());
+    toast(r.ok?(r.message||('Logged '+(r.req_no||''))):(r.error||'request failed'),!!r.ok);
+  }catch(err){ toast('request failed: '+err.message,false); }
+  btn.disabled=false; btn.textContent=old;
+});
+let toastT=null;
+function toast(msg,ok){
+  let t=document.getElementById('toast');
+  if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
+  t.textContent=msg; t.className='show '+(ok?'ok':'err');
+  clearTimeout(toastT); toastT=setTimeout(()=>t.className='',6000);
+}
+function applyFilters(nav){
+  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.sku,p.dw_sku,p.color,p.book,p.japan_sku,p.japan_vendor].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
+    return true;
+  });
+  const s=$('#sort').value, cmp={
+    newest:null,
+    color:(a,b)=>(a.hue??999)-(b.hue??999)||(a.val??0)-(b.val??0),
+    style:(a,b)=>{const av=a.style||'￿',bv=b.style||'￿';return av.localeCompare(bv)||String(a.series||'').localeCompare(String(b.series||''));},
+    pattern:(a,b)=>String(a.series||a.display_name||'').localeCompare(String(b.series||b.display_name||'')),
+    sku:(a,b)=>String(a.sku||a.handle).localeCompare(String(b.sku||b.handle)),
+    title:(a,b)=>String(a.display_name||'').localeCompare(String(b.display_name||'')),
+    book:(a,b)=>String(a.book||'').localeCompare(String(b.book||''))||String(a.series||'').localeCompare(String(b.series||'')),
+  }[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;   // empties last either way
+      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()+' designs';
+  $('#empty').style.display=VIEW.length?'none':'block';
+  renderSide();
+  if(nav!=='none')writeURL(nav==='push');   // reflect filter state into the URL (shareable/deep-linkable)
+}
+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;}
+    FCOUNTS[k]=Object.entries(m).sort((a,b)=>b[1]-a[1]);
+  }
+  // Pattern type-ahead over EVERY distinct pattern
+  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>`:'';
+      // the count IS an href: its target is the grid filtered to this value (deeper data)
+      const href=qstr({...FIL,[k]:(FIL[k]===String(v)?'':String(v))},$('#q').value.trim(),$('#pattern').value.trim());
+      return `<a class="frow${FIL[k]===String(v)?' active':''}" href="${href}" data-f="${k}" data-v="${esc(v)}">
+        <span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></a>`;
+    }).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('push');}   // facet row: toggle (setF is in /drill.js)
+window.pickF=pickF;
+// facet counts are real <a> hrefs — plain click filters in place, modified click opens the
+// filtered view in a new tab. Delegated on the container so it survives renderSide() rebuilds.
+$('#facetbox').addEventListener('click',e=>{
+  const a=e.target.closest('a.frow'); if(!a)return;
+  if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1)return;
+  e.preventDefault(); pickF(a.dataset.f,a.dataset.v);
+});
+$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters('push');};
+$('#sort').onchange=()=>{localStorage.setItem('fabricut.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,''],['book','Book'],['display_name','Pattern'],['color','Color'],['color_bucket','Bucket'],
+ ['sku','SKU'],['dw_sku','DW SKU'],['material','Material'],['width','Width'],['length','Length'],['repeat','Repeat'],['match','Match']];
+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('fabricut.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('fabricut.view',localStorage.getItem('fabricut.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(); readURL(); applyFilters();   // land in the filter state carried by the URL
+}
+loadAll();
+// back/forward re-applies the URL's filter state without re-pushing history
+addEventListener('popstate',()=>{readURL();applyFilters('none');});
+
+/* ── Corner refresh countdown ──────────────────────────────────────────────
+   Data is rebuilt from dw_unified every 15 min by a cron. This pill counts down
+   to the next refresh (synced to the real data-file timestamp via /api/meta) and
+   pulls fresh data in-place when it lands — no manual reload. */
+(function(){
+  var meta={lastRefresh:null,intervalSec:900}, target=0, waiting=false;
+  var pill=document.createElement('div');
+  pill.className='fabricut-refresh-pill';
+  pill.title='Fabricut catalog data auto-refreshes from Designer Wallcoverings every 15 minutes';
+  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; // clamp stale files to a fresh cycle
+      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>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
+</body></html>
diff --git a/public/nav-agent/nav-agent.css b/public/nav-agent/nav-agent.css
new file mode 100644
index 0000000..d43a0d1
--- /dev/null
+++ b/public/nav-agent/nav-agent.css
@@ -0,0 +1,133 @@
+/* ============================================================================
+ * Nav Agent — universal grid-controls drop-in  (v1.0)
+ * Left field-toggle panel + sort-every-field + grouping + movable/removable/
+ * expandable data panels.  Zero dependencies. Additive & defensive.
+ * Honors the house internal-line-viewer spec (left panel, density type-scale,
+ * tight cards, localStorage-persisted).
+ * ==========================================================================*/
+
+:root{
+  --na-acc:var(--acc,#1f9e8f);
+  --na-line:var(--line,#2a2f3a);
+  --na-bg:var(--panel,#12151c);
+  --na-bg2:var(--bg,#0c0e13);
+  --na-fg:var(--fg,#e7e9ee);
+  --na-mut:var(--mut,#8b93a3);
+}
+
+/* ---- launcher (bottom-left cog) --------------------------------------- */
+.na-launch{position:fixed;left:14px;bottom:14px;z-index:2147483000;
+  width:42px;height:42px;border-radius:50%;border:1px solid var(--na-line);
+  background:var(--na-bg);color:var(--na-fg);font-size:19px;cursor:pointer;
+  display:flex;align-items:center;justify-content:center;
+  box-shadow:0 4px 18px rgba(0,0,0,.45);transition:transform .15s}
+.na-launch:hover{transform:scale(1.08);border-color:var(--na-acc)}
+
+/* ---- left field/sort/group panel -------------------------------------- */
+.na-panel{position:fixed;left:0;top:0;bottom:0;width:290px;z-index:2147483001;
+  background:var(--na-bg);border-right:1px solid var(--na-line);color:var(--na-fg);
+  display:flex;flex-direction:column;font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
+  transform:translateX(-100%);transition:transform .2s ease;box-shadow:2px 0 24px rgba(0,0,0,.4)}
+.na-panel.open{transform:translateX(0)}
+.na-panel header{display:flex;align-items:center;gap:8px;padding:12px 14px;
+  border-bottom:1px solid var(--na-line);font-weight:600;letter-spacing:.3px}
+.na-panel header .na-title{flex:1}
+.na-panel header button{background:none;border:0;color:var(--na-mut);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
+.na-panel header button:hover{color:var(--na-fg);background:rgba(255,255,255,.06)}
+.na-body{flex:1;overflow:auto;padding:8px 12px 40px}
+.na-sec{border-top:1px solid var(--na-line);padding:10px 0}
+.na-sec:first-child{border-top:0}
+.na-sec>.na-h{display:flex;align-items:center;gap:6px;cursor:pointer;
+  font-weight:600;color:var(--na-mut);text-transform:uppercase;font-size:11px;letter-spacing:.6px;user-select:none}
+.na-sec>.na-h::before{content:'▸';font-size:9px;transition:transform .15s}
+.na-sec.open>.na-h::before{transform:rotate(90deg)}
+.na-sec .na-c{display:none;padding:8px 2px 2px}
+.na-sec.open .na-c{display:block}
+.na-search{width:100%;box-sizing:border-box;background:var(--na-bg2);border:1px solid var(--na-line);
+  color:var(--na-fg);border-radius:6px;padding:8px 10px;margin:4px 0 10px;font-size:13px}
+.na-fieldrow{display:flex;align-items:center;gap:8px;padding:4px 2px;cursor:pointer;border-radius:4px}
+.na-fieldrow:hover{background:rgba(255,255,255,.04)}
+.na-fieldrow input{accent-color:var(--na-acc);cursor:pointer}
+.na-fieldrow .na-cnt{margin-left:auto;color:var(--na-mut);font-size:11px}
+.na-row{display:flex;gap:6px;align-items:center;margin:6px 0}
+.na-row label{color:var(--na-mut);min-width:52px}
+.na-row select,.na-row input[type=range]{flex:1;background:var(--na-bg2);border:1px solid var(--na-line);
+  color:var(--na-fg);border-radius:6px;padding:6px 8px;font-size:13px}
+.na-mini{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
+.na-mini button{background:var(--na-bg2);border:1px solid var(--na-line);color:var(--na-fg);
+  border-radius:6px;padding:5px 9px;font-size:12px;cursor:pointer}
+.na-mini button:hover,.na-mini button.on{border-color:var(--na-acc);color:var(--na-acc)}
+.na-reset{width:100%;margin-top:14px;background:var(--na-bg2);border:1px solid var(--na-line);
+  color:var(--na-fg);border-radius:6px;padding:9px;cursor:pointer;font-size:13px}
+.na-reset:hover{border-color:#c0563f;color:#e08a76}
+.na-note{color:var(--na-mut);font-size:11px;padding:2px;line-height:1.5}
+
+/* ---- grouping headers injected into the grid -------------------------- */
+.na-group-h{grid-column:1/-1;display:flex;align-items:center;gap:8px;
+  padding:8px 4px;margin:6px 0 2px;border-bottom:1px solid var(--na-line);
+  color:var(--na-acc);font-weight:600;font-size:13px;cursor:pointer;user-select:none}
+.na-group-h .na-gcount{color:var(--na-mut);font-weight:400}
+.na-group-h.collapsed + .na-group-body{display:none}
+table .na-group-h{display:table-row}
+table .na-group-h td{padding:8px 6px;color:var(--na-acc);font-weight:600;border-bottom:1px solid var(--na-line)}
+
+/* ---- movable / resizable / removable data-panel chrome ---------------- */
+.na-wrap{position:relative}
+.na-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;cursor:move;
+  background:var(--na-bg);border:1px solid var(--na-line);border-bottom:0;
+  border-radius:6px 6px 0 0;color:var(--na-mut);font-size:12px;user-select:none}
+.na-bar .na-bt{flex:1;font-weight:600;color:var(--na-fg)}
+.na-bar button{background:none;border:0;color:var(--na-mut);cursor:pointer;font-size:14px;padding:1px 5px;border-radius:4px}
+.na-bar button:hover{color:var(--na-fg);background:rgba(255,255,255,.08)}
+.na-wrap.na-float{position:fixed;z-index:2000;box-shadow:0 10px 40px rgba(0,0,0,.55);
+  background:var(--na-bg2);border-radius:6px;resize:both;overflow:auto;min-width:280px;min-height:160px}
+.na-wrap.na-collapsed .na-inner{display:none}
+.na-wrap.na-removed{display:none}
+.na-wrap.na-max{position:fixed;inset:12px;z-index:2001;background:var(--na-bg2);
+  box-shadow:0 10px 60px rgba(0,0,0,.6);overflow:auto;resize:none}
+.na-restorebar{position:fixed;left:14px;bottom:66px;z-index:2147483000;display:flex;flex-direction:column;gap:6px}
+.na-restorebar button{background:var(--na-bg);border:1px solid var(--na-line);color:var(--na-fg);
+  border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer}
+.na-restorebar button:hover{border-color:var(--na-acc)}
+
+/* ---- merged mode: folded INTO an existing house panel ----------------- */
+.na-merge{border-top:1px dashed var(--na-line);margin-top:12px;padding-top:10px}
+.na-merge .na-mh{display:flex;align-items:center;gap:6px;font-weight:600;color:var(--na-acc);
+  text-transform:uppercase;font-size:11px;letter-spacing:.6px;margin-bottom:8px}
+.na-merge .na-row label{color:var(--na-mut);min-width:48px}
+.na-merge select{background:var(--na-bg2);border:1px solid var(--na-line);color:var(--na-fg);border-radius:6px;padding:6px 8px;font-size:13px;flex:1}
+
+/* ---- Grid / List view toggle ----------------------------------------- */
+.na-viewseg{display:inline-flex;margin:4px 0 10px;border:1px solid var(--na-line);border-radius:6px;overflow:hidden}
+.na-viewseg button{background:var(--na-bg2);color:var(--na-mut);border:0;border-right:1px solid var(--na-line);
+  padding:6px 12px;font-size:12px;cursor:pointer}
+.na-viewseg button:last-child{border-right:0}
+.na-viewseg button.on{background:var(--na-acc);color:#fff}
+
+/* ---- compact LIST view (the kv-list "third view") --------------------- */
+.na-list{width:100%}
+.na-lrows{display:flex;flex-direction:column}
+.na-lrow{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;padding:10px 14px;
+  border-bottom:1px solid var(--na-line)}
+.na-lrow:hover{background:rgba(255,255,255,.04)}
+.na-lmain{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;min-width:210px;flex:0 0 auto}
+.na-la{font-weight:600;color:var(--na-fg)}
+/* data spans the FULL row width in tight columns (left edge → right edge),
+   not left-packed chips — matches the CRCP "expanded to left and right" layout */
+.na-lmeta{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:6px 16px;flex:1 1 320px;min-width:0;align-content:start}
+.na-lm{font-size:12.5px;color:var(--na-fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.na-lmk{color:var(--na-mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
+.na-lempty{padding:40px;text-align:center;color:var(--na-mut)}
+
+/* host left-sidebar collapse (v1.3) — always collapsed on load, ☰ reveals */
+.na-side{transition:transform .18s ease}
+.na-side.na-side-collapsed{transform:translateX(-100%)!important;pointer-events:none}
+.na-side-main{transition:margin-left .18s ease}
+.na-side-main.na-side-shift{margin-left:0!important}
+.na-side-toggle{position:fixed;left:10px;top:10px;z-index:2147483002;width:38px;height:34px;
+  display:flex;align-items:center;justify-content:center;border-radius:8px;
+  border:1px solid var(--na-line);background:var(--na-bg2);color:var(--na-fg);
+  font-size:16px;line-height:1;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.28)}
+.na-side-toggle:hover,.na-side-toggle.on{border-color:var(--na-acc);color:var(--na-acc)}
+
+@media (max-width:640px){.na-panel{width:86vw}.na-lmain{min-width:100%}}
diff --git a/public/nav-agent/nav-agent.js b/public/nav-agent/nav-agent.js
new file mode 100644
index 0000000..f955268
--- /dev/null
+++ b/public/nav-agent/nav-agent.js
@@ -0,0 +1,620 @@
+/* ============================================================================
+ * Nav Agent — universal grid-controls drop-in  (v1.2)
+ *
+ * Drop one <script src=".../nav-agent.js" defer></script> (+ the CSS) onto any
+ * page. It auto-detects the primary data grid (a <table> or a .grid/.card list),
+ * then injects:
+ *   • a LEFT collapsible panel: text search, per-FIELD show/hide toggles,
+ *     sort-by-any-field (asc/desc), group-by-any-field, density slider
+ *   • a Grid / List view toggle — List is the compact "kv-list" third view:
+ *     one scannable row per record, first visible field as the identity + the
+ *     rest as inline LABEL·value chips (default OFF; the native grid is default)
+ *   • movable / resizable / collapsible / removable / maximizable panel chrome
+ *     on the grid itself
+ * All state persists to localStorage, namespaced per page path, so every
+ * surface remembers its own layout. Strictly additive & wrapped in try/catch —
+ * if it can't understand a page it degrades (panel chrome + search + density)
+ * and never throws into the host page.
+ *
+ * Manual control:  window.NavAgent.mount({ target:'#grid', title:'Deals' })
+ * Opt-out a page:  <body data-nav-agent="off">
+ * Force a target:  <div id="grid" data-nav-grid> ... </div>
+ * ==========================================================================*/
+(function () {
+  'use strict';
+  if (window.NavAgent) return;
+
+  var _uid = 0;
+  var navKey = function (target) {
+    if (!target.getAttribute('data-na-uid')) target.setAttribute('data-na-uid', target.id || ('g' + (++_uid)));
+    return 'navagent:' + location.pathname + ':' + target.getAttribute('data-na-uid');
+  };
+  var load = function (key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (e) { return {}; } };
+  var save = function (key, s) { try { localStorage.setItem(key, JSON.stringify(s)); } catch (e) {} };
+  var slug = function (t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'f'; };
+  var txt = function (el) { return (el && el.textContent || '').trim(); };
+  // magnitude-aware numeric parse — $2.1B > $780M > 42.  Handles K/M/B/T + commas.
+  var num = function (v) {
+    var s = String(v).replace(/,/g, '').trim();
+    var m = s.match(/-?\d*\.?\d+/); if (!m) return null;
+    var n = parseFloat(m[0]); if (isNaN(n)) return null;
+    var suf = (s.slice(m.index + m[0].length).match(/[kmbt]/i) || [''])[0].toLowerCase();
+    var mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[suf] || 1;
+    return n * mult;
+  };
+  var el = function (tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
+
+  /* -- target detection -------------------------------------------------- */
+  function findTarget(opt) {
+    if (opt && opt.target) return document.querySelector(opt.target);
+    var q = document.querySelector('[data-nav-grid]');
+    if (q) return q;
+    // biggest visible table wins, else #grid, else first .grid, else .cards
+    var tables = [].slice.call(document.querySelectorAll('table'))
+      .filter(function (t) { return t.querySelector('tbody tr') && t.offsetParent !== null; })
+      .sort(function (a, b) { return b.querySelectorAll('tr').length - a.querySelectorAll('tr').length; });
+    if (tables[0] && tables[0].querySelectorAll('tr').length > 2) return tables[0];
+    return document.querySelector('#grid') || document.querySelector('.grid') ||
+           document.querySelector('.cards') || tables[0] || null;
+  }
+
+  /* -- field model ------------------------------------------------------- */
+  // Returns { kind:'table'|'cards', fields:[{key,label,idx?,cls?}], items() }
+  function model(target) {
+    if (target.tagName === 'TABLE') {
+      var ths = [].slice.call(target.querySelectorAll('thead th, thead td'));
+      if (!ths.length) { // header-less table: synthesize from first row
+        var r0 = target.querySelector('tbody tr, tr');
+        ths = r0 ? [].slice.call(r0.children).map(function (_, i) { var s = el('th'); s.textContent = 'Col ' + (i + 1); return s; }) : [];
+      }
+      var fields = ths.map(function (th, i) { return { key: slug(txt(th)) + '-' + i, label: txt(th) || ('Col ' + (i + 1)), idx: i }; });
+      return {
+        kind: 'table', fields: fields,
+        rows: function () { return [].slice.call(target.querySelectorAll('tbody tr')).filter(function (r) { return !r.classList.contains('na-group-h'); }); },
+        cellText: function (row, f) { var c = row.children[f.idx]; return txt(c); }
+      };
+    }
+    // card grid — infer fields from repeated child element classes / [data-field]
+    var cards = [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body'); });
+    var sample = cards.slice(0, 24), seen = {}, order = [];
+    sample.forEach(function (card) {
+      var dfCells = [].slice.call(card.querySelectorAll('[data-field]'));
+      dfCells.forEach(function (n) {
+        var k = n.getAttribute('data-field'); if (!seen[k]) { seen[k] = { key: slug(k), label: k, cls: '[data-field="' + k + '"]', df: k, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
+      });
+      // coarse child-class fields ONLY when this card tags no [data-field] cells —
+      // otherwise a cell with both a class and a data-field yields a duplicate field.
+      if (dfCells.length) return;
+      [].slice.call(card.children).forEach(function (n) {
+        var cls = (n.className && typeof n.className === 'string') ? n.className.split(/\s+/)[0] : '';
+        if (!cls || cls.indexOf('na-') === 0) return;
+        var k = 'c-' + cls;
+        if (!seen[k]) { seen[k] = { key: slug(cls), label: cls.replace(/[-_]/g, ' '), cls: '.' + cls, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
+      });
+    });
+    // keep fields present on a majority of sampled cards (stable structure)
+    var min = Math.max(1, Math.floor(sample.length * 0.4));
+    var fields = order.filter(function (f) { return f.hits >= min; });
+    return {
+      kind: 'cards', cards: true, fields: fields,
+      rows: function () { return [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body') && c.offsetParent !== null || (c.nodeType === 1 && !c.classList.contains('na-group-h')); }); },
+      cellText: function (card, f) { var n = f.df ? card.querySelector('[data-field="' + f.df + '"]') : card.querySelector(f.cls); return txt(n); }
+    };
+  }
+
+  /* -- one instance ------------------------------------------------------ */
+  function Agent(target, opt) {
+    var key = navKey(target);
+    var st = load(key);
+    var M = model(target);
+    var self = { target: target, model: M, state: st, key: key };
+    var applyingSort = false;
+
+    /* field show/hide via CONTAINER classes (survives host re-renders) */
+    function fieldStyleTag() {
+      var id = 'na-style-' + key.replace(/[^a-z0-9]/gi, '');
+      var tag = document.getElementById(id) || (function () { var t = el('style'); t.id = id; document.head.appendChild(t); return t; })();
+      var css = '';
+      (st.hidden || []).forEach(function (key) {
+        var f = M.fields.filter(function (x) { return x.key === key; })[0]; if (!f) return;
+        if (M.kind === 'table') {
+          css += 'table[data-na] thead th:nth-child(' + (f.idx + 1) + '),table[data-na] tbody td:nth-child(' + (f.idx + 1) + '){display:none}';
+        } else {
+          css += target.getAttribute('data-na-sel') + ' ' + f.cls + '{display:none}';
+        }
+      });
+      tag.textContent = css;
+    }
+
+    function applySort() {
+      if (!st.sort) return;
+      var f = M.fields.filter(function (x) { return x.key === st.sort; })[0]; if (!f) return;
+      var dir = st.dir === 'desc' ? -1 : 1;
+      var rows = M.rows();
+      if (!rows.length) return;
+      applyingSort = true;
+      // detect numeric
+      var numeric = rows.slice(0, 12).every(function (r) { var v = M.cellText(r, f); return v === '' || num(v) !== null; });
+      rows.sort(function (a, b) {
+        var va = M.cellText(a, f), vb = M.cellText(b, f);
+        if (numeric) { var na = num(va), nb = num(vb); na = na == null ? -Infinity : na; nb = nb == null ? -Infinity : nb; return (na - nb) * dir; }
+        return va.localeCompare(vb, undefined, { numeric: true }) * dir;
+      });
+      var parent = M.kind === 'table' ? target.querySelector('tbody') : target;
+      rows.forEach(function (r) { parent.appendChild(r); });
+      applyGroup(); // grouping depends on sorted order
+      applyingSort = false;
+    }
+
+    function clearGroups() {
+      [].slice.call(target.querySelectorAll('.na-group-h')).forEach(function (n) { n.remove(); });
+    }
+
+    function applyGroup() {
+      clearGroups();
+      if (!st.group) return;
+      var f = M.fields.filter(function (x) { return x.key === st.group; })[0]; if (!f) return;
+      var rows = M.rows(), last = null, parent = M.kind === 'table' ? target.querySelector('tbody') : target;
+      var counts = {};
+      rows.forEach(function (r) { var v = M.cellText(r, f) || '—'; counts[v] = (counts[v] || 0) + 1; });
+      rows.forEach(function (r) {
+        var v = M.cellText(r, f) || '—';
+        if (v !== last) {
+          var h;
+          if (M.kind === 'table') { h = el('tr', 'na-group-h'); var td = el('td'); td.colSpan = M.fields.length; td.innerHTML = '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'; h.appendChild(td); }
+          else { h = el('div', 'na-group-h', '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'); }
+          parent.insertBefore(h, r); last = v;
+        }
+      });
+    }
+
+    function applySearch() {
+      var qraw = (st.q || '').toLowerCase();
+      M.rows().forEach(function (r) {
+        var show = !qraw || r.textContent.toLowerCase().indexOf(qraw) >= 0;
+        r.style.display = show ? '' : 'none';
+      });
+    }
+
+    function applyAll() { fieldStyleTag(); applySort(); applySearch(); if (self.renderList && st.view === 'list') self.renderList(); }
+
+    /* re-apply after host re-renders (debounced, ignores our own mutations).
+       If the FIELD SET changed (async data arrived, filter repopulated), tell
+       mount() to rebuild the panel UI. */
+    function fieldSig(m) { return m.kind + ':' + m.fields.map(function (f) { return f.key; }).join(','); }
+    var sig = fieldSig(M);
+    var t;
+    var mo = new MutationObserver(function () {
+      if (applyingSort) return;
+      clearTimeout(t); t = setTimeout(function () {
+        M = model(target); self.model = M;
+        var ns = fieldSig(M);
+        if (ns !== sig) { sig = ns; if (self.onFields) try { self.onFields(); } catch (e) {} }
+        applyAll();
+      }, 140);
+    });
+    try { mo.observe(M.kind === 'table' ? (target.querySelector('tbody') || target) : target, { childList: true }); } catch (e) {}
+
+    self.applyAll = applyAll;
+    self.applySort = applySort;
+    self.applyGroup = applyGroup;
+    self.applySearch = applySearch;
+    self.save = function () { save(key, st); };
+    return self;
+  }
+
+  function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
+
+  /* -- Grid / List view toggle (shared by standalone + merged panels) ---- */
+  function viewToggle(agent) {
+    var st = agent.state, wrap = el('div', 'na-viewseg');
+    wrap.innerHTML = '<button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button>';
+    function mark() { [].slice.call(wrap.children).forEach(function (b) { b.classList.toggle('on', (st.view || 'grid') === b.getAttribute('data-v')); }); }
+    [].slice.call(wrap.children).forEach(function (b) { b.onclick = function () { agent.setView(b.getAttribute('data-v')); mark(); }; });
+    mark(); return wrap;
+  }
+
+  /* -- panel UI ---------------------------------------------------------- */
+  function buildPanel(agent, opt) {
+    var M = agent.model, st = agent.state;
+    var panel = el('aside', 'na-panel');
+    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'View controls');
+    var title = (opt && opt.title) || document.title || 'View';
+    panel.appendChild(el('header', null,
+      '<span class="na-title">⚙ View · ' + esc(title) + '</span>' +
+      '<button class="na-x" title="Close">✕</button>'));
+    var body = el('div', 'na-body'); panel.appendChild(body);
+
+    // Grid / List view toggle (only meaningful when we have fields to lay out)
+    if (M.fields && M.fields.length) body.appendChild(viewToggle(agent));
+
+    // search
+    var search = el('input', 'na-search'); search.type = 'search'; search.placeholder = 'Search all fields…';
+    search.value = st.q || '';
+    search.addEventListener('input', function () { st.q = search.value; agent.save(); agent.applySearch(); });
+    body.appendChild(search);
+
+    function section(label, openDefault) {
+      var s = el('div', 'na-sec' + (openDefault ? ' open' : ''));
+      s.appendChild(el('div', 'na-h', label));
+      var c = el('div', 'na-c'); s.appendChild(c);
+      s.querySelector('.na-h').addEventListener('click', function () { s.classList.toggle('open'); });
+      body.appendChild(s); return c;
+    }
+
+    var haveFields = M.fields && M.fields.length;
+
+    // FIELDS toggle
+    if (haveFields) {
+      var fc = section('Fields (' + M.fields.length + ')', true);
+      st.hidden = st.hidden || [];
+      M.fields.forEach(function (f) {
+        var row = el('label', 'na-fieldrow');
+        var cb = el('input'); cb.type = 'checkbox'; cb.checked = st.hidden.indexOf(f.key) < 0;
+        cb.addEventListener('change', function () {
+          var i = st.hidden.indexOf(f.key);
+          if (cb.checked && i >= 0) st.hidden.splice(i, 1);
+          else if (!cb.checked && i < 0) st.hidden.push(f.key);
+          agent.save(); agent.applyAll();
+        });
+        row.appendChild(cb);
+        row.appendChild(el('span', null, esc(f.label)));
+        fc.appendChild(row);
+      });
+      var mini = el('div', 'na-mini',
+        '<button data-all>All</button><button data-none>None</button>');
+      mini.querySelector('[data-all]').onclick = function () { st.hidden = []; agent.save(); agent.applyAll(); refreshChecks(); };
+      mini.querySelector('[data-none]').onclick = function () { st.hidden = M.fields.map(function (f) { return f.key; }); agent.save(); agent.applyAll(); refreshChecks(); };
+      fc.appendChild(mini);
+      function refreshChecks() { [].slice.call(fc.querySelectorAll('.na-fieldrow input')).forEach(function (cb, i) { cb.checked = st.hidden.indexOf(M.fields[i].key) < 0; }); }
+    }
+
+    // SORT
+    if (haveFields) {
+      var sc = section('Sort', true);
+      var srow = el('div', 'na-row', '<label>Field</label>');
+      var sel = el('select'); sel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { sel.appendChild(new Option(f.label, f.key)); });
+      sel.value = st.sort || ''; srow.appendChild(sel); sc.appendChild(srow);
+      var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
+      function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
+      sel.addEventListener('change', function () { st.sort = sel.value; agent.save(); agent.applySort(); });
+      dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; markDir(); agent.save(); agent.applySort(); };
+      dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; markDir(); agent.save(); agent.applySort(); };
+      markDir(); sc.appendChild(dir);
+    }
+
+    // GROUP
+    if (haveFields) {
+      var gc = section('Group by', false);
+      var grow = el('div', 'na-row', '<label>Field</label>');
+      var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
+      gsel.value = st.group || ''; grow.appendChild(gsel); gc.appendChild(grow);
+      gsel.addEventListener('change', function () { st.group = gsel.value; agent.save(); agent.applyGroup(); });
+    }
+
+    // DENSITY (only if the target reacts to --cols or has a page density var)
+    var dc = section('Density', false);
+    var drow = el('div', 'na-row', '<label>Cols</label>');
+    var rng = el('input'); rng.type = 'range'; rng.min = 2; rng.max = 12; rng.value = st.cols || getComputedCols(agent.target) || 5;
+    rng.addEventListener('input', function () { st.cols = +rng.value; agent.save(); setCols(agent.target, +rng.value); });
+    drow.appendChild(rng); dc.appendChild(drow);
+    dc.appendChild(el('div', 'na-note', 'Adjusts grid columns where the layout supports it.'));
+    if (st.cols) setCols(agent.target, st.cols);
+
+    if (!haveFields) body.appendChild(el('div', 'na-note',
+      'No structured fields detected on this grid — field toggles, sort & group need <code>&lt;table&gt;</code> headers or <code>[data-field]</code>/classed card elements. Search, density and the movable panel are active.'));
+
+    var reset = el('button', 'na-reset', '↺ Reset this page’s view');
+    reset.onclick = function () { try { localStorage.removeItem(agent.key); } catch (e) {} location.reload(); };
+    body.appendChild(reset);
+
+    panel.querySelector('.na-x').onclick = function () { panel.classList.remove('open'); };
+    document.body.appendChild(panel);
+    return panel;
+  }
+
+  function getComputedCols(t) { try { var v = getComputedStyle(t).getPropertyValue('--cols'); return v ? parseInt(v) : null; } catch (e) { return null; } }
+  function setCols(t, n) {
+    try {
+      t.style.setProperty('--cols', n);
+      // fallback for grids without a --cols hook
+      if (!/grid-template-columns/.test(t.style.cssText) && getComputedStyle(t).display === 'grid')
+        t.style.gridTemplateColumns = 'repeat(' + n + ',minmax(0,1fr))';
+    } catch (e) {}
+  }
+
+  /* -- movable / resizable / removable panel chrome ---------------------- */
+  function chrome(agent, opt) {
+    var t = agent.target;
+    if (t.closest('.na-wrap')) return;
+    var wrap = el('div', 'na-wrap');
+    var title = (opt && opt.title) || document.title || 'Data';
+    var bar = el('div', 'na-bar',
+      '<span class="na-bt">▤ ' + esc(title) + '</span>' +
+      '<button data-collapse title="Collapse">▁</button>' +
+      '<button data-max title="Maximize / float">⤢</button>' +
+      '<button data-remove title="Remove panel">✕</button>');
+    var inner = el('div', 'na-inner');
+    t.parentNode.insertBefore(wrap, t);
+    wrap.appendChild(bar); wrap.appendChild(inner); inner.appendChild(t);
+
+    var g = agent.state.geo || {};
+    bar.querySelector('[data-collapse]').onclick = function () { wrap.classList.toggle('na-collapsed'); persist(); };
+    bar.querySelector('[data-max]').onclick = function () { wrap.classList.toggle('na-max'); wrap.classList.remove('na-float'); persist(); };
+    bar.querySelector('[data-remove]').onclick = function () { wrap.classList.add('na-removed'); addRestore(); persist(); };
+
+    // drag to float/move
+    var down = null;
+    bar.addEventListener('mousedown', function (e) {
+      if (e.target.tagName === 'BUTTON') return;
+      var r = wrap.getBoundingClientRect();
+      if (!wrap.classList.contains('na-float')) { wrap.classList.add('na-float'); wrap.style.left = r.left + 'px'; wrap.style.top = r.top + 'px'; wrap.style.width = r.width + 'px'; }
+      down = { x: e.clientX, y: e.clientY, l: parseFloat(wrap.style.left) || r.left, t: parseFloat(wrap.style.top) || r.top };
+      e.preventDefault();
+    });
+    window.addEventListener('mousemove', function (e) {
+      if (!down) return;
+      wrap.style.left = (down.l + e.clientX - down.x) + 'px';
+      wrap.style.top = Math.max(0, down.t + e.clientY - down.y) + 'px';
+    });
+    window.addEventListener('mouseup', function () { if (down) { down = null; persist(); } });
+
+    function persist() {
+      agent.state.geo = {
+        float: wrap.classList.contains('na-float'), max: wrap.classList.contains('na-max'),
+        collapsed: wrap.classList.contains('na-collapsed'), removed: wrap.classList.contains('na-removed'),
+        left: wrap.style.left, top: wrap.style.top, width: wrap.style.width, height: wrap.style.height
+      };
+      agent.save();
+    }
+    function addRestore() {
+      var rb = document.querySelector('.na-restorebar') || (function () { var b = el('div', 'na-restorebar'); document.body.appendChild(b); return b; })();
+      var btn = el('button', null, '⟲ ' + esc(title));
+      btn.onclick = function () { wrap.classList.remove('na-removed'); btn.remove(); persist(); };
+      rb.appendChild(btn);
+    }
+    // restore saved geometry
+    if (g.float) { wrap.classList.add('na-float'); ['left', 'top', 'width', 'height'].forEach(function (k) { if (g[k]) wrap.style[k] = g[k]; }); }
+    if (g.max) wrap.classList.add('na-max');
+    if (g.collapsed) wrap.classList.add('na-collapsed');
+    // ROOT FIX (Steve-approved 2026-08-17): a "removed" grid must NEVER stay hidden across reloads —
+    // that trap has no recovery and blanked whole pages (CRCP mls.html). Do NOT restore `removed` on
+    // load: the grid always reappears; in-session Remove still works, it just no longer persist-hides.
+    // (Float / max / collapsed still persist — those are safe, recoverable layout choices.)
+  }
+
+  /* -- merged mode: fold into an existing house panel ("2 into 1") ------- */
+  function findHousePanel(target) {
+    var asides = [].slice.call(document.querySelectorAll('aside, .sidebar, .panel-left, [data-house-panel]'));
+    for (var i = 0; i < asides.length; i++) {
+      var a = asides[i];
+      if (a.classList.contains('na-panel')) continue;
+      if (a.contains(target)) continue;            // a real control sidebar won't contain the grid
+      if (a.querySelector('input, select, details, .toggle, .chip')) return a; // has controls
+    }
+    return null;
+  }
+
+  function buildMergeSection(agent, house, opt) {
+    var M = agent.model, st = agent.state;
+    var old = house.querySelector('.na-merge'); if (old) old.remove();
+    var box = el('div', 'na-merge');
+    box.appendChild(el('div', 'na-mh', '⚙ Nav Agent'));
+    if (M.fields && M.fields.length) {
+      // sort ANY field (superset of the house's fixed sort options)
+      var srow = el('div', 'na-row', '<label>Sort</label>');
+      var ssel = el('select'); ssel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { ssel.appendChild(new Option(f.label, f.key)); });
+      ssel.value = st.sort || ''; srow.appendChild(ssel); box.appendChild(srow);
+      var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
+      function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
+      ssel.onchange = function () { st.sort = ssel.value; agent.save(); agent.applySort(); };
+      dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; agent.save(); agent.applySort(); markDir(); };
+      dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; agent.save(); agent.applySort(); markDir(); };
+      markDir(); box.appendChild(dir);
+      // group ANY field
+      var grow = el('div', 'na-row', '<label>Group</label>');
+      var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
+      gsel.value = st.group || ''; gsel.onchange = function () { st.group = gsel.value; agent.save(); agent.applyGroup(); };
+      grow.appendChild(gsel); box.appendChild(grow);
+    }
+    if (M.fields && M.fields.length) box.appendChild(viewToggle(agent));
+    box.appendChild(el('div', 'na-note', 'Drag the grid’s ▤ bar to float · ▁ collapse · ⤢ maximize · ✕ remove.'));
+    house.appendChild(box);
+    return box;
+  }
+
+  /* -- public mount ------------------------------------------------------ */
+  var API = {
+    instances: [],
+    mount: function (opt) {
+      opt = opt || {};
+      try {
+        var target = findTarget(opt);
+        if (!target) return null;
+        if (target.__naMounted) return target.__naAgent;
+        target.__naMounted = true;
+        if (target.tagName === 'TABLE') target.setAttribute('data-na', '1');
+        else target.setAttribute('data-na-sel', target.id ? '#' + target.id : '.' + (target.className.split(/\s+/)[0] || 'grid'));
+        var agent = Agent(target, opt);
+        target.__naAgent = agent;
+
+        // ---- compact LIST view (the kv-list "third view", v1.2) -----------
+        // Renders each live grid row as one scannable line: first visible field
+        // as the identity, the rest as inline LABEL·value chips. DOM-based (reads
+        // M.cellText), so sort/search/field-toggle all flow through for free.
+        // Default OFF — the page's native grid stays the default view.
+        function ensureListEl() {
+          if (agent._listEl) return agent._listEl;
+          var lel = el('div', 'na-list');
+          (target.parentNode || target).insertBefore(lel, target.nextSibling);
+          agent._listEl = lel; return lel;
+        }
+        agent.renderList = function () {
+          var lel = agent._listEl; if (!lel) return;
+          var M2 = agent.model, hidden = agent.state.hidden || [];
+          var vis = (M2.fields || []).filter(function (f) { return hidden.indexOf(f.key) < 0; });
+          if (!vis.length) vis = (M2.fields || []).slice();
+          if (!vis.length) { lel.innerHTML = '<div class="na-lempty">No structured fields detected on this grid.</div>'; return; }
+          var titleF = vis[0], chipF = vis.slice(1);
+          var rows = M2.rows().filter(function (r) { return r.style.display !== 'none'; });
+          if (!rows.length) { lel.innerHTML = '<div class="na-lempty">No matching rows.</div>'; return; }
+          lel.innerHTML = '<div class="na-lrows">' + rows.map(function (r) {
+            var tt = esc(M2.cellText(r, titleF) || '—');
+            var chips = chipF.map(function (f) {
+              var v = M2.cellText(r, f); if (v === '' || v == null) return '';
+              return '<span class="na-lm"><span class="na-lmk">' + esc(f.label) + '</span>' + esc(v) + '</span>';
+            }).join('');
+            return '<div class="na-lrow"><div class="na-lmain"><span class="na-la">' + tt + '</span></div>' +
+                   '<div class="na-lmeta">' + chips + '</div></div>';
+          }).join('') + '</div>';
+        };
+        agent.setView = function (v) {
+          agent.state.view = (v === 'list') ? 'list' : 'grid'; agent.save();
+          if (agent.state.view === 'list') { ensureListEl(); target.style.display = 'none'; agent._listEl.style.display = ''; agent.renderList(); }
+          else { if (agent._listEl) agent._listEl.style.display = 'none'; target.style.display = ''; }
+        };
+        // "2 into 1": if the page already has a house control panel, fold into it
+        var house = opt.merge === false ? null : findHousePanel(target);
+        // (re)build the controls — called again by the observer when fields change
+        function makePanel() {
+          if (house) { agent._merge = buildMergeSection(agent, house, opt); return; }
+          var wasOpen = agent._panel && agent._panel.classList.contains('open');
+          if (agent._panel) agent._panel.remove();
+          agent._panel = buildPanel(agent, opt);
+          if (wasOpen) agent._panel.classList.add('open');
+        }
+        agent.onFields = makePanel;
+        agent.merged = !!house;
+        makePanel();
+        chrome(agent, opt);
+        agent.applyAll();
+        // restore a persisted List-view choice (default is Grid — nothing changes)
+        if (agent.state.view === 'list') agent.setView('list');
+
+        // standalone launcher only when NOT merged into a house panel
+        if (!house && !document.querySelector('.na-launch')) {
+          var b = el('button', 'na-launch', '⚙'); b.title = 'View controls';
+          b.onclick = function () {
+            var anyOpen = document.querySelector('.na-panel.open');
+            [].slice.call(document.querySelectorAll('.na-panel')).forEach(function (p) { p.classList.toggle('open', !anyOpen); });
+          };
+          document.body.appendChild(b);
+        }
+        API.instances.push(agent);
+        return agent;
+      } catch (e) { if (window.console) console.warn('[nav-agent] mount skipped:', e); return null; }
+    }
+  };
+  window.NavAgent = API;
+
+  /* -- host left-sidebar collapse (standard feature, v1.3, 2026-08-20) ----
+   * The Nav Agent collapses the host page's primary LEFT sidebar on load and
+   * adds a ☰ toggle to reveal/hide it. ALWAYS collapsed on load — the reveal is
+   * an in-session choice (deliberately NOT persisted), so every page load starts
+   * clean and collapsed. Runs even on pages with no data grid.
+   *   Opt out:  <body data-nav-sidebar="off">
+   *   Force:    <body data-nav-sidebar="#mySelector">  or  <aside data-nav-sidebar>
+   *   Content:  the shifted main column auto-detects; force with [data-nav-main]. */
+  function naIsSidebar(e) {
+    if (!e || e === document.body) return false;
+    if (e.classList && (e.classList.contains('na-panel') || e.classList.contains('na-list') ||
+        e.classList.contains('na-launch') || e.classList.contains('na-side-toggle'))) return false;
+    var cs; try { cs = getComputedStyle(e); } catch (x) { return false; }
+    if (['fixed', 'sticky', 'absolute'].indexOf(cs.position) < 0) return false;
+    var r = e.getBoundingClientRect();
+    var atLeft = r.left <= 6;
+    var tall = r.height >= Math.min(window.innerHeight * 0.55, 380);
+    var narrow = r.width > 20 && r.width <= 360;
+    var hasNav = e.querySelectorAll('a,button,nav,.nav').length >= 1;
+    return atLeft && tall && narrow && hasNav;
+  }
+  function naFindSidebar() {
+    var body = document.body, a = body.getAttribute('data-nav-sidebar');
+    if (a === 'off') return null;
+    if (a && a !== 'on' && a !== '') { var s = document.querySelector(a); if (s) return s; }
+    var marked = document.querySelector('[data-nav-sidebar]');
+    if (marked && marked !== body) return marked;
+    var sel = ['#side', '#sidebar', '.sidebar', '#side-nav', '.side-nav', '#leftnav', '.leftnav', 'aside'];
+    for (var i = 0; i < sel.length; i++) { var e = document.querySelector(sel[i]); if (naIsSidebar(e)) return e; }
+    var kids = [].slice.call(body.children);
+    for (var j = 0; j < kids.length; j++) { if (naIsSidebar(kids[j])) return kids[j]; }
+    return null;
+  }
+  function naFindMain(side) {
+    var m = document.querySelector('[data-nav-main]'); if (m) return m;
+    var w = side.getBoundingClientRect().width;
+    var pool = [].slice.call(document.body.children).filter(function (c) {
+      return c.nodeType === 1 && c !== side &&
+        !(c.classList && (c.classList.contains('na-panel') || c.classList.contains('na-launch') ||
+          c.classList.contains('na-side-toggle') || c.classList.contains('na-list')));
+    });
+    for (var i = 0; i < pool.length; i++) {
+      var ml = parseFloat(getComputedStyle(pool[i]).marginLeft) || 0;
+      if (ml > 40 && Math.abs(ml - w) <= 28) return pool[i];
+    }
+    return side.nextElementSibling || null;
+  }
+  function naMountSidebar() {
+    try {
+      if (document.body.getAttribute('data-nav-agent') === 'off') return;
+      if (document.querySelector('.na-side-toggle')) return;   // once per page
+      var side = naFindSidebar(); if (!side) return;
+      side.classList.add('na-side');
+      var main = naFindMain(side); if (main) main.classList.add('na-side-main');
+      var open = false;                                        // ALWAYS collapsed on load
+      var tog = el('button', 'na-side-toggle', '☰'); tog.type = 'button';
+      function apply() {
+        side.classList.toggle('na-side-collapsed', !open);
+        if (main) main.classList.toggle('na-side-shift', !open);
+        tog.classList.toggle('on', open);
+        tog.title = open ? 'Collapse sidebar' : 'Expand sidebar';
+        tog.setAttribute('aria-expanded', open ? 'true' : 'false');
+      }
+      tog.onclick = function () { open = !open; apply(); };
+      document.body.appendChild(tog);
+      apply();
+    } catch (e) { if (window.console) console.warn('[nav-agent] sidebar collapse skipped:', e); }
+  }
+  API.collapseSidebar = naMountSidebar;
+
+  function boot() {
+    try {
+      var body = document.body;
+      if (!body || body.getAttribute('data-nav-agent') === 'off') return;
+      naMountSidebar(); // collapse the host left sidebar on load (grid-independent)
+      // wait a tick for API-driven grids to render their first batch
+      var tries = 0;
+      (function attempt() {
+        naMountSidebar(); // idempotent — catches a late-rendered sidebar too
+        var t = findTarget();
+        var populated = false;
+        if (t) {
+          if (t.tagName === 'TABLE') populated = !!t.querySelector('tbody tr');
+          else {
+            var kids = [].slice.call(t.children).filter(function (c) { return c.nodeType === 1; });
+            // ignore an empty-state placeholder (a lone div, often "No … match")
+            populated = !!t.querySelector('.card') || kids.length >= 2;
+          }
+        }
+        if (populated || tries++ > 32) {
+          var _agent = API.mount(); // mount anyway after ~8s; observer fills fields when data arrives
+          // diagnosability (DTD C, 2026-08-13): a null mount is otherwise SILENT — md5/marker/200
+          // all pass while no panel/launcher appears. Emit ONE passive console.info (never warn/error,
+          // never throw, never alter flow) so a future silent-null-mount is greppable, not invisible.
+          // Only nudge when there is GENUINELY no target. mount() also returns null when it
+          // THREW mid-mount on a real grid (its own console.warn already fired) — re-check
+          // findTarget() so we never emit a false "no grid" on a page that actually has one.
+          if (!_agent) { try { if (findTarget() === null && window.console && console.info) console.info('[nav-agent] no grid target found on this marker-bearing page — add data-nav-grid to the real grid container, or data-nav-agent="off" to opt out (see _shared/nav-agent/README.md).'); } catch (e) {} }
+          return;
+        }
+        setTimeout(attempt, 250);
+      })();
+    } catch (e) {}
+  }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();
diff --git a/public/product.html b/public/product.html
new file mode 100644
index 0000000..a5b7588
--- /dev/null
+++ b/public/product.html
@@ -0,0 +1,242 @@
+<!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">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+</head>
+<body data-nav-agent="off"><!-- nav-agent off (TK-10525): product DETAIL page (single product + 6-item pairs strip), no browsable grid to control -->
+
+<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 Fabricut 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>`:'';}
+// HARD RULE: a PDP spec value drills BACK to the catalog filtered by it (chain all the way down)
+function specDrill(k,field,v){return v?`<div class="row"><span class="k">${k}</span><span class="v"><a class="drill" href="/?${field}=${encodeURIComponent(v)}" title="See all with ${esc(k)} = ${esc(v)}">${esc(v)}</a></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 || 'Fabricut';
+  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)}
+          ${specDrill('Pattern','series',p.series)}
+          ${specDrill('Colorway','color',p.color)}
+          ${specDrill('Width','width',p.width)}
+          ${specDrill('Length','length',p.length)}
+          ${specDrill('Repeat','repeat',p.repeat)}
+          ${specDrill('Material','material',p.material)}
+          ${specDrill('Match','match',p.match)}
+          ${specDrill('Format','book',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 (Fabricut 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>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..6abbe3d
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,289 @@
+/* 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}
+/* drill atom: a spec value is an href back to the catalog filtered by it (deeper data) */
+.spec .row .v a.drill{color:inherit;text-decoration:none;border-bottom:1px dotted var(--line);transition:color .12s,border-color .12s}
+.spec .row .v a.drill:hover{color:var(--accent,#5eead4);border-bottom-color:currentColor}
+.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-fabricut.js b/scripts/build-fabricut.js
new file mode 100644
index 0000000..b22d5d2
--- /dev/null
+++ b/scripts/build-fabricut.js
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+/**
+ * Fabricut microsite data — build data/products.json from dw_unified.fabricut_catalog.
+ * MODE=internal (default) → full data incl. cost/trade + vendor account (basic-auth site).
+ * MODE=public            → customer-facing: NO cost, NO trade, NO account#; per-yard retail only.
+ * Self-contained: emits its OWN internal PDP handles + a memo-sample/inquiry CTA. $0 local PG read.
+ */
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+const MODE = (process.env.MODE || 'internal').toLowerCase();
+const PUBLIC = MODE === 'public';
+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 {}
+  return process.env.PGPASSWORD || '';
+})();
+// Prefer the /tmp socket (fast local mirror); fall back to TCP.
+const pool = new Pool(fs.existsSync('/tmp/.s.PGSQL.5432')
+  ? { host: '/tmp', database: 'dw_unified' }
+  : { host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW });
+
+const clean = s => (s == null ? s : String(s)
+  .replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering'));
+const cap = s => String(s || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();
+const jparse = v => { if (v == null) return null; try { return typeof v === 'string' ? JSON.parse(v) : v; } catch { return null; } };
+const slug = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+
+const BUCKET_ORDER = ['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
+// hex → HSV + nearest color bucket (real per-SKU color sort, better than tag buckets)
+function hexHSV(hex) {
+  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim()); if (!m) return null;
+  const n = parseInt(m[1], 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  let h = 0; if (d) { if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h *= 60; if (h < 0) h += 360; }
+  return { h, s: mx ? d / mx : 0, v: mx };
+}
+function bucketFromHSV(hsv) {
+  if (!hsv) return null; const { h, s, v } = hsv;
+  if (v < 0.15) return 'black'; if (s < 0.12) return v > 0.75 ? 'white' : 'grey';
+  if (h < 15 || h >= 345) return 'red'; if (h < 40) return v < 0.55 ? 'brown' : 'orange';
+  if (h < 65) return 'gold'; if (h < 160) return 'green'; if (h < 195) return 'teal';
+  if (h < 255) return 'blue'; if (h < 290) return 'purple'; return 'pink';
+}
+
+async function main() {
+  const { rows } = await pool.query(`
+    SELECT dw_sku, mfr_sku, pattern_name, color_name, product_type, collection, book_name,
+           width, length, repeat_v, material, match_type, finish, backing, fire_codes, scale,
+           country_origin, roll_yards, min_order_yards, msrp_unit, vendor_msrp,
+           price_retail, price_trade, ai_description, ai_styles, ai_patterns, ai_colors,
+           dominant_color_hex, color_hex, ai_background_color, all_images, image_url,
+           coordinating_patterns, created_at
+    FROM fabricut_catalog
+    WHERE full_scraped = true AND product_url ~ 'fabricut.com'
+      AND NULLIF(pattern_name,'') IS NOT NULL
+    ORDER BY pattern_name, color_name, dw_sku
+  `);
+
+  // vendor ops meta (internal PDP: Call Vendor, Our Account #, pricing basis)
+  let vendorMeta = { name: 'Fabricut' };
+  if (!PUBLIC) {
+    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 = 'fabricut'`).catch(() => ({ rows: [] }))).rows[0] || {};
+    const fm = (await pool.query(
+      `SELECT phone, email_1, account_num FROM fmpro WHERE vendor_name = 'Fabricut'
+         AND account_num IS NOT NULL LIMIT 1`).catch(() => ({ rows: [] }))).rows[0] || {};
+    vendorMeta = {
+      name: vr.vendor_name || 'Fabricut', 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 || 'Yard', pricing_model: vr.pricing_model || null,
+      pricing_notes: vr.pricing_notes || null,
+      sample_price: vr.sample_price != null ? Number(vr.sample_price) : 4.25,
+    };
+  }
+
+  const products = [];
+  let dropped = 0;
+  for (const r of rows) {
+    const imgs = String(r.all_images || r.image_url || '').split(',').map(s => s.trim()).filter(Boolean);
+    const primary = imgs[0] || r.image_url || null;
+    if (!primary) { dropped++; continue; }
+    const styles = (jparse(r.ai_styles) || []).filter(Boolean).map(cap);
+    const pats = (jparse(r.ai_patterns) || []).filter(Boolean);
+    const colors = (jparse(r.ai_colors) || []).map(c => c && c.name).filter(Boolean);
+    const hex = r.dominant_color_hex || r.color_hex || null;
+    const hsv = hexHSV(hex);
+    const isComm = r.product_type === 'Commercial Wallcovering';
+    const perYard = r.vendor_msrp != null ? Number(r.vendor_msrp) : (r.price_retail != null ? Number(r.price_retail) : null);
+    const rollYd = Number(r.roll_yards) || 0;
+    const specBits = [];
+    if (r.material) specBits.push(`Content: ${clean(r.material)}`);
+    if (r.width) specBits.push(`Width: ${r.width}`);
+    if (rollYd) specBits.push(`Sold per yard in ${rollYd}-yard rolls`);
+    if (r.fire_codes) specBits.push(`Flammability: ${r.fire_codes}`);
+    const body = (r.ai_description ? `<p>${clean(r.ai_description)}</p>` : '')
+      + (specBits.length ? `<p>${specBits.join(' · ')}</p>` : '')
+      || `<p>${cap(r.pattern_name)} in ${cap(r.color_name)} by Fabricut.</p>`;
+    const p = {
+      handle: `${slug(r.pattern_name)}-${slug(r.color_name)}--${(r.dw_sku || '').toLowerCase()}`,
+      dw_sku: r.dw_sku, sku: r.mfr_sku || null,
+      title: clean(`${cap(r.pattern_name)} ${cap(r.color_name)} | Fabricut`),
+      display_eyebrow: clean(r.pattern_name || ''),
+      display_name: clean(r.color_name || r.pattern_name || ''),
+      series: clean(r.pattern_name) || null,
+      color: clean(r.color_name) || null,
+      book: r.book_name || r.collection || (isComm ? 'Commercial' : 'Fabricut'),
+      style: styles[0] || null, styles,
+      patterns: pats,
+      ai_colors: colors,
+      color_bucket: bucketFromHSV(hsv), hue: hsv ? Math.round(hsv.h) : null,
+      hex, sat: hsv ? +hsv.s.toFixed(3) : null, val: hsv ? +hsv.v.toFixed(3) : null,
+      bg_color: r.ai_background_color || null,
+      width: r.width || null,
+      length: rollYd ? `${rollYd} Yards` : (r.length || null),
+      repeat: r.repeat_v && !/^0\.0+/.test(String(r.repeat_v)) ? r.repeat_v : null,
+      material: clean(r.material) || null,
+      match: r.match_type || null, finish: r.finish || null, backing: r.backing || null,
+      fire: r.fire_codes || null, scale: r.scale || null, country: r.country_origin || null,
+      unit: isComm ? 'Priced Per Yard' : 'Priced Per Yard',
+      roll_yards: rollYd || null,
+      min_order: rollYd ? `${rollYd} Yards` : (r.min_order_yards ? `${r.min_order_yards} Yards` : null),
+      price: perYard != null && perYard > 0 ? perYard : null,   // per-yard retail
+      price_unit: 'yard',
+      body_html: body,
+      swatch: primary, room: imgs[1] || primary, images: imgs.slice(0, 12),
+      coordinating: r.coordinating_patterns && !/other colors/i.test(r.coordinating_patterns) ? clean(r.coordinating_patterns) : null,
+      published_at: r.created_at, inquiry_sku: r.dw_sku,
+    };
+    if (!PUBLIC) { p.cost = r.price_trade != null && Number(r.price_trade) > 0 ? Number(r.price_trade) : null; }
+    products.push(p);
+  }
+
+  const facets = { books: {}, series: {}, colors: {}, styles: {} };
+  for (const p of products) {
+    if (p.book) facets.books[p.book] = (facets.books[p.book] || 0) + 1;
+    if (p.series) facets.series[p.series] = (facets.series[p.series] || 0) + 1;
+    if (p.color_bucket) facets.colors[p.color_bucket] = (facets.colors[p.color_bucket] || 0) + 1;
+    if (p.style) facets.styles[p.style] = (facets.styles[p.style] || 0) + 1;
+  }
+  const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);
+
+  const snapshot = {
+    built_at: new Date().toISOString(),
+    source: `dw_unified.fabricut_catalog (${PUBLIC ? 'PUBLIC — no cost' : 'INTERNAL'})`,
+    mode: MODE, count: products.length, dropped_no_image: dropped, vendor: vendorMeta,
+    facets: {
+      total: products.length,
+      books: Object.entries(facets.books).sort((a, b) => b[1] - a[1]),
+      series: Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, 400),
+      styles: Object.entries(facets.styles).sort((a, b) => b[1] - a[1]),
+      colors: orderedColors,
+    },
+    products,
+  };
+  fs.writeFileSync(OUT, JSON.stringify(snapshot));
+  console.log(`[${MODE}] products.json -> ${OUT}`);
+  console.log(`  products: ${products.length} | dropped(no image): ${dropped}`);
+  console.log(`  colors: ${orderedColors.map(c => c[0] + ':' + c[1]).join(', ')}`);
+  console.log(`  patterns: ${snapshot.facets.series.length} | books: ${snapshot.facets.books.length} | styles: ${snapshot.facets.styles.length}`);
+  await pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/build-products-json.js b/scripts/build-products-json.js
new file mode 100644
index 0000000..faee80b
--- /dev/null
+++ b/scripts/build-products-json.js
@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+/**
+ * Workstream B data — build data/products.json from the internal dw_unified astek_catalog.
+ * These products are deliberately NOT on Shopify. The landing serves its OWN internal PDPs.
+ * No vendor (astek.com) URLs, no Shopify URLs are emitted. $0 (local PG read).
+ */
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+const OUT = path.join(__dirname, '..', 'data', 'products.json');
+const PW = (() => {
+  const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
+  const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m);
+  return m ? m[1].replace(/^["']|["']$/g, '').trim() : (process.env.PGPASSWORD || '');
+})();
+const pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW });
+
+// Map Astek color_ tags -> our canonical color buckets (drives the color filter + color sort).
+const BUCKET = {
+  white: 'white', ivory: 'white', cream: 'white', beige: 'white',
+  grey: 'grey', gray: 'grey', silver: 'grey', neutral: 'grey', taupe: 'grey',
+  black: 'black', charcoal: 'black',
+  red: 'red', burgundy: 'red',
+  orange: 'orange', rust: 'orange', copper: 'orange', terracotta: 'orange',
+  brown: 'brown', tan: 'brown', bronze: 'brown', chocolate: 'brown',
+  gold: 'gold', yellow: 'gold', mustard: 'gold',
+  green: 'green', olive: 'green', sage: 'green', emerald: 'green',
+  teal: 'teal', turquoise: 'teal', aqua: 'teal',
+  blue: 'blue', navy: 'blue', indigo: 'blue',
+  purple: 'purple', violet: 'purple', lavender: 'purple', plum: 'purple',
+  pink: 'pink', rose: 'pink', blush: 'pink', magenta: 'pink',
+};
+// order buckets so the color-wheel sort reads neutrals-first then chromatic
+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 customer-facing string (titles, colors, descriptions)
+const clean = s => (s == null ? s : String(s)
+  .replace(/\bWallpapers\b/gi, 'Wallcoverings')
+  .replace(/\bWallpaper\b/gi, 'Wallcovering'));
+
+const cap = s => String(s || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();
+
+function bucketFor(tags) {
+  for (const t of (tags || [])) {
+    const key = (t || '').toLowerCase().trim();
+    if (BUCKET[key]) return BUCKET[key];
+  }
+  return null;
+}
+
+async function main() {
+  // one row per (dw_sku) colorway variant; drop the rare image-less rows from the public grid
+  const { rows } = await pool.query(`
+    SELECT dw_sku, mfr_sku, handle, pattern_name, color_name, title, product_type,
+           description_text, width, length, repeat, material, match_type, price, cost,
+           color_tags, style_tags, all_images, image_url, variant_image, created_at
+    FROM astek_catalog
+    ORDER BY pattern_name, color_name, dw_sku
+  `);
+
+  // Vendor ops meta — drives the internal PDP (Call Vendor, Our Account #, pricing
+  // basis). vendor_registry holds the line's discount/pricing model; fmpro (the
+  // FileMaker mirror) holds phone + our account number with the vendor.
+  const VENDOR_CODE_REG = 'astek';
+  const FM_VENDOR_NAME = 'Astek';
+  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] || {};
+  // Japan-vendor image-match cross-reference (astek_japan_match, keyed by image_url).
+  // These are VISUAL matches (reused-photo detection) — 'high' = near-identical swatch,
+  // 'possible' = same color/texture family. Surfaced as an "≈ Japan match" chip so the
+  // buyer can trace an Astek relabel back to its Sangetsu/Lilycolor origin SKU.
+  const jm = new Map();
+  try {
+    (await pool.query('SELECT image_url, japan_sku, japan_vendor, confidence, dist FROM astek_japan_match')).rows
+      .forEach(m => jm.set(m.image_url, m));
+  } catch (e) {
+    // Table not present on this host (e.g. Kamatera before the match table is loaded).
+    // Degrade gracefully — build without Japan cross-refs rather than failing the refresh.
+    console.warn('[astek] astek_japan_match unavailable — building without Japan matches:', e.message);
+  }
+  const safeThumb = s => String(s || '').replace(/[^A-Za-z0-9_-]/g, '_');
+  // Exact-image deep link — authoritative per-SKU image URL from the Japan viewer's own
+  // catalog (some are on japan.designerwallcoverings.com, some are the mill's external
+  // image; SKUs not served there fall back to the local exact thumbnail). Built by
+  // japan-enrich/match; if absent, we degrade to a ?q= search.
+  let JEXACT = {};
+  try { JEXACT = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'japan_exact_image.json'), 'utf8')); } catch (_) {}
+  const japanImgUrl = m => m
+    ? (JEXACT[m.japan_sku] || `https://japan.designerwallcoverings.com/?q=${encodeURIComponent(m.japan_sku)}`)
+    : null;
+
+  const vendorMeta = {
+    name: vr.vendor_name || FM_VENDOR_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 imgs = (r.all_images || []).filter(Boolean);
+    if (imgs.length === 0) { dropped++; continue; }               // no image → not shown on public grid
+    const bucket = bucketFor(r.color_tags);
+    const jmatch = jm.get(r.image_url);
+    // internal-only, self-contained: NO shopify url, NO astek.com url.
+    products.push({
+      // Japan-vendor origin cross-reference (visual image match). null when no match.
+      japan_sku: jmatch ? jmatch.japan_sku : null,
+      japan_vendor: jmatch ? jmatch.japan_vendor : null,
+      japan_conf: jmatch ? jmatch.confidence : null,
+      japan_thumb: jmatch ? `/japan-thumbs/${safeThumb(jmatch.japan_sku)}.jpg` : null,
+      japan_img_url: japanImgUrl(jmatch),   // exact image on the Japan viewer
+      handle: `${r.handle}--${(r.dw_sku || '').toLowerCase()}`,   // unique internal PDP handle per colorway
+      dw_sku: r.dw_sku,
+      // Astek jams color tags into variant skus ("AD752-1__black__gold…") — the
+      // real vendor SKU is the part before the first "__". Never show the junk.
+      sku: (r.mfr_sku || '').split('__')[0] || null,
+      title: clean(r.title),
+      display_eyebrow: clean(r.pattern_name || ''),
+      display_name: clean(r.color_name || r.pattern_name || r.title),
+      series: clean(r.pattern_name) || null,                       // pattern = "series" for pairing/sort
+      color: clean(r.color_name) || null,
+      book: r.product_type || 'Astek',                             // Digital / Traditional etc → collection chip
+      // style facet + Style sort — primary tag is the scalar the generic filter/sort
+      // machinery uses; the full tag list rides along for search.
+      style: (r.style_tags || [])[0] ? cap(r.style_tags[0]) : null,
+      styles: (r.style_tags || []).map(cap),
+      color_bucket: bucket,
+      hue: bucket ? BUCKET_HUE[bucket] : null,
+      hex: null,                                                   // no per-sku hex extraction (tag-bucket only)
+      sat: null, val: bucket === 'white' ? 1 : bucket === 'black' ? 0 : 0.5,
+      width: r.width || null,
+      length: r.length || null,
+      repeat: r.repeat || null,
+      material: r.material || null,
+      match: r.match_type || null,
+      // internal pricing — list/cost straight from the catalog row; the vendor's
+      // letter price-code (e.g. "Price Code M") parsed from the spec prose.
+      price: r.price != null && Number(r.price) > 0 ? Number(r.price) : null,
+      cost: r.cost != null && Number(r.cost) > 0 ? Number(r.cost) : null,
+      price_code: (() => { const m = (r.description_text || '').match(/Price\s*Code\s+([A-Z0-9]{1,3})\b/i); return m ? m[1].toUpperCase() : null; })(),
+      body_html: clean(r.description_text || ''),
+      swatch: imgs[0] || null,
+      room: imgs[1] || imgs[0] || null,
+      images: imgs.slice(0, 12),
+      published_at: r.created_at,
+      // memo-sample / inquiry — the internal CTA target (no checkout, no external store)
+      inquiry_sku: r.dw_sku,
+    });
+  }
+
+  const facets = { books: {}, series: {}, colors: {} };
+  for (const p of products) {
+    if (p.book) facets.books[p.book] = (facets.books[p.book] || 0) + 1;
+    if (p.series) facets.series[p.series] = (facets.series[p.series] || 0) + 1;
+    if (p.color_bucket) facets.colors[p.color_bucket] = (facets.colors[p.color_bucket] || 0) + 1;
+  }
+  const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);
+
+  const snapshot = {
+    built_at: new Date().toISOString(),
+    source: 'dw_unified.astek_catalog (INTERNAL — not on Shopify)',
+    count: products.length,
+    dropped_no_image: dropped,
+    vendor: vendorMeta,
+    facets: {
+      total: products.length,
+      books: Object.entries(facets.books).sort((a, b) => b[1] - a[1]),
+      series: Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, 300),
+      colors: orderedColors,
+    },
+    products,
+  };
+  fs.writeFileSync(OUT, JSON.stringify(snapshot));
+  console.log(`products.json -> ${OUT}`);
+  console.log(`  products: ${products.length} | dropped (no image): ${dropped}`);
+  console.log(`  color buckets: ${orderedColors.map(c => c[0] + ':' + c[1]).join(', ')}`);
+  console.log(`  patterns: ${snapshot.facets.series.length} | books: ${snapshot.facets.books.length}`);
+  await pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/monthly-refresh.sh b/scripts/monthly-refresh.sh
new file mode 100755
index 0000000..97ef10b
--- /dev/null
+++ b/scripts/monthly-refresh.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+# Astek monthly full refresh: re-scrape astek.com -> astek_catalog -> rebuild
+# snapshot -> deploy the fresh data to the live (gated, internal) microsite.
+#
+# The existing com.steve.astek-data-refresh only REBUILDS the local snapshot
+# from the DB every 15 min; it never re-scrapes the vendor and never deploys.
+# This monthly job closes both gaps. Scrape+rebuild are $0 local; the deploy
+# targets an internal basic-auth microsite (no Shopify, no prices) so an
+# unattended monthly push is low-risk.
+set -uo pipefail
+
+# launchd hands us a minimal PATH — restore node + ssh/rsync (deploy.sh needs them).
+export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
+
+PROJ="/Users/macstudio3/Projects/astek-landing"
+LOG="/tmp/astek-monthly-refresh.log"
+cd "$PROJ" || { echo "$(date '+%F %T') FATAL cd $PROJ failed" >>"$LOG"; exit 1; }
+
+echo "===== $(date '+%F %T') astek monthly refresh START =====" >>"$LOG"
+
+echo "[1/3] scrape astek.com -> astek_catalog" >>"$LOG"
+if ! node scripts/scrape-astek.js >>"$LOG" 2>&1; then
+  echo "$(date '+%F %T') SCRAPE FAILED — aborting (snapshot + live left untouched)" >>"$LOG"; exit 1
+fi
+
+echo "[2/3] rebuild data/products.json" >>"$LOG"
+if ! node scripts/build-products-json.js >>"$LOG" 2>&1; then
+  echo "$(date '+%F %T') REBUILD FAILED — aborting before deploy" >>"$LOG"; exit 1
+fi
+
+# Commit the refreshed data so the deploy ships a tracked snapshot.
+git add data/products.json data/astek-raw.json 2>/dev/null
+git -c user.email="steve@designerwallcoverings.com" \
+    commit -q -m "data: astek monthly refresh $(date '+%F')" 2>/dev/null || true
+
+echo "[3/3] deploy fresh snapshot to Kamatera" >>"$LOG"
+if ! bash /Users/macstudio3/Projects/_shared/scripts/deploy.sh >>"$LOG" 2>&1; then
+  echo "$(date '+%F %T') DEPLOY FAILED — local snapshot is fresh, live is stale" >>"$LOG"; exit 1
+fi
+
+echo "===== $(date '+%F %T') astek monthly refresh DONE =====" >>"$LOG"
diff --git a/scripts/scrape-astek.js b/scripts/scrape-astek.js
new file mode 100644
index 0000000..63b981f
--- /dev/null
+++ b/scripts/scrape-astek.js
@@ -0,0 +1,277 @@
+#!/usr/bin/env node
+/**
+ * Workstream A — Astek catalog scrape (INTERNAL DATA ONLY, no Shopify).
+ * Feed-first: paginate https://www.astek.com/products.json?limit=250&page=N
+ * PostgreSQL-FIRST: raw snapshot -> astek_catalog in dw_unified -> register DW SKUs.
+ *
+ * Cost: $0 (local HTTP). No Browserbase.
+ */
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+const VENDOR = 'Astek';
+const VENDOR_CODE = 'astek';     // existing canonical vendor_registry row
+const DW_PREFIX = 'DWPX';        // pre-assigned to Astek in vendor_registry (compliant: P,X allowed; 0 SKUs minted)
+const SKU_RANGE_START = 400000;  // Astek block (kept distinct from legacy ranges)
+const FEED = 'https://www.astek.com/products.json';
+const RAW_OUT = path.join(__dirname, '..', 'data', 'astek-raw.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);
+    return m ? m[1].replace(/^["']|["']$/g, '').trim() : (process.env.PGPASSWORD || '');
+  } catch { return process.env.PGPASSWORD || ''; }
+})();
+
+const pool = new Pool({
+  host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW,
+});
+
+const BANNED = s => (s || '')
+  .replace(/\bWallpapers\b/gi, 'Wallcoverings')
+  .replace(/\bWallpaper\b/gi, 'Wallcovering');
+
+function toTitleCase(str) {
+  const small = new Set(['a','an','the','and','but','or','for','nor','on','at','to','from','by','in','of','with']);
+  return (str || '').split(/\s+/).map((w, i) => {
+    const lw = w.toLowerCase();
+    if (i !== 0 && small.has(lw)) return lw;
+    return w.charAt(0).toUpperCase() + w.slice(1);
+  }).join(' ').trim();
+}
+
+// Parse specs out of the body_html prose / spec block.
+function parseSpecs(html) {
+  const text = (html || '').replace(/<[^>]+>/g, ' ').replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
+  const grab = (re) => { const m = text.match(re); return m ? m[1].trim() : null; };
+  return {
+    width:    grab(/(?:Roll\s*Width|Width)[:\s]*([0-9][0-9.\s\/"]*(?:in(?:ches)?|"|cm|mm)?)/i),
+    length:   grab(/(?:Roll\s*Length|Length)[:\s]*([0-9][0-9.\s\/"]*(?:ft|feet|yd|yard|in(?:ches)?|m)?)/i),
+    repeat:   grab(/(?:Pattern\s*Repeat|Repeat)[:\s]*([0-9][0-9.\s\/"]*(?:in(?:ches)?|"|cm)?)/i),
+    material: grab(/(?:Material|Substrate|Type|Content)[:\s]*([A-Za-z][A-Za-z0-9 ,\/\-]{2,40})/i),
+    match:    grab(/(?:Match)[:\s]*([A-Za-z][A-Za-z ]{2,30})/i),
+  };
+}
+
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
+
+// Shopify/CF occasionally returns a transient 503 on the products.json feed
+// (seen 2026-07-13). Retry with backoff so the monthly unattended job doesn't
+// die on a single blip. Raises only after all attempts are exhausted.
+async function fetchFeedPage(url, attempts = 4) {
+  let lastStatus = 0;
+  for (let i = 0; i < attempts; i++) {
+    const res = await fetch(url, { headers: { 'User-Agent': UA } });
+    if (res.ok) return res;
+    lastStatus = res.status;
+    if (i < attempts - 1) await new Promise(r => setTimeout(r, 1500 * (i + 1))); // 1.5s, 3s, 4.5s
+  }
+  throw new Error(`feed HTTP ${lastStatus} after ${attempts} attempts`);
+}
+
+async function fetchAllProducts() {
+  let page = 1, all = [];
+  while (true) {
+    const url = `${FEED}?limit=250&page=${page}`;
+    const res = await fetchFeedPage(url);
+    const json = await res.json();
+    const products = json.products || [];
+    if (products.length === 0) break;
+    all.push(...products);
+    process.stdout.write(`\r  fetched page ${page} (total ${all.length})   `);
+    page++;
+    if (page > 40) break; // hard safety cap
+    await new Promise(r => setTimeout(r, 120)); // polite
+  }
+  process.stdout.write('\n');
+  return all;
+}
+
+async function ensureSchema(client) {
+  await client.query(`
+    CREATE TABLE IF NOT EXISTS astek_catalog (
+      id                 BIGSERIAL PRIMARY KEY,
+      dw_sku             TEXT UNIQUE,
+      mfr_sku            TEXT NOT NULL,
+      shopify_product_id BIGINT,
+      handle             TEXT,
+      pattern_name       TEXT,
+      color_name         TEXT,
+      title              TEXT,             -- Pattern Color | Astek (title-cased, banned-word clean)
+      vendor             TEXT DEFAULT 'Astek',
+      product_type       TEXT,
+      body_html          TEXT,
+      description_text   TEXT,
+      price              NUMERIC,
+      cost               NUMERIC,
+      width              TEXT,
+      length             TEXT,
+      repeat             TEXT,
+      material           TEXT,
+      match_type         TEXT,
+      color_tags         TEXT[],
+      style_tags         TEXT[],
+      all_images         TEXT[],
+      image_url          TEXT,             -- primary image
+      variant_image      TEXT,
+      product_url        TEXT,
+      raw                JSONB,
+      created_at         TIMESTAMPTZ DEFAULT NOW(),
+      updated_at         TIMESTAMPTZ DEFAULT NOW(),
+      UNIQUE (mfr_sku)
+    );
+    CREATE INDEX IF NOT EXISTS astek_catalog_pattern_idx ON astek_catalog (pattern_name);
+    CREATE INDEX IF NOT EXISTS astek_catalog_handle_idx  ON astek_catalog (handle);
+  `);
+}
+
+// registry helpers (inline; mirror lib/sku-registry semantics but with our pooled client)
+async function checkDup(client, mfrSku) {
+  const r = await client.query(
+    `SELECT dw_sku FROM dw_sku_registry WHERE vendor_prefix=$1 AND mfr_sku=$2`, [DW_PREFIX, mfrSku]);
+  return r.rows[0]?.dw_sku || null;
+}
+async function nextSkuNum(client) {
+  const r = await client.query(
+    `SELECT MAX(CAST(SUBSTRING(dw_sku FROM '[0-9]+$') AS INTEGER)) mx FROM dw_sku_registry WHERE vendor_prefix=$1`,
+    [DW_PREFIX]);
+  return r.rows[0]?.mx ? (r.rows[0].mx + 1) : SKU_RANGE_START;
+}
+
+// TK-10374: per-run ground-truth logger (shared, zero-dep, psql shell-out). Best-effort require so
+// a missing shared lib never breaks the scrape.
+let runLog = null;
+try { runLog = require('/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/lib/scraper-run-log.js'); } catch { /* optional */ }
+let _run = null; // module-scope handle so the top-level .catch can stamp status=error
+
+async function main() {
+  const run = runLog ? runLog.start({ vendorCode: VENDOR_CODE, vendorName: VENDOR, agentName: 'astek-scrape', metadata: { feed: FEED, harness: 'scrape-astek.js' } }) : null;
+  _run = run;
+  console.log(`\n[Astek scrape] prefix=${DW_PREFIX} range_start=${SKU_RANGE_START}`);
+  console.log('Fetching products.json feed ($0 local HTTP)...');
+  const products = await fetchAllProducts();
+  console.log(`Products: ${products.length}`);
+  fs.writeFileSync(RAW_OUT, JSON.stringify({ scraped_at: new Date().toISOString(), count: products.length, products }, null, 0));
+  console.log(`Raw snapshot -> ${RAW_OUT}`);
+
+  const client = await pool.connect();
+  let rows = 0, variants = 0, images = 0, registered = 0, dups = 0, skipped = 0;
+  try {
+    await ensureSchema(client);
+
+    // Seed the sku counter once, then increment locally to avoid N round-trips.
+    let counter = await nextSkuNum(client);
+
+    for (const p of products) {
+      const specs = parseSpecs(p.body_html);
+      const descText = BANNED((p.body_html || '').replace(/<[^>]+>/g, ' ').replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim());
+      const patternName = BANNED(toTitleCase((p.title || '').replace(/\s*Wallcovering\s*$/i, '').replace(/\s*Wallpaper\s*$/i, '').trim()));
+      const colorTags = (p.tags || []).filter(t => /^color_/.test(t)).map(t => t.replace(/^color_/, ''));
+      // ONLY style_ tags are real styles — feature_/design_ carry ops junk (instock, pricing, color)
+      const styleTags = (p.tags || []).filter(t => /^style_/.test(t)).map(t => t.replace(/^style_/, ''));
+      const allImages = (p.images || []).map(i => i.src);
+      images += allImages.length;
+
+      // Map variant image ids -> src
+      const imgById = {};
+      for (const im of (p.images || [])) for (const vid of (im.variant_ids || [])) imgById[vid] = im.src;
+
+      const vlist = (p.variants || []);
+      for (const v of vlist) {
+        variants++;
+        // MFR SKU: prefer clean variant SKU; fall back to handle+position.
+        let mfrSku = (v.sku && v.sku.trim()) ? v.sku.trim() : `${p.handle}-${v.position || v.id}`;
+        // Real color name from option/variant title (NEVER "Unknown").
+        let colorName = (v.option1 && v.option1.trim()) || (v.title && v.title.trim()) || '';
+        if (/^unknown$/i.test(colorName)) colorName = '';
+
+        // Title: Pattern Color | Astek  (fallback order: color -> mfr sku -> none; never "Unknown")
+        let titleColor = colorName ? toTitleCase(colorName) : '';
+        let title = titleColor ? `${patternName} ${titleColor} | Astek` : `${patternName} | Astek`;
+        if (!patternName) { // last-resort pattern fallback: mfr sku
+          title = `${mfrSku}${titleColor ? ' ' + titleColor : ''} | Astek`;
+        }
+        title = BANNED(title);
+
+        const priceNum = (v.price != null && parseFloat(v.price) > 0) ? parseFloat(v.price) : null;
+        const variantImg = imgById[v.id] || v.featured_image?.src || allImages[0] || null;
+
+        // ---- PostgreSQL FIRST: register DW SKU ----
+        // dw_sku_registry.mfr_sku is varchar(50); some Astek feed skus jam color tags in
+        // and overflow it — register those under a deterministic hash-truncated key while
+        // astek_catalog (TEXT) keeps the full sku.
+        const regSku = mfrSku.length <= 50
+          ? mfrSku
+          : mfrSku.slice(0, 41) + '~' + require('crypto').createHash('sha1').update(mfrSku).digest('hex').slice(0, 8);
+        let dwSku = await checkDup(client, regSku);
+        if (dwSku) { dups++; }
+        else {
+          dwSku = `${DW_PREFIX}-${counter}`;
+          try {
+            await client.query(
+              `INSERT INTO dw_sku_registry (dw_sku, vendor_prefix, vendor_name, mfr_sku) VALUES ($1,$2,$3,$4)`,
+              [dwSku, DW_PREFIX, VENDOR, regSku]);
+            registered++; counter++;
+          } catch (e) {
+            if (e.code === '23505') { dwSku = await checkDup(client, regSku); dups++; }
+            else { console.error('reg err', mfrSku, e.message); skipped++; continue; }
+          }
+        }
+
+        // ---- upsert catalog row ----
+        await client.query(`
+          INSERT INTO astek_catalog
+            (dw_sku, mfr_sku, handle, pattern_name, color_name, title, product_type,
+             body_html, description_text, price, cost, width, length, repeat, material, match_type,
+             color_tags, style_tags, all_images, image_url, variant_image, product_url, raw, updated_at)
+          VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,NOW())
+          ON CONFLICT (mfr_sku) DO UPDATE SET
+            dw_sku=EXCLUDED.dw_sku, pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name,
+            title=EXCLUDED.title, body_html=EXCLUDED.body_html, description_text=EXCLUDED.description_text,
+            price=EXCLUDED.price, width=EXCLUDED.width, length=EXCLUDED.length, repeat=EXCLUDED.repeat,
+            material=EXCLUDED.material, match_type=EXCLUDED.match_type, color_tags=EXCLUDED.color_tags,
+            style_tags=EXCLUDED.style_tags, all_images=EXCLUDED.all_images, image_url=EXCLUDED.image_url,
+            variant_image=EXCLUDED.variant_image, product_url=EXCLUDED.product_url, raw=EXCLUDED.raw,
+            updated_at=NOW()
+        `, [
+          dwSku, mfrSku, p.handle, patternName, colorName ? toTitleCase(colorName) : null, title, p.product_type,
+          p.body_html, descText, priceNum, null, specs.width, specs.length, specs.repeat, specs.material, specs.match,
+          colorTags, styleTags, allImages, allImages[0] || null, variantImg,
+          `https://www.astek.com/products/${p.handle}`, JSON.stringify(v),
+        ]);
+        rows++;
+      }
+    }
+
+    // update the existing canonical Astek vendor_registry row (internal, skip_shopify true)
+    await client.query(`
+      UPDATE vendor_registry
+      SET sku_prefix=$1, sku_range_start=$2, catalog_table='astek_catalog',
+          skip_shopify=true, is_active=true,
+          notes='INTERNAL ONLY — astek.designerwallcoverings.com landing; deliberately NOT on Shopify',
+          updated_at=NOW()
+      WHERE vendor_code=$3
+    `, [DW_PREFIX + '-', SKU_RANGE_START, VENDOR_CODE]).catch(e => console.warn('vendor_registry update skipped:', e.message));
+
+  } finally {
+    client.release();
+  }
+
+  const tot = await pool.query('SELECT COUNT(*) c, COUNT(DISTINCT pattern_name) p FROM astek_catalog');
+  console.log(`\n=== DONE ===`);
+  console.log(`catalog rows (variants): ${rows} | distinct patterns: ${tot.rows[0].p}`);
+  console.log(`variants seen: ${variants} | images seen: ${images}`);
+  console.log(`DW SKUs newly registered: ${registered} | dupes reused: ${dups} | skipped: ${skipped}`);
+  console.log(`astek_catalog total rows now: ${tot.rows[0].c}`);
+  // TK-10374: stamp the run. skusFetched = variants pulled from the feed; skusChanged = newly registered.
+  if (run) run.finish({ status: 'ok', skusFetched: products.length, skusChanged: registered });
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error('FATAL', e);
+  try { if (_run) _run.finish({ status: 'error', errorText: String(e && e.stack || e) }); } catch { /* best-effort */ }
+  process.exit(1);
+});
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..aa38774
--- /dev/null
+++ b/server.js
@@ -0,0 +1,150 @@
+/* Fabricut editorial vendor-landing — Designer Wallcoverings house brand.
+   INTERNAL DATA ONLY: catalog is dw_unified.fabricut_catalog (NOT on Shopify).
+   Self-contained: reads data/products.json. Links resolve to this site's OWN
+   internal PDPs and a memo-sample/inquiry CTA — never Shopify, never fabricut.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 }));
+
+// PUBLIC customer-facing site — NO auth gate. (The internal twin,
+// fabricut--internal.designerwallcoverings.com, is the basic-auth version.)
+// Data here is the PUBLIC build (MODE=public): no cost, no trade, no account #.
+
+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(`[fabricut] loaded ${SNAP.products.length} products (data mtime ${LAST_REFRESH})`);
+}
+load();
+// Hot-reload when the 15-min 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('[fabricut] reload failed:', e.message); } }
+});
+
+// House identity — Designer Wallcoverings is the brand; Astek is the featured 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: 'Fabricut',
+  line: 'Fabricut',
+  wordmark: 'Designer Wallcoverings',
+  eyebrow: 'A Designer Wallcoverings Collection',
+  kicker: 'To the Trade · Sold Per Yard',
+  tagline: 'Fabricut wallcoverings — patterns, textures & colorways, curated by Designer Wallcoverings.',
+  booksHeading: 'The Fabricut Collection',
+  title: 'Fabricut — A Designer Wallcoverings Collection',
+  metaDescription: 'The Fabricut wallcovering collection at Designer Wallcoverings. To the trade — order a memo sample before specifying.',
+  slug: 'fabricut',
+  palette: null,
+  about: {
+    paragraphs: [
+      'Fabricut is one of the largest privately held distributors of decorative fabrics and wallcoverings in the world — an American house whose library spans traditional damasks and botanicals through contemporary textures, geometrics, and grasscloths. Every pattern is offered in a full run of colorways developed for residential and hospitality specification.',
+      'The Fabricut wallcovering collection is sold by the yard, in full-roll put-ups — presented here for the trade with complete specifications and memo samples before you specify.',
+    ],
+    collab: 'The Fabricut collection, presented in collaboration with 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: 'fabricut', 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 (Fabricut curation) ─────────────────────
+// 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: 'Fabricut curation 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(`Fabricut landing → http://127.0.0.1:${server.address().port}`);
+});

(oldest)  ·  back to Fabricut Landing  ·  Public site hardening (Cody red-team FIX FIRST): remove vend 5048cfe →