← 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
A scripts/wallquest-refresh/build-pr-drafts-carl-robinson.cjsA scripts/wallquest-refresh/build-viewer-carl-robinson.cjsA scripts/wallquest-refresh/enrich-carl-robinson.cjsA scripts/wallquest-refresh/load-db-carl-robinson.cjsA scripts/wallquest-refresh/normalize-carl-robinson.cjsA scripts/wallquest-refresh/onboard-carl-robinson.cjsA scripts/wallquest-refresh/room-composite-carl-robinson.cjs
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 & 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 →