[object Object]

← back to Dw Hero Admin

feat: per-site fashion-house style guide picker with color dots + theme apply

74b391739d0a3b2cd6da4112b4ccade80046fc63 · 2026-05-14 09:25:27 -0700 · SteveStudio2

- Custom dropdown (native <select> can't render color dots): 20 brands from fashion-style-guides/data/brands.json, each option shows name + signature motif + 5 inline color dots
- Server: GET /api/brands (proxies brands.json), GET /api/brand-assignments, POST /api/site/:site/brand
- Assignments persist to ~/Projects/dw-hero-admin/data/brand-assignments.json (per-site → brand_id)
- Apply: admin chrome theme swaps to brand palette via CSS vars (--theme-bg, --theme-panel, --theme-accent, --theme-line) using primary mixed at 92% black for readable dark surface; primary save button takes brand accent
- Brand picker button shows currently-applied brand name + 4 dot preview
- Clear assignment row at bottom of menu reverts to default chrome
- Click-outside closes menu, click on row applies + persists

Verified via Playwright: dropdown renders with dots, click Gucci → theme applies (rgb(1,7,4) bg, #b58a2a accent), persists across page reloads, /api/brand-assignments returns the saved record.

Files touched

Diff

commit 74b391739d0a3b2cd6da4112b4ccade80046fc63
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 14 09:25:27 2026 -0700

    feat: per-site fashion-house style guide picker with color dots + theme apply
    
    - Custom dropdown (native <select> can't render color dots): 20 brands from fashion-style-guides/data/brands.json, each option shows name + signature motif + 5 inline color dots
    - Server: GET /api/brands (proxies brands.json), GET /api/brand-assignments, POST /api/site/:site/brand
    - Assignments persist to ~/Projects/dw-hero-admin/data/brand-assignments.json (per-site → brand_id)
    - Apply: admin chrome theme swaps to brand palette via CSS vars (--theme-bg, --theme-panel, --theme-accent, --theme-line) using primary mixed at 92% black for readable dark surface; primary save button takes brand accent
    - Brand picker button shows currently-applied brand name + 4 dot preview
    - Clear assignment row at bottom of menu reverts to default chrome
    - Click-outside closes menu, click on row applies + persists
    
    Verified via Playwright: dropdown renders with dots, click Gucci → theme applies (rgb(1,7,4) bg, #b58a2a accent), persists across page reloads, /api/brand-assignments returns the saved record.
---
 data/brand-assignments.json |   6 ++
 public/index.html           | 155 +++++++++++++++++++++++++++++++++++++++++++-
 server.js                   |  36 ++++++++++
 3 files changed, 196 insertions(+), 1 deletion(-)

diff --git a/data/brand-assignments.json b/data/brand-assignments.json
new file mode 100644
index 0000000..95f22bb
--- /dev/null
+++ b/data/brand-assignments.json
@@ -0,0 +1,6 @@
+{
+  "1800swallpaper": {
+    "brand_id": "gucci",
+    "updated_at": "2026-05-14T16:24:55.992Z"
+  }
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index abb8be8..8b766ef 100644
--- a/public/index.html
+++ b/public/index.html
@@ -38,6 +38,31 @@
   .save-status { font-size:12px; color:var(--muted); }
   .save-status.ok { color:#7ed3a3; }
   .save-status.err { color:#ff8c8c; }
+  /* ─ brand picker (custom dropdown — native <select> can't render color dots) ─ */
+  .brand-picker { position:relative; }
+  .brand-picker-btn { display:inline-flex; align-items:center; gap:10px; background:transparent; border:1px solid var(--line); color:var(--paper); padding:7px 12px; cursor:pointer; font:inherit; font-size:12px; letter-spacing:0.04em; border-radius:3px; min-width:240px; justify-content:space-between; }
+  .brand-picker-btn:hover { border-color:var(--accent); }
+  .brand-picker-btn .dots { display:inline-flex; gap:3px; }
+  .brand-picker-btn .dot { width:12px; height:12px; border-radius:50%; border:1px solid rgba(255,255,255,0.15); }
+  .brand-picker-btn .label { flex:1; text-align:left; }
+  .brand-picker-btn .caret { opacity:0.5; }
+  .brand-menu { position:absolute; top:calc(100% + 4px); right:0; background:#15110b; border:1px solid var(--slot-bd); border-radius:6px; padding:6px; min-width:340px; max-height:min(70vh, 540px); overflow-y:auto; z-index:30; box-shadow:0 12px 32px rgba(0,0,0,0.5); }
+  .brand-menu[hidden] { display:none; }
+  .brand-option { display:flex; align-items:center; gap:12px; padding:8px 10px; cursor:pointer; border-radius:4px; transition:background .12s; }
+  .brand-option:hover { background:rgba(176,160,128,0.10); }
+  .brand-option.selected { background:rgba(176,160,128,0.18); }
+  .brand-option .name { flex:1; font-size:13px; color:var(--paper); }
+  .brand-option .name small { display:block; font-size:10px; color:var(--muted); margin-top:2px; letter-spacing:0.05em; }
+  .brand-option .dots { display:inline-flex; gap:4px; }
+  .brand-option .dot { width:18px; height:18px; border-radius:50%; border:1px solid rgba(255,255,255,0.18); }
+  .brand-option.clear { color:var(--muted); font-style:italic; border-top:1px solid var(--line); margin-top:4px; padding-top:10px; }
+  /* applied theme — palette swap on admin chrome */
+  body.brand-theme { background:var(--theme-bg, var(--bg)); }
+  body.brand-theme .topbar { background:var(--theme-panel, transparent); border-bottom-color:var(--theme-line, var(--line)); }
+  body.brand-theme .sidebar { background:var(--theme-panel, var(--panel)); }
+  body.brand-theme .slots-wrap { background:var(--theme-panel, #0d0a07); }
+  body.brand-theme .save-status:not(.ok):not(.err) { color:var(--theme-accent, var(--muted)); }
+  body.brand-theme .toolbar button.primary { background:var(--theme-accent, var(--accent)); border-color:var(--theme-accent, var(--accent)); color:#0a0a0a; }
   /* ─ slot grid ─ */
   .slots-wrap { flex-shrink:0; padding:18px 24px; border-bottom:1px solid var(--line); background:#0d0a07; }
   .slots-label { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
@@ -106,7 +131,17 @@
         <h1 id="siteName">— pick a site —</h1>
         <div class="meta" id="siteMeta"></div>
       </div>
-      <div class="save-status" id="saveStatus"></div>
+      <div style="display:flex; align-items:center; gap:14px;">
+        <div class="brand-picker" id="brandPicker" hidden>
+          <button id="brandBtn" class="brand-picker-btn" type="button">
+            <span class="label">Apply style guide…</span>
+            <span class="dots" id="brandBtnDots"></span>
+            <span class="caret">▾</span>
+          </button>
+          <div id="brandMenu" class="brand-menu" hidden></div>
+        </div>
+        <div class="save-status" id="saveStatus"></div>
+      </div>
     </div>
     <div class="slots-wrap" id="slotsWrap" style="display:none">
       <div class="slots-label">
@@ -142,6 +177,8 @@
     filter: '',
     sort: localStorage.getItem(LS + 'sort') || 'newest',
     cardW: parseInt(localStorage.getItem(LS + 'cardW') || '140', 10),
+    brands: [],
+    assignments: {},   // { site_id: { brand_id, updated_at } }
   };
   document.documentElement.style.setProperty('--card-w', state.cardW + 'px');
   var els = {
@@ -156,6 +193,10 @@
     saveBtn: document.getElementById('saveBtn'),
     resetBtn: document.getElementById('resetBtn'),
     openSiteBtn: document.getElementById('openSiteBtn'),
+    brandPicker: document.getElementById('brandPicker'),
+    brandBtn: document.getElementById('brandBtn'),
+    brandBtnDots: document.getElementById('brandBtnDots'),
+    brandMenu: document.getElementById('brandMenu'),
   };
 
   function api(url, opts) { return fetch(url, opts).then(function (r) { return r.json(); }); }
@@ -172,6 +213,106 @@
     });
   }
 
+  function loadBrandsAndAssignments() {
+    return Promise.all([
+      api('/api/brands').catch(function () { return { brands: [] }; }),
+      api('/api/brand-assignments').catch(function () { return {}; }),
+    ]).then(function (results) {
+      state.brands = (results[0] && results[0].brands) || [];
+      state.assignments = results[1] || {};
+      renderBrandMenu();
+    });
+  }
+
+  function renderBrandMenu() {
+    if (!state.brands.length) return;
+    var current = state.site ? (state.assignments[state.site] || {}).brand_id : null;
+    var rows = state.brands.map(function (b) {
+      var dots = b.palette.slice(0, 5).map(function (c) {
+        return '<span class="dot" style="background:' + escapeHtml(c.hex) + '" title="' + escapeHtml(c.name) + '"></span>';
+      }).join('');
+      var cls = 'brand-option' + (b.id === current ? ' selected' : '');
+      return '<div class="' + cls + '" data-brand="' + escapeHtml(b.id) + '">\
+        <div class="name">' + escapeHtml(b.name) + '<small>' + escapeHtml(b.signature_motif || '').slice(0, 60) + '</small></div>\
+        <div class="dots">' + dots + '</div>\
+      </div>';
+    }).join('');
+    rows += '<div class="brand-option clear" data-brand=""><div class="name">— clear assignment —</div></div>';
+    els.brandMenu.innerHTML = rows;
+    Array.prototype.forEach.call(els.brandMenu.querySelectorAll('.brand-option'), function (row) {
+      row.addEventListener('click', function () {
+        applyBrand(row.dataset.brand || null);
+        els.brandMenu.hidden = true;
+      });
+    });
+    refreshBrandBtn();
+  }
+
+  function refreshBrandBtn() {
+    var assign = state.site ? state.assignments[state.site] : null;
+    var brand = assign ? state.brands.find(function (b) { return b.id === assign.brand_id; }) : null;
+    var label = els.brandBtn.querySelector('.label');
+    var dots = els.brandBtnDots;
+    if (brand) {
+      label.textContent = brand.name;
+      dots.innerHTML = brand.palette.slice(0, 4).map(function (c) {
+        return '<span class="dot" style="background:' + escapeHtml(c.hex) + '"></span>';
+      }).join('');
+      applyTheme(brand);
+    } else {
+      label.textContent = 'Apply style guide…';
+      dots.innerHTML = '';
+      applyTheme(null);
+    }
+  }
+
+  function applyTheme(brand) {
+    var root = document.documentElement.style;
+    if (!brand) {
+      document.body.classList.remove('brand-theme');
+      ['--theme-bg','--theme-panel','--theme-accent','--theme-line'].forEach(function (k) { root.removeProperty(k); });
+      return;
+    }
+    document.body.classList.add('brand-theme');
+    var primary = brand.palette.find(function (c) { return c.role === 'primary'; }) || brand.palette[0];
+    var accent = brand.palette.find(function (c) { return c.role === 'accent'; }) ||
+                 brand.palette.find(function (c) { return c.role === 'accent-soft'; }) ||
+                 brand.palette[1] || primary;
+    var ground = brand.palette.find(function (c) { return c.role === 'ground'; }) || null;
+    // Mix the primary at ~10% with the dark base so admin chrome remains dark-readable
+    root.setProperty('--theme-bg', mixWithBlack(primary.hex, 0.92));
+    root.setProperty('--theme-panel', mixWithBlack(primary.hex, 0.86));
+    root.setProperty('--theme-accent', accent.hex);
+    root.setProperty('--theme-line', mixWithBlack(ground ? ground.hex : primary.hex, 0.7));
+  }
+
+  function mixWithBlack(hex, blackAmount) {
+    var c = hex.replace('#','');
+    var r = parseInt(c.slice(0,2), 16);
+    var g = parseInt(c.slice(2,4), 16);
+    var b = parseInt(c.slice(4,6), 16);
+    r = Math.round(r * (1 - blackAmount));
+    g = Math.round(g * (1 - blackAmount));
+    b = Math.round(b * (1 - blackAmount));
+    return 'rgb(' + r + ',' + g + ',' + b + ')';
+  }
+
+  function applyBrand(brandId) {
+    if (!state.site) return;
+    if (brandId) state.assignments[state.site] = { brand_id: brandId, updated_at: new Date().toISOString() };
+    else delete state.assignments[state.site];
+    refreshBrandBtn();
+    renderBrandMenu();
+    // persist server-side
+    api('/api/site/' + encodeURIComponent(state.site) + '/brand', {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ brand_id: brandId }),
+    }).then(function (r) {
+      flashStatus(brandId ? ('✓ applied ' + (state.brands.find(function (b) { return b.id === brandId; }) || {}).name) : '✓ cleared style guide', 'ok');
+    }).catch(function (e) { flashStatus('✗ save assignment: ' + e.message, 'err'); });
+  }
+
   function pickSite(site) {
     state.site = site;
     Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
@@ -185,9 +326,12 @@
       state.grid = data.grid;
       els.siteMeta.textContent = data.productCount + ' products · newest first';
       els.slotsWrap.style.display = 'block';
+      els.brandPicker.hidden = false;
       renderSlots();
       renderProductsHeader();
       renderProductsGrid();
+      renderBrandMenu();
+      refreshBrandBtn();
     }).catch(function (e) {
       els.productsWrap.innerHTML = '<div class="empty-state">Error: ' + e.message + '</div>';
     });
@@ -391,7 +535,16 @@
     });
   });
 
+  els.brandBtn.addEventListener('click', function (ev) {
+    ev.stopPropagation();
+    els.brandMenu.hidden = !els.brandMenu.hidden;
+  });
+  document.addEventListener('click', function (ev) {
+    if (!els.brandPicker.contains(ev.target)) els.brandMenu.hidden = true;
+  });
+
   loadSites();
+  loadBrandsAndAssignments();
 })();
 </script>
 </body>
diff --git a/server.js b/server.js
index 3dd4a41..3aa9c45 100644
--- a/server.js
+++ b/server.js
@@ -147,6 +147,42 @@ function pushToProd(site, localPath) {
   });
 }
 
+// ── fashion-house style guides (proxied from fashion-style-guides project) ──
+const BRANDS_JSON = path.join(PROJECTS_DIR, 'fashion-style-guides', 'data', 'brands.json');
+const BRAND_ASSIGN_FILE = path.join(__dirname, 'data', 'brand-assignments.json');
+
+app.get('/api/brands', (req, res) => {
+  try {
+    const buf = fs.readFileSync(BRANDS_JSON, 'utf8');
+    res.json(JSON.parse(buf));
+  } catch (e) {
+    res.status(500).json({ error: 'cannot read brands.json', detail: e.message, path: BRANDS_JSON });
+  }
+});
+
+function loadAssignments() {
+  try { return JSON.parse(fs.readFileSync(BRAND_ASSIGN_FILE, 'utf8')); }
+  catch (e) { return {}; }
+}
+function saveAssignments(obj) {
+  const dir = path.dirname(BRAND_ASSIGN_FILE);
+  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+  fs.writeFileSync(BRAND_ASSIGN_FILE, JSON.stringify(obj, null, 2));
+}
+
+app.get('/api/brand-assignments', (req, res) => res.json(loadAssignments()));
+
+app.post('/api/site/:site/brand', (req, res) => {
+  const site = req.params.site;
+  if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
+  const brandId = (req.body && req.body.brand_id) || null;
+  const all = loadAssignments();
+  if (brandId) all[site] = { brand_id: brandId, updated_at: new Date().toISOString() };
+  else delete all[site];
+  saveAssignments(all);
+  res.json({ ok: true, site, brand_id: brandId });
+});
+
 // ── ping / health ───────────────────────────────────────────────────────
 app.get('/api/health', (req, res) => res.json({ ok: true, sites: SITES.length, ts: Date.now() }));
 

← a2f7a8c fleet: honor _shared/data/dw-tossed-domains.json — exclude t  ·  back to Dw Hero Admin  ·  chore: add tmp/ to .gitignore per standing rule 0ac6205 →