[object Object]

← 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

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 →