[object Object]

← 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

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&nbsp;ft and 12&nbsp;ft × 11&nbsp;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&nbsp;ft and 12&nbsp;ft × 11&nbsp;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&nbsp;ft × 11&nbsp;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> &rsaquo; ${design.category}</p>
+      <p class="detail-breadcrumb"><a href="/designs" id="back-to-grid">Designs</a> &rsaquo; ${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')} &middot; ${(design.category || 'mixed').charAt(0).toUpperCase()+(design.category || 'mixed').slice(1)} &middot; ${design.kind === 'seamless_tile' ? 'Seamless Tile' : (design.kind || 'pattern')}
+        Pattern No.${String(design.id).padStart(4,'0')} &middot; ${categoryDisplay(design.category)} &middot; ${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&nbsp;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 →