[object Object]

← back to New Import Viewer

auto-save: 2026-07-02T17:51:29 (2 files) — public/index.html server.js

e34ec3125c59018152af10db1203346bcb0c5509 · 2026-07-02 17:51:37 -0700 · Steve Abrams

Files touched

Diff

commit e34ec3125c59018152af10db1203346bcb0c5509
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 17:51:37 2026 -0700

    auto-save: 2026-07-02T17:51:29 (2 files) — public/index.html server.js
---
 public/index.html | 465 ++++++++++++++++++++++++++++++------------------------
 server.js         | 118 +++++++++++++-
 2 files changed, 370 insertions(+), 213 deletions(-)

diff --git a/public/index.html b/public/index.html
index d11b846..c4ce7a2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,68 +2,98 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Catalog · Shopify state · dw_unified</title>
 <style>
-:root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#181b24;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
  --new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--del:#6b7280;--ship:#60a5fa}
 *{box-sizing:border-box}
 body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
 header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
- border-bottom:1px solid var(--line);padding:12px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+ border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
 h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
 .pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
 .muted{color:var(--mut)}
-select,input[type=text],input[type=number]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 9px;font-size:13px}
-input[type=text]{min-width:180px}
-input[type=number]{width:78px}
+select,input[type=text],input[type=number]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
+input[type=number]{width:70px}
 .ctl{display:flex;align-items:center;gap:7px}
 input[type=range]{accent-color:var(--acc)}
-/* Live-writes arm toggle — defaults OFF every page load (per-session). OFF = safe
-   grey; ARMED = red with pulsing dot. Only shown when the .env ceiling permits. */
+.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
+.btn:hover{border-color:var(--acc)}
+/* Live-writes arm toggle — defaults OFF every page load (per-session). */
 .armtoggle{font-size:12px;font-weight:800;letter-spacing:.3px;border-radius:6px;padding:4px 11px;cursor:pointer;
  border:1px solid var(--line);background:var(--panel);color:var(--mut);display:inline-flex;align-items:center;gap:6px}
 .armtoggle:hover{border-color:var(--mut)}
 body.armed .armtoggle{background:var(--arch);color:#2a0606;border-color:#fca5a5;box-shadow:0 0 0 2px #f8717155}
 body.armed .armtoggle::before{content:'';width:8px;height:8px;border-radius:50%;background:#2a0606;animation:livepulse 1.6s ease-in-out infinite}
 @keyframes livepulse{0%,100%{opacity:1}50%{opacity:.25}}
-/* armed top strip — unmissable peripheral signal that live writes are on */
 #livestrip{display:none;position:fixed;top:0;left:0;right:0;height:3px;background:var(--arch);z-index:30;box-shadow:0 0 8px #f87171}
 body.armed #livestrip{display:block}
-.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:16px}
-.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
+/* ── left panel: search + field tables, ALL collapsed on load ── */
+#layout{display:flex;align-items:flex-start}
+aside{position:sticky;width:236px;min-width:236px;max-height:calc(100vh - 52px);overflow:auto;
+ padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
+aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
+aside details{border-bottom:1px solid var(--line)}
+aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
+ font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
+aside summary::-webkit-details-marker{display:none}
+aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
+aside details[open] summary::before{transform:rotate(90deg)}
+aside summary:hover{color:var(--ink)}
+aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
+ max-width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.fbody{padding:2px 0 8px}
+.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
+ cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
+.frow:hover{background:var(--panel);color:var(--ink)}
+.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
+.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
+.frow .imgpct{font-size:9.5px;color:var(--pub);font-weight:700;flex:none}
+.frow .imgpct.warn{color:var(--new)}
+.frow .sdot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
+.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
+.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
+.fnote{padding:4px 8px;font-size:10.5px;color:var(--new)}
+#clearf{display:none;margin-top:10px;width:100%}
+main{flex:1;min-width:0}
+/* mobile: hamburger + full-screen filter drawer */
+#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
+#fclose{display:none}
+@media(max-width:880px){
+ #layout{display:block}
+ header{padding:8px 10px;gap:8px}
+ #burger{display:inline-flex;align-items:center;gap:6px}
+ .ctl:has(#density){display:none}
+ aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
+  overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
+ aside.open-mobile{display:block}
+ #fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
+ .grid:not(.list){grid-template-columns:repeat(2,1fr)}
+}
+/* ── grid — tight, 1px stroke; text scales with density via --fs ── */
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
+.card:hover{border-color:#414a5e}
 .card.sel{outline:2px solid var(--acc);outline-offset:-2px}
 .imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
 .imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center 12%;display:block}
 .imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
 .imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
-#vbreak{margin:0 14px 6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:9px 11px}
-.vb-head{font-size:12px;color:var(--mut);margin-bottom:7px}
-.vb-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:5px}
-.vb-row{display:grid;grid-template-columns:1fr auto 46px 38px;align-items:center;gap:7px;background:var(--card);
- border:1px solid var(--line);border-radius:7px;padding:5px 9px;cursor:pointer;color:var(--ink);font:inherit;text-align:left}
-.vb-row:hover{border-color:var(--acc)}.vb-row.sel{border-color:var(--acc);background:#1a2330}
-.vb-v{font-size:11.5px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.vb-n{font-size:13px;font-weight:800;font-variant-numeric:tabular-nums}
-.vb-bar{height:5px;background:#0a0b0f;border-radius:3px;overflow:hidden}.vb-bar i{display:block;height:100%;background:var(--pub)}
-.vb-img{font-size:9.5px;color:var(--pub);font-weight:700;text-align:right}.vb-img.warn{color:var(--new)}
-/* catalog-health strip — one colored count chip per state; click to filter */
-#health{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line);background:#0e0f15}
-#health .hc{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;border-radius:999px;padding:3px 11px;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--mut)}
-#health .hc .n{font-variant-numeric:tabular-nums}
-#health .hc:hover{border-color:var(--acc)}
-#health .hc.active{outline:2px solid var(--acc);outline-offset:1px}
-#health .hc.zero{opacity:.4}
-#health .hc.warn{background:#2a1c05;border-color:var(--new);color:var(--new)}
-#health .hc.bad{background:#2a0606;border-color:var(--arch);color:#fca5a5}
-#health .hlabel{font-size:10.5px;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;margin-right:2px}
-.card .b{padding:9px 10px;display:flex;flex-direction:column;gap:6px}
-.vend{font-size:10.5px;color:var(--mut);font-weight:600;letter-spacing:.2px}
-.ttl{font-weight:600;font-size:14px;line-height:1.25;letter-spacing:-.1px}
-.sub{color:var(--mut);font-size:12px}
-.sku{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut)}
+.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
+.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px}
+.sub{color:var(--mut);font-size:calc(11px*var(--fs))}
+.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
 .pick{position:absolute;top:7px;left:7px;z-index:3;background:#000a;border-radius:6px;padding:3px 4px;display:flex;cursor:pointer}
 .pick input{width:17px;height:17px;accent-color:var(--acc);cursor:pointer;margin:0}
+/* field visibility toggles (grid view only — list view columns stay intact) */
+body.f-off-vend .grid:not(.list) .vend{display:none}
+body.f-off-ttl .grid:not(.list) .ttl{display:none}
+body.f-off-when .grid:not(.list) .sub.when{display:none}
+body.f-off-chips .grid:not(.list) .card>.b>.chips{display:none}
+body.imgonly .grid:not(.list) .card .b{display:none}
 /* ── status chip (always visible) + chip row ── */
 .chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
-.chip{font-size:10.5px;font-weight:700;border-radius:999px;padding:2px 9px;letter-spacing:.3px;white-space:nowrap;border:1px solid transparent}
+.chip{font-size:calc(10.5px*var(--fs));font-weight:700;border-radius:999px;padding:2px calc(9px*var(--fs));letter-spacing:.3px;white-space:nowrap;border:1px solid transparent}
 .chip.st-NEW{background:var(--new);color:#1a1205}
 .chip.st-PUBLISHED{background:var(--pub);color:#04211a}
 .chip.st-UNPUBLISHED{background:var(--unpub);color:#11151c}
@@ -72,13 +102,10 @@ body.armed #livestrip{display:block}
 .chip.st-ON_SHOPIFY{background:var(--ship);color:#04122a}
 .chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
 .chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
-/* ── collapsed detail drawer ── */
-/* Two independent drawers: ⓘ details (read-only) and ⚙ actions (write surface).
-   Browsing metadata can never reveal a live-write button — separate triggers. */
-.info,.actpane{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);margin-top:2px;padding-top:8px}
+/* ── collapsed drawers: ⓘ details (read-only) and ⚙ actions (write surface) ── */
+.info,.actpane{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
 .card.info-open .info{display:flex}
 .card.acts-open .actpane{display:flex}
-/* the ⚙ actions chip — distinct from the safe info chip; turns red when armed */
 .chip.act-toggle{border-color:#3a4150}
 body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 .kv{display:grid;grid-template-columns:104px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
@@ -92,8 +119,6 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 .act.pub{border-color:var(--pub);color:var(--pub)}
 .act.unpub{border-color:var(--unpub);color:var(--unpub)}
 .act.arch{border-color:var(--arch);color:var(--arch)}
-/* a button that will fire a LIVE store write — danger fill, can't be confused
-   with a staged action. Only rendered when its action is armed. */
 .act.live{background:#7f1d1d;border-color:#fca5a5;color:#fff;font-weight:800;box-shadow:0 0 0 1px #f8717155}
 .act.live:hover{background:#991b1b;border-color:#fff}
 .acts.haslive{border-left:2px solid var(--arch);padding-left:8px;margin-left:-2px}
@@ -101,36 +126,23 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 .actmsg{font-size:11px;color:var(--mut);min-height:14px}
 #more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
 #empty{padding:60px;text-align:center;color:var(--mut)}
-.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
-.btn:hover{border-color:var(--acc)}
 #selbar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:#11131bF2;backdrop-filter:blur(10px);
  border-top:1px solid var(--acc);padding:11px 16px;display:none;align-items:center;gap:12px;flex-wrap:wrap}
 #selbar.on{display:flex}
 #selbar .n{font-weight:700;color:var(--acc)}
 #bulkgo{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
 #bulkgo:disabled{opacity:.4;cursor:default}
-/* ── List view ── aligned CSS-grid rows. Each .card is itself a grid with a
-   shared column template; .b uses display:contents so vendor/title/chips/
-   list-cells become columns of the SAME card grid → every row lines up.
-   Columns: checkbox · 42px thumb · vendor · pattern+color · status chip ·
-   SKU · 🕓 crawl date+time (full, left-aligned). The .details drawer is a
-   full-width row spanning all columns, dropping below when the card is open.
-   Compact ~52px rows. List-only SKU + crawl cells (.lsku/.lcrawl) are hidden
-   in grid view and shown only here. */
+/* ── List view ── aligned CSS-grid rows; .b uses display:contents. Fixed type
+   sizes (single column — density/font scaling is a grid-view concern). */
 .grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px 60px}
-/* ONE shared column template for list rows AND the sticky sort-header row —
-   header labels stay aligned with row cells by construction. Columns:
-   ☑ · thumb · vendor · pattern · color · status · vendor SKU · DW SKU ·
-   collection · type · sync · shopify id · first seen · last crawl */
 .grid.list .card,body.listview #listhead{
   grid-template-columns:26px 42px 100px minmax(120px,1.4fr) 80px 128px 105px 85px minmax(80px,0.9fr) 80px 70px 80px 92px 92px;
   column-gap:10px;
 }
 .grid.list .card{
-  display:grid;align-items:center;border-radius:8px;
+  display:grid;align-items:center;border-radius:2px;
   min-height:52px;padding:0 12px 0 8px;
 }
-/* sticky sortable column-header row (list view only; JS pins top under header) */
 #listhead{display:none;position:sticky;z-index:8;align-items:center;margin:12px 16px -8px;
  padding:6px 12px 6px 8px;background:#0e0f15;border:1px solid var(--line);border-radius:8px;
  font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(--mut)}
@@ -139,60 +151,46 @@ body.listview #listhead{display:grid}
 #listhead .lh:hover{color:var(--ink)}
 #listhead .lh.on{color:var(--acc)}
 .grid.list .card>.b{display:contents}
-.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:6px;overflow:hidden;margin:5px 0}
+.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
 .grid.list .imgwrap img{object-position:center}
 .grid.list .imgwrap.noimg::after{font-size:8px}
 .grid.list .pick{position:static;background:none;padding:0;justify-content:center}
 .grid.list .vend{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
 .grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
+.grid.list .chip{font-size:10px;padding:1px 8px}
 .grid.list .chips .chip.toggle{font-size:9.5px;padding:2px 7px}
 .grid.list .lcell{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;justify-self:start;text-align:left}
 .grid.list .lsku,.grid.list .ldw,.grid.list .lshopid{font-family:ui-monospace,Menlo,monospace}
 .grid.list .info,.grid.list .actpane{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
-/* list-only cells: hidden in the default card grid, shown only in list view */
 .lcell{display:none}
 .grid.list .lcell{display:block}
-/* grid-card 🕓 chip duplicates the list view's lcrawl column — hide it there */
-.sub.when{font-size:11px}
+.sub.when{font-size:calc(11px*var(--fs))}
 .grid.list .sub.when{display:none}
 </style></head>
 <body>
 <div id="livestrip" title="Live Shopify writes are armed"></div>
 <header>
   <h1>Catalog · Shopify state</h1>
+  <button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
   <span class="pill" id="total">…</span>
   <span class="muted" id="meta"></span>
-  <div class="ctl"><label class="muted">Status</label>
-    <select id="status">
-      <option value="netnew">✨ NET NEW (not on Shopify)</option>
-      <option value="new">🟠 New — flagged (incl. dupes)</option>
-      <option value="onshopify">🔵 On Shopify (all)</option>
-      <option value="published">🟢 Published</option>
-      <option value="unpublished">⚪ Unpublished</option>
-      <option value="archived">🔴 Archived</option>
-      <option value="noimage">🚫 No image</option>
-      <option value="all">▦ All</option>
-    </select></div>
-  <div class="ctl"><label class="muted">Vendor</label>
-    <select id="vendor"><option value="">All vendors</option></select></div>
-  <div class="ctl"><input type="text" id="q" placeholder="search pattern / sku / color…"></div>
   <div class="ctl"><label class="muted">Sort</label>
     <select id="sort">
       <option value="newest">Newest crawl</option>
       <option value="oldest">Oldest crawl</option>
       <option value="firstseen">First seen</option>
-      <option value="vendor">Vendor</option>
+      <option value="colorgroup">Color (group)</option>
+      <option value="color">Color name A→Z</option>
+      <option value="style">Style</option>
+      <option value="pattern">Pattern / Title A→Z</option>
       <option value="sku">SKU A→Z</option>
-      <option value="pattern">Pattern A→Z</option>
-      <option value="color">Color A→Z</option>
+      <option value="dwsku">DW SKU A→Z</option>
+      <option value="vendor">Vendor</option>
     </select></div>
   <div class="ctl"><label class="muted">Density</label>
     <input type="range" id="density" min="3" max="20" step="1"></div>
   <button class="btn" id="view">▤ List view</button>
-  <!-- select 1 → all: first-N (number input), all loaded cards, or the ENTIRE
-       filtered set (ids fetched server-side — selection isn't limited to what's
-       rendered). Bulk actions on big selections chunk through the 5k API cap. -->
   <div class="ctl"><label class="muted">Select</label>
     <input type="number" id="selcount" min="1" step="1" value="1" title="how many to select (first N in current sort)">
     <button class="btn" id="selN" title="select the first N matching the current filter">☑ first N</button>
@@ -201,14 +199,31 @@ body.listview #listhead{display:grid}
   </div>
   <button class="armtoggle" id="armtoggle" style="display:none" onclick="toggleArm()">🔒 Live writes: OFF</button>
 </header>
-<!-- catalog-health strip: threshold-colored, clickable quick-filters (D) -->
-<div id="health" aria-label="catalog health"></div>
-<div id="vbreak" style="display:none"></div>
-<!-- list-view sortable column headers — same grid template as .grid.list .card -->
-<div id="listhead" aria-label="list columns"></div>
-<div class="grid" id="grid"></div>
-<button id="more" style="display:none">Load more</button>
-<div id="empty" style="display:none">No products match.</div>
+<div id="layout">
+<aside aria-label="filters">
+  <button id="fclose" type="button">✕ Done — show products</button>
+  <input type="text" id="q" placeholder="Search pattern / sku / color…">
+  <input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern matching the current view (server-backed)">
+  <datalist id="patternlist"></datalist>
+  <details id="fieldsbox"><summary>Card fields</summary>
+    <div class="fbody">
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Vendor</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Pattern / color</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="when" checked>🕓 Crawl date</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · status</span></label>
+    </div>
+  </details>
+  <div id="statusbox"><!-- Shopify-state table (counts from /api/stats) --></div>
+  <div id="facetbox"><!-- one collapsed <details> table per data field --></div>
+  <button class="btn" id="clearf">✕ Clear filters</button>
+</aside>
+<main>
+  <div id="listhead" aria-label="list columns"></div>
+  <div class="grid" id="grid"></div>
+  <button id="more" style="display:none">Load more</button>
+  <div id="empty" style="display:none">No products match.</div>
+</main>
+</div>
 <div id="selbar">
   <span class="n" id="seln">0 selected</span>
   <button class="btn" id="clearsel">clear</button>
@@ -228,21 +243,36 @@ const $=s=>document.querySelector(s);
 const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false,live:false,liveActions:[],store:''};
 const sel=new Set();
 const STATUS_LABEL={NEW:'NEW',PUBLISHED:'PUBLISHED',UNPUBLISHED:'UNPUBLISHED',ARCHIVED:'ARCHIVED',DELETED:'DELETED',ON_SHOPIFY:'ON SHOPIFY'};
-// Resulting status after a live action lands (optimistic; live path returns truth).
 const ACTION_RESULT={publish:'PUBLISHED',unpublish:'UNPUBLISHED',archive:'ARCHIVED'};
-// ── List-view sortable column headers ──────────────────────────────────────
-// One entry per grid column (same order as the shared grid-template-columns).
-// ''-keyed entries are non-sortable spacers (checkbox, thumbnail). Every key
-// maps to a SERVER sort (asc/desc pair in server.js SORTS) — header clicks
-// re-query, they never client-sort the loaded page (pagination would lie).
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+// ── left-panel field tables: server-computed facets (291k rows — the grid is
+// paginated, so counts come from /api/facets, filters re-query the server) ──
+const FIELDS=[
+ ['vendor','Vendors'],['style','Styles'],['color','Colors'],['colorway','Colorways'],
+ ['collection','Collections'],['ptype','Type'],['width','Width'],['match','Match'],['sync','Sync status'],
+];
+const FIL={};            // field -> active value ('' = off)
+FIELDS.forEach(([k])=>FIL[k]='');
+let FACETS={};           // field -> [[value,count(,withImg)]…] from /api/facets
+const FCAP=40;           // rows shown per table (top-by-count)
+// Shopify-state quick-filter table (replaces the old header select + health strip)
+const STATUS_DEFS=[
+ ['netnew','✨ NET NEW (not on Shopify)','#5eead4'],['new','🟠 New — flagged (incl. dupes)','#f59e0b'],
+ ['onshopify','🔵 On Shopify (all)','#60a5fa'],['published','🟢 Published','#34d399'],
+ ['unpublished','⚪ Unpublished','#94a3b8'],['archived','🔴 Archived','#f87171'],
+ ['noimage','🚫 No image','#f87171'],['all','▦ All','#9aa0b0']];
+let STATUS='netnew';
+// Boot status: restore the saved filter EXCEPT the 🚫 No image diagnostic view
+{const sv=localStorage.getItem('nip.status');
+ if(sv&&sv!=='noimage'&&STATUS_DEFS.some(([k])=>k===sv))STATUS=sv;
+ localStorage.setItem('nip.status',STATUS);}
+// ── List-view sortable column headers (server-side sorts) ──
 const SORTABLE=[
   ['',''],['',''],
   ['vendor','Vendor'],['pattern','Pattern'],['color','Color'],['status','Status'],
   ['sku','Vendor SKU'],['dwsku','DW SKU'],['collection','Collection'],['type','Type'],
   ['sync','Sync'],['shopid','Shopify ID'],['firstseen','First seen'],['crawl','Last crawl']];
-// crawl + firstseen reuse the pre-existing sort keys (newest/oldest/firstseen)
-// so the Sort <select> stays in step; everything else is <key>/<key>_desc.
-const HDR_KEYS={crawl:{asc:'oldest',desc:'newest'},firstseen:{asc:'firstseen_asc',desc:'firstseen'}};
+const HDR_KEYS={crawl:{asc:'oldest',desc:'newest'},firstseen:{asc:'firstseen_asc',desc:'firstseen'},type:{asc:'type',desc:'type_desc'}};
 function sortKeys(k){return HDR_KEYS[k]||{asc:k,desc:k+'_desc'};}
 function renderListHead(){
   const el=$('#listhead'); if(!el)return;
@@ -255,26 +285,24 @@ function renderListHead(){
 }
 function headSort(k){
   const {asc,desc}=sortKeys(k);
-  state.sort=state.sort===asc?desc:asc;   // 1st click asc, 2nd click desc
+  state.sort=state.sort===asc?desc:asc;
   localStorage.setItem('nip.sort',state.sort);
   const so=$('#sort');
   so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'';
   renderListHead(); load(true);
 }
-// keep the sticky column-header row pinned just below the (wrap-height) header
-function pinListHead(){const h=document.querySelector('header');const el=$('#listhead');if(h&&el)el.style.top=h.offsetHeight+'px';}
+function pinListHead(){const h=document.querySelector('header');const el=$('#listhead');if(h&&el)el.style.top=h.offsetHeight+'px';syncAside();}
+function syncAside(){const h=document.querySelector('header').offsetHeight;const a=document.querySelector('aside');a.style.top=h+'px';a.style.maxHeight=`calc(100vh - ${h}px)`;}
 addEventListener('resize',pinListHead);
-// A single-card action fires live ONLY when the .env ceiling allows it (it's in
-// liveActions) AND this session is armed. state.armed defaults false every load.
+// ── live-write arming (unchanged safety model) ──
 const isLive=a=>state.armed&&state.liveActions.includes(a);
-// Per-session arm toggle — defaults OFF on every page load (DTD verdict C).
 function toggleArm(){
   if(!state.armable)return;
   if(!state.armed){
     if(!confirm(`Arm LIVE writes to the REAL store (${state.store}) for THIS session?\n\nWhile armed, single-card ${state.liveActions.join('/')} buttons write to the live storefront (still confirm each). Resets to OFF when you reload.`))return;
     state.armed=true;
   }else state.armed=false;
-  renderArm(); load(true);   // re-render cards so live styling appears/clears
+  renderArm(); load(true);
 }
 function renderArm(){
   const b=$('#armtoggle'); if(!b)return;
@@ -283,9 +311,7 @@ function renderArm(){
   document.body.classList.toggle('armed',state.armed);
   b.textContent=state.armed?`🔴 Live writes: ARMED (${state.liveActions.join('/')})`:'🔒 Live writes: OFF';
 }
-// "nina_campbell" → "Nina Campbell" (de-shout raw vendor codes)
 function fmtVendor(v){return String(v||'').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());}
-// Build the action-button row; live-armed actions get the danger treatment.
 function buildActs(id,st,hasImg){
   const defs=actionsFor(st,hasImg);
   const btns=defs.map(([a,label,cls,dis,why])=>{
@@ -295,8 +321,6 @@ function buildActs(id,st,hasImg){
   const hasLive=defs.some(([a,,,dis])=>!dis&&isLive(a.split(':')[0]));
   return `<div class="acts${hasLive?' haslive':''}">${btns.join('')}</div>`;
 }
-// Copy-to-clipboard for the per-card utility buttons (SKU / DW SKU). Value
-// travels in data-copy so quote-bearing SKUs can't break the onclick string.
 function copyTxt(btn){
   const t=btn.dataset.copy||'';
   navigator.clipboard.writeText(t).then(()=>{
@@ -304,7 +328,6 @@ function copyTxt(btn){
     setTimeout(()=>{btn.textContent=o;},1200);
   });
 }
-// Rewrite a card's status chip + action buttons in place after its state changes.
 function updateCardStatus(id,st){
   const c=grid.querySelector('.card[data-id="'+id+'"]'); if(!c)return;
   c.dataset.st=st;
@@ -316,34 +339,42 @@ function updateCardStatus(id,st){
 function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#bulkgo').disabled=!n;}
 function togglePick(id,cb){if(cb.checked)sel.add(id);else sel.delete(id);cb.closest('.card').classList.toggle('sel',cb.checked);syncSel();}
 function togglePane(el,which){el.closest('.card').classList.toggle(which==='info'?'info-open':'acts-open');}
-// persisted prefs (CLAUDE.md: sort + density persist in localStorage)
-// Boot status: restore the saved filter EXCEPT the 🚫 No image diagnostic view —
-// booting into it renders a wall of placeholder tiles that reads as "all images
-// broken" (the 2026-06-10 'why do no images show' trap). noimage stays click-only
-// per visit. Also fall back when the saved value is no longer a real option.
-{const sv=localStorage.getItem('nip.status');
- const okSv=sv&&sv!=='noimage'&&!!document.querySelector(`#status option[value="${sv}"]`);
- $('#status').value=okSv?sv:'netnew';
- if(!okSv)localStorage.setItem('nip.status',$('#status').value);}
-// Sort is server-side (the grid paginates limit/offset — client-side sorting of
-// loaded rows would be misleading). state.sort is the truth; the header <select>
-// mirrors it when it has a matching option, and list-view column headers can set
-// sort keys the select doesn't carry (dwsku/collection/type/sync/shopid/status…).
+// sort persistence (server-side sort keys)
 state.sort=localStorage.getItem('nip.sort')||'newest';
-{const so=$('#sort');so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'newest';
- if(so.value!==state.sort&&!SORTABLE.some(([k])=>k&&(sortKeys(k).asc===state.sort||sortKeys(k).desc===state.sort))){state.sort='newest';localStorage.setItem('nip.sort','newest');}}
-const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
-document.documentElement.style.setProperty('--cols',dens);
-function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+{const so=$('#sort');so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'';
+ if(!so.value&&!SORTABLE.some(([k])=>k&&(sortKeys(k).asc===state.sort||sortKeys(k).desc===state.sort))){state.sort='newest';so.value='newest';localStorage.setItem('nip.sort','newest');}}
+// ── density also drives type scale: fewer/larger cards → bigger type ──
+function setDensity(v){
+  v=Number(v);
+  const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
+  document.documentElement.style.setProperty('--cols',v);
+  document.documentElement.style.setProperty('--fs',fs.toFixed(3));
+}
+const dens=localStorage.getItem('nip.density')||'6';
+$('#density').value=dens; setDensity(dens);
+$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('nip.density',$('#density').value);};
+// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
+const asideEl=document.querySelector('aside');
+$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
+$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
+window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
+  if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
+});
+// ── card-field visibility toggles (image-only when all off) ──
+const FVIS=JSON.parse(localStorage.getItem('nip.fieldvis')||'{"vend":true,"ttl":true,"when":true,"chips":true}');
+function applyFieldVis(){
+  for(const k of ['vend','ttl','when','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
+  document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.when&&!FVIS.chips);
+  document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
+  localStorage.setItem('nip.fieldvis',JSON.stringify(FVIS));
+}
+document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
+  cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
+});
+applyFieldVis();
 const SHOP='https://admin.shopify.com/store/designer-laboratory-sandbox/products/';
-// FULL SUITE — every card renders every action; impossible ones are disabled
-// with the reason in the tooltip instead of hidden. [action, label, class,
-// disabled, why]. Launch is hard-disabled on already-linked rows (staging a
-// launch there would create a DUPLICATE product — the one unforgivable sin).
 function actionsFor(st,hasImg){
   const linked=st!=='NEW';
-  // HARD GATE (Steve 2026-06-10): all products must have images — a no-image
-  // row can never be launched (server blocks too; this is the honest UI).
   const launchDis=linked||!hasImg;
   const launchWhy=linked?'already linked to Shopify — launching again would create a duplicate'
                         :'no image — ALL PRODUCTS MUST HAVE IMAGES (fix the scrape first)';
@@ -355,6 +386,8 @@ function actionsFor(st,hasImg){
     ['archive:','🔴 Archive','arch',!linked||st==='ARCHIVED',!linked?'not on Shopify yet — stage a launch first':'already archived'],
   ];
 }
+// NOTE: cards are never wrapped in <a> (adoption-agency DOM split). This viewer
+// has no PDP — the delegated grid click below toggles the ⓘ details drawer.
 function card(p){
   const title=esc(p.pattern||p.sku||'(untitled)')+(p.color?(', '+esc(p.color)):'');
   const img=p.image?`<div class="imgwrap"><img loading="lazy" src="/img?u=${encodeURIComponent(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
@@ -367,7 +400,6 @@ function card(p){
   const vendLink=(p.url&&/^https?:\/\//i.test(p.url))?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:'';
   const storeLink=p.handle?`<a class="lnk" href="https://www.designerwallcoverings.com/products/${esc(p.handle)}" target="_blank" rel="noopener noreferrer">storefront ↗</a>`:'';
   const acts=buildActs(p.id,st,!!p.image);
-  // utility row — copy SKUs + every outbound link, always present in ⚙ actions
   const utils=`<div class="acts">
     ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
     ${p.dwSku?`<button class="act" data-copy="${esc(p.dwSku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
@@ -395,7 +427,9 @@ function card(p){
     <span class="lcell lcrawl" title="Last crawl: ${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
     <div class="info">
       ${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
-      ${kv('Type',p.type)}${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
+      ${kv('Type',p.type)}${kv('Style',p.style)}${kv('Color group',p.colorGroup)}
+      ${kv('Width',p.width)}${kv('Match',p.match)}
+      ${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
       ${kv('First seen',p.firstSeen)}${kv('Last crawl',p.crawled)}${kv('Shopify synced',p.shopSynced)}
       <div class="chips">${vendLink}${shopLink}</div>
     </div>
@@ -406,95 +440,113 @@ function card(p){
     </div>
   </div></div>`;
 }
+// delegated card interaction — clicking anywhere non-interactive on the card
+// toggles the ⓘ details drawer (never <a>-wrap the card)
+grid.addEventListener('click',e=>{
+  if(e.target.closest('.pick,.chip,.info,.actpane,a,button,input,label'))return;
+  const c=e.target.closest('.card');
+  if(c)c.classList.toggle('info-open');
+});
+function filsParams(p){
+  for(const [k] of FIELDS)if(FIL[k])p.set(k,FIL[k]);
+  const pat=$('#pattern').value.trim(); if(pat)p.set('pattern',pat);
+  return p;
+}
 async function load(reset){
   if(state.loading) return; state.loading=true;
   if(reset){ state.offset=0; grid.innerHTML=''; }
-  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:state.sort,q:$('#q').value,limit:state.limit,offset:state.offset});
+  const p=filsParams(new URLSearchParams({status:STATUS,sort:state.sort,q:$('#q').value,limit:state.limit,offset:state.offset}));
   // location.origin prefix: pages opened via user:pass@ URLs break RELATIVE
   // fetches in WebKit/Safari — absolute same-origin URLs keep creds working.
   const d=await (await fetch(location.origin+'/api/new-products?'+p)).json();
   state.count=d.count; state.offset+=d.items.length;
   grid.insertAdjacentHTML('beforeend', d.items.map(card).join(''));
+  $('#total').textContent=d.count.toLocaleString()+' '+STATUS;
   $('#meta').textContent=`${state.offset.toLocaleString()} of ${d.count.toLocaleString()} shown`;
   $('#empty').style.display=d.count?'none':'block';
   $('#more').style.display=state.offset<d.count?'block':'none';
   state.loading=false;
 }
-async function reloadVendors(){
-  const vs=await (await fetch(location.origin+'/api/vendors?status='+$('#status').value)).json();
-  window.__vendors=vs;
-  $('#vendor').innerHTML='<option value="">All vendors</option>'+vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join('');
-  renderVendorBreak(vs);
+async function loadFacets(){
+  try{FACETS=await (await fetch(location.origin+'/api/facets?status='+STATUS)).json();}
+  catch(e){FACETS={};}
+  renderSide();
 }
-// Per-vendor breakdown for the selected status (esp. NET NEW). Click a row to
-// filter the grid to that vendor. Shows image coverage so you see which vendors
-// have importable rows WITH images.
-function renderVendorBreak(vs){
-  const el=$('#vbreak'); if(!el)return;
-  const cur=$('#status').value;
-  if(cur!=='netnew' && cur!=='new'){ el.style.display='none'; return; }
-  el.style.display='block';
-  const tot=vs.reduce((a,v)=>a+v.count,0), totImg=vs.reduce((a,v)=>a+(v.withImg||0),0);
-  const label = cur==='netnew' ? '✨ NET NEW by vendor' : '🟠 New by vendor';
-  el.innerHTML=`<div class="vb-head">${label} — <b>${tot.toLocaleString()}</b> across <b>${vs.length}</b> vendors · <b>${totImg.toLocaleString()}</b> with image (${tot?Math.round(totImg/tot*100):0}%)</div>`+
-    '<div class="vb-grid">'+vs.map(v=>{
-      const pct=v.count?Math.round((v.withImg||0)/v.count*100):0;
-      const sel=$('#vendor').value===v.vendor?' sel':'';
-      return `<button class="vb-row${sel}" onclick="pickVendor('${esc(v.vendor)}')" title="${v.withImg||0} with image / ${v.noImg||0} missing">
-        <span class="vb-v">${esc(v.vendor)}</span>
-        <span class="vb-n">${v.count.toLocaleString()}</span>
-        <span class="vb-bar"><i style="width:${pct}%"></i></span>
-        <span class="vb-img${pct<100?' warn':''}">${pct}%🖼</span>
-      </button>`;
-    }).join('')+'</div>';
+// ── left panel — Shopify-state table + one collapsed <details> per field ──
+function renderStatusBox(){
+  const c=window.__counts||{};
+  const openNow=$('#statusbox details')?$('#statusbox details').open:false;
+  const curLbl=(STATUS_DEFS.find(([k])=>k===STATUS)||[])[1]||STATUS;
+  const rows=STATUS_DEFS.map(([k,lbl,col])=>{
+    const n=c[k]!=null?c[k]:null;
+    return `<div class="frow${STATUS===k?' active':''}" onclick="pickStatus('${k}')">
+      <span class="fl"><span class="sdot" style="background:${col}"></span>${esc(lbl)}</span>
+      <span class="n">${n!=null?n.toLocaleString():''}</span></div>`;
+  }).join('');
+  const dupNote=c.newButOnShopify?`<div class="fnote" title="flagged 'new' but the mfr pattern number is ALREADY on Shopify — excluded from NET NEW">⚠ ${c.newButOnShopify.toLocaleString()} flagged-new already on Shopify (dupes)</div>`:'';
+  $('#statusbox').innerHTML=`<details${openNow?' open':''}><summary>Shopify state<span class="cur" title="${esc(curLbl)}">${esc(curLbl)}</span></summary>
+    <div class="fbody">${rows}${dupNote}</div></details>`;
 }
-function pickVendor(v){ const el=$('#vendor'); el.value=(el.value===v?'':v); el.onchange(); renderVendorBreak(window.__vendors||[]); }
-function setHeaderCount(counts){
-  const cur=$('#status').value;
-  $('#total').textContent=((counts||{})[cur]!=null?counts[cur]:0).toLocaleString()+' '+cur;
+function pickStatus(k){
+  STATUS=k; localStorage.setItem('nip.status',k);
+  renderStatusBox(); loadFacets(); load(true); refreshPatternList();
 }
-async function boot(){
-  const s=await (await fetch(location.origin+'/api/stats')).json();
-  state.armable=!!s.armable; state.liveActions=s.liveActions||[]; state.store=s.store||''; state.armed=false; const c=s.counts||{};
-  setHeaderCount(c);
-  [['netnew','✨ NET NEW (not on Shopify)'],['new','🟠 New — flagged (incl. dupes)'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['noimage','🚫 No image'],['all','▦ All']].forEach(([k,lbl])=>{
-    const o=$('#status').querySelector(`option[value="${k}"]`); if(o&&c[k]!=null) o.textContent=`${lbl} (${c[k].toLocaleString()})`;
-  });
-  renderArm();
-  window.__counts=c; renderHealth(c);
-  pinListHead();   // header height can change once counts/arm render
-  await reloadVendors(); load(true);
+function renderSide(){
+  renderStatusBox();
+  const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
+  $('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
+    const all=FACETS[k]||[];
+    const rows=all.slice(0,FCAP).map(([v,n,wi])=>{
+      const pct=(k==='vendor'&&n)?Math.round((wi||0)/n*100):null;
+      const img=pct!=null?`<span class="imgpct${pct<100?' warn':''}" title="${wi||0} of ${n} rows have an image">${pct}%🖼</span>`:'';
+      return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
+        <span class="fl">${esc(k==='vendor'?fmtVendor(v):v)}</span>${img}<span class="n">${n.toLocaleString()}</span></div>`;
+    }).join('');
+    const more=all.length>FCAP?`<div class="fmore">+ ${(all.length-FCAP).toLocaleString()} more — use search</div>`:'';
+    const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(k==='vendor'?fmtVendor(FIL[k]):FIL[k])}</span>`:'';
+    return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
+  }).join('');
+  $('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value||STATUS!=='netnew')?'block':'none';
 }
-// Threshold-colored catalog-health strip (D). Actionable-bad states (Unpublished,
-// Archived, No image) flag amber/red when non-zero; healthy/neutral stay plain;
-// zero counts dim. Each chip is a one-click quick-filter.
-function renderHealth(c){
-  const el=$('#health'); if(!el)return;
-  const cur=$('#status').value;
-  const defs=[['netnew','✨ NET NEW','ok'],['new','🟠 New(flagged)','warn'],['published','🟢 Published','ok'],['unpublished','⚪ Unpublished','warn'],
-    ['archived','🔴 Archived','bad'],['noimage','🚫 No image','bad'],['onshopify','🔵 On Shopify','ok'],['all','▦ All','ok']];
-  el.innerHTML='<span class="hlabel">catalog health</span>'+defs.map(([k,lbl,sev])=>{
-    const n=(c||{})[k]||0; let cls='hc';
-    if(n===0) cls+=' zero'; else if(sev==='bad') cls+=' bad'; else if(sev==='warn') cls+=' warn';
-    if(k===cur) cls+=' active';
-    return `<span class="${cls}" onclick="pickHealth('${k}')">${lbl} <span class="n">${n.toLocaleString()}</span></span>`;
-  }).join('')+ (c.newButOnShopify? `<span class="hc warn" title="flagged 'new' but the mfr pattern number is ALREADY on Shopify — excluded from NET NEW">⚠ ${c.newButOnShopify.toLocaleString()} flagged-new already on Shopify (dupes)</span>`:'');
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);renderSide();load(true);refreshPatternList();}
+$('#clearf').onclick=()=>{
+  FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';
+  STATUS='netnew';localStorage.setItem('nip.status',STATUS);
+  renderStatusBox();loadFacets();load(true);
+};
+// server-backed pattern type-ahead (126k distinct patterns — datalist refreshes
+// as you type, scoped to the current status + filters)
+let tp;
+async function refreshPatternList(){
+  const qv=$('#pattern').value.trim();
+  const p=filsParams(new URLSearchParams({status:STATUS}));
+  p.delete('pattern'); if(qv)p.set('q',qv);
+  try{
+    const d=await (await fetch(location.origin+'/api/patterns?'+p)).json();
+    $('#patternlist').innerHTML=(d.patterns||[]).map(s=>`<option value="${esc(s)}">`).join('');
+  }catch(e){}
 }
-function pickHealth(k){const s=$('#status'); s.value=k; s.onchange();}
-$('#status').onchange=async()=>{localStorage.setItem('nip.status',$('#status').value);setHeaderCount(window.__counts);renderHealth(window.__counts);await reloadVendors();load(true);};
-$('#vendor').onchange=()=>load(true);
+$('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(()=>{refreshPatternList();renderSide();load(true);},300);};
 $('#sort').onchange=()=>{state.sort=$('#sort').value||'newest';localStorage.setItem('nip.sort',state.sort);renderListHead();load(true);};
-let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300);};
-$('#density').oninput=()=>{document.documentElement.style.setProperty('--cols',$('#density').value);localStorage.setItem('nip.density',$('#density').value);};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{renderSide();load(true);},300);};
 $('#more').onclick=()=>load(false);
 function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listview',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();pinListHead();}
 $('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('nip.view')==='list'?'grid':'list');applyView();};
 applyView();
+async function boot(){
+  const s=await (await fetch(location.origin+'/api/stats')).json();
+  state.armable=!!s.armable; state.liveActions=s.liveActions||[]; state.store=s.store||''; state.armed=false;
+  window.__counts=s.counts||{};
+  renderArm();
+  renderSide();          // status table + (empty) facet tables render immediately
+  pinListHead();
+  loadFacets();          // fills the field tables when the server responds
+  refreshPatternList();
+  load(true);
+}
 $('#selall').onclick=()=>{grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;if(!id)return;sel.add(id);c.classList.add('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=true;});syncSel();};
-// ── select 1 → all ── server-side id fetch so selection isn't bounded by what's
-// rendered. "first N" follows the current sort; "ALL matching" = entire filter.
 async function fetchIds(limit){
-  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:state.sort,q:$('#q').value});
+  const p=filsParams(new URLSearchParams({status:STATUS,sort:state.sort,q:$('#q').value}));
   if(limit)p.set('limit',limit);
   const d=await(await fetch(location.origin+'/api/ids?'+p)).json();
   return d.ids||[];
@@ -541,16 +593,15 @@ async function rowAction(id,spec,btn){
   }catch(e){if(msg)msg.textContent='✗ '+e.message;}
   btn.disabled=false;
 }
-window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm; window.pickHealth=pickHealth; window.headSort=headSort;
+window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm; window.headSort=headSort; window.pickF=pickF; window.pickStatus=pickStatus; window.copyTxt=copyTxt;
 $('#bulkgo').onclick=async()=>{
   const ids=[...sel]; if(!ids.length)return;
   const [action,mode]=$('#bulkact').value.split(':');
   if(!confirm(`${action.toUpperCase()} ${ids.length} product(s)${mode?(' as '+mode.toUpperCase()):''}?\n\nBulk actions ALWAYS stage to the launch queue — bulk never writes live to the store (single-card only).`))return;
   $('#bulkgo').disabled=true; $('#bulkmsg').textContent='staging…';
-  // server caps 5,000 ids per request — chunk so "select ALL matching" (50k+)
-  // stages completely instead of silently truncating at the first 5k.
+  let staged=0;
   try{
-    let staged=0,blockedNoImg=0; const byVendor={};
+    let blockedNoImg=0; const byVendor={};
     for(let i=0;i<ids.length;i+=5000){
       const ch=ids.slice(i,i+5000);
       $('#bulkmsg').textContent=`staging… ${Math.min(i+ch.length,ids.length).toLocaleString()} / ${ids.length.toLocaleString()}`;
diff --git a/server.js b/server.js
index 72548b9..2395587 100644
--- a/server.js
+++ b/server.js
@@ -38,6 +38,12 @@ const ENV = loadEnv();
 // before process.env. (The pm2 God daemon can carry a stale SHOPIFY_LIVE_WRITES
 // in its inherited environment that children pick up; reading the file first
 // makes the on-disk .env the single source of truth, which is how it's edited.)
+// Basic-auth gate (internal-line-viewer spec): admin / DW2024! default,
+// BASIC_AUTH=user:pass env/.env override. Local-only server, but every internal
+// line viewer carries the same gate so pages behave identically (incl. the
+// Safari creds-in-URL fetch quirk the location.origin rule exists for).
+const BASIC_AUTH = ENV.BASIC_AUTH || process.env.BASIC_AUTH || 'admin:DW2024!';
+const AUTH_HDR = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
 const SHOP_STORE = ENV.SHOPIFY_STORE || process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
 const SHOP_TOKEN = ENV.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN || '';
 const SHOP_API = '2024-10';
@@ -243,12 +249,41 @@ const SORTS = {
   // in both /api/new-products and /api/ids, so the fragment is valid in both)
   status:    `${SHOP_STATUS} ASC, vc.id ASC`,
   status_desc:`${SHOP_STATUS} DESC, vc.id DESC`,
+  // Style = first AI style tag; Color (group) = color_primary bucket then colorway
+  style:     "vc.ai_styles->>0 ASC NULLS LAST, vc.pattern_name ASC, vc.id ASC",
+  style_desc:"vc.ai_styles->>0 DESC NULLS LAST, vc.pattern_name DESC, vc.id DESC",
+  colorgroup:"vc.color_primary ASC NULLS LAST, vc.color_name ASC, vc.id ASC",
+  colorgroup_desc:"vc.color_primary DESC NULLS LAST, vc.color_name DESC, vc.id DESC",
 };
 
+// Facet-field → column map (equality filters). Style is jsonb-contains, pattern
+// is a substring match (type-ahead) — both handled separately in buildWhere.
+const FACET_COLS = {
+  vendor: 'vendor_code', color: 'color_primary', colorway: 'color_name',
+  collection: 'collection', ptype: 'product_type', width: 'width',
+  match: 'match_type', sync: 'sync_status',
+};
+// Parse the facet-filter params off a request URL (shared by listing/ids/patterns).
+function filParams(u) {
+  const g = k => (u.searchParams.get(k) || '').slice(0, 120);
+  const fil = {};
+  for (const k of Object.keys(FACET_COLS)) fil[k] = g(k);
+  fil.vendor = fil.vendor.replace(/[^a-z0-9_]/gi, '').slice(0, 64);
+  fil.style = g('style');
+  fil.pattern = g('pattern');
+  return fil;
+}
 // Build the WHERE clause shared by listing + counts.
-function buildWhere({ status, vendor, term }) {
+function buildWhere({ status, vendor, term, fil }) {
   const parts = [STATUS_FILTERS[status] || NEW_PRED];
   if (vendor) parts.push(`vc.vendor_code='${esc(vendor)}'`);
+  if (fil) {
+    for (const [k, col] of Object.entries(FACET_COLS)) {
+      if (fil[k]) parts.push(`vc.${col}='${esc(fil[k])}'`);
+    }
+    if (fil.style) parts.push(`vc.ai_styles @> '${esc(JSON.stringify([fil.style]))}'::jsonb`);
+    if (fil.pattern) parts.push(`vc.pattern_name ILIKE '%${esc(fil.pattern.replace(/[%_]/g, ''))}%'`);
+  }
   if (term) {
     const t = `%${esc(term)}%`;
     parts.push(`(vc.pattern_name ILIKE '${t}' OR vc.mfr_sku ILIKE '${t}' OR vc.color_name ILIKE '${t}' OR vc.dw_sku ILIKE '${t}' OR vc.collection ILIKE '${t}')`);
@@ -256,6 +291,38 @@ function buildWhere({ status, vendor, term }) {
   return 'WHERE ' + parts.join(' AND ');
 }
 
+// ── Facet tables (left panel) ──────────────────────────────────────────────
+// Per-field value+count tables for the CURRENT status view (other filters are
+// intentionally not applied — same convention as /api/vendors, keeps the cache
+// small and the counts stable while drilling). 10-min TTL per status.
+const FACET_CACHE = {};
+async function computeFacets(status) {
+  const where = buildWhere({ status, vendor: '', term: '' });
+  const jobs = {};
+  // vendor rows also carry image coverage — the old #vbreak feature folded in
+  jobs.vendor = qAsync(`SELECT vc.vendor_code, count(*),
+      count(*) FILTER (WHERE vc.image_url IS NOT NULL AND vc.image_url<>'')
+    FROM vendor_catalog vc ${SP_JOIN} ${where}
+    GROUP BY 1 ORDER BY 2 DESC LIMIT 160`)
+    .then(r => r.map(x => [x[0], +x[1], +x[2]])).catch(() => []);
+  for (const [k, col] of Object.entries(FACET_COLS)) {
+    if (k === 'vendor') continue;
+    jobs[k] = qAsync(`SELECT vc.${col}, count(*) FROM vendor_catalog vc ${SP_JOIN} ${where}
+        AND vc.${col} IS NOT NULL AND vc.${col}<>'' GROUP BY 1 ORDER BY 2 DESC LIMIT 60`)
+      .then(r => r.map(x => [x[0], +x[1]])).catch(() => []);
+  }
+  jobs.style = qAsync(`SELECT s.v, count(*) FROM vendor_catalog vc ${SP_JOIN},
+      LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(vc.ai_styles)='array'
+        THEN vc.ai_styles ELSE '[]'::jsonb END) s(v)
+      ${where} GROUP BY 1 ORDER BY 2 DESC LIMIT 60`)
+    .then(r => r.map(x => [x[0], +x[1]])).catch(() => []);
+  const keys = Object.keys(jobs);
+  const vals = await Promise.all(keys.map(k => jobs[k]));
+  const data = {};
+  keys.forEach((k, i) => (data[k] = vals[i]));
+  return data;
+}
+
 function send(res, code, body, type) {
   res.writeHead(code, { 'content-type': type || 'application/json', 'cache-control': 'no-store' });
   res.end(body);
@@ -395,6 +462,11 @@ async function serveImg(u0, res) {
 const server = http.createServer((req, res) => {
   try {
     const u = new URL(req.url, 'http://x');
+    // basic-auth gate on everything (internal-line-viewer spec)
+    if ((req.headers.authorization || '') !== AUTH_HDR) {
+      res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="new-import-viewer"', 'content-type': 'text/plain' });
+      return res.end('auth required');
+    }
     if (u.pathname === '/' || u.pathname === '/index.html') {
       return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
     }
@@ -426,6 +498,36 @@ const server = http.createServer((req, res) => {
         cachedAt: STATS_CACHE.computedAt,
       }));
     }
+    if (u.pathname === '/api/facets') {
+      const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
+      const cached = FACET_CACHE[status];
+      if (cached && Date.now() - cached.at < 600000) return send(res, 200, JSON.stringify(cached.data));
+      (async () => {
+        try {
+          const data = await computeFacets(status);
+          FACET_CACHE[status] = { data, at: Date.now() };
+          send(res, 200, JSON.stringify(data));
+        } catch (e) { send(res, 500, JSON.stringify({ error: e.message })); }
+      })();
+      return;
+    }
+    // Pattern type-ahead — server-backed (126k distinct patterns is too many for
+    // a static datalist); returns up to 200 matches for the current filter.
+    if (u.pathname === '/api/patterns') {
+      const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
+      const fil = filParams(u); fil.pattern = '';
+      const qs = (u.searchParams.get('q') || '').slice(0, 80).replace(/[%_]/g, '');
+      const where = buildWhere({ status, vendor: '', term: '', fil });
+      const extra = qs ? ` AND vc.pattern_name ILIKE '%${esc(qs)}%'` : '';
+      (async () => {
+        try {
+          const rows = await qAsync(`SELECT DISTINCT vc.pattern_name FROM vendor_catalog vc ${SP_JOIN} ${where}${extra}
+            AND vc.pattern_name IS NOT NULL AND vc.pattern_name<>'' ORDER BY 1 LIMIT 200`);
+          send(res, 200, JSON.stringify({ patterns: rows.map(r => r[0]) }));
+        } catch (e) { send(res, 500, JSON.stringify({ error: e.message })); }
+      })();
+      return;
+    }
     if (u.pathname === '/api/vendors') {
       const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
       const where = buildWhere({ status, vendor: '', term: '' });
@@ -442,19 +544,20 @@ const server = http.createServer((req, res) => {
     }
     if (u.pathname === '/api/new-products') {
       const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
-      const vendor = (u.searchParams.get('vendor') || '').replace(/[^a-z0-9_]/gi, '').slice(0, 64);
+      const fil = filParams(u);
       const sort = SORTS[u.searchParams.get('sort')] ? u.searchParams.get('sort') : 'newest';
       const term = (u.searchParams.get('q') || '').slice(0, 80);
       const limit = Math.min(parseInt(u.searchParams.get('limit'), 10) || 120, 500);
       const offset = Math.max(parseInt(u.searchParams.get('offset'), 10) || 0, 0);
-      const where = buildWhere({ status, vendor, term });
+      const where = buildWhere({ status, vendor: '', term, fil });
       const rows = q(`SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, vc.color_name, vc.collection,
                              vc.product_type, vc.image_url, vc.product_url,
                              to_char(vc.first_seen_at,'YYYY-MM-DD HH24:MI') AS first_seen,
                              to_char(vc.last_scraped_at,'YYYY-MM-DD HH24:MI') AS crawled,
                              vc.sync_status, ${SHOP_STATUS} AS shop_status,
                              vc.shopify_product_id, sp.handle,
-                             to_char(vc.shopify_synced_at,'YYYY-MM-DD HH24:MI') AS shop_synced
+                             to_char(vc.shopify_synced_at,'YYYY-MM-DD HH24:MI') AS shop_synced,
+                             vc.color_primary, vc.width, vc.match_type, vc.ai_styles->>0 AS style1
                       FROM vendor_catalog vc ${SP_JOIN} ${where}
                       ORDER BY ${SORTS[sort]} LIMIT ${limit} OFFSET ${offset}`);
       const cnt = +(q(`SELECT count(*) FROM vendor_catalog vc ${SP_JOIN} ${where}`)[0] || [0])[0];
@@ -463,6 +566,7 @@ const server = http.createServer((req, res) => {
         collection: r[6], type: r[7], image: r[8], url: r[9],
         firstSeen: r[10], crawled: r[11], syncStatus: r[12], shopStatus: r[13],
         shopifyId: r[14] || null, handle: r[15] || null, shopSynced: r[16] || null,
+        colorGroup: r[17] || null, width: r[18] || null, match: r[19] || null, style: r[20] || null,
       }));
       return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
     }
@@ -472,11 +576,11 @@ const server = http.createServer((req, res) => {
     // 197k catalog is a small payload. limit=0/absent → all (hard cap 250k).
     if (u.pathname === '/api/ids') {
       const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
-      const vendor = (u.searchParams.get('vendor') || '').replace(/[^a-z0-9_]/gi, '').slice(0, 64);
+      const fil = filParams(u);
       const sort = SORTS[u.searchParams.get('sort')] ? u.searchParams.get('sort') : 'newest';
       const term = (u.searchParams.get('q') || '').slice(0, 80);
       const limit = Math.min(Math.max(parseInt(u.searchParams.get('limit'), 10) || 0, 0), 250000);
-      const where = buildWhere({ status, vendor, term });
+      const where = buildWhere({ status, vendor: '', term, fil });
       const rows = q(`SELECT vc.id FROM vendor_catalog vc ${SP_JOIN} ${where}
                       ORDER BY ${SORTS[sort]} LIMIT ${limit || 250000}`);
       return send(res, 200, JSON.stringify({ ids: rows.map(r => +r[0]) }));
@@ -614,4 +718,6 @@ const server = http.createServer((req, res) => {
 server.listen(PORT, '127.0.0.1', () => {
   console.log(`[new-import-viewer] http://127.0.0.1:${PORT}  · store=${SHOP_STORE} · live-actions=[${[...LIVE_ACTIONS].join(',') || 'none'}]${SHOP_TOKEN ? '' : ' (NO TOKEN)'}`);
   refreshStats(); // warm the stats cache on boot so the first /api/stats hit is fast
+  // warm the default-view facet tables too (netnew group-bys are the slowest)
+  computeFacets('netnew').then(d => (FACET_CACHE.netnew = { data: d, at: Date.now() })).catch(() => {});
 });

← f65d9a6 Grid UI upgrade: sortable list-view column headers (server-s  ·  back to New Import Viewer  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 7582d03 →