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