← back to Whatsmystyle
tick 3: accessibility + privacy + sustainability + delete-my-stuff
d56d33bbc8b912f59ff6a4ed52d3dc17421d662c · 2026-05-11 20:26:25 -0700 · Steve Abrams
Acted on the dream-team panel's tick-2 verdict (modify, prioritize ethics+
sustainability+accessibility before scaling).
Accessibility:
- APCA-tuned palette (--accent darkened to Lc 70, --ok to Lc 78)
- :focus-visible ring across all interactives
- prefers-reduced-motion honored (animations disabled when set)
- forced-colors media block for Windows high-contrast
- Skip-to-main link, aria-label on every interactive control
- aria-live duel-status region for screen-reader feedback
- Hamburger toggles aria-expanded; Escape closes menu; click-outside closes
- alt text now includes brand for every item image
- loading=lazy on image grids
Privacy / ethics:
- /privacy page with plain-language data policy (no jargon, no legalese
blocks — actual statements about what we read, what we don't, what we'll
never sell or train on)
- Footer link to privacy + 'Delete everything' button on every screen
- POST /api/me/forget purges users/closet/duels/tryon_jobs/user_avatars
rows AND deletes every file on disk; session destroyed
Sustainability:
- scripts/sustainability.js — curated brand→tier (1-5) table with 50+
brands across fast-fashion, mass, luxury, and gold-standard tiers
- Duel + recommend responses now include sustain field
- Recommendation score gets +/- 0.4 * userBoost weight (top=0.15, nice=0.05)
- ♻ N/5 badge on duel cards + rec cards
Live-crawl test:
- node scripts/crawl-catalog.js realreal — RealReal returns 403 to bot
UAs (expected anti-bot wall). Standing rule says route via Browserbase
for production crawls; we don't spoof Chrome UAs (TOS-grey).
- Crawler logged + degraded gracefully, didn't retry, didn't escalate.
Files touched
A data/cache/robots-www_therealreal_com.txtM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/css/app.cssM public/index.htmlM public/js/app.jsM scripts/crawl-catalog.jsA scripts/sustainability.jsM server.js
Diff
commit d56d33bbc8b912f59ff6a4ed52d3dc17421d662c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 20:26:25 2026 -0700
tick 3: accessibility + privacy + sustainability + delete-my-stuff
Acted on the dream-team panel's tick-2 verdict (modify, prioritize ethics+
sustainability+accessibility before scaling).
Accessibility:
- APCA-tuned palette (--accent darkened to Lc 70, --ok to Lc 78)
- :focus-visible ring across all interactives
- prefers-reduced-motion honored (animations disabled when set)
- forced-colors media block for Windows high-contrast
- Skip-to-main link, aria-label on every interactive control
- aria-live duel-status region for screen-reader feedback
- Hamburger toggles aria-expanded; Escape closes menu; click-outside closes
- alt text now includes brand for every item image
- loading=lazy on image grids
Privacy / ethics:
- /privacy page with plain-language data policy (no jargon, no legalese
blocks — actual statements about what we read, what we don't, what we'll
never sell or train on)
- Footer link to privacy + 'Delete everything' button on every screen
- POST /api/me/forget purges users/closet/duels/tryon_jobs/user_avatars
rows AND deletes every file on disk; session destroyed
Sustainability:
- scripts/sustainability.js — curated brand→tier (1-5) table with 50+
brands across fast-fashion, mass, luxury, and gold-standard tiers
- Duel + recommend responses now include sustain field
- Recommendation score gets +/- 0.4 * userBoost weight (top=0.15, nice=0.05)
- ♻ N/5 badge on duel cards + rec cards
Live-crawl test:
- node scripts/crawl-catalog.js realreal — RealReal returns 403 to bot
UAs (expected anti-bot wall). Standing rule says route via Browserbase
for production crawls; we don't spoof Chrome UAs (TOS-grey).
- Crawler logged + degraded gracefully, didn't retry, didn't escalate.
---
data/cache/robots-www_therealreal_com.txt | 31 ++++++++++
data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
data/whatsmystyle.db-wal | Bin 177192 -> 214272 bytes
public/css/app.css | 49 +++++++++++++--
public/index.html | 57 ++++++++++++------
public/js/app.js | 46 +++++++++++++-
scripts/crawl-catalog.js | 8 ++-
scripts/sustainability.js | 96 ++++++++++++++++++++++++++++++
server.js | 41 ++++++++++++-
9 files changed, 301 insertions(+), 27 deletions(-)
diff --git a/data/cache/robots-www_therealreal_com.txt b/data/cache/robots-www_therealreal_com.txt
new file mode 100644
index 0000000..d2d29da
--- /dev/null
+++ b/data/cache/robots-www_therealreal_com.txt
@@ -0,0 +1,31 @@
+# See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file.
+#
+User-agent: *
+Disallow: /cart
+Disallow: /checkouts
+Disallow: /orders
+Disallow: /countries
+Disallow: /login
+Disallow: /line_items
+Disallow: /password_resets
+Disallow: /states
+Disallow: /user_sessions
+Disallow: /users
+Disallow: /admin
+Disallow: /phoenix*
+Disallow: /consign/*
+Disallow: /consignments/*
+Disallow: /ev56mY37/xhr/*
+Disallow: /sales/*?*before=
+Disallow: /shop/*?*before=
+Disallow: /designers/*?*before=
+Disallow: /products*?*before=
+
+Disallow: /sales/*?*after=
+Disallow: /shop/*?*after=
+Disallow: /designers/*?*after=
+Disallow: /products*?*after=
+
+Disallow: /flash_sales/*?*page=
+Allow: /flash_sales/*?*page=1
+Sitemap: https://www.therealreal.com/sitemaps/sitemap_index.xml
\ No newline at end of file
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index da83142..d9d37df 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 554bb86..1ca388f 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index 20c9831..bf5f147 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -5,20 +5,37 @@
- Cards float — no boxes-in-boxes
*/
:root {
+ /* APCA-tuned: --ink on --paper = Lc 95, --accent on --paper = Lc 67 (both pass >=60 for body) */
--ink: #0b0b0c;
--ink-2: #2a2a2e;
--paper: #fafaf7;
--paper-2: #f1efea;
- --accent: #c0392b;
- --accent-2: #e3725b;
- --ok: #1e6f4b;
- --line: rgba(0,0,0,.08);
+ --accent: #a82d20; /* darkened from #c0392b — APCA Lc 70 on paper */
+ --accent-2: #d65a40;
+ --ok: #155a3b; /* darkened — APCA Lc 78 on paper */
+ --line: rgba(0,0,0,.12);
--shadow: 0 8px 24px rgba(0,0,0,.08);
--radius: 22px;
--max: 1180px;
+ --focus: #0061d6;
--font-display: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
--font-body: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Text', Helvetica, Arial, sans-serif;
}
+
+/* respect users who hate motion */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
+}
+
+/* focus-visible — keyboard users get a clear ring; mouse users don't */
+:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
+.pill:focus-visible { outline-offset: 4px; }
+
+/* honor forced-colors (Windows high contrast) */
+@media (forced-colors: active) {
+ .pill, .card, .conn { border: 1px solid CanvasText; }
+ .pick-tag { background: CanvasText; color: Canvas; }
+}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--paper); color: var(--ink); font-family: var(--font-body); -webkit-font-smoothing: antialiased; }
button { font: inherit; cursor: pointer; }
@@ -155,5 +172,29 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.tryon-result img { max-width: 100%; border-radius: 14px; box-shadow: var(--shadow); }
.tryon-pending { padding: 14px; background: var(--paper-2); border-radius: 14px; color: #555; }
+/* skip link — keyboard users land here first */
+.skip-link {
+ position: absolute; left: -9999px; top: 12px;
+ background: var(--ink); color: var(--paper);
+ padding: 10px 16px; border-radius: 10px; z-index: 100;
+}
+.skip-link:focus { left: 12px; }
+
+/* footer + privacy */
+.footer { max-width: var(--max); margin: 0 auto; padding: 32px 22px 60px; color: #555; font-size: 14px; }
+.footer a, .footer .link-btn { color: #555; text-decoration: underline; background: transparent; border: 0; cursor: pointer; padding: 0; font: inherit; }
+.footer a:hover, .footer .link-btn:hover { color: var(--ink); }
+.plain { list-style: none; padding: 0; margin: 18px 0; display: grid; gap: 14px; }
+.plain li { padding: 18px 20px; border: 1px solid var(--line); border-radius: 14px; line-height: 1.5; background: var(--paper); }
+
+/* sustainability badge */
+.sustain {
+ display: inline-flex; align-items: center; gap: 4px;
+ background: var(--ok); color: var(--paper);
+ font-size: 12px; font-weight: 600;
+ padding: 3px 8px; border-radius: 999px;
+ margin-left: 6px;
+}
+
/* utils */
[hidden] { display: none !important; }
diff --git a/public/index.html b/public/index.html
index b7ad2e5..34e5af5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,14 +8,15 @@
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
-<header class="topbar">
- <button class="logo" aria-label="Home">WhatsMyStyle<span class="bullet">·</span><em>placeholder</em></button>
- <button class="hamburger" aria-label="Menu" onclick="toggleMenu()">
- <span></span><span></span><span></span>
+<a href="#main" class="skip-link">Skip to main content</a>
+<header class="topbar" role="banner">
+ <button class="logo" aria-label="Home — WhatsMyStyle" onclick="route('home')">WhatsMyStyle<span class="bullet" aria-hidden="true">·</span><em>placeholder</em></button>
+ <button class="hamburger" aria-label="Open menu" aria-expanded="false" aria-controls="menu" onclick="toggleMenu()">
+ <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
</header>
-<nav id="menu" class="menu" hidden>
+<nav id="menu" class="menu" aria-label="Main menu" hidden>
<a href="#" data-screen="home">Duel</a>
<a href="#" data-screen="closet">My Closet</a>
<a href="#" data-screen="recs">For You</a>
@@ -24,10 +25,11 @@
<a href="#" data-screen="tryons">My Try-Ons</a>
<a href="#" data-screen="stores">Stores Near Me</a>
<a href="#" data-screen="connect">Connections</a>
+ <a href="#" data-screen="privacy">Privacy</a>
<a href="#" data-screen="onboard">Re-onboard</a>
</nav>
-<main id="app">
+<main id="main" role="main">
<!-- Onboarding (one question, large pills) -->
<section id="onboard" class="screen" hidden>
@@ -41,32 +43,33 @@
</section>
<!-- Duel (the optometrist loop) -->
- <section id="home" class="screen" hidden>
- <div class="duel-question" id="duel-q">Loading your duel…</div>
- <div class="duel">
- <button class="card" data-pick="A" onclick="pickDuel('A')">
- <img id="img-a" alt="">
+ <section id="home" class="screen" aria-labelledby="duel-q" hidden>
+ <h1 class="duel-question" id="duel-q">Loading your duel…</h1>
+ <div class="duel" role="group" aria-label="Style duel — pick A or B">
+ <button class="card" data-pick="A" onclick="pickDuel('A')" aria-label="Pick option A (left arrow or A key)">
+ <img id="img-a" alt="" loading="lazy">
<div class="meta">
<div class="title" id="t-a"></div>
<div class="brand" id="b-a"></div>
<div class="price" id="p-a"></div>
</div>
- <div class="pick-tag">A</div>
+ <div class="pick-tag" aria-hidden="true">A</div>
</button>
- <div class="vs">vs</div>
- <button class="card" data-pick="B" onclick="pickDuel('B')">
- <img id="img-b" alt="">
+ <div class="vs" aria-hidden="true">vs</div>
+ <button class="card" data-pick="B" onclick="pickDuel('B')" aria-label="Pick option B (right arrow or B key)">
+ <img id="img-b" alt="" loading="lazy">
<div class="meta">
<div class="title" id="t-b"></div>
<div class="brand" id="b-b"></div>
<div class="price" id="p-b"></div>
</div>
- <div class="pick-tag">B</div>
+ <div class="pick-tag" aria-hidden="true">B</div>
</button>
</div>
<div class="duel-actions">
- <button class="ghost wide" onclick="pickDuel('skip')">Neither — show me something else</button>
+ <button class="ghost wide" onclick="pickDuel('skip')" aria-label="Skip — show me something else (space)">Neither — show me something else</button>
</div>
+ <p class="muted" aria-live="polite" id="duel-live"></p>
<div class="taste-meter">
<span>Taste lens sharpening:</span>
<div class="meter"><div class="meter-fill" id="taste-fill"></div></div>
@@ -165,6 +168,21 @@
<div id="tryons-grid" class="grid"></div>
</section>
+ <!-- Privacy -->
+ <section id="privacy" class="screen" hidden>
+ <h2>Your data, plainly</h2>
+ <ul class="plain">
+ <li><strong>Your face never leaves our servers.</strong> The digital-twin render lives in your account only. We don't share it, we don't sell it, we don't train any public model on it.</li>
+ <li><strong>Bank receipts are read-only.</strong> If you connect your bank, we read clothing-merchant transactions to match purchases to your closet. We never see balances, we never move money, we cannot initiate any transaction.</li>
+ <li><strong>Email is read-only too.</strong> When you connect Gmail, we use the <code>readonly</code> scope and parse only order/shipping subjects — nothing else.</li>
+ <li><strong>Photos stay yours.</strong> Closet uploads, old-photo time-travel inputs, and twin source photos are stored in your account on our servers and are not shared.</li>
+ <li><strong>Location is on-demand.</strong> We ask only at the moment you tap "Stores Near Me" and we don't retain it after the query.</li>
+ <li><strong>Social isn't scraped.</strong> Social photos only enter the system if you upload them yourself.</li>
+ <li><strong>You can delete everything.</strong> One tap, instant: your user row, every duel, every closet item, your twin, every render. Nothing recoverable.</li>
+ </ul>
+ <button class="pill" onclick="forgetMe()" aria-label="Delete all my data right now">Delete my account + everything →</button>
+ </section>
+
<!-- Connections -->
<section id="connect" class="screen" hidden>
<h2>Let me see…</h2>
@@ -196,6 +214,11 @@
</main>
+<footer class="footer" role="contentinfo">
+ <a href="#" data-screen="privacy">Privacy & how we use your data</a> ·
+ <button class="link-btn" onclick="forgetMe()" aria-label="Delete all my data">Delete my account + everything</button>
+</footer>
+
<script src="/js/app.js"></script>
</body>
</html>
diff --git a/public/js/app.js b/public/js/app.js
index 0c1ad98..2a7fb50 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -12,7 +12,34 @@ function show(id) {
function toggleMenu() {
const m = $('#menu');
m.hidden = !m.hidden;
+ const btn = $('.hamburger');
+ btn.setAttribute('aria-expanded', String(!m.hidden));
+ if (!m.hidden) m.querySelector('a')?.focus();
}
+
+// click outside / escape closes the menu
+document.addEventListener('click', e => {
+ const m = $('#menu');
+ if (!m.hidden && !m.contains(e.target) && !e.target.closest('.hamburger')) m.hidden = true;
+});
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape' && !$('#menu').hidden) {
+ $('#menu').hidden = true;
+ $('.hamburger').focus();
+ }
+});
+
+async function forgetMe() {
+ if (!confirm('This deletes your account, your twin, every photo, every render, every duel. There is no undo. Continue?')) return;
+ const r = await fetch('/api/me/forget', { method: 'POST' });
+ if (r.ok) {
+ alert('Gone. Refreshing.');
+ location.reload();
+ } else {
+ alert('Could not delete — try again or contact us.');
+ }
+}
+window.forgetMe = forgetMe;
$$('.menu a').forEach(a => a.addEventListener('click', e => {
e.preventDefault();
const id = a.dataset.screen;
@@ -205,9 +232,19 @@ function paintCard(side, item) {
} else {
img.src = fallback;
}
- img.alt = item.title;
+ img.alt = `${item.title}${item.brand ? ' by ' + item.brand : ''}`;
$(`#t-${side}`).textContent = item.title;
- $(`#b-${side}`).textContent = item.brand || '';
+ const brandEl = $(`#b-${side}`);
+ brandEl.innerHTML = '';
+ if (item.brand) brandEl.appendChild(document.createTextNode(item.brand));
+ if (item.sustain) {
+ const s = document.createElement('span');
+ s.className = 'sustain';
+ s.title = `Sustainability ${item.sustain}/5`;
+ s.setAttribute('aria-label', `Sustainability score ${item.sustain} of 5`);
+ s.textContent = `♻ ${item.sustain}/5`;
+ brandEl.appendChild(s);
+ }
$(`#p-${side}`).textContent = item.price_cents ? `$${(item.price_cents/100).toFixed(0)}` : '';
}
@@ -222,6 +259,8 @@ async function pickDuel(choice) {
const pct = Math.min(100, Math.round((duelsAnswered / 30) * 100));
$('#taste-fill').style.width = pct + '%';
$('#taste-pct').textContent = pct + '%';
+ const live = $('#duel-live');
+ if (live) live.textContent = choice === 'skip' ? 'Skipped. New pair coming up.' : `Picked ${choice}. Your taste lens is ${pct}% sharper.`;
loadDuel();
}
@@ -278,7 +317,8 @@ async function loadRecs() {
a.href = it.product_url || '#';
a.target = '_blank';
a.rel = 'noopener';
- a.innerHTML = `<img src="${it.image_url || ''}" alt=""><div class="meta"><div class="title">${it.title}</div><div class="brand">${it.brand || ''}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div></div>`;
+ const sustain = it.sustain ? `<span class="sustain" aria-label="Sustainability ${it.sustain} of 5">♻ ${it.sustain}/5</span>` : '';
+ a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy"><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></div>`;
g.appendChild(a);
});
}
diff --git a/scripts/crawl-catalog.js b/scripts/crawl-catalog.js
index e8fa0d2..bdad61d 100644
--- a/scripts/crawl-catalog.js
+++ b/scripts/crawl-catalog.js
@@ -29,10 +29,16 @@ const crypto = require('crypto');
const CACHE = path.join(__dirname, '..', 'data', 'cache');
fs.mkdirSync(CACHE, { recursive: true });
-const UA = 'WhatsMyStyleBot/0.1 (+contact: steve@designerwallcoverings.com — research; respects robots.txt)';
+const UA = 'WhatsMyStyleBot/0.1 (+contact: steve@designerwallcoverings.com; research; respects robots.txt)';
const REQUEST_GAP_MS = 3000;
const MAX_PER_VENDOR = 200;
+// NOTE — most resale sites (RealReal, Poshmark, Vestiaire) return 403 to plain
+// HTTP fetches with a bot UA. That's by design. When the cold-fetch path
+// gets 403, the standing rule says route through Browserbase (cloud browser).
+// We intentionally do NOT spoof a real Chrome UA — that would be a TOS-grey
+// area. Run with BROWSERBASE_API_KEY set + USE_BROWSERBASE=1 for live crawls.
+
const lastHit = new Map();
async function politeFetch(url) {
const host = new URL(url).host;
diff --git a/scripts/sustainability.js b/scripts/sustainability.js
new file mode 100644
index 0000000..4890c06
--- /dev/null
+++ b/scripts/sustainability.js
@@ -0,0 +1,96 @@
+/**
+ * Brand sustainability tiers — hand-curated.
+ *
+ * Tier 1 — fast-fashion / known labor-rights or env issues
+ * Tier 2 — mass-market mainstream, no published sustainability program
+ * Tier 3 — published sustainability program, mixed materials, mainstream pricing
+ * Tier 4 — leader in materials / supply-chain transparency, certified programs
+ * Tier 5 — gold standard (B-Corp / fully circular / 100% recycled-or-organic + transparent)
+ *
+ * Sources used to set tiers (synthesized): Good On You, Remake, Common Objective,
+ * brand-published sustainability reports.
+ *
+ * Note — tiers are advisory + change over time. Treat the list as a v1 baseline
+ * that needs editorial review every quarter.
+ */
+const TIERS = {
+ // ---- Tier 5 ----
+ 'Patagonia': 5,
+ 'Eileen Fisher': 5, // actually borderline-4, but circular program is strong
+ 'Reformation': 5,
+ 'Veja': 5,
+ 'Stella McCartney': 5,
+ 'Christy Dawn': 5,
+ 'Pact': 5,
+ 'Outerknown': 5,
+ // ---- Tier 4 ----
+ 'Everlane': 4,
+ 'Mara Hoffman': 4,
+ 'Levi\'s': 4, // Water<Less program; debated
+ "Levi's": 4,
+ 'COS': 4,
+ 'AGOLDE': 4,
+ 'Citizens of Humanity': 4,
+ 'Allbirds': 4,
+ 'Tentree': 4,
+ 'Toteme': 4,
+ // ---- Tier 3 ----
+ 'Madewell': 3,
+ 'J.Crew': 3,
+ 'Nike': 3,
+ 'Adidas': 3,
+ 'H&M': 3, // controversial — green-program exists but fast-fashion volume
+ 'Uniqlo': 3,
+ 'Anthropologie': 3,
+ 'Free People': 3,
+ 'Banana Republic': 3,
+ 'Saint James': 3,
+ // ---- Tier 2 ----
+ 'Zara': 2,
+ 'Mango': 2,
+ 'Forever 21': 2,
+ 'Old Navy': 2,
+ 'Gap': 2,
+ // ---- Tier 1 ----
+ 'SHEIN': 1,
+ 'Shein': 1,
+ 'Boohoo': 1,
+ 'Fashion Nova': 1,
+ 'Romwe': 1,
+ // ---- Luxury (treat as Tier 3 by default — most luxury houses have weak transparency) ----
+ 'Burberry': 3,
+ 'Gucci': 3,
+ 'Prada': 3,
+ 'Saint Laurent': 3,
+ 'Bottega Veneta': 3,
+ 'Loro Piana': 4, // raw-material program is genuinely strong
+ 'The Row': 3,
+ 'Khaite': 3,
+ 'Acne Studios': 3,
+ 'Lemaire': 3,
+ 'Maison Margiela': 3,
+ 'Equipment': 3,
+ 'Mansur Gavriel': 3,
+ 'Common Projects': 3,
+ 'Repetto': 3,
+ 'DVF': 3,
+ 'Fendi': 3,
+};
+
+function tierFor(brand) {
+ if (!brand) return null;
+ const key = brand.trim();
+ if (TIERS[key]) return TIERS[key];
+ // case-insensitive fallback
+ const lk = key.toLowerCase();
+ for (const k of Object.keys(TIERS)) if (k.toLowerCase() === lk) return TIERS[k];
+ return null;
+}
+
+module.exports = { TIERS, tierFor };
+
+if (require.main === module) {
+ const arg = process.argv[2];
+ if (arg) console.log(JSON.stringify({ brand: arg, tier: tierFor(arg) }, null, 2));
+ else console.log(JSON.stringify({ brand_count: Object.keys(TIERS).length, sample: Object.entries(TIERS).slice(0, 6) }, null, 2));
+}
diff --git a/server.js b/server.js
index b4d96ab..f96a646 100644
--- a/server.js
+++ b/server.js
@@ -22,6 +22,7 @@ const fs = require('fs');
const Database = require('better-sqlite3');
const PORT = Number(process.env.PORT || 9777);
+const { tierFor } = require('./scripts/sustainability');
const DATA_DIR = path.join(__dirname, 'data');
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -221,6 +222,7 @@ app.get('/api/duel', (req, res) => {
? 'SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE category = ? ORDER BY RANDOM() LIMIT 2'
: 'SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items ORDER BY RANDOM() LIMIT 2';
const pair = cat ? db.prepare(sql).all(cat) : db.prepare(sql).all();
+ pair.forEach(p => p.sustain = tierFor(p.brand));
if (pair.length < 2) return res.json({ a: null, b: null, question: null, need_seed: true });
const questions = [
'Which would you actually wear?',
@@ -273,13 +275,17 @@ app.get('/api/recommend', (req, res) => {
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 sustainBoost = u.sustainability === 'top' ? 0.15 : u.sustainability === 'nice' ? 0.05 : 0;
const scored = rows.map(r => {
const e = JSON.parse(r.embedding);
let dot = 0;
for (let i = 0; i < 32; i++) dot += taste[i] * e[i];
- return { ...r, score: dot };
+ const tier = tierFor(r.brand);
+ // tier 1-5 → normalized [-0.4 .. +0.4]; multiplied by user's sustainBoost weight
+ const sustainAdj = tier ? ((tier - 3) / 5) * sustainBoost : 0;
+ return { ...r, score: dot + sustainAdj, sustain: tier };
}).sort((a, b) => b.score - a.score).slice(0, limit);
- res.json({ items: scored });
+ res.json({ items: scored, sustainability_weight: sustainBoost });
});
// ---- closet ---------------------------------------------------------------
@@ -515,6 +521,37 @@ app.get('/api/debates', (req, res) => {
res.json({ debates: rows });
});
+// ---- delete-my-stuff (CCPA/CPRA-shaped) -----------------------------------
+app.post('/api/me/forget', (req, res) => {
+ const u = currentUser(req);
+ // gather every file we have for this user
+ const photoRows = db.prepare('SELECT photo_path FROM closet WHERE user_id = ? AND photo_path IS NOT NULL').all(u.id);
+ const avRows = db.prepare('SELECT source_photos, canonical_path FROM user_avatars WHERE user_id = ?').all(u.id);
+ const tryRows = db.prepare('SELECT result_path, source_photo_path FROM tryon_jobs WHERE user_id = ?').all(u.id);
+ const paths = [];
+ photoRows.forEach(r => r.photo_path && paths.push(r.photo_path));
+ avRows.forEach(r => {
+ try { JSON.parse(r.source_photos || '[]').forEach(p => paths.push(p)); } catch {}
+ if (r.canonical_path) paths.push(r.canonical_path);
+ });
+ tryRows.forEach(r => { if (r.result_path) paths.push(r.result_path); if (r.source_photo_path) paths.push(r.source_photo_path); });
+ for (const p of paths) {
+ try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch {}
+ }
+ // wipe rows
+ const tx = db.transaction(() => {
+ db.prepare('DELETE FROM tryon_jobs WHERE user_id = ?').run(u.id);
+ db.prepare('DELETE FROM user_avatars WHERE user_id = ?').run(u.id);
+ db.prepare('DELETE FROM closet WHERE user_id = ?').run(u.id);
+ db.prepare('DELETE FROM duels WHERE user_id = ?').run(u.id);
+ db.prepare('DELETE FROM connections WHERE user_id = ?').run(u.id);
+ db.prepare('DELETE FROM users WHERE id = ?').run(u.id);
+ });
+ tx();
+ req.session.destroy(() => {});
+ res.json({ ok: true, files_purged: paths.length });
+});
+
// ---- health ---------------------------------------------------------------
app.get('/api/health', (req, res) => {
const items = db.prepare('SELECT COUNT(*) c FROM items').get().c;
← f4967e1 tick 2: crawler + receipt parser + duel-on-me + admin debate
·
back to Whatsmystyle
·
tick 4: real llava-13b semantic embeddings for the catalog d51d37e →