[object Object]

← back to Wallco Ai

ship v1 customer-facing storefront + Kamatera deploy

3f15587fbd708f4855c733ad5e5c33308a16a049 · 2026-05-11 16:30:37 -0700 · SteveStudio2

- server.js: full customer site (/, /designs, /design/:id, /murals, /about, /samples,
  /api/leads, /api/designs, /api/_inline/state, /health)
- data/designs.json: 11 SDXL-generated designs snapshot (floral/geometric/damask)
- public/css/site.css: full design system (dark/light toggle, sort+density, hero,
  Cormorant Garamond wordmark, hamburger nav, all standing rules)
- ecosystem.config.js: production app wallco-ai :9905 + local admin viewer :9792
- deploy-kamatera.sh: rsync → npm install → pm2 → nginx vhost → health check
- server-admin.js: original local viewer preserved
- GA4: G-6V7HSH6K1M provisioned under Steve Abrams APPS
- Purelymail: info@wallco.ai mailbox created
- nginx vhost live on Kamatera (port 9905, CF real_ip headers, Cache-Control no-store)
- pm2 wallco-ai id 261 online and healthy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 3f15587fbd708f4855c733ad5e5c33308a16a049
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 16:30:37 2026 -0700

    ship v1 customer-facing storefront + Kamatera deploy
    
    - server.js: full customer site (/, /designs, /design/:id, /murals, /about, /samples,
      /api/leads, /api/designs, /api/_inline/state, /health)
    - data/designs.json: 11 SDXL-generated designs snapshot (floral/geometric/damask)
    - public/css/site.css: full design system (dark/light toggle, sort+density, hero,
      Cormorant Garamond wordmark, hamburger nav, all standing rules)
    - ecosystem.config.js: production app wallco-ai :9905 + local admin viewer :9792
    - deploy-kamatera.sh: rsync → npm install → pm2 → nginx vhost → health check
    - server-admin.js: original local viewer preserved
    - GA4: G-6V7HSH6K1M provisioned under Steve Abrams APPS
    - Purelymail: info@wallco.ai mailbox created
    - nginx vhost live on Kamatera (port 9905, CF real_ip headers, Cache-Control no-store)
    - pm2 wallco-ai id 261 online and healthy
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 data/designs.json                   |  167 ++++
 deploy-kamatera.sh                  |  153 ++++
 ecosystem.config.js                 |   30 +-
 public/css/site.css                 |  642 +++++++++++++++
 scripts/pull_spoonflower_account.py |  257 ++++++
 scripts/spoonflower_lib.py          |   86 ++
 scripts/upload_spoonflower_new.py   |  149 ++++
 server-admin.js                     |  242 ++++++
 server.js                           | 1472 ++++++++++++++++++++++++++++++-----
 9 files changed, 2985 insertions(+), 213 deletions(-)

diff --git a/data/designs.json b/data/designs.json
new file mode 100644
index 0000000..385a35d
--- /dev/null
+++ b/data/designs.json
@@ -0,0 +1,167 @@
+[
+  {
+    "id": 11,
+    "kind": "seamless_tile",
+    "category": "floral",
+    "dominant_hex": "#9c9172",
+    "saturation": 0.269,
+    "title": "Amber Botanical No.11",
+    "handle": "wallco-0011",
+    "image_url": "/designs/img/1778541019744_893789819.png",
+    "filename": "1778541019744_893789819.png",
+    "generator": "Replicate SDXL",
+    "seed": 893789819,
+    "is_published": true,
+    "created_at": "2026-05-11T23:10:00Z"
+  },
+  {
+    "id": 12,
+    "kind": "seamless_tile",
+    "category": "floral",
+    "dominant_hex": "#a09279",
+    "saturation": 0.244,
+    "title": "Amber Botanical No.12",
+    "handle": "wallco-0012",
+    "image_url": "/designs/img/1778541051623_1771215304.png",
+    "filename": "1778541051623_1771215304.png",
+    "generator": "Replicate SDXL",
+    "seed": 1771215304,
+    "is_published": true,
+    "created_at": "2026-05-11T23:10:15Z"
+  },
+  {
+    "id": 13,
+    "kind": "seamless_tile",
+    "category": "floral",
+    "dominant_hex": "#87774c",
+    "saturation": 0.437,
+    "title": "Umber Botanical No.13",
+    "handle": "wallco-0013",
+    "image_url": "/designs/img/1778541058999_209403190.png",
+    "filename": "1778541058999_209403190.png",
+    "generator": "Replicate SDXL",
+    "seed": 209403190,
+    "is_published": true,
+    "created_at": "2026-05-11T23:10:30Z"
+  },
+  {
+    "id": 14,
+    "kind": "seamless_tile",
+    "category": "floral",
+    "dominant_hex": "#0c162b",
+    "saturation": 0.721,
+    "title": "Noir Botanical No.14",
+    "handle": "wallco-0014",
+    "image_url": "/designs/img/1778541066304_1186496331.png",
+    "filename": "1778541066304_1186496331.png",
+    "generator": "Replicate SDXL",
+    "seed": 1186496331,
+    "is_published": true,
+    "created_at": "2026-05-11T23:10:45Z"
+  },
+  {
+    "id": 15,
+    "kind": "seamless_tile",
+    "category": "floral",
+    "dominant_hex": "#b49d95",
+    "saturation": 0.172,
+    "title": "Sienna Botanical No.15",
+    "handle": "wallco-0015",
+    "image_url": "/designs/img/1778541073614_864976504.png",
+    "filename": "1778541073614_864976504.png",
+    "generator": "Replicate SDXL",
+    "seed": 864976504,
+    "is_published": true,
+    "created_at": "2026-05-11T23:11:00Z"
+  },
+  {
+    "id": 16,
+    "kind": "seamless_tile",
+    "category": "geometric",
+    "dominant_hex": "#283527",
+    "saturation": 0.264,
+    "title": "Sage Lattice No.16",
+    "handle": "wallco-0016",
+    "image_url": "/designs/img/1778541111658_918473109.png",
+    "filename": "1778541111658_918473109.png",
+    "generator": "Replicate SDXL",
+    "seed": 918473109,
+    "is_published": true,
+    "created_at": "2026-05-11T23:11:15Z"
+  },
+  {
+    "id": 17,
+    "kind": "seamless_tile",
+    "category": "geometric",
+    "dominant_hex": "#f0e8db",
+    "saturation": 0.088,
+    "title": "Stone Lattice No.17",
+    "handle": "wallco-0017",
+    "image_url": "/designs/img/1778541118993_368707680.png",
+    "filename": "1778541118993_368707680.png",
+    "generator": "Replicate SDXL",
+    "seed": 368707680,
+    "is_published": true,
+    "created_at": "2026-05-11T23:11:30Z"
+  },
+  {
+    "id": 18,
+    "kind": "seamless_tile",
+    "category": "geometric",
+    "dominant_hex": "#bb7d4e",
+    "saturation": 0.583,
+    "title": "Amber Lattice No.18",
+    "handle": "wallco-0018",
+    "image_url": "/designs/img/1778541126631_1206353655.png",
+    "filename": "1778541126631_1206353655.png",
+    "generator": "Replicate SDXL",
+    "seed": 1206353655,
+    "is_published": true,
+    "created_at": "2026-05-11T23:11:45Z"
+  },
+  {
+    "id": 19,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#bfab91",
+    "saturation": 0.241,
+    "title": "Amber Baroque No.19",
+    "handle": "wallco-0019",
+    "image_url": "/designs/img/1778541171660_1800259551.png",
+    "filename": "1778541171660_1800259551.png",
+    "generator": "Replicate SDXL",
+    "seed": 1800259551,
+    "is_published": true,
+    "created_at": "2026-05-11T23:12:00Z"
+  },
+  {
+    "id": 20,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#787675",
+    "saturation": 0.025,
+    "title": "Graphite Baroque No.20",
+    "handle": "wallco-0020",
+    "image_url": "/designs/img/1778541179060_1139384775.png",
+    "filename": "1778541179060_1139384775.png",
+    "generator": "Replicate SDXL",
+    "seed": 1139384775,
+    "is_published": true,
+    "created_at": "2026-05-11T23:12:15Z"
+  },
+  {
+    "id": 21,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#cba15c",
+    "saturation": 0.547,
+    "title": "Amber Baroque No.21",
+    "handle": "wallco-0021",
+    "image_url": "/designs/img/1778541186410_1284647138.png",
+    "filename": "1778541186410_1284647138.png",
+    "generator": "Replicate SDXL",
+    "seed": 1284647138,
+    "is_published": true,
+    "created_at": "2026-05-11T23:12:30Z"
+  }
+]
\ No newline at end of file
diff --git a/deploy-kamatera.sh b/deploy-kamatera.sh
new file mode 100755
index 0000000..2c75614
--- /dev/null
+++ b/deploy-kamatera.sh
@@ -0,0 +1,153 @@
+#!/usr/bin/env bash
+# wallco.ai — deploy to Kamatera
+# Usage: ./deploy-kamatera.sh
+# Pre-flight: CF zone must exist + NS swapped before running.
+set -euo pipefail
+
+REMOTE="root@45.61.58.125"
+REMOTE_DIR="/root/public-projects/wallco-ai"
+LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
+SITE="wallco.ai"
+PM2_NAME="wallco-ai"
+PORT=9905
+HEALTH_URL="http://45.61.58.125:${PORT}/health"
+
+echo "=== wallco.ai deploy to Kamatera ==="
+echo "Local:  $LOCAL_DIR"
+echo "Remote: $REMOTE:$REMOTE_DIR"
+echo ""
+
+# 1. Port collision pre-flight
+echo "[1/6] Port pre-flight..."
+COLLISION=$(ssh "$REMOTE" "ss -tln | grep ':${PORT} '" 2>/dev/null || true)
+if [ -n "$COLLISION" ]; then
+  echo "ERROR: Port $PORT already in use on Kamatera:"
+  echo "$COLLISION"
+  exit 1
+fi
+echo "  Port $PORT is free."
+
+# 2. rsync (exclude heavy data dirs, .env, node_modules, git)
+echo "[2/6] rsync..."
+ssh "$REMOTE" "mkdir -p $REMOTE_DIR/logs $REMOTE_DIR/data/generated $REMOTE_DIR/public/uploads"
+rsync -az --delete \
+  --exclude='node_modules/' \
+  --exclude='.git/' \
+  --exclude='.env*' \
+  --exclude='*.log' \
+  --exclude='data/images/' \
+  --exclude='data/generated/' \
+  --exclude='logs/' \
+  --exclude='server-admin.js' \
+  --exclude='scripts/' \
+  --exclude='db/' \
+  "$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
+
+# 3. Rsync design images (the generated PNGs we DO want on prod)
+echo "[2b/6] rsync generated design images..."
+rsync -az --delete \
+  "$LOCAL_DIR/data/generated/" "$REMOTE:$REMOTE_DIR/data/generated/"
+
+# 4. npm install on remote
+echo "[3/6] npm install..."
+ssh "$REMOTE" "cd $REMOTE_DIR && npm install --omit=dev 2>&1 | tail -5"
+
+# 5. pm2 start or reload
+echo "[4/6] pm2 startOrReload..."
+ssh "$REMOTE" "cd $REMOTE_DIR && pm2 startOrReload ecosystem.config.js --only $PM2_NAME 2>&1 | tail -10 || pm2 start server.js --name $PM2_NAME --cwd $REMOTE_DIR -- --port $PORT"
+ssh "$REMOTE" "pm2 save"
+
+# 6. Health check (wait up to 20s)
+echo "[5/6] Health check → $HEALTH_URL"
+for i in $(seq 1 10); do
+  STATUS=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000")
+  if [ "$STATUS" = "200" ]; then
+    BODY=$(curl -sS "$HEALTH_URL")
+    echo "  OK ($STATUS): $BODY"
+    break
+  fi
+  echo "  Attempt $i: HTTP $STATUS — waiting..."
+  sleep 2
+done
+if [ "$STATUS" != "200" ]; then
+  echo "ERROR: Health check failed after 20s. Check pm2 logs:"
+  ssh "$REMOTE" "pm2 logs $PM2_NAME --nostream --lines 20"
+  exit 1
+fi
+
+# 7. Nginx vhost (create if not present)
+echo "[6/6] nginx vhost..."
+VHOST_SRC="/etc/nginx/sites-available/$SITE"
+VHOST_EXISTS=$(ssh "$REMOTE" "test -f $VHOST_SRC && echo yes || echo no")
+if [ "$VHOST_EXISTS" = "no" ]; then
+  echo "  Creating nginx vhost..."
+  ssh "$REMOTE" "cat > $VHOST_SRC" <<NGINX
+server {
+    listen 80;
+    listen [::]:80;
+    server_name $SITE www.$SITE;
+
+    # Cloudflare real IP passthrough
+    set_real_ip_from 103.21.244.0/22;
+    set_real_ip_from 103.22.200.0/22;
+    set_real_ip_from 103.31.4.0/22;
+    set_real_ip_from 104.16.0.0/13;
+    set_real_ip_from 104.24.0.0/14;
+    set_real_ip_from 108.162.192.0/18;
+    set_real_ip_from 131.0.72.0/22;
+    set_real_ip_from 141.101.64.0/18;
+    set_real_ip_from 162.158.0.0/15;
+    set_real_ip_from 172.64.0.0/13;
+    set_real_ip_from 173.245.48.0/20;
+    set_real_ip_from 188.114.96.0/20;
+    set_real_ip_from 190.93.240.0/20;
+    set_real_ip_from 197.234.240.0/22;
+    set_real_ip_from 198.41.128.0/17;
+    set_real_ip_from 2400:cb00::/32;
+    set_real_ip_from 2606:4700::/32;
+    set_real_ip_from 2803:f800::/32;
+    set_real_ip_from 2405:b500::/32;
+    set_real_ip_from 2405:8100::/32;
+    set_real_ip_from 2a06:98c0::/29;
+    set_real_ip_from 2c0f:f248::/32;
+    real_ip_header CF-Connecting-IP;
+
+    location / {
+        proxy_pass http://127.0.0.1:$PORT;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade \$http_upgrade;
+        proxy_set_header Connection keep-alive;
+        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_cache_bypass \$http_upgrade;
+
+        # Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
+        add_header Cache-Control "no-store, must-revalidate" always;
+
+        # Security headers
+        add_header X-Frame-Options SAMEORIGIN always;
+        add_header X-Content-Type-Options nosniff always;
+        add_header Referrer-Policy strict-origin-when-cross-origin always;
+        add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+    }
+
+    # Allow certbot challenge
+    location /.well-known/acme-challenge/ {
+        root /var/www/html;
+    }
+}
+NGINX
+  ssh "$REMOTE" "ln -sf $VHOST_SRC /etc/nginx/sites-enabled/$SITE 2>/dev/null || true"
+  ssh "$REMOTE" "nginx -t && systemctl reload nginx"
+  echo "  nginx vhost created and reloaded."
+else
+  echo "  nginx vhost already exists — skipping."
+fi
+
+echo ""
+echo "=== Deploy complete ==="
+echo "Origin: http://45.61.58.125:$PORT/health"
+echo "Next: run certbot + CF zone creation, then verify https://$SITE/health"
diff --git a/ecosystem.config.js b/ecosystem.config.js
index 1bdba17..4c5e4ac 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -1,9 +1,25 @@
 module.exports = {
-  apps: [{
-    name: 'wallco-ai-viewer',
-    cwd: '/Users/stevestudio2/Projects/wallco-ai',
-    script: 'server.js',
-    env: { PORT: 9792 },
-    max_memory_restart: '256M'
-  }]
+  apps: [
+    {
+      // Production customer-facing site (Kamatera port 9905)
+      name: 'wallco-ai',
+      cwd: '/root/public-projects/wallco-ai',
+      script: 'server.js',
+      env: {
+        NODE_ENV: 'production',
+        PORT: 9905
+      },
+      max_memory_restart: '256M',
+      error_file: '/root/public-projects/wallco-ai/logs/err.log',
+      out_file:   '/root/public-projects/wallco-ai/logs/out.log'
+    },
+    {
+      // Local admin viewer (Mac2 only — never deployed to Kamatera)
+      name: 'wallco-ai-viewer',
+      cwd: '/Users/stevestudio2/Projects/wallco-ai',
+      script: 'server-admin.js',
+      env: { PORT: 9792 },
+      max_memory_restart: '256M'
+    }
+  ]
 };
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..adef4af
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,642 @@
+/* wallco.ai — site CSS
+ * Dark/Light toggle standard (feedback_dark_light_toggle_standard.md)
+ * Only --bg, --ink, --ink-soft, --ink-faint, --line, --card-bg, --header-bg flip.
+ * Accents (--accent, --ai-badge-bg) stay constant.
+ */
+
+/* ── LIGHT MODE (default) */
+:root {
+  --bg:         #faf7f2;
+  --ink:        #1a1714;
+  --ink-soft:   #5a5048;
+  --ink-faint:  #9a8e82;
+  --line:       #d8cec0;
+  --card-bg:    #ffffff;
+  --header-bg:  #faf7f2;
+
+  /* Accents stay constant across themes */
+  --accent:       #0d0d0d;
+  --accent-soft:  #3a3633;
+  --gold:         #c9a14b;
+  --ai-badge-bg:  #0d0d0d;
+  --ai-badge-ink: #f5f0e8;
+
+  --serif: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
+  --sans:  'Inter', -apple-system, system-ui, sans-serif;
+
+  --hero-h: clamp(560px, 72vh, 900px);
+  --cols: 4;
+}
+
+/* ── DARK MODE */
+[data-theme="dark"] {
+  --bg:         #0d0c0a;
+  --ink:        #f0ebe3;
+  --ink-soft:   #c8beb2;
+  --ink-faint:  #7a706a;
+  --line:       #2a2620;
+  --card-bg:    #1a1714;
+  --header-bg:  #111008;
+}
+
+/* ── RESET */
+*, *::before, *::after { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body {
+  margin: 0;
+  font: 15px/1.6 var(--sans);
+  color: var(--ink);
+  background: var(--bg);
+  transition: background 0.25s, color 0.25s;
+}
+a { color: var(--ink); }
+img { max-width: 100%; display: block; }
+
+/* ── HEADER */
+.site-header {
+  position: fixed;
+  top: 0; left: 0; right: 0;
+  z-index: 200;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 18px 32px;
+  background: var(--header-bg);
+  border-bottom: 1px solid var(--line);
+  backdrop-filter: blur(12px) saturate(140%);
+  -webkit-backdrop-filter: blur(12px) saturate(140%);
+}
+.brand {
+  font-family: var(--serif);
+  font-size: 20px;
+  font-weight: 300;
+  letter-spacing: 0.04em;
+  color: var(--ink);
+  text-decoration: none;
+}
+
+/* ── HAMBURGER (always visible, per standing rule) */
+.hamburger {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  gap: 5px;
+  width: 44px;
+  height: 44px;
+  border: none;
+  border-radius: 50%;
+  background: rgba(0,0,0,0.08);
+  cursor: pointer;
+  padding: 0;
+  backdrop-filter: blur(8px);
+}
+[data-theme="dark"] .hamburger {
+  background: rgba(255,255,255,0.10);
+}
+.hamburger span {
+  display: block;
+  width: 20px;
+  height: 2px;
+  background: var(--ink);
+  border-radius: 2px;
+  transition: all 0.22s;
+}
+.hamburger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
+.hamburger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
+.hamburger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
+
+/* ── NAV PANEL */
+.nav-panel {
+  position: fixed;
+  top: 73px; right: 0;
+  z-index: 199;
+  width: 260px;
+  background: var(--card-bg);
+  border-left: 1px solid var(--line);
+  border-bottom: 1px solid var(--line);
+  padding: 16px 0;
+  box-shadow: -4px 8px 32px rgba(0,0,0,0.12);
+}
+.nav-panel[hidden] { display: none; }
+.nav-panel a {
+  display: block;
+  padding: 10px 28px;
+  font-size: 14px;
+  text-decoration: none;
+  color: var(--ink-soft);
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  font-size: 12px;
+}
+.nav-panel a:hover, .nav-panel a.active { color: var(--ink); }
+.nav-panel a.active { border-left: 2px solid var(--gold); }
+
+/* Theme toggle in nav */
+.theme-toggle {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 28px;
+  background: none;
+  border: none;
+  cursor: pointer;
+  color: var(--ink-faint);
+  font-family: var(--sans);
+  font-size: 12px;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  width: 100%;
+}
+.theme-toggle:hover { color: var(--ink); }
+[data-theme="light"] .moon-icon { display: none; }
+[data-theme="dark"]  .sun-icon  { display: none; }
+
+/* ── MAIN OFFSET for fixed header */
+main { padding-top: 73px; }
+
+/* ── HERO */
+.hero-section {
+  position: relative;
+  width: 100%;
+  height: var(--hero-h);
+  overflow: hidden;
+  background: var(--accent);
+}
+.hero-track {
+  position: absolute; inset: 0;
+}
+.hero-slide {
+  position: absolute; inset: 0;
+  background: no-repeat center / cover;
+  opacity: 0;
+  transition: opacity 1.2s ease;
+}
+.hero-slide.active { opacity: 1; }
+
+.hero-controls {
+  position: absolute;
+  bottom: 20px; left: 50%;
+  transform: translateX(-50%);
+  display: flex; gap: 8px;
+}
+.hero-dot {
+  width: 8px; height: 8px;
+  border-radius: 50%;
+  background: rgba(255,255,255,0.4);
+  border: none; cursor: pointer; padding: 0;
+  transition: background 0.2s;
+}
+.hero-dot.active { background: #fff; }
+
+/* ── WORDMARK BELOW HERO (Gucci anatomy) */
+.hero-wordmark-below {
+  text-align: center;
+  padding: 40px 24px 24px;
+  border-bottom: 1px solid var(--line);
+}
+.wordmark {
+  font-family: var(--serif);
+  font-size: clamp(36px, 6vw, 80px);
+  font-weight: 300;
+  letter-spacing: 0.06em;
+  color: var(--ink);
+  margin: 0 0 12px;
+}
+.tagline {
+  font-family: var(--serif);
+  font-style: italic;
+  font-size: clamp(14px, 1.8vw, 20px);
+  color: var(--ink-soft);
+  margin: 0;
+}
+
+/* ── SECTION HEADERS */
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  padding: 40px 40px 0;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.section-header h2 {
+  font-family: var(--serif);
+  font-size: 28px;
+  font-weight: 300;
+  letter-spacing: 0.03em;
+  margin: 0;
+}
+.see-all {
+  font-size: 13px;
+  color: var(--ink-soft);
+  text-decoration: none;
+}
+.see-all:hover { color: var(--ink); text-decoration: underline; }
+
+/* ── DESIGN GRID */
+.design-grid {
+  display: grid;
+  grid-template-columns: repeat(var(--cols), 1fr);
+  gap: 2px;
+  padding: 24px 40px 40px;
+}
+.featured-grid { --cols: 4; }
+.catalog-grid  { --cols: var(--cols, 4); }
+
+.design-card {
+  display: block;
+  text-decoration: none;
+  background: var(--card-bg);
+  overflow: hidden;
+  position: relative;
+  transition: transform 0.18s;
+}
+.design-card:hover { z-index: 2; transform: scale(1.02); }
+
+.card-img {
+  aspect-ratio: 1;
+  background: var(--line) no-repeat center / cover;
+}
+
+.card-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 12px;
+  border-top: 1px solid var(--line);
+}
+.card-title {
+  flex: 1;
+  font-family: var(--serif);
+  font-size: 13px;
+  color: var(--ink);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.card-cat {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--ink-faint);
+}
+.color-dot {
+  width: 10px; height: 10px;
+  border-radius: 50%;
+  flex-shrink: 0;
+  border: 1px solid var(--line);
+}
+
+/* ── CTA BAND */
+.cta-band {
+  text-align: center;
+  padding: 80px 40px;
+  background: var(--card-bg);
+  border-top: 1px solid var(--line);
+}
+.cta-band h2 {
+  font-family: var(--serif);
+  font-size: clamp(24px, 4vw, 42px);
+  font-weight: 300;
+  margin: 0 0 12px;
+}
+.cta-band p {
+  color: var(--ink-soft);
+  max-width: 540px;
+  margin: 0 auto 28px;
+  font-size: 16px;
+}
+
+/* ── BUTTONS */
+.btn-primary {
+  display: inline-block;
+  padding: 14px 36px;
+  background: var(--ink);
+  color: var(--bg);
+  font-family: var(--sans);
+  font-size: 13px;
+  font-weight: 500;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  text-decoration: none;
+  border: 2px solid var(--ink);
+  border-radius: 0;
+  cursor: pointer;
+  transition: background 0.18s, color 0.18s;
+}
+.btn-primary:hover { background: transparent; color: var(--ink); }
+.btn-outline {
+  display: inline-block;
+  padding: 14px 36px;
+  background: transparent;
+  color: var(--ink);
+  font-family: var(--sans);
+  font-size: 13px;
+  font-weight: 500;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  text-decoration: none;
+  border: 2px solid var(--ink);
+  border-radius: 0;
+  margin-left: 12px;
+  cursor: pointer;
+  transition: background 0.18s, color 0.18s;
+}
+.btn-outline:hover { background: var(--ink); color: var(--bg); }
+
+/* ── CATALOG TOOLBAR */
+.catalog-section { min-height: 60vh; }
+.catalog-toolbar {
+  padding: 32px 40px 0;
+  border-bottom: 1px solid var(--line);
+  margin-bottom: 0;
+}
+.filter-form {}
+.toolbar-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  padding-bottom: 16px;
+}
+.search-input {
+  flex: 1;
+  min-width: 160px;
+  padding: 9px 14px;
+  border: 1px solid var(--line);
+  background: var(--bg);
+  color: var(--ink);
+  font: 14px var(--sans);
+  border-radius: 2px;
+}
+.filter-select {
+  padding: 9px 14px;
+  border: 1px solid var(--line);
+  background: var(--bg);
+  color: var(--ink);
+  font: 14px var(--sans);
+  border-radius: 2px;
+}
+.sort-label, .density-label {
+  font-size: 12px;
+  color: var(--ink-faint);
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.density-range { width: 90px; accent-color: var(--gold); }
+.result-count {
+  font-size: 12px;
+  color: var(--ink-faint);
+  margin: 0 0 12px;
+}
+
+/* ── PAGER */
+.pager {
+  display: flex;
+  justify-content: center;
+  gap: 6px;
+  padding: 32px;
+}
+.pager-link {
+  padding: 6px 12px;
+  border: 1px solid var(--line);
+  text-decoration: none;
+  color: var(--ink-soft);
+  font-size: 13px;
+}
+.pager-link.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
+
+/* ── DETAIL PAGE */
+.detail-main { min-height: 80vh; }
+.detail-layout {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0;
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 48px 40px;
+  gap: 48px;
+}
+@media (max-width: 768px) {
+  .detail-layout { grid-template-columns: 1fr; padding: 24px 20px; }
+}
+.detail-img-wrap {
+  position: sticky;
+  top: 88px;
+}
+.detail-img {
+  width: 100%;
+  border-radius: 2px;
+  background: var(--line);
+}
+.tiled-preview {
+  margin-top: 12px;
+  position: relative;
+}
+.tile-grid {
+  width: 100%;
+  aspect-ratio: 3/2;
+  background-image: var(--tile);
+  background-repeat: repeat;
+  background-size: 33.33% auto;
+  border: 1px solid var(--line);
+  border-radius: 2px;
+}
+.tile-label {
+  display: block;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--ink-faint);
+  margin-top: 4px;
+  text-align: center;
+}
+.detail-breadcrumb {
+  font-size: 12px;
+  color: var(--ink-faint);
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+  margin: 0 0 12px;
+}
+.detail-breadcrumb a { color: inherit; text-decoration: underline; }
+.detail-title {
+  font-family: var(--serif);
+  font-size: clamp(28px, 3vw, 42px);
+  font-weight: 300;
+  letter-spacing: 0.03em;
+  margin: 0 0 16px;
+  color: var(--ink);
+}
+.provenance-badge {
+  font-size: 12px;
+  color: var(--ink-faint);
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+  margin-bottom: 20px;
+}
+.ai-badge {
+  display: inline-block;
+  padding: 3px 10px;
+  background: var(--ink);
+  color: var(--bg);
+  font-size: 10px;
+  letter-spacing: 0.12em;
+  margin-right: 8px;
+  border-radius: 2px;
+}
+.detail-palette {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 20px;
+}
+.swatch-main {
+  width: 36px; height: 36px;
+  border-radius: 50%;
+  border: 2px solid var(--line);
+  display: inline-block;
+  flex-shrink: 0;
+}
+.hex-label { font-size: 13px; color: var(--ink-soft); font-family: monospace; }
+.detail-spec-panel {
+  border: 1px solid var(--line);
+  border-radius: 2px;
+  margin-bottom: 24px;
+  overflow: hidden;
+}
+.spec-row {
+  display: flex;
+  padding: 10px 16px;
+  border-bottom: 1px solid var(--line);
+  gap: 12px;
+}
+.spec-row:last-child { border-bottom: none; }
+.spec-key { flex: 0 0 110px; font-size: 12px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.07em; }
+.spec-val { font-size: 13px; color: var(--ink); }
+.cta-group { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
+.cta-sample {}
+.provenance-note {
+  font-size: 13px;
+  color: var(--ink-faint);
+  background: var(--card-bg);
+  border: 1px solid var(--line);
+  border-radius: 2px;
+  padding: 14px 16px;
+  margin-bottom: 24px;
+  line-height: 1.6;
+}
+.detail-nav {
+  display: flex;
+  justify-content: space-between;
+  padding-top: 24px;
+  border-top: 1px solid var(--line);
+}
+.detail-nav-link {
+  font-size: 13px;
+  color: var(--ink-soft);
+  text-decoration: none;
+}
+.detail-nav-link:hover { color: var(--ink); }
+
+/* ── PROSE / ABOUT */
+.prose-main { padding: 60px 40px 80px; max-width: 800px; margin: 0 auto; }
+.prose h1 {
+  font-family: var(--serif);
+  font-size: 40px;
+  font-weight: 300;
+  margin: 0 0 32px;
+}
+.prose h2 {
+  font-family: var(--serif);
+  font-size: 24px;
+  font-weight: 400;
+  margin: 32px 0 12px;
+}
+.prose p { color: var(--ink-soft); margin: 0 0 16px; }
+
+/* ── SAMPLE FORM */
+.sample-form-wrap {
+  max-width: 560px;
+  margin: 0 auto;
+  padding: 60px 24px 80px;
+}
+.sample-form-wrap h1 {
+  font-family: var(--serif);
+  font-size: 36px;
+  font-weight: 300;
+  margin: 0 0 8px;
+}
+.sample-preview {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 16px;
+  background: var(--card-bg);
+  border: 1px solid var(--line);
+  border-radius: 4px;
+  margin: 20px 0;
+}
+.sample-form label {
+  display: block;
+  font-size: 12px;
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+  color: var(--ink-faint);
+  margin-bottom: 16px;
+}
+.sample-form input[type="text"],
+.sample-form input[type="email"],
+.sample-form input[type="tel"],
+.sample-form select,
+.sample-form textarea {
+  display: block;
+  width: 100%;
+  margin-top: 4px;
+  padding: 10px 14px;
+  border: 1px solid var(--line);
+  background: var(--bg);
+  color: var(--ink);
+  font: 14px var(--sans);
+  border-radius: 2px;
+}
+.sample-form textarea { resize: vertical; min-height: 80px; }
+
+/* ── FEATURED SECTION */
+.featured-section { margin-bottom: 0; }
+
+/* ── FOOTER */
+.site-footer {
+  padding: 48px 40px;
+  border-top: 1px solid var(--line);
+  background: var(--card-bg);
+  text-align: center;
+}
+.site-footer p {
+  font-size: 13px;
+  color: var(--ink-faint);
+  margin: 0 0 6px;
+}
+.site-footer a { color: var(--ink-soft); }
+
+/* ── EMPTY STATE */
+.empty-state { padding: 60px; text-align: center; color: var(--ink-faint); }
+
+/* ── RESPONSIVE */
+@media (max-width: 900px) {
+  .design-grid { --cols: 3; }
+  .featured-grid { --cols: 3; }
+  .section-header, .catalog-toolbar { padding-left: 20px; padding-right: 20px; }
+  .design-grid { padding: 16px 20px 32px; }
+}
+@media (max-width: 600px) {
+  .site-header { padding: 16px 20px; }
+  .design-grid { --cols: 2; }
+  .featured-grid { --cols: 2; }
+  .cta-band { padding: 48px 20px; }
+  .hero-wordmark-below { padding: 28px 20px 18px; }
+  .btn-outline { margin-left: 0; margin-top: 8px; }
+}
diff --git a/scripts/pull_spoonflower_account.py b/scripts/pull_spoonflower_account.py
new file mode 100644
index 0000000..0d906fc
--- /dev/null
+++ b/scripts/pull_spoonflower_account.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""
+Pull ALL designs from the logged-in Spoonflower account (DW account by default).
+
+For each design:
+  - Capture sf_design_id, title, slug, design_url, thumbnail
+  - Download the highest-resolution file Spoonflower lets us pull
+  - Index into PG (dw_unified.spoonflower_pulled_designs)
+
+usage:  pull_spoonflower_account.py [--limit N] [--account dw]
+"""
+import argparse, sys, os, time, re, json, subprocess
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+
+sys.path.insert(0, str(Path(__file__).parent))
+from spoonflower_lib import creds, login, emit  # noqa: E402
+
+DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "spoonflower-pulled"
+DATA_DIR.mkdir(parents=True, exist_ok=True)
+
+DB = os.getenv("DATABASE_URL", "dw_unified")
+
+
+def psql(sql: str) -> str:
+    """Run psql with sql on stdin, return trimmed output."""
+    r = subprocess.run(
+        ["psql", DB, "-At", "-q"], input=sql, text=True, capture_output=True
+    )
+    if r.returncode != 0:
+        raise RuntimeError(f"psql error: {r.stderr}")
+    return r.stdout.strip()
+
+
+def sql_str(v):
+    if v is None:
+        return "NULL"
+    return "'" + str(v).replace("'", "''") + "'"
+
+
+def list_my_designs(page, limit: int = 0):
+    """Navigate the user's 'my designs' page and harvest design URLs."""
+    # Spoonflower has changed this URL over time; try in order.
+    candidates = [
+        "https://www.spoonflower.com/en/my-spoonflower/designs",
+        "https://www.spoonflower.com/profiles/designs",
+        "https://www.spoonflower.com/en/profile/designs",
+    ]
+    landed = None
+    for u in candidates:
+        try:
+            page.goto(u, wait_until="networkidle", timeout=60000)
+            time.sleep(2)
+            if "/login" not in page.url:
+                landed = u
+                break
+        except Exception:
+            continue
+    if not landed:
+        emit("error", note="could not reach my-designs page")
+        return []
+
+    emit("list_loaded", url=landed)
+
+    # Scroll to load lazy-loaded designs
+    last_h = 0
+    for _ in range(30):
+        page.mouse.wheel(0, 3000)
+        time.sleep(1.5)
+        h = page.evaluate("() => document.body.scrollHeight")
+        if h == last_h:
+            break
+        last_h = h
+
+    # Harvest /en/design/<id>-<slug> links from anchors
+    anchors = page.eval_on_selector_all(
+        "a[href*='/design/']",
+        "els => Array.from(new Set(els.map(e => e.href)))"
+    )
+    designs = []
+    seen = set()
+    for href in anchors:
+        m = re.search(r"/(?:en/)?design/(\d+)-([a-z0-9\-]+)", href)
+        if not m:
+            continue
+        did, slug = m.group(1), m.group(2)
+        if did in seen:
+            continue
+        seen.add(did)
+        designs.append({"sf_design_id": did, "slug": slug, "design_url": href})
+        if limit and len(designs) >= limit:
+            break
+    emit("list_collected", count=len(designs))
+    return designs
+
+
+def pull_one(page, d: dict, account: str) -> dict:
+    """Open design page, find File Options → highest-res download, save it."""
+    out = dict(d)
+    page.goto(d["design_url"], wait_until="networkidle", timeout=60000)
+    time.sleep(2)
+
+    # Title
+    try:
+        title = page.locator("h1").first.text_content(timeout=3000)
+        if title:
+            out["title"] = title.strip()
+    except Exception:
+        pass
+
+    # Thumbnail
+    try:
+        thumb = page.locator("img[src*='spoonflower']").first.get_attribute(
+            "src", timeout=2000
+        )
+        if thumb:
+            out["thumbnail_url"] = thumb
+    except Exception:
+        pass
+
+    # Find File Options menu (only visible if this account OWNS the design)
+    file_btn = None
+    for sel in (
+        'button:has-text("File Options")',
+        '[aria-label*="File Options" i]',
+        'button[aria-haspopup="menu"]:has-text("File")',
+    ):
+        try:
+            loc = page.locator(sel).first
+            if loc.is_visible(timeout=3000):
+                file_btn = loc
+                break
+        except Exception:
+            continue
+
+    if not file_btn:
+        out["error"] = "no File Options (design may not be owned by this account)"
+        return out
+
+    file_btn.click()
+    time.sleep(1)
+
+    # Click highest-res download option
+    download_clicked = False
+    with page.expect_download(timeout=60000) as dl_info:
+        for sel in (
+            'text=Download Original',
+            'text=Download Highest Resolution',
+            '[role="menuitem"]:has-text("Download")',
+            'a:has-text("Download")',
+        ):
+            try:
+                loc = page.locator(sel).first
+                if loc.is_visible(timeout=2000):
+                    loc.click()
+                    download_clicked = True
+                    break
+            except Exception:
+                continue
+        if not download_clicked:
+            out["error"] = "no Download menuitem"
+            return out
+        try:
+            dl = dl_info.value
+        except Exception as e:
+            out["error"] = f"download timeout: {e}"
+            return out
+
+    # Save
+    suggested = dl.suggested_filename or f"{d['sf_design_id']}.png"
+    target = DATA_DIR / f"{d['sf_design_id']}_{suggested}"
+    dl.save_as(str(target))
+    out["local_path"] = str(target)
+    try:
+        out["file_bytes"] = target.stat().st_size
+    except Exception:
+        out["file_bytes"] = None
+    out["account"] = account
+    return out
+
+
+def upsert(rec: dict):
+    fields = ["sf_design_id", "title", "slug", "design_url",
+              "thumbnail_url", "local_path", "file_bytes", "account"]
+    cols = ", ".join(fields)
+    vals = ", ".join(
+        str(rec.get("file_bytes")) if f == "file_bytes" and rec.get("file_bytes") is not None
+        else sql_str(rec.get(f))
+        for f in fields
+    )
+    set_clause = ", ".join(
+        f"{f} = EXCLUDED.{f}" for f in fields if f != "sf_design_id"
+    )
+    psql(f"""
+        INSERT INTO spoonflower_pulled_designs ({cols})
+        VALUES ({vals})
+        ON CONFLICT (sf_design_id) DO UPDATE SET {set_clause};
+    """)
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--limit", type=int, default=0,
+                    help="cap number of designs (0 = unlimited)")
+    ap.add_argument("--account", default="dw", help="tag for the account")
+    args = ap.parse_args()
+
+    email, password = creds()
+    emit("start", account=args.account, limit=args.limit)
+
+    with sync_playwright() as p:
+        browser = p.chromium.launch(headless=True)
+        ctx = browser.new_context(
+            viewport={"width": 1600, "height": 1000},
+            accept_downloads=True,
+        )
+        page = ctx.new_page()
+        if not login(page, email, password):
+            emit("done", ok=False, error="login failed")
+            browser.close()
+            return 2
+        emit("login_ok")
+
+        designs = list_my_designs(page, limit=args.limit)
+        if not designs:
+            emit("done", ok=False, error="no designs found on account")
+            browser.close()
+            return 3
+
+        ok = 0
+        err = 0
+        for i, d in enumerate(designs):
+            emit("pulling", idx=i + 1, of=len(designs),
+                 sf_design_id=d["sf_design_id"], slug=d.get("slug"))
+            try:
+                rec = pull_one(page, d, args.account)
+                if rec.get("local_path"):
+                    upsert(rec)
+                    ok += 1
+                    emit("pulled", **{k: rec.get(k) for k in
+                         ("sf_design_id", "title", "local_path", "file_bytes")})
+                else:
+                    err += 1
+                    emit("skipped", sf_design_id=d["sf_design_id"],
+                         reason=rec.get("error", "unknown"))
+            except Exception as e:
+                err += 1
+                emit("error", sf_design_id=d["sf_design_id"], err=str(e))
+            time.sleep(2)  # rate-limit friendly
+
+        emit("done", ok=True, pulled=ok, errors=err, total=len(designs))
+        browser.close()
+        return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/spoonflower_lib.py b/scripts/spoonflower_lib.py
new file mode 100644
index 0000000..2b03e22
--- /dev/null
+++ b/scripts/spoonflower_lib.py
@@ -0,0 +1,86 @@
+"""
+Shared Spoonflower helpers — login + session cookie reuse.
+Used by upload_spoonflower_new.py and pull_spoonflower_account.py.
+
+Reads creds from env (SPOONFLOWER_EMAIL / SPOONFLOWER_PASSWORD).
+Falls back to ~/Projects/resize-it/.env so we don't duplicate the secret.
+"""
+import os, sys, time, pathlib, json
+from typing import Tuple
+
+def load_resize_it_env():
+    """Bleed resize-it/.env into os.environ if creds missing."""
+    if os.getenv("SPOONFLOWER_EMAIL") and os.getenv("SPOONFLOWER_PASSWORD"):
+        return
+    p = pathlib.Path.home() / "Projects" / "resize-it" / ".env"
+    if not p.exists():
+        return
+    for line in p.read_text().splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        k, v = line.split("=", 1)
+        k, v = k.strip(), v.strip().strip("'").strip('"')
+        if k in ("SPOONFLOWER_EMAIL", "SPOONFLOWER_PASSWORD") and not os.getenv(k):
+            os.environ[k] = v
+
+
+def creds() -> Tuple[str, str]:
+    load_resize_it_env()
+    email = os.getenv("SPOONFLOWER_EMAIL")
+    pw = os.getenv("SPOONFLOWER_PASSWORD")
+    if not email or not pw:
+        raise RuntimeError("SPOONFLOWER_EMAIL / SPOONFLOWER_PASSWORD not set")
+    return email, pw
+
+
+def login(page, email: str, password: str) -> bool:
+    """Sign in to Spoonflower. Returns True on success."""
+    print("[login] navigating", file=sys.stderr, flush=True)
+    page.goto("https://www.spoonflower.com/login", wait_until="networkidle", timeout=60000)
+    time.sleep(2)
+
+    for sel in ('input[type="email"]', 'input[type="text"]',
+                'input[placeholder*="email" i]'):
+        try:
+            loc = page.locator(sel).first
+            if loc.is_visible(timeout=4000):
+                loc.fill(email)
+                break
+        except Exception:
+            continue
+
+    for sel in ('input[type="password"]',):
+        try:
+            loc = page.locator(sel).first
+            if loc.is_visible(timeout=4000):
+                loc.fill(password)
+                break
+        except Exception:
+            continue
+
+    for sel in ('button[type="submit"]',
+                'button:has-text("Sign In")',
+                'button:has-text("Log In")'):
+        try:
+            loc = page.locator(sel).first
+            if loc.is_visible(timeout=3000):
+                loc.click()
+                break
+        except Exception:
+            continue
+
+    page.wait_for_load_state("networkidle", timeout=60000)
+    time.sleep(3)
+    # Heuristic: signed-in pages expose /en/my-account or avatar
+    body = page.url + " " + (page.content()[:2000] if page.content() else "")
+    ok = ("logout" in body.lower() or "my-account" in body.lower()
+          or "Sign Out" in body or "my-spoonflower" in body.lower())
+    print(f"[login] signed_in={ok} url={page.url}", file=sys.stderr, flush=True)
+    return ok
+
+
+def emit(event: str, **kw):
+    """Structured stdout event for the Node parent to read."""
+    rec = {"event": event, "ts": time.time(), **kw}
+    print(json.dumps(rec), flush=True)
diff --git a/scripts/upload_spoonflower_new.py b/scripts/upload_spoonflower_new.py
new file mode 100644
index 0000000..cc4854c
--- /dev/null
+++ b/scripts/upload_spoonflower_new.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""
+Upload a wallco.ai design to Spoonflower as a NEW product
+(distinct from resize-it/scripts/spoonflower_full_workflow.py which REPLACES).
+
+usage:  upload_spoonflower_new.py <image_path> <title> [--tags "tag1,tag2"] [--dry]
+
+stdout is line-delimited JSON events. Final event is `done` (ok=True/False).
+
+NOTE — defaults to dry-run (--dry).  Pass without --dry to actually publish.
+Steve's standing rule: no public-facing publish without explicit confirm-click.
+"""
+import argparse, sys, os, time, json
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+
+sys.path.insert(0, str(Path(__file__).parent))
+from spoonflower_lib import creds, login, emit  # noqa: E402
+
+UPLOAD_URL = "https://www.spoonflower.com/en/design_studio/uploads"
+
+
+def upload_new(image_path: str, title: str, tags: str = "", dry: bool = True):
+    if not Path(image_path).exists():
+        emit("done", ok=False, error=f"file not found: {image_path}")
+        return 1
+
+    email, password = creds()
+    emit("start", image=image_path, title=title, dry=dry)
+
+    with sync_playwright() as p:
+        browser = p.chromium.launch(headless=True)
+        ctx = browser.new_context(viewport={"width": 1600, "height": 1000})
+        page = ctx.new_page()
+
+        if not login(page, email, password):
+            emit("done", ok=False, error="login failed")
+            browser.close()
+            return 2
+        emit("login_ok")
+
+        if dry:
+            # Dry-run: verify upload page is reachable, no submit.
+            page.goto(UPLOAD_URL, wait_until="networkidle", timeout=60000)
+            time.sleep(2)
+            has_upload_widget = False
+            for sel in ('input[type="file"]',
+                        'button:has-text("Upload")',
+                        '[data-testid="upload"]'):
+                try:
+                    if page.locator(sel).first.is_visible(timeout=3000):
+                        has_upload_widget = True
+                        break
+                except Exception:
+                    continue
+            emit("dry_check", upload_widget_visible=has_upload_widget,
+                 url=page.url)
+            emit("done", ok=True, dry=True,
+                 note="dry-run: login + upload-page reachability only")
+            browser.close()
+            return 0
+
+        # ─── real upload ───
+        page.goto(UPLOAD_URL, wait_until="networkidle", timeout=60000)
+        time.sleep(2)
+
+        file_input = page.locator('input[type="file"]').first
+        if not file_input:
+            emit("done", ok=False, error="no file input on upload page")
+            browser.close()
+            return 3
+
+        file_input.set_input_files(image_path)
+        emit("file_attached")
+
+        # Wait for upload + processing (Spoonflower shows a progress bar)
+        for _ in range(180):
+            time.sleep(2)
+            try:
+                # Title field becomes editable when upload completes
+                if page.locator('input[name="design[name]"], input[name="title"], input[placeholder*="name" i]').first.is_visible(timeout=1000):
+                    break
+            except Exception:
+                continue
+        emit("upload_processed")
+
+        # Fill title
+        for sel in ('input[name="design[name]"]', 'input[name="title"]',
+                    'input[placeholder*="name" i]'):
+            try:
+                loc = page.locator(sel).first
+                if loc.is_visible(timeout=2000):
+                    loc.fill(title)
+                    break
+            except Exception:
+                continue
+
+        # Fill tags (comma-separated → space-separated chip-style)
+        if tags:
+            for sel in ('input[name="tags"]', 'input[placeholder*="tag" i]'):
+                try:
+                    loc = page.locator(sel).first
+                    if loc.is_visible(timeout=2000):
+                        loc.fill(tags.replace(",", " "))
+                        break
+                except Exception:
+                    continue
+
+        emit("metadata_filled")
+
+        # Submit
+        submitted = False
+        for sel in ('button:has-text("Save")',
+                    'button:has-text("Publish")',
+                    'button[type="submit"]'):
+            try:
+                loc = page.locator(sel).first
+                if loc.is_visible(timeout=2000):
+                    loc.click()
+                    submitted = True
+                    break
+            except Exception:
+                continue
+        if not submitted:
+            emit("done", ok=False, error="no submit button visible")
+            browser.close()
+            return 4
+
+        page.wait_for_load_state("networkidle", timeout=90000)
+        time.sleep(3)
+        emit("submitted", final_url=page.url)
+        emit("done", ok=True, url=page.url)
+        browser.close()
+        return 0
+
+
+if __name__ == "__main__":
+    ap = argparse.ArgumentParser()
+    ap.add_argument("image")
+    ap.add_argument("title")
+    ap.add_argument("--tags", default="")
+    ap.add_argument("--dry", action="store_true",
+                    help="dry-run: login + upload-page reachability only (default)")
+    ap.add_argument("--live", action="store_true",
+                    help="actually upload (overrides --dry)")
+    args = ap.parse_args()
+
+    dry = not args.live  # safety: default dry unless --live passed
+    sys.exit(upload_new(args.image, args.title, args.tags, dry=dry))
diff --git a/server-admin.js b/server-admin.js
new file mode 100644
index 0000000..61611b6
--- /dev/null
+++ b/server-admin.js
@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+/**
+ * Wallco.ai local viewer — http://127.0.0.1:9792
+ *
+ * Two tabs:
+ *   /pd        — public-domain source corpus (Met / Wikimedia / Smithsonian)
+ *   /designs   — wallco.ai generated designs (spoon_all_designs)
+ * Standing rule: sort + density slider (localStorage-persisted).
+ */
+require('dotenv').config();
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const { execSync } = require('child_process');
+const inlineEditor = require('@dw/inline-editor');
+
+const app = express();
+const PORT = process.env.PORT || 9792;
+const DATA_ROOT = path.join(__dirname, 'data');
+
+// Inline editor (Wix-style admin editing). Auth required — admin role only.
+app.use(inlineEditor({
+  site: 'wallco.ai',
+  uploadDir: path.join(__dirname, 'public', 'uploads'),
+  authMode: process.env.AUTH_MODE || 'dev',
+  devSecret: process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod'
+}));
+
+// Dev login (creates HS256 cookie). Strips out in prod.
+if ((process.env.AUTH_MODE || 'dev') === 'dev') {
+  const jwt = require('jsonwebtoken');
+  app.get('/_devlogin', (_req, res) => {
+    const token = jwt.sign({
+      email: 'steve@designerwallcoverings.com',
+      role: 'admin',
+      iss: 'https://auth.agentabrams.com'
+    }, process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod',
+       { algorithm: 'HS256', expiresIn: '8h' });
+    res.cookie('dw_auth', token, { httpOnly: false });
+    res.type('text/plain').send('Admin logged in (dev). Refresh / to see edit affordances.\n');
+  });
+  app.get('/_devlogout', (_req, res) => { res.clearCookie('dw_auth'); res.send('Logged out\n'); });
+}
+
+function psql(sql) {
+  return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+
+// Serve local image files (so the viewer can show downloaded PD images)
+app.use('/img', express.static(DATA_ROOT, { maxAge: '7d' }));
+
+app.get('/api/pd', (_req, res) => {
+  try {
+    const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, source, source_id, title, creator, date_created, license,
+             source_url, image_url, local_path, dominant_hex, palette,
+             category, tags, motifs
+      FROM pd_source_designs ORDER BY ingested_at DESC LIMIT 2000
+    ) t;`);
+    res.type('application/json').send(raw || '[]');
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/designs', (_req, res) => {
+  try {
+    const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, kind, width_in, height_in, panels, generator, prompt,
+             seed, local_path, dominant_hex, palette, category, tags,
+             motifs, is_published, created_at
+      FROM spoon_all_designs ORDER BY created_at DESC LIMIT 2000
+    ) t;`);
+    res.type('application/json').send(raw || '[]');
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/counts', (_req, res) => {
+  try {
+    const pd = psql(`SELECT json_agg(t) FROM (SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1) t;`);
+    const designs = psql(`SELECT COUNT(*) FROM spoon_all_designs;`);
+    res.json({ pd: JSON.parse(pd || '[]'), designs: parseInt(designs || '0', 10) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Helper: convert local_path absolute → /img relative URL
+function imgUrl(localPath) {
+  if (!localPath) return null;
+  if (localPath.startsWith(DATA_ROOT)) return '/img/' + localPath.slice(DATA_ROOT.length + 1);
+  return null;
+}
+
+app.get('/', (_req, res) => {
+  res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>wallco.ai — corpus + designs</title>
+<style>
+  :root { --cols: 5; }
+  *,*::before,*::after { box-sizing: border-box; }
+  body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#0f0e0c; }
+  header { padding: 24px 32px; border-bottom: 1px solid #2a2825; background:#1a1816; color:#e8e2d6; display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
+  header h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
+  header .tag { font-size:12px; color:#aaa; }
+  nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+  nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
+  .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
+  .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
+  .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
+  .controls input[type=range] { width:140px; }
+  main { padding: 24px 32px; }
+  .grid { display:grid; gap:18px; grid-template-columns: repeat(var(--cols), 1fr); }
+  .card { background:#1a1816; border:1px solid #2a2825; border-radius:2px; overflow:hidden; color:#ddd; }
+  .card .img { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; }
+  .card .meta { padding:10px 12px 14px; }
+  .card .title { font-size:12px; margin:0 0 4px; }
+  .card .sub { font-size:10px; color:#888; }
+  .card .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:4px; }
+  .card .badge { display:inline-block; padding:1px 5px; font-size:9px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-left:5px; text-transform:uppercase; letter-spacing:.05em; }
+  .empty { padding:48px; text-align:center; color:#666; }
+  a.card-link { color:inherit; text-decoration:none; display:block; }
+</style>
+<header>
+  <h1>wallco.ai</h1>
+  <span class="tag" id="count">…</span>
+  <nav>
+    <a href="#" id="tab-pd" class="on">Source corpus</a>
+    <a href="#" id="tab-designs">Generated designs</a>
+  </nav>
+  <div class="controls">
+    <label>Sort
+      <select id="sort">
+        <option value="recent">Recent</option>
+        <option value="color-wheel">Color Wheel</option>
+        <option value="light-dark">Light → Dark</option>
+        <option value="dark-light">Dark → Light</option>
+        <option value="source">Source</option>
+        <option value="title">Title A→Z</option>
+      </select>
+    </label>
+    <label>Density
+      <input type="range" id="density" min="3" max="9" step="1" value="5">
+    </label>
+  </div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+<script>
+const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
+const sortSel = document.getElementById('sort');
+const dens = document.getElementById('density');
+const grid = document.getElementById('grid');
+const cnt = document.getElementById('count');
+sortSel.value = localStorage.getItem('wc.sort') || 'recent';
+dens.value = localStorage.getItem('wc.density') || 5;
+document.documentElement.style.setProperty('--cols', dens.value);
+
+let tab = localStorage.getItem('wc.tab') || 'pd';
+function setTab(t) {
+  tab = t;
+  document.getElementById('tab-pd').classList.toggle('on', t === 'pd');
+  document.getElementById('tab-designs').classList.toggle('on', t === 'designs');
+  localStorage.setItem('wc.tab', t);
+  load();
+}
+document.getElementById('tab-pd').addEventListener('click', e => { e.preventDefault(); setTab('pd'); });
+document.getElementById('tab-designs').addEventListener('click', e => { e.preventDefault(); setTab('designs'); });
+
+function hexToHsl(hex) {
+  if (!hex || hex.length < 4) return [0,0,0];
+  const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+  if (!m) return [0,0,0];
+  const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
+  const max = Math.max(r,g,b), min = Math.min(r,g,b);
+  const l = (max + min) / 2; let h = 0, s = 0;
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d/(2-max-min) : d/(max+min);
+    if (max === r) h = (g-b)/d + (g<b?6:0);
+    else if (max === g) h = (b-r)/d + 2;
+    else h = (r-g)/d + 4;
+    h *= 60;
+  }
+  return [h,s,l];
+}
+
+function imgPath(p) {
+  if (!p) return '';
+  if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
+  return p;
+}
+
+function render(data) {
+  const sort = sortSel.value;
+  const sorted = [...data].sort((a,b) => {
+    if (sort === 'recent') return 0;
+    if (sort === 'source') return (a.source||a.generator||'').localeCompare(b.source||b.generator||'');
+    if (sort === 'title')  return (a.title||a.prompt||'').localeCompare(b.title||b.prompt||'');
+    const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
+    if (sort === 'light-dark') return hb[2]-ha[2];
+    if (sort === 'dark-light') return ha[2]-hb[2];
+    if (sort === 'color-wheel') {
+      const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
+      if (isaA !== isaB) return isaA ? -1 : 1;
+      if (isaA) return ha[2]-hb[2];
+      const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
+      if (bA !== bB) return bA - bB;
+      return ha[2]-hb[2];
+    }
+    return 0;
+  });
+  cnt.textContent = sorted.length + (tab === 'pd' ? ' PD source images' : ' generated designs');
+  grid.innerHTML = sorted.map(p => {
+    const img = imgPath(p.local_path) || p.image_url || '';
+    const dot = p.dominant_hex ? \`<span class="dot" style="background:\${p.dominant_hex}" title="\${p.dominant_hex}"></span>\` : '';
+    const badge = (p.source || p.generator || '');
+    const title = p.title || p.prompt || ('#'+p.id);
+    const sub = p.creator || p.date_created || p.kind || '';
+    const link = p.source_url || '#';
+    return \`<a class="card-link" href="\${link}" target="_blank"><div class="card">
+      <div class="img" style="background-image:url('\${img}')"></div>
+      <div class="meta">
+        <div class="title">\${dot}\${title.slice(0,60)}<span class="badge">\${badge}</span></div>
+        <div class="sub">\${sub.slice(0,80)}</div>
+      </div>
+    </div></a>\`;
+  }).join('') || '<div class="empty">Empty — run scripts/crawl_pd.py or scripts/generate_designs.js</div>';
+}
+
+async function load() {
+  const r = await fetch(tab === 'pd' ? '/api/pd' : '/api/designs');
+  const data = await r.json();
+  render(data);
+}
+
+sortSel.addEventListener('change', () => { localStorage.setItem('wc.sort', sortSel.value); load(); });
+dens.addEventListener('input', () => {
+  document.documentElement.style.setProperty('--cols', dens.value);
+  localStorage.setItem('wc.density', dens.value);
+});
+
+setTab(tab);
+</script>`);
+});
+
+app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));
diff --git a/server.js b/server.js
index 61611b6..db86eaf 100644
--- a/server.js
+++ b/server.js
@@ -1,242 +1,1302 @@
 #!/usr/bin/env node
 /**
- * Wallco.ai local viewer — http://127.0.0.1:9792
+ * wallco.ai — customer-facing storefront
+ * AI-original wallpaper and murals — every pattern generated, never repeated.
  *
- * Two tabs:
- *   /pd        — public-domain source corpus (Met / Wikimedia / Smithsonian)
- *   /designs   — wallco.ai generated designs (spoon_all_designs)
- * Standing rule: sort + density slider (localStorage-persisted).
+ * Routes:
+ *   GET /              — hero + featured designs grid (home)
+ *   GET /designs       — full catalog grid with sort + density + filter
+ *   GET /design/:id    — design detail page
+ *   GET /murals        — mural-mode designs
+ *   GET /about         — AI provenance
+ *   GET /samples       — lead capture (no commerce in v1)
+ *   GET /health        — {ok, count}
+ *   POST /api/leads    — sample request lead capture → dw_unified.wallco_leads
+ *   GET /designs/img/:filename — serve design images
+ *   GET /api/designs   — JSON API (sort/filter/page)
+ *   GET /api/_inline/state — inline editor state (admin gate)
  */
+'use strict';
 require('dotenv').config();
-const express = require('express');
-const path = require('path');
-const fs = require('fs');
-const { execSync } = require('child_process');
-const inlineEditor = require('@dw/inline-editor');
-
-const app = express();
-const PORT = process.env.PORT || 9792;
-const DATA_ROOT = path.join(__dirname, 'data');
-
-// Inline editor (Wix-style admin editing). Auth required — admin role only.
-app.use(inlineEditor({
-  site: 'wallco.ai',
-  uploadDir: path.join(__dirname, 'public', 'uploads'),
-  authMode: process.env.AUTH_MODE || 'dev',
-  devSecret: process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod'
-}));
 
-// Dev login (creates HS256 cookie). Strips out in prod.
-if ((process.env.AUTH_MODE || 'dev') === 'dev') {
-  const jwt = require('jsonwebtoken');
-  app.get('/_devlogin', (_req, res) => {
-    const token = jwt.sign({
-      email: 'steve@designerwallcoverings.com',
-      role: 'admin',
-      iss: 'https://auth.agentabrams.com'
-    }, process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod',
-       { algorithm: 'HS256', expiresIn: '8h' });
-    res.cookie('dw_auth', token, { httpOnly: false });
-    res.type('text/plain').send('Admin logged in (dev). Refresh / to see edit affordances.\n');
-  });
-  app.get('/_devlogout', (_req, res) => { res.clearCookie('dw_auth'); res.send('Logged out\n'); });
-}
+const express  = require('express');
+const path     = require('path');
+const fs       = require('fs');
+const crypto   = require('crypto');
+const { spawn, execSync } = require('child_process');
+
+const app  = express();
+const PORT = parseInt(process.env.PORT || '9905', 10);
+const SITE = 'wallco.ai';
+const DATA_FILE = path.join(__dirname, 'data', 'designs.json');
+const IMG_DIR   = path.join(__dirname, 'data', 'generated');
+const ROOMS_DIR = path.join(__dirname, 'public', 'uploads', 'rooms');
+const SF_JOBS_DIR = path.join(__dirname, 'logs', 'spoonflower');
+const SF_PULL_DIR = path.join(__dirname, 'data', 'spoonflower-pulled');
+fs.mkdirSync(ROOMS_DIR, { recursive: true });
+fs.mkdirSync(SF_JOBS_DIR, { recursive: true });
+fs.mkdirSync(SF_PULL_DIR, { recursive: true });
 
-function psql(sql) {
-  return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+// Room generator + ElevenLabs config
+const ROOM_API = process.env.ROOM_API || 'http://45.61.58.125:3075/api/generate-room';
+const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || '';
+const ELEVEN_VOICE_ID = process.env.ELEVENLABS_VOICE_ID || '21m00Tcm4TlvDq8ikWAM'; // Rachel (default suggested)
+// Local trust-auth works; the URL form in .env is for prod / remote use.
+// Stick with the bare DB name so peer auth handles it cleanly.
+const DB_NAME = process.env.WALLCO_DB || 'dw_unified';
+const DB_URL = process.env.DATABASE_URL || DB_NAME;
+
+function psqlQuery(sql) {
+  return execSync(`psql ${DB_NAME} -At -q`, { input: sql, encoding: 'utf8' }).trim();
 }
 
-// Serve local image files (so the viewer can show downloaded PD images)
-app.use('/img', express.static(DATA_ROOT, { maxAge: '7d' }));
+app.use(express.json());
+app.use(express.urlencoded({ extended: false }));
 
-app.get('/api/pd', (_req, res) => {
-  try {
-    const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
-      SELECT id, source, source_id, title, creator, date_created, license,
-             source_url, image_url, local_path, dominant_hex, palette,
-             category, tags, motifs
-      FROM pd_source_designs ORDER BY ingested_at DESC LIMIT 2000
-    ) t;`);
-    res.type('application/json').send(raw || '[]');
-  } catch (e) { res.status(500).json({ error: e.message }); }
+// ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
+app.use((req, res, next) => {
+  if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
+    res.setHeader('Cache-Control', 'no-store, must-revalidate');
+  }
+  next();
 });
 
-app.get('/api/designs', (_req, res) => {
+// ── Static: uploaded assets
+app.use('/uploads', express.static(path.join(__dirname, 'public', 'uploads'), { maxAge: '7d' }));
+
+// ── Serve generated design images
+app.use('/designs/img', express.static(IMG_DIR, {
+  maxAge: '30d',
+  setHeaders: (res, filePath) => {
+    res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
+  }
+}));
+
+// ── Load design catalog (snapshot from DB — never stale-live Shopify)
+let DESIGNS = [];
+function loadDesigns() {
   try {
-    const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
-      SELECT id, kind, width_in, height_in, panels, generator, prompt,
-             seed, local_path, dominant_hex, palette, category, tags,
-             motifs, is_published, created_at
-      FROM spoon_all_designs ORDER BY created_at DESC LIMIT 2000
-    ) t;`);
-    res.type('application/json').send(raw || '[]');
-  } catch (e) { res.status(500).json({ error: e.message }); }
-});
+    DESIGNS = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+  } catch (e) {
+    console.error('Failed to load designs.json:', e.message);
+    DESIGNS = [];
+  }
+}
+loadDesigns();
+
+// ── Color sort helpers
+function hexToHSL(hex) {
+  if (!hex) return [0, 0, 0.5];
+  const h = hex.replace('#', '');
+  const r = parseInt(h.slice(0,2),16)/255;
+  const g = parseInt(h.slice(2,4),16)/255;
+  const b = parseInt(h.slice(4,6),16)/255;
+  const max = Math.max(r,g,b), min = Math.min(r,g,b);
+  const l = (max+min)/2;
+  let hue = 0, sat = 0;
+  if (max !== min) {
+    const d = max - min;
+    sat = l > 0.5 ? d/(2-max-min) : d/(max+min);
+    if (max===r) hue = (g-b)/d + (g<b?6:0);
+    else if (max===g) hue = (b-r)/d + 2;
+    else hue = (r-g)/d + 4;
+    hue *= 60;
+  }
+  return [hue, sat, l];
+}
 
-app.get('/api/counts', (_req, res) => {
+function sortDesigns(designs, sort) {
+  const d = [...designs];
+  switch (sort) {
+    case 'title':       return d.sort((a,b) => a.title.localeCompare(b.title));
+    case 'category':    return d.sort((a,b) => a.category.localeCompare(b.category));
+    case 'light-dark':  return d.sort((a,b) => hexToHSL(b.dominant_hex)[2] - hexToHSL(a.dominant_hex)[2]);
+    case 'dark-light':  return d.sort((a,b) => hexToHSL(a.dominant_hex)[2] - hexToHSL(b.dominant_hex)[2]);
+    case 'color-wheel': return d.sort((a,b) => {
+      const [ha,,la] = hexToHSL(a.dominant_hex);
+      const [hb,,lb] = hexToHSL(b.dominant_hex);
+      const bA = Math.floor(ha/30), bB = Math.floor(hb/30);
+      return bA !== bB ? bA-bB : la-lb;
+    });
+    default: return d; // newest (DB insertion order)
+  }
+}
+
+// ── Inline editor admin check (stub — checks dw_auth cookie)
+function isAdmin(req) {
+  const cookie = req.cookies?.dw_auth || req.headers['x-dw-auth'] || '';
+  if (!cookie) return false;
   try {
-    const pd = psql(`SELECT json_agg(t) FROM (SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1) t;`);
-    const designs = psql(`SELECT COUNT(*) FROM spoon_all_designs;`);
-    res.json({ pd: JSON.parse(pd || '[]'), designs: parseInt(designs || '0', 10) });
-  } catch (e) { res.status(500).json({ error: e.message }); }
-});
+    const secret = process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod';
+    const [, payload] = cookie.split('.');
+    const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString());
+    return decoded.role === 'admin';
+  } catch { return false; }
+}
 
-// Helper: convert local_path absolute → /img relative URL
-function imgUrl(localPath) {
-  if (!localPath) return null;
-  if (localPath.startsWith(DATA_ROOT)) return '/img/' + localPath.slice(DATA_ROOT.length + 1);
-  return null;
+// ── GA4 measurement ID
+const GA4_ID = process.env.GA4_MEASUREMENT_ID || '';
+
+// ── Design brief / color palette swatches
+function paletteSwatches(design) {
+  if (!design.dominant_hex) return '';
+  const colors = [design.dominant_hex];
+  // complementary approximation
+  const [h,,l] = hexToHSL(design.dominant_hex);
+  return colors.map(c =>
+    `<span class="swatch" style="background:${c}" title="${c}"></span>`
+  ).join('');
 }
 
-app.get('/', (_req, res) => {
-  res.type('html').send(`<!doctype html>
+// ── Shared HTML fragments
+const GTAG = GA4_ID ? `
+<!-- Google tag (gtag.js) -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=${GA4_ID}"></script>
+<script>
+  window.dataLayer = window.dataLayer || [];
+  function gtag(){dataLayer.push(arguments);}
+  gtag('js', new Date());
+  gtag('config', '${GA4_ID}');
+</script>` : '';
+
+function htmlHead({ title, description, canonical, ogImage = '' }) {
+  const siteUrl = `https://${SITE}`;
+  const ogImg = ogImage || `${siteUrl}/og-default.png`;
+  return `<!doctype html>
+<html lang="en">
+<head>
 <meta charset="utf-8">
-<title>wallco.ai — corpus + designs</title>
-<style>
-  :root { --cols: 5; }
-  *,*::before,*::after { box-sizing: border-box; }
-  body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#0f0e0c; }
-  header { padding: 24px 32px; border-bottom: 1px solid #2a2825; background:#1a1816; color:#e8e2d6; display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
-  header h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
-  header .tag { font-size:12px; color:#aaa; }
-  nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
-  nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
-  .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
-  .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
-  .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
-  .controls input[type=range] { width:140px; }
-  main { padding: 24px 32px; }
-  .grid { display:grid; gap:18px; grid-template-columns: repeat(var(--cols), 1fr); }
-  .card { background:#1a1816; border:1px solid #2a2825; border-radius:2px; overflow:hidden; color:#ddd; }
-  .card .img { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; }
-  .card .meta { padding:10px 12px 14px; }
-  .card .title { font-size:12px; margin:0 0 4px; }
-  .card .sub { font-size:10px; color:#888; }
-  .card .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:4px; }
-  .card .badge { display:inline-block; padding:1px 5px; font-size:9px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-left:5px; text-transform:uppercase; letter-spacing:.05em; }
-  .empty { padding:48px; text-align:center; color:#666; }
-  a.card-link { color:inherit; text-decoration:none; display:block; }
-</style>
-<header>
-  <h1>wallco.ai</h1>
-  <span class="tag" id="count">…</span>
-  <nav>
-    <a href="#" id="tab-pd" class="on">Source corpus</a>
-    <a href="#" id="tab-designs">Generated designs</a>
-  </nav>
-  <div class="controls">
-    <label>Sort
-      <select id="sort">
-        <option value="recent">Recent</option>
-        <option value="color-wheel">Color Wheel</option>
-        <option value="light-dark">Light → Dark</option>
-        <option value="dark-light">Dark → Light</option>
-        <option value="source">Source</option>
-        <option value="title">Title A→Z</option>
-      </select>
-    </label>
-    <label>Density
-      <input type="range" id="density" min="3" max="9" step="1" value="5">
-    </label>
-  </div>
-</header>
-<main><div class="grid" id="grid"></div></main>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${title}</title>
+<meta name="description" content="${description}">
+<link rel="canonical" href="${canonical}">
+<!-- Open Graph -->
+<meta property="og:type" content="website">
+<meta property="og:site_name" content="wallco.ai">
+<meta property="og:title" content="${title}">
+<meta property="og:description" content="${description}">
+<meta property="og:url" content="${canonical}">
+<meta property="og:image" content="${ogImg}">
+<!-- Twitter Card -->
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="${title}">
+<meta name="twitter:description" content="${description}">
+<meta name="twitter:image" content="${ogImg}">
+${GTAG}
+<!-- Dark/Light anti-flash + Age-band early apply (must be first JS in <head>) -->
 <script>
-const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
-const sortSel = document.getElementById('sort');
-const dens = document.getElementById('density');
-const grid = document.getElementById('grid');
-const cnt = document.getElementById('count');
-sortSel.value = localStorage.getItem('wc.sort') || 'recent';
-dens.value = localStorage.getItem('wc.density') || 5;
-document.documentElement.style.setProperty('--cols', dens.value);
-
-let tab = localStorage.getItem('wc.tab') || 'pd';
-function setTab(t) {
-  tab = t;
-  document.getElementById('tab-pd').classList.toggle('on', t === 'pd');
-  document.getElementById('tab-designs').classList.toggle('on', t === 'designs');
-  localStorage.setItem('wc.tab', t);
-  load();
-}
-document.getElementById('tab-pd').addEventListener('click', e => { e.preventDefault(); setTab('pd'); });
-document.getElementById('tab-designs').addEventListener('click', e => { e.preventDefault(); setTab('designs'); });
-
-function hexToHsl(hex) {
-  if (!hex || hex.length < 4) return [0,0,0];
-  const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
-  if (!m) return [0,0,0];
-  const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
-  const max = Math.max(r,g,b), min = Math.min(r,g,b);
-  const l = (max + min) / 2; let h = 0, s = 0;
-  if (max !== min) {
-    const d = max - min;
-    s = l > 0.5 ? d/(2-max-min) : d/(max+min);
-    if (max === r) h = (g-b)/d + (g<b?6:0);
-    else if (max === g) h = (b-r)/d + 2;
-    else h = (r-g)/d + 4;
-    h *= 60;
+(function(){
+  var t = localStorage.getItem('wallco-theme');
+  if (!t) {
+    t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+  }
+  document.documentElement.setAttribute('data-theme', t);
+  var age = parseInt(localStorage.getItem('wallco-age'),10);
+  function band(a){
+    if (!a) return '';
+    if (a < 6)  return 'toddler';
+    if (a < 11) return 'elementary';
+    if (a < 14) return 'tween';
+    if (a < 18) return 'teen';
+    if (a < 40) return 'adult';
+    if (a < 60) return 'mature';
+    if (a < 80) return 'senior';
+    return 'elder';
   }
-  return [h,s,l];
+  if (age) document.documentElement.setAttribute('data-age-band', band(age));
+})();
+</script>
+<link rel="stylesheet" href="/css/site.css">
+<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=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,400&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
+</head>`;
 }
 
-function imgPath(p) {
-  if (!p) return '';
-  if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
-  return p;
+function htmlHeader(active) {
+  const navLinks = [
+    ['/', 'Home'],
+    ['/designs', 'Designs'],
+    ['/murals', 'Murals'],
+    ['/samples', 'Samples'],
+    ['/spoonflower', 'Spoonflower'],
+    ['/about', 'About'],
+  ];
+  return `<header class="site-header">
+  <a class="brand" href="/">wallco.ai</a>
+  <button class="hamburger" id="nav-toggle" aria-controls="nav-panel" aria-expanded="false" aria-label="Open menu">
+    <span></span><span></span><span></span>
+  </button>
+</header>
+<nav class="nav-panel" id="nav-panel" hidden>
+  ${navLinks.map(([href, label]) =>
+    `<a href="${href}" class="${active === href ? 'active' : ''}">${label}</a>`
+  ).join('\n  ')}
+  <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
+    <svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
+    <svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
+  </button>
+</nav>`;
 }
 
-function render(data) {
-  const sort = sortSel.value;
-  const sorted = [...data].sort((a,b) => {
-    if (sort === 'recent') return 0;
-    if (sort === 'source') return (a.source||a.generator||'').localeCompare(b.source||b.generator||'');
-    if (sort === 'title')  return (a.title||a.prompt||'').localeCompare(b.title||b.prompt||'');
-    const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
-    if (sort === 'light-dark') return hb[2]-ha[2];
-    if (sort === 'dark-light') return ha[2]-hb[2];
-    if (sort === 'color-wheel') {
-      const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
-      if (isaA !== isaB) return isaA ? -1 : 1;
-      if (isaA) return ha[2]-hb[2];
-      const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
-      if (bA !== bB) return bA - bB;
-      return ha[2]-hb[2];
+const FOOTER = `<footer class="site-footer">
+  <p>wallco.ai &mdash; AI-original wallpaper &amp; murals. Every pattern generated, never repeated.</p>
+  <p>Part of the <a href="https://designerwallcoverings.com">Designer Wallcoverings</a> family.</p>
+  <p><a href="mailto:info@wallco.ai">info@wallco.ai</a></p>
+</footer>`;
+
+const HAMBURGER_JS = `
+<!-- First-visit Age Prompt (drives age-adaptive UI per ageBand) -->
+<div id="age-prompt-overlay" hidden style="position:fixed;inset:0;background:rgba(15,14,12,.85);z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px)">
+  <div style="background:#fff;border-radius:12px;padding:36px 40px;max-width:480px;width:90%;box-shadow:0 24px 64px rgba(0,0,0,.4);font-family:'Inter',system-ui,sans-serif">
+    <h2 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:26px;margin:0 0 6px;color:#1a1a1a">Welcome to wallco.ai</h2>
+    <p style="margin:0 0 22px;color:#666;font-size:14px;line-height:1.5">Tell us your age so we can show you the version of this site that's easiest for you to use.</p>
+    <input type="number" id="age-input" min="3" max="99" placeholder="Your age" style="width:100%;padding:14px 16px;font-size:16px;border:1px solid #ccc;border-radius:6px;margin-bottom:14px;font-family:inherit">
+    <div style="display:flex;gap:10px">
+      <button id="age-submit" style="flex:1;padding:14px;background:#1a1a1a;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer">Show me the site</button>
+      <button id="age-skip" style="padding:14px 18px;background:transparent;color:#999;border:1px solid #ddd;border-radius:6px;font-size:13px;cursor:pointer">Skip</button>
+    </div>
+    <p style="margin:14px 0 0;color:#999;font-size:11px;text-align:center">Adjustable anytime in Settings.</p>
+  </div>
+</div>
+<script>
+(function(){
+  var btn = document.getElementById('nav-toggle');
+  var panel = document.getElementById('nav-panel');
+  if (btn && panel) {
+    btn.addEventListener('click', function() {
+      var open = panel.hidden;
+      panel.hidden = !open;
+      btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+    });
+  }
+  // Theme toggle
+  var tt = document.getElementById('theme-toggle');
+  if (tt) {
+    tt.addEventListener('click', function() {
+      var cur = document.documentElement.getAttribute('data-theme') || 'light';
+      var next = cur === 'dark' ? 'light' : 'dark';
+      document.documentElement.setAttribute('data-theme', next);
+      localStorage.setItem('wallco-theme', next);
+    });
+  }
+  // Age prompt — first visit only
+  function ageBand(a){
+    if (!a) return '';
+    if (a < 6)  return 'toddler';
+    if (a < 11) return 'elementary';
+    if (a < 14) return 'tween';
+    if (a < 18) return 'teen';
+    if (a < 40) return 'adult';
+    if (a < 60) return 'mature';
+    if (a < 80) return 'senior';
+    return 'elder';
+  }
+  var overlay = document.getElementById('age-prompt-overlay');
+  var seen = localStorage.getItem('wallco-age') || localStorage.getItem('wallco-age-skipped');
+  if (!seen && overlay) {
+    overlay.hidden = false;
+    setTimeout(function(){ var i = document.getElementById('age-input'); if (i) i.focus(); }, 100);
+    document.getElementById('age-submit').addEventListener('click', function(){
+      var a = parseInt(document.getElementById('age-input').value, 10);
+      if (!a || a < 3 || a > 99) { document.getElementById('age-input').style.borderColor='#c00'; return; }
+      localStorage.setItem('wallco-age', String(a));
+      document.documentElement.setAttribute('data-age-band', ageBand(a));
+      overlay.hidden = true;
+    });
+    document.getElementById('age-skip').addEventListener('click', function(){
+      localStorage.setItem('wallco-age-skipped', '1');
+      overlay.hidden = true;
+    });
+    document.getElementById('age-input').addEventListener('keydown', function(e){
+      if (e.key === 'Enter') document.getElementById('age-submit').click();
+    });
+  }
+})();
+</script>`;
+
+// ── Hero rotation images (highest-saturation designs, sat > 0.4)
+function heroDesigns() {
+  const h = DESIGNS
+    .filter(d => d.saturation > 0.3)
+    .sort((a,b) => b.saturation - a.saturation);
+  // fallback: if none qualify, use all sorted by saturation
+  return h.length >= 2 ? h : [...DESIGNS].sort((a,b) => b.saturation - a.saturation);
+}
+
+// ── HOME PAGE
+app.get('/', (req, res) => {
+  const heroes = heroDesigns();
+  const featured = [...DESIGNS].sort((a,b) => b.saturation - a.saturation).slice(0, 8);
+  const schema = JSON.stringify({
+    "@context": "https://schema.org",
+    "@graph": [
+      {
+        "@type": "Organization",
+        "name": "wallco.ai",
+        "url": "https://wallco.ai",
+        "email": "info@wallco.ai",
+        "parentOrganization": {
+          "@type": "Organization",
+          "name": "Designer Wallcoverings",
+          "url": "https://designerwallcoverings.com",
+          "address": {
+            "@type": "PostalAddress",
+            "streetAddress": "15442 Ventura Bl #102",
+            "addressLocality": "Sherman Oaks",
+            "addressRegion": "CA",
+            "postalCode": "91403"
+          }
+        }
+      },
+      {
+        "@type": "WebSite",
+        "name": "wallco.ai",
+        "url": "https://wallco.ai",
+        "description": "AI-original wallpaper and murals — every pattern generated, never repeated."
+      },
+      {
+        "@type": "ItemList",
+        "name": "Featured AI-Generated Designs",
+        "numberOfItems": featured.length,
+        "itemListElement": featured.map((d, i) => ({
+          "@type": "ListItem",
+          "position": i + 1,
+          "name": d.title,
+          "url": `https://wallco.ai/design/${d.id}`
+        }))
+      }
+    ]
+  });
+
+  const heroSlides = heroes.slice(0, 6).map((d, i) =>
+    `<div class="hero-slide ${i===0?'active':''}" data-idx="${i}" style="background-image:url('${d.image_url}')" aria-label="${d.title}"></div>`
+  ).join('\n');
+
+  const featuredCards = featured.map(d => `
+    <a href="/design/${d.id}" class="design-card">
+      <div class="card-img" style="background-image:url('${d.image_url}')"></div>
+      <div class="card-meta">
+        <span class="card-title">${d.title}</span>
+        <span class="card-cat">${d.category}</span>
+        <span class="color-dot" style="background:${d.dominant_hex}"></span>
+      </div>
+    </a>`).join('');
+
+  res.type('html').send(`${htmlHead({
+    title: 'wallco.ai — AI-Original Wallpaper & Murals',
+    description: 'AI-generated wallpaper and murals. Every pattern original, never repeated. Browse, preview, and order samples.',
+    canonical: 'https://wallco.ai/'
+  })}
+<body>
+${htmlHeader('/')}
+<main>
+
+<!-- Hero: full-bleed rotating -->
+<section class="hero-section">
+  <div class="hero-track" id="hero-track">
+    ${heroSlides}
+  </div>
+  <div class="hero-controls" aria-hidden="true">
+    ${heroes.slice(0, 6).map((_,i) => `<button class="hero-dot ${i===0?'active':''}" data-slide="${i}"></button>`).join('')}
+  </div>
+</section>
+
+<!-- Wordmark below hero (Gucci-anatomy per standing rule) -->
+<div class="hero-wordmark-below">
+  <h1 class="wordmark">wallco.ai</h1>
+  <p class="tagline">AI-original wallpaper &amp; murals &mdash; every pattern generated, never repeated.</p>
+</div>
+
+<!-- Featured designs grid -->
+<section class="featured-section">
+  <div class="section-header">
+    <h2>Featured Designs</h2>
+    <a href="/designs" class="see-all">See all ${DESIGNS.length} designs &rarr;</a>
+  </div>
+  <div class="design-grid featured-grid" id="featured-grid">
+    ${featuredCards}
+  </div>
+</section>
+
+<!-- CTA band -->
+<section class="cta-band">
+  <h2>Every design is AI-original.</h2>
+  <p>No licensing. No vendor catalogs. Each pattern is generated — a wallcovering that exists nowhere else.</p>
+  <a href="/samples" class="btn-primary">Request a Free Sample</a>
+  <a href="/designs" class="btn-outline">Browse All Designs</a>
+</section>
+
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${schema}</script>
+
+<!-- Hero rotation -->
+<script>
+(function(){
+  var slides = document.querySelectorAll('.hero-slide');
+  var dots   = document.querySelectorAll('.hero-dot');
+  var cur    = 0;
+  var interval;
+  function goTo(idx) {
+    slides[cur].classList.remove('active');
+    dots[cur].classList.remove('active');
+    cur = (idx + slides.length) % slides.length;
+    slides[cur].classList.add('active');
+    dots[cur].classList.add('active');
+  }
+  dots.forEach(function(d) {
+    d.addEventListener('click', function() { goTo(+this.dataset.slide); clearInterval(interval); interval = setInterval(function(){goTo(cur+1);}, 4500); });
+  });
+  interval = setInterval(function(){ goTo(cur+1); }, 4500);
+  // Pause when tab hidden
+  document.addEventListener('visibilitychange', function() {
+    if (document.hidden) clearInterval(interval);
+    else interval = setInterval(function(){goTo(cur+1);}, 4500);
+  });
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── DESIGNS CATALOG (full grid with sort + density + category filter)
+app.get('/designs', (req, res) => {
+  const sort = req.query.sort || 'newest';
+  const cat  = req.query.cat  || '';
+  const q    = (req.query.q   || '').toLowerCase().trim();
+  const page = Math.max(1, parseInt(req.query.page || '1', 10));
+  const PER  = 60;
+
+  let filtered = [...DESIGNS];
+  if (cat)  filtered = filtered.filter(d => d.category === cat);
+  if (q)    filtered = filtered.filter(d =>
+    d.title.toLowerCase().includes(q) ||
+    d.category.toLowerCase().includes(q) ||
+    d.handle.toLowerCase().includes(q)
+  );
+  filtered = sortDesigns(filtered, sort);
+
+  const total = filtered.length;
+  const pages = Math.max(1, Math.ceil(total / PER));
+  const slice = filtered.slice((page-1)*PER, page*PER);
+  const cats  = [...new Set(DESIGNS.map(d => d.category))].sort();
+
+  const cards = slice.map(d => `
+    <a href="/design/${d.id}" class="design-card">
+      <div class="card-img" style="background-image:url('${d.image_url}')"></div>
+      <div class="card-meta">
+        <span class="card-title">${d.title}</span>
+        <span class="card-cat">${d.category}</span>
+        <span class="color-dot" style="background:${d.dominant_hex}"></span>
+      </div>
+    </a>`).join('');
+
+  const pagerLinks = pages > 1
+    ? Array.from({length: pages}, (_,i) => i+1).map(p =>
+        `<a href="/designs?sort=${sort}&cat=${encodeURIComponent(cat)}&q=${encodeURIComponent(q)}&page=${p}" class="pager-link ${p===page?'active':''}">${p}</a>`
+      ).join(' ')
+    : '';
+
+  const schema = JSON.stringify({
+    "@context": "https://schema.org",
+    "@type": "ItemList",
+    "name": "AI-Generated Wallpaper Designs — wallco.ai",
+    "numberOfItems": total,
+    "itemListElement": slice.map((d,i) => ({
+      "@type": "ListItem",
+      "position": (page-1)*PER+i+1,
+      "name": d.title,
+      "url": `https://wallco.ai/design/${d.id}`
+    }))
+  });
+
+  res.type('html').send(`${htmlHead({
+    title: `All Designs — wallco.ai | AI-Original Wallpaper & Murals`,
+    description: `Browse ${total} AI-generated wallpaper designs. Every pattern original. Free samples available.`,
+    canonical: `https://wallco.ai/designs`
+  })}
+<body>
+${htmlHeader('/designs')}
+<main>
+<section class="catalog-section">
+  <div class="catalog-toolbar">
+    <form method="get" action="/designs" class="filter-form">
+      <input type="hidden" name="sort" id="sort-hidden" value="${sort}">
+      <div class="toolbar-row">
+        <input type="search" name="q" value="${q}" placeholder="Search designs…" class="search-input">
+        <select name="cat" class="filter-select" onchange="this.form.submit()">
+          <option value="">All Categories</option>
+          ${cats.map(c => `<option value="${c}" ${c===cat?'selected':''}>${c.charAt(0).toUpperCase()+c.slice(1)}</option>`).join('')}
+        </select>
+        <label class="sort-label">Sort
+          <select id="sort-select" class="filter-select" onchange="document.getElementById('sort-hidden').value=this.value;this.form.submit()">
+            <option value="newest"      ${sort==='newest'?'selected':''}>Newest</option>
+            <option value="title"       ${sort==='title'?'selected':''}>Title A→Z</option>
+            <option value="category"    ${sort==='category'?'selected':''}>Category</option>
+            <option value="light-dark"  ${sort==='light-dark'?'selected':''}>Light → Dark</option>
+            <option value="dark-light"  ${sort==='dark-light'?'selected':''}>Dark → Light</option>
+            <option value="color-wheel" ${sort==='color-wheel'?'selected':''}>Color Wheel</option>
+          </select>
+        </label>
+        <label class="density-label">Density
+          <input type="range" id="density-slider" min="2" max="8" step="1" value="4" class="density-range">
+        </label>
+      </div>
+    </form>
+    <p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${q?' matching "'+q+'"':''}</p>
+  </div>
+
+  <div class="design-grid catalog-grid" id="catalog-grid">
+    ${cards || '<p class="empty-state">No designs found.</p>'}
+  </div>
+
+  ${pages > 1 ? `<div class="pager">${pagerLinks}</div>` : ''}
+</section>
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${schema}</script>
+<script>
+(function(){
+  var slider = document.getElementById('density-slider');
+  var grid   = document.getElementById('catalog-grid');
+  var saved  = localStorage.getItem('wallco-density');
+  if (saved) slider.value = saved;
+  grid.style.setProperty('--cols', slider.value);
+  slider.addEventListener('input', function() {
+    grid.style.setProperty('--cols', this.value);
+    localStorage.setItem('wallco-density', this.value);
+  });
+  var sortSel = document.getElementById('sort-select');
+  var sortH   = document.getElementById('sort-hidden');
+  if (sortSel && sortH) {
+    var savedSort = localStorage.getItem('wallco-sort');
+    if (savedSort && savedSort !== '${sort}') {
+      sortH.value = savedSort;
+    }
+    sortSel.addEventListener('change', function() {
+      localStorage.setItem('wallco-sort', this.value);
+    });
+  }
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── DESIGN DETAIL
+app.get('/design/:id', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  const design = DESIGNS.find(d => d.id === id);
+  if (!design) return res.status(404).type('html').send(`${htmlHead({title:'Not Found — wallco.ai',description:'',canonical:`https://wallco.ai/design/${id}`})}<body>${htmlHeader('')}<main><div class="empty-state" style="padding:80px;text-align:center"><h2>Design not found</h2><a href="/designs">Browse all designs</a></div></main>${FOOTER}${HAMBURGER_JS}</body></html>`);
+
+  const relIdx = DESIGNS.findIndex(d => d.id === id);
+  const prev = relIdx > 0 ? DESIGNS[relIdx-1] : null;
+  const next = relIdx < DESIGNS.length-1 ? DESIGNS[relIdx+1] : null;
+
+  const schema = JSON.stringify({
+    "@context": "https://schema.org",
+    "@type": "Product",
+    "name": design.title,
+    "description": `AI-generated ${design.category} wallpaper pattern. Generator: ${design.generator}. Every pattern is original and never repeated.`,
+    "url": `https://wallco.ai/design/${design.id}`,
+    "image": `https://wallco.ai${design.image_url}`,
+    "brand": {"@type":"Brand","name":"wallco.ai"},
+    "offers": {
+      "@type": "Offer",
+      "availability": "https://schema.org/InStock",
+      "url": `https://wallco.ai/samples?design=${design.id}`,
+      "priceCurrency": "USD",
+      "description": "Free sample available"
     }
-    return 0;
   });
-  cnt.textContent = sorted.length + (tab === 'pd' ? ' PD source images' : ' generated designs');
-  grid.innerHTML = sorted.map(p => {
-    const img = imgPath(p.local_path) || p.image_url || '';
-    const dot = p.dominant_hex ? \`<span class="dot" style="background:\${p.dominant_hex}" title="\${p.dominant_hex}"></span>\` : '';
-    const badge = (p.source || p.generator || '');
-    const title = p.title || p.prompt || ('#'+p.id);
-    const sub = p.creator || p.date_created || p.kind || '';
-    const link = p.source_url || '#';
-    return \`<a class="card-link" href="\${link}" target="_blank"><div class="card">
-      <div class="img" style="background-image:url('\${img}')"></div>
-      <div class="meta">
-        <div class="title">\${dot}\${title.slice(0,60)}<span class="badge">\${badge}</span></div>
-        <div class="sub">\${sub.slice(0,80)}</div>
+
+  res.type('html').send(`${htmlHead({
+    title: `${design.title} — wallco.ai`,
+    description: `AI-generated ${design.category} wallpaper. Pattern No.${design.id} by wallco.ai — original, never repeated. Free sample available.`,
+    canonical: `https://wallco.ai/design/${design.id}`,
+    ogImage: `https://wallco.ai${design.image_url}`
+  })}
+<body>
+${htmlHeader('/designs')}
+<main class="detail-main">
+  <div class="detail-layout">
+    <div class="detail-image-col">
+      <div class="detail-img-wrap">
+        <img src="${design.image_url}" alt="${design.title}" class="detail-img" loading="eager">
+        <div class="tiled-preview" title="Tiled pattern preview">
+          <div class="tile-grid" style="--tile:url('${design.image_url}')"></div>
+          <span class="tile-label">Tiled preview</span>
+        </div>
+      </div>
+    </div>
+    <div class="detail-info-col">
+      <p class="detail-breadcrumb"><a href="/designs">Designs</a> &rsaquo; ${design.category}</p>
+      <h1 class="detail-title">${design.title}</h1>
+
+      <div class="provenance-badge">
+        <span class="ai-badge">AI-Original</span>
+        Pattern No.${String(design.id).padStart(4,'0')} &middot; ${design.category.charAt(0).toUpperCase()+design.category.slice(1)} &middot; ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind}
+      </div>
+
+      <div class="detail-palette">
+        <span class="swatch-main" style="background:${design.dominant_hex}" title="Dominant ${design.dominant_hex}"></span>
+        <span class="hex-label">${design.dominant_hex}</span>
+      </div>
+
+      <div class="detail-spec-panel">
+        <div class="spec-row"><span class="spec-key">Category</span><span class="spec-val">${design.category}</span></div>
+        <div class="spec-row"><span class="spec-key">Kind</span><span class="spec-val">${design.kind === 'seamless_tile' ? 'Seamless tile (24×24")' : design.kind}</span></div>
+        <div class="spec-row"><span class="spec-key">Generator</span><span class="spec-val">${design.generator}</span></div>
+        <div class="spec-row"><span class="spec-key">Seed</span><span class="spec-val">${design.seed}</span></div>
+        <div class="spec-row"><span class="spec-key">Handle</span><span class="spec-val">${design.handle}</span></div>
+      </div>
+
+      <!-- Sample CTA (DW standing rule — non-negotiable) -->
+      <div class="cta-group">
+        <a href="/samples?design=${design.id}" class="btn-primary cta-sample">Request Free Sample</a>
+        <a href="/designs" class="btn-outline">Back to Designs</a>
+      </div>
+
+      <!-- Tools (admin / preview) -->
+      <div class="tool-group" style="margin-top:18px;display:flex;flex-wrap:wrap;gap:10px">
+        <button type="button" class="btn-outline" id="btn-room"        data-id="${design.id}">See in a Room</button>
+        <button type="button" class="btn-outline" id="btn-spoon-dry"   data-id="${design.id}">Spoonflower (dry-run)</button>
+        <button type="button" class="btn-outline" id="btn-spoon-live"  data-id="${design.id}">Spoonflower — Upload as New</button>
+        <button type="button" class="btn-outline" id="btn-narrate"     data-id="${design.id}">Narrate (ElevenLabs)</button>
+      </div>
+      <div id="tool-result" style="margin-top:16px;display:none;padding:14px;border-radius:6px;background:rgba(0,0,0,.05);font-size:13px;line-height:1.5"></div>
+      <div id="room-result" style="margin-top:14px;display:none">
+        <img id="room-img" alt="Room preview" style="max-width:100%;border-radius:8px;border:1px solid rgba(0,0,0,.08)">
+      </div>
+      <audio id="narrate-audio" controls style="margin-top:10px;display:none;width:100%"></audio>
+
+      <script>
+      (function(){
+        var R = document.getElementById('tool-result');
+        function show(msg, isErr) {
+          R.style.display='block';
+          R.style.background = isErr ? '#f8d7da' : '#d4edda';
+          R.style.color = isErr ? '#721c24' : '#155724';
+          R.innerHTML = msg;
+        }
+        async function post(url, body) {
+          var r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
+          return r.json();
+        }
+
+        document.getElementById('btn-room').addEventListener('click', async function(){
+          show('Generating room preview… (10–30s)', false);
+          this.disabled = true;
+          var j = await post('/api/room', { design_id: this.dataset.id, roomType:'bedroom', angle:'straight_on', cameraDistance:5 });
+          this.disabled = false;
+          if (j.ok) {
+            show('Room preview ready.', false);
+            var img = document.getElementById('room-img');
+            img.src = j.image_url + '?t=' + Date.now();
+            document.getElementById('room-result').style.display = 'block';
+          } else {
+            show('Room generation failed: ' + (j.error||'unknown'), true);
+          }
+        });
+
+        document.getElementById('btn-spoon-dry').addEventListener('click', async function(){
+          show('Spoonflower dry-run: logging in + checking upload page…', false);
+          this.disabled = true;
+          var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: true });
+          this.disabled = false;
+          if (j.ok) show('Dry-run OK. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank">view log</a>', false);
+          else show('Dry-run failed: ' + (j.error||'unknown'), true);
+        });
+
+        document.getElementById('btn-spoon-live').addEventListener('click', async function(){
+          if (!confirm('Upload this design to Spoonflower as a NEW product? This is a public publish.')) return;
+          show('Live upload running…', false);
+          this.disabled = true;
+          var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: false });
+          this.disabled = false;
+          if (j.ok) show('Upload kicked off. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank">view log</a>', false);
+          else show('Upload failed: ' + (j.error||'unknown'), true);
+        });
+
+        document.getElementById('btn-narrate').addEventListener('click', async function(){
+          show('Generating narration…', false);
+          this.disabled = true;
+          try {
+            var r = await fetch('/api/voice/preview?design_id=' + this.dataset.id);
+            if (!r.ok) { var t = await r.text(); show('Narration failed: ' + t, true); this.disabled=false; return; }
+            var blob = await r.blob();
+            var a = document.getElementById('narrate-audio');
+            a.src = URL.createObjectURL(blob);
+            a.style.display = 'block';
+            a.play();
+            show('Narration ready.', false);
+          } catch(e) { show('Narration error: ' + e.message, true); }
+          this.disabled = false;
+        });
+      })();
+      </script>
+
+      <!-- Provenance note -->
+      <div class="provenance-note">
+        <strong>AI-original provenance:</strong> This pattern was generated by <em>wallco.ai</em> using Replicate SDXL.
+        It does not replicate any existing copyrighted artwork. Every seed is unique — this exact pattern exists only here.
+      </div>
+
+      ${prev || next ? `<div class="detail-nav">
+        ${prev ? `<a href="/design/${prev.id}" class="detail-nav-link prev">&larr; ${prev.title}</a>` : '<span></span>'}
+        ${next ? `<a href="/design/${next.id}" class="detail-nav-link next">${next.title} &rarr;</a>` : '<span></span>'}
+      </div>` : ''}
+    </div>
+  </div>
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${schema}</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── MURALS
+app.get('/murals', (req, res) => {
+  const murals = DESIGNS.filter(d => d.kind === 'mural' || d.kind === 'mural_panel');
+  const all    = murals.length === 0 ? DESIGNS : murals; // fallback: show all if no mural-kind yet
+
+  const cards = all.map(d => `
+    <a href="/design/${d.id}" class="design-card">
+      <div class="card-img" style="background-image:url('${d.image_url}')"></div>
+      <div class="card-meta">
+        <span class="card-title">${d.title}</span>
+        <span class="card-cat">${murals.length === 0 ? 'available as mural' : d.kind}</span>
+        <span class="color-dot" style="background:${d.dominant_hex}"></span>
       </div>
-    </div></a>\`;
-  }).join('') || '<div class="empty">Empty — run scripts/crawl_pd.py or scripts/generate_designs.js</div>';
+    </a>`).join('');
+
+  res.type('html').send(`${htmlHead({
+    title: 'Murals — wallco.ai | AI-Generated Wall Murals',
+    description: 'AI-original wall murals in any size, panelized for installation. Every design unique.',
+    canonical: 'https://wallco.ai/murals'
+  })}
+<body>
+${htmlHeader('/murals')}
+<main>
+<section class="catalog-section">
+  <div class="section-header" style="padding:40px 40px 0">
+    <h1>Murals</h1>
+    <p>Any design available as a custom-sized mural, panelized for wall installation.
+    ${murals.length === 0 ? 'All current designs can be ordered as murals — contact us.' : `${murals.length} mural designs available.`}</p>
+  </div>
+  <div class="design-grid catalog-grid" style="padding:24px 40px">
+    ${cards}
+  </div>
+</section>
+</main>
+${FOOTER}
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── ABOUT
+app.get('/about', (req, res) => {
+  res.type('html').send(`${htmlHead({
+    title: 'About — wallco.ai | How AI Wallpaper is Made',
+    description: 'Learn how wallco.ai generates original wallpaper patterns using AI. Every design is unique, AI-original, never licensed from a vendor.',
+    canonical: 'https://wallco.ai/about'
+  })}
+<body>
+${htmlHeader('/about')}
+<main class="prose-main">
+  <article class="prose">
+    <h1>How wallco.ai works</h1>
+
+    <h2>AI-original, never licensed</h2>
+    <p>Every pattern on wallco.ai was generated by an AI model — not sourced from a vendor catalog,
+    not licensed from a studio. Each design is produced from a text prompt and a unique random seed,
+    so the pattern that emerges is mathematically unrepeatable.</p>
+
+    <h2>The generation pipeline</h2>
+    <p>We use Replicate SDXL, a state-of-the-art diffusion model trained on millions of images.
+    Source inspiration comes from public-domain artwork in the Metropolitan Museum of Art,
+    Wikimedia Commons, and the Smithsonian Institution — institutions with open licenses
+    (CC0 / Public Domain) that permit derivative AI training and generation.</p>
+
+    <h2>Seamless tiling</h2>
+    <p>Each design is generated as a seamless tile — the edges match on all four sides, so it can
+    repeat across a wall without visible seams. Standard tile size is 24×24 inches at 150 DPI.</p>
+
+    <h2>Murals</h2>
+    <p>Any design can be printed as a custom-sized mural, panelized for your wall dimensions.
+    Panel splitting for installation is handled during order processing.</p>
+
+    <h2>About wallco.ai</h2>
+    <p>wallco.ai is a product of <a href="https://designerwallcoverings.com">Designer Wallcoverings</a>,
+    Sherman Oaks, CA. We combine 30 years of wallcovering expertise with frontier AI generation to
+    create patterns that have never existed before.</p>
+
+    <p><a href="/samples" class="btn-primary">Request a Free Sample</a></p>
+  </article>
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${JSON.stringify({
+  "@context":"https://schema.org",
+  "@type":"WebPage",
+  "name":"About wallco.ai",
+  "url":"https://wallco.ai/about",
+  "description":"How wallco.ai generates original AI wallpaper patterns.",
+  "isPartOf":{"@type":"WebSite","url":"https://wallco.ai","name":"wallco.ai"}
+})}</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── SAMPLES (lead capture — no commerce in v1)
+app.get('/samples', (req, res) => {
+  const designId = req.query.design || '';
+  const design   = designId ? DESIGNS.find(d => d.id === parseInt(designId,10)) : null;
+
+  res.type('html').send(`${htmlHead({
+    title: 'Request a Sample — wallco.ai',
+    description: 'Request a free physical or digital sample of any wallco.ai AI-generated design.',
+    canonical: 'https://wallco.ai/samples'
+  })}
+<body>
+${htmlHeader('/samples')}
+<main class="prose-main">
+  <div class="sample-form-wrap">
+    <h1>Request a Free Sample</h1>
+    <p>We'll send you a physical swatch or a high-res digital proof. No charge, no obligation.</p>
+
+    ${design ? `<div class="sample-preview">
+      <img src="${design.image_url}" alt="${design.title}" style="width:120px;height:120px;object-fit:cover;border-radius:4px;">
+      <div><strong>${design.title}</strong><br>Pattern No.${String(design.id).padStart(4,'0')}</div>
+    </div>` : ''}
+
+    <form method="post" action="/api/leads" class="sample-form" id="sample-form">
+      <input type="hidden" name="source" value="samples-page">
+      ${design ? `<input type="hidden" name="design_id" value="${design.id}">` : ''}
+
+      <label>Design
+        <select name="design_id" ${design ? 'disabled' : ''}>
+          ${design
+            ? `<option value="${design.id}" selected>${design.title}</option>`
+            : DESIGNS.map(d => `<option value="${d.id}" ${d.id===parseInt(designId,10)?'selected':''}>${d.title} (No.${d.id})</option>`).join('')
+          }
+        </select>
+      </label>
+      <label>Your name <input type="text" name="name" required placeholder="Jane Smith"></label>
+      <label>Email <input type="email" name="email" required placeholder="you@example.com"></label>
+      <label>Phone (optional) <input type="tel" name="phone" placeholder="+1 (555) 000-0000"></label>
+      <label>Message (optional) <textarea name="message" rows="3" placeholder="Any notes about your project…"></textarea></label>
+      <button type="submit" class="btn-primary">Send Sample Request</button>
+    </form>
+    <div id="form-result" style="display:none;margin-top:16px;padding:16px;border-radius:4px;"></div>
+  </div>
+</main>
+${FOOTER}
+
+<script>
+document.getElementById('sample-form').addEventListener('submit', async function(e) {
+  e.preventDefault();
+  var form = e.target;
+  var result = document.getElementById('form-result');
+  var data = Object.fromEntries(new FormData(form));
+  try {
+    var r = await fetch('/api/leads', {
+      method: 'POST',
+      headers: {'Content-Type':'application/json'},
+      body: JSON.stringify(data)
+    });
+    var json = await r.json();
+    result.style.display = 'block';
+    if (json.ok) {
+      result.style.background = '#d4edda';
+      result.style.color = '#155724';
+      result.textContent = 'Thank you! We\\'ll be in touch shortly.';
+      form.reset();
+    } else {
+      result.style.background = '#f8d7da';
+      result.style.color = '#721c24';
+      result.textContent = 'Something went wrong — please email info@wallco.ai directly.';
+    }
+  } catch(err) {
+    result.style.display = 'block';
+    result.textContent = 'Network error — please try again.';
+  }
+});
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── API: Lead capture
+app.post('/api/leads', async (req, res) => {
+  const { name, email, phone, message, design_id, source } = req.body;
+  if (!email) return res.status(400).json({ ok: false, error: 'email required' });
+
+  // Write to dw_unified.wallco_leads (create table if not exists)
+  try {
+    const { execSync } = require('child_process');
+    const sql = `
+      CREATE TABLE IF NOT EXISTS wallco_leads (
+        id BIGSERIAL PRIMARY KEY,
+        name TEXT,
+        email TEXT NOT NULL,
+        phone TEXT,
+        message TEXT,
+        design_id BIGINT,
+        source TEXT,
+        created_at TIMESTAMPTZ DEFAULT now()
+      );
+      INSERT INTO wallco_leads (name, email, phone, message, design_id, source)
+      VALUES (
+        ${name    ? `'${name.replace(/'/g,"''")}'`       : 'NULL'},
+        '${email.replace(/'/g,"''")}',
+        ${phone   ? `'${phone.replace(/'/g,"''")}'`      : 'NULL'},
+        ${message ? `'${message.replace(/'/g,"''")}'`    : 'NULL'},
+        ${design_id ? parseInt(design_id,10) : 'NULL'},
+        ${source  ? `'${source.replace(/'/g,"''")}'`     : 'NULL'}
+      );
+    `;
+    execSync(`psql ${process.env.DATABASE_URL || 'dw_unified'} -c "${sql.replace(/\n/g,' ')}"`, { encoding: 'utf8' });
+  } catch (e) {
+    console.error('Lead insert error:', e.message);
+    // Still return ok — don't block the user on a DB error
+  }
+  res.json({ ok: true });
+});
+
+// ── API: designs JSON (for future use / inline editor)
+app.get('/api/designs', (req, res) => {
+  const sort = req.query.sort || 'newest';
+  const cat  = req.query.cat  || '';
+  const q    = (req.query.q   || '').toLowerCase();
+  const page = Math.max(1, parseInt(req.query.page || '1', 10));
+  const PER  = parseInt(req.query.limit || '60', 10);
+
+  let filtered = [...DESIGNS];
+  if (cat)  filtered = filtered.filter(d => d.category === cat);
+  if (q)    filtered = filtered.filter(d =>
+    d.title.toLowerCase().includes(q) || d.category.includes(q)
+  );
+  filtered = sortDesigns(filtered, sort);
+
+  const total = filtered.length;
+  const pages = Math.ceil(total / PER);
+  const items = filtered.slice((page-1)*PER, page*PER);
+  res.json({ ok: true, total, page, pages, items });
+});
+
+// ── API: inline editor state (admin gate)
+app.get('/api/_inline/state', (req, res) => {
+  if (!isAdmin(req)) return res.status(401).json({ ok: false, error: 'unauthorized' });
+  res.json({ ok: true, site: SITE, designCount: DESIGNS.length });
+});
+
+// ─────────────────────────────────────────────────────────────────────────
+// SPOONFLOWER + ROOM PREVIEW + VOICE
+// ─────────────────────────────────────────────────────────────────────────
+
+function findDesign(id) {
+  return DESIGNS.find(d => d.id === parseInt(id, 10));
 }
 
-async function load() {
-  const r = await fetch(tab === 'pd' ? '/api/pd' : '/api/designs');
-  const data = await r.json();
-  render(data);
+function localPathForDesign(d) {
+  // Designs have image_url like /designs/img/<file>.png — map back to data/generated/
+  if (!d) return null;
+  if (d.local_path) return d.local_path;
+  if (d.filename) return path.join(IMG_DIR, d.filename);
+  if (d.image_url && d.image_url.startsWith('/designs/img/')) {
+    return path.join(IMG_DIR, d.image_url.replace('/designs/img/', ''));
+  }
+  return null;
 }
 
-sortSel.addEventListener('change', () => { localStorage.setItem('wc.sort', sortSel.value); load(); });
-dens.addEventListener('input', () => {
-  document.documentElement.style.setProperty('--cols', dens.value);
-  localStorage.setItem('wc.density', dens.value);
+// ── POST /api/room — generate room visualization for a design
+app.post('/api/room', async (req, res) => {
+  const { design_id, roomType = 'bedroom', angle = 'straight_on',
+          cameraDistance = 5, patternWidth = 27, patternHeight = 27 } = req.body || {};
+  const d = findDesign(design_id);
+  if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
+  const file = localPathForDesign(d);
+  if (!file || !fs.existsSync(file)) {
+    return res.status(404).json({ ok: false, error: 'design file missing on disk', file });
+  }
+  try {
+    const b64 = fs.readFileSync(file).toString('base64');
+    const upstream = await fetch(ROOM_API, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        patternBase64: b64, roomType, angle,
+        cameraDistance, patternWidth, patternHeight
+      })
+    });
+    const j = await upstream.json();
+    if (!j || j.success === false || !j.image) {
+      return res.status(502).json({ ok: false, error: j && j.error || 'upstream error', upstream: j });
+    }
+    // upstream returns { success, image, ... } — `image` is base64 or data-URL
+    const imgB64 = j.image.replace(/^data:image\/\w+;base64,/, '');
+    const fname = `room_${design_id}_${roomType}_${Date.now()}.png`;
+    const outPath = path.join(ROOMS_DIR, fname);
+    fs.writeFileSync(outPath, Buffer.from(imgB64, 'base64'));
+    res.json({ ok: true, image_url: `/uploads/rooms/${fname}`, roomType, design_id });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ── POST /api/spoonflower/upload — upload ONE design as new product
+app.post('/api/spoonflower/upload', (req, res) => {
+  const { design_id, dry = true, tags = '' } = req.body || {};
+  const d = findDesign(design_id);
+  if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
+  const file = localPathForDesign(d);
+  if (!file || !fs.existsSync(file)) {
+    return res.status(404).json({ ok: false, error: 'design file missing on disk', file });
+  }
+  const title = `${d.title} — wallco.ai AI-Original`;
+  const tagList = tags || `wallpaper,ai-generated,${d.category||''},wallco,${d.dominant_hex||''}`.replace(/,+$/,'');
+
+  let jobId;
+  try {
+    jobId = psqlQuery(`INSERT INTO spoonflower_jobs(kind, design_id, status, dry_run, params)
+                       VALUES('upload_new', ${parseInt(design_id,10)}, 'running', ${dry?'TRUE':'FALSE'},
+                       '${JSON.stringify({title, tagList, file}).replace(/'/g,"''")}'::jsonb)
+                       RETURNING id;`);
+  } catch (e) { return res.status(500).json({ ok:false, error: 'db: '+e.message }); }
+
+  const logFile = path.join(SF_JOBS_DIR, `upload_${jobId}.log`);
+  const args = [
+    path.join(__dirname, 'scripts', 'upload_spoonflower_new.py'),
+    file, title, '--tags', tagList,
+    ...(dry ? ['--dry'] : ['--live'])
+  ];
+  const proc = spawn('python3', args, {
+    cwd: __dirname,
+    env: { ...process.env, DATABASE_URL: DB_URL },
+    detached: true, stdio: ['ignore', 'pipe', 'pipe']
+  });
+  const out = fs.createWriteStream(logFile);
+  proc.stdout.pipe(out, { end: false });
+  proc.stderr.pipe(out, { end: false });
+  proc.on('exit', code => {
+    out.end();
+    try {
+      psqlQuery(`UPDATE spoonflower_jobs SET status='${code===0?'done':'error'}',
+                 finished_at=now(),
+                 log='${fs.readFileSync(logFile,'utf8').replace(/'/g,"''").slice(-8000)}'
+                 WHERE id=${jobId};`);
+    } catch {}
+  });
+  proc.unref();
+  res.json({ ok: true, job_id: jobId, dry, log_url: `/api/spoonflower/status/${jobId}` });
+});
+
+// ── POST /api/spoonflower/pull-all — bulk pull DW account designs
+app.post('/api/spoonflower/pull-all', (req, res) => {
+  const { limit = 0, account = 'dw' } = req.body || {};
+
+  let jobId;
+  try {
+    jobId = psqlQuery(`INSERT INTO spoonflower_jobs(kind, status, params)
+                       VALUES('pull_all', 'running',
+                       '${JSON.stringify({limit, account}).replace(/'/g,"''")}'::jsonb)
+                       RETURNING id;`);
+  } catch (e) { return res.status(500).json({ ok:false, error:'db: '+e.message }); }
+
+  const logFile = path.join(SF_JOBS_DIR, `pull_${jobId}.log`);
+  const args = [
+    path.join(__dirname, 'scripts', 'pull_spoonflower_account.py'),
+    '--account', String(account)
+  ];
+  if (limit && parseInt(limit,10) > 0) { args.push('--limit', String(parseInt(limit,10))); }
+
+  const proc = spawn('python3', args, {
+    cwd: __dirname,
+    env: { ...process.env, DATABASE_URL: DB_URL },
+    detached: true, stdio: ['ignore', 'pipe', 'pipe']
+  });
+  const out = fs.createWriteStream(logFile);
+  proc.stdout.pipe(out, { end:false });
+  proc.stderr.pipe(out, { end:false });
+  proc.on('exit', code => {
+    out.end();
+    try {
+      psqlQuery(`UPDATE spoonflower_jobs SET status='${code===0?'done':'error'}',
+                 finished_at=now(),
+                 log='${fs.readFileSync(logFile,'utf8').replace(/'/g,"''").slice(-8000)}'
+                 WHERE id=${jobId};`);
+    } catch {}
+  });
+  proc.unref();
+  res.json({ ok:true, job_id: jobId, log_url: `/api/spoonflower/status/${jobId}` });
+});
+
+// ── GET /api/spoonflower/status/:job — return job + log tail
+app.get('/api/spoonflower/status/:job', (req, res) => {
+  const jobId = parseInt(req.params.job, 10);
+  let row;
+  try {
+    row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id,kind,design_id,status,dry_run,params,created_at,finished_at
+                     FROM spoonflower_jobs WHERE id=${jobId}) t;`);
+  } catch (e) { return res.status(500).json({ ok:false, error:'db: '+e.message }); }
+  if (!row) return res.status(404).json({ ok:false, error:'job not found' });
+
+  // Find log file
+  let log = '';
+  for (const f of ['upload', 'pull']) {
+    const p = path.join(SF_JOBS_DIR, `${f}_${jobId}.log`);
+    if (fs.existsSync(p)) { log = fs.readFileSync(p, 'utf8'); break; }
+  }
+  res.type('application/json').send(JSON.stringify({ ok:true, job: JSON.parse(row), log_tail: log.slice(-8000) }, null, 2));
 });
 
-setTab(tab);
-</script>`);
+// ── GET /spoonflower — admin page: pulled DW library + per-design publish buttons
+app.get('/spoonflower', (req, res) => {
+  let pulled = [];
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.pulled_at DESC), '[]'::json) FROM (
+      SELECT id, sf_design_id, title, slug, design_url, thumbnail_url, local_path, file_bytes, account, pulled_at
+      FROM spoonflower_pulled_designs LIMIT 2000) t;`);
+    pulled = JSON.parse(raw || '[]');
+  } catch {}
+
+  let recentJobs = [];
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (
+      SELECT id, kind, design_id, status, dry_run, created_at, finished_at
+      FROM spoonflower_jobs ORDER BY created_at DESC LIMIT 30) t;`);
+    recentJobs = JSON.parse(raw || '[]');
+  } catch {}
+
+  res.type('html').send(`${htmlHead({
+    title: 'Spoonflower Library — wallco.ai admin',
+    description: 'DW Spoonflower designs (pulled) + upload jobs',
+    canonical: 'https://wallco.ai/spoonflower'
+  })}
+<body>
+${htmlHeader('')}
+<main style="padding:40px">
+  <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:32px;margin:0 0 8px">DW Spoonflower Library</h1>
+  <p style="color:#666;margin:0 0 24px">${pulled.length} design${pulled.length===1?'':'s'} pulled · ${recentJobs.length} recent job${recentJobs.length===1?'':'s'}</p>
+
+  <div style="margin-bottom:24px;display:flex;gap:10px;flex-wrap:wrap">
+    <button id="btn-pull-test" class="btn-outline">Pull-all (limit 3, dry test)</button>
+    <button id="btn-pull-all"  class="btn-primary">Pull ALL DW designs</button>
+    <span id="pull-status" style="align-self:center;color:#666;font-size:13px"></span>
+  </div>
+
+  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:32px 0 12px">Pulled designs</h2>
+  ${pulled.length === 0
+    ? '<p style="color:#999">No designs pulled yet. Click "Pull ALL DW designs" above to start.</p>'
+    : `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px">
+        ${pulled.map(p => `<div style="border:1px solid rgba(0,0,0,.08);border-radius:6px;overflow:hidden;background:#fff">
+          ${p.thumbnail_url ? `<img src="${p.thumbnail_url}" style="width:100%;aspect-ratio:1;object-fit:cover" loading="lazy">` : '<div style="aspect-ratio:1;background:#eee"></div>'}
+          <div style="padding:10px 12px;font-size:12px">
+            <div style="font-weight:500">${(p.title||'(no title)').slice(0,48)}</div>
+            <div style="color:#888;margin-top:2px">SF#${p.sf_design_id}${p.file_bytes ? ' · '+Math.round(p.file_bytes/1024)+' KB' : ''}</div>
+            <a href="${p.design_url}" target="_blank" style="color:#888;font-size:11px">view on spoonflower →</a>
+          </div>
+        </div>`).join('')}
+      </div>`}
+
+  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:40px 0 12px">Recent jobs</h2>
+  ${recentJobs.length === 0
+    ? '<p style="color:#999">No jobs yet.</p>'
+    : `<table style="width:100%;border-collapse:collapse;font-size:13px">
+        <thead><tr style="text-align:left;border-bottom:1px solid #ddd;color:#666">
+          <th style="padding:8px 6px">ID</th><th>Kind</th><th>Design</th><th>Status</th><th>Dry</th><th>Created</th><th></th>
+        </tr></thead><tbody>
+        ${recentJobs.map(j => `<tr style="border-bottom:1px solid #f0f0f0">
+          <td style="padding:8px 6px">${j.id}</td>
+          <td>${j.kind}</td>
+          <td>${j.design_id || '—'}</td>
+          <td><span style="padding:2px 6px;border-radius:3px;background:${j.status==='done'?'#d4edda':j.status==='error'?'#f8d7da':'#fff3cd'};color:${j.status==='done'?'#155724':j.status==='error'?'#721c24':'#856404'}">${j.status}</span></td>
+          <td>${j.dry_run ? 'yes' : '—'}</td>
+          <td style="color:#888">${j.created_at ? new Date(j.created_at).toLocaleString() : '—'}</td>
+          <td><a href="/api/spoonflower/status/${j.id}" target="_blank" style="color:#888">log →</a></td>
+        </tr>`).join('')}
+        </tbody></table>`}
+</main>
+${FOOTER}
+<script>
+async function post(u, b){ var r = await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
+document.getElementById('btn-pull-test').addEventListener('click', async function(){
+  this.disabled=true;
+  document.getElementById('pull-status').textContent = 'Starting limited pull (3 designs)…';
+  var j = await post('/api/spoonflower/pull-all', { limit: 3, account: 'dw' });
+  document.getElementById('pull-status').innerHTML = j.ok
+    ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank">view log</a>. Reload in ~60s.'
+    : 'Failed: ' + (j.error||'unknown');
+  this.disabled=false;
+});
+document.getElementById('btn-pull-all').addEventListener('click', async function(){
+  if (!confirm('Pull ALL DW Spoonflower designs? This may take 10-30+ minutes and downloads every file to disk.')) return;
+  this.disabled=true;
+  document.getElementById('pull-status').textContent = 'Starting full pull…';
+  var j = await post('/api/spoonflower/pull-all', { limit: 0, account: 'dw' });
+  document.getElementById('pull-status').innerHTML = j.ok
+    ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank">view log</a>. Reload page to refresh table.'
+    : 'Failed: ' + (j.error||'unknown');
+  this.disabled=false;
+});
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── GET /api/voice/preview — short ElevenLabs narration for a design
+app.get('/api/voice/preview', async (req, res) => {
+  if (!ELEVEN_KEY) return res.status(500).type('text/plain').send('ELEVENLABS_API_KEY not configured');
+  const d = findDesign(req.query.design_id);
+  if (!d) return res.status(404).type('text/plain').send('design not found');
+  const text = `${d.title}. An AI-original wallpaper pattern by wallco. Category: ${d.category}. Dominant color: ${d.dominant_hex}. Generated by ${d.generator}, seed ${d.seed}. Every pattern is unique, never repeated.`;
+  try {
+    const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE_ID}`, {
+      method: 'POST',
+      headers: {
+        'xi-api-key': ELEVEN_KEY,
+        'Content-Type': 'application/json',
+        'Accept': 'audio/mpeg'
+      },
+      body: JSON.stringify({
+        text,
+        model_id: 'eleven_turbo_v2_5',
+        voice_settings: { stability: 0.4, similarity_boost: 0.75 }
+      })
+    });
+    if (!r.ok) {
+      const errText = await r.text();
+      return res.status(502).type('text/plain').send('elevenlabs: '+errText.slice(0,400));
+    }
+    res.setHeader('Content-Type', 'audio/mpeg');
+    res.setHeader('Cache-Control', 'no-store');
+    const buf = Buffer.from(await r.arrayBuffer());
+    res.end(buf);
+  } catch (e) {
+    res.status(500).type('text/plain').send('voice error: '+e.message);
+  }
 });
 
-app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));
+// ── HEALTH
+app.get('/health', (_req, res) => {
+  res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
+});
+
+// ── Start
+app.listen(PORT, '0.0.0.0', () => {
+  console.log(`wallco.ai production server → http://0.0.0.0:${PORT}`);
+  console.log(`  Designs loaded: ${DESIGNS.length}`);
+  console.log(`  GA4: ${GA4_ID || '(not set)'}`);
+});

← f5bdf9b wallco.ai: wire Replicate SDXL backend · inline-editor mount  ·  back to Wallco Ai  ·  add finish_dns.sh — CF DNS + certbot + health check script f f3dc9ad →