← back to Designer Wallcoverings
Add offline web viewer for staged Sangetsu + Lilycolor SKUs (sort+density+infinite-scroll, single-writer restage)
f8cd79bc077fcc69e50170fbcca4bd025f38c196 · 2026-06-30 04:18:34 -0700 · Steve
Files touched
A onboarding/sangetsu-lilycolor/restage-sangetsu.shA onboarding/sangetsu-lilycolor/viewer/public/index.htmlA onboarding/sangetsu-lilycolor/viewer/server.js
Diff
commit f8cd79bc077fcc69e50170fbcca4bd025f38c196
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 30 04:18:34 2026 -0700
Add offline web viewer for staged Sangetsu + Lilycolor SKUs (sort+density+infinite-scroll, single-writer restage)
---
onboarding/sangetsu-lilycolor/restage-sangetsu.sh | 11 ++
.../sangetsu-lilycolor/viewer/public/index.html | 128 +++++++++++++++++++++
onboarding/sangetsu-lilycolor/viewer/server.js | 105 +++++++++++++++++
3 files changed, 244 insertions(+)
diff --git a/onboarding/sangetsu-lilycolor/restage-sangetsu.sh b/onboarding/sangetsu-lilycolor/restage-sangetsu.sh
new file mode 100644
index 00000000..8299338e
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/restage-sangetsu.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Clean single-writer restage of all 623 Sangetsu patterns. Kills any concurrent
+# stage process first so the JSONL can't be corrupted by interleaved writes.
+set -u
+cd "$(dirname "$0")"
+pkill -f "sangetsu-sitemap-scraper.cjs stage" 2>/dev/null
+sleep 1
+: > staging/sangetsu-staging.jsonl # truncate to a clean empty file
+echo "[restage] starting clean single-writer stage of 623 patterns $(date)"
+node sangetsu-sitemap-scraper.cjs stage 623
+echo "[restage] DONE $(date) — staged $(wc -l < staging/sangetsu-staging.jsonl) patterns"
diff --git a/onboarding/sangetsu-lilycolor/viewer/public/index.html b/onboarding/sangetsu-lilycolor/viewer/public/index.html
new file mode 100644
index 00000000..bd89949a
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/viewer/public/index.html
@@ -0,0 +1,128 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sangetsu + Lilycolor — Staged SKUs (offline preview)</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; }
+ 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); }
+ .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; }
+ .ttl { color:var(--mut); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
+ .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>
+<body>
+<header>
+ <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>
+ <button data-src="lilycolor">Lilycolor</button>
+ <button data-src="sangetsu">Sangetsu</button>
+ </div>
+ <input type="search" id="q" placeholder="Search SKU or pattern…">
+ <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="width">Width ↑</option>
+ </select>
+ </label>
+ <label class="dens">Density
+ <input type="range" id="dens" min="3" max="10" value="6">
+ </label>
+ </div>
+</header>
+<div class="grid" id="grid"></div>
+<div id="sentinel"></div>
+<div id="status">loading…</div>
+
+<script>
+const grid = document.getElementById('grid');
+const statusEl = document.getElementById('status');
+const subEl = document.getElementById('sub');
+const LIMIT = 120;
+let state = {
+ source: localStorage.getItem('dw_src') || 'all',
+ sort: localStorage.getItem('dw_sort') || 'newest',
+ q: '',
+ 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); };
+
+// restore sort/source UI
+document.getElementById('sort').value = state.sort;
+document.querySelectorAll('#srcSeg button').forEach(b => b.classList.toggle('on', b.dataset.src === state.source));
+
+function reset() { grid.innerHTML=''; state.offset=0; state.done=false; statusEl.textContent='loading…'; load(); }
+
+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); reset();
+};
+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(); reset(); }, 250); };
+
+function card(r) {
+ const el = document.createElement('div'); el.className='card';
+ const thumb = r.image
+ ? `<div class="thumb" style="background-image:url('${r.image}')"><span class="badge ${r.source}">${r.source==='lilycolor'?'Lily':'Sangetsu'}</span>${r.is_sample?'<span class="badge sample">sample</span>':''}</div>`
+ : `<div class="thumb empty"><span class="badge ${r.source}">${r.source==='lilycolor'?'Lily':'Sangetsu'}</span>no image</div>`;
+ const specs = [r.width, r.composition, r.fire].filter(Boolean).map(s=>`<span>${s}</span>`).join('');
+ el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku||'—'}</div><div class="ttl" title="${(r.title||'').replace(/"/g,'"')}">${r.title||''}</div><div class="spec">${specs}</div></div>`;
+ if (r.url) { el.style.cursor='pointer'; el.onclick=()=>window.open(r.url,'_blank'); }
+ return el;
+}
+
+async function load() {
+ if (state.loading || state.done) return;
+ state.loading = true;
+ const p = new URLSearchParams({ source:state.source, sort:state.sort, q:state.q, offset:state.offset, limit:LIMIT });
+ const res = await fetch('/api/skus?'+p); 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;
+ state.loading = false;
+ subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs (Lily ${data.counts.lilycolor.toLocaleString()} / Sangetsu ${data.counts.sangetsu.toLocaleString()}) · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}`;
+ statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} SKUs —` : 'scroll for more…';
+}
+
+// infinite scroll
+new IntersectionObserver((es)=>{ if (es[0].isIntersecting) load(); }, { rootMargin:'600px' }).observe(document.getElementById('sentinel'));
+load();
+</script>
+</body>
+</html>
diff --git a/onboarding/sangetsu-lilycolor/viewer/server.js b/onboarding/sangetsu-lilycolor/viewer/server.js
new file mode 100644
index 00000000..aaf2e178
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/viewer/server.js
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+/**
+ * Staging viewer for the Sangetsu + Lilycolor onboarding catalogs (OFFLINE / read-only).
+ * Reads the two staging JSONL files, flattens them to a uniform SKU row, and serves a
+ * paginated /api/skus endpoint behind a sort + density + infinite-scroll grid.
+ *
+ * HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
+ * node viewer/server.js [port] (default 9931)
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '9931', 10);
+const DIR = path.join(__dirname, '..', 'staging');
+const LILY = path.join(DIR, 'lilycolor-staging.jsonl');
+const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
+const PUBLIC = path.join(__dirname, 'public');
+
+const readJsonl = (f) => (fs.existsSync(f)
+ ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+ : []);
+
+// Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
+function buildRows() {
+ const rows = [];
+ // Lilycolor: one product = one row (its mfr_sku is the colorway)
+ for (const r of readJsonl(LILY)) {
+ rows.push({
+ source: 'lilycolor',
+ sku: r.mfr_sku || r.handle,
+ prefix: r.mfr_prefix || null,
+ title: r.title_ja || r.handle,
+ width: r.width || null,
+ composition: r.composition || null,
+ fire: r.fire_grade || null,
+ image: (r.images && r.images[0]) || null,
+ url: r.handle ? `https://shop.lilycolor.co.jp/products/${r.handle}` : null,
+ is_sample: !!(r.product_type && /サンプル/.test(r.product_type)) || /_{3}sample/.test(r.handle || ''),
+ });
+ }
+ // Sangetsu: one PATTERN explodes to N colorway SKUs, each with its own full-res image
+ for (const r of readJsonl(SANG)) {
+ const imgs = r.sku_images || {};
+ for (const sku of (r.colorway_skus || [])) {
+ rows.push({
+ source: 'sangetsu',
+ sku,
+ prefix: r.mfr_prefix || null,
+ title: r.pattern || sku,
+ width: (r.spec && r.spec.width) || null,
+ composition: (r.spec && r.spec.is_grasscloth) ? 'Grasscloth' : null,
+ fire: (r.spec && r.spec.type) || null,
+ image: imgs[sku] || null,
+ url: r.source_url || null,
+ is_sample: false,
+ });
+ }
+ }
+ return rows;
+}
+
+let ROWS = buildRows();
+const reload = () => { ROWS = buildRows(); };
+fs.watchFile(SANG, { interval: 4000 }, reload); // pick up Sangetsu staging as it grows
+fs.watchFile(LILY, { interval: 8000 }, reload);
+
+const sorters = {
+ newest: null, // server natural order
+ sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
+ title: (a, b) => String(a.title).localeCompare(String(b.title)),
+ width: (a, b) => (parseFloat(a.width) || 0) - (parseFloat(b.width) || 0),
+};
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, `http://localhost:${PORT}`);
+ if (u.pathname === '/api/skus') {
+ const src = u.searchParams.get('source') || 'all';
+ const q = (u.searchParams.get('q') || '').toLowerCase();
+ const sort = u.searchParams.get('sort') || 'newest';
+ const offset = parseInt(u.searchParams.get('offset') || '0', 10);
+ const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
+ let rows = ROWS;
+ if (src !== 'all') rows = rows.filter((r) => r.source === src);
+ if (q) rows = rows.filter((r) => (r.sku + ' ' + r.title).toLowerCase().includes(q));
+ const counts = { all: ROWS.length, lilycolor: ROWS.filter((r) => r.source === 'lilycolor').length, sangetsu: ROWS.filter((r) => r.source === 'sangetsu').length };
+ if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+ const page = rows.slice(offset, offset + limit);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
+ return;
+ }
+ // static
+ let p = u.pathname === '/' ? '/index.html' : u.pathname;
+ const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
+ if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
+ const ext = path.extname(file);
+ const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain';
+ res.writeHead(200, { 'Content-Type': mime });
+ res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+ console.log(`Staging viewer → http://localhost:${PORT} (lilycolor ${ROWS.filter((r) => r.source === 'lilycolor').length} / sangetsu ${ROWS.filter((r) => r.source === 'sangetsu').length} SKUs)`);
+});
← a2399f8e stage free-samples banner on dedicated dev theme [Dev] free-
·
back to Designer Wallcoverings
·
free-samples phase 2: verified live-main theme role, staged d477dbc9 →