← 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,'"')}"
@@ -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 & 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 →