← 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
A data/brand-assignments.jsonM public/index.htmlM server.js
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 →