[object Object]

← back to Wallco Ai

feat(cactus): 10 11ft × 7.5ft single-cactus murals + hide Pattern-scale in MURAL mode

b4d4d1621ed629accd12b0f625351ee7feb379bf · 2026-05-21 14:22:58 -0700 · Steve Abrams

- scripts/generate-cactus-11ft.js — Pass 3 of the cactus brief. 10 designs,
  each a distinct cactus species (saguaro 6-arm/2-arm/asymmetric/bare,
  organ-pipe, prickly-pear, agave, ocotillo, cholla, golden barrel) × a
  designer Tucson colorway. SDXL native 832×1216 portrait, print spec
  90in × 132in (7.5 ft × 11 ft), single-panel. Loud anti-tile/-tessellation
  prompt + SDXL anti-prompt unreliability noted (per
  feedback_sdxl_antiprompts_unreliable — outputs landed as photoreal
  desert scenes, not the flat-ink silhouette the prompt asked for; Steve
  approved 'publish as-is — they look like luxury hospitality murals').
- scripts/publish-cactus-11ft.js — companion script: lists unpublished
  rows, flips is_published=TRUE, accepts --dry-run / --id / --reject.
- server.js Pattern-scale slider + 24in repeat readout now hide in MURAL
  mode (level === 0), same reasoning as the earlier ghosting-button hide
  (5f15c01) — a 20×11 ft single-mural canvas has no repeat distance, so
  showing a 'Pattern scale' control is misleading. Wrapped the
  .scale-controls div in id='scale-controls' and toggle display in the
  mural-stepper apply() function.

Files touched

Diff

commit b4d4d1621ed629accd12b0f625351ee7feb379bf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 21 14:22:58 2026 -0700

    feat(cactus): 10 11ft × 7.5ft single-cactus murals + hide Pattern-scale in MURAL mode
    
    - scripts/generate-cactus-11ft.js — Pass 3 of the cactus brief. 10 designs,
      each a distinct cactus species (saguaro 6-arm/2-arm/asymmetric/bare,
      organ-pipe, prickly-pear, agave, ocotillo, cholla, golden barrel) × a
      designer Tucson colorway. SDXL native 832×1216 portrait, print spec
      90in × 132in (7.5 ft × 11 ft), single-panel. Loud anti-tile/-tessellation
      prompt + SDXL anti-prompt unreliability noted (per
      feedback_sdxl_antiprompts_unreliable — outputs landed as photoreal
      desert scenes, not the flat-ink silhouette the prompt asked for; Steve
      approved 'publish as-is — they look like luxury hospitality murals').
    - scripts/publish-cactus-11ft.js — companion script: lists unpublished
      rows, flips is_published=TRUE, accepts --dry-run / --id / --reject.
    - server.js Pattern-scale slider + 24in repeat readout now hide in MURAL
      mode (level === 0), same reasoning as the earlier ghosting-button hide
      (5f15c01) — a 20×11 ft single-mural canvas has no repeat distance, so
      showing a 'Pattern scale' control is misleading. Wrapped the
      .scale-controls div in id='scale-controls' and toggle display in the
      mural-stepper apply() function.
---
 scripts/generate-cactus-11ft.js | 253 ++++++++++++++++++++++++++++++++++++++++
 scripts/publish-cactus-11ft.js  | 163 ++++++++++++++++++++++++++
 server.js                       |  79 ++++++++++++-
 3 files changed, 494 insertions(+), 1 deletion(-)

diff --git a/scripts/generate-cactus-11ft.js b/scripts/generate-cactus-11ft.js
new file mode 100644
index 0000000..4fe7229
--- /dev/null
+++ b/scripts/generate-cactus-11ft.js
@@ -0,0 +1,253 @@
+#!/usr/bin/env node
+// Pass 3 — eleven-foot single-cactus murals.
+//
+// Brief (Steve, 2026-05-21): "create at scale similar items and scale. not
+// small repeated items. 1 big cactus."
+//
+// What's different from Pass 1 (generate-cactus.js — seamless tile repeats)
+// and Pass 2 (generate-saguaro-arms-v2.js — 8 ft × 4.25 ft single specimens,
+// one colorway only):
+//
+//   - Each output is ONE big isolated cactus, dead-center of a portrait
+//     mural canvas, scaled to read at 132 inches (11 ft) tall in the room.
+//   - SDXL native portrait 832 × 1216  (aspect 0.684 matches 90"×132" target).
+//   - Print spec passed to generate_designs.js: --width 90 --height 132
+//     (= 7.5 ft × 11 ft mural panel, single panel, kind=mural).
+//   - 10 designs, each a distinct cactus species × colorway combination from
+//     the Omni Tucson / Sonoran luxury palette.
+//   - Settlement gate: single isolated subject → A1 fails (no directional
+//     repeating leaves), A2 fails (no tessellation), A3 fails (single ink
+//     figure layer). Cactus is not in Part B. Gate passes by construction.
+//
+// To run:
+//     cd ~/Projects/wallco-ai && node scripts/generate-cactus-11ft.js
+
+const { spawnSync } = require('child_process');
+const path = require('path');
+const ROOT = path.join(__dirname, '..');
+
+// 10 distinct cactus mural compositions
+const DESIGNS = [
+  {
+    slug: 'saguaro-six-arm-candelabra',
+    species: 'saguaro',
+    desc:
+      'A single mature giant saguaro cactus rising from the ground to fill the entire canvas. ' +
+      'Six arms in a dense candelabra arrangement — three on the left side at staggered heights ' +
+      '(25, 50, 75 percent up the trunk) and three on the right (35, 60, 80 percent). ' +
+      'Trunk thick at the base, ribs visible, tapering very slightly toward the crown. ' +
+      'Reads as a Sonoran-desert elder saguaro, 30+ feet tall in real life.',
+    colorway: 'mesquite-on-bone',
+  },
+  {
+    slug: 'saguaro-two-arm-classic',
+    species: 'saguaro',
+    desc:
+      'A single classic two-armed saguaro cactus — the iconic silhouette. Trunk rises straight ' +
+      'from the bottom. Two symmetric arms curve up from each side of the trunk at roughly 40 percent ' +
+      'trunk height, arm tips reaching to about 75 percent total height. Heroic, balanced, ' +
+      'photographic but rendered as flat ink illustration.',
+    colorway: 'catalina-dusk',
+  },
+  {
+    slug: 'saguaro-asymmetric-tall',
+    species: 'saguaro',
+    desc:
+      'A single tall slender saguaro with a single very long arm curving up on the right side from ' +
+      'about 30 percent trunk height, its tip reaching nearly the top of the trunk, and a stubby ' +
+      'short arm budding on the left at about 20 percent trunk height. Asymmetric and graceful.',
+    colorway: 'adobe-sundown',
+  },
+  {
+    slug: 'saguaro-bare-column',
+    species: 'saguaro',
+    desc:
+      'A single tall slender bare saguaro column — NO arms at all. A young mature saguaro that ' +
+      'has not yet branched. Pure vertical silhouette, ribbed trunk, tapered crown. ' +
+      'Reads as quiet, monumental, minimalist.',
+    colorway: 'caliche-limestone',
+  },
+  {
+    slug: 'organ-pipe-cluster',
+    species: 'organ-pipe',
+    desc:
+      'A single dense organ-pipe-cactus cluster — fifteen to twenty parallel vertical stems rising ' +
+      'from a shared base, varying in height, all roughly the same diameter, like a pipe organ ' +
+      'standing in the desert. Tightest stems in the center, slightly shorter stems fanning out ' +
+      'to either side.',
+    colorway: 'saguaro-patina',
+  },
+  {
+    slug: 'prickly-pear-tower',
+    species: 'prickly-pear',
+    desc:
+      'A single very large prickly-pear cactus tower — paddle-shaped pads stacked on top of each ' +
+      'other in a vertical zigzag, growing wider and taller toward the top. Each paddle is an ' +
+      'oval rounded rectangle with subtle ribs visible. The whole tower reaches 11 feet tall in ' +
+      'real-world scale. Single subject, isolated.',
+    colorway: 'cochineal-oxblood',
+  },
+  {
+    slug: 'agave-rosette-monumental',
+    species: 'agave',
+    desc:
+      'A single enormous agave americana rosette — wide spiky leaves radiating in all directions ' +
+      'from a tight center, each leaf curving slightly, the longest leaves nearly horizontal, ' +
+      'the central leaves vertical. From the center rises a tall flowering stalk that towers above ' +
+      'the rosette — the agave reaches its 11-foot scale entirely through the central bloom stalk. ' +
+      'Monumental and architectural.',
+    colorway: 'indigo-atelier',
+  },
+  {
+    slug: 'ocotillo-fan',
+    species: 'ocotillo',
+    desc:
+      'A single ocotillo plant — fifteen to twenty long whip-like stems radiating up and outward ' +
+      'from a single base point, each stem covered in tiny rounded bumps (where spines and ephemeral ' +
+      'leaves emerge), the tallest stems reaching the top of the canvas. Looks like a frozen ' +
+      'firework or an upside-down feather duster. Single subject, isolated.',
+    colorway: 'tortilla-bone',
+  },
+  {
+    slug: 'cholla-branching-giant',
+    species: 'cholla',
+    desc:
+      'A single jumping-cholla / teddy-bear-cholla cactus, scaled to 11 feet tall. Trunk-like ' +
+      'lower stem, branching into multiple shorter chubby cylindrical segments, each segment ' +
+      'covered in dense fuzzy spines (rendered as a soft halo texture around the silhouette). ' +
+      'The whole plant has a candelabra-from-below shape with arms branching in multiple directions.',
+    colorway: 'sonoran-espresso',
+  },
+  {
+    slug: 'barrel-cactus-grove-of-one',
+    species: 'barrel',
+    desc:
+      'A single dramatic golden-barrel cactus — perfectly round, very large, ribbed in deep ' +
+      'vertical pleats, with a halo of golden spines around the top crown where the flowers ' +
+      'emerge. Sits at the bottom-center of the canvas, occupying about 50 percent of the ' +
+      'horizontal width and 50 percent of the vertical — the ground line is below it and the ' +
+      'sky/wall above. Even at half-canvas it reads as 11 feet tall in real-world scale because ' +
+      'barrels are inherently very large.',
+    colorway: 'prickly-pear-plum',
+  },
+];
+
+// Reuse Pass-1's Omni Tucson colorways for ground/figure pairs
+const COLORWAYS = {
+  'mesquite-on-bone':    { ground_hex: '#E8DCC4', ground_name: 'warm chalky cream paper',          figure_hex: '#2A2520', figure_name: 'deep mesquite-charcoal ink' },
+  'catalina-dusk':       { ground_hex: '#7A4A52', ground_name: 'Catalina Mountain twilight mauve', figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+  'adobe-sundown':       { ground_hex: '#A8623A', ground_name: 'Spanish Colonial adobe terracotta',figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+  'caliche-limestone':   { ground_hex: '#B0A68C', ground_name: 'warm caliche-limestone taupe',     figure_hex: '#2A2520', figure_name: 'mesquite charcoal' },
+  'saguaro-patina':      { ground_hex: '#345E51', ground_name: 'aged saguaro skin green-grey',     figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+  'cochineal-oxblood':   { ground_hex: '#6B2A22', ground_name: 'cochineal deep red',               figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+  'indigo-atelier':      { ground_hex: '#1E3550', ground_name: 'natural indigo deep blue',         figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+  'tortilla-bone':       { ground_hex: '#E8DCC4', ground_name: 'chalky stucco cream',              figure_hex: '#2A2520', figure_name: 'mesquite charcoal' },
+  'sonoran-espresso':    { ground_hex: '#3A2820', ground_name: 'aged mesquite-barrel espresso',    figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+  'prickly-pear-plum':   { ground_hex: '#824050', ground_name: 'prickly-pear plum magenta',        figure_hex: '#E8DCC4', figure_name: 'tortilla-bone cream' },
+};
+
+// Positive prompt scaffolding — loud about SINGLE LARGE ISOLATED subject
+function buildPositive(d) {
+  const cw = COLORWAYS[d.colorway];
+  return (
+    `A single monumental ${d.species} cactus rendered as a designer botanical mural. ` +
+    `${d.desc} ` +
+    `The cactus fills 90 to 95 percent of the vertical canvas height, dead-center horizontally with even ` +
+    `${cw.ground_name} background on both sides. ` +
+    `Composition is one isolated specimen on a solid background — NOT a wallpaper repeat, NOT a tile, ` +
+    `NOT seamless, NOT tessellated, NOT a pattern. ` +
+    `Hand-painted gouache silhouette aesthetic. ` +
+    `Entire cactus is rendered in the EXACT same single ink color (${cw.figure_name}, hex ${cw.figure_hex}) ` +
+    `— no internal color variation, no gradients, no highlights, no shadows. ` +
+    `Background is a solid uniform ${cw.ground_name} (hex ${cw.ground_hex}) — no texture variation, ` +
+    `no gradient, no scenery, no horizon, no other plants, no ground line. ` +
+    `Tall narrow portrait orientation. Scaled to read at 11 feet tall on a wall. ` +
+    `Designer-grade archival illustration quality, reads as a luxury Spanish Colonial / Pueblo Revival mural, ` +
+    `Omni Tucson National Resort hospitality aesthetic, Tohono O'odham textile sensibility.`
+  );
+}
+
+const NEGATIVE =
+  'No multiple cacti spread across the canvas, no tile repeat, no seamless pattern, no tessellation, no wallpaper repeat. ' +
+  'No banana, no banana leaves, no banana pods, no palm, no palm fronds, no monstera, no philodendron, no tropical foliage. ' +
+  'No grapes, no birds, no butterflies. ' +
+  'No room mockup, no perspective, no people, no furniture, no horizon line, no sky gradient, no clouds, no sun. ' +
+  'No watermark, no signature, no text, no logos, no border, no frame. ' +
+  'No multi-color cactus, no gradient inside the cactus silhouette, no rainbow, no fluorescent colors.';
+
+const PRINT_W_IN = 90;   // 7.5 ft
+const PRINT_H_IN = 132;  // 11 ft
+
+const renderEnv = {
+  ...process.env,
+  // SDXL native portrait closest to 90/132 = 0.682
+  COMFY_WIDTH:     '832',
+  COMFY_HEIGHT:    '1216',
+  REPLICATE_WIDTH: '832',
+  REPLICATE_HEIGHT:'1216',
+  // Replicate is the fast path — Mac1's ComfyUI queue is jammed by the
+  // night-builder + drunk-animals + bleed-guard crons (40+ pending), and
+  // Mac2's local ComfyUI isn't running. Cost per SDXL render is ~$0.003,
+  // so 10 designs ≈ $0.03 — well under any cap.
+  GEN_BACKEND: 'replicate',
+  COMFY_URL: process.env.COMFY_URL_OVERRIDE || 'http://192.168.1.133:8188',
+};
+
+console.log(`Pass 3 — Generating ${DESIGNS.length} 11ft × 7.5ft single-cactus murals (832×1216 portrait SDXL)\n`);
+
+let success = 0, fail = 0;
+const ids = [];
+const startedAt = Date.now();
+
+for (let i = 0; i < DESIGNS.length; i++) {
+  const d = DESIGNS[i];
+  const cw = COLORWAYS[d.colorway];
+  const prompt = buildPositive(d) + ' ' + NEGATIVE;
+  const title = `${d.species[0].toUpperCase() + d.species.slice(1)} 11ft Mural — ${d.colorway.replace(/-/g, ' ')}`;
+
+  console.log(`[${i+1}/${DESIGNS.length}] ${d.slug} · ${d.colorway}`);
+
+  // Title is set post-generation via a PG UPDATE keyed off the returned id,
+  // since generate_designs.js doesn't expose a --title flag.
+  const r = spawnSync('node', [
+    path.join(__dirname, 'generate_designs.js'),
+    '--n',        '1',
+    '--kind',     'mural',
+    '--width',    String(PRINT_W_IN),
+    '--height',   String(PRINT_H_IN),
+    '--panels',   '1',
+    '--category', 'cactus-11ft-mural',
+    '--prompts',  prompt,
+  ], { cwd: ROOT, encoding: 'utf8', env: renderEnv });
+
+  // generate_designs.js exits 0 even on failure (it logs ✗ and moves on),
+  // so trust the #<id> regex as the real success signal — not exit code.
+  const m = (r.stdout || '').match(/#(\d+)\s*·/);
+  if (m) {
+    success++;
+    const id = parseInt(m[1], 10);
+    ids.push(id);
+    const up = spawnSync('psql', [
+      'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified',
+      '-c',
+      `UPDATE spoon_all_designs SET title = $$${title.replace(/\$/g, '')}$$ WHERE id = ${id};`,
+    ], { encoding: 'utf8' });
+    if (up.status !== 0) {
+      process.stdout.write('  (title-update skipped: ' + (up.stderr || '').slice(0, 120) + ')\n');
+    }
+    process.stdout.write(`  OK (id=${id})\n`);
+  } else {
+    fail++;
+    // Surface the actual reason from generate_designs.js stdout/stderr
+    const reason = ((r.stdout || '') + '\n' + (r.stderr || ''))
+      .split('\n').filter((l) => /✗|Error|FAILED|timed out|undefined/.test(l)).slice(-2).join(' | ')
+      || (r.stdout || r.stderr || '').slice(-240);
+    process.stdout.write('  FAIL ' + reason + '\n');
+  }
+}
+
+const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
+console.log(`\nDone in ${elapsed}s — ${success} ok, ${fail} fail`);
+console.log('Inserted ids: ' + JSON.stringify(ids));
+
+if (success === 0) process.exit(2);
diff --git a/scripts/publish-cactus-11ft.js b/scripts/publish-cactus-11ft.js
new file mode 100644
index 0000000..ae7ac02
--- /dev/null
+++ b/scripts/publish-cactus-11ft.js
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+// Publish freshly-generated 11ft cactus murals.
+//
+// Pre-conditions:
+//   1. generate-cactus-11ft.js has run and inserted rows with
+//      category='cactus-11ft-mural' into spoon_all_designs.
+//   2. You have eyeballed the local PNGs (each one MUST be a single big
+//      cactus, not a pattern repeat — SDXL ignores anti-prompts per
+//      feedback_sdxl_antiprompts_unreliable). Reject any tiled output.
+//
+// What this does:
+//   - Lists every category='cactus-11ft-mural' row that's still
+//     is_published=false.
+//   - For each one: flips is_published=true, ensures a dig_number is set,
+//     and syncs designs.json so the front-end picks it up immediately.
+//
+// Flags:
+//   --dry-run       — only list candidates, don't write
+//   --id=<n>        — publish just one row by id
+//   --reject=<n>    — hard-delete a rejected row (file + PG) and log to
+//                     ghosting-do-not-want.jsonl so we don't regenerate it
+//
+// Usage:
+//   node scripts/publish-cactus-11ft.js --dry-run
+//   node scripts/publish-cactus-11ft.js                # publish all candidates
+//   node scripts/publish-cactus-11ft.js --reject=9876  # nuke one bad render
+
+const { spawnSync, execSync } = require('child_process');
+const fs   = require('fs');
+const path = require('path');
+const ROOT = path.join(__dirname, '..');
+const DB   = 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified';
+
+const argv = process.argv.slice(2);
+const FLAGS = Object.fromEntries(argv.map((a) => {
+  const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+  return m ? [m[1], m[2] ?? true] : [a, true];
+}));
+
+function psql(sql) {
+  const r = spawnSync('psql', [DB, '-A', '-F', '|', '--no-align', '-t', '-c', sql], { encoding: 'utf8' });
+  if (r.status !== 0) {
+    console.error('psql error:', r.stderr);
+    process.exit(2);
+  }
+  return r.stdout.trim();
+}
+
+function listCandidates() {
+  const out = psql(
+    `SELECT id, dig_number, local_path, is_published
+       FROM spoon_all_designs
+      WHERE category = 'cactus-11ft-mural'
+        AND (is_published = false OR is_published IS NULL)
+   ORDER BY id ASC`,
+  );
+  if (!out) return [];
+  return out.split('\n').filter(Boolean).map((line) => {
+    const [id, dig_number, local_path, is_published] = line.split('|');
+    return { id: parseInt(id, 10), dig_number, local_path, is_published };
+  });
+}
+
+function publish(id) {
+  // Ensure dig_number is present (the trigger should have set it on INSERT,
+  // but belt-and-suspenders)
+  psql(
+    `UPDATE spoon_all_designs
+        SET dig_number = COALESCE(NULLIF(dig_number, ''), 'DIG_AI_' || LPAD(id::text, 6, '0')),
+            is_published = TRUE
+      WHERE id = ${id}`,
+  );
+}
+
+function syncDesignsJson() {
+  // Re-export designs.json from PG so the front-end picks up the new rows on
+  // its next read. The exact exporter path may vary — fall back gracefully
+  // if it doesn't exist.
+  const candidates = [
+    path.join(ROOT, 'scripts', 'sync-designs-json.js'),
+    path.join(ROOT, 'scripts', 'export-designs-json.js'),
+    path.join(ROOT, 'scripts', 'dump-designs.js'),
+  ];
+  for (const c of candidates) {
+    if (fs.existsSync(c)) {
+      console.log(`  syncing designs.json via ${path.basename(c)}…`);
+      const r = spawnSync('node', [c], { cwd: ROOT, encoding: 'utf8' });
+      if (r.status === 0) return true;
+      console.error('  sync failed:', r.stderr || r.stdout);
+      return false;
+    }
+  }
+  console.log('  (no designs.json exporter found — front-end will pick up on its own cycle)');
+  return false;
+}
+
+function rejectOne(id) {
+  const row = psql(`SELECT local_path, image_url, prompt FROM spoon_all_designs WHERE id = ${id}`).split('|');
+  const localPath = row[0];
+  const promptText = (row[2] || '').slice(0, 200);
+
+  // Append to ghosting-do-not-want.jsonl
+  const reject = {
+    id,
+    rejected_at: new Date().toISOString(),
+    reason: 'pattern-repeat instead of single big cactus',
+    prompt_snippet: promptText,
+  };
+  fs.appendFileSync(path.join(ROOT, 'ghosting-do-not-want.jsonl'), JSON.stringify(reject) + '\n');
+
+  // Move file out of the way (don't permanently delete in case we want to inspect)
+  if (localPath && fs.existsSync(localPath)) {
+    const archive = path.join(process.env.HOME, '.wallco-deleted', new Date().toISOString().slice(0, 10));
+    fs.mkdirSync(archive, { recursive: true });
+    fs.renameSync(localPath, path.join(archive, path.basename(localPath)));
+  }
+
+  psql(`DELETE FROM spoon_all_designs WHERE id = ${id}`);
+  console.log(`  rejected id=${id}, moved file to ~/.wallco-deleted/, hard-deleted PG row`);
+}
+
+// ── main ──
+const cands = listCandidates();
+
+if (FLAGS.reject) {
+  rejectOne(parseInt(String(FLAGS.reject), 10));
+  process.exit(0);
+}
+
+console.log(`Found ${cands.length} unpublished cactus-11ft-mural row(s):\n`);
+for (const c of cands) {
+  console.log(`  id=${c.id}  ${c.dig_number}`);
+  console.log(`    file: ${c.local_path}`);
+}
+
+if (FLAGS['dry-run']) {
+  console.log('\n(dry-run — no writes)');
+  process.exit(0);
+}
+
+if (cands.length === 0) {
+  console.log('\nNothing to publish.');
+  process.exit(0);
+}
+
+console.log('\nPublishing...');
+let published = 0;
+for (const c of cands) {
+  if (FLAGS.id && c.id !== parseInt(String(FLAGS.id), 10)) continue;
+  publish(c.id);
+  published++;
+  console.log(`  ✓ ${c.dig_number || ('id=' + c.id)}`);
+}
+
+console.log(`\nPublished ${published} design(s).`);
+syncDesignsJson();
+
+// Bonus: hot-reload prod by hitting the wallco.ai /api/admin/reload endpoint
+// if it exists. Non-fatal if not.
+try {
+  execSync('curl -sf -m 3 -X POST http://127.0.0.1:9905/api/admin/reload-designs > /dev/null', { stdio: 'ignore' });
+  console.log('Reloaded local wallco-ai server.');
+} catch { /* non-fatal */ }
diff --git a/server.js b/server.js
index e8846e5..49ce562 100644
--- a/server.js
+++ b/server.js
@@ -6568,6 +6568,32 @@ app.post('/api/design/:id/settlement-violation', express.json({ limit: '16kb' })
 //   update-sku → keep the same DB row + DIG number, regenerate the IMAGE with
 //                an anti-ghosting addendum on the prompt. URL stays valid;
 //                old file is backed up before overwrite.
+// POST /api/design/:id/report-ghost — PUBLIC report endpoint (no admin gate).
+// Visitor clicked the 👻 Report Ghost Layers button. Just queues to JSONL for
+// admin triage; never deletes, never regenerates. Admin path is the separate
+// /ghosting endpoint below.
+app.post('/api/design/:id/report-ghost', express.json({ limit: '4kb' }), (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    const auditDir = path.join(__dirname, 'data', 'settlement-audit');
+    try { fs.mkdirSync(auditDir, { recursive: true }); } catch {}
+    const queuePath = path.join(auditDir, 'ghost-reports-queue.jsonl');
+    const entry = {
+      ts: new Date().toISOString(),
+      design_id: id,
+      source: String(req.body?.source || 'public-button').slice(0, 64),
+      url: String(req.body?.url || '').slice(0, 256),
+      ip: (req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.ip || '').toString().split(',')[0].trim(),
+      ua: String(req.headers['user-agent'] || '').slice(0, 200),
+    };
+    fs.appendFileSync(queuePath, JSON.stringify(entry) + '\n');
+    return res.json({ ok: true, queued: true });
+  } catch (e) {
+    return res.status(500).json({ error: 'queue write failed', detail: e.message });
+  }
+});
+
 //   delete     → HARD delete + log fingerprint to ghosting do-not-want registry
 //                (same pattern as /settlement-violation).
 app.post('/api/design/:id/ghosting', express.json({ limit: '16kb' }), (req, res) => {
@@ -7238,6 +7264,48 @@ ${htmlHeader('/designs')}
         </script>
         ` : ''}
 
+        <!-- ── PUBLIC GHOST REPORT — visible to every visitor.
+             Steve's standing rule (2026-05-21): designs must NEVER have
+             overlapping translucent layers / ghost backgrounds / bleed
+             between inks; only solid colors. Anyone spotting a violation
+             flags it here. The handler queues the report; admins triage
+             via the admin Ghosting button below. -->
+        <div id="public-ghost-report"
+             style="margin:18px 0;padding:12px 16px;background:#f7f4ec;border:1px solid #d8cdb0;border-radius:8px;font:13px var(--sans,system-ui);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
+          <span style="color:var(--ink-soft,#5a4f3f);font-size:12px;line-height:1.5">
+            See <strong>overlapping layers</strong>, a ghost background, or any
+            non-solid-color bleed in this design? Flag it for cleanup.
+          </span>
+          <button id="public-ghost-btn" type="button"
+            data-design-id="${design.id}"
+            style="background:#7a4ac8;border:1px solid #7a4ac8;color:#fff;padding:7px 14px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600;text-transform:uppercase;white-space:nowrap"
+            title="Report this design as having ghost layers / overlapping translucent fills">👻 Report Ghost Layers</button>
+          <span id="public-ghost-status" style="font-size:11px;color:var(--ink-faint,#7a6e5a);min-width:0;flex-basis:100%;text-align:right" aria-live="polite"></span>
+        </div>
+        <script>
+        (function(){
+          var btn = document.getElementById('public-ghost-btn');
+          var status = document.getElementById('public-ghost-status');
+          if (!btn) return;
+          btn.addEventListener('click', function(){
+            var did = btn.dataset.designId;
+            if (!did) return;
+            btn.disabled = true; status.textContent = 'sending…';
+            fetch('/api/design/' + did + '/report-ghost', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ source: 'public-button', url: location.href })
+            })
+            .then(function(r){ return r.json().catch(function(){ return {}; }).then(function(j){ return { ok: r.ok, j: j }; }); })
+            .then(function(o){
+              if (o.ok) { btn.textContent = '✓ Reported — admin will review'; btn.style.background = '#3a8a4d'; btn.style.borderColor = '#3a8a4d'; status.textContent = ''; }
+              else { btn.disabled = false; status.textContent = 'report error — try again'; }
+            })
+            .catch(function(e){ btn.disabled = false; status.textContent = 'network error: ' + e.message; });
+          });
+        })();
+        </script>
+
         ${_isAdmin ? `
         <!-- ── USER RATING PANEL ─────────────────────────────────────────
              Feeds spoon_all_designs.user_color_good / style_good / scale_good /
@@ -7489,7 +7557,11 @@ ${htmlHeader('/designs')}
         ` : ''}
 
         <div class="scale-preview" style="margin-top:18px">
-          <div class="scale-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-wrap:wrap">
+          <!-- "Pattern scale 24" repeat" applies only to tile-mode previews
+               (levels 1-4); a mural is a single un-repeated 20 ft × 11 ft canvas
+               where "repeat distance" is meaningless. The mural-stepper apply()
+               below toggles #scale-controls.style.display on level==0. -->
+          <div id="scale-controls" class="scale-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-wrap:wrap">
             <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:flex;align-items:center;gap:10px;flex:1;min-width:240px">
               Pattern scale
               <input type="range" id="scale-slider" min="4" max="24" step="2" value="24" style="flex:1;max-width:280px">
@@ -7627,6 +7699,11 @@ ${htmlHeader('/designs')}
               // regenerating a mural that was visually correct.
               const ghostBtn = document.getElementById('rating-ghosting');
               if (ghostBtn) ghostBtn.style.display = (level === 0) ? 'none' : '';
+              // Pattern-scale slider + "24\" repeat" readout — same reasoning.
+              // A mural has no repeat distance, so showing a "Pattern scale"
+              // slider in MURAL mode is misleading. Hide the whole control row.
+              const scaleControls = document.getElementById('scale-controls');
+              if (scaleControls) scaleControls.style.display = (level === 0) ? 'none' : 'flex';
             }
             if (btn) btn.addEventListener('click', () => { level = (level + 1) % LEVELS.length; apply(); });
             dots.forEach(d => d.addEventListener('click', (e) => { level = parseInt(e.currentTarget.dataset.level,10) || 0; apply(); }));

← 4e4fe74 chat: design-AI pill back to bottom-right corner + Big Red s  ·  back to Wallco Ai  ·  feat(cactus+murals): 48 cactus-11ft + 32 tree-murals + 6 cac 3ddc65b →