[object Object]

← back to Greenland China

greenland viewer: material+with-image filters, color-dot sorts, dominant-hex extractor; QA report + DW onboarding map

ea895c2086180a418f17e18898e7186bcb0ff005 · 2026-07-01 09:57:16 -0700 · Steve

Files touched

Diff

commit ea895c2086180a418f17e18898e7186bcb0ff005
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 1 09:57:16 2026 -0700

    greenland viewer: material+with-image filters, color-dot sorts, dominant-hex extractor; QA report + DW onboarding map
---
 ONBOARDING-MAP.md         | 62 +++++++++++++++++++++++++++++++++++++++
 QA-REPORT.md              | 71 +++++++++++++++++++++++++++++++++++++++++++++
 scripts/extract-colors.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++
 viewer/public/index.html  | 34 +++++++++++++++++++---
 viewer/server.js          | 52 +++++++++++++++++++++++++++++++--
 5 files changed, 287 insertions(+), 6 deletions(-)

diff --git a/ONBOARDING-MAP.md b/ONBOARDING-MAP.md
new file mode 100644
index 0000000..fef8563
--- /dev/null
+++ b/ONBOARDING-MAP.md
@@ -0,0 +1,62 @@
+# Greenland → Designer Wallcoverings — Onboarding Field Map
+
+_Planning doc only. Greenland is **net-new** — not in `dw_unified`. Nothing here writes
+to Shopify or `dw_unified`; those steps are Steve-gated._
+
+## Open decisions for Steve (blockers before mapping is final)
+
+1. **Private-label or named?** Greenland is China-direct. DW's pattern for such lines is
+   to relabel (WallQuest → *Malibu*, Command54 → *Phillipe Romano*, Justin David →
+   *Phillipe Romano*). **Is "Greenland" allowed to be customer-facing, or does it get a
+   DW house label?** This decides titles, tags, image alt text, and leak-scanner rules.
+2. **DW series / SKU prefix.** New vendor needs a `DWxx` series code (e.g. the way
+   Sister Parish = `DWPH`). What prefix?
+3. **Cost basis.** No usable price in the feed (`unit_price` = placeholder `2`). Need a
+   real cost list/quote → retail via `cost / 0.65 / 0.85`.
+
+## Field mapping
+
+| Greenland field | DW target | Transform / notes |
+|---|---|---|
+| `id` | internal `vendor_ref` | stable unique key; **use this**, not `sku` |
+| `sku` / `code` | mfr SKU → DW variant SKU (`<PREFIX><n>`) | mfr code for 786/800; normalize ~14 color-name outliers first |
+| `pattern` | product **title** / pattern name | human name; if private-label, rename per house convention |
+| `code` (color name, on outliers) | **colorway / option value** | inconsistent — derive color from title/enrichment where `code` is a mfr code |
+| `category` | product type | Wallcovering (795) / Engineering wallpaper (5) |
+| `wall_class` | **material** metafield + tag(s) | 20 values; drives the ≥2-tags rule |
+| `wall_type` | collection / **book** | only 50% present |
+| `width` | `width` spec metafield | e.g. `91.5 cm/36"` — split value+unit |
+| `repeat` | `repeat` spec metafield | ⚠️ **0% captured — must re-scrape** |
+| `fire_rating` | `fire_rating` metafield | mostly `ASTM E84 Class A` |
+| `backing` | `backing` metafield | e.g. `Non Woven` |
+| `weight` | `weight` metafield | ⚠️ confirm units (g/m²?) |
+| `composition` | `composition` metafield | only 21% present |
+| `minimum_order` | MOQ / min-order field | e.g. `5.5 meters / 6 yards` |
+| `cover_image` + `images` | product images | 7 rows have none — fix/drop |
+| `colorways` | linked colorway variants | only 18% present |
+| `unit_price` | **do not use** | placeholder `2`; price from cost basis instead |
+| `url` | `vendor_source_url` (admin only) | never customer-facing |
+
+## DW 5-field readiness (per active-SKU rule)
+
+Every active DW SKU needs: (1) sample variant, (2) sellable variant, (3) complete
+pricing, (4) description, (5) ≥2 tags. Greenland status today:
+
+| Requirement | Status from feed |
+|---|---|
+| Sample variant | ✖ must be constructed at import |
+| Sellable variant | ✖ must be constructed at import |
+| Complete pricing | ✖ **blocked — no cost basis** |
+| Description | ✖ none in feed — must be generated (dw-marketing-copy) |
+| ≥2 tags | ◑ derivable now from `category` + `wall_class` (+ `wall_type` where present) |
+
+## Suggested pipeline (once decisions above are made)
+
+1. Fix scraper (`repeat`, colorways, composition) + re-pull.
+2. Secure cost list → compute retail (`cost/0.65/0.85`).
+3. Normalize identity (mfr code → SKU, de-dupe, resolve color).
+4. Assign DW series/prefix + private-label decision.
+5. Generate descriptions + tags; build sample + sellable variants.
+6. Settlement gate is **not** applicable (no tropical/foliage AI generation here) — but
+   run the standard leak-scanner rules if relabeled.
+7. Steve-gated: push to `dw_unified` / Shopify.
diff --git a/QA-REPORT.md b/QA-REPORT.md
new file mode 100644
index 0000000..9e5ba79
--- /dev/null
+++ b/QA-REPORT.md
@@ -0,0 +1,71 @@
+# Greenland Wallcoverings — Staging Catalog QA
+
+_Read-only audit of `staging/greenland.jsonl` · 800 SKUs · generated 2026-07-01_
+
+## Summary
+
+800 staged products — **795 Wallcovering + 5 Engineering wallpaper**. Structural
+capture is strong (image + core spec coverage all ≥96%), but three things **block a
+DW import today**: no real pricing, `repeat` never captured, and a handful of
+identity/image gaps. None are fatal; all are fixable at the scraper or enrichment step.
+
+## Field coverage (non-null / non-empty, of 800)
+
+| Field | Coverage | Notes |
+|---|---|---|
+| id, sku, code, pattern, name, category, weight, unit_price, url | 100% | see caveats below |
+| cover_image / images | 99.1% | 7 products have none |
+| sub_categories | 99.5% | |
+| use | 98.9% | |
+| backing | 97.9% | |
+| width | 97.8% | |
+| fire_rating | 96.6% | mostly `ASTM E84 Class A` |
+| minimum_order | 95.9% | e.g. `5.5 meters / 6 yards` |
+| wall_class (material) | 91.5% | 20 distinct materials |
+| **wall_type** | **50.3%** | half the catalog has no book/type |
+| **composition** | **21.1%** | enrichment gap |
+| **colorways** | **18.4%** | most rows carry no colorway array |
+| **repeat** | **0.0%** | **never captured — field is always null** |
+
+## Blockers (fix before any DW onboarding)
+
+1. **No real pricing.** `unit_price` is the literal value `2` for **all 800** rows — a
+   placeholder, not a price. Greenland is a China-direct vendor, so cost is
+   quote/negotiated. Per the standing rule (every scraper confirms **cost + discount**
+   before import), onboarding is blocked until a real cost basis exists. Retail would
+   then follow the DW formula `cost / 0.65 / 0.85`.
+2. **`repeat` = 0% captured.** The scraper (`scripts/scrape.js`) never populates repeat.
+   Repeat is a required spec for wallcovering PDPs — needs a scraper fix + re-pull.
+3. **7 products with no image** — ids `651, 525, 493, 748, 678, 675, 668`. Cannot
+   merchandise without a cover image; re-scrape or drop.
+
+## Data-quality issues (fix at normalize step)
+
+4. **SKU identity is ~98% clean, not 100%.** The alphanumeric manufacturer code lives in
+   `sku`/`code` for **786/800** rows (e.g. `V020P1455`). ~14 outliers instead carry a
+   **color name** in `sku` (e.g. `Warm White`, `JUNGLE PULSE`) with the real code in
+   `pattern`. The only **duplicate SKU** is `"JUNGLE PULSE"` (ids `260` + `262`).
+   → The stable unique key is **`id`**; treat `sku` as mfr-code only after normalizing
+   those ~14 rows.
+5. **Field-role note.** `pattern` = human pattern name; `code`/`sku` = mfr code;
+   `name` mirrors `pattern` (or the code on the outliers). `sku === code` for all 800.
+6. **`weight` units unconfirmed** — bare integers (e.g. `270`). Confirm g/m² vs g/roll
+   before trusting it as a spec.
+7. **Enrichment gaps** — `wall_type` 50%, `composition` 21%, `colorways` 18%. Fine for a
+   preview; incomplete for a customer-facing PDP.
+
+## What's good
+
+- Material taxonomy is rich and usable now: Sisal 139, Vinyl 100, PE-Weave 98, Cork 95,
+  Wood 72, Hemp 41, Abaca 40, Linen 39, Paper-Weave 26, Suede 21, Silk 16 … (20 total).
+- Images, width, backing, fire rating, MOQ all ≥95% — enough to preview + spec-sheet.
+
+## Recommended next steps
+
+- [ ] Patch `scripts/scrape.js` to capture **repeat** (+ colorways/composition where present), re-pull.
+- [ ] Get a real **cost basis** from Greenland (quote) → unblock pricing.
+- [ ] Normalize the ~14 color-name-as-SKU outliers; de-dupe `JUNGLE PULSE`.
+- [ ] Re-scrape or drop the 7 image-less ids.
+- [ ] Confirm `weight` units.
+- [ ] **Decide private-label vs. named** (see `ONBOARDING-MAP.md`) — a China-direct line
+      may need relabeling like WallQuest→Malibu / Command54→Phillipe Romano.
diff --git a/scripts/extract-colors.py b/scripts/extract-colors.py
new file mode 100644
index 0000000..7f10f01
--- /dev/null
+++ b/scripts/extract-colors.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""
+Dominant-hex extractor for the Greenland staging catalog (offline / read-only).
+Downloads each SKU's cover image from Greenland's CDN, shrinks it to a single
+representative pixel, and writes {id: "#rrggbb"} to staging/greenland-colors.json
+so the viewer can offer Light->Dark / Dark->Light / Color-Wheel sorts + a per-card
+color dot (Steve's standing sort-skill pattern, sips/PIL variant).
+
+Resumable: re-running skips ids already in the cache. Safe to Ctrl-C; it flushes
+every 25 rows.
+"""
+import json, os, io, sys, urllib.request
+from PIL import Image
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+STAGING = os.path.join(HERE, "..", "staging")
+SRC = os.path.join(STAGING, "greenland.jsonl")
+OUT = os.path.join(STAGING, "greenland-colors.json")
+REFERER = "http://m.greenlandwallcoverings.com/"
+UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
+
+def load_rows():
+    rows = []
+    with open(SRC, encoding="utf-8") as f:
+        for line in f:
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                rows.append(json.loads(line))
+            except Exception:
+                pass
+    return rows
+
+def dominant_hex(url):
+    req = urllib.request.Request(url, headers={"User-Agent": UA, "Referer": REFERER})
+    with urllib.request.urlopen(req, timeout=20) as r:
+        raw = r.read()
+    im = Image.open(io.BytesIO(raw)).convert("RGB")
+    # median-ish representative: shrink to 16x16 then to 1x1 (box filter = average,
+    # the two-step avoids one hot pixel dominating on busy patterns)
+    im = im.resize((16, 16)).resize((1, 1))
+    r, g, b = im.getpixel((0, 0))
+    return "#%02x%02x%02x" % (r, g, b)
+
+def main():
+    rows = load_rows()
+    cache = {}
+    if os.path.exists(OUT):
+        try:
+            cache = json.load(open(OUT))
+        except Exception:
+            cache = {}
+    todo = [r for r in rows if str(r.get("id")) not in cache and (r.get("cover_image") or (r.get("images") or [None])[0])]
+    print(f"{len(rows)} rows · {len(cache)} cached · {len(todo)} to fetch", flush=True)
+    done = 0
+    for r in todo:
+        rid = str(r.get("id"))
+        url = r.get("cover_image") or (r.get("images") or [None])[0]
+        try:
+            cache[rid] = dominant_hex(url)
+        except Exception as e:
+            cache[rid] = None
+            print(f"  id={rid} FAILED: {e}", flush=True)
+        done += 1
+        if done % 25 == 0:
+            json.dump(cache, open(OUT, "w"))
+            print(f"  {done}/{len(todo)} …", flush=True)
+    json.dump(cache, open(OUT, "w"), indent=0)
+    ok = sum(1 for v in cache.values() if v)
+    print(f"DONE · {ok}/{len(cache)} colors resolved → {OUT}", flush=True)
+
+if __name__ == "__main__":
+    main()
diff --git a/viewer/public/index.html b/viewer/public/index.html
index 0bea6d0..7cb4483 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -23,6 +23,7 @@
   .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; }
@@ -39,17 +40,24 @@
     <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>
@@ -64,12 +72,16 @@ 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,
+  offset: 0, total: 0, loading: false, done: false, catsBuilt: false, matsBuilt: false,
 };
 
 // density persists
@@ -79,10 +91,13 @@ 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); };
 
@@ -98,8 +113,9 @@ function card(r) {
     : `<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">${r.sku||'—'}${imgBadge}</div>
+    <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>`:''}
@@ -119,12 +135,22 @@ function buildCats(categories) {
   state.catsBuilt = true;
 }
 
+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);
+  }
+  matSel.value = state.material;
+  state.matsBuilt = true;
+}
+
 async function load() {
   if (state.loading || state.done) return;
   state.loading = true;
-  const p = new URLSearchParams({ category:state.category, sort:state.sort, q:state.q, offset:state.offset, limit:LIMIT });
+  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);
+  buildCats(data.categories); buildMats(data.materials);
   state.total = data.total;
   const frag = document.createDocumentFragment();
   data.rows.forEach(r => frag.appendChild(card(r)));
diff --git a/viewer/server.js b/viewer/server.js
index 64f6c35..a300696 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -20,16 +20,40 @@ const path = require('path');
 const PORT = parseInt(process.argv[2] || '0', 10);
 const DIR = path.join(__dirname, '..', 'staging');
 const GREEN = path.join(DIR, 'greenland.jsonl');
+const COLORS = path.join(DIR, 'greenland-colors.json');
 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)
   : []);
 
+// dominant-hex cache (produced by scripts/extract-colors.py). Absent = color sorts fall back to newest.
+const readColors = () => { try { return JSON.parse(fs.readFileSync(COLORS, 'utf8')); } catch { return {}; } };
+let COLOR = readColors();
+
+// #rrggbb -> {h,s,l} for hue/lightness sorting
+function hsl(hex) {
+  if (!hex) return null;
+  const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
+  if (!m) return null;
+  const r = parseInt(m[1], 16) / 255, g = parseInt(m[2], 16) / 255, b = parseInt(m[3], 16) / 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, l = (mx + mn) / 2;
+  let h = 0, s = 0;
+  if (d) {
+    s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+    if (mx === r) h = (g - b) / d + (g < b ? 6 : 0);
+    else if (mx === g) h = (b - r) / d + 2;
+    else h = (r - g) / d + 4;
+    h *= 60;
+  }
+  return { h, s, l };
+}
+
 function buildRows() {
   const rows = [];
   for (const r of readJsonl(GREEN)) {
     const specs = [r.width, r.repeat, r.fire_rating, r.backing, r.weight ? r.weight + ' g' : null].filter(Boolean);
+    const hex = COLOR[String(r.id)] || null;
     rows.push({
       source: 'greenland',
       id: r.id,
@@ -37,6 +61,7 @@ function buildRows() {
       title: r.pattern || r.name || r.sku || String(r.id),
       color: r.code || null,
       category: r.category || null,
+      wall_classes: (r.wall_class && r.wall_class.length) ? r.wall_class : [],
       wall_class: (r.wall_class && r.wall_class.length) ? r.wall_class.join(', ') : null,
       wall_type: (r.wall_type && r.wall_type.length) ? r.wall_type.join(', ') : null,
       width: r.width || null,
@@ -50,6 +75,8 @@ function buildRows() {
       image: r.cover_image || (r.images && r.images[0]) || null,
       image_count: r.image_count || (r.images ? r.images.length : 0),
       colorway_count: r.colorways ? r.colorways.length : 0,
+      hex,
+      hsl: hsl(hex),
       url: r.url || null,
     });
   }
@@ -57,8 +84,11 @@ function buildRows() {
 }
 
 let ROWS = buildRows();
-fs.watchFile(GREEN, { interval: 5000 }, () => { ROWS = buildRows(); });
+fs.watchFile(GREEN, { interval: 5000 }, () => { COLOR = readColors(); ROWS = buildRows(); });
+if (fs.existsSync(COLORS)) fs.watchFile(COLORS, { interval: 5000 }, () => { COLOR = readColors(); ROWS = buildRows(); });
 
+// rows with no resolved color sort to the end of any color sort
+const colorLast = (a, b) => (a.hsl ? 0 : 1) - (b.hsl ? 0 : 1);
 const sorters = {
   newest: null, // server natural order (list order from vendor)
   sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
@@ -66,6 +96,9 @@ const sorters = {
   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),
+  lightdark: (a, b) => colorLast(a, b) || (b.hsl?.l ?? -1) - (a.hsl?.l ?? -1),
+  darklight: (a, b) => colorLast(a, b) || (a.hsl?.l ?? 2) - (b.hsl?.l ?? 2),
+  colorwheel: (a, b) => colorLast(a, b) || (a.hsl?.h ?? 999) - (b.hsl?.h ?? 999) || (b.hsl?.l ?? 0) - (a.hsl?.l ?? 0),
 };
 
 const categoriesOf = (rows) => {
@@ -74,22 +107,37 @@ const categoriesOf = (rows) => {
   return m;
 };
 
+const materialsOf = (rows) => {
+  const m = {};
+  for (const r of rows) for (const w of r.wall_classes) m[w] = (m[w] || 0) + 1;
+  return m;
+};
+
 const server = http.createServer((req, res) => {
   const u = new URL(req.url, `http://localhost`);
 
   if (u.pathname === '/api/skus') {
     const cat = u.searchParams.get('category') || 'all';
+    const material = u.searchParams.get('material') || 'all';
+    const withimg = u.searchParams.get('withimg') === '1';
     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 (cat !== 'all') rows = rows.filter((r) => (r.category || 'Uncategorized') === cat);
+    if (material !== 'all') rows = rows.filter((r) => r.wall_classes.includes(material));
+    if (withimg) rows = rows.filter((r) => !!r.image);
     if (q) rows = rows.filter((r) => (r.sku + ' ' + r.title + ' ' + (r.color || '') + ' ' + (r.wall_class || '')).toLowerCase().includes(q));
     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, all: ROWS.length, categories: categoriesOf(ROWS), offset, limit, rows: page }));
+    res.end(JSON.stringify({
+      total: rows.length, all: ROWS.length,
+      categories: categoriesOf(ROWS), materials: materialsOf(ROWS),
+      colors_ready: Object.keys(COLOR).length,
+      offset, limit, rows: page,
+    }));
     return;
   }
 

← 4ffa8a5 auto-save: 2026-06-30T10:09:30 (1 files) — staging/greenland  ·  back to Greenland China  ·  extract-colors: URL-encode filenames w/ spaces + retry nulls 9e6e870 →