← back to Whatsmystyle
feat(tailor): set-decorator 'Tailor the view' panel — Era/Palette/Density/Tone sliders re-rank recs grid to match show's visual language
73ee5d4ac4476e4064eee84f4e79040b1af36b29 · 2026-05-12 07:58:31 -0700 · SteveStudio2
Files touched
M public/css/app.cssM public/index.htmlM public/js/app.js
Diff
commit 73ee5d4ac4476e4064eee84f4e79040b1af36b29
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 07:58:31 2026 -0700
feat(tailor): set-decorator 'Tailor the view' panel — Era/Palette/Density/Tone sliders re-rank recs grid to match show's visual language
---
public/css/app.css | 62 ++++++++++++++++++++++++++
public/index.html | 33 ++++++++++++++
public/js/app.js | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 221 insertions(+), 2 deletions(-)
diff --git a/public/css/app.css b/public/css/app.css
index 28c6714..a6c79f1 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -545,6 +545,68 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.tag-prod-status { font-size: 13px; }
.tag-prod a { color: #1d1d1f; }
+/* ---- Tailor the view (tick 16) — set-decorator-only slider panel ---- */
+.tailor-panel {
+ background: #fff;
+ border: 1px solid #e6e1d8;
+ border-radius: 20px;
+ padding: 20px 24px;
+ margin: 12px 0 20px;
+}
+.tailor-header h3 {
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin: 0 0 4px;
+}
+.tailor-header p.muted {
+ font-size: 13px;
+ margin: 0 0 14px;
+}
+.tailor-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr) auto;
+ gap: 18px;
+ align-items: end;
+}
+@media (max-width: 720px) {
+ .tailor-grid { grid-template-columns: repeat(2, 1fr); }
+ .tailor-reset { grid-column: span 2; justify-self: end; }
+}
+.tailor-slider {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.tailor-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: #707070;
+ font-weight: 600;
+}
+.tailor-slider input[type=range] {
+ width: 100%;
+ accent-color: #1d1d1f;
+}
+.tailor-slider output {
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ color: #1d1d1f;
+ font-weight: 500;
+}
+.tailor-reset {
+ font: inherit;
+ font-size: 12px;
+ padding: 6px 14px;
+ border: 1px solid #d6d0c4;
+ background: #faf7f2;
+ color: #555;
+ border-radius: 999px;
+ cursor: pointer;
+}
+.tailor-reset:hover { background: #fff; color: #1d1d1f; }
+
/* ---- Closet privacy pill (tick 15.8) ---- */
.meta-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
.privacy-pill {
diff --git a/public/index.html b/public/index.html
index 968d7d5..0720c4d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -118,6 +118,39 @@
</label>
</div>
</div>
+
+ <!-- Tailor the view — set-decorator only (tick 16). Sliders re-rank
+ the grid to match the show's visual language. Persisted to localStorage. -->
+ <section class="tailor-panel" data-role-only="set_decorator" hidden>
+ <header class="tailor-header">
+ <h3>Tailor the view</h3>
+ <p class="muted">Sliders set the visual language so what you see matches the show you're decorating.</p>
+ </header>
+ <div class="tailor-grid">
+ <label class="tailor-slider">
+ <span class="tailor-label">Era</span>
+ <input id="tailor-era" type="range" min="1900" max="2030" step="1" value="1975" />
+ <output id="tailor-era-val">1975</output>
+ </label>
+ <label class="tailor-slider">
+ <span class="tailor-label">Palette</span>
+ <input id="tailor-palette" type="range" min="0" max="100" step="1" value="93" />
+ <output id="tailor-palette-val">93</output>
+ </label>
+ <label class="tailor-slider">
+ <span class="tailor-label">Density</span>
+ <input id="tailor-density" type="range" min="180" max="400" step="1" value="277" />
+ <output id="tailor-density-val">277</output>
+ </label>
+ <label class="tailor-slider">
+ <span class="tailor-label">Tone</span>
+ <input id="tailor-tone" type="range" min="0" max="100" step="1" value="73" />
+ <output id="tailor-tone-val">73</output>
+ </label>
+ <button type="button" class="tailor-reset" id="tailor-reset" title="Reset to defaults">Reset</button>
+ </div>
+ </section>
+
<div id="recs-grid" class="grid"></div>
</section>
diff --git a/public/js/app.js b/public/js/app.js
index 3bd5481..5ea596b 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -80,6 +80,15 @@ const Role = {
(need === 'set_decorator' && (this.effective === 'set_decorator' || this.role === 'set_decorator'));
a.style.display = showIt ? '' : 'none';
});
+ // Anything with [data-role-only] outside the menu (e.g., the Tailor panel
+ // inside /recs) toggles via the `hidden` attribute so layout collapses.
+ $$('[data-role-only]').forEach(el => {
+ if (el.matches('.menu a')) return; // already handled above
+ const need = el.dataset.roleOnly;
+ const showIt = (need === 'admin' && this.is_admin) ||
+ (need === 'set_decorator' && (this.effective === 'set_decorator' || this.role === 'set_decorator'));
+ el.hidden = !showIt;
+ });
// Banner: only show when an admin is previewing as something else.
const banner = $('#role-banner');
if (this.is_admin && this.view_as) {
@@ -496,17 +505,109 @@ document.addEventListener('click', async e => {
});
// ---------- Recommendations -----------------------------------------------
+// ---- Tailor the view (tick 16) ------------------------------------------
+// Four sliders re-rank the recs grid to match the show being decorated.
+// Era → era hint match. Palette → saturation bias. Tone → formal/casual bias.
+// Density → CSS card-min. Persisted to localStorage; only shown when
+// effective_role === 'set_decorator'.
+const TAILOR_DEFAULTS = { era: 1975, palette: 93, density: 277, tone: 73 };
+
+function getTailor() {
+ return {
+ era: Number(localStorage.getItem('wms.tailor.era') || TAILOR_DEFAULTS.era),
+ palette: Number(localStorage.getItem('wms.tailor.palette') || TAILOR_DEFAULTS.palette),
+ density: Number(localStorage.getItem('wms.tailor.density') || TAILOR_DEFAULTS.density),
+ tone: Number(localStorage.getItem('wms.tailor.tone') || TAILOR_DEFAULTS.tone),
+ };
+}
+
+// Heuristic era extraction from item title/brand. Most catalog items have no
+// year, so we map common era words to a representative decade.
+const ERA_WORDS = [
+ [/\b(victorian|edwardian)\b/i, 1900],
+ [/\b(1920s|art deco|gatsby|jazz age)\b/i, 1925],
+ [/\b(1930s|depression|hollywood golden)\b/i, 1935],
+ [/\b(1940s|war[- ]era|forties)\b/i, 1945],
+ [/\b(1950s|fifties|midcentury|mid[- ]?century|atomic)\b/i, 1955],
+ [/\b(1960s|sixties|mod|psychedelic|space age)\b/i, 1965],
+ [/\b(1970s|seventies|disco|bohemian|hippie)\b/i, 1975],
+ [/\b(1980s|eighties|new wave|preppy revival|power)\b/i, 1985],
+ [/\b(1990s|nineties|grunge|minimal[- ]?ist)\b/i, 1995],
+ [/\b(y2k|early 2000s|2000s)\b/i, 2005],
+ [/\b(2010s)\b/i, 2015],
+ [/\b(modern|contemporary|current|today)\b/i, 2025],
+ [/\bvintage\b/i, 1965], // generic vintage → mid-60s
+ [/\bretro\b/i, 1975], // generic retro → 70s
+];
+function eraOf(it) {
+ const text = `${it.title || ''} ${it.brand || ''} ${it.tags || ''}`;
+ for (const [re, year] of ERA_WORDS) if (re.test(text)) return year;
+ return null; // unknown — neutral score
+}
+
+const PALETTE_SATURATED = /\b(red|crimson|cobalt|emerald|fuchsia|magenta|electric|neon|vibrant|saffron|chartreuse|tangerine|hot pink|royal blue)\b/i;
+const PALETTE_NEUTRAL = /\b(beige|stone|ivory|cream|oat|sand|taupe|nude|mushroom|greige|bone|natural)\b/i;
+const TONE_FORMAL = /\b(blazer|tuxedo|gown|silk|wool|tweed|cashmere|tailored|suit|oxford|loafer|trouser|evening|cocktail)\b/i;
+const TONE_CASUAL = /\b(tee|t-shirt|hoodie|sweatpant|jean|denim|sneaker|track|jogger|cargo|baseball)\b/i;
+
+function tailorScore(it, tailor) {
+ let s = 0;
+ // Era — Gaussian-ish: full credit at exact match, drop to 0 at ±25 years
+ const yr = eraOf(it);
+ if (yr !== null) {
+ const delta = Math.abs(yr - tailor.era);
+ s += Math.max(0, 1 - delta / 25) * 1.0;
+ }
+ const text = `${it.title || ''} ${it.brand || ''} ${it.color || ''} ${it.tags || ''}`;
+ // Palette — high palette → saturated bonus, low palette → neutral bonus
+ const sat = PALETTE_SATURATED.test(text) ? 1 : 0;
+ const neu = PALETTE_NEUTRAL.test(text) ? 1 : 0;
+ const palT = tailor.palette / 100; // 0..1
+ s += sat * palT * 0.8 + neu * (1 - palT) * 0.8;
+ // Tone — high tone → formal bonus, low tone → casual bonus
+ const fml = TONE_FORMAL.test(text) ? 1 : 0;
+ const csl = TONE_CASUAL.test(text) ? 1 : 0;
+ const tonT = tailor.tone / 100;
+ s += fml * tonT * 0.7 + csl * (1 - tonT) * 0.7;
+ return s;
+}
+
async function loadRecs() {
const sort = localStorage.getItem('wms.sort') || 'taste';
- const minPx = localStorage.getItem('wms.density') || 220;
+ const isSetDec = (Role.effective === 'set_decorator' || Role.role === 'set_decorator');
+ const tailor = getTailor();
+ // Density: set-decorator's Tailor slider wins; otherwise the legacy slider.
+ const minPx = isSetDec ? tailor.density : (Number(localStorage.getItem('wms.density')) || 220);
+
$('#sort').value = sort;
- $('#density').value = minPx;
+ $('#density').value = Math.min(320, Math.max(120, minPx)); // legacy slider clamped to its range
+
+ // Mirror tailor values into the panel inputs.
+ if (isSetDec) {
+ const ids = ['era', 'palette', 'density', 'tone'];
+ ids.forEach(k => {
+ const inp = $(`#tailor-${k}`);
+ const out = $(`#tailor-${k}-val`);
+ if (inp) inp.value = tailor[k];
+ if (out) out.textContent = tailor[k];
+ });
+ }
+
const r = await fetch('/api/recommend?limit=60').then(r => r.json());
let items = r.items || [];
if (sort === 'price_asc') items.sort((a, b) => (a.price_cents || 0) - (b.price_cents || 0));
else if (sort === 'price_desc') items.sort((a, b) => (b.price_cents || 0) - (a.price_cents || 0));
else if (sort === 'brand') items.sort((a, b) => (a.brand || '').localeCompare(b.brand || ''));
else if (sort === 'title') items.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
+ // For set decorators on Best Match, apply Tailor re-rank on top of taste order.
+ if (isSetDec && sort === 'taste') {
+ items.forEach((it, i) => { it._origIdx = i; it._tailorScore = tailorScore(it, tailor); });
+ items.sort((a, b) => {
+ // Tailor score dominates; ties broken by original taste order.
+ if (b._tailorScore !== a._tailorScore) return b._tailorScore - a._tailorScore;
+ return a._origIdx - b._origIdx;
+ });
+ }
const g = $('#recs-grid');
g.style.setProperty('--card-min', minPx + 'px');
g.innerHTML = '';
@@ -524,6 +625,29 @@ async function loadRecs() {
$('#sort').addEventListener('change', e => { localStorage.setItem('wms.sort', e.target.value); loadRecs(); });
$('#density').addEventListener('input', e => { localStorage.setItem('wms.density', e.target.value); loadRecs(); });
+// Tailor sliders — live update output + localStorage + reflow grid.
+['era', 'palette', 'density', 'tone'].forEach(k => {
+ const inp = $(`#tailor-${k}`);
+ const out = $(`#tailor-${k}-val`);
+ if (!inp || !out) return;
+ inp.addEventListener('input', e => {
+ out.textContent = e.target.value;
+ localStorage.setItem(`wms.tailor.${k}`, e.target.value);
+ if (k === 'density') {
+ // Density-only changes: just update CSS, no need to re-fetch + re-sort.
+ $('#recs-grid')?.style.setProperty('--card-min', e.target.value + 'px');
+ } else {
+ loadRecs();
+ }
+ });
+});
+$('#tailor-reset')?.addEventListener('click', () => {
+ for (const [k, v] of Object.entries(TAILOR_DEFAULTS)) {
+ localStorage.setItem(`wms.tailor.${k}`, v);
+ }
+ loadRecs();
+});
+
// ---------- Stores ---------------------------------------------------------
async function requestLocation() {
navigator.geolocation?.getCurrentPosition(async p => {
← 514d806 feat(tick15): outfit rationale text + /privacy-policy page +
·
back to Whatsmystyle
·
test(tailor): 10 scoring assertions + bump era weight 1.0→1. eb523a4 →