← back to Whatsmystyle
yolo tick 26: image-isnull guard on duel/recs/outfit-builder (no more broken thumbs) + admin-config quick-link nav (config/catalog/cron/drifts/roles) + compact closet upload form when ≥3 items
e29fe3f49fcdfa6dd0595de40d427564bd3d180f · 2026-05-12 14:17:36 -0700 · SteveStudio2
Files touched
M public/admin-config.htmlM public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
Diff
commit e29fe3f49fcdfa6dd0595de40d427564bd3d180f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 14:17:36 2026 -0700
yolo tick 26: image-isnull guard on duel/recs/outfit-builder (no more broken thumbs) + admin-config quick-link nav (config/catalog/cron/drifts/roles) + compact closet upload form when ≥3 items
---
public/admin-config.html | 33 ++++++++++++++++++++++++++++++---
public/css/app.css | 5 +++++
public/index.html | 4 ++--
public/js/app.js | 7 +++++++
server.js | 20 +++++++++++++-------
5 files changed, 57 insertions(+), 12 deletions(-)
diff --git a/public/admin-config.html b/public/admin-config.html
index e624759..c25b1e0 100644
--- a/public/admin-config.html
+++ b/public/admin-config.html
@@ -29,9 +29,36 @@
<body>
<div class="nav"><a href="/">← back to app</a></div>
<h1>Admin config</h1>
+
+ <!-- Tick 26: quick-link nav across all admin surfaces -->
+ <nav class="admin-quicklinks">
+ <a href="#config-section">Config knobs</a>
+ <a href="#catalog-section">Catalog</a>
+ <a href="/admin/cron">Cron</a>
+ <a href="/admin/drifts">Drifts</a>
+ <a href="#role-section">Roles</a>
+ </nav>
+ <style>
+ .admin-quicklinks {
+ display: flex; flex-wrap: wrap; gap: 8px;
+ margin-bottom: 24px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid #e6e1d8;
+ }
+ .admin-quicklinks a {
+ font-size: 13px;
+ padding: 6px 14px;
+ border: 1px solid #d6d0c4;
+ border-radius: 999px;
+ background: #fff;
+ color: #555;
+ text-decoration: none;
+ }
+ .admin-quicklinks a:hover { background: #faf7f2; color: #1d1d1f; }
+ </style>
<p class="sub">Knobs the duel engine + budget guard read on every request. Saved values persist in <code>app_config</code>.</p>
- <form id="cfg">
+ <form id="cfg" data-section="config"><a id="config-section"></a>
<div class="pill-row">
<label>
<span class="name">Cold-start cutoff</span>
@@ -78,7 +105,7 @@
<hr style="margin: 40px 0; border: 0; border-top: 1px solid #e6e1d8;" />
- <h2 style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">User layer · preview</h2>
+ <h2 id="role-section" style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">User layer · preview</h2>
<p class="sub">WhatsMyStyle has three user tiers. As admin you see everything. Use this toggle to preview what a public consumer (general user) or a film/TV set-decoration buyer sees — without losing your admin session.</p>
<div class="pill-row">
@@ -102,7 +129,7 @@
<hr style="margin: 40px 0; border: 0; border-top: 1px solid #e6e1d8;" />
- <h2 style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">Clothing-API catalog</h2>
+ <h2 id="catalog-section" style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">Clothing-API catalog</h2>
<p class="sub">WhatsMyStyle pulls from public Shopify storefronts (zero key) and optional Etsy / eBay / Amazon (free keys). One adapter per source in <code>scripts/clothing-apis/</code>.</p>
<div id="catalog_status" class="pill-row" style="display: block;">
diff --git a/public/css/app.css b/public/css/app.css
index d8dd68e..39dc2bd 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -822,6 +822,11 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.embed-chip-budget.budget-over { color: #ef4444; font-weight: 600; }
.embed-chip-budget.budget-over .muted-sm { color: #ef4444; }
+/* ---- Closet compact upload (tick 26) — hide hero copy once ≥3 items ---- */
+#closet-form.compact { display: flex; gap: 8px; align-items: center; margin: 4px 0 16px; font-size: 12px; }
+#closet-form.compact input[type=file] { font-size: 12px; max-width: 200px; }
+#closet-form.compact button { font-size: 12px; padding: 6px 14px; }
+
/* ---- Quick-look modal + share-favorites (tick 25) ---- */
.fav-actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin: 6px 0 14px; font-size: 13px; }
.fav-actions button.ghost { font: inherit; background: #fff; color: #1d1d1f; border: 1px solid #d6d0c4; padding: 8px 16px; border-radius: 999px; cursor: pointer; }
diff --git a/public/index.html b/public/index.html
index 80d0fce..09b1539 100644
--- a/public/index.html
+++ b/public/index.html
@@ -102,13 +102,13 @@
<!-- Closet -->
<section id="closet" class="screen" hidden>
<h2>My Closet</h2>
- <p class="muted">Upload a photo of your closet — we'll catalogue each piece automatically.</p>
+ <p class="muted" id="closet-empty-state">Upload a photo of your closet — we'll catalogue each piece automatically.</p>
<form id="closet-form">
<input type="file" name="photo" accept="image/*" capture="environment">
<select name="production_id" id="closet-prod-select" data-role-only="set_decorator" hidden>
<option value="">— untagged —</option>
</select>
- <button type="submit">Upload</button>
+ <button type="submit" id="closet-upload-btn">Upload</button>
</form>
<div id="closet-grid" class="grid"></div>
</section>
diff --git a/public/js/app.js b/public/js/app.js
index 4fe5b0e..cb23538 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -823,6 +823,13 @@ async function loadCloset() {
const r = await fetch('/api/closet').then(r => r.json());
const g = $('#closet-grid');
g.innerHTML = '';
+ // Tick 26: hide the "upload a photo…" empty-state and shrink the upload
+ // form to a compact "+ Add another" button once the closet has ≥3 items.
+ const count = (r.items || []).length;
+ const isCompact = count >= 3;
+ $('#closet-empty-state')?.toggleAttribute('hidden', isCompact);
+ $('#closet-form')?.classList.toggle('compact', isCompact);
+ if ($('#closet-upload-btn')) $('#closet-upload-btn').textContent = isCompact ? '+ Add another' : 'Upload';
(r.items || []).forEach(it => {
const c = document.createElement('div');
c.className = 'card';
diff --git a/server.js b/server.js
index 551cda6..10f3ed8 100644
--- a/server.js
+++ b/server.js
@@ -580,6 +580,10 @@ app.get('/api/duel', (req, res) => {
const outlierRate = configValue('outlier_injection_rate', 0.2);
const dueled = db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B','skip')").get(u.id).c;
+ // Tick 26: hard guard — never serve an item with a null/empty image_url.
+ // Broken thumbnails poison the demo and the duel UI just shows a gray box.
+ const IMG_OK = "image_url IS NOT NULL AND image_url != ''";
+
let pair;
if (dueled < cutoff && u.budget_band) {
// Anchored: filter by user's budget band first, optionally body-shape via tags
@@ -587,23 +591,23 @@ app.get('/api/duel', (req, res) => {
const anchorSql = cat
? `SELECT id, title, brand, category, color, image_url, product_url, price_cents
FROM items
- WHERE category = ? AND (price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?))
+ WHERE ${IMG_OK} AND category = ? AND (price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?))
ORDER BY RANDOM() LIMIT 4`
: `SELECT id, title, brand, category, color, image_url, product_url, price_cents
FROM items
- WHERE price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?)
+ WHERE ${IMG_OK} AND (price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?))
ORDER BY RANDOM() LIMIT 4`;
const pool = cat ? db.prepare(anchorSql).all(cat, lo, hi) : db.prepare(anchorSql).all(lo, hi);
pair = pool.slice(0, 2);
// Inject an outlier with probability outlierRate (swap pair[1] for a fully-random item)
if (pair.length === 2 && Math.random() < outlierRate) {
- const outlier = db.prepare('SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 1').get(pair[0].id, pair[1].id);
+ const outlier = db.prepare(`SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 1`).get(pair[0].id, pair[1].id);
if (outlier) pair[1] = outlier;
}
} else {
const sql = cat
- ? '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';
+ ? `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} AND category = ? ORDER BY RANDOM() LIMIT 2`
+ : `SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE ${IMG_OK} ORDER BY RANDOM() LIMIT 2`;
pair = cat ? db.prepare(sql).all(cat) : db.prepare(sql).all();
}
pair.forEach(p => p.sustain = tierFor(p.brand));
@@ -679,7 +683,8 @@ app.get('/api/recommend', (req, res) => {
// pro_only restricts to set-grade pieces (pro_grade = 1). Tick 16:
// surfaced from the /recs UI for set decorators so the grid biases toward
// multi-stocked vendor pieces instead of one-off resale.
- const where = ['embedding IS NOT NULL'];
+ // Tick 26: image-isnull guard — never recommend an item with a broken thumb.
+ const where = ['embedding IS NOT NULL', "image_url IS NOT NULL AND image_url != ''"];
const params = [];
if (cat) { where.push('category = ?'); params.push(cat); }
if (proOnly) { where.push('pro_grade = 1'); }
@@ -1482,7 +1487,8 @@ function rationaleFor(outfit) {
function buildOutfitsFor(db, u, anchorEmbed, anchorCategory) {
const need = SLOT_RULES[anchorCategory] || ['top', 'bottom', 'shoes'];
const candidatesByCat = {};
- const rows = db.prepare('SELECT id, title, brand, category, color, image_url, product_url, price_cents, embedding FROM items WHERE embedding IS NOT NULL').all();
+ // Tick 26: image guard on outfit builder pool too.
+ const rows = db.prepare("SELECT id, title, brand, category, color, image_url, product_url, price_cents, embedding FROM items WHERE embedding IS NOT NULL AND image_url IS NOT NULL AND image_url != ''").all();
for (const r of rows) {
if (!candidatesByCat[r.category]) candidatesByCat[r.category] = [];
try {
← 113d543 yolo tick 25: share-favorites endpoint + public /share/favor
·
back to Whatsmystyle
·
yolo tick 27: duel prefetch (next_pair returned + client Ima 12e6669 →