← 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
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
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 →