[object Object]

← 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

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 →