[object Object]

← back to Wallco Ai

tick 6: tileability scored on all 304 PD images · verdict: 62 seamless / 95 near / 132 photo / 15 scene · note: paintings false-positive seamless (uniform-bg edges); classification-filter needed for real AI-input seeds

78f6515f4c0e14caf47018683c476cc71583058b · 2026-05-11 17:15:37 -0700 · Steve

Files touched

Diff

commit 78f6515f4c0e14caf47018683c476cc71583058b
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 17:15:37 2026 -0700

    tick 6: tileability scored on all 304 PD images · verdict: 62 seamless / 95 near / 132 photo / 15 scene · note: paintings false-positive seamless (uniform-bg edges); classification-filter needed for real AI-input seeds
---
 data/chip-chats.json                |  14 ++
 data/reviews.json                   |  11 +
 scripts/cdp_login_and_extract.py    | 167 +++++++++++++++
 scripts/launch_spoon_chrome.sh      |   1 +
 scripts/pull_spoonflower_account.py | 111 ++++++++--
 scripts/tileability_check.py        |  85 ++++++++
 server-admin.js                     | 400 ++++++++++++++++++++++++++++++++++++
 server.js                           | 282 ++++++++++++++++++++++++-
 src/review.js                       | 212 +++++++++++++++++++
 9 files changed, 1268 insertions(+), 15 deletions(-)

diff --git a/data/chip-chats.json b/data/chip-chats.json
new file mode 100644
index 0000000..db0e2a2
--- /dev/null
+++ b/data/chip-chats.json
@@ -0,0 +1,14 @@
+{
+  "11:cat-floral": [
+    {
+      "role": "user",
+      "content": "Why is this floral chip relevant to my collection?",
+      "ts": "2026-05-12T00:15:21.709Z"
+    },
+    {
+      "role": "assistant",
+      "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"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/data/reviews.json b/data/reviews.json
new file mode 100644
index 0000000..9d2a43b
--- /dev/null
+++ b/data/reviews.json
@@ -0,0 +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"
+  }
+}
\ No newline at end of file
diff --git a/scripts/cdp_login_and_extract.py b/scripts/cdp_login_and_extract.py
new file mode 100644
index 0000000..ccbb8fe
--- /dev/null
+++ b/scripts/cdp_login_and_extract.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""
+Drive the debug Chrome on :9222 via raw CDP (websocket-client, no Playwright):
+  1. Find the Spoonflower tab (or open one if missing)
+  2. If not signed in, fill #email/#password and click #submit
+  3. Read all Spoonflower cookies via Network.getAllCookies
+  4. Save to ~/.spoonflower-cookies.json (Playwright cookie shape)
+
+This sidesteps Playwright's Browser.setDownloadBehavior issue with
+externally-launched Chrome.
+"""
+import json, os, sys, time, urllib.request, pathlib
+import websocket  # type: ignore
+
+DEBUG_PORT = 9222
+COOKIES_PATH = pathlib.Path.home() / ".spoonflower-cookies.json"
+
+
+def list_targets():
+    with urllib.request.urlopen(f"http://127.0.0.1:{DEBUG_PORT}/json", timeout=4) as r:
+        return json.loads(r.read())
+
+
+def open_new_tab(url):
+    with urllib.request.urlopen(
+        f"http://127.0.0.1:{DEBUG_PORT}/json/new?{url}", timeout=4
+    ) as r:
+        return json.loads(r.read())
+
+
+class CDP:
+    def __init__(self, ws_url):
+        self.ws = websocket.create_connection(ws_url, timeout=30)
+        self._id = 0
+
+    def call(self, method, params=None, timeout=20):
+        self._id += 1
+        mid = self._id
+        self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            self.ws.settimeout(max(0.1, deadline - time.time()))
+            try:
+                msg = json.loads(self.ws.recv())
+            except websocket.WebSocketTimeoutException:
+                break
+            if msg.get("id") == mid:
+                if "error" in msg:
+                    raise RuntimeError(f"CDP error {method}: {msg['error']}")
+                return msg.get("result", {})
+        raise TimeoutError(f"CDP {method} timed out")
+
+    def close(self):
+        try:
+            self.ws.close()
+        except Exception:
+            pass
+
+
+def get_creds():
+    sys.path.insert(0, str(pathlib.Path(__file__).parent))
+    from spoonflower_lib import creds
+    return creds()
+
+
+def find_spoonflower_target():
+    targets = list_targets()
+    for t in targets:
+        if t.get("type") == "page" and "spoonflower" in t.get("url", ""):
+            return t
+    # No spoonflower tab — open one
+    print("[cdp] no spoonflower tab — opening one", flush=True)
+    open_new_tab("https://www.spoonflower.com/login")
+    time.sleep(4)
+    for t in list_targets():
+        if t.get("type") == "page" and "spoonflower" in t.get("url", ""):
+            return t
+    return None
+
+
+def main():
+    email, password = get_creds()
+
+    target = find_spoonflower_target()
+    if not target:
+        print(json.dumps({"ok": False, "error": "no spoonflower tab and could not open one"}))
+        return 1
+
+    cdp = CDP(target["webSocketDebuggerUrl"])
+    try:
+        cdp.call("Page.enable")
+        cdp.call("Network.enable")
+
+        url = target.get("url", "")
+        print(f"[cdp] attached to {url}", flush=True)
+
+        # If we're on /login, drive it
+        if "/login" in url:
+            print("[cdp] driving login form", flush=True)
+            cdp.call("Runtime.evaluate", {
+                "expression": (
+                    f"document.querySelector('#email').value = {json.dumps(email)};"
+                    f"document.querySelector('#password').value = {json.dumps(password)};"
+                    "var f = document.querySelector('#email').form;"
+                    "if (f) f.submit(); else document.querySelector('#submit').click();"
+                ),
+                "awaitPromise": False,
+            })
+            # Wait for navigation
+            time.sleep(8)
+
+        # Check signed-in via get-user-stats inline
+        stats_js = """
+        fetch('https://cart.spoonflower.com/api/spoonflower/get-user-stats', {credentials:'include'})
+          .then(r => r.text()).then(t => t).catch(e => 'ERR:'+e)
+        """
+        r = cdp.call("Runtime.evaluate", {
+            "expression": stats_js, "awaitPromise": True, "returnByValue": True,
+        })
+        body = r.get("result", {}).get("value", "")
+        print(f"[cdp] get-user-stats body: {body[:300]}", flush=True)
+        try:
+            stats = json.loads(body).get("data_layer", {})
+        except Exception:
+            stats = {}
+        user_id = stats.get("userID")
+        print(f"[cdp] userID={user_id}  designForSale={stats.get('designForSale')}",
+              flush=True)
+
+        # Pull cookies regardless — even partial cookies help debug
+        cks = cdp.call("Network.getAllCookies").get("cookies", [])
+        # Normalize to Playwright shape
+        pw_cookies = []
+        for c in cks:
+            if "spoonflower" not in c.get("domain", ""):
+                continue
+            pw_cookies.append({
+                "name": c["name"],
+                "value": c["value"],
+                "domain": c["domain"],
+                "path": c.get("path", "/"),
+                "secure": bool(c.get("secure", True)),
+                "httpOnly": bool(c.get("httpOnly", False)),
+                "sameSite": c.get("sameSite", "Lax"),
+                "expires": int(c.get("expires") or -1),
+            })
+
+        COOKIES_PATH.write_text(json.dumps(pw_cookies, indent=2))
+        print(f"[cdp] wrote {len(pw_cookies)} spoonflower cookies to {COOKIES_PATH}",
+              flush=True)
+
+        # Print summary as last JSON line for the caller
+        print(json.dumps({
+            "ok": bool(user_id and user_id != "null"),
+            "userID": user_id,
+            "designForSale": stats.get("designForSale"),
+            "numOrders": stats.get("numOrders"),
+            "cookies_saved": len(pw_cookies),
+            "cookies_path": str(COOKIES_PATH),
+        }))
+        return 0 if user_id else 2
+    finally:
+        cdp.close()
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/launch_spoon_chrome.sh b/scripts/launch_spoon_chrome.sh
index f29192f..04f8b39 100755
--- a/scripts/launch_spoon_chrome.sh
+++ b/scripts/launch_spoon_chrome.sh
@@ -31,6 +31,7 @@ mkdir -p "$PROFILE_DIR"
 echo "Launching debug Chrome on :${DEBUG_PORT} (profile: ${PROFILE_DIR})…"
 nohup "$CHROME" \
   --remote-debugging-port=${DEBUG_PORT} \
+  --remote-allow-origins='*' \
   --user-data-dir="$PROFILE_DIR" \
   --no-first-run \
   --no-default-browser-check \
diff --git a/scripts/pull_spoonflower_account.py b/scripts/pull_spoonflower_account.py
index 7aae136..0e16f65 100644
--- a/scripts/pull_spoonflower_account.py
+++ b/scripts/pull_spoonflower_account.py
@@ -22,7 +22,9 @@ from spoonflower_lib import (  # noqa: E402
 DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "spoonflower-pulled"
 DATA_DIR.mkdir(parents=True, exist_ok=True)
 
-DB = os.getenv("DATABASE_URL", "dw_unified")
+# Local trust auth — never use the URL form even if DATABASE_URL is set
+# (URL has dw_admin user which fails GSSAPI on this Mac).
+DB = "dw_unified"
 
 
 def psql(sql: str) -> str:
@@ -57,10 +59,12 @@ def list_my_designs(page, limit: int = 0):
     # Use account username if we have it from stats
     username = stats.get("userID") or os.getenv("SPOONFLOWER_USERNAME") or ""
     candidates = [
-        # Proven artist-profile pattern (months-ago working scraper)
+        # PRIMARY: confirmed by Steve 2026-05-11 — logged-in users land here
+        "https://www.spoonflower.com/designs",
+        # Profile pattern (months-ago working scraper)
         f"https://www.spoonflower.com/profiles/{username}/designs" if username else None,
         f"https://www.spoonflower.com/artists/{username}/designs" if username else None,
-        # SPA fallback
+        # SPA fallbacks
         "https://www.spoonflower.com/en/my-spoonflower/designs",
         "https://www.spoonflower.com/profiles/designs",
         "https://www.spoonflower.com/en/profile/designs",
@@ -137,9 +141,75 @@ def list_my_designs(page, limit: int = 0):
     return designs
 
 
+DIRECT_DOWNLOAD_URL = "https://design-upload-api.spoonflower.com/design/{id}/image"
+
+
 def pull_one(page, d: dict, account: str) -> dict:
-    """Open design page, find File Options → highest-res download, save it."""
+    """Download a design via the direct API endpoint discovered 2026-05-11:
+        https://design-upload-api.spoonflower.com/design/<id>/image
+    Uses the session cookies from the current context, no click flow needed.
+    Visit the design page first only to grab title + thumbnail metadata.
+    """
+    import urllib.request as _ur
+
     out = dict(d)
+    # Page visit for metadata
+    try:
+        page.goto(d["design_url"], wait_until="domcontentloaded", timeout=45000)
+        time.sleep(1.5)
+        try:
+            title = page.locator("h1").first.text_content(timeout=2000)
+            if title:
+                out["title"] = title.strip()
+        except Exception:
+            pass
+        try:
+            thumb = page.locator("img[src*='spoonflower']").first.get_attribute(
+                "src", timeout=1500)
+            if thumb:
+                out["thumbnail_url"] = thumb
+        except Exception:
+            pass
+    except Exception:
+        pass
+
+    # Direct download via API endpoint with browser cookies
+    download_url = DIRECT_DOWNLOAD_URL.format(id=d["sf_design_id"])
+    # Pull cookies from Playwright context for the call
+    cookies = page.context.cookies()
+    cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookies
+                              if "spoonflower.com" in c["domain"])
+    try:
+        req = _ur.Request(download_url, headers={
+            "Cookie": cookie_header,
+            "Referer": "https://www.spoonflower.com/",
+            "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                           "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"),
+        })
+        with _ur.urlopen(req, timeout=120) as r:
+            content_type = r.headers.get("Content-Type", "application/octet-stream")
+            data = r.read()
+        # Sniff actual file magic since Spoonflower sends application/octet-stream
+        ext = ".bin"
+        if len(data) >= 12:
+            head = data[:12]
+            if head.startswith(b"\xff\xd8\xff"):              ext = ".jpg"
+            elif head.startswith(b"\x89PNG\r\n\x1a\n"):       ext = ".png"
+            elif head[:4] in (b"II*\x00", b"MM\x00*"):       ext = ".tiff"
+            elif head[:4] == b"GIF8":                          ext = ".gif"
+            elif "jpeg" in content_type or "jpg" in content_type: ext = ".jpg"
+            elif "png" in content_type:                       ext = ".png"
+            elif "tiff" in content_type:                       ext = ".tiff"
+        target = DATA_DIR / f"{d['sf_design_id']}{ext}"
+        target.write_bytes(data)
+        out["local_path"] = str(target)
+        out["file_bytes"] = len(data)
+        out["account"] = account
+        return out
+    except Exception as e:
+        out["error"] = f"direct download failed: {e}"
+        return out
+    # Legacy click flow below — preserved but unreachable now
     page.goto(d["design_url"], wait_until="networkidle", timeout=60000)
     time.sleep(2)
 
@@ -161,12 +231,15 @@ def pull_one(page, d: dict, account: str) -> dict:
     except Exception:
         pass
 
-    # Find File Options menu (only visible if this account OWNS the design)
+    # File Options is a <div> on the artists/designs/<id> page (not <button>).
+    # Use text-based locator that matches ANY element.
     file_btn = None
     for sel in (
-        'button:has-text("File Options")',
+        'text="File Options"',
+        ':text-is("File Options")',
+        ':text("File Options")',
         '[aria-label*="File Options" i]',
-        'button[aria-haspopup="menu"]:has-text("File")',
+        'div:has-text("File Options")',
     ):
         try:
             loc = page.locator(sel).first
@@ -177,20 +250,32 @@ def pull_one(page, d: dict, account: str) -> dict:
             continue
 
     if not file_btn:
-        out["error"] = "no File Options (design may not be owned by this account)"
+        out["error"] = "no File Options element found"
         return out
 
-    file_btn.click()
-    time.sleep(1)
+    try:
+        file_btn.click()
+    except Exception as e:
+        # Some labels are inside <a> or click-through divs — try parent
+        try:
+            file_btn.locator("xpath=ancestor-or-self::*[self::a or self::button or @role='button'][1]").first.click()
+        except Exception:
+            out["error"] = f"click File Options failed: {e}"
+            return out
+    time.sleep(1.5)
 
-    # Click highest-res download option
+    # Click highest-res download option in the menu/dropdown
     download_clicked = False
     with page.expect_download(timeout=60000) as dl_info:
         for sel in (
-            'text=Download Original',
-            'text=Download Highest Resolution',
+            'text="Download Original"',
+            'text="Download original"',
+            'text="Download Highest Resolution"',
+            ':text("Download Original")',
             '[role="menuitem"]:has-text("Download")',
             'a:has-text("Download")',
+            'button:has-text("Download")',
+            'text="Download"',
         ):
             try:
                 loc = page.locator(sel).first
diff --git a/scripts/tileability_check.py b/scripts/tileability_check.py
new file mode 100644
index 0000000..fd13d60
--- /dev/null
+++ b/scripts/tileability_check.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""
+Tileability check for the wallco.ai PD corpus (mirror of the SP-side check
+but pointed at pd_source_designs + their local_path).
+
+Scoring: average pixel diff between opposite edges, after thumbnailing to
+800px. Lower = more tileable. Verdict bands match the SP script:
+  0–10  → seamless
+  10–30 → near
+  30–80 → photo
+  80+   → scene
+
+Persists to pd_source_designs.tileability JSONB.
+Idempotent.
+"""
+import json, os, subprocess
+from pathlib import Path
+from PIL import Image, ImageOps
+import numpy as np
+
+def psql_json(sql):
+    out = subprocess.run(['psql','dw_unified','-At','-q','-c',sql], capture_output=True, text=True, check=True).stdout.strip()
+    return json.loads(out) if out else []
+
+def edge_score(path, strip_px=20):
+    img = Image.open(path).convert('RGB')
+    img = ImageOps.exif_transpose(img)
+    img.thumbnail((800, 800))
+    a = np.asarray(img, dtype=np.float32)
+    h, w, _ = a.shape
+    s = min(strip_px, w // 8, h // 8)
+    L = a[:, :s, :]; R = a[:, -s:, :]
+    horiz = float(np.mean(np.abs(L - R)))
+    T = a[:s, :, :]; B = a[-s:, :, :]
+    vert = float(np.mean(np.abs(T - B)))
+    return round((horiz + vert) / 2, 2), round(horiz, 2), round(vert, 2)
+
+# Ensure column
+subprocess.run(['psql','dw_unified','-q','-c',
+    'ALTER TABLE pd_source_designs ADD COLUMN IF NOT EXISTS tileability JSONB;'], check=True)
+
+rows = psql_json("SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT id, source, local_path FROM pd_source_designs WHERE local_path IS NOT NULL AND tileability IS NULL ORDER BY id) t;")
+print(f"{len(rows)} PD images pending tileability score")
+
+scored = []
+failed = 0
+for i, r in enumerate(rows, 1):
+    p = Path(r['local_path'])
+    if not p.exists():
+        failed += 1; continue
+    try:
+        score, h, v = edge_score(p)
+        verdict = ('seamless' if score < 10
+                   else 'near'    if score < 30
+                   else 'photo'   if score < 80
+                   else 'scene')
+        scored.append((r['id'], score, h, v, verdict))
+        if i % 25 == 0:
+            print(f"  [{i:>4}/{len(rows)}] score={score:>5.1f}  {verdict}")
+    except Exception as e:
+        failed += 1
+        if i <= 5: print(f"  [{i:>4}] FAIL {p.name[:40]}: {e}")
+
+# Write back in one transaction
+sql_file = '/tmp/pd_tile.sql'
+with open(sql_file, 'w') as f:
+    f.write("BEGIN;\n")
+    for rid, score, h, v, verdict in scored:
+        obj = {"score": score, "edge_horiz": h, "edge_vert": v, "verdict": verdict}
+        s = json.dumps(obj).replace("'", "''")
+        f.write(f"UPDATE pd_source_designs SET tileability='{s}'::jsonb WHERE id={rid};\n")
+    f.write("COMMIT;\n")
+subprocess.run(['psql','dw_unified','-q','-f', sql_file], check=True)
+os.unlink(sql_file)
+
+# Stats
+from collections import Counter
+print(f"\nScored {len(scored)} · failed {failed}")
+print("Verdict distribution:", dict(Counter(s[4] for s in scored)))
+print("\nMost-tileable PD assets (potential AI input seeds):")
+out = subprocess.run(['psql','dw_unified','-c',
+  "SELECT source, title, (tileability->>'score')::numeric AS s, tileability->>'verdict' AS v "
+  "FROM pd_source_designs WHERE tileability IS NOT NULL ORDER BY (tileability->>'score')::numeric LIMIT 10;"],
+  capture_output=True, text=True)
+print(out.stdout)
diff --git a/server-admin.js b/server-admin.js
index 61611b6..b7ca0b3 100644
--- a/server-admin.js
+++ b/server-admin.js
@@ -15,9 +15,32 @@ const { execSync } = require('child_process');
 const inlineEditor = require('@dw/inline-editor');
 
 const app = express();
+app.use(express.json());
 const PORT = process.env.PORT || 9792;
 const DATA_ROOT = path.join(__dirname, 'data');
 
+// ── Designs cache for review layer (5s TTL) ─────────────────────────
+let DESIGNS_CACHE = { ts: 0, rows: [] };
+function getDesignsCached() {
+  if (Date.now() - DESIGNS_CACHE.ts < 5000) return DESIGNS_CACHE.rows;
+  try {
+    const raw = execSync(`psql dw_unified -At -q`, {
+      input: `SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+                SELECT id, kind, prompt, dominant_hex, palette, category, tags, motifs,
+                       generator, seed, local_path, is_published, created_at
+                FROM spoon_all_designs ORDER BY created_at DESC LIMIT 500
+              ) t;`,
+      encoding: 'utf8'
+    }).trim();
+    DESIGNS_CACHE = { ts: Date.now(), rows: JSON.parse(raw || '[]') };
+  } catch (e) { console.error('getDesignsCached:', e.message); }
+  return DESIGNS_CACHE.rows;
+}
+
+// ── Mount review layer (sliders + chips + chip-chat) ────────────────
+try { require('./src/review').mount(app, getDesignsCached); }
+catch (e) { console.error('Review mount failed:', e.message); }
+
 // Inline editor (Wix-style admin editing). Auth required — admin role only.
 app.use(inlineEditor({
   site: 'wallco.ai',
@@ -239,4 +262,381 @@ setTab(tab);
 </script>`);
 });
 
+// ── /designs — review interface (sliders + K/R + chips + chat) ────
+app.get('/designs', (_req, res) => {
+  const designs = getDesignsCached();
+  res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>wallco.ai · designs review</title>
+<style>
+  :root { --cols: 4; --keep:#5fbf6e; --reject:#d2554a; --chip:#3a3631; --chip-hot:#d2b15c; }
+  *,*::before,*::after { box-sizing: border-box; }
+  body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#e8e2d6; background:#0f0e0c; }
+  header { padding:18px 28px; border-bottom:1px solid #2a2825; background:#1a1816; display:flex; gap:18px; align-items:baseline; flex-wrap:wrap; position:sticky; top:0; z-index:10; }
+  header h1 { margin:0; font-weight:300; font-size:20px; letter-spacing:.5px; }
+  header .tag { font-size:11px; color:#aaa; }
+  nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+  nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
+  .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
+  .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
+  .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
+  .controls input[type=range] { width:140px; }
+  .filter-pill { background:#2a2825; padding:3px 10px; border-radius:12px; font-size:11px; cursor:pointer; color:#bba; border:1px solid transparent; }
+  .filter-pill.on { background:#d2b15c; color:#0f0e0c; }
+  main { padding: 22px 28px; }
+  .grid { display:grid; gap:20px; grid-template-columns: repeat(var(--cols), 1fr); }
+  .card { background:#1a1816; border:1px solid #2a2825; border-radius:3px; overflow:hidden; transition: border-color .15s; }
+  .card[data-decision=keep]   { border-color: var(--keep); }
+  .card[data-decision=reject] { border-color: var(--reject); opacity:.55; }
+  .card .img-wrap { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; display:block; cursor:zoom-in; position:relative; }
+  .card .img-wrap .decision-badge { position:absolute; top:8px; right:8px; padding:2px 8px; font-size:10px; border-radius:2px; text-transform:uppercase; letter-spacing:.06em; display:none; }
+  .card[data-decision=keep] .img-wrap .decision-badge { display:inline-block; background:var(--keep); color:#0f0e0c; }
+  .card[data-decision=reject] .img-wrap .decision-badge { display:inline-block; background:var(--reject); color:#fff; }
+  .card .body { padding:12px 14px 14px; }
+  .card .title { font-size:12px; margin:0 0 4px; color:#e8e2d6; line-height:1.3; max-height:2.6em; overflow:hidden; }
+  .card .sub { font-size:10px; color:#888; margin-bottom:10px; }
+  .dot { display:inline-block; width:9px; height:9px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:5px; }
+  .sliders { display:flex; flex-direction:column; gap:4px; margin: 10px 0 10px; }
+  .sliders label { display:grid; grid-template-columns: 48px 1fr 22px; gap:6px; align-items:center; font-size:10px; color:#bba; text-transform:uppercase; letter-spacing:.06em; }
+  .sliders label span { text-align:right; color:#d2b15c; font-variant-numeric: tabular-nums; }
+  .sliders input[type=range] { width:100%; -webkit-appearance:none; height:2px; background:#3a3631; border-radius:2px; outline:none; }
+  .sliders input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:11px; height:11px; border-radius:50%; background:#d2b15c; cursor:pointer; border:none; }
+  .decision { display:flex; gap:6px; margin-bottom:9px; }
+  .decision button { flex:1; padding:5px 8px; font-size:11px; border:1px solid #3a3631; background:#16140f; color:#bba; cursor:pointer; border-radius:2px; transition: all .12s; text-transform:uppercase; letter-spacing:.06em; }
+  .decision button:hover { background:#1f1c18; color:#fff; }
+  .card[data-decision=keep]   .btn-keep   { background:var(--keep);   color:#0f0e0c; border-color:var(--keep); }
+  .card[data-decision=reject] .btn-reject { background:var(--reject); color:#fff;    border-color:var(--reject); }
+  .why-chip { display:none; padding:6px 9px; background:#16140f; border:1px solid #3a3631; border-radius:14px; font-size:11px; color:#d2b15c; margin-bottom:8px; cursor:pointer; line-height:1.35; }
+  .why-chip.visible { display:block; }
+  .why-chip.loading::after { content:' …'; opacity:.6; }
+  .chips { display:flex; flex-wrap:wrap; gap:5px; }
+  .chip { padding:3px 8px; background:var(--chip); color:#bba; border-radius:10px; font-size:10px; cursor:pointer; border:1px solid transparent; transition: all .12s; }
+  .chip:hover { background:#4a443d; color:#fff; }
+  .chip.has-chat { border-color: var(--chip-hot); color:#d2b15c; }
+  .empty { padding:48px; text-align:center; color:#666; }
+
+  /* chat modal */
+  #chat-modal { position:fixed; top:0; right:0; bottom:0; width:420px; background:#16140f; border-left:1px solid #2a2825; display:none; flex-direction:column; z-index:100; box-shadow:-8px 0 20px rgba(0,0,0,.4); }
+  #chat-modal.open { display:flex; }
+  #chat-head { padding:14px 18px; border-bottom:1px solid #2a2825; display:flex; gap:10px; align-items:center; }
+  #chat-head .ctx { flex:1; font-size:12px; color:#d2b15c; }
+  #chat-head .ctx-sub { font-size:10px; color:#888; }
+  #chat-head .close { background:none; border:none; color:#999; font-size:22px; cursor:pointer; padding:0 4px; }
+  #chat-history { flex:1; overflow-y:auto; padding:16px 18px; display:flex; flex-direction:column; gap:10px; }
+  #chat-history .msg { padding:9px 12px; border-radius:10px; max-width:88%; font-size:12px; line-height:1.45; white-space:pre-wrap; word-wrap:break-word; }
+  #chat-history .msg.user { background:#2a2825; color:#fff; align-self:flex-end; }
+  #chat-history .msg.assistant { background:#1f1c18; color:#e8e2d6; align-self:flex-start; border:1px solid #2a2825; }
+  #chat-history .msg.assistant.loading { color:#888; font-style:italic; }
+  #chat-form { display:flex; padding:12px 14px; border-top:1px solid #2a2825; gap:8px; }
+  #chat-form input { flex:1; padding:8px 12px; background:#0f0e0c; border:1px solid #2a2825; color:#fff; font:inherit; border-radius:2px; outline:none; }
+  #chat-form input:focus { border-color:#d2b15c; }
+  #chat-form button { padding:8px 14px; background:#d2b15c; color:#0f0e0c; border:none; cursor:pointer; font:inherit; font-weight:500; border-radius:2px; }
+  #chat-form button:disabled { opacity:.5; cursor:not-allowed; }
+  a.card-link { color:inherit; text-decoration:none; display:block; }
+</style>
+<header>
+  <h1>wallco.ai</h1>
+  <span class="tag" id="count">${designs.length} designs</span>
+  <nav>
+    <a href="/" class="">Source corpus / generated</a>
+    <a href="/designs" class="on">Review queue</a>
+  </nav>
+  <div class="controls">
+    <div class="filter-pill" data-filter="all">All</div>
+    <div class="filter-pill" data-filter="unreviewed">Unreviewed</div>
+    <div class="filter-pill" data-filter="keep">Kept</div>
+    <div class="filter-pill" data-filter="reject">Rejected</div>
+    <label>Sort
+      <select id="sort">
+        <option value="recent">Recent</option>
+        <option value="color-wheel">Color wheel</option>
+        <option value="light-dark">Light → Dark</option>
+        <option value="dark-light">Dark → Light</option>
+        <option value="title">Title A→Z</option>
+      </select>
+    </label>
+    <label>Density
+      <input type="range" id="density" min="2" max="7" step="1" value="4">
+    </label>
+  </div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+
+<div id="chat-modal">
+  <div id="chat-head">
+    <div>
+      <div class="ctx" id="chat-chip-label">chip</div>
+      <div class="ctx-sub" id="chat-design-label">design</div>
+    </div>
+    <button class="close" id="chat-close" title="Close (Esc)">×</button>
+  </div>
+  <div id="chat-history"></div>
+  <form id="chat-form">
+    <input type="text" id="chat-input" placeholder="Ask about this chip…" autocomplete="off">
+    <button type="submit" id="chat-send">Send</button>
+  </form>
+</div>
+
+<script>
+const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
+const DESIGNS = ${JSON.stringify(designs)};
+let REVIEWS = {};      // designId -> {design,color,style,decision,why}
+let CHIPS = {};        // designId -> [{key,label,kind}]
+let CURRENT_CHIP = null;
+let FILTER = 'all';
+
+const $ = (s, el=document) => el.querySelector(s);
+const $$ = (s, el=document) => Array.from(el.querySelectorAll(s));
+const sortSel = $('#sort');
+const dens = $('#density');
+const grid = $('#grid');
+sortSel.value = localStorage.getItem('wc.review.sort') || 'recent';
+dens.value = localStorage.getItem('wc.review.density') || 4;
+document.documentElement.style.setProperty('--cols', dens.value);
+
+function imgPath(p) {
+  if (!p) return '';
+  if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
+  return p;
+}
+function hexToHsl(hex) {
+  if (!hex) return [0,0,0];
+  const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+  if (!m) return [0,0,0];
+  const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
+  const max = Math.max(r,g,b), min = Math.min(r,g,b);
+  const l = (max+min)/2; let h = 0, s = 0;
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d/(2-max-min) : d/(max+min);
+    if (max === r) h = (g-b)/d + (g<b?6:0);
+    else if (max === g) h = (b-r)/d + 2;
+    else h = (r-g)/d + 4;
+    h *= 60;
+  }
+  return [h,s,l];
+}
+function sortDesigns(data) {
+  const sort = sortSel.value;
+  return [...data].sort((a,b) => {
+    if (sort === 'recent') return new Date(b.created_at||0) - new Date(a.created_at||0);
+    if (sort === 'title')  return (a.prompt||'').slice(0,40).localeCompare((b.prompt||'').slice(0,40));
+    const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
+    if (sort === 'light-dark') return hb[2]-ha[2];
+    if (sort === 'dark-light') return ha[2]-hb[2];
+    if (sort === 'color-wheel') {
+      const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
+      if (isaA !== isaB) return isaA ? -1 : 1;
+      if (isaA) return ha[2]-hb[2];
+      const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
+      if (bA !== bB) return bA - bB;
+      return ha[2]-hb[2];
+    }
+    return 0;
+  });
+}
+
+async function fetchInitialReviews() {
+  try { REVIEWS = await (await fetch('/api/reviews/all')).json(); } catch {}
+}
+
+function makeChipsHTML(designId) {
+  const chips = CHIPS[designId] || [];
+  return chips.map(c => \`<span class="chip" data-design="\${designId}" data-chip="\${c.key}">\${c.label}</span>\`).join('');
+}
+
+function renderCard(d) {
+  const img = imgPath(d.local_path) || '';
+  const review = REVIEWS[d.id] || {};
+  const decision = review.decision || '';
+  const title = (d.prompt || '#'+d.id).slice(0, 80);
+  const sub = (d.category || '') + (d.kind ? ' · ' + d.kind : '');
+  const dot = d.dominant_hex ? '<span class="dot" style="background:'+d.dominant_hex+'"></span>' : '';
+  const sD = review.design ?? 5, sC = review.color ?? 5, sS = review.style ?? 5;
+  return \`<div class="card" data-id="\${d.id}" data-decision="\${decision}">
+    <a class="img-wrap" style="background-image:url('\${img}')" href="/design/\${d.id}" target="_blank">
+      <span class="decision-badge">\${decision || ''}</span>
+    </a>
+    <div class="body">
+      <div class="title">\${dot}\${title}</div>
+      <div class="sub">\${sub}</div>
+      <div class="sliders">
+        <label>Design <input type="range" min="0" max="10" value="\${sD}" data-axis="design"><span>\${sD}</span></label>
+        <label>Color  <input type="range" min="0" max="10" value="\${sC}" data-axis="color"><span>\${sC}</span></label>
+        <label>Style  <input type="range" min="0" max="10" value="\${sS}" data-axis="style"><span>\${sS}</span></label>
+      </div>
+      <div class="decision">
+        <button class="btn-keep">Keep</button>
+        <button class="btn-reject">Reject</button>
+      </div>
+      <div class="why-chip \${review.why ? 'visible' : ''}">\${review.why || ''}</div>
+      <div class="chips">\${makeChipsHTML(d.id)}</div>
+    </div>
+  </div>\`;
+}
+
+function applyFilter(d) {
+  if (FILTER === 'all') return true;
+  const r = REVIEWS[d.id];
+  if (FILTER === 'unreviewed') return !r || !r.decision;
+  if (FILTER === 'keep') return r && r.decision === 'keep';
+  if (FILTER === 'reject') return r && r.decision === 'reject';
+  return true;
+}
+
+function renderAll() {
+  const filtered = sortDesigns(DESIGNS.filter(applyFilter));
+  $('#count').textContent = filtered.length + ' designs' + (FILTER !== 'all' ? ' (' + FILTER + ')' : '');
+  grid.innerHTML = filtered.length
+    ? filtered.map(renderCard).join('')
+    : '<div class="empty">No matches for filter "' + FILTER + '"</div>';
+  wireCards();
+}
+
+async function loadChipsForVisibleCards() {
+  const toFetch = $$('.card').map(c => c.dataset.id).filter(id => !CHIPS[id]);
+  await Promise.all(toFetch.map(async id => {
+    try { const r = await fetch('/api/chips/' + id); const j = await r.json(); CHIPS[id] = j.chips || []; } catch {}
+  }));
+  toFetch.forEach(id => {
+    const card = grid.querySelector('.card[data-id="' + id + '"]');
+    if (card) card.querySelector('.chips').innerHTML = makeChipsHTML(id);
+  });
+}
+
+function wireCards() {
+  $$('.card').forEach(card => {
+    const id = card.dataset.id;
+    // sliders
+    card.querySelectorAll('.sliders input[type=range]').forEach(slider => {
+      slider.addEventListener('input', () => {
+        slider.nextElementSibling.textContent = slider.value;
+      });
+      slider.addEventListener('change', () => saveReview(id, card));
+    });
+    // K/R buttons
+    card.querySelector('.btn-keep').addEventListener('click', () => {
+      card.dataset.decision = card.dataset.decision === 'keep' ? '' : 'keep';
+      saveReview(id, card);
+    });
+    card.querySelector('.btn-reject').addEventListener('click', () => {
+      card.dataset.decision = card.dataset.decision === 'reject' ? '' : 'reject';
+      saveReview(id, card);
+    });
+    // chips → open chat
+    card.querySelectorAll('.chip').forEach(chip => {
+      chip.addEventListener('click', () => openChat(id, chip.dataset.chip, chip.textContent));
+    });
+    // why chip → open chat (uses special key "_verdict")
+    const whyChip = card.querySelector('.why-chip');
+    if (whyChip) whyChip.addEventListener('click', () => {
+      if (whyChip.classList.contains('visible')) openChat(id, '_verdict', '"' + whyChip.textContent + '"');
+    });
+  });
+}
+
+async function saveReview(id, card) {
+  const sliders = card.querySelectorAll('.sliders input[type=range]');
+  const payload = {
+    design: parseInt(sliders[0].value, 10),
+    color:  parseInt(sliders[1].value, 10),
+    style:  parseInt(sliders[2].value, 10),
+    decision: card.dataset.decision || null,
+  };
+  const why = card.querySelector('.why-chip');
+  if (payload.decision) { why.classList.add('visible', 'loading'); why.textContent = 'thinking…'; }
+  else                  { why.classList.remove('visible'); why.textContent = ''; }
+  try {
+    const r = await fetch('/api/review/' + id, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
+    });
+    const j = await r.json();
+    REVIEWS[id] = j;
+    if (j.why) { why.textContent = j.why; why.classList.add('visible'); why.classList.remove('loading'); }
+    else why.classList.remove('visible');
+  } catch (e) { why.textContent = '(error: ' + e.message + ')'; why.classList.remove('loading'); }
+}
+
+// ── chat modal ────────────────────────────────────────────────────
+const modal = $('#chat-modal');
+const chatHistory = $('#chat-history');
+const chatInput = $('#chat-input');
+const chatSend = $('#chat-send');
+const chatForm = $('#chat-form');
+$('#chat-close').addEventListener('click', () => modal.classList.remove('open'));
+document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.classList.contains('open')) modal.classList.remove('open'); });
+
+async function openChat(designId, chipKey, chipLabel) {
+  CURRENT_CHIP = { designId, chipKey, chipLabel };
+  const design = DESIGNS.find(d => String(d.id) === String(designId));
+  $('#chat-chip-label').textContent = 'chip: ' + chipLabel;
+  $('#chat-design-label').textContent = '#' + designId + ' · ' + (design ? (design.prompt||'').slice(0,60) : '');
+  chatHistory.innerHTML = '<div class="msg assistant loading">loading history…</div>';
+  modal.classList.add('open');
+  setTimeout(() => chatInput.focus(), 100);
+  try {
+    const r = await fetch('/api/chip/' + designId + '/' + encodeURIComponent(chipKey) + '/chat');
+    const j = await r.json();
+    renderChatHistory(j.history || []);
+  } catch (e) { chatHistory.innerHTML = '<div class="msg assistant">load error: ' + e.message + '</div>'; }
+}
+function renderChatHistory(msgs) {
+  chatHistory.innerHTML = msgs.length
+    ? msgs.map(m => '<div class="msg ' + m.role + '">' + escapeHTML(m.content) + '</div>').join('')
+    : '<div class="msg assistant" style="opacity:.5">Ask the panel about this chip.</div>';
+  chatHistory.scrollTop = chatHistory.scrollHeight;
+}
+function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[c])); }
+
+chatForm.addEventListener('submit', async e => {
+  e.preventDefault();
+  if (!CURRENT_CHIP) return;
+  const message = chatInput.value.trim();
+  if (!message) return;
+  chatInput.value = '';
+  chatSend.disabled = true;
+  const userDiv = document.createElement('div'); userDiv.className = 'msg user'; userDiv.textContent = message;
+  const loadingDiv = document.createElement('div'); loadingDiv.className = 'msg assistant loading'; loadingDiv.textContent = 'thinking…';
+  if (chatHistory.firstElementChild && chatHistory.firstElementChild.style && chatHistory.firstElementChild.style.opacity) chatHistory.innerHTML = '';
+  chatHistory.appendChild(userDiv); chatHistory.appendChild(loadingDiv);
+  chatHistory.scrollTop = chatHistory.scrollHeight;
+  try {
+    const r = await fetch('/api/chip/' + CURRENT_CHIP.designId + '/' + encodeURIComponent(CURRENT_CHIP.chipKey) + '/chat', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message })
+    });
+    const j = await r.json();
+    loadingDiv.classList.remove('loading'); loadingDiv.textContent = j.reply || '(no reply)';
+    // mark chip as having chat history
+    document.querySelectorAll('.chip[data-design="' + CURRENT_CHIP.designId + '"][data-chip="' + CURRENT_CHIP.chipKey + '"]').forEach(c => c.classList.add('has-chat'));
+  } catch (e) {
+    loadingDiv.classList.remove('loading'); loadingDiv.textContent = 'error: ' + e.message;
+  }
+  chatSend.disabled = false;
+  chatInput.focus();
+});
+
+// ── filter pills ─────────────────────────────────────────────────
+$$('.filter-pill').forEach(p => p.addEventListener('click', () => {
+  $$('.filter-pill').forEach(x => x.classList.remove('on'));
+  p.classList.add('on');
+  FILTER = p.dataset.filter;
+  renderAll(); loadChipsForVisibleCards();
+}));
+$('.filter-pill[data-filter="all"]').classList.add('on');
+
+sortSel.addEventListener('change', () => { localStorage.setItem('wc.review.sort', sortSel.value); renderAll(); });
+dens.addEventListener('input', () => {
+  document.documentElement.style.setProperty('--cols', dens.value);
+  localStorage.setItem('wc.review.density', dens.value);
+});
+
+// ── boot ─────────────────────────────────────────────────────────
+(async () => {
+  await fetchInitialReviews();
+  renderAll();
+  await loadChipsForVisibleCards();
+})();
+</script>`);
+});
+
 app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));
diff --git a/server.js b/server.js
index b96f6ba..0adb65f 100644
--- a/server.js
+++ b/server.js
@@ -56,6 +56,9 @@ app.use(express.urlencoded({ extended: false }));
 // ── Chat layer: catalog + per-design variation
 try { require('./src/chat').mount(app); console.log('  Chat layer mounted'); } catch (e) { console.error('Chat mount failed:', e.message); }
 
+// ── Review layer: sliders + chips + chip-chat on /designs (admin-gated by hostname/query)
+try { require('./src/review').mount(app, () => DESIGNS); } catch (e) { console.error('Review mount failed:', e.message); }
+
 // ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
 app.use((req, res, next) => {
   if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
@@ -582,6 +585,8 @@ app.get('/designs', (req, res) => {
   const q    = (req.query.q   || '').toLowerCase().trim();
   const page = Math.max(1, parseInt(req.query.page || '1', 10));
   const PER  = 60;
+  // Admin/review-mode flag: only enabled at 127.0.0.1 / localhost / ?review=1 — public visits unchanged.
+  const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
 
   let filtered = [...DESIGNS];
   if (cat)  filtered = filtered.filter(d => d.category === cat);
@@ -597,7 +602,9 @@ app.get('/designs', (req, res) => {
   const slice = filtered.slice((page-1)*PER, page*PER);
   const cats  = [...new Set(DESIGNS.map(d => d.category))].sort();
 
-  const cards = slice.map(d => `
+  const cards = slice.map(d => {
+    if (!isAdmin) {
+      return `
     <a href="/design/${d.id}" class="design-card">
       <div class="card-img" style="background-image:url('${d.image_url}')"></div>
       <div class="card-meta">
@@ -605,7 +612,34 @@ app.get('/designs', (req, res) => {
         <span class="card-cat">${d.category}</span>
         <span class="color-dot" style="background:${d.dominant_hex}"></span>
       </div>
-    </a>`).join('');
+    </a>`;
+    }
+    // Admin review-mode card: sliders, K/R, chips, why-chip
+    return `
+    <div class="design-card review-card" data-id="${d.id}">
+      <a href="/design/${d.id}" target="_blank" class="card-img" style="background-image:url('${d.image_url}')">
+        <span class="decision-badge"></span>
+      </a>
+      <div class="card-meta">
+        <span class="card-title">${d.title}</span>
+        <span class="card-cat">${d.category}</span>
+        <span class="color-dot" style="background:${d.dominant_hex}"></span>
+      </div>
+      <div class="review-controls">
+        <div class="sliders">
+          <label>Design <input type="range" min="0" max="10" value="5" data-axis="design"><span>5</span></label>
+          <label>Color  <input type="range" min="0" max="10" value="5" data-axis="color"><span>5</span></label>
+          <label>Style  <input type="range" min="0" max="10" value="5" data-axis="style"><span>5</span></label>
+        </div>
+        <div class="decision">
+          <button class="btn-keep" type="button">Keep</button>
+          <button class="btn-reject" type="button">Reject</button>
+        </div>
+        <div class="why-chip" title="Click to chat about this verdict"></div>
+        <div class="chips"></div>
+      </div>
+    </div>`;
+  }).join('');
 
   const pagerLinks = pages > 1
     ? Array.from({length: pages}, (_,i) => i+1).map(p =>
@@ -696,6 +730,250 @@ ${FOOTER}
   }
 })();
 </script>
+
+${isAdmin ? `
+<style>
+  /* Review-mode controls (admin only — 127.0.0.1 / ?review=1) */
+  :root { --keep:#5fbf6e; --reject:#d2554a; --chip-bg:#3a3631; --chip-hot:#d2b15c; }
+  .design-card.review-card { display:block; background:#1a1816; border:1px solid #2a2825; border-radius:3px; overflow:hidden; transition: border-color .15s; padding:0; }
+  .design-card.review-card[data-decision=keep]   { border-color: var(--keep); }
+  .design-card.review-card[data-decision=reject] { border-color: var(--reject); opacity:.55; }
+  .design-card.review-card .card-img { position:relative; display:block; aspect-ratio:1; background:#0d0c0a no-repeat center/cover; }
+  .design-card.review-card .card-img .decision-badge { position:absolute; top:8px; right:8px; padding:2px 8px; font-size:10px; border-radius:2px; text-transform:uppercase; letter-spacing:.06em; display:none; pointer-events:none; }
+  .design-card.review-card[data-decision=keep]   .decision-badge { display:inline-block; background:var(--keep); color:#0f0e0c; }
+  .design-card.review-card[data-decision=keep]   .decision-badge::after { content:'KEEP'; }
+  .design-card.review-card[data-decision=reject] .decision-badge { display:inline-block; background:var(--reject); color:#fff; }
+  .design-card.review-card[data-decision=reject] .decision-badge::after { content:'REJECT'; }
+  .design-card.review-card .card-meta { padding:10px 12px 6px; display:flex; flex-direction:column; gap:2px; }
+  .design-card.review-card .card-title { font-size:12px; line-height:1.3; color:#e8e2d6; max-height:2.6em; overflow:hidden; }
+  .design-card.review-card .card-cat   { font-size:10px; color:#888; }
+  .design-card.review-card .color-dot  { width:9px; height:9px; border-radius:50%; border:1px solid #00000060; display:inline-block; margin-right:5px; }
+  .review-controls { padding:0 12px 14px; }
+  .sliders { display:flex; flex-direction:column; gap:4px; margin: 8px 0 10px; }
+  .sliders label { display:grid; grid-template-columns:48px 1fr 22px; gap:6px; align-items:center; font-size:10px; color:#bba; text-transform:uppercase; letter-spacing:.06em; }
+  .sliders label span { text-align:right; color:#d2b15c; font-variant-numeric:tabular-nums; }
+  .sliders input[type=range] { width:100%; -webkit-appearance:none; height:2px; background:#3a3631; border-radius:2px; outline:none; }
+  .sliders input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:11px; height:11px; border-radius:50%; background:#d2b15c; cursor:pointer; border:none; }
+  .decision { display:flex; gap:6px; margin-bottom:9px; }
+  .decision button { flex:1; padding:5px 8px; font-size:11px; border:1px solid #3a3631; background:#16140f; color:#bba; cursor:pointer; border-radius:2px; transition:all .12s; text-transform:uppercase; letter-spacing:.06em; }
+  .decision button:hover { background:#1f1c18; color:#fff; }
+  .design-card.review-card[data-decision=keep]   .btn-keep   { background:var(--keep);   color:#0f0e0c; border-color:var(--keep); }
+  .design-card.review-card[data-decision=reject] .btn-reject { background:var(--reject); color:#fff;    border-color:var(--reject); }
+  .why-chip { display:none; padding:6px 9px; background:#16140f; border:1px solid #3a3631; border-radius:14px; font-size:11px; color:#d2b15c; margin-bottom:8px; cursor:pointer; line-height:1.35; }
+  .why-chip.visible { display:block; }
+  .why-chip.loading::after { content:' …'; opacity:.6; }
+  .chips { display:flex; flex-wrap:wrap; gap:5px; }
+  .chip { padding:3px 8px; background:var(--chip-bg); color:#bba; border-radius:10px; font-size:10px; cursor:pointer; border:1px solid transparent; transition:all .12s; }
+  .chip:hover { background:#4a443d; color:#fff; }
+  .chip.has-chat { border-color: var(--chip-hot); color: var(--chip-hot); }
+  /* chat side panel */
+  #chat-modal { position:fixed; top:0; right:0; bottom:0; width:420px; background:#16140f; border-left:1px solid #2a2825; display:none; flex-direction:column; z-index:1000; box-shadow:-8px 0 20px rgba(0,0,0,.4); color:#e8e2d6; font:13px/1.4 -apple-system, system-ui, sans-serif; }
+  #chat-modal.open { display:flex; }
+  #chat-modal header { padding:14px 18px; border-bottom:1px solid #2a2825; display:flex; gap:10px; align-items:center; }
+  #chat-modal header .ctx-main { flex:1; font-size:12px; color:#d2b15c; }
+  #chat-modal header .ctx-sub  { font-size:10px; color:#888; }
+  #chat-modal header .close { background:none; border:none; color:#999; font-size:22px; cursor:pointer; padding:0 4px; }
+  #chat-history { flex:1; overflow-y:auto; padding:16px 18px; display:flex; flex-direction:column; gap:10px; }
+  #chat-history .msg { padding:9px 12px; border-radius:10px; max-width:88%; font-size:12px; line-height:1.45; white-space:pre-wrap; word-wrap:break-word; }
+  #chat-history .msg.user { background:#2a2825; color:#fff; align-self:flex-end; }
+  #chat-history .msg.assistant { background:#1f1c18; color:#e8e2d6; align-self:flex-start; border:1px solid #2a2825; }
+  #chat-history .msg.assistant.loading { color:#888; font-style:italic; }
+  #chat-form { display:flex; padding:12px 14px; border-top:1px solid #2a2825; gap:8px; }
+  #chat-form input { flex:1; padding:8px 12px; background:#0f0e0c; border:1px solid #2a2825; color:#fff; font:inherit; border-radius:2px; outline:none; }
+  #chat-form input:focus { border-color:#d2b15c; }
+  #chat-form button { padding:8px 14px; background:#d2b15c; color:#0f0e0c; border:none; cursor:pointer; font:inherit; font-weight:500; border-radius:2px; }
+  #chat-form button:disabled { opacity:.5; cursor:not-allowed; }
+  .admin-banner { background:#2a2825; color:#d2b15c; padding:6px 14px; font-size:11px; letter-spacing:.06em; text-transform:uppercase; }
+</style>
+
+<div id="chat-modal">
+  <header>
+    <div style="flex:1">
+      <div class="ctx-main" id="chat-chip-label">chip</div>
+      <div class="ctx-sub" id="chat-design-label">design</div>
+    </div>
+    <button class="close" id="chat-close" title="Close (Esc)">&times;</button>
+  </header>
+  <div id="chat-history"></div>
+  <form id="chat-form">
+    <input type="text" id="chat-input" placeholder="Ask the panel about this chip…" autocomplete="off">
+    <button type="submit" id="chat-send">Send</button>
+  </form>
+</div>
+
+<script>
+(function(){
+  // ── State ──────────────────────────────────────────────────────
+  const REVIEWS = {};            // designId -> review object
+  const CHIPS = {};              // designId -> [{key,label,kind}]
+  let CURRENT_CHIP = null;
+
+  const $ = (s, el=document) => el.querySelector(s);
+  const $$ = (s, el=document) => Array.from(el.querySelectorAll(s));
+
+  // ── Boot: fetch reviews + chips, then wire UI ──────────────────
+  async function boot() {
+    try {
+      const r = await fetch('/api/reviews/all');
+      Object.assign(REVIEWS, await r.json());
+    } catch {}
+    $$('.design-card.review-card').forEach(card => {
+      applyReviewToCard(card);
+      wireCard(card);
+    });
+    loadChipsForVisibleCards();
+  }
+
+  function applyReviewToCard(card) {
+    const id = card.dataset.id;
+    const r = REVIEWS[id];
+    if (!r) return;
+    card.dataset.decision = r.decision || '';
+    const inputs = card.querySelectorAll('.sliders input[type=range]');
+    if (r.design != null) { inputs[0].value = r.design; inputs[0].nextElementSibling.textContent = r.design; }
+    if (r.color  != null) { inputs[1].value = r.color;  inputs[1].nextElementSibling.textContent = r.color;  }
+    if (r.style  != null) { inputs[2].value = r.style;  inputs[2].nextElementSibling.textContent = r.style;  }
+    if (r.why) {
+      const why = card.querySelector('.why-chip');
+      why.textContent = r.why;
+      why.classList.add('visible');
+    }
+  }
+
+  function wireCard(card) {
+    const id = card.dataset.id;
+    // sliders
+    card.querySelectorAll('.sliders input[type=range]').forEach(slider => {
+      slider.addEventListener('input', () => { slider.nextElementSibling.textContent = slider.value; });
+      slider.addEventListener('change', () => saveReview(card));
+    });
+    // K/R
+    card.querySelector('.btn-keep').addEventListener('click', e => {
+      e.preventDefault(); e.stopPropagation();
+      card.dataset.decision = card.dataset.decision === 'keep' ? '' : 'keep';
+      saveReview(card);
+    });
+    card.querySelector('.btn-reject').addEventListener('click', e => {
+      e.preventDefault(); e.stopPropagation();
+      card.dataset.decision = card.dataset.decision === 'reject' ? '' : 'reject';
+      saveReview(card);
+    });
+    // why-chip → chat
+    card.querySelector('.why-chip').addEventListener('click', () => {
+      const why = card.querySelector('.why-chip');
+      if (!why.classList.contains('visible')) return;
+      openChat(id, '_verdict', '"' + why.textContent + '"');
+    });
+  }
+
+  async function saveReview(card) {
+    const id = card.dataset.id;
+    const inputs = card.querySelectorAll('.sliders input[type=range]');
+    const payload = {
+      design: parseInt(inputs[0].value, 10),
+      color:  parseInt(inputs[1].value, 10),
+      style:  parseInt(inputs[2].value, 10),
+      decision: card.dataset.decision || null,
+    };
+    const why = card.querySelector('.why-chip');
+    if (payload.decision) { why.classList.add('visible', 'loading'); why.textContent = 'thinking'; }
+    else                  { why.classList.remove('visible', 'loading'); why.textContent = ''; }
+    try {
+      const r = await fetch('/api/review/' + id, {
+        method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
+      });
+      const j = await r.json();
+      REVIEWS[id] = j;
+      why.classList.remove('loading');
+      if (j.why) { why.textContent = j.why; why.classList.add('visible'); }
+    } catch (e) {
+      why.textContent = '(error: ' + e.message + ')';
+      why.classList.remove('loading');
+    }
+  }
+
+  async function loadChipsForVisibleCards() {
+    const cards = $$('.design-card.review-card');
+    const ids = cards.map(c => c.dataset.id).filter(id => !CHIPS[id]);
+    await Promise.all(ids.map(async id => {
+      try { const r = await fetch('/api/chips/' + id); const j = await r.json(); CHIPS[id] = j.chips || []; }
+      catch { CHIPS[id] = []; }
+    }));
+    cards.forEach(card => {
+      const id = card.dataset.id;
+      const chipsHTML = (CHIPS[id]||[]).map(c => '<span class="chip" data-chip="' + c.key + '">' + c.label + '</span>').join('');
+      card.querySelector('.chips').innerHTML = chipsHTML;
+      card.querySelectorAll('.chip').forEach(chip => {
+        chip.addEventListener('click', () => openChat(id, chip.dataset.chip, chip.textContent));
+      });
+    });
+  }
+
+  // ── Chat panel ─────────────────────────────────────────────────
+  const modal = $('#chat-modal');
+  const history = $('#chat-history');
+  const input = $('#chat-input');
+  const send  = $('#chat-send');
+  $('#chat-close').addEventListener('click', () => modal.classList.remove('open'));
+  document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.classList.contains('open')) modal.classList.remove('open'); });
+
+  async function openChat(designId, chipKey, chipLabel) {
+    CURRENT_CHIP = { designId, chipKey, chipLabel };
+    const card = document.querySelector('.design-card.review-card[data-id="' + designId + '"]');
+    const title = card ? (card.querySelector('.card-title').textContent || '#'+designId) : '#'+designId;
+    $('#chat-chip-label').textContent = chipLabel;
+    $('#chat-design-label').textContent = '#' + designId + ' · ' + title.slice(0,60);
+    history.innerHTML = '<div class="msg assistant loading">loading…</div>';
+    modal.classList.add('open');
+    setTimeout(() => input.focus(), 100);
+    try {
+      const r = await fetch('/api/chip/' + designId + '/' + encodeURIComponent(chipKey) + '/chat');
+      const j = await r.json();
+      renderHistory(j.history || []);
+    } catch (e) {
+      history.innerHTML = '<div class="msg assistant">load error: ' + e.message + '</div>';
+    }
+  }
+  function renderHistory(msgs) {
+    history.innerHTML = msgs.length
+      ? msgs.map(m => '<div class="msg ' + m.role + '">' + escapeHTML(m.content) + '</div>').join('')
+      : '<div class="msg assistant" style="opacity:.5">Ask the panel about this chip.</div>';
+    history.scrollTop = history.scrollHeight;
+  }
+  function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[c])); }
+
+  $('#chat-form').addEventListener('submit', async e => {
+    e.preventDefault();
+    if (!CURRENT_CHIP) return;
+    const message = input.value.trim();
+    if (!message) return;
+    input.value = '';
+    send.disabled = true;
+    const userMsg = document.createElement('div'); userMsg.className = 'msg user'; userMsg.textContent = message;
+    const loading = document.createElement('div'); loading.className = 'msg assistant loading'; loading.textContent = 'thinking…';
+    const first = history.firstElementChild;
+    if (first && first.style && first.style.opacity) history.innerHTML = '';
+    history.appendChild(userMsg); history.appendChild(loading);
+    history.scrollTop = history.scrollHeight;
+    try {
+      const r = await fetch('/api/chip/' + CURRENT_CHIP.designId + '/' + encodeURIComponent(CURRENT_CHIP.chipKey) + '/chat', {
+        method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ message })
+      });
+      const j = await r.json();
+      loading.classList.remove('loading');
+      loading.textContent = j.reply || '(no reply)';
+      document.querySelectorAll('.design-card.review-card[data-id="' + CURRENT_CHIP.designId + '"] .chip[data-chip="' + CURRENT_CHIP.chipKey + '"]').forEach(c => c.classList.add('has-chat'));
+    } catch (e) {
+      loading.classList.remove('loading');
+      loading.textContent = 'error: ' + e.message;
+    }
+    send.disabled = false; input.focus();
+  });
+
+  boot();
+})();
+</script>
+` : ''}
 ${HAMBURGER_JS}
 </body>
 </html>`);
diff --git a/src/review.js b/src/review.js
new file mode 100644
index 0000000..1fb6852
--- /dev/null
+++ b/src/review.js
@@ -0,0 +1,212 @@
+/**
+ * wallco.ai review layer — sliders (design/color/style), keep/reject decision,
+ * auto-generated descriptor chips, per-chip chat with local Ollama.
+ *
+ * Wired into /designs grid. Persistence is JSON file (no native deps).
+ *
+ * Routes:
+ *   GET  /api/review/:id                              — current review state
+ *   POST /api/review/:id                              — { design, color, style, decision, why }
+ *   GET  /api/chips/:id                               — generated chips for design
+ *   GET  /api/chip/:id/:chipKey/chat                  — chat history for a chip
+ *   POST /api/chip/:id/:chipKey/chat                  — { message } → { reply }
+ *   GET  /api/reviews/all                             — full review log (for admin)
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const REVIEWS_FILE = path.join(__dirname, '..', 'data', 'reviews.json');
+const CHATS_FILE   = path.join(__dirname, '..', 'data', 'chip-chats.json');
+const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+const OLLAMA_MODEL = process.env.OLLAMA_REVIEW_MODEL || 'qwen3:14b';
+
+// ── persistence helpers ──────────────────────────────────────────────
+function loadJSON(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
+}
+function saveJSON(file, data) {
+  fs.writeFileSync(file + '.tmp', JSON.stringify(data, null, 2));
+  fs.renameSync(file + '.tmp', file);
+}
+
+// ── color naming from hex (no external dep) ──────────────────────────
+function hexToHsl(hex) {
+  const m = hex.replace('#','').match(/.{2}/g) || [];
+  const [r,g,b] = m.map(x => parseInt(x,16)/255);
+  const max = Math.max(r,g,b), min = Math.min(r,g,b);
+  let h=0, s=0, l=(max+min)/2;
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d/(2-max-min) : d/(max+min);
+    switch(max){
+      case r: h=(g-b)/d+(g<b?6:0); break;
+      case g: h=(b-r)/d+2; break;
+      case b: h=(r-g)/d+4; break;
+    }
+    h *= 60;
+  }
+  return { h, s, l };
+}
+function colorChipLabel(hex) {
+  if (!hex) return 'neutral';
+  const { h, s, l } = hexToHsl(hex);
+  if (s < 0.12) {
+    if (l < 0.2)  return 'near-black';
+    if (l < 0.4)  return 'charcoal';
+    if (l < 0.6)  return 'mid-grey';
+    if (l < 0.8)  return 'light-grey';
+    return 'off-white';
+  }
+  const buckets = [
+    { max:15,  name:'red' }, { max:40, name:'orange' }, { max:65, name:'yellow' },
+    { max:160, name:'green' }, { max:200, name:'teal' }, { max:250, name:'blue' },
+    { max:290, name:'purple' }, { max:330, name:'magenta' }, { max:360, name:'red' }
+  ];
+  let hue = 'red';
+  for (const b of buckets) { if (h <= b.max) { hue = b.name; break; } }
+  const tone = l < 0.35 ? 'deep ' : (l > 0.7 ? 'pale ' : '');
+  return tone + hue;
+}
+function saturationChipLabel(sat) {
+  if (sat == null) return null;
+  if (sat < 0.15) return 'desaturated';
+  if (sat < 0.35) return 'muted';
+  if (sat < 0.6)  return 'mid-chroma';
+  return 'high-chroma';
+}
+
+// ── chip generation (deterministic from design metadata) ─────────────
+function generateChips(design) {
+  const chips = [];
+  if (design.category) chips.push({ key: 'cat-' + design.category, label: design.category, kind: 'category' });
+  if (design.dominant_hex) chips.push({ key: 'color-' + design.dominant_hex.replace('#',''), label: colorChipLabel(design.dominant_hex), kind: 'color', hex: design.dominant_hex });
+  const sat = saturationChipLabel(design.saturation);
+  if (sat) chips.push({ key: 'sat-' + sat, label: sat, kind: 'saturation' });
+  if (design.kind && design.kind !== 'seamless_tile') chips.push({ key: 'kind-' + design.kind, label: design.kind.replace(/_/g, ' '), kind: 'kind' });
+  // prompt-derived motif chip (if prompt available — first noun-ish word)
+  if (design.prompt) {
+    const motif = (design.prompt.match(/\b(damask|toile|chinoiserie|floral|geometric|stripe|botanical|abstract|paisley|mural|trellis|fauna|fern|peacock|crane|cherry|jungle|deco|moroccan|grasscloth|moiré|moire)\b/i) || [])[0];
+    if (motif) chips.push({ key: 'motif-' + motif.toLowerCase(), label: motif.toLowerCase(), kind: 'motif' });
+  }
+  return chips;
+}
+
+// ── Ollama bridge ────────────────────────────────────────────────────
+async function ollamaCall({ system, history, message }) {
+  const msgs = [{ role: 'system', content: system }, ...history, { role: 'user', content: message }];
+  const res = await fetch(`${OLLAMA}/api/chat`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ model: OLLAMA_MODEL, messages: msgs, stream: false, options: { temperature: 0.5 } }),
+    signal: AbortSignal.timeout(60_000)
+  });
+  if (!res.ok) throw new Error(`ollama ${res.status}: ${(await res.text()).slice(0, 200)}`);
+  const j = await res.json();
+  return (j.message && j.message.content) || '';
+}
+
+function chipSystemPrompt(design, chip) {
+  return `You are a senior wallcovering creative director reviewing AI-generated designs for Steve Abrams at wallco.ai. The current design is "${design.title}" (category: ${design.category || 'n/a'}, kind: ${design.kind || 'n/a'}, dominant hex: ${design.dominant_hex || 'n/a'}). The user is focusing on the descriptor chip "${chip.label}" (${chip.kind}). Answer concisely (2-4 sentences max), specific to this chip's relationship to the design. If the user proposes a change, suggest concrete pattern/color/scale adjustments. Never recommend non-actionable advice.`;
+}
+
+function whySystemPrompt(design, decision, scores) {
+  return `You are a senior wallcovering creative director. Steve just rated this AI-generated design (title: "${design.title}", category: ${design.category}, dominant hex: ${design.dominant_hex}, kind: ${design.kind}) with: design ${scores.design}/10, color ${scores.color}/10, style ${scores.style}/10, decision: ${decision}. In exactly one short sentence (max 20 words), state the dominant reason for that verdict. No preamble.`;
+}
+
+// ── mount ─────────────────────────────────────────────────────────────
+function mount(app, getDesigns) {
+  // ensure files exist
+  if (!fs.existsSync(REVIEWS_FILE)) saveJSON(REVIEWS_FILE, {});
+  if (!fs.existsSync(CHATS_FILE)) saveJSON(CHATS_FILE, {});
+
+  app.get('/api/review/:id', (req, res) => {
+    const reviews = loadJSON(REVIEWS_FILE, {});
+    res.json(reviews[req.params.id] || null);
+  });
+
+  app.post('/api/review/:id', async (req, res) => {
+    const id = req.params.id;
+    const designs = getDesigns();
+    const design = designs.find(d => String(d.id) === String(id));
+    if (!design) return res.status(404).json({ error: 'design not found' });
+    const reviews = loadJSON(REVIEWS_FILE, {});
+    const body = req.body || {};
+    const scores = {
+      design: clamp(body.design, 0, 10),
+      color:  clamp(body.color, 0, 10),
+      style:  clamp(body.style, 0, 10),
+    };
+    const decision = body.decision === 'keep' ? 'keep' : (body.decision === 'reject' ? 'reject' : null);
+    let why = (body.why || '').trim();
+
+    // auto-generate why if decision set and no manual override
+    if (decision && !why) {
+      try { why = (await ollamaCall({ system: whySystemPrompt(design, decision, scores), history: [], message: 'Give the one-sentence verdict reason.' })).trim().replace(/^["']|["']$/g, '').split('\n')[0].slice(0, 140); }
+      catch (e) { why = `(auto-rationale unavailable: ${e.message.slice(0, 40)})`; }
+    }
+
+    reviews[id] = { id, ...scores, decision, why, updated_at: new Date().toISOString() };
+    saveJSON(REVIEWS_FILE, reviews);
+    res.json(reviews[id]);
+  });
+
+  app.get('/api/chips/:id', (req, res) => {
+    const designs = getDesigns();
+    const design = designs.find(d => String(d.id) === String(req.params.id));
+    if (!design) return res.status(404).json({ error: 'design not found' });
+    res.json({ design_id: design.id, chips: generateChips(design) });
+  });
+
+  app.get('/api/chip/:id/:chipKey/chat', (req, res) => {
+    const chats = loadJSON(CHATS_FILE, {});
+    const key = `${req.params.id}:${req.params.chipKey}`;
+    res.json({ key, history: chats[key] || [] });
+  });
+
+  app.post('/api/chip/:id/:chipKey/chat', async (req, res) => {
+    const id = req.params.id, chipKey = req.params.chipKey;
+    const designs = getDesigns();
+    const design = designs.find(d => String(d.id) === String(id));
+    if (!design) return res.status(404).json({ error: 'design not found' });
+    const chip = generateChips(design).find(c => c.key === chipKey);
+    if (!chip) return res.status(404).json({ error: 'chip not found' });
+    const message = (req.body && req.body.message || '').trim();
+    if (!message) return res.status(400).json({ error: 'message required' });
+
+    const chats = loadJSON(CHATS_FILE, {});
+    const key = `${id}:${chipKey}`;
+    const history = chats[key] || [];
+
+    let reply;
+    try {
+      reply = (await ollamaCall({
+        system: chipSystemPrompt(design, chip),
+        history: history.map(m => ({ role: m.role, content: m.content })),
+        message,
+      })).trim();
+    } catch (e) {
+      reply = `(Ollama unreachable: ${e.message.slice(0, 80)}. Set OLLAMA_URL or run \`ollama serve\` on Mac1 (192.168.1.133:11434) or localhost.)`;
+    }
+
+    history.push({ role: 'user',      content: message, ts: new Date().toISOString() });
+    history.push({ role: 'assistant', content: reply,    ts: new Date().toISOString() });
+    chats[key] = history;
+    saveJSON(CHATS_FILE, chats);
+    res.json({ reply, history });
+  });
+
+  app.get('/api/reviews/all', (req, res) => {
+    res.json(loadJSON(REVIEWS_FILE, {}));
+  });
+
+  console.log('  Review layer mounted (sliders + chips + chip-chat on Ollama ' + OLLAMA_MODEL + ')');
+}
+
+function clamp(n, min, max) {
+  const x = Number(n);
+  if (!Number.isFinite(x)) return null;
+  return Math.max(min, Math.min(max, x));
+}
+
+module.exports = { mount, generateChips };

← 5f79c25 wallco.ai chat layer: POST /api/chat/catalog + /api/chat/des  ·  back to Wallco Ai  ·  chat panel UI polish: extract to /css/chat.css using site de cd26879 →