[object Object]

← back to Wallco Ai

Add color-dots.js: vertical strip of the pattern's screen-print ink colors (canvas-extracted) along the left edge of every pattern, included across all 17 PDP theme variants

8e674199948c79cd43b68d6903e1462ab87d9ec1 · 2026-05-29 13:17:57 -0700 · Steve

Files touched

Diff

commit 8e674199948c79cd43b68d6903e1462ab87d9ec1
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 13:17:57 2026 -0700

    Add color-dots.js: vertical strip of the pattern's screen-print ink colors (canvas-extracted) along the left edge of every pattern, included across all 17 PDP theme variants
---
 public/js/color-dots.js                   | 99 +++++++++++++++++++++++++++++++
 public/theme-variants/v1-compact.html     |  1 +
 public/theme-variants/v10-stack.html      |  1 +
 public/theme-variants/v11-best.html       |  1 +
 public/theme-variants/v12-roomsplit.html  |  1 +
 public/theme-variants/v13-roomdrawer.html |  1 +
 public/theme-variants/v14-roomzine.html   |  1 +
 public/theme-variants/v15-roomsticky.html |  1 +
 public/theme-variants/v16-roommin.html    |  1 +
 public/theme-variants/v17-roomshow.html   |  1 +
 public/theme-variants/v2-bento.html       |  1 +
 public/theme-variants/v3-tabs.html        |  1 +
 public/theme-variants/v4-hero.html        |  1 +
 public/theme-variants/v5-vertical.html    |  1 +
 public/theme-variants/v6-drawer.html      |  1 +
 public/theme-variants/v7-editorial.html   |  1 +
 public/theme-variants/v8-spec.html        |  1 +
 public/theme-variants/v9-config.html      |  1 +
 scripts/room-left-template.html           |  1 +
 server.js                                 | 27 +++++----
 20 files changed, 131 insertions(+), 13 deletions(-)

diff --git a/public/js/color-dots.js b/public/js/color-dots.js
new file mode 100644
index 0000000..ae0da37
--- /dev/null
+++ b/public/js/color-dots.js
@@ -0,0 +1,99 @@
+/* color-dots.js — overlay a vertical strip of the pattern's screen-print ink
+ * colors along the LEFT edge of every pattern image, on every PDP theme.
+ * Same-origin design images (/designs/img/...) are canvas-readable, so we
+ * downscale + quantize to the dominant flat inks and render them as dots.
+ * Shared across all theme variants + the classic PDP (one include, no per-
+ * theme markup edits). Skips room mockups (/designs/room/) and small thumbs. */
+(function () {
+  'use strict';
+  var DOT_COUNT = 6;       // up to N ink dots
+  var MIN_W = 150;         // ignore thumbnails smaller than this (rendered px)
+
+  function isPattern(img) {
+    var s = img.currentSrc || img.src || '';
+    if (!/\/designs\/img\//.test(s)) return false;   // only design patterns
+    if (/\/designs\/room\//.test(s)) return false;    // not room mockups
+    return true;
+  }
+
+  // Downscale to a small canvas, bucket colors (coarse quantize merges AA
+  // fringe), return the top DOT_COUNT inks by coverage, dropping near-dupes.
+  function extractInks(img) {
+    try {
+      var W = 56, H = 56;
+      var c = document.createElement('canvas'); c.width = W; c.height = H;
+      var ctx = c.getContext('2d', { willReadFrequently: true });
+      ctx.drawImage(img, 0, 0, W, H);
+      var data = ctx.getImageData(0, 0, W, H).data;
+      var buckets = {};
+      for (var i = 0; i < data.length; i += 4) {
+        var a = data[i + 3]; if (a < 128) continue;
+        var r = data[i], g = data[i + 1], b = data[i + 2];
+        var q = (Math.round(r / 24) * 24) + ',' + (Math.round(g / 24) * 24) + ',' + (Math.round(b / 24) * 24);
+        if (!buckets[q]) buckets[q] = { n: 0, r: 0, g: 0, b: 0 };
+        var bk = buckets[q]; bk.n++; bk.r += r; bk.g += g; bk.b += b;
+      }
+      var arr = Object.keys(buckets).map(function (k) {
+        var bk = buckets[k];
+        return { n: bk.n, r: Math.round(bk.r / bk.n), g: Math.round(bk.g / bk.n), b: Math.round(bk.b / bk.n) };
+      }).sort(function (a, b) { return b.n - a.n; });
+      var out = [];
+      for (var j = 0; j < arr.length && out.length < DOT_COUNT; j++) {
+        var col = arr[j];
+        var dup = out.some(function (o) {
+          return Math.abs(o.r - col.r) + Math.abs(o.g - col.g) + Math.abs(o.b - col.b) < 36;
+        });
+        if (!dup) out.push(col);
+      }
+      return out.map(function (o) {
+        return '#' + [o.r, o.g, o.b].map(function (v) { return ('0' + v.toString(16)).slice(-2); }).join('');
+      });
+    } catch (e) { return []; }
+  }
+
+  function addDots(img) {
+    if (img.dataset.cdDone) return;
+    if ((img.naturalWidth || 0) < 8) return;             // not decoded yet
+    if ((img.clientWidth || img.width || 0) < MIN_W) return;
+    img.dataset.cdDone = '1';
+    var colors = extractInks(img);
+    if (!colors.length) return;
+    var host = img.parentElement; if (!host) return;
+    if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
+    var col = document.createElement('div');
+    col.className = 'cd-strip';
+    col.setAttribute('aria-label', 'pattern screen-print colors');
+    col.style.cssText = 'position:absolute;left:10px;top:50%;transform:translateY(-50%);' +
+      'display:flex;flex-direction:column;gap:9px;z-index:6;pointer-events:none';
+    colors.forEach(function (hex) {
+      var d = document.createElement('span');
+      d.title = hex; d.style.pointerEvents = 'auto';
+      d.style.cssText += 'width:18px;height:18px;border-radius:50%;background:' + hex +
+        ';box-shadow:0 0 0 2px rgba(255,255,255,.95),0 1px 5px rgba(0,0,0,.35)';
+      col.appendChild(d);
+    });
+    host.appendChild(col);
+  }
+
+  function scan() {
+    var imgs = document.images;
+    for (var i = 0; i < imgs.length; i++) {
+      var img = imgs[i];
+      if (img.dataset.cdDone || !isPattern(img)) continue;
+      if (img.complete && img.naturalWidth) addDots(img);
+      else img.addEventListener('load', function (e) { addDots(e.target); }, { once: true });
+    }
+  }
+
+  function boot() {
+    scan();
+    // re-scan on DOM changes (theme PDPs render their pattern client-side after fetch)
+    try {
+      new MutationObserver(function () { scan(); }).observe(document.body, { childList: true, subtree: true });
+    } catch (e) {}
+    window.addEventListener('resize', function () { clearTimeout(window._cdRt); window._cdRt = setTimeout(scan, 250); });
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();
diff --git a/public/theme-variants/v1-compact.html b/public/theme-variants/v1-compact.html
index 5a759fe..56ed11c 100644
--- a/public/theme-variants/v1-compact.html
+++ b/public/theme-variants/v1-compact.html
@@ -669,5 +669,6 @@ function escapeHtml(v) {
 }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v10-stack.html b/public/theme-variants/v10-stack.html
index 8a7f508..43a57b6 100644
--- a/public/theme-variants/v10-stack.html
+++ b/public/theme-variants/v10-stack.html
@@ -349,5 +349,6 @@ function saveDesign(id) { alert('Saved #' + id + ' (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v11-best.html b/public/theme-variants/v11-best.html
index 4662d09..9a19b37 100644
--- a/public/theme-variants/v11-best.html
+++ b/public/theme-variants/v11-best.html
@@ -570,5 +570,6 @@ function saveDesign(id) { alert('Saved #' + id + ' (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v12-roomsplit.html b/public/theme-variants/v12-roomsplit.html
index 5e9c8ed..b57f053 100644
--- a/public/theme-variants/v12-roomsplit.html
+++ b/public/theme-variants/v12-roomsplit.html
@@ -306,5 +306,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v13-roomdrawer.html b/public/theme-variants/v13-roomdrawer.html
index 8282a94..f5fd1eb 100644
--- a/public/theme-variants/v13-roomdrawer.html
+++ b/public/theme-variants/v13-roomdrawer.html
@@ -306,5 +306,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v14-roomzine.html b/public/theme-variants/v14-roomzine.html
index a054bbe..f4149d4 100644
--- a/public/theme-variants/v14-roomzine.html
+++ b/public/theme-variants/v14-roomzine.html
@@ -306,5 +306,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v15-roomsticky.html b/public/theme-variants/v15-roomsticky.html
index 99b47c3..4795d2e 100644
--- a/public/theme-variants/v15-roomsticky.html
+++ b/public/theme-variants/v15-roomsticky.html
@@ -306,5 +306,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v16-roommin.html b/public/theme-variants/v16-roommin.html
index 4895872..cc1f4e9 100644
--- a/public/theme-variants/v16-roommin.html
+++ b/public/theme-variants/v16-roommin.html
@@ -306,5 +306,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v17-roomshow.html b/public/theme-variants/v17-roomshow.html
index 2c2dcf6..cd4f99a 100644
--- a/public/theme-variants/v17-roomshow.html
+++ b/public/theme-variants/v17-roomshow.html
@@ -306,5 +306,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v2-bento.html b/public/theme-variants/v2-bento.html
index 540085c..01f78c6 100644
--- a/public/theme-variants/v2-bento.html
+++ b/public/theme-variants/v2-bento.html
@@ -648,5 +648,6 @@ function escapeHtml(v) {
 }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v3-tabs.html b/public/theme-variants/v3-tabs.html
index 4fcc4e5..6859ebc 100644
--- a/public/theme-variants/v3-tabs.html
+++ b/public/theme-variants/v3-tabs.html
@@ -678,5 +678,6 @@ function escapeHtml(v) {
 }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v4-hero.html b/public/theme-variants/v4-hero.html
index 845bfd2..8bfb89a 100644
--- a/public/theme-variants/v4-hero.html
+++ b/public/theme-variants/v4-hero.html
@@ -598,5 +598,6 @@ function escapeHtml(v) {
 }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v5-vertical.html b/public/theme-variants/v5-vertical.html
index cc044bf..88f8454 100644
--- a/public/theme-variants/v5-vertical.html
+++ b/public/theme-variants/v5-vertical.html
@@ -416,5 +416,6 @@ function saveDesign(id) { alert('Saved #' + id + ' (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v6-drawer.html b/public/theme-variants/v6-drawer.html
index c1ead2c..65cd02a 100644
--- a/public/theme-variants/v6-drawer.html
+++ b/public/theme-variants/v6-drawer.html
@@ -398,5 +398,6 @@ function saveDesign(id) { alert('Saved #' + id + ' (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v7-editorial.html b/public/theme-variants/v7-editorial.html
index 2d0ab49..dfb4072 100644
--- a/public/theme-variants/v7-editorial.html
+++ b/public/theme-variants/v7-editorial.html
@@ -349,5 +349,6 @@ function addSample() { alert('Sample added (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v8-spec.html b/public/theme-variants/v8-spec.html
index 09a19a7..a67cbf2 100644
--- a/public/theme-variants/v8-spec.html
+++ b/public/theme-variants/v8-spec.html
@@ -338,5 +338,6 @@ function addSample() { alert('Sample added (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/public/theme-variants/v9-config.html b/public/theme-variants/v9-config.html
index 2f91a92..f5554b7 100644
--- a/public/theme-variants/v9-config.html
+++ b/public/theme-variants/v9-config.html
@@ -372,5 +372,6 @@ function addBundle() { alert('Bundle added (stub)'); }
 function escapeHtml(v) { if (v == null) return ''; return String(v).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v) { return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/scripts/room-left-template.html b/scripts/room-left-template.html
index ff29d3c..6c4e0b8 100644
--- a/scripts/room-left-template.html
+++ b/scripts/room-left-template.html
@@ -290,5 +290,6 @@ window.copyLink = async function(){ try{ await navigator.clipboard.writeText(loc
 function escapeHtml(v){ if(v==null) return ''; return String(v).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
 function escapeAttr(v){ return escapeHtml(v); }
 </script>
+<script src="/js/color-dots.js" defer></script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 69243a7..173f7fc 100644
--- a/server.js
+++ b/server.js
@@ -137,13 +137,18 @@ try { require('./src/social').mount(app); } catch (e) { console.error('Social mo
 // Inject the universal share layer onto the designs grid + every design page.
 // Done as a response rewrite so the 863KB server.js page templates stay untouched.
 app.use((req, res, next) => {
-  if (req.path === '/designs' || /^\/design\/\d+$/.test(req.path)) {
+  const isPDP = /^\/design\/\d+$/.test(req.path);
+  if (req.path === '/designs' || isPDP) {
     const origSend = res.send.bind(res);
     res.send = (body) => {
-      if (typeof body === 'string' && body.indexOf('</body>') !== -1
-          && body.indexOf('social-share.js') === -1) {
-        body = body.replace('</body>',
-          '<script src="/js/social-share.js" defer></script>\n</body>');
+      if (typeof body === 'string' && body.indexOf('</body>') !== -1) {
+        let inj = '';
+        if (body.indexOf('social-share.js') === -1) inj += '<script src="/js/social-share.js" defer></script>\n';
+        // color-dots on the CLASSIC PDP too (the default "theme") — left-edge
+        // screen-print ink dots, matching the 17 theme variants. PDP only (the
+        // grid isn't a theme; per-card dots would clutter it).
+        if (isPDP && body.indexOf('color-dots.js') === -1) inj += '<script src="/js/color-dots.js" defer></script>\n';
+        if (inj) body = body.replace('</body>', inj + '</body>');
       }
       return origSend(body);
     };
@@ -6379,14 +6384,10 @@ app.get('/designs', (req, res) => {
 <body>
 ${htmlHeader('/designs')}
 <main id="grid-main">
-<!-- Theme toggle removed 2026-05-29 (Steve directive: 'i ages should not be
-     superzooom on load.. revert https://wallco.ai/designs'). The theme2 CSS
-     used `background:var(--card-bg)` shorthand on [class*="card"], which has
-     higher specificity than the .card-img stylesheet rule and reset
-     background-size from 'cover' back to 'auto' — leaving every card-img
-     rendering its 1024px source at natural size inside a 256px box (=
-     super-zoom). PDP /design/:id keeps its own theme toggle; this revert
-     is grid-only. -->
+<!-- Theme toggle removed 2026-05-29 (Steve: revert /designs super-zoom).
+     theme2 CSS used "background" shorthand on [class*=card] which reset
+     background-size from cover to auto, making each card show its 1024px
+     source at natural size inside a 256px box. PDP keeps its own toggle. -->
 ${''}
 ${cat === 'drunk-animals' ? `
 <section style="padding:48px 24px 24px;text-align:center;background:linear-gradient(180deg,#1a0f1a 0%,#2a1530 100%);color:#fff;margin-bottom:0">

← 8c1ed72 revert: theme1/theme2 toggle on /designs admin (was super-zo  ·  back to Wallco Ai  ·  cactus-curator: select-all button + live-animate-out + auto- 6f2666d →