[object Object]

← back to Designer Wallcoverings

Carl Robinson E20 → Phillipe Romano onboard pipeline (scrape→enrich→rooms→drafts, DWCR-, beach-city naming)

60fb58506d43953551cb569ba6153d4ca3dea788 · 2026-07-06 10:44:30 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 60fb58506d43953551cb569ba6153d4ca3dea788
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Mon Jul 6 10:44:30 2026 -0700

    Carl Robinson E20 → Phillipe Romano onboard pipeline (scrape→enrich→rooms→drafts, DWCR-, beach-city naming)
---
 .../build-pr-drafts-carl-robinson.cjs              | 101 +++++++++++++++
 .../build-viewer-carl-robinson.cjs                 | 100 +++++++++++++++
 scripts/wallquest-refresh/enrich-carl-robinson.cjs |  73 +++++++++++
 .../wallquest-refresh/load-db-carl-robinson.cjs    |  99 +++++++++++++++
 .../wallquest-refresh/normalize-carl-robinson.cjs  |  53 ++++++++
 .../wallquest-refresh/onboard-carl-robinson.cjs    | 141 +++++++++++++++++++++
 .../room-composite-carl-robinson.cjs               |  83 ++++++++++++
 7 files changed, 650 insertions(+)

diff --git a/scripts/wallquest-refresh/build-pr-drafts-carl-robinson.cjs b/scripts/wallquest-refresh/build-pr-drafts-carl-robinson.cjs
new file mode 100644
index 00000000..1811fdfd
--- /dev/null
+++ b/scripts/wallquest-refresh/build-pr-drafts-carl-robinson.cjs
@@ -0,0 +1,101 @@
+// Phase 8 (DRAFT ONLY) — transform enriched Carl Robinson naturals → Phillipe Romano
+// private-label Shopify draft payloads with beach-city naming. NOTHING is pushed here;
+// writes draft JSON + a human preview for Steve's approval gate.
+const fs = require('fs');
+const IN = process.env.IN || '/tmp/wq-carl-robinson-enriched.json';
+const OUT = '/tmp/carl-robinson-PR-drafts.json';
+const PREVIEW = '/tmp/carl-robinson-PR-preview.txt';
+
+// One East-Coast beach town per material family → pattern prefix + collection bucket.
+// Adjustable: edit any value; re-run is idempotent (keyed off mfr_sku).
+const CITY = {
+  Grasscloth: 'Nantucket', Paperweave: 'Montauk', Raffia: 'Newport',
+  Netting: 'Southampton', Bamboo: 'Cape May', Sisal: 'Sag Harbor',
+  Cork: 'Kennebunkport', Fiber: 'Rockport', Jute: 'Chatham',
+  Mica: 'Bar Harbor', Nonwoven: 'Provincetown', Silk: 'Watch Hill',
+  'Wood Veneer': 'Edgartown',
+};
+const materialOf = (pattern) => {
+  // pattern e.g. "White Naturals Grasscloth" → "Grasscloth"
+  const m = pattern.replace(/^white naturals\s*/i, '').trim();
+  return m || 'Naturals';
+};
+// DW hard rules: banned word "Wallpaper" → "Wallcovering"; Title Case.
+const cleanWallpaper = (s) => !s ? s : s.replace(/\bWalls Wallpaper\b/gi, 'Wallcoverings').replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering');
+const SMALL = new Set(['a','an','the','and','but','or','for','in','on','at','of','to','with']);
+const toTitleCase = (s) => !s ? s : s.split(/\s+/).map((w, i) => (i > 0 && SMALL.has(w.toLowerCase())) ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+const BAD_NAME = /^\s*$|unknown|page not found|404|not found|undefined|null/i;
+
+const recs = JSON.parse(fs.readFileSync(IN, 'utf8'));
+const drafts = [];
+for (const r of recs) {
+  const material = materialOf(r.pattern);
+  const city = CITY[material] || 'Hamptons';
+  let patternName = toTitleCase(`${city} ${material}`); // "Nantucket Grasscloth"
+  if (BAD_NAME.test(patternName)) patternName = r.mfr_sku; // NEVER "Unknown" (DW rule)
+  const color = toTitleCase(r.color || r.ai_dominant_color || '');
+  // DW title format: "Pattern Color | Brand" — Title Case, NO banned word "Wallpaper"
+  const title = cleanWallpaper(`${patternName}${color ? ' ' + color : ''} | Phillipe Romano`);
+  const tags = [
+    city, material, 'Natural Texture', 'Grasscloth & Naturals', 'Phillipe Romano',
+    ...(r.ai_styles || []), ...(r.ai_colors || []), color,
+  ].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i);
+
+  drafts.push({
+    dw_sku: r.dw_sku,
+    title,
+    vendor: 'Phillipe Romano',                 // private label — WallQuest/Carl Robinson hidden
+    product_type: 'Wallcovering',
+    status: 'active',
+    body_html: cleanWallpaper(r.ai_description || ''),   // description only, no spec tables, no banned word
+    tags: tags.map(cleanWallpaper),
+    collection: city,                           // beach-city collection bucket
+    price: '0.00',                              // product price 0; real price on variants
+    options: [{ name: 'Size' }],
+    variants: [
+      // DW rule: every product has a Sample variant "{DW_SKU}-Sample" @ $4.25
+      { option1: 'Sample', sku: `${r.dw_sku}-Sample`, price: '4.25', inventory_management: null, inventory_policy: 'deny' },
+      { option1: 'Standard', sku: r.dw_sku, price: String(r.our_price ?? ''), inventory_quantity: 2026, inventory_policy: 'continue' },
+    ],
+    images: r.images || [],
+    metafields: [
+      // MFR SKU in BOTH custom + dwc (DW rule); WallQuest hidden here only
+      { namespace: 'custom', key: 'manufacturer_sku', value: r.mfr_sku, type: 'single_line_text_field' },
+      { namespace: 'dwc', key: 'manufacturer_sku', value: r.mfr_sku, type: 'single_line_text_field' },
+      { namespace: 'private_label', key: 'real_vendor_name', value: 'WallQuest', type: 'single_line_text_field' },
+      { namespace: 'private_label', key: 'source_book', value: 'Carl Robinson Edition 20 - White & Natural Textures', type: 'single_line_text_field' },
+      { namespace: 'custom', key: 'color', value: color, type: 'single_line_text_field' },
+      { namespace: 'custom', key: 'color_hex', value: r.color_hex || '', type: 'single_line_text_field' },
+      { namespace: 'global', key: 'width', value: r.width || '', type: 'single_line_text_field' },
+      { namespace: 'specs', key: 'material', value: r.material || material, type: 'single_line_text_field' },
+      { namespace: 'specs', key: 'repeat_v', value: r.repeat || '', type: 'single_line_text_field' },
+      { namespace: 'specs', key: 'match', value: r.match || '', type: 'single_line_text_field' },
+      { namespace: 'specs', key: 'collection', value: `${city} Naturals`, type: 'single_line_text_field' },
+    ],
+    _cost: r.price_retail, _mfr: r.mfr_sku, _src_url: r.product_url,
+  });
+}
+
+fs.writeFileSync(OUT, JSON.stringify(drafts, null, 2));
+
+// leak self-check: no forbidden vendor words in customer-facing fields
+const FORBIDDEN = /wallquest|carl robinson|chesapeake|brewster|nextwall|seabrook|voyage/i;
+let leaks = 0;
+for (const d of drafts) {
+  const cust = [d.title, d.body_html, ...d.tags, d.collection].join(' | ');
+  if (FORBIDDEN.test(cust)) { leaks++; console.error('⚠ LEAK in', d.dw_sku, '→', cust.match(FORBIDDEN)[0]); }
+}
+
+// human preview
+const byCity = {};
+for (const d of drafts) (byCity[d.collection] = byCity[d.collection] || []).push(d);
+let out = `CARL ROBINSON E20 → PHILLIPE ROMANO — ${drafts.length} DRAFT PRODUCTS (NOT PUSHED)\n`;
+out += `leak-check: ${leaks === 0 ? 'PASS (0 forbidden vendor names customer-facing)' : leaks + ' LEAKS'}\n\n`;
+for (const [city, ds] of Object.entries(byCity).sort()) {
+  out += `── ${city} (${ds.length}) ──\n`;
+  for (const d of ds) out += `  ${d.dw_sku}  ${d.title.replace(' Wallpaper | Phillipe Romano','')}  | cost $${d._cost} → std $${d.variants[1].price} | ${d.images.length} imgs\n`;
+}
+fs.writeFileSync(PREVIEW, out);
+console.log(out);
+console.log(`\nDRAFTS → ${OUT}\nPREVIEW → ${PREVIEW}`);
+console.log(leaks === 0 ? 'LEAK-CHECK PASS ✓' : `LEAK-CHECK FAIL: ${leaks}`);
diff --git a/scripts/wallquest-refresh/build-viewer-carl-robinson.cjs b/scripts/wallquest-refresh/build-viewer-carl-robinson.cjs
new file mode 100644
index 00000000..4a5f5f7b
--- /dev/null
+++ b/scripts/wallquest-refresh/build-viewer-carl-robinson.cjs
@@ -0,0 +1,100 @@
+// Build a self-contained web viewer for the Carl Robinson → Phillipe Romano onboard.
+// Reads normalized (or enriched, if present) records, applies the beach-city map,
+// emits index.html with a product grid + sort + density slider (Steve's standing rules).
+const fs = require('fs');
+const OUTDIR = '/tmp/carl-robinson-viewer';
+const ENR = '/tmp/wq-carl-robinson-enriched.json';
+const NORM = '/tmp/wq-carl-robinson-normalized.json';
+const src = fs.existsSync(ENR) ? ENR : NORM;
+let recs = JSON.parse(fs.readFileSync(src, 'utf8'));
+
+const CITY = { Grasscloth:'Nantucket', Paperweave:'Montauk', Raffia:'Newport', Netting:'Southampton', Bamboo:'Cape May', Sisal:'Sag Harbor', Cork:'Kennebunkport', Fiber:'Rockport', Jute:'Chatham', Mica:'Bar Harbor', Nonwoven:'Provincetown', Silk:'Watch Hill', 'Wood Veneer':'Edgartown' };
+const materialOf = p => (p||'').replace(/^white naturals\s*/i,'').trim() || 'Naturals';
+
+const data = recs.map(r => {
+  const material = materialOf(r.pattern);
+  const city = CITY[material] || 'Hamptons';
+  return {
+    dw_sku: r.dw_sku, mfr_sku: r.mfr_sku,
+    city, material, color: r.color || '',
+    pattern: `${city} ${material}`,
+    title: `${city} ${material}${r.color ? ' - ' + r.color : ''}`,
+    cost: r.price_retail, retail: r.our_price,
+    hex: r.color_hex || '', desc: r.ai_description || '',
+    styles: r.ai_styles || [],
+    img: fs.existsSync(`${OUTDIR}/img/${r.dw_sku}.jpeg`) ? `img/${r.dw_sku}.jpeg` : ((r.images||[]).find(u=>/_650\./.test(u)) || (r.images||[])[0] || ''),
+    room: fs.existsSync(`${OUTDIR}/rooms/${r.dw_sku}-living.png`) ? `rooms/${r.dw_sku}-living.png` : '',
+    src_url: r.product_url,
+  };
+});
+data.sort((a,b)=> a.city.localeCompare(b.city) || a.material.localeCompare(b.material) || a.color.localeCompare(b.color));
+
+fs.mkdirSync(OUTDIR, { recursive: true });
+const enrichedN = data.filter(d=>d.desc).length;
+const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Carl Robinson E20 → Phillipe Romano — Onboard Preview</title>
+<style>
+:root{--cols:4;--bg:#faf8f4;--ink:#1a1a1a;--mut:#8a8377;--line:#e7e2d8;--accent:#3d5a4c}
+*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
+header{padding:22px 28px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:5}
+h1{margin:0 0 3px;font-size:19px;letter-spacing:.14em;text-transform:uppercase;font-weight:600}
+.sub{color:var(--mut);font-size:12.5px;letter-spacing:.03em}
+.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-top:14px}
+.controls label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-right:6px}
+select,input[type=range]{vertical-align:middle}select{padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:13px}
+.pl{margin-left:auto;font-size:11px;color:var(--mut);border:1px solid var(--line);padding:4px 9px;border-radius:20px}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px;padding:22px 28px}
+.card{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+.ph{aspect-ratio:1/1;background:#efeae0 center/cover no-repeat;position:relative}
+.mat{position:absolute;top:8px;left:8px;background:rgba(255,255,255,.92);font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:3px 8px;border-radius:20px;color:var(--accent);font-weight:600}
+.body{padding:11px 13px 13px}
+.t{font-size:13.5px;font-weight:600;line-height:1.3}
+.c{font-size:12px;color:var(--mut);margin-top:2px}
+.meta{display:flex;justify-content:space-between;align-items:center;margin-top:9px;font-size:11.5px;color:var(--mut)}
+.sku{font-family:ui-monospace,Menlo,monospace;font-size:11px}
+.px{color:var(--accent);font-weight:600}
+.dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid rgba(0,0,0,.15);vertical-align:-1px;margin-right:5px}
+.desc{font-size:11.5px;color:#5c574d;margin-top:8px;display:none}
+body.showdesc .desc{display:block}
+</style></head><body>
+<header>
+<h1>Carl Robinson Edition 20 · White &amp; Natural Textures</h1>
+<div class="sub">Private-label onboard preview → <b>Phillipe Romano</b> · beach-city naming · ${data.length} products · DWCR- SKUs · <b>NOT PUBLISHED</b> (draft for approval)</div>
+<div class="controls">
+<div><label>Sort</label><select id="sort" onchange="render()">
+<option value="city">City → Material → Color</option>
+<option value="material">Material</option>
+<option value="color">Color A→Z</option>
+<option value="sku">SKU A→Z</option>
+<option value="pricelow">Price ↑</option>
+<option value="pricehigh">Price ↓</option>
+</select></div>
+<div><label>Density</label><input type="range" id="dens" min="2" max="7" value="4" oninput="setCols(this.value)"></div>
+<div><label><input type="checkbox" id="dc" onchange="document.body.classList.toggle('showdesc',this.checked)"> descriptions</label></div>
+<div><label><input type="checkbox" id="rm" onchange="document.body.classList.toggle('showroom',this.checked);render()"> room settings</label></div>
+<span class="pl">leak-safe: no WallQuest / Carl Robinson on cards · enriched ${enrichedN}/${data.length}</span>
+</div></header>
+<div class="grid" id="grid"></div>
+<script>
+const DATA=${JSON.stringify(data)};
+function setCols(n){document.documentElement.style.setProperty('--cols',n);localStorage.crCols=n}
+function render(){
+ const s=document.getElementById('sort').value;const d=[...DATA];
+ const by={city:(a,b)=>a.city.localeCompare(b.city)||a.material.localeCompare(b.material)||a.color.localeCompare(b.color),
+  material:(a,b)=>a.material.localeCompare(b.material)||a.color.localeCompare(b.color),
+  color:(a,b)=>a.color.localeCompare(b.color),sku:(a,b)=>a.dw_sku.localeCompare(b.dw_sku),
+  pricelow:(a,b)=>a.retail-b.retail,pricehigh:(a,b)=>b.retail-a.retail};
+ d.sort(by[s]);
+ const showRoom=document.body.classList.contains('showroom');
+ document.getElementById('grid').innerHTML=d.map(x=>\`<div class="card">
+  <div class="ph" style="background-image:url('\${showRoom&&x.room?x.room:x.img}')"><span class="mat">\${x.material}</span>\${showRoom&&x.room?'<span class="mat" style="left:auto;right:8px;color:#8a8377">room</span>':''}</div>
+  <div class="body"><div class="t">\${x.pattern}</div>
+  <div class="c">\${x.hex?\`<span class="dot" style="background:\${x.hex}"></span>\`:''}\${x.color||'—'}</div>
+  <div class="meta"><span class="sku">\${x.dw_sku}</span><span><span class="px">$\${(x.retail||0).toFixed(2)}</span> · cost $\${(x.cost||0).toFixed(0)}</span></div>
+  \${x.desc?\`<div class="desc">\${x.desc}</div>\`:''}</div></div>\`).join('');
+}
+if(localStorage.crCols)setCols(localStorage.crCols);
+render();
+</script></body></html>`;
+fs.writeFileSync(`${OUTDIR}/index.html`, html);
+console.log(`viewer → ${OUTDIR}/index.html  (${data.length} products, enriched ${enrichedN})`);
diff --git a/scripts/wallquest-refresh/enrich-carl-robinson.cjs b/scripts/wallquest-refresh/enrich-carl-robinson.cjs
new file mode 100644
index 00000000..e1ae9ea6
--- /dev/null
+++ b/scripts/wallquest-refresh/enrich-carl-robinson.cjs
@@ -0,0 +1,73 @@
+// Phase 3 — Gemini vision enrichment for the Carl Robinson naturals book.
+// Reads normalized records, enriches each primary image with colors/hex/styles/description,
+// writes an enriched JSON. Local cost logged. No DB write here (kept file-based pre-gate).
+const fs = require('fs');
+const https = require('https');
+const http = require('http');
+
+const KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
+if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1); }
+const MODEL = process.env.GEMINI_MODEL || 'gemini-3.5-flash';
+const URL_ = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+const IN = process.env.IN || '/tmp/wq-carl-robinson-normalized.json';
+const OUT = process.env.OUT || '/tmp/wq-carl-robinson-enriched.json';
+
+const PROMPT = `You are cataloguing a NATURAL-TEXTURE wallcovering (grasscloth, raffia, paperweave, cork, mica, bamboo, netting, sisal, etc.). Analyze the image and return STRICT JSON only:
+{"dominant_color":"plain-english name","dominant_hex":"#RRGGBB","background_hex":"#RRGGBB","all_colors":["name",...max4],"styles":["Coastal","Organic Modern",...max4],"material_texture":"grasscloth|raffia|paperweave|cork|mica|bamboo|netting|sisal|linen|other","description":"1-2 sentence luxury retail description, NO brand names","image_type":"scan_swatch|photo_full|room_scene"}
+Return ONLY the JSON object, no markdown.`;
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function dl(url) {
+  return new Promise((resolve, reject) => {
+    const req = (u, n = 0) => {
+      if (n > 5) return reject(new Error('redirects'));
+      (u.startsWith('https') ? https : http).get(u, { timeout: 15000 }, res => {
+        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return req(res.headers.location, n + 1);
+        if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
+        const c = []; res.on('data', d => c.push(d));
+        res.on('end', () => { const b = Buffer.concat(c); resolve({ base64: b.toString('base64'), mime: (res.headers['content-type'] || 'image/jpeg').split(';')[0].trim() }); });
+        res.on('error', reject);
+      }).on('error', reject).on('timeout', function () { this.destroy(); reject(new Error('timeout')); });
+    }; req(url);
+  });
+}
+function gemini(base64, mime) {
+  const payload = { contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: base64 } }] }], generationConfig: { temperature: 0.2, maxOutputTokens: 900, responseMimeType: 'application/json' } };
+  return new Promise((resolve, reject) => {
+    const u = new URL(URL_); const body = JSON.stringify(payload);
+    const r = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 40000 }, res => {
+      let d = ''; res.on('data', c => d += c);
+      res.on('end', () => { try { const j = JSON.parse(d); if (j.error) return reject(new Error(j.error.message)); let t = j.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; t = t.replace(/^```json\s*/i, '').replace(/```\s*$/, '').trim(); const m = t.match(/\{[\s\S]*\}/); resolve(JSON.parse(m ? m[0] : t)); } catch (e) { reject(e); } });
+    });
+    r.on('error', reject); r.on('timeout', () => { r.destroy(); reject(new Error('gemini timeout')); });
+    r.write(body); r.end();
+  });
+}
+
+(async () => {
+  const recs = JSON.parse(fs.readFileSync(IN, 'utf8'));
+  let ok = 0, err = 0, calls = 0;
+  for (const rec of recs) {
+    const img = (rec.images || [])[0];
+    if (!img) { rec.enrich_error = 'no image'; err++; continue; }
+    try {
+      const { base64, mime } = await dl(img);
+      const g = await gemini(base64, mime); calls++;
+      rec.ai_dominant_color = g.dominant_color;
+      rec.color_hex = g.dominant_hex;
+      rec.ai_background_color = g.background_hex;
+      rec.ai_colors = g.all_colors;
+      rec.ai_styles = g.styles;
+      rec.ai_material_texture = g.material_texture;
+      rec.ai_description = g.description;
+      rec.ai_image_type = g.image_type;
+      ok++;
+      process.stderr.write(`\r[${ok + err}/${recs.length}] ok=${ok} err=${err}  ${rec.dw_sku} ${g.dominant_color||''}          `);
+    } catch (e) { rec.enrich_error = String(e).slice(0, 100); err++; }
+    await sleep(600); // rate limit
+  }
+  fs.writeFileSync(OUT, JSON.stringify(recs, null, 2));
+  const cost = (calls * 0.0006).toFixed(4);
+  console.error(`\nENRICH DONE ok=${ok} err=${err} calls=${calls} → ${OUT}`);
+  console.error(`COST ~$${cost} (Gemini ${MODEL}, ${calls} img calls @ ~$0.0006)`);
+})();
diff --git a/scripts/wallquest-refresh/load-db-carl-robinson.cjs b/scripts/wallquest-refresh/load-db-carl-robinson.cjs
new file mode 100644
index 00000000..92bb26da
--- /dev/null
+++ b/scripts/wallquest-refresh/load-db-carl-robinson.cjs
@@ -0,0 +1,99 @@
+// Phase 6/7 — load all 67 Carl Robinson naturals into dw_unified.carl_robinson_catalog
+// with full specs + Phillipe Romano private-label naming + (backfilled) room settings.
+// Dedicated table: isolated from wallquest_catalog (Malibu-bound) so leak-scanner/refresh
+// never treat these as Malibu. Idempotent upsert keyed on mfr_sku.
+const fs = require('fs');
+const { Client } = require('pg');
+
+const CONN = process.env.DATABASE_URL || 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
+const ENR = '/tmp/wq-carl-robinson-enriched.json';
+const NORM = '/tmp/wq-carl-robinson-normalized.json';
+const recs = JSON.parse(fs.readFileSync(fs.existsSync(ENR) ? ENR : NORM, 'utf8'));
+const ROOMDIR = '/tmp/carl-robinson-viewer/rooms';
+
+const CITY = { Grasscloth:'Nantucket', Paperweave:'Montauk', Raffia:'Newport', Netting:'Southampton', Bamboo:'Cape May', Sisal:'Sag Harbor', Cork:'Kennebunkport', Fiber:'Rockport', Jute:'Chatham', Mica:'Bar Harbor', Nonwoven:'Provincetown', Silk:'Watch Hill', 'Wood Veneer':'Edgartown' };
+const materialOf = p => (p || '').replace(/^white naturals\s*/i, '').trim() || 'Naturals';
+
+const DDL = `
+CREATE TABLE IF NOT EXISTS carl_robinson_catalog (
+  id serial PRIMARY KEY,
+  mfr_sku text UNIQUE NOT NULL,
+  dw_sku text NOT NULL,
+  pattern_name text,           -- beach-city, e.g. "Southampton Netting"
+  color_name text,
+  collection text,             -- beach-city bucket
+  material text,
+  product_type text DEFAULT 'Wallcovering',
+  price_retail numeric,        -- our cost
+  our_price numeric,           -- our retail (cost/0.65/0.85)
+  price_dw numeric,
+  width text, length text, repeat_v text, match_type text, backing text,
+  specs jsonb,                 -- full raw spec map
+  image_url text,
+  all_images jsonb,
+  local_image_path text,
+  color_hex text,
+  ai_dominant_color text,
+  ai_background_color text,
+  ai_colors jsonb,
+  ai_styles jsonb,
+  ai_description text,
+  room_setting_images jsonb DEFAULT '[]'::jsonb,
+  private_label_vendor text DEFAULT 'Phillipe Romano',
+  real_vendor_name text DEFAULT 'WallQuest',
+  source_book text DEFAULT 'Carl Robinson Edition 20 - White & Natural Textures',
+  on_shopify boolean DEFAULT false,
+  product_url text,
+  created_at timestamptz DEFAULT now(),
+  updated_at timestamptz DEFAULT now()
+);`;
+
+(async () => {
+  const db = new Client({ connectionString: CONN });
+  await db.connect();
+  await db.query(DDL);
+  let n = 0;
+  for (const r of recs) {
+    const material = materialOf(r.pattern);
+    const city = CITY[material] || 'Hamptons';
+    const patternName = `${city} ${material}`;
+    // room images that already exist on disk for this sku
+    const rooms = fs.existsSync(ROOMDIR)
+      ? fs.readdirSync(ROOMDIR).filter(f => f.startsWith(r.dw_sku + '-') && !f.includes('-v1') && f.endsWith('.png')).map(f => `rooms/${f}`)
+      : [];
+    const localImg = fs.existsSync(`/tmp/carl-robinson-viewer/img/${r.dw_sku}.jpeg`) ? `img/${r.dw_sku}.jpeg` : null;
+    await db.query(`
+      INSERT INTO carl_robinson_catalog
+        (mfr_sku,dw_sku,pattern_name,color_name,collection,material,product_type,
+         price_retail,our_price,price_dw,width,length,repeat_v,match_type,backing,specs,
+         image_url,all_images,local_image_path,color_hex,ai_dominant_color,ai_background_color,
+         ai_colors,ai_styles,ai_description,room_setting_images,product_url,updated_at)
+      VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,now())
+      ON CONFLICT (mfr_sku) DO UPDATE SET
+        pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name, collection=EXCLUDED.collection,
+        material=EXCLUDED.material, price_retail=EXCLUDED.price_retail, our_price=EXCLUDED.our_price,
+        price_dw=EXCLUDED.price_dw, width=EXCLUDED.width, length=EXCLUDED.length, repeat_v=EXCLUDED.repeat_v,
+        match_type=EXCLUDED.match_type, backing=EXCLUDED.backing, specs=EXCLUDED.specs,
+        image_url=EXCLUDED.image_url, all_images=EXCLUDED.all_images, local_image_path=EXCLUDED.local_image_path,
+        color_hex=EXCLUDED.color_hex, ai_dominant_color=EXCLUDED.ai_dominant_color,
+        ai_background_color=EXCLUDED.ai_background_color, ai_colors=EXCLUDED.ai_colors,
+        ai_styles=EXCLUDED.ai_styles, ai_description=EXCLUDED.ai_description,
+        room_setting_images=CASE WHEN jsonb_array_length(EXCLUDED.room_setting_images)>0 THEN EXCLUDED.room_setting_images ELSE carl_robinson_catalog.room_setting_images END,
+        product_url=EXCLUDED.product_url, updated_at=now()`,
+      [r.mfr_sku, r.dw_sku, patternName, r.color || '', city, material, r.product_type || 'Wallcovering',
+       r.price_retail ?? null, r.our_price ?? null, r.price_retail ? +(r.price_retail/0.55).toFixed(2) : null,
+       r.width || '', r.length || '', r.repeat || '', r.match || '', r.backing || '', JSON.stringify(r.specs || {}),
+       (r.images||[])[0] || '', JSON.stringify(r.images || []), localImg,
+       r.color_hex || null, r.ai_dominant_color || null, r.ai_background_color || null,
+       JSON.stringify(r.ai_colors || []), JSON.stringify(r.ai_styles || []), r.ai_description || null,
+       JSON.stringify(rooms), r.product_url || '']);
+    n++;
+  }
+  const { rows } = await db.query(`SELECT count(*) total, count(*) FILTER (WHERE price_retail>0) priced,
+    count(*) FILTER (WHERE ai_description IS NOT NULL) enriched,
+    count(*) FILTER (WHERE jsonb_array_length(room_setting_images)>0) with_rooms,
+    count(DISTINCT collection) cities FROM carl_robinson_catalog`);
+  console.log(`loaded/updated ${n} rows into dw_unified.carl_robinson_catalog`);
+  console.log('DB tally:', rows[0]);
+  await db.end();
+})().catch(e => { console.error('DB ERROR:', e.message); process.exit(1); });
diff --git a/scripts/wallquest-refresh/normalize-carl-robinson.cjs b/scripts/wallquest-refresh/normalize-carl-robinson.cjs
new file mode 100644
index 00000000..49d940c6
--- /dev/null
+++ b/scripts/wallquest-refresh/normalize-carl-robinson.cjs
@@ -0,0 +1,53 @@
+// Phase 2 — normalize the raw Carl Robinson scrape → structured records + DWCR- SKUs.
+// RAW source = /tmp/wq-carl-robinson.jsonl (WallQuest). TARGET private label = Phillipe Romano.
+// This pass does NOT rename yet — it structures the vendor data and surfaces the distinct
+// pattern families so the beach-city naming map can be built from real data.
+const fs = require('fs');
+const IN = process.env.IN || '/tmp/wq-carl-robinson-merged.jsonl';
+const OUT = '/tmp/wq-carl-robinson-normalized.json';
+
+const rows = fs.readFileSync(IN, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
+  .filter(r => r.title && r.priceValue); // keep only fully-scraped rows
+
+const rec = rows.map(r => {
+  const slug = r.url.replace(/https?:\/\/[^/]+\//i, '').split('?')[0].replace(/\/$/, '');
+  const mfr_sku = slug.toUpperCase();                     // cl50055 → CL50055
+  const numTail = (mfr_sku.match(/\d+/) || ['0'])[0];     // 50055
+  const dw_sku = `DWCR-${numTail}`;
+  const s = r.specs || {};
+  // Prefer explicit spec fields; fall back to title split "Pattern - Color"
+  const pattern = (s['Design Name'] || (r.title.split(' - ')[0] || '')).trim();
+  const color   = (s['Colorway Name'] || (r.title.split(' - ')[1] || '')).trim();
+  const cost = parseFloat(String(r.priceValue).replace(/,/g, ''));
+  return {
+    mfr_sku, dw_sku,
+    src_title: r.title,
+    pattern, color,
+    price_retail: cost,                 // = our COST (WallQuest portal convention)
+    our_price: +(cost / 0.65 / 0.85).toFixed(2), // new-product retail formula
+    material: s['Material'] || '',
+    backing: s['Backing Material'] || '',
+    width: s['Roll Width'] || s['Width'] || '',
+    length: s['Roll Length'] || s['Length'] || '',
+    repeat: s['Design Repeat'] || s['Vertical Repeat'] || '',
+    match: s['Match'] || s['Design Match'] || '',
+    product_type: s['Product Type'] || 'Wallcovering',
+    specs: s,
+    images: r.imgs || [],
+    product_url: r.url,
+  };
+});
+
+fs.writeFileSync(OUT, JSON.stringify(rec, null, 2));
+
+// Report distinct pattern families (drives the beach-city collection mapping)
+const byPattern = {};
+for (const x of rec) (byPattern[x.pattern] = byPattern[x.pattern] || []).push(x.color);
+console.log(`normalized ${rec.length} rows → ${OUT}`);
+console.log(`\nDISTINCT PATTERN FAMILIES (${Object.keys(byPattern).length}):`);
+for (const [p, cols] of Object.entries(byPattern).sort()) {
+  console.log(`  • ${p}  (${cols.length} colorways: ${cols.join(', ')})`);
+}
+const noPrice = rows.length - rec.length;
+if (noPrice) console.log(`\n⚠ ${noPrice} scraped rows dropped (missing title/price) — will re-check`);
+console.log(`\ncost range: $${Math.min(...rec.map(r=>r.price_retail))}–$${Math.max(...rec.map(r=>r.price_retail))}`);
diff --git a/scripts/wallquest-refresh/onboard-carl-robinson.cjs b/scripts/wallquest-refresh/onboard-carl-robinson.cjs
new file mode 100644
index 00000000..2ba2df7a
--- /dev/null
+++ b/scripts/wallquest-refresh/onboard-carl-robinson.cjs
@@ -0,0 +1,141 @@
+// Carl Robinson Edition 20 — White & Natural Textures — collection ONBOARD scraper.
+// Discovers product URLs from the collection listing, then login-scrapes each
+// (specs/price/images/title/sku). PRIVATE LABEL target = Phillipe Romano (renaming
+// happens in a later phase; this pass captures RAW vendor data only). NO publish.
+// Run:  MODE=discover  → paginate listing, print + save product URLs (cheap)
+//       MODE=scrape    → scrape each discovered URL → JSONL
+const Browserbase = require('@browserbasehq/sdk').default;
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/browserbase/.env' });
+
+const COLLECTION = 'https://www.wallquest.com/carl-robinson-edition-20-white-natural-textures';
+const WPW = '*Wallquestaccess911*';
+const WUSER = 'info@designerwallcoverings.com';
+const URLS_FILE = '/tmp/wq-carl-robinson-urls.json';
+const OUT = '/tmp/wq-carl-robinson.jsonl';
+const MODE = process.env.MODE || 'discover';
+
+const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
+const goto = async (p, u) => { for (let i = 0; i < 3; i++) { try { await p.goto(u, { waitUntil: 'domcontentloaded', timeout: 45000 }); return 1; } catch (e) { await p.waitForTimeout(3000); } } return 0; };
+const isLoggedIn = (page) => page.evaluate(() =>
+  /log\s*out|sign\s*out/i.test(document.body.innerText) ||
+  !!document.querySelector('a.ico-logout, .header-links a[href*="logout"]')
+).catch(() => false);
+
+async function login(page) {
+  for (let attempt = 1; attempt <= 3; attempt++) {
+    await goto(page, 'https://www.wallquest.com/login');
+    await page.waitForTimeout(7000);
+    try {
+      await page.fill('#Email', WUSER);
+      await page.fill('#Password', WPW);
+      await page.press('#Password', 'Enter');
+    } catch (e) { /* form not ready; loop retries */ }
+    await page.waitForTimeout(9000);
+    if (await isLoggedIn(page)) { console.error(`  login OK (attempt ${attempt})`); return true; }
+    console.error(`  login attempt ${attempt} not confirmed, retrying…`);
+    await page.waitForTimeout(3000);
+  }
+  console.error('  LOGIN FAILED after 3 attempts — chunk will likely be priceless');
+  return false;
+}
+
+// Collect product-detail URLs from a nopCommerce collection listing page.
+const discoverOnPage = (p) => p.evaluate(() => {
+  const bad = /\/(login|logout|cart|wishlist|compare|register|account|search)|#/i;
+  const set = new Set();
+  document.querySelectorAll('.item-box a, .product-item a, .product-title a, .picture a, h2.product-title a').forEach(a => {
+    const href = a.href || '';
+    if (!href || bad.test(href)) return;
+    if (!/wallquest\.com\//i.test(href)) return;
+    // product detail pages are single-segment slugs/SKUs, not the collection slug itself
+    const path = href.replace(/https?:\/\/[^/]+/i, '').split('?')[0].replace(/\/$/, '');
+    if (!path || path.split('/').length > 2) return;
+    if (/carl-robinson-edition-20/i.test(path)) return; // the collection page/anchors
+    set.add(href.split('?')[0]);
+  });
+  return [...set];
+});
+
+const extract = (p) => p.evaluate(() => {
+  const Q = s => [...document.querySelectorAll(s)];
+  const specs = {};
+  Q('.spec-name').forEach(n => { const v = n.nextElementSibling; if (v) specs[n.textContent.trim()] = v.textContent.trim(); });
+  const imgs = [...new Set(
+    Q('a[data-full-image-url]').map(a => a.getAttribute('data-full-image-url'))
+      .concat(Q('#cloudZoomImage,.picture img').map(i => i.src))
+  )].filter(u => u && /jpe?g|png/i.test(u));
+  const pv = ((Q('[class*=price-value]')[0] || {}).textContent || '').match(/[\d,]+\.\d{2}/);
+  const title = (document.querySelector('.product-name h1, h1.product-title, .product-essential h1, h1') || {}).textContent || '';
+  const sku = (document.querySelector('[itemprop=sku], .sku .value, .sku-number, .product-sku .value') || {}).textContent || '';
+  return { title: title.trim(), sku: sku.trim(), specs, imgs: imgs.slice(0, 20), priceValue: pv ? pv[0] : null };
+});
+
+(async () => {
+  if (MODE === 'discover') {
+    const s = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID, proxies: true, browserSettings: { solveCaptchas: true } });
+    const br = await chromium.connectOverCDP(s.connectUrl);
+    const page = br.contexts()[0].pages()[0] || await br.contexts()[0].newPage();
+    try {
+      await login(page);
+      const all = new Set();
+      for (let pn = 1; pn <= 10; pn++) {
+        if (!await goto(page, `${COLLECTION}?orderby=50&pagesize=100&viewmode=grid&pagenumber=${pn}`)) break;
+        await page.waitForTimeout(6000);
+        const found = await discoverOnPage(page);
+        const before = all.size; found.forEach(u => all.add(u));
+        console.error(`[page ${pn}] found=${found.length} total=${all.size}`);
+        if (all.size === before) break; // no new products → past the end
+      }
+      const urls = [...all];
+      fs.writeFileSync(URLS_FILE, JSON.stringify(urls, null, 2));
+      console.error(`DISCOVER DONE — ${urls.length} product URLs → ${URLS_FILE}`);
+      console.log(JSON.stringify({ count: urls.length, sample: urls.slice(0, 8) }, null, 2));
+    } finally { await br.close().catch(() => {}); }
+    return;
+  }
+
+  // MODE=scrape (env-tunable: SRC url-file, OUTF output, CHUNK, WAITMS)
+  const SRC = process.env.SRC || URLS_FILE;
+  const OUTF = process.env.OUTF || OUT;
+  const CHUNK = parseInt(process.env.CHUNK || '40', 10);
+  const WAITMS = parseInt(process.env.WAITMS || '9000', 10);
+  const urls = JSON.parse(fs.readFileSync(SRC, 'utf8'));
+  fs.writeFileSync(OUTF, '');
+  const out = fs.createWriteStream(OUTF, { flags: 'a' });
+  let done = 0, priced = 0;
+  for (let i = 0; i < urls.length; i += CHUNK) {
+    const chunk = urls.slice(i, i + CHUNK); let br, page, authed = false;
+    // spin up fresh sessions until one authenticates (bad session => all-priceless)
+    for (let sTry = 1; sTry <= 3 && !authed; sTry++) {
+      try {
+        if (br) await br.close().catch(() => {});
+        const s = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID, proxies: true, browserSettings: { solveCaptchas: true } });
+        br = await chromium.connectOverCDP(s.connectUrl);
+        page = br.contexts()[0].pages()[0] || await br.contexts()[0].newPage();
+        authed = await login(page);
+        if (!authed) console.error(`  session ${sTry} did not authenticate — recreating`);
+      } catch (e) { console.error('session create err', String(e).slice(0, 120)); }
+    }
+    if (!authed) { console.error(`[chunk ${Math.floor(i / CHUNK) + 1}] SKIP — no authed session`); if (br) await br.close().catch(() => {}); continue; }
+    try {
+      for (const url of chunk) {
+        try {
+          if (!await goto(page, url)) { out.write(JSON.stringify({ url, nav: 0 }) + '\n'); done++; continue; }
+          await page.waitForTimeout(WAITMS);
+          let x = await extract(page);
+          // retry once if price didn't lazy-load (login-for-price placeholder)
+          if (!x.priceValue) { await page.waitForTimeout(6000); x = await extract(page); }
+          if (x.priceValue) priced++;
+          out.write(JSON.stringify({ url, ...x }) + '\n');
+        } catch (e) { out.write(JSON.stringify({ url, err: String(e).slice(0, 120) }) + '\n'); }
+        done++;
+      }
+    } catch (e) { console.error('session err', String(e).slice(0, 160)); }
+    finally { if (br) await br.close().catch(() => {}); }
+    console.error(`[chunk ${Math.floor(i / CHUNK) + 1}] done=${done}/${urls.length} priced=${priced}`);
+  }
+  out.end();
+  console.error(`SCRAPE DONE done=${done} priced=${priced} → ${OUTF}`);
+})();
diff --git a/scripts/wallquest-refresh/room-composite-carl-robinson.cjs b/scripts/wallquest-refresh/room-composite-carl-robinson.cjs
new file mode 100644
index 00000000..473b9bc3
--- /dev/null
+++ b/scripts/wallquest-refresh/room-composite-carl-robinson.cjs
@@ -0,0 +1,83 @@
+// Phase 6 — image-to-image room compositor for the naturals book.
+// Feeds the ACTUAL scanned swatch to gemini-2.5-flash-image and asks it to render a
+// room where that exact texture covers the wall — so the real weave shows (not an
+// Imagen hallucination). Saves PNGs into the viewer's rooms/ dir.
+// Env: SKUS="DWCR-50055,DWCR-50019" to limit; else all. ROOM=living|bedroom.
+const fs = require('fs');
+const https = require('https');
+
+const KEY = process.env.GEMINI_API_KEY;
+if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1); }
+const MODEL = 'gemini-2.5-flash-image';
+const URL_ = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+const VIEWDIR = '/tmp/carl-robinson-viewer';
+const ROOMDIR = `${VIEWDIR}/rooms`;
+fs.mkdirSync(ROOMDIR, { recursive: true });
+
+const ROOMS = {
+  living: 'a bright, elegant contemporary living room — the camera faces the main accent wall, a low linen sofa and a slim console with a ceramic lamp in front of it, oak floor, soft daylight from the side',
+  bedroom: 'a serene luxury bedroom — the camera faces the headboard wall, an upholstered bed with linen bedding in front of it, two bedside tables with brass lamps, soft morning light',
+};
+const ROOM = process.env.ROOM || 'living';
+const scene = ROOMS[ROOM];
+
+const recs = JSON.parse(fs.readFileSync('/tmp/wq-carl-robinson-normalized.json', 'utf8'));
+const only = (process.env.SKUS || '').split(',').map(s => s.trim()).filter(Boolean);
+const work = recs.filter(r => (!only.length || only.includes(r.dw_sku)) && fs.existsSync(`${VIEWDIR}/img/${r.dw_sku}.jpeg`));
+
+// WINNING METHOD (B): fixed empty-room base + product's TILED swatch → paper ONLY the wall.
+const { execSync } = require('child_process');
+const BASE_ROOM = process.env.BASE_ROOM || `${VIEWDIR}/bakeoff/empty-room.png`;
+const roomB64 = fs.readFileSync(BASE_ROOM).toString('base64');
+const MACRO = 'The wall must look like a real hand-woven natural-fibre wallcovering with three-dimensional tactile texture: individual fibres and the over-under weave clearly visible and in crisp focus, grazing raking side-light casting micro-shadows between fibres revealing real depth and relief — NOT a flat printed surface. Realistic architectural weave scale, editorial interior photography, high micro-contrast on the wall.';
+const prompt = (material) => `Take the FIRST image (an empty room). Paper ONLY the blank accent wall with the EXACT ${material} texture and color from the SECOND image. Keep the furniture, floor, lighting and composition of the first image identical. ${MACRO}`;
+
+// tile a product swatch to a 1024 canvas so the model has abundant texture info
+function tiledSwatchB64(sku) {
+  const t = `/tmp/tile-${sku}.png`;
+  execSync(`magick "${VIEWDIR}/img/${sku}.jpeg" -resize 380x380^ -gravity center -extent 380x380 /tmp/_sw-${sku}.png && magick -size 1024x1024 tile:/tmp/_sw-${sku}.png "${t}" && magick "${t}" -unsharp 0x1.0 "${t}"`);
+  return fs.readFileSync(t).toString('base64');
+}
+
+function genRoom(swatchB64, material) {
+  const payload = {
+    contents: [{ parts: [{ text: prompt(material) }, { inline_data: { mime_type: 'image/png', data: roomB64 } }, { inline_data: { mime_type: 'image/png', data: swatchB64 } }] }],
+    generationConfig: { responseModalities: ['IMAGE'], temperature: 0.4 },
+  };
+  return new Promise((resolve, reject) => {
+    const u = new URL(URL_); const body = JSON.stringify(payload);
+    const r = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 90000 }, res => {
+      let d = ''; res.on('data', c => d += c);
+      res.on('end', () => {
+        try {
+          const j = JSON.parse(d);
+          if (j.error) return reject(new Error(j.error.message));
+          const parts = j.candidates?.[0]?.content?.parts || [];
+          const imgPart = parts.find(p => p.inline_data || p.inlineData);
+          const inline = imgPart && (imgPart.inline_data || imgPart.inlineData);
+          if (!inline) return reject(new Error('no image in response: ' + JSON.stringify(parts).slice(0, 120)));
+          resolve(Buffer.from(inline.data, 'base64'));
+        } catch (e) { reject(e); }
+      });
+    });
+    r.on('error', reject); r.on('timeout', () => { r.destroy(); reject(new Error('timeout')); });
+    r.write(body); r.end();
+  });
+}
+
+(async () => {
+  const materialOf = p => (p || '').replace(/^white naturals\s*/i, '').trim().toLowerCase() || 'natural texture';
+  let ok = 0, err = 0;
+  for (const r of work) {
+    try {
+      const swatch = tiledSwatchB64(r.dw_sku);
+      const png = await genRoom(swatch, materialOf(r.pattern));
+      fs.writeFileSync(`${ROOMDIR}/${r.dw_sku}-${ROOM}.png`, png);
+      ok++; console.error(`✓ ${r.dw_sku} ${ROOM} (${(png.length/1024|0)}KB) [${ok}/${work.length}]`);
+    } catch (e) { err++; console.error(`✗ ${r.dw_sku}: ${String(e).slice(0,90)}`); }
+    await new Promise(z => setTimeout(z, 500));
+  }
+  const cost = (ok * 0.039).toFixed(3);
+  console.error(`ROOMS DONE ok=${ok} err=${err} room=${ROOM} → ${ROOMDIR}`);
+  console.error(`COST ~$${cost} (gemini-2.5-flash-image, ${ok} imgs @ ~$0.039)`);
+})();

← 95e17fd7 wallquest: onboard specialty grasscloths & veneers (87 SKUs  ·  back to Designer Wallcoverings  ·  Carl Robinson: Shopify push (draft) + activate/publish-all-c 0eddd307 →