← 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
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 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 →