← 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
M public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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 →