[object Object]

← 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

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 →