← 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
A data/cache/img/0d02e5b5c20bf447059c3e8dfff295126ebd89f0.jpgA data/cache/img/1f576472be2699081b119254f0dc5a7da8779899.jpgA data/cache/img/2119a6aaccf5d0f9242c6e4346a2e3674fc754c5.jpgA data/cache/img/249da902d3f3712ea3d15d7feab06c8fea68640f.jpgA data/cache/img/25b978f745893e9a9a1737eba9b906f221bbb8a6.jpgA data/cache/img/3fbec0ed9c8d83896ffdb579e2fd21aa15cffdee.jpgA data/cache/img/404cb2586e495e2e1be7da27ff193ac3d660364b.jpgA data/cache/img/40dc35fc67b42a892cdd0a03c064ce85bd275b4d.jpgA data/cache/img/499411f5bc12aecdb5d549c39afbb3344c727f5f.jpgA data/cache/img/5a4d5680079ecbaef752699a3b915ac07b85d32a.jpgA data/cache/img/77b15648a37135aca6e5b361900e7c1922946145.jpgA data/cache/img/82c54a7f6099a41418ff4f88113f8891f0edfbf2.jpgA data/cache/img/9c37855eb6a86003cf724287dbe05de3742c7705.jpgA data/cache/img/bba3988feabdac3cb53b87e403810dd4524bdb38.jpgA data/cache/img/bfefe404e7f2b78327293c9416aaa4a0c48a0ead.jpgA data/cache/img/c1c22b5c865af1fd7bed19887261dc3900cbeeec.jpgA data/cache/img/ecdbfaf25388b24700025e6af77a8a7c3c066d61.jpgA data/cache/img/f02492b82a37cc61c2121fc1850e2ab8ef4f222c.jpgA data/cache/img/f34c8c07760eebc13c9b46b37ffaf3d150e54c60.jpgA data/logs/embed-drainer.errA data/logs/embed-drainer.outM data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/brands.htmlM public/css/app.cssM public/index.htmlM public/js/app.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →