[object Object]

← back to Wallco Ai

design grid: universal hover-preview for the ~93% of cards without a room mockup — pure-CSS ::after pseudo-element on .card-img tiles the same image at 33% (3×3 zoom-out, reveals the seamless repeat). cards WITH a room mockup keep the existing photoreal swap (.has-room wins). Stamped --card-bg on every card-img site (7 spots) so the pseudo-element gets the right source w/o extra fetch

a5228dbe7209db6c9eeeb8d7ebb58ea9d74703ed · 2026-05-28 10:49:47 -0700 · Steve Abrams

Files touched

Diff

commit a5228dbe7209db6c9eeeb8d7ebb58ea9d74703ed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 10:49:47 2026 -0700

    design grid: universal hover-preview for the ~93% of cards without a room mockup — pure-CSS ::after pseudo-element on .card-img tiles the same image at 33% (3×3 zoom-out, reveals the seamless repeat). cards WITH a room mockup keep the existing photoreal swap (.has-room wins). Stamped --card-bg on every card-img site (7 spots) so the pseudo-element gets the right source w/o extra fetch
---
 data/cactus-decisions.jsonl |  1 +
 public/css/site.css         | 23 +++++++++++++++++++++++
 server.js                   | 14 +++++++-------
 3 files changed, 31 insertions(+), 7 deletions(-)

diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index 53bc7dc..c30fdf6 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -3895,3 +3895,4 @@
 {"ts":"2026-05-28T16:41:23.595Z","id":16366,"action":"bad"}
 {"ts":"2026-05-28T16:41:24.548Z","id":17278,"action":"bad"}
 {"ts":"2026-05-28T16:41:27.589Z","id":20171,"action":"bad"}
+{"ts":"2026-05-28T17:49:14.597Z","id":54048,"action":"live"}
diff --git a/public/css/site.css b/public/css/site.css
index 6dac6c8..edc79f6 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -358,6 +358,29 @@ main { padding-top: 73px; }
 }
 .design-card.has-room:hover .card-room,
 .design-card.has-room:focus-visible .card-room { opacity: 1; }
+
+/* Universal hover preview — for cards that DON'T have a room mockup file
+   (≈93% of catalog), show the same image tiled at 25% size on hover. This
+   reveals the seamless-repeat structure of the design — a "2nd image" that
+   exists FOR FREE on every card without an extra fetch. The room-mockup
+   path above takes precedence (.has-room cards win); this ::after layer
+   sits underneath and only fades in when neither room nor user motion-
+   preferences-reduced is active. */
+.design-card:not(.has-room) .card-img::after {
+  content: "";
+  position: absolute;
+  inset: 0;
+  background-image: var(--card-bg, none);
+  background-repeat: repeat;
+  background-size: 33.33% 33.33%;  /* 3×3 tile zoom-out → shows the repeat */
+  background-position: center;
+  opacity: 0;
+  transition: opacity 360ms ease;
+  pointer-events: none;
+  will-change: opacity;
+}
+.design-card:not(.has-room):hover .card-img::after,
+.design-card:not(.has-room):focus-visible .card-img::after { opacity: 1; }
 .card-room-badge {
   position: absolute;
   top: 8px; left: 8px;
diff --git a/server.js b/server.js
index a90775c..5d7b1af 100644
--- a/server.js
+++ b/server.js
@@ -3866,7 +3866,7 @@ app.get('/me/saved', (req, res) => {
     return `
     <div class="saved-card-wrap" data-design-id="${d.id}" style="position:relative">
       <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${_escTrade(d.title || ('Design #' + d.id))}">
-        <div class="card-img" style="background-image:url('${_escTrade(d.image_url)}')" role="img" aria-label="${_escTrade(d.title)} pattern thumbnail">${roomOverlay}</div>
+        <div class="card-img" style="background-image:url('${_escTrade(d.image_url)}');--card-bg:url('${_escTrade(d.image_url)}')" role="img" aria-label="${_escTrade(d.title)} pattern thumbnail">${roomOverlay}</div>
         <div class="card-meta">
           <span class="card-title">${_escTrade(d.title)}</span>
           <span class="card-cat">${_escTrade(d.category)}</span>
@@ -4777,7 +4777,7 @@ app.get('/', (req, res) => {
   });
 
   const heroSlides = heroes.slice(0, 6).map((d, i) =>
-    `<div class="hero-slide ${i===0?'active':''}" data-idx="${i}" style="background-image:url('${d.image_url}')" aria-label="${d.title}"></div>`
+    `<div class="hero-slide ${i===0?'active':''}" data-idx="${i}" style="background-image:url('${d.image_url}');--card-bg:url('${d.image_url}')" aria-label="${d.title}"></div>`
   ).join('\n');
 
   // Top categories by count — used to render the home random-rail chip strip.
@@ -4800,7 +4800,7 @@ app.get('/', (req, res) => {
       : '';
     return `
     <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}">
-      <div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
+      <div class="card-img" style="background-image:url('${d.image_url}');--card-bg:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
       <div class="card-meta">
         <span class="card-title">${d.title}</span>
         <span class="card-cat">${d.category}</span>
@@ -5711,7 +5711,7 @@ app.get('/designs', (req, res) => {
       <button type="button" class="fav-btn" data-fav-id="${d.id}" aria-pressed="false" aria-label="Save ${d.title} to favorites" title="Favorite"><span class="fav-icon"></span></button>
       ${newBadge}
       <a href="/design/${d.id}" class="card-link" aria-label="${cardAriaLabel(d)}">
-        <div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
+        <div class="card-img" style="background-image:url('${d.image_url}');--card-bg:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
         <div class="card-meta">
           <span class="card-title">${d.title}</span>
           <span class="card-sku" title="DW catalog SKU">${sku}</span>
@@ -5749,7 +5749,7 @@ app.get('/designs', (req, res) => {
     const _digAdm = d.dig_number || `DIG_AI_${String(d.id).padStart(6, '0')}`;
     return `
     <div class="design-card review-card" data-id="${d.id}" data-dig="${_digAdm}">
-      <a href="/design/${d.id}" target="_blank" class="card-img" style="background-image:url('${d.image_url}')" rel="noopener noreferrer">
+      <a href="/design/${d.id}" target="_blank" class="card-img" style="background-image:url('${d.image_url}');--card-bg:url('${d.image_url}')" rel="noopener noreferrer">
         <span class="decision-badge"></span>
         <div class="card-hover-actions admin-hover-actions" aria-hidden="true">
           <a class="chov-btn chov-view" href="/design/${d.id}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">View</a>
@@ -7983,7 +7983,7 @@ function renderDesignNotFound(req, idForCanonical) {
   } catch {}
   const pickupHtml = pickups.map(d => `
     <a class="nf-pick" href="/design/${d.id}" aria-label="${d.title || 'Design ' + d.id}">
-      <div class="nf-pick-img" style="background-image:url('${d.image_url}')"></div>
+      <div class="nf-pick-img" style="background-image:url('${d.image_url}');--card-bg:url('${d.image_url}')"></div>
       <div class="nf-pick-meta">
         <div class="nf-pick-title">${(d.title || 'Untitled').slice(0, 40)}</div>
         <div class="nf-pick-cat">${d.category || ''}</div>
@@ -17108,7 +17108,7 @@ app.get('/murals', (req, res) => {
       : '';
     return `
     <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}">
-      <div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
+      <div class="card-img" style="background-image:url('${d.image_url}');--card-bg:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
       <div class="card-meta">
         <span class="card-title">${d.title}</span>
         <span class="card-cat">${murals.length === 0 ? 'available as mural' : d.kind}</span>

← 86dd47e wall-viewer: ROOM_API render as sign-in upsell. /api/room no  ·  back to Wallco Ai  ·  wall-room-viewer: add Wall HEIGHT slider matching the Wall W b471c32 →