[object Object]

← back to Greenland China

Retrofit viewer to internal-line-viewer spec (astek 9944 pattern): left-panel collapsed field tables (10 facets, click=filter+teal badge), search+pattern type-ahead, card-field toggles to image-only wall, density 3-20 drives --cols+--fs type scale, tight 1px cards, data-href delegated clicks (no <a>-wrapped cards), grid/list with 11 sortable columns, location.origin fetches, greenland.* localStorage; new read-only /api/rows; E2E-verified chromium+webkit (0 console errors)

c53495747aabc3de1f0831b9545925b2e7ad1306 · 2026-07-02 17:26:42 -0700 · Steve

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit c53495747aabc3de1f0831b9545925b2e7ad1306
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 17:26:42 2026 -0700

    Retrofit viewer to internal-line-viewer spec (astek 9944 pattern): left-panel collapsed field tables (10 facets, click=filter+teal badge), search+pattern type-ahead, card-field toggles to image-only wall, density 3-20 drives --cols+--fs type scale, tight 1px cards, data-href delegated clicks (no <a>-wrapped cards), grid/list with 11 sortable columns, location.origin fetches, greenland.* localStorage; new read-only /api/rows; E2E-verified chromium+webkit (0 console errors)
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 viewer/public/index.html | 534 +++++++++++++++++++++++++++++++++--------------
 viewer/server.js         |  12 +-
 2 files changed, 389 insertions(+), 157 deletions(-)

diff --git a/viewer/public/index.html b/viewer/public/index.html
index 7cb4483..ee2da30 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -1,169 +1,391 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
+<!doctype html><html lang="en"><head><meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Greenland Wallcoverings — Staged SKUs (offline preview)</title>
+<title>Greenland Wallcoverings · Staged SKUs · internal viewer</title>
 <style>
-  :root { --cols: 6; --bg:#0d0f0d; --card:#161b16; --line:#2a302a; --txt:#e8eae8; --mut:#9aa29a; --accent:#7fb069; }
-  * { box-sizing: border-box; }
-  body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
-  header { position:sticky; top:0; z-index:10; background:rgba(13,15,13,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; }
-  h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
-  h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
-  .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
-  select, input[type=search] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
-  input[type=search] { min-width:220px; }
-  .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
-  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
-  .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
-  .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
-  .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
-  .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; background:#2f7d4f; color:#fff; }
-  .badge.imgs { position:static; background:#2a302a; color:#9aa29a; font-size:9px; padding:1px 5px; margin-left:5px; border-radius:10px; vertical-align:middle; text-transform:none; }
-  .meta { padding:8px 10px; }
-  .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
-  .dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; vertical-align:middle; border:1px solid rgba(255,255,255,.25); }
-  .ttl { color:var(--txt); font-size:12px; margin-top:3px; max-height:2.8em; overflow:hidden; }
-  .color { color:var(--mut); font-size:11px; margin-top:2px; }
-  .cat { margin-top:4px; }
-  .cat span { display:inline-block; background:#1e241e; color:#9aa29a; font-size:9.5px; padding:1px 6px; border-radius:10px; margin-right:4px; margin-top:2px; }
-  .spec { margin-top:5px; font-size:11px; color:#777; display:flex; gap:8px; flex-wrap:wrap; }
-  #sentinel { height:1px; }
-  #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
-</style>
-</head>
+:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+ --new:#f59e0b;--pub:#34d399}
+*{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: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]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
+.ctl{display:flex;align-items:center;gap:7px}
+input[type=range]{accent-color:var(--acc)}
+.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)}
+/* ── left panel: filters + field tables, ALL collapsed on load ── */
+#layout{display:flex;align-items:flex-start}
+aside{position:sticky;width:230px;min-width:230px;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:100px;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 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}
+#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 edges; 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}
+.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
+.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center;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}
+.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;font-family:ui-monospace,Menlo,monospace}
+/* 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-chips .grid:not(.list) .chips{display:none}
+body.imgonly .grid:not(.list) .card .b{display:none}
+/* ── chips (minimal) ── */
+.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
+.chip{font-size:calc(10px*var(--fs));font-weight:600;border-radius:999px;padding:1px calc(8px*var(--fs));letter-spacing:.2px;white-space:nowrap;border:1px solid transparent}
+.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
+.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
+.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 (direct child of .card so list-mode span works) ── */
+.info{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}
+.kv{display:grid;grid-template-columns:88px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
+.kv .k{color:var(--mut)}
+.kv .v{text-align:left;word-break:break-word;font-weight:500}
+.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
+.lnk:hover{background:var(--panel)}
+.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
+.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
+.act:hover{border-color:var(--acc)}
+#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)}
+/* ── List view ── aligned CSS-grid rows */
+:root{--listcols:42px 130px 100px minmax(100px,1fr) minmax(120px,1.2fr) 95px 64px minmax(100px,1fr) 90px minmax(110px,1fr) minmax(110px,1fr)}
+.grid.list{grid-template-columns:1fr;gap:4px;padding:4px 12px 60px}
+.grid.list .card{
+  display:grid;align-items:center;border-radius:2px;
+  grid-template-columns:var(--listcols);
+  column-gap:12px;min-height:52px;padding:0 12px 0 8px;
+}
+.grid.list .card>.b{display:contents}
+.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
+.grid.list .imgwrap.noimg::after{font-size:8px}
+.grid.list .vend,.grid.list .chips{display:none} /* list columns carry these fields explicitly */
+.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
+.grid.list .lc{display:block;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .lsku{display:block;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .info{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
+.lsku,.lc{display:none}
+/* sortable column header — same template as rows, sticky under the main header */
+#listhead{display:none;position:sticky;z-index:8;background:#0e0f15;border-bottom:1px solid var(--line);
+ grid-template-columns:var(--listcols);column-gap:12px;padding:6px 24px 6px 20px;align-items:center}
+body.listmode #listhead{display:grid}
+#listhead .lh{font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;
+ cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none}
+#listhead .lh:hover{color:var(--ink)}
+#listhead .lh.on{color:var(--acc)}
+</style></head>
 <body>
 <header>
-  <h1>Greenland Wallcoverings <small id="sub">staged offline · read-only preview</small></h1>
-  <div class="controls">
-    <label class="dens">Category
-      <select id="cat"><option value="all">All categories</option></select>
-    </label>
-    <label class="dens">Material
-      <select id="mat"><option value="all">All materials</option></select>
-    </label>
-    <input type="search" id="q" placeholder="Search SKU, pattern, color, material…">
-    <label class="dens">Sort
-      <select id="sort">
-        <option value="newest">Newest</option>
-        <option value="sku">SKU A→Z</option>
-        <option value="title">Title A→Z</option>
-        <option value="lightdark">Color · Light→Dark</option>
-        <option value="darklight">Color · Dark→Light</option>
-        <option value="colorwheel">Color · Color Wheel</option>
-        <option value="width">Width ↑</option>
-        <option value="weight">Weight ↑</option>
-        <option value="images">Most images</option>
-      </select>
-    </label>
-    <label class="dens" style="cursor:pointer"><input type="checkbox" id="withimg"> with image</label>
-    <label class="dens">Density
-      <input type="range" id="dens" min="3" max="10" value="6">
-    </label>
-  </div>
+  <h1>Greenland Wallcoverings · Staged</h1>
+  <button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
+  <span class="pill" id="total">…</span>
+  <span class="muted" id="meta">staged offline · read-only preview</span>
+  <div class="ctl"><label class="muted">Sort</label>
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="colorwheel">Color · Wheel</option>
+      <option value="lightdark">Color · Light→Dark</option>
+      <option value="darklight">Color · Dark→Light</option>
+      <option value="pattern">Pattern A→Z</option>
+      <option value="sku">SKU A→Z</option>
+      <option value="title">Title A→Z</option>
+      <option value="width">Width ↑</option>
+      <option value="weight">Weight ↑</option>
+      <option value="images">Most images</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>
 </header>
-<div class="grid" id="grid"></div>
-<div id="sentinel"></div>
-<div id="status">loading…</div>
-
+<div id="layout">
+<aside aria-label="filters">
+  <button id="fclose" type="button">✕ Done — show products</button>
+  <input type="text" id="q" placeholder="Search sku / pattern / material…">
+  <input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the catalog">
+  <datalist id="patternlist"></datalist>
+  <label class="frow" style="margin-bottom:4px"><span class="fl"><input type="checkbox" id="withimg">with image only</span></label>
+  <details id="fieldsbox"><summary>Card fields</summary>
+    <div class="fbody">
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Category · material</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Pattern / SKU</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
+    </div>
+  </details>
+  <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="sortable 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>
 <script>
-const grid = document.getElementById('grid');
-const statusEl = document.getElementById('status');
-const subEl = document.getElementById('sub');
-const catSel = document.getElementById('cat');
-const matSel = document.getElementById('mat');
-const withimgEl = document.getElementById('withimg');
-const LIMIT = 120;
-let state = {
-  category: localStorage.getItem('gl_cat') || 'all',
-  material: localStorage.getItem('gl_mat') || 'all',
-  sort: localStorage.getItem('gl_sort') || 'newest',
-  withimg: localStorage.getItem('gl_withimg') === '1',
-  q: '',
-  offset: 0, total: 0, loading: false, done: false, catsBuilt: false, matsBuilt: false,
-};
-
-// density persists
-const dens = document.getElementById('dens');
-dens.value = localStorage.getItem('gl_cols') || 6;
-document.documentElement.style.setProperty('--cols', dens.value);
-dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('gl_cols', dens.value); };
-
-document.getElementById('sort').value = state.sort;
-withimgEl.checked = state.withimg;
-
-function reset() { grid.innerHTML=''; state.offset=0; state.done=false; statusEl.textContent='loading…'; load(); }
-
-catSel.onchange = (e) => { state.category = e.target.value; localStorage.setItem('gl_cat', state.category); reset(); };
-matSel.onchange = (e) => { state.material = e.target.value; localStorage.setItem('gl_mat', state.material); reset(); };
-withimgEl.onchange = (e) => { state.withimg = e.target.checked; localStorage.setItem('gl_withimg', state.withimg?'1':'0'); reset(); };
-document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('gl_sort', state.sort); reset(); };
-let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); reset(); }, 250); };
-
-// same-origin proxy fallback so the grid renders even if CDN hot-link-blocks
-function imgSrc(u) { return u ? ('/img?u=' + encodeURIComponent(u)) : null; }
-
-function card(r) {
-  const el = document.createElement('div'); el.className='card';
-  const imgBadge = r.image_count>1 ? `<span class="badge imgs">📷 ${r.image_count}</span>` : '';
-  const src = imgSrc(r.image);
-  const thumb = src
-    ? `<div class="thumb" style="background-image:url('${src}')"><span class="badge">Greenland</span></div>`
-    : `<div class="thumb empty"><span class="badge">Greenland</span>no image</div>`;
-  const cats = [r.category, r.wall_class, r.wall_type].filter(Boolean).map(s=>`<span>${s}</span>`).join('');
-  const specs = r.specs.map(s=>`<span>${s}</span>`).join('');
-  const dot = r.hex ? `<span class="dot" style="background:${r.hex}" title="${r.hex}"></span>` : '';
-  el.innerHTML = `${thumb}<div class="meta">
-    <div class="sku">${dot}${r.sku||'—'}${imgBadge}</div>
-    <div class="ttl" title="${(r.title||'').replace(/"/g,'&quot;')}">${r.title||''}</div>
-    ${r.color && r.color!==r.title?`<div class="color">${r.color}</div>`:''}
-    ${cats?`<div class="cat">${cats}</div>`:''}
-    <div class="spec">${specs}</div>
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], VIEW=[], shown=0;
+const PAGE=120;
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+function imgSrc(u){return u?('/img?u='+encodeURIComponent(u)):null;}
+// ── every populated data field is a left-panel table: collapsed on load, click value = filter ──
+// (repeat / collection / style are 100% empty in the staged data — no tables for them)
+const FIELDS=[
+ ['category','Category'],['use','Use'],['wall_class','Material'],['wall_type','Type / Book'],
+ ['composition','Composition'],['width','Width'],['weight','Weight'],['backing','Backing'],
+ ['fire','Fire Rating'],['min_order','Min Order'],
+];
+const MULTI={wall_class:'wall_classes',wall_type:'wall_types'};   // list-valued fields facet per element
+const FIL={};            // field -> active value ('' = off)
+FIELDS.forEach(([k])=>FIL[k]='');
+let FCOUNTS={};          // field -> [[value,count]…] computed once from ALL
+const FCAP=40;           // rows shown per table (top-by-count)
+// persisted prefs (CLAUDE.md: sort + density persist in localStorage; keys namespaced greenland.*)
+$('#sort').value=localStorage.getItem('greenland.sort')||'newest';
+// ── density also drives type scale: fewer/larger cards → bigger type, 20/row → smallest ──
+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('greenland.density')||'6';
+$('#density').value=dens; setDensity(dens);
+$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('greenland.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='';}
+});
+// with-image-only checkbox (persisted)
+$('#withimg').checked=localStorage.getItem('greenland.withimg')==='1';
+$('#withimg').onchange=()=>{localStorage.setItem('greenland.withimg',$('#withimg').checked?'1':'0');applyFilters();};
+// ── card-field visibility toggles (image-only when all off) ──
+const FVIS=JSON.parse(localStorage.getItem('greenland.fieldvis')||'{"vend":true,"ttl":true,"chips":true}');
+function applyFieldVis(){
+  for(const k of ['vend','ttl','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
+  document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.chips);
+  document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
+  localStorage.setItem('greenland.fieldvis',JSON.stringify(FVIS));
+}
+document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
+  cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
+});
+applyFieldVis();
+// NOTE: no <a> wrapper around the card — nesting anchors splits the DOM (adoption
+// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
+function card(r){
+  const src=imgSrc(r.image);
+  const img=src
+    ?`<div class="imgwrap"><img loading="lazy" src="${esc(src)}" alt="${esc(r.title||r.sku)}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
+    :`<div class="imgwrap noimg"></div>`;
+  const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+  return `<div class="card" data-href="${esc(r.url||'')}" data-id="${esc(r.id)}">
+    ${img}<div class="b">
+    <span class="vend">${esc(r.category||'Greenland')}${r.wall_class?(' · '+esc(r.wall_class)):''}</span>
+    <div class="ttl">${esc(r.title||r.sku||'—')}</div>
+    <div class="chips">
+      ${r.hex?`<span class="chip cl"><span class="dot" style="background:${esc(r.hex)}" title="${esc(r.hex)}"></span></span>`:''}
+      ${r.image_count>1?`<span class="chip cl">📷 ${r.image_count}</span>`:''}
+      <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
+    </div>
+    <span class="lc" title="Category">${esc(r.category||'')}</span>
+    <span class="lc" title="Material">${esc(r.wall_class||'')}</span>
+    <span class="lc" title="Type">${esc(r.wall_type||'')}</span>
+    <span class="lc" title="Width">${esc(r.width||'')}</span>
+    <span class="lc" title="Weight">${esc(r.weight?r.weight+' g':'')}</span>
+    <span class="lc" title="Fire">${esc(r.fire||'')}</span>
+    <span class="lc" title="Backing">${esc(r.backing||'')}</span>
+    <span class="lc" title="Min order">${esc(r.min_order||'')}</span>
+    <span class="lc" title="Composition">${esc(r.composition||'')}</span>
+    </div>
+    <div class="info" onclick="event.stopPropagation()">
+      ${kv('SKU',r.sku)}${kv('Code',r.code)}${kv('Category',r.category)}${kv('Use',r.use)}
+      ${kv('Material',r.wall_class)}${kv('Type',r.wall_type)}${kv('Width',r.width)}
+      ${kv('Weight',r.weight?r.weight+' g':null)}${kv('Fire',r.fire)}${kv('Backing',r.backing)}
+      ${kv('Min order',r.min_order)}${kv('Composition',r.composition)}
+      ${kv('Images',r.image_count||null)}${kv('Colorways',r.colorway_count||null)}
+      <div class="acts">
+        ${r.sku?`<button class="act" data-copy="${esc(r.sku)}" onclick="copyTxt(this)" title="copy SKU">📋 SKU</button>`:''}
+        ${r.url?`<a class="lnk" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:''}
+      </div>
+    </div>
   </div>`;
-  if (r.url) { el.style.cursor='pointer'; el.onclick=()=>window.open(r.url,'_blank'); }
-  return el;
 }
-
-function buildCats(categories) {
-  if (state.catsBuilt) return;
-  const entries = Object.entries(categories).sort((a,b)=>b[1]-a[1]);
-  for (const [name,n] of entries) {
-    const o = document.createElement('option'); o.value=name; o.textContent=`${name} (${n})`; catSel.appendChild(o);
-  }
-  catSel.value = state.category;
-  state.catsBuilt = true;
+// delegated card navigation — anything not interactive opens the vendor detail page
+grid.addEventListener('click',e=>{
+  if(e.target.closest('.chip.toggle,.info,a,button'))return;
+  const c=e.target.closest('.card[data-href]');
+  if(c&&c.dataset.href)window.open(c.dataset.href,'_blank','noopener');
+});
+function copyTxt(btn){
+  const t=btn.dataset.copy||'';
+  navigator.clipboard.writeText(t).then(()=>{
+    const o=btn.textContent; btn.textContent='✓ copied';
+    setTimeout(()=>{btn.textContent=o;},1200);
+  });
+}
+window.copyTxt=copyTxt;
+const fieldVal=(r,k)=>k==='weight'?(r.weight?r.weight+' g':''):String(r[k]??'');
+function matches(r,k,v){
+  if(MULTI[k])return (r[MULTI[k]]||[]).includes(v);
+  return fieldVal(r,k)===v;
 }
-
-function buildMats(materials) {
-  if (state.matsBuilt || !materials) return;
-  const entries = Object.entries(materials).sort((a,b)=>b[1]-a[1]);
-  for (const [name,n] of entries) {
-    const o = document.createElement('option'); o.value=name; o.textContent=`${name.trim()} (${n})`; matSel.appendChild(o);
+function applyFilters(){
+  const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
+  VIEW=ALL.filter(r=>{
+    for(const [k] of FIELDS){ if(FIL[k]&&!matches(r,k,FIL[k]))return false; }
+    if($('#withimg').checked&&!r.image)return false;
+    if(pat&&!String(r.title||r.sku||'').toLowerCase().includes(pat))return false;
+    if(q&&![r.sku,r.title,r.code,r.wall_class,r.wall_type,r.category,r.composition].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
+    return true;
+  });
+  const colorLast=(a,b)=>(a.hsl?0:1)-(b.hsl?0:1);
+  const s=$('#sort').value, cmp={
+    newest:null,
+    lightdark:(a,b)=>colorLast(a,b)||((b.hsl?b.hsl.l:-1)-(a.hsl?a.hsl.l:-1)),
+    darklight:(a,b)=>colorLast(a,b)||((a.hsl?a.hsl.l:2)-(b.hsl?b.hsl.l:2)),
+    colorwheel:(a,b)=>colorLast(a,b)||((a.hsl?a.hsl.h:999)-(b.hsl?b.hsl.h:999))||((b.hsl?b.hsl.l:0)-(a.hsl?a.hsl.l:0)),
+    pattern:(a,b)=>String(a.title||a.sku||'').localeCompare(String(b.title||b.sku||''),undefined,{numeric:true}),
+    sku:(a,b)=>String(a.sku||'').localeCompare(String(b.sku||''),undefined,{numeric:true}),
+    title:(a,b)=>String(a.title||'').localeCompare(String(b.title||''),undefined,{numeric:true}),
+    width:(a,b)=>(parseFloat(a.width)||0)-(parseFloat(b.width)||0),
+    weight:(a,b)=>(a.weight||0)-(b.weight||0),
+    images:(a,b)=>(b.image_count||0)-(a.image_count||0),
+  }[s];
+  if(COLSORT.key){
+    const k=COLSORT.key, dir=COLSORT.dir;
+    VIEW=[...VIEW].sort((a,b)=>{
+      const av=a[k], bv=b[k];
+      if(av==null||av==='')return 1; if(bv==null||bv==='')return -1;   // empties last either way
+      return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
+    });
+  }else if(cmp)VIEW=[...VIEW].sort(cmp);
+  shown=0; grid.innerHTML=''; renderMore();
+  $('#total').textContent=VIEW.length.toLocaleString()+' SKUs';
+  $('#empty').style.display=VIEW.length?'none':'block';
+  renderSide();
+}
+function renderMore(){
+  const slice=VIEW.slice(shown,shown+PAGE);
+  grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
+  shown+=slice.length;
+  $('#meta').textContent=`staged offline · ${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
+  $('#more').style.display=shown<VIEW.length?'block':'none';
+}
+function buildFacetCache(){
+  FCOUNTS={};
+  for(const [k] of FIELDS){
+    const m={};
+    if(MULTI[k]){
+      for(const r of ALL)for(const v of (r[MULTI[k]]||[])){if(!v)continue;m[v]=(m[v]||0)+1;}
+    }else{
+      for(const r of ALL){const v=fieldVal(r,k);if(!v)continue;m[v]=(m[v]||0)+1;}
+    }
+    FCOUNTS[k]=Object.entries(m).sort((a,b)=>b[1]-a[1]);
   }
-  matSel.value = state.material;
-  state.matsBuilt = true;
+  // Pattern type-ahead over EVERY distinct pattern/sku
+  const pats=[...new Set(ALL.map(r=>r.title||r.sku).filter(Boolean))].sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
+  $('#patternlist').innerHTML=pats.map(s=>`<option value="${esc(s)}">`).join('');
+}
+// left panel — one collapsed <details> table per field; open state survives re-render
+function renderSide(){
+  const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
+  $('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
+    const rows=(FCOUNTS[k]||[]).slice(0,FCAP).map(([v,n])=>
+      `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
+        <span class="fl">${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`).join('');
+    const more=(FCOUNTS[k]||[]).length>FCAP?`<div class="fmore">+ ${(FCOUNTS[k].length-FCAP).toLocaleString()} more — use search</div>`:'';
+    const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(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)?'block':'none';
+}
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
+window.pickF=pickF;
+$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
+$('#sort').onchange=()=>{localStorage.setItem('greenland.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
+let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
+$('#more').onclick=renderMore;
+// ── sortable column header (list view) ──
+const LIST_COLS=[[null,''],['title','Pattern'],['category','Category'],['wall_class','Material'],
+ ['wall_type','Type'],['width','Width'],['weight','Weight'],['fire','Fire'],['backing','Backing'],
+ ['min_order','Min Order'],['composition','Composition']];
+const COLSORT={key:null,dir:1};
+function renderListHead(){
+  $('#listhead').innerHTML=LIST_COLS.map(([k,lbl])=>k
+    ?`<span class="lh${COLSORT.key===k?' on':''}" onclick="colSort('${k}')">${lbl}${COLSORT.key===k?(COLSORT.dir>0?' ▲':' ▼'):''}</span>`
+    :'<span></span>').join('');
+  syncSticky();
+}
+function colSort(k){
+  if(COLSORT.key===k)COLSORT.dir=-COLSORT.dir; else{COLSORT.key=k;COLSORT.dir=1;}
+  renderListHead(); applyFilters();
+}
+window.colSort=colSort;
+function syncSticky(){
+  const h=document.querySelector('header').offsetHeight;
+  $('#listhead').style.top=h+'px';
+  document.querySelector('aside').style.top=h+'px';
+  document.querySelector('aside').style.maxHeight=`calc(100vh - ${h}px)`;
 }
-
-async function load() {
-  if (state.loading || state.done) return;
-  state.loading = true;
-  const p = new URLSearchParams({ category:state.category, material:state.material, withimg:state.withimg?'1':'0', sort:state.sort, q:state.q, offset:state.offset, limit:LIMIT });
-  const res = await fetch('/api/skus?'+p); const data = await res.json();
-  buildCats(data.categories); buildMats(data.materials);
-  state.total = data.total;
-  const frag = document.createDocumentFragment();
-  data.rows.forEach(r => frag.appendChild(card(r)));
-  grid.appendChild(frag);
-  state.offset += data.rows.length;
-  state.done = state.offset >= data.total;
-  state.loading = false;
-  subEl.textContent = `staged offline · ${data.all.toLocaleString()} SKUs total · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}`;
-  statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} SKUs —` : 'scroll for more…';
+addEventListener('resize',syncSticky);
+function applyView(){const list=localStorage.getItem('greenland.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listmode',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();}
+$('#view').onclick=()=>{localStorage.setItem('greenland.view',localStorage.getItem('greenland.view')==='list'?'grid':'list');applyView();};
+applyView();
+addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
+async function loadAll(){
+  // Safari rejects relative fetch on creds-in-URL pages — always resolve via location.origin
+  const data=await fetch(location.origin+'/api/rows').then(r=>r.json());
+  ALL=data.rows;
+  buildFacetCache(); applyFilters();
 }
-
-new IntersectionObserver((es)=>{ if (es[0].isIntersecting) load(); }, { rootMargin:'600px' }).observe(document.getElementById('sentinel'));
-load();
+loadAll();
 </script>
-</body>
-</html>
+</body></html>
diff --git a/viewer/server.js b/viewer/server.js
index 9df712f..6f6c3e6 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -62,9 +62,12 @@ function buildRows() {
       color: r.color || null,                 // real color name only; r.code is a style code, not a color
       collection: r.collection || null,
       category: r.category || null,
-      wall_classes: (r.wall_class && r.wall_class.length) ? r.wall_class : [],
+      wall_classes: (r.wall_class && r.wall_class.length) ? r.wall_class.map((s) => String(s).trim()) : [],
       wall_class: (r.wall_class && r.wall_class.length) ? r.wall_class.join(', ') : null,
+      wall_types: (r.wall_type && r.wall_type.length) ? r.wall_type.map((s) => String(s).trim()) : [],
       wall_type: (r.wall_type && r.wall_type.length) ? r.wall_type.join(', ') : null,
+      use: r.use || null,
+      code: r.code || null,
       width: r.width || null,
       repeat: r.repeat || null,
       fire: r.fire_rating || null,
@@ -117,6 +120,13 @@ const materialsOf = (rows) => {
 const server = http.createServer((req, res) => {
   const u = new URL(req.url, `http://localhost`);
 
+  // whole-catalog snapshot (read-only) — the front end filters/sorts/facets client-side
+  if (u.pathname === '/api/rows') {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ total: ROWS.length, colors_ready: Object.keys(COLOR).length, rows: ROWS }));
+    return;
+  }
+
   if (u.pathname === '/api/skus') {
     const cat = u.searchParams.get('category') || 'all';
     const material = u.searchParams.get('material') || 'all';

← 3bbaf26 chore: lint defensive null-init (session close)  ·  back to Greenland China  ·  (newest)