← 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
M data/chip-chats.jsonM data/moodboards.jsonM data/pairings.jsonM data/reviews.jsonM data/vision-cache.jsonA scripts/await_cf_zone.shM server.jsA tests/.gitignoreA tests/README.mdA tests/package-lock.jsonA tests/package.jsonA tests/playwright.config.jsA tests/review.spec.js
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,'"')+')"><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,'"')+')"><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,'"')+')" 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,'"')+')" onclick="addTextureToBoard('+JSON.stringify(t).replace(/"/g,'"')+')" 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 →