[object Object]

← back to Whatsmystyle

feat(tick15): outfit rationale text + /privacy-policy page + closet privacy pill (shared/private toggle)

514d80669328d21b2ab958311fcdb37196718d92 · 2026-05-12 07:36:54 -0700 · SteveStudio2

Files touched

Diff

commit 514d80669328d21b2ab958311fcdb37196718d92
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 07:36:54 2026 -0700

    feat(tick15): outfit rationale text + /privacy-policy page + closet privacy pill (shared/private toggle)
---
 public/css/app.css         | 32 ++++++++++++++++
 public/index.html          |  1 +
 public/js/app.js           | 30 ++++++++++++++-
 public/privacy-policy.html | 94 ++++++++++++++++++++++++++++++++++++++++++++++
 server.js                  | 81 +++++++++++++++++++++++++++++++++++++++
 5 files changed, 236 insertions(+), 2 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index cfd0582..28c6714 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -545,5 +545,37 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .tag-prod-status { font-size: 13px; }
 .tag-prod a { 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 {
+  font: inherit;
+  padding: 4px 10px;
+  border: 1px solid #d6d0c4;
+  border-radius: 999px;
+  background: rgba(255,255,255,0.7);
+  color: #555;
+  font-size: 11px;
+  cursor: pointer;
+}
+.privacy-pill.is-private {
+  background: #f4ecdd;
+  border-color: #c8b78e;
+  color: #6e4f0d;
+}
+.privacy-pill:hover { filter: brightness(1.05); }
+
+/* ---- Outfit rationale (tick 15.7) ---- */
+.outfit-rationale {
+  font-size: 13px;
+  color: #555;
+  line-height: 1.45;
+  margin: 10px 0 12px;
+  font-style: italic;
+  padding: 8px 12px;
+  background: #faf7f2;
+  border-left: 2px solid #c8c1b3;
+  border-radius: 0 8px 8px 0;
+}
+
 /* utils */
 [hidden] { display: none !important; }
diff --git a/public/index.html b/public/index.html
index b534c54..968d7d5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -490,6 +490,7 @@
   <a href="#" data-screen="about">About</a> ·
   <a href="#" data-screen="support">Support</a> ·
   <a href="#" data-screen="privacy">Privacy</a> ·
+  <a href="/privacy-policy">Privacy policy (full)</a> ·
   <a href="#" data-screen="debates">How decisions get made</a> ·
   <button class="link-btn" onclick="forgetMe()" aria-label="Delete all my data">Delete my account + everything</button>
 </footer>
diff --git a/public/js/app.js b/public/js/app.js
index bf7fe02..3bd5481 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -465,11 +465,36 @@ async function loadCloset() {
   (r.items || []).forEach(it => {
     const c = document.createElement('div');
     c.className = 'card';
-    c.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || 'analyzing…'}</div><div class="brand">${it.color || ''}</div><a class="credits-pill" href="#" data-credits-kind="closet" data-credits-id="${it.id}">view credits →</a></div>`;
+    const isPrivate = (it.privacy || 'shared') === 'private';
+    c.innerHTML = `
+      <img src="/api/closet/photo/${it.id}" alt="">
+      <div class="meta">
+        <div class="title">${it.category || 'analyzing…'}</div>
+        <div class="brand">${it.color || ''}</div>
+        <div class="meta-row">
+          <a class="credits-pill" href="#" data-credits-kind="closet" data-credits-id="${it.id}">credits →</a>
+          <button type="button" class="privacy-pill ${isPrivate ? 'is-private' : 'is-shared'}" data-closet-id="${it.id}" data-privacy="${isPrivate ? 'private' : 'shared'}" aria-label="Toggle item privacy">${isPrivate ? '🔒 private' : '· shared'}</button>
+        </div>
+      </div>`;
     g.appendChild(c);
   });
 }
 
+// Closet privacy pill toggle — tick 15.8. Click flips shared ↔ private.
+document.addEventListener('click', async e => {
+  const btn = e.target.closest('.privacy-pill');
+  if (!btn) return;
+  e.preventDefault();
+  const id = btn.dataset.closetId;
+  const next = btn.dataset.privacy === 'private' ? 'shared' : 'private';
+  const r = await fetch(`/api/closet/${id}/privacy`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ privacy: next }) });
+  if (r.ok) {
+    btn.dataset.privacy = next;
+    btn.className = `privacy-pill ${next === 'private' ? 'is-private' : 'is-shared'}`;
+    btn.textContent = next === 'private' ? '🔒 private' : '· shared';
+  }
+});
+
 // ---------- Recommendations -----------------------------------------------
 async function loadRecs() {
   const sort = localStorage.getItem('wms.sort') || 'taste';
@@ -892,7 +917,8 @@ async function buildOutfits(closetItem) {
         <div class="cat">${cat}</div>
         <div class="name">${s.brand || ''}</div>
       </div>`).join('');
-    card.innerHTML = `<h4>Look ${i+1}</h4><div class="outfit-slots">${slots}</div><button class="pick-this">Wear this</button>`;
+    const rationaleHtml = o.rationale ? `<p class="outfit-rationale">${escapeHtml(o.rationale)}</p>` : '';
+    card.innerHTML = `<h4>Look ${i+1}</h4><div class="outfit-slots">${slots}</div>${rationaleHtml}<button class="pick-this">Wear this</button>`;
     card.querySelector('.pick-this').onclick = async () => {
       await fetch(`/api/outfits/${r.pick_id}/pick`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ index: i })});
       grid.querySelectorAll('.outfit-card').forEach(x => x.classList.remove('picked'));
diff --git a/public/privacy-policy.html b/public/privacy-policy.html
new file mode 100644
index 0000000..08d6f46
--- /dev/null
+++ b/public/privacy-policy.html
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>WhatsMyStyle — Privacy</title>
+  <link rel="stylesheet" href="/css/app.css" />
+  <style>
+    body { max-width: 720px; margin: 40px auto; padding: 0 24px; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #1d1d1f; background: #faf7f2; }
+    h1 { font-size: 36px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
+    h2 { font-size: 22px; font-weight: 600; margin: 32px 0 8px; }
+    p, li { font-size: 16px; line-height: 1.6; color: #1d1d1f; }
+    .muted { color: #707070; }
+    ul { padding-left: 22px; }
+    .card { background: #fff; border: 1px solid #e6e1d8; border-radius: 20px; padding: 22px 28px; margin: 14px 0; }
+    .back { display: inline-block; margin-bottom: 18px; color: #555; text-decoration: none; font-size: 14px; }
+    .back:hover { color: #1d1d1f; }
+    code { background: #efe9dd; padding: 2px 6px; border-radius: 6px; font-size: 13px; }
+  </style>
+</head>
+<body>
+  <a class="back" href="/">← back to app</a>
+  <h1>Privacy</h1>
+  <p class="muted">Last updated: 2026-05-12 · short version, written in plain English. The legal version lives below it.</p>
+
+  <h2>What we collect</h2>
+  <div class="card">
+    <ul>
+      <li><strong>Photos you upload.</strong> Closet pictures, source photos for try-on, old photos for Time-Travel. Stored on our server only.</li>
+      <li><strong>Style signals.</strong> Every duel A-or-B pick. The 32-axis taste vector is computed from these. We don't sell this vector and we don't ship it to ad networks.</li>
+      <li><strong>Onboarding answers.</strong> Body shape, budget band, location for "stores near me", optional age/hair/undertone. All stored on us, none required except body shape, budget, and city.</li>
+      <li><strong>Receipts (opt-in).</strong> If you connect Gmail or Stripe Financial Connections, we parse shipping/order emails to seed your closet. Raw email bodies are never persisted — only brand + title + price.</li>
+      <li><strong>Try-on renders.</strong> If a render uses a paid AI provider (FASHN, Gemini, OpenAI), the input photo + garment image are sent to that provider per its TOS. Cost is logged in our per-user budget ledger.</li>
+    </ul>
+  </div>
+
+  <h2>What we don't do</h2>
+  <div class="card">
+    <ul>
+      <li>We don't sell your photos, taste vector, or receipts to anyone.</li>
+      <li>We don't share data with ad networks or trackers. There are zero ad-network pixels on the app.</li>
+      <li>We don't train a global ML model on your private photos. The 32-axis projection is deterministic + local-only.</li>
+      <li>We don't expose which AI provider does the try-on — to you the result is "WhatsMyStyle's proprietary stylist system."</li>
+    </ul>
+  </div>
+
+  <h2>Three user tiers</h2>
+  <div class="card">
+    <ul>
+      <li><strong>Admin.</strong> Steve and the team. Gated by hostname or <code>?admin=1</code>. Sees the config console and can preview the public / set-decorator views without leaving session.</li>
+      <li><strong>General user — public.</strong> The retail experience: duel, recs, closet, try-on, time-travel. Default tier on signup.</li>
+      <li><strong>General user — set decorator.</strong> Film/TV trade tier. Same retail features plus per-production tagging so closet pieces + try-ons can be grouped by show. Productions live only on your account; nothing about your shows is exposed publicly.</li>
+    </ul>
+  </div>
+
+  <h2>Couples / shared-closets mode</h2>
+  <div class="card">
+    <p>The couples pilot is <strong>flag-gated and off by default</strong>. When two users opt in:</p>
+    <ul>
+      <li>Each partner controls per-garment privacy: <code>shared</code> (visible to both, eligible for joint outfit picks) or <code>private</code> (your eyes only).</li>
+      <li>Gift mode requires the receiving partner to approve each render slot before it generates.</li>
+      <li>Either partner can hit <strong>Exit couple</strong> at any time. Exit marks the couple <code>exited</code>, locks the join, and purges cross-twin training data.</li>
+    </ul>
+  </div>
+
+  <h2>Retention + deletion</h2>
+  <div class="card">
+    <p>Hit <strong>Forget me</strong> (footer of the app, or POST <code>/api/me/forget</code>) and we hard-delete:</p>
+    <ul>
+      <li>Your user row, taste vector, taste-history snapshots.</li>
+      <li>All closet rows + photo files on disk.</li>
+      <li>Your avatar + every try-on render.</li>
+      <li>All outfit picks, debate rows, productions, waitlist entries.</li>
+      <li>Couples links you've created (and any cross-twin training tied to them).</li>
+    </ul>
+    <p class="muted">There is no soft-delete. Once you've hit forget-me, your data is gone from disk and from backups within 30 days.</p>
+  </div>
+
+  <h2>Receipt parsing — ephemeral</h2>
+  <div class="card">
+    <p>Receipts are processed in-process. We extract <code>brand</code>, <code>title</code>, and <code>price_cents</code>, then drop the email body. We don't persist <code>from</code>/<code>to</code> headers, subject lines, or any other email metadata.</p>
+  </div>
+
+  <h2>Resale guardrails</h2>
+  <div class="card">
+    <p>Per the 2026-05-12 design verdict, any "sell this" nudge requires you to have owned the piece ≥ 90 days OR confirmed "I won't wear it again." Even then, repair/recycle options surface first — we don't accelerate fast-fashion churn for affiliate revenue.</p>
+  </div>
+
+  <h2>Contact</h2>
+  <div class="card">
+    <p>Privacy questions: <a href="mailto:steve@designerwallcoverings.com">steve@designerwallcoverings.com</a>.</p>
+  </div>
+</body>
+</html>
diff --git a/server.js b/server.js
index 449af95..dc067fe 100644
--- a/server.js
+++ b/server.js
@@ -631,6 +631,18 @@ app.post('/api/closet/photo', upload.single('photo'), (req, res) => {
   res.json({ ok: true, pending_vision: true });
 });
 
+// Tick 15.8: per-item privacy toggle (shared / private). Ahead of couples
+// pilot — gives users a primitive to mark closet items they don't want
+// auto-included in shared-mode outfit picks once couples ships.
+app.put('/api/closet/:id/privacy', (req, res) => {
+  const u = currentUser(req);
+  const v = req.body?.privacy;
+  if (!['shared', 'private'].includes(v)) return res.status(400).json({ error: 'privacy must be shared|private' });
+  const result = db.prepare('UPDATE closet SET privacy=? WHERE id=? AND user_id=?').run(v, req.params.id, u.id);
+  if (result.changes === 0) return res.status(404).json({ error: 'closet item not found' });
+  res.json({ ok: true, privacy: v });
+});
+
 // ---- connections (placeholders until OAuth wired) -------------------------
 app.post('/api/connect/:kind', (req, res) => {
   const u = currentUser(req);
@@ -714,6 +726,11 @@ app.get('/admin-config', (req, res) => {
   if (!adminGate(req)) return res.status(403).send('admin only');
   res.sendFile(path.join(__dirname, 'public', 'admin-config.html'));
 });
+
+// Privacy page (tick 15.8). Public — no admin gate.
+app.get('/privacy-policy', (_req, res) => {
+  res.sendFile(path.join(__dirname, 'public', 'privacy-policy.html'));
+});
 app.put('/api/admin/config', express.json({ limit: '8kb' }), (req, res) => {
   if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
   const updates = req.body || {};
@@ -974,6 +991,67 @@ const SLOT_RULES = {
 
 function dot(a, b) { let s = 0; for (let i = 0; i < a.length && i < b.length; i++) s += a[i] * b[i]; return s; }
 
+// Tick 15.7: deterministic outfit rationale builder.
+// Reads slot titles + brands and templates a 1–2 sentence "why this works"
+// blurb. NO LLM call — pure string/regex inspection so it stays cheap +
+// reproducible across reloads. Renders well even when items have sparse
+// metadata: degrades to a brand-pairing sentence instead of a wardrobe one.
+const RATIONALE_PATTERNS = {
+  workwear:    /blazer|trouser|suit|oxford|loafer|button[- ]?down/i,
+  evening:     /silk|sequin|gown|tuxedo|velvet|wrap dress|cocktail/i,
+  casual:      /tee|t-shirt|jean|denim|sneaker|hoodie|sweatpant|chino/i,
+  outdoorsy:   /trench|parka|puffer|raincoat|hiking|barn jacket/i,
+  knitted:     /knit|cashmere|wool|merino|cardigan|sweater/i,
+  leather:     /leather|suede/i,
+  warm_hue:    /cream|camel|tan|rust|gold|brown|terracotta|ivory|oat|honey/i,
+  cool_hue:    /black|navy|grey|gray|charcoal|indigo|cobalt|teal|sage/i,
+};
+function tagsForSlot(slot) {
+  const text = `${slot?.title || ''} ${slot?.brand || ''}`;
+  const out = new Set();
+  for (const [tag, re] of Object.entries(RATIONALE_PATTERNS)) {
+    if (re.test(text)) out.add(tag);
+  }
+  return out;
+}
+function rationaleFor(outfit) {
+  if (!outfit || !outfit.slots) return '';
+  const slots = outfit.slots;
+  const titles = Object.entries(slots).map(([k, v]) => v ? `${k}: ${v.title}` : null).filter(Boolean);
+  if (!titles.length) return '';
+  const tagSets = Object.fromEntries(Object.entries(slots).map(([k, v]) => [k, tagsForSlot(v)]));
+  const all = new Set();
+  for (const s of Object.values(tagSets)) for (const t of s) all.add(t);
+
+  // sentence 1 — pick the dominant register
+  let s1 = '';
+  if (all.has('evening')) s1 = 'Goes evening without trying too hard.';
+  else if (all.has('workwear')) s1 = 'Tailored enough for a meeting, easy enough for after.';
+  else if (all.has('outdoorsy') && all.has('knitted')) s1 = 'A weather-ready layering set; the knit softens the outer shell.';
+  else if (all.has('knitted') && all.has('casual')) s1 = 'A cozy weekday pairing that won\'t read as sweats.';
+  else if (all.has('leather') && all.has('casual')) s1 = 'A casual base with leather to pull the look up a notch.';
+  else if (all.has('casual')) s1 = 'A relaxed everyday combination — easy to throw on.';
+  else s1 = 'A balanced pairing pulled from your taste signals.';
+
+  // sentence 2 — color contrast cue
+  let s2 = '';
+  const warm = all.has('warm_hue'), cool = all.has('cool_hue');
+  if (warm && cool) s2 = 'The warm/cool tension keeps it from going monochrome.';
+  else if (warm) s2 = 'A warm palette — easy on the skin, photographs well.';
+  else if (cool) s2 = 'Cool tones throughout — sharp and forgiving in low light.';
+
+  // sentence 3 — brand callout (optional)
+  const brands = Object.values(slots).map(v => v?.brand).filter(Boolean);
+  let s3 = '';
+  if (brands.length >= 2) {
+    const u = [...new Set(brands)];
+    if (u.length === 1) s3 = `Head-to-toe ${u[0]} keeps the silhouette consistent.`;
+    else if (u.length >= 2 && u.length <= 3) s3 = `${u.slice(0, 2).join(' + ')} pair well in this register.`;
+  }
+
+  return [s1, s2, s3].filter(Boolean).join(' ');
+}
+
 function buildOutfitsFor(db, u, anchorEmbed, anchorCategory) {
   const need = SLOT_RULES[anchorCategory] || ['top', 'bottom', 'shoes'];
   const candidatesByCat = {};
@@ -1032,6 +1110,9 @@ app.post('/api/outfits', (req, res) => {
   }
 
   const outfits = buildOutfitsFor(db, u, anchorEmbed, anchorCategory);
+  // Tick 15.7: attach a deterministic "why this works" rationale per outfit.
+  for (const o of outfits) o.rationale = rationaleFor(o);
+
   const { lastInsertRowid } = db.prepare(
     'INSERT INTO outfit_picks (user_id, anchor_kind, anchor_id, outfits) VALUES (?, ?, ?, ?)'
   ).run(u.id, closet_id ? 'closet' : 'catalog', closet_id || item_id, JSON.stringify(outfits));

← 545c7d2 feat(productions): per-production résumé + closet/tryon tagg  ·  back to Whatsmystyle  ·  feat(tailor): set-decorator 'Tailor the view' panel — Era/Pa 73ee5d4 →