[object Object]

← back to Whatsmystyle

tick 8: /support page + taste history + duel hero + 2v4 debate

0834008c42916bfaec7d9aa255cab3a4b7978657 · 2026-05-11 21:46:06 -0700 · Steve Abrams

Monetization-verdict action items:
- /api/support returns model + donation_url (gated on STRIPE_DONATION_LINK)
  + rationale citing the panel's verdict. no_affiliate/no_subscription
  fields make the policy machine-readable for future audits.
- /support screen: plain-language explainer (no jargon), donation CTA
  rendered iff STRIPE_DONATION_LINK is set, fallback message otherwise.
  Footer adds 'Support' link alongside About/Privacy/Debates.
- Hard no on affiliate links anywhere in the codebase per the verdict.

Taste-vector history:
- New table taste_history. Auto-snapshot on every 5th picked duel
  (inside the existing duel POST handler, no separate worker needed).
- GET /api/me/taste/history returns ordered snapshots.
- /taste screen renders sparklines for the top-5 dims across snapshots
  (svg polyline, dashed zero-line, green/red coloring by final-sign).
  Plain-language summary above each.

Duel hero strip (Gucci-anatomy rule):
- 5-column horizontal strip above the duel-question text, drawn from
  recommend endpoint filtered to sustain >= 4. Mobile collapses to 3-col.
  Only renders once per session (cached after first paint).

2v4 duel debate:
- Verdict: HOLD on 4-up. Keep 2-up as baseline. A/B-test 4-up
  incrementally with grid-focus-order + mobile-friendly layout
  refinements before any migration. Logged in /debates.

Files touched

Diff

commit 0834008c42916bfaec7d9aa255cab3a4b7978657
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 21:46:06 2026 -0700

    tick 8: /support page + taste history + duel hero + 2v4 debate
    
    Monetization-verdict action items:
    - /api/support returns model + donation_url (gated on STRIPE_DONATION_LINK)
      + rationale citing the panel's verdict. no_affiliate/no_subscription
      fields make the policy machine-readable for future audits.
    - /support screen: plain-language explainer (no jargon), donation CTA
      rendered iff STRIPE_DONATION_LINK is set, fallback message otherwise.
      Footer adds 'Support' link alongside About/Privacy/Debates.
    - Hard no on affiliate links anywhere in the codebase per the verdict.
    
    Taste-vector history:
    - New table taste_history. Auto-snapshot on every 5th picked duel
      (inside the existing duel POST handler, no separate worker needed).
    - GET /api/me/taste/history returns ordered snapshots.
    - /taste screen renders sparklines for the top-5 dims across snapshots
      (svg polyline, dashed zero-line, green/red coloring by final-sign).
      Plain-language summary above each.
    
    Duel hero strip (Gucci-anatomy rule):
    - 5-column horizontal strip above the duel-question text, drawn from
      recommend endpoint filtered to sustain >= 4. Mobile collapses to 3-col.
      Only renders once per session (cached after first paint).
    
    2v4 duel debate:
    - Verdict: HOLD on 4-up. Keep 2-up as baseline. A/B-test 4-up
      incrementally with grid-focus-order + mobile-friendly layout
      refinements before any migration. Logged in /debates.
---
 data/waitlist.csv        |   2 ++
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 1746912 -> 2117712 bytes
 public/css/app.css       |  21 ++++++++++++++
 public/index.html        |  24 ++++++++++++++++
 public/js/app.js         |  70 +++++++++++++++++++++++++++++++++++++++++++++++
 server.js                |  39 ++++++++++++++++++++++++++
 7 files changed, 156 insertions(+)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index deea594..ff74c42 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -7,3 +7,5 @@ e2e-1778558857120@example.com,e2e,2026-05-12T04:07:37.126Z
 e2e-1778559568780@example.com,e2e,2026-05-12T04:19:28.781Z
 e2e-1778560417298@example.com,e2e,2026-05-12T04:33:37.318Z
 e2e-1778560665834@example.com,e2e,2026-05-12T04:37:45.837Z
+e2e-1778560882575@example.com,e2e,2026-05-12T04:41:22.586Z
+e2e-1778561154050@example.com,e2e,2026-05-12T04:45:54.051Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 913ae8e..7a70968 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 a3e1eaa..0be67ac 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 2588a7e..915d03d 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -188,6 +188,27 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .pitch-waitlist input { padding: 14px 18px; border: 1.5px solid var(--ink); border-radius: 999px; font-size: 16px; min-width: 260px; }
 .pitch-waitlist button { padding: 14px 24px; border: 0; border-radius: 999px; background: var(--ink); color: var(--paper); font-size: 16px; font-weight: 600; cursor: pointer; }
 
+/* duel hero strip — Gucci anatomy: huge horizontal image above the wordmark */
+.duel-hero {
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 6px;
+  height: clamp(160px, 22vh, 280px);
+  margin: 0 0 22px;
+  border-radius: 18px;
+  overflow: hidden;
+}
+.duel-hero .hero-cell { background-size: cover; background-position: center; background-color: var(--paper-2); }
+@media (max-width: 640px) { .duel-hero { grid-template-columns: repeat(3, 1fr); height: clamp(120px, 18vh, 200px); } .duel-hero .hero-cell:nth-child(n+4) { display: none; } }
+
+/* taste timeline — stacked sparklines of top-5 dims over snapshots */
+.taste-timeline-row { display: grid; grid-template-columns: 140px 1fr; gap: 10px; align-items: center; padding: 6px 0; }
+.taste-timeline-row .label { font-size: 13px; text-transform: capitalize; color: var(--ink-2); }
+.taste-timeline-row svg { width: 100%; height: 28px; }
+.taste-timeline-row .spark { stroke: var(--ok); stroke-width: 2; fill: none; }
+.taste-timeline-row .spark.neg { stroke: var(--accent); }
+.taste-timeline-row .zero { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; }
+
 /* taste visualizer */
 .taste-bar { display: grid; grid-template-columns: 180px 1fr 60px; align-items: center; gap: 12px; padding: 8px 0; }
 .taste-bar .label { font-size: 14px; text-transform: capitalize; }
diff --git a/public/index.html b/public/index.html
index f733c91..8d873d1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -48,6 +48,8 @@
 
   <!-- Duel (the optometrist loop) -->
   <section id="home" class="screen" aria-labelledby="duel-q" hidden>
+    <!-- Hero strip — sustainability tier-5 highlights, gucci-anatomy rule -->
+    <div id="duel-hero" class="duel-hero" hidden aria-hidden="true"></div>
     <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)">
@@ -221,6 +223,9 @@
     <h2>Your taste, decoded</h2>
     <p class="muted" id="taste-summary">Pick a few duels to start sharpening the lens.</p>
     <div id="taste-bars"></div>
+    <h3 style="margin-top:30px;">How your lens has sharpened</h3>
+    <p class="muted">Each snapshot taken every 5 picked duels.</p>
+    <div id="taste-timeline"></div>
     <h3 style="margin-top:30px;">All 32 style dimensions</h3>
     <div id="taste-all"></div>
   </section>
@@ -267,6 +272,24 @@
     <div id="debates-list"></div>
   </section>
 
+  <!-- Support / donations -->
+  <section id="support" class="screen" hidden>
+    <h2>How this stays honest</h2>
+    <p>WhatsMyStyle takes no affiliate cuts and runs no ads. Our dream-team debate panel told us why, in plain terms: any commission on outbound clicks structurally pulls against the repair-first nudges and sustainability score we already shipped. So we just don't take them.</p>
+    <p>That means we're <strong>donation-supported</strong>. If the app helped you — if Time-Travel made you smile, if the optometrist loop saved you from buying a wrong piece — kick in.</p>
+    <div id="support-cta-wrap" style="margin: 30px 0;">
+      <a id="support-cta" class="pill" href="#" target="_blank" rel="noopener" style="display:none;">Send a tip →</a>
+      <p id="support-cta-fallback" class="muted">Donation link not configured yet. Steve's setting up Stripe Checkout.</p>
+    </div>
+    <h3>What your tip pays for</h3>
+    <ul class="plain">
+      <li>Server time on a small VPS (~$30/mo)</li>
+      <li>Local-LLM compute (zero marginal $ — runs on a Mac in Steve's office)</li>
+      <li>Domain + SSL renewal</li>
+      <li>That's it. There's no team. There's no growth funnel.</li>
+    </ul>
+  </section>
+
   <!-- Privacy -->
   <section id="privacy" class="screen" hidden>
     <h2>Your data, plainly</h2>
@@ -315,6 +338,7 @@
 
 <footer class="footer" role="contentinfo">
   <a href="#" data-screen="about">About</a> ·
+  <a href="#" data-screen="support">Support</a> ·
   <a href="#" data-screen="privacy">Privacy</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>
diff --git a/public/js/app.js b/public/js/app.js
index bb3d92a..6f078cc 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -207,8 +207,26 @@ async function finishOnboarding() {
 let currentDuel = null;
 let duelsAnswered = 0;
 
+async function loadDuelHero() {
+  if (document.querySelector('#duel-hero .hero-cell')) return;  // only once
+  try {
+    const r = await fetch('/api/recommend?limit=8').then(r => r.json());
+    const cells = (r.items || [])
+      .filter(it => (it.sustain || 0) >= 4)
+      .slice(0, 5)
+      .map(it => `<div class="hero-cell" style="background-image:url('${it.image_url}')" title="${it.brand || ''}"></div>`)
+      .join('');
+    if (cells) {
+      const el = document.getElementById('duel-hero');
+      el.innerHTML = cells;
+      el.hidden = false;
+    }
+  } catch {}
+}
+
 async function loadDuel() {
   if (avatarReady === false) await checkAvatarReady();  // first call only
+  loadDuelHero();
   const r = await fetch('/api/duel');
   const j = await r.json();
   if (j.need_seed || !j.a) {
@@ -567,7 +585,47 @@ $('#waitlist-form').addEventListener('submit', async e => {
 });
 
 // ---------- Router ---------------------------------------------------------
+async function loadTasteTimeline() {
+  const r = await fetch('/api/me/taste/history').then(r => r.json());
+  const container = $('#taste-timeline');
+  if (!container) return;
+  container.innerHTML = '';
+  const snaps = r.snapshots || [];
+  if (snaps.length < 2) {
+    container.innerHTML = `<p class="muted">${snaps.length === 0 ? 'No snapshots yet — keep picking duels.' : 'One snapshot so far — pick 5 more for a trend line.'}</p>`;
+    return;
+  }
+  // pick the top-5 dims by absolute value in the final snapshot
+  const final = snaps[snaps.length - 1].taste_vec;
+  const topIdx = final.map((v, i) => [i, Math.abs(v)]).sort((a,b) => b[1]-a[1]).slice(0, 5).map(p => p[0]);
+  // Need BASIS labels — pull from /api/me/taste once
+  const tasteResp = await fetch('/api/me/taste').then(r => r.json());
+  const labels = (tasteResp.dims || []).map(d => d.key);
+  const W = 320, H = 28, PAD = 2;
+  topIdx.forEach(idx => {
+    const vals = snaps.map(s => s.taste_vec[idx]);
+    const minV = Math.min(...vals, 0), maxV = Math.max(...vals, 0);
+    const range = (maxV - minV) || 1;
+    const pts = vals.map((v, i) => {
+      const x = PAD + (i * (W - 2*PAD)) / Math.max(1, vals.length - 1);
+      const y = H - PAD - ((v - minV) / range) * (H - 2*PAD);
+      return `${x.toFixed(1)},${y.toFixed(1)}`;
+    }).join(' ');
+    const zeroY = H - PAD - ((0 - minV) / range) * (H - 2*PAD);
+    const neg = final[idx] < 0 ? ' neg' : '';
+    const row = document.createElement('div');
+    row.className = 'taste-timeline-row';
+    row.innerHTML = `<div class="label">${(labels[idx] || `dim ${idx}`).replace(/_/g, ' ')}</div>
+      <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
+        <line class="zero" x1="0" y1="${zeroY.toFixed(1)}" x2="${W}" y2="${zeroY.toFixed(1)}"/>
+        <polyline class="spark${neg}" points="${pts}"/>
+      </svg>`;
+    container.appendChild(row);
+  });
+}
+
 async function loadTaste() {
+  loadTasteTimeline();
   const r = await fetch('/api/me/taste').then(r => r.json());
   const summary = $('#taste-summary');
   if (!r.duels_answered) {
@@ -599,6 +657,17 @@ async function loadTaste() {
   }
 }
 
+async function loadSupport() {
+  const r = await fetch('/api/support').then(r => r.json());
+  const cta = $('#support-cta');
+  const fallback = $('#support-cta-fallback');
+  if (r.donation_url) {
+    cta.href = r.donation_url;
+    cta.style.display = 'inline-block';
+    if (fallback) fallback.style.display = 'none';
+  }
+}
+
 async function loadDebates() {
   const r = await fetch('/api/debates').then(r => r.json());
   const list = $('#debates-list');
@@ -628,6 +697,7 @@ function route(name) {
   if (name === 'timetravel') loadTimeTravel();
   if (name === 'tryons') loadTryons();
   if (name === 'taste') loadTaste();
+  if (name === 'support') loadSupport();
   if (name === 'debates') loadDebates();
   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 fa2fd28..7e1a945 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,15 @@ CREATE TABLE IF NOT EXISTS waitlist (
   created_at TEXT DEFAULT (datetime('now'))
 );
 
+CREATE TABLE IF NOT EXISTS taste_history (
+  id INTEGER PRIMARY KEY,
+  user_id INTEGER NOT NULL,
+  duels_answered INTEGER,
+  taste_vec TEXT,
+  created_at TEXT DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS taste_history_user_idx ON taste_history(user_id, id);
+
 CREATE TABLE IF NOT EXISTS debates (
   id INTEGER PRIMARY KEY,
   topic TEXT NOT NULL,
@@ -281,6 +290,12 @@ app.post('/api/duel', (req, res) => {
       taste[i] = taste[i] * 0.95 + (we[i] - le[i]) * 0.05;
     }
     db.prepare('UPDATE users SET taste_vec = ? WHERE id = ?').run(JSON.stringify(taste), u.id);
+    // Snapshot every 5th picked duel so /taste can render the progression
+    const answered = db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B')").get(u.id).c;
+    if (answered > 0 && answered % 5 === 0) {
+      db.prepare('INSERT INTO taste_history (user_id, duels_answered, taste_vec) VALUES (?, ?, ?)')
+        .run(u.id, answered, JSON.stringify(taste));
+    }
   }
   res.json({ ok: true });
 });
@@ -732,6 +747,30 @@ app.post('/api/waitlist', (req, res) => {
   res.json({ ok: true });
 });
 
+// ---- taste history -------------------------------------------------------
+app.get('/api/me/taste/history', (req, res) => {
+  const u = currentUser(req);
+  const rows = db.prepare('SELECT duels_answered, taste_vec, created_at FROM taste_history WHERE user_id=? ORDER BY id ASC').all(u.id);
+  const snapshots = rows.map(r => {
+    let v = [];
+    try { v = JSON.parse(r.taste_vec); } catch {}
+    if (!Array.isArray(v) || v.length !== 32) v = Array(32).fill(0);
+    return { duels_answered: r.duels_answered, taste_vec: v, created_at: r.created_at };
+  });
+  res.json({ snapshots });
+});
+
+// ---- support / donation (per monetization verdict) -----------------------
+app.get('/api/support', (req, res) => {
+  res.json({
+    model: 'donation-supported',
+    donation_url: process.env.STRIPE_DONATION_LINK || null,
+    no_affiliate: true,
+    no_subscription: true,
+    rationale: "Per the dream-team monetization debate (2026-05-12), donation-supported is the only model fully aligned with our privacy + sustainability + repair-first commitments. Affiliate ruled out. Subscription rejected.",
+  });
+});
+
 // ---- taste-vector visualizer ---------------------------------------------
 const { BASIS } = require('./scripts/embed-items');
 app.get('/api/me/taste', (req, res) => {

← b314fd5 tick 7 follow-up: surface worker queue depths in /api/health  ·  back to Whatsmystyle  ·  tick 9: prove the workers — taste-history + closet-vision in c7a376c →