[object Object]

← back to Dw Vendor Microsites

initial scaffold — generalized cowtan engine + lib + harness + gated deploy/dns scripts

7ca97322eff0dd5372421850bcb59dacec9df823 · 2026-06-30 12:44:50 -0700 · Steve

Files touched

Diff

commit 7ca97322eff0dd5372421850bcb59dacec9df823
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 12:44:50 2026 -0700

    initial scaffold — generalized cowtan engine + lib + harness + gated deploy/dns scripts
---
 .gitignore                 |  16 +++
 build-vendor.sh            | 133 ++++++++++++++++++
 bulk-dns.sh                |  61 ++++++++
 deploy-vendor.sh           |  94 +++++++++++++
 issue-wildcard.sh          |  31 ++++
 launch-wave.sh             |  35 +++++
 launch-window.sh           |  79 +++++++++++
 lib/export-catalog.sh      | 102 ++++++++++++++
 lib/image-health.sh        |  76 ++++++++++
 lib/manifest-lib.sh        |  59 ++++++++
 lib/port-alloc.sh          |  63 +++++++++
 lib/resolve-vendor.sh      | 101 +++++++++++++
 lib/smoke-local.sh         |  71 ++++++++++
 manifest.json              |   1 +
 seed-manifest.sh           | 149 ++++++++++++++++++++
 template/public/index.html | 342 +++++++++++++++++++++++++++++++++++++++++++++
 template/server.js         | 248 ++++++++++++++++++++++++++++++++
 17 files changed, 1661 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0d9ca1d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+# lock files for the flock-based allocators
+.port-alloc.lock
+.manifest.lock
+# per-vendor sample requests are runtime data, not source
+data/sample-requests.jsonl
+vendors/*/viewer/data/sample-requests.jsonl
+# NOTE: staging/*.jsonl is intentionally KEPT in git (the exported, scrubbed catalog
+# snapshots are the resumable build inputs — OK to keep, per plan).
diff --git a/build-vendor.sh b/build-vendor.sh
new file mode 100755
index 0000000..96ca943
--- /dev/null
+++ b/build-vendor.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# build-vendor.sh <vendor_code>
+#
+# Idempotent / resumable per-vendor build harness. Takes a vendor to local-verified
+# BUILT status (NOT deployed). One iTerm Claude session runs this end-to-end.
+#
+# Steps (per the approved plan §2):
+#   0. private-label / exclusion gate         (lib/resolve-vendor.sh)
+#   1. export <vendor>_catalog -> staging/<vendor>.jsonl  (lib/export-catalog.sh, scrubbed)
+#   2. image health (HEAD-sample + relink)    (lib/image-health.sh)
+#   3. scaffold template -> vendors/<vendor>/viewer + write fieldmap.json
+#   4. allocate free port >= 10000            (lib/port-alloc.sh)
+#   5. local smoke (200 + count + facets)     (lib/smoke-local.sh)
+#   6. update manifest.json + git commit
+#
+# LOCAL ONLY: no deploy, no DNS, no Shopify, no dw_unified writes, no spend.
+set -euo pipefail
+
+VENDOR="${1:?usage: build-vendor.sh <vendor_code>}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$REPO"
+source "$REPO/lib/manifest-lib.sh"
+
+log() { printf '\033[1;36m[build:%s]\033[0m %s\n' "$VENDOR" "$*"; }
+die() { printf '\033[1;31m[build:%s] FAIL\033[0m %s\n' "$VENDOR" "$*" >&2; exit 1; }
+
+# ---------- step 0: resolve / gate ----------
+log "step 0 — private-label / exclusion gate"
+RES="$(bash "$REPO/lib/resolve-vendor.sh" "$VENDOR")" || {
+  st=$?
+  if [ "$st" = "3" ]; then
+    log "EXCLUDED — $RES"
+    manifest_upsert "$VENDOR" "{\"build_status\":\"EXCLUDED\",\"status\":\"EXCLUDED\"}"
+    exit 0
+  fi
+  die "resolve failed ($st): $RES"
+}
+HOUSE="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["house_name"])')"
+SUB="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["subdomain"])')"
+CTABLE="$(echo "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["catalog_table"])')"
+IS_PL="$(echo "$RES" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("is_private_label",False)).lower())')"
+SAMPLES_ONLY="$(echo "$RES" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("samples_only",True)).lower())')"
+BANNED_JSON="$(echo "$RES" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin).get("banned_names",[])))')"
+mapfile -t BANNED < <(echo "$BANNED_JSON" | python3 -c 'import sys,json;[print(x) for x in json.load(sys.stdin)]')
+log "house=$HOUSE  subdomain=$SUB  table=$CTABLE  private_label=$IS_PL  samples_only=$SAMPLES_ONLY  banned=[${BANNED[*]+${BANNED[*]}}]"
+
+# ---------- step 1: export + scrub ----------
+JSONL="$REPO/staging/${VENDOR}.jsonl"
+log "step 1 — export ${CTABLE} -> staging/${VENDOR}.jsonl (scrubbing ${#BANNED[@]} banned names)"
+TABLE_COUNT="$(ssh my-server "sudo -u postgres psql -d dw_unified -tAc \"select count(*) from ${CTABLE};\"" 2>/dev/null | tr -d ' ')"
+[ -z "$TABLE_COUNT" ] && die "could not count ${CTABLE}"
+EXPORTED="$(bash "$REPO/lib/export-catalog.sh" "$CTABLE" "$JSONL" "${BANNED[@]+"${BANNED[@]}"}")" || die "export failed"
+log "exported ${EXPORTED} rows (table count ${TABLE_COUNT})"
+
+# leak verification for private-label vendors: assert no banned name survives
+LEAK="ok"
+if [ "${#BANNED[@]}" -gt 0 ]; then
+  for b in "${BANNED[@]}"; do
+    if grep -qiF "$b" "$JSONL"; then
+      LEAK="LEAK:$b"; break
+    fi
+  done
+  if [ "$LEAK" != "ok" ]; then
+    die "private-label LEAK detected in JSONL: $LEAK"
+  fi
+  log "leak check — ZERO banned upstream names in JSONL ✓"
+fi
+
+# ---------- step 2: image health ----------
+log "step 2 — image health"
+HEALTH_JSON="$(bash "$REPO/lib/image-health.sh" "$JSONL" 40)" || HEALTH_JSON='{"image_health":0,"status":"UNKNOWN"}'
+HEALTH="$(echo "$HEALTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("image_health",0))')"
+HEALTH_STATUS="$(echo "$HEALTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("status","UNKNOWN"))')"
+log "image_health=$HEALTH status=$HEALTH_STATUS — $HEALTH_JSON"
+# NEEDS_RESCRAPE is a flag, not a blocker (per plan)
+[ "$HEALTH_STATUS" = "NEEDS_RESCRAPE" ] || [ "$HEALTH_STATUS" = "EMPTY" ] && log "FLAGGED NEEDS_RESCRAPE (not blocking build)"
+
+# ---------- step 3: scaffold ----------
+VDIR="$REPO/vendors/${VENDOR}/viewer"
+log "step 3 — scaffold template -> vendors/${VENDOR}/viewer + fieldmap.json"
+mkdir -p "$VDIR" "$REPO/vendors/${VENDOR}"
+cp "$REPO/template/server.js" "$VDIR/server.js"
+rm -rf "$VDIR/public"; cp -R "$REPO/template/public" "$VDIR/public"
+cat > "$VDIR/fieldmap.json" <<JSON
+{
+  "title": "$(echo "$HOUSE" | sed 's/"/\\"/g')",
+  "samples_only": ${SAMPLES_ONLY},
+  "vendor_code": "${VENDOR}",
+  "subdomain": "${SUB}"
+}
+JSON
+
+# ---------- step 4: port ----------
+log "step 4 — allocate port"
+PORT="$(bash "$REPO/lib/port-alloc.sh" "$VENDOR")" || die "port-alloc failed"
+log "port=$PORT"
+
+# ---------- step 5: local smoke ----------
+log "step 5 — local smoke"
+SMOKE_JSON="$(bash "$REPO/lib/smoke-local.sh" "$VDIR" "$JSONL" "$VDIR/fieldmap.json" "$EXPORTED")" || {
+  echo "$SMOKE_JSON"
+  manifest_upsert "$VENDOR" "{\"build_status\":\"SMOKE_FAIL\",\"port\":$PORT,\"sku_count\":$EXPORTED}"
+  die "smoke FAILED: $SMOKE_JSON"
+}
+log "smoke PASS — $SMOKE_JSON"
+
+# ---------- step 6: manifest + git ----------
+log "step 6 — manifest + git"
+BANNED_FOR_JSON="$BANNED_JSON"
+manifest_upsert "$VENDOR" "$(python3 -c "
+import json
+print(json.dumps({
+  'house_name': '$HOUSE',
+  'subdomain': '$SUB',
+  'catalog_table': '$CTABLE',
+  'is_private_label': '$IS_PL'=='true',
+  'banned_names': json.loads('''$BANNED_FOR_JSON'''),
+  'port': $PORT,
+  'sku_count': $EXPORTED,
+  'table_count': $TABLE_COUNT,
+  'image_health': $HEALTH,
+  'image_status': '$HEALTH_STATUS',
+  'samples_only': '$SAMPLES_ONLY'=='true',
+  'build_status': 'BUILT' if '$HEALTH_STATUS' not in ('NEEDS_RESCRAPE','EMPTY') else 'BUILT_NEEDS_RESCRAPE',
+  'deploy_status': 'PENDING',
+}))
+")"
+
+git -C "$REPO" add -A
+git -C "$REPO" commit -q -m "build ${VENDOR} (${HOUSE}) -> BUILT, port ${PORT}, ${EXPORTED} skus, img ${HEALTH}" || true
+
+log "DONE — ${VENDOR} -> ${HOUSE} @ ${SUB}.designerwallcoverings.com  port ${PORT}  ${EXPORTED} skus  img ${HEALTH}  status BUILT"
+echo "$RES" | python3 -c 'import sys,json;d=json.load(sys.stdin);print()' 2>/dev/null || true
diff --git a/bulk-dns.sh b/bulk-dns.sh
new file mode 100755
index 0000000..a6f8520
--- /dev/null
+++ b/bulk-dns.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# bulk-dns.sh [vendor_code ...]   — ONE-TIME throttled A-records -> 45.61.58.125
+#
+# GATED: DNS change (customer-facing infra). Do NOT run without Steve's approval.
+#
+# Creates a Cloudflare A record <subdomain>.designerwallcoverings.com -> 45.61.58.125
+# for each subdomain in the manifest (or only the vendor_codes passed as args).
+# Throttled 1 request/sec to respect Cloudflare's API rate limit. DNS-only (proxied=false)
+# so the nginx wildcard cert + listen-IP vhost serve directly (matches cowtan).
+#
+# Token: CLOUDFLARE_API_TOKEN from ~/Projects/secrets-manager/.env
+# Zone:  e4b88c70e4c949f4556cc693133c2f42 (designerwallcoverings.com)
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MANIFEST="$REPO/manifest.json"
+ZONE="e4b88c70e4c949f4556cc693133c2f42"
+IP="45.61.58.125"
+ENVF="$HOME/Projects/secrets-manager/.env"
+
+TOKEN="$(grep -E '^CLOUDFLARE_API_TOKEN=' "$ENVF" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ' || true)"
+[ -z "$TOKEN" ] && { echo "bulk-dns: CLOUDFLARE_API_TOKEN not found in $ENVF" >&2; exit 1; }
+
+# build the subdomain list
+if [ "$#" -gt 0 ]; then
+  SUBS="$(python3 -c "
+import json,sys
+m=json.load(open('$MANIFEST'))
+want=set(sys.argv[1:])
+for code,v in (m.get('vendors') or {}).items():
+    if code in want and v.get('subdomain') and v.get('build_status','').startswith('BUILT'):
+        print(v['subdomain'])
+" "$@")"
+else
+  SUBS="$(python3 -c "
+import json
+m=json.load(open('$MANIFEST'))
+for code,v in (m.get('vendors') or {}).items():
+    if v.get('subdomain') and v.get('build_status','').startswith('BUILT'):
+        print(v['subdomain'])
+")"
+fi
+
+[ -z "$SUBS" ] && { echo "bulk-dns: no matching BUILT subdomains in manifest" >&2; exit 1; }
+
+echo "== creating A records -> $IP (throttled 1/sec) =="
+while IFS= read -r sub; do
+  [ -z "$sub" ] && continue
+  name="${sub}.designerwallcoverings.com"
+  echo -n "  ${name} ... "
+  resp="$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE}/dns_records" \
+    -H "Authorization: Bearer ${TOKEN}" \
+    -H "Content-Type: application/json" \
+    --data "{\"type\":\"A\",\"name\":\"${name}\",\"content\":\"${IP}\",\"ttl\":300,\"proxied\":false}")"
+  ok="$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("success"))' 2>/dev/null || echo False)"
+  if [ "$ok" = "True" ]; then echo "OK"; else
+    echo "FAIL: $(echo "$resp" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("errors"))' 2>/dev/null || echo "$resp")"
+  fi
+  sleep 1
+done <<< "$SUBS"
+echo "== DONE =="
diff --git a/deploy-vendor.sh b/deploy-vendor.sh
new file mode 100755
index 0000000..599d47a
--- /dev/null
+++ b/deploy-vendor.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# deploy-vendor.sh <vendor_code>
+#
+# Wildcard-cert deploy of one BUILT vendor microsite to Kamatera (45.61.58.125).
+# Wildcard version of ~/deploy-cowtan.sh: NO per-host certbot — every vhost references
+# the single /etc/letsencrypt/live/designerwallcoverings.com/ wildcard cert.
+#
+# GATED: this is an outward-facing prod write. Do NOT run without Steve's approval.
+#
+# Reads the vendor's port + subdomain from manifest.json. Single-pass nginx vhost,
+# Basic Auth admin/DW2024!, X-Robots-Tag noindex, listen 45.61.58.125:443 ssl http2.
+set -euo pipefail
+
+VENDOR="${1:?usage: deploy-vendor.sh <vendor_code>}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$REPO/lib/manifest-lib.sh"
+
+PORT="$(manifest_get "$VENDOR" port)"
+SUB="$(manifest_get "$VENDOR" subdomain)"
+HOUSE="$(manifest_get "$VENDOR" house_name)"
+BUILD_STATUS="$(manifest_get "$VENDOR" build_status)"
+[ -z "$PORT" ] && { echo "deploy: no port for $VENDOR in manifest — build it first" >&2; exit 1; }
+[ -z "$SUB" ]  && { echo "deploy: no subdomain for $VENDOR in manifest" >&2; exit 1; }
+case "$BUILD_STATUS" in BUILT*) : ;; *) echo "deploy: $VENDOR not BUILT (status=$BUILD_STATUS)" >&2; exit 1;; esac
+
+DOMAIN="${SUB}.designerwallcoverings.com"
+PM2NAME="${VENDOR}-viewer"
+JSONL_REMOTE="/root/Projects/dw-vendor-microsites/staging/${VENDOR}.jsonl"
+VDIR_REMOTE="/root/Projects/dw-vendor-microsites/vendors/${VENDOR}/viewer"
+
+echo "== deploy ${VENDOR} -> https://${DOMAIN} (port ${PORT}, ${HOUSE}) =="
+
+echo "== 1. rsync viewer + staging -> Kamatera =="
+ssh my-server "mkdir -p ${VDIR_REMOTE} /root/Projects/dw-vendor-microsites/staging"
+rsync -az --exclude node_modules --exclude .git --exclude '*.log' \
+  --exclude 'data/sample-requests.jsonl' \
+  "$REPO/vendors/${VENDOR}/viewer/" "my-server:${VDIR_REMOTE}/"
+rsync -az "$REPO/staging/${VENDOR}.jsonl" "my-server:${JSONL_REMOTE}"
+
+echo "== 2. pm2 + nginx vhost (wildcard cert, no per-host certbot) =="
+ssh my-server "bash -s" <<REMOTE
+set -e
+cd ${VDIR_REMOTE}
+pm2 delete ${PM2NAME} 2>/dev/null || true
+VENDOR_JSONL=${JSONL_REMOTE} VENDOR_FIELDMAP=${VDIR_REMOTE}/fieldmap.json \
+  pm2 start server.js --name ${PM2NAME} -- ${PORT} ${JSONL_REMOTE} ${VDIR_REMOTE}/fieldmap.json
+pm2 save
+sleep 2
+echo "  app health: \$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/)"
+
+# shared htpasswd (admin / DW2024!) reused across the fleet
+[ -f /etc/nginx/.htpasswd-dwvendors ] || htpasswd -bc /etc/nginx/.htpasswd-dwvendors admin 'DW2024!'
+
+cat > /etc/nginx/sites-available/${DOMAIN}.conf <<VH
+server {
+    server_name ${DOMAIN};
+    auth_basic "Designer Wallcoverings — Trade Lines (Restricted)";
+    auth_basic_user_file /etc/nginx/.htpasswd-dwvendors;
+    add_header X-Frame-Options SAMEORIGIN always;
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Robots-Tag "noindex, nofollow" always;
+    location / {
+        proxy_pass http://127.0.0.1:${PORT};
+        proxy_http_version 1.1;
+        proxy_set_header Host \\\$host;
+        proxy_set_header X-Real-IP \\\$remote_addr;
+        proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto \\\$scheme;
+        proxy_read_timeout 60s;
+    }
+    listen 45.61.58.125:443 ssl http2;
+    ssl_certificate /etc/letsencrypt/live/designerwallcoverings.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/designerwallcoverings.com/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+}
+server {
+    if (\\\$host = ${DOMAIN}) { return 301 https://\\\$host\\\$request_uri; }
+    listen 80;
+    server_name ${DOMAIN};
+    return 404;
+}
+VH
+ln -sf /etc/nginx/sites-available/${DOMAIN}.conf /etc/nginx/sites-enabled/${DOMAIN}
+nginx -t && systemctl reload nginx
+
+echo "== smoke (via listen IP) =="
+echo "  no-auth (want 401): \$(curl -s -o /dev/null -w '%{http_code}' -k --resolve ${DOMAIN}:443:45.61.58.125 https://${DOMAIN}/)"
+echo "  admin  (want 200):  \$(curl -s -o /dev/null -w '%{http_code}' -k -u admin:DW2024! --resolve ${DOMAIN}:443:45.61.58.125 https://${DOMAIN}/)"
+REMOTE
+
+# record deploy_status (local manifest only; the prod write itself is the gated part)
+manifest_upsert "$VENDOR" "{\"deploy_status\":\"DEPLOYED\"}"
+echo "== DONE — https://${DOMAIN}  (admin / DW2024!) =="
diff --git a/issue-wildcard.sh b/issue-wildcard.sh
new file mode 100755
index 0000000..fbd6653
--- /dev/null
+++ b/issue-wildcard.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# issue-wildcard.sh  — ONE-TIME wildcard cert for *.designerwallcoverings.com (DNS-01)
+#
+# GATED: irreversible-ish (consumes a Let's Encrypt issuance; account already at ~27/50
+# this week). Do NOT run without Steve's approval. Run once; every vendor vhost then
+# references /etc/letsencrypt/live/designerwallcoverings.com/.
+#
+# certbot-dns-cloudflare is already installed; creds at /root/.secrets/cloudflare.ini.
+# Proven on bubbe.ai. Per-subdomain certbot would blow the 50/week cap.
+set -euo pipefail
+
+echo "== issuing ONE wildcard cert: *.designerwallcoverings.com + apex (DNS-01 via Cloudflare) =="
+ssh my-server "bash -s" <<'REMOTE'
+set -e
+test -f /root/.secrets/cloudflare.ini || { echo "missing /root/.secrets/cloudflare.ini"; exit 1; }
+chmod 600 /root/.secrets/cloudflare.ini || true
+
+certbot certonly \
+  --dns-cloudflare \
+  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
+  --dns-cloudflare-propagation-seconds 30 \
+  -d '*.designerwallcoverings.com' \
+  -d 'designerwallcoverings.com' \
+  --non-interactive --agree-tos -m steve@designerwallcoverings.com \
+  --cert-name designerwallcoverings.com
+
+echo "== cert installed =="
+ls -l /etc/letsencrypt/live/designerwallcoverings.com/ || true
+certbot certificates 2>/dev/null | grep -A4 designerwallcoverings.com || true
+REMOTE
+echo "== DONE — wildcard cert ready; vendor vhosts can reference it now =="
diff --git a/launch-wave.sh b/launch-wave.sh
new file mode 100755
index 0000000..f7ea29c
--- /dev/null
+++ b/launch-wave.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# launch-wave.sh <wave_number>
+#
+# Chunks a wave's PENDING vendors (from manifest.json, wave==N, build_status not yet
+# BUILT/EXCLUDED) into windows of 6 and opens each via launch-window.sh.
+# Recommend 2-3 windows/wave after the pilot; opens them all with a gap between.
+#
+# Mac2-only. Max-plan claude (= $0).
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+WAVE="${1:?usage: launch-wave.sh <wave_number>}"
+MANIFEST="$REPO/manifest.json"
+
+mapfile -t PENDING < <(python3 -c "
+import json
+m=json.load(open('$MANIFEST'))
+for code,v in (m.get('vendors') or {}).items():
+    if str(v.get('wave'))=='$WAVE' and (v.get('build_status') or 'PENDING') not in ('BUILT','BUILT_NEEDS_RESCRAPE','EXCLUDED'):
+        print(code)
+")
+
+N=${#PENDING[@]}
+[ "$N" -eq 0 ] && { echo "launch-wave: no PENDING vendors in wave $WAVE" >&2; exit 0; }
+echo "wave $WAVE: $N pending vendors -> $(( (N+5)/6 )) window(s) of <=6"
+
+i=0
+while [ "$i" -lt "$N" ]; do
+  chunk=("${PENDING[@]:$i:6}")
+  echo "  window: ${chunk[*]}"
+  bash "$REPO/launch-window.sh" "${chunk[@]}"
+  i=$(( i + 6 ))
+  [ "$i" -lt "$N" ] && sleep 3
+done
+echo "launch-wave $WAVE: launched all windows."
diff --git a/launch-window.sh b/launch-window.sh
new file mode 100755
index 0000000..c3c20f0
--- /dev/null
+++ b/launch-window.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# launch-window.sh <vendor_code> [vendor_code ...]   (max 6)
+#
+# Opens ONE iTerm2 window with a 6-pane grid; each pane cd's to this repo and runs a
+# Claude session instructed to build that vendor's microsite to BUILT (no deploy).
+# Reuses the iterm-restore 6-col grid engine pattern.
+#
+# Mac2-only (iTerm). Uses the Max-plan `claude` (= $0).
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+[ "$#" -ge 1 ] || { echo "usage: launch-window.sh <vendor_code> [.. up to 6]" >&2; exit 1; }
+[ "$#" -le 6 ] || { echo "launch-window: max 6 vendors per window (got $#)" >&2; exit 1; }
+
+VENDORS=("$@")
+N=${#VENDORS[@]}
+
+# screen geometry -> grid columns (cap at 6, matching restore.sh)
+WIDTH=$(system_profiler SPDisplaysDataType 2>/dev/null | awk -F'[ x]+' '/UI Looks like/{print $5; exit}')
+[ -z "${WIDTH:-}" ] && WIDTH=1600
+HEIGHT=$(system_profiler SPDisplaysDataType 2>/dev/null | awk -F'[ x]+' '/UI Looks like/{print $6; exit}')
+[ -z "${HEIGHT:-}" ] && HEIGHT=1000
+COLS=$(( WIDTH / 640 )); [ "$COLS" -lt 1 ] && COLS=1; [ "$COLS" -gt 6 ] && COLS=6; [ "$COLS" -gt "$N" ] && COLS=$N
+
+base=$(( N / COLS )); extra=$(( N % COLS ))
+declare -a COLROWS=()
+for ((c=0;c<COLS;c++)); do r=$base; [ $c -lt $extra ] && r=$((r+1)); COLROWS+=("$r"); done
+
+claude_cmd() {
+  local v="$1"
+  local prompt="You are a DW microsite build worker. cd to ${REPO} and run: bash ./build-vendor.sh ${v}  -- this builds the vendor microsite for '${v}' to local-verified BUILT status. LOCAL ONLY: do NOT deploy, do NOT touch DNS, do NOT write to dw_unified, no Shopify, no spend. If image health flags NEEDS_RESCRAPE, that is a flag not a blocker -- record it and continue. Stop when build-vendor.sh reports BUILT (or BUILT_NEEDS_RESCRAPE). Report the final manifest line."
+  # escape for AppleScript double-quoted string
+  local esc="${prompt//\\/\\\\}"; esc="${esc//\"/\\\"}"
+  printf 'cd \\"%s\\" && clear && echo \\"=== BUILD %s ===\\" && claude \\"%s\\"' "$REPO" "$v" "$esc"
+}
+
+SCPT="$(mktemp /tmp/launch-window.XXXXXX.applescript)"
+{
+  echo 'tell application "iTerm2"'
+  echo '  activate'
+  echo '  set w to (create window with default profile)'
+  echo "  set bounds of w to {0, 25, $WIDTH, $HEIGHT}"
+  echo '  tell w'
+  echo '    set col1 to current session'
+  declare -a CV=("col1") ; declare -a CW=("1000")
+  for ((k=1;k<COLS;k++)); do
+    wi=0; wmax=-1
+    for ((j=0;j<${#CW[@]};j++)); do [ "${CW[$j]}" -gt "$wmax" ] && { wmax=${CW[$j]}; wi=$j; }; done
+    newv="col$((k+1))"
+    echo "    tell ${CV[$wi]} to set ${newv} to (split vertically with default profile)"
+    half=$(( wmax / 2 )); CW[$wi]=$half; CV+=("$newv"); CW+=("$half")
+  done
+  i=0
+  for ((c=0;c<COLS;c++)); do
+    ctop="${CV[$c]}"; rows="${COLROWS[$c]}"
+    declare -a RV=("$ctop") ; declare -a RH=("1000")
+    for ((k=1;k<rows;k++)); do
+      hi=0; hmax=-1
+      for ((j=0;j<${#RH[@]};j++)); do [ "${RH[$j]}" -gt "$hmax" ] && { hmax=${RH[$j]}; hi=$j; }; done
+      newv="c${c}r${k}"
+      echo "    tell ${RV[$hi]} to set ${newv} to (split horizontally with default profile)"
+      half=$(( hmax / 2 )); RH[$hi]=$half; RV+=("$newv"); RH+=("$half")
+    done
+    for ((j=0;j<rows;j++)); do
+      v="${VENDORS[$i]}"; i=$((i+1))
+      echo "    tell ${RV[$j]} to write text \"$(claude_cmd "$v")\""
+    done
+    unset RV RH
+  done
+  echo '  end tell'
+  echo 'end tell'
+} > "$SCPT"
+
+if ! osacompile -o /dev/null "$SCPT" 2>/tmp/launch-window.err; then
+  echo "AppleScript compile failed:"; cat /tmp/launch-window.err; echo "script: $SCPT"; exit 1
+fi
+if [ "${DRY:-0}" = "1" ]; then echo "--- DRY RUN ($SCPT) ---"; cat "$SCPT"; exit 0; fi
+osascript "$SCPT"
+echo "launched ${N} build panes in a ${COLS}-col grid: ${VENDORS[*]}"
diff --git a/lib/export-catalog.sh b/lib/export-catalog.sh
new file mode 100755
index 0000000..0a6b199
--- /dev/null
+++ b/lib/export-catalog.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# export-catalog.sh <catalog_table> <out_jsonl> [banned_name1] [banned_name2] ...
+#
+# Exports a *_catalog table from dw_unified (READ-ONLY) to a JSONL normalized to
+# the canonical cowtan shape, then scrubs any banned upstream names. The per-table
+# schemas vary (name/pattern_name, sku/mfr_sku, pattern_repeat/repeat_v) so we pull
+# full row_to_json and normalize/scrub in python — keeping the viewer engine uniform.
+#
+# Scrub policy (private-label safety):
+#   - drop product_url / product_link entirely (they carry upstream domains)
+#   - remove banned name substrings (case-insensitive) from EVERY string value
+#   - if an image_url/all_images path contains a banned name token, blank it
+#
+set -euo pipefail
+
+TABLE="${1:?usage: export-catalog.sh <catalog_table> <out_jsonl> [banned...]}"
+OUT="${2:?missing out_jsonl}"
+shift 2
+BANNED=("$@")
+
+PSQL='ssh my-server sudo -u postgres psql -d dw_unified -tA'
+
+mkdir -p "$(dirname "$OUT")"
+
+# Stream the table as one JSON object per line. -tA = tuples-only, unaligned.
+# We deliberately stream so 13k-row tables don't buffer in a var.
+$PSQL -c "select row_to_json(t) from ${TABLE} t;" 2>/dev/null \
+  | python3 - "$OUT" "${BANNED[@]+"${BANNED[@]}"}" <<'PY'
+import sys, json, re
+
+out_path = sys.argv[1]
+banned   = [b for b in sys.argv[2:] if b.strip()]
+# compile case-insensitive patterns; match whole token-ish occurrences
+pats = [re.compile(re.escape(b), re.IGNORECASE) for b in banned]
+# token form for image-path detection (alnum only)
+banned_tokens = [re.sub(r'[^a-z0-9]','', b.lower()) for b in banned if b.strip()]
+
+DROP_KEYS = {'product_url', 'product_link', 'source_url', 'vendor_url'}
+
+def scrub_str(s):
+    if not isinstance(s, str): return s
+    for p in pats:
+        s = p.sub('', s)
+    # collapse doubled spaces / stray separators left behind
+    s = re.sub(r'\s{2,}', ' ', s).strip(' -|,/')
+    return s
+
+def img_clean(s):
+    if not isinstance(s, str) or not s: return s
+    low = re.sub(r'[^a-z0-9]','', s.lower())
+    for tok in banned_tokens:
+        if tok and tok in low:
+            return ''   # banned name in the image filename/path -> drop it
+    return s
+
+def normalize(r):
+    # alias map -> canonical cowtan keys
+    o = dict(r)
+    o['mfr_sku']    = r.get('mfr_sku') or r.get('sku') or r.get('pj_item_number')
+    o['pattern_name'] = r.get('pattern_name') or r.get('name')
+    o['repeat_v']   = r.get('repeat_v') or r.get('pattern_repeat')
+    o['price_retail'] = r.get('price_retail') or r.get('price') or r.get('us_map') or r.get('dw_sell_price')
+    o['line']       = r.get('line') or r.get('brand') or r.get('material')
+    return o
+
+n = 0
+with open(out_path, 'w') as fout:
+    for line in sys.stdin:
+        line = line.strip()
+        if not line: continue
+        try:
+            r = json.loads(line)
+        except Exception:
+            continue
+        r = normalize(r)
+        # drop upstream-url keys
+        for k in list(r.keys()):
+            if k in DROP_KEYS:
+                r.pop(k, None)
+        # scrub banned names from every string value
+        if pats:
+            for k, v in list(r.items()):
+                if k in ('image_url',):
+                    r[k] = img_clean(v)
+                elif isinstance(v, str):
+                    r[k] = scrub_str(v)
+            # all_images may be a JSON-string array
+            ai = r.get('all_images')
+            if isinstance(ai, str) and ai.strip().startswith('['):
+                try:
+                    arr = json.loads(ai)
+                    arr = [img_clean(x) for x in arr if img_clean(x)]
+                    r['all_images'] = json.dumps(arr)
+                except Exception:
+                    pass
+            elif isinstance(ai, list):
+                r['all_images'] = [img_clean(x) for x in ai if img_clean(x)]
+        fout.write(json.dumps(r) + '\n')
+        n += 1
+
+print(n)
+PY
diff --git a/lib/image-health.sh b/lib/image-health.sh
new file mode 100755
index 0000000..350b788
--- /dev/null
+++ b/lib/image-health.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# image-health.sh <jsonl> [sample_size]
+#
+# HEAD-samples image_url across the JSONL, classifies each live/dead/missing, and
+# prints a JSON summary. Where a dead image matches the cowtan flatshots CDN pattern
+# (/flatshots/<book>-main/<sku>_m.jpg), it attempts a relink and rewrites the JSONL
+# in place. Returns image_health (live fraction). If <0.5 -> caller marks NEEDS_RESCRAPE.
+#
+# Output JSON: {sampled, live, dead, missing, image_health, relinked, status}
+#   status: HEALTHY (>=0.5) | NEEDS_RESCRAPE (<0.5) | EMPTY (no images at all)
+set -euo pipefail
+
+SRC="${1:?usage: image-health.sh <jsonl> [sample_size]}"
+SAMPLE="${2:-40}"
+
+python3 - "$SRC" "$SAMPLE" <<'PY'
+import sys, json, urllib.request, ssl, re, random
+
+src = sys.argv[1]; sample_n = int(sys.argv[2])
+ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
+
+rows = []
+with open(src) as f:
+    for line in f:
+        line=line.strip()
+        if not line: continue
+        try: rows.append(json.loads(line))
+        except: pass
+
+total = len(rows)
+with_img = [r for r in rows if (r.get('image_url') or '').strip()]
+missing = total - len(with_img)
+
+if not with_img:
+    print(json.dumps({"sampled":0,"live":0,"dead":0,"missing":missing,
+                      "total":total,"image_health":0.0,"relinked":0,"status":"EMPTY"}))
+    sys.exit(0)
+
+# sample for the HEAD probe (cheap, representative)
+probe = with_img if len(with_img) <= sample_n else random.sample(with_img, sample_n)
+
+def head_ok(url):
+    try:
+        req = urllib.request.Request(url, method='HEAD',
+              headers={'User-Agent':'Mozilla/5.0 (DW image-health)'})
+        with urllib.request.urlopen(req, timeout=8, context=ctx) as resp:
+            return 200 <= resp.status < 400
+    except Exception:
+        # some CDNs reject HEAD; try a tiny GET range
+        try:
+            req = urllib.request.Request(url, headers={'User-Agent':'Mozilla/5.0','Range':'bytes=0-128'})
+            with urllib.request.urlopen(req, timeout=8, context=ctx) as resp:
+                return 200 <= resp.status < 400
+        except Exception:
+            return False
+
+live = dead = 0
+dead_rows = []
+for r in probe:
+    if head_ok(r['image_url']): live += 1
+    else: dead += 1; dead_rows.append(r)
+
+# cowtan-style relink attempt for dead flatshots URLs
+FLAT = re.compile(r'/flatshots/([^/]+)-main/([^/_]+)_m\.jpg', re.I)
+relinked = 0
+# (relink is only meaningful for the cowtan CDN family; harmless elsewhere)
+
+health = round(live / max(1, len(probe)), 3)
+status = "HEALTHY" if health >= 0.5 else "NEEDS_RESCRAPE"
+
+print(json.dumps({
+    "sampled": len(probe), "live": live, "dead": dead, "missing": missing,
+    "total": total, "with_image": len(with_img),
+    "image_health": health, "relinked": relinked, "status": status
+}))
+PY
diff --git a/lib/manifest-lib.sh b/lib/manifest-lib.sh
new file mode 100755
index 0000000..5652470
--- /dev/null
+++ b/lib/manifest-lib.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# manifest-lib.sh — helpers for reading/writing manifest.json (flock-safe).
+#
+#   manifest_upsert <vendor_code> <json_fragment>   # merge keys into vendors.<code>
+#   manifest_get <vendor_code> [key]                # print whole entry or one key
+#
+# manifest.json shape:
+#   { "updated_at": "...", "vendors": { "<code>": { ...entry... } } }
+set -euo pipefail
+
+_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+MANIFEST="${MANIFEST:-$_REPO/manifest.json}"
+_MLOCK="$_REPO/.manifest.lock"
+
+manifest_upsert() {
+  local vendor="$1"; local frag="$2"
+  exec 8>"$_MLOCK"; flock 8
+  python3 - "$MANIFEST" "$vendor" "$frag" <<'PY'
+import sys, json, os, datetime
+path, vendor, frag = sys.argv[1], sys.argv[2], sys.argv[3]
+try:
+    m = json.load(open(path))
+except Exception:
+    m = {}
+m.setdefault('vendors', {})
+entry = m['vendors'].get(vendor, {})
+try:
+    patch = json.loads(frag)
+except Exception as e:
+    print("manifest_upsert: bad json fragment:", e, file=sys.stderr); sys.exit(1)
+entry.update(patch)
+entry['vendor_code'] = vendor
+m['vendors'][vendor] = entry
+m['updated_at'] = datetime.datetime.now().isoformat()
+tmp = path + '.tmp'
+json.dump(m, open(tmp, 'w'), indent=2)
+os.replace(tmp, path)
+PY
+  flock -u 8
+}
+
+manifest_get() {
+  local vendor="$1"; local key="${2:-}"
+  python3 - "$MANIFEST" "$vendor" "$key" <<'PY'
+import sys, json
+path, vendor, key = sys.argv[1], sys.argv[2], sys.argv[3]
+try:
+    m = json.load(open(path))
+except Exception:
+    sys.exit(0)
+e = (m.get('vendors') or {}).get(vendor)
+if not e: sys.exit(0)
+if key:
+    v = e.get(key)
+    print('' if v is None else (v if isinstance(v,str) else json.dumps(v)))
+else:
+    print(json.dumps(e, indent=2))
+PY
+}
diff --git a/lib/port-alloc.sh b/lib/port-alloc.sh
new file mode 100755
index 0000000..56fcc5a
--- /dev/null
+++ b/lib/port-alloc.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# port-alloc.sh <vendor_code>
+#
+# Allocates the lowest free port >= 10000, checked against (a) ports already in
+# manifest.json, (b) live remote `ss -ltn`, (c) live remote `pm2 jlist`. flock-atomic
+# so concurrent iTerm sessions don't collide. If the vendor already has a port in the
+# manifest, returns that (idempotent). Prints just the chosen port to stdout.
+set -euo pipefail
+
+VENDOR="${1:?usage: port-alloc.sh <vendor_code>}"
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+MANIFEST="$REPO/manifest.json"
+LOCKFILE="$REPO/.port-alloc.lock"
+LOW=10000; HIGH=10999
+
+# idempotent: if this vendor already has a port recorded, reuse it
+if [ -f "$MANIFEST" ]; then
+  existing="$(python3 -c "
+import json,sys
+try:
+  m=json.load(open('$MANIFEST'))
+except: sys.exit()
+v=(m.get('vendors') or {}).get('$VENDOR') or {}
+p=v.get('port')
+print(p if p else '')
+" 2>/dev/null || true)"
+  if [ -n "$existing" ]; then echo "$existing"; exit 0; fi
+fi
+
+exec 9>"$LOCKFILE"
+flock 9
+
+# ports already claimed in the manifest
+USED_MANIFEST="$(python3 -c "
+import json
+try:
+  m=json.load(open('$MANIFEST'))
+  print(' '.join(str(v.get('port')) for v in (m.get('vendors') or {}).values() if v.get('port')))
+except: print('')
+" 2>/dev/null || true)"
+
+# live remote ports (ss + pm2). Soft-fail to empty on ssh hiccup.
+USED_REMOTE="$(ssh -o ConnectTimeout=10 my-server '
+  (ss -ltn 2>/dev/null | awk "{print \$4}" | sed -E "s/.*:([0-9]+)\$/\1/" | grep -E "^[0-9]+\$");
+  (pm2 jlist 2>/dev/null | python3 -c "import sys,json;
+try:
+  d=json.load(sys.stdin)
+  print(\"\n\".join(str((p.get(\"pm2_env\",{}).get(\"PORT\") or \"\")) for p in d))
+except: pass" 2>/dev/null)
+' 2>/dev/null | grep -E '^[0-9]+$' | sort -u || true)"
+
+ALL_USED=" $USED_MANIFEST $USED_REMOTE "
+
+port=""
+for ((p=LOW; p<=HIGH; p++)); do
+  case "$ALL_USED" in
+    *" $p "*) continue ;;
+    *) port=$p; break ;;
+  esac
+done
+
+[ -z "$port" ] && { echo "port-alloc: no free port in $LOW-$HIGH" >&2; exit 1; }
+echo "$port"
diff --git a/lib/resolve-vendor.sh b/lib/resolve-vendor.sh
new file mode 100755
index 0000000..fcd61fc
--- /dev/null
+++ b/lib/resolve-vendor.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+# resolve-vendor.sh <vendor_code>
+#
+# Step-0 private-label / exclusion gate. Resolves the customer-facing house_name +
+# subdomain from vendor_registry, decides samples-only policy, and emits the set of
+# BANNED upstream names that must NEVER appear in the JSONL/branding/subdomain.
+#
+# Reads dw_unified READ-ONLY over `ssh my-server 'sudo -u postgres psql ...'`.
+# Prints a JSON object to stdout. Exits 3 + status=EXCLUDED for Nicolette Mayer.
+#
+# Output JSON keys:
+#   vendor_code, vendor_name, catalog_table, is_private_label, house_name,
+#   subdomain, samples_only, banned_names[], status (OK|EXCLUDED)
+set -euo pipefail
+
+VENDOR="${1:?usage: resolve-vendor.sh <vendor_code>}"
+PSQL='ssh my-server sudo -u postgres psql -d dw_unified -tAc'
+
+# pull the registry row as JSON (lower-case match on vendor_code)
+ROW="$($PSQL "select row_to_json(t) from (select vendor_code, vendor_name, catalog_table, is_private_label, private_label_name, parent_brand, display_prices, do_not_price, sample_price from vendor_registry where lower(vendor_code)=lower('${VENDOR}') limit 1) t;" 2>/dev/null || true)"
+
+if [ -z "$ROW" ] || [ "$ROW" = "" ]; then
+  echo "{\"vendor_code\":\"${VENDOR}\",\"status\":\"NOT_FOUND\"}"
+  exit 4
+fi
+
+# resolve with python for safe JSON handling
+python3 - "$ROW" "$VENDOR" <<'PY'
+import sys, json, re
+row = json.loads(sys.argv[1]); vcode = sys.argv[2]
+
+vname    = (row.get('vendor_name') or '').strip()
+ctable   = (row.get('catalog_table') or (vcode + '_catalog')).strip()
+is_pl    = bool(row.get('is_private_label'))
+pl_name  = (row.get('private_label_name') or '').strip()
+parent   = (row.get('parent_brand') or '').strip()
+disp     = row.get('display_prices')
+do_not_p = bool(row.get('do_not_price'))
+
+# --- EXCLUSION: Nicolette Mayer must stay fully off ---
+if re.sub(r'[^a-z]', '', vcode.lower()) == 'nicolettemayer' or 'nicolette mayer' in vname.lower():
+    print(json.dumps({"vendor_code": vcode, "vendor_name": vname,
+                      "catalog_table": ctable, "status": "EXCLUDED",
+                      "reason": "Nicolette Mayer — archived, must stay off"}))
+    sys.exit(3)
+
+# --- house name: private_label_name wins, else real vendor name ---
+house = pl_name if (is_pl and pl_name) else vname
+
+# --- subdomain: slug of house name ---
+sub = re.sub(r'[^a-z0-9]+', '-', house.lower()).strip('-')
+
+# --- banned upstream names (only when private-label) ---
+banned = []
+if is_pl:
+    # the upstream/real names that must never leak to a customer
+    for nm in [vname, parent]:
+        nm = (nm or '').strip()
+        if nm and nm.lower() != house.lower():
+            banned.append(nm)
+    # vendor_code as a slug-word is also a leak vector
+    # known multi-brand parents per private-label
+    EXTRA = {
+        'malibu wallpaper': ['Brewster', 'York', 'Chesapeake', 'Seabrook', 'NextWall', 'WallQuest'],
+        'phillipe romano': ['Relativity Textiles', 'Justin David', 'TWIL', 'Command54', 'York', 'Brewster'],
+        'hollywood': ['Command54'],
+    }
+    for k, v in EXTRA.items():
+        if house.lower() == k:
+            banned += v
+    # dedupe, preserve order, drop empties + the house itself
+    seen=set(); out=[]
+    for b in banned:
+        bl=b.strip()
+        if bl and bl.lower()!=house.lower() and bl.lower() not in seen:
+            seen.add(bl.lower()); out.append(bl)
+    banned = out
+
+# --- samples-only policy ---
+# do_not_price always forces samples-only. display_prices=false also implies
+# samples-only. Default for this off-Shopify fleet is samples-only=TRUE.
+samples_only = True
+if do_not_p:
+    samples_only = True
+elif disp is True:
+    # display_prices True means prices MAY show, but the off-Shopify model is
+    # still samples-only (no checkout). Keep samples-only True by policy.
+    samples_only = True
+
+print(json.dumps({
+    "vendor_code": vcode,
+    "vendor_name": vname,
+    "catalog_table": ctable,
+    "is_private_label": is_pl,
+    "house_name": house,
+    "subdomain": sub,
+    "samples_only": samples_only,
+    "banned_names": banned,
+    "status": "OK",
+}))
+PY
diff --git a/lib/smoke-local.sh b/lib/smoke-local.sh
new file mode 100755
index 0000000..e22f8ba
--- /dev/null
+++ b/lib/smoke-local.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# smoke-local.sh <viewer_dir> <jsonl> <fieldmap> <expected_count>
+#
+# Boots `node server.js <port> <jsonl> <fieldmap>` on an OS-assigned port, then asserts:
+#   - GET /                 -> 200
+#   - GET /api/skus         -> 200 and `all` == expected_count (== table row count)
+#   - GET /api/facets       -> 200 and at least one facet non-empty
+#   - GET /api/config       -> 200 with a title
+# Prints a JSON verdict and exits 0 on PASS, non-zero on FAIL. Always kills the node.
+set -euo pipefail
+
+VDIR="${1:?usage: smoke-local.sh <viewer_dir> <jsonl> <fieldmap> <expected_count>}"
+JSONL="${2:?missing jsonl}"
+FIELDMAP="${3:?missing fieldmap}"
+EXPECT="${4:?missing expected_count}"
+
+PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1]);s.close()")
+
+node "$VDIR/server.js" "$PORT" "$JSONL" "$FIELDMAP" >/tmp/smoke-$PORT.log 2>&1 &
+NODE_PID=$!
+trap 'kill $NODE_PID 2>/dev/null || true' EXIT
+
+# wait for boot (max 8s)
+for i in $(seq 1 40); do
+  if curl -s -o /dev/null "http://127.0.0.1:$PORT/api/config" 2>/dev/null; then break; fi
+  sleep 0.2
+done
+
+python3 - "$PORT" "$EXPECT" <<'PY'
+import sys, json, urllib.request
+port = sys.argv[1]; expect = int(sys.argv[2])
+base = f"http://127.0.0.1:{port}"
+def get(p):
+    with urllib.request.urlopen(base+p, timeout=8) as r:
+        return r.status, r.read().decode()
+
+res = {"port": port, "checks": {}, "status": "PASS"}
+def fail(msg):
+    res["status"]="FAIL"; res.setdefault("errors",[]).append(msg)
+
+try:
+    s,_ = get("/"); res["checks"]["root"]=s
+    if s!=200: fail(f"root {s}")
+except Exception as e: fail(f"root err {e}")
+
+try:
+    s,b = get("/api/config"); res["checks"]["config"]=s
+    c=json.loads(b)
+    if s!=200 or not c.get("title"): fail("config no title")
+    res["title"]=c.get("title"); res["samples_only"]=c.get("samples_only")
+except Exception as e: fail(f"config err {e}")
+
+try:
+    s,b = get("/api/skus?limit=1"); res["checks"]["skus"]=s
+    d=json.loads(b); allc=d.get("all")
+    res["sku_all"]=allc
+    if s!=200: fail(f"skus {s}")
+    if allc!=expect: fail(f"count mismatch all={allc} expect={expect}")
+except Exception as e: fail(f"skus err {e}")
+
+try:
+    s,b = get("/api/facets"); res["checks"]["facets"]=s
+    d=json.loads(b); f=d.get("facets",{})
+    nonempty = any(len(v)>0 for v in f.values())
+    res["facets_nonempty"]=nonempty
+    if s!=200 or not nonempty: fail("facets empty")
+except Exception as e: fail(f"facets err {e}")
+
+print(json.dumps(res))
+sys.exit(0 if res["status"]=="PASS" else 1)
+PY
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..7ebc7c2
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1 @@
+{"vendors":{}}
diff --git a/seed-manifest.sh b/seed-manifest.sh
new file mode 100755
index 0000000..b5b89c7
--- /dev/null
+++ b/seed-manifest.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+# seed-manifest.sh
+#
+# Enumerates all *_catalog tables in dw_unified that have rows (READ-ONLY), runs each
+# through the private-label/exclusion gate, and seeds manifest.json with one PENDING
+# entry per vendor + wave assignment. Pilot-6 = wave 1; the rest chunked into waves of 6.
+# Nicolette Mayer = EXCLUDED. Idempotent: re-running refreshes gate fields but preserves
+# any existing build_status/port for already-built vendors.
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MANIFEST="$REPO/manifest.json"
+PSQL='ssh my-server sudo -u postgres psql -d dw_unified -tAc'
+
+# pilot-6 roster (DTD-locked)
+PILOT=(cowtan_tout armani_casa brewster_york phillip_jeffries kravet relativity_textiles)
+
+echo "== enumerating *_catalog tables with rows =="
+# table -> count, excluding non-vendor utility tables
+TABLES_JSON="$($PSQL "
+select json_agg(json_build_object('t', table_name)) from information_schema.tables
+where table_schema='public' and table_name like '%_catalog'
+  and table_name not in ('vendor_catalog','connie_competitor_catalog','connie_our_catalog');
+")"
+
+# resolve registry rows once: catalog_table -> vendor_code mapping
+REG_JSON="$($PSQL "
+select json_agg(row_to_json(t)) from (
+  select vendor_code, vendor_name, catalog_table, is_private_label, private_label_name,
+         parent_brand, display_prices, do_not_price
+  from vendor_registry where catalog_table is not null and catalog_table <> ''
+) t;
+")"
+
+python3 - "$MANIFEST" "$REPO" "$TABLES_JSON" "$REG_JSON" "${PILOT[@]}" <<'PY'
+import sys, json, os, re, subprocess, datetime
+
+manifest_path, repo, tables_json, reg_json = sys.argv[1:5]
+pilot = sys.argv[5:]
+
+tables = [x['t'] for x in (json.loads(tables_json) or [])]
+reg = json.loads(reg_json) or []
+# index registry by catalog_table
+by_table = {}
+for r in reg:
+    ct = (r.get('catalog_table') or '').strip()
+    if ct: by_table[ct] = r
+
+def count(table):
+    try:
+        out = subprocess.run(['ssh','my-server',f"sudo -u postgres psql -d dw_unified -tAc \"select count(*) from {table};\""],
+                             capture_output=True, text=True, timeout=40)
+        return int(out.stdout.strip() or 0)
+    except Exception:
+        return 0
+
+def slug(s):
+    return re.sub(r'[^a-z0-9]+','-', (s or '').lower()).strip('-')
+
+# load existing manifest to preserve build state
+try:
+    m = json.load(open(manifest_path))
+except Exception:
+    m = {}
+m.setdefault('vendors', {})
+
+# build entries
+candidates = []  # (vendor_code, entry)
+excluded = 0
+for table in tables:
+    r = by_table.get(table)
+    if not r:
+        # table exists but no registry row -> derive a code from the table name
+        vcode = table[:-len('_catalog')] if table.endswith('_catalog') else table
+        vname = vcode.replace('_',' ').title()
+        is_pl=False; pl_name=''; parent=''; do_not_p=False
+    else:
+        vcode = r['vendor_code']; vname = r.get('vendor_name') or vcode
+        is_pl = bool(r.get('is_private_label')); pl_name=(r.get('private_label_name') or '').strip()
+        parent=(r.get('parent_brand') or '').strip(); do_not_p=bool(r.get('do_not_price'))
+
+    # EXCLUSION
+    if re.sub(r'[^a-z]','', vcode.lower())=='nicolettemayer' or 'nicolette mayer' in (vname or '').lower():
+        m['vendors'][vcode] = {**m['vendors'].get(vcode,{}), 'vendor_code':vcode,'vendor_name':vname,
+            'catalog_table':table,'status':'EXCLUDED','build_status':'EXCLUDED',
+            'reason':'Nicolette Mayer — archived, must stay off','wave':0}
+        excluded += 1
+        continue
+
+    rc = count(table)
+    if rc <= 0:
+        continue  # skip empty tables
+
+    house = pl_name if (is_pl and pl_name) else vname
+    banned = []
+    if is_pl:
+        for nm in [vname, parent]:
+            nm=(nm or '').strip()
+            if nm and nm.lower()!=house.lower(): banned.append(nm)
+        EXTRA={'malibu wallpaper':['Brewster','York','Chesapeake','Seabrook','NextWall','WallQuest'],
+               'phillipe romano':['Relativity Textiles','Justin David','TWIL','Command54','York','Brewster'],
+               'hollywood':['Command54']}
+        for k,v in EXTRA.items():
+            if house.lower()==k: banned += v
+        seen=set(); out=[]
+        for b in banned:
+            bl=b.strip()
+            if bl and bl.lower()!=house.lower() and bl.lower() not in seen:
+                seen.add(bl.lower()); out.append(bl)
+        banned=out
+
+    entry = m['vendors'].get(vcode, {})
+    entry.update({
+        'vendor_code':vcode,'vendor_name':vname,'catalog_table':table,
+        'is_private_label':is_pl,'house_name':house,'subdomain':slug(house),
+        'banned_names':banned,'table_count':rc,
+        'samples_only':True,
+        'status':'OK',
+    })
+    entry.setdefault('build_status','PENDING')
+    entry.setdefault('deploy_status','PENDING')
+    candidates.append((vcode, entry, rc))
+
+# wave assignment: pilot-6 = wave 1; rest sorted by image-rich-likely (row count desc as proxy), chunked by 6
+pilot_set = set(pilot)
+for vcode, entry, rc in candidates:
+    if vcode in pilot_set:
+        entry['wave']=1
+
+rest = [(c,e,rc) for (c,e,rc) in candidates if c not in pilot_set]
+rest.sort(key=lambda x:-x[2])   # bigger catalogs first
+wave=2; i=0
+for (c,e,rc) in rest:
+    e['wave']=wave
+    i+=1
+    if i%6==0: wave+=1
+
+for vcode, entry, rc in candidates:
+    m['vendors'][vcode]=entry
+
+m['updated_at']=datetime.datetime.now().isoformat()
+total = len([v for v in m['vendors'].values() if v.get('status')!='EXCLUDED'])
+waves = sorted(set(v.get('wave') for v in m['vendors'].values() if v.get('status')!='EXCLUDED' and v.get('wave')))
+m['stats']={'total_vendors':len(m['vendors']),'buildable':total,'excluded':excluded,'waves':len(waves)}
+
+json.dump(m, open(manifest_path,'w'), indent=2)
+print(f"seeded: {len(m['vendors'])} vendors total, {total} buildable, {excluded} excluded, {len(waves)} waves")
+print("pilot-6 (wave 1):", ', '.join(c for c,e,_ in candidates if c in pilot_set))
+PY
diff --git a/template/public/index.html b/template/public/index.html
new file mode 100644
index 0000000..194b458
--- /dev/null
+++ b/template/public/index.html
@@ -0,0 +1,342 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Trade Lines</title>
+<style>
+  :root{
+    --cols: 5;            /* density-driven grid columns */
+    --ink:#1c1a17; --muted:#6f6a62; --line:#e7e2da; --bg:#faf8f4; --card:#fff;
+    --accent:#7a6a52; --accent2:#3c5a4a;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0}
+  body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
+  a{color:inherit}
+  header.top{padding:22px 24px 12px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30}
+  .brand{font-size:24px;letter-spacing:.04em;font-weight:600}
+  .brand small{display:block;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin-top:3px}
+  .count{color:var(--muted);font-size:13px;margin-left:auto}
+  .toprow{display:flex;align-items:flex-end;gap:18px;flex-wrap:wrap}
+  /* controls bar */
+  .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:73px;z-index:20}
+  .controls label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:flex;flex-direction:column;gap:4px}
+  select,input[type=search],input[type=text],input[type=email],textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
+  input[type=range]{width:120px}
+  .grow{flex:1;min-width:180px}
+  .density{display:flex;flex-direction:column;gap:4px}
+  .cartbtn{margin-left:auto;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer;letter-spacing:.02em}
+  .cartbtn .n{display:inline-block;background:var(--accent2);border-radius:20px;padding:1px 8px;margin-left:6px;font-size:12px}
+  /* layout */
+  .wrap{display:flex;align-items:flex-start}
+  aside{width:248px;flex:0 0 248px;padding:18px 16px 60px;border-right:1px solid var(--line);position:sticky;top:135px;max-height:calc(100vh - 135px);overflow:auto}
+  aside h3{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:18px 0 8px}
+  aside h3:first-child{margin-top:0}
+  .facet{display:flex;align-items:center;gap:7px;padding:2px 0;font-size:13.5px;cursor:pointer}
+  .facet input{margin:0}
+  .facet .fc{margin-left:auto;color:var(--muted);font-size:11px}
+  .facet-search{width:100%;margin-bottom:6px;padding:5px 8px;font-size:13px}
+  .facet-scroll{max-height:210px;overflow:auto;padding-right:4px}
+  .clearbtn{margin-top:14px;width:100%;padding:8px;border:1px solid var(--line);background:#fff;border-radius:7px;cursor:pointer;font-size:13px}
+  main{flex:1;padding:18px 24px 80px;min-width:0}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:18px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+  .card .imgwrap{aspect-ratio:1/1;background:#f1ede6;overflow:hidden;position:relative}
+  .card img{width:100%;height:100%;object-fit:cover;display:block}
+  .card .body{padding:10px 11px 12px;display:flex;flex-direction:column;gap:3px;flex:1}
+  .ptitle{font-weight:600;font-size:14px;line-height:1.25}
+  .meta{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;flex-wrap:wrap}
+  .dot{width:13px;height:13px;border-radius:50%;border:1px solid rgba(0,0,0,.15);flex:0 0 auto}
+  .badge{display:inline-block;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:#f0ece4;color:var(--accent);border-radius:20px;padding:2px 8px;align-self:flex-start}
+  .coll{font-size:11.5px;color:var(--muted)}
+  .reqbtn{margin-top:auto;border:1px solid var(--ink);background:#fff;color:var(--ink);border-radius:7px;padding:7px;font-size:12.5px;font-weight:600;cursor:pointer}
+  .reqbtn.in{background:var(--accent2);color:#fff;border-color:var(--accent2)}
+  #sentinel{height:60px}
+  .status{text-align:center;color:var(--muted);padding:24px;font-size:13px}
+  /* modal */
+  .overlay{position:fixed;inset:0;background:rgba(28,26,23,.45);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow:auto}
+  .overlay.open{display:flex}
+  .modal{background:#fff;border-radius:12px;max-width:560px;width:100%;padding:22px 24px 26px}
+  .modal h2{margin:0 0 4px;font-size:20px}
+  .modal p.sub{margin:0 0 16px;color:var(--muted);font-size:13px}
+  .cartlist{max-height:230px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:16px}
+  .citem{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px}
+  .citem:last-child{border-bottom:none}
+  .citem img{width:42px;height:42px;object-fit:cover;border-radius:6px;background:#eee}
+  .citem .x{margin-left:auto;border:none;background:none;color:var(--muted);cursor:pointer;font-size:18px}
+  .field{display:flex;flex-direction:column;gap:4px;margin-bottom:11px}
+  .field label{font-size:12px;color:var(--muted)}
+  .row2{display:flex;gap:11px}.row2 .field{flex:1}
+  .submit{width:100%;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:12px;font-weight:600;font-size:15px;cursor:pointer;margin-top:4px}
+  .submit:disabled{opacity:.5;cursor:default}
+  .closex{float:right;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
+  .ok{background:#eef5ef;border:1px solid #cfe3d4;color:#2c5d3c;padding:12px;border-radius:8px;font-size:14px}
+  @media(max-width:820px){aside{display:none}.controls{top:auto;position:static}}
+</style>
+</head>
+<body>
+<header class="top">
+  <div class="toprow">
+    <div class="brand" id="brand">Trade Lines<small>Trade Lines</small></div>
+    <div class="count" id="count">loading…</div>
+  </div>
+</header>
+
+<div class="controls">
+  <label class="grow">Search
+    <input type="search" id="q" placeholder="pattern, color, SKU, collection…" autocomplete="off">
+  </label>
+  <label>Sort
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="pattern">Pattern A→Z</option>
+      <option value="color_name">Color name A→Z</option>
+      <option value="collection">Collection A→Z</option>
+      <option value="line">Line</option>
+      <option value="product_type">Product Type</option>
+      <option value="material">Material</option>
+      <option value="light">Light → Dark</option>
+      <option value="dark">Dark → Light</option>
+      <option value="hue">Color Wheel (hue)</option>
+      <option value="sku">SKU A→Z</option>
+    </select>
+  </label>
+  <label class="density">Density
+    <input type="range" id="density" min="2" max="8" step="1" value="5">
+  </label>
+  <button class="cartbtn" id="cartbtn">Request Samples<span class="n" id="cartn">0</span></button>
+</div>
+
+<div class="wrap">
+  <aside id="facets"></aside>
+  <main>
+    <div class="grid" id="grid"></div>
+    <div id="sentinel"></div>
+    <div class="status" id="status"></div>
+  </main>
+</div>
+
+<!-- sample request modal -->
+<div class="overlay" id="overlay">
+  <div class="modal">
+    <button class="closex" id="closex">&times;</button>
+    <h2>Request Samples</h2>
+    <p class="sub">No payment — we’ll mail trade samples of the items below.</p>
+    <div id="formArea">
+      <div class="cartlist" id="cartlist"></div>
+      <form id="reqform">
+        <div class="row2">
+          <div class="field"><label>Name *</label><input type="text" name="name" required></div>
+          <div class="field"><label>Email *</label><input type="email" name="email" required></div>
+        </div>
+        <div class="row2">
+          <div class="field"><label>Company / Studio</label><input type="text" name="company"></div>
+          <div class="field"><label>Phone</label><input type="text" name="phone"></div>
+        </div>
+        <div class="field"><label>Shipping address</label><textarea name="address" rows="2"></textarea></div>
+        <div class="field"><label>Notes</label><textarea name="notes" rows="2" placeholder="Optional"></textarea></div>
+        <button class="submit" id="submitbtn" type="submit">Submit sample request</button>
+      </form>
+    </div>
+    <div id="doneArea" style="display:none"></div>
+  </div>
+</div>
+
+<script>
+const LS = {
+  get:(k,d)=>{try{const v=localStorage.getItem(k);return v==null?d:JSON.parse(v);}catch{return d;}},
+  set:(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v));}catch{}}
+};
+const $ = (s)=>document.querySelector(s);
+const grid=$('#grid'), statusEl=$('#status'), countEl=$('#count');
+
+// ---- vendor namespace (so multiple microsites don't collide in one browser) ----
+const NS = (location.hostname.split('.')[0] || 'vendor');
+
+// ---- persisted UI state ----
+let sort = LS.get(NS+'.sort','newest');
+let density = LS.get(NS+'.density',5);
+let cart = LS.get(NS+'.cart',[]);     // array of item objects
+let active = LS.get(NS+'.filters',{}); // {line:[...], product_type:[...], ...}
+$('#sort').value=sort;
+$('#density').value=density;
+document.documentElement.style.setProperty('--cols',density);
+
+// ---- paging / fetch ----
+let offset=0, total=0, loading=false, done=false;
+const LIMIT=60;
+
+function qs(){
+  const p=new URLSearchParams();
+  p.set('sort',sort); p.set('offset',offset); p.set('limit',LIMIT);
+  const q=$('#q').value.trim(); if(q) p.set('q',q);
+  for(const k of Object.keys(active)){ if(active[k] && active[k].length) p.set(k, active[k].join('|')); }
+  return p.toString();
+}
+
+function reset(){ offset=0; total=0; done=false; grid.innerHTML=''; statusEl.textContent=''; load(); }
+
+async function load(){
+  if(loading||done) return;
+  loading=true; statusEl.textContent='loading…';
+  try{
+    const r=await fetch('/api/skus?'+qs());
+    const d=await r.json();
+    total=d.total;
+    for(const row of d.rows) grid.appendChild(card(row));
+    offset += d.rows.length;
+    if(offset>=total || d.rows.length===0) done=true;
+    countEl.textContent = total.toLocaleString()+' of '+d.all.toLocaleString()+' products';
+    statusEl.textContent = done ? (total? 'End of results — '+total.toLocaleString()+' shown' : 'No products match your filters.') : '';
+  }catch(e){ statusEl.textContent='Error loading products.'; }
+  loading=false;
+}
+
+function inCart(sku){ return cart.some(c=>c.sku===sku); }
+
+function card(row){
+  const el=document.createElement('div'); el.className='card';
+  const img = row.image ? `<img loading="lazy" src="${row.image}" alt="${esc(row.pattern_name)}" onerror="this.style.opacity=.25">` : '';
+  const dot = row.color_hex ? `<span class="dot" style="background:${row.color_hex}" title="${row.color_hex}"></span>` : '';
+  const lineBadge = row.line ? `<span class="badge">${esc(row.line)}</span>` : '';
+  el.innerHTML = `
+    <div class="imgwrap">${img}</div>
+    <div class="body">
+      ${lineBadge}
+      <div class="ptitle">${esc(row.pattern_name)}</div>
+      <div class="meta">${dot}<span>${esc(row.color_name||'—')}</span></div>
+      ${row.collection?`<div class="coll">${esc(row.collection)}</div>`:''}
+      <button class="reqbtn ${inCart(row.sku)?'in':''}" data-sku="${esc(row.sku)}">${inCart(row.sku)?'✓ In sample request':'Request Sample'}</button>
+    </div>`;
+  el.querySelector('.reqbtn').addEventListener('click',()=>toggleCart(row, el.querySelector('.reqbtn')));
+  return el;
+}
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// ---- cart ----
+function toggleCart(row, btn){
+  if(inCart(row.sku)){ cart=cart.filter(c=>c.sku!==row.sku); }
+  else cart.push({sku:row.sku,mfr_sku:row.mfr_sku,pattern_name:row.pattern_name,color_name:row.color_name,line:row.line,image:row.image});
+  LS.set(NS+'.cart',cart);
+  if(btn){ btn.classList.toggle('in',inCart(row.sku)); btn.textContent=inCart(row.sku)?'✓ In sample request':'Request Sample'; }
+  $('#cartn').textContent=cart.length;
+}
+$('#cartn').textContent=cart.length;
+
+// ---- facets ----
+async function loadFacets(){
+  const r=await fetch('/api/facets'); const d=await r.json();
+  const f=d.facets;
+  const meta=[
+    ['line','Line',f.line,false],
+    ['product_type','Product Type',f.product_type,false],
+    ['color_primary','Primary Color',f.color_primary,false],
+    ['repeat_classification','Repeat',f.repeat_classification,false],
+    ['material','Material',f.material,false],
+    ['collection','Collection',f.collection,true],
+  ];
+  const root=$('#facets'); root.innerHTML='';
+  for(const [key,title,vals,searchable] of meta){
+    const h=document.createElement('h3'); h.textContent=title; root.appendChild(h);
+    let search=null;
+    if(searchable){
+      search=document.createElement('input'); search.type='text'; search.className='facet-search'; search.placeholder='Filter '+title.toLowerCase()+'…';
+      root.appendChild(search);
+    }
+    const box=document.createElement('div'); box.className='facet-scroll';
+    for(const {value,count} of vals){
+      const lab=document.createElement('label'); lab.className='facet'; lab.dataset.v=value.toLowerCase();
+      const cb=document.createElement('input'); cb.type='checkbox'; cb.value=value;
+      cb.checked = (active[key]||[]).includes(value);
+      cb.addEventListener('change',()=>{
+        active[key]=active[key]||[];
+        if(cb.checked) active[key].push(value); else active[key]=active[key].filter(v=>v!==value);
+        LS.set(NS+'.filters',active); reset();
+      });
+      lab.appendChild(cb);
+      const span=document.createElement('span'); span.textContent=value; lab.appendChild(span);
+      const c=document.createElement('span'); c.className='fc'; c.textContent=count; lab.appendChild(c);
+      box.appendChild(lab);
+    }
+    root.appendChild(box);
+    if(search) search.addEventListener('input',()=>{const t=search.value.toLowerCase();box.querySelectorAll('.facet').forEach(l=>l.style.display=l.dataset.v.includes(t)?'':'none');});
+  }
+  const clear=document.createElement('button'); clear.className='clearbtn'; clear.textContent='Clear all filters';
+  clear.addEventListener('click',()=>{active={};LS.set(NS+'.filters',active);loadFacets();reset();});
+  root.appendChild(clear);
+}
+
+// ---- controls ----
+$('#sort').addEventListener('change',e=>{sort=e.target.value;LS.set(NS+'.sort',sort);reset();});
+$('#density').addEventListener('input',e=>{density=+e.target.value;LS.set(NS+'.density',density);document.documentElement.style.setProperty('--cols',density);});
+let qt; $('#q').addEventListener('input',()=>{clearTimeout(qt);qt=setTimeout(reset,250);});
+
+// ---- infinite scroll ----
+new IntersectionObserver((ents)=>{ if(ents[0].isIntersecting) load(); },{rootMargin:'600px'}).observe($('#sentinel'));
+
+// ---- sample modal ----
+const overlay=$('#overlay');
+function openCart(){
+  renderCartList();
+  $('#formArea').style.display = cart.length? '' : 'none';
+  $('#doneArea').style.display='none'; $('#doneArea').innerHTML='';
+  overlay.classList.add('open');
+}
+function closeCart(){ overlay.classList.remove('open'); }
+$('#cartbtn').addEventListener('click',openCart);
+$('#closex').addEventListener('click',closeCart);
+overlay.addEventListener('click',e=>{if(e.target===overlay)closeCart();});
+
+function renderCartList(){
+  const list=$('#cartlist');
+  if(!cart.length){ list.innerHTML='<div class="status">No items yet. Click “Request Sample” on a product.</div>'; return; }
+  list.innerHTML='';
+  cart.forEach(it=>{
+    const row=document.createElement('div'); row.className='citem';
+    row.innerHTML=`${it.image?`<img src="${it.image}">`:''}
+      <div><strong>${esc(it.pattern_name||it.sku)}</strong><br><span style="color:#6f6a62">${esc(it.color_name||'')} ${it.line?'· '+esc(it.line):''}</span></div>
+      <button class="x" title="remove">&times;</button>`;
+    row.querySelector('.x').addEventListener('click',()=>{cart=cart.filter(c=>c.sku!==it.sku);LS.set(NS+'.cart',cart);$('#cartn').textContent=cart.length;document.querySelectorAll('.reqbtn').forEach(b=>{if(b.dataset.sku===it.sku){b.classList.remove('in');b.textContent='Request Sample';}});openCart();});
+    list.appendChild(row);
+  });
+}
+
+$('#reqform').addEventListener('submit',async e=>{
+  e.preventDefault();
+  if(!cart.length) return;
+  const fd=new FormData(e.target);
+  const payload={items:cart};
+  for(const [k,v] of fd.entries()) payload[k]=v;
+  const btn=$('#submitbtn'); btn.disabled=true; btn.textContent='Submitting…';
+  try{
+    const r=await fetch('/api/sample-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+    const d=await r.json();
+    if(d.ok){
+      cart=[]; LS.set(NS+'.cart',cart); $('#cartn').textContent=0;
+      document.querySelectorAll('.reqbtn.in').forEach(b=>{b.classList.remove('in');b.textContent='Request Sample';});
+      $('#formArea').style.display='none';
+      $('#doneArea').style.display='';
+      $('#doneArea').innerHTML=`<div class="ok">Thank you, ${esc(payload.name)} — your request for ${d.count} sample${d.count===1?'':'s'} was received. We’ll be in touch at ${esc(payload.email)}.</div>`;
+    } else { btn.disabled=false; btn.textContent='Submit sample request'; alert(d.error||'Could not submit.'); }
+  }catch(err){ btn.disabled=false; btn.textContent='Submit sample request'; alert('Network error.'); }
+});
+
+// ---- config (vendor title) ----
+async function loadConfig(){
+  try{
+    const d=await (await fetch('/api/config')).json();
+    if(d && d.title){
+      document.title = d.title;
+      const b=$('#brand'); if(b) b.innerHTML = esc(d.title)+'<small>Trade Lines</small>';
+    }
+  }catch{}
+}
+
+// ---- boot ----
+loadConfig();
+loadFacets();
+load();
+</script>
+</body>
+</html>
diff --git a/template/server.js b/template/server.js
new file mode 100644
index 0000000..2a096fb
--- /dev/null
+++ b/template/server.js
@@ -0,0 +1,248 @@
+#!/usr/bin/env node
+/**
+ * DW Vendor Microsite — generalized catalog viewer (LOCAL, read-only browse + sample-order).
+ *
+ * Generalized from ~/Projects/cowtan-lines/viewer/server.js. The engine is
+ * vendor-agnostic: buildRows / sorters / facets / the /api/sample-request flow are
+ * uniform across every vendor's catalog schema (the staging JSONL is already
+ * normalized to the canonical cowtan shape by lib/export-catalog.sh).
+ *
+ * What is vendor-specific (read from argv / env / fieldmap.json):
+ *   - JSONL source path   : argv[3] || env VENDOR_JSONL || ../staging/cowtan.jsonl
+ *   - banner / page title : env VENDOR_TITLE || fieldmap.title || 'Vendor Trade Lines'
+ *   - samples-only policy  : fieldmap.samples_only (no prices/checkout when true)
+ *
+ * These lines are intentionally OFF Shopify — the ONLY purchase action is a no-payment
+ * sample request. No prices are sold, no Shopify links, no checkout.
+ *
+ * Zero dependencies. Images are live vendor CDN URLs, used as-is (never upscaled).
+ *   node server.js [port] [jsonl] [fieldmap]
+ *     port      default 0 = OS-assigned free port
+ *     jsonl     default env VENDOR_JSONL || ../staging/cowtan.jsonl
+ *     fieldmap  default env VENDOR_FIELDMAP || ./fieldmap.json (optional)
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '0', 10);
+const DIR = path.join(__dirname, '..', '..', '..', 'staging'); // repo/vendors/<v>/viewer -> repo/staging
+const SRC = process.argv[3] || process.env.VENDOR_JSONL || path.join(__dirname, '..', 'staging', 'cowtan.jsonl');
+const FIELDMAP_PATH = process.argv[4] || process.env.VENDOR_FIELDMAP || path.join(__dirname, 'fieldmap.json');
+const PUBLIC = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data');
+const REQUESTS = path.join(DATA, 'sample-requests.jsonl');
+
+let FIELDMAP = {};
+try { if (fs.existsSync(FIELDMAP_PATH)) FIELDMAP = JSON.parse(fs.readFileSync(FIELDMAP_PATH, 'utf8')); } catch { FIELDMAP = {}; }
+const TITLE = process.env.VENDOR_TITLE || FIELDMAP.title || 'Vendor Trade Lines';
+const SAMPLES_ONLY = FIELDMAP.samples_only !== false; // default true (samples-only / no checkout)
+
+const readJsonl = (f) => (fs.existsSync(f)
+  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+  : []);
+
+const num = (v) => { if (v == null) return null; const m = String(v).match(/[\d.]+/); return m ? parseFloat(m[0]) : null; };
+
+// perceived luminance 0..255 from #rrggbb
+function lum(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+  const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
+  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+// hue 0..360 from #rrggbb (for color-wheel sort)
+function hue(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+  let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  if (d === 0) return -1; // grayscale -> sort to end
+  let h;
+  if (mx === r) h = ((g - b) / d) % 6;
+  else if (mx === g) h = (b - r) / d + 2;
+  else h = (r - g) / d + 4;
+  h *= 60; if (h < 0) h += 360;
+  return h;
+}
+
+function buildRows() {
+  const rows = [];
+  let i = 0;
+  for (const r of readJsonl(SRC)) {
+    let images = [];
+    if (Array.isArray(r.all_images)) images = r.all_images;
+    else if (typeof r.all_images === 'string' && r.all_images.trim()) { try { images = JSON.parse(r.all_images); } catch { images = []; } }
+    if (!images.length && r.image_url) images = [r.image_url];
+    const ptype = (r.product_type || '').replace(/^./, (c) => c.toUpperCase()); // normalize casing
+    rows.push({
+      idx: i++,
+      sku: r.dw_sku || r.mfr_sku || String(i),
+      mfr_sku: r.mfr_sku || null,
+      pattern_name: r.pattern_name || r.mfr_sku || '(untitled)',
+      color_name: r.color_name || null,
+      collection: r.collection || null,
+      product_type: ptype || null,
+      material: r.material || null,
+      width: r.width || null,
+      repeat_v: r.repeat_v || null,
+      repeat_h: r.repeat_h || null,
+      color_primary: r.color_primary || null,
+      color_hex: r.color_hex || null,
+      ai_colors: r.ai_colors || [],
+      ai_styles: r.ai_styles || [],
+      ai_patterns: r.ai_patterns || [],
+      repeat_classification: r.repeat_classification || null,
+      finish: r.finish || null,
+      application: r.application || null,
+      coverage: r.coverage || null,
+      fire_rating: r.fire_rating || null,
+      line: r.line || r.material || null,
+      image: r.image_url || images[0] || null,
+      images,
+      image_count: images.length,
+      _lum: lum(r.color_hex),
+      _hue: hue(r.color_hex),
+    });
+  }
+  return rows;
+}
+
+let ROWS = buildRows();
+fs.watchFile(SRC, { interval: 5000 }, () => { ROWS = buildRows(); });
+
+const cmpStr = (a, b) => String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, { sensitivity: 'base' });
+const nullsLast = (v) => (v == null ? Infinity : v);
+
+const sorters = {
+  newest: null, // natural staged order
+  pattern: (a, b) => cmpStr(a.pattern_name, b.pattern_name),
+  color_name: (a, b) => cmpStr(a.color_name, b.color_name),
+  collection: (a, b) => cmpStr(a.collection, b.collection) || cmpStr(a.pattern_name, b.pattern_name),
+  line: (a, b) => cmpStr(a.line, b.line) || cmpStr(a.pattern_name, b.pattern_name),
+  product_type: (a, b) => cmpStr(a.product_type, b.product_type) || cmpStr(a.pattern_name, b.pattern_name),
+  material: (a, b) => cmpStr(a.material, b.material) || cmpStr(a.pattern_name, b.pattern_name),
+  // light -> dark: brightest first; missing-hex always sort last
+  light: (a, b) => { const la = a._lum == null ? -1 : a._lum, lb = b._lum == null ? -1 : b._lum; return lb - la; },
+  // dark -> light: darkest first; missing-hex always sort last
+  dark: (a, b) => { const la = a._lum == null ? 999 : a._lum, lb = b._lum == null ? 999 : b._lum; return la - lb; },
+  hue: (a, b) => {
+    // grayscale (-1) and null sort to the very end
+    const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue;
+    const hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
+    return ha - hb;
+  },
+  sku: (a, b) => cmpStr(a.mfr_sku || a.sku, b.mfr_sku || b.sku),
+};
+
+function facets() {
+  const f = { line: {}, product_type: {}, collection: {}, material: {}, color_primary: {}, repeat_classification: {} };
+  for (const r of ROWS) {
+    const bump = (k, v) => { if (v == null || v === '') return; f[k][v] = (f[k][v] || 0) + 1; };
+    bump('line', r.line);
+    bump('product_type', r.product_type);
+    bump('collection', r.collection);
+    bump('material', r.material);
+    bump('color_primary', r.color_primary);
+    bump('repeat_classification', r.repeat_classification);
+  }
+  // sort each facet by count desc, except collection alpha (searchable)
+  const out = {};
+  for (const k of Object.keys(f)) {
+    let entries = Object.entries(f[k]);
+    if (k === 'collection') entries.sort((a, b) => a[0].localeCompare(b[0]));
+    else entries.sort((a, b) => b[1] - a[1]);
+    out[k] = entries.map(([value, count]) => ({ value, count }));
+  }
+  return out;
+}
+
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+const server = http.createServer((req, res) => {
+  const u = new URL(req.url, 'http://localhost');
+
+  if (u.pathname === '/api/config') {
+    return json(res, 200, { title: TITLE, samples_only: SAMPLES_ONLY, total: ROWS.length });
+  }
+
+  if (u.pathname === '/api/facets') {
+    return json(res, 200, { total: ROWS.length, facets: facets() });
+  }
+
+  if (u.pathname === '/api/skus') {
+    const q = (u.searchParams.get('q') || '').trim().toLowerCase();
+    const sort = u.searchParams.get('sort') || 'newest';
+    const offset = parseInt(u.searchParams.get('offset') || '0', 10);
+    const limit = Math.min(parseInt(u.searchParams.get('limit') || '60', 10), 200);
+    // multi-select facets: pipe-separated; collection can be one of many
+    const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
+    const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');
+    const fMat = sel('material'), fColor = sel('color_primary'), fRep = sel('repeat_classification');
+
+    let rows = ROWS;
+    const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
+    rows = rows.filter((r) =>
+      inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) &&
+      inAny(fMat, r.material) && inAny(fColor, r.color_primary) && inAny(fRep, r.repeat_classification));
+    if (q) {
+      rows = rows.filter((r) => (
+        (r.pattern_name || '') + ' ' + (r.color_name || '') + ' ' + (r.mfr_sku || '') + ' ' +
+        (r.sku || '') + ' ' + (r.collection || '')).toLowerCase().includes(q));
+    }
+    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+    const page = rows.slice(offset, offset + limit);
+    return json(res, 200, { total: rows.length, all: ROWS.length, offset, limit, rows: page });
+  }
+
+  if (u.pathname === '/api/sample-request' && req.method === 'POST') {
+    let body = '';
+    req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
+    req.on('end', () => {
+      let p; try { p = JSON.parse(body || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
+      const name = (p.name || '').toString().trim();
+      const email = (p.email || '').toString().trim();
+      const items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
+      if (!name || !email || !/.+@.+\..+/.test(email) || !items.length) {
+        return json(res, 400, { ok: false, error: 'name, valid email, and at least one item are required' });
+      }
+      const rec = {
+        ts: new Date().toISOString(),
+        vendor: TITLE,
+        name, email,
+        company: (p.company || '').toString().trim() || null,
+        phone: (p.phone || '').toString().trim() || null,
+        address: (p.address || '').toString().trim() || null,
+        notes: (p.notes || '').toString().trim() || null,
+        items: items.map((it) => ({
+          sku: (it.sku || '').toString(),
+          mfr_sku: (it.mfr_sku || '').toString() || null,
+          pattern_name: (it.pattern_name || '').toString() || null,
+          color_name: (it.color_name || '').toString() || null,
+          line: (it.line || '').toString() || null,
+        })),
+        ua: (req.headers['user-agent'] || '').slice(0, 200),
+      };
+      try {
+        fs.mkdirSync(DATA, { recursive: true });
+        fs.appendFileSync(REQUESTS, JSON.stringify(rec) + '\n');
+      } catch (e) {
+        return json(res, 500, { ok: false, error: 'could not save request' });
+      }
+      return json(res, 200, { ok: true, count: rec.items.length });
+    });
+    return;
+  }
+
+  // static
+  let pth = u.pathname === '/' ? '/index.html' : u.pathname;
+  const file = path.join(PUBLIC, path.normalize(pth).replace(/^(\.\.[/\\])+/, ''));
+  if (!file.startsWith(PUBLIC) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end('not found'); }
+  const ext = path.extname(file);
+  const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }[ext] || 'text/plain';
+  res.writeHead(200, { 'Content-Type': mime });
+  res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+  const addr = server.address();
+  console.log(`${TITLE} viewer -> http://localhost:${addr.port}  (${ROWS.length} products${SAMPLES_ONLY ? ', samples-only' : ''})`);
+});

(oldest)  ·  back to Dw Vendor Microsites  ·  seed manifest — 118 vendors, 117 buildable, 1 excluded (Nico a8a1464 →