[object Object]

← back to Wallco Ai

Unified /api/bg-textures — title-keyword classification + dedup

091fbfa12b4fb16551dcfe125e35e89051beba0d · 2026-05-25 00:04:40 -0700 · Steve Abrams

- Drop wrong SKU-prefix assumptions (COR- = Coordonné vendor, not cork;
  PRN- = Phillipe Romano, not print). Replace with title-keyword
  classification across 17 material categories: grasscloth, madagascar,
  raffia, sisal, abaca, jute, hemp, seagrass, paperweave, burlap, mica,
  cork, vellum, rice-paper, silk, linen, natural.
- Keyword detection runs against shopify_products filtered by
  product_type ILIKE 'wallcovering' — catches Phillipe Romano Naturals
  (DWKK-*), Coordonné texture lines, all vendors' natural-material
  series without needing a per-vendor allowlist.
- Vendor names stripped via cleanTitleSql chain: drop "| vendor" segment,
  "- Sample" suffix, "- Type N" + tail, trailing "Wallpaper/Wallcovering/
  Vinyl" descriptors. "Albany Linen-Flint Durable Vinyl | Phillipe Romano"
  → "Albany Linen-Flint Durable".
- DISTINCT ON (LOWER(name)) dedup — prefers rows with hex over null.
  Hex coverage 62% (3,109 / 5,034) vs 0% before.
- Sort modes: name | category | color (LAB hue-wheel via Math.atan2(b*,a*)).
- LIMIT param caps dw-catalog at 5,000 default for response-size sanity.

lib/db.js: psqlQuery + psqlExecLocal maxBuffer raised to 200 MB.
Default execSync buffer (~1 MB) was overflowing on the unified 5k-row
JSON pull → spawnSync ENOBUFS.

Files touched

Diff

commit 091fbfa12b4fb16551dcfe125e35e89051beba0d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 25 00:04:40 2026 -0700

    Unified /api/bg-textures — title-keyword classification + dedup
    
    - Drop wrong SKU-prefix assumptions (COR- = Coordonné vendor, not cork;
      PRN- = Phillipe Romano, not print). Replace with title-keyword
      classification across 17 material categories: grasscloth, madagascar,
      raffia, sisal, abaca, jute, hemp, seagrass, paperweave, burlap, mica,
      cork, vellum, rice-paper, silk, linen, natural.
    - Keyword detection runs against shopify_products filtered by
      product_type ILIKE 'wallcovering' — catches Phillipe Romano Naturals
      (DWKK-*), Coordonné texture lines, all vendors' natural-material
      series without needing a per-vendor allowlist.
    - Vendor names stripped via cleanTitleSql chain: drop "| vendor" segment,
      "- Sample" suffix, "- Type N" + tail, trailing "Wallpaper/Wallcovering/
      Vinyl" descriptors. "Albany Linen-Flint Durable Vinyl | Phillipe Romano"
      → "Albany Linen-Flint Durable".
    - DISTINCT ON (LOWER(name)) dedup — prefers rows with hex over null.
      Hex coverage 62% (3,109 / 5,034) vs 0% before.
    - Sort modes: name | category | color (LAB hue-wheel via Math.atan2(b*,a*)).
    - LIMIT param caps dw-catalog at 5,000 default for response-size sanity.
    
    lib/db.js: psqlQuery + psqlExecLocal maxBuffer raised to 200 MB.
    Default execSync buffer (~1 MB) was overflowing on the unified 5k-row
    JSON pull → spawnSync ENOBUFS.
---
 lib/db.js |   4 +-
 server.js | 484 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 475 insertions(+), 13 deletions(-)

diff --git a/lib/db.js b/lib/db.js
index a781ce4..af44320 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -27,7 +27,7 @@ const PSQL_CMD = (process.platform === 'linux')
 // double-quote injection. (SEC-1 fix in commit cf388a2 covers this for the
 // last remaining shell-pipe site.)
 function psqlQuery(sql) {
-  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
+  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8', maxBuffer: 200_000_000 }).trim();
 }
 
 // PG value escaper for inline SQL — matches the project's existing inline-string style.
@@ -44,7 +44,7 @@ function pgEsc(v) {
 // (like `\d`, `\l`) — yielding `invalid command \nINSERT`. Stdin-feed avoids all shell
 // escaping and supports INSERT...RETURNING + multi-statement batches identically.
 function psqlExecLocal(sql) {
-  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
+  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8', maxBuffer: 200_000_000 }).trim();
 }
 
 module.exports = {
diff --git a/server.js b/server.js
index 247659d..4560cb0 100644
--- a/server.js
+++ b/server.js
@@ -8115,6 +8115,96 @@ app.get('/api/categories', (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// POST /api/dedupe/scan — perceptual-hash near-duplicate detector.
+// Body: { status?: 'published', category?: '', limit?: 500, threshold?: 8 }
+// Returns: { ok, clusters: [{ keeper_id, dupes: [id,...] }], scanned, time_ms }
+//
+// Pure-PIL dHash (8x8 difference hash, 64-bit). No imagehash install. Hamming
+// distance ≤ threshold = visually near-identical. Threshold 8 catches "same
+// composition, different colorway" duplicates. Keeper = lowest id in cluster.
+app.post('/api/dedupe/scan', express.json({ limit: '4kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const status = String(req.body?.status || 'published').toLowerCase();
+  const category = String(req.body?.category || '').toLowerCase();
+  const limit = Math.min(2000, Math.max(1, parseInt(req.body?.limit || '500', 10)));
+  const threshold = Math.min(20, Math.max(0, parseInt(req.body?.threshold || '8', 10)));
+  let where = "brand='wallco.ai' AND local_path IS NOT NULL";
+  if (status === 'published')        where += ' AND is_published=TRUE AND (user_removed IS NOT TRUE)';
+  else if (status === 'unpublished') where += ' AND is_published=FALSE AND (user_removed IS NOT TRUE)';
+  if (category) where += ` AND category='${category.replace(/'/g, "''")}'`;
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.id), '[]'::json) FROM (
+      SELECT id, local_path FROM spoon_all_designs
+      WHERE ${where} ORDER BY id ASC LIMIT ${limit}
+    ) t;`);
+    const items = JSON.parse(raw || '[]').filter(r => r.local_path);
+    if (!items.length) return res.json({ ok: true, clusters: [], scanned: 0, time_ms: 0 });
+    const t0 = Date.now();
+    const pathsJson = JSON.stringify(items.map(r => [r.id, r.local_path]));
+    const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+pairs = json.loads(sys.argv[1])
+def dhash(path):
+    try:
+        img = Image.open(path).convert('L').resize((9, 8), Image.LANCZOS)
+        px = list(img.getdata())
+        bits = 0
+        for y in range(8):
+            for x in range(8):
+                left = px[y * 9 + x]
+                right = px[y * 9 + x + 1]
+                bits = (bits << 1) | (1 if left > right else 0)
+        return bits
+    except Exception:
+        return None
+out = []
+for id, pth in pairs:
+    h = dhash(pth)
+    if h is not None:
+        # Emit 64-bit hash as two 32-bit halves so JS can XOR without precision loss.
+        out.append([id, (h >> 32) & 0xFFFFFFFF, h & 0xFFFFFFFF])
+print(json.dumps(out))
+`, pathsJson], { encoding: 'utf8', maxBuffer: 50_000_000 });
+    if (py.status !== 0) return res.status(500).json({ error: 'dhash python failed: ' + (py.stderr || '').slice(0, 240) });
+    let hashes;
+    try { hashes = JSON.parse(py.stdout); }
+    catch { return res.status(500).json({ error: 'parse hashes failed' }); }
+    const n = hashes.length;
+    const parent = Array.from({ length: n }, (_, i) => i);
+    const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
+    const unite = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
+    // 32-bit popcount via SWAR (Hamming weight). JS bitwise ops are int32 so this is precision-safe.
+    const popcount32 = (v) => {
+      v = v - ((v >>> 1) & 0x55555555);
+      v = (v & 0x33333333) + ((v >>> 2) & 0x33333333);
+      v = (v + (v >>> 4)) & 0x0f0f0f0f;
+      return (v * 0x01010101) >>> 24;
+    };
+    for (let i = 0; i < n; i++) {
+      const hi_i = hashes[i][1], lo_i = hashes[i][2];
+      for (let j = i + 1; j < n; j++) {
+        const dist = popcount32((hi_i ^ hashes[j][1]) >>> 0) + popcount32((lo_i ^ hashes[j][2]) >>> 0);
+        if (dist <= threshold) unite(i, j);
+      }
+    }
+    const byRoot = new Map();
+    for (let i = 0; i < n; i++) {
+      const r = find(i);
+      if (!byRoot.has(r)) byRoot.set(r, []);
+      byRoot.get(r).push(hashes[i][0]);
+    }
+    const clusters = [...byRoot.values()]
+      .filter(g => g.length > 1)
+      .map(g => {
+        const keeper_id = Math.min(...g);
+        return { keeper_id, dupes: g.filter(id => id !== keeper_id).sort((a, b) => a - b) };
+      })
+      .sort((a, b) => b.dupes.length - a.dupes.length);
+    res.json({ ok: true, clusters, scanned: n, time_ms: Date.now() - t0, threshold });
+  } catch (e) { console.error('[dedupe/scan]', e); res.status(500).json({ error: e.message }); }
+});
+
 // GET /admin/status-browser — one place to see ALL designs with ALL statuses.
 app.get('/admin/status-browser', (req, res) => {
   if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
@@ -8292,14 +8382,175 @@ app.get('/admin/bg-tester', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'bg-tester.html'));
 });
 
+// GET /api/bg-textures — UNIFIED "DW Textures" picker. Pulls from THREE
+// sources and presents them as a single library the client sees as
+// generically "DW Textures":
+//   1. bg_textures table — 10 hand-curated DW textures with hex/material/desc
+//   2. wallco_textures — 24 wikimedia natural-material references
+//   3. shopify_products — wallcovering rows whose title contains a real
+//      natural-texture keyword (grasscloth/linen/cork/raffia/mica/madagascar/etc).
+//      Title keyword IS the category — SKU prefix not used as a category
+//      signal because COR-/PRN- turned out to be vendor codes (Coordonné /
+//      Phillipe Romano), not material codes. Joins shopify_color_enrichment for hex.
+// Vendor names stripped from titles before exposing — clients see clean
+// names like "Madagascar Heavy Green Washed", not "...| Phillipe Romano".
+//
+// Query params:
+//   ?category=<grasscloth|linen|cork|mica|raffia|madagascar|silk|sisal|jute|...>
+//   ?sort=name|category|color (default: category then name)
+//   ?limit=<n> (default 5000)
 app.get('/api/bg-textures', (req, res) => {
+  const cat   = String(req.query.category || '').toLowerCase();
+  const sort  = String(req.query.sort     || 'category').toLowerCase();
+  const limit = Math.min(parseInt(req.query.limit, 10) || 5000, 10000);
   try {
-    const raw = psqlQuery(`SELECT json_agg(t ORDER BY t.name) FROM (
-      SELECT id, slug, name, material, color_family, hex, description, image_url
-      FROM bg_textures
-    ) t;`);
-    const items = raw ? JSON.parse(raw) : [];
-    res.json({ ok: true, items: items || [] });
+    // Title-keyword → category. First-match-wins, ordered most-specific first
+    // so e.g. "Madagascar Linen" classifies as madagascar not linen.
+    const KEYWORDS = [
+      [`grass\\s*cloth|grasscloth`, 'grasscloth'],
+      [`madagascar`,                'madagascar'],
+      [`raffia`,                    'raffia'],
+      [`sisal`,                     'sisal'],
+      [`abaca`,                     'abaca'],
+      [`jute`,                      'jute'],
+      [`hemp`,                      'hemp'],
+      [`seagrass|sea\\s*grass`,     'seagrass'],
+      [`paper\\s*weave|paperweave`, 'paperweave'],
+      [`burlap`,                    'burlap'],
+      [`mica`,                      'mica'],
+      [`cork`,                      'cork'],
+      [`vellum`,                    'vellum'],
+      [`rice\\s*paper`,             'rice-paper'],
+      [`silk\\s*weave|silk`,        'silk'],
+      [`linen`,                     'linen'],
+      [`natural\\s*textile|natural\\s*texture`, 'natural'],
+    ];
+    const titleCaseSql = `CASE\n      ` +
+      KEYWORDS.map(([re, c]) => `WHEN p.title ~* '\\m(${re})\\M' THEN '${c}'`).join('\n      ') +
+      `\n      ELSE NULL\n    END`;
+    const titleWhereSql = `(p.title ~* '\\m(${KEYWORDS.map(([re])=>re).join('|')})\\M')`;
+    const safeCat = cat.replace(/'/g, "''");
+    const catFilter = cat ? ` AND (${titleCaseSql}) = '${safeCat}'` : '';
+    // Title-clean chain (applied bottom-up, all case-insensitive):
+    //   - take first "|" segment (drop vendor/series tail)
+    //   - strip "- Sample" suffix
+    //   - strip "- Type N" and anything after it
+    //   - strip trailing "- ... Wallcoverings" descriptor segments
+    //   - strip trailing "Wallpaper" / "Wallcovering(s)" / "Vinyl"
+    //   - collapse internal multi-space, trim ends
+    const cleanTitleSql = `
+      regexp_replace(
+        regexp_replace(
+          regexp_replace(
+            regexp_replace(
+              regexp_replace(
+                regexp_replace(
+                  split_part(p.title, '|', 1),
+                  '\\s*-\\s*Sample\\s*$', '', 'i'),
+                '\\s*-\\s*Type\\s+\\d+\\b.*$', '', 'i'),
+              '\\s*-\\s*[^-]*Wallcoverings?\\s*$', '', 'i'),
+            '\\s+(Wallpaper|Wallcoverings?|Vinyl)\\s*$', '', 'gi'),
+          '\\s+', ' ', 'g'),
+        '^\\s+|\\s+$', '', 'g')`;
+    const sql = `
+SELECT json_agg(t.*) FROM (
+  -- (1) hand-curated bg_textures
+  SELECT
+    'bg-' || id::text AS id,
+    slug,
+    name,
+    material AS category,
+    hex,
+    description,
+    image_url,
+    'curated' AS source
+  FROM bg_textures
+  ${cat ? `WHERE LOWER(material) = '${safeCat}'` : ''}
+
+  UNION ALL
+
+  -- (2) wikimedia natural-material refs
+  SELECT
+    'wm-' || id::text AS id,
+    LOWER(replace(name, ' ', '-')) AS slug,
+    name,
+    LOWER(category) AS category,
+    dominant_hex AS hex,
+    description,
+    image_url,
+    'wikimedia' AS source
+  FROM wallco_textures
+  ${cat ? `WHERE LOWER(category) = '${safeCat}'` : ''}
+
+  UNION ALL
+
+  -- (3) actual DW catalog textures — title-keyword classification,
+  --     vendor-stripped, DEDUP by cleaned name (prefer rows w/ hex),
+  --     capped at ${limit} for response-size sanity
+  SELECT id, slug, name, category, hex, description, image_url, source
+  FROM (
+    SELECT DISTINCT ON (LOWER(name))
+      id, slug, name, category, hex, description, image_url, source
+    FROM (
+      SELECT
+        'sk-' || p.shopify_id::text AS id,
+        LOWER(replace(COALESCE(p.dw_sku, p.shopify_id::text), '_', '-')) AS slug,
+        CASE
+          WHEN ${cleanTitleSql} = '' OR ${cleanTitleSql} IS NULL
+            THEN COALESCE(p.dw_sku, p.shopify_id::text)
+          ELSE ${cleanTitleSql}
+        END AS name,
+        ${titleCaseSql} AS category,
+        COALESCE(c.dominant_hex, c.background_hex) AS hex,
+        NULL::text AS description,
+        p.image_url,
+        'dw-catalog' AS source
+      FROM shopify_products p
+      LEFT JOIN shopify_color_enrichment c ON c.shopify_id = p.shopify_id
+      WHERE p.image_url IS NOT NULL
+        AND p.product_type ILIKE '%wallcovering%'
+        AND ${titleWhereSql}
+        ${catFilter}
+    ) raw
+    ORDER BY LOWER(name), (hex IS NULL) ASC, id ASC
+    LIMIT ${limit}
+  ) deduped
+) t;`;
+    const raw = psqlQuery(sql);
+    let items = raw ? (JSON.parse(raw) || []) : [];
+    // Sort in JS (LAB-distance sort needs hex parsing — do it here, not in SQL)
+    function hexToLab(hex) {
+      if (!hex) return null;
+      const m = /^#?([0-9a-f]{6})$/i.exec(hex);
+      if (!m) return null;
+      const r = parseInt(m[1].slice(0,2), 16) / 255;
+      const g = parseInt(m[1].slice(2,4), 16) / 255;
+      const b = parseInt(m[1].slice(4,6), 16) / 255;
+      // sRGB → linear → XYZ (D65) → LAB (CIE-1976)
+      const lin = c => c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
+      const R = lin(r), G = lin(g), B = lin(b);
+      const X = (R*0.4124 + G*0.3576 + B*0.1805) / 0.95047;
+      const Y = (R*0.2126 + G*0.7152 + B*0.0722);
+      const Z = (R*0.0193 + G*0.1192 + B*0.9505) / 1.08883;
+      const f = t => t > 0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
+      return [116*f(Y)-16, 500*(f(X)-f(Y)), 200*(f(Y)-f(Z))];
+    }
+    if (sort === 'color') {
+      // hue-wheel sort: order by (a*, b*) angle in LAB
+      items.forEach(t => {
+        const lab = hexToLab(t.hex);
+        t._h = lab ? Math.atan2(lab[2], lab[1]) : 999;
+        t._L = lab ? lab[0] : 999;
+      });
+      items.sort((a, b) => (a._h - b._h) || (a._L - b._L) || (a.name || '').localeCompare(b.name || ''));
+      items.forEach(t => { delete t._h; delete t._L; });
+    } else if (sort === 'name') {
+      items.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+    } else {
+      // category then name
+      items.sort((a, b) => (a.category || '').localeCompare(b.category || '') || (a.name || '').localeCompare(b.name || ''));
+    }
+    res.json({ ok: true, total: items.length, items });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
@@ -8817,6 +9068,32 @@ app.get('/design/:id', (req, res) => {
 <body>
 ${htmlHeader('/designs')}
 <main class="detail-main">
+<style>
+  /* Collapse-pane — tappable header row + chevron, closed by default.
+     Wraps non-essential panels (specs / calculator / coordinates / room
+     mockups / etc.) so the page lands clean instead of "busy". */
+  .collapse-pane { margin:14px 0; border:1px solid var(--line,#d8d0c0); border-radius:8px; background:var(--card-bg,#f7f3eb); overflow:hidden; }
+  .collapse-pane > summary {
+    list-style:none; cursor:pointer; padding:12px 16px;
+    font:600 12px var(--sans,system-ui); text-transform:uppercase; letter-spacing:.08em;
+    color:var(--ink-soft,#5a4628);
+    display:flex; align-items:center; justify-content:space-between; gap:10px;
+    user-select:none;
+  }
+  .collapse-pane > summary::-webkit-details-marker { display:none; }
+  .collapse-pane > summary::after {
+    content:'›'; font-size:20px; line-height:1; color:var(--ink-faint,#a08b6a);
+    transform:rotate(90deg); transition:transform .18s ease;
+    margin-left:8px;
+  }
+  .collapse-pane[open] > summary::after { transform:rotate(270deg); }
+  .collapse-pane > summary:hover { background:rgba(0,0,0,.025); }
+  .collapse-pane > .collapse-body { padding:6px 16px 16px; }
+  .collapse-pane > summary .cp-sub {
+    text-transform:none; letter-spacing:0; font-weight:400;
+    color:var(--ink-faint,#a08b6a); font-size:11px; margin-left:8px;
+  }
+</style>
   <div class="detail-layout">
     <div class="detail-image-col" style="position:relative">
       <!-- HAMBURGER ICON (top-right of image) opens the INFO + EDIT modal ─── -->
@@ -9016,6 +9293,23 @@ ${htmlHeader('/designs')}
         }
       </style>
 
+      <!-- Color strip above image: auto-extracted palette dots, drag onto image to swap colors -->
+      <div id="palette-strip" data-design-id="${design.id}"
+           style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px;padding:10px 12px;background:var(--card-bg,#f4f0e8);border:1px solid var(--line,#d8d0c0);border-radius:8px;min-height:46px">
+        <span style="font:600 10px ui-sans-serif,system-ui;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">Palette</span>
+        <span id="ps-hint" style="font:11px ui-sans-serif,system-ui;color:var(--ink-faint,#7a6e5a);opacity:.85">drag a dot onto the image to recolor</span>
+        <span id="ps-dots" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"></span>
+        <button id="ps-reset" type="button" style="margin-left:auto;display:none;padding:5px 12px;border:1px solid #c8b89a;border-radius:4px;background:#fff;color:#3a2f1c;font:11px ui-sans-serif,system-ui;cursor:pointer;letter-spacing:.04em">↺ Reset</button>
+      </div>
+      <style>
+        .ps-dot { width:30px; height:30px; border-radius:50%; border:2px solid rgba(0,0,0,.12); cursor:grab; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18); transition: transform 120ms ease, box-shadow 120ms ease; position:relative; }
+        .ps-dot:hover { transform: scale(1.12); box-shadow: 0 2px 8px rgba(0,0,0,.18), inset 0 0 0 1px rgba(255,255,255,.25); }
+        .ps-dot:active { cursor:grabbing; }
+        .ps-dot::after { content: attr(data-hex); position:absolute; top:36px; left:50%; transform:translateX(-50%); font:10px ui-monospace,Menlo,monospace; color:#7a6e5a; opacity:0; pointer-events:none; white-space:nowrap; transition: opacity 120ms ease; }
+        .ps-dot:hover::after { opacity:1; }
+        .detail-img-wrap.ps-drop-active { outline: 3px dashed #c8b89a; outline-offset: -3px; }
+      </style>
+
       <div class="detail-img-wrap" data-mural="${(design.kind === 'mural' || design.kind === 'mural_panel') ? '1' : '0'}" data-original-src="${design.image_url}" data-design-id="${design.id}">
         <img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" fetchpriority="high" decoding="async" crossorigin="anonymous">
         ${(design.kind === 'mural' || design.kind === 'mural_panel') ? `
@@ -9115,6 +9409,158 @@ ${htmlHeader('/designs')}
         </script>` : ''}
         <canvas id="detail-canvas" style="display:none"></canvas>
 
+        <!-- Palette-strip live recolor: extract top-N colors, drag onto image to swap -->
+        <script>
+        (function(){
+          var img = document.getElementById('detail-img');
+          var wrap = img && img.closest('.detail-img-wrap');
+          var dotsEl = document.getElementById('ps-dots');
+          var resetBtn = document.getElementById('ps-reset');
+          if (!img || !wrap || !dotsEl) return;
+
+          var origSrc = wrap.dataset.originalSrc || img.src;
+          var workCanvas, workCtx, workW, workH;  // current canvas state (recolored)
+
+          function hex(r,g,b){ return '#' + [r,g,b].map(function(n){ return Math.max(0,Math.min(255, n|0)).toString(16).padStart(2,'0'); }).join(''); }
+          function parseHex(h){
+            h = (h||'').trim().replace(/^#/, '');
+            if (h.length === 3) h = h.split('').map(function(c){return c+c;}).join('');
+            if (h.length !== 6) return null;
+            return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
+          }
+          // Quantize to 5-bit-per-channel bucket → top N
+          function paletteFromCanvas(ctx, w, h, n){
+            var data = ctx.getImageData(0, 0, w, h).data;
+            var bins = new Map();
+            for (var i = 0; i < data.length; i += 16) {  // sample every 4th pixel
+              var r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
+              if (a < 200) continue;
+              var key = (r >> 3) << 10 | (g >> 3) << 5 | (b >> 3);
+              var bin = bins.get(key);
+              if (bin) { bin[0]++; bin[1]+=r; bin[2]+=g; bin[3]+=b; }
+              else bins.set(key, [1, r, g, b]);
+            }
+            var arr = Array.from(bins.values());
+            arr.sort(function(a, b){ return b[0] - a[0]; });
+            // Dedupe near-identical bins (within 24 RGB units)
+            var picked = [];
+            for (var j = 0; j < arr.length && picked.length < n; j++) {
+              var br = arr[j][1] / arr[j][0], bg = arr[j][2] / arr[j][0], bb = arr[j][3] / arr[j][0];
+              var dup = picked.some(function(p){ return Math.abs(p[0]-br)+Math.abs(p[1]-bg)+Math.abs(p[2]-bb) < 24; });
+              if (!dup) picked.push([br, bg, bb]);
+            }
+            return picked.map(function(p){ return hex(p[0], p[1], p[2]); });
+          }
+
+          function setupWorkCanvas(){
+            if (workCanvas) return;
+            workCanvas = document.createElement('canvas');
+            // Use natural size, capped to keep recolor under ~150ms
+            var maxDim = 600;
+            var s = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
+            workW = workCanvas.width  = Math.round(img.naturalWidth  * s);
+            workH = workCanvas.height = Math.round(img.naturalHeight * s);
+            workCtx = workCanvas.getContext('2d', { willReadFrequently: true });
+            workCtx.drawImage(img, 0, 0, workW, workH);
+          }
+
+          function renderDots(palette){
+            dotsEl.innerHTML = palette.map(function(h){
+              return '<span class="ps-dot" draggable="true" data-hex="'+h+'" title="'+h+'" style="background:'+h+'"></span>';
+            }).join('');
+          }
+
+          function recolorAtPixel(clientX, clientY, targetHex){
+            setupWorkCanvas();
+            var rect = img.getBoundingClientRect();
+            var x = ((clientX - rect.left) / rect.width)  * workW;
+            var y = ((clientY - rect.top)  / rect.height) * workH;
+            if (x < 0 || x >= workW || y < 0 || y >= workH) return false;
+            var imgData = workCtx.getImageData(0, 0, workW, workH);
+            var data = imgData.data;
+            var idx = (Math.floor(y) * workW + Math.floor(x)) * 4;
+            var sR = data[idx], sG = data[idx+1], sB = data[idx+2];  // source color
+            var t = parseHex(targetHex); if (!t) return false;
+            var tol = 38;  // RGB distance tolerance for "same color"
+            for (var i = 0; i < data.length; i += 4) {
+              var dr = data[i] - sR, dg = data[i+1] - sG, db = data[i+2] - sB;
+              if (dr*dr + dg*dg + db*db < tol*tol) {
+                // Apply target color but preserve original lightness variation slightly
+                data[i]   = t[0];
+                data[i+1] = t[1];
+                data[i+2] = t[2];
+              }
+            }
+            workCtx.putImageData(imgData, 0, 0);
+            img.src = workCanvas.toDataURL('image/png');
+            resetBtn.style.display = 'inline-block';
+            // Refresh palette after recolor
+            try {
+              var pal = paletteFromCanvas(workCtx, workW, workH, 8);
+              renderDots(pal);
+            } catch (_) {}
+            return true;
+          }
+
+          // Drag start → set hex on dataTransfer
+          dotsEl.addEventListener('dragstart', function(e){
+            var d = e.target.closest('.ps-dot');
+            if (!d) return;
+            e.dataTransfer.effectAllowed = 'copy';
+            e.dataTransfer.setData('text/plain', d.dataset.hex);
+            e.dataTransfer.setData('application/x-wallco-color', d.dataset.hex);
+          });
+
+          // Drop target = image wrap
+          wrap.addEventListener('dragenter', function(e){
+            if (e.dataTransfer && Array.from(e.dataTransfer.types).indexOf('application/x-wallco-color') >= 0) {
+              e.preventDefault(); wrap.classList.add('ps-drop-active');
+            }
+          });
+          wrap.addEventListener('dragover', function(e){
+            if (e.dataTransfer && Array.from(e.dataTransfer.types).indexOf('application/x-wallco-color') >= 0) {
+              e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
+            }
+          });
+          wrap.addEventListener('dragleave', function(e){
+            if (!wrap.contains(e.relatedTarget)) wrap.classList.remove('ps-drop-active');
+          });
+          wrap.addEventListener('drop', function(e){
+            wrap.classList.remove('ps-drop-active');
+            var hexVal = e.dataTransfer.getData('application/x-wallco-color') || e.dataTransfer.getData('text/plain');
+            if (!hexVal || hexVal.charAt(0) !== '#') return;
+            e.preventDefault();
+            recolorAtPixel(e.clientX, e.clientY, hexVal);
+          });
+
+          resetBtn.addEventListener('click', function(){
+            img.src = origSrc;
+            resetBtn.style.display = 'none';
+            workCanvas = null; workCtx = null;
+            img.addEventListener('load', extractInitial, { once: true });
+          });
+
+          function extractInitial(){
+            try {
+              var c = document.createElement('canvas');
+              var maxDim = 400;
+              var s = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
+              c.width  = Math.round(img.naturalWidth  * s);
+              c.height = Math.round(img.naturalHeight * s);
+              var ctx = c.getContext('2d', { willReadFrequently: true });
+              ctx.drawImage(img, 0, 0, c.width, c.height);
+              var pal = paletteFromCanvas(ctx, c.width, c.height, 8);
+              renderDots(pal);
+              document.getElementById('ps-hint').style.display = pal.length ? 'inline' : 'none';
+            } catch (err) {
+              document.getElementById('ps-hint').textContent = 'palette extraction failed: ' + err.message;
+            }
+          }
+          if (img.complete && img.naturalWidth) extractInitial();
+          else img.addEventListener('load', extractInitial, { once: true });
+        })();
+        </script>
+
         <!-- ── COLOR STORY — palette dots + Sherwin-Williams + Dunn-Edwards rows
              Drag any dot onto a pattern editor color slot to recolor it. -->
         <section id="color-story" data-design-id="${design.id}" data-palette="${(design.dominant_hex || '').replace(/"/g,'&quot;')}"
@@ -10675,7 +11121,10 @@ ${htmlHeader('/designs')}
       })()}
 
       <!-- Width + material selector -->
-      <div class="material-picker" style="margin:18px 0 20px;padding:16px 18px;border:1px solid var(--line);border-radius:8px;background:var(--card-bg)">
+      <details class="collapse-pane">
+        <summary>Width &amp; Material <span class="cp-sub">choose roll width and material</span></summary>
+        <div class="collapse-body">
+      <div class="material-picker" style="padding:4px 0 0;border:0;background:transparent">
         <div style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);margin-bottom:10px">Width</div>
         <div class="width-tabs" id="width-tabs" data-native-w="${design.width_in || ''}" style="display:flex;gap:8px;margin-bottom:14px">
           <button type="button" class="w-tab" data-w="24" style="flex:1;padding:10px 14px;border:1px solid var(--line);background:transparent;color:var(--ink);font:13px var(--sans);font-weight:500;border-radius:6px;cursor:pointer">24" residential</button>
@@ -10686,10 +11135,15 @@ ${htmlHeader('/designs')}
         <div class="material-tabs" id="material-tabs" style="display:flex;flex-wrap:wrap;gap:6px"></div>
         <div id="material-readout" style="margin-top:10px;font:12px var(--sans);color:var(--ink-soft)"></div>
       </div>
+        </div>
+      </details>
 
       <!-- data-design-kind drives mode-aware show/hide; .repeat-only is shown
            when the user is in WALLPAPER (level≠0) and hidden in MURAL mode.
            .mural-only is the inverse. -->
+      <details class="collapse-pane">
+        <summary>Specifications <span class="cp-sub">category, width, repeat, roll length</span></summary>
+        <div class="collapse-body">
       <div class="detail-spec-panel" data-design-kind="${design.kind}" data-product-line="${productLine(design) || ''}" data-design-id="${design.id}">
         <div class="spec-row"><span class="spec-key">Category</span><span class="spec-val">${categoryDisplay(design.category)}</span></div>
         <div class="spec-row"><span class="spec-key">Width</span><span class="spec-val" id="spec-width">24"</span></div>
@@ -10702,6 +11156,8 @@ ${htmlHeader('/designs')}
         <div class="spec-row"><span class="spec-key">Designed by</span><span class="spec-val">${design.generator}</span></div>
         <div class="spec-row"><span class="spec-key">Handle</span><span class="spec-val">${design.handle}</span></div>
       </div>
+        </div>
+      </details>
 
       <script>
       (function(){
@@ -11223,13 +11679,17 @@ ${htmlHeader('/designs')}
       </script>
 
       <!-- Pair-with sibling palette neighbors -->
-      <div class="pair-with-block" style="margin-top:32px">
-        <h3 style="font-family:var(--serif);font-weight:300;font-size:20px;margin:0 0 4px">Pair this with</h3>
+      <details class="collapse-pane">
+        <summary>Pair this with <span class="cp-sub">sibling designs by palette</span></summary>
+        <div class="collapse-body">
+      <div class="pair-with-block">
         <p style="font-size:12px;color:var(--ink-faint);margin:0 0 14px;letter-spacing:.04em">Sibling designs by palette · nearest neighbors in CIE Lab space</p>
         <div id="pair-with-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;min-height:120px">
           <div style="grid-column:1/-1;color:var(--ink-faint);font-size:12px">Loading pairings…</div>
         </div>
       </div>
+        </div>
+      </details>
       <script>
       (function(){
         var id = ${design.id};
@@ -11250,9 +11710,11 @@ ${htmlHeader('/designs')}
 
       ${_isAdmin ? `
       <!-- Admin: isolate colors (palette + SW + coordinating) -->
-      <div id="iso-block" style="margin-top:28px;padding:18px;border:1px solid var(--line);border-radius:10px;background:rgba(0,0,0,.02)">
+      <details class="collapse-pane">
+        <summary>Color isolate <span class="cp-sub">admin · palette + SW + coordinating</span></summary>
+        <div class="collapse-body">
+      <div id="iso-block">
         <div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-bottom:10px;flex-wrap:wrap">
-          <h3 style="font-family:var(--serif);font-weight:300;font-size:18px;margin:0">Color isolate</h3>
           <span style="font-size:11px;color:var(--ink-faint);letter-spacing:.04em">Admin-only · palette + Sherwin-Williams + coordinating</span>
         </div>
         <div id="iso-palette-detail" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px"><span style="color:var(--ink-faint);font-size:12px">loading palette…</span></div>

← 75c5fe0 feat(ticks): rsync-pull prod bad-aesthetic-patterns.jsonl at  ·  back to Wallco Ai  ·  collapse all design-page panels by default — page lands clea c7ca41e →