[object Object]

← back to Whatsmystyle

tick 7: in-process workers + /taste + /tt-demo + monetization debate

b7f1e7c91a161913b5dcde62d47de4c312a65106 · 2026-05-11 21:08:19 -0700 · Steve Abrams

In-process workers (no launchd needed for the demo path):
- closet vision drain every 60s — pipes pending closet photos to llava-13b
- tryon-jobs drain every 30s — handles Dopple/Gemini/stub providers
- avatar-build drain every 30s — picks up pending twin builds
All workers detach + unref so they never block the request loop. No-op when
ollama unreachable.

Taste-vector visualizer:
- GET /api/me/taste returns the 32-d vector with BASIS labels, top-5
  positive dims, top-3 negative dims, total duels answered
- New /taste screen — horizontal bars centered on zero (positive lean
  green right, negative lean red left), plain-language summary at top
  ('You lean fitted, evening, monochrome'), all 32 dims listed below

Time-Travel demo gallery:
- /tt-demo screen with 3 example before/after pairs (wedding gown,
  graduation suit, first-date jacket). Static placeholders for now —
  swap in real renders once a Dopple key is wired. CTA to actual feature.

Monetization debate (live local panel):
- Topic: affiliate vs subscription vs freemium vs donation
- Verdict: donation-supported is the only model fully aligned with the
  privacy + sustainability + repair-first stack already shipped.
  Affiliate ruled out (conflicts with repair-first nudges). Subscription/
  freemium acceptable only with strict data-limit guardrails.

Re-validated post-edit: scripts/e2e.js → 25/25 still green.

Files touched

Diff

commit b7f1e7c91a161913b5dcde62d47de4c312a65106
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 21:08:19 2026 -0700

    tick 7: in-process workers + /taste + /tt-demo + monetization debate
    
    In-process workers (no launchd needed for the demo path):
    - closet vision drain every 60s — pipes pending closet photos to llava-13b
    - tryon-jobs drain every 30s — handles Dopple/Gemini/stub providers
    - avatar-build drain every 30s — picks up pending twin builds
    All workers detach + unref so they never block the request loop. No-op when
    ollama unreachable.
    
    Taste-vector visualizer:
    - GET /api/me/taste returns the 32-d vector with BASIS labels, top-5
      positive dims, top-3 negative dims, total duels answered
    - New /taste screen — horizontal bars centered on zero (positive lean
      green right, negative lean red left), plain-language summary at top
      ('You lean fitted, evening, monochrome'), all 32 dims listed below
    
    Time-Travel demo gallery:
    - /tt-demo screen with 3 example before/after pairs (wedding gown,
      graduation suit, first-date jacket). Static placeholders for now —
      swap in real renders once a Dopple key is wired. CTA to actual feature.
    
    Monetization debate (live local panel):
    - Topic: affiliate vs subscription vs freemium vs donation
    - Verdict: donation-supported is the only model fully aligned with the
      privacy + sustainability + repair-first stack already shipped.
      Affiliate ruled out (conflicts with repair-first nudges). Subscription/
      freemium acceptable only with strict data-limit guardrails.
    
    Re-validated post-edit: scripts/e2e.js → 25/25 still green.
---
 data/waitlist.csv        |   2 ++
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 877592 -> 1223672 bytes
 public/css/app.css       |  19 +++++++++++++++++++
 public/index.html        |  46 ++++++++++++++++++++++++++++++++++++++++++++++
 public/js/app.js         |  33 +++++++++++++++++++++++++++++++++
 server.js                |  44 ++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 144 insertions(+)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 628462e..7044485 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -2,3 +2,5 @@ email,source,created_at
 steve@designerwallcoverings.com,smoke,2026-05-12T03:45:26.941Z
 e2e-1778557939546@example.com,e2e,2026-05-12T03:52:19.577Z
 e2e-1778558413111@example.com,e2e,2026-05-12T04:00:13.114Z
+e2e-1778558607191@example.com,e2e,2026-05-12T04:03:27.194Z
+e2e-1778558857120@example.com,e2e,2026-05-12T04:07:37.126Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 46f8207..d40ad71 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 ced2f82..c377608 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 cc2dc44..2588a7e 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -188,6 +188,25 @@ 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; }
 
+/* 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; }
+.taste-bar .track { background: var(--paper-2); border-radius: 999px; height: 12px; position: relative; overflow: hidden; }
+.taste-bar .fill { position: absolute; top: 0; bottom: 0; left: 50%; background: var(--ok); border-radius: 999px; transition: width .4s; }
+.taste-bar .fill.neg { background: var(--accent); transform: translateX(-100%); }
+.taste-bar .val { font-variant-numeric: tabular-nums; font-size: 14px; text-align: right; color: var(--ink-2); }
+
+/* time-travel demo */
+.tt-demos { display: grid; gap: 22px; margin: 22px 0; }
+.tt-demo-card { padding: 22px; border: 1px solid var(--line); border-radius: 22px; background: var(--paper); }
+.tt-demo-card h3 { font-family: var(--font-display); font-size: 22px; margin: 0 0 14px; }
+.ba-pair { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: center; }
+.ba-pair figure { margin: 0; }
+.ba-pair img { width: 100%; aspect-ratio: 3/4; object-fit: cover; border-radius: 14px; background: var(--paper-2); }
+.ba-pair figcaption { font-size: 13px; color: #777; margin-top: 6px; text-align: center; }
+.ba-pair .arrow { font-size: 32px; opacity: .35; }
+@media (max-width: 600px) { .ba-pair { grid-template-columns: 1fr; } .ba-pair .arrow { transform: rotate(90deg); } }
+
 /* debates */
 .debate { padding: 22px; border: 1px solid var(--line); border-radius: 18px; background: var(--paper); margin-bottom: 14px; }
 .debate h3 { margin: 0 0 12px; font-family: var(--font-display); font-size: 22px; }
diff --git a/public/index.html b/public/index.html
index 33c56f4..f733c91 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,6 +23,8 @@
   <a href="#" data-screen="recs">For You</a>
   <a href="#" data-screen="avatar">My Avatar</a>
   <a href="#" data-screen="timetravel">Time-Travel Wardrobe</a>
+  <a href="#" data-screen="tt-demo">Time-Travel Demo</a>
+  <a href="#" data-screen="taste">My Taste</a>
   <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>
@@ -214,6 +216,50 @@
     <div id="tryons-grid" class="grid"></div>
   </section>
 
+  <!-- Taste-vector visualizer -->
+  <section id="taste" class="screen" hidden>
+    <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;">All 32 style dimensions</h3>
+    <div id="taste-all"></div>
+  </section>
+
+  <!-- Time-Travel demo gallery -->
+  <section id="tt-demo" class="screen" hidden>
+    <h2>What Time-Travel Wardrobe does</h2>
+    <p class="muted">Pick a piece from your closet. Pick an old photo. Tell us the occasion. We re-dress past-you in the outfit you wish you'd worn — face, friends, venue all preserved. Here are three examples.</p>
+    <div class="tt-demos">
+      <article class="tt-demo-card">
+        <h3>"I wish I'd worn the navy gown to my sister's wedding"</h3>
+        <div class="ba-pair">
+          <figure><img src="https://images.unsplash.com/photo-1606216794074-735e91aa2c92?w=600" alt="Wedding photo before"><figcaption>Before — what was worn</figcaption></figure>
+          <span class="arrow" aria-hidden="true">→</span>
+          <figure><img src="https://images.unsplash.com/photo-1594736797933-d0501ba2fe65?w=600" alt="Wedding photo after, re-dressed"><figcaption>After — the dress from her closet</figcaption></figure>
+        </div>
+        <p class="muted">Face preserved. Wedding party preserved. Venue preserved. Only the outfit changed.</p>
+      </article>
+      <article class="tt-demo-card">
+        <h3>"My graduation in the suit I outgrew before I bought one I love"</h3>
+        <div class="ba-pair">
+          <figure><img src="https://images.unsplash.com/photo-1607369329012-5c5f24b91f15?w=600" alt="Graduation photo before"><figcaption>Before</figcaption></figure>
+          <span class="arrow" aria-hidden="true">→</span>
+          <figure><img src="https://images.unsplash.com/photo-1593032465175-481ac7f401f0?w=600" alt="Graduation photo after"><figcaption>After</figcaption></figure>
+        </div>
+      </article>
+      <article class="tt-demo-card">
+        <h3>"First-date photo. Different jacket this time."</h3>
+        <div class="ba-pair">
+          <figure><img src="https://images.unsplash.com/photo-1495427513693-15f5e80f7adb?w=600" alt="Date photo before"><figcaption>Before</figcaption></figure>
+          <span class="arrow" aria-hidden="true">→</span>
+          <figure><img src="https://images.unsplash.com/photo-1543076447-215ad9ba6923?w=600" alt="Date photo after"><figcaption>After — denim jacket from closet</figcaption></figure>
+        </div>
+      </article>
+    </div>
+    <p class="muted">These are illustrative examples — your actual renders use your own face, your own photos, your own closet.</p>
+    <button class="pill" onclick="route('timetravel')">Try it with your own photo →</button>
+  </section>
+
   <!-- Debates — how product decisions get made -->
   <section id="debates" class="screen" hidden>
     <h2>How decisions get made</h2>
diff --git a/public/js/app.js b/public/js/app.js
index af5c0e8..bb3d92a 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -567,6 +567,38 @@ $('#waitlist-form').addEventListener('submit', async e => {
 });
 
 // ---------- Router ---------------------------------------------------------
+async function loadTaste() {
+  const r = await fetch('/api/me/taste').then(r => r.json());
+  const summary = $('#taste-summary');
+  if (!r.duels_answered) {
+    summary.textContent = 'No duels yet — pick a few pairs and your taste lens will start sharpening.';
+  } else {
+    const top = (r.top || []).map(d => d.key.replace(/_/g, ' ')).slice(0, 3);
+    summary.textContent = `${r.duels_answered} duel${r.duels_answered === 1 ? '' : 's'} answered. You lean ${top.join(', ') || 'no direction yet'}.`;
+  }
+  const top = $('#taste-bars');
+  top.innerHTML = '';
+  (r.top || []).forEach(d => top.appendChild(renderBar(d, false)));
+  (r.bottom || []).forEach(d => top.appendChild(renderBar(d, true)));
+  const all = $('#taste-all');
+  all.innerHTML = '';
+  (r.dims || []).forEach(d => all.appendChild(renderBar(d, false)));
+  function renderBar(d, isNeg) {
+    const row = document.createElement('div');
+    row.className = 'taste-bar';
+    const v = d.value;
+    const pct = Math.min(50, Math.abs(v) * 100);
+    const fillCls = v < 0 ? 'fill neg' : 'fill';
+    const fillStyle = v < 0
+      ? `width:${pct}%;left:${50 - pct}%;transform:none;`
+      : `width:${pct}%;left:50%;`;
+    row.innerHTML = `<div class="label">${d.key.replace(/_/g, ' ')}</div>
+      <div class="track"><div class="${fillCls}" style="${fillStyle}"></div></div>
+      <div class="val">${v > 0 ? '+' : ''}${v}</div>`;
+    return row;
+  }
+}
+
 async function loadDebates() {
   const r = await fetch('/api/debates').then(r => r.json());
   const list = $('#debates-list');
@@ -595,6 +627,7 @@ function route(name) {
   if (name === 'avatar') pollAvatar();
   if (name === 'timetravel') loadTimeTravel();
   if (name === 'tryons') loadTryons();
+  if (name === 'taste') loadTaste();
   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 3ee6a9a..432992f 100644
--- a/server.js
+++ b/server.js
@@ -732,6 +732,20 @@ app.post('/api/waitlist', (req, res) => {
   res.json({ ok: true });
 });
 
+// ---- taste-vector visualizer ---------------------------------------------
+const { BASIS } = require('./scripts/embed-items');
+app.get('/api/me/taste', (req, res) => {
+  const u = currentUser(req);
+  let v = [];
+  try { v = JSON.parse(u.taste_vec || '[]'); } catch {}
+  if (!Array.isArray(v) || v.length !== 32) v = Array(32).fill(0);
+  const dims = BASIS.map((b, i) => ({ key: b.key, value: Number(v[i].toFixed(3)) }));
+  const sorted = [...dims].sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
+  const top = sorted.filter(d => d.value > 0).slice(0, 5);
+  const bottom = sorted.filter(d => d.value < 0).slice(0, 3);
+  res.json({ dims, top, bottom, duels_answered: db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B')").get(u.id).c });
+});
+
 // ---- delete-my-stuff (CCPA/CPRA-shaped) -----------------------------------
 app.post('/api/me/forget', (req, res) => {
   const u = currentUser(req);
@@ -772,6 +786,36 @@ app.get('/api/health', (req, res) => {
   res.json({ ok: true, items, users, duels, port: PORT });
 });
 
+// ---- in-process workers ---------------------------------------------------
+// Closet vision: every 60s, hand pending closet rows to llava-13b. No-op
+// when ollama is unreachable. Avoids needing launchd for the demo path.
+const { spawn } = require('child_process');
+function drainClosetVision() {
+  const pending = db.prepare("SELECT COUNT(*) c FROM closet WHERE category IS NULL AND photo_path IS NOT NULL").get().c;
+  if (!pending) return;
+  const p = spawn('node', [path.join(__dirname, 'scripts', 'closet-vision.js')], { detached: true, stdio: 'ignore' });
+  p.unref();
+}
+setInterval(drainClosetVision, 60 * 1000);
+
+// Try-on worker: every 30s drain queued tryon_jobs (handles Dopple/Gemini/stub)
+function drainTryonJobs() {
+  const queued = db.prepare("SELECT COUNT(*) c FROM tryon_jobs WHERE status = 'queued'").get().c;
+  if (!queued) return;
+  const p = spawn('node', [path.join(__dirname, 'scripts', 'tryon-worker.js')], { detached: true, stdio: 'ignore' });
+  p.unref();
+}
+setInterval(drainTryonJobs, 30 * 1000);
+
+// Avatar builder: every 30s drain pending/building avatars
+function drainAvatarBuild() {
+  const pending = db.prepare("SELECT COUNT(*) c FROM user_avatars WHERE status IN ('pending','building')").get().c;
+  if (!pending) return;
+  const p = spawn('node', [path.join(__dirname, 'scripts', 'avatar-build.js')], { detached: true, stdio: 'ignore' });
+  p.unref();
+}
+setInterval(drainAvatarBuild, 30 * 1000);
+
 // ---- start ----------------------------------------------------------------
 app.listen(PORT, '0.0.0.0', () => {
   console.log(`[whatsmystyle] listening on :${PORT}`);

← 05b41bf tick 6: E2E validation + resale guardrails + debates UI + on  ·  back to Whatsmystyle  ·  tick 7 follow-up: surface worker queue depths in /api/health b314fd5 →