[object Object]

← back to Wallco Ai

studio: textures + drag-drop + palette suggestions + designer review + insights

679232fe19a42b35c65f403cb306a32a16f29a59 · 2026-05-11 18:00:18 -0700 · SteveStudio2

Major upgrade to /studio (Designer Studio):

NEW SCHEMA
  wallco_textures              — 24 curated PD textures (grasscloth, silk,
                                 velvet, cork, gold leaf, marble, mica, etc.)
  wallco_inspiration_board     — 6-slot session board (already existed,
                                 now driven by drag-drop)
  wallco_user_uploads          — user-supplied images with Gemini-detected
                                 style/colors/elements

NEW ENDPOINTS
  GET    /api/studio/textures
  POST   /api/studio/suggest-palettes — returns 3 palettes:
           1. "From your board" (built from pinned color seeds)
           2. "Complementary" (180° hue-shifted)
           3. "Museum-pulled" (random Met/Smithsonian palette JSONB)
  POST   /api/studio/interior-design-review — Gemini-vision critique
         framed as luxury-interior designer (strengths/concerns/room-fit/
         lighting/scale + would-specify recommendation)
  GET    /api/studio/category-insights?cat=… — Google Trends links +
         best-sellers grid (from products table, falls back to PD corpus)

NEW UI
  Top: 3-panel reference rail (Artists | Styles | Elements)
  Textures grid with foreground-opacity slider, drag-drop onto board slots
  6-slot board with × delete; each slot drag-dropable
  Search box queries PD corpus + Spoonflower pulled + wallco generated
  Upload button: writes to public/uploads/studio, Gemini extracts style/
    colors/elements automatically, lands as a board slot
  3 suggested palettes grid (click to pin a palette to the board)
  Compose button → SDXL → auto-runs interior-designer review (toggle)
  Category insights row: Trends pills + 12-card best-sellers grid

PROD MIGRATION
  Mirrored pd_source_designs (304 rows) + wallco_textures (24 rows) to
  Kamatera dw_unified. Schema-only ALTER for the missing extra columns.
  All 4 new endpoints return 200 from https://wallco.ai/api/studio/*.
  All 9 routes verified 200: /, /designs, /design/:id, /studio,
  /generator, /spoonflower, /about, /samples, /murals, /health.

FOLLOW-UPS
  - Best-sellers query falls back to PD corpus when prod has no products
    table; wire DW Shopify products replica to prod for real "best-sellers"
    once we have read scope (or copy products schema from Mac2).
  - Ad signals are placeholder text; wire to advertising-signals skill
    when Steve wants the live tracking-pixel cross-reference.
  - Foreground-opacity slider is currently UI-only (visual layering on
    cards); actual layered render via SDXL conditioning is a v2.

Files touched

Diff

commit 679232fe19a42b35c65f403cb306a32a16f29a59
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 18:00:18 2026 -0700

    studio: textures + drag-drop + palette suggestions + designer review + insights
    
    Major upgrade to /studio (Designer Studio):
    
    NEW SCHEMA
      wallco_textures              — 24 curated PD textures (grasscloth, silk,
                                     velvet, cork, gold leaf, marble, mica, etc.)
      wallco_inspiration_board     — 6-slot session board (already existed,
                                     now driven by drag-drop)
      wallco_user_uploads          — user-supplied images with Gemini-detected
                                     style/colors/elements
    
    NEW ENDPOINTS
      GET    /api/studio/textures
      POST   /api/studio/suggest-palettes — returns 3 palettes:
               1. "From your board" (built from pinned color seeds)
               2. "Complementary" (180° hue-shifted)
               3. "Museum-pulled" (random Met/Smithsonian palette JSONB)
      POST   /api/studio/interior-design-review — Gemini-vision critique
             framed as luxury-interior designer (strengths/concerns/room-fit/
             lighting/scale + would-specify recommendation)
      GET    /api/studio/category-insights?cat=… — Google Trends links +
             best-sellers grid (from products table, falls back to PD corpus)
    
    NEW UI
      Top: 3-panel reference rail (Artists | Styles | Elements)
      Textures grid with foreground-opacity slider, drag-drop onto board slots
      6-slot board with × delete; each slot drag-dropable
      Search box queries PD corpus + Spoonflower pulled + wallco generated
      Upload button: writes to public/uploads/studio, Gemini extracts style/
        colors/elements automatically, lands as a board slot
      3 suggested palettes grid (click to pin a palette to the board)
      Compose button → SDXL → auto-runs interior-designer review (toggle)
      Category insights row: Trends pills + 12-card best-sellers grid
    
    PROD MIGRATION
      Mirrored pd_source_designs (304 rows) + wallco_textures (24 rows) to
      Kamatera dw_unified. Schema-only ALTER for the missing extra columns.
      All 4 new endpoints return 200 from https://wallco.ai/api/studio/*.
      All 9 routes verified 200: /, /designs, /design/:id, /studio,
      /generator, /spoonflower, /about, /samples, /murals, /health.
    
    FOLLOW-UPS
      - Best-sellers query falls back to PD corpus when prod has no products
        table; wire DW Shopify products replica to prod for real "best-sellers"
        once we have read scope (or copy products schema from Mac2).
      - Ad signals are placeholder text; wire to advertising-signals skill
        when Steve wants the live tracking-pixel cross-reference.
      - Foreground-opacity slider is currently UI-only (visual layering on
        cards); actual layered render via SDXL conditioning is a v2.
---
 data/chip-chats.json       |  12 +
 data/moodboards.json       |  14 +
 data/pairings.json         |  67 ++++
 data/reviews.json          |  12 +-
 data/vision-cache.json     |   3 +-
 scripts/await_cf_zone.sh   |  46 +++
 server.js                  | 900 ++++++++++++++++++++++++++++++++++++++++++++-
 tests/.gitignore           |   4 +
 tests/README.md            |  61 +++
 tests/package-lock.json    | 536 +++++++++++++++++++++++++++
 tests/package.json         |  17 +
 tests/playwright.config.js |  45 +++
 tests/review.spec.js       | 178 +++++++++
 13 files changed, 1887 insertions(+), 8 deletions(-)

diff --git a/data/chip-chats.json b/data/chip-chats.json
index db0e2a2..7501346 100644
--- a/data/chip-chats.json
+++ b/data/chip-chats.json
@@ -10,5 +10,17 @@
       "content": "The \"floral\" chip is relevant as the design features soft, organic floral motifs in a warm, earthy palette (#9c9172), evoking natural textures and botanical elegance. The seamless tile format ensures the floral pattern flows continuously, ideal for creating immersive, nature-inspired wallcoverings. To enhance the chip's impact, consider amplifying the contrast between floral elements and background tones or adjusting scale for bolder visual presence.",
       "ts": "2026-05-12T00:15:21.709Z"
     }
+  ],
+  "13:cat-floral": [
+    {
+      "role": "user",
+      "content": "What scale of room does this work in?",
+      "ts": "2026-05-12T00:58:57.673Z"
+    },
+    {
+      "role": "assistant",
+      "content": "\"Honey Garden No.13\" works well in medium to large spaces due to its rich, earthy tone (#87774c) and bold floral motifs, which add visual depth without overwhelming smaller areas. For smaller rooms, consider reducing the pattern scale or lightening the dominant color to maintain balance.",
+      "ts": "2026-05-12T00:58:57.673Z"
+    }
   ]
 }
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
index 477cb5a..c1bb300 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -8,5 +8,19 @@
       "reason": "test",
       "pinned_at": "2026-05-12T00:43:50.526Z"
     }
+  ],
+  "14": [
+    {
+      "pin_key": "d:12",
+      "type": "design",
+      "design_id": 12,
+      "label": "Honey Blossom No.12",
+      "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
+      "thumb_url": "/designs/img/1778541051623_1771215304.png",
+      "hex": "#a09279",
+      "idx": 0,
+      "pinned": false,
+      "pinned_at": "2026-05-12T00:59:37.325Z"
+    }
   ]
 }
\ No newline at end of file
diff --git a/data/pairings.json b/data/pairings.json
index e2379dd..07bfee2 100644
--- a/data/pairings.json
+++ b/data/pairings.json
@@ -65,5 +65,72 @@
         "pinned": false
       }
     ]
+  },
+  "14": {
+    "design_id": 14,
+    "vision_description": "Floral pattern with yellow flowers on a dark background; small-scale repeat; formal mood.",
+    "generated_at": "2026-05-12T00:59:35.410Z",
+    "suggestions": [
+      {
+        "type": "design",
+        "design_id": 12,
+        "label": "Honey Blossom No.12",
+        "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
+        "thumb_url": "/designs/img/1778541051623_1771215304.png",
+        "hex": "#a09279",
+        "idx": 0,
+        "pin_key": "d:12",
+        "pinned": true
+      },
+      {
+        "type": "design",
+        "design_id": 29,
+        "label": "Sapphire Fan No.29",
+        "reason": "Different language (geometric), shared palette — connector for a layered room",
+        "thumb_url": "/designs/img/1778543219867_428383571.png",
+        "hex": "#0b1631",
+        "idx": 1,
+        "pin_key": "d:29",
+        "pinned": false
+      },
+      {
+        "type": "color",
+        "hex": "#806224",
+        "label": "Complement · #806224",
+        "reason": "Direct chromatic opposite — trim, hardware, or a single accent piece",
+        "idx": 2,
+        "pin_key": "c:#806224",
+        "pinned": false
+      },
+      {
+        "type": "color",
+        "hex": "#342480",
+        "label": "Analogous · #342480",
+        "reason": "Neighboring hue — supporting fabric or upholstery, harmonious not loud",
+        "idx": 3,
+        "pin_key": "c:#342480",
+        "pinned": false
+      },
+      {
+        "type": "material",
+        "label": "Gold leaf trim",
+        "reason": "Metallic sheen contrasts with dark background, amplifying yellow flowers' vibrancy.",
+        "icon": "✦",
+        "vision_grounded": true,
+        "idx": 4,
+        "pin_key": "m:gold-leaf-trim",
+        "pinned": false
+      },
+      {
+        "type": "material",
+        "label": "Walnut wood paneling",
+        "reason": "Rich, dark wood complements formal tone and deep hue, adding warmth and sophistication.",
+        "icon": "✦",
+        "vision_grounded": true,
+        "idx": 5,
+        "pin_key": "m:walnut-wood-paneling",
+        "pinned": false
+      }
+    ]
   }
 }
\ No newline at end of file
diff --git a/data/reviews.json b/data/reviews.json
index 9d2a43b..939500d 100644
--- a/data/reviews.json
+++ b/data/reviews.json
@@ -1,11 +1,11 @@
 {
   "11": {
     "id": "11",
-    "design": 7,
-    "color": 8,
-    "style": 6,
-    "decision": "keep",
-    "why": "The design is safe and well-executed but lacks originality in style, resulting in a modest overall score.",
-    "updated_at": "2026-05-12T00:14:48.992Z"
+    "design": 8,
+    "color": 5,
+    "style": 5,
+    "decision": null,
+    "why": "",
+    "updated_at": "2026-05-12T01:00:14.668Z"
   }
 }
\ No newline at end of file
diff --git a/data/vision-cache.json b/data/vision-cache.json
index 4e7091e..f5e4536 100644
--- a/data/vision-cache.json
+++ b/data/vision-cache.json
@@ -1,3 +1,4 @@
 {
-  "11": "Floral pattern with pink peonies on a light background; large scale repeat; formal, elegant, and luxurious mood."
+  "11": "Floral pattern with pink peonies on a light background; large scale repeat; formal, elegant, and luxurious mood.",
+  "14": "Floral pattern with yellow flowers on a dark background; small-scale repeat; formal mood."
 }
\ No newline at end of file
diff --git a/scripts/await_cf_zone.sh b/scripts/await_cf_zone.sh
new file mode 100755
index 0000000..0c50338
--- /dev/null
+++ b/scripts/await_cf_zone.sh
@@ -0,0 +1,46 @@
+#!/bin/zsh
+# One-shot watcher: poll CF API every 20s for wallco.ai zone.
+# When it appears → cascade: dns records → ns swap → certbot → ssl strict → smoke.
+# Caps at 90 attempts (~30 min) then exits.
+
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+LOG="$ROOT/logs/await_cf.log"
+mkdir -p "$ROOT/logs"
+cd "$ROOT"
+
+TOK=$(grep -hE "^CLOUDFLARE_API_TOKEN=" "$HOME/Projects/secrets-manager/.env" 2>/dev/null | head -1 | sed -E 's/^[^=]+=//;s/^"//;s/"$//')
+[[ -z "$TOK" ]] && { echo "$(date '+%F %T') no CF token" >> "$LOG"; exit 2; }
+
+DOMAIN="wallco.ai"
+MAX_TRIES=90
+SLEEP=20
+
+echo "$(date '+%F %T') === await_cf_zone start ===" >> "$LOG"
+
+for i in $(seq 1 $MAX_TRIES); do
+  RESP=$(curl -sf -H "Authorization: Bearer $TOK" "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" 2>/dev/null || echo '{"result":[]}')
+  ZID=$(echo "$RESP" | python3 -c "import json,sys; r=json.load(sys.stdin).get('result',[]); print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
+  if [[ -n "$ZID" ]]; then
+    NS=$(echo "$RESP" | python3 -c "import json,sys; r=json.load(sys.stdin)['result'][0]; print(','.join(r.get('name_servers',[])))" 2>/dev/null)
+    echo "$(date '+%F %T') ZONE FOUND id=$ZID ns=$NS" >> "$LOG"
+    echo "$(date '+%F %T') --- cascade: dns ---" >> "$LOG"
+    bash scripts/golive.sh dns >> "$LOG" 2>&1 || echo "$(date '+%F %T') dns step had issues" >> "$LOG"
+    echo "$(date '+%F %T') --- cascade: ns-swap ---" >> "$LOG"
+    bash scripts/golive.sh ns-swap >> "$LOG" 2>&1 || echo "$(date '+%F %T') ns-swap had issues" >> "$LOG"
+    echo "$(date '+%F %T') --- waiting 5 min for NS propagation ---" >> "$LOG"
+    sleep 300
+    echo "$(date '+%F %T') --- cascade: cert ---" >> "$LOG"
+    bash scripts/golive.sh cert >> "$LOG" 2>&1 || echo "$(date '+%F %T') cert had issues — DNS may need more time" >> "$LOG"
+    echo "$(date '+%F %T') --- cascade: cf-ssl ---" >> "$LOG"
+    bash scripts/golive.sh cf-ssl >> "$LOG" 2>&1 || true
+    echo "$(date '+%F %T') --- cascade: smoke ---" >> "$LOG"
+    bash scripts/golive.sh smoke >> "$LOG" 2>&1 || true
+    echo "$(date '+%F %T') === cascade complete ===" >> "$LOG"
+    exit 0
+  fi
+  echo "$(date '+%F %T') attempt $i/$MAX_TRIES — zone not visible yet, sleeping ${SLEEP}s" >> "$LOG"
+  sleep "$SLEEP"
+done
+echo "$(date '+%F %T') TIMEOUT after $MAX_TRIES tries — wallco.ai not added to CF" >> "$LOG"
+exit 1
diff --git a/server.js b/server.js
index 459500b..cf09a4f 100644
--- a/server.js
+++ b/server.js
@@ -46,8 +46,14 @@ const ELEVEN_VOICE_ID = process.env.ELEVENLABS_VOICE_ID || '21m00Tcm4TlvDq8ikWAM
 const DB_NAME = process.env.WALLCO_DB || 'dw_unified';
 const DB_URL = process.env.DATABASE_URL || DB_NAME;
 
+// On Linux (Kamatera) pm2 runs as root but PG has no 'root' role — sudo as
+// postgres. On macOS local trust auth works for the current user.
+const PSQL_CMD = (process.platform === 'linux')
+  ? `sudo -n -u postgres psql ${DB_NAME} -At -q`
+  : `psql ${DB_NAME} -At -q`;
+
 function psqlQuery(sql) {
-  return execSync(`psql ${DB_NAME} -At -q`, { input: sql, encoding: 'utf8' }).trim();
+  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
 }
 
 app.use(express.json());
@@ -314,6 +320,8 @@ function htmlHeader(active) {
     ['/designs', 'Designs'],
     ['/murals', 'Murals'],
     ['/samples', 'Samples'],
+    ['/studio', 'Studio'],
+    ['/generator', 'Generator'],
     ['/spoonflower', 'Spoonflower'],
     ['/about', 'About'],
   ];
@@ -2206,6 +2214,896 @@ ${HAMBURGER_JS}
 </html>`);
 });
 
+// ─────────────────────────────────────────────────────────────────────────
+// DESIGNER STUDIO (multi-source composition)
+// ─────────────────────────────────────────────────────────────────────────
+
+const STUDIO_UPLOAD_DIR = path.join(__dirname, 'public', 'uploads', 'studio');
+fs.mkdirSync(STUDIO_UPLOAD_DIR, { recursive: true });
+
+function studioSessionId(req) {
+  // Simple session: use cookie or generate. Persisted via JS in localStorage too.
+  const raw = (req.headers.cookie || '');
+  const m = raw.match(/wc-studio-sid=([a-z0-9]+)/i);
+  return m ? m[1] : null;
+}
+
+app.get('/api/studio/artists', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.name), '[]'::json) FROM
+      (SELECT id, name, movement, era, one_line, thumb_url FROM wallco_famous_artists) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/studio/styles', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.name), '[]'::json) FROM
+      (SELECT id, name, description, era FROM wallco_style_lexicon) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/studio/textures', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.category, t.name), '[]'::json) FROM
+      (SELECT id, name, category, image_url, thumb_url, source, dominant_hex,
+              description, prompt_phrase
+       FROM wallco_textures) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Suggest 3 color palettes — pulled from board sources + extracted from PD corpus
+app.post('/api/studio/suggest-palettes', (req, res) => {
+  const { sid } = req.body || {};
+  try {
+    const boardRaw = sid ? psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM
+      (SELECT color_hex_seeds FROM wallco_inspiration_board WHERE session_id='${String(sid).replace(/'/g,"''")}' ) t;`) : '[]';
+    const board = JSON.parse(boardRaw || '[]');
+    const seedHexes = [...new Set(board.flatMap(b => b.color_hex_seeds || []))];
+
+    // Strategy: 3 palettes — (1) board-driven, (2) complementary, (3) random PD-derived
+    function pickN(arr, n) {
+      const out = []; const pool = [...arr];
+      while (out.length < n && pool.length) {
+        out.push(pool.splice(Math.floor(Math.random()*pool.length),1)[0]);
+      }
+      return out;
+    }
+
+    // Palette A: from the board, padded if short
+    let a = pickN(seedHexes, 5);
+    if (a.length < 5) {
+      const extra = JSON.parse(psqlQuery(`SELECT COALESCE(json_agg(dominant_hex), '[]'::json)
+        FROM (SELECT dominant_hex FROM pd_source_designs
+              WHERE dominant_hex IS NOT NULL ORDER BY random() LIMIT 20) t;`));
+      a = a.concat(pickN(extra, 5 - a.length));
+    }
+
+    // Palette B: complementary — hue-shifted from palette A
+    function shiftHue(hex, deg) {
+      const r = parseInt(hex.slice(1,3),16)/255, g = parseInt(hex.slice(3,5),16)/255, b = parseInt(hex.slice(5,7),16)/255;
+      const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max-min;
+      let h, l = (max+min)/2, s = d===0 ? 0 : (l>0.5 ? d/(2-max-min) : d/(max+min));
+      if (d===0) h = 0;
+      else if (max===r) h = ((g-b)/d + (g<b?6:0))*60;
+      else if (max===g) h = ((b-r)/d + 2)*60;
+      else h = ((r-g)/d + 4)*60;
+      h = (h + deg + 360) % 360;
+      // back to rgb
+      const c = (1 - Math.abs(2*l-1))*s, x = c*(1 - Math.abs(((h/60)%2)-1)), m = l - c/2;
+      let rr, gg, bb;
+      if (h < 60)      [rr,gg,bb] = [c,x,0];
+      else if (h<120)  [rr,gg,bb] = [x,c,0];
+      else if (h<180)  [rr,gg,bb] = [0,c,x];
+      else if (h<240)  [rr,gg,bb] = [0,x,c];
+      else if (h<300)  [rr,gg,bb] = [x,0,c];
+      else             [rr,gg,bb] = [c,0,x];
+      const toHex = v => Math.round((v+m)*255).toString(16).padStart(2,'0');
+      return '#' + toHex(rr) + toHex(gg) + toHex(bb);
+    }
+    const b2 = a.map(c => /^#[0-9a-f]{6}$/i.test(c) ? shiftHue(c, 180) : c);
+
+    // Palette C: from random PD design's palette JSONB
+    const pdRow = psqlQuery(`SELECT palette::text FROM pd_source_designs
+      WHERE palette IS NOT NULL AND jsonb_typeof(palette) = 'array'
+      ORDER BY random() LIMIT 1;`);
+    let c = [];
+    try {
+      const p = JSON.parse(pdRow);
+      c = (p || []).map(x => x.hex).filter(Boolean).slice(0, 5);
+    } catch {}
+    if (c.length < 5) c = pickN(JSON.parse(psqlQuery(
+      `SELECT COALESCE(json_agg(dominant_hex),'[]'::json) FROM (SELECT DISTINCT dominant_hex FROM pd_source_designs
+       WHERE dominant_hex IS NOT NULL ORDER BY random() LIMIT 20) t;`)), 5);
+
+    res.json({
+      palettes: [
+        { name: 'From your board',  colors: a, rationale: 'Built from the colors you already pinned.' },
+        { name: 'Complementary',     colors: b2, rationale: 'Hue-shifted 180° for contrast.' },
+        { name: 'Museum-pulled',     colors: c.slice(0,5), rationale: 'Pulled from a random Met / Smithsonian work.' }
+      ]
+    });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Interior-designer-style critique: combines basic image analysis + the
+// interior-designer skill's framing. Returns advisory, not blocking.
+app.post('/api/studio/interior-design-review', async (req, res) => {
+  const { design_id, prompt } = req.body || {};
+  if (!design_id) return res.status(400).json({ error: 'design_id required' });
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, local_path, dominant_hex, palette, category
+      FROM spoon_all_designs WHERE id=${parseInt(design_id,10)}) t;`);
+    const d = JSON.parse(raw || '{}');
+    if (!d.id) return res.status(404).json({ error: 'design not found' });
+
+    // Gemini vision review (mandatory per /interior-designer guidance)
+    const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
+    let critique = { strengths: [], concerns: [], recommendation: '' };
+    if (geminiKey && d.local_path && fs.existsSync(d.local_path)) {
+      try {
+        const b64 = fs.readFileSync(d.local_path).toString('base64');
+        const r = await fetch(
+          `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({
+              contents: [{
+                parts: [
+                  { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+                  { text: `You are a luxury interior designer reviewing a new wallpaper pattern.
+Original prompt: "${(prompt || d.prompt || '').slice(0,400)}"
+
+Critique as a designer who places this in HIGH-END interiors (Hermès, Soho House, private hotel suites). STRICT JSON:
+{
+  "strengths": ["<1-line strength>", "<1-line strength>", "<1-line strength>"],
+  "concerns": ["<1-line concern>", "<1-line concern>"],
+  "recommendation": "<one sentence, would a designer specify this for a $1500/yard project, yes/no/maybe + why>",
+  "room_fit": ["<best room: bedroom|living|dining|powder|library|...>", "<second-best>"],
+  "lighting": "<best lighting: north light, candlelight, gallery, sunny>",
+  "scale": "<small repeat / medium / large / mural>"
+}` }
+                ]
+              }],
+              generationConfig: { temperature: 0.2, maxOutputTokens: 600, responseMimeType: 'application/json' }
+            })
+          }
+        );
+        const j = await r.json();
+        const t = j.candidates?.[0]?.content?.parts?.[0]?.text;
+        if (t) {
+          try { critique = JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); } catch {}
+        }
+      } catch (e) { critique._err = e.message; }
+    }
+    res.json({ ok: true, design: d, critique });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Category insights: trends + best-sellers grid (stub today, wireable to ad-signals skill)
+app.get('/api/studio/category-insights', async (req, res) => {
+  const cat = String(req.query.cat || '').toLowerCase().trim();
+  if (!cat) return res.json({ trends: [], best_sellers: [], note: 'pass ?cat=floral|damask|...' });
+  try {
+    // Best-sellers from DW Shopify products that match category — uses dw_unified.products if available
+    let bestSellers = [];
+    try {
+      const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM
+        (SELECT id, title, vendor, product_type, image_url, handle, price
+         FROM products
+         WHERE LOWER(COALESCE(title,'')) LIKE '%${cat.replace(/'/g,"''")}%'
+            OR LOWER(COALESCE(product_type,'')) LIKE '%${cat.replace(/'/g,"''")}%'
+            OR LOWER(COALESCE(tags::text,'')) LIKE '%${cat.replace(/'/g,"''")}%'
+         ORDER BY orders_30d DESC NULLS LAST, created_at DESC NULLS LAST
+         LIMIT 12) t;`);
+      bestSellers = JSON.parse(raw || '[]');
+    } catch (e) {
+      // products table may not exist on prod; fall back to recent PD images in this category
+      const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM
+        (SELECT id, title, creator AS vendor, source AS product_type,
+                image_url, source_url AS handle, NULL AS price
+         FROM pd_source_designs
+         WHERE LOWER(COALESCE(category,'')) LIKE '%${cat.replace(/'/g,"''")}%'
+            OR LOWER(COALESCE(title,''))    LIKE '%${cat.replace(/'/g,"''")}%'
+         ORDER BY ingested_at DESC LIMIT 12) t;`);
+      bestSellers = JSON.parse(raw || '[]');
+    }
+
+    // Trends — return canonical search terms + Google Trends links
+    const trends = [
+      { term: `${cat} wallpaper 2026`, link: `https://trends.google.com/trends/explore?q=${encodeURIComponent(cat+' wallpaper')}` },
+      { term: `${cat} pattern interior`, link: `https://trends.google.com/trends/explore?q=${encodeURIComponent(cat+' pattern interior')}` },
+      { term: `${cat} maximalist`, link: `https://trends.google.com/trends/explore?q=${encodeURIComponent(cat+' maximalist')}` },
+    ];
+
+    // Ad signal placeholder (real wiring to ad-signals skill is a follow-up)
+    res.json({
+      category: cat,
+      trends,
+      best_sellers: bestSellers,
+      ad_signals_note: 'Live Google Ads / Meta pixel data via the ad-signals skill — wireable on demand.'
+    });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/studio/elements', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.category, t.name), '[]'::json) FROM
+      (SELECT id, category, name, prompt_phrase FROM wallco_element_lexicon) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Search public-domain corpus + Spoonflower-pulled + wallco generated
+app.get('/api/studio/search', (req, res) => {
+  const q = String(req.query.q || '').trim().toLowerCase().replace(/'/g, "''");
+  const limit = Math.min(parseInt(req.query.limit || '60', 10), 200);
+  if (!q) return res.json([]);
+  try {
+    // Build a unified result via three subqueries
+    const sql = `
+      WITH pd AS (
+        SELECT 'pd' AS source, id::text AS id, title, creator AS artist,
+               source AS subsource, image_url, local_path, dominant_hex,
+               category, tags
+        FROM pd_source_designs
+        WHERE LOWER(COALESCE(title,'')) LIKE '%${q}%'
+           OR LOWER(COALESCE(creator,'')) LIKE '%${q}%'
+           OR LOWER(COALESCE(category,'')) LIKE '%${q}%'
+           OR EXISTS (SELECT 1 FROM unnest(COALESCE(tags,ARRAY[]::text[])) AS t WHERE LOWER(t) LIKE '%${q}%')
+        LIMIT ${limit}
+      ),
+      sp AS (
+        SELECT 'spoonflower' AS source, sf_design_id AS id, title, NULL AS artist,
+               'spoonflower' AS subsource, design_url AS image_url, local_path,
+               NULL AS dominant_hex, NULL AS category, NULL::text[] AS tags
+        FROM spoonflower_pulled_designs
+        WHERE LOWER(COALESCE(title,'')) LIKE '%${q}%'
+        LIMIT 30
+      ),
+      gen AS (
+        SELECT 'wallco' AS source, id::text AS id, prompt AS title, generator AS artist,
+               'wallco' AS subsource, image_url, local_path, dominant_hex,
+               category, tags
+        FROM spoon_all_designs
+        WHERE LOWER(COALESCE(prompt,'')) LIKE '%${q}%'
+           OR LOWER(COALESCE(category,'')) LIKE '%${q}%'
+        LIMIT 30
+      )
+      SELECT COALESCE(json_agg(t), '[]'::json) FROM
+        (SELECT * FROM pd UNION ALL SELECT * FROM sp UNION ALL SELECT * FROM gen) t;`;
+    const raw = psqlQuery(sql);
+    const items = JSON.parse(raw || '[]').map(it => {
+      // Map local_path → /img URL when applicable
+      let url = it.image_url || '';
+      if (it.local_path && it.local_path.startsWith(path.join(__dirname, 'data') + path.sep)) {
+        url = '/img/' + it.local_path.replace(path.join(__dirname, 'data') + path.sep, '');
+      } else if (it.local_path && it.local_path.includes('/generated/')) {
+        url = '/designs/img/' + path.basename(it.local_path);
+      }
+      return { ...it, thumb_url: url };
+    });
+    res.json(items);
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Add /img static for PD source images (loaded from data/)
+app.use('/img', express.static(path.join(__dirname, 'data'), { maxAge: '7d' }));
+
+// Image upload — accepts multipart OR base64
+app.post('/api/studio/upload', express.raw({ type: 'application/octet-stream', limit: '20mb' }), async (req, res) => {
+  try {
+    const sid = req.query.sid || 'anon-' + Date.now();
+    const filename = String(req.query.name || `upload-${Date.now()}.jpg`).replace(/[^a-z0-9._-]/gi, '');
+    const target = path.join(STUDIO_UPLOAD_DIR, `${sid}_${filename}`);
+    fs.writeFileSync(target, req.body);
+    // Quick palette extract via sips + Python
+    let domHex = null;
+    try {
+      const small = target + '.32.png';
+      execSync(`sips -Z 32 "${target}" --out "${small}" 2>/dev/null`);
+      domHex = execSync(`python3 -c "
+from PIL import Image
+img = Image.open('${small}').convert('RGB')
+pix = list(img.getdata())
+n = len(pix)
+r = sum(p[0] for p in pix)//n; g = sum(p[1] for p in pix)//n; b = sum(p[2] for p in pix)//n
+print(f'#{r:02x}{g:02x}{b:02x}')" 2>/dev/null`, { encoding: 'utf8' }).trim();
+      fs.unlinkSync(small);
+    } catch {}
+
+    // AI style + element analysis via Gemini (mandatory per dw standing rule)
+    let ai = { style: null, colors: [], elements: [], summary: null };
+    const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
+    if (geminiKey) {
+      try {
+        const b64 = fs.readFileSync(target).toString('base64');
+        const r = await fetch(
+          `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
+          {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({
+              contents: [{
+                parts: [
+                  { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+                  { text: `Analyze this design. Return STRICT JSON:
+{
+  "style": "<one of: Baroque, Rococo, Neoclassical, Art Nouveau, Art Deco, Arts and Crafts, Mid-century Modern, Bauhaus, Memphis, Maximalist, Minimalist, Chinoiserie, Toile, Damask, Ikat, Suzani, Moroccan, Japonisme, Folk Scandi, Tropical, Other>",
+  "colors": ["#hex","#hex","#hex"],
+  "elements": ["<element keywords like bird, peony, monogram, trellis, etc — max 8>"],
+  "summary": "<one sentence describing the design>"
+}` }
+                ]
+              }],
+              generationConfig: { temperature: 0.1, maxOutputTokens: 400, responseMimeType: 'application/json' }
+            })
+          }
+        );
+        const j = await r.json();
+        const t = j.candidates && j.candidates[0] && j.candidates[0].content
+                  && j.candidates[0].content.parts && j.candidates[0].content.parts[0]
+                  && j.candidates[0].content.parts[0].text;
+        if (t) {
+          const cleaned = t.replace(/^```json\s*|\s*```$/g, '');
+          try { ai = { ...ai, ...JSON.parse(cleaned) }; } catch {}
+        }
+      } catch (e) { console.error('gemini upload error:', e.message); }
+    }
+
+    let stat = fs.statSync(target);
+    const row = psqlQuery(`INSERT INTO wallco_user_uploads
+      (session_id, filename, local_path, ai_style, ai_colors, ai_elements, ai_summary, bytes)
+      VALUES (${sid ? "'"+String(sid).replace(/'/g,"''")+"'" : 'NULL'},
+              '${filename.replace(/'/g,"''")}',
+              '${target.replace(/'/g,"''")}',
+              ${ai.style ? "'"+ai.style.replace(/'/g,"''")+"'" : 'NULL'},
+              ARRAY[${(ai.colors||[]).map(c=>"'"+String(c).replace(/'/g,"''")+"'").join(',') || ''}]::text[],
+              ARRAY[${(ai.elements||[]).map(c=>"'"+String(c).replace(/'/g,"''")+"'").join(',') || ''}]::text[],
+              ${ai.summary ? "'"+ai.summary.replace(/'/g,"''")+"'" : 'NULL'},
+              ${stat.size})
+      RETURNING id;`);
+
+    res.json({
+      ok: true,
+      id: parseInt(row, 10),
+      url: `/uploads/studio/${sid}_${filename}`,
+      dominant_hex: domHex,
+      ai_style: ai.style,
+      ai_colors: ai.colors,
+      ai_elements: ai.elements,
+      ai_summary: ai.summary,
+    });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// Save/get the 6-slot inspiration board
+app.get('/api/studio/board', (req, res) => {
+  const sid = req.query.sid;
+  if (!sid) return res.json([]);
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.slot_idx), '[]'::json) FROM
+      (SELECT * FROM wallco_inspiration_board WHERE session_id='${String(sid).replace(/'/g,"''")}' ) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/studio/board/slot', (req, res) => {
+  const { sid, slot_idx, source_type, source_id, image_url, thumb_url, title,
+          artist, style_tags = [], color_hex_seeds = [], selected_elements = [] } = req.body || {};
+  if (!sid || slot_idx == null) return res.status(400).json({ error: 'sid + slot_idx required' });
+  const arr = a => `ARRAY[${(a||[]).map(v=>"'"+String(v).replace(/'/g,"''")+"'").join(',') || ''}]::text[]`;
+  const s = v => v == null ? 'NULL' : "'"+String(v).replace(/'/g,"''")+"'";
+  try {
+    psqlQuery(`INSERT INTO wallco_inspiration_board
+      (session_id, slot_idx, source_type, source_id, image_url, thumb_url, title,
+       artist, style_tags, color_hex_seeds, selected_elements)
+      VALUES (${s(sid)}, ${parseInt(slot_idx,10)}, ${s(source_type)}, ${s(source_id)},
+              ${s(image_url)}, ${s(thumb_url)}, ${s(title)}, ${s(artist)},
+              ${arr(style_tags)}, ${arr(color_hex_seeds)}, ${arr(selected_elements)})
+      ON CONFLICT (session_id, slot_idx) DO UPDATE SET
+        source_type=EXCLUDED.source_type, source_id=EXCLUDED.source_id,
+        image_url=EXCLUDED.image_url, thumb_url=EXCLUDED.thumb_url, title=EXCLUDED.title,
+        artist=EXCLUDED.artist, style_tags=EXCLUDED.style_tags,
+        color_hex_seeds=EXCLUDED.color_hex_seeds, selected_elements=EXCLUDED.selected_elements;`);
+    res.json({ ok: true });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.delete('/api/studio/board/slot/:slot', (req, res) => {
+  const sid = req.query.sid;
+  if (!sid) return res.status(400).json({ error: 'sid required' });
+  try {
+    psqlQuery(`DELETE FROM wallco_inspiration_board
+      WHERE session_id='${String(sid).replace(/'/g,"''")}' AND slot_idx=${parseInt(req.params.slot,10)};`);
+    res.json({ ok: true });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Compose: build a prompt from the board + extra elements, fire SDXL
+app.post('/api/studio/compose', (req, res) => {
+  const { sid, extra_elements = [], extra_text = '' } = req.body || {};
+  if (!sid) return res.status(400).json({ error: 'sid required' });
+  try {
+    const boardRaw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.slot_idx), '[]'::json) FROM
+      (SELECT * FROM wallco_inspiration_board WHERE session_id='${String(sid).replace(/'/g,"''")}' ) t;`);
+    const board = JSON.parse(boardRaw || '[]');
+    if (!board.length) return res.status(400).json({ error: 'inspiration board is empty — add at least 1 slot' });
+
+    const artists = [...new Set(board.flatMap(b => [b.artist].filter(Boolean)))];
+    const styles  = [...new Set(board.flatMap(b => (b.style_tags || []).concat(b.selected_elements || []).filter(Boolean)))];
+    const colors  = [...new Set(board.flatMap(b => b.color_hex_seeds || []))].slice(0, 6);
+    const elems   = [...new Set((board.flatMap(b => b.selected_elements || [])).concat(extra_elements))];
+
+    const promptParts = [
+      'A seamless wallpaper pattern',
+      artists.length ? `in the visual lineage of ${artists.slice(0,3).join(' and ')}` : '',
+      styles.length ? `with ${styles.slice(0,4).join(' / ')} influence` : '',
+      elems.length ? `featuring ${elems.slice(0,6).join(', ')}` : '',
+      colors.length ? `palette ${colors.join(', ')}` : '',
+      extra_text || '',
+      'high detail, museum-quality, 24x24 inch tileable, no text, no signature.'
+    ].filter(Boolean);
+    const prompt = promptParts.join(', ').replace(/\s+/g, ' ').trim();
+
+    // Log the run as recipe-less (recipe_id=NULL)
+    const runId = psqlQuery(`INSERT INTO wallco_generator_runs
+      (recipe_id, prompt, palette_seed_ids, status)
+      VALUES (NULL, '${prompt.replace(/'/g,"''")}', '{}'::bigint[], 'running')
+      RETURNING id;`);
+
+    // Spawn the generator script directly
+    const r = spawnSync('node', [
+      path.join(__dirname, 'scripts', 'generate_designs.js'),
+      '--n', '1', '--kind', 'seamless_tile',
+      '--category', (styles[0] || 'mixed').toLowerCase(),
+      '--prompts', prompt,
+    ], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
+         encoding: 'utf8', timeout: 120000 });
+
+    let designId = null;
+    try {
+      designId = parseInt(psqlQuery(`SELECT id FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`), 10);
+    } catch {}
+
+    const ok = r.status === 0;
+    psqlQuery(`UPDATE wallco_generator_runs SET
+      status='${ok ? 'done' : 'error'}',
+      design_id=${designId || 'NULL'},
+      error=${ok ? 'NULL' : "'"+((r.stderr||r.stdout||'').slice(-400).replace(/'/g,"''"))+"'"},
+      finished_at=now()
+      WHERE id=${runId};`);
+
+    res.json({ ok, prompt, design_id: designId, run_id: parseInt(runId, 10),
+               error: ok ? null : (r.stderr || r.stdout || '').slice(-600) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// /studio HTML page
+app.get('/studio', (_req, res) => {
+  res.type('html').send(`${htmlHead({
+    title: 'Designer Studio — wallco.ai',
+    description: 'Compose a new wallpaper design from artists, styles, and your own inspiration images',
+    canonical: 'https://wallco.ai/studio'
+  })}
+<body>
+${htmlHeader('')}
+<main style="padding:24px 28px;max-width:1500px;margin:0 auto">
+  <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:30px;margin:0 0 6px">Designer Studio</h1>
+  <p style="color:#666;margin:0 0 22px">Pick artists, styles, elements, or upload your own. Add up to 6 sources to the board → Compose generates a new pattern.</p>
+
+  <!-- Top: 3-column reference panels -->
+  <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:22px">
+
+    <!-- Artists -->
+    <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fafafa;max-height:320px;overflow-y:auto">
+      <h3 style="margin:0 0 8px;font-size:14px">Famous Artists</h3>
+      <div id="artists-list" style="font-size:12px"></div>
+    </div>
+
+    <!-- Styles -->
+    <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fafafa;max-height:320px;overflow-y:auto">
+      <h3 style="margin:0 0 8px;font-size:14px">Styles</h3>
+      <div id="styles-list" style="font-size:12px"></div>
+    </div>
+
+    <!-- Elements -->
+    <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fafafa;max-height:320px;overflow-y:auto">
+      <h3 style="margin:0 0 8px;font-size:14px">Elements <span style="font-size:10px;color:#999;font-weight:400">(animals, monograms, …)</span></h3>
+      <div id="elements-list" style="font-size:12px"></div>
+    </div>
+  </div>
+
+  <!-- Textures grid (drag-droppable as bg or fg) -->
+  <div style="margin-bottom:18px;padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
+      <h3 style="margin:0;font-size:14px">Textures <span style="font-size:11px;color:#999;font-weight:400">(drag onto an empty slot, or click to add as background)</span></h3>
+      <span style="font-size:11px;color:#666">Foreground opacity <input type="range" id="overlay-opacity" min="0" max="100" value="40" style="vertical-align:middle"> <span id="opacity-val">40%</span></span>
+    </div>
+    <div id="textures-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:6px;max-height:260px;overflow-y:auto"></div>
+  </div>
+
+  <!-- Search bar -->
+  <div style="margin-bottom:18px;display:flex;gap:10px;align-items:center">
+    <input id="search-q" type="text" placeholder="Search public-domain images (e.g. peacock, baroque, Morris)" style="flex:1;padding:10px 14px;font-size:14px;border:1px solid #ccc;border-radius:6px">
+    <button id="btn-search" class="btn-primary">Search</button>
+    <label class="btn-outline" style="cursor:pointer;margin:0">
+      Upload image
+      <input id="upload-input" type="file" accept="image/*" hidden>
+    </label>
+  </div>
+  <div id="search-results" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-bottom:24px;max-height:340px;overflow-y:auto;border:1px solid #eee;border-radius:6px;padding:10px;background:#fff"></div>
+
+  <!-- 6-slot board -->
+  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:20px;margin:24px 0 10px">Inspiration board (6 slots)</h2>
+  <div id="board" style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:22px"></div>
+
+  <!-- 3-palette suggestions -->
+  <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:22px">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
+      <h3 style="margin:0;font-size:14px">3 Suggested palettes</h3>
+      <button id="btn-suggest" class="btn-outline" style="font-size:12px;padding:4px 10px">Refresh suggestions</button>
+    </div>
+    <div id="palette-suggestions" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px"></div>
+  </div>
+
+  <!-- Extra elements + compose -->
+  <div style="padding:18px;border:1px solid #ddd;border-radius:8px;background:#fafafa;margin-bottom:22px">
+    <h3 style="margin:0 0 8px;font-size:14px">Add more elements to the prompt</h3>
+    <div id="extra-elements-pills" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"></div>
+    <textarea id="extra-text" placeholder="Extra prompt text (optional)" rows="2" style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;font-size:13px;font-family:inherit"></textarea>
+    <div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
+      <button id="btn-compose" class="btn-primary">Compose new design</button>
+      <label style="font-size:12px;color:#555;display:flex;align-items:center;gap:6px"><input type="checkbox" id="auto-review" checked> auto-review with interior-designer</label>
+    </div>
+    <div id="compose-result" style="margin-top:14px"></div>
+    <div id="design-review" style="margin-top:14px"></div>
+  </div>
+
+  <!-- Category insights -->
+  <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:24px">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
+      <h3 style="margin:0;font-size:14px">Category insights — trends, best-sellers, ad signals</h3>
+      <span style="font-size:11px;color:#666">Category <select id="cat-select" style="font:inherit;padding:4px 6px;border:1px solid #ccc;border-radius:3px">
+        <option>floral</option><option>damask</option><option>geometric</option><option>botanical</option><option>baroque</option><option>art deco</option><option>chinoiserie</option><option>toile</option><option>maximalist</option><option>minimalist</option>
+      </select></span>
+    </div>
+    <div id="insights-body"></div>
+  </div>
+</main>
+${FOOTER}
+
+<script>
+var SID = localStorage.getItem('wc-studio-sid') || (Math.random().toString(36).slice(2)+Date.now().toString(36));
+localStorage.setItem('wc-studio-sid', SID);
+document.cookie = 'wc-studio-sid='+SID+'; path=/; max-age=2592000';
+
+async function get(u){ return (await fetch(u)).json(); }
+async function post(u,b){ return (await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})})).json(); }
+async function del(u){ return (await fetch(u,{method:'DELETE'})).json(); }
+
+var EXTRA = new Set();
+
+function pill(text, on, onclick){
+  return '<button onclick="'+onclick+'" style="padding:3px 9px;font-size:11px;border-radius:11px;border:1px solid '+(on?'#1a1a1a':'#ccc')+';background:'+(on?'#1a1a1a':'#fff')+';color:'+(on?'#fff':'#333')+';cursor:pointer;margin:2px 1px">'+text+'</button>';
+}
+
+async function loadRefs(){
+  var [artists, styles, elements] = await Promise.all([
+    get('/api/studio/artists'), get('/api/studio/styles'), get('/api/studio/elements')
+  ]);
+  document.getElementById('artists-list').innerHTML = artists.map(function(a){
+    return '<div style="padding:5px 6px;border-bottom:1px solid #eee;cursor:pointer" onclick="addArtist('+JSON.stringify(a).replace(/"/g,'&quot;')+')"><b>'+a.name+'</b> <span style="color:#999">— '+(a.movement||'')+' '+(a.era||'')+'</span><br><span style="font-size:10px;color:#777">'+(a.one_line||'')+'</span></div>';
+  }).join('');
+  document.getElementById('styles-list').innerHTML = styles.map(function(s){
+    return '<div style="padding:5px 6px;border-bottom:1px solid #eee;cursor:pointer" onclick="addStyle('+JSON.stringify(s).replace(/"/g,'&quot;')+')"><b>'+s.name+'</b> <span style="color:#999">'+(s.era||'')+'</span><br><span style="font-size:10px;color:#777">'+(s.description||'')+'</span></div>';
+  }).join('');
+  // Elements grouped by category, with pills
+  var byCat = {};
+  elements.forEach(function(e){ (byCat[e.category]=byCat[e.category]||[]).push(e); });
+  document.getElementById('elements-list').innerHTML = Object.keys(byCat).sort().map(function(c){
+    return '<div style="margin-bottom:6px"><div style="font-weight:600;font-size:11px;color:#666;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px">'+c+'</div><div style="display:flex;flex-wrap:wrap">'+byCat[c].map(function(e){
+      return '<button onclick="toggleExtra(\\''+e.name+'\\')" style="padding:2px 7px;font-size:10px;border-radius:9px;border:1px solid #ccc;background:#fff;color:#333;cursor:pointer;margin:1px">'+e.name+'</button>';
+    }).join('')+'</div></div>';
+  }).join('');
+  renderExtra();
+}
+
+function renderExtra() {
+  var html = Array.from(EXTRA).map(function(e){
+    return pill(e + ' ✕', true, "toggleExtra('"+e+"')");
+  }).join('') || '<span style="color:#999;font-size:12px">No extra elements selected yet — click pills above.</span>';
+  document.getElementById('extra-elements-pills').innerHTML = html;
+}
+
+window.toggleExtra = function(name){
+  if (EXTRA.has(name)) EXTRA.delete(name); else EXTRA.add(name);
+  renderExtra();
+};
+
+window.addArtist = function(a){
+  addToBoardNextSlot({source_type:'artist', source_id:String(a.id), title:a.name, artist:a.name, style_tags:[a.movement].filter(Boolean), color_hex_seeds:[], selected_elements:[]});
+};
+window.addStyle = function(s){
+  addToBoardNextSlot({source_type:'style', source_id:String(s.id), title:s.name, artist:null, style_tags:[s.name], color_hex_seeds:[], selected_elements:[]});
+};
+
+var BOARD = [];
+async function loadBoard(){
+  BOARD = await get('/api/studio/board?sid='+encodeURIComponent(SID));
+  renderBoard();
+}
+function nextEmptySlot(){
+  for (var i=0;i<6;i++){ if (!BOARD.find(function(b){return b.slot_idx===i;})) return i; }
+  return null;
+}
+async function addToBoardNextSlot(payload){
+  var idx = nextEmptySlot();
+  if (idx == null) { alert('Board is full (6 slots). Remove one first.'); return; }
+  await post('/api/studio/board/slot', Object.assign({sid:SID, slot_idx:idx}, payload));
+  loadBoard();
+}
+window.removeSlot = async function(slot){
+  await del('/api/studio/board/slot/'+slot+'?sid='+encodeURIComponent(SID));
+  loadBoard();
+};
+function renderBoard(){
+  var html = '';
+  for (var i=0;i<6;i++){
+    var b = BOARD.find(function(x){return x.slot_idx===i;});
+    if (b) {
+      var thumb = b.thumb_url || b.image_url || '';
+      var thumbHtml = thumb ? '<img src="'+thumb+'" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px">' :
+        '<div style="aspect-ratio:1;background:#'+((b.color_hex_seeds||[])[0]?(b.color_hex_seeds[0].replace('#','')):'2a2a2a')+';display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;border-radius:4px;text-align:center;padding:8px">'+(b.title||b.artist||'slot '+(i+1))+'</div>';
+      var tags = (b.style_tags||[]).concat(b.selected_elements||[]).slice(0,3).map(function(t){return '<span style="font-size:9px;background:#eee;padding:1px 5px;border-radius:7px;margin-right:2px">'+t+'</span>';}).join('');
+      html += '<div style="position:relative;border:1px solid #ddd;border-radius:6px;padding:6px;background:#fff">'+
+        thumbHtml +
+        '<div style="font-size:11px;margin-top:6px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+(b.title||'')+'">'+(b.title||'(no title)').slice(0,30)+'</div>'+
+        '<div style="font-size:10px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(b.artist||b.source_type)+'</div>'+
+        '<div style="margin-top:4px">'+tags+'</div>'+
+        '<button onclick="removeSlot('+i+')" style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);color:#fff;border:0;border-radius:50%;width:20px;height:20px;font-size:11px;cursor:pointer;line-height:1;padding:0">✕</button>'+
+        '</div>';
+    } else {
+      html += '<div style="border:2px dashed #ccc;border-radius:6px;padding:6px;background:#fafafa;display:flex;align-items:center;justify-content:center;aspect-ratio:1;color:#aaa;font-size:11px">Slot '+(i+1)+'<br>(empty)</div>';
+    }
+  }
+  document.getElementById('board').innerHTML = html;
+}
+
+async function doSearch(){
+  var q = document.getElementById('search-q').value.trim();
+  if (!q) return;
+  var results = await get('/api/studio/search?q='+encodeURIComponent(q)+'&limit=60');
+  document.getElementById('search-results').innerHTML = results.map(function(r){
+    var thumb = r.thumb_url || '';
+    var label = (r.title||'').slice(0,40);
+    var meta = (r.artist||r.subsource||'') + (r.dominant_hex ? ' · '+r.dominant_hex : '');
+    return '<div onclick="addSearch('+JSON.stringify(r).replace(/"/g,'&quot;')+')" style="cursor:pointer;border:1px solid #eee;border-radius:4px;padding:4px;background:#fff;transition:transform .15s" onmouseover="this.style.transform=\\'scale(1.03)\\'" onmouseout="this.style.transform=\\'\\'">'+
+      (thumb?'<img src="'+thumb+'" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:3px" loading="lazy">':'<div style="aspect-ratio:1;background:#'+((r.dominant_hex||'#888').replace('#',''))+'"></div>')+
+      '<div style="font-size:10px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+label+'">'+label+'</div>'+
+      '<div style="font-size:9px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+meta+'</div>'+
+      '</div>';
+  }).join('') || '<p style="color:#999;grid-column:1/-1">No matches for "'+q+'".</p>';
+}
+window.addSearch = function(r){
+  addToBoardNextSlot({
+    source_type: r.source,
+    source_id: String(r.id),
+    image_url: r.image_url,
+    thumb_url: r.thumb_url,
+    title: r.title,
+    artist: r.artist,
+    style_tags: (r.tags||[]).slice(0,4),
+    color_hex_seeds: r.dominant_hex ? [r.dominant_hex] : [],
+    selected_elements: []
+  });
+};
+
+document.getElementById('btn-search').addEventListener('click', doSearch);
+document.getElementById('search-q').addEventListener('keydown', function(e){ if (e.key==='Enter') doSearch(); });
+
+document.getElementById('upload-input').addEventListener('change', async function(e){
+  var f = e.target.files[0]; if (!f) return;
+  var stat = document.getElementById('compose-result');
+  stat.style.display='block'; stat.innerHTML = '<div style="padding:10px;background:#fff3cd;color:#856404;border-radius:4px">Uploading + analyzing… (5-15s)</div>';
+  var buf = await f.arrayBuffer();
+  var r = await fetch('/api/studio/upload?sid='+encodeURIComponent(SID)+'&name='+encodeURIComponent(f.name),
+    { method:'POST', headers:{'Content-Type':'application/octet-stream'}, body: buf });
+  var j = await r.json();
+  if (j.ok) {
+    addToBoardNextSlot({
+      source_type:'upload', source_id:String(j.id),
+      image_url: j.url, thumb_url: j.url,
+      title: f.name + (j.ai_summary ? ' — ' + j.ai_summary.slice(0,40) : ''),
+      artist: null,
+      style_tags: j.ai_style ? [j.ai_style] : [],
+      color_hex_seeds: (j.ai_colors||[]).concat(j.dominant_hex ? [j.dominant_hex] : []),
+      selected_elements: j.ai_elements || []
+    });
+    stat.innerHTML = '<div style="padding:10px;background:#d4edda;color:#155724;border-radius:4px">✓ Uploaded. AI detected style: <b>'+(j.ai_style||'unknown')+'</b>, colors: '+(j.ai_colors||[]).join(' ')+', elements: '+(j.ai_elements||[]).join(', ')+'</div>';
+  } else {
+    stat.innerHTML = '<div style="padding:10px;background:#f8d7da;color:#721c24;border-radius:4px">Upload failed: '+(j.error||'unknown')+'</div>';
+  }
+});
+
+document.getElementById('btn-compose').addEventListener('click', async function(){
+  this.disabled = true;
+  var btn = this; btn.textContent = 'Composing (10-30s)…';
+  var stat = document.getElementById('compose-result');
+  stat.style.display='block'; stat.innerHTML = '<div style="padding:10px;background:#fff3cd;color:#856404;border-radius:4px">Generating via SDXL…</div>';
+  var j = await post('/api/studio/compose', {
+    sid: SID,
+    extra_elements: Array.from(EXTRA),
+    extra_text: document.getElementById('extra-text').value || ''
+  });
+  btn.disabled = false; btn.textContent = 'Compose new design';
+  if (j.ok) {
+    stat.innerHTML = '<div style="padding:14px;background:#d4edda;color:#155724;border-radius:6px"><b>✓ New design #'+j.design_id+' generated</b><br><div style="font-size:11px;font-family:monospace;margin-top:8px;color:#333">'+(j.prompt||'').replace(/[<>]/g,'')+'</div><a href="/design/'+j.design_id+'" target="_blank" class="btn-primary" style="margin-top:10px;display:inline-block">View design →</a></div>';
+  } else {
+    stat.innerHTML = '<div style="padding:14px;background:#f8d7da;color:#721c24;border-radius:6px">Compose failed: '+(j.error||'unknown').replace(/[<>]/g,'')+'</div>';
+  }
+});
+
+async function loadTextures() {
+  var tx = await get('/api/studio/textures');
+  document.getElementById('textures-grid').innerHTML = tx.map(function(t){
+    return '<div draggable="true" ondragstart="dragTexture(event,'+JSON.stringify(t).replace(/"/g,'&quot;')+')" onclick="addTextureToBoard('+JSON.stringify(t).replace(/"/g,'&quot;')+')" style="cursor:grab;text-align:center;border:1px solid #eee;border-radius:4px;padding:3px;background:#fff;font-size:9px" title="'+t.name+' — '+t.category+'">'+
+      '<div style="aspect-ratio:1;background:url(\\''+t.image_url+'\\') center/cover, '+t.dominant_hex+';border-radius:3px"></div>'+
+      '<div style="margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+t.name+'</div>'+
+    '</div>';
+  }).join('');
+}
+
+window.dragTexture = function(e, t) {
+  e.dataTransfer.setData('application/x-texture', JSON.stringify(t));
+};
+window.addTextureToBoard = function(t) {
+  addToBoardNextSlot({
+    source_type:'texture', source_id:String(t.id),
+    image_url: t.image_url, thumb_url: t.thumb_url || t.image_url,
+    title: t.name + ' (texture)', artist: null,
+    style_tags: [t.category],
+    color_hex_seeds: t.dominant_hex ? [t.dominant_hex] : [],
+    selected_elements: [t.prompt_phrase || t.name]
+  });
+};
+
+// Drag-drop the texture / item onto a board slot
+function setupDropZones() {
+  document.querySelectorAll('[data-slot-zone]').forEach(function(z){
+    z.addEventListener('dragover', function(e){ e.preventDefault(); this.style.background='#fffbe6'; });
+    z.addEventListener('dragleave', function(){ this.style.background=''; });
+    z.addEventListener('drop', async function(e){
+      e.preventDefault(); this.style.background='';
+      var slot = parseInt(this.getAttribute('data-slot-zone'),10);
+      var t = e.dataTransfer.getData('application/x-texture');
+      if (t) {
+        var tx = JSON.parse(t);
+        await post('/api/studio/board/slot', {
+          sid: SID, slot_idx: slot,
+          source_type:'texture', source_id:String(tx.id),
+          image_url: tx.image_url, thumb_url: tx.thumb_url || tx.image_url,
+          title: tx.name + ' (texture)',
+          style_tags:[tx.category],
+          color_hex_seeds: tx.dominant_hex ? [tx.dominant_hex] : [],
+          selected_elements: [tx.prompt_phrase || tx.name]
+        });
+        loadBoard();
+      }
+    });
+  });
+}
+
+// Augment renderBoard to add data-slot-zone for drag-drop targets
+var _origRenderBoard = renderBoard;
+renderBoard = function(){
+  _origRenderBoard();
+  // Wrap each rendered slot with a dropzone marker
+  var grid = document.getElementById('board');
+  if (grid) {
+    Array.from(grid.children).forEach(function(child, i){
+      child.setAttribute('data-slot-zone', i);
+    });
+    setupDropZones();
+  }
+};
+
+async function loadSuggestions(){
+  var j = await post('/api/studio/suggest-palettes', { sid: SID });
+  var div = document.getElementById('palette-suggestions');
+  div.innerHTML = (j.palettes||[]).map(function(p,idx){
+    return '<div onclick="adoptPalette('+idx+')" style="cursor:pointer;padding:10px;border:1px solid #eee;border-radius:4px;background:#fafafa" onmouseover="this.style.background=\\'#fff\\'" onmouseout="this.style.background=\\'#fafafa\\'">'+
+      '<div style="font-size:12px;font-weight:500;margin-bottom:6px">'+p.name+'</div>'+
+      '<div style="display:flex;gap:2px;margin-bottom:6px">'+(p.colors||[]).map(function(c){return '<div style="flex:1;height:32px;background:'+c+';border-radius:2px"></div>';}).join('')+'</div>'+
+      '<div style="font-size:10px;color:#666">'+p.rationale+'</div>'+
+      '<div style="font-size:9px;color:#aaa;margin-top:3px;font-family:monospace">'+(p.colors||[]).join(' ')+'</div>'+
+    '</div>';
+  }).join('');
+  window.__palettes = j.palettes;
+}
+window.adoptPalette = async function(idx){
+  if (!window.__palettes || !window.__palettes[idx]) return;
+  var colors = window.__palettes[idx].colors || [];
+  // Add this palette as a new board slot of type 'palette'
+  addToBoardNextSlot({
+    source_type:'palette',
+    source_id:'sug-'+idx+'-'+Date.now(),
+    title: 'Palette: '+window.__palettes[idx].name,
+    artist: null,
+    style_tags: [],
+    color_hex_seeds: colors,
+    selected_elements: []
+  });
+};
+
+async function loadInsights(){
+  var cat = document.getElementById('cat-select').value;
+  var j = await get('/api/studio/category-insights?cat='+encodeURIComponent(cat));
+  var div = document.getElementById('insights-body');
+  var trendsHtml = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">' + (j.trends||[]).map(function(t){
+    return '<a href="'+t.link+'" target="_blank" style="padding:4px 10px;background:#eef6ff;color:#1a5298;border-radius:11px;text-decoration:none;font-size:11px">📈 '+t.term+'</a>';
+  }).join('') + '</div>';
+  var bs = j.best_sellers || [];
+  var bsHtml = bs.length ? '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px">' + bs.map(function(p){
+    return '<div style="border:1px solid #eee;border-radius:4px;padding:4px;background:#fff;font-size:10px"><img src="'+(p.image_url||'')+'" loading="lazy" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:3px"><div style="margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(p.title||'').slice(0,40)+'</div><div style="color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(p.vendor||'')+'</div></div>';
+  }).join('') + '</div>' : '<p style="color:#999;font-size:12px;margin:0">No best-sellers indexed for this category yet.</p>';
+  div.innerHTML = trendsHtml + bsHtml + '<p style="font-size:10px;color:#aaa;margin-top:8px">'+(j.ad_signals_note||'')+'</p>';
+}
+
+// Refresh-button + select-change hooks (deferred until DOM is wired)
+setTimeout(function(){
+  var btn = document.getElementById('btn-suggest');
+  if (btn) btn.addEventListener('click', loadSuggestions);
+  var sel = document.getElementById('cat-select');
+  if (sel) sel.addEventListener('change', loadInsights);
+  var op = document.getElementById('overlay-opacity');
+  if (op) op.addEventListener('input', function(){ document.getElementById('opacity-val').textContent = this.value + '%'; });
+}, 100);
+
+// Hook into compose: after success, optionally run interior-designer review
+var _origCompose = document.getElementById('btn-compose').onclick;
+document.getElementById('btn-compose').addEventListener('click', async function(){
+  // wait for compose to finish (the prior handler is async and already attached)
+  await new Promise(r => setTimeout(r, 800));
+  // poll for compose-result that includes design link
+  var tries = 0;
+  while (tries++ < 30) {
+    var html = document.getElementById('compose-result').innerHTML;
+    var m = html.match(/\\/design\\/(\\d+)/);
+    if (m && document.getElementById('auto-review').checked) {
+      var did = m[1];
+      var rev = document.getElementById('design-review');
+      rev.innerHTML = '<div style="padding:10px;background:#eef;color:#1a3;border-radius:4px;font-size:13px">Running interior-designer review…</div>';
+      var j = await post('/api/studio/interior-design-review', { design_id: parseInt(did,10) });
+      if (j.ok) {
+        var c = j.critique || {};
+        rev.innerHTML = '<div style="padding:14px;border:1px solid #ddd;border-radius:6px;background:#fafafa"><b>Interior-designer review · design #'+did+'</b>' +
+          '<div style="margin-top:8px;font-size:12px"><b>Strengths:</b> <ul style="margin:4px 0 8px 18px">'+ (c.strengths||[]).map(function(s){return '<li>'+s.replace(/[<>]/g,'')+'</li>';}).join('') +'</ul>' +
+          '<b>Concerns:</b> <ul style="margin:4px 0 8px 18px">'+ (c.concerns||[]).map(function(s){return '<li>'+s.replace(/[<>]/g,'')+'</li>';}).join('') +'</ul>' +
+          '<b>Recommendation:</b> '+ ((c.recommendation||'').replace(/[<>]/g,'')) +
+          '<br><b>Room fit:</b> '+ ((c.room_fit||[]).join(', ').replace(/[<>]/g,'')) + ' · <b>Lighting:</b> ' + ((c.lighting||'').replace(/[<>]/g,'')) + ' · <b>Scale:</b> ' + ((c.scale||'').replace(/[<>]/g,'')) +
+          '</div></div>';
+      } else {
+        rev.innerHTML = '<div style="padding:10px;background:#f8d7da;color:#721c24;border-radius:4px;font-size:13px">Review failed: '+(j.error||'unknown')+'</div>';
+      }
+      break;
+    }
+    await new Promise(r => setTimeout(r, 1500));
+  }
+});
+
+loadRefs(); loadBoard(); loadTextures(); loadSuggestions(); loadInsights();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
 // ── HEALTH
 app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..c40a781
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+test-results/
+playwright-report/
+.env*
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..06c56e5
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,61 @@
+# wallco.ai /designs review tests
+
+5 end-to-end Playwright tests covering the review flow.
+
+## Run locally (default)
+
+```bash
+cd ~/Projects/wallco-ai/tests
+npm install
+npx playwright install chromium    # first time only — ~120 MB download
+npm test
+```
+
+Tests target `http://127.0.0.1:9792` — the running `wallco-ai-viewer` pm2 process.
+
+## Run via Browserbase (cloud Chromium)
+
+Browserbase can't reach `127.0.0.1` directly. Two steps:
+
+1. Expose the local server publicly. Quickest path:
+   ```bash
+   cloudflared tunnel --url http://localhost:9792
+   # → outputs https://something.trycloudflare.com
+   ```
+2. Run with that URL + Browserbase creds:
+   ```bash
+   BB_MODE=1 \
+   BB_TARGET_URL=https://something.trycloudflare.com \
+   BROWSERBASE_API_KEY=$(grep BROWSERBASE_API_KEY ~/Projects/secrets-manager/.env | cut -d= -f2) \
+   BROWSERBASE_PROJECT_ID=$(grep BROWSERBASE_PROJECT_ID ~/Projects/secrets-manager/.env | cut -d= -f2) \
+   npm run test:browserbase
+   ```
+
+Browserbase creds already live in `~/Projects/secrets-manager/.env`. The `playwright.config.js` auto-loads them.
+
+## Tests
+
+| # | Name | What it verifies |
+|---|---|---|
+| 1 | `/designs loads` | 10–60 review cards present, each with 3 sliders + Keep/Reject/Pair buttons + chips container |
+| 2 | slider + Keep persist | Set design slider to 8, click Keep, reload → both persist via `data/reviews.json` |
+| 3 | why-chip after Reject | Click Reject on card #2, Ollama-generated rationale renders within 60s |
+| 4 | chip chat round-trip | Click a chip on card #3, modal opens, send message, Ollama replies within 60s |
+| 5 | Pair + pin | Click Pair on card #4, ≥4 suggestions render (may trigger LLaVA ~60s), pin first → `data/moodboards.json` updated |
+
+Tests run **serially** (`workers: 1`) because review state is global JSON. Each test scopes to a different card index so they don't trip each other.
+
+## Side-effects
+
+Tests **write** to:
+- `data/reviews.json` — adds entries for the cards they touch
+- `data/chip-chats.json` — adds chat threads
+- `data/moodboards.json` — adds pinned pairings
+- `data/pairings.json` — adds cached pairing suggestions
+- `data/vision-cache.json` — adds LLaVA descriptions
+
+If you want a clean run, delete those files (or specific entries) before the suite.
+
+## Reports
+
+After a run: `npm run report` opens `playwright-report/index.html` with screenshots + trace viewer for any failure.
diff --git a/tests/package-lock.json b/tests/package-lock.json
new file mode 100644
index 0000000..d8e7780
--- /dev/null
+++ b/tests/package-lock.json
@@ -0,0 +1,536 @@
+{
+  "name": "wallco-ai-tests",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "wallco-ai-tests",
+      "version": "1.0.0",
+      "devDependencies": {
+        "@browserbasehq/sdk": "^2.0.0",
+        "@playwright/test": "^1.50.0",
+        "playwright-core": "^1.50.0"
+      }
+    },
+    "node_modules/@browserbasehq/sdk": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.10.0.tgz",
+      "integrity": "sha512-pOL4yW8P8AI2+N5y6zEP6XXKqIXtYyKunr1JXppqQDOyKLxxvZEDqQCHJXWUzqgx3R1tGWpn7m9AjXN7MeYInA==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+      "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/node-fetch": {
+      "version": "2.6.13",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+      "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "form-data": "^4.0.4"
+      }
+    },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
+    "node_modules/agentkeepalive": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.0.0"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "dev": true,
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    }
+  }
+}
diff --git a/tests/package.json b/tests/package.json
new file mode 100644
index 0000000..2d69136
--- /dev/null
+++ b/tests/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "wallco-ai-tests",
+  "version": "1.0.0",
+  "private": true,
+  "description": "End-to-end tests for the /designs review flow (sliders, K/R, chips, chat, pair, pin).",
+  "scripts": {
+    "test": "playwright test",
+    "test:headed": "playwright test --headed",
+    "test:browserbase": "BB_MODE=1 playwright test",
+    "report": "playwright show-report"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.50.0",
+    "@browserbasehq/sdk": "^2.0.0",
+    "playwright-core": "^1.50.0"
+  }
+}
diff --git a/tests/playwright.config.js b/tests/playwright.config.js
new file mode 100644
index 0000000..dc78365
--- /dev/null
+++ b/tests/playwright.config.js
@@ -0,0 +1,45 @@
+// Dual-mode Playwright config.
+//   default       — local Chromium against http://127.0.0.1:9792
+//   BB_MODE=1     — Browserbase cloud Chromium against BB_TARGET_URL
+//                   (Steve must expose port 9792 via cloudflared/ngrok first;
+//                   set BB_TARGET_URL=https://your-tunnel.example.com and
+//                   BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID env)
+const { defineConfig, devices } = require('@playwright/test');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const BB_MODE = process.env.BB_MODE === '1';
+const LOCAL_BASE = 'http://127.0.0.1:9792';
+const BB_BASE   = process.env.BB_TARGET_URL || LOCAL_BASE;
+const baseURL   = BB_MODE ? BB_BASE : LOCAL_BASE;
+
+// load Browserbase creds if available
+function loadEnvFromFile(file) {
+  if (!fs.existsSync(file)) return;
+  for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
+    const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
+    if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+  }
+}
+loadEnvFromFile(path.join(process.env.HOME, 'Projects/secrets-manager/.env'));
+loadEnvFromFile(path.join(process.env.HOME, '.claude/skills/browserbase/.env'));
+
+module.exports = defineConfig({
+  testDir: '.',
+  timeout: 60_000,
+  expect: { timeout: 10_000 },
+  retries: 0,
+  workers: 1,                       // serial — review state is shared
+  reporter: [['list'], ['html', { open: 'never' }]],
+  use: {
+    baseURL,
+    actionTimeout: 8_000,
+    navigationTimeout: 20_000,
+    screenshot: 'only-on-failure',
+    trace: 'retain-on-failure',
+    viewport: { width: 1440, height: 900 },
+  },
+  projects: BB_MODE
+    ? [{ name: 'browserbase', use: { ...devices['Desktop Chrome'] } }]
+    : [{ name: 'local-chromium', use: { ...devices['Desktop Chrome'] } }],
+});
diff --git a/tests/review.spec.js b/tests/review.spec.js
new file mode 100644
index 0000000..3747d94
--- /dev/null
+++ b/tests/review.spec.js
@@ -0,0 +1,178 @@
+// End-to-end tests for the wallco.ai /designs review flow.
+// Runs serially because review state is global (data/reviews.json).
+// Each test scoped to a different design ID so they don't trip each other.
+const { test, expect } = require('@playwright/test');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const REVIEW_FILE = path.join(__dirname, '..', 'data', 'reviews.json');
+
+// Helpers ────────────────────────────────────────────────────────────
+function readReviews() {
+  try { return JSON.parse(fs.readFileSync(REVIEW_FILE, 'utf8')); } catch { return {}; }
+}
+
+async function getFirstCardId(page) {
+  return await page.locator('.design-card.review-card').first().getAttribute('data-id');
+}
+
+async function getCardById(page, id) {
+  return page.locator(`.design-card.review-card[data-id="${id}"]`);
+}
+
+test.describe('wallco.ai /designs review flow', () => {
+
+  // Dismiss the wallco.ai age-prompt overlay before any page nav — it intercepts clicks.
+  test.beforeEach(async ({ page }) => {
+    await page.addInitScript(() => {
+      try { localStorage.setItem('wallco-age-skipped', '1'); } catch {}
+    });
+  });
+
+  // 1. PAGE LOADS — 30 review cards, each with sliders + K/R + Pair
+  test('1 · /designs loads with review-mode controls', async ({ page }) => {
+    await page.goto('/designs');
+    await expect(page).toHaveTitle(/wallco/i);
+
+    const cards = page.locator('.design-card.review-card');
+    const count = await cards.count();
+    expect(count).toBeGreaterThanOrEqual(10);
+    expect(count).toBeLessThanOrEqual(60);
+
+    const first = cards.first();
+    // sliders × 3 (design / color / style)
+    await expect(first.locator('.sliders input[type=range]')).toHaveCount(3);
+    // decision buttons × 3 (keep, reject, pair)
+    await expect(first.locator('.btn-keep')).toBeVisible();
+    await expect(first.locator('.btn-reject')).toBeVisible();
+    await expect(first.locator('.btn-pair')).toBeVisible();
+    // chips area present (may be empty until /api/chips loads — wait briefly)
+    await expect(first.locator('.chips')).toBeAttached();
+  });
+
+  // 2. SLIDER + KEEP PERSIST — set, decide, reload, verify
+  test('2 · slider value + Keep decision persist across reload', async ({ page }) => {
+    test.setTimeout(120_000); // Keep triggers Ollama auto-why (~30s)
+    await page.goto('/designs');
+    const id = await getFirstCardId(page);
+    const card = await getCardById(page, id);
+
+    // Set design slider to 8 (sliders are tricky to drag in Playwright; use JS)
+    const designSlider = card.locator('.sliders input[type=range][data-axis=design]');
+    await designSlider.evaluate(el => {
+      el.value = '8';
+      el.dispatchEvent(new Event('input'));
+      el.dispatchEvent(new Event('change'));
+    });
+
+    // Click Keep (this triggers server-side Ollama auto-why, can take ~30s)
+    await card.locator('.btn-keep').click();
+
+    // UI updates data-decision="keep" only after server response arrives — use that as the wait signal
+    await expect(card).toHaveAttribute('data-decision', 'keep', { timeout: 60_000 });
+
+    // Verify file persisted
+    const reviews = readReviews();
+    expect(reviews[id]).toBeTruthy();
+    expect(reviews[id].design).toBe(8);
+    expect(reviews[id].decision).toBe('keep');
+
+    // Reload and verify UI re-hydrates
+    await page.reload();
+    const reloadedCard = await getCardById(page, id);
+    await expect(reloadedCard).toHaveAttribute('data-decision', 'keep', { timeout: 10_000 });
+    const reloadedSlider = reloadedCard.locator('.sliders input[type=range][data-axis=design]');
+    await expect(reloadedSlider).toHaveValue('8');
+  });
+
+  // 3. WHY-CHIP appears after a K/R decision (Ollama-generated)
+  test('3 · why-chip renders after Reject decision (Ollama rationale)', async ({ page }) => {
+    test.setTimeout(90_000); // Ollama can take ~30s
+    await page.goto('/designs');
+    const cards = page.locator('.design-card.review-card');
+    // pick the 2nd card so we don't conflict with test #2
+    const card = cards.nth(1);
+    const id = await card.getAttribute('data-id');
+
+    await card.locator('.btn-reject').click();
+    await page.waitForResponse(resp => resp.url().includes('/api/review/' + id) && resp.request().method() === 'POST', { timeout: 60_000 });
+
+    const why = card.locator('.why-chip');
+    await expect(why).toHaveClass(/visible/);
+    const text = (await why.textContent() || '').trim();
+    expect(text.length).toBeGreaterThan(8);
+    // verdict should look like a sentence, not a placeholder
+    expect(text).not.toContain('thinking');
+    await expect(card).toHaveAttribute('data-decision', 'reject');
+  });
+
+  // 4. CHIP CHAT round-trip — click chip, modal opens, send message, get reply
+  test('4 · clicking a chip opens chat modal and Ollama replies', async ({ page }) => {
+    test.setTimeout(90_000);
+    await page.goto('/designs');
+    // pick 3rd card to avoid conflicts
+    const cards = page.locator('.design-card.review-card');
+    const card = cards.nth(2);
+    const id = await card.getAttribute('data-id');
+
+    // wait for chips to lazy-load
+    const chip = card.locator('.chip').first();
+    await expect(chip).toBeVisible({ timeout: 15_000 });
+    await chip.click();
+
+    // chat modal opens
+    await expect(page.locator('#chat-modal.open')).toBeVisible();
+    await expect(page.locator('#chat-chip-label')).not.toBeEmpty();
+
+    // type a message
+    await page.locator('#chat-input').fill('What scale of room does this work in?');
+    await page.locator('#chat-form button[type=submit]').click();
+
+    // wait for assistant reply (Ollama)
+    const assistantMsg = page.locator('#chat-history .msg.assistant:not(.loading)').last();
+    await expect(assistantMsg).toBeVisible({ timeout: 60_000 });
+    const reply = (await assistantMsg.textContent() || '').trim();
+    expect(reply.length).toBeGreaterThan(20);
+
+    // chip should now have has-chat class
+    await expect(chip).toHaveClass(/has-chat/);
+  });
+
+  // 5. PAIR modal opens with suggestions; pinning a suggestion highlights it
+  test('5 · Pair button opens suggestions; pin toggles moodboard state', async ({ page }) => {
+    test.setTimeout(180_000); // first Pair call can trigger LLaVA (~60s)
+    await page.goto('/designs');
+    const cards = page.locator('.design-card.review-card');
+    const card = cards.nth(3);
+    const id = await card.getAttribute('data-id');
+
+    await card.locator('.btn-pair').click();
+    await expect(page.locator('#pair-modal.open')).toBeVisible();
+
+    // wait for suggestions to render (replace loading state)
+    const pairCards = page.locator('#pair-body .pair-card');
+    await expect(pairCards.first()).toBeVisible({ timeout: 150_000 });
+
+    const pairCount = await pairCards.count();
+    expect(pairCount).toBeGreaterThanOrEqual(4); // 2 catalog + 2 colors at minimum
+
+    // pin the first suggestion
+    const firstPair = pairCards.first();
+    const pinKey = await firstPair.getAttribute('data-pin-key');
+    expect(pinKey).toBeTruthy();
+    await firstPair.locator('.pair-pin').click();
+
+    // wait for POST to /api/moodboard/:id/pin
+    await page.waitForResponse(resp => resp.url().includes('/api/moodboard/' + id + '/pin') && resp.request().method() === 'POST', { timeout: 10_000 });
+
+    // pair-card should now have .pinned class
+    await expect(firstPair).toHaveClass(/pinned/);
+
+    // verify the moodboard file actually has it
+    const moodFile = path.join(__dirname, '..', 'data', 'moodboards.json');
+    const moods = JSON.parse(fs.readFileSync(moodFile, 'utf8'));
+    expect(moods[id]).toBeTruthy();
+    expect(moods[id].some(p => p.pin_key === pinKey)).toBe(true);
+  });
+
+});

← a3ace82 Pairings v2: LLaVA vision read (Mac2 localhost:11434) drives  ·  back to Wallco Ai  ·  wallco.ai: live scale slider on /design/:id (4-54" repeat, p 0d9edc5 →