[object Object]

← 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

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 &amp; 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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 →