[object Object]

← back to Japan Enrich

Retrofit japan/china staging viewer to internal-line-viewer spec (left-panel field tables, card-field toggles, density type-scale, data-href cards, gzipped /api/products); E2E-verified chromium+webkit

d13399a4060dbe29b07f7fd9646e73a4c155985c · 2026-07-02 17:28:05 -0700 · Steve Abrams

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

Files touched

Diff

commit d13399a4060dbe29b07f7fd9646e73a4c155985c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 17:28:05 2026 -0700

    Retrofit japan/china staging viewer to internal-line-viewer spec (left-panel field tables, card-field toggles, density type-scale, data-href cards, gzipped /api/products); E2E-verified chromium+webkit
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 viewer-local/public/index.html | 867 +++++++++++++++++++----------------------
 viewer-local/server.js         |  35 ++
 2 files changed, 441 insertions(+), 461 deletions(-)

diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 4ad2c93..277820f 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -1,485 +1,430 @@
-<!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>Staged SKUs (offline preview)</title>
+<title>Japan · Sangetsu + Lilycolor · staged</title>
 <style>
-  :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
-  * { 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,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; max-height:78vh; overflow:auto; }
-  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; }
-  .seg { display:flex; gap:2px; background:var(--card); border:1px solid var(--line); border-radius:8px; padding:2px; }
-  .seg button { background:none; border:none; color:var(--mut); padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; }
-  .seg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
-  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); }
-  /* ---- enriched facet nav bar ---- */
-  details.facets { margin-top:12px; }
-  details.facets > summary { cursor:pointer; color:var(--mut); font-size:12px; letter-spacing:.04em; text-transform:uppercase; list-style:none; user-select:none; padding:2px 0; }
-  details.facets > summary::-webkit-details-marker { display:none; }
-  details.facets > summary::before { content:'▸ '; color:var(--accent); }
-  details.facets[open] > summary::before { content:'▾ '; }
-  summary b { color:var(--accent); }
-  .facetbody { display:flex; flex-direction:column; gap:9px; margin-top:9px; }
-  .frow { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
-  .flabel { color:var(--mut); font-size:11px; text-transform:uppercase; letter-spacing:.06em; min-width:62px; }
-  .swatch { display:inline-flex; align-items:center; gap:5px; background:var(--card); border:1px solid var(--line); border-radius:20px; padding:3px 9px 3px 4px; cursor:pointer; font-size:11.5px; color:var(--mut); }
-  .swatch .dot { width:14px; height:14px; border-radius:50%; box-shadow:0 0 0 1px rgba(255,255,255,.18) inset; flex:0 0 auto; }
-  .swatch.on { border-color:var(--accent); color:var(--txt); background:#23211a; }
-  .swatch.zero { opacity:.3; }
-  .chip { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:4px 10px; cursor:pointer; font-size:11.5px; color:var(--mut); }
-  .chip.on { background:var(--accent); color:#1a1407; border-color:var(--accent); font-weight:600; }
-  .chip .ct { opacity:.65; margin-left:4px; font-size:10px; }
-  .chip.zero { opacity:.3; }
-  .chip.allchip { font-weight:600; border-style:dashed; }
-  .chip.allchip.on { border-style:solid; }
-  .active { display:flex; flex-wrap:wrap; gap:6px; align-items:center; padding-top:2px; }
-  .afilter { background:#23211a; border:1px solid var(--accent); color:var(--txt); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
-  .afilter b { color:var(--accent); font-weight:600; }
-  .clearall { background:none; border:1px solid var(--line); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
-  .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; }
-  .badge.lilycolor { background:#7c5cff; color:#fff; }
-  .badge.sangetsu { background:#1f9e6b; color:#fff; }
-  .badge.sample { position:absolute; top:6px; right:6px; left:auto; background:#444; color:#ddd; }
-  .meta { padding:8px 10px; }
-  .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
-  .badge.imgs { background:#2a2a30; color:#9a9aa2; font-size:9px; padding:1px 5px; margin-left:5px; border-radius:10px; vertical-align:middle; }
-  .ttl { color:var(--mut); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
-  /* card enrichment swatches */
-  .swatchrow { display:flex; align-items:center; gap:6px; margin-top:5px; flex-wrap:wrap; }
-  .dots { display:inline-flex; gap:2px; }
-  .dots i { width:11px; height:11px; border-radius:2px; display:inline-block; box-shadow:0 0 0 1px rgba(255,255,255,.12) inset; }
-  .fam { font-size:10px; color:var(--mut); }
-  .sm { margin-top:4px; font-size:10.5px; color:var(--accent); }
-  .un { font-size:9.5px; color:#6a6a72; border:1px dashed #3a3a40; border-radius:10px; padding:1px 6px; }
-  /* colorway siblings — the "other colors of this pattern", vendor-style chips */
-  .colorways { display:flex; align-items:center; gap:3px; margin-top:5px; flex-wrap:wrap; }
-  .colorways .cw { width:13px; height:13px; border-radius:3px; display:inline-block; box-shadow:0 0 0 1px rgba(255,255,255,.18) inset; background:#3a3a40; }
-  .colorways .cw.pend { background:repeating-linear-gradient(45deg,#33333a,#33333a 2px,#26262b 2px,#26262b 4px); }
-  .colorways .cwmore { font-size:9.5px; color:#8a8a92; }
-  .colorways .cwlbl { font-size:9.5px; color:#6a6a72; margin-left:3px; }
-  .priceline { margin-top:4px; }
-  .price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
-  .price i { color:#777; font-weight:400; font-style:normal; font-size:10px; margin-left: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; }
-  /* ---- mobile: compact header + hamburger filter drawer ---- */
-  #burger { display:none; background:var(--card); border:1px solid var(--line); color:var(--txt); border-radius:8px; padding:7px 12px; font-size:13px; font-weight:600; cursor:pointer; line-height:1; white-space:nowrap; }
-  #fclose { display:none; }
-  @media (max-width:700px) {
-    /* backdrop-filter would make the header the containing block for the
-       fixed-position filter drawer inside it — kill it so the drawer is viewport-sized */
-    header { padding:10px 12px; max-height:none; overflow:visible; backdrop-filter:none; }
-    h1 { font-size:15px; }
-    h1 small { display:block; margin:3px 0 0; font-size:11px; }
-    .controls { gap:8px; }
-    #burger { display:inline-flex; align-items:center; gap:6px; }
-    .controls input[type=search] { min-width:0; flex:1 1 130px; }
-    #densCtl { display:none; } /* grid is fixed 2-up on phones — slider is noise */
-    .grid { grid-template-columns:repeat(2,1fr); gap:8px; padding:10px 10px 60px; }
-    /* filter panel becomes a full-screen drawer, hidden until the hamburger opens it */
-    details.facets { display:none; position:fixed; inset:0; z-index:60; background:var(--bg); overflow:auto; padding:12px 12px 40px; margin:0; }
-    details.facets.open-mobile { display:block; }
-    details.facets > summary { display:none; }
-    #fclose { display:block; position:sticky; top:0; z-index:2; width:100%; background:var(--accent); color:#1a1407; border:none; border-radius:8px; padding:11px; font-size:14px; font-weight:700; cursor:pointer; margin-bottom:10px; }
-  }
-</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 .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0008;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}
+#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}
+.srcbadge{position:absolute;top:6px;left:6px;z-index:2;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px;text-transform:uppercase;background:#000a;color:#cbd5e1}
+.srcbadge.lilycolor{background:#7c5cffcc;color:#fff}
+.srcbadge.sangetsu{background:#1f9e6bcc;color:#fff}
+.srcbadge.greenland{background:#b0813bcc;color:#fff}
+.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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
+/* 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-sku .grid:not(.list) .card .b>.sku{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.price{color:var(--ink);background:transparent;border-color:var(--line)}
+.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)}
+/* colorway sibling dots — the "other colors of this pattern" */
+.cws{display:inline-flex;gap:2px;align-items:center}
+.cws i{width:calc(10px*var(--fs));height:calc(10px*var(--fs));border-radius:2px;display:inline-block;box-shadow:0 0 0 1px #fff2 inset;background:#3a3a40}
+.cws i.pend{background:repeating-linear-gradient(45deg,#33333a,#33333a 2px,#26262b 2px,#26262b 4px)}
+.cws .cwn{font-size:calc(9.5px*var(--fs));color:var(--mut);margin-left:2px}
+/* ── collapsed detail drawer ── */
+.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 (.b display:contents → columns) ── */
+:root{--listcols:42px 84px 105px minmax(140px,1.5fr) minmax(90px,1fr) 92px minmax(95px,1fr) minmax(95px,1fr) 78px 84px minmax(100px,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 .srcbadge{display:none}
+.grid.list .vend{font-size:10.5px}
+.grid.list .ttl{min-width:0;font-size:12.5px}
+.grid.list .sku{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .chips{display:none}
+.grid.list .lc{display:block;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}
+.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 id="h1">Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
-  <div class="controls">
-    <div class="seg" id="srcSeg">
-      <button data-src="all" class="on">All</button>
-    </div>
-    <input type="search" id="q" placeholder="Search SKU or pattern…">
-    <button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
-    <label class="dens">Sort
-      <select id="sort">
-        <option value="newest">Newest</option>
-        <option value="color">Color (family)</option>
-        <option value="style">Style A→Z</option>
-        <option value="material">Material A→Z</option>
-        <option value="sku">SKU A→Z</option>
-        <option value="title">Title A→Z</option>
-        <option value="width">Width ↑</option>
-        <option value="price">Price ↑</option>
-        <option value="images">Most images</option>
-      </select>
-    </label>
-    <label class="dens" id="densCtl">Density
-      <input type="range" id="dens" min="3" max="10" value="6">
-    </label>
-  </div>
-
-  <details class="facets" id="facets">
-    <summary>Filters <b id="facetCount"></b></summary>
-    <button id="fclose" type="button">✕ Done — show products</button>
-    <div class="facetbody">
-      <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
-      <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
-      <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
-      <div class="frow"><span class="flabel">Collection</span><div class="frow" id="collRow"></div></div>
-      <div class="frow"><span class="flabel">Width</span><div class="frow" id="widthRow"></div></div>
-      <div class="frow"><span class="flabel">Fire</span><div class="frow" id="fireRow"></div></div>
-      <div class="frow"><span class="flabel">Performance</span><div class="frow" id="funcRow"></div></div>
-      <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
-      <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
-      <div class="frow"><span class="flabel">Enriched</span>
-        <div class="seg" id="enrSeg">
-          <button data-enr="all" class="on">All</button>
-          <button data-enr="enriched">Enriched</button>
-          <button data-enr="unenriched">Unenriched</button>
-        </div>
-      </div>
-      <div class="active" id="activeRow"></div>
+  <h1 id="h1">Japan · Sangetsu + Lilycolor</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">Sort</label>
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="color">Color</option>
+      <option value="style">Style</option>
+      <option value="pattern">Pattern A→Z</option>
+      <option value="sku">SKU A→Z</option>
+      <option value="title">Title A→Z</option>
+      <option value="material">Material A→Z</option>
+      <option value="width">Width ↑</option>
+      <option value="price">Price ↑</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 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 staged catalog">
+  <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>Source · collection</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Pattern / title</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="sku" checked>SKU</span></label>
+      <label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
     </div>
   </details>
-</header>
-<div class="grid" id="grid"></div>
-<div id="sentinel"></div>
-<div id="status">loading…</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="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 SKUs match.</div>
+</main>
+</div>
 <script>
-const grid = document.getElementById('grid');
-const statusEl = document.getElementById('status');
-const subEl = document.getElementById('sub');
-const LIMIT = 120;
-// host scope (set by loadScope() before first load): {id,label,sub,tabs[]}
-let SCOPE = { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
-  tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
-
-// representative dot color per family (UI only — true hex comes from each card's palette)
-const FAMILY_DOT = {
-  'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b',
-  'Teal':'#1aa5a5','Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5',
-  'Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2',
-};
-const DEFAULT_FAMILY_ORDER = Object.keys(FAMILY_DOT);
-const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
-
-// --- live JPY→USD exchange rate, fetched fresh on load (no API key) ---
-let FX = { rate: null, asOf: null, src: null };
-async function loadFX() {
-  try {
-    const r = await fetch('https://open.er-api.com/v6/latest/JPY');
-    const d = await r.json();
-    if (d && d.result === 'success' && d.rates && d.rates.USD) {
-      FX = { rate: d.rates.USD, asOf: (d.time_last_update_utc || '').replace(/ \d\d:.*/, '') || 'today', src: 'live' };
-      return;
-    }
-  } catch (e) { /* fall through */ }
-  FX = { rate: 0.0064, asOf: 'approx', src: 'fallback' }; // ~June 2026 rate; safety net so prices still show in $
-}
-const USD_ROUND_THRESHOLD = 100; // round to whole dollars above this; cents below
-function usd(yen) {
-  if (!yen || !FX.rate) return null;
-  const v = yen * FX.rate;
-  return '$' + (v >= USD_ROUND_THRESHOLD ? Math.round(v).toLocaleString() : v.toFixed(2));
+const $=s=>document.querySelector(s);
+const grid=$('#grid');
+let ALL=[], VIEW=[], shown=0, FAMILY_ORDER=[], PRICE_ORDER=[];
+const PAGE=120;
+const FAMILY_DOT={'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b','Teal':'#1aa5a5',
+ 'Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5','Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2'};
+const SRC_LABEL={lilycolor:'Lilycolor',sangetsu:'Sangetsu',greenland:'Greenland'};
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+// ── every data field is a left-panel table: collapsed on load, click value = filter ──
+// 'functions' is an array field (multi-valued) — counted/filtered element-wise.
+const FIELDS=[
+ ['src_label','Source'],['collection','Collections'],['color_family','Colors'],['style','Styles'],
+ ['material','Material'],['width','Width'],['repeat','Repeat'],['fire_class','Fire class'],
+ ['functions','Performance'],['price_band','Price ¥'],['image_state','Image'],['enr','Enriched'],
+ ['prefix','SKU prefix'],['color_name','Color name'],
+];
+const ARR_FIELDS=new Set(['functions']);
+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) — 'japan.' namespace
+$('#sort').value=localStorage.getItem('japan.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));
 }
-
-let state = {
-  source: localStorage.getItem('dw_src') || 'all',
-  sort: localStorage.getItem('dw_sort') || 'newest',
-  q: '',
-  colors: loadSet('dw_colors'),
-  styles: loadSet('dw_styles'),
-  materials: loadSet('dw_materials'),
-  collections: loadSet('dw_collections'),
-  widths: loadSet('dw_widths'),
-  fires: loadSet('dw_fires'),
-  funcs: loadSet('dw_funcs'),
-  prices: loadSet('dw_prices'),
-  images: loadSet('dw_images'),
-  enriched: localStorage.getItem('dw_enr') || 'all',
-  offset: 0, total: 0, loading: false, done: false,
-};
-
-// density persists
-const dens = document.getElementById('dens');
-dens.value = localStorage.getItem('dw_cols') || 6;
-document.documentElement.style.setProperty('--cols', dens.value);
-dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('dw_cols', dens.value); };
-
-// facet panel open/closed persists
-const facetsEl = document.getElementById('facets');
-facetsEl.open = localStorage.getItem('dw_facets_open') !== '0';
-facetsEl.ontoggle = () => localStorage.setItem('dw_facets_open', facetsEl.open ? '1' : '0');
-
-// mobile: hamburger opens the filter panel as a full-screen drawer (CSS hides it otherwise)
-const mobileMQ = window.matchMedia('(max-width:700px)');
-const burgerEl = document.getElementById('burger');
-document.getElementById('fclose').onclick = () => {
-  facetsEl.classList.remove('open-mobile');
-  document.body.style.overflow = '';
-};
-burgerEl.onclick = () => {
-  facetsEl.open = true; // drawer always shows the facet body, regardless of persisted collapse
-  facetsEl.classList.add('open-mobile');
-  document.body.style.overflow = 'hidden'; // freeze the grid behind the drawer
-};
-mobileMQ.addEventListener('change', (e) => {
-  if (!e.matches) { facetsEl.classList.remove('open-mobile'); document.body.style.overflow = ''; }
+const dens=localStorage.getItem('japan.density')||'6';
+$('#density').value=dens; setDensity(dens);
+$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('japan.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='';}
 });
-
-// restore sort/enriched UI (source tabs are built by loadScope() once the host scope is known)
-document.getElementById('sort').value = state.sort;
-document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === state.enriched));
-
-// Build the source-tab bar for the current host scope and clamp a stale persisted source.
-// china shows only [All, Greenland]; japan shows [All, Lilycolor, Sangetsu].
-function buildSrcTabs() {
-  const seg = document.getElementById('srcSeg');
-  const valid = new Set(['all', ...SCOPE.tabs.map(t => t.src)]);
-  if (!valid.has(state.source)) { state.source = 'all'; localStorage.setItem('dw_src', 'all'); }
-  seg.innerHTML = '';
-  const mk = (src, label) => { const b = document.createElement('button'); b.dataset.src = src; b.textContent = label; if (src === state.source) b.classList.add('on'); return b; };
-  seg.appendChild(mk('all', 'All'));
-  SCOPE.tabs.forEach(t => seg.appendChild(mk(t.src, t.label)));
+// ── card-field visibility toggles (image-only when all off) ──
+const FVIS=JSON.parse(localStorage.getItem('japan.fieldvis')||'{"vend":true,"ttl":true,"sku":true,"chips":true}');
+function applyFieldVis(){
+  for(const k of ['vend','ttl','sku','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
+  document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.sku&&!FVIS.chips);
+  document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
+  localStorage.setItem('japan.fieldvis',JSON.stringify(FVIS));
 }
-
-async function loadScope() {
-  try {
-    const res = await fetch('/api/scope');
-    if (res.ok) SCOPE = await res.json();
-  } catch (e) { /* keep default japan scope */ }
-  document.title = SCOPE.label + ' — Staged SKUs (offline preview)';
-  const h1 = document.getElementById('h1');
-  if (h1) h1.firstChild.textContent = SCOPE.label + ' ';
-  subEl.textContent = SCOPE.sub;
-  buildSrcTabs();
+document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
+  cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
+});
+applyFieldVis();
+// ── live JPY→USD rate (no key; fails soft to approx) ──
+let FX={rate:0.0064,src:'approx'};
+async function loadFX(){
+  try{
+    const d=await fetch('https://open.er-api.com/v6/latest/JPY').then(r=>r.json());
+    if(d&&d.result==='success'&&d.rates&&d.rates.USD)FX={rate:d.rates.USD,src:'live'};
+  }catch(e){}
 }
-
-function persistFacets() {
-  localStorage.setItem('dw_colors', JSON.stringify([...state.colors]));
-  localStorage.setItem('dw_styles', JSON.stringify([...state.styles]));
-  localStorage.setItem('dw_materials', JSON.stringify([...state.materials]));
-  localStorage.setItem('dw_collections', JSON.stringify([...state.collections]));
-  localStorage.setItem('dw_widths', JSON.stringify([...state.widths]));
-  localStorage.setItem('dw_fires', JSON.stringify([...state.fires]));
-  localStorage.setItem('dw_funcs', JSON.stringify([...state.funcs]));
-  localStorage.setItem('dw_prices', JSON.stringify([...state.prices]));
-  localStorage.setItem('dw_images', JSON.stringify([...state.images]));
-  localStorage.setItem('dw_enr', state.enriched);
+function usd(yen){if(!yen||!FX.rate)return null;const v=yen*FX.rate;return '$'+(v>=100?Math.round(v).toLocaleString():v.toFixed(2));}
+// 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(p){
+  const src=esc(p.src_label||'');
+  const img=p.image
+    ?`<div class="imgwrap"><img loading="lazy" src="${esc(p.image)}" alt="${esc(p.title||p.sku)}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"><span class="srcbadge ${esc(p.source)}">${src}</span></div>`
+    :`<div class="imgwrap noimg"><span class="srcbadge ${esc(p.source)}">${src}</span></div>`;
+  const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+  const dot=FAMILY_DOT[p.color_family]||'#666';
+  const cwDots=(p.siblings&&p.siblings.length)
+    ?`<span class="cws" title="${p.colorway_count} colorways in this pattern">${p.siblings.map(s=>`<i class="${s.hex?'':'pend'}" style="${s.hex?('background:'+esc(s.hex)):''}" title="${esc(s.sku)}${s.family?(' · '+esc(s.family)):''}"></i>`).join('')}<span class="cwn">${p.colorway_count} cw</span></span>`:'';
+  const price=p.price_yen?`<span class="chip price" title="Japan LIST ¥${p.price_yen.toLocaleString()}/m — not net cost">${usd(p.price_yen)||('¥'+p.price_yen.toLocaleString())}<span class="muted">/m</span></span>`:'';
+  return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}">
+    ${img}<div class="b">
+    <span class="vend">${src}${p.collection?(' · '+esc(p.collection)):''}</span>
+    <div class="ttl" title="${esc(p.title||'')}">${esc(p.title||p.sku||'(untitled)')}</div>
+    <span class="sku">${esc(p.sku||'')}</span>
+    <span class="lc" title="Collection">${esc(p.collection||'')}</span>
+    <div class="chips">
+      ${p.color_family?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_family)}</span>`:''}
+      ${cwDots}${price}
+      <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
+    </div>
+    <span class="lc" title="Color family">${esc(p.color_family||'')}</span>
+    <span class="lc" title="Style">${esc(p.style||'')}</span>
+    <span class="lc" title="Material">${esc(p.material||'')}</span>
+    <span class="lc" title="Width">${esc(p.width||'')}</span>
+    <span class="lc" title="Price ¥/m">${p.price_yen?('¥'+p.price_yen.toLocaleString()):''}</span>
+    <span class="lc" title="Fire class">${esc(p.fire_class||'')}</span>
+    </div>
+    <div class="info" onclick="event.stopPropagation()">
+      ${kv('SKU',p.sku)}${kv('Source',p.src_label)}${kv('Collection',p.collection)}${kv('Pattern',p.title)}
+      ${kv('Color name',p.color_name)}${kv('Color family',p.color_family)}${kv('Style',p.style)}${kv('Material',p.material)}
+      ${kv('Width',p.width)}${kv('Repeat',p.repeat)}${kv('Fire',p.fire)}
+      ${kv('Performance',(p.functions||[]).join(', '))}
+      ${kv('¥ list',p.price_yen?('¥'+p.price_yen.toLocaleString()+'/m'):'')}
+      ${kv('Colorways',p.colorway_count>1?p.colorway_count:'')}
+      ${kv('Enriched',p.enr)}
+      <div class="acts">
+        ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy SKU">📋 SKU</button>`:''}
+        ${p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor page ↗</a>`:''}
+      </div>
+    </div>
+  </div>`;
 }
-function facetParams(extra = {}) {
-  return new URLSearchParams({
-    source: state.source, sort: state.sort, q: state.q,
-    colors: [...state.colors].join(','), styles: [...state.styles].join(','),
-    materials: [...state.materials].join(','),
-    collections: [...state.collections].join(','), widths: [...state.widths].join(','),
-    fires: [...state.fires].join(','), funcs: [...state.funcs].join(','),
-    prices: [...state.prices].join(','), images: [...state.images].join(','),
-    enriched: state.enriched, ...extra,
+// delegated card navigation — anything not interactive opens the vendor page (new tab)
+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,noreferrer');
+});
+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);
   });
 }
-
-function reset() { grid.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
-function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
-
-// --- existing controls ---
-document.getElementById('srcSeg').onclick = (e) => {
-  const b = e.target.closest('button'); if (!b) return;
-  document.querySelectorAll('#srcSeg button').forEach(x => x.classList.remove('on')); b.classList.add('on');
-  state.source = b.dataset.src; localStorage.setItem('dw_src', state.source); applyFilterChange();
-};
-document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('dw_sort', state.sort); reset(); };
-let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
-document.getElementById('enrSeg').onclick = (e) => {
-  const b = e.target.closest('button'); if (!b) return;
-  document.querySelectorAll('#enrSeg button').forEach(x => x.classList.remove('on')); b.classList.add('on');
-  state.enriched = b.dataset.enr; applyFilterChange();
-};
-
-// --- facet rendering ---
-function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
-
-// "All" toggle for a chip category — highlighted when nothing is selected (showing all);
-// click it to switch that whole category OFF (clear its selections). Lets the user toggle
-// off any/all categories without hunting down each active chip.
-function allChip(set) {
-  const el = document.createElement('span');
-  el.className = 'chip allchip' + (set.size === 0 ? ' on' : '');
-  el.textContent = 'All';
-  el.title = set.size ? 'Clear this category — show all' : 'Showing all';
-  el.onclick = () => { if (set.size) { set.clear(); applyFilterChange(); } };
-  return el;
+window.copyTxt=copyTxt;
+function matchField(p,k,v){
+  if(ARR_FIELDS.has(k))return (p[k]||[]).includes(v);
+  return String(p[k]??'')===v;
 }
-
-function renderFacets(f) {
-  const order = (f.family_order && f.family_order.length) ? f.family_order : DEFAULT_FAMILY_ORDER;
-  // color swatches
-  const colorRow = document.getElementById('colorRow'); colorRow.innerHTML = '';
-  colorRow.appendChild(allChip(state.colors));
-  order.forEach(fam => {
-    const ct = (f.color_family && f.color_family[fam]) || 0;
-    const on = state.colors.has(fam);
-    const el = document.createElement('span');
-    el.className = 'swatch' + (on ? ' on' : '') + (ct ? '' : ' zero');
-    el.title = fam + ' · ' + ct;
-    el.innerHTML = `<span class="dot" style="background:${FAMILY_DOT[fam] || '#888'}"></span>${fam} <span class="ct">${ct}</span>`;
-    el.onclick = () => toggleSet(state.colors, fam);
-    colorRow.appendChild(el);
+function applyFilters(){
+  const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
+  VIEW=ALL.filter(p=>{
+    for(const [k] of FIELDS){ if(FIL[k]&&!matchField(p,k,FIL[k]))return false; }
+    if(pat&&!String(p.title||'').toLowerCase().includes(pat))return false;
+    if(q&&![p.title,p.sku,p.collection,p.style,p.material,p.color_family,p.color_name].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
+    return true;
   });
-  // generic count-chip row (sorted by count desc, or by a fixed `order` array when given)
-  const chipRow = (rowId, counts, set, order) => {
-    const row = document.getElementById(rowId); row.innerHTML = '';
-    row.appendChild(allChip(set));
-    let entries = Object.entries(counts || {});
-    entries = order
-      ? entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
-      : entries.sort((a, b) => b[1] - a[1]);
-    if (!entries.length) { const s = document.createElement('span'); s.className = 'un'; s.textContent = 'none yet'; row.appendChild(s); return; }
-    entries.forEach(([val, ct]) => {
-      const on = set.has(val);
-      const el = document.createElement('span');
-      el.className = 'chip' + (on ? ' on' : '') + (ct ? '' : ' zero');
-      el.innerHTML = `${val}<span class="ct">${ct}</span>`;
-      el.onclick = () => toggleSet(set, val);
-      row.appendChild(el);
+  const famIdx=x=>{const i=FAMILY_ORDER.indexOf(x.color_family);return i<0?99:i;};
+  const s=$('#sort').value, cmp={
+    newest:null,
+    color:(a,b)=>famIdx(a)-famIdx(b)||String(a.hex||'').localeCompare(String(b.hex||''))||String(a.sku).localeCompare(String(b.sku)),
+    style:(a,b)=>String(a.style||'￿').localeCompare(String(b.style||'￿'))||String(a.sku).localeCompare(String(b.sku)),
+    pattern:(a,b)=>String(a.title||'').localeCompare(String(b.title||''))||String(a.sku).localeCompare(String(b.sku)),
+    sku:(a,b)=>String(a.sku||'').localeCompare(String(b.sku||''),undefined,{numeric:true}),
+    title:(a,b)=>String(a.title||'').localeCompare(String(b.title||'')),
+    material:(a,b)=>String(a.material||'￿').localeCompare(String(b.material||'￿'))||String(a.sku).localeCompare(String(b.sku)),
+    width:(a,b)=>(parseFloat(a.width)||1e9)-(parseFloat(b.width)||1e9),
+    price:(a,b)=>(a.price_yen||1e15)-(b.price_yen||1e15),
+    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
+      if(typeof av==='number'&&typeof bv==='number')return dir*(av-bv);
+      return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
     });
-  };
-  chipRow('styleRow', f.style, state.styles);
-  chipRow('matRow', f.material, state.materials);
-  chipRow('collRow', f.collection, state.collections);
-  chipRow('widthRow', f.width, state.widths);
-  chipRow('fireRow', f.fire_class, state.fires);
-  chipRow('funcRow', f.functions, state.funcs);
-  chipRow('priceRow', f.price_band, state.prices, f.price_order);
-  chipRow('imgRow', f.image_state, state.images);
-  // enriched seg counts
-  document.querySelector('#enrSeg [data-enr=enriched]').textContent = `Enriched (${(f.enriched || 0).toLocaleString()})`;
-  document.querySelector('#enrSeg [data-enr=unenriched]').textContent = `Unenriched (${(f.unenriched || 0).toLocaleString()})`;
-  renderActive();
+  }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 renderActive() {
-  const row = document.getElementById('activeRow'); row.innerHTML = '';
-  const add = (label, val, set) => {
-    const el = document.createElement('span'); el.className = 'afilter';
-    el.innerHTML = `<b>${label}:</b> ${val} ✕`;
-    el.onclick = () => { set.delete(val); applyFilterChange(); };
-    row.appendChild(el);
-  };
-  state.colors.forEach(v => add('Color', v, state.colors));
-  state.styles.forEach(v => add('Style', v, state.styles));
-  state.materials.forEach(v => add('Material', v, state.materials));
-  state.collections.forEach(v => add('Collection', v, state.collections));
-  state.widths.forEach(v => add('Width', v, state.widths));
-  state.fires.forEach(v => add('Fire', v, state.fires));
-  state.funcs.forEach(v => add('Performance', v, state.funcs));
-  state.prices.forEach(v => add('Price', v, state.prices));
-  state.images.forEach(v => add('Image', v, state.images));
-  if (state.enriched !== 'all') {
-    const el = document.createElement('span'); el.className = 'afilter';
-    el.innerHTML = `<b>Enriched:</b> ${state.enriched} ✕`;
-    el.onclick = () => { state.enriched = 'all'; document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === 'all')); applyFilterChange(); };
-    row.appendChild(el);
-  }
-  const n = state.colors.size + state.styles.size + state.materials.size
-    + state.collections.size + state.widths.size + state.fires.size
-    + state.funcs.size + state.prices.size + state.images.size
-    + (state.enriched !== 'all' ? 1 : 0);
-  document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
-  burgerEl.textContent = n ? `☰ Filters · ${n}` : '☰ Filters';
-  if (n) {
-    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
-    c.onclick = () => {
-      [state.colors, state.styles, state.materials, state.collections, state.widths,
-       state.fires, state.funcs, state.prices, state.images].forEach(s => s.clear());
-      state.enriched = 'all';
-      document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === 'all'));
-      applyFilterChange();
-    };
-    row.appendChild(c);
+function renderMore(){
+  const slice=VIEW.slice(shown,shown+PAGE);
+  grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
+  shown+=slice.length;
+  $('#meta').textContent=`${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(ARR_FIELDS.has(k)){for(const p of ALL)for(const v of (p[k]||[]))m[v]=(m[v]||0)+1;}
+    else{for(const p of ALL){const v=p[k];if(v==null||v==='')continue;m[v]=(m[v]||0)+1;}}
+    let entries=Object.entries(m);
+    if(k==='color_family'&&FAMILY_ORDER.length)entries.sort((a,b)=>FAMILY_ORDER.indexOf(a[0])-FAMILY_ORDER.indexOf(b[0]));
+    else if(k==='price_band'&&PRICE_ORDER.length)entries.sort((a,b)=>PRICE_ORDER.indexOf(a[0])-PRICE_ORDER.indexOf(b[0]));
+    else entries.sort((a,b)=>b[1]-a[1]);
+    FCOUNTS[k]=entries;
   }
+  // Pattern type-ahead over EVERY distinct pattern title
+  const seen=new Set(); const titles=[];
+  for(const p of ALL){const t=p.title;if(t&&!seen.has(t)){seen.add(t);titles.push(t);}}
+  titles.sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
+  $('#patternlist').innerHTML=titles.map(s=>`<option value="${esc(s)}">`).join('');
 }
-
-async function refreshFacets() {
-  try {
-    const res = await fetch('/api/facets?' + facetParams());
-    renderFacets(await res.json());
-  } catch (e) { /* facets degrade silently — grid still works */ }
+// 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 all=FCOUNTS[k]||[];
+    if(!all.length)return '';   // field has no real values in this scope — skip the table
+    const rows=all.slice(0,FCAP).map(([v,n])=>{
+      const dot=k==='color_family'?`<span class="dot" style="background:${FAMILY_DOT[v]||'#666'}"></span>`:'';
+      return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
+        <span class="fl">${dot}${esc(v)}</span><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(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 card(r) {
-  const el = document.createElement('div'); el.className = 'card';
-  const tag = r.source === 'lilycolor' ? (r.catalog || 'Lily') : 'Sangetsu';
-  const imgCountBadge = (!r.image && r.image_count) ? `<span class="badge imgs" title="${r.image_count} official images archived (${(r.image_kinds || []).join('/')}) — bytes not yet pulled">📷 ${r.image_count}</span>` : '';
-  const thumb = r.image
-    ? `<div class="thumb" style="background-image:url('${r.image}')"><span class="badge ${r.source}">${tag}</span></div>`
-    : `<div class="thumb empty"><span class="badge ${r.source}">${tag}</span>${r.image_count ? `📷 ${r.image_count} archived` : 'no image'}</div>`;
-  // enrichment swatch row — dominant palette dots + color family; "unenriched" pill otherwise
-  let dots = '';
-  if (r.palette && r.palette.length) dots = `<span class="dots">${r.palette.map(h => `<i style="background:${h}"></i>`).join('')}</span>`;
-  else if (r.hex) dots = `<span class="dots"><i style="background:${r.hex}"></i></span>`;
-  const fam = r.color_family ? `<span class="fam">${r.color_family}</span>` : '';
-  const swatchrow = r.enriched
-    ? `<div class="swatchrow">${dots}${fam}</div>`
-    : `<div class="swatchrow"><span class="un">unenriched</span></div>`;
-  const sm = (r.style || r.material) ? `<div class="sm">${[r.style, r.material].filter(Boolean).join(' · ')}</div>` : '';
-  // colorway siblings — the OTHER colors of this pattern, vendor-style chips tinted by each colorway's enriched hex
-  let colorways = '';
-  const CW_CHIP_MAX = 12; // max sibling chips shown inline before "+N more"
-  if (r.siblings && r.siblings.length) {
-    const chips = r.siblings.slice(0, CW_CHIP_MAX).map(s =>
-      `<i class="cw${s.hex ? '' : ' pend'}" style="${s.hex ? `background:${s.hex}` : ''}" title="${s.sku}${s.family ? ' · ' + s.family : ' · color pending'}"></i>`
-    ).join('');
-    const more = r.siblings.length > CW_CHIP_MAX ? `<span class="cwmore">+${r.siblings.length - CW_CHIP_MAX}</span>` : '';
-    colorways = `<div class="colorways" title="${r.colorway_count} colorways in this pattern">${chips}${more}<span class="cwlbl">${r.colorway_count} colorways</span></div>`;
-  }
-  const price = r.price_yen ? `<span class="price">${usd(r.price_yen) || ('¥' + r.price_yen.toLocaleString())}<i>/m list${usd(r.price_yen) ? ' · ¥' + r.price_yen.toLocaleString() : ''}</i></span>` : '';
-  const specs = [r.width, r.repeat, r.fire, r.composition].filter(Boolean).map(s => `<span>${s}</span>`).join('');
-  el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}${imgCountBadge}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${swatchrow}${colorways}${sm}${price ? `<div class="priceline">${price}</div>` : ''}<div class="spec">${specs}</div></div>`;
-  if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
-  return el;
+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('japan.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,''],['src_label','Source'],['sku','SKU'],['title','Pattern'],['collection','Collection'],
+ ['color_family','Color'],['style','Style'],['material','Material'],['width','Width'],['price_yen','¥ list'],['fire_class','Fire']];
+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();
 }
-
-async function load() {
-  if (state.loading || state.done) return;
-  state.loading = true;
-  try {
-    const res = await fetch('/api/skus?' + facetParams({ offset: state.offset, limit: LIMIT }));
-    const data = await res.json();
-    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 || data.rows.length === 0;
-    const fxNote = FX.rate ? ` · ¥→$ ${FX.rate.toFixed(5)} ${FX.src === 'live' ? 'live' : 'approx'} (${FX.asOf})` : ' · ¥→$ …';
-    // per-source breakdown built from the scope's tabs (Lily/Sangetsu for japan; Greenland for china)
-    const parts = (SCOPE.tabs || []).map(t => `${t.label} ${(data.counts[t.src] || 0).toLocaleString()}`).join(' / ');
-    const brk = parts ? ` (${parts})` : '';
-    subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs${brk} · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}${fxNote}`;
-    if (data.total === 0) {
-      // graceful empty state — e.g. china before any Greenland data lands
-      statusEl.textContent = SCOPE.id === 'china' ? 'Greenland — catalog coming soon' : '— no matching SKUs —';
-    } else {
-      statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} matching SKUs —` : 'scroll for more…';
-    }
-  } catch (e) {
-    statusEl.textContent = 'Load error — check server';
-  } finally {
-    state.loading = false;
-  }
+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)`;
+}
+addEventListener('resize',syncSticky);
+function applyView(){const list=localStorage.getItem('japan.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('japan.view',localStorage.getItem('japan.view')==='list'?'grid':'list');applyView();};
+applyView();
+addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
+async function loadAll(){
+  // resolve the host scope first (japan vs china) so H1/title match the served pool
+  try{
+    const sc=await fetch(location.origin+'/api/scope').then(r=>r.json());
+    if(sc&&sc.label){$('#h1').textContent=(sc.id==='japan'?'Japan · ':sc.id==='china'?'China · ':'')+sc.label;document.title=$('#h1').textContent+' · staged';}
+  }catch(e){}
+  const pd=await fetch(location.origin+'/api/products').then(r=>r.json());
+  FAMILY_ORDER=pd.family_order||[]; PRICE_ORDER=pd.price_order||[];
+  ALL=(pd.products||[]).map(p=>({...p,
+    src_label:SRC_LABEL[p.source]||p.source,
+    enr:p.enriched?'Enriched':'Unenriched',
+  }));
+  buildFacetCache(); applyFilters();
 }
-
-// infinite scroll
-new IntersectionObserver((es) => { if (es[0].isIntersecting) load(); }, { rootMargin: '600px' }).observe(document.getElementById('sentinel'));
-// resolve the host scope FIRST (sets title/H1/subtitle + builds the source tabs), then
-// fan out facets + grid. Clamp the persisted source before any /api call goes out.
-loadScope().then(() => { refreshFacets(); loadFX().finally(load); });
+loadFX().finally(loadAll);
 </script>
-</body>
-</html>
+</body></html>
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 855c870..1a3a71a 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -10,6 +10,7 @@
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
+const zlib = require('zlib');
 
 const PORT = parseInt(process.argv[2] || '9931', 10);
 const DIR = path.join(__dirname, '..', 'staging');
@@ -333,6 +334,22 @@ function hostScope(req) {
 // rows visible for this host (the pool every count + filter runs against)
 const scopedRows = (scope) => ROWS.filter((r) => scope.sources.includes(r.source));
 
+// gzip JSON responses when the client accepts it (the light /api/products list is
+// ~15.9k rows — gzip takes it from ~7MB to ~1MB; internal-line-viewer spec).
+function sendJson(req, res, obj) {
+  const body = JSON.stringify(obj);
+  if (/\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))) {
+    zlib.gzip(body, (e, buf) => {
+      if (e) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(body); }
+      res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' });
+      res.end(buf);
+    });
+    return;
+  }
+  res.writeHead(200, { 'Content-Type': 'application/json' });
+  res.end(body);
+}
+
 const server = http.createServer((req, res) => {
   if (REQUIRE_AUTH && !authed(req)) {
     res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
@@ -341,6 +358,24 @@ const server = http.createServer((req, res) => {
   const u = new URL(req.url, `http://localhost:${PORT}`);
   const scope = hostScope(req);
   const POOL = scopedRows(scope);
+  // LIGHT full list for the client-side internal-line-viewer front end: every scoped row,
+  // siblings capped (big Sangetsu patterns explode quadratically otherwise), no heavy fields.
+  if (u.pathname === '/api/products') {
+    const SIB_MAX = 12;
+    const products = POOL.map((r) => ({
+      source: r.source, sku: r.sku, prefix: r.prefix, title: r.title, collection: r.collection,
+      width: r.width, repeat: r.repeat || null, composition: r.composition, functions: r.functions || [],
+      fire: r.fire, fire_class: r.fire_class, price_yen: r.price_yen, price_band: r.price_band,
+      image: r.image, image_state: r.image_state, image_count: r.image_count || 0,
+      url: r.url, colorway_count: r.colorway_count, siblings: (r.siblings || []).slice(0, SIB_MAX),
+      enriched: !!r.enriched, color_family: r.color_family, style: r.style, material: r.material,
+      hex: r.hex, palette: r.palette || [], color_name: r.color_name || null,
+    }));
+    return sendJson(req, res, {
+      total: products.length, scope: scope.id,
+      family_order: FAMILY_ORDER, price_order: PRICE_ORDER, products,
+    });
+  }
   // tell the client which host scope it's in so it can set the H1/subtitle + hide the
   // irrelevant source tabs (china shows only Greenland; japan shows Lilycolor+Sangetsu).
   if (u.pathname === '/api/scope') {

← d4d1634 mobile: hamburger filter drawer + 2-up grid for japan/china  ·  back to Japan Enrich  ·  japan viewer: add distributor (buy-from channel) chip + face b1fd6b0 →