[object Object]

← back to Wallco Ai

wallco: AI Interior Designer button + Settlement Violation button on /design/:id

c7a227b2295a7435f136220facbce3fa2dd2b880 · 2026-05-13 13:10:24 -0700 · SteveStudio2

AI Interior Designer (admin-only, Info tab on edit-modal):
  POST /api/design/:id/ai-designer — Gemini vision reads the image and
  returns title, canonical pattern name, named color, tags, plus a hard
  seam-visible + contains-leaves check. Suggested values populate
  editable inputs; Apply persists to new ai_title / ai_pattern /
  ai_color_name / dig_number / tags columns. DIG# auto-assigned in
  DIG_AI_xxxxxx namespace when unassigned. Settlement gate refuses to
  tag/persist if Gemini reports banana/monstera/palm/tropical leaf
  content in the image.

Settlement Violation button (admin-only, next to Remove design):
  POST /api/design/:id/settlement-violation — HARD delete (URL goes
  404), backs up file to ~/.wallco-deleted/, appends prompt + motifs +
  tags signature to data/settlement-audit/do-not-want.jsonl, kicks a
  fresh regen with the violation excluded as a negative prompt. NOT
  reversible. Reserved for content we DO NOT WANT (banana leaves,
  monstera, tropical-leaf bleed-through).

Schema:
  ALTER TABLE spoon_all_designs ADD ai_title text, ai_pattern text,
  ai_color_name text, dig_number text (unique-when-not-null),
  ai_reviewed_at timestamptz, ai_review_raw jsonb.

Files touched

Diff

commit c7a227b2295a7435f136220facbce3fa2dd2b880
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 13:10:24 2026 -0700

    wallco: AI Interior Designer button + Settlement Violation button on /design/:id
    
    AI Interior Designer (admin-only, Info tab on edit-modal):
      POST /api/design/:id/ai-designer — Gemini vision reads the image and
      returns title, canonical pattern name, named color, tags, plus a hard
      seam-visible + contains-leaves check. Suggested values populate
      editable inputs; Apply persists to new ai_title / ai_pattern /
      ai_color_name / dig_number / tags columns. DIG# auto-assigned in
      DIG_AI_xxxxxx namespace when unassigned. Settlement gate refuses to
      tag/persist if Gemini reports banana/monstera/palm/tropical leaf
      content in the image.
    
    Settlement Violation button (admin-only, next to Remove design):
      POST /api/design/:id/settlement-violation — HARD delete (URL goes
      404), backs up file to ~/.wallco-deleted/, appends prompt + motifs +
      tags signature to data/settlement-audit/do-not-want.jsonl, kicks a
      fresh regen with the violation excluded as a negative prompt. NOT
      reversible. Reserved for content we DO NOT WANT (banana leaves,
      monstera, tropical-leaf bleed-through).
    
    Schema:
      ALTER TABLE spoon_all_designs ADD ai_title text, ai_pattern text,
      ai_color_name text, dig_number text (unique-when-not-null),
      ai_reviewed_at timestamptz, ai_review_raw jsonb.
---
 server.js | 492 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 459 insertions(+), 33 deletions(-)

diff --git a/server.js b/server.js
index ed4330d..886c88d 100644
--- a/server.js
+++ b/server.js
@@ -3144,29 +3144,33 @@ app.get('/designs', (req, res) => {
     </a>`;
     }
     // Admin review-mode card: sliders, K/R, chips, why-chip
+    const _sku = `DW-WC-${String(d.id).padStart(4, '0')}`;
     return `
     <div class="design-card review-card" data-id="${d.id}">
       <a href="/design/${d.id}" target="_blank" class="card-img" style="background-image:url('${d.image_url}')" rel="noopener noreferrer">
         <span class="decision-badge"></span>
       </a>
-      <div class="card-meta">
-        <span class="card-title">${d.title}</span>
-        <span class="card-cat">${d.category}</span>
-        <span class="color-dot" style="background:${d.dominant_hex}"></span>
-      </div>
-      <div class="review-controls">
-        <div class="sliders">
-          <label>Design <input type="range" min="0" max="10" value="5" data-axis="design"><span>5</span></label>
-          <label>Color  <input type="range" min="0" max="10" value="5" data-axis="color"><span>5</span></label>
-          <label>Style  <input type="range" min="0" max="10" value="5" data-axis="style"><span>5</span></label>
+      <span class="pattern-num" aria-hidden="true">${_sku}</span>
+      <div class="review-flyout">
+        <div class="card-meta">
+          <span class="card-title">${d.title}</span>
+          <span class="card-cat">${d.category}</span>
+          <span class="color-dot" style="background:${d.dominant_hex}"></span>
         </div>
-        <div class="decision">
-          <button class="btn-keep" type="button">Keep</button>
-          <button class="btn-reject" type="button">Reject</button>
-          <button class="btn-pair" type="button" title="Suggest combinations">Pair</button>
+        <div class="review-controls">
+          <div class="sliders">
+            <label>Design <input type="range" min="0" max="10" value="5" data-axis="design"><span>5</span></label>
+            <label>Color  <input type="range" min="0" max="10" value="5" data-axis="color"><span>5</span></label>
+            <label>Style  <input type="range" min="0" max="10" value="5" data-axis="style"><span>5</span></label>
+          </div>
+          <div class="decision">
+            <button class="btn-keep" type="button">Keep</button>
+            <button class="btn-reject" type="button">Reject</button>
+            <button class="btn-pair" type="button" title="Suggest combinations">Pair</button>
+          </div>
+          <div class="why-chip" title="Click to chat about this verdict"></div>
+          <div class="chips"></div>
         </div>
-        <div class="why-chip" title="Click to chat about this verdict"></div>
-        <div class="chips"></div>
       </div>
     </div>`;
   }).join('');
@@ -3925,24 +3929,37 @@ ${_isAdmin ? `
 <style>
   /* Review-mode controls (admin only — 127.0.0.1 / ?review=1) */
   :root { --keep:#5fbf6e; --reject:#d2554a; --chip-bg:#3a3631; --chip-hot:#d2b15c; }
-  .design-card.review-card { display:block; background:#1a1816; border:1px solid #2a2825; border-radius:3px; overflow:hidden; transition: border-color .15s; padding:0; position:relative; }
-  /* On load show only the image. Sliders / title / chips / buttons fade in on  */
-  /* hover so the catalog grid reads as clean covers first, review tools second. */
-  .design-card.review-card .card-meta,
-  .design-card.review-card .review-controls {
-    opacity: 0;
-    transform: translateY(4px);
+  .design-card.review-card { display:block; background:transparent; border:1px solid #2a2825; border-radius:3px; overflow:hidden; transition: border-color .15s; padding:0; position:relative; }
+  /* Card IS the image. Meta + controls live in a slide-up flyout pinned to the
+     bottom of the image — no black void below the image when the card is idle.
+     Hover or focus-within expands the flyout; a pattern-# pill also surfaces
+     at top-left of the image on hover. */
+  .design-card.review-card .review-flyout {
+    position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
+    background: linear-gradient(to top, rgba(15,14,12,0.96) 0%, rgba(15,14,12,0.86) 70%, rgba(15,14,12,0) 100%);
+    color: #e8e2d6;
+    max-height: 0; overflow: hidden;
+    transition: max-height .26s ease;
     pointer-events: none;
-    transition: opacity .18s ease, transform .18s ease;
   }
-  .design-card.review-card:hover .card-meta,
-  .design-card.review-card:hover .review-controls,
-  .design-card.review-card:focus-within .card-meta,
-  .design-card.review-card:focus-within .review-controls {
-    opacity: 1;
-    transform: translateY(0);
+  .design-card.review-card:hover .review-flyout,
+  .design-card.review-card:focus-within .review-flyout {
+    max-height: 300px;
     pointer-events: auto;
   }
+  /* Pattern # pill — visible only on hover, top-left of the image. */
+  .design-card.review-card .pattern-num {
+    position: absolute; top: 8px; left: 8px; z-index: 3;
+    padding: 3px 8px; font-size: 10px; font-weight: 600; letter-spacing: .06em;
+    background: rgba(15,14,12,0.78); color: #d2b15c;
+    border: 1px solid rgba(210,177,92,0.35); border-radius: 2px;
+    font-family: 'JetBrains Mono', Menlo, monospace;
+    opacity: 0; transform: translateY(-2px);
+    transition: opacity .18s ease, transform .18s ease;
+    pointer-events: none;
+  }
+  .design-card.review-card:hover .pattern-num,
+  .design-card.review-card:focus-within .pattern-num { opacity: 1; transform: translateY(0); }
   /* Keep decision border + opacity dim visible regardless (so kept/rejected */
   /* state shows at a glance without hovering) */
   .design-card.review-card[data-decision]:not([data-decision=""]) .decision-badge { opacity:1 !important; }
@@ -5149,6 +5166,101 @@ app.post('/api/design/:id/retry', express.json({ limit: '32kb' }), (req, res) =>
   }
 });
 
+// POST /api/design/:id/settlement-violation — HARD delete (URL goes 404),
+// append the prompt+motifs+tags signature to the settlement do-not-want
+// registry, and queue a fresh regen with this violation excluded as a
+// negative prompt. Reserved for content we DO NOT WANT (banana leaves,
+// monstera, tropical-leaf bleed-through, etc.). NOT reversible.
+app.post('/api/design/:id/settlement-violation', express.json({ limit: '16kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const reason = String(req.body?.reason || '').slice(0, 300).trim();
+  if (!reason) return res.status(400).json({ error: 'reason required' });
+
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, category,
+      motifs, tags, generator, dominant_hex, local_path, parent_design_id
+      FROM spoon_all_designs WHERE id=${id}) t;`);
+    if (!raw) return res.status(404).json({ error: 'design not found' });
+    const d = JSON.parse(raw);
+
+    // 1) Backup file before delete (forensic only — never republish)
+    const backupDir = path.join(require('os').homedir(), '.wallco-deleted', new Date().toISOString().slice(0,10));
+    try { fs.mkdirSync(backupDir, { recursive: true }); } catch {}
+    let backedUp = null;
+    if (d.local_path && fs.existsSync(d.local_path)) {
+      const bn = `${id}_${path.basename(d.local_path)}`;
+      backedUp = path.join(backupDir, bn);
+      try { fs.copyFileSync(d.local_path, backedUp); } catch {}
+    }
+
+    // 2) Append do-not-want signature to settlement registry
+    const auditDir = path.join(__dirname, 'data', 'settlement-audit');
+    try { fs.mkdirSync(auditDir, { recursive: true }); } catch {}
+    const registryPath = path.join(auditDir, 'do-not-want.jsonl');
+    const violationEntry = {
+      ts: new Date().toISOString(),
+      id, reason,
+      prompt:   d.prompt   || null,
+      category: d.category || null,
+      motifs:   d.motifs   || [],
+      tags:     d.tags     || [],
+      generator: d.generator || null,
+      dominant_hex: d.dominant_hex || null,
+      parent_design_id: d.parent_design_id || null,
+      backup_file: backedUp,
+    };
+    try { fs.appendFileSync(registryPath, JSON.stringify(violationEntry) + '\n', 'utf8'); } catch {}
+
+    // 3) Hard delete: file from disk + DB row (URL becomes 404)
+    if (d.local_path && fs.existsSync(d.local_path)) {
+      try { fs.unlinkSync(d.local_path); } catch {}
+    }
+    psqlQuery(`DELETE FROM spoon_all_designs WHERE id=${id};`);
+    // Drop from in-memory DESIGNS cache so /design/:id 404s immediately
+    const idx = DESIGNS.findIndex(x => x.id === id);
+    if (idx >= 0) DESIGNS.splice(idx, 1);
+
+    // 4) Queue a fresh regen with this violation excluded as a negative prompt
+    let regenStarted = false;
+    let regenErr = null;
+    try {
+      const basePrompt = (d.prompt || `${d.category || 'mixed'} seamless wallpaper repeat`).trim();
+      // Strip any existing "Corrections:" tail so we don't compound
+      const cleanBase = basePrompt.replace(/\.\s*Corrections:[^.]*\.?\s*$/i, '').trim();
+      const negativeAddon =
+        `Corrections: ABSOLUTELY NO ${reason}. Settlement-blocked content for this design family: no banana leaves, no monstera leaves, no palm fronds, no tropical foliage, no plantain leaves — any plant content must be redrawn as the user's specified motif only.`;
+      const finalPrompt = `${cleanBase}. ${negativeAddon}`;
+      const { spawn } = require('child_process');
+      const p = spawn('node', [
+        path.join(__dirname, 'scripts', 'generate_designs.js'),
+        '--n', '1',
+        '--kind', 'seamless_tile',
+        '--category', d.category || 'mixed',
+        '--prompts', finalPrompt,
+      ], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
+           detached: true, stdio: 'ignore' });
+      p.unref();
+      regenStarted = true;
+    } catch (e) { regenErr = e.message; }
+
+    res.json({
+      ok: true,
+      deleted: true,
+      id,
+      url_now_404: true,
+      backed_up_to: backedUp,
+      do_not_want_entry: registryPath,
+      regen_started: regenStarted,
+      regen_error: regenErr,
+      note: 'Design hard-deleted. Future generations will be filtered through the do-not-want registry.',
+    });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/design/:id', (req, res) => {
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) {
@@ -5326,6 +5438,41 @@ ${htmlHeader('/designs')}
                 <dt>Handle</dt><dd>${design.handle}</dd>
                 <dt>Dominant hex</dt><dd><span class="hex-chip" style="background:${design.dominant_hex}"></span>${design.dominant_hex}</dd>
               </dl>
+
+              <!-- AI Interior Designer block — admin-only (front-end hides for
+                   non-admins; endpoint is requireAdmin so non-admins 404
+                   anyway). Generates title / pattern / color name / DIG# /
+                   tags via Gemini Vision per /interior-designer skill. -->
+              <div id="ai-designer-block" style="display:none;margin-top:24px;padding:16px;border:1px solid #d4cfc1;border-radius:8px;background:#faf7f1">
+                <div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
+                  <h3 class="pane-h" style="margin:0">AI Interior Designer</h3>
+                  <button id="ai-designer-run" type="button"
+                    style="padding:8px 14px;border:0;border-radius:4px;background:#1f1808;color:#f4ece0;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">
+                    Run AI Designer
+                  </button>
+                </div>
+                <p class="pane-readout" style="margin:6px 0 12px">
+                  Reads the image, suggests a designer title, canonical pattern name, named color, DIG number (if unassigned), and tag set. Review → Apply.
+                </p>
+                <div id="ai-designer-result" style="display:none">
+                  <div id="ai-designer-seam-warn" style="display:none;margin-bottom:10px;padding:8px 12px;border-radius:4px;background:#b00020;color:#fff;font:600 12px var(--sans);letter-spacing:.04em">
+                    ⚠ Visible seam detected — image may not tile cleanly. Consider regenerating.
+                  </div>
+                  <dl class="info-grid">
+                    <dt>Title</dt><dd><input id="ai-d-title"    type="text" style="width:100%;padding:6px 8px;border:1px solid #d4cfc1;border-radius:3px;font:13px var(--sans)"></dd>
+                    <dt>Pattern</dt><dd><input id="ai-d-pattern" type="text" style="width:100%;padding:6px 8px;border:1px solid #d4cfc1;border-radius:3px;font:13px var(--sans)"></dd>
+                    <dt>Color</dt><dd><input id="ai-d-color"     type="text" style="width:100%;padding:6px 8px;border:1px solid #d4cfc1;border-radius:3px;font:13px var(--sans)"></dd>
+                    <dt>DIG #</dt><dd><input id="ai-d-dig"       type="text" style="width:100%;padding:6px 8px;border:1px solid #d4cfc1;border-radius:3px;font:13px var(--sans);font-family:'SF Mono',ui-monospace,monospace"></dd>
+                    <dt>Tags</dt><dd><input id="ai-d-tags"       type="text" style="width:100%;padding:6px 8px;border:1px solid #d4cfc1;border-radius:3px;font:13px var(--sans)" placeholder="comma-separated"></dd>
+                  </dl>
+                  <p id="ai-d-notes" class="pane-readout" style="margin:10px 0 12px;font-style:italic"></p>
+                  <button id="ai-designer-apply" type="button"
+                    style="padding:9px 18px;border:0;border-radius:4px;background:#1f1808;color:#f4ece0;font:600 11px var(--sans);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">
+                    Apply &amp; Save
+                  </button>
+                  <span id="ai-designer-status" style="margin-left:12px;font:12px var(--sans);color:#1a3"></span>
+                </div>
+              </div>
             </section>
 
             <!-- COLOR ─────────────────────────────────────────────────────── -->
@@ -5536,9 +5683,14 @@ ${htmlHeader('/designs')}
              style="margin:18px 0;padding:14px 16px;background:var(--card-bg,#f4f0e8);border:1px solid var(--line,#d8d0c0);border-radius:8px;font:13px var(--sans,system-ui)">
           <div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:space-between">
             <strong style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">Rate this design</strong>
-            <button id="rating-remove" type="button"
-              style="background:none;border:1px solid #c84a3a;color:#c84a3a;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em"
-              title="Soft-remove from public listings — hides everywhere but row stays in DB">Remove design</button>
+            <div style="display:flex;gap:8px;flex-wrap:wrap">
+              <button id="rating-remove" type="button"
+                style="background:none;border:1px solid #c84a3a;color:#c84a3a;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em"
+                title="Soft-remove from public listings — hides everywhere but row stays in DB">Remove design</button>
+              <button id="rating-settlement" type="button"
+                style="background:#b00020;border:1px solid #b00020;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
+                title="HARD delete: removes from URL (404), logs motifs/prompt to settlement do-not-want registry, kicks a fresh regen with this violation excluded">Settlement Violation</button>
+            </div>
           </div>
           <div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:12px;align-items:center">
             <div class="rate-axis" data-axis="color_good">
@@ -5648,6 +5800,32 @@ ${htmlHeader('/designs')}
                 setTimeout(function(){ location.href = '/designs'; }, 800);
               }).catch(function(){ statusEl.textContent = 'remove error'; });
           });
+          // Settlement Violation — HARD delete + log to do-not-want + queue regen.
+          // Only to be used for content we DO NOT WANT (e.g., banana leaves).
+          document.getElementById('rating-settlement').addEventListener('click', function(){
+            var reason = prompt('Settlement Violation reason (e.g. "banana leaves visible", "monstera", "tropical foliage"):');
+            if (!reason || !reason.trim()) return;
+            if (!confirm('HARD delete design #'+did+'?\\nThis will:\\n  • remove the URL (404)\\n  • delete the file from disk\\n  • log the prompt/motifs/tags to the settlement do-not-want registry\\n  • queue a fresh regen excluding this violation\\nThis is NOT reversible.')) return;
+            statusEl.textContent = 'logging violation + regenerating…';
+            var params = new URLSearchParams(window.location.search);
+            var adminToken = params.get('admin') || localStorage.getItem('wallco_admin_token') || '';
+            fetch('/api/design/' + did + '/settlement-violation', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken },
+              credentials:'same-origin',
+              body: JSON.stringify({ reason: reason.trim() })
+            })
+              .then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); })
+              .then(function(res){
+                if (!res.ok) {
+                  statusEl.textContent = 'error: ' + (res.j.error || 'unknown');
+                  return;
+                }
+                statusEl.textContent = 'violation logged · regen queued';
+                setTimeout(function(){ location.href = '/designs'; }, 1200);
+              })
+              .catch(function(e){ statusEl.textContent = 'settlement-violation error: ' + e.message; });
+          });
         })();
         </script>
         ` : ''}
@@ -5660,7 +5838,64 @@ ${htmlHeader('/designs')}
             </label>
             <span id="scale-readout" style="font-family:var(--serif);font-size:18px;color:var(--ink);min-width:90px">24" repeat</span>
           </div>
+          <!-- Mural-size repeat stepper. Click Next → to walk through 1× (full
+               tile), 2×, 4×, 8× (room-scale), 16× (mural-scale) repeat counts.
+               Synchronizes the #scale-viewport background-size so a single
+               tap previews how the design feels at mural scale. -->
+          <div class="mural-stepper" style="display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap">
+            <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint)">Mural repeat</span>
+            <div id="mural-dots" style="display:flex;gap:6px">
+              <span class="mural-dot active" data-level="0" title="full tile"></span>
+              <span class="mural-dot" data-level="1" title="2×2"></span>
+              <span class="mural-dot" data-level="2" title="4×4"></span>
+              <span class="mural-dot" data-level="3" title="8×8 — room"></span>
+              <span class="mural-dot" data-level="4" title="16×16 — mural"></span>
+            </div>
+            <span id="mural-readout" style="font-family:var(--serif);font-size:14px;color:var(--ink-soft,#555);min-width:130px">full tile · 1×1</span>
+            <button type="button" id="mural-next" style="padding:6px 12px;border:1px solid var(--line);background:transparent;color:var(--ink);font:11px var(--sans);font-weight:600;letter-spacing:.06em;text-transform:uppercase;border-radius:4px;cursor:pointer;margin-left:auto">Next →</button>
+          </div>
+          <style>
+            .mural-dot { width:8px; height:8px; border-radius:50%; background:#d8d2c4; border:1px solid #b9b3a3; transition: background .15s, transform .15s; cursor:pointer; }
+            .mural-dot.active { background: var(--gold,#c9a14b); border-color: var(--gold,#c9a14b); transform: scale(1.25); }
+          </style>
           <div id="scale-viewport" style="width:100%;aspect-ratio:3/2;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:repeat;background-size:160px 160px"></div>
+          <script>
+          (function(){
+            // Mural-size stepper. Cycles 1×→2×→4×→8×→16× repeat counts on the
+            // existing #scale-viewport. Labels: "full tile · 1×1", "2×2",
+            // "4×4", "8×8 · room scale", "16×16 · mural". Persists last level
+            // in localStorage under "wallco.detail.mural-level" so the
+            // preference survives a refresh.
+            const LEVELS = [
+              { count: 1,  label: 'full tile · 1×1' },
+              { count: 2,  label: '2×2' },
+              { count: 4,  label: '4×4' },
+              { count: 8,  label: '8×8 · room scale' },
+              { count: 16, label: '16×16 · mural' },
+            ];
+            const vp     = document.getElementById('scale-viewport');
+            const dots   = document.querySelectorAll('#mural-dots .mural-dot');
+            const read   = document.getElementById('mural-readout');
+            const btn    = document.getElementById('mural-next');
+            const SLIDER = document.getElementById('scale-slider');
+            let level = 0;
+            try { const stored = localStorage.getItem('wallco.detail.mural-level'); if (stored!=null) level = Math.min(Math.max(parseInt(stored,10)||0, 0), LEVELS.length-1); } catch {}
+            function apply() {
+              const { count, label } = LEVELS[level];
+              if (vp) vp.style.backgroundSize = (100/count).toFixed(2) + '% ' + (100/count).toFixed(2) + '%';
+              dots.forEach((d,i) => d.classList.toggle('active', i===level));
+              if (read) read.textContent = label;
+              try { localStorage.setItem('wallco.detail.mural-level', String(level)); } catch {}
+            }
+            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(); }));
+            // The legacy "Pattern scale" slider keeps working — its background-size is
+            // overridden the moment the user clicks Next. If they touch the slider
+            // again, we hand control back to it (reset level → 0 read-only).
+            if (SLIDER) SLIDER.addEventListener('input', () => { level = 0; dots.forEach(d => d.classList.remove('active')); dots[0] && dots[0].classList.add('active'); if (read) read.textContent = 'full tile · 1×1'; });
+            apply();
+          })();
+          </script>
           <div style="display:flex;justify-content:space-between;font:10.5px var(--sans);color:var(--ink-faint);margin-top:6px;letter-spacing:.04em">
             <span>← Smaller repeat (denser pattern)</span>
             <span id="scale-width-hint">24" wide, sold per yard</span>
@@ -6846,6 +7081,75 @@ ${htmlHeader('/designs')}
           });
         });
 
+        // ── AI Interior Designer panel (admin-only) ────────────────────────
+        (function(){
+          var params = new URLSearchParams(window.location.search);
+          var adminToken = params.get('admin') || localStorage.getItem('wallco_admin_token') || '';
+          if (params.get('admin')) localStorage.setItem('wallco_admin_token', adminToken);
+          if (!adminToken) return;
+          var block = document.getElementById('ai-designer-block');
+          if (!block) return;
+          block.style.display = 'block';
+
+          var designId = parseInt(document.body.dataset.designId || '0', 10);
+          if (!designId) {
+            var sec = document.getElementById('rooms-gallery');
+            if (sec && sec.dataset.designId) designId = parseInt(sec.dataset.designId, 10);
+          }
+          if (!designId) {
+            // Last-ditch fallback — parse from URL
+            var m = window.location.pathname.match(/\/design\/(\d+)/);
+            if (m) designId = parseInt(m[1], 10);
+          }
+
+          var runBtn   = document.getElementById('ai-designer-run');
+          var applyBtn = document.getElementById('ai-designer-apply');
+          var status   = document.getElementById('ai-designer-status');
+          var result   = document.getElementById('ai-designer-result');
+          var seamWarn = document.getElementById('ai-designer-seam-warn');
+          var lastSuggestion = null;
+
+          async function runAI(apply){
+            status.textContent = apply ? 'Saving…' : 'Asking Gemini…';
+            status.style.color = '#555';
+            try {
+              var r = await fetch('/api/design/' + designId + '/ai-designer', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken },
+                body: JSON.stringify({ apply: !!apply })
+              });
+              var j = await r.json();
+              if (!r.ok) {
+                if (j.error === 'SETTLEMENT_VIOLATION') {
+                  status.style.color = '#b00020';
+                  status.textContent = '⚠ ' + j.message;
+                } else {
+                  status.style.color = '#b00020';
+                  status.textContent = 'Error: ' + (j.error || r.statusText);
+                }
+                return;
+              }
+              lastSuggestion = j.suggested;
+              result.style.display = 'block';
+              document.getElementById('ai-d-title').value   = j.suggested.title    || '';
+              document.getElementById('ai-d-pattern').value = j.suggested.pattern  || '';
+              document.getElementById('ai-d-color').value   = j.suggested.color_name || '';
+              document.getElementById('ai-d-dig').value     = j.suggested.dig_number || '';
+              document.getElementById('ai-d-tags').value    = (j.suggested.tags || []).join(', ');
+              document.getElementById('ai-d-notes').textContent = j.suggested.designer_notes || '';
+              seamWarn.style.display = j.suggested.seam_visible ? 'block' : 'none';
+              status.style.color = '#1a3';
+              status.textContent = apply ? '✓ Saved' : 'Review & Apply →';
+            } catch (e) {
+              status.style.color = '#b00020';
+              status.textContent = 'Error: ' + e.message;
+            }
+          }
+
+          runBtn.addEventListener('click', function(){ runAI(false); });
+          applyBtn.addEventListener('click', function(){ runAI(true); });
+        })();
+
         // ── 1. CSS-filter live adjustments ─────────────────────────────────
         var previewImg = document.getElementById('edit-preview-img');
         var adjState = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 };
@@ -10146,6 +10450,128 @@ Critique as a designer who places this in HIGH-END interiors (Hermès, Soho Hous
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// ── AI Interior Designer: assign metadata (title, pattern, color name, DIG#, tags)
+// Admin-only. Reads the design image, runs the interior-designer skill's
+// vision pass via Gemini, and persists ai_title / ai_pattern / ai_color_name /
+// dig_number / merged tags. The DIG number, when freshly assigned, slots into
+// the DW DIG_AI_xxxxxx namespace (no collision with vendor DIG_ SKUs because
+// those don't carry the AI_ infix). Returns the suggested values for a
+// confirm-before-save UI on /design/:id; pass `apply: true` to persist.
+app.post('/api/design/:id/ai-designer', requireAdmin, express.json({ limit: '16kb' }), async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const apply = req.body && req.body.apply === true;
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, local_path,
+      dominant_hex, palette, category, motifs, tags, dig_number, ai_title, ai_pattern,
+      ai_color_name FROM spoon_all_designs WHERE id=${id}) t;`);
+    const d = JSON.parse(raw || '{}');
+    if (!d.id) return res.status(404).json({ error: 'design not found' });
+    if (!d.local_path || !fs.existsSync(d.local_path)) {
+      return res.status(409).json({ error: 'image file missing on disk', local_path: d.local_path });
+    }
+
+    const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
+    if (!geminiKey) return res.status(503).json({ error: 'GEMINI_API_KEY not configured' });
+
+    const b64 = fs.readFileSync(d.local_path).toString('base64');
+    const r = await fetch(
+      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          contents: [{
+            parts: [
+              { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+              { text: `You are a luxury interior designer cataloging a new wallpaper pattern for Designer Wallcoverings.
+Look at the image and answer with STRICT JSON only. The current generation prompt is provided as a HINT only — your authority is what you actually SEE in the image, not what the prompt requested. If the image contains a motif the prompt tried to remove (e.g. leaves, plants, banana, monstera, palm fronds), say so — accuracy matters.
+
+Generation prompt hint: "${(d.prompt || '').slice(0, 500)}"
+
+Return STRICT JSON:
+{
+  "title": "<2-5 word evocative title, designer language, e.g. 'Iridescent Butterfly Trellis'>",
+  "pattern": "<one canonical pattern name from this list: Damask, Floral, Trellis, Botanical, Geometric, Stripe, Toile, Chinoiserie, Foulard, Abstract, Texture, Animal, Figurative, Mural, Other>",
+  "color_name": "<2-4 word color description, e.g. 'Charcoal & Jewel Tones', 'Bone White Champagne'>",
+  "tags": ["<5-10 short lowercase tags describing motifs and style>"],
+  "contains_leaves": <true|false — does the image visibly contain leaves, fronds, or banana/monstera/tropical plant content?>,
+  "leaf_kind": "<if contains_leaves true: 'banana'|'monstera'|'palm'|'tropical'|'generic'; else 'none'>",
+  "seam_visible": <true|false — does the image show visible horizontal or vertical seam lines / tile cut-marks indicating bad tiling?>,
+  "designer_notes": "<one sentence the catalog can quote>"
+}` }
+            ]
+          }],
+          generationConfig: { temperature: 0.2, maxOutputTokens: 500, responseMimeType: 'application/json' }
+        })
+      }
+    );
+    const j = await r.json();
+    try {
+      const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
+      logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'ai-designer ' + id });
+    } catch {}
+    const t = j.candidates?.[0]?.content?.parts?.[0]?.text;
+    let suggested = null;
+    if (t) {
+      try { suggested = JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); }
+      catch { return res.status(502).json({ error: 'gemini JSON parse failed', raw: t.slice(0, 400) }); }
+    }
+    if (!suggested) return res.status(502).json({ error: 'no candidate text from gemini' });
+
+    // Hard settlement gate — if Gemini reports leaves/banana, refuse to persist
+    if (suggested.contains_leaves === true && /^(banana|monstera|palm|tropical)$/i.test(suggested.leaf_kind || '')) {
+      return res.status(422).json({
+        error: 'SETTLEMENT_VIOLATION',
+        message: `Image contains ${suggested.leaf_kind} leaf content — settlement-blocked. Image will not be tagged or published.`,
+        suggested,
+      });
+    }
+
+    // Merge tags — preserve existing user/auto tags, add new lowercase tags from AI
+    const existingTags = Array.isArray(d.tags) ? d.tags : [];
+    const mergedTags = Array.from(new Set([
+      ...existingTags.map(s => String(s).toLowerCase().trim()).filter(Boolean),
+      ...((suggested.tags || []).map(s => String(s).toLowerCase().trim()).filter(Boolean))
+    ]));
+
+    // Assign DIG# if missing — DW digital-AI namespace
+    let digNumber = d.dig_number;
+    if (!digNumber) {
+      digNumber = `DIG_AI_${String(id).padStart(6, '0')}`;
+    }
+
+    if (apply) {
+      const tagsLit = "ARRAY[" + mergedTags.map(t => "'" + String(t).replace(/'/g,"''") + "'").join(',') + "]::text[]";
+      psqlQuery(`UPDATE spoon_all_designs SET
+        ai_title       = ${pgEsc(suggested.title || null)},
+        ai_pattern     = ${pgEsc(suggested.pattern || null)},
+        ai_color_name  = ${pgEsc(suggested.color_name || null)},
+        dig_number     = ${pgEsc(digNumber)},
+        tags           = ${mergedTags.length ? tagsLit : 'tags'},
+        ai_reviewed_at = NOW(),
+        ai_review_raw  = ${pgEsc(JSON.stringify(suggested))}::jsonb
+        WHERE id = ${id};`);
+    }
+
+    res.json({
+      ok: true,
+      id,
+      applied: apply,
+      suggested: {
+        title:      suggested.title || null,
+        pattern:    suggested.pattern || null,
+        color_name: suggested.color_name || null,
+        dig_number: digNumber,
+        tags:       mergedTags,
+        seam_visible: suggested.seam_visible === true,
+        designer_notes: suggested.designer_notes || null,
+      },
+      raw: suggested
+    });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 // Category insights: trends + best-sellers grid (stub today, wireable to ad-signals skill)
 app.get('/api/studio/category-insights', async (req, res) => {
   const cat = String(req.query.cat || '').toLowerCase().trim();

← ef4e20c settlement: hard-delete 14-design Cane Trellis chain (banana  ·  back to Wallco Ai  ·  wallco /design/:id: fix Design Coordinate panel (CORS) + buc 57798c8 →