← back to Wallco Ai
styleguides: admin can add/delete custom style guides; usable by Generate + Apply/recolor
23691242c40182708b9e24812da24fd1e183facf · 2026-06-01 12:56:13 -0700 · Steve Abrams
Files touched
M .deploy.confM deploy-kamatera.shM public/admin/styleguides.htmlM server.js
Diff
commit 23691242c40182708b9e24812da24fd1e183facf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 1 12:56:13 2026 -0700
styleguides: admin can add/delete custom style guides; usable by Generate + Apply/recolor
---
.deploy.conf | 2 +-
deploy-kamatera.sh | 1 +
public/admin/styleguides.html | 148 ++++++++++++++++++++++++++++++++++++++----
server.js | 109 ++++++++++++++++++++++++++++++-
4 files changed, 244 insertions(+), 16 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index 10dc317..963f15d 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -31,4 +31,4 @@ HEALTH_URL="https://wallco.ai/health"
# the 6:30am wallco-room-backfill cron calls; it was never in the local repo, so
# --delete would have removed it and broken the cron — local has a different
# script, backfill-wallco-rooms.js, not a rename).
-RSYNC_EXTRA_EXCLUDES=".venv __pycache__ *.pyc logs scripts/backfill-rooms.js data/generated data/generated_pre_seamless_backup data/generated_ghost_quarantine data/generated_cactus_quarantine data/elements data/tif data/images data/rooms data/spoonflower-pulled data/fliepaper-bugs public/uploads public/marketplace/uploads data/designs.json data/marketplace/events.jsonl data/cactus-decisions.jsonl data/prompt-match-ratings.jsonl data/ghost-scan-flagged.jsonl data/ghost-scan-results.jsonl data/ghost-scan-triage.jsonl data/ghost-scan-flagged.pre-*.jsonl data/ghost-scan-flagged.post-*.jsonl data/ghost-scan-results.pre-*.jsonl data/bad-aesthetic-patterns.jsonl data/bad-aesthetic-patterns.*.jsonl data/ghost-purge.jsonl data/ghost-labels.jsonl data/fixes-feed.jsonl data/.rescan-current.txt data/.rescan-ready.txt data/pdp-theme.json"
+RSYNC_EXTRA_EXCLUDES=".venv __pycache__ *.pyc logs scripts/backfill-rooms.js data/generated data/generated_pre_seamless_backup data/generated_ghost_quarantine data/generated_cactus_quarantine data/elements data/tif data/images data/rooms data/spoonflower-pulled data/fliepaper-bugs public/uploads public/marketplace/uploads data/designs.json data/marketplace/events.jsonl data/cactus-decisions.jsonl data/prompt-match-ratings.jsonl data/ghost-scan-flagged.jsonl data/ghost-scan-results.jsonl data/ghost-scan-triage.jsonl data/ghost-scan-flagged.pre-*.jsonl data/ghost-scan-flagged.post-*.jsonl data/ghost-scan-results.pre-*.jsonl data/bad-aesthetic-patterns.jsonl data/bad-aesthetic-patterns.*.jsonl data/ghost-purge.jsonl data/ghost-labels.jsonl data/fixes-feed.jsonl data/.rescan-current.txt data/.rescan-ready.txt data/pdp-theme.json data/custom-styleguides.json"
diff --git a/deploy-kamatera.sh b/deploy-kamatera.sh
index 7517edd..030dde7 100755
--- a/deploy-kamatera.sh
+++ b/deploy-kamatera.sh
@@ -52,6 +52,7 @@ rsync -az --delete \
--exclude='data/rooms/' \
--exclude='data/spoonflower-pulled/' \
--exclude='data/pdp-theme.json' \
+ --exclude='data/custom-styleguides.json' \
--exclude='data/cactus-murals/' \
--exclude='data/cactus-decisions.jsonl' \
--exclude='data/ghost-scan-flagged.jsonl' \
diff --git a/public/admin/styleguides.html b/public/admin/styleguides.html
index 14698ac..4071f14 100644
--- a/public/admin/styleguides.html
+++ b/public/admin/styleguides.html
@@ -101,6 +101,22 @@
.toast{position:fixed;left:50%;bottom:108px;transform:translateX(-50%);z-index:80;background:#16161d;border:1px solid var(--gold-soft);color:var(--ink);font:500 13px var(--sans);padding:12px 20px;border-radius:10px;opacity:0;transition:.25s;pointer-events:none}
.toast.on{opacity:1}
.skeleton{color:var(--faint);font:14px var(--sans);padding:40px 0;text-align:center}
+ /* ── Add Style Guide form + custom-card delete ── */
+ .addform{display:flex;flex-direction:column;gap:14px;margin:6px 0 4px}
+ .afrow{display:flex;gap:14px;flex-wrap:wrap}
+ .fld{display:flex;flex-direction:column;gap:6px}
+ .fld.grow{flex:1;min-width:220px}
+ .fld label{font:600 10px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
+ .fld input[type=text],.fld input[type=number],.fld select{background:#08080b;border:1px solid var(--line);border-radius:8px;color:var(--ink);padding:10px 12px;font:14px var(--sans)}
+ .fld input:focus,.fld select:focus{outline:none;border-color:var(--gold-soft)}
+ .colors{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
+ .colors .cw{position:relative}
+ .colors input[type=color]{width:44px;height:44px;border:1px solid var(--line);border-radius:9px;background:#08080b;cursor:pointer;padding:2px}
+ .colors .rm{position:absolute;top:-7px;right:-7px;width:18px;height:18px;border-radius:50%;border:1px solid var(--line);background:#16161d;color:var(--ink-soft);font:700 11px var(--sans);cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center}
+ .colors .rm:hover{border-color:#d57;color:#f88}
+ .card .del{position:absolute;top:14px;left:14px;width:22px;height:22px;border-radius:50%;border:1px solid var(--line);background:rgba(20,20,26,.85);color:var(--muted);font:700 13px var(--sans);cursor:pointer;display:none;align-items:center;justify-content:center;z-index:3}
+ .card.custom:hover .del{display:flex}
+ .card .del:hover{border-color:#d57;color:#f88}
</style>
</head>
<body>
@@ -151,6 +167,22 @@
</div>
<div class="grid" id="grid-wallcovering"></div>
</section>
+
+ <section>
+ <div class="sec-top">
+ <div>
+ <div class="sec-no">III.</div>
+ <div class="sec-h">Your Style Guides</div>
+ <div class="sec-sub">Custom houses you've added — usable by Generate & Apply</div>
+ </div>
+ <div class="controls">
+ <button class="ctl gold" id="btn-add-sg">+ Add Style Guide</button>
+ <button class="ctl" data-all="custom">Select all</button>
+ <button class="ctl" data-clear="custom">Clear</button>
+ </div>
+ </div>
+ <div class="grid" id="grid-custom"><div class="skeleton">No custom guides yet — click “+ Add Style Guide”.</div></div>
+ </section>
</main>
<div class="bar">
@@ -212,6 +244,36 @@
</div>
</div>
+<!-- Add Style Guide modal -->
+<div class="scrim" id="scrim-add">
+ <div class="modal">
+ <span class="x" data-close>×</span>
+ <h3>Add a Style Guide</h3>
+ <div class="mh-sub">Define a custom brand palette. It joins the selector above and becomes usable by <b>Generate</b> (seeds the prompt) and <b>Apply</b> (recolors a design).</div>
+ <div class="addform">
+ <div class="afrow">
+ <div class="fld grow"><label>Name</label><input id="af-label" type="text" maxlength="60" placeholder="e.g. Mid-Century Lounge"></div>
+ <div class="fld"><label>Category</label>
+ <select id="af-cat"><option value="custom">Custom</option><option value="fashion">Fashion</option><option value="wallcovering">Wallcovering</option></select>
+ </div>
+ </div>
+ <div class="afrow">
+ <div class="fld"><label>Country</label><input id="af-country" type="text" maxlength="4" placeholder="US"></div>
+ <div class="fld"><label>Est.</label><input id="af-est" type="number" min="0" placeholder="1960"></div>
+ </div>
+ <div class="fld"><label>Signature</label><input id="af-sig" type="text" maxlength="200" placeholder="warm walnut & ochre, atomic-era restraint"></div>
+ <div class="fld"><label>Colors (1–6)</label>
+ <div class="colors" id="af-colors"></div>
+ <button class="ctl" id="af-addcolor" type="button" style="align-self:flex-start">+ color</button>
+ </div>
+ </div>
+ <div class="modal-row">
+ <button class="btn primary" id="btn-add-save">Save Style Guide</button>
+ </div>
+ <div class="status" id="add-status"></div>
+ </div>
+</div>
+
<div class="toast" id="toast"></div>
<script>
@@ -227,7 +289,7 @@
return fetch(authd(p), Object.assign({ credentials:'same-origin' }, opts, { headers }));
};
- const state = { brands: { fashion: [], wallcovering: [] }, sel: new Set(), quota: { admin:false, remaining:5, cap:5 } };
+ const state = { brands: { fashion: [], wallcovering: [], custom: [] }, sel: new Set(), quota: { admin:false, remaining:5, cap:5 } };
const byKey = {};
const $ = (s, r=document) => r.querySelector(s);
@@ -236,22 +298,28 @@
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('on'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('on'),2600); }
// ── render cards ──────────────────────────────────────────────────────────
+ const esc = s => String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]));
function cardFor(b){
const c = el('div','card'); c.dataset.key=b.key;
- const dots = b.hex.slice(0,3).map(h=>`<span class="dot" style="background:${h}"></span>`).join('');
- c.innerHTML =
+ if(b.custom) c.classList.add('custom');
+ const dots = b.hex.slice(0,3).map(h=>`<span class="dot" style="background:${esc(h)}"></span>`).join('');
+ const del = b.custom ? `<button class="del" title="Delete this style guide">×</button>` : '';
+ c.innerHTML = del +
`<div class="tick"><svg viewBox="0 0 24 24" fill="none"><path d="M5 12l4.5 4.5L19 7" stroke="#1a1505" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>`+
- `<div class="name">${b.label}</div>`+
- `<div class="meta"><span class="chip">${b.country||'—'}</span><span class="chip">est. ${b.est||'—'}</span></div>`+
+ `<div class="name">${esc(b.label)}</div>`+
+ `<div class="meta"><span class="chip">${esc(b.country||'—')}</span><span class="chip">est. ${esc(b.est||'—')}</span></div>`+
`<div class="dots">${dots}</div>`;
c.addEventListener('click',()=>toggle(b.key,c));
+ if(b.custom){ const d=c.querySelector('.del'); d.addEventListener('click',e=>{ e.stopPropagation(); deleteGuide(b.key,b.label); }); }
return c;
}
function renderGrids(){
- const gf=$('#grid-fashion'), gw=$('#grid-wallcovering');
- gf.innerHTML=''; gw.innerHTML='';
+ const gf=$('#grid-fashion'), gw=$('#grid-wallcovering'), gc=$('#grid-custom');
+ gf.innerHTML=''; gw.innerHTML=''; gc.innerHTML='';
state.brands.fashion.forEach(b=>gf.appendChild(cardFor(b)));
state.brands.wallcovering.forEach(b=>gw.appendChild(cardFor(b)));
+ if(state.brands.custom.length) state.brands.custom.forEach(b=>gc.appendChild(cardFor(b)));
+ else gc.innerHTML='<div class="skeleton">No custom guides yet — click “+ Add Style Guide”.</div>';
$('#cnt-fashion').textContent=state.brands.fashion.length;
$('#cnt-wall').textContent=state.brands.wallcovering.length;
}
@@ -402,14 +470,68 @@
}catch(e){ $('#apply-status').textContent='✗ '+e.message; refreshQuota(); }
}
+ // ── add / delete custom style guides ───────────────────────────────────────
+ const colorsWrap = () => $('#af-colors');
+ function addColorInput(val){
+ if(colorsWrap().children.length>=6){ toast('Max 6 colors'); return; }
+ const wrap=el('div','cw');
+ const inp=el('input'); inp.type='color'; inp.value=val||'#8a7f6a';
+ const rm=el('button','rm'); rm.type='button'; rm.textContent='×';
+ rm.addEventListener('click',()=>{ if(colorsWrap().children.length>1) wrap.remove(); else toast('Need at least one color'); });
+ wrap.appendChild(inp); wrap.appendChild(rm); colorsWrap().appendChild(wrap);
+ }
+ function resetAddForm(){
+ ['af-label','af-country','af-sig'].forEach(id=>{const e=$('#'+id); if(e)e.value='';});
+ $('#af-est').value=''; $('#af-cat').value='custom'; $('#add-status').textContent='';
+ colorsWrap().innerHTML=''; addColorInput('#3a3a44'); addColorInput('#c8a96a'); addColorInput('#f3ecdd');
+ }
+ $('#btn-add-sg').addEventListener('click',()=>{ resetAddForm(); openModal('scrim-add'); setTimeout(()=>$('#af-label').focus(),50); });
+ $('#af-addcolor').addEventListener('click',()=>addColorInput());
+ $('#btn-add-save').addEventListener('click',async()=>{
+ const label=$('#af-label').value.trim();
+ if(!label){ $('#add-status').textContent='✗ Name is required'; $('#af-label').focus(); return; }
+ const hex=[...colorsWrap().querySelectorAll('input[type=color]')].map(i=>i.value.toUpperCase());
+ const body={ label, cat:$('#af-cat').value, country:$('#af-country').value.trim(),
+ est:$('#af-est').value.trim(), signature:$('#af-sig').value.trim(), hex };
+ const save=$('#btn-add-save'); save.disabled=true; $('#add-status').textContent='Saving…';
+ try{
+ const r=await jfetch('/api/styleguides/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ const j=await r.json();
+ if(!j.ok) throw new Error(j.error||'failed');
+ await loadBrands();
+ const k=j.guide&&j.guide.key; if(k) setSel(k,true), syncBar();
+ closeModal($('#scrim-add'));
+ toast((j.updated?'Updated':'Added')+' “'+label+'”');
+ }catch(e){ $('#add-status').textContent='✗ '+e.message; }
+ finally{ save.disabled=false; }
+ });
+ async function deleteGuide(key,label){
+ if(!confirm('Delete style guide “'+label+'”? This cannot be undone.')) return;
+ try{
+ const r=await jfetch('/api/styleguides/'+encodeURIComponent(key),{method:'DELETE'});
+ const j=await r.json();
+ if(!j.ok) throw new Error(j.error||'failed');
+ state.sel.delete(key); await loadBrands(); syncBar();
+ toast('Deleted “'+label+'”');
+ }catch(e){ toast('✗ '+e.message); }
+ }
+
+ // ── load brands (built-ins + custom); preserves current selection ──────────
+ async function loadBrands(){
+ const r=await jfetch('/api/styleguides/brands'); const j=await r.json();
+ state.brands.fashion=j.fashion||[]; state.brands.wallcovering=j.wallcovering||[]; state.brands.custom=j.custom||[];
+ Object.keys(byKey).forEach(k=>delete byKey[k]);
+ [...state.brands.fashion,...state.brands.wallcovering,...state.brands.custom].forEach(b=>byKey[b.key]=b);
+ renderGrids();
+ // re-apply selection classes for keys that still exist; drop the rest
+ [...state.sel].forEach(k=>{ if(byKey[k]){ const c=cardEl(k); if(c)c.classList.add('sel'); } else state.sel.delete(k); });
+ syncBar();
+ }
+
// ── boot ──────────────────────────────────────────────────────────────────
(async function(){
- try{
- const r=await jfetch('/api/styleguides/brands'); const j=await r.json();
- state.brands.fashion=j.fashion||[]; state.brands.wallcovering=j.wallcovering||[];
- [...state.brands.fashion,...state.brands.wallcovering].forEach(b=>byKey[b.key]=b);
- renderGrids(); syncBar(); refreshQuota();
- }catch(e){ $('#grid-fashion').innerHTML='<div class="skeleton">Failed to load houses: '+e.message+'</div>'; }
+ try{ await loadBrands(); refreshQuota(); }
+ catch(e){ $('#grid-fashion').innerHTML='<div class="skeleton">Failed to load houses: '+e.message+'</div>'; }
})();
})();
</script>
diff --git a/server.js b/server.js
index 2ed1f8c..6849e11 100644
--- a/server.js
+++ b/server.js
@@ -1245,7 +1245,106 @@ app.get('/api/styleguides/brands', (req, res) => {
key: kebab(p.brand), label: p.brand, cat: p.cat || 'fashion',
country: p.country || null, est: p.est || null, hex: p.hex, signature: p.signature,
}));
- res.json({ ok: true, fashion: pick(FASHION_TOP), wallcovering: pick(WALL_TOP) });
+ // Admin-authored custom guides (data/custom-styleguides.json) join the
+ // built-ins so they're selectable for Generate + Apply just like a house.
+ const custom = loadCustomStyleguides().map(g => ({
+ key: g.key, label: g.label, cat: g.cat || 'custom',
+ country: g.country || null, est: g.est || null,
+ hex: g.hex, signature: g.signature || '', custom: true,
+ createdAt: g.createdAt || null,
+ }));
+ res.json({ ok: true, fashion: pick(FASHION_TOP), wallcovering: pick(WALL_TOP), custom });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// ── Custom style guides (Steve 2026-06-01 "allow generator to add style
+// guides"). Admin authors a brand palette on the styleguides page; it persists
+// to data/custom-styleguides.json and immediately becomes usable by the
+// generator (Generate seeds the prompt from its hexes + signature) AND by
+// recolor/Apply (resolveRecolorBrand() checks this store at request time, so a
+// guide added after server boot still works without a restart). The file is
+// prod-authored — add it to .deploy.conf excludes so a Mac2 deploy never
+// clobbers guides authored on prod. Same kebab-key scheme as RECOLOR_BRANDS so
+// keys line up across add → list → generate → recolor.
+const CUSTOM_SG_FILE = path.join(__dirname, 'data', 'custom-styleguides.json');
+const sgKebab = s => String(s).normalize('NFD').replace(/[̀-ͯ]/g, '')
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+let _customSgCache = { val: null, at: 0 };
+function loadCustomStyleguides() {
+ const now = Date.now();
+ if (_customSgCache.val && now - _customSgCache.at < 3000) return _customSgCache.val;
+ let arr = [];
+ try {
+ const j = JSON.parse(fs.readFileSync(CUSTOM_SG_FILE, 'utf8'));
+ if (Array.isArray(j)) arr = j;
+ } catch { /* no file / bad json = no custom guides */ }
+ _customSgCache = { val: arr, at: now };
+ return arr;
+}
+function saveCustomStyleguides(arr) {
+ fs.mkdirSync(path.dirname(CUSTOM_SG_FILE), { recursive: true });
+ const tmp = CUSTOM_SG_FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
+ fs.renameSync(tmp, CUSTOM_SG_FILE); // atomic: no partial-JSON window
+ _customSgCache = { val: arr, at: Date.now() };
+}
+// Normalize + validate an inbound guide. Returns {guide} or {error}.
+function normalizeStyleguide(body) {
+ const b = body || {};
+ const label = String(b.label || '').trim();
+ if (!label) return { error: 'label required' };
+ if (label.length > 60) return { error: 'label too long (max 60)' };
+ let hex = (Array.isArray(b.hex) ? b.hex : [])
+ .map(h => String(h).trim().match(/^#?([0-9a-fA-F]{6})$/))
+ .filter(Boolean).map(m => '#' + m[1].toUpperCase());
+ hex = [...new Set(hex)].slice(0, 6);
+ if (!hex.length) return { error: 'at least one valid #RRGGBB color required' };
+ const signature = String(b.signature || '').trim().slice(0, 200);
+ let cat = String(b.cat || 'custom').toLowerCase();
+ if (!['fashion', 'wallcovering', 'custom'].includes(cat)) cat = 'custom';
+ const country = b.country ? String(b.country).trim().slice(0, 4).toUpperCase() : null;
+ const estN = parseInt(b.est, 10);
+ const est = Number.isFinite(estN) ? estN : null;
+ const key = sgKebab(label);
+ if (!key) return { error: 'label has no usable letters/numbers' };
+ return { guide: { key, label, cat, country, est, hex, signature } };
+}
+
+// POST /api/styleguides/add — create/update a custom style guide (admin only).
+app.post('/api/styleguides/add', requireAdmin, express.json(), (req, res) => {
+ try {
+ const { guide, error } = normalizeStyleguide(req.body);
+ if (error) return res.status(400).json({ ok: false, error });
+ // Don't let a custom guide shadow a built-in house key.
+ if (RECOLOR_BRANDS[guide.key]) {
+ return res.status(409).json({ ok: false, error: 'a built-in house already uses that name' });
+ }
+ const arr = loadCustomStyleguides().slice();
+ const ix = arr.findIndex(g => g.key === guide.key);
+ const nowIso = new Date().toISOString();
+ const record = Object.assign({ custom: true }, guide, {
+ createdAt: ix >= 0 ? (arr[ix].createdAt || nowIso) : nowIso,
+ updatedAt: nowIso,
+ createdBy: 'admin',
+ });
+ if (ix >= 0) arr[ix] = record; else arr.push(record);
+ saveCustomStyleguides(arr);
+ res.json({ ok: true, guide: record, updated: ix >= 0 });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// DELETE /api/styleguides/:key — remove a custom guide (admin only; built-ins
+// are immutable so a missing key from the custom store is a 404).
+app.delete('/api/styleguides/:key', requireAdmin, (req, res) => {
+ try {
+ const key = sgKebab(req.params.key || '');
+ const arr = loadCustomStyleguides();
+ const next = arr.filter(g => g.key !== key);
+ if (next.length === arr.length) {
+ return res.status(404).json({ ok: false, error: 'no custom style guide with that key (built-in houses cannot be deleted)' });
+ }
+ saveCustomStyleguides(next);
+ res.json({ ok: true, removed: key });
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
@@ -23994,7 +24093,13 @@ app.post('/api/design/:id/recolor', sgRecolorGate, express.json(), async (req, r
const rawHex = String((req.body && req.body.hex) || '').trim();
const hexMatch = rawHex.match(/^#?([0-9a-fA-F]{6})$/);
const hex = hexMatch ? ('#' + hexMatch[1].toUpperCase()) : null;
- const brand = brandKey ? RECOLOR_BRANDS[brandKey] : null;
+ // Built-in houses resolve from the boot-time map; admin-authored custom
+ // guides resolve from the live store so a guide added after boot works too.
+ let brand = brandKey ? RECOLOR_BRANDS[brandKey] : null;
+ if (!brand && brandKey) {
+ const cg = loadCustomStyleguides().find(g => g.key === brandKey);
+ if (cg) brand = { label: cg.label, hex: (cg.hex || []).slice(), signature: cg.signature || '' };
+ }
const hue = hueKey ? RECOLOR_HUES[hueKey] : null;
if (!brand && !hue && !hex) {
return res.status(400).json({
← 005165e TODO: track 284 settlement-blocked batch-regen run
·
back to Wallco Ai
·
TODO: orphan rooms fixed-locally (141 copies, 0 remain; prod 443ac42 →