[object Object]

← back to Whatsmystyle

yolo tick 24: favorites table + ★ star icon on cards + /favorites screen + /api/brand/:name brand page (tier badge + bio) + taste-delta toast surfacing top-3 axis movers after each duel pick

4a6d1257c5778f68c43dd3dda23eb42f3490dad3 · 2026-05-12 13:19:49 -0700 · SteveStudio2

Files touched

Diff

commit 4a6d1257c5778f68c43dd3dda23eb42f3490dad3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 13:19:49 2026 -0700

    yolo tick 24: favorites table + ★ star icon on cards + /favorites screen + /api/brand/:name brand page (tier badge + bio) + taste-delta toast surfacing top-3 axis movers after each duel pick
---
 public/css/app.css |  47 +++++++++++++++++++
 public/index.html  |  22 +++++++++
 public/js/app.js   | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js          | 109 +++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 310 insertions(+), 3 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index f6bea46..c162707 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -819,6 +819,53 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .embed-chip-budget.budget-over { color: #ef4444; font-weight: 600; }
 .embed-chip-budget.budget-over .muted-sm { color: #ef4444; }
 
+/* ---- Star button + delta toast + brand page (tick 24) ---- */
+.star-btn {
+  position: absolute;
+  top: 8px; right: 8px;
+  z-index: 2;
+  width: 30px; height: 30px;
+  border: 0; border-radius: 50%;
+  background: rgba(255,255,255,0.85);
+  color: #707070;
+  font-size: 18px; line-height: 1;
+  cursor: pointer;
+  display: flex; align-items: center; justify-content: center;
+  box-shadow: 0 1px 4px rgba(0,0,0,0.18);
+}
+.star-btn:hover { color: #c9a96e; }
+.star-btn.is-fav { color: #c9a96e; background: #fff; }
+.star-btn.star-pop { animation: starPop 0.4s ease-out; }
+@keyframes starPop {
+  0%   { transform: scale(1); }
+  40%  { transform: scale(1.4); }
+  100% { transform: scale(1); }
+}
+
+.delta-toast {
+  position: fixed;
+  bottom: 70px;
+  left: 50%;
+  transform: translateX(-50%);
+  background: #1d1d1f;
+  color: #faf7f2;
+  padding: 10px 18px;
+  border-radius: 999px;
+  font-size: 13px;
+  display: flex; gap: 14px;
+  box-shadow: 0 6px 20px rgba(0,0,0,0.3);
+  z-index: 70;
+  opacity: 1;
+  transition: opacity 0.35s ease-out;
+}
+.delta-toast.toast-hide { opacity: 0; }
+.delta-toast .delta-up { color: #4ade80; font-weight: 600; }
+.delta-toast .delta-down { color: #f87171; font-weight: 600; }
+
+.brand-header { margin: 16px 0 22px; }
+.brand-header h2 { font-size: 36px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 6px; }
+.brand-bio { font-size: 14px; line-height: 1.5; margin: 6px 0 0; }
+
 /* ---- Production credit badge (tick 19) ---- */
 .card { position: relative; }   /* anchor for the absolute-positioned badge */
 .prod-credit-badge {
diff --git a/public/index.html b/public/index.html
index ff36db5..a83b57e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -38,6 +38,7 @@
   <a href="#" data-screen="taste">My Taste</a>
   <a href="#" data-screen="studio">Studio</a>
   <a href="#" data-screen="tryons">My Try-Ons</a>
+  <a href="#" data-screen="favorites">My Favorites</a>
   <a href="#" data-screen="productions" data-role-only="set_decorator">My Productions</a>
   <a href="#" data-screen="couple" data-role-only="couples_pilot">Couple</a>
   <a href="/admin-config" data-role-only="admin">Admin Config</a>
@@ -440,6 +441,27 @@
     </div>
   </section>
 
+  <!-- Favorites (tick 24) — starred catalog + closet items -->
+  <section id="favorites" class="screen" hidden>
+    <h2>My Favorites</h2>
+    <p class="muted">Tap the star on any item to save it here.</p>
+    <div id="favorites-grid" class="grid"></div>
+  </section>
+
+  <!-- Brand page (tick 24) — list every catalog item for one brand -->
+  <section id="brand" class="screen" hidden>
+    <a href="#" class="back-pill" id="brand-back">← back</a>
+    <header class="brand-header">
+      <h2 id="brand-title">Brand</h2>
+      <div id="brand-meta" class="muted"></div>
+      <p id="brand-bio" class="brand-bio muted"></p>
+    </header>
+    <div id="brand-grid" class="grid"></div>
+  </section>
+
+  <!-- Delta toast (tick 24) — top movers after every duel pick -->
+  <div id="delta-toast" class="delta-toast" hidden></div>
+
   <!-- Try-on history -->
   <section id="tryons" class="screen" hidden>
     <h2>My Try-Ons</h2>
diff --git a/public/js/app.js b/public/js/app.js
index 943d291..d51dd5a 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -232,6 +232,126 @@ $('#set-grade-only')?.addEventListener('change', e => {
   loadRecs();
 });
 
+// ---- Favorites + brand page + delta toast (tick 24) ---------------------
+const Favs = { set: new Set() };   // 'catalog:42', 'closet:7'
+
+async function refreshFavorites() {
+  try {
+    const r = await fetch('/api/favorites');
+    if (r.ok) {
+      const j = await r.json();
+      Favs.set = new Set(j.items.map(i => `${i.kind}:${i.item_id}`));
+    }
+  } catch {}
+}
+
+function isFav(kind, id) { return Favs.set.has(`${kind}:${id}`); }
+
+async function toggleFav(kind, id, btn) {
+  const key = `${kind}:${id}`;
+  const wasFav = Favs.set.has(key);
+  // Optimistic toggle + animation
+  if (wasFav) {
+    Favs.set.delete(key);
+    await fetch(`/api/favorites/${kind}/${id}`, { method: 'DELETE' });
+  } else {
+    Favs.set.add(key);
+    await fetch('/api/favorites', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind, id }) });
+    btn?.classList.add('star-pop');
+    setTimeout(() => btn?.classList.remove('star-pop'), 400);
+  }
+  if (btn) {
+    btn.classList.toggle('is-fav', !wasFav);
+    btn.textContent = !wasFav ? '★' : '☆';
+  }
+}
+
+async function loadFavorites() {
+  await refreshFavorites();
+  const r = await fetch('/api/favorites');
+  const j = r.ok ? await r.json() : { items: [] };
+  const g = $('#favorites-grid');
+  if (!j.items.length) {
+    g.innerHTML = '<p class="muted">No favorites yet. Tap the star on any item card to save it here.</p>';
+    return;
+  }
+  g.innerHTML = j.items.map(it => `
+    <div class="card">
+      <img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
+      <button type="button" class="star-btn is-fav" data-fav-kind="${it.kind}" data-fav-id="${it.item_id}" aria-label="Unfavorite">★</button>
+      <div class="meta">
+        <div class="title">${escapeHtml(it.title || it.category || 'piece')}</div>
+        <div class="brand">${escapeHtml(it.brand || '')}</div>
+        ${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
+      </div>
+    </div>
+  `).join('');
+}
+
+// Brand page
+async function openBrand(name) {
+  show('brand');
+  $('#brand-title').textContent = 'Loading…';
+  $('#brand-meta').textContent = '';
+  $('#brand-bio').textContent = '';
+  $('#brand-grid').innerHTML = '<p class="muted">Loading…</p>';
+  const r = await fetch(`/api/brand/${encodeURIComponent(name)}`);
+  if (!r.ok) { $('#brand-grid').innerHTML = '<p class="muted">Brand not found.</p>'; return; }
+  const j = await r.json();
+  $('#brand-title').textContent = j.brand;
+  $('#brand-meta').innerHTML = [
+    j.sustain_tier ? `<span class="sustain">♻ ${j.sustain_tier}/5</span>` : '',
+    `<span class="muted">${j.item_count} item${j.item_count === 1 ? '' : 's'}</span>`,
+  ].filter(Boolean).join(' · ');
+  $('#brand-bio').textContent = j.bio || '';
+  if (!j.items.length) { $('#brand-grid').innerHTML = '<p class="muted">No items in the catalog for this brand yet.</p>'; return; }
+  $('#brand-grid').innerHTML = j.items.map(it => `
+    <a class="card" href="${escapeHtml(it.product_url || '#')}" target="_blank" rel="noopener">
+      <img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
+      <button type="button" class="star-btn ${isFav('catalog', it.id) ? 'is-fav' : ''}" data-fav-kind="catalog" data-fav-id="${it.id}" onclick="event.preventDefault();event.stopPropagation();" aria-label="Favorite">${isFav('catalog', it.id) ? '★' : '☆'}</button>
+      <div class="meta">
+        <div class="title">${escapeHtml(it.title || '')}</div>
+        ${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
+      </div>
+    </a>
+  `).join('');
+}
+
+document.addEventListener('click', e => {
+  const star = e.target.closest('.star-btn');
+  if (star) {
+    e.preventDefault(); e.stopPropagation();
+    toggleFav(star.dataset.favKind, star.dataset.favId, star);
+    return;
+  }
+  const brandLink = e.target.closest('[data-brand]');
+  if (brandLink) {
+    e.preventDefault();
+    openBrand(brandLink.dataset.brand);
+  }
+});
+$('#brand-back')?.addEventListener('click', e => { e.preventDefault(); route('recs'); });
+
+// Delta toast (tick 24) — show top-3 taste movers after a duel pick
+function showDeltaToast(deltas) {
+  if (!deltas?.length) return;
+  const el = $('#delta-toast');
+  if (!el) return;
+  el.innerHTML = deltas.map(d => {
+    const sign = d.delta >= 0 ? '+' : '−';
+    const cls = d.delta >= 0 ? 'delta-up' : 'delta-down';
+    return `<span class="${cls}">${sign}${Math.abs(d.delta).toFixed(3)} ${d.dim}</span>`;
+  }).join('  ');
+  el.hidden = false;
+  el.classList.remove('toast-hide');
+  clearTimeout(showDeltaToast._t);
+  showDeltaToast._t = setTimeout(() => {
+    el.classList.add('toast-hide');
+    setTimeout(() => { el.hidden = true; }, 400);
+  }, 3000);
+}
+window.showDeltaToast = showDeltaToast;
+
 // ---- Facet pills (tick 23) — toggle color/material filter on click -------
 document.addEventListener('click', e => {
   const pill = e.target.closest('.facet-pill');
@@ -606,11 +726,13 @@ function paintCard(side, item) {
 
 async function pickDuel(choice) {
   if (!currentDuel) return;
-  await fetch('/api/duel', {
+  const r = await fetch('/api/duel', {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({ duel_id: currentDuel.duel_id, picked: choice })
   });
+  // Tick 24: surface top taste-axis movers as a toast.
+  try { const j = await r.json(); if (j.deltas?.length) showDeltaToast(j.deltas); } catch {}
   duelsAnswered++;
   const pct = Math.min(100, Math.round((duelsAnswered / 30) * 100));
   $('#taste-fill').style.width = pct + '%';
@@ -651,9 +773,12 @@ async function loadCloset() {
     // Tick 19: production-credit badge — set-decorator role only, item must have ≥1 credit.
     const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
     const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
+    const favOn = isFav('closet', it.id);
+    const star = `<button type="button" class="star-btn ${favOn ? 'is-fav' : ''}" data-fav-kind="closet" data-fav-id="${it.id}" aria-label="${favOn ? 'Unfavorite' : 'Favorite'}">${favOn ? '★' : '☆'}</button>`;
     c.innerHTML = `
       <img src="/api/closet/photo/${it.id}" alt="">
       ${badge}
+      ${star}
       <div class="meta">
         <div class="title">${it.category || 'analyzing…'}</div>
         <div class="brand">${it.color || ''}</div>
@@ -825,7 +950,10 @@ async function loadRecs() {
     const sustain = it.sustain ? `<span class="sustain" aria-label="Sustainability ${it.sustain} of 5">♻ ${it.sustain}/5</span>` : '';
     const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
     const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
-    a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy">${badge}<div class="meta"><div class="title">${it.title}</div><div class="brand">${it.brand || ''}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
+    const favOn = isFav('catalog', it.id);
+    const star = `<button type="button" class="star-btn ${favOn ? 'is-fav' : ''}" data-fav-kind="catalog" data-fav-id="${it.id}" aria-label="${favOn ? 'Unfavorite' : 'Favorite'}">${favOn ? '★' : '☆'}</button>`;
+    const brandLink = it.brand ? `<a href="#" data-brand="${escapeHtml(it.brand)}" onclick="event.stopPropagation();">${escapeHtml(it.brand)}</a>` : '';
+    a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy">${badge}${star}<div class="meta"><div class="title">${it.title}</div><div class="brand">${brandLink}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
     g.appendChild(a);
   });
 }
@@ -1663,6 +1791,8 @@ function route(name) {
   if (name === 'support') loadSupport();
   if (name === 'debates') loadDebates();
   if (name === 'productions') loadProductions();
+  if (name === 'favorites') loadFavorites();
+  if (name === 'brand') { /* loadBrand called by openBrand() with arg */ }
   if (name === 'couple') {
     // Reset any leftover panels when re-entering the screen.
     if ($('#couple-invite-out')) $('#couple-invite-out').hidden = true;
@@ -1785,6 +1915,7 @@ document.addEventListener('click', e => {
 // ---------- Boot -----------------------------------------------------------
 (async function boot() {
   await Role.refresh();  // tick 15.5 — populate role + banner before first paint
+  await refreshFavorites();   // tick 24 — populate star state before card renders
   const me = await fetch('/api/me').then(r => r.json());
   if (!me.onboarded) { show('onboard'); onbRender(); }
   else route('home');
diff --git a/server.js b/server.js
index 4e37c7c..2739f09 100644
--- a/server.js
+++ b/server.js
@@ -247,6 +247,19 @@ safeAddColumn('closet', "privacy TEXT DEFAULT 'shared'");
 safeAddColumn('users', "user_role TEXT DEFAULT 'public'");
 safeAddColumn('users', "view_as TEXT");
 
+// Tick 24: favorites — per-user star list across catalog + closet items.
+db.exec(`
+CREATE TABLE IF NOT EXISTS favorites (
+  id INTEGER PRIMARY KEY,
+  user_id INTEGER NOT NULL,
+  item_kind TEXT NOT NULL,       -- 'catalog' | 'closet'
+  item_id INTEGER NOT NULL,
+  created_at TEXT DEFAULT (datetime('now')),
+  UNIQUE(user_id, item_kind, item_id)
+);
+CREATE INDEX IF NOT EXISTS favorites_user_idx ON favorites(user_id, created_at DESC);
+`);
+
 // Tick 15.5: productions — film/TV/commercial projects a set_decorator user
 // is buying for. Lets set-decorator-mode anchor closet + try-on rows to a
 // specific show + episode, mirroring shotonwhat.com's credit structure.
@@ -617,6 +630,9 @@ app.post('/api/duel', (req, res) => {
   db.prepare("UPDATE duels SET picked = ?, answered_at = datetime('now') WHERE id = ?").run(picked, duel_id);
 
   // taste-vector update — light additive learning
+  // Tick 24: compute per-dim deltas and surface the top-3 movers so the /taste
+  // screen can render a "+0.03 fitted, +0.02 evening" toast after each pick.
+  let topDeltas = [];
   if (picked !== 'skip') {
     const winnerId = picked === 'A' ? d.item_a : d.item_b;
     const loserId  = picked === 'A' ? d.item_b : d.item_a;
@@ -626,6 +642,7 @@ app.post('/api/duel', (req, res) => {
     if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
     const we = w?.embedding ? JSON.parse(w.embedding) : Array(32).fill(0);
     const le = l?.embedding ? JSON.parse(l.embedding) : Array(32).fill(0);
+    const prev = taste.slice();
     for (let i = 0; i < 32; i++) {
       taste[i] = taste[i] * 0.95 + (we[i] - le[i]) * 0.05;
     }
@@ -636,8 +653,17 @@ app.post('/api/duel', (req, res) => {
       db.prepare('INSERT INTO taste_history (user_id, duels_answered, taste_vec) VALUES (?, ?, ?)')
         .run(u.id, answered, JSON.stringify(taste));
     }
+    // Tick 24: top movers — name comes from the BASIS list in embed-items.js
+    try {
+      const { BASIS } = require('./scripts/embed-items');
+      topDeltas = taste.map((v, i) => ({ dim: BASIS[i]?.key || `dim${i}`, delta: v - prev[i] }))
+        .filter(d => Math.abs(d.delta) > 0.001)
+        .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
+        .slice(0, 3)
+        .map(d => ({ ...d, delta: Number(d.delta.toFixed(3)) }));
+    } catch {}
   }
-  res.json({ ok: true });
+  res.json({ ok: true, deltas: topDeltas });
 });
 
 // ---- recommendations ------------------------------------------------------
@@ -703,6 +729,87 @@ app.get('/api/recommend', (req, res) => {
   });
 });
 
+// ---- brand page (tick 24) -------------------------------------------------
+// GET /api/brand/:name → { brand, sustain_tier, bio, items[] }
+// Server-side filter on items.brand (case-insensitive match). Sustainability
+// tier pulled from scripts/sustainability.js. Bio sourced from brands.json
+// (clothing-apis registry) for any registered brand.
+app.get('/api/brand/:name', (req, res) => {
+  const u = currentUser(req);
+  const name = decodeURIComponent(req.params.name || '').trim();
+  if (!name) return res.status(400).json({ error: 'brand required' });
+  const items = db.prepare(
+    `SELECT id, title, brand, category, color, pattern, material, price_cents, image_url, product_url, pro_grade
+       FROM items WHERE LOWER(brand) = LOWER(?) ORDER BY id DESC LIMIT 120`
+  ).all(name);
+  // Sustainability tier
+  const tier = (() => {
+    try { return require('./scripts/sustainability').tierFor(name); }
+    catch { return null; }
+  })();
+  // Bio from brands.json (sustain_tier + domain hint; LLM enrichment later)
+  let bio = null;
+  try {
+    const BRANDS = require('./scripts/clothing-apis/brands.json');
+    const all = [...(BRANDS.shopify_dtc || []), ...(BRANDS.headless_spa || [])];
+    const entry = all.find(b => b.name?.toLowerCase() === name.toLowerCase());
+    if (entry) {
+      const tierText = entry.sustain_tier ? `Sustainability tier ${entry.sustain_tier}/5.` : '';
+      const proText = entry.pro_grade ? 'Multi-stocked — set-decorator friendly.' : '';
+      bio = [tierText, proText, entry.disabled_reason ? `(${entry.disabled_reason})` : ''].filter(Boolean).join(' ');
+    }
+  } catch {}
+  res.json({
+    brand: name,
+    sustain_tier: tier,
+    bio,
+    item_count: items.length,
+    items,
+  });
+});
+
+// ---- favorites (tick 24) -------------------------------------------------
+// GET    /api/favorites               → list with hydrated item data
+// POST   /api/favorites               → { kind, id } (idempotent — INSERT OR IGNORE)
+// DELETE /api/favorites/:kind/:id     → unstar
+app.get('/api/favorites', (req, res) => {
+  const u = currentUser(req);
+  const rows = db.prepare(
+    `SELECT f.id AS fav_id, f.item_kind, f.item_id, f.created_at FROM favorites f
+      WHERE f.user_id=? ORDER BY f.created_at DESC`
+  ).all(u.id);
+  // Hydrate from items / closet
+  const hydrated = rows.map(r => {
+    if (r.item_kind === 'catalog') {
+      const it = db.prepare('SELECT id, title, brand, category, color, price_cents, image_url, product_url FROM items WHERE id=?').get(r.item_id);
+      return it ? { ...r, ...it, kind: 'catalog' } : { ...r, kind: 'catalog', _missing: true };
+    } else {
+      const it = db.prepare('SELECT id, vendor_guess AS brand, category, color, photo_path FROM closet WHERE id=? AND user_id=?').get(r.item_id, u.id);
+      return it ? { ...r, ...it, kind: 'closet', image_url: `/api/closet/photo/${it.id}` } : { ...r, kind: 'closet', _missing: true };
+    }
+  }).filter(x => !x._missing);
+  res.json({ items: hydrated });
+});
+
+app.post('/api/favorites', express.json({ limit: '4kb' }), (req, res) => {
+  const u = currentUser(req);
+  const { kind, id } = req.body || {};
+  if (!['catalog', 'closet'].includes(kind)) return res.status(400).json({ error: 'kind must be catalog|closet' });
+  const itemId = Number(id);
+  if (!Number.isFinite(itemId)) return res.status(400).json({ error: 'id required' });
+  db.prepare(`INSERT OR IGNORE INTO favorites (user_id, item_kind, item_id) VALUES (?, ?, ?)`).run(u.id, kind, itemId);
+  res.json({ ok: true });
+});
+
+app.delete('/api/favorites/:kind/:id', (req, res) => {
+  const u = currentUser(req);
+  const { kind } = req.params;
+  const itemId = Number(req.params.id);
+  if (!['catalog', 'closet'].includes(kind) || !Number.isFinite(itemId)) return res.status(400).json({ error: 'bad params' });
+  const r = db.prepare(`DELETE FROM favorites WHERE user_id=? AND item_kind=? AND item_id=?`).run(u.id, kind, itemId);
+  res.json({ ok: true, deleted: r.changes });
+});
+
 // ---- closet ---------------------------------------------------------------
 app.get('/api/closet', (req, res) => {
   const u = currentUser(req);

← fcfd422 yolo tick 23: color+material facet pills on /recs (server-si  ·  back to Whatsmystyle  ·  yolo tick 25: share-favorites endpoint + public /share/favor 113d543 →