[object Object]

← back to Whatsmystyle

yolo tick 28: brands sustainability filter pills (All/♻5/♻4/♻3+/No tier · localStorage) + duel keyboard cheat sheet (A·B·Space, auto-fade after first kb pick) + bulk-upload per-file progress dots (poll /api/closet every 5s, dots turn green as vision tags land)

636d4e8c32dcfc3fd379ce1bae002285c6256194 · 2026-05-12 15:39:16 -0700 · SteveStudio2

Files touched

Diff

commit 636d4e8c32dcfc3fd379ce1bae002285c6256194
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 15:39:16 2026 -0700

    yolo tick 28: brands sustainability filter pills (All/♻5/♻4/♻3+/No tier · localStorage) + duel keyboard cheat sheet (A·B·Space, auto-fade after first kb pick) + bulk-upload per-file progress dots (poll /api/closet every 5s, dots turn green as vision tags land)
---
 .../0d02e5b5c20bf447059c3e8dfff295126ebd89f0.jpg   | Bin 0 -> 457745 bytes
 .../1f576472be2699081b119254f0dc5a7da8779899.jpg   | Bin 0 -> 319941 bytes
 .../2119a6aaccf5d0f9242c6e4346a2e3674fc754c5.jpg   | Bin 0 -> 613754 bytes
 .../249da902d3f3712ea3d15d7feab06c8fea68640f.jpg   | Bin 0 -> 381584 bytes
 .../25b978f745893e9a9a1737eba9b906f221bbb8a6.jpg   | Bin 0 -> 300646 bytes
 .../3fbec0ed9c8d83896ffdb579e2fd21aa15cffdee.jpg   | Bin 0 -> 763466 bytes
 .../404cb2586e495e2e1be7da27ff193ac3d660364b.jpg   | Bin 0 -> 320742 bytes
 .../40dc35fc67b42a892cdd0a03c064ce85bd275b4d.jpg   | Bin 0 -> 320742 bytes
 .../499411f5bc12aecdb5d549c39afbb3344c727f5f.jpg   | Bin 0 -> 342915 bytes
 .../5a4d5680079ecbaef752699a3b915ac07b85d32a.jpg   | Bin 0 -> 336585 bytes
 .../77b15648a37135aca6e5b361900e7c1922946145.jpg   | Bin 0 -> 990791 bytes
 .../82c54a7f6099a41418ff4f88113f8891f0edfbf2.jpg   | Bin 0 -> 380552 bytes
 .../9c37855eb6a86003cf724287dbe05de3742c7705.jpg   | Bin 0 -> 209652 bytes
 .../bba3988feabdac3cb53b87e403810dd4524bdb38.jpg   | Bin 0 -> 387355 bytes
 .../bfefe404e7f2b78327293c9416aaa4a0c48a0ead.jpg   | Bin 0 -> 300646 bytes
 .../c1c22b5c865af1fd7bed19887261dc3900cbeeec.jpg   | Bin 0 -> 395393 bytes
 .../ecdbfaf25388b24700025e6af77a8a7c3c066d61.jpg   | Bin 0 -> 301776 bytes
 .../f02492b82a37cc61c2121fc1850e2ab8ef4f222c.jpg   | Bin 0 -> 1228869 bytes
 .../f34c8c07760eebc13c9b46b37ffaf3d150e54c60.jpg   | Bin 0 -> 613754 bytes
 data/logs/embed-drainer.err                        |   0
 data/logs/embed-drainer.out                        |   0
 data/waitlist.csv                                  |  45 +++++++++++
 data/whatsmystyle.db-shm                           | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal                           | Bin 4120032 -> 4610312 bytes
 public/brands.html                                 |  84 +++++++++++++++++++--
 public/css/app.css                                 |  31 ++++++++
 public/index.html                                  |   3 +
 public/js/app.js                                   |  74 +++++++++++++++++-
 28 files changed, 227 insertions(+), 10 deletions(-)

diff --git a/data/cache/img/0d02e5b5c20bf447059c3e8dfff295126ebd89f0.jpg b/data/cache/img/0d02e5b5c20bf447059c3e8dfff295126ebd89f0.jpg
new file mode 100644
index 0000000..16db1c5
Binary files /dev/null and b/data/cache/img/0d02e5b5c20bf447059c3e8dfff295126ebd89f0.jpg differ
diff --git a/data/cache/img/1f576472be2699081b119254f0dc5a7da8779899.jpg b/data/cache/img/1f576472be2699081b119254f0dc5a7da8779899.jpg
new file mode 100644
index 0000000..b405bce
Binary files /dev/null and b/data/cache/img/1f576472be2699081b119254f0dc5a7da8779899.jpg differ
diff --git a/data/cache/img/2119a6aaccf5d0f9242c6e4346a2e3674fc754c5.jpg b/data/cache/img/2119a6aaccf5d0f9242c6e4346a2e3674fc754c5.jpg
new file mode 100644
index 0000000..9704fa3
Binary files /dev/null and b/data/cache/img/2119a6aaccf5d0f9242c6e4346a2e3674fc754c5.jpg differ
diff --git a/data/cache/img/249da902d3f3712ea3d15d7feab06c8fea68640f.jpg b/data/cache/img/249da902d3f3712ea3d15d7feab06c8fea68640f.jpg
new file mode 100644
index 0000000..658ea2f
Binary files /dev/null and b/data/cache/img/249da902d3f3712ea3d15d7feab06c8fea68640f.jpg differ
diff --git a/data/cache/img/25b978f745893e9a9a1737eba9b906f221bbb8a6.jpg b/data/cache/img/25b978f745893e9a9a1737eba9b906f221bbb8a6.jpg
new file mode 100644
index 0000000..ffe04fb
Binary files /dev/null and b/data/cache/img/25b978f745893e9a9a1737eba9b906f221bbb8a6.jpg differ
diff --git a/data/cache/img/3fbec0ed9c8d83896ffdb579e2fd21aa15cffdee.jpg b/data/cache/img/3fbec0ed9c8d83896ffdb579e2fd21aa15cffdee.jpg
new file mode 100644
index 0000000..5a21560
Binary files /dev/null and b/data/cache/img/3fbec0ed9c8d83896ffdb579e2fd21aa15cffdee.jpg differ
diff --git a/data/cache/img/404cb2586e495e2e1be7da27ff193ac3d660364b.jpg b/data/cache/img/404cb2586e495e2e1be7da27ff193ac3d660364b.jpg
new file mode 100644
index 0000000..f9a52be
Binary files /dev/null and b/data/cache/img/404cb2586e495e2e1be7da27ff193ac3d660364b.jpg differ
diff --git a/data/cache/img/40dc35fc67b42a892cdd0a03c064ce85bd275b4d.jpg b/data/cache/img/40dc35fc67b42a892cdd0a03c064ce85bd275b4d.jpg
new file mode 100644
index 0000000..f9a52be
Binary files /dev/null and b/data/cache/img/40dc35fc67b42a892cdd0a03c064ce85bd275b4d.jpg differ
diff --git a/data/cache/img/499411f5bc12aecdb5d549c39afbb3344c727f5f.jpg b/data/cache/img/499411f5bc12aecdb5d549c39afbb3344c727f5f.jpg
new file mode 100644
index 0000000..841f753
Binary files /dev/null and b/data/cache/img/499411f5bc12aecdb5d549c39afbb3344c727f5f.jpg differ
diff --git a/data/cache/img/5a4d5680079ecbaef752699a3b915ac07b85d32a.jpg b/data/cache/img/5a4d5680079ecbaef752699a3b915ac07b85d32a.jpg
new file mode 100644
index 0000000..0dafa2f
Binary files /dev/null and b/data/cache/img/5a4d5680079ecbaef752699a3b915ac07b85d32a.jpg differ
diff --git a/data/cache/img/77b15648a37135aca6e5b361900e7c1922946145.jpg b/data/cache/img/77b15648a37135aca6e5b361900e7c1922946145.jpg
new file mode 100644
index 0000000..0fedc37
Binary files /dev/null and b/data/cache/img/77b15648a37135aca6e5b361900e7c1922946145.jpg differ
diff --git a/data/cache/img/82c54a7f6099a41418ff4f88113f8891f0edfbf2.jpg b/data/cache/img/82c54a7f6099a41418ff4f88113f8891f0edfbf2.jpg
new file mode 100644
index 0000000..27fc160
Binary files /dev/null and b/data/cache/img/82c54a7f6099a41418ff4f88113f8891f0edfbf2.jpg differ
diff --git a/data/cache/img/9c37855eb6a86003cf724287dbe05de3742c7705.jpg b/data/cache/img/9c37855eb6a86003cf724287dbe05de3742c7705.jpg
new file mode 100644
index 0000000..ac4ac97
Binary files /dev/null and b/data/cache/img/9c37855eb6a86003cf724287dbe05de3742c7705.jpg differ
diff --git a/data/cache/img/bba3988feabdac3cb53b87e403810dd4524bdb38.jpg b/data/cache/img/bba3988feabdac3cb53b87e403810dd4524bdb38.jpg
new file mode 100644
index 0000000..c705148
Binary files /dev/null and b/data/cache/img/bba3988feabdac3cb53b87e403810dd4524bdb38.jpg differ
diff --git a/data/cache/img/bfefe404e7f2b78327293c9416aaa4a0c48a0ead.jpg b/data/cache/img/bfefe404e7f2b78327293c9416aaa4a0c48a0ead.jpg
new file mode 100644
index 0000000..ffe04fb
Binary files /dev/null and b/data/cache/img/bfefe404e7f2b78327293c9416aaa4a0c48a0ead.jpg differ
diff --git a/data/cache/img/c1c22b5c865af1fd7bed19887261dc3900cbeeec.jpg b/data/cache/img/c1c22b5c865af1fd7bed19887261dc3900cbeeec.jpg
new file mode 100644
index 0000000..f2c3d6e
Binary files /dev/null and b/data/cache/img/c1c22b5c865af1fd7bed19887261dc3900cbeeec.jpg differ
diff --git a/data/cache/img/ecdbfaf25388b24700025e6af77a8a7c3c066d61.jpg b/data/cache/img/ecdbfaf25388b24700025e6af77a8a7c3c066d61.jpg
new file mode 100644
index 0000000..c51ef63
Binary files /dev/null and b/data/cache/img/ecdbfaf25388b24700025e6af77a8a7c3c066d61.jpg differ
diff --git a/data/cache/img/f02492b82a37cc61c2121fc1850e2ab8ef4f222c.jpg b/data/cache/img/f02492b82a37cc61c2121fc1850e2ab8ef4f222c.jpg
new file mode 100644
index 0000000..02fa4e8
Binary files /dev/null and b/data/cache/img/f02492b82a37cc61c2121fc1850e2ab8ef4f222c.jpg differ
diff --git a/data/cache/img/f34c8c07760eebc13c9b46b37ffaf3d150e54c60.jpg b/data/cache/img/f34c8c07760eebc13c9b46b37ffaf3d150e54c60.jpg
new file mode 100644
index 0000000..9704fa3
Binary files /dev/null and b/data/cache/img/f34c8c07760eebc13c9b46b37ffaf3d150e54c60.jpg differ
diff --git a/data/logs/embed-drainer.err b/data/logs/embed-drainer.err
new file mode 100644
index 0000000..e69de29
diff --git a/data/logs/embed-drainer.out b/data/logs/embed-drainer.out
new file mode 100644
index 0000000..e69de29
diff --git a/data/waitlist.csv b/data/waitlist.csv
index ca6f36b..94b35a3 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -18,3 +18,48 @@ e2e-1778569258754@example.com,e2e,2026-05-12T07:00:58.755Z
 e2e-1778569432594@example.com,e2e,2026-05-12T07:03:52.597Z
 e2e-1778569951884@example.com,e2e,2026-05-12T07:12:31.885Z
 e2e-1778570110245@example.com,e2e,2026-05-12T07:15:10.246Z
+e2e-1778572058319@example.com,e2e,2026-05-12T07:47:38.319Z
+e2e-1778572524631@example.com,e2e,2026-05-12T07:55:24.647Z
+e2e-1778574519635@example.com,e2e,2026-05-12T08:28:39.636Z
+e2e-1778574696123@example.com,e2e,2026-05-12T08:31:36.166Z
+e2e-1778575998078@example.com,e2e,2026-05-12T08:53:18.081Z
+e2e-1778581390398@example.com,e2e,2026-05-12T10:23:10.401Z
+e2e-1778594178823@example.com,e2e,2026-05-12T13:56:18.824Z
+e2e-1778594350722@example.com,e2e,2026-05-12T13:59:10.724Z
+e2e-1778594690096@example.com,e2e,2026-05-12T14:04:50.097Z
+e2e-1778595235587@example.com,e2e,2026-05-12T14:13:55.587Z
+e2e-1778596102300@example.com,e2e,2026-05-12T14:28:22.301Z
+e2e-1778596585103@example.com,e2e,2026-05-12T14:36:25.117Z
+e2e-1778596734887@example.com,e2e,2026-05-12T14:38:54.888Z
+e2e-1778597903429@example.com,e2e,2026-05-12T14:58:23.430Z
+e2e-1778598164304@example.com,e2e,2026-05-12T15:02:44.306Z
+e2e-1778598806437@example.com,e2e,2026-05-12T15:13:26.438Z
+e2e-1778599155902@example.com,e2e,2026-05-12T15:19:15.903Z
+e2e-1778600881887@example.com,e2e,2026-05-12T15:48:01.888Z
+e2e-1778601101171@example.com,e2e,2026-05-12T15:51:41.172Z
+e2e-1778601349358@example.com,e2e,2026-05-12T15:55:49.358Z
+e2e-1778603332564@example.com,e2e,2026-05-12T16:28:52.567Z
+e2e-1778603581427@example.com,e2e,2026-05-12T16:33:01.431Z
+e2e-1778604226484@example.com,e2e,2026-05-12T16:43:46.485Z
+e2e-1778605661949@example.com,e2e,2026-05-12T17:07:41.950Z
+e2e-1778606022955@example.com,e2e,2026-05-12T17:13:42.956Z
+e2e-1778606570341@example.com,e2e,2026-05-12T17:22:50.343Z
+e2e-1778606870676@example.com,e2e,2026-05-12T17:27:50.686Z
+e2e-1778608007184@example.com,e2e,2026-05-12T17:46:47.185Z
+e2e-1778610172596@example.com,e2e,2026-05-12T18:22:52.597Z
+e2e-1778610415845@example.com,e2e,2026-05-12T18:26:55.845Z
+e2e-1778612391123@example.com,e2e,2026-05-12T18:59:51.125Z
+e2e-1778612620281@example.com,e2e,2026-05-12T19:03:40.282Z
+e2e-1778614615277@example.com,e2e,2026-05-12T19:36:55.278Z
+e2e-1778614851487@example.com,e2e,2026-05-12T19:40:51.488Z
+e2e-1778616842654@example.com,e2e,2026-05-12T20:14:02.655Z
+e2e-1778617175521@example.com,e2e,2026-05-12T20:19:35.523Z
+e2e-1778619350398@example.com,e2e,2026-05-12T20:55:50.399Z
+e2e-1778619588162@example.com,e2e,2026-05-12T20:59:48.163Z
+e2e-1778620373031@example.com,e2e,2026-05-12T21:12:53.031Z
+e2e-1778620640896@example.com,e2e,2026-05-12T21:17:20.896Z
+e2e-1778621574184@example.com,e2e,2026-05-12T21:32:54.185Z
+e2e-1778622743627@example.com,e2e,2026-05-12T21:52:23.636Z
+e2e-1778623219818@example.com,e2e,2026-05-12T22:00:19.824Z
+e2e-1778625279476@example.com,e2e,2026-05-12T22:34:39.477Z
+e2e-1778625542846@example.com,e2e,2026-05-12T22:39:02.846Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 1eb6c39..d52509b 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 871b665..33e7980 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/brands.html b/public/brands.html
index b3c6377..d1dd355 100644
--- a/public/brands.html
+++ b/public/brands.html
@@ -8,10 +8,20 @@
   <style>
     body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 1100px; margin: 40px auto; padding: 0 24px; }
     h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
-    .sub { color: #707070; margin-bottom: 28px; font-size: 14px; }
+    .sub { color: #707070; margin-bottom: 18px; font-size: 14px; }
     .nav { margin-bottom: 18px; }
     .nav a { color: #707070; text-decoration: none; font-size: 14px; }
     .nav a:hover { color: #1d1d1f; }
+    .filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 22px; }
+    .filter-pill {
+      background: #fff; border: 1px solid #e6e1d8; border-radius: 999px;
+      padding: 7px 14px; font-size: 13px; cursor: pointer; color: #1d1d1f;
+      font-family: inherit; letter-spacing: -0.005em; transition: all 0.12s;
+    }
+    .filter-pill:hover { border-color: #c8c1b3; }
+    .filter-pill.is-active { background: #1d1d1f; color: #fff; border-color: #1d1d1f; }
+    .filter-pill .pill-count { color: #999; font-variant-numeric: tabular-nums; margin-left: 4px; font-size: 11px; }
+    .filter-pill.is-active .pill-count { color: #aaa; }
     .brand-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
     .brand-card {
       background: #fff; border: 1px solid #e6e1d8; border-radius: 16px;
@@ -28,22 +38,71 @@
     }
     .brand-card .tier-5 { background: #d4f4d4; color: #1d5e1d; }
     .brand-card .tier-1 { background: #fbe4e4; color: #7a1717; }
+    .empty { color: #999; font-size: 14px; padding: 24px 0; text-align: center; }
   </style>
 </head>
 <body>
   <div class="nav"><a href="/">← back to app</a></div>
   <h1>Brands</h1>
   <p class="sub" id="sub">Every brand in our catalog, sorted by piece count.</p>
+  <div id="filters" class="filter-row" hidden></div>
   <div id="grid" class="brand-grid"></div>
 
   <script>
     const $ = s => document.querySelector(s);
     function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
-    (async function () {
-      const r = await fetch('/api/brands');
-      const j = await r.json();
-      $('#sub').textContent = `${j.brands.length} brand${j.brands.length === 1 ? '' : 's'} · sorted by piece count.`;
-      $('#grid').innerHTML = j.brands.map(b => `
+
+    const FILTER_KEY = 'wms.brands.sustain_filter';
+    let ALL = [];
+
+    function currentFilter() {
+      return localStorage.getItem(FILTER_KEY) || 'all';
+    }
+    function setFilter(v) {
+      localStorage.setItem(FILTER_KEY, v);
+      render();
+    }
+    function matches(b, f) {
+      if (f === 'all') return true;
+      if (f === 'none') return !b.sustain_tier;
+      if (f === '5') return b.sustain_tier === 5;
+      if (f === '4') return b.sustain_tier === 4;
+      if (f === '3plus') return (b.sustain_tier || 0) >= 3;
+      return true;
+    }
+
+    function render() {
+      const f = currentFilter();
+      const counts = {
+        all: ALL.length,
+        '5': ALL.filter(b => b.sustain_tier === 5).length,
+        '4': ALL.filter(b => b.sustain_tier === 4).length,
+        '3plus': ALL.filter(b => (b.sustain_tier || 0) >= 3).length,
+        none: ALL.filter(b => !b.sustain_tier).length,
+      };
+      const pills = [
+        ['all',   'All',     counts.all],
+        ['5',     '♻ 5',     counts['5']],
+        ['4',     '♻ 4',     counts['4']],
+        ['3plus', '♻ 3+',    counts['3plus']],
+        ['none',  'No tier', counts.none],
+      ];
+      const fr = $('#filters');
+      fr.hidden = false;
+      fr.innerHTML = pills.map(([v, label, n]) => `
+        <button type="button" class="filter-pill ${f === v ? 'is-active' : ''}" data-filter="${v}">
+          ${label} <span class="pill-count">${n}</span>
+        </button>
+      `).join('');
+
+      const filtered = ALL.filter(b => matches(b, f));
+      $('#sub').textContent = `${filtered.length} of ${ALL.length} brand${ALL.length === 1 ? '' : 's'} · sorted by piece count.`;
+
+      if (!filtered.length) {
+        $('#grid').innerHTML = '<div class="empty">No brands match this filter.</div>';
+        return;
+      }
+      $('#grid').innerHTML = filtered.map(b => `
         <a class="brand-card" href="/?brand=${encodeURIComponent(b.brand)}" onclick="event.preventDefault();const u=new URL(location.origin);u.searchParams.set('brand',this.dataset.brand||'${escapeHtml(b.brand)}');location.href=u.toString();" data-brand="${escapeHtml(b.brand)}">
           <div>
             <div class="name">${escapeHtml(b.brand)}</div>
@@ -52,6 +111,19 @@
           <div class="count">${b.item_count} pcs</div>
         </a>
       `).join('');
+    }
+
+    document.addEventListener('click', e => {
+      const pill = e.target.closest('.filter-pill');
+      if (!pill) return;
+      setFilter(pill.dataset.filter);
+    });
+
+    (async function () {
+      const r = await fetch('/api/brands');
+      const j = await r.json();
+      ALL = j.brands || [];
+      render();
     })();
   </script>
 </body>
diff --git a/public/css/app.css b/public/css/app.css
index 39dc2bd..ddaa571 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -127,6 +127,37 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 }
 .vs { display: grid; place-items: center; font-family: var(--font-display); font-size: 28px; opacity: .5; }
 .duel-actions { margin: 18px 0; }
+.kb-hint {
+  display: inline-flex; align-items: center; gap: 6px;
+  font-size: 12px; color: #888; padding: 6px 14px;
+  background: var(--paper-2); border-radius: 999px;
+  margin: 0 0 12px; transition: opacity 0.6s ease;
+}
+.kb-hint kbd {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 11px; padding: 1px 6px; border: 1px solid var(--line);
+  border-bottom-width: 2px; border-radius: 4px; background: #fff;
+  color: var(--ink); font-weight: 600;
+}
+.kb-hint.is-fading { opacity: 0; }
+.bulk-progress {
+  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
+  padding: 8px 12px; margin: 8px 0; background: var(--paper-2);
+  border-radius: 8px; font-size: 12px;
+}
+.bulk-progress .dot {
+  width: 10px; height: 10px; border-radius: 999px;
+  background: #d8d3c8; transition: background 0.4s ease, transform 0.3s ease;
+}
+.bulk-progress .dot.is-done {
+  background: #2ea44f; transform: scale(1.1);
+}
+.bulk-progress .dot-progress-label {
+  margin-left: auto; color: #707070; font-variant-numeric: tabular-nums;
+}
+@media (hover: none) and (pointer: coarse) {
+  .kb-hint { display: none !important; }
+}
 .taste-meter { display: flex; align-items: center; gap: 12px; margin-top: 16px; color: #777; font-size: 14px; }
 .taste-meter .meter { flex: 1; height: 6px; background: var(--paper-2); border-radius: 6px; overflow: hidden; }
 .taste-meter .meter-fill { height: 100%; background: var(--ok); width: 0%; transition: width .4s ease; }
diff --git a/public/index.html b/public/index.html
index 51e7a05..02d53bc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -92,6 +92,9 @@
     <div class="duel-actions">
       <button class="ghost wide" onclick="pickDuel('skip')" aria-label="Skip — show me something else (space)">Neither — show me something else</button>
     </div>
+    <div id="duel-kb-hint" class="kb-hint" hidden aria-hidden="true">
+      <kbd>A</kbd> left · <kbd>B</kbd> right · <kbd>Space</kbd> skip
+    </div>
     <p class="muted" aria-live="polite" id="duel-live"></p>
     <div class="taste-meter">
       <span>Taste lens sharpening:</span>
diff --git a/public/js/app.js b/public/js/app.js
index cf4c19c..6db78ab 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -810,11 +810,33 @@ async function pickDuel(choice) {
   loadDuel();
 }
 
+// Tick 28: keyboard cheat sheet — show until first keyboard pick.
+const KB_HINT_KEY = 'wms.duel.kb_hint_dismissed';
+function showKbHintIfNew() {
+  const el = $('#duel-kb-hint');
+  if (!el) return;
+  if (localStorage.getItem(KB_HINT_KEY) === '1') { el.hidden = true; return; }
+  // Desktop only — kbd hints make no sense on touch
+  if (window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
+    el.hidden = true; return;
+  }
+  el.hidden = false;
+}
+function fadeKbHint() {
+  const el = $('#duel-kb-hint');
+  if (!el || el.hidden) return;
+  localStorage.setItem(KB_HINT_KEY, '1');
+  el.classList.add('is-fading');
+  setTimeout(() => { el.hidden = true; el.classList.remove('is-fading'); }, 700);
+}
+
 window.addEventListener('keydown', e => {
   if ($('#home').hidden) return;
-  if (e.key === 'ArrowLeft' || e.key.toLowerCase() === 'a') pickDuel('A');
-  if (e.key === 'ArrowRight' || e.key.toLowerCase() === 'b') pickDuel('B');
-  if (e.key === ' ' || e.key.toLowerCase() === 's') { e.preventDefault(); pickDuel('skip'); }
+  let used = false;
+  if (e.key === 'ArrowLeft' || e.key.toLowerCase() === 'a') { pickDuel('A'); used = true; }
+  if (e.key === 'ArrowRight' || e.key.toLowerCase() === 'b') { pickDuel('B'); used = true; }
+  if (e.key === ' ' || e.key.toLowerCase() === 's') { e.preventDefault(); pickDuel('skip'); used = true; }
+  if (used) fadeKbHint();
 });
 
 async function seedCatalog() {
@@ -851,8 +873,52 @@ $('#closet-form').addEventListener('submit', async e => {
     const j = await r.json();
     if (status) status.textContent = `uploaded ${j.count}. analyzing in background.`;
     loadCloset();
+    // Tick 28: per-file progress dots — one gray dot per upload, fills green
+    // as each row's vision-tag (category) lands. Polls /api/closet every 5s.
+    if (j.closet_ids?.length) startBulkProgress(j.closet_ids);
   } else if (status) status.textContent = 'bulk upload failed.';
 });
+
+// Tick 28: per-file progress dots for bulk closet uploads.
+let bulkProgressTimer = null;
+function startBulkProgress(ids) {
+  if (bulkProgressTimer) { clearInterval(bulkProgressTimer); bulkProgressTimer = null; }
+  if (!ids?.length) return;
+  let host = $('#closet-bulk-progress');
+  if (!host) {
+    host = document.createElement('div');
+    host.id = 'closet-bulk-progress';
+    host.className = 'bulk-progress';
+    const status = $('#closet-upload-status');
+    (status?.parentNode || $('#closet-form')).insertBefore(host, status?.nextSibling || null);
+  }
+  host.hidden = false;
+  host.innerHTML = ids.map(id => `<span class="dot" data-cid="${id}" aria-label="processing ${id}"></span>`).join('') +
+                   `<span class="dot-progress-label" id="bulk-progress-label">0 / ${ids.length} tagged</span>`;
+  const idSet = new Set(ids);
+  async function tick() {
+    try {
+      const r = await fetch('/api/closet').then(r => r.json());
+      const map = new Map((r.items || []).map(i => [i.id, i]));
+      let tagged = 0;
+      ids.forEach(id => {
+        const it = map.get(id);
+        const dot = host.querySelector(`.dot[data-cid="${id}"]`);
+        if (!dot) return;
+        if (it?.category) { dot.classList.add('is-done'); tagged++; }
+      });
+      const label = $('#bulk-progress-label');
+      if (label) label.textContent = `${tagged} / ${ids.length} tagged`;
+      if (tagged >= ids.length) {
+        clearInterval(bulkProgressTimer); bulkProgressTimer = null;
+        // Auto-hide the strip 4s after fully tagged so the closet grid takes the focus.
+        setTimeout(() => { host.hidden = true; }, 4000);
+      }
+    } catch {}
+  }
+  tick();
+  bulkProgressTimer = setInterval(tick, 5000);
+}
 async function loadCloset() {
   const r = await fetch('/api/closet').then(r => r.json());
   const g = $('#closet-grid');
@@ -1876,7 +1942,7 @@ async function loadDebates() {
 
 function route(name) {
   show(name);
-  if (name === 'home') loadDuel();
+  if (name === 'home') { loadDuel(); showKbHintIfNew(); }
   if (name === 'closet') loadCloset();
   if (name === 'stylepiece') loadStylePiece();
   if (name === 'recs') loadRecs();

← 12e6669 yolo tick 27: duel prefetch (next_pair returned + client Ima  ·  back to Whatsmystyle  ·  yolo tick 29: brands page sort selector (Most pieces / A→Z / 3c7e580 →