[object Object]

← 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

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,'&quot;')}"
+                 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
     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 →