[object Object]

← back to Whatsmystyle

yolo tick 16: pro_grade catalog filter + production picker on closet upload + /couple lifecycle UI (flag-gated)

f20ac43e21b8dd43fdd13c8f5a5bf2eb171f0c49 · 2026-05-12 08:19:29 -0700 · SteveStudio2

Files touched

Diff

commit f20ac43e21b8dd43fdd13c8f5a5bf2eb171f0c49
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 08:19:29 2026 -0700

    yolo tick 16: pro_grade catalog filter + production picker on closet upload + /couple lifecycle UI (flag-gated)
---
 public/css/app.css |  96 ++++++++++++++++++++++++++++++++++++++++++
 public/index.html  |  38 +++++++++++++++++
 public/js/app.js   | 121 +++++++++++++++++++++++++++++++++++++++++++++++------
 server.js          |  80 ++++++++++++++++++++++++++++++-----
 4 files changed, 311 insertions(+), 24 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index a6c79f1..fd9181e 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -545,6 +545,102 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .tag-prod-status { font-size: 13px; }
 .tag-prod a { color: #1d1d1f; }
 
+/* ---- Set-grade toggle (tick 16) — set-decorator catalog filter ---- */
+.set-grade-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  padding: 6px 14px;
+  border: 1px solid #d6d0c4;
+  border-radius: 999px;
+  background: #fff;
+  color: #555;
+  cursor: pointer;
+}
+.set-grade-toggle input[type=checkbox] {
+  width: 16px; height: 16px;
+  accent-color: #1d1d1f;
+}
+
+/* Closet upload production picker — same look as other selects */
+#closet-prod-select {
+  font: inherit; padding: 8px 14px;
+  border: 1px solid #d6d0c4; border-radius: 14px;
+  background: #faf7f2;
+  margin: 0 8px;
+}
+
+/* ---- Couple lifecycle (tick 16, flag-gated) ---- */
+.couple-card {
+  background: #fff;
+  border: 1px solid #e6e1d8;
+  border-radius: 20px;
+  padding: 18px 22px;
+  margin: 14px 0;
+}
+.couple-card h3 {
+  margin: 0 0 6px;
+  font-size: 17px;
+  font-weight: 600;
+  letter-spacing: -0.01em;
+}
+.couple-card p.muted {
+  font-size: 13px;
+  margin: 0 0 12px;
+}
+.couple-card button.primary {
+  font: inherit;
+  background: #1d1d1f; color: #faf7f2;
+  border: 0;
+  padding: 10px 20px;
+  border-radius: 999px;
+  cursor: pointer;
+}
+.couple-card button.ghost {
+  font: inherit;
+  background: #fff; color: #1d1d1f;
+  border: 1px solid #d6d0c4;
+  padding: 10px 20px;
+  border-radius: 999px;
+  cursor: pointer;
+}
+.couple-token {
+  margin-top: 12px;
+  font-size: 13px;
+  background: #faf7f2;
+  border: 1px solid #e6e1d8;
+  border-radius: 12px;
+  padding: 10px 14px;
+  display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
+}
+.couple-token code {
+  background: #efe9dd;
+  padding: 4px 8px;
+  border-radius: 8px;
+  font-size: 12px;
+  word-break: break-all;
+}
+.couple-accept {
+  display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
+}
+.couple-accept input {
+  flex: 1; min-width: 220px;
+  font: inherit;
+  padding: 10px 14px;
+  border: 1px solid #d6d0c4;
+  border-radius: 14px;
+  background: #faf7f2;
+}
+.couple-accept button {
+  font: inherit;
+  background: #1d1d1f; color: #faf7f2;
+  border: 0;
+  padding: 10px 20px;
+  border-radius: 999px;
+  cursor: pointer;
+}
+
 /* ---- Tailor the view (tick 16) — set-decorator-only slider panel ---- */
 .tailor-panel {
   background: #fff;
diff --git a/public/index.html b/public/index.html
index 0720c4d..e882dac 100644
--- a/public/index.html
+++ b/public/index.html
@@ -30,6 +30,7 @@
   <a href="#" data-screen="studio">Studio</a>
   <a href="#" data-screen="tryons">My Try-Ons</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>
   <a href="#" data-screen="stores">Stores Near Me</a>
   <a href="#" data-screen="connect">Connections</a>
@@ -94,6 +95,9 @@
     <p class="muted">Upload a photo of your closet — we'll catalogue each piece automatically.</p>
     <form id="closet-form">
       <input type="file" name="photo" accept="image/*" capture="environment">
+      <select name="production_id" id="closet-prod-select" data-role-only="set_decorator" hidden>
+        <option value="">— untagged —</option>
+      </select>
       <button type="submit">Upload</button>
     </form>
     <div id="closet-grid" class="grid"></div>
@@ -116,6 +120,10 @@
         <label>Density
           <input id="density" type="range" min="120" max="320" value="220">
         </label>
+        <label class="set-grade-toggle" data-role-only="set_decorator" hidden>
+          <input type="checkbox" id="set-grade-only" />
+          <span>Set-grade only</span>
+        </label>
       </div>
     </div>
 
@@ -391,6 +399,36 @@
     <div id="productions-list" class="productions-list"></div>
   </section>
 
+  <!-- Couple (flag-gated lifecycle UI — tick 16) -->
+  <section id="couple" class="screen" hidden>
+    <h2>Couple</h2>
+    <p class="muted">Pilot. Each partner controls per-garment privacy. Either of you can exit at any time — exit purges the shared training join.</p>
+
+    <div class="couple-card">
+      <h3>Invite a partner</h3>
+      <p class="muted">Generate a one-time token. Share it any way you like; your partner pastes it on their device.</p>
+      <button type="button" id="couple-invite-btn" class="primary">Generate invite token</button>
+      <div id="couple-invite-out" class="couple-token" hidden></div>
+    </div>
+
+    <div class="couple-card">
+      <h3>Accept a partner's token</h3>
+      <p class="muted">Paste the token your partner sent. This links the two of you. Both can opt out later.</p>
+      <form id="couple-accept-form" class="couple-accept">
+        <input type="text" id="couple-token-input" placeholder="paste token" autocomplete="off" />
+        <button type="submit">Accept</button>
+      </form>
+      <div id="couple-accept-out" class="muted"></div>
+    </div>
+
+    <div class="couple-card">
+      <h3>Exit couple</h3>
+      <p class="muted">This sets the join to exited and purges the cross-twin training. Your individual closet stays intact.</p>
+      <button type="button" id="couple-exit-btn" class="ghost">Exit couple</button>
+      <div id="couple-exit-out" class="muted"></div>
+    </div>
+  </section>
+
   <!-- 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 2c75f3d..fc010ec 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -59,6 +59,7 @@ const Role = {
   view_as: null,
   effective: 'public',
   is_admin: false,
+  couples_pilot: false,
   async refresh() {
     try {
       const r = await fetch('/api/me');
@@ -67,27 +68,45 @@ const Role = {
       this.view_as = me.view_as || null;
       this.effective = me.effective_role || this.role;
       this.is_admin = !!me.is_admin;
+      // Read couples_pilot flag from admin config when available (admin only).
+      if (this.is_admin) {
+        try {
+          const cfg = await (await fetch('/api/admin/config')).json();
+          this.couples_pilot = !!cfg.config?.couples_pilot;
+        } catch {}
+      } else {
+        // Non-admins can't read the flag; rely on backend 503 if they try the
+        // couple endpoints. Hide the menu item conservatively.
+        this.couples_pilot = false;
+      }
       this.applyToDom();
+      // Populate any production-picker selects for set-decorator role.
+      if (this.effective === 'set_decorator' || this.role === 'set_decorator') {
+        populateProductionSelects();
+      }
     } catch {}
   },
   applyToDom() {
     // Menu items can be role-gated. data-role-only="admin" shows only when
     // user_role is admin. data-role-only="set_decorator" shows when effective
-    // role is set_decorator OR user_role is set_decorator.
+    // role is set_decorator OR user_role is set_decorator. data-role-only=
+    // "couples_pilot" is feature-flag-gated (Role.couples_pilot, refreshed
+    // from /api/admin/config).
+    const showFor = (need) => {
+      if (need === 'admin') return this.is_admin;
+      if (need === 'set_decorator') return this.effective === 'set_decorator' || this.role === 'set_decorator';
+      if (need === 'couples_pilot') return !!this.couples_pilot;
+      return true;
+    };
     $$('.menu a[data-role-only]').forEach(a => {
-      const need = a.dataset.roleOnly;
-      const showIt = (need === 'admin' && this.is_admin) ||
-                     (need === 'set_decorator' && (this.effective === 'set_decorator' || this.role === 'set_decorator'));
-      a.style.display = showIt ? '' : 'none';
+      a.style.display = showFor(a.dataset.roleOnly) ? '' : 'none';
     });
-    // Anything with [data-role-only] outside the menu (e.g., the Tailor panel
-    // inside /recs) toggles via the `hidden` attribute so layout collapses.
+    // Anything with [data-role-only] outside the menu (Tailor panel, set-
+    // grade toggle, closet production picker) 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;
+      if (el.matches('.menu a')) return;
+      el.hidden = !showFor(el.dataset.roleOnly);
     });
     // Banner: only show when an admin is previewing as something else.
     const banner = $('#role-banner');
@@ -108,6 +127,70 @@ const Role = {
 };
 window.Role = Role;
 
+// Populate every <select> that's a production picker (closet upload, future
+// try-on submit) with the current user's productions. Idempotent — wipes &
+// re-fills.
+async function populateProductionSelects() {
+  const selects = $$('select[name="production_id"]');
+  if (!selects.length) return;
+  let opts = '<option value="">— untagged —</option>';
+  try {
+    const r = await fetch('/api/productions');
+    if (r.ok) {
+      const { productions } = await r.json();
+      opts += productions.map(p =>
+        `<option value="${p.id}">${(p.title || '').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]))}${p.year ? ' (' + p.year + ')' : ''}</option>`
+      ).join('');
+    }
+  } catch {}
+  selects.forEach(s => {
+    const cur = s.value;
+    s.innerHTML = opts;
+    if (cur) s.value = cur;
+  });
+}
+
+// ---- Set-grade only toggle (tick 16) — set-decorator role ----
+$('#set-grade-only')?.addEventListener('change', e => {
+  localStorage.setItem('wms.proOnly', e.target.checked ? '1' : '0');
+  loadRecs();
+});
+
+// ---- Couple lifecycle UI (tick 16, flag-gated) ----
+async function coupleInvite() {
+  const out = $('#couple-invite-out');
+  out.hidden = false; out.textContent = 'Generating…';
+  const r = await fetch('/api/couple/invite', { method: 'POST', headers: { 'content-type': 'application/json' } });
+  const j = await r.json();
+  if (!r.ok) { out.textContent = j.reason || j.error || 'Failed'; return; }
+  out.innerHTML = `Token: <code>${escapeHtml(j.invite_token || '(reused)')}</code> <button type="button" class="ghost" id="copy-tok">Copy</button>`;
+  $('#copy-tok')?.addEventListener('click', async () => {
+    try { await navigator.clipboard.writeText(j.invite_token); $('#copy-tok').textContent = 'Copied'; }
+    catch { $('#copy-tok').textContent = 'Copy failed'; }
+  });
+}
+async function coupleAccept(e) {
+  e.preventDefault();
+  const t = $('#couple-token-input').value.trim();
+  if (!t) return;
+  const r = await fetch(`/api/couple/accept/${encodeURIComponent(t)}`, { method: 'POST' });
+  const j = await r.json();
+  const out = $('#couple-accept-out');
+  if (r.ok) { out.textContent = `Accepted. Couple #${j.couple_id} is now active.`; out.style.color = '#14572a'; $('#couple-token-input').value = ''; }
+  else { out.textContent = j.error || j.reason || 'Failed'; out.style.color = '#7a1717'; }
+}
+async function coupleExit() {
+  if (!confirm('Exit your couple? This purges the cross-twin training join. Your individual closet stays intact.')) return;
+  const r = await fetch('/api/couple/exit', { method: 'POST' });
+  const j = await r.json();
+  const out = $('#couple-exit-out');
+  if (r.ok) { out.textContent = `Exited couple #${j.couple_id}.`; out.style.color = '#14572a'; }
+  else { out.textContent = j.error || j.reason || 'Failed'; out.style.color = '#7a1717'; }
+}
+$('#couple-invite-btn')?.addEventListener('click', coupleInvite);
+$('#couple-accept-form')?.addEventListener('submit', coupleAccept);
+$('#couple-exit-btn')?.addEventListener('click', coupleExit);
+
 // ---------- Productions (set-decorator only) -------------------------------
 async function loadProductions() {
   const r = await fetch('/api/productions');
@@ -596,7 +679,13 @@ async function loadRecs() {
     });
   }
 
-  const r = await fetch('/api/recommend?limit=60').then(r => r.json());
+  // Set-grade only: persisted toggle, default ON for set-decorator role.
+  let proOnly = localStorage.getItem('wms.proOnly');
+  if (proOnly === null && isSetDec) proOnly = '1';
+  const proOnlyChecked = proOnly === '1';
+  if ($('#set-grade-only')) $('#set-grade-only').checked = proOnlyChecked;
+  const proQS = proOnlyChecked && isSetDec ? '&pro_only=1' : '';
+  const r = await fetch(`/api/recommend?limit=60${proQS}`).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));
@@ -1455,6 +1544,12 @@ function route(name) {
   if (name === 'support') loadSupport();
   if (name === 'debates') loadDebates();
   if (name === 'productions') loadProductions();
+  if (name === 'couple') {
+    // Reset any leftover panels when re-entering the screen.
+    if ($('#couple-invite-out')) $('#couple-invite-out').hidden = true;
+    if ($('#couple-accept-out')) $('#couple-accept-out').textContent = '';
+    if ($('#couple-exit-out'))   $('#couple-exit-out').textContent = '';
+  }
   if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
 }
 
diff --git a/server.js b/server.js
index dc067fe..8b87271 100644
--- a/server.js
+++ b/server.js
@@ -265,6 +265,39 @@ CREATE INDEX IF NOT EXISTS productions_user_idx ON productions(user_id, id);
 safeAddColumn('closet', 'production_id INTEGER');
 safeAddColumn('tryon_jobs', 'production_id INTEGER');
 
+// Tick 16: pro_grade flag on catalog items — set decorators need pieces that
+// are durable / replaceable / commercial-grade (multiple in stock at the
+// vendor, not one-off resale). 0/1 int; null = unknown. Heuristic seed pass
+// below tags items whose brand+category combo is typically multi-stocked.
+safeAddColumn('items', 'pro_grade INTEGER');
+try {
+  // Idempotent seed pass — tag items as pro_grade where brand is a known
+  // multi-stock retailer (mass + DTC contemporary). Resale-only brands stay
+  // untagged (single-piece inventory). Run once-per-boot; quick.
+  const PRO_BRANDS = new Set([
+    'Everlane', 'COS', 'Uniqlo', 'Madewell', 'J.Crew', 'Banana Republic',
+    'Theory', 'AGOLDE', 'Mother', 'Frame', 'Levi\'s', "Levi's",
+    'Vince', 'rag & bone', 'Marine Layer', 'L.L.Bean', 'Carhartt',
+    'Citizens of Humanity', 'Saint James', 'Tradlands', 'Mott & Bow',
+    'Patagonia', 'Allbirds', 'Veja', 'Naadam', 'Boody', 'Pact',
+    'Aritzia', 'Anthropologie', 'Free People', 'Express', 'Gap', 'Old Navy',
+    'Nike', 'Adidas',
+  ]);
+  const RESALE_SOURCES = new Set(['realreal', 'poshmark', 'vestiaire', 'grailed']);
+  const all = db.prepare("SELECT id, source, brand, pro_grade FROM items").all();
+  const upd = db.prepare('UPDATE items SET pro_grade = ? WHERE id = ?');
+  let tagged = 0;
+  for (const r of all) {
+    if (r.pro_grade !== null) continue;  // respect any prior manual override
+    const isResale = RESALE_SOURCES.has((r.source || '').toLowerCase());
+    const isProBrand = PRO_BRANDS.has((r.brand || '').trim());
+    const grade = isResale ? 0 : (isProBrand ? 1 : 0);
+    upd.run(grade, r.id);
+    tagged++;
+  }
+  if (tagged > 0) console.log(`[pro_grade seed] tagged ${tagged} items`);
+} catch (e) { console.error('[pro_grade seed] error', e.message); }
+
 // ---------- app ------------------------------------------------------------
 const app = express();
 app.use(express.json({ limit: '5mb' }));
@@ -600,9 +633,15 @@ app.get('/api/recommend', (req, res) => {
   if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
   const limit = Math.min(Number(req.query.limit) || 24, 100);
   const cat = req.query.category || null;
-  const rows = cat
-    ? db.prepare('SELECT * FROM items WHERE category = ? AND embedding IS NOT NULL').all(cat)
-    : db.prepare('SELECT * FROM items WHERE embedding IS NOT NULL').all();
+  const proOnly = req.query.pro_only === '1' || req.query.pro_only === 'true';
+  // pro_only restricts to set-grade pieces (pro_grade = 1). Tick 16:
+  // surfaced from the /recs UI for set decorators so the grid biases toward
+  // multi-stocked vendor pieces instead of one-off resale.
+  const where = ['embedding IS NOT NULL'];
+  const params = [];
+  if (cat) { where.push('category = ?'); params.push(cat); }
+  if (proOnly) { where.push('pro_grade = 1'); }
+  const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(' AND ')}`).all(...params);
   const sustainBoost = u.sustainability === 'top' ? 0.15 : u.sustainability === 'nice' ? 0.05 : 0;
   const scored = rows.map(r => {
     const e = JSON.parse(r.embedding);
@@ -626,9 +665,19 @@ app.get('/api/closet', (req, res) => {
 app.post('/api/closet/photo', upload.single('photo'), (req, res) => {
   const u = currentUser(req);
   if (!req.file) return res.status(400).json({ error: 'no file' });
-  db.prepare('INSERT INTO closet (user_id, photo_path, acquired_via) VALUES (?, ?, ?)')
-    .run(u.id, req.file.path, 'photo');
-  res.json({ ok: true, pending_vision: true });
+  // Tick 16: optional production_id from form-data (set decorators).
+  let pid = null;
+  const raw = req.body?.production_id;
+  if (raw && raw !== '0' && raw !== '') {
+    const n = Number(raw);
+    if (Number.isFinite(n) && n > 0) {
+      const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
+      if (owned) pid = n;
+    }
+  }
+  const result = db.prepare('INSERT INTO closet (user_id, photo_path, acquired_via, production_id) VALUES (?, ?, ?, ?)')
+    .run(u.id, req.file.path, 'photo', pid);
+  res.json({ ok: true, pending_vision: true, closet_id: result.lastInsertRowid, production_id: pid });
 });
 
 // Tick 15.8: per-item privacy toggle (shared / private). Ahead of couples
@@ -912,8 +961,17 @@ const swapUpload = multer({ dest: path.join(UPLOAD_DIR, 'swap'), limits: { fileS
 
 app.post('/api/tryon', swapUpload.single('source_photo'), (req, res) => {
   const u = currentUser(req);
-  const { item_id, closet_id, mode, occasion } = req.body;
+  const { item_id, closet_id, mode, occasion, production_id } = req.body;
   if (!item_id && !closet_id) return res.status(400).json({ error: 'item_id or closet_id required' });
+  // Tick 16: optional production_id binds the try-on to a set-decorator project.
+  let pid = null;
+  if (production_id && production_id !== '0' && production_id !== '') {
+    const n = Number(production_id);
+    if (Number.isFinite(n) && n > 0) {
+      const owned = db.prepare('SELECT 1 FROM productions WHERE id=? AND user_id=?').get(n, u.id);
+      if (owned) pid = n;
+    }
+  }
 
   // resolve garment ref — catalog or closet
   let garmentExists = false;
@@ -941,11 +999,11 @@ app.post('/api/tryon', swapUpload.single('source_photo'), (req, res) => {
   else if (process.env.OOTD_URL) provider = 'ootdiffusion';
 
   const { lastInsertRowid } = db.prepare(`
-    INSERT INTO tryon_jobs (user_id, avatar_id, item_id, closet_id, mode, source_photo_path, occasion, provider, status)
-    VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued')
-  `).run(u.id, av?.id || null, item_id || null, closet_id || null, m, source_photo_path, occasion || null, provider);
+    INSERT INTO tryon_jobs (user_id, avatar_id, item_id, closet_id, mode, source_photo_path, occasion, provider, status, production_id)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?)
+  `).run(u.id, av?.id || null, item_id || null, closet_id || null, m, source_photo_path, occasion || null, provider, pid);
 
-  res.json({ ok: true, job_id: lastInsertRowid, provider, queued: true });
+  res.json({ ok: true, job_id: lastInsertRowid, provider, queued: true, production_id: pid });
 });
 
 app.get('/api/tryon/:id', (req, res) => {

← eb523a4 test(tailor): 10 scoring assertions + bump era weight 1.0→1.  ·  back to Whatsmystyle  ·  feat(catalog-apis): adapter framework for Shopify/Etsy/eBay 202347c →