← 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
M data/cactus-decisions.jsonlM public/css/site.cssM server.js
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 →