← back to Wallco Ai
admin/inspiration: two-step flow + Export decisions.json
d12476dbc4e19803543228fb2232a87fa697af6d · 2026-05-19 14:53:07 -0700 · SteveStudio2
Splits Generate into Step 1 (LLM-only /prompts, returns 3 SDXL prompts with per-prompt settlement_warnings) and Step 2 (/generate now accepts body.prompts so an edited prompt can be rendered solo). Client renders editable textareas with warning banners + per-prompt Render button + result tile; every action lands in localStorage wallco.admin.decisions, downloadable as wallco-decisions-YYYY-MM-DD.json. Lets the admin edit settlement-tripping terms before burning a Replicate call.
Files touched
M public/js/admin.jsM src/admin.js
Diff
commit d12476dbc4e19803543228fb2232a87fa697af6d
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 19 14:53:07 2026 -0700
admin/inspiration: two-step flow + Export decisions.json
Splits Generate into Step 1 (LLM-only /prompts, returns 3 SDXL prompts with per-prompt settlement_warnings) and Step 2 (/generate now accepts body.prompts so an edited prompt can be rendered solo). Client renders editable textareas with warning banners + per-prompt Render button + result tile; every action lands in localStorage wallco.admin.decisions, downloadable as wallco-decisions-YYYY-MM-DD.json. Lets the admin edit settlement-tripping terms before burning a Replicate call.
---
public/js/admin.js | 182 +++++++++++++++++++++++++++++++++++++++++------------
src/admin.js | 165 ++++++++++++++++++++++++++++--------------------
2 files changed, 240 insertions(+), 107 deletions(-)
diff --git a/public/js/admin.js b/public/js/admin.js
index 1b22205..4311c48 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -48,46 +48,150 @@
});
});
- // Generate-from-pool button
+ // Two-step Generate flow + Export decisions.json.
+ // Step 1 — Click gen-btn → POST /api/admin/inspiration/prompts → 3 SDXL
+ // prompts come back with per-prompt settlement_warnings. The
+ // admin can edit each prompt in a textarea before rendering.
+ // Step 2 — Click "Render →" on a card → POST /api/admin/inspiration/generate
+ // with { prompts:[edited] } → just THAT prompt is screened +
+ // sent to Replicate. The card swaps in either the new design
+ // tile or a clear error (blocked term, network, etc.).
+ // Every action lands in localStorage `wallco.admin.decisions` so the admin
+ // has an audit trail of which prompts were edited, which rendered, and
+ // which were blocked. "Export decisions.json" downloads that ledger.
const gen = document.getElementById('gen-btn');
- if (gen) gen.addEventListener('click', async () => {
- gen.disabled = true;
- const orig = gen.textContent;
- gen.textContent = 'Generating 3 designs… (~90s)';
- try {
- const r = await fetch('/api/admin/inspiration/generate', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- credentials: 'include'
+ if (gen) {
+ const DEC_KEY = 'wallco.admin.decisions';
+ const loadDecisions = () => { try { return JSON.parse(localStorage.getItem(DEC_KEY) || '[]'); } catch { return []; } };
+ const saveDecision = (d) => {
+ const a = loadDecisions();
+ a.push({ ts: new Date().toISOString(), ...d });
+ localStorage.setItem(DEC_KEY, JSON.stringify(a));
+ };
+ const escHtml = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+ const origGenText = gen.textContent;
+
+ function refreshDecCount() {
+ const el = document.getElementById('dec-count');
+ if (el) el.textContent = loadDecisions().length;
+ }
+
+ function renderPromptTray(container, prompts, warnings) {
+ container.innerHTML =
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">'
+ + '<h2 style="font:300 22px/1 var(--serif,Georgia);margin:0;color:var(--ink,#1a1208)">'
+ + prompts.length + ' prompt' + (prompts.length === 1 ? '' : 's') + ' — edit, then render</h2>'
+ + '<button id="dec-export" type="button" style="padding:6px 12px;border:1px solid var(--gold,#c69b3e);background:transparent;color:var(--gold,#c69b3e);border-radius:4px;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">'
+ + 'Export decisions.json (<span id="dec-count">' + loadDecisions().length + '</span>)</button>'
+ + '</div>'
+ + prompts.map(function (p, i) {
+ const w = warnings[i] || {};
+ const warnBanner = w.verdict === 'BLOCK'
+ ? '<div style="margin:8px 0;padding:8px 10px;background:#3a2419;border-left:3px solid #c46a3a;color:#e6b896;font:12px var(--sans,system-ui);border-radius:0 4px 4px 0">⚖ Contains "<b>' + escHtml(w.term) + '</b>" — the settlement gate will BLOCK this. Edit it out, then render.</div>'
+ : '<div style="margin:4px 0 8px;font:11px var(--sans,system-ui);color:#7fc77f;letter-spacing:.05em">✓ settlement-safe</div>';
+ return ''
+ + '<div class="dec-card" data-i="' + i + '" data-original="' + escHtml(p) + '"'
+ + ' style="margin-bottom:16px;padding:14px;background:var(--card-bg,#1a140c);border:1px solid var(--card-border,#3a2f1a);border-radius:6px">'
+ + '<div style="font:600 11px var(--sans,system-ui);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-muted,#8a7960);margin-bottom:6px">Prompt ' + (i + 1) + '</div>'
+ + warnBanner
+ + '<textarea class="dec-prompt" rows="4" style="width:100%;min-height:80px;padding:9px 11px;background:#0f0c08;color:var(--ink,#f4ece0);border:1px solid var(--card-border,#3a2f1a);border-radius:4px;font:13px/1.5 var(--sans,system-ui);box-sizing:border-box;resize:vertical">' + escHtml(p) + '</textarea>'
+ + '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">'
+ + '<span style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)">≈ $0.003 to render via Replicate · ~30 s</span>'
+ + '<button class="dec-render" type="button" style="padding:7px 14px;border:0;border-radius:4px;background:var(--gold,#c69b3e);color:#1a1208;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">Render →</button>'
+ + '</div>'
+ + '<div class="dec-result" style="margin-top:10px"></div>'
+ + '</div>';
+ }).join('');
+
+ container.querySelectorAll('.dec-render').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const card = btn.closest('.dec-card');
+ const original = card.dataset.original;
+ const ta = card.querySelector('.dec-prompt');
+ const edited = (ta.value || '').trim();
+ const resultEl = card.querySelector('.dec-result');
+ if (edited.length < 10) { resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Prompt too short — write at least 10 chars.</div>'; return; }
+ btn.disabled = true; btn.textContent = 'Rendering…';
+ resultEl.innerHTML = '<div style="color:var(--ink-muted,#8a7960);font:12px var(--sans,system-ui)">~30 s on Replicate…</div>';
+ const decBase = { original_prompt: original, edited_prompt: edited, edited: edited !== original, action: 'render' };
+ try {
+ const r = await fetch('/api/admin/inspiration/generate', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompts: [edited] }), credentials: 'include'
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ const msg = j.error || ('HTTP ' + r.status);
+ const hint = j.hint ? ' — ' + j.hint : (j.url ? ' (url: ' + j.url + ')' : '');
+ resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">' + escHtml(msg) + escHtml(hint) + '</div>';
+ saveDecision({ ...decBase, result: { error: msg, url: j.url || null } });
+ } else {
+ const d = (j.designs || [])[0] || {};
+ if (d.id) {
+ resultEl.innerHTML =
+ '<a href="/design/' + d.id + '" style="display:flex;gap:12px;align-items:center;padding:10px;background:#0f0c08;border:1px solid var(--card-border,#3a2f1a);border-radius:4px;text-decoration:none;color:inherit">'
+ + '<div style="width:60px;height:60px;background:url(\'' + escHtml(d.image_url) + '\') center/cover;border-radius:3px;flex:none"></div>'
+ + '<div><div style="font:600 13px var(--sans,system-ui)">Design #' + d.id + ' ✓</div>'
+ + '<div style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + escHtml(d.dominant_hex || '#888') + ';vertical-align:middle;margin-right:4px"></span>' + escHtml(d.dominant_hex || '') + '</div></div>'
+ + '</a>';
+ saveDecision({ ...decBase, result: { id: d.id, image_url: d.image_url, dominant_hex: d.dominant_hex } });
+ } else {
+ const err = d.error || 'no_design_returned';
+ const term = d.term ? ' — blocked term: "' + d.term + '"' : '';
+ resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">⚠ ' + escHtml(err) + escHtml(term) + '. Edit the prompt and try again.</div>';
+ saveDecision({ ...decBase, result: { error: err, blocked_term: d.term || null } });
+ }
+ }
+ } catch (e) {
+ resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Network error: ' + escHtml(e.message) + '</div>';
+ saveDecision({ ...decBase, result: { error: 'network: ' + e.message } });
+ }
+ btn.disabled = false; btn.textContent = 'Render →';
+ refreshDecCount();
+ });
+ });
+
+ document.getElementById('dec-export').addEventListener('click', () => {
+ const data = { exported_at: new Date().toISOString(), decisions: loadDecisions() };
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'wallco-decisions-' + new Date().toISOString().slice(0, 10) + '.json';
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
});
- const j = await r.json();
- if (!r.ok) {
- alert('Generation failed: ' + (j.error || r.status));
- gen.disabled = false; gen.textContent = orig; return;
- }
- const designs = j.designs || [];
- const made = designs.filter(d => d.id);
- const blocked = j.blocked_by_settlement || 0;
- // Render results above grid
- const out = document.createElement('div');
- out.style.cssText = 'margin:20px 0;padding:18px;background:var(--card-bg);border:1px solid var(--gold);border-radius:8px';
- out.innerHTML = `<h2 style="font:300 22px/1 var(--serif);margin:0 0 10px;color:var(--ink)">${made.length} new wallco originals from your pool</h2>
- ${blocked ? `<p style="margin:0 0 10px;font-size:13px;color:#9a3b2e">⚖ ${blocked} prompt${blocked > 1 ? 's' : ''} blocked by the settlement gate (birds / foliage / prohibited motifs) — click again to retry.</p>` : ''}
- <div class="p-grid" style="margin-top:10px">
- ${made.map(d => `
- <a href="/design/${d.id}" class="p-card" style="text-decoration:none;color:inherit">
- <div class="p-img" style="background-image:url('${d.image_url}')"></div>
- <div class="p-meta">
- <div class="p-name">Design #${d.id}</div>
- <div class="p-coll"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${d.dominant_hex};vertical-align:middle;margin-right:4px"></span>${d.dominant_hex}</div>
- </div>
- </a>`).join('')}
- </div>`;
- gen.after(out);
- gen.textContent = `✓ Generated ${made.length} · click to generate 3 more`;
- gen.disabled = false;
- } catch (e) {
- alert('Generation error: ' + e.message);
- gen.disabled = false; gen.textContent = orig;
}
- });
+
+ gen.addEventListener('click', async () => {
+ gen.disabled = true;
+ gen.textContent = 'Fetching 3 prompts from Ollama… (~15 s)';
+ try {
+ const r = await fetch('/api/admin/inspiration/prompts', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ credentials: 'include'
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ const msg = j.error || ('HTTP ' + r.status);
+ const hint = j.hint ? '\n\n' + j.hint : '';
+ const url = j.url ? '\n\nURL tried: ' + j.url : '';
+ alert('Prompt fetch failed: ' + msg + hint + url);
+ gen.disabled = false; gen.textContent = origGenText; return;
+ }
+ saveDecision({ action: 'fetched_prompts', prompts: j.prompts, settlement_warnings: j.settlement_warnings });
+ const prev = document.getElementById('dec-tray'); if (prev) prev.remove();
+ const out = document.createElement('div');
+ out.id = 'dec-tray';
+ out.style.cssText = 'margin:20px 0';
+ gen.after(out);
+ renderPromptTray(out, j.prompts, j.settlement_warnings || []);
+ gen.textContent = '↺ Re-fetch prompts';
+ gen.disabled = false;
+ } catch (e) {
+ alert('Network error: ' + e.message);
+ gen.disabled = false; gen.textContent = origGenText;
+ }
+ });
+ }
})();
diff --git a/src/admin.js b/src/admin.js
index c6ded1a..69b6a8c 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -381,80 +381,109 @@ function mount(app) {
return m ? { verdict: 'BLOCK', term: m[0] } : { verdict: 'OK' };
}
- // ── Generate 3 wallco-originals from the admin's inspiration pool.
- // STRICT NO-LEAK: only pattern_name is sent to the LLM and Replicate.
- // Vendor names are NEVER included in the prompt that's stored or sent out.
- // SETTLEMENT GATE: every prompt is screened pre-generation (settlementScreen).
- // BACKEND: forced to Replicate so the route runs from prod (no local ComfyUI).
- app.post('/api/admin/inspiration/generate', gate, async (req, res) => {
+ // Calls Ollama with the inspiration items, returns 3 SDXL prompts. Throws
+ // { status, body } on failure so route handlers can `return res.status(s).json(b)`.
+ // Pulled out of /generate so /prompts (Step 1 of the two-step flow) can reuse it.
+ async function fetchLlmPrompts(items) {
+ const scrubbedList = items.slice(0, 8).map(i => `- ${i.pattern_name || 'untitled pattern'}`).join('\n');
+ const sys = [
+ 'You are a wallpaper design prompt engineer for wallco.ai.',
+ 'Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts',
+ 'for NEW wallpaper designs that share the aesthetic family but are NOT copies.',
+ '',
+ 'HARD LEGAL CONSTRAINT — a binding settlement prohibits these; every prompt MUST avoid them:',
+ 'birds, butterflies, bananas, banana leaves, banana pods, grapes, monstera leaves, palm fronds,',
+ 'and directional variation among leaves/foliage. If an inspiration name implies them',
+ '(e.g. "scenic", "toile", "chinoiserie"), REINTERPRET with settlement-safe motifs instead:',
+ 'peony & chrysanthemum florals, pagodas, lattice / fretwork / key-fret geometry, ginger jars',
+ '& vases, stylized clouds and mountains, edge-to-edge coverage, one dominant ink family.',
+ 'Crisp opaque screen-print color — never fuzzy or layer-over-layer.',
+ '',
+ 'Output ONLY a JSON array of 3 strings — each prompt one paragraph <= 220 chars,',
+ 'describing motif + palette + mood + texture. Include "seamless tile, 24-inch repeat,',
+ 'archival quality". Never mention a vendor name.'
+ ].join('\n');
+ const userMsg = `Aesthetic inspiration (names only):\n${scrubbedList}\n\nReturn a JSON array of 3 prompts.`;
+
+ const ollamaUrl = process.env.OLLAMA_URL
+ || (process.env.OLLAMA_HOST ? `http://${String(process.env.OLLAMA_HOST).replace(/^https?:\/\//, '')}` : null)
+ || 'http://192.168.1.133:11434';
+
+ let ollText;
+ try {
+ const ollResp = await fetch(`${ollamaUrl}/api/chat`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: process.env.OLLAMA_MODEL || 'qwen3:14b',
+ messages: [{ role: 'system', content: sys }, { role: 'user', content: userMsg }],
+ stream: false, options: { temperature: 0.7 }
+ }),
+ signal: AbortSignal.timeout(60_000)
+ });
+ if (!ollResp.ok) {
+ throw { status: 503, body: { error: 'ollama_http_error', status: ollResp.status, url: ollamaUrl } };
+ }
+ ollText = (await ollResp.json()).message?.content || '';
+ } catch (e) {
+ if (e && e.status && e.body) throw e;
+ throw { status: 503, body: {
+ error: 'ollama_unreachable', url: ollamaUrl,
+ reason: String(e.message || e).slice(0, 200),
+ hint: 'Set OLLAMA_URL (or OLLAMA_HOST) in wallco-ai/.env. For prod use the Mac1→Kamatera Tailscale bridge: OLLAMA_URL=http://100.65.187.120:11435',
+ } };
+ }
+ let prompts = [];
+ try {
+ const m = ollText.match(/\[\s*"[\s\S]*?"\s*\]/);
+ if (m) prompts = JSON.parse(m[0]);
+ } catch {}
+ prompts = (Array.isArray(prompts) ? prompts : []).slice(0, 3).filter(p => typeof p === 'string' && p.length > 20);
+ if (prompts.length === 0) {
+ throw { status: 502, body: { error: 'llm_returned_no_prompts', raw: ollText.slice(0, 400) } };
+ }
+ return { prompts, raw: ollText };
+ }
+
+ // ── Step 1 of the two-step flow — pool → 3 SDXL prompts, LLM only, NO render.
+ // Returns per-prompt settlement_warnings so the admin can edit risky prompts
+ // BEFORE burning a Replicate call on something the settlement gate will block.
+ app.post('/api/admin/inspiration/prompts', gate, async (req, res) => {
try {
const items = inspirationFor(req.admin.email);
if (items.length === 0) return res.status(400).json({ error: 'pool_empty' });
+ const { prompts } = await fetchLlmPrompts(items);
+ const settlement_warnings = prompts.map(p => settlementScreen(p));
+ res.json({ ok: true, prompts, settlement_warnings });
+ } catch (e) {
+ if (e && e.status && e.body) return res.status(e.status).json(e.body);
+ res.status(500).json({ error: e.message });
+ }
+ });
- // 1) Ask Ollama qwen3:14b to translate the items into 3 settlement-safe SDXL prompts.
- const scrubbedList = items.slice(0, 8).map(i => `- ${i.pattern_name || 'untitled pattern'}`).join('\n');
- const sys = [
- 'You are a wallpaper design prompt engineer for wallco.ai.',
- 'Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts',
- 'for NEW wallpaper designs that share the aesthetic family but are NOT copies.',
- '',
- 'HARD LEGAL CONSTRAINT — a binding settlement prohibits these; every prompt MUST avoid them:',
- 'birds, butterflies, bananas, banana leaves, banana pods, grapes, monstera leaves, palm fronds,',
- 'and directional variation among leaves/foliage. If an inspiration name implies them',
- '(e.g. "scenic", "toile", "chinoiserie"), REINTERPRET with settlement-safe motifs instead:',
- 'peony & chrysanthemum florals, pagodas, lattice / fretwork / key-fret geometry, ginger jars',
- '& vases, stylized clouds and mountains, edge-to-edge coverage, one dominant ink family.',
- 'Crisp opaque screen-print color — never fuzzy or layer-over-layer.',
- '',
- 'Output ONLY a JSON array of 3 strings — each prompt one paragraph <= 220 chars,',
- 'describing motif + palette + mood + texture. Include "seamless tile, 24-inch repeat,',
- 'archival quality". Never mention a vendor name.'
- ].join('\n');
- const userMsg = `Aesthetic inspiration (names only):\n${scrubbedList}\n\nReturn a JSON array of 3 prompts.`;
-
- // Ollama URL — explicit OLLAMA_URL wins; else OLLAMA_HOST (host:port form
- // used by the Mac1→Kamatera Tailscale bridge convention); else fall back to
- // Mac1's LAN address, which prod CANNOT reach — so prod must set one of
- // OLLAMA_URL / OLLAMA_HOST in wallco-ai/.env or the try/catch below fires
- // with an actionable error (no more generic 500 on "Generate 3 originals").
- const ollamaUrl = process.env.OLLAMA_URL
- || (process.env.OLLAMA_HOST ? `http://${String(process.env.OLLAMA_HOST).replace(/^https?:\/\//, '')}` : null)
- || 'http://192.168.1.133:11434';
-
- let ollText;
- try {
- const ollResp = await fetch(`${ollamaUrl}/api/chat`, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- model: process.env.OLLAMA_MODEL || 'qwen3:14b',
- messages: [{ role: 'system', content: sys }, { role: 'user', content: userMsg }],
- stream: false, options: { temperature: 0.7 }
- }),
- signal: AbortSignal.timeout(60_000)
- });
- if (!ollResp.ok) {
- return res.status(503).json({
- error: 'ollama_http_error', status: ollResp.status, url: ollamaUrl,
- });
+ // ── Generate wallco-originals from the admin's inspiration pool.
+ // Two-step flow — client first hits /prompts (above), admin edits the
+ // returned text, then POSTs the edited prompts here as `body.prompts`.
+ // Legacy end-to-end — if `body.prompts` is absent, runs the LLM internally
+ // and renders the unedited result (the original /generate behaviour).
+ // STRICT NO-LEAK: only pattern_name is sent to the LLM; vendor names never
+ // leave the server. SETTLEMENT GATE screens every prompt before Replicate.
+ // BACKEND: forced to Replicate so the route runs from prod (no local ComfyUI).
+ app.post('/api/admin/inspiration/generate', gate, async (req, res) => {
+ try {
+ // Step 2 mode — admin-supplied (possibly edited) prompts skip the LLM.
+ // Step 1 mode — no body.prompts → pool→LLM→render end-to-end (legacy).
+ let prompts = Array.isArray(req.body && req.body.prompts)
+ ? req.body.prompts.filter(p => typeof p === 'string' && p.length > 10).slice(0, 5)
+ : null;
+ if (!prompts || !prompts.length) {
+ const items = inspirationFor(req.admin.email);
+ if (items.length === 0) return res.status(400).json({ error: 'pool_empty' });
+ try { prompts = (await fetchLlmPrompts(items)).prompts; }
+ catch (e) {
+ if (e && e.status && e.body) return res.status(e.status).json(e.body);
+ return res.status(500).json({ error: e.message });
}
- ollText = (await ollResp.json()).message?.content || '';
- } catch (e) {
- return res.status(503).json({
- error: 'ollama_unreachable',
- url: ollamaUrl,
- reason: String(e.message || e).slice(0, 200),
- hint: 'Set OLLAMA_URL (or OLLAMA_HOST) in wallco-ai/.env. For prod use the Mac1→Kamatera Tailscale bridge: OLLAMA_URL=http://100.65.187.120:11435',
- });
- }
- let prompts = [];
- try {
- const m = ollText.match(/\[\s*"[\s\S]*?"\s*\]/);
- if (m) prompts = JSON.parse(m[0]);
- } catch {}
- if (!Array.isArray(prompts) || prompts.length === 0) {
- return res.status(502).json({ error: 'llm_returned_no_prompts', raw: ollText.slice(0, 400) });
}
- prompts = prompts.slice(0, 3).filter(p => typeof p === 'string' && p.length > 20);
// 2) Generate via Replicate SDXL (DTD verdict B — runs from prod, no local
// ComfyUI dependency). spawnSync with an arg array = no shell injection
← b6b1b00 /design page: show full palette as paint chips + paint strip
·
back to Wallco Ai
·
feat(scripts): add cull-viewer-server.js 2e90f4f →