← back to Wallco Ai
furnish: per-item Google Images / 1stdibs / Pinterest / Etsy search chips
6afffe717a1723b5934a822d887d0948ef2158cb · 2026-05-13 13:40:57 -0700 · SteveStudio2
Steve's feedback (2026-05-13): clicking 'Generate furnishings' returned
only text descriptions — 'nothing generated'. Visual lookup chips next
to each item turn each recommendation into a click-to-see-it action.
Free (zero API cost), opens in new tab, four pre-formatted searches per
item using the item name + first phrase of the detail.
Files touched
M data/designs.jsonM server.js
Diff
commit 6afffe717a1723b5934a822d887d0948ef2158cb
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 13:40:57 2026 -0700
furnish: per-item Google Images / 1stdibs / Pinterest / Etsy search chips
Steve's feedback (2026-05-13): clicking 'Generate furnishings' returned
only text descriptions — 'nothing generated'. Visual lookup chips next
to each item turn each recommendation into a click-to-see-it action.
Free (zero API cost), opens in new tab, four pre-formatted searches per
item using the item name + first phrase of the detail.
---
data/designs.json | 286 ++++++++++++++++++++++++
server.js | 642 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 913 insertions(+), 15 deletions(-)
diff --git a/data/designs.json b/data/designs.json
index ffaf3db..ce357fa 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -80535,5 +80535,291 @@
"user_scale_good": null,
"user_stars": null,
"room_mockups": []
+ },
+ {
+ "id": 3890,
+ "kind": "seamless_tile",
+ "category": "stoned-animals",
+ "dominant_hex": "#6a4b46",
+ "saturation": 0.205,
+ "title": "Rose Stealie No.3890",
+ "handle": "wallco-3890",
+ "image_url": "/designs/img/1778704591636_226014447.png",
+ "filename": "1778704591636_226014447.png",
+ "generator": "wallco.ai",
+ "seed": 226014447,
+ "is_published": false,
+ "created_at": "2026-05-13T20:36:39.936224+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3891,
+ "kind": "seamless_tile",
+ "category": "drunk-animals",
+ "dominant_hex": "#d03188",
+ "saturation": 0.628,
+ "title": "Plum Tipple No.3891",
+ "handle": "wallco-3891",
+ "image_url": "/designs/img/1778704591636_1823517830.png",
+ "filename": "1778704591636_1823517830.png",
+ "generator": "wallco.ai",
+ "seed": 1823517830,
+ "is_published": false,
+ "created_at": "2026-05-13T20:36:46.892657+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3892,
+ "kind": "seamless_tile",
+ "category": "stoned-animals",
+ "dominant_hex": "#562a4c",
+ "saturation": 0.344,
+ "title": "Plum Stardust No.3892",
+ "handle": "wallco-3892",
+ "image_url": "/designs/img/1778704681650_849982474.png",
+ "filename": "1778704681650_849982474.png",
+ "generator": "wallco.ai",
+ "seed": 849982474,
+ "is_published": false,
+ "created_at": "2026-05-13T20:38:09.829859+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3893,
+ "kind": "seamless_tile",
+ "category": "drunk-animals",
+ "dominant_hex": "#4f668b",
+ "saturation": 0.275,
+ "title": "Sapphire Reverie No.3893",
+ "handle": "wallco-3893",
+ "image_url": "/designs/img/1778704681650_509807280.png",
+ "filename": "1778704681650_509807280.png",
+ "generator": "wallco.ai",
+ "seed": 509807280,
+ "is_published": false,
+ "created_at": "2026-05-13T20:38:13.506978+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3894,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1f1c1b",
+ "saturation": 0.069,
+ "title": "Noir Salon No.3894",
+ "handle": "wallco-3894",
+ "image_url": "/designs/img/1778704747161_303561097.png",
+ "filename": "1778704747161_303561097.png",
+ "generator": "wallco.ai",
+ "seed": 303561097,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:15.465659+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3895,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1f1c1b",
+ "saturation": 0.069,
+ "title": "Noir Baroque No.3895",
+ "handle": "wallco-3895",
+ "image_url": "/designs/img/var_3894_hue+60_1778704755567.png",
+ "filename": "var_3894_hue+60_1778704755567.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:17.664407+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3896,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1f1c1b",
+ "saturation": 0.069,
+ "title": "Noir Damask No.3896",
+ "handle": "wallco-3896",
+ "image_url": "/designs/img/var_3894_hue+120_1778704757668.png",
+ "filename": "var_3894_hue+120_1778704757668.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:19.766302+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3897,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1f1c1b",
+ "saturation": 0.069,
+ "title": "Noir Heirloom No.3897",
+ "handle": "wallco-3897",
+ "image_url": "/designs/img/var_3894_hue+180_1778704759769.png",
+ "filename": "var_3894_hue+180_1778704759769.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:22.017287+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3898,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1f1c1b",
+ "saturation": 0.069,
+ "title": "Noir Atelier No.3898",
+ "handle": "wallco-3898",
+ "image_url": "/designs/img/var_3894_hue-60_1778704762020.png",
+ "filename": "var_3894_hue-60_1778704762020.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:24.336317+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3899,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1f1c1b",
+ "saturation": 0.069,
+ "title": "Noir Salon No.3899",
+ "handle": "wallco-3899",
+ "image_url": "/designs/img/var_3894_mono_1778704764340.png",
+ "filename": "var_3894_mono_1778704764340.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:26.513279+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3900,
+ "kind": "seamless_tile",
+ "category": "drunk-animals",
+ "dominant_hex": "#212627",
+ "saturation": 0.083,
+ "title": "Marine Hiccup No.3900",
+ "handle": "wallco-3900",
+ "image_url": "/designs/img/1778704771658_1784468424.png",
+ "filename": "1778704771658_1784468424.png",
+ "generator": "wallco.ai",
+ "seed": 1784468424,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:39.891409+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3901,
+ "kind": "seamless_tile",
+ "category": "stoned-animals",
+ "dominant_hex": "#6c735f",
+ "saturation": 0.095,
+ "title": "Sage Trip No.3901",
+ "handle": "wallco-3901",
+ "image_url": "/designs/img/1778704771658_180768443.png",
+ "filename": "1778704771658_180768443.png",
+ "generator": "wallco.ai",
+ "seed": 180768443,
+ "is_published": false,
+ "created_at": "2026-05-13T20:39:46.715765+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
+ },
+ {
+ "id": 3902,
+ "kind": "seamless_tile",
+ "category": "drunk-animals",
+ "dominant_hex": "#282234",
+ "saturation": 0.209,
+ "title": "Mauve Tableau No.3902",
+ "handle": "wallco-3902",
+ "image_url": "/designs/img/1778704830631_1639023191.png",
+ "filename": "1778704830631_1639023191.png",
+ "generator": "wallco.ai",
+ "seed": 1639023191,
+ "is_published": false,
+ "created_at": "2026-05-13T20:40:38.972328+00:00",
+ "motifs": [],
+ "user_removed": false,
+ "user_color_good": null,
+ "user_style_good": null,
+ "user_scale_good": null,
+ "user_stars": null,
+ "room_mockups": []
}
]
\ No newline at end of file
diff --git a/server.js b/server.js
index 10e9208..2902b06 100644
--- a/server.js
+++ b/server.js
@@ -5652,6 +5652,91 @@ ${htmlHeader('/designs')}
<img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" crossorigin="anonymous">
<canvas id="detail-canvas" style="display:none"></canvas>
+ <!-- ── 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,'"')}"
+ style="margin:18px 0 6px;padding:14px 16px;background:var(--card-bg,#f4f0e8);border:1px solid var(--line,#d8d0c0);border-radius:8px;">
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;">
+ <strong style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">Color story</strong>
+ <span style="font-size:10.5px;color:var(--ink-faint,#7a6e5a);letter-spacing:.04em;">drag any dot onto a pattern color slot to recolor it</span>
+ </div>
+ <div id="cs-dots" class="cs-row" data-vendor="raw"
+ style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;min-height:28px;"></div>
+ <div id="cs-sw" class="cs-row" data-vendor="sw"
+ style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:8px;min-height:24px;"></div>
+ <div id="cs-de" class="cs-row" data-vendor="de"
+ style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:6px;min-height:24px;"></div>
+ </section>
+ <style>
+ .cs-dot { display:inline-flex; align-items:center; gap:6px; cursor:grab; user-select:none; }
+ .cs-dot[draggable=true]:active { cursor:grabbing; }
+ .cs-swatch { width:28px; height:28px; border-radius:50%; border:1px solid rgba(0,0,0,.18); box-shadow: inset 0 0 0 1px rgba(255,255,255,.18); }
+ .cs-row[data-vendor=sw] .cs-swatch, .cs-row[data-vendor=de] .cs-swatch { width:18px; height:18px; border-radius:4px; }
+ .cs-hex { font-family: ui-monospace, Menlo, monospace; font-size:10.5px; color:#5a4628; letter-spacing:.02em; }
+ .cs-name { font-size:11.5px; color:#3a2f1c; }
+ .cs-code { font-family: ui-monospace, Menlo, monospace; font-size:10.5px; color:#a08b6a; margin-left:4px; }
+ .cs-label { font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-faint,#7a6e5a); margin-right:4px; min-width:90px; display:inline-block; }
+ </style>
+ <script>
+ (function(){
+ var sec = document.getElementById('color-story');
+ if (!sec) return;
+ var palette = (sec.getAttribute('data-palette') || '').split(',').map(function(s){return s.trim();}).filter(Boolean);
+ if (!palette.length) { sec.hidden = true; return; }
+
+ function dotHtml(hex, sub) {
+ var swatch = sub && sub.swatch_hex ? sub.swatch_hex : hex;
+ return '<span class="cs-dot" draggable="true" data-hex="' + hex + '" data-paint="' + (sub ? (sub.name + ' ' + sub.code) : '') + '">' +
+ '<span class="cs-swatch" style="background:' + swatch + '"></span>' +
+ (sub
+ ? '<span class="cs-name">' + sub.name + ' <span class="cs-code">' + sub.code + '</span></span>'
+ : '<span class="cs-hex">' + hex.toUpperCase() + '</span>') +
+ '</span>';
+ }
+
+ document.getElementById('cs-dots').innerHTML = palette.map(function(h){ return dotHtml(h); }).join('');
+
+ var ep = 'https://pairs.designerwallcoverings.com/api/pairs?palette=' +
+ encodeURIComponent(palette.join(',')) + '&limit=1';
+ fetch(ep, { credentials: 'omit' })
+ .then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); })
+ .then(function(d){
+ var pal = d && d.source && d.source.palette;
+ if (!pal || !pal.length) return;
+ var swRow = ['<span class="cs-label">Sherwin-Williams</span>'];
+ var deRow = ['<span class="cs-label">Dunn-Edwards</span>'];
+ pal.forEach(function(c){
+ var sw = c && c.paints && c.paints.sw;
+ var de = c && c.paints && c.paints.de;
+ if (sw) swRow.push(dotHtml(c.hex, { name: sw.name, code: sw.code, swatch_hex: sw.hex }));
+ if (de) deRow.push(dotHtml(c.hex, { name: de.name, code: de.code, swatch_hex: de.hex }));
+ });
+ if (swRow.length > 1) document.getElementById('cs-sw').innerHTML = swRow.join('');
+ else document.getElementById('cs-sw').remove();
+ if (deRow.length > 1) document.getElementById('cs-de').innerHTML = deRow.join('');
+ else document.getElementById('cs-de').remove();
+ })
+ .catch(function(){});
+
+ sec.addEventListener('dragstart', function(e){
+ var d = e.target.closest('.cs-dot');
+ if (!d) return;
+ e.dataTransfer.effectAllowed = 'copy';
+ var swatchEl = d.querySelector('.cs-swatch');
+ var swatchBg = swatchEl ? swatchEl.style.background : d.getAttribute('data-hex');
+ function rgbToHex(s){
+ var m = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(s||'');
+ if (!m) return s;
+ return '#' + [+m[1],+m[2],+m[3]].map(function(n){return n.toString(16).padStart(2,'0');}).join('');
+ }
+ var hex = (swatchBg && swatchBg.charAt(0) === '#') ? swatchBg : rgbToHex(swatchBg);
+ if (!hex || hex.charAt(0) !== '#') hex = d.getAttribute('data-hex');
+ e.dataTransfer.setData('text/plain', hex);
+ e.dataTransfer.setData('application/x-wallco-color', hex);
+ });
+ })();
+ </script>
+
${design.category === 'mural-scenic' ? `
<!-- ── LICENSE CTA — $950 infinite license + 150dpi download ─── -->
<div id="license-panel" data-design-id="${design.id}"
@@ -6004,10 +6089,27 @@ ${htmlHeader('/designs')}
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'gen failed');
let html = '';
+ // Each item gets clickable "find images" chips so the text
+ // recommendation becomes a visual lookup tool. Steve's
+ // 2026-05-13 feedback: "but nothing generated" — text alone
+ // doesn't satisfy a furnishing browse.
+ const searchChips = (q) => {
+ const enc = encodeURIComponent(q);
+ return '<span style="display:inline-flex;gap:4px;margin-left:6px;font-size:10px;vertical-align:middle;flex-wrap:wrap">'
+ + '<a href="https://www.google.com/search?tbm=isch&q=' + enc + '" target="_blank" rel="noopener" style="padding:2px 7px;border-radius:9999px;background:#eff6ff;color:#1d4ed8;text-decoration:none;border:1px solid #bfdbfe">🖼 Google</a>'
+ + '<a href="https://www.1stdibs.com/search/?q=' + enc + '" target="_blank" rel="noopener" style="padding:2px 7px;border-radius:9999px;background:#fef3c7;color:#92400e;text-decoration:none;border:1px solid #fde68a">1stdibs</a>'
+ + '<a href="https://www.pinterest.com/search/pins/?q=' + enc + '" target="_blank" rel="noopener" style="padding:2px 7px;border-radius:9999px;background:#fee2e2;color:#b91c1c;text-decoration:none;border:1px solid #fecaca">Pinterest</a>'
+ + '<a href="https://www.etsy.com/search?q=' + enc + '" target="_blank" rel="noopener" style="padding:2px 7px;border-radius:9999px;background:#fef9c3;color:#854d0e;text-decoration:none;border:1px solid #fde047">Etsy</a>'
+ + '</span>';
+ };
for (const cat of (j.categories || [])) {
html += '<div style="margin-bottom:14px"><div style="font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint);margin-bottom:6px">' + cat.category + '</div><ul style="margin:0;padding-left:18px">';
for (const item of (cat.items || [])) {
- html += '<li style="margin-bottom:4px"><strong>' + item.name + '</strong>' + (item.detail ? ' — <span style="color:var(--ink-soft,#555)">' + item.detail + '</span>' : '') + '</li>';
+ const q = (item.name || '') + (item.detail ? ' ' + item.detail.split(/[.,]/)[0] : '');
+ html += '<li style="margin-bottom:6px"><strong>' + item.name + '</strong>'
+ + (item.detail ? ' — <span style="color:var(--ink-soft,#555)">' + item.detail + '</span>' : '')
+ + searchChips(q)
+ + '</li>';
}
html += '</ul></div>';
}
@@ -7625,16 +7727,115 @@ ${htmlHeader('/designs')}
<div class="pairs-well__ai" id="pairs-well-ai" hidden>
<div class="pairs-well__panel-head">
<h3 style="font:500 22px/1.2 ui-serif,Georgia,serif;">AI Generated Coordinates <span class="ai-badge">Preview · 24 options</span></h3>
- <div class="pairs-well__controls">
+ </div>
+ <!-- ── TOP CONTROLS — repeat width + room scale + columns ─────────────
+ All three persist to localStorage. Repeat-width snaps server-side
+ to a multiple of each pattern's pitch so seams always fit. -->
+ <div class="pw-ai-controls">
+ <div class="pw-ctrl">
+ <label for="pw-tile-in">Repeat width</label>
+ <input id="pw-tile-in" type="range" min="12" max="48" step="2" value="24">
+ <span class="pw-ctrl-val"><span id="pw-tile-in-val">24</span>″</span>
+ </div>
+ <div class="pw-ctrl">
+ <label for="pw-room-in">Room scale</label>
+ <input id="pw-room-in" type="range" min="60" max="180" step="6" value="96">
+ <span class="pw-ctrl-val"><span id="pw-room-in-val">96</span>″ wall</span>
+ </div>
+ <div class="pw-ctrl">
<label for="pairs-well-cols-ai">Columns</label>
<input id="pairs-well-cols-ai" type="range" min="3" max="12" value="8" step="1">
- <span class="pairs-well__cols-val" id="pairs-well-cols-ai-val">8</span>
+ <span class="pw-ctrl-val" id="pairs-well-cols-ai-val">8</span>
</div>
</div>
<div id="pairs-well-ai-grid" class="pairs-well__grid pairs-well__grid--ai"></div>
</div>
</section>
+ ${_isAdmin ? `
+ <!-- ── PATTERN EDITOR MODAL (admin) ────────────────────────────────────
+ 1–5 color slots that accept drag-drop from any .cs-dot on the page.
+ Live preview re-renders via /api/ai-coordinates/render. Save updates
+ the source card in-place; Save as New appends a custom card. -->
+ <div id="pw-editor" class="pw-editor" hidden>
+ <div class="pw-editor__backdrop" data-close="1"></div>
+ <div class="pw-editor__sheet" role="dialog" aria-label="Pattern editor">
+ <header class="pw-editor__head">
+ <h3>Edit pattern</h3>
+ <button type="button" class="pw-editor__x" data-close="1">×</button>
+ </header>
+ <div class="pw-editor__body">
+ <div class="pw-editor__preview">
+ <img id="pw-editor-img" alt="" src="">
+ </div>
+ <div class="pw-editor__form">
+ <label class="pw-fld">
+ <span>Name</span>
+ <input id="pw-fld-label" type="text" maxlength="80" autocomplete="off">
+ </label>
+ <label class="pw-fld">
+ <span>Motif</span>
+ <select id="pw-fld-motif">
+ <option value="stripe">Stripe</option>
+ <option value="plaid">Plaid</option>
+ <option value="tartan">Tartan</option>
+ </select>
+ </label>
+ <label class="pw-fld">
+ <span>Style</span>
+ <select id="pw-fld-style">
+ <optgroup label="Stripes">
+ <option value="even">Even stripe</option>
+ <option value="triple">Triple stripe (3 colors)</option>
+ <option value="pinstripe">Pinstripe</option>
+ <option value="pinstripe-pair">Twin pinstripe</option>
+ <option value="cabana">Cabana</option>
+ <option value="awning">Awning</option>
+ <option value="wavy">Wavy stripe</option>
+ <option value="hairline">Hairline</option>
+ <option value="block">Block</option>
+ </optgroup>
+ <optgroup label="Plaids / checks">
+ <option value="buffalo">Buffalo check</option>
+ <option value="gingham">Gingham</option>
+ <option value="tattersall">Tattersall</option>
+ <option value="windowpane">Windowpane</option>
+ <option value="madras">Madras</option>
+ <option value="tartan">Tartan</option>
+ <option value="glen">Glen plaid</option>
+ <option value="box">Box plaid</option>
+ </optgroup>
+ </select>
+ </label>
+ <label class="pw-fld">
+ <span>Stripe width <strong id="pw-fld-pitch-val">6″</strong></span>
+ <input id="pw-fld-pitch" type="range" min="0.5" max="18" step="0.5" value="6">
+ </label>
+
+ <div class="pw-fld pw-slot-group">
+ <span>Colors (drag from page)</span>
+ <div class="pw-slots" id="pw-slots"></div>
+ <div class="pw-slot-count">
+ <button type="button" data-n="1">1</button>
+ <button type="button" data-n="2">2</button>
+ <button type="button" data-n="3" class="active">3</button>
+ <button type="button" data-n="4">4</button>
+ <button type="button" data-n="5">5</button>
+ <span class="pw-slot-hint">color slots</span>
+ </div>
+ </div>
+
+ <div class="pw-editor__actions">
+ <button type="button" id="pw-btn-save" class="pw-btn pw-btn-primary">Save changes</button>
+ <button type="button" id="pw-btn-savenew" class="pw-btn">Save as new</button>
+ <button type="button" id="pw-btn-cancel" class="pw-btn pw-btn-ghost" data-close="1">Cancel</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ ` : ''}
+
<style>
#pairs-well-with { --cols-catalog: 8; --cols-ai: 8; font-family: inherit; }
.pairs-well__cta { display:flex; align-items:center; justify-content:space-between; width:100%; padding:18px 24px; margin:0; background:#111; color:#fff; border:0; border-radius:999px; font-size:15px; font-weight:500; letter-spacing:.06em; text-transform:uppercase; cursor:pointer; transition: background 160ms ease, transform 120ms ease; }
@@ -7687,6 +7888,101 @@ ${htmlHeader('/designs')}
.pairs-well__skeleton { background: linear-gradient(90deg, #eee 0%, #f6f6f6 50%, #eee 100%); background-size: 200% 100%; animation: pwShim 1.2s linear infinite; aspect-ratio: 1 / 1; border-radius: 6px; }
@keyframes pwShim { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.pairs-well__empty { padding: 18px; color: #777; font-size: 14px; grid-column: 1 / -1; }
+
+ /* ── Top controls (repeat / room / columns) ──────────────────────────── */
+ .pw-ai-controls {
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px;
+ padding: 14px 16px; background: #fff; border: 1px solid #e7e7e7;
+ border-radius: 8px; margin: 0 0 16px;
+ }
+ @media (max-width: 800px) { .pw-ai-controls { grid-template-columns: 1fr; } }
+ .pw-ctrl { display: flex; align-items: center; gap: 10px; }
+ .pw-ctrl label { font-size: 10.5px; letter-spacing:.14em; text-transform: uppercase;
+ color: #7a6e5a; min-width: 88px; }
+ .pw-ctrl input[type=range] { flex:1; accent-color: #1a1a1a; }
+ .pw-ctrl-val { font-variant-numeric: tabular-nums; color: #1a1a1a;
+ font-size: 12px; font-weight: 600; min-width: 64px; text-align: right; }
+
+ /* ── AI card actions (Duplicate / Edit) ──────────────────────────────── */
+ .pairs-well__card { position: relative; }
+ .pw-card-actions {
+ position: absolute; top: 6px; right: 6px;
+ display: flex; gap: 4px; opacity: 0; transition: opacity .15s;
+ pointer-events: none;
+ }
+ .pairs-well__card:hover .pw-card-actions,
+ .pairs-well__card:focus-within .pw-card-actions { opacity: 1; pointer-events: auto; }
+ .pw-card-actions button {
+ background: rgba(20,20,20,0.85); color: #fff; border: 0;
+ font: 600 10px/1 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing:.08em; text-transform: uppercase;
+ padding: 5px 8px; border-radius: 999px; cursor: pointer;
+ backdrop-filter: blur(2px);
+ }
+ .pw-card-actions button:hover { background: #000; }
+ .pw-pitch-chip {
+ display: inline-block; padding: 1px 6px; margin-left: 4px;
+ background: #eef0ee; color: #2a4a2a; border-radius: 999px;
+ font-size: 10px; font-weight: 600; letter-spacing:.04em;
+ }
+
+ /* ── Pattern editor modal ────────────────────────────────────────────── */
+ .pw-editor { position: fixed; inset: 0; z-index: 9000; display: flex;
+ align-items: center; justify-content: center; }
+ .pw-editor[hidden] { display: none; }
+ .pw-editor__backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); cursor: pointer; }
+ .pw-editor__sheet {
+ position: relative; width: 92vw; max-width: 880px; max-height: 92vh; overflow: auto;
+ background: #fdfaf3; border-radius: 12px; border: 1px solid #d8d0c0;
+ box-shadow: 0 24px 60px rgba(0,0,0,0.25);
+ }
+ .pw-editor__head { display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 18px; border-bottom: 1px solid #e9e0c8; }
+ .pw-editor__head h3 { margin: 0; font: 500 18px/1.2 ui-serif, Georgia, serif; }
+ .pw-editor__x { background: none; border: 0; font-size: 26px; line-height: 1; cursor: pointer; color: #5a4628; padding: 0 4px; }
+ .pw-editor__body { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 18px; }
+ @media (max-width: 700px) { .pw-editor__body { grid-template-columns: 1fr; } }
+ .pw-editor__preview { background: #f0eadc; border-radius: 8px; aspect-ratio: 1/1; overflow: hidden; display:flex; align-items:center; justify-content:center; }
+ .pw-editor__preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .pw-editor__form { display: flex; flex-direction: column; gap: 12px; }
+ .pw-fld { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #5a4628; }
+ .pw-fld > span { font-size: 10.5px; letter-spacing:.14em; text-transform: uppercase; color: #7a6e5a; }
+ .pw-fld > span strong { font-weight: 700; color: #1a1a1a; letter-spacing: 0; text-transform: none; font-size: 12px; }
+ .pw-fld input[type=text], .pw-fld select { font: inherit; padding: 8px 10px; border: 1px solid #d8d0c0; border-radius: 4px; background: #fff; color: #1a1a1a; }
+ .pw-fld input[type=range] { accent-color: #1a1a1a; }
+ .pw-slots { display: flex; flex-wrap: wrap; gap: 8px; }
+ .pw-slot {
+ width: 42px; height: 42px; border-radius: 50%;
+ border: 2px dashed #c8b88a; background: #fff;
+ display: flex; align-items: center; justify-content: center;
+ cursor: pointer; position: relative;
+ font: 600 9px/1 ui-sans-serif, system-ui, sans-serif; color: #b08a3a;
+ letter-spacing:.06em; text-transform: uppercase;
+ }
+ .pw-slot.filled { border-style: solid; border-color: rgba(0,0,0,0.15); color: transparent; }
+ .pw-slot.drag-over { box-shadow: 0 0 0 3px #b08a3a; border-color: #b08a3a; }
+ .pw-slot input[type=color] {
+ position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; border: 0; padding: 0;
+ }
+ .pw-slot-count { display: flex; align-items: center; gap: 4px; margin-top: 4px; }
+ .pw-slot-count button {
+ width: 26px; height: 26px; border-radius: 50%;
+ border: 1px solid #d8d0c0; background: #fff; cursor: pointer;
+ font: 600 11px/1 ui-sans-serif, system-ui, sans-serif; color: #5a4628;
+ }
+ .pw-slot-count button.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
+ .pw-slot-hint { font-size: 10px; color: #7a6e5a; margin-left: 8px; letter-spacing:.04em; }
+ .pw-editor__actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
+ .pw-btn {
+ padding: 10px 16px; border-radius: 4px; cursor: pointer;
+ font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing:.08em; text-transform: uppercase;
+ background: #fff; border: 1px solid #d8d0c0; color: #1a1a1a;
+ }
+ .pw-btn:hover { background: #f4ede4; }
+ .pw-btn-primary { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
+ .pw-btn-primary:hover { background: #000; }
+ .pw-btn-ghost { background: transparent; border-color: transparent; color: #7a6e5a; }
</style>
<script>
@@ -7710,31 +8006,347 @@ ${htmlHeader('/designs')}
var aiGrid = document.getElementById('pairs-well-ai-grid');
var aiCols = document.getElementById('pairs-well-cols-ai');
var aiColsVal = document.getElementById('pairs-well-cols-ai-val');
+ var tileIn = document.getElementById('pw-tile-in');
+ var tileInVal = document.getElementById('pw-tile-in-val');
+ var roomIn = document.getElementById('pw-room-in');
+ var roomInVal = document.getElementById('pw-room-in-val');
var sourcePalette = document.getElementById('pairs-well-source-palette');
var vendorPicker = document.getElementById('pairs-well-vendor-picker');
var vendorChecks = vendorPicker.querySelectorAll('input[type=checkbox]');
- var loaded=false, cachedPairs=null, cachedAi=null, cachedSource=null;
+ var editor = document.getElementById('pw-editor');
+ var loaded=false, cachedPairs=null, cachedAi=null, customCards=[], cachedSource=null;
+ var IS_ADMIN = ${_isAdmin ? 'true' : 'false'};
var VENDOR_LABEL={sw:'Sherwin-Williams',de:'Dunn-Edwards',bm:'Benjamin Moore',fb:'Farrow & Ball',behr:'Behr',ppg:'PPG',ral:'RAL'};
var VENDOR_SHORT={sw:'SW',de:'DE',bm:'BM',fb:'F&B',behr:'Behr',ppg:'PPG',ral:'RAL'};
var DEFAULT_VENDORS=['sw','de','bm','fb'];
+
function selectedVendors(){var o=[];vendorChecks.forEach(function(c){if(c.checked)o.push(c.value)});return o.length?o:DEFAULT_VENDORS.slice()}
try{var st=JSON.parse(localStorage.getItem('pw.vendors')||'null');if(Array.isArray(st)&&st.length)vendorChecks.forEach(function(c){c.checked=st.indexOf(c.value)!==-1})}catch(_){}
function persistVendors(){try{localStorage.setItem('pw.vendors',JSON.stringify(selectedVendors()))}catch(_){}}
vendorChecks.forEach(function(c){c.addEventListener('change',function(){persistVendors();if(cachedSource)renderSourcePalette(cachedSource.palette);if(cachedPairs&&!catalogPanel.hidden)renderGrid(catalogGrid,cachedPairs,false)})});
- function wireSlider(input,valEl,varName,storageKey){function apply(){var n=Math.max(3,Math.min(12,parseInt(input.value,10)||8));valEl.textContent=n;root.style.setProperty('--'+varName,n);try{localStorage.setItem(storageKey,n)}catch(_){}}try{var s=parseInt(localStorage.getItem(storageKey),10);if(!isNaN(s)&&s>=3&&s<=12)input.value=s}catch(_){}input.addEventListener('input',apply);apply()}
- wireSlider(catalogCols,catalogColsVal,'cols-catalog','pw.cols.catalog');
- wireSlider(aiCols,aiColsVal,'cols-ai','pw.cols.ai');
+
+ function wireCols(input,valEl,varName,storageKey){function apply(){var n=Math.max(3,Math.min(12,parseInt(input.value,10)||8));valEl.textContent=n;root.style.setProperty('--'+varName,n);try{localStorage.setItem(storageKey,n)}catch(_){}}try{var s=parseInt(localStorage.getItem(storageKey),10);if(!isNaN(s)&&s>=3&&s<=12)input.value=s}catch(_){}input.addEventListener('input',apply);apply()}
+ wireCols(catalogCols,catalogColsVal,'cols-catalog','pw.cols.catalog');
+ wireCols(aiCols,aiColsVal,'cols-ai','pw.cols.ai');
+
+ // Repeat-width + Room-scale sliders. tile_in re-fetches the AI grid.
+ var aiRefetchTimer = null;
+ function wireInchSlider(input, valEl, storageKey, onChange) {
+ try { var s = parseFloat(localStorage.getItem(storageKey)); if (!isNaN(s)) input.value = s; } catch (_) {}
+ function apply() {
+ valEl.textContent = input.value;
+ try { localStorage.setItem(storageKey, input.value); } catch (_) {}
+ if (onChange) onChange(input.value);
+ }
+ input.addEventListener('input', apply);
+ apply();
+ }
+ wireInchSlider(tileIn, tileInVal, 'pw.tile_in', function () {
+ // Debounce re-fetch by 300ms so dragging doesn't hammer the API.
+ clearTimeout(aiRefetchTimer);
+ aiRefetchTimer = setTimeout(refetchAi, 300);
+ });
+ wireInchSlider(roomIn, roomInVal, 'pw.room_in', function (val) {
+ // Room scale only affects display sizing — set a CSS var the cards can use later.
+ root.style.setProperty('--pw-room-in', val);
+ });
+
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
function renderSkeletons(g,n){var h='';for(var i=0;i<n;i++)h+='<div class="pairs-well__skeleton"></div>';g.innerHTML=h}
+
function renderSourcePalette(pal){if(!pal||!pal.length){sourcePalette.hidden=true;vendorPicker.hidden=true;return}var vs=selectedVendors();var head='<th></th>'+vs.map(function(v){return '<th>'+esc(VENDOR_LABEL[v])+'</th>'}).join('');var body=pal.map(function(c){var hex=typeof c==='string'?c:c.hex;var cells='<td class="swatch"><span class="pw-dot" style="background:'+esc(hex)+'"></span></td>';vs.forEach(function(v){var p=c&&c.paints&&c.paints[v];cells+='<td>'+(p?'<span class="pw-square" style="background:'+esc(p.hex)+'"></span>'+esc(p.name)+' <span class="pw-code">'+esc(p.code)+'</span>':'—')+'</td>'});return '<tr>'+cells+'</tr>'}).join('');sourcePalette.innerHTML='<h4>Color story</h4><table><thead><tr>'+head+'</tr></thead><tbody>'+body+'</tbody></table>';sourcePalette.hidden=false;vendorPicker.hidden=false}
- function renderCard(p,isAi){var img=p.image_url?esc(p.image_url):'';var why=(p.why&&p.why[0])?esc(p.why[0]):(p.motif?'AI '+esc(p.motif):'');var pal=(p.palette||[]).map(function(c){var hex=typeof c==='string'?c:c.hex;var sw=c&&c.paints&&c.paints.sw,de=c&&c.paints&&c.paints.de;var tip=(sw?'SW '+sw.name+' '+sw.code:'')+(de?' · DE '+de.name+' '+de.code:'');return '<span class="pw-dot" style="background:'+esc(hex)+'" title="'+esc(tip)+'"></span>'}).join('');var dom=(p.palette&&p.palette[0])||null;var pb='';if(dom&&dom.paints){var vs=selectedVendors();var rows=vs.map(function(v){var pt=dom.paints[v];if(!pt)return '';return '<span class="row"><span class="label">'+esc(VENDOR_SHORT[v])+'</span><span class="pw-square" style="background:'+esc(pt.hex)+'"></span>'+esc(pt.name)+' <span class="pw-code">'+esc(pt.code)+'</span></span>'}).filter(Boolean).join('');if(rows)pb='<div class="pairs-well__paints">'+rows+'</div>'}var href=p.url?esc(p.url):(p.handle?'https://www.designerwallcoverings.com/products/'+esc(p.handle):'#');return '<a class="pairs-well__card'+(isAi?' pairs-well__card--ai':'')+'" target="_blank" rel="noopener" href="'+href+'" title="'+esc((p.why||[]).join(' · '))+'"><div class="pairs-well__img-wrap">'+(img?'<img class="pairs-well__img" src="'+img+'" alt="'+esc(p.title)+'" loading="lazy">':'')+'</div><div class="pairs-well__meta"><p class="pairs-well__title">'+esc(p.title||'')+'</p><p class="pairs-well__vendor">'+esc(p.vendor||(isAi?'AI-Generated':''))+'</p>'+(why?'<span class="pairs-well__why">'+why+'</span>':'')+(pal?'<div class="pairs-well__palette">'+pal+'</div>':'')+pb+'</div></a>'}
- function renderGrid(g,items,isAi){if(!items||!items.length){g.innerHTML='<div class="pairs-well__empty">No coordinates found.</div>';return}g.innerHTML=items.map(function(p){return renderCard(p,isAi)}).join('')}
- function buildUrl(base,limit){var qs=['palette='+encodeURIComponent(palette)];if(sourceTitle)qs.push('title='+encodeURIComponent(sourceTitle));if(sourceImage)qs.push('image_url='+encodeURIComponent(sourceImage));if(limit)qs.push('limit='+limit);return base+'?'+qs.join('&')}
- function load(){if(loaded)return Promise.resolve();loaded=true;renderSkeletons(catalogGrid,12);renderSkeletons(aiGrid,12);catalogPanel.hidden=false;aiPanel.hidden=false;
- var pp=fetch(buildUrl(endpointPairs,24),{credentials:'omit'}).then(function(r){return r.ok?r.json():Promise.reject(new Error('http '+r.status))}).then(function(d){if(!d||d.ok===false)throw new Error((d&&d.error)||'bad payload');cachedSource=d.source||null;cachedPairs=d.pairs||[];if(cachedSource)renderSourcePalette(cachedSource.palette||[]);renderGrid(catalogGrid,cachedPairs,false);catalogCount.textContent='('+cachedPairs.length+')';if(!cachedPairs.length&&(!cachedSource||!cachedSource.palette||!cachedSource.palette.length))root.hidden=true}).catch(function(){catalogGrid.innerHTML='<div class="pairs-well__empty">Could not load coordinates right now.</div>'});
- var ap=fetch(buildUrl(endpointAi,24),{credentials:'omit'}).then(function(r){return r.ok?r.json():Promise.reject(new Error('http '+r.status))}).then(function(d){if(!d||d.ok===false)throw new Error('bad payload');cachedAi=d.items||[];renderGrid(aiGrid,cachedAi,true)}).catch(function(){aiGrid.innerHTML='<div class="pairs-well__empty">AI generator not ready.</div>'});
- return Promise.all([pp,ap])}
- cta.addEventListener('click',function(){var op=cta.getAttribute('aria-expanded')==='true';if(op){cta.setAttribute('aria-expanded','false');sourcePalette.hidden=true;vendorPicker.hidden=true;catalogPanel.hidden=true;aiPanel.hidden=true;return}cta.setAttribute('aria-expanded','true');load()});
+
+ // ── Card renderer — AI cards get a pitch chip + Duplicate/Edit buttons.
+ function renderCard(p, isAi, idx) {
+ var img = p.image_url ? esc(p.image_url) : '';
+ var why = (p.why && p.why[0]) ? esc(p.why[0]) : (p.motif ? 'AI ' + esc(p.motif) : '');
+ var pitchChip = (isAi && p.pitch_in) ? '<span class="pw-pitch-chip">'+p.pitch_in+'″</span>' : '';
+ var pal = (p.palette||[]).map(function(c){var hex=typeof c==='string'?c:c.hex;return '<span class="pw-dot" style="background:'+esc(hex)+'" title="'+esc(hex)+'"></span>'}).join('');
+ var dom = (p.palette && p.palette[0]) || null;
+ var pb = '';
+ if (dom && dom.paints) {
+ var vs = selectedVendors();
+ var rows = vs.map(function(v){var pt=dom.paints[v];if(!pt)return '';return '<span class="row"><span class="label">'+esc(VENDOR_SHORT[v])+'</span><span class="pw-square" style="background:'+esc(pt.hex)+'"></span>'+esc(pt.name)+' <span class="pw-code">'+esc(pt.code)+'</span></span>'}).filter(Boolean).join('');
+ if (rows) pb = '<div class="pairs-well__paints">'+rows+'</div>';
+ }
+ var href = p.url ? esc(p.url) : (p.handle ? 'https://www.designerwallcoverings.com/products/'+esc(p.handle) : '#');
+ var aiOnly = isAi
+ ? '<div class="pw-card-actions">'
+ + (IS_ADMIN ? '<button type="button" data-card-action="edit" data-idx="'+idx+'">Edit</button>' : '')
+ + '<button type="button" data-card-action="duplicate" data-idx="'+idx+'">Duplicate</button>'
+ + '</div>'
+ : '';
+ // For AI cards the href doesn't lead anywhere (placeholder) — use a div instead of an <a>
+ // so the Duplicate/Edit buttons don't trigger navigation when clicked.
+ var Tag = isAi ? 'div' : 'a';
+ var openAttrs = isAi ? '' : ' target="_blank" rel="noopener" href="'+href+'"';
+ return '<'+Tag+' class="pairs-well__card'+(isAi?' pairs-well__card--ai':'')+'"'+openAttrs+' data-card-idx="'+idx+'" title="'+esc((p.why||[]).join(' · '))+'">'
+ + aiOnly
+ + '<div class="pairs-well__img-wrap">'+(img?'<img class="pairs-well__img" src="'+img+'" alt="'+esc(p.title)+'" loading="lazy">':'')+'</div>'
+ + '<div class="pairs-well__meta">'
+ + '<p class="pairs-well__title">'+esc(p.title||'')+pitchChip+'</p>'
+ + '<p class="pairs-well__vendor">'+esc(p.vendor||(isAi?'AI-Generated':''))+'</p>'
+ + (why ? '<span class="pairs-well__why">'+why+'</span>' : '')
+ + (pal ? '<div class="pairs-well__palette">'+pal+'</div>' : '')
+ + pb
+ + '</div>'
+ + '</'+Tag+'>';
+ }
+ function renderGrid(g, items, isAi) {
+ if (!items || !items.length) { g.innerHTML = '<div class="pairs-well__empty">No coordinates found.</div>'; return; }
+ g.innerHTML = items.map(function (p, i) { return renderCard(p, isAi, i); }).join('');
+ }
+ function renderAiGrid() {
+ var all = (customCards || []).concat(cachedAi || []);
+ renderGrid(aiGrid, all, true);
+ }
+
+ function buildUrl(base, limit, opts) {
+ var qs = ['palette=' + encodeURIComponent(palette)];
+ if (sourceTitle) qs.push('title=' + encodeURIComponent(sourceTitle));
+ if (sourceImage) qs.push('image_url=' + encodeURIComponent(sourceImage));
+ if (limit) qs.push('limit=' + limit);
+ if (opts && opts.tile_in) qs.push('tile_in=' + opts.tile_in);
+ return base + '?' + qs.join('&');
+ }
+
+ function refetchAi() {
+ if (!loaded) return;
+ renderSkeletons(aiGrid, 12);
+ fetch(buildUrl(endpointAi, 24, { tile_in: parseFloat(tileIn.value) }), { credentials: 'omit' })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
+ .then(function (d) { if (!d || d.ok === false) throw new Error('bad payload'); cachedAi = d.items || []; renderAiGrid(); })
+ .catch(function () { aiGrid.innerHTML = '<div class="pairs-well__empty">AI generator not ready.</div>'; });
+ }
+
+ function load() {
+ if (loaded) return Promise.resolve();
+ loaded = true;
+ renderSkeletons(catalogGrid, 12); renderSkeletons(aiGrid, 12);
+ catalogPanel.hidden = false; aiPanel.hidden = false;
+ var pp = fetch(buildUrl(endpointPairs, 24), { credentials: 'omit' })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
+ .then(function (d) {
+ if (!d || d.ok === false) throw new Error((d && d.error) || 'bad payload');
+ cachedSource = d.source || null; cachedPairs = d.pairs || [];
+ if (cachedSource) renderSourcePalette(cachedSource.palette || []);
+ renderGrid(catalogGrid, cachedPairs, false);
+ catalogCount.textContent = '(' + cachedPairs.length + ')';
+ if (!cachedPairs.length && (!cachedSource || !cachedSource.palette || !cachedSource.palette.length)) root.hidden = true;
+ })
+ .catch(function () { catalogGrid.innerHTML = '<div class="pairs-well__empty">Could not load coordinates right now.</div>'; });
+ var ap = fetch(buildUrl(endpointAi, 24, { tile_in: parseFloat(tileIn.value) }), { credentials: 'omit' })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
+ .then(function (d) { if (!d || d.ok === false) throw new Error('bad payload'); cachedAi = d.items || []; renderAiGrid(); })
+ .catch(function () { aiGrid.innerHTML = '<div class="pairs-well__empty">AI generator not ready.</div>'; });
+ return Promise.all([pp, ap]);
+ }
+
+ cta.addEventListener('click', function () {
+ var op = cta.getAttribute('aria-expanded') === 'true';
+ if (op) {
+ cta.setAttribute('aria-expanded', 'false');
+ sourcePalette.hidden = true; vendorPicker.hidden = true;
+ catalogPanel.hidden = true; aiPanel.hidden = true;
+ return;
+ }
+ cta.setAttribute('aria-expanded', 'true');
+ load();
+ });
+
+ // ── Duplicate / Edit button handler on AI cards ─────────────────────────
+ function duplicateName(name) {
+ // "X" → "X (copy)" → "X (copy 2)" → "X (copy 3)"...
+ var m = /^(.*?) \(copy(?: (\d+))?\)$/.exec(name);
+ if (m) {
+ var n = parseInt(m[2] || '1', 10) + 1;
+ return m[1] + ' (copy ' + n + ')';
+ }
+ return name + ' (copy)';
+ }
+ aiGrid.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-card-action]');
+ if (!btn) return;
+ e.preventDefault(); e.stopPropagation();
+ var idx = parseInt(btn.getAttribute('data-idx'), 10);
+ var all = (customCards || []).concat(cachedAi || []);
+ var src = all[idx]; if (!src) return;
+ if (btn.getAttribute('data-card-action') === 'duplicate') {
+ var clone = JSON.parse(JSON.stringify(src));
+ clone.id = 'custom-' + Date.now();
+ clone.title = duplicateName(src.title || 'Pattern');
+ clone.custom = true;
+ customCards.unshift(clone);
+ renderAiGrid();
+ } else if (btn.getAttribute('data-card-action') === 'edit') {
+ openEditor(src, idx);
+ }
+ });
+
+ ${_isAdmin ? `
+ // ── Pattern editor (admin only) ─────────────────────────────────────────
+ var editorImg = document.getElementById('pw-editor-img');
+ var fldLabel = document.getElementById('pw-fld-label');
+ var fldMotif = document.getElementById('pw-fld-motif');
+ var fldStyle = document.getElementById('pw-fld-style');
+ var fldPitch = document.getElementById('pw-fld-pitch');
+ var fldPitchVal = document.getElementById('pw-fld-pitch-val');
+ var slotsEl = document.getElementById('pw-slots');
+ var slotCountEls = editor.querySelectorAll('.pw-slot-count button');
+ var btnSave = document.getElementById('pw-btn-save');
+ var btnSaveNew = document.getElementById('pw-btn-savenew');
+ var editingState = null; // { sourceCard, sourceIdx }
+
+ function renderSlots(colors, n) {
+ slotsEl.innerHTML = '';
+ for (var i = 0; i < n; i++) {
+ var hex = colors[i] || '';
+ var slot = document.createElement('div');
+ slot.className = 'pw-slot' + (hex ? ' filled' : '');
+ slot.setAttribute('data-slot', String(i));
+ slot.style.background = hex || '#fff';
+ if (!hex) slot.textContent = '+';
+ var picker = document.createElement('input');
+ picker.type = 'color'; picker.value = hex || '#888888';
+ picker.addEventListener('input', function (ev) {
+ var s = ev.target.parentNode;
+ var ix = parseInt(s.getAttribute('data-slot'), 10);
+ editingState.colors[ix] = ev.target.value;
+ s.style.background = ev.target.value;
+ s.classList.add('filled');
+ s.textContent = '';
+ previewRender();
+ });
+ slot.appendChild(picker);
+ slotsEl.appendChild(slot);
+ }
+ }
+
+ function activeSlotCount() {
+ var n = 3;
+ slotCountEls.forEach(function (b) { if (b.classList.contains('active')) n = parseInt(b.getAttribute('data-n'), 10); });
+ return n;
+ }
+
+ slotCountEls.forEach(function (b) {
+ b.addEventListener('click', function () {
+ slotCountEls.forEach(function (x) { x.classList.toggle('active', x === b); });
+ var n = parseInt(b.getAttribute('data-n'), 10);
+ if (editingState) {
+ editingState.colors = editingState.colors.slice(0, n);
+ while (editingState.colors.length < n) editingState.colors.push('');
+ renderSlots(editingState.colors, n);
+ previewRender();
+ }
+ });
+ });
+
+ slotsEl.addEventListener('dragover', function (e) {
+ var slot = e.target.closest('.pw-slot');
+ if (slot) { e.preventDefault(); slot.classList.add('drag-over'); e.dataTransfer.dropEffect = 'copy'; }
+ });
+ slotsEl.addEventListener('dragleave', function (e) {
+ var slot = e.target.closest('.pw-slot');
+ if (slot) slot.classList.remove('drag-over');
+ });
+ slotsEl.addEventListener('drop', function (e) {
+ var slot = e.target.closest('.pw-slot'); if (!slot) return;
+ e.preventDefault(); slot.classList.remove('drag-over');
+ var hex = e.dataTransfer.getData('application/x-wallco-color') || e.dataTransfer.getData('text/plain');
+ if (!hex || !/^#?[0-9a-fA-F]{3,8}$/.test(hex.replace(/[^#0-9a-fA-F]/g,''))) return;
+ if (!hex.startsWith('#')) hex = '#' + hex;
+ var ix = parseInt(slot.getAttribute('data-slot'), 10);
+ editingState.colors[ix] = hex;
+ slot.style.background = hex;
+ slot.classList.add('filled');
+ slot.textContent = '';
+ var picker = slot.querySelector('input[type=color]'); if (picker) picker.value = hex;
+ previewRender();
+ });
+
+ fldPitch.addEventListener('input', function () { fldPitchVal.textContent = fldPitch.value + '″'; previewRender(); });
+ fldMotif.addEventListener('change', previewRender);
+ fldStyle.addEventListener('change', previewRender);
+
+ var previewTimer = null;
+ function previewRender() {
+ if (!editingState) return;
+ clearTimeout(previewTimer);
+ previewTimer = setTimeout(function () {
+ var body = {
+ motif: fldMotif.value,
+ style: fldStyle.value,
+ pitch_in: parseFloat(fldPitch.value),
+ colors: editingState.colors.filter(Boolean),
+ tile_in: parseFloat(tileIn.value),
+ label: fldLabel.value
+ };
+ fetch(endpointAi.replace('/ai-coordinates', '/ai-coordinates/render'), {
+ method: 'POST', credentials: 'omit',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ }).then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d || !d.image_url) return;
+ editorImg.src = d.image_url;
+ editingState.lastRender = d;
+ })
+ .catch(function () {});
+ }, 180);
+ }
+
+ function openEditor(card, idx) {
+ var colors = (card.colors && card.colors.length ? card.colors : (card.palette || [])).slice(0, 5);
+ while (colors.length < 3) colors.push('');
+ editingState = { sourceCard: card, sourceIdx: idx, colors: colors.slice() };
+ fldLabel.value = card.title || '';
+ fldMotif.value = (card.motif === 'tartan') ? 'tartan' : ((card.motif === 'plaid') ? 'plaid' : 'stripe');
+ fldStyle.value = card.style || 'even';
+ fldPitch.value = card.pitch_in || 6;
+ fldPitchVal.textContent = fldPitch.value + '″';
+ // pick active slot-count button
+ var n = colors.length || 3;
+ slotCountEls.forEach(function (b) { b.classList.toggle('active', parseInt(b.getAttribute('data-n'), 10) === n); });
+ renderSlots(editingState.colors, n);
+ editorImg.src = card.image_url || '';
+ editor.hidden = false;
+ document.body.style.overflow = 'hidden';
+ }
+ function closeEditor() { editor.hidden = true; editingState = null; document.body.style.overflow = ''; }
+
+ editor.addEventListener('click', function (e) { if (e.target.getAttribute('data-close') === '1') closeEditor(); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !editor.hidden) closeEditor(); });
+
+ btnSave.addEventListener('click', function () {
+ if (!editingState || !editingState.lastRender) return;
+ var src = editingState.sourceCard;
+ src.motif = editingState.lastRender.motif;
+ src.style = editingState.lastRender.style;
+ src.pitch_in = editingState.lastRender.pitch_in;
+ src.colors = editingState.lastRender.colors;
+ src.palette = editingState.lastRender.colors;
+ src.image_url = editingState.lastRender.image_url;
+ src.title = fldLabel.value || src.title;
+ src.why = [src.style + ' · ' + src.pitch_in + '″'];
+ renderAiGrid();
+ closeEditor();
+ });
+ btnSaveNew.addEventListener('click', function () {
+ if (!editingState || !editingState.lastRender) return;
+ var card = {
+ id: 'custom-' + Date.now(), motif: editingState.lastRender.motif,
+ style: editingState.lastRender.style, pitch_in: editingState.lastRender.pitch_in,
+ colors: editingState.lastRender.colors, palette: editingState.lastRender.colors,
+ image_url: editingState.lastRender.image_url,
+ title: (fldLabel.value || (editingState.sourceCard.title || 'Custom')) + (fldLabel.value ? '' : ' (custom)'),
+ why: [editingState.lastRender.style + ' · ' + editingState.lastRender.pitch_in + '″'],
+ custom: true
+ };
+ customCards.unshift(card);
+ renderAiGrid();
+ closeEditor();
+ });
+ ` : ''}
})();
</script>
← ade6dc4 migrate: ensure user_removed column on spoon_all_designs (wa
·
back to Wallco Ai
·
docs: 6afffe7 also shipped — design page color-story (dots+S fb5abf5 →