← 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 & 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 →