← back to Interiordesignershowroom
feat(room): thumbnail rail beside the scene — stacked right on desktop, below on mobile; image-only thumbs, item name on hover; each a tracked shop link
bede8a7a010be2ed6b1a743c19255faba72c1d12 · 2026-08-01 23:19:33 -0700 · Steve Abrams
Files touched
M lib/render.jsM public/css/site.cssM server.js
Diff
commit bede8a7a010be2ed6b1a743c19255faba72c1d12
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 23:19:33 2026 -0700
feat(room): thumbnail rail beside the scene — stacked right on desktop, below on mobile; image-only thumbs, item name on hover; each a tracked shop link
---
lib/render.js | 16 +++++++++++++++-
public/css/site.css | 29 +++++++++++++++++++++++++++++
server.js | 6 ++++--
3 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/lib/render.js b/lib/render.js
index d17e840..3d5f0c1 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -128,6 +128,20 @@ function sceneFigure({ scene_image, title, hotspots = [] }) {
</figure><script src="/js/hotspots.js" defer></script>`;
}
+// Small thumbnail rail of the room's pieces — stacks to the RIGHT of the scene on
+// desktop, BELOW it on mobile (CSS). Each thumb is image-only; the item name shows
+// on hover. Every thumb is a tracked shop link, so the rail is a compact shop-list.
+function roomThumbs(products = []) {
+ const withImg = (products || []).filter((p) => p.image_url);
+ if (!withImg.length) return '';
+ const thumbs = withImg.map((p) =>
+ `<a class="room-thumb" href="/go/${p.id}" rel="nofollow sponsored" target="_blank" role="listitem" aria-label="Shop ${esc(p.title)}">
+ <img src="${esc(p.image_url)}" alt="${esc(p.title)}" loading="lazy">
+ <span class="room-thumb-label">${esc(p.title)}</span>
+ </a>`).join('');
+ return `<div class="room-thumbs" role="list" aria-label="Items in this room">${thumbs}</div>`;
+}
+
function layout({ title, description, canonical, jsonld, image, body, activeNav }) {
const ogImage = absUrl(image) || `${SITE.url}/img/og.png`;
const nav = [
@@ -188,4 +202,4 @@ ${disclosureBar()}
</html>`;
}
-module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl, sceneFigure };
+module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl, sceneFigure, roomThumbs };
diff --git a/public/css/site.css b/public/css/site.css
index 00e29fd..4d0e7e1 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -740,6 +740,35 @@ h2.ruled::after {
object-fit: cover;
display: block;
}
+/* Scene + thumbnail rail: rail stacks to the RIGHT of the image on desktop,
+ BELOW it on mobile. Each thumb is image-only; the item name shows on hover. */
+.room-scene-row { display: flex; gap: 12px; align-items: flex-start; margin: 20px 0; }
+.room-scene-row .room-scene-wrap { flex: 1 1 auto; min-width: 0; margin: 0; }
+.room-thumbs {
+ display: flex; flex-direction: column; gap: 8px;
+ flex: 0 0 60px; width: 60px; max-height: 78vh; overflow-y: auto; padding: 1px;
+}
+.room-thumb { position: relative; display: block; line-height: 0; flex: 0 0 auto; }
+.room-thumb img {
+ width: 56px; height: 56px; object-fit: cover; border-radius: 4px;
+ border: 1px solid var(--line); transition: border-color .15s, transform .15s;
+}
+.room-thumb:hover img, .room-thumb:focus-visible img { border-color: var(--accent); transform: scale(1.06); }
+.room-thumb-label {
+ position: absolute; right: calc(100% + 8px); top: 50%; transform: translateY(-50%);
+ white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;
+ background: rgba(34,30,25,.93); color: #f3ede3; font-size: .68rem; letter-spacing: .01em;
+ padding: 4px 9px; border-radius: 4px; pointer-events: none; z-index: 10;
+ opacity: 0; visibility: hidden; transition: opacity .14s;
+}
+.room-thumb:hover .room-thumb-label, .room-thumb:focus-visible .room-thumb-label { opacity: 1; visibility: visible; }
+/* mobile: rail drops BELOW the image as a horizontal wrap; label appears above the thumb */
+@media (max-width: 700px) {
+ .room-scene-row { flex-direction: column; }
+ .room-thumbs { flex-direction: row; flex-wrap: wrap; width: auto; flex: none; max-height: none; }
+ .room-thumb-label { right: auto; left: 50%; top: auto; bottom: calc(100% + 8px); transform: translateX(-50%); }
+}
+
/* Shoppable scene: show the render at its NATURAL ratio (no 16/9 crop) so the
vision-located hotspot %-coordinates line up 1:1 with the visible pixels. */
.room-scene-wrap { position: relative; display: block; margin: 20px 0; line-height: 0; }
diff --git a/server.js b/server.js
index bb900e7..6b9aa4d 100644
--- a/server.js
+++ b/server.js
@@ -3,7 +3,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const db = require('./lib/db');
-const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, sceneFigure } = require('./lib/render');
+const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, sceneFigure, roomThumbs } = require('./lib/render');
const catalog = require('./lib/catalog');
const rooms = require('./lib/rooms');
const scene = require('./lib/scene');
@@ -477,7 +477,9 @@ app.get('/room/:slug', async (req, res, next) => {
<h1>${esc(room.title)}</h1>
<p class="subtle">${esc(styleLabel)}${styleLabel && products.length ? ' · ' : ''}${products.length ? `${products.length} shoppable piece${products.length === 1 ? '' : 's'}` : ''}</p>
</div>
- ${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}
+ ${room.scene_image
+ ? `<div class="room-scene-row">${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}${roomThumbs(products)}</div>`
+ : ''}
${paintBlock}
<h2 class="shop-heading">Shop this room</h2>
${piecesGrid}
← 439a773 ui(build): persistent 3-column Room Builder — left words, ri
·
back to Interiordesignershowroom
·
feat(build): infinite-scroll the products panel — offset pag 9906389 →