← 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
M public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
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 →