← back to Wallco Ai
feat(catalog): categoryDisplay translator + vendor-inspired generator + brand library
05924484e959986fad4e4bd755bf560f4baf5bb0 · 2026-05-22 14:18:29 -0700 · Steve Abrams
Two systems land together — both serve the same goal: surface clean luxury
copy to customers while keeping internal taxonomy intact for tooling.
## 1. categoryDisplay() — slug→luxury name translator
server.js:12248 — maps category slugs to customer-facing display names:
cactus-pine-scenic → 'Scenic Wraparound'
cactus-11ft-mural → 'Single-Subject Mural'
cactus-accent-mural → 'Accent Mural'
tree-mural → 'Full-Room Scenic'
monterey-mural → 'Coastal Scenic'
mural-scenic → 'Scenic Mural'
(anything else) → titleCase(slug.replace(/-/g, ' '))
Wired into every customer-visible category render:
- PDP provenance badge (line 8834)
- PDP breadcrumb (line 8796)
- PDP info-pane Category row (line 7244)
- PDP style-pane "reads as a <strong>X</strong>" (line 7309)
- PDP spec panel Category row (line 8989)
- Grid card style-tag (line 3866)
- Card aria-label (cardAriaLabel, line 754)
- Meta description / SEO blurb (line 7135)
- OG image alt text (line 7196)
- OG product:category meta (line 7202)
- JSON-LD Product.category (line 7175)
- JSON-LD Product additionalProperty.Category (line 7144)
- JSON-LD BreadcrumbList position 3 (line 7161)
Customer audit before:
PDP 39055 cactus-pine-scenic visible: 8
/designs?line=Studio cactus-pine-scenic: 60+
Customer audit after:
PDP 39055 cactus-pine-scenic visible: 1 (URL only, not copy)
/designs?line=Studio cactus-pine-scenic: 2 (URLs only)
PDP 39055 Scenic Wraparound: 8 (clean luxury copy)
/designs?line=Studio Scenic Wraparound: 60
## 2. Vendor-inspired wallco generator
scripts/generate-vendor-inspired.js — Steve directive 2026-05-22:
"use the shared unified postgresql database but only use the files for
ideas for wallco to generate."
Pipeline per design:
1. Random row from a curated *_catalog table (thibaut/rebel_walls/arte/
phillipe_romano/harlequin/missoni/armani_casa/nlxl/pj/cole_son)
2. Extract: ai_styles, ai_patterns, color_hex, abstract motif category
3. abstractMotif(pattern_name) → "soft atmospheric cloud-form silhouettes"
or "large flat foliage silhouettes" etc. (NEVER copies source pattern
name verbatim)
4. SDXL Pass-2 render at 1536x640
5. Gemini image-edit push to flat hand-painted-silk
6. INSERT as Studio design with notes JSON carrying source vendor +
pattern name (internal-only audit, never customer-facing)
Test batch of 6 (one SDXL failed, 5 succeeded):
39278, 39280, 39282, 39284, 39286 — published Studio
~$0.20 in Gemini cost, 193s wall time
## 3. wallco / Scenic landing pitch
Scrubbed 'Phillip Jeffries or Calico mural' → 'trade-grade photoreal mural'.
Smoke confirms zero competitor-name occurrences on /scenic.
## 4. Brand reference PG table (already committed in prior commit)
wallco_brand_references in dw_unified holds the 9 competitor + hospitality
brands (Phillip Jeffries / Calico / Rebel Walls / Zuber / Gracie /
de Gournay / Aman / Auberge / Four Seasons) as a wallco-OWNED reference
library, not hardcoded in code. Phase 2 (pulling landing-page copy from
this table at request time) deferred — current pitch copy is hardcoded
generic descriptors which is fine for now.
Files touched
A scripts/generate-vendor-inspired.jsM server.js
Diff
commit 05924484e959986fad4e4bd755bf560f4baf5bb0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri May 22 14:18:29 2026 -0700
feat(catalog): categoryDisplay translator + vendor-inspired generator + brand library
Two systems land together — both serve the same goal: surface clean luxury
copy to customers while keeping internal taxonomy intact for tooling.
## 1. categoryDisplay() — slug→luxury name translator
server.js:12248 — maps category slugs to customer-facing display names:
cactus-pine-scenic → 'Scenic Wraparound'
cactus-11ft-mural → 'Single-Subject Mural'
cactus-accent-mural → 'Accent Mural'
tree-mural → 'Full-Room Scenic'
monterey-mural → 'Coastal Scenic'
mural-scenic → 'Scenic Mural'
(anything else) → titleCase(slug.replace(/-/g, ' '))
Wired into every customer-visible category render:
- PDP provenance badge (line 8834)
- PDP breadcrumb (line 8796)
- PDP info-pane Category row (line 7244)
- PDP style-pane "reads as a <strong>X</strong>" (line 7309)
- PDP spec panel Category row (line 8989)
- Grid card style-tag (line 3866)
- Card aria-label (cardAriaLabel, line 754)
- Meta description / SEO blurb (line 7135)
- OG image alt text (line 7196)
- OG product:category meta (line 7202)
- JSON-LD Product.category (line 7175)
- JSON-LD Product additionalProperty.Category (line 7144)
- JSON-LD BreadcrumbList position 3 (line 7161)
Customer audit before:
PDP 39055 cactus-pine-scenic visible: 8
/designs?line=Studio cactus-pine-scenic: 60+
Customer audit after:
PDP 39055 cactus-pine-scenic visible: 1 (URL only, not copy)
/designs?line=Studio cactus-pine-scenic: 2 (URLs only)
PDP 39055 Scenic Wraparound: 8 (clean luxury copy)
/designs?line=Studio Scenic Wraparound: 60
## 2. Vendor-inspired wallco generator
scripts/generate-vendor-inspired.js — Steve directive 2026-05-22:
"use the shared unified postgresql database but only use the files for
ideas for wallco to generate."
Pipeline per design:
1. Random row from a curated *_catalog table (thibaut/rebel_walls/arte/
phillipe_romano/harlequin/missoni/armani_casa/nlxl/pj/cole_son)
2. Extract: ai_styles, ai_patterns, color_hex, abstract motif category
3. abstractMotif(pattern_name) → "soft atmospheric cloud-form silhouettes"
or "large flat foliage silhouettes" etc. (NEVER copies source pattern
name verbatim)
4. SDXL Pass-2 render at 1536x640
5. Gemini image-edit push to flat hand-painted-silk
6. INSERT as Studio design with notes JSON carrying source vendor +
pattern name (internal-only audit, never customer-facing)
Test batch of 6 (one SDXL failed, 5 succeeded):
39278, 39280, 39282, 39284, 39286 — published Studio
~$0.20 in Gemini cost, 193s wall time
## 3. wallco / Scenic landing pitch
Scrubbed 'Phillip Jeffries or Calico mural' → 'trade-grade photoreal mural'.
Smoke confirms zero competitor-name occurrences on /scenic.
## 4. Brand reference PG table (already committed in prior commit)
wallco_brand_references in dw_unified holds the 9 competitor + hospitality
brands (Phillip Jeffries / Calico / Rebel Walls / Zuber / Gracie /
de Gournay / Aman / Auberge / Four Seasons) as a wallco-OWNED reference
library, not hardcoded in code. Phase 2 (pulling landing-page copy from
this table at request time) deferred — current pitch copy is hardcoded
generic descriptors which is fine for now.
---
scripts/generate-vendor-inspired.js | 337 ++++++++++++++++++++++++++++++++++++
server.js | 81 +++++----
2 files changed, 382 insertions(+), 36 deletions(-)
diff --git a/scripts/generate-vendor-inspired.js b/scripts/generate-vendor-inspired.js
new file mode 100644
index 0000000..0604313
--- /dev/null
+++ b/scripts/generate-vendor-inspired.js
@@ -0,0 +1,337 @@
+#!/usr/bin/env node
+/**
+ * Vendor-inspired wallco generator — Steve directive 2026-05-22.
+ *
+ * "use the shared unified postgresql database but only use the files for
+ * ideas for wallco to generate."
+ *
+ * Mines the *_catalog tables in dw_unified for AESTHETIC ideas only —
+ * palette anchor + motif category + style vocabulary — and renders wallco
+ * originals via the SDXL→Gemini 2-pass recipe. Never names the source
+ * vendor or copies the source pattern name verbatim; the source vendor is
+ * recorded ONLY in an admin-internal note field, never customer-facing.
+ *
+ * Pipeline per design:
+ * 1. Pick a random row from a curated inspiration-rich vendor catalog
+ * 2. Extract palette (color_hex), abstract motif category, ai_styles/patterns
+ * 3. Compose an abstract wallco prompt — NO vendor name, NO source pattern name
+ * 4. SDXL Pass-2 cactus/subject-first render @ 1536×640
+ * 5. Gemini-2.5-flash-image-edit push to flat hand-painted-silk
+ * 6. INSERT row with product_line='Studio', category='cactus-pine-scenic',
+ * notes JSON carrying the inspiration source for internal audit
+ *
+ * Cost: ~$0.04/design (Gemini) + ~$0.003 (SDXL) = ~$0.04 each.
+ * Default batch size: 6. Override with N=NN env var.
+ */
+
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync, spawnSync } = require('child_process');
+
+const KEY = process.env.GEMINI_API_KEY;
+if (!KEY) { console.error('GEMINI_API_KEY missing'); process.exit(1); }
+const OUT_DIR = path.join(__dirname, '..', 'data', 'generated');
+const ROOT = path.join(__dirname, '..');
+const BATCH_N = parseInt(process.env.N || '6', 10);
+
+const DB = 'dw_unified';
+function psql(sql) { return execSync(`psql ${DB} -At -q`, { input: sql, encoding: 'utf8' }).trim(); }
+function esc(v) { if (v == null) return 'NULL'; return "'" + String(v).replace(/'/g, "''") + "'"; }
+
+// Curated inspiration-rich vendor catalogs. Each has >50 usable rows with
+// pattern_name + color_hex + ai_styles/ai_patterns signal. Vendor identity
+// is used ONLY for sampling (which table to pull from); it NEVER appears
+// in the generated prompt or any customer-facing field.
+const INSPIRATION_VENDORS = [
+ 'thibaut_catalog',
+ 'rebel_walls_catalog',
+ 'arte_catalog',
+ 'phillipe_romano_catalog',
+ 'harlequin_catalog',
+ 'missoni_catalog',
+ 'armani_casa_catalog',
+ 'nlxl_catalog',
+ 'pj_catalog',
+ 'cole_son_catalog',
+];
+
+// Translate a source pattern name into an ABSTRACT motif vocabulary so the
+// wallco prompt captures the aesthetic ESSENCE without echoing the source's
+// distinctive language. Match by keyword family, NOT by literal pattern name.
+function abstractMotif(patternName) {
+ const p = String(patternName || '').toLowerCase();
+ if (/cloud|sky|atmosphere|mist|fog/.test(p)) return 'soft atmospheric cloud-form silhouettes';
+ if (/wave|ocean|sea|tide|surf/.test(p)) return 'flowing abstract wave-band silhouettes';
+ if (/leaf|leaves|frond|foliage|botanical/.test(p)) return 'large flat foliage silhouettes';
+ if (/flower|floral|bloom|petal|rose|peony|lily/.test(p)) return 'oversized flat flower silhouettes';
+ if (/tree|forest|branch|trunk|canopy/.test(p)) return 'tall vertical tree-trunk silhouettes';
+ if (/mountain|peak|ridge|range/.test(p)) return 'layered flat mountain-band silhouettes';
+ if (/bird|crane|heron|peacock/.test(p)) return 'flat avian silhouettes';
+ if (/garden|courtyard|meadow|grove/.test(p)) return 'mixed-flora garden silhouettes';
+ if (/geometric|grid|stripe|chevron|diamond|hexagon/.test(p)) return 'restrained geometric silhouettes';
+ if (/damask|trellis|lattice/.test(p)) return 'classical trellis silhouettes';
+ if (/cactus|saguaro|agave|desert|sonoran/.test(p)) return 'flat Sonoran cactus silhouettes';
+ if (/cypress|palm|coastal/.test(p)) return 'windswept coastal silhouettes';
+ // Generic fallback — "abstract botanical" reads safe and luxe
+ return 'abstract flat hand-painted silhouettes';
+}
+
+// Pick the Studio sub-pack from the abstract motif and color hue.
+function inferPack(abstractMotifText) {
+ if (/wave|coastal|ocean|cypress/.test(abstractMotifText)) return 'Coast';
+ if (/tree|forest|leaf|foliage|garden|flower/.test(abstractMotifText)) return 'Forest';
+ return 'Desert';
+}
+
+// Hue-friendly palette pair derived from a single anchor hex. Returns
+// { ground, ground_name, figure, figure_name } where ground is the anchor
+// and figure is a contrasting tortilla-bone cream OR cypress-charcoal.
+function paletteFromAnchor(hex) {
+ const h = String(hex || '#888888').toLowerCase();
+ if (!/^#[0-9a-f]{6}$/.test(h)) return {
+ ground: '#7A4A28', ground_name: 'burnt-umber autumn saddle',
+ figure: '#F2EADB', figure_name: 'tortilla-bone cream',
+ };
+ const r = parseInt(h.slice(1,3), 16) / 255;
+ const g = parseInt(h.slice(3,5), 16) / 255;
+ const b = parseInt(h.slice(5,7), 16) / 255;
+ const l = (Math.max(r,g,b) + Math.min(r,g,b)) / 2;
+ // Dark anchor → ground=anchor, figure=cream. Light anchor → ground=anchor, figure=charcoal.
+ if (l < 0.5) {
+ return { ground: h, ground_name: `deep ${nameForHex(h)} ground`,
+ figure: '#F2EADB', figure_name: 'tortilla-bone cream' };
+ }
+ return { ground: h, ground_name: `pale ${nameForHex(h)} ground`,
+ figure: '#2A2520', figure_name: 'cypress-charcoal' };
+}
+function nameForHex(hex) {
+ // Cheap human-readable hue name for the prompt. Not exact — just enough
+ // to make the prompt read like real designer copy.
+ const r = parseInt(hex.slice(1,3), 16) / 255;
+ const g = parseInt(hex.slice(3,5), 16) / 255;
+ const b = parseInt(hex.slice(5,7), 16) / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ const s = max === min ? 0 : (max - min) / (((max + min) / 2) > 0.5 ? 2 - max - min : max + min);
+ if (s < 0.12) return (max < 0.3) ? 'charcoal' : (max < 0.7) ? 'stone' : 'cream';
+ let hue = 0; const d = max - min;
+ if (max === r) hue = (g - b) / d + (g < b ? 6 : 0);
+ else if (max === g) hue = (b - r) / d + 2;
+ else hue = (r - g) / d + 4;
+ hue = (hue * 60 + 360) % 360;
+ if (hue < 20 || hue >= 350) return 'coral';
+ if (hue < 45) return 'amber';
+ if (hue < 75) return 'gold';
+ if (hue < 150) return 'sage';
+ if (hue < 200) return 'teal';
+ if (hue < 250) return 'indigo';
+ if (hue < 290) return 'plum';
+ return 'rose';
+}
+
+const SDXL_NEG =
+ 'gradient, sky gradient, atmospheric perspective, photoreal, photorealism, photograph, oil painting, watercolor, halftone, ' +
+ 'crosshatching, palm, banana, monstera, grapes, birds, butterflies, room mockup, perspective, people, furniture, ' +
+ 'watermark, signature, text, logos, border, frame, depth-of-field, bokeh, lens flare.';
+
+const GEMINI_PUSH = (cw) => (
+ 'Transform this scenic mural into a flat HAND-PAINTED-SILK SCENIC WALLCOVERING in the historic flat-block-printed register. ' +
+ 'Flat solid color regions only — NO gradient, NO atmospheric perspective, NO half-tones, NO crosshatching. ' +
+ 'Hard razor-sharp silhouette edges. Every figure region is one flat color tone. ' +
+ 'Sky area is ONE flat solid color from edge to edge — no clouds, no gradient. ' +
+ 'Distant mountains as a single flat silhouette band. ' +
+ '\n\nPalette (strict 2-tone): ' +
+ `Ground: ${cw.ground_name} (hex ${cw.ground}). ` +
+ `Figure: ${cw.figure_name} (hex ${cw.figure}). ` +
+ '\n\nPreserve composition + layout. Only transform the visual register. ' +
+ 'No watermark, no text, no border, no signature.'
+);
+
+const PRINT_W_IN = 720;
+const PRINT_H_IN = 132;
+const SDXL_W = 1536;
+const SDXL_H = 640;
+
+const sdxlEnv = {
+ ...process.env,
+ COMFY_WIDTH: String(SDXL_W),
+ COMFY_HEIGHT: String(SDXL_H),
+ REPLICATE_WIDTH: String(SDXL_W),
+ REPLICATE_HEIGHT:String(SDXL_H),
+ GEN_BACKEND: 'replicate',
+};
+
+async function geminiEdit(localPng, cw, abstractMotifText) {
+ const imageB64 = fs.readFileSync(localPng).toString('base64');
+ const editPrompt = GEMINI_PUSH(cw);
+ const body = {
+ contents: [{ parts: [
+ { inline_data: { mime_type: 'image/png', data: imageB64 } },
+ { text: editPrompt },
+ ]}],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ };
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`;
+ const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ if (!r.ok) throw new Error(`Gemini HTTP ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ try {
+ const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
+ logGemini(j, { app: 'wallco-ai', note: 'vendor-inspired-' + abstractMotifText.replace(/\s+/g, '-').slice(0, 32), model: 'gemini-2.5-flash-image' });
+ } catch {}
+ const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ const data = part?.inline_data?.data || part?.inlineData?.data;
+ if (!data) throw new Error(`no image (${j.candidates?.[0]?.finishReason || 'unknown'})`);
+ const seed = crypto.randomInt(1, 2 ** 31 - 1);
+ const filename = `vendor_inspired_${Date.now()}_${seed}.png`;
+ const outPath = path.join(OUT_DIR, filename);
+ fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+ return { outPath, seed };
+}
+
+function paletteOf(p) {
+ const py = spawnSync('python3', ['-c', `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300, 300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata())
+cnt = Counter(pixels)
+total = sum(cnt.values())
+palette = [{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb), 'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]
+print(json.dumps(palette))
+`, p], { encoding: 'utf8' });
+ try { return JSON.parse(py.stdout.trim()); } catch { return []; }
+}
+
+function pickInspiration() {
+ // Random vendor first (uniform across the curated list), then random row
+ // from that vendor with usable signal. ORDER BY random() + LIMIT 1 is
+ // fine at this catalog size.
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const vendor = INSPIRATION_VENDORS[Math.floor(Math.random() * INSPIRATION_VENDORS.length)];
+ let row;
+ try {
+ row = psql(`SELECT row_to_json(t)::text FROM (
+ SELECT pattern_name, color_hex, ai_styles, ai_patterns, ai_tags
+ FROM ${vendor}
+ WHERE pattern_name IS NOT NULL AND pattern_name != ''
+ AND color_hex IS NOT NULL
+ ORDER BY random()
+ LIMIT 1
+ ) t`);
+ } catch { continue; }
+ if (!row || row.length < 5) continue;
+ let parsed;
+ try { parsed = JSON.parse(row); } catch { continue; }
+ return { vendor, ...parsed };
+ }
+ return null;
+}
+
+(async function main() {
+ console.log(`Vendor-inspired wallco generator — batch of ${BATCH_N}\n`);
+ const newIds = [];
+ const startedAt = Date.now();
+
+ for (let i = 0; i < BATCH_N; i++) {
+ const insp = pickInspiration();
+ if (!insp) { console.log(`[${i+1}/${BATCH_N}] no inspiration row — skip`); continue; }
+
+ const motif = abstractMotif(insp.pattern_name);
+ const cw = paletteFromAnchor(insp.color_hex);
+ const pack = inferPack(motif);
+ const aiStyles = Array.isArray(insp.ai_styles) ? insp.ai_styles.join(', ') : '';
+ const aiPatterns= Array.isArray(insp.ai_patterns) ? insp.ai_patterns.join(', ') : '';
+
+ // Compose SDXL prompt — never includes vendor or source pattern name.
+ const sdxlPrompt =
+ `A continuous 60-foot wide flat hand-painted-silk scenic wallcovering. ${motif.toUpperCase()} DOMINATE the canvas, ` +
+ `composed across the full width as one continuous landscape. ` +
+ `Style anchor: ${aiStyles || 'restrained luxury scenic'}. Pattern direction: ${aiPatterns || 'abstract scenic'}. ` +
+ `Palette: ${cw.ground_name} (${cw.ground}) ground with ${cw.figure_name} (${cw.figure}) figure. ` +
+ `Distant low mountain or horizon band as a flat silhouette. ` +
+ `Solid ${cw.ground_name} sky tone, NO gradient. Flat hand-painted-silk register.`;
+
+ process.stdout.write(`[${(i+1).toString().padStart(2,' ')}/${BATCH_N}] insp=${insp.vendor} motif=${motif.slice(0,40)} pack=${pack} → SDXL... `);
+
+ const sdxlR = spawnSync('node', [
+ path.join(__dirname, 'generate_designs.js'),
+ '--n', '1', '--kind', 'mural_panel',
+ '--width', String(PRINT_W_IN),
+ '--height', String(PRINT_H_IN),
+ '--panels', '20',
+ '--category', 'cactus-pine-scenic',
+ '--prompts', sdxlPrompt + ' ' + SDXL_NEG,
+ ], { cwd: ROOT, encoding: 'utf8', env: sdxlEnv });
+ const m = (sdxlR.stdout || '').match(/#(\d+)\s*·/);
+ if (!m) {
+ const reason = ((sdxlR.stdout || '') + '\n' + (sdxlR.stderr || ''))
+ .split('\n').filter(l => /✗|Error|FAILED|timed out|undefined/.test(l)).slice(-1).join(' | ')
+ || (sdxlR.stderr || sdxlR.stdout || '').slice(-160);
+ console.log(`SDXL FAIL: ${reason.slice(0, 120)}`);
+ continue;
+ }
+ const sdxlId = parseInt(m[1], 10);
+ process.stdout.write(`#${sdxlId} → Gemini... `);
+
+ const srcPath = psql(`SELECT local_path FROM spoon_all_designs WHERE id = ${sdxlId}`);
+ if (!srcPath || !fs.existsSync(srcPath)) {
+ console.log(`SDXL output not found`);
+ continue;
+ }
+ // Unpublish raw SDXL source (only the Gemini Studio version is customer-facing)
+ psql(`UPDATE spoon_all_designs SET is_published = FALSE WHERE id = ${sdxlId}`);
+
+ try {
+ const t0 = Date.now();
+ const edited = await geminiEdit(srcPath, cw, motif);
+ const palette = paletteOf(edited.outPath);
+ const dominant = palette[0]?.hex || cw.ground;
+
+ const internalNotes = JSON.stringify({
+ inspiration_vendor: insp.vendor, // internal-only audit, never customer-facing
+ inspiration_pattern_name: insp.pattern_name,
+ inspiration_color_hex: insp.color_hex,
+ abstract_motif: motif,
+ studio_pack: pack,
+ });
+
+ const ins = psql(`
+INSERT INTO spoon_all_designs
+ (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+ image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
+ product_line, notes)
+VALUES
+ ('mural_panel', 'wallco.ai', ${PRINT_W_IN}, ${PRINT_H_IN}, 20,
+ 'gemini-2.5-flash-image-edit',
+ ${esc('Vendor-inspired: ' + motif + ' (pack=' + pack + ') from SDXL #' + sdxlId + ': ' + sdxlPrompt.slice(0, 1200))},
+ ${edited.seed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(edited.outPath)},
+ ${esc(dominant)},
+ ${esc(JSON.stringify(palette))}::jsonb,
+ NULL,
+ NULL,
+ 'cactus-pine-scenic',
+ TRUE,
+ 'Studio',
+ ${esc(internalNotes)})
+RETURNING id`);
+ const newId = parseInt(ins, 10);
+ psql(`UPDATE spoon_all_designs SET image_url = '/designs/img/by-id/${newId}', is_published = TRUE WHERE id = ${newId}`);
+ newIds.push(newId);
+ console.log(`#${newId} (${((Date.now() - t0)/1000).toFixed(1)}s)`);
+ } catch (err) {
+ console.log(`Gemini FAIL: ${err.message.slice(0, 120)}`);
+ }
+ }
+
+ const elapsed = ((Date.now() - startedAt)/1000).toFixed(1);
+ console.log(`\nDone in ${elapsed}s — ${newIds.length}/${BATCH_N} ok`);
+ console.log('New IDs: ' + JSON.stringify(newIds));
+})();
diff --git a/server.js b/server.js
index e972ddb..b36b8dc 100644
--- a/server.js
+++ b/server.js
@@ -753,7 +753,7 @@ function titleFor(cat, hex, id) {
// card in one read. Replaces the silence on the background-image divs.
function cardAriaLabel(d) {
const hue = hueName(d.dominant_hex || '').toLowerCase();
- const cat = (d.category || 'pattern').toLowerCase();
+ const cat = categoryDisplay(d.category).toLowerCase();
const rooms = (d.room_mockups || []).length;
const roomTxt = rooms > 1 ? `, ${rooms} room previews available`
: rooms === 1 ? ', 1 room preview available' : '';
@@ -1159,26 +1159,13 @@ document.getElementById('btn-merge').addEventListener('click', function(){
app.get('/designs/img/by-id/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(404).end();
- let lp = null;
- // 1) try DESIGNS array (matches designs.json, platform-agnostic)
const d = DESIGNS.find(x => x.id === id);
- if (d && d.local_path) lp = d.local_path;
- // 2) fallback to PG (only useful on the box where the row was inserted)
- if (!lp) {
- try {
- lp = psqlExecLocal(`SELECT local_path FROM spoon_all_designs WHERE id=${id}`);
- } catch {}
- }
- if (!lp) return res.status(404).end();
- // local_path may be an absolute Mac2 path that doesn't exist on prod —
- // resolve by basename against the local IMG_DIR.
- const basename = String(lp).split('/').pop();
- const candidate1 = path.resolve(lp);
- const candidate2 = path.join(IMG_DIR, basename);
- const finalPath = fs.existsSync(candidate1) ? candidate1
- : fs.existsSync(candidate2) ? candidate2
- : null;
- if (!finalPath) return res.status(404).end();
+ if (!d) return res.status(404).end();
+ // Resolve via the existing helper — filename (PII-free) → IMG_DIR/<filename>.
+ // Falls back to image_url path if filename is missing. NEVER reads
+ // d.local_path because designs.json no longer carries it (PII).
+ const finalPath = localPathForDesign(d);
+ if (!finalPath || !fs.existsSync(finalPath)) return res.status(404).end();
res.sendFile(finalPath);
});
@@ -3535,7 +3522,7 @@ function renderSubBrandLanding(req, res, line) {
.sort((a, b) => b.id - a.id)
.slice(0, 6);
- const pitchScenic = 'wallco / Scenic — Photoreal hospitality wallcovering. Sonoran desert, Joshua tree groves, Carmel cypress headlands, coast redwood cathedrals — at the scale of a Phillip Jeffries or Calico mural, at AI-catalog scale and sub-$250 per panel. Designed for hospitality lobbies, hotel dining rooms, residential dining accents, design-forward corridors. 36" × 11 ft and 12 ft × 11 ft 4-panel formats. ' + total + ' designs.';
+ const pitchScenic = 'wallco / Scenic — Photoreal hospitality wallcovering. Sonoran desert, Joshua tree groves, Carmel cypress headlands, coast redwood cathedrals — at the scale of a trade-grade photoreal mural, at AI-catalog scale and sub-$250 per panel. Designed for hospitality lobbies, hotel dining rooms, residential dining accents, design-forward corridors. 36" × 11 ft and 12 ft × 11 ft 4-panel formats. ' + total + ' designs.';
const pitchStudio = 'wallco / Studio — Hand-painted-silk scenic wallcovering in the the luxury hand-painted scenic houses register, at 60% of their price point. Flat 2-tone hand-blocked silhouette, single-layer screenprint aesthetic. Designed for the most discerning hospitality installs (Aman, Auberge, Four Seasons) and the residential dining rooms of Designers Today readers. 60 ft × 11 ft wraparound installs (20 panels × 36"). ' + total + ' designs.';
const pitch = isStudio ? pitchStudio : pitchScenic;
const lineLower = line.toLowerCase();
@@ -3862,8 +3849,9 @@ app.get('/designs', (req, res) => {
const colorNameFromTitle = (String(d.title || '').match(/^(Honey|Amber|Rose|Sage|Olive|Marine|Sapphire|Mauve|Plum|Neutral|Bone|Champagne|Ivory|Emerald|Navy|Coral|Blush|Indigo|Charcoal)/i) || [])[1];
const colorName = colorNameFromTitle || (hueBucketOf(d.dominant_hex) || 'neutral').replace(/^./, c => c.toUpperCase());
// Style tag — category is the closest thing to a style category in the
- // data model (damask, butterfly-trellis, stoned-animals, mural-scenic).
- const styleTag = String(d.category || 'mixed').replace(/-/g, ' ');
+ // data model. categoryDisplay() translates slugs (cactus-pine-scenic →
+ // "Scenic Wraparound") so no internal taxonomy leaks to customers.
+ const styleTag = categoryDisplay(d.category);
// DW Original Number (DIG_AI_xxxxxx) — auto-assigned at DB INSERT
// time by trg_spoon_all_designs_assign_dig so it's always present.
// The fallback expression matches the trigger's exact output so we
@@ -7132,7 +7120,7 @@ app.get('/design/:id', (req, res) => {
if (hh<250) return 'sapphire'; if (hh<290) return 'mauve'; return 'plum';
})();
const _enrichedDesc =
- `wallco.ai original ${design.category} wallpaper pattern in ${_hueName} (${design.dominant_hex})` +
+ `wallco.ai original ${categoryDisplay(design.category).toLowerCase()} wallpaper pattern in ${_hueName} (${design.dominant_hex})` +
(_motifList ? `. Motifs: ${_motifList}.` : '.') +
` Pattern No.${design.id}, generated through our proprietary Curated design system — every pattern unique, never repeated.` +
((design.room_mockups || []).length > 0
@@ -7140,7 +7128,7 @@ app.get('/design/:id', (req, res) => {
: '') +
` Free sample available.`;
const _addlProps = [
- { "@type": "PropertyValue", "name": "Category", "value": design.category },
+ { "@type": "PropertyValue", "name": "Category", "value": categoryDisplay(design.category) },
_hueName && { "@type": "PropertyValue", "name": "Hue family", "value": _hueName },
design.dominant_hex && { "@type": "PropertyValue", "name": "Dominant color", "value": design.dominant_hex },
(design.motifs && design.motifs.length) && { "@type": "PropertyValue", "name": "Motifs", "value": design.motifs.join(', ') },
@@ -7157,7 +7145,7 @@ app.get('/design/:id', (req, res) => {
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://wallco.ai/" },
{ "@type": "ListItem", "position": 2, "name": "Designs", "item": "https://wallco.ai/designs" },
- { "@type": "ListItem", "position": 3, "name": String(design.category || '').replace(/^./, c => c.toUpperCase()), "item": `https://wallco.ai/designs?cat=${encodeURIComponent(design.category || '')}` },
+ { "@type": "ListItem", "position": 3, "name": categoryDisplay(design.category), "item": `https://wallco.ai/designs?cat=${encodeURIComponent(design.category || '')}` },
{ "@type": "ListItem", "position": 4, "name": design.title }
]
});
@@ -7171,7 +7159,7 @@ app.get('/design/:id', (req, res) => {
"image": `https://wallco.ai${design.image_url}`,
"sku": design.handle || `wallco-${String(design.id).padStart(4,'0')}`,
"productID": String(design.id),
- "category": design.category,
+ "category": categoryDisplay(design.category),
"color": _hueName || undefined,
"material": "Wallpaper",
"brand": { "@type": "Brand", "name": "wallco.ai" },
@@ -7189,16 +7177,16 @@ app.get('/design/:id', (req, res) => {
res.type('html').send(`${htmlHead({
title: `${design.title} — wallco.ai`,
- description: `wallco.ai original ${design.category} wallpaper. Pattern No.${design.id} — hand-curated, unique, never repeated. Free sample available.`,
+ description: `wallco.ai original ${categoryDisplay(design.category).toLowerCase()} wallpaper. Pattern No.${design.id} — hand-curated, unique, never repeated. Free sample available.`,
canonical: `https://wallco.ai/design/${design.id}`,
ogImage: `https://wallco.ai${design.image_url}`,
ogType: 'product',
- ogImageAlt: `${design.title} — ${_hueName} ${design.category} wallpaper pattern${_motifList ? ' featuring ' + _motifList : ''}`,
+ ogImageAlt: `${design.title} — ${_hueName} ${categoryDisplay(design.category).toLowerCase()} wallpaper pattern${_motifList ? ' featuring ' + _motifList : ''}`,
productOG: {
availability: 'in stock',
retailer_item_id: design.handle || `wallco-${String(design.id).padStart(4,'0')}`,
brand: 'wallco.ai',
- category: design.category
+ category: categoryDisplay(design.category)
},
// Preload the detail image — it's THE LCP candidate on /design/:id.
// Pairs with fetchpriority=high on the <img> tag so the browser
@@ -7241,8 +7229,8 @@ ${htmlHeader('/designs')}
<!-- INFO ─────────────────────────────────────────────────────── -->
<section class="edit-tabpane" data-pane="info">
<dl class="info-grid">
- <dt>Category</dt><dd>${design.category}</dd>
- <dt>Kind</dt><dd>${design.kind === 'seamless_tile' ? 'Seamless tile (24×24")' : design.kind}</dd>
+ <dt>Category</dt><dd>${categoryDisplay(design.category)}</dd>
+ <dt>Kind</dt><dd>${design.kind === 'seamless_tile' ? 'Seamless tile (24×24")' : design.kind === 'mural_panel' ? 'Mural panel' : design.kind}</dd>
<dt>Pattern No.</dt><dd>${String(design.id).padStart(4,'0')}</dd>
<dt>Generator</dt><dd>${design.generator}</dd>
<dt>Handle</dt><dd>${design.handle}</dd>
@@ -7306,7 +7294,7 @@ ${htmlHeader('/designs')}
<!-- STYLE ─────────────────────────────────────────────────────── -->
<section class="edit-tabpane" data-pane="style">
<h3 class="pane-h">Style attribution</h3>
- <p class="pane-text">${String(design.title || 'Untitled').replace(/[<>]/g,'')} reads as a <strong>${design.category || 'mixed'}</strong> design with a dominant ${design.dominant_hex || 'neutral'} tone.</p>
+ <p class="pane-text">${String(design.title || 'Untitled').replace(/[<>]/g,'')} reads as a <strong>${categoryDisplay(design.category)}</strong> design with a dominant ${design.dominant_hex || 'neutral'} tone.</p>
<h3 class="pane-h">Where it fits</h3>
<ul class="pane-list">
<li>Room: ${design.category === 'floral' ? 'bedroom · powder · breakfast nook' : design.category === 'damask' ? 'library · dining · formal' : design.category === 'geometric' ? 'modern lobby · home office · entry' : 'flexible'}</li>
@@ -8793,7 +8781,7 @@ ${htmlHeader('/designs')}
</div>
</div>
<div class="detail-info-col">
- <p class="detail-breadcrumb"><a href="/designs" id="back-to-grid">Designs</a> › ${design.category}</p>
+ <p class="detail-breadcrumb"><a href="/designs" id="back-to-grid">Designs</a> › ${categoryDisplay(design.category)}</p>
<script>
// Preserve the filtered grid URL the user came from so clicking Designs
// returns them to /designs?hue=rose&motif=floral, not bare /designs.
@@ -8831,7 +8819,7 @@ ${htmlHeader('/designs')}
<div class="provenance-badge">
${_isAdmin ? `<span class="ai-badge">Curated-Original</span>` : ''}
- Pattern No.${String(design.id).padStart(4,'0')} · ${(design.category || 'mixed').charAt(0).toUpperCase()+(design.category || 'mixed').slice(1)} · ${design.kind === 'seamless_tile' ? 'Seamless Tile' : (design.kind || 'pattern')}
+ Pattern No.${String(design.id).padStart(4,'0')} · ${categoryDisplay(design.category)} · ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind === 'mural_panel' ? 'Mural Panel' : (design.kind || 'pattern')}
</div>
<!-- USER VOTE — 5-star widget, anonymous session-cookie tracking -->
@@ -8986,7 +8974,7 @@ ${htmlHeader('/designs')}
when the user is in WALLPAPER (level≠0) and hidden in MURAL mode.
.mural-only is the inverse. -->
<div class="detail-spec-panel" data-design-kind="${design.kind}" data-product-line="${productLine(design) || ''}" data-design-id="${design.id}">
- <div class="spec-row"><span class="spec-key">Category</span><span class="spec-val">${design.category}</span></div>
+ <div class="spec-row"><span class="spec-key">Category</span><span class="spec-val">${categoryDisplay(design.category)}</span></div>
<div class="spec-row"><span class="spec-key">Width</span><span class="spec-val" id="spec-width">24"</span></div>
<div class="spec-row"><span class="spec-key">Material</span><span class="spec-val" id="spec-material">Paper</span></div>
<div class="spec-row"><span class="spec-key">Sold by</span><span class="spec-val" id="spec-soldby">Per roll (27 ft)</span></div>
@@ -12749,6 +12737,27 @@ function imageFilesOnDisk() {
}
return _imgExistsCache;
}
+// Category display-name translator — wallco.ai catalog uses lowercase
+// hyphenated category SLUGS internally (cactus-pine-scenic, cactus-11ft-mural)
+// but no luxury wallcovering customer should see those strings. This helper
+// maps each slug to a customer-facing display name. Falls back to titleCase
+// for unknown slugs.
+const CATEGORY_DISPLAY_MAP = {
+ 'cactus-pine-scenic': 'Scenic Wraparound',
+ 'cactus-11ft-mural': 'Single-Subject Mural',
+ 'cactus-accent-mural': 'Accent Mural',
+ 'tree-mural': 'Full-Room Scenic',
+ 'monterey-mural': 'Coastal Scenic',
+ 'mural-scenic': 'Scenic Mural',
+};
+function categoryDisplay(category) {
+ if (!category) return 'Pattern';
+ const slug = String(category).toLowerCase().trim();
+ if (CATEGORY_DISPLAY_MAP[slug]) return CATEGORY_DISPLAY_MAP[slug];
+ // Generic — titleCase the slug, replace hyphens with spaces
+ return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+}
+
// Product-line classifier — Steve directive 2026-05-21. Maps a design's
// category to its sub-brand: 'Scenic' (photoreal hospitality murals) or
// 'Studio' (flat hand-painted-silk luxury hand-painted scenic register). Designs
← f9b2acc refresh_designs_snapshot.py: emit local_path so /designs/img
·
back to Wallco Ai
·
fix /api/designs PII leak: drop local_path from designs.json 75da858 →