[object Object]

← back to Whatsmystyle

yolo tick 29: brands page sort selector (Most pieces / A→Z / Sustainability ↓) + density slider (160-320px, --card-min CSS var, localStorage persist, hydrated before first render) + × close button on bulk-upload progress strip (clears poll timer + fade-out) + aria-live=polite on closet-upload-status (screen-reader announces upload progress)

3c7e580f86c5bd716f29647185d024db26c9e1b0 · 2026-05-12 16:14:58 -0700 · SteveStudio2

Files touched

Diff

commit 3c7e580f86c5bd716f29647185d024db26c9e1b0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:14:58 2026 -0700

    yolo tick 29: brands page sort selector (Most pieces / A→Z / Sustainability ↓) + density slider (160-320px, --card-min CSS var, localStorage persist, hydrated before first render) + × close button on bulk-upload progress strip (clears poll timer + fade-out) + aria-live=polite on closet-upload-status (screen-reader announces upload progress)
---
 data/waitlist.csv        |   2 ++
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 4610312 -> 4610312 bytes
 public/brands.html       |  79 ++++++++++++++++++++++++++++++++++++++++-------
 public/css/app.css       |   7 +++++
 public/index.html        |   2 +-
 public/js/app.js         |   9 +++++-
 7 files changed, 86 insertions(+), 13 deletions(-)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 94b35a3..149d0be 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -63,3 +63,5 @@ 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
+e2e-1778627519615@example.com,e2e,2026-05-12T23:11:59.620Z
+e2e-1778627684022@example.com,e2e,2026-05-12T23:14:44.022Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index d52509b..b8ac341 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 33e7980..3a67447 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 d1dd355..58fbb9d 100644
--- a/public/brands.html
+++ b/public/brands.html
@@ -12,6 +12,19 @@
     .nav { margin-bottom: 18px; }
     .nav a { color: #707070; text-decoration: none; font-size: 14px; }
     .nav a:hover { color: #1d1d1f; }
+
+    .controls {
+      display: flex; flex-wrap: wrap; align-items: center; gap: 14px;
+      margin-bottom: 14px; padding: 12px 14px;
+      background: #fff; border: 1px solid #e6e1d8; border-radius: 12px;
+    }
+    .controls label { font-size: 12px; color: #707070; display: inline-flex; align-items: center; gap: 8px; }
+    .controls select {
+      font-family: inherit; font-size: 13px; padding: 6px 10px;
+      border: 1px solid #c8c1b3; border-radius: 8px; background: #faf7f2; color: #1d1d1f;
+    }
+    .controls input[type=range] { width: 140px; }
+
     .filter-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 22px; }
     .filter-pill {
       background: #fff; border: 1px solid #e6e1d8; border-radius: 999px;
@@ -22,7 +35,11 @@
     .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-grid {
+      display: grid; gap: 12px;
+      grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 220px), 1fr));
+    }
     .brand-card {
       background: #fff; border: 1px solid #e6e1d8; border-radius: 16px;
       padding: 18px 22px; text-decoration: none; color: inherit;
@@ -34,7 +51,7 @@
     .brand-card .count { font-size: 12px; color: #707070; font-variant-numeric: tabular-nums; }
     .brand-card .tier {
       font-size: 11px; padding: 2px 8px; border-radius: 999px;
-      background: #f3eee2; color: #6e4f0d; margin-top: 4px;
+      background: #f3eee2; color: #6e4f0d; margin-top: 4px; display: inline-block;
     }
     .brand-card .tier-5 { background: #d4f4d4; color: #1d5e1d; }
     .brand-card .tier-1 { background: #fbe4e4; color: #7a1717; }
@@ -44,7 +61,22 @@
 <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>
+  <p class="sub" id="sub">Every brand in our catalog.</p>
+
+  <div class="controls" id="controls" hidden>
+    <label>Sort
+      <select id="sort">
+        <option value="pieces">Most pieces</option>
+        <option value="az">A → Z</option>
+        <option value="sustain">Sustainability ↓</option>
+      </select>
+    </label>
+    <label>Density
+      <input id="density" type="range" min="160" max="320" step="20" value="220" aria-label="Card density" />
+      <span id="density-val" style="font-variant-numeric: tabular-nums; color: #999;">220px</span>
+    </label>
+  </div>
+
   <div id="filters" class="filter-row" hidden></div>
   <div id="grid" class="brand-grid"></div>
 
@@ -53,15 +85,21 @@
     function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 
     const FILTER_KEY = 'wms.brands.sustain_filter';
+    const SORT_KEY   = 'wms.brands.sort';
+    const DENSITY_KEY= 'wms.brands.density';
     let ALL = [];
 
-    function currentFilter() {
-      return localStorage.getItem(FILTER_KEY) || 'all';
-    }
-    function setFilter(v) {
-      localStorage.setItem(FILTER_KEY, v);
-      render();
+    function currentFilter()  { return localStorage.getItem(FILTER_KEY) || 'all'; }
+    function currentSort()    { return localStorage.getItem(SORT_KEY) || 'pieces'; }
+    function currentDensity() { return Number(localStorage.getItem(DENSITY_KEY) || 220); }
+    function setFilter(v)  { localStorage.setItem(FILTER_KEY, v); render(); }
+    function setSort(v)    { localStorage.setItem(SORT_KEY, v); render(); }
+    function setDensity(n) {
+      localStorage.setItem(DENSITY_KEY, String(n));
+      document.documentElement.style.setProperty('--card-min', `${n}px`);
+      $('#density-val').textContent = `${n}px`;
     }
+
     function matches(b, f) {
       if (f === 'all') return true;
       if (f === 'none') return !b.sustain_tier;
@@ -70,9 +108,17 @@
       if (f === '3plus') return (b.sustain_tier || 0) >= 3;
       return true;
     }
+    function sortList(list, mode) {
+      const arr = list.slice();
+      if (mode === 'az')      arr.sort((a, b) => a.brand.localeCompare(b.brand));
+      else if (mode === 'sustain') arr.sort((a, b) => (b.sustain_tier || 0) - (a.sustain_tier || 0) || b.item_count - a.item_count);
+      else                    arr.sort((a, b) => b.item_count - a.item_count); // 'pieces'
+      return arr;
+    }
 
     function render() {
       const f = currentFilter();
+      const s = currentSort();
       const counts = {
         all: ALL.length,
         '5': ALL.filter(b => b.sustain_tier === 5).length,
@@ -95,8 +141,10 @@
         </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.`;
+      $('#sort').value = s;
+      const filtered = sortList(ALL.filter(b => matches(b, f)), s);
+      const sortLabel = { pieces: 'most pieces', az: 'A→Z', sustain: 'sustainability' }[s];
+      $('#sub').textContent = `${filtered.length} of ${ALL.length} brand${ALL.length === 1 ? '' : 's'} · sorted by ${sortLabel}.`;
 
       if (!filtered.length) {
         $('#grid').innerHTML = '<div class="empty">No brands match this filter.</div>';
@@ -123,6 +171,15 @@
       const r = await fetch('/api/brands');
       const j = await r.json();
       ALL = j.brands || [];
+      $('#controls').hidden = false;
+      // Hydrate sort + density BEFORE first render (standing rule M-6/M-7).
+      const d = currentDensity();
+      document.documentElement.style.setProperty('--card-min', `${d}px`);
+      $('#density').value = d;
+      $('#density-val').textContent = `${d}px`;
+      $('#sort').value = currentSort();
+      $('#sort').addEventListener('change', () => setSort($('#sort').value));
+      $('#density').addEventListener('input', () => setDensity(Number($('#density').value)));
       render();
     })();
   </script>
diff --git a/public/css/app.css b/public/css/app.css
index ddaa571..c6c5730 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -155,6 +155,13 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .bulk-progress .dot-progress-label {
   margin-left: auto; color: #707070; font-variant-numeric: tabular-nums;
 }
+.bulk-progress .bulk-progress-close {
+  background: transparent; border: 0; color: #999; cursor: pointer;
+  font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 4px;
+  font-family: inherit;
+}
+.bulk-progress .bulk-progress-close:hover { color: #1d1d1f; background: rgba(0,0,0,.05); }
+.bulk-progress.is-dismissed { opacity: 0; transition: opacity .2s ease; pointer-events: none; }
 @media (hover: none) and (pointer: coarse) {
   .kb-hint { display: none !important; }
 }
diff --git a/public/index.html b/public/index.html
index 02d53bc..8a1748b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -113,7 +113,7 @@
         <option value="">— untagged —</option>
       </select>
       <button type="submit" id="closet-upload-btn">Upload</button>
-      <span id="closet-upload-status" class="muted"></span>
+      <span id="closet-upload-status" class="muted" role="status" aria-live="polite" aria-atomic="true"></span>
     </form>
     <div id="closet-grid" class="grid"></div>
   </section>
diff --git a/public/js/app.js b/public/js/app.js
index 6db78ab..b3cdf3d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -893,8 +893,15 @@ function startBulkProgress(ids) {
     (status?.parentNode || $('#closet-form')).insertBefore(host, status?.nextSibling || null);
   }
   host.hidden = false;
+  host.classList.remove('is-dismissed');
   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>`;
+                   `<span class="dot-progress-label" id="bulk-progress-label">0 / ${ids.length} tagged</span>` +
+                   `<button type="button" class="bulk-progress-close" id="bulk-progress-close" aria-label="Dismiss progress strip">×</button>`;
+  $('#bulk-progress-close')?.addEventListener('click', () => {
+    if (bulkProgressTimer) { clearInterval(bulkProgressTimer); bulkProgressTimer = null; }
+    host.classList.add('is-dismissed');
+    setTimeout(() => { host.hidden = true; }, 250);
+  });
   const idSet = new Set(ids);
   async function tick() {
     try {

← 636d4e8 yolo tick 28: brands sustainability filter pills (All/♻5/♻4/  ·  back to Whatsmystyle  ·  yolo tick 30: recs grid server-side sort (best/sustain/newes 6515824 →